Compare commits

...

22 Commits

Author SHA1 Message Date
6f83311d94 Merge pull request 'feat: display inference statistics in chat UI' (#45) from feature/issue-43-inference-stats into main
Merge pull request 'feat: display inference statistics in chat UI' (#45)
2026-03-13 14:50:35 +01:00
shahondin1624
36731aac1d chore: mark issue #43 plan as COMPLETED
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:46:47 +01:00
shahondin1624
3d85cc6b8c feat: add aria attributes to context utilization progress bar
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:46:35 +01:00
shahondin1624
52eaf661c4 feat: display inference statistics in chat UI
Add InferenceStats proto message and InferenceStatsPanel component that
displays token counts, throughput, and context window utilization as a
collapsible panel below assistant messages after orchestration completes.

Closes #43

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:44:12 +01:00
e93158f670 Merge pull request 'feat: structured artifact rendering, UX improvements' (#44) from feat/structured-artifact-passthrough into main 2026-03-12 23:14:28 +01:00
shahondin1624
2c6c961e08 feat: structured artifact rendering, UX improvements
- Render structured artifacts from agent results with type-aware
  formatting: code blocks with syntax highlighting and copy button,
  terminal-style command output, search result cards, and text findings
- Make FinalResult panel collapsible (default collapsed) with scrollable
  content (max-h-96) to prevent dominating the chat view
- Add clickable URL detection in summaries and artifact content
- Fix code block contrast for both light and dark mode
- Animate progress bar with pulse ring on active step and gradient
  shimmer on connecting lines
- Fix tab-switching bug: use module-level orchestrationStore singleton
  so orchestration state survives route navigation
- Remove sample/demo data seeding and clean up persisted localStorage
  entries from previous sample sessions
- Remove showSampleBadge prop from PageHeader
- Regenerate proto types for new Artifact message and ArtifactType enum
- Update README project structure (remove deleted data/ directory)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 23:13:33 +01:00
cfd338028a Merge pull request 'fix: prevent session creation loop without backend' (#42) from fix/session-creation-loop into main 2026-03-12 16:41:41 +01:00
shahondin1624
a5bc38cb65 fix: prevent session creation loop when running without backend
Make getOrCreateSession idempotent by returning the existing active
session when called with no ID, instead of always creating a new one.
Use untrack() in the $effect to sever SvelteMap dependency so it only
re-runs on $page changes. Disable SSR on client-only routes, reduce
preload aggressiveness, and skip retries when already disconnected.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 16:41:08 +01:00
d7d1d9fd57 Merge pull request 'refactor: code review improvements' (#41) from refactor/code-review-improvements into main 2026-03-12 13:48:57 +01:00
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
bb0eebff1b Merge pull request 'feat: error handling and connection status (#20)' (#40) from feature/issue-20-error-handling into main 2026-03-12 13:28:34 +01:00
shahondin1624
14c83832f5 feat: add error handling, toast notifications, connection status, and retry logic (#20)
Add global error boundary, toast notification system for gRPC errors,
connection status indicator in chat header, and automatic retry with
exponential backoff for transient failures. Map gRPC status codes to
user-friendly messages and add a Retry button on failed requests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 13:27:40 +01:00
284f84bd39 Merge pull request 'feat: responsive layout and mobile support (#19)' (#39) from feature/issue-19-responsive-layout into main 2026-03-12 13:20:54 +01:00
shahondin1624
dfef26bad5 feat: add responsive layout and mobile support (#19)
Add collapsible sidebars with slide-in drawers on mobile, hamburger menu
for session sidebar, stacked layouts for screens under 768px, 44px touch
targets, and overflow prevention across all pages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 13:17:02 +01:00
d837ed9050 Merge pull request 'feat: dark/light theme toggle (#18)' (#38) from feature/issue-18-dark-theme into main 2026-03-12 13:04:56 +01:00
shahondin1624
60f3d8eeda feat: add dark/light theme toggle with system preference support (#18)
Add theme switching with three modes (system/light/dark) using Tailwind v4
class-based dark mode. Theme preference is persisted in localStorage and
defaults to the system's prefers-color-scheme. All components updated with
dark: variants for consistent dark mode rendering including SVG elements.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 12:54:04 +01:00
79c14378a2 Merge pull request 'feat: audit/activity log view (#17)' (#37) from feature/issue-17-audit-log into main 2026-03-12 12:46:01 +01:00
shahondin1624
4fc84ccd62 feat: add audit/activity log view with timeline and event filtering (#17)
Add a dedicated /audit route displaying a chronological timeline of
orchestration events (state transitions, tool invocations, errors,
messages) grouped by session. Includes session selector, event type
filtering, URL deep-linking via ?session=id, and audit event capture
during chat streaming.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 12:41:40 +01:00
807311d7c9 Merge pull request 'feat: memory candidates viewer (#16)' (#36) from feature/issue-16-memory-viewer into main 2026-03-12 12:36:04 +01:00
shahondin1624
9ad772f83f feat: add memory candidates viewer with filtering and confidence visualization
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 12:33:48 +01:00
5efc5c351c Merge pull request 'feat: agent lineage visualization (#15)' (#35) from feature/issue-15-agent-lineage into main 2026-03-12 12:21:23 +01:00
shahondin1624
209e38d8a6 feat: add agent lineage visualization with SVG tree rendering
Add a dedicated /lineage route with custom SVG-based tree visualization
of the agent spawn chain. Nodes are colored by agent type (orchestrator,
researcher, coder, sysadmin, assistant) and display agent ID, type, and
spawn depth. Clicking a node opens a detail panel. Uses sample data
since the API does not yet expose lineage information.

Closes #15

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 12:19:49 +01:00
56 changed files with 3350 additions and 270 deletions

View File

@@ -1,3 +1,97 @@
# llm-multiverse-ui # llm-multiverse-ui
Web frontend for the llm-multiverse orchestration system Web frontend for the **llm-multiverse** orchestration system. Built with SvelteKit 5, TypeScript, Tailwind CSS v4, and gRPC-Web (Connect).
## Prerequisites
- Node.js 20+
- A running llm-multiverse gRPC backend (the UI proxies requests through Vite/your production reverse proxy)
## Getting Started
```bash
# Install dependencies
npm install
# Generate protobuf TypeScript definitions (only needed after .proto changes)
npm run generate
# Start the dev server
npm run dev
```
The app starts at `http://localhost:5173` by default.
## Scripts
| Command | Description |
| ------------------- | ------------------------------------------------ |
| `npm run dev` | Start Vite dev server with HMR |
| `npm run build` | Production build |
| `npm run preview` | Preview the production build locally |
| `npm run check` | Run svelte-check for TypeScript/Svelte errors |
| `npm run lint` | Run ESLint |
| `npm run format` | Format code with Prettier |
| `npm run generate` | Regenerate protobuf TypeScript from `.proto` files |
## Using the UI
### Landing Page
The root page (`/`) links to every section of the app.
### Chat (`/chat`)
The main interface for interacting with the orchestrator.
- **Sessions** are listed in the left sidebar. Click **+ New Chat** to start a session, or select an existing one. On mobile, tap the hamburger menu to open the sidebar.
- Type a message and press **Enter** (or the **Send** button) to send it. While the orchestrator is processing, a progress indicator and thinking section show the current state.
- **Session Config** (gear icon / "Config" button) opens a right sidebar where you can adjust the override level, disable specific tools, grant permissions, and save/load presets.
- If a request fails, an error banner appears with a **Retry** button.
- Navigation links to Lineage, Memory, and Audit are in the header (hidden on small screens).
### Agent Lineage (`/lineage`)
Visualizes the agent spawn tree as an interactive SVG diagram.
- Click a node to view agent details (ID, type, depth, children) in a side panel.
- A legend at the bottom shows agent type colors.
- Supports dark mode with theme-aware SVG colors.
### Memory Candidates (`/memory`)
Lists memory candidates captured during orchestration, grouped by session.
- **Source filter** — filter by Tool Output, Model Knowledge, or Web.
- **Confidence slider** — set a minimum confidence threshold.
- Each card shows the candidate content, source badge, and a confidence bar.
### Audit Log (`/audit`)
Timeline view of orchestration events for each session.
- Select a session from the dropdown, then optionally filter by event type (State Change, Tool, Error, Message).
- Events are displayed chronologically with date separators and color-coded badges.
### Theme
Use the theme toggle (sun/moon icon in any header) to cycle between **System**, **Light**, and **Dark** modes. The preference is persisted in localStorage.
## Project Structure
```
src/
lib/
components/ Svelte components (Backdrop, PageHeader, MessageInput, ...)
composables/ Reactive composables (useOrchestration)
proto/ Generated protobuf TypeScript
services/ gRPC client (orchestrator)
stores/ Svelte 5 reactive stores (sessions, audit, memory, theme, ...)
types/ TypeScript types and helpers (lineage, resultSource)
utils/ Shared utilities (date formatting, session config)
routes/
chat/ Chat page
lineage/ Agent lineage visualization
memory/ Memory candidates browser
audit/ Audit log timeline
```

View File

@@ -16,3 +16,10 @@
| #12 | Session history sidebar | COMPLETED | [issue-012.md](issue-012.md) | | #12 | Session history sidebar | COMPLETED | [issue-012.md](issue-012.md) |
| #13 | Session config sidebar component | COMPLETED | [issue-013.md](issue-013.md) | | #13 | Session config sidebar component | COMPLETED | [issue-013.md](issue-013.md) |
| #14 | Preset configurations | COMPLETED | [issue-014.md](issue-014.md) | | #14 | Preset configurations | COMPLETED | [issue-014.md](issue-014.md) |
| #15 | Agent lineage visualization | COMPLETED | [issue-015.md](issue-015.md) |
| #16 | Memory candidates viewer | COMPLETED | [issue-016.md](issue-016.md) |
| #17 | Audit/activity log view | COMPLETED | [issue-017.md](issue-017.md) |
| #18 | Dark/light theme toggle | COMPLETED | [issue-018.md](issue-018.md) |
| #19 | Responsive layout and mobile support | COMPLETED | [issue-019.md](issue-019.md) |
| #20 | Error handling and connection status | COMPLETED | [issue-020.md](issue-020.md) |
| #43 | Display inference statistics in chat UI | COMPLETED | [issue-043.md](issue-043.md) |

View File

@@ -0,0 +1,39 @@
---
---
# Issue #15: Agent lineage visualization
**Status:** COMPLETED
**Issue:** https://git.shahondin1624.de/llm-multiverse/llm-multiverse-ui/issues/15
**Branch:** `feature/issue-15-agent-lineage`
## Acceptance Criteria
- [x] Dedicated `/lineage` route
- [x] Tree/graph visualization of agent spawn chain
- [x] Each node displays: agent_id, agent_type, spawn_depth
- [x] Visual hierarchy reflecting parent-child agent relationships
- [x] Custom SVG-based rendering (no external graph library needed)
- [x] Updates as new agents appear in the stream (reactive via `$derived`)
- [x] Clickable nodes to show agent details
## Implementation
### New Files
- `src/lib/types/lineage.ts` — lineage types (`LineageNode`, `SimpleAgentIdentifier`), tree builder, color/label helpers, sample data
- `src/lib/components/LineageTree.svelte` — custom SVG tree visualization with horizontal layout, bezier edges, colored nodes by agent type, click selection
- `src/routes/lineage/+page.svelte` — dedicated lineage route with tree, legend, and detail panel
- `src/routes/lineage/+page.ts` — SSR disabled for this route
### Modified Files
- `src/routes/+page.svelte` — added navigation links to Chat and Agent Lineage
- `src/routes/chat/+page.svelte` — added Lineage link in header alongside Config button
### Key Decisions
- Uses sample/demo data since the API does not yet expose lineage information (AuditService is write-only, ProcessRequestResponse does not include lineage)
- `SimpleAgentIdentifier` type decouples the UI from protobuf Message dependency, making it easy to adapt when real data arrives
- Horizontal tree layout: root on left, children branching right, computed via recursive leaf-counting algorithm
- SVG bezier curves for edges, rounded rectangles for nodes
- Agent type colors: Orchestrator=blue, Researcher=green, Coder=purple, SysAdmin=orange, Assistant=teal, Unspecified=gray
- Detail panel slides in from right when a node is clicked, showing full agent info and child list
- Tree layout is fully reactive via `$derived` runes — updating the `agents` array recomputes the tree automatically

View File

@@ -0,0 +1,37 @@
---
---
# Issue #16: Memory candidates viewer
**Status:** COMPLETED
**Issue:** https://git.shahondin1624.de/llm-multiverse/llm-multiverse-ui/issues/16
**Branch:** `feature/issue-16-memory-viewer`
## Acceptance Criteria
- [x] Dedicated `/memory` route
- [x] List of `MemoryCandidate` entries
- [x] Each entry shows: content, source badge, confidence score
- [x] Entries grouped by session
- [x] Confidence score visualized (progress bar with color-coding)
- [x] Filterable by source or confidence threshold
## Implementation
### New Files
- `src/lib/stores/memory.svelte.ts` — reactive store for memory candidates grouped by session ID, with localStorage persistence, sample/demo data, and `addCandidates()` / `getAllBySession()` / `clearSession()` API
- `src/lib/components/MemoryCandidateCard.svelte` — card component displaying a single memory candidate with content, color-coded source badge (Tool Output=blue, Model Knowledge=purple, Web=green, Unspecified=gray), and horizontal confidence progress bar with percentage
- `src/routes/memory/+page.svelte` — dedicated memory route with session-grouped layout, source dropdown filter, confidence threshold slider, empty state, and back-to-chat link
- `src/routes/memory/+page.ts` — SSR disabled for this route
### Modified Files
- `src/routes/+page.svelte` — added Memory Candidates navigation link on the home page
- `src/routes/chat/+page.svelte` — added Memory link in the header next to Lineage; wired up capture of `finalResult.newMemoryCandidates` into the memory store
### Key Decisions
- Uses sample/demo data seeded into localStorage on first load, since the orchestrator doesn't currently include memory candidates in typical responses
- `StoredMemoryCandidate` type decouples the UI store from protobuf Message dependency (same pattern as lineage's `SimpleAgentIdentifier`)
- Confidence bar color-coded: green (>=80%), amber (>=50%), red (<50%)
- Source badge colors match the project's existing badge conventions (blue, purple, green, gray)
- Grid layout (1-3 columns responsive) for candidate cards within each session group
- Filter controls use a source dropdown and a range slider for confidence threshold

View File

@@ -0,0 +1,56 @@
# Issue #17 — Audit/Activity Log View
**Status: COMPLETED**
## Overview
Timeline view of all orchestration events for a session — state transitions, tool invocations, errors. Populated from the streamed responses captured during the session.
## Implementation Steps
### 1. Audit Event Store (`src/lib/stores/audit.svelte.ts`) — COMPLETED
- Created `AuditEvent` type with `id`, `sessionId`, `timestamp`, `eventType`, `details`, `state?`
- Event types: `state_change`, `tool_invocation`, `error`, `message`
- Store events grouped by session using `SvelteMap`, persisted to `localStorage`
- Functions: `addEvent()`, `getEventsBySession()`, `getAllSessions()`, `clearSession()`
- Sample data seeded for demo sessions
### 2. AuditTimeline Component (`src/lib/components/AuditTimeline.svelte`) — COMPLETED
- Vertical timeline with left-aligned time markers and event cards
- Color-coded type badges: state change=blue, tool=green, error=red, message=gray
- Each event shows timestamp, type badge, state label (when applicable), and detail text
- Date headers when events span multiple days
- Hover shadow effect on event cards
### 3. Audit Route (`src/routes/audit/+page.svelte`) — COMPLETED
- Dedicated `/audit` route with session selector dropdown and timeline
- Filter controls for event type (all, state changes, tool invocations, errors, messages)
- URL query param `?session=id` for deep linking to specific session
- Empty states when no events or no session selected
- Header with back-to-chat link and "Sample Data" badge
### 4. Audit Route Config (`src/routes/audit/+page.ts`) — COMPLETED
- `export const prerender = false` and `export const ssr = false`
### 5. Homepage Navigation (`src/routes/+page.svelte`) — COMPLETED
- Added "Audit Log" navigation link alongside existing Chat, Lineage, Memory links
### 6. Chat Page Integration (`src/routes/chat/+page.svelte`) — COMPLETED
- Added "Audit" link in the header navigation bar
- Capture orchestration state changes during streaming into the audit store
- Capture errors into the audit store
## Files Changed
- `src/lib/stores/audit.svelte.ts` (new)
- `src/lib/components/AuditTimeline.svelte` (new)
- `src/routes/audit/+page.svelte` (new)
- `src/routes/audit/+page.ts` (new)
- `src/routes/+page.svelte` (modified)
- `src/routes/chat/+page.svelte` (modified)

View File

@@ -0,0 +1,68 @@
# Issue #18: Dark/Light Theme Toggle
## Status: COMPLETED
## Overview
Add a dark/light theme toggle to the UI with three modes (system, light, dark), persisted in localStorage, defaulting to system preference.
## Implementation Details
### 1. Tailwind v4 Dark Mode Configuration
- Updated `src/app.css` with `@variant dark (&:where(.dark, .dark *));` for class-based dark mode
### 2. Theme Store (`src/lib/stores/theme.svelte.ts`)
- Svelte 5 runes-based store with three modes: 'light', 'dark', 'system'
- Loads preference from localStorage on init, defaults to 'system'
- Listens to `prefers-color-scheme` media query when in 'system' mode
- Applies/removes `dark` class on `document.documentElement`
- Adds temporary `theme-transition` class for smooth CSS transitions
### 3. ThemeToggle Component (`src/lib/components/ThemeToggle.svelte`)
- Compact button that cycles: system -> light -> dark -> system
- SVG icons: monitor (system), sun (light), moon (dark)
- Text label showing current mode
- Dark mode aware styling
### 4. Layout Integration
- Theme store initialized via `$effect` in `+layout.svelte`
- Global CSS transition styles for smooth theme switching
- ThemeToggle added to all page headers (chat, lineage, memory, audit, home)
### 5. Dark Mode Classes Applied To
- `src/routes/+page.svelte` (home page)
- `src/routes/chat/+page.svelte` (chat page)
- `src/routes/lineage/+page.svelte` (lineage page)
- `src/routes/memory/+page.svelte` (memory page)
- `src/routes/audit/+page.svelte` (audit page)
- `src/lib/components/SessionSidebar.svelte`
- `src/lib/components/ConfigSidebar.svelte`
- `src/lib/components/MessageBubble.svelte`
- `src/lib/components/MessageList.svelte`
- `src/lib/components/MessageInput.svelte`
- `src/lib/components/OrchestrationProgress.svelte`
- `src/lib/components/ThinkingSection.svelte`
- `src/lib/components/FinalResult.svelte`
- `src/lib/components/LineageTree.svelte`
- `src/lib/components/MemoryCandidateCard.svelte`
- `src/lib/components/AuditTimeline.svelte`
### Color Mapping Applied
- `bg-white` -> `dark:bg-gray-900`
- `bg-gray-50` -> `dark:bg-gray-800`
- `bg-gray-100` -> `dark:bg-gray-700`
- `bg-gray-200` -> `dark:bg-gray-600`/`dark:bg-gray-700`
- `text-gray-900` -> `dark:text-gray-100`
- `text-gray-700` -> `dark:text-gray-300`
- `text-gray-500` -> `dark:text-gray-400`
- `text-gray-400` -> `dark:text-gray-500`
- `border-gray-200` -> `dark:border-gray-700`
- `border-gray-300` -> `dark:border-gray-600`
- Colored backgrounds (blue, green, amber, red, purple) use opacity-based dark variants (e.g., `dark:bg-blue-900/40`)
- SVG elements in LineageTree use reactive color values based on `themeStore.isDark`
## Files Changed
- `src/app.css` - Added dark mode variant configuration
- `src/lib/stores/theme.svelte.ts` - New theme store
- `src/lib/components/ThemeToggle.svelte` - New toggle component
- `src/routes/+layout.svelte` - Theme init and transition styles
- All page and component files listed above - Added dark: variants

View File

@@ -0,0 +1,80 @@
# Issue #19: Responsive layout and mobile support
**Status: COMPLETED**
## Summary
Make the dashboard fully usable on mobile with collapsible sidebars, stacked layouts for small screens, touch-friendly tap targets, and no horizontal scrolling.
## Changes
### Chat Page (`src/routes/chat/+page.svelte`)
- Added hamburger menu button visible only on mobile (`md:hidden`) to toggle the session sidebar
- SessionSidebar rendered twice: always-visible wrapper for desktop (`hidden md:flex`) and a mobile drawer controlled by `showSessionSidebar` state
- Config sidebar now passes `onClose` callback to enable closing on mobile
- Nav links (Lineage, Memory, Audit) hidden on small screens (`hidden sm:inline-flex`)
- Config button shows gear icon on small screens, text label on larger screens
- Added `overflow-hidden` to root container to prevent horizontal scrolling
- All header padding reduced on mobile (`px-3` vs `md:px-4`)
### SessionSidebar (`src/lib/components/SessionSidebar.svelte`)
- Accepts new `open` and `onClose` props for mobile drawer control
- On mobile: renders as fixed overlay (`fixed inset-y-0 left-0 z-50`) with slide-in transition
- On desktop: renders as relative-positioned sidebar with no transition (`md:relative md:translate-x-0`)
- Backdrop overlay (`bg-black/50`) shown on mobile when sidebar is open
- Close button visible only on mobile (`md:hidden`)
- Session selection and new chat actions auto-close the sidebar on mobile
### ConfigSidebar (`src/lib/components/ConfigSidebar.svelte`)
- Accepts new optional `onClose` prop
- On mobile: renders as fixed overlay from the right (`fixed inset-y-0 right-0 z-50`)
- On desktop: renders as relative-positioned sidebar (`md:relative md:z-auto`)
- Backdrop overlay shown when `onClose` is provided (mobile only)
- Close button visible only on mobile
### MessageInput (`src/lib/components/MessageInput.svelte`)
- Send button has `min-h-[44px] min-w-[44px]` for touch-friendly 44px tap target
- Textarea uses `text-base` on mobile (prevents iOS zoom) and `md:text-sm` on desktop
- Reduced padding on mobile (`p-3` vs `md:p-4`)
### MessageBubble (`src/lib/components/MessageBubble.svelte`)
- Increased max-width on mobile from 75% to 85% (`max-w-[85%] md:max-w-[75%]`)
- Slightly reduced padding on mobile
### Home Page (`src/routes/+page.svelte`)
- Nav cards stack vertically on mobile (`flex-col sm:flex-row`)
- Full-width centered layout with `max-w-md` constraint on mobile
- Larger touch targets for nav links (`py-3 sm:py-2`)
- Added horizontal padding to prevent edge clipping
### Lineage Page (`src/routes/lineage/+page.svelte`)
- Content area stacks vertically on mobile (`flex-col md:flex-row`)
- Detail panel renders as fixed overlay on mobile with backdrop
- Reduced padding on mobile
- "Sample Data" badge hidden on very small screens
### Memory Page (`src/routes/memory/+page.svelte`)
- Filters stack vertically on mobile (`flex-col sm:flex-row`)
- Reduced padding on mobile
- "Sample Data" badge hidden on very small screens
### Audit Page (`src/routes/audit/+page.svelte`)
- Filters stack vertically on mobile (`flex-col sm:flex-row`)
- Session select constrained to prevent horizontal overflow (`min-w-0 max-w-full truncate`)
- Reduced padding on mobile
- "Sample Data" badge hidden on very small screens
## Acceptance Criteria Met
- [x] Sidebars collapsible on mobile (hamburger menu for sessions, tap to close)
- [x] Stacked layout for screens < 768px (md breakpoint = 768px)
- [x] Chat input remains accessible and usable on mobile (always at bottom, full width)
- [x] Touch-friendly tap targets (min 44px on send button, hamburger, config toggle)
- [x] No horizontal scrolling on any viewport (overflow-hidden on containers)
- [x] Works on common mobile breakpoints (320px, 375px, 414px) via responsive Tailwind classes
## Quality Gates
- `npm run build` - PASSED
- `npm run lint` - PASSED (0 errors)
- `npm run check` - PASSED (0 errors, 0 warnings)

View File

@@ -0,0 +1,44 @@
# Issue #20 — Error handling and connection status
**Status: COMPLETED**
## Overview
Global error boundary, toast notifications for gRPC errors, connection status indicator, and retry logic with exponential backoff.
## Implemented Components
### Toast System
- **`src/lib/stores/toast.svelte.ts`** — Svelte 5 runes-based toast store with `addToast`, `removeToast`, `clear`. Auto-dismiss with configurable durations (5s for success/info, 10s for errors, 8s for warnings).
- **`src/lib/components/ToastContainer.svelte`** — Fixed bottom-right toast stack. Color-coded by type (red/amber/green/blue) with icons, dismiss buttons, and full dark mode support.
### Connection Status
- **`src/lib/stores/connection.svelte.ts`** — Tracks gRPC connection state (`connected` / `reconnecting` / `disconnected`) based on consecutive failures.
- **`src/lib/components/ConnectionStatus.svelte`** — Header indicator with colored dot (green/amber-pulse/red) and label. Dark mode support.
### Retry Logic
- **`src/lib/services/orchestrator.ts`** — Added:
- Exponential backoff retry (up to 3 retries) for transient gRPC codes (`UNAVAILABLE`, `DEADLINE_EXCEEDED`, `ABORTED`, `INTERNAL`).
- `friendlyMessage()` mapper from gRPC codes to user-friendly strings.
- Connection store integration (reports success/failure).
- Toast notifications on errors.
### Error Boundary
- **`src/routes/+error.svelte`** — Global SvelteKit error page with status code, friendly message, and "Go back" / "Try again" / "Home" buttons. Full dark mode support.
### Integration
- **`src/routes/+layout.svelte`** — Added `ToastContainer` to global layout.
- **`src/routes/chat/+page.svelte`** — Added `ConnectionStatus` in header, "Retry" button on failed requests, toast notifications on errors, user-friendly error messages.
## Files Changed
| File | Change |
|------|--------|
| `src/lib/stores/toast.svelte.ts` | New |
| `src/lib/stores/connection.svelte.ts` | New |
| `src/lib/components/ToastContainer.svelte` | New |
| `src/lib/components/ConnectionStatus.svelte` | New |
| `src/routes/+error.svelte` | New |
| `src/lib/services/orchestrator.ts` | Modified — retry, friendly messages, store integration |
| `src/routes/+layout.svelte` | Modified — added ToastContainer |
| `src/routes/chat/+page.svelte` | Modified — ConnectionStatus, Retry button, toast integration |
| `implementation-plans/issue-020.md` | New |
| `implementation-plans/_index.md` | Modified |

View File

@@ -0,0 +1,60 @@
# Issue #43: Display Inference Statistics in Chat UI
**Status:** COMPLETED
**Issue:** [#43](https://git.shahondin1624.de/llm-multiverse/llm-multiverse-ui/issues/43)
**Branch:** `feature/issue-43-inference-stats`
## Overview
Add a collapsible UI panel that displays LLM inference statistics (token counts, context window utilization, throughput) below assistant messages after orchestration completes.
## Phases
### Phase 1: Proto Types
**Files:**
- `proto/upstream/proto/llm_multiverse/v1/common.proto` — Add `InferenceStats` message
- `proto/upstream/proto/llm_multiverse/v1/orchestrator.proto` — Add optional `inference_stats` field to `ProcessRequestResponse`
**InferenceStats message fields:**
- `prompt_tokens` (uint32) — tokens in the prompt
- `completion_tokens` (uint32) — tokens generated
- `total_tokens` (uint32) — sum of prompt + completion
- `context_window_size` (uint32) — model's maximum context length
- `tokens_per_second` (float) — generation throughput
**Then regenerate types:** `npm run generate`
### Phase 2: Orchestration State
**Files:**
- `src/lib/composables/useOrchestration.svelte.ts` — Extract `inferenceStats` from response, expose via store getter
### Phase 3: InferenceStatsPanel Component
**Files:**
- `src/lib/components/InferenceStatsPanel.svelte` — New component
**Design:**
- Follow `<details>` pattern from FinalResult.svelte
- Collapsed by default
- Summary line shows key stat (e.g., total tokens + tokens/sec)
- Expanded content shows all stats in a grid layout
- Context utilization shown as a progress bar
- Blue/indigo color scheme (neutral, info-like)
- Full dark mode support
### Phase 4: Chat Page Integration
**Files:**
- `src/routes/chat/+page.svelte` — Render `InferenceStatsPanel` after `FinalResult` when stats available
## Acceptance Criteria
- [x] InferenceStats proto message defined and TypeScript types generated
- [x] InferenceStatsPanel displays all required metrics
- [x] Panel is collapsible, collapsed by default
- [x] Context utilization shows visual progress bar
- [x] Integrates cleanly into chat page below assistant message
- [x] Dark mode support
- [x] Build, lint, typecheck pass

View File

@@ -1 +1,3 @@
@import 'tailwindcss'; @import 'tailwindcss';
@variant dark (&:where(.dark, .dark *));

View File

@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="tap">
<div style="display: contents">%sveltekit.body%</div> <div style="display: contents">%sveltekit.body%</div>
</body> </body>
</html> </html>

View File

@@ -0,0 +1,92 @@
<script lang="ts">
import type { AuditEvent, AuditEventType } from '$lib/stores/audit.svelte';
import { formatShortDate } from '$lib/utils/date';
let { events }: { events: AuditEvent[] } = $props();
function formatTimestamp(date: Date): string {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
const eventTypeConfig: Record<AuditEventType, { badge: string; dot: string; label: string }> = {
state_change: {
badge: 'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300',
dot: 'bg-blue-500',
label: 'State Change'
},
tool_invocation: {
badge: 'bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300',
dot: 'bg-green-500',
label: 'Tool'
},
error: {
badge: 'bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300',
dot: 'bg-red-500',
label: 'Error'
},
message: {
badge: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400',
dot: 'bg-gray-400',
label: 'Message'
}
};
const defaultEventConfig = { badge: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400', dot: 'bg-gray-400', label: 'Unknown' };
function getEventConfig(eventType: string) {
return eventTypeConfig[eventType as AuditEventType] ?? defaultEventConfig;
}
</script>
{#if events.length === 0}
<div class="flex flex-col items-center justify-center py-16 text-center">
<div class="mb-3 text-4xl text-gray-300 dark:text-gray-600">&#128196;</div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">No events for this session</p>
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">Events will appear here as orchestration runs.</p>
</div>
{:else}
<div class="relative ml-4">
<!-- Vertical line -->
<div class="absolute top-0 bottom-0 left-3 w-0.5 bg-gray-200 dark:bg-gray-700"></div>
<ol class="space-y-4">
{#each events as event, i (event.id)}
{@const showDate = i === 0 || formatShortDate(event.timestamp) !== formatShortDate(events[i - 1].timestamp)}
{@const config = getEventConfig(event.eventType)}
{#if showDate}
<li class="relative pl-10 pt-2">
<span class="text-xs font-medium text-gray-400 dark:text-gray-500">{formatShortDate(event.timestamp)}</span>
</li>
{/if}
<li class="group relative flex items-start pl-10">
<!-- Dot on the timeline -->
<div
class="absolute left-1.5 top-1.5 h-3 w-3 rounded-full ring-2 ring-white dark:ring-gray-900 {config.dot}"
></div>
<!-- Event card -->
<div
class="flex-1 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-4 py-3 shadow-sm transition-shadow group-hover:shadow-md"
>
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs text-gray-400 dark:text-gray-500">{formatTimestamp(event.timestamp)}</span>
<span
class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {config.badge}"
>
{config.label}
</span>
{#if event.state}
<span class="rounded-md bg-blue-50 dark:bg-blue-900/30 px-1.5 py-0.5 text-xs font-mono text-blue-600 dark:text-blue-400">
{event.state}
</span>
{/if}
</div>
<p class="mt-1.5 text-sm text-gray-700 dark:text-gray-300">{event.details}</p>
</div>
</li>
{/each}
</ol>
</div>
{/if}

View File

@@ -0,0 +1,12 @@
<script lang="ts">
let { onClose }: { onClose: () => void } = $props();
</script>
<div
class="fixed inset-0 z-40 bg-black/50 md:hidden"
role="button"
tabindex="-1"
onclick={onClose}
onkeydown={(e) => { if (e.key === 'Escape') onClose(); }}
aria-label="Close overlay"
></div>

View File

@@ -4,11 +4,18 @@
import { SessionConfigSchema } from '$lib/proto/llm_multiverse/v1/orchestrator_pb'; import { SessionConfigSchema } from '$lib/proto/llm_multiverse/v1/orchestrator_pb';
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { presetStore } from '$lib/stores/presets.svelte'; import { presetStore } from '$lib/stores/presets.svelte';
import { isNonDefaultConfig } from '$lib/utils/sessionConfig';
import Backdrop from '$lib/components/Backdrop.svelte';
let { let {
config, config,
onConfigChange onConfigChange,
}: { config: SessionConfig; onConfigChange: (config: SessionConfig) => void } = $props(); onClose
}: {
config: SessionConfig;
onConfigChange: (config: SessionConfig) => void;
onClose?: () => void;
} = $props();
let newPermission = $state(''); let newPermission = $state('');
let newPresetName = $state(''); let newPresetName = $state('');
@@ -32,11 +39,7 @@
{ value: OverrideLevel.ALL, label: 'All', description: 'No enforcement' } { value: OverrideLevel.ALL, label: 'All', description: 'No enforcement' }
]; ];
const isNonDefault = $derived( const isNonDefault = $derived(isNonDefaultConfig(config));
config.overrideLevel !== OverrideLevel.NONE ||
config.disabledTools.length > 0 ||
config.grantedPermissions.length > 0
);
function setOverrideLevel(level: OverrideLevel) { function setOverrideLevel(level: OverrideLevel) {
const updated = create(SessionConfigSchema, { const updated = create(SessionConfigSchema, {
@@ -115,47 +118,67 @@
} }
</script> </script>
<aside class="flex h-full w-72 flex-col border-l border-gray-200 bg-white"> {#if onClose}
<div class="flex items-center justify-between border-b border-gray-200 px-4 py-3"> <Backdrop onClose={onClose} />
{/if}
<aside
class="flex h-full w-72 flex-col border-l border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900
fixed inset-y-0 right-0 z-50
md:relative md:z-auto"
>
<div class="flex items-center justify-between border-b border-gray-200 dark:border-gray-700 px-4 py-3">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<h2 class="text-sm font-semibold text-gray-900">Session Config</h2> <h2 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Session Config</h2>
{#if isNonDefault} {#if isNonDefault}
<span class="h-2 w-2 rounded-full bg-amber-500" title="Non-default config active"></span> <span class="h-2 w-2 rounded-full bg-amber-500" title="Non-default config active"></span>
{/if} {/if}
</div> </div>
<button <div class="flex items-center gap-2">
type="button" <button
onclick={resetConfig} type="button"
class="text-xs text-gray-500 hover:text-gray-700" onclick={resetConfig}
> class="text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
Reset >
</button> Reset
</button>
{#if onClose}
<button
type="button"
onclick={onClose}
class="flex h-7 w-7 items-center justify-center rounded text-gray-400 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-600 dark:hover:text-gray-300 md:hidden"
aria-label="Close config sidebar"
>
&#10005;
</button>
{/if}
</div>
</div> </div>
<div class="flex-1 overflow-y-auto p-4 space-y-5"> <div class="flex-1 overflow-y-auto p-4 space-y-5">
<!-- Presets --> <!-- Presets -->
<div> <div>
<p class="mb-2 text-xs font-medium text-gray-700">Presets</p> <p class="mb-2 text-xs font-medium text-gray-700 dark:text-gray-300">Presets</p>
<div class="space-y-1"> <div class="space-y-1">
{#each presetStore.getAllPresets() as preset (preset.name)} {#each presetStore.getAllPresets() as preset (preset.name)}
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<button <button
type="button" type="button"
onclick={() => handleLoadPreset(preset.name)} onclick={() => handleLoadPreset(preset.name)}
class="flex-1 rounded-lg px-3 py-1.5 text-left text-sm text-gray-700 hover:bg-gray-50" class="flex-1 rounded-lg px-3 py-1.5 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
> >
{preset.name} {preset.name}
{#if preset.builtIn} {#if preset.builtIn}
<span class="text-[10px] text-gray-400">built-in</span> <span class="text-[10px] text-gray-400 dark:text-gray-500">built-in</span>
{/if} {/if}
</button> </button>
{#if !preset.builtIn} {#if !preset.builtIn}
<button <button
type="button" type="button"
onclick={() => handleDeletePreset(preset.name)} onclick={() => handleDeletePreset(preset.name)}
class="rounded px-1.5 py-1 text-xs text-gray-400 hover:text-red-500" class="rounded px-1.5 py-1 text-xs text-gray-400 dark:text-gray-500 hover:text-red-500"
> >
&#10005;
</button> </button>
{/if} {/if}
</div> </div>
@@ -168,22 +191,22 @@
bind:value={newPresetName} bind:value={newPresetName}
onkeydown={(e) => { if (e.key === 'Enter') handleSavePreset(); }} onkeydown={(e) => { if (e.key === 'Enter') handleSavePreset(); }}
placeholder="Preset name" placeholder="Preset name"
class="flex-1 rounded-lg border border-gray-300 px-2 py-1.5 text-xs focus:border-blue-500 focus:outline-none" class="flex-1 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-2 py-1.5 text-xs text-gray-900 dark:text-gray-100 focus:border-blue-500 focus:outline-none"
/> />
<button <button
type="button" type="button"
onclick={handleSavePreset} onclick={handleSavePreset}
disabled={!newPresetName.trim()} disabled={!newPresetName.trim()}
class="rounded-lg bg-blue-50 px-2 py-1.5 text-xs font-medium text-blue-700 hover:bg-blue-100 disabled:opacity-50" class="rounded-lg bg-blue-50 dark:bg-blue-900/40 px-2 py-1.5 text-xs font-medium text-blue-700 dark:text-blue-300 hover:bg-blue-100 dark:hover:bg-blue-900/60 disabled:opacity-50"
> >
Save Save
</button> </button>
<button <button
type="button" type="button"
onclick={() => { showSavePreset = false; saveError = ''; newPresetName = ''; }} onclick={() => { showSavePreset = false; saveError = ''; newPresetName = ''; }}
class="rounded-lg px-1.5 py-1.5 text-xs text-gray-400 hover:text-gray-600" class="rounded-lg px-1.5 py-1.5 text-xs text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
> >
&#10005;
</button> </button>
</div> </div>
{#if saveError} {#if saveError}
@@ -193,7 +216,7 @@
<button <button
type="button" type="button"
onclick={() => (showSavePreset = true)} onclick={() => (showSavePreset = true)}
class="mt-2 w-full rounded-lg border border-dashed border-gray-300 px-3 py-1.5 text-xs text-gray-500 hover:border-gray-400 hover:text-gray-700" class="mt-2 w-full rounded-lg border border-dashed border-gray-300 dark:border-gray-600 px-3 py-1.5 text-xs text-gray-500 dark:text-gray-400 hover:border-gray-400 dark:hover:border-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
> >
Save current as preset Save current as preset
</button> </button>
@@ -202,7 +225,7 @@
<!-- Override Level --> <!-- Override Level -->
<div> <div>
<p class="mb-2 text-xs font-medium text-gray-700">Override Level</p> <p class="mb-2 text-xs font-medium text-gray-700 dark:text-gray-300">Override Level</p>
<div class="space-y-1"> <div class="space-y-1">
{#each overrideLevels as level (level.value)} {#each overrideLevels as level (level.value)}
<button <button
@@ -210,11 +233,11 @@
onclick={() => setOverrideLevel(level.value)} onclick={() => setOverrideLevel(level.value)}
class="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm class="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm
{config.overrideLevel === level.value {config.overrideLevel === level.value
? 'bg-blue-50 text-blue-700 ring-1 ring-blue-200' ? 'bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 ring-1 ring-blue-200 dark:ring-blue-700'
: 'text-gray-700 hover:bg-gray-50'}" : 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'}"
> >
<span class="font-medium">{level.label}</span> <span class="font-medium">{level.label}</span>
<span class="text-xs text-gray-500"> {level.description}</span> <span class="text-xs text-gray-500 dark:text-gray-400">&mdash; {level.description}</span>
</button> </button>
{/each} {/each}
</div> </div>
@@ -222,17 +245,17 @@
<!-- Disabled Tools --> <!-- Disabled Tools -->
<div> <div>
<p class="mb-2 text-xs font-medium text-gray-700">Disabled Tools</p> <p class="mb-2 text-xs font-medium text-gray-700 dark:text-gray-300">Disabled Tools</p>
<div class="space-y-1"> <div class="space-y-1">
{#each toolTypes as tool (tool.value)} {#each toolTypes as tool (tool.value)}
<label class="flex items-center gap-2 rounded px-2 py-1 text-sm hover:bg-gray-50"> <label class="flex items-center gap-2 rounded px-2 py-1 text-sm hover:bg-gray-50 dark:hover:bg-gray-700">
<input <input
type="checkbox" type="checkbox"
checked={config.disabledTools.includes(tool.label)} checked={config.disabledTools.includes(tool.label)}
onchange={() => toggleTool(tool.label)} onchange={() => toggleTool(tool.label)}
class="rounded border-gray-300 text-blue-600" class="rounded border-gray-300 dark:border-gray-600 text-blue-600"
/> />
<span class="text-gray-700">{tool.label}</span> <span class="text-gray-700 dark:text-gray-300">{tool.label}</span>
</label> </label>
{/each} {/each}
</div> </div>
@@ -240,19 +263,19 @@
<!-- Granted Permissions --> <!-- Granted Permissions -->
<div> <div>
<p class="mb-2 text-xs font-medium text-gray-700">Granted Permissions</p> <p class="mb-2 text-xs font-medium text-gray-700 dark:text-gray-300">Granted Permissions</p>
<div class="flex gap-1"> <div class="flex gap-1">
<input <input
type="text" type="text"
bind:value={newPermission} bind:value={newPermission}
onkeydown={(e) => { if (e.key === 'Enter') addPermission(); }} onkeydown={(e) => { if (e.key === 'Enter') addPermission(); }}
placeholder="agent_type:tool" placeholder="agent_type:tool"
class="flex-1 rounded-lg border border-gray-300 px-2 py-1.5 text-xs focus:border-blue-500 focus:outline-none" class="flex-1 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-2 py-1.5 text-xs text-gray-900 dark:text-gray-100 focus:border-blue-500 focus:outline-none"
/> />
<button <button
type="button" type="button"
onclick={addPermission} onclick={addPermission}
class="rounded-lg bg-gray-100 px-2 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-200" class="rounded-lg bg-gray-100 dark:bg-gray-700 px-2 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600"
> >
Add Add
</button> </button>
@@ -260,14 +283,14 @@
{#if config.grantedPermissions.length > 0} {#if config.grantedPermissions.length > 0}
<div class="mt-2 space-y-1"> <div class="mt-2 space-y-1">
{#each config.grantedPermissions as perm (perm)} {#each config.grantedPermissions as perm (perm)}
<div class="flex items-center justify-between rounded bg-gray-50 px-2 py-1"> <div class="flex items-center justify-between rounded bg-gray-50 dark:bg-gray-800 px-2 py-1">
<code class="text-xs text-gray-700">{perm}</code> <code class="text-xs text-gray-700 dark:text-gray-300">{perm}</code>
<button <button
type="button" type="button"
onclick={() => removePermission(perm)} onclick={() => removePermission(perm)}
class="text-xs text-gray-400 hover:text-red-500" class="text-xs text-gray-400 dark:text-gray-500 hover:text-red-500"
> >
&#10005;
</button> </button>
</div> </div>
{/each} {/each}

View File

@@ -0,0 +1,28 @@
<script lang="ts">
import { connectionStore } from '$lib/stores/connection.svelte';
const statusConfig = {
connected: {
dot: 'bg-green-500',
label: 'Connected',
textColor: 'text-green-700 dark:text-green-400'
},
reconnecting: {
dot: 'bg-amber-500 animate-pulse',
label: 'Reconnecting',
textColor: 'text-amber-700 dark:text-amber-400'
},
disconnected: {
dot: 'bg-red-500',
label: 'Disconnected',
textColor: 'text-red-700 dark:text-red-400'
}
};
const config = $derived(statusConfig[connectionStore.status]);
</script>
<div class="flex items-center gap-1.5" title="Server: {config.label}">
<span class="h-2 w-2 rounded-full {config.dot}"></span>
<span class="hidden text-xs {config.textColor} sm:inline">{config.label}</span>
</div>

View File

@@ -1,19 +1,20 @@
<script lang="ts"> <script lang="ts">
import type { SubagentResult } from '$lib/proto/llm_multiverse/v1/common_pb'; import type { SubagentResult } from '$lib/proto/llm_multiverse/v1/common_pb';
import { ResultStatus, ResultQuality, ResultSource } from '$lib/proto/llm_multiverse/v1/common_pb'; import { ResultStatus, ResultQuality, ArtifactType } from '$lib/proto/llm_multiverse/v1/common_pb';
import { resultSourceConfig } from '$lib/types/resultSource';
let { result }: { result: SubagentResult } = $props(); let { result }: { result: SubagentResult } = $props();
const statusConfig = $derived.by(() => { const statusConfig = $derived.by(() => {
switch (result.status) { switch (result.status) {
case ResultStatus.SUCCESS: case ResultStatus.SUCCESS:
return { label: 'Success', bg: 'bg-green-100', text: 'text-green-800', border: 'border-green-200' }; return { label: 'Success', bg: 'bg-green-100 dark:bg-green-900/30', text: 'text-green-800 dark:text-green-300', border: 'border-green-200 dark:border-green-800' };
case ResultStatus.PARTIAL: case ResultStatus.PARTIAL:
return { label: 'Partial', bg: 'bg-amber-100', text: 'text-amber-800', border: 'border-amber-200' }; return { label: 'Partial', bg: 'bg-amber-100 dark:bg-amber-900/30', text: 'text-amber-800 dark:text-amber-300', border: 'border-amber-200 dark:border-amber-800' };
case ResultStatus.FAILED: case ResultStatus.FAILED:
return { label: 'Failed', bg: 'bg-red-100', text: 'text-red-800', border: 'border-red-200' }; return { label: 'Failed', bg: 'bg-red-100 dark:bg-red-900/30', text: 'text-red-800 dark:text-red-300', border: 'border-red-200 dark:border-red-800' };
default: default:
return { label: 'Unknown', bg: 'bg-gray-100', text: 'text-gray-800', border: 'border-gray-200' }; return { label: 'Unknown', bg: 'bg-gray-100 dark:bg-gray-800', text: 'text-gray-800 dark:text-gray-300', border: 'border-gray-200 dark:border-gray-700' };
} }
}); });
@@ -26,52 +27,156 @@
} }
}); });
const sourceLabel = $derived.by(() => { const sourceBadge = $derived(resultSourceConfig(result.source));
switch (result.source) {
case ResultSource.TOOL_OUTPUT: return 'Tool Output'; const LINE_COLLAPSE_THRESHOLD = 20;
case ResultSource.MODEL_KNOWLEDGE: return 'Model Knowledge';
case ResultSource.WEB: return 'Web'; function contentLineCount(content: string): number {
default: return ''; return content.split('\n').length;
} }
});
const URL_RE = /(https?:\/\/[^\s<>"')\]]+)/g;
function linkify(text: string): string {
return text.replace(URL_RE, '<a href="$1" target="_blank" rel="noopener noreferrer" class="text-blue-600 dark:text-blue-400 underline hover:text-blue-800 dark:hover:text-blue-300 break-all">$1</a>');
}
let copiedIndex: number | null = $state(null);
async function copyToClipboard(content: string, index: number) {
await navigator.clipboard.writeText(content);
copiedIndex = index;
setTimeout(() => {
if (copiedIndex === index) copiedIndex = null;
}, 2000);
}
</script> </script>
<div class="mx-4 mb-3 rounded-xl border {statusConfig.border} {statusConfig.bg} p-4"> <details class="mx-4 mb-3 rounded-xl border {statusConfig.border} {statusConfig.bg}">
<div class="mb-2 flex items-center gap-2"> <summary class="flex cursor-pointer items-center gap-2 px-4 py-2.5 select-none">
<svg class="chevron h-4 w-4 shrink-0 text-gray-500 dark:text-gray-400 transition-transform" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" /></svg>
<span class="rounded-full px-2.5 py-0.5 text-xs font-medium {statusConfig.bg} {statusConfig.text}"> <span class="rounded-full px-2.5 py-0.5 text-xs font-medium {statusConfig.bg} {statusConfig.text}">
{statusConfig.label} {statusConfig.label}
</span> </span>
{#if qualityLabel} {#if qualityLabel}
<span class="rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800"> <span class="rounded-full bg-blue-100 dark:bg-blue-900/40 px-2.5 py-0.5 text-xs font-medium text-blue-800 dark:text-blue-300">
{qualityLabel} {qualityLabel}
</span> </span>
{/if} {/if}
{#if sourceLabel} {#if sourceBadge.label !== 'Unspecified'}
<span class="rounded-full bg-purple-100 px-2.5 py-0.5 text-xs font-medium text-purple-800"> <span class="rounded-full {sourceBadge.bg} px-2.5 py-0.5 text-xs font-medium {sourceBadge.text}">
{sourceLabel} {sourceBadge.label}
</span> </span>
{/if} {/if}
{#if result.summary}
<span class="ml-1 truncate text-xs {statusConfig.text}">{result.summary}</span>
{/if}
</summary>
<div class="max-h-96 overflow-y-auto border-t {statusConfig.border} px-4 pb-4 pt-3">
{#if result.summary}
<p class="text-sm {statusConfig.text}">{@html linkify(result.summary)}</p>
{/if}
{#if result.failureReason}
<p class="mt-2 text-sm text-red-700 dark:text-red-400">Reason: {result.failureReason}</p>
{/if}
{#if result.artifacts.length > 0}
<div class="mt-3 border-t {statusConfig.border} pt-3">
<p class="mb-1.5 text-xs font-medium text-gray-600 dark:text-gray-400">Artifacts</p>
<div class="space-y-3">
{#each result.artifacts as artifact, i (artifact.label + i)}
{#if artifact.artifactType === ArtifactType.CODE}
<!-- Code artifact: syntax-highlighted block with filename header -->
<div class="rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="flex items-center gap-2 bg-gray-100 dark:bg-gray-800 px-3 py-1.5 text-xs">
<span class="text-blue-600 dark:text-blue-400">&#128196;</span>
<span class="font-mono font-medium text-gray-700 dark:text-gray-300">{artifact.label}</span>
<div class="ml-auto flex items-center gap-1.5">
{#if artifact.metadata?.language}
<span class="rounded bg-gray-200 dark:bg-gray-700 px-1.5 py-0.5 text-[10px] text-gray-500 dark:text-gray-400">{artifact.metadata.language}</span>
{/if}
<button
type="button"
onclick={() => copyToClipboard(artifact.content, i)}
class="rounded p-0.5 text-gray-400 hover:bg-gray-200 hover:text-gray-600 dark:text-gray-500 dark:hover:bg-gray-700 dark:hover:text-gray-300 transition-colors"
title="Copy code"
>
{#if copiedIndex === i}
<svg class="h-3.5 w-3.5 text-green-500" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" /></svg>
{:else}
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M15.666 3.888A2.25 2.25 0 0 0 13.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 0 1-.75.75H9.75a.75.75 0 0 1-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 0 1-2.25 2.25H6.75A2.25 2.25 0 0 1 4.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 0 1 1.927-.184" /></svg>
{/if}
</button>
</div>
</div>
{#if contentLineCount(artifact.content) > LINE_COLLAPSE_THRESHOLD}
<details>
<summary class="cursor-pointer bg-gray-50 dark:bg-gray-900 px-3 py-1 text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300">
Show {contentLineCount(artifact.content)} lines
</summary>
<pre class="overflow-x-auto bg-gray-50 dark:bg-gray-900 p-3 text-xs leading-relaxed text-gray-800 dark:text-gray-200"><code class="language-{artifact.metadata?.language ?? ''}">{artifact.content}</code></pre>
</details>
{:else}
<pre class="overflow-x-auto bg-gray-50 dark:bg-gray-900 p-3 text-xs leading-relaxed text-gray-800 dark:text-gray-200"><code class="language-{artifact.metadata?.language ?? ''}">{artifact.content}</code></pre>
{/if}
</div>
{:else if artifact.artifactType === ArtifactType.COMMAND_OUTPUT}
<!-- Command output: terminal-style block -->
<div class="rounded-lg border border-gray-700 dark:border-gray-600 overflow-hidden">
<div class="flex items-center gap-2 bg-gray-800 dark:bg-gray-900 px-3 py-1.5 text-xs">
<span class="text-green-400">&#9654;</span>
<span class="font-mono text-gray-300">{artifact.label}</span>
</div>
{#if contentLineCount(artifact.content) > LINE_COLLAPSE_THRESHOLD}
<details>
<summary class="cursor-pointer bg-gray-900 dark:bg-black px-3 py-1 text-xs text-gray-400 hover:text-gray-200">
Show {contentLineCount(artifact.content)} lines
</summary>
<pre class="overflow-x-auto bg-gray-900 dark:bg-black p-3 font-mono text-xs leading-relaxed text-green-300 dark:text-green-400">{artifact.content}</pre>
</details>
{:else}
<pre class="overflow-x-auto bg-gray-900 dark:bg-black p-3 font-mono text-xs leading-relaxed text-green-300 dark:text-green-400">{artifact.content}</pre>
{/if}
</div>
{:else if artifact.artifactType === ArtifactType.SEARCH_RESULT}
<!-- Search result: card with query title -->
<div class="rounded-lg border border-purple-200 dark:border-purple-800 bg-purple-50 dark:bg-purple-900/20 p-3">
<div class="mb-1 flex items-center gap-2 text-xs">
<span class="text-purple-500">&#128269;</span>
<span class="font-medium text-purple-700 dark:text-purple-300">{artifact.label}</span>
</div>
{#if contentLineCount(artifact.content) > LINE_COLLAPSE_THRESHOLD}
<details>
<summary class="cursor-pointer text-xs text-purple-500 dark:text-purple-400 hover:text-purple-700 dark:hover:text-purple-300">
Show full results
</summary>
<p class="mt-1 whitespace-pre-wrap text-xs text-gray-700 dark:text-gray-300">{@html linkify(artifact.content)}</p>
</details>
{:else}
<p class="whitespace-pre-wrap text-xs text-gray-700 dark:text-gray-300">{@html linkify(artifact.content)}</p>
{/if}
</div>
{:else}
<!-- Text / finding artifact: plain text -->
<div class="flex items-start gap-2 text-sm">
<span class="mt-0.5 text-gray-400 dark:text-gray-500">&#128196;</span>
<span class="text-xs text-gray-700 dark:text-gray-300">{@html linkify(artifact.content)}</span>
</div>
{/if}
{/each}
</div>
</div>
{/if}
</div> </div>
</details>
{#if result.summary} <style>
<p class="text-sm {statusConfig.text}">{result.summary}</p> details[open] > summary .chevron {
{/if} transform: rotate(90deg);
}
{#if result.failureReason} </style>
<p class="mt-2 text-sm text-red-700">Reason: {result.failureReason}</p>
{/if}
{#if result.artifacts.length > 0}
<div class="mt-3 border-t {statusConfig.border} pt-3">
<p class="mb-1.5 text-xs font-medium text-gray-600">Artifacts</p>
<ul class="space-y-1">
{#each result.artifacts as artifact (artifact)}
<li class="flex items-center gap-2 text-sm">
<span class="text-gray-400">&#128196;</span>
<span class="font-mono text-xs text-gray-700">{artifact}</span>
</li>
{/each}
</ul>
</div>
{/if}
</div>

View File

@@ -0,0 +1,94 @@
<script lang="ts">
import type { InferenceStats } from '$lib/proto/llm_multiverse/v1/common_pb';
let { stats }: { stats: InferenceStats } = $props();
const utilizationPct = $derived(
stats.contextWindowSize > 0
? Math.min(100, (stats.totalTokens / stats.contextWindowSize) * 100)
: 0
);
const utilizationColor = $derived.by(() => {
if (utilizationPct >= 90) return { bar: 'bg-red-500', text: 'text-red-700 dark:text-red-400' };
if (utilizationPct >= 70) return { bar: 'bg-amber-500', text: 'text-amber-700 dark:text-amber-400' };
return { bar: 'bg-blue-500', text: 'text-blue-700 dark:text-blue-400' };
});
function formatNumber(n: number): string {
return n.toLocaleString();
}
</script>
<details class="mx-4 mb-3 rounded-xl border border-indigo-200 dark:border-indigo-800 bg-indigo-50 dark:bg-indigo-900/20">
<summary class="flex cursor-pointer items-center gap-2 px-4 py-2.5 select-none">
<svg class="chevron h-4 w-4 shrink-0 text-gray-500 dark:text-gray-400 transition-transform" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" /></svg>
<span class="rounded-full bg-indigo-100 dark:bg-indigo-900/40 px-2.5 py-0.5 text-xs font-medium text-indigo-800 dark:text-indigo-300">
Stats
</span>
<span class="truncate text-xs text-indigo-700 dark:text-indigo-400">
{formatNumber(stats.totalTokens)} tokens
{#if stats.tokensPerSecond > 0}
&middot; {stats.tokensPerSecond.toFixed(1)} tok/s
{/if}
{#if stats.contextWindowSize > 0}
&middot; {utilizationPct.toFixed(0)}% context
{/if}
</span>
</summary>
<div class="border-t border-indigo-200 dark:border-indigo-800 px-4 pb-4 pt-3">
<div class="grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-3">
<div>
<p class="text-[10px] font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Prompt</p>
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{formatNumber(stats.promptTokens)}</p>
</div>
<div>
<p class="text-[10px] font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Completion</p>
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{formatNumber(stats.completionTokens)}</p>
</div>
<div>
<p class="text-[10px] font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Total</p>
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{formatNumber(stats.totalTokens)}</p>
</div>
{#if stats.tokensPerSecond > 0}
<div>
<p class="text-[10px] font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Throughput</p>
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{stats.tokensPerSecond.toFixed(1)} tok/s</p>
</div>
{/if}
{#if stats.contextWindowSize > 0}
<div class="col-span-2">
<p class="text-[10px] font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Context Window</p>
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{formatNumber(stats.contextWindowSize)}</p>
</div>
{/if}
</div>
{#if stats.contextWindowSize > 0}
<div class="mt-3 border-t border-indigo-200 dark:border-indigo-800 pt-3">
<div class="flex items-center justify-between text-xs">
<span class="font-medium text-gray-600 dark:text-gray-400">Context Utilization</span>
<span class="font-semibold {utilizationColor.text}">{utilizationPct.toFixed(1)}%</span>
</div>
<div class="mt-1.5 h-2 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
class="h-full rounded-full transition-all duration-300 {utilizationColor.bar}"
style="width: {utilizationPct}%"
role="progressbar"
aria-valuenow={utilizationPct}
aria-valuemin={0}
aria-valuemax={100}
aria-label="Context window utilization"
></div>
</div>
</div>
{/if}
</div>
</details>
<style>
details[open] > summary .chevron {
transform: rotate(90deg);
}
</style>

View File

@@ -0,0 +1,301 @@
<script lang="ts">
import type { LineageNode } from '$lib/types/lineage';
import { agentTypeLabel, agentTypeColor } from '$lib/types/lineage';
import { themeStore } from '$lib/stores/theme.svelte';
let {
nodes,
onSelectNode
}: {
nodes: LineageNode[];
onSelectNode?: (node: LineageNode) => void;
} = $props();
let selectedNodeId: string | null = $state(null);
// Layout constants
const NODE_WIDTH = 180;
const NODE_HEIGHT = 64;
const HORIZONTAL_GAP = 60;
const VERTICAL_GAP = 24;
/**
* Positioned node with computed x/y coordinates for SVG rendering.
*/
interface PositionedNode {
node: LineageNode;
x: number;
y: number;
children: PositionedNode[];
}
/**
* Edge between two positioned nodes.
*/
interface Edge {
fromX: number;
fromY: number;
toX: number;
toY: number;
}
/**
* Computes leaf counts for every node in a single bottom-up pass.
* Stores results in a map keyed by node identity.
*/
function computeLeafCounts(roots: LineageNode[]): Map<LineageNode, number> {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- internal computation map, not reactive state
const counts = new Map<LineageNode, number>();
function walk(node: LineageNode): number {
if (node.children.length === 0) {
counts.set(node, 1);
return 1;
}
const total = node.children.reduce((sum, child) => sum + walk(child), 0);
counts.set(node, total);
return total;
}
for (const root of roots) walk(root);
return counts;
}
/**
* Computes positioned nodes using a horizontal tree layout.
* Root is on the left; children branch to the right.
*/
function layoutTree(roots: LineageNode[]): {
positioned: PositionedNode[];
totalWidth: number;
totalHeight: number;
} {
const leafCounts = computeLeafCounts(roots);
const totalLeaves = roots.reduce((sum, r) => sum + (leafCounts.get(r) ?? 1), 0);
const totalHeight = totalLeaves * (NODE_HEIGHT + VERTICAL_GAP) - VERTICAL_GAP;
function positionNode(
node: LineageNode,
depth: number,
startY: number,
availableHeight: number
): PositionedNode {
const x = depth * (NODE_WIDTH + HORIZONTAL_GAP);
if (node.children.length === 0) {
const y = startY + availableHeight / 2 - NODE_HEIGHT / 2;
return { node, x, y, children: [] };
}
const childLeafCounts = node.children.map((c) => leafCounts.get(c) ?? 1);
const totalChildLeaves = childLeafCounts.reduce((a, b) => a + b, 0);
let currentY = startY;
const positionedChildren: PositionedNode[] = [];
for (let i = 0; i < node.children.length; i++) {
const childHeight = (childLeafCounts[i] / totalChildLeaves) * availableHeight;
const positioned = positionNode(node.children[i], depth + 1, currentY, childHeight);
positionedChildren.push(positioned);
currentY += childHeight;
}
// Center parent vertically relative to its children
const firstChildCenter = positionedChildren[0].y + NODE_HEIGHT / 2;
const lastChildCenter =
positionedChildren[positionedChildren.length - 1].y + NODE_HEIGHT / 2;
const y = (firstChildCenter + lastChildCenter) / 2 - NODE_HEIGHT / 2;
return { node, x, y, children: positionedChildren };
}
let currentY = 0;
const positioned: PositionedNode[] = [];
for (const root of roots) {
const rootLeaves = leafCounts.get(root) ?? 1;
const rootHeight = (rootLeaves / totalLeaves) * totalHeight;
positioned.push(positionNode(root, 0, currentY, rootHeight));
currentY += rootHeight;
}
// Calculate max depth for width
function maxDepth(pn: PositionedNode): number {
if (pn.children.length === 0) return 0;
return 1 + Math.max(...pn.children.map(maxDepth));
}
const depth = positioned.length > 0 ? Math.max(...positioned.map(maxDepth)) : 0;
const totalWidth = (depth + 1) * (NODE_WIDTH + HORIZONTAL_GAP) - HORIZONTAL_GAP;
return { positioned, totalWidth, totalHeight };
}
/**
* Collects all edges from the positioned tree for SVG rendering.
*/
function collectEdges(positioned: PositionedNode[]): Edge[] {
const edges: Edge[] = [];
function walk(pn: PositionedNode) {
for (const child of pn.children) {
edges.push({
fromX: pn.x + NODE_WIDTH,
fromY: pn.y + NODE_HEIGHT / 2,
toX: child.x,
toY: child.y + NODE_HEIGHT / 2
});
walk(child);
}
}
for (const pn of positioned) {
walk(pn);
}
return edges;
}
/**
* Collects all positioned nodes into a flat list for rendering.
*/
function collectNodes(positioned: PositionedNode[]): PositionedNode[] {
const result: PositionedNode[] = [];
function walk(pn: PositionedNode) {
result.push(pn);
for (const child of pn.children) {
walk(child);
}
}
for (const pn of positioned) {
walk(pn);
}
return result;
}
const layout = $derived(layoutTree(nodes));
const edges = $derived(collectEdges(layout.positioned));
const allNodes = $derived(collectNodes(layout.positioned));
const svgWidth = $derived(layout.totalWidth + 40);
const svgHeight = $derived(layout.totalHeight + 40);
// Dark-mode aware colors for SVG elements
const edgeColor = $derived(themeStore.isDark ? '#4b5563' : '#d1d5db');
const defaultStroke = $derived(themeStore.isDark ? '#4b5563' : '#e5e7eb');
const idTextColor = $derived(themeStore.isDark ? '#9ca3af' : '#6b7280');
const depthTextColor = $derived(themeStore.isDark ? '#6b7280' : '#9ca3af');
function handleNodeClick(node: LineageNode) {
selectedNodeId = selectedNodeId === node.id ? null : node.id;
onSelectNode?.(node);
}
/**
* Generates an SVG path for a smooth bezier curve between two points.
*/
function edgePath(edge: Edge): string {
const midX = (edge.fromX + edge.toX) / 2;
return `M ${edge.fromX} ${edge.fromY} C ${midX} ${edge.fromY}, ${midX} ${edge.toY}, ${edge.toX} ${edge.toY}`;
}
/**
* Truncates an ID string for display in the node.
*/
function truncateId(id: string): string {
return id.length > 16 ? id.slice(0, 14) + '..' : id;
}
</script>
<div class="overflow-auto rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
{#if nodes.length === 0}
<div class="flex items-center justify-center p-12 text-sm text-gray-400 dark:text-gray-500">
No lineage data available
</div>
{:else}
<svg
width={svgWidth}
height={svgHeight}
viewBox="0 0 {svgWidth} {svgHeight}"
class="min-w-full"
>
<g transform="translate(20, 20)">
<!-- Edges -->
{#each edges as edge, i (i)}
<path
d={edgePath(edge)}
fill="none"
stroke={edgeColor}
stroke-width="2"
stroke-linecap="round"
/>
{/each}
<!-- Nodes -->
{#each allNodes as pn (pn.node.id)}
{@const colors = agentTypeColor(pn.node.agentType)}
{@const colorSet = themeStore.isDark ? colors.dark : colors.light}
{@const isSelected = selectedNodeId === pn.node.id}
<g
class="cursor-pointer"
transform="translate({pn.x}, {pn.y})"
onclick={() => handleNodeClick(pn.node)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') handleNodeClick(pn.node);
}}
role="button"
tabindex="0"
aria-label="Agent {pn.node.id}, type {agentTypeLabel(pn.node.agentType)}, depth {pn.node.spawnDepth}"
>
<!-- Node background -->
<rect
width={NODE_WIDTH}
height={NODE_HEIGHT}
rx="8"
ry="8"
fill={colorSet.fill}
stroke={isSelected ? colorSet.stroke : defaultStroke}
stroke-width={isSelected ? 2.5 : 1.5}
/>
<!-- Agent type label -->
<text
x={NODE_WIDTH / 2}
y="22"
text-anchor="middle"
fill={colorSet.text}
font-size="12"
font-weight="600"
>
{agentTypeLabel(pn.node.agentType)}
</text>
<!-- Agent ID -->
<text
x={NODE_WIDTH / 2}
y="38"
text-anchor="middle"
fill={idTextColor}
font-size="10"
font-family="monospace"
>
{truncateId(pn.node.id)}
</text>
<!-- Spawn depth badge -->
<text
x={NODE_WIDTH / 2}
y="54"
text-anchor="middle"
fill={depthTextColor}
font-size="10"
>
depth: {pn.node.spawnDepth}
</text>
</g>
{/each}
</g>
</svg>
{/if}
</div>

View File

@@ -0,0 +1,40 @@
<script lang="ts">
import type { StoredMemoryCandidate } from '$lib/stores/memory.svelte';
import { resultSourceConfig } from '$lib/types/resultSource';
let { candidate }: { candidate: StoredMemoryCandidate } = $props();
const sourceBadge = $derived(resultSourceConfig(candidate.source));
const confidencePct = $derived(Math.round(candidate.confidence * 100));
const confidenceColor = $derived.by(() => {
if (candidate.confidence >= 0.8) return { bar: 'bg-green-500', text: 'text-green-700 dark:text-green-400' };
if (candidate.confidence >= 0.5) return { bar: 'bg-amber-500', text: 'text-amber-700 dark:text-amber-400' };
return { bar: 'bg-red-500', text: 'text-red-700 dark:text-red-400' };
});
</script>
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-4">
<div class="mb-2 flex items-center justify-between gap-2">
<span
class="shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium {sourceBadge.bg} {sourceBadge.text}"
>
{sourceBadge.label}
</span>
<span class="text-xs font-medium {confidenceColor.text}">
{confidencePct}%
</span>
</div>
<p class="mb-3 text-sm text-gray-700 dark:text-gray-300">{candidate.content}</p>
<div class="flex items-center gap-2">
<div class="h-2 flex-1 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-700">
<div
class="h-full rounded-full transition-all {confidenceColor.bar}"
style="width: {confidencePct}%"
></div>
</div>
</div>
</div>

View File

@@ -8,12 +8,12 @@
<div class="flex {isUser ? 'justify-end' : 'justify-start'} mb-3"> <div class="flex {isUser ? 'justify-end' : 'justify-start'} mb-3">
<div <div
class="max-w-[75%] rounded-2xl px-4 py-2.5 {isUser class="max-w-[85%] md:max-w-[75%] rounded-2xl px-3 py-2 md:px-4 md:py-2.5 {isUser
? 'bg-blue-600 text-white rounded-br-md' ? 'bg-blue-600 text-white rounded-br-md'
: 'bg-gray-200 text-gray-900 rounded-bl-md'}" : 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-bl-md'}"
> >
<p class="text-sm whitespace-pre-wrap">{message.content}</p> <p class="text-sm whitespace-pre-wrap">{message.content}</p>
<time class="mt-1 block text-xs {isUser ? 'text-blue-200' : 'text-gray-500'}"> <time class="mt-1 block text-xs {isUser ? 'text-blue-200' : 'text-gray-500 dark:text-gray-400'}">
{message.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} {message.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</time> </time>
</div> </div>

View File

@@ -35,8 +35,8 @@
}); });
</script> </script>
<div class="border-t border-gray-200 bg-white p-4"> <div class="border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-3 md:p-4">
<form onsubmit={handleSubmit} class="flex items-end gap-2"> <form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="flex items-end gap-2">
<textarea <textarea
bind:this={textarea} bind:this={textarea}
bind:value={input} bind:value={input}
@@ -45,17 +45,17 @@
{disabled} {disabled}
placeholder="Type a message..." placeholder="Type a message..."
rows="1" rows="1"
class="flex-1 resize-none rounded-xl border border-gray-300 px-4 py-2.5 text-sm class="flex-1 resize-none rounded-xl border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2.5 text-base md:px-4 md:text-sm text-gray-900 dark:text-gray-100
focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none
disabled:bg-gray-100 disabled:text-gray-400" disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:text-gray-400 dark:disabled:text-gray-500
placeholder:text-gray-400 dark:placeholder:text-gray-500"
></textarea> ></textarea>
<button <button
type="button" type="submit"
onclick={handleSubmit}
disabled={disabled || !input.trim()} disabled={disabled || !input.trim()}
class="rounded-xl bg-blue-600 px-4 py-2.5 text-sm font-medium text-white class="min-h-[44px] min-w-[44px] rounded-xl bg-blue-600 px-4 py-2.5 text-sm font-medium text-white
hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none
disabled:bg-gray-300 disabled:cursor-not-allowed" disabled:bg-gray-300 dark:disabled:bg-gray-600 disabled:cursor-not-allowed"
> >
Send Send
</button> </button>

View File

@@ -21,10 +21,10 @@
}); });
</script> </script>
<div bind:this={container} class="flex-1 overflow-y-auto p-4"> <div bind:this={container} class="flex-1 overflow-y-auto p-4 bg-white dark:bg-gray-900">
{#if messages.length === 0} {#if messages.length === 0}
<div class="flex h-full items-center justify-center"> <div class="flex h-full items-center justify-center">
<div class="text-center text-gray-400"> <div class="text-center text-gray-400 dark:text-gray-500">
<p class="text-lg font-medium">No messages yet</p> <p class="text-lg font-medium">No messages yet</p>
<p class="mt-1 text-sm">Send a message to start a conversation</p> <p class="mt-1 text-sm">Send a message to start a conversation</p>
</div> </div>

View File

@@ -19,7 +19,7 @@
} }
</script> </script>
<div class="mx-4 mb-2 rounded-xl bg-gray-50 px-4 py-3"> <div class="mx-4 mb-2 rounded-xl bg-gray-50 dark:bg-gray-800 px-4 py-3">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
{#each phases as phase, i (phase.state)} {#each phases as phase, i (phase.state)}
{@const status = getStatus(phase.state)} {@const status = getStatus(phase.state)}
@@ -29,8 +29,8 @@
{status === 'completed' {status === 'completed'
? 'bg-green-500 text-white' ? 'bg-green-500 text-white'
: status === 'active' : status === 'active'
? 'bg-blue-500 text-white ring-2 ring-blue-300 ring-offset-1' ? 'bg-blue-500 text-white ring-2 ring-blue-300 dark:ring-blue-600 ring-offset-1 dark:ring-offset-gray-800 animate-pulse-ring'
: 'bg-gray-200 text-gray-500'}" : 'bg-gray-200 dark:bg-gray-600 text-gray-500 dark:text-gray-400'}"
> >
{#if status === 'completed'} {#if status === 'completed'}
&#10003; &#10003;
@@ -40,17 +40,51 @@
</div> </div>
<span <span
class="text-xs transition-colors duration-300 class="text-xs transition-colors duration-300
{status === 'active' ? 'font-medium text-blue-600' : 'text-gray-500'}" {status === 'active' ? 'font-medium text-blue-600 dark:text-blue-400' : 'text-gray-500 dark:text-gray-400'}"
> >
{phase.label} {phase.label}
</span> </span>
</div> </div>
{#if i < phases.length - 1} {#if i < phases.length - 1}
<div {@const nextStatus = getStatus(phases[i + 1].state)}
class="mb-5 h-0.5 flex-1 transition-colors duration-300 <div class="relative mb-5 h-0.5 flex-1 bg-gray-200 dark:bg-gray-600 overflow-hidden">
{getStatus(phases[i + 1].state) !== 'pending' ? 'bg-green-500' : 'bg-gray-200'}" {#if nextStatus !== 'pending'}
></div> <!-- Completed: solid green fill -->
<div class="absolute inset-0 bg-green-500"></div>
{:else if status === 'active'}
<!-- Active: animated fill shimmer -->
<div class="absolute inset-0 animate-progress-fill bg-gradient-to-r from-blue-500 via-blue-400 to-blue-500 bg-[length:200%_100%]"></div>
{/if}
</div>
{/if} {/if}
{/each} {/each}
</div> </div>
</div> </div>
<style>
@keyframes pulse-ring {
0%, 100% {
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.5);
}
50% {
box-shadow: 0 0 0 6px rgba(59, 130, 246, 0);
}
}
@keyframes progress-fill {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
:global(.animate-pulse-ring) {
animation: pulse-ring 2s ease-in-out infinite;
}
:global(.animate-progress-fill) {
animation: progress-fill 2s linear infinite;
}
</style>

View File

@@ -0,0 +1,38 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
let {
title,
backHref,
backLabel = 'Chat',
children
}: {
title: string;
backHref?: string;
backLabel?: string;
children?: Snippet;
} = $props();
</script>
<header class="flex items-center justify-between border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-4 py-3 md:px-6">
<div class="flex items-center gap-2 md:gap-4">
{#if backHref}
<!-- eslint-disable svelte/no-navigation-without-resolve -- resolveRoute is resolve; plugin does not recognize the alias -->
<a
href={backHref}
class="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"
>
&larr; {backLabel}
</a>
<!-- eslint-enable svelte/no-navigation-without-resolve -->
{/if}
<h1 class="text-base md:text-lg font-semibold text-gray-900 dark:text-gray-100">{title}</h1>
</div>
<div class="flex items-center gap-2">
{#if children}
{@render children()}
{/if}
<ThemeToggle />
</div>
</header>

View File

@@ -1,10 +1,19 @@
<script lang="ts"> <script lang="ts">
import { sessionStore } from '$lib/stores/sessions.svelte'; import { sessionStore } from '$lib/stores/sessions.svelte';
import { formatRelativeDate } from '$lib/utils/date';
import Backdrop from '$lib/components/Backdrop.svelte';
let { let {
onSelectSession, onSelectSession,
onNewChat onNewChat,
}: { onSelectSession: (id: string) => void; onNewChat: () => void } = $props(); open = false,
onClose
}: {
onSelectSession: (id: string) => void;
onNewChat: () => void;
open?: boolean;
onClose?: () => void;
} = $props();
let confirmDeleteId: string | null = $state(null); let confirmDeleteId: string | null = $state(null);
@@ -24,55 +33,76 @@
} }
} }
function formatDate(date: Date): string { function handleSelectAndClose(id: string) {
const now = new Date(); onSelectSession(id);
const diff = now.getTime() - date.getTime(); onClose?.();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) return 'Today';
if (days === 1) return 'Yesterday';
if (days < 7) return `${days} days ago`;
return date.toLocaleDateString();
} }
function handleNewChatAndClose() {
onNewChat();
onClose?.();
}
</script> </script>
<aside class="flex h-full w-64 flex-col border-r border-gray-200 bg-gray-50"> {#if open}
<div class="border-b border-gray-200 p-3"> <Backdrop onClose={() => onClose?.()} />
{/if}
<!-- Sidebar: always visible on md+, slide-in drawer on mobile when open -->
<aside
class="flex h-full w-64 flex-col border-r border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800
fixed inset-y-0 left-0 z-50 transition-transform duration-300 ease-in-out
{open ? 'translate-x-0' : '-translate-x-full'}
md:relative md:z-auto md:translate-x-0 md:transition-none"
>
<div class="flex items-center justify-between border-b border-gray-200 dark:border-gray-700 p-3">
<button <button
type="button" type="button"
onclick={onNewChat} onclick={handleNewChatAndClose}
class="w-full rounded-lg bg-blue-600 px-3 py-2 text-sm font-medium text-white hover:bg-blue-700" class="flex-1 rounded-lg bg-blue-600 px-3 py-2 text-sm font-medium text-white hover:bg-blue-700"
> >
+ New Chat + New Chat
</button> </button>
<!-- Close button visible only on mobile -->
<button
type="button"
onclick={onClose}
class="ml-2 flex h-9 w-9 items-center justify-center rounded-lg text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700 md:hidden"
aria-label="Close sidebar"
>
&#10005;
</button>
</div> </div>
<div class="flex-1 overflow-y-auto"> <div class="flex-1 overflow-y-auto">
{#if sessions.length === 0} {#if sessions.length === 0}
<p class="p-4 text-center text-sm text-gray-400">No sessions yet</p> <p class="p-4 text-center text-sm text-gray-400 dark:text-gray-500">No sessions yet</p>
{:else} {:else}
{#each sessions as session (session.id)} {#each sessions as session (session.id)}
<div <div
role="button" role="button"
tabindex="0" tabindex="0"
onclick={() => onSelectSession(session.id)} onclick={() => handleSelectAndClose(session.id)}
onkeydown={(e) => { if (e.key === 'Enter') onSelectSession(session.id); }} onkeydown={(e) => { if (e.key === 'Enter') handleSelectAndClose(session.id); }}
class="group flex w-full cursor-pointer items-start gap-2 border-b border-gray-100 px-3 py-3 text-left hover:bg-gray-100 class="group flex w-full cursor-pointer items-start gap-2 border-b border-gray-100 dark:border-gray-700 px-3 py-3 text-left hover:bg-gray-100 dark:hover:bg-gray-700
{activeId === session.id ? 'bg-blue-50 border-l-2 border-l-blue-500' : ''}" {activeId === session.id ? 'bg-blue-50 dark:bg-blue-900/30 border-l-2 border-l-blue-500' : ''}"
> >
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-gray-900">{session.title}</p> <p class="truncate text-sm font-medium text-gray-900 dark:text-gray-100">{session.title}</p>
<p class="mt-0.5 text-xs text-gray-500">{formatDate(session.createdAt)}</p> <p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">{formatRelativeDate(session.createdAt)}</p>
</div> </div>
<button <button
type="button" type="button"
onclick={(e) => handleDelete(e, session.id)} onclick={(e) => handleDelete(e, session.id)}
class="shrink-0 rounded p-1 text-xs opacity-0 group-hover:opacity-100 class="shrink-0 rounded p-1 text-xs opacity-0 group-hover:opacity-100
{confirmDeleteId === session.id {confirmDeleteId === session.id
? 'bg-red-100 text-red-600' ? 'bg-red-100 dark:bg-red-900/40 text-red-600 dark:text-red-400'
: 'text-gray-400 hover:bg-gray-200 hover:text-gray-600'}" : 'text-gray-400 dark:text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-600 hover:text-gray-600 dark:hover:text-gray-300'}"
title={confirmDeleteId === session.id ? 'Click again to confirm' : 'Delete session'} title={confirmDeleteId === session.id ? 'Click again to confirm' : 'Delete session'}
> >
{confirmDeleteId === session.id ? '' : ''} {confirmDeleteId === session.id ? '\u2713' : '\u2715'}
</button> </button>
</div> </div>
{/each} {/each}

View File

@@ -0,0 +1,37 @@
<script lang="ts">
import { themeStore } from '$lib/stores/theme.svelte';
const labels: Record<string, string> = {
system: 'System',
light: 'Light',
dark: 'Dark'
};
</script>
<button
type="button"
onclick={() => themeStore.cycle()}
class="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-100"
title="Theme: {labels[themeStore.mode]} (click to cycle)"
>
{#if themeStore.mode === 'light'}
<!-- Sun icon -->
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="5" />
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
</svg>
{:else if themeStore.mode === 'dark'}
<!-- Moon icon -->
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
{:else}
<!-- Monitor/system icon -->
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
<line x1="8" y1="21" x2="16" y2="21" />
<line x1="12" y1="17" x2="12" y2="21" />
</svg>
{/if}
<span class="text-xs">{labels[themeStore.mode]}</span>
</button>

View File

@@ -9,7 +9,7 @@
<button <button
type="button" type="button"
onclick={() => (expanded = !expanded)} onclick={() => (expanded = !expanded)}
class="flex w-full items-center gap-2 rounded-lg bg-amber-50 px-3 py-2 text-left text-sm text-amber-700 hover:bg-amber-100 transition-colors" class="flex w-full items-center gap-2 rounded-lg bg-amber-50 dark:bg-amber-900/30 px-3 py-2 text-left text-sm text-amber-700 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-900/50 transition-colors"
> >
<span <span
class="inline-block transition-transform duration-200 {expanded class="inline-block transition-transform duration-200 {expanded
@@ -22,7 +22,7 @@
</button> </button>
{#if expanded} {#if expanded}
<div <div
class="mt-1 rounded-b-lg border border-t-0 border-amber-200 bg-amber-50/50 px-4 py-3 text-sm text-amber-800 whitespace-pre-wrap" class="mt-1 rounded-b-lg border border-t-0 border-amber-200 dark:border-amber-700 bg-amber-50/50 dark:bg-amber-900/20 px-4 py-3 text-sm text-amber-800 dark:text-amber-200 whitespace-pre-wrap"
> >
{content} {content}
</div> </div>

View File

@@ -0,0 +1,86 @@
<script lang="ts">
import { toastStore, type ToastType } from '$lib/stores/toast.svelte';
const typeStyles: Record<ToastType, { bg: string; border: string; icon: string }> = {
error: {
bg: 'bg-red-50 dark:bg-red-900/40',
border: 'border-red-200 dark:border-red-800',
icon: 'text-red-500 dark:text-red-400'
},
warning: {
bg: 'bg-amber-50 dark:bg-amber-900/40',
border: 'border-amber-200 dark:border-amber-800',
icon: 'text-amber-500 dark:text-amber-400'
},
success: {
bg: 'bg-green-50 dark:bg-green-900/40',
border: 'border-green-200 dark:border-green-800',
icon: 'text-green-500 dark:text-green-400'
},
info: {
bg: 'bg-blue-50 dark:bg-blue-900/40',
border: 'border-blue-200 dark:border-blue-800',
icon: 'text-blue-500 dark:text-blue-400'
}
};
const typeLabels: Record<ToastType, string> = {
error: 'Error',
warning: 'Warning',
success: 'Success',
info: 'Info'
};
</script>
{#if toastStore.toasts.length > 0}
<div class="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-sm w-full pointer-events-none">
{#each toastStore.toasts as toast (toast.id)}
{@const style = typeStyles[toast.type]}
<div
class="pointer-events-auto flex items-start gap-3 rounded-lg border px-4 py-3 shadow-lg {style.bg} {style.border}"
role="alert"
>
<!-- Icon -->
<div class="mt-0.5 shrink-0 {style.icon}">
{#if toast.type === 'error'}
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
{:else if toast.type === 'warning'}
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z" />
</svg>
{:else if toast.type === 'success'}
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
{:else}
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" />
</svg>
{/if}
</div>
<!-- Content -->
<div class="min-w-0 flex-1">
<p class="text-xs font-medium text-gray-900 dark:text-gray-100">{typeLabels[toast.type]}</p>
<p class="mt-0.5 text-sm text-gray-700 dark:text-gray-300">{toast.message}</p>
</div>
<!-- Dismiss button -->
{#if toast.dismissable}
<button
type="button"
onclick={() => toastStore.removeToast(toast.id)}
class="shrink-0 rounded-md p-1 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
aria-label="Dismiss"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
{/if}
</div>
{/each}
</div>
{/if}

View File

@@ -0,0 +1,174 @@
import type { ChatMessage } from '$lib/types';
import type { SubagentResult, InferenceStats } from '$lib/proto/llm_multiverse/v1/common_pb';
import type { SessionConfig } from '$lib/proto/llm_multiverse/v1/orchestrator_pb';
import { processRequest, OrchestratorError, friendlyMessage } from '$lib/services/orchestrator';
import { OrchestrationState } from '$lib/proto/llm_multiverse/v1/orchestrator_pb';
import { sessionStore } from '$lib/stores/sessions.svelte';
import { memoryStore } from '$lib/stores/memory.svelte';
import { auditStore } from '$lib/stores/audit.svelte';
import { logger } from '$lib/utils/logger';
export function createOrchestration() {
let isStreaming = $state(false);
let error: string | null = $state(null);
let lastFailedContent: string | null = $state(null);
let orchestrationState: OrchestrationState = $state(OrchestrationState.UNSPECIFIED);
let intermediateResult: string = $state('');
let finalResult: SubagentResult | null = $state(null);
let inferenceStats: InferenceStats | null = $state(null);
async function send(
sessionId: string,
content: string,
config: SessionConfig,
messages: ChatMessage[]
): Promise<ChatMessage[]> {
error = null;
lastFailedContent = null;
orchestrationState = OrchestrationState.UNSPECIFIED;
intermediateResult = '';
finalResult = null;
inferenceStats = null;
let lastAuditState = OrchestrationState.UNSPECIFIED;
const userMessage: ChatMessage = {
id: crypto.randomUUID(),
role: 'user',
content,
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- plain data, not reactive state
timestamp: new Date()
};
messages.push(userMessage);
const assistantMessage: ChatMessage = {
id: crypto.randomUUID(),
role: 'assistant',
content: '',
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- plain data, not reactive state
timestamp: new Date()
};
messages.push(assistantMessage);
sessionStore.updateMessages(sessionId, messages);
isStreaming = true;
try {
for await (const response of processRequest(sessionId, content, config)) {
orchestrationState = response.state;
if (response.state !== lastAuditState) {
const stateLabel = OrchestrationState[response.state] ?? String(response.state);
logger.debug('useOrchestration', `State: ${stateLabel}`, {
sessionId,
state: response.state
});
auditStore.addEvent(sessionId, {
eventType: 'state_change',
details: response.message || `State changed to ${stateLabel}`,
state: stateLabel
});
lastAuditState = response.state;
}
if (response.intermediateResult) {
intermediateResult = response.intermediateResult;
}
if (response.inferenceStats) {
inferenceStats = response.inferenceStats;
}
if (response.finalResult) {
finalResult = response.finalResult;
if (response.finalResult.newMemoryCandidates.length > 0) {
memoryStore.addCandidates(
sessionId,
response.finalResult.newMemoryCandidates.map((mc) => ({
content: mc.content,
source: mc.source,
confidence: mc.confidence
}))
);
}
}
const idx = messages.length - 1;
messages[idx] = {
...messages[idx],
content: response.message
};
}
} catch (err) {
const isOrcErr = err instanceof OrchestratorError;
const code = isOrcErr ? err.code : 'unknown';
const details = isOrcErr ? err.details : undefined;
const friendlyMsg = isOrcErr
? friendlyMessage(err.code)
: 'An unexpected error occurred';
const displayMsg =
code && code !== 'unknown'
? `${friendlyMsg} (${code})`
: friendlyMsg;
error = displayMsg;
lastFailedContent = content;
logger.error('useOrchestration', 'Request failed', { code, details });
auditStore.addEvent(sessionId, {
eventType: 'error',
details: `${friendlyMsg} | code=${code}${details ? ` | details=${details}` : ''}`
});
const idx = messages.length - 1;
messages[idx] = {
...messages[idx],
content: `\u26A0 ${displayMsg}`
};
} finally {
isStreaming = false;
sessionStore.updateMessages(sessionId, messages);
}
return messages;
}
function retry(
sessionId: string,
config: SessionConfig,
messages: ChatMessage[]
): { messages: ChatMessage[]; sending: boolean } {
if (!lastFailedContent) return { messages, sending: false };
const content = lastFailedContent;
// Remove the failed user+assistant pair before retrying
if (
messages.length >= 2 &&
messages[messages.length - 2].role === 'user' &&
messages[messages.length - 1].role === 'assistant' &&
messages[messages.length - 1].content.startsWith('\u26A0')
) {
messages = messages.slice(0, -2);
}
send(sessionId, content, config, messages);
return { messages, sending: true };
}
function reset() {
error = null;
finalResult = null;
inferenceStats = null;
}
return {
get isStreaming() { return isStreaming; },
get error() { return error; },
get lastFailedContent() { return lastFailedContent; },
get orchestrationState() { return orchestrationState; },
get intermediateResult() { return intermediateResult; },
get finalResult() { return finalResult; },
get inferenceStats() { return inferenceStats; },
send,
retry,
reset
};
}
/**
* Module-level singleton so orchestration state survives tab/route changes.
* The chat page uses this instead of calling createOrchestration() per mount.
*/
export const orchestrationStore = createOrchestration();

View File

@@ -12,7 +12,47 @@ import type { Message } from "@bufbuild/protobuf";
* Describes the file llm_multiverse/v1/common.proto. * Describes the file llm_multiverse/v1/common.proto.
*/ */
export const file_llm_multiverse_v1_common: GenFile = /*@__PURE__*/ export const file_llm_multiverse_v1_common: GenFile = /*@__PURE__*/
fileDesc("Ch5sbG1fbXVsdGl2ZXJzZS92MS9jb21tb24ucHJvdG8SEWxsbV9tdWx0aXZlcnNlLnYxImoKD0FnZW50SWRlbnRpZmllchIQCghhZ2VudF9pZBgBIAEoCRIwCgphZ2VudF90eXBlGAIgASgOMhwubGxtX211bHRpdmVyc2UudjEuQWdlbnRUeXBlEhMKC3NwYXduX2RlcHRoGAMgASgNIkIKDEFnZW50TGluZWFnZRIyCgZhZ2VudHMYASADKAsyIi5sbG1fbXVsdGl2ZXJzZS52MS5BZ2VudElkZW50aWZpZXIi1wEKDlNlc3Npb25Db250ZXh0EhIKCnNlc3Npb25faWQYASABKAkSDwoHdXNlcl9pZBgCIAEoCRI2Cg1hZ2VudF9saW5lYWdlGAMgASgLMh8ubGxtX211bHRpdmVyc2UudjEuQWdlbnRMaW5lYWdlEjgKDm92ZXJyaWRlX2xldmVsGAQgASgOMiAubGxtX211bHRpdmVyc2UudjEuT3ZlcnJpZGVMZXZlbBIuCgpjcmVhdGVkX2F0GAUgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCKdAQoLRXJyb3JEZXRhaWwSDAoEY29kZRgBIAEoCRIPCgdtZXNzYWdlGAIgASgJEj4KCG1ldGFkYXRhGAMgAygLMiwubGxtX211bHRpdmVyc2UudjEuRXJyb3JEZXRhaWwuTWV0YWRhdGFFbnRyeRovCg1NZXRhZGF0YUVudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoCToCOAEiZwoPTWVtb3J5Q2FuZGlkYXRlEg8KB2NvbnRlbnQYASABKAkSLwoGc291cmNlGAIgASgOMh8ubGxtX211bHRpdmVyc2UudjEuUmVzdWx0U291cmNlEhIKCmNvbmZpZGVuY2UYAyABKAIiwwIKDlN1YmFnZW50UmVzdWx0Ei8KBnN0YXR1cxgBIAEoDjIfLmxsbV9tdWx0aXZlcnNlLnYxLlJlc3VsdFN0YXR1cxIPCgdzdW1tYXJ5GAIgASgJEhEKCWFydGlmYWN0cxgDIAMoCRI4Cg5yZXN1bHRfcXVhbGl0eRgEIAEoDjIgLmxsbV9tdWx0aXZlcnNlLnYxLlJlc3VsdFF1YWxpdHkSLwoGc291cmNlGAUgASgOMh8ubGxtX211bHRpdmVyc2UudjEuUmVzdWx0U291cmNlEkEKFW5ld19tZW1vcnlfY2FuZGlkYXRlcxgGIAMoCzIiLmxsbV9tdWx0aXZlcnNlLnYxLk1lbW9yeUNhbmRpZGF0ZRIbCg5mYWlsdXJlX3JlYXNvbhgHIAEoCUgAiAEBQhEKD19mYWlsdXJlX3JlYXNvbiqoAQoJQWdlbnRUeXBlEhoKFkFHRU5UX1RZUEVfVU5TUEVDSUZJRUQQABIbChdBR0VOVF9UWVBFX09SQ0hFU1RSQVRPUhABEhkKFUFHRU5UX1RZUEVfUkVTRUFSQ0hFUhACEhQKEEFHRU5UX1RZUEVfQ09ERVIQAxIXChNBR0VOVF9UWVBFX1NZU0FETUlOEAQSGAoUQUdFTlRfVFlQRV9BU1NJU1RBTlQQBSr1AQoIVG9vbFR5cGUSGQoVVE9PTF9UWVBFX1VOU1BFQ0lGSUVEEAASGQoVVE9PTF9UWVBFX01FTU9SWV9SRUFEEAESGgoWVE9PTF9UWVBFX01FTU9SWV9XUklURRACEhgKFFRPT0xfVFlQRV9XRUJfU0VBUkNIEAMSFQoRVE9PTF9UWVBFX0ZTX1JFQUQQBBIWChJUT09MX1RZUEVfRlNfV1JJVEUQBRIWChJUT09MX1RZUEVfUlVOX0NPREUQBhIXChNUT09MX1RZUEVfUlVOX1NIRUxMEAcSHQoZVE9PTF9UWVBFX1BBQ0tBR0VfSU5TVEFMTBAIKnoKDU92ZXJyaWRlTGV2ZWwSHgoaT1ZFUlJJREVfTEVWRUxfVU5TUEVDSUZJRUQQABIXChNPVkVSUklERV9MRVZFTF9OT05FEAESGAoUT1ZFUlJJREVfTEVWRUxfUkVMQVgQAhIWChJPVkVSUklERV9MRVZFTF9BTEwQAyp9CgxSZXN1bHRTdGF0dXMSHQoZUkVTVUxUX1NUQVRVU19VTlNQRUNJRklFRBAAEhkKFVJFU1VMVF9TVEFUVVNfU1VDQ0VTUxABEhkKFVJFU1VMVF9TVEFUVVNfUEFSVElBTBACEhgKFFJFU1VMVF9TVEFUVVNfRkFJTEVEEAMqhwEKDVJlc3VsdFF1YWxpdHkSHgoaUkVTVUxUX1FVQUxJVFlfVU5TUEVDSUZJRUQQABIbChdSRVNVTFRfUVVBTElUWV9WRVJJRklFRBABEhsKF1JFU1VMVF9RVUFMSVRZX0lORkVSUkVEEAISHAoYUkVTVUxUX1FVQUxJVFlfVU5DRVJUQUlOEAMqhgEKDFJlc3VsdFNvdXJjZRIdChlSRVNVTFRfU09VUkNFX1VOU1BFQ0lGSUVEEAASHQoZUkVTVUxUX1NPVVJDRV9UT09MX09VVFBVVBABEiEKHVJFU1VMVF9TT1VSQ0VfTU9ERUxfS05PV0xFREdFEAISFQoRUkVTVUxUX1NPVVJDRV9XRUIQA2IGcHJvdG8z", [file_google_protobuf_timestamp]); fileDesc("Ch5sbG1fbXVsdGl2ZXJzZS92MS9jb21tb24ucHJvdG8SEWxsbV9tdWx0aXZlcnNlLnYxItABCghBcnRpZmFjdBINCgVsYWJlbBgBIAEoCRIPCgdjb250ZW50GAIgASgJEjYKDWFydGlmYWN0X3R5cGUYAyABKA4yHy5sbG1fbXVsdGl2ZXJzZS52MS5BcnRpZmFjdFR5cGUSOwoIbWV0YWRhdGEYBCADKAsyKS5sbG1fbXVsdGl2ZXJzZS52MS5BcnRpZmFjdC5NZXRhZGF0YUVudHJ5Gi8KDU1ldGFkYXRhRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASJqCg9BZ2VudElkZW50aWZpZXISEAoIYWdlbnRfaWQYASABKAkSMAoKYWdlbnRfdHlwZRgCIAEoDjIcLmxsbV9tdWx0aXZlcnNlLnYxLkFnZW50VHlwZRITCgtzcGF3bl9kZXB0aBgDIAEoDSJCCgxBZ2VudExpbmVhZ2USMgoGYWdlbnRzGAEgAygLMiIubGxtX211bHRpdmVyc2UudjEuQWdlbnRJZGVudGlmaWVyItcBCg5TZXNzaW9uQ29udGV4dBISCgpzZXNzaW9uX2lkGAEgASgJEg8KB3VzZXJfaWQYAiABKAkSNgoNYWdlbnRfbGluZWFnZRgDIAEoCzIfLmxsbV9tdWx0aXZlcnNlLnYxLkFnZW50TGluZWFnZRI4Cg5vdmVycmlkZV9sZXZlbBgEIAEoDjIgLmxsbV9tdWx0aXZlcnNlLnYxLk92ZXJyaWRlTGV2ZWwSLgoKY3JlYXRlZF9hdBgFIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAinQEKC0Vycm9yRGV0YWlsEgwKBGNvZGUYASABKAkSDwoHbWVzc2FnZRgCIAEoCRI+CghtZXRhZGF0YRgDIAMoCzIsLmxsbV9tdWx0aXZlcnNlLnYxLkVycm9yRGV0YWlsLk1ldGFkYXRhRW50cnkaLwoNTWV0YWRhdGFFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBImcKD01lbW9yeUNhbmRpZGF0ZRIPCgdjb250ZW50GAEgASgJEi8KBnNvdXJjZRgCIAEoDjIfLmxsbV9tdWx0aXZlcnNlLnYxLlJlc3VsdFNvdXJjZRISCgpjb25maWRlbmNlGAMgASgCIpABCg5JbmZlcmVuY2VTdGF0cxIVCg1wcm9tcHRfdG9rZW5zGAEgASgNEhkKEWNvbXBsZXRpb25fdG9rZW5zGAIgASgNEhQKDHRvdGFsX3Rva2VucxgDIAEoDRIbChNjb250ZXh0X3dpbmRvd19zaXplGAQgASgNEhkKEXRva2Vuc19wZXJfc2Vjb25kGAUgASgCIuACCg5TdWJhZ2VudFJlc3VsdBIvCgZzdGF0dXMYASABKA4yHy5sbG1fbXVsdGl2ZXJzZS52MS5SZXN1bHRTdGF0dXMSDwoHc3VtbWFyeRgCIAEoCRIuCglhcnRpZmFjdHMYAyADKAsyGy5sbG1fbXVsdGl2ZXJzZS52MS5BcnRpZmFjdBI4Cg5yZXN1bHRfcXVhbGl0eRgEIAEoDjIgLmxsbV9tdWx0aXZlcnNlLnYxLlJlc3VsdFF1YWxpdHkSLwoGc291cmNlGAUgASgOMh8ubGxtX211bHRpdmVyc2UudjEuUmVzdWx0U291cmNlEkEKFW5ld19tZW1vcnlfY2FuZGlkYXRlcxgGIAMoCzIiLmxsbV9tdWx0aXZlcnNlLnYxLk1lbW9yeUNhbmRpZGF0ZRIbCg5mYWlsdXJlX3JlYXNvbhgHIAEoCUgAiAEBQhEKD19mYWlsdXJlX3JlYXNvbiqoAQoJQWdlbnRUeXBlEhoKFkFHRU5UX1RZUEVfVU5TUEVDSUZJRUQQABIbChdBR0VOVF9UWVBFX09SQ0hFU1RSQVRPUhABEhkKFUFHRU5UX1RZUEVfUkVTRUFSQ0hFUhACEhQKEEFHRU5UX1RZUEVfQ09ERVIQAxIXChNBR0VOVF9UWVBFX1NZU0FETUlOEAQSGAoUQUdFTlRfVFlQRV9BU1NJU1RBTlQQBSr1AQoIVG9vbFR5cGUSGQoVVE9PTF9UWVBFX1VOU1BFQ0lGSUVEEAASGQoVVE9PTF9UWVBFX01FTU9SWV9SRUFEEAESGgoWVE9PTF9UWVBFX01FTU9SWV9XUklURRACEhgKFFRPT0xfVFlQRV9XRUJfU0VBUkNIEAMSFQoRVE9PTF9UWVBFX0ZTX1JFQUQQBBIWChJUT09MX1RZUEVfRlNfV1JJVEUQBRIWChJUT09MX1RZUEVfUlVOX0NPREUQBhIXChNUT09MX1RZUEVfUlVOX1NIRUxMEAcSHQoZVE9PTF9UWVBFX1BBQ0tBR0VfSU5TVEFMTBAIKnoKDU92ZXJyaWRlTGV2ZWwSHgoaT1ZFUlJJREVfTEVWRUxfVU5TUEVDSUZJRUQQABIXChNPVkVSUklERV9MRVZFTF9OT05FEAESGAoUT1ZFUlJJREVfTEVWRUxfUkVMQVgQAhIWChJPVkVSUklERV9MRVZFTF9BTEwQAyp9CgxSZXN1bHRTdGF0dXMSHQoZUkVTVUxUX1NUQVRVU19VTlNQRUNJRklFRBAAEhkKFVJFU1VMVF9TVEFUVVNfU1VDQ0VTUxABEhkKFVJFU1VMVF9TVEFUVVNfUEFSVElBTBACEhgKFFJFU1VMVF9TVEFUVVNfRkFJTEVEEAMqhwEKDVJlc3VsdFF1YWxpdHkSHgoaUkVTVUxUX1FVQUxJVFlfVU5TUEVDSUZJRUQQABIbChdSRVNVTFRfUVVBTElUWV9WRVJJRklFRBABEhsKF1JFU1VMVF9RVUFMSVRZX0lORkVSUkVEEAISHAoYUkVTVUxUX1FVQUxJVFlfVU5DRVJUQUlOEAMqhgEKDFJlc3VsdFNvdXJjZRIdChlSRVNVTFRfU09VUkNFX1VOU1BFQ0lGSUVEEAASHQoZUkVTVUxUX1NPVVJDRV9UT09MX09VVFBVVBABEiEKHVJFU1VMVF9TT1VSQ0VfTU9ERUxfS05PV0xFREdFEAISFQoRUkVTVUxUX1NPVVJDRV9XRUIQAyqgAQoMQXJ0aWZhY3RUeXBlEh0KGUFSVElGQUNUX1RZUEVfVU5TUEVDSUZJRUQQABIWChJBUlRJRkFDVF9UWVBFX0NPREUQARIWChJBUlRJRkFDVF9UWVBFX1RFWFQQAhIgChxBUlRJRkFDVF9UWVBFX0NPTU1BTkRfT1VUUFVUEAMSHwobQVJUSUZBQ1RfVFlQRV9TRUFSQ0hfUkVTVUxUEARiBnByb3RvMw", [file_google_protobuf_timestamp]);
/**
* A concrete output produced by an agent (code, command output, etc.).
*
* @generated from message llm_multiverse.v1.Artifact
*/
export type Artifact = Message<"llm_multiverse.v1.Artifact"> & {
/**
* Display name (filename, query, etc.)
*
* @generated from field: string label = 1;
*/
label: string;
/**
* Full content
*
* @generated from field: string content = 2;
*/
content: string;
/**
* @generated from field: llm_multiverse.v1.ArtifactType artifact_type = 3;
*/
artifactType: ArtifactType;
/**
* language, path, tool_name, exit_code, etc.
*
* @generated from field: map<string, string> metadata = 4;
*/
metadata: { [key: string]: string };
};
/**
* Describes the message llm_multiverse.v1.Artifact.
* Use `create(ArtifactSchema)` to create a new message.
*/
export const ArtifactSchema: GenMessage<Artifact> = /*@__PURE__*/
messageDesc(file_llm_multiverse_v1_common, 0);
/** /**
* Identifies a single agent in the lineage chain. * Identifies a single agent in the lineage chain.
@@ -41,7 +81,7 @@ export type AgentIdentifier = Message<"llm_multiverse.v1.AgentIdentifier"> & {
* Use `create(AgentIdentifierSchema)` to create a new message. * Use `create(AgentIdentifierSchema)` to create a new message.
*/ */
export const AgentIdentifierSchema: GenMessage<AgentIdentifier> = /*@__PURE__*/ export const AgentIdentifierSchema: GenMessage<AgentIdentifier> = /*@__PURE__*/
messageDesc(file_llm_multiverse_v1_common, 0); messageDesc(file_llm_multiverse_v1_common, 1);
/** /**
* Ordered chain of agents from orchestrator (index 0) to current agent. * Ordered chain of agents from orchestrator (index 0) to current agent.
@@ -61,7 +101,7 @@ export type AgentLineage = Message<"llm_multiverse.v1.AgentLineage"> & {
* Use `create(AgentLineageSchema)` to create a new message. * Use `create(AgentLineageSchema)` to create a new message.
*/ */
export const AgentLineageSchema: GenMessage<AgentLineage> = /*@__PURE__*/ export const AgentLineageSchema: GenMessage<AgentLineage> = /*@__PURE__*/
messageDesc(file_llm_multiverse_v1_common, 1); messageDesc(file_llm_multiverse_v1_common, 2);
/** /**
* Carried in every gRPC request for audit trail and broker enforcement. * Carried in every gRPC request for audit trail and broker enforcement.
@@ -100,7 +140,7 @@ export type SessionContext = Message<"llm_multiverse.v1.SessionContext"> & {
* Use `create(SessionContextSchema)` to create a new message. * Use `create(SessionContextSchema)` to create a new message.
*/ */
export const SessionContextSchema: GenMessage<SessionContext> = /*@__PURE__*/ export const SessionContextSchema: GenMessage<SessionContext> = /*@__PURE__*/
messageDesc(file_llm_multiverse_v1_common, 2); messageDesc(file_llm_multiverse_v1_common, 3);
/** /**
* Structured error detail for gRPC error responses. * Structured error detail for gRPC error responses.
@@ -129,7 +169,7 @@ export type ErrorDetail = Message<"llm_multiverse.v1.ErrorDetail"> & {
* Use `create(ErrorDetailSchema)` to create a new message. * Use `create(ErrorDetailSchema)` to create a new message.
*/ */
export const ErrorDetailSchema: GenMessage<ErrorDetail> = /*@__PURE__*/ export const ErrorDetailSchema: GenMessage<ErrorDetail> = /*@__PURE__*/
messageDesc(file_llm_multiverse_v1_common, 3); messageDesc(file_llm_multiverse_v1_common, 4);
/** /**
* A candidate memory entry proposed by a subagent for persistence. * A candidate memory entry proposed by a subagent for persistence.
@@ -158,7 +198,56 @@ export type MemoryCandidate = Message<"llm_multiverse.v1.MemoryCandidate"> & {
* Use `create(MemoryCandidateSchema)` to create a new message. * Use `create(MemoryCandidateSchema)` to create a new message.
*/ */
export const MemoryCandidateSchema: GenMessage<MemoryCandidate> = /*@__PURE__*/ export const MemoryCandidateSchema: GenMessage<MemoryCandidate> = /*@__PURE__*/
messageDesc(file_llm_multiverse_v1_common, 4); messageDesc(file_llm_multiverse_v1_common, 5);
/**
* Inference statistics surfaced from model-gateway through the orchestrator.
*
* @generated from message llm_multiverse.v1.InferenceStats
*/
export type InferenceStats = Message<"llm_multiverse.v1.InferenceStats"> & {
/**
* Number of tokens in the prompt.
*
* @generated from field: uint32 prompt_tokens = 1;
*/
promptTokens: number;
/**
* Number of tokens generated.
*
* @generated from field: uint32 completion_tokens = 2;
*/
completionTokens: number;
/**
* Sum of prompt + completion tokens.
*
* @generated from field: uint32 total_tokens = 3;
*/
totalTokens: number;
/**
* Model's maximum context length.
*
* @generated from field: uint32 context_window_size = 4;
*/
contextWindowSize: number;
/**
* Generation throughput (tokens per second).
*
* @generated from field: float tokens_per_second = 5;
*/
tokensPerSecond: number;
};
/**
* Describes the message llm_multiverse.v1.InferenceStats.
* Use `create(InferenceStatsSchema)` to create a new message.
*/
export const InferenceStatsSchema: GenMessage<InferenceStats> = /*@__PURE__*/
messageDesc(file_llm_multiverse_v1_common, 6);
/** /**
* Standardized return value from any subagent to its parent. * Standardized return value from any subagent to its parent.
@@ -179,9 +268,11 @@ export type SubagentResult = Message<"llm_multiverse.v1.SubagentResult"> & {
summary: string; summary: string;
/** /**
* @generated from field: repeated string artifacts = 3; * Structured artifacts produced during the agent loop.
*
* @generated from field: repeated llm_multiverse.v1.Artifact artifacts = 3;
*/ */
artifacts: string[]; artifacts: Artifact[];
/** /**
* @generated from field: llm_multiverse.v1.ResultQuality result_quality = 4; * @generated from field: llm_multiverse.v1.ResultQuality result_quality = 4;
@@ -209,7 +300,7 @@ export type SubagentResult = Message<"llm_multiverse.v1.SubagentResult"> & {
* Use `create(SubagentResultSchema)` to create a new message. * Use `create(SubagentResultSchema)` to create a new message.
*/ */
export const SubagentResultSchema: GenMessage<SubagentResult> = /*@__PURE__*/ export const SubagentResultSchema: GenMessage<SubagentResult> = /*@__PURE__*/
messageDesc(file_llm_multiverse_v1_common, 5); messageDesc(file_llm_multiverse_v1_common, 7);
/** /**
* Agent types with distinct tool permission manifests. * Agent types with distinct tool permission manifests.
@@ -450,3 +541,49 @@ export enum ResultSource {
export const ResultSourceSchema: GenEnum<ResultSource> = /*@__PURE__*/ export const ResultSourceSchema: GenEnum<ResultSource> = /*@__PURE__*/
enumDesc(file_llm_multiverse_v1_common, 5); enumDesc(file_llm_multiverse_v1_common, 5);
/**
* Type of artifact produced by an agent during its tool call loop.
*
* @generated from enum llm_multiverse.v1.ArtifactType
*/
export enum ArtifactType {
/**
* @generated from enum value: ARTIFACT_TYPE_UNSPECIFIED = 0;
*/
UNSPECIFIED = 0,
/**
* Code written via fs_write
*
* @generated from enum value: ARTIFACT_TYPE_CODE = 1;
*/
CODE = 1,
/**
* Plain text / file content from fs_read
*
* @generated from enum value: ARTIFACT_TYPE_TEXT = 2;
*/
TEXT = 2,
/**
* Output from run_code / run_shell
*
* @generated from enum value: ARTIFACT_TYPE_COMMAND_OUTPUT = 3;
*/
COMMAND_OUTPUT = 3,
/**
* Web search results
*
* @generated from enum value: ARTIFACT_TYPE_SEARCH_RESULT = 4;
*/
SEARCH_RESULT = 4,
}
/**
* Describes the enum llm_multiverse.v1.ArtifactType.
*/
export const ArtifactTypeSchema: GenEnum<ArtifactType> = /*@__PURE__*/
enumDesc(file_llm_multiverse_v1_common, 6);

View File

@@ -4,7 +4,7 @@
import type { GenEnum, GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; import type { GenEnum, GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2";
import { enumDesc, fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; import { enumDesc, fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2";
import type { AgentType, OverrideLevel, SessionContext, SubagentResult, ToolType } from "./common_pb"; import type { AgentType, InferenceStats, OverrideLevel, SessionContext, SubagentResult, ToolType } from "./common_pb";
import { file_llm_multiverse_v1_common } from "./common_pb"; import { file_llm_multiverse_v1_common } from "./common_pb";
import type { Message } from "@bufbuild/protobuf"; import type { Message } from "@bufbuild/protobuf";
@@ -12,7 +12,7 @@ import type { Message } from "@bufbuild/protobuf";
* Describes the file llm_multiverse/v1/orchestrator.proto. * Describes the file llm_multiverse/v1/orchestrator.proto.
*/ */
export const file_llm_multiverse_v1_orchestrator: GenFile = /*@__PURE__*/ export const file_llm_multiverse_v1_orchestrator: GenFile = /*@__PURE__*/
fileDesc("CiRsbG1fbXVsdGl2ZXJzZS92MS9vcmNoZXN0cmF0b3IucHJvdG8SEWxsbV9tdWx0aXZlcnNlLnYxIn4KDVNlc3Npb25Db25maWcSOAoOb3ZlcnJpZGVfbGV2ZWwYASABKA4yIC5sbG1fbXVsdGl2ZXJzZS52MS5PdmVycmlkZUxldmVsEhYKDmRpc2FibGVkX3Rvb2xzGAIgAygJEhsKE2dyYW50ZWRfcGVybWlzc2lvbnMYAyADKAkirwEKEVN1YnRhc2tEZWZpbml0aW9uEgoKAmlkGAEgASgJEhMKC2Rlc2NyaXB0aW9uGAIgASgJEjAKCmFnZW50X3R5cGUYAyABKA4yHC5sbG1fbXVsdGl2ZXJzZS52MS5BZ2VudFR5cGUSEgoKZGVwZW5kc19vbhgEIAMoCRIzCg50b29sc19yZXF1aXJlZBgFIAMoDjIbLmxsbV9tdWx0aXZlcnNlLnYxLlRvb2xUeXBlIoYCCg9TdWJhZ2VudFJlcXVlc3QSMgoHY29udGV4dBgBIAEoCzIhLmxsbV9tdWx0aXZlcnNlLnYxLlNlc3Npb25Db250ZXh0EhAKCGFnZW50X2lkGAIgASgJEjAKCmFnZW50X3R5cGUYAyABKA4yHC5sbG1fbXVsdGl2ZXJzZS52MS5BZ2VudFR5cGUSDAoEdGFzaxgEIAEoCRIfChdyZWxldmFudF9tZW1vcnlfY29udGV4dBgFIAMoCRISCgptYXhfdG9rZW5zGAYgASgNEjgKDnNlc3Npb25fY29uZmlnGAcgASgLMiAubGxtX211bHRpdmVyc2UudjEuU2Vzc2lvbkNvbmZpZyKTAQoVUHJvY2Vzc1JlcXVlc3RSZXF1ZXN0EhIKCnNlc3Npb25faWQYASABKAkSFAoMdXNlcl9tZXNzYWdlGAIgASgJEj0KDnNlc3Npb25fY29uZmlnGAMgASgLMiAubGxtX211bHRpdmVyc2UudjEuU2Vzc2lvbkNvbmZpZ0gAiAEBQhEKD19zZXNzaW9uX2NvbmZpZyLoAQoWUHJvY2Vzc1JlcXVlc3RSZXNwb25zZRI0CgVzdGF0ZRgBIAEoDjIlLmxsbV9tdWx0aXZlcnNlLnYxLk9yY2hlc3RyYXRpb25TdGF0ZRIPCgdtZXNzYWdlGAIgASgJEiAKE2ludGVybWVkaWF0ZV9yZXN1bHQYAyABKAlIAIgBARI8CgxmaW5hbF9yZXN1bHQYBCABKAsyIS5sbG1fbXVsdGl2ZXJzZS52MS5TdWJhZ2VudFJlc3VsdEgBiAEBQhYKFF9pbnRlcm1lZGlhdGVfcmVzdWx0Qg8KDV9maW5hbF9yZXN1bHQq7AEKEk9yY2hlc3RyYXRpb25TdGF0ZRIjCh9PUkNIRVNUUkFUSU9OX1NUQVRFX1VOU1BFQ0lGSUVEEAASIwofT1JDSEVTVFJBVElPTl9TVEFURV9ERUNPTVBPU0lORxABEiMKH09SQ0hFU1RSQVRJT05fU1RBVEVfRElTUEFUQ0hJTkcQAhIhCh1PUkNIRVNUUkFUSU9OX1NUQVRFX0VYRUNVVElORxADEiIKHk9SQ0hFU1RSQVRJT05fU1RBVEVfQ09NUEFDVElORxAEEiAKHE9SQ0hFU1RSQVRJT05fU1RBVEVfQ09NUExFVEUQBTJ+ChNPcmNoZXN0cmF0b3JTZXJ2aWNlEmcKDlByb2Nlc3NSZXF1ZXN0EigubGxtX211bHRpdmVyc2UudjEuUHJvY2Vzc1JlcXVlc3RSZXF1ZXN0GikubGxtX211bHRpdmVyc2UudjEuUHJvY2Vzc1JlcXVlc3RSZXNwb25zZTABYgZwcm90bzM", [file_llm_multiverse_v1_common]); fileDesc("CiRsbG1fbXVsdGl2ZXJzZS92MS9vcmNoZXN0cmF0b3IucHJvdG8SEWxsbV9tdWx0aXZlcnNlLnYxIn4KDVNlc3Npb25Db25maWcSOAoOb3ZlcnJpZGVfbGV2ZWwYASABKA4yIC5sbG1fbXVsdGl2ZXJzZS52MS5PdmVycmlkZUxldmVsEhYKDmRpc2FibGVkX3Rvb2xzGAIgAygJEhsKE2dyYW50ZWRfcGVybWlzc2lvbnMYAyADKAkirwEKEVN1YnRhc2tEZWZpbml0aW9uEgoKAmlkGAEgASgJEhMKC2Rlc2NyaXB0aW9uGAIgASgJEjAKCmFnZW50X3R5cGUYAyABKA4yHC5sbG1fbXVsdGl2ZXJzZS52MS5BZ2VudFR5cGUSEgoKZGVwZW5kc19vbhgEIAMoCRIzCg50b29sc19yZXF1aXJlZBgFIAMoDjIbLmxsbV9tdWx0aXZlcnNlLnYxLlRvb2xUeXBlIoYCCg9TdWJhZ2VudFJlcXVlc3QSMgoHY29udGV4dBgBIAEoCzIhLmxsbV9tdWx0aXZlcnNlLnYxLlNlc3Npb25Db250ZXh0EhAKCGFnZW50X2lkGAIgASgJEjAKCmFnZW50X3R5cGUYAyABKA4yHC5sbG1fbXVsdGl2ZXJzZS52MS5BZ2VudFR5cGUSDAoEdGFzaxgEIAEoCRIfChdyZWxldmFudF9tZW1vcnlfY29udGV4dBgFIAMoCRISCgptYXhfdG9rZW5zGAYgASgNEjgKDnNlc3Npb25fY29uZmlnGAcgASgLMiAubGxtX211bHRpdmVyc2UudjEuU2Vzc2lvbkNvbmZpZyKTAQoVUHJvY2Vzc1JlcXVlc3RSZXF1ZXN0EhIKCnNlc3Npb25faWQYASABKAkSFAoMdXNlcl9tZXNzYWdlGAIgASgJEj0KDnNlc3Npb25fY29uZmlnGAMgASgLMiAubGxtX211bHRpdmVyc2UudjEuU2Vzc2lvbkNvbmZpZ0gAiAEBQhEKD19zZXNzaW9uX2NvbmZpZyK9AgoWUHJvY2Vzc1JlcXVlc3RSZXNwb25zZRI0CgVzdGF0ZRgBIAEoDjIlLmxsbV9tdWx0aXZlcnNlLnYxLk9yY2hlc3RyYXRpb25TdGF0ZRIPCgdtZXNzYWdlGAIgASgJEiAKE2ludGVybWVkaWF0ZV9yZXN1bHQYAyABKAlIAIgBARI8CgxmaW5hbF9yZXN1bHQYBCABKAsyIS5sbG1fbXVsdGl2ZXJzZS52MS5TdWJhZ2VudFJlc3VsdEgBiAEBEj8KD2luZmVyZW5jZV9zdGF0cxgFIAEoCzIhLmxsbV9tdWx0aXZlcnNlLnYxLkluZmVyZW5jZVN0YXRzSAKIAQFCFgoUX2ludGVybWVkaWF0ZV9yZXN1bHRCDwoNX2ZpbmFsX3Jlc3VsdEISChBfaW5mZXJlbmNlX3N0YXRzKuwBChJPcmNoZXN0cmF0aW9uU3RhdGUSIwofT1JDSEVTVFJBVElPTl9TVEFURV9VTlNQRUNJRklFRBAAEiMKH09SQ0hFU1RSQVRJT05fU1RBVEVfREVDT01QT1NJTkcQARIjCh9PUkNIRVNUUkFUSU9OX1NUQVRFX0RJU1BBVENISU5HEAISIQodT1JDSEVTVFJBVElPTl9TVEFURV9FWEVDVVRJTkcQAxIiCh5PUkNIRVNUUkFUSU9OX1NUQVRFX0NPTVBBQ1RJTkcQBBIgChxPUkNIRVNUUkFUSU9OX1NUQVRFX0NPTVBMRVRFEAUyfgoTT3JjaGVzdHJhdG9yU2VydmljZRJnCg5Qcm9jZXNzUmVxdWVzdBIoLmxsbV9tdWx0aXZlcnNlLnYxLlByb2Nlc3NSZXF1ZXN0UmVxdWVzdBopLmxsbV9tdWx0aXZlcnNlLnYxLlByb2Nlc3NSZXF1ZXN0UmVzcG9uc2UwAWIGcHJvdG8z", [file_llm_multiverse_v1_common]);
/** /**
* Per-session configuration for override control. * Per-session configuration for override control.
@@ -199,6 +199,13 @@ export type ProcessRequestResponse = Message<"llm_multiverse.v1.ProcessRequestRe
* @generated from field: optional llm_multiverse.v1.SubagentResult final_result = 4; * @generated from field: optional llm_multiverse.v1.SubagentResult final_result = 4;
*/ */
finalResult?: SubagentResult; finalResult?: SubagentResult;
/**
* Inference statistics from the model-gateway (on the final streamed message).
*
* @generated from field: optional llm_multiverse.v1.InferenceStats inference_stats = 5;
*/
inferenceStats?: InferenceStats;
}; };
/** /**

View File

@@ -1,4 +1,4 @@
import { createClient } from '@connectrpc/connect'; import { createClient, ConnectError, Code } from '@connectrpc/connect';
import { createGrpcWebTransport } from '@connectrpc/connect-web'; import { createGrpcWebTransport } from '@connectrpc/connect-web';
import { OrchestratorService } from '$lib/proto/llm_multiverse/v1/orchestrator_pb'; import { OrchestratorService } from '$lib/proto/llm_multiverse/v1/orchestrator_pb';
import type { import type {
@@ -7,6 +7,9 @@ import type {
} from '$lib/proto/llm_multiverse/v1/orchestrator_pb'; } from '$lib/proto/llm_multiverse/v1/orchestrator_pb';
import { create } from '@bufbuild/protobuf'; import { create } from '@bufbuild/protobuf';
import { ProcessRequestRequestSchema } from '$lib/proto/llm_multiverse/v1/orchestrator_pb'; import { ProcessRequestRequestSchema } from '$lib/proto/llm_multiverse/v1/orchestrator_pb';
import { connectionStore } from '$lib/stores/connection.svelte';
import { toastStore } from '$lib/stores/toast.svelte';
import { logger } from '$lib/utils/logger';
/** /**
* Application-level error wrapping gRPC status codes. * Application-level error wrapping gRPC status codes.
@@ -22,6 +25,45 @@ export class OrchestratorError extends Error {
} }
} }
/**
* Map gRPC status codes to user-friendly messages.
*/
const GRPC_USER_MESSAGES: Record<string, string> = {
unavailable: 'The server is currently unavailable. Please try again later.',
deadline_exceeded: 'The request timed out. Please try again.',
cancelled: 'The request was cancelled.',
not_found: 'The requested resource was not found.',
already_exists: 'The resource already exists.',
permission_denied: 'You do not have permission to perform this action.',
resource_exhausted: 'Rate limit reached. Please wait a moment and try again.',
failed_precondition: 'The operation cannot be performed in the current state.',
aborted: 'The operation was aborted. Please try again.',
unimplemented: 'This feature is not yet available.',
internal: 'An internal server error occurred. Please try again.',
unauthenticated: 'Authentication required. Please log in.',
data_loss: 'Data loss detected. Please contact support.',
unknown: 'An unexpected error occurred. Please try again.'
};
/**
* Codes considered transient / retriable.
*/
const TRANSIENT_CODES = new Set(['unavailable', 'deadline_exceeded', 'aborted', 'internal']);
/**
* Return a user-friendly message for a gRPC error code.
*/
export function friendlyMessage(code: string): string {
return GRPC_USER_MESSAGES[code.toLowerCase()] ?? GRPC_USER_MESSAGES['unknown'];
}
/**
* Whether the given error code is transient and should be retried.
*/
function isTransient(code: string): boolean {
return TRANSIENT_CODES.has(code.toLowerCase());
}
const DEFAULT_ENDPOINT = '/'; const DEFAULT_ENDPOINT = '/';
let transport: ReturnType<typeof createGrpcWebTransport> | null = null; let transport: ReturnType<typeof createGrpcWebTransport> | null = null;
@@ -38,20 +80,110 @@ function getTransport(endpoint?: string) {
/** /**
* Reset the transport (useful for reconfiguring the endpoint). * Reset the transport (useful for reconfiguring the endpoint).
*/ */
export function resetTransport(): void { export function resetTransport(newEndpoint?: string): void {
transport = null; transport = null;
if (newEndpoint !== undefined) {
transport = createGrpcWebTransport({ baseUrl: newEndpoint });
}
} }
/** /**
* Create a configured orchestrator client. * Create a configured orchestrator client.
*/ */
function getClient(endpoint?: string) { function getClient() {
return createClient(OrchestratorService, getTransport(endpoint)); return createClient(OrchestratorService, getTransport());
}
/**
* Sleep for a given number of milliseconds.
*/
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Calculate exponential backoff delay with jitter.
* Base delay doubles each attempt: 1s, 2s, 4s (capped at 8s).
*/
function backoffDelay(attempt: number): number {
const base = Math.min(1000 * Math.pow(2, attempt), 8000);
const jitter = Math.random() * base * 0.25;
return base + jitter;
}
const MAX_RETRIES = 3;
/**
* Map numeric Code enum values to lowercase string names matching GRPC_USER_MESSAGES keys.
*/
const CODE_TO_STRING: Record<number, string> = {
[Code.Canceled]: 'cancelled',
[Code.Unknown]: 'unknown',
[Code.InvalidArgument]: 'failed_precondition',
[Code.DeadlineExceeded]: 'deadline_exceeded',
[Code.NotFound]: 'not_found',
[Code.AlreadyExists]: 'already_exists',
[Code.PermissionDenied]: 'permission_denied',
[Code.ResourceExhausted]: 'resource_exhausted',
[Code.FailedPrecondition]: 'failed_precondition',
[Code.Aborted]: 'aborted',
[Code.OutOfRange]: 'failed_precondition',
[Code.Unimplemented]: 'unimplemented',
[Code.Internal]: 'internal',
[Code.Unavailable]: 'unavailable',
[Code.DataLoss]: 'data_loss',
[Code.Unauthenticated]: 'unauthenticated'
};
/**
* Extract gRPC error code from an error, normalising to lowercase string.
*/
function extractCode(err: unknown): string {
if (err instanceof ConnectError) {
return CODE_TO_STRING[err.code] ?? 'unknown';
}
if (err instanceof Error && 'code' in err) {
const raw = (err as { code: unknown }).code;
if (typeof raw === 'string') return raw.toLowerCase();
if (typeof raw === 'number') return String(raw);
}
return 'unknown';
}
/**
* Wrap an error into an OrchestratorError with a friendly message.
*/
function toOrchestratorError(err: unknown): OrchestratorError {
if (err instanceof OrchestratorError) return err;
if (err instanceof ConnectError) {
const code = extractCode(err);
const details = [
err.rawMessage,
err.cause ? String(err.cause) : ''
].filter(Boolean).join('; ');
return new OrchestratorError(friendlyMessage(code), code, details || undefined);
}
if (err instanceof Error) {
const code = extractCode(err);
return new OrchestratorError(friendlyMessage(code), code, err.message);
}
return new OrchestratorError(friendlyMessage('unknown'), 'unknown');
}
/**
* Append a diagnostic code suffix to a message, e.g. "(code: unavailable)".
*/
function diagnosticSuffix(err: OrchestratorError): string {
return err.code && err.code !== 'unknown' ? ` (code: ${err.code})` : '';
} }
/** /**
* Send a request to the orchestrator and yield streaming responses. * Send a request to the orchestrator and yield streaming responses.
* *
* Includes automatic retry with exponential backoff for transient failures.
* Updates the connection status store on success or failure and fires
* toast notifications on errors.
*
* Returns an async iterator of `ProcessRequestResponse` messages, * Returns an async iterator of `ProcessRequestResponse` messages,
* each containing the current orchestration state, status message, * each containing the current orchestration state, status message,
* and optionally intermediate or final results. * and optionally intermediate or final results.
@@ -59,31 +191,72 @@ function getClient(endpoint?: string) {
export async function* processRequest( export async function* processRequest(
sessionId: string, sessionId: string,
userMessage: string, userMessage: string,
sessionConfig?: SessionConfig, sessionConfig?: SessionConfig
endpoint?: string
): AsyncGenerator<ProcessRequestResponse> { ): AsyncGenerator<ProcessRequestResponse> {
const client = getClient(endpoint);
const request = create(ProcessRequestRequestSchema, { const request = create(ProcessRequestRequestSchema, {
sessionId, sessionId,
userMessage, userMessage,
sessionConfig sessionConfig
}); });
try { logger.debug('orchestrator', 'processRequest', {
for await (const response of client.processRequest(request)) { sessionId,
yield response; messageLength: userMessage.length
});
let lastError: OrchestratorError | null = null;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
// Skip retries if already known disconnected
if (connectionStore.status === 'disconnected' && attempt > 0) {
break;
} }
} catch (err: unknown) {
if (err instanceof Error) { if (attempt > 0) {
// ConnectError has a `code` property connectionStore.setReconnecting();
const code = 'code' in err ? (err as { code: unknown }).code : undefined; const delay = backoffDelay(attempt - 1);
throw new OrchestratorError( logger.warn('orchestrator', `Retry attempt ${attempt}/${MAX_RETRIES}`, {
err.message, sessionId,
typeof code === 'string' ? code : 'unknown', previousCode: lastError?.code,
err.message delay
); });
await sleep(delay);
} }
throw new OrchestratorError('Unknown error', 'unknown');
try {
const client = getClient();
for await (const response of client.processRequest(request)) {
connectionStore.reportSuccess();
yield response;
}
logger.debug('orchestrator', 'Stream completed', { sessionId });
// Completed successfully — no retry needed
return;
} catch (err: unknown) {
logger.grpcError('orchestrator', `Request failed (attempt ${attempt + 1}/${MAX_RETRIES + 1})`, err);
lastError = toOrchestratorError(err);
const code = lastError.code;
if (isTransient(code) && attempt < MAX_RETRIES) {
// Will retry — continue loop
connectionStore.reportFailure();
continue;
}
// Non-transient or exhausted retries
connectionStore.reportFailure();
const suffix = diagnosticSuffix(lastError);
logger.error('orchestrator', 'Request failed permanently', {
code: lastError.code,
details: lastError.details
});
toastStore.addToast({ message: lastError.message + suffix, type: 'error' });
throw lastError;
}
}
// Should not reach here, but guard against it
if (lastError) {
throw lastError;
} }
} }

View File

@@ -0,0 +1,88 @@
import { SvelteMap } from 'svelte/reactivity';
export type AuditEventType = 'state_change' | 'tool_invocation' | 'error' | 'message';
export interface AuditEvent {
id: string;
sessionId: string;
timestamp: Date;
eventType: AuditEventType;
details: string;
state?: string;
}
export interface SessionAuditLog {
sessionId: string;
events: AuditEvent[];
}
const STORAGE_KEY = 'llm-multiverse-audit-events';
function loadEvents(): SvelteMap<string, AuditEvent[]> {
if (typeof localStorage === 'undefined') return new SvelteMap();
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return new SvelteMap();
const arr: [string, AuditEvent[]][] = JSON.parse(raw);
return new SvelteMap(
arr.map(([id, events]) => [
id,
events.map((e) => ({ ...e, timestamp: new Date(e.timestamp) }))
])
);
} catch {
return new SvelteMap();
}
}
function saveEvents(events: SvelteMap<string, AuditEvent[]>) {
if (typeof localStorage === 'undefined') return;
localStorage.setItem(STORAGE_KEY, JSON.stringify([...events.entries()]));
}
function createAuditStore() {
const events = $state<SvelteMap<string, AuditEvent[]>>(loadEvents());
function addEvent(
sessionId: string,
event: Omit<AuditEvent, 'id' | 'sessionId' | 'timestamp'>
) {
const auditEvent: AuditEvent = {
id: crypto.randomUUID(),
sessionId,
timestamp: new Date(),
...event
};
const existing = events.get(sessionId) ?? [];
events.set(sessionId, [...existing, auditEvent]);
saveEvents(events);
}
function getEventsBySession(sessionId: string): AuditEvent[] {
return events.get(sessionId) ?? [];
}
function getAllSessions(): SessionAuditLog[] {
return [...events.entries()]
.map(([sessionId, evts]) => ({ sessionId, events: evts }))
.sort((a, b) => {
const aLatest = a.events.length > 0 ? a.events[a.events.length - 1].timestamp.getTime() : 0;
const bLatest = b.events.length > 0 ? b.events[b.events.length - 1].timestamp.getTime() : 0;
return bLatest - aLatest;
});
}
function clearSession(sessionId: string) {
events.delete(sessionId);
saveEvents(events);
}
return {
addEvent,
getEventsBySession,
getAllSessions,
clearSession
};
}
export const auditStore = createAuditStore();

View File

@@ -0,0 +1,38 @@
export type ConnectionStatus = 'connected' | 'reconnecting' | 'disconnected';
function createConnectionStore() {
let status: ConnectionStatus = $state('connected');
let consecutiveFailures = $state(0);
function reportSuccess() {
status = 'connected';
consecutiveFailures = 0;
}
function reportFailure() {
consecutiveFailures += 1;
if (consecutiveFailures >= 3) {
status = 'disconnected';
} else {
status = 'reconnecting';
}
}
function setReconnecting() {
status = 'reconnecting';
}
return {
get status() {
return status;
},
get consecutiveFailures() {
return consecutiveFailures;
},
reportSuccess,
reportFailure,
setReconnecting
};
}
export const connectionStore = createConnectionStore();

View File

@@ -0,0 +1,87 @@
import { SvelteMap } from 'svelte/reactivity';
import { ResultSource } from '$lib/proto/llm_multiverse/v1/common_pb';
export interface StoredMemoryCandidate {
content: string;
source: ResultSource;
confidence: number;
}
export interface SessionMemory {
sessionId: string;
candidates: StoredMemoryCandidate[];
}
const STORAGE_KEY = 'llm-multiverse-memory-candidates';
const SAMPLE_CLEANUP_KEY = 'llm-multiverse-sample-data-cleaned';
function loadMemory(): SvelteMap<string, StoredMemoryCandidate[]> {
if (typeof localStorage === 'undefined') return new SvelteMap();
_cleanupSampleData();
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return new SvelteMap();
const arr: [string, StoredMemoryCandidate[]][] = JSON.parse(raw);
return new SvelteMap(arr);
} catch {
return new SvelteMap();
}
}
/** One-time removal of previously seeded sample sessions. */
function _cleanupSampleData() {
if (typeof localStorage === 'undefined') return;
if (localStorage.getItem(SAMPLE_CLEANUP_KEY)) return;
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const arr: [string, unknown[]][] = JSON.parse(raw);
const filtered = arr.filter(([id]) => !id.startsWith('session-demo-'));
localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered));
}
// Also clean audit store
const auditKey = 'llm-multiverse-audit-events';
const auditRaw = localStorage.getItem(auditKey);
if (auditRaw) {
const arr: [string, unknown[]][] = JSON.parse(auditRaw);
const filtered = arr.filter(([id]) => !id.startsWith('session-demo-'));
localStorage.setItem(auditKey, JSON.stringify(filtered));
}
} catch { /* ignore */ }
localStorage.setItem(SAMPLE_CLEANUP_KEY, '1');
}
function saveMemory(memory: SvelteMap<string, StoredMemoryCandidate[]>) {
if (typeof localStorage === 'undefined') return;
localStorage.setItem(STORAGE_KEY, JSON.stringify([...memory.entries()]));
}
function createMemoryStore() {
const memory = $state<SvelteMap<string, StoredMemoryCandidate[]>>(loadMemory());
function addCandidates(sessionId: string, candidates: StoredMemoryCandidate[]) {
if (candidates.length === 0) return;
const existing = memory.get(sessionId) ?? [];
memory.set(sessionId, [...existing, ...candidates]);
saveMemory(memory);
}
function getAllBySession(): SessionMemory[] {
return [...memory.entries()]
.map(([sessionId, candidates]) => ({ sessionId, candidates }))
.sort((a, b) => a.sessionId.localeCompare(b.sessionId));
}
function clearSession(sessionId: string) {
memory.delete(sessionId);
saveMemory(memory);
}
return {
addCandidates,
getAllBySession,
clearSession
};
}
export const memoryStore = createMemoryStore();

View File

@@ -1,3 +1,4 @@
import { SvelteMap } from 'svelte/reactivity';
import type { ChatMessage } from '$lib/types'; import type { ChatMessage } from '$lib/types';
export interface Session { export interface Session {
@@ -10,24 +11,24 @@ export interface Session {
const STORAGE_KEY = 'llm-multiverse-sessions'; const STORAGE_KEY = 'llm-multiverse-sessions';
const ACTIVE_SESSION_KEY = 'llm-multiverse-active-session'; const ACTIVE_SESSION_KEY = 'llm-multiverse-active-session';
function loadSessions(): Map<string, Session> { function loadSessions(): SvelteMap<string, Session> {
if (typeof localStorage === 'undefined') return new Map(); if (typeof localStorage === 'undefined') return new SvelteMap();
try { try {
const raw = localStorage.getItem(STORAGE_KEY); const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return new Map(); if (!raw) return new SvelteMap();
const arr: [string, Session][] = JSON.parse(raw); const arr: [string, Session][] = JSON.parse(raw);
return new Map( return new SvelteMap(
arr.map(([id, s]) => [ arr.map(([id, s]) => [
id, id,
{ ...s, createdAt: new Date(s.createdAt), messages: s.messages.map(m => ({ ...m, timestamp: new Date(m.timestamp) })) } { ...s, createdAt: new Date(s.createdAt), messages: s.messages.map(m => ({ ...m, timestamp: new Date(m.timestamp) })) }
]) ])
); );
} catch { } catch {
return new Map(); return new SvelteMap();
} }
} }
function saveSessions(sessions: Map<string, Session>) { function saveSessions(sessions: SvelteMap<string, Session>) {
if (typeof localStorage === 'undefined') return; if (typeof localStorage === 'undefined') return;
localStorage.setItem(STORAGE_KEY, JSON.stringify([...sessions.entries()])); localStorage.setItem(STORAGE_KEY, JSON.stringify([...sessions.entries()]));
} }
@@ -43,7 +44,7 @@ function saveActiveSessionId(id: string) {
} }
function createSessionStore() { function createSessionStore() {
const sessions = $state<Map<string, Session>>(loadSessions()); const sessions = $state<SvelteMap<string, Session>>(loadSessions());
let activeSessionId = $state<string | null>(loadActiveSessionId()); let activeSessionId = $state<string | null>(loadActiveSessionId());
function createSession(id?: string): Session { function createSession(id?: string): Session {
@@ -66,6 +67,10 @@ function createSessionStore() {
saveActiveSessionId(id); saveActiveSessionId(id);
return sessions.get(id)!; return sessions.get(id)!;
} }
// No specific ID — prefer existing active session
if (!id && activeSessionId && sessions.has(activeSessionId)) {
return sessions.get(activeSessionId)!;
}
return createSession(id); return createSession(id);
} }

View File

@@ -0,0 +1,99 @@
export type ThemeMode = 'light' | 'dark' | 'system';
const STORAGE_KEY = 'llm-multiverse-theme';
function createThemeStore() {
let mode: ThemeMode = $state('system');
let resolvedDark = $state(false);
let initialized = false;
let mediaQuery: MediaQueryList | null = null;
let mediaListener: ((e: MediaQueryListEvent) => void) | null = null;
function applyTheme(isDark: boolean) {
if (typeof document === 'undefined') return;
const root = document.documentElement;
// Add transition class for smooth switching
root.classList.add('theme-transition');
if (isDark) {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
// Remove transition class after animation completes
setTimeout(() => {
root.classList.remove('theme-transition');
}, 300);
}
function getSystemPreference(): boolean {
if (typeof window === 'undefined') return false;
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
function init(): (() => void) | undefined {
if (typeof window === 'undefined') return;
if (initialized) return;
initialized = true;
// Load saved preference
const saved = localStorage.getItem(STORAGE_KEY) as ThemeMode | null;
if (saved === 'light' || saved === 'dark' || saved === 'system') {
mode = saved;
} else {
mode = 'system';
}
// Listen for system preference changes
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaListener = (e: MediaQueryListEvent) => {
if (mode === 'system') {
resolvedDark = e.matches;
applyTheme(resolvedDark);
}
};
mediaQuery.addEventListener('change', mediaListener);
// Apply initial theme
resolvedDark =
mode === 'dark' ? true : mode === 'light' ? false : getSystemPreference();
applyTheme(resolvedDark);
return () => {
if (mediaQuery && mediaListener) {
mediaQuery.removeEventListener('change', mediaListener);
}
initialized = false;
};
}
function setMode(newMode: ThemeMode) {
mode = newMode;
if (typeof window !== 'undefined') {
localStorage.setItem(STORAGE_KEY, newMode);
}
resolvedDark =
newMode === 'dark' ? true : newMode === 'light' ? false : getSystemPreference();
applyTheme(resolvedDark);
}
function cycle() {
const order: ThemeMode[] = ['system', 'light', 'dark'];
const idx = order.indexOf(mode);
const next = order[(idx + 1) % order.length];
setMode(next);
}
return {
get mode() {
return mode;
},
get isDark() {
return resolvedDark;
},
init,
setMode,
cycle
};
}
export const themeStore = createThemeStore();

View File

@@ -0,0 +1,82 @@
export type ToastType = 'error' | 'warning' | 'success' | 'info';
export interface Toast {
id: string;
message: string;
type: ToastType;
duration: number;
dismissable: boolean;
}
const DEFAULT_DURATIONS: Record<ToastType, number> = {
success: 5000,
info: 5000,
warning: 8000,
error: 10000
};
function createToastStore() {
let toasts: Toast[] = $state([]);
// Internal bookkeeping only — not reactive state, so Map is intentional.
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const timers = new Map<string, ReturnType<typeof setTimeout>>();
function addToast(
options: Omit<Toast, 'id' | 'duration' | 'dismissable'> & {
id?: string;
duration?: number;
dismissable?: boolean;
}
): string {
const id = options.id ?? crypto.randomUUID();
const duration = options.duration ?? DEFAULT_DURATIONS[options.type];
const dismissable = options.dismissable ?? true;
const toast: Toast = {
id,
message: options.message,
type: options.type,
duration,
dismissable
};
toasts = [...toasts, toast];
if (duration > 0) {
const timer = setTimeout(() => {
removeToast(id);
}, duration);
timers.set(id, timer);
}
return id;
}
function removeToast(id: string) {
const timer = timers.get(id);
if (timer) {
clearTimeout(timer);
timers.delete(id);
}
toasts = toasts.filter((t) => t.id !== id);
}
function clear() {
for (const timer of timers.values()) {
clearTimeout(timer);
}
timers.clear();
toasts = [];
}
return {
get toasts() {
return toasts;
},
addToast,
removeToast,
clear
};
}
export const toastStore = createToastStore();

153
src/lib/types/lineage.ts Normal file
View File

@@ -0,0 +1,153 @@
import { AgentType } from '$lib/proto/llm_multiverse/v1/common_pb';
/**
* A node in the agent lineage tree, enriched with children references
* for tree rendering.
*/
export interface LineageNode {
id: string;
agentType: AgentType;
spawnDepth: number;
children: LineageNode[];
}
/**
* Flat agent identifier matching the proto shape but without protobuf Message dependency.
* Used for sample data and as input to tree building.
*/
export interface SimpleAgentIdentifier {
agentId: string;
agentType: AgentType;
spawnDepth: number;
parentId?: string;
}
/**
* Maps AgentType enum values to human-readable labels.
*/
export function agentTypeLabel(type: AgentType): string {
switch (type) {
case AgentType.ORCHESTRATOR:
return 'Orchestrator';
case AgentType.RESEARCHER:
return 'Researcher';
case AgentType.CODER:
return 'Coder';
case AgentType.SYSADMIN:
return 'SysAdmin';
case AgentType.ASSISTANT:
return 'Assistant';
default:
return 'Unspecified';
}
}
export interface AgentColorSet {
fill: string;
stroke: string;
text: string;
}
export interface AgentColors {
light: AgentColorSet;
dark: AgentColorSet;
badge: string;
}
/**
* Maps AgentType enum values to light/dark color tokens for SVG rendering
* and Tailwind badge classes.
*/
export function agentTypeColor(type: AgentType): AgentColors {
switch (type) {
case AgentType.ORCHESTRATOR:
return {
light: { fill: '#dbeafe', stroke: '#3b82f6', text: '#1e40af' },
dark: { fill: '#1e3a5f', stroke: '#60a5fa', text: '#93c5fd' },
badge: 'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300'
};
case AgentType.RESEARCHER:
return {
light: { fill: '#dcfce7', stroke: '#22c55e', text: '#166534' },
dark: { fill: '#14532d', stroke: '#4ade80', text: '#86efac' },
badge: 'bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300'
};
case AgentType.CODER:
return {
light: { fill: '#f3e8ff', stroke: '#a855f7', text: '#6b21a8' },
dark: { fill: '#3b0764', stroke: '#c084fc', text: '#d8b4fe' },
badge: 'bg-purple-100 dark:bg-purple-900/40 text-purple-700 dark:text-purple-300'
};
case AgentType.SYSADMIN:
return {
light: { fill: '#ffedd5', stroke: '#f97316', text: '#9a3412' },
dark: { fill: '#431407', stroke: '#fb923c', text: '#fdba74' },
badge: 'bg-orange-100 dark:bg-orange-900/40 text-orange-700 dark:text-orange-300'
};
case AgentType.ASSISTANT:
return {
light: { fill: '#ccfbf1', stroke: '#14b8a6', text: '#115e59' },
dark: { fill: '#134e4a', stroke: '#2dd4bf', text: '#5eead4' },
badge: 'bg-teal-100 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300'
};
default:
return {
light: { fill: '#f3f4f6', stroke: '#9ca3af', text: '#374151' },
dark: { fill: '#1f2937', stroke: '#6b7280', text: '#9ca3af' },
badge: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300'
};
}
}
/**
* Builds a tree of LineageNode from a flat list of SimpleAgentIdentifier.
*
* The algorithm groups agents by spawnDepth. Depth-0 agents become root nodes.
* Each agent at depth N is attached as a child of the last agent at depth N-1
* (based on insertion order), which models a sequential spawn chain.
*
* If parentId is provided, it is used for explicit parent-child linking.
*/
export function buildLineageTree(agents: SimpleAgentIdentifier[]): LineageNode[] {
if (agents.length === 0) return [];
const nodeMap = new Map<string, LineageNode>();
// Create all nodes first
for (const agent of agents) {
nodeMap.set(agent.agentId, {
id: agent.agentId,
agentType: agent.agentType,
spawnDepth: agent.spawnDepth,
children: []
});
}
const roots: LineageNode[] = [];
const depthLastNode = new Map<number, LineageNode>();
for (const agent of agents) {
const node = nodeMap.get(agent.agentId)!;
if (agent.parentId && nodeMap.has(agent.parentId)) {
// Explicit parent link
nodeMap.get(agent.parentId)!.children.push(node);
} else if (agent.spawnDepth === 0) {
roots.push(node);
} else {
// Attach to the last node at depth - 1
const parent = depthLastNode.get(agent.spawnDepth - 1);
if (parent) {
parent.children.push(node);
} else {
// Fallback: treat as root if no parent found
roots.push(node);
}
}
depthLastNode.set(agent.spawnDepth, node);
}
return roots;
}

View File

@@ -0,0 +1,23 @@
import { ResultSource } from '$lib/proto/llm_multiverse/v1/common_pb';
export interface ResultSourceStyle {
label: string;
bg: string;
text: string;
}
/**
* Returns label and badge classes for a ResultSource value.
*/
export function resultSourceConfig(source: ResultSource): ResultSourceStyle {
switch (source) {
case ResultSource.TOOL_OUTPUT:
return { label: 'Tool Output', bg: 'bg-blue-100 dark:bg-blue-900/40', text: 'text-blue-800 dark:text-blue-300' };
case ResultSource.MODEL_KNOWLEDGE:
return { label: 'Model Knowledge', bg: 'bg-purple-100 dark:bg-purple-900/40', text: 'text-purple-800 dark:text-purple-300' };
case ResultSource.WEB:
return { label: 'Web', bg: 'bg-green-100 dark:bg-green-900/40', text: 'text-green-800 dark:text-green-300' };
default:
return { label: 'Unspecified', bg: 'bg-gray-100 dark:bg-gray-800', text: 'text-gray-800 dark:text-gray-300' };
}
}

19
src/lib/utils/date.ts Normal file
View File

@@ -0,0 +1,19 @@
/**
* Formats a date as a relative string: "Today", "Yesterday", "N days ago", or locale date.
*/
export function formatRelativeDate(date: Date): string {
const now = new Date();
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) return 'Today';
if (days === 1) return 'Yesterday';
if (days < 7) return `${days} days ago`;
return date.toLocaleDateString();
}
/**
* Formats a date as a short string like "Mar 12, 2026".
*/
export function formatShortDate(date: Date): string {
return date.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' });
}

60
src/lib/utils/logger.ts Normal file
View File

@@ -0,0 +1,60 @@
/**
* Lightweight dev-mode logging utility.
* All diagnostic logging flows through this module.
*/
import { ConnectError } from '@connectrpc/connect';
declare global {
var __LLM_DEBUG: boolean | undefined;
}
function isDebugEnabled(): boolean {
return import.meta.env.DEV || globalThis.__LLM_DEBUG === true;
}
function fmt(tag: string, message: string): string {
return `[${tag}] ${message}`;
}
export const logger = {
debug(tag: string, message: string, ...data: unknown[]): void {
if (isDebugEnabled()) {
console.debug(fmt(tag, message), ...data);
}
},
info(tag: string, message: string, ...data: unknown[]): void {
if (isDebugEnabled()) {
console.info(fmt(tag, message), ...data);
}
},
warn(tag: string, message: string, ...data: unknown[]): void {
if (isDebugEnabled()) {
console.warn(fmt(tag, message), ...data);
}
},
error(tag: string, message: string, ...data: unknown[]): void {
if (isDebugEnabled()) {
console.error(fmt(tag, message), ...data);
}
},
/** Always logs regardless of debug toggle. Destructures ConnectError fields. */
grpcError(tag: string, label: string, err: unknown): void {
if (err instanceof ConnectError) {
console.error(fmt(tag, label), {
code: err.code,
rawMessage: err.rawMessage,
cause: err.cause,
metadata: Object.fromEntries(err.metadata.entries())
});
} else if (err instanceof Error) {
console.error(fmt(tag, label), { message: err.message });
} else {
console.error(fmt(tag, label), err);
}
}
};

View File

@@ -0,0 +1,13 @@
import { OverrideLevel } from '$lib/proto/llm_multiverse/v1/common_pb';
import type { SessionConfig } from '$lib/proto/llm_multiverse/v1/orchestrator_pb';
/**
* Returns true if the session config differs from defaults.
*/
export function isNonDefaultConfig(config: SessionConfig): boolean {
return (
config.overrideLevel !== OverrideLevel.NONE ||
config.disabledTools.length > 0 ||
config.grantedPermissions.length > 0
);
}

54
src/routes/+error.svelte Normal file
View File

@@ -0,0 +1,54 @@
<script lang="ts">
import { page } from '$app/stores';
import { resolveRoute } from '$app/paths';
const homeHref = resolveRoute('/');
</script>
<div class="flex min-h-screen items-center justify-center bg-white px-4 dark:bg-gray-900">
<div class="w-full max-w-md text-center">
<!-- Error icon -->
<div class="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/40">
<svg class="h-8 w-8 text-red-500 dark:text-red-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
</div>
<!-- Status code -->
<p class="text-5xl font-bold text-gray-900 dark:text-gray-100">{$page.status}</p>
<!-- Message -->
<h1 class="mt-3 text-lg font-semibold text-gray-900 dark:text-gray-100">
Something went wrong
</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
{$page.error?.message ?? 'An unexpected error occurred. Please try again.'}
</p>
<!-- Actions -->
<div class="mt-8 flex flex-col gap-3 sm:flex-row sm:justify-center">
<button
type="button"
onclick={() => history.back()}
class="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
Go back
</button>
<button
type="button"
onclick={() => location.reload()}
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Try again
</button>
<!-- eslint-disable svelte/no-navigation-without-resolve -- resolveRoute is resolve; plugin does not recognize the alias -->
<a
href={homeHref}
class="inline-flex items-center justify-center rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
Home
</a>
<!-- eslint-enable svelte/no-navigation-without-resolve -->
</div>
</div>
</div>

View File

@@ -1,8 +1,16 @@
<script lang="ts"> <script lang="ts">
import favicon from '$lib/assets/favicon.svg'; import favicon from '$lib/assets/favicon.svg';
import '../app.css'; import '../app.css';
import { onMount } from 'svelte';
import { themeStore } from '$lib/stores/theme.svelte';
import ToastContainer from '$lib/components/ToastContainer.svelte';
let { children } = $props(); let { children } = $props();
onMount(() => {
const cleanup = themeStore.init();
return cleanup;
});
</script> </script>
<svelte:head> <svelte:head>
@@ -10,3 +18,14 @@
</svelte:head> </svelte:head>
{@render children()} {@render children()}
<ToastContainer />
<style>
:global(.theme-transition),
:global(.theme-transition *) {
transition:
background-color 0.3s ease,
border-color 0.3s ease,
color 0.3s ease !important;
}
</style>

View File

@@ -1,2 +1,27 @@
<h1 class="text-3xl font-bold text-center mt-8">LLM Multiverse UI</h1> <script lang="ts">
<p class="text-center text-gray-600 mt-2">Orchestration interface</p> import { resolveRoute } from '$app/paths';
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
const chatHref = resolveRoute('/chat');
const lineageHref = resolveRoute('/lineage');
const memoryHref = resolveRoute('/memory');
const auditHref = resolveRoute('/audit');
</script>
<div class="flex min-h-screen flex-col items-center bg-white dark:bg-gray-900 px-4">
<div class="absolute right-4 top-4">
<ThemeToggle />
</div>
<h1 class="text-2xl md:text-3xl font-bold text-center mt-8 text-gray-900 dark:text-gray-100">LLM Multiverse UI</h1>
<p class="text-center text-gray-600 dark:text-gray-400 mt-2">Orchestration interface</p>
<nav class="flex flex-col sm:flex-row justify-center gap-3 sm:gap-4 mt-6 w-full max-w-md sm:max-w-none sm:w-auto">
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -- resolveRoute is resolve; plugin does not recognize the alias -->
<a href={chatHref} class="rounded-lg bg-blue-600 px-4 py-3 sm:py-2 text-center text-sm font-medium text-white hover:bg-blue-700">Chat</a>
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -- resolveRoute is resolve; plugin does not recognize the alias -->
<a href={lineageHref} class="rounded-lg bg-gray-100 dark:bg-gray-700 px-4 py-3 sm:py-2 text-center text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600">Agent Lineage</a>
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -- resolveRoute is resolve; plugin does not recognize the alias -->
<a href={memoryHref} class="rounded-lg bg-gray-100 dark:bg-gray-700 px-4 py-3 sm:py-2 text-center text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600">Memory Candidates</a>
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -- resolveRoute is resolve; plugin does not recognize the alias -->
<a href={auditHref} class="rounded-lg bg-gray-100 dark:bg-gray-700 px-4 py-3 sm:py-2 text-center text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600">Audit Log</a>
</nav>
</div>

2
src/routes/+page.ts Normal file
View File

@@ -0,0 +1,2 @@
export const prerender = false;
export const ssr = false;

View File

@@ -0,0 +1,113 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { resolveRoute } from '$app/paths';
import AuditTimeline from '$lib/components/AuditTimeline.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import { auditStore } from '$lib/stores/audit.svelte';
import type { AuditEventType } from '$lib/stores/audit.svelte';
const chatHref = resolveRoute('/chat');
const allSessions = $derived(auditStore.getAllSessions());
const selectedSessionId = $derived($page.url.searchParams.get('session'));
let typeFilter = $state<AuditEventType | 'all'>('all');
const events = $derived.by(() => {
if (!selectedSessionId) return [];
const raw = auditStore.getEventsBySession(selectedSessionId);
if (typeFilter === 'all') return raw;
return raw.filter((e) => e.eventType === typeFilter);
});
const totalEvents = $derived(events.length);
function selectSession(sessionId: string) {
const url = `${resolveRoute('/audit')}?session=${sessionId}`;
// eslint-disable-next-line svelte/no-navigation-without-resolve
goto(url, { replaceState: true });
}
const filterOptions: { value: AuditEventType | 'all'; label: string }[] = [
{ value: 'all', label: 'All Events' },
{ value: 'state_change', label: 'State Changes' },
{ value: 'tool_invocation', label: 'Tool Invocations' },
{ value: 'error', label: 'Errors' },
{ value: 'message', label: 'Messages' }
];
</script>
<div class="flex h-screen flex-col overflow-hidden bg-gray-50 dark:bg-gray-900">
<PageHeader title="Audit Log" backHref={chatHref} />
<!-- Filters -->
<div class="border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-4 py-3 md:px-6">
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:gap-4">
<div class="flex min-w-0 items-center gap-2">
<label for="session-select" class="shrink-0 text-xs font-medium text-gray-500 dark:text-gray-400">Session</label>
<select
id="session-select"
value={selectedSessionId ?? ''}
onchange={(e) => {
const target = e.target as HTMLSelectElement;
if (target.value) selectSession(target.value);
}}
class="min-w-0 max-w-full truncate rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-2.5 py-1.5 text-sm text-gray-700 dark:text-gray-300 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
>
<option value="" disabled>Select a session</option>
{#each allSessions as session (session.sessionId)}
<option value={session.sessionId}>
{session.sessionId} ({session.events.length} events)
</option>
{/each}
</select>
</div>
{#if selectedSessionId}
<div class="flex items-center gap-2">
<label for="type-filter" class="text-xs font-medium text-gray-500 dark:text-gray-400">Type</label>
<select
id="type-filter"
bind:value={typeFilter}
class="rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-2.5 py-1.5 text-sm text-gray-700 dark:text-gray-300 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
>
{#each filterOptions as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
<span class="text-xs text-gray-400 dark:text-gray-500">
{totalEvents} event{totalEvents !== 1 ? 's' : ''}
</span>
{/if}
</div>
</div>
<!-- Content -->
<main class="flex-1 overflow-auto p-4 md:p-6">
{#if !selectedSessionId}
{#if allSessions.length === 0}
<div class="flex flex-col items-center justify-center py-16 text-center">
<div class="mb-3 text-4xl text-gray-300 dark:text-gray-600">&#128203;</div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">No audit events recorded</p>
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">
Events will appear here as orchestration sessions run.
</p>
</div>
{:else}
<div class="flex flex-col items-center justify-center py-16 text-center">
<div class="mb-3 text-4xl text-gray-300 dark:text-gray-600">&#128269;</div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Select a session to view its audit log</p>
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">
Choose a session from the dropdown above.
</p>
</div>
{/if}
{:else}
<AuditTimeline {events} />
{/if}
</main>
</div>

View File

@@ -0,0 +1,2 @@
export const prerender = false;
export const ssr = false;

View File

@@ -3,7 +3,6 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { resolveRoute } from '$app/paths'; import { resolveRoute } from '$app/paths';
import type { ChatMessage } from '$lib/types'; import type { ChatMessage } from '$lib/types';
import type { SubagentResult } from '$lib/proto/llm_multiverse/v1/common_pb';
import { OverrideLevel } from '$lib/proto/llm_multiverse/v1/common_pb'; import { OverrideLevel } from '$lib/proto/llm_multiverse/v1/common_pb';
import type { SessionConfig } from '$lib/proto/llm_multiverse/v1/orchestrator_pb'; import type { SessionConfig } from '$lib/proto/llm_multiverse/v1/orchestrator_pb';
import { SessionConfigSchema } from '$lib/proto/llm_multiverse/v1/orchestrator_pb'; import { SessionConfigSchema } from '$lib/proto/llm_multiverse/v1/orchestrator_pb';
@@ -13,28 +12,27 @@
import OrchestrationProgress from '$lib/components/OrchestrationProgress.svelte'; import OrchestrationProgress from '$lib/components/OrchestrationProgress.svelte';
import ThinkingSection from '$lib/components/ThinkingSection.svelte'; import ThinkingSection from '$lib/components/ThinkingSection.svelte';
import FinalResult from '$lib/components/FinalResult.svelte'; import FinalResult from '$lib/components/FinalResult.svelte';
import InferenceStatsPanel from '$lib/components/InferenceStatsPanel.svelte';
import SessionSidebar from '$lib/components/SessionSidebar.svelte'; import SessionSidebar from '$lib/components/SessionSidebar.svelte';
import ConfigSidebar from '$lib/components/ConfigSidebar.svelte'; import ConfigSidebar from '$lib/components/ConfigSidebar.svelte';
import { processRequest, OrchestratorError } from '$lib/services/orchestrator'; import ThemeToggle from '$lib/components/ThemeToggle.svelte';
import { OrchestrationState } from '$lib/proto/llm_multiverse/v1/orchestrator_pb'; import ConnectionStatus from '$lib/components/ConnectionStatus.svelte';
import { onMount, untrack } from 'svelte';
import { sessionStore } from '$lib/stores/sessions.svelte'; import { sessionStore } from '$lib/stores/sessions.svelte';
import { isNonDefaultConfig } from '$lib/utils/sessionConfig';
import { orchestrationStore as orchestration } from '$lib/composables/useOrchestration.svelte';
let messages: ChatMessage[] = $state([]); let messages: ChatMessage[] = $state([]);
let isStreaming = $state(false); let initialized = $state(false);
let error: string | null = $state(null);
let orchestrationState: OrchestrationState = $state(OrchestrationState.UNSPECIFIED);
let intermediateResult: string = $state('');
let finalResult: SubagentResult | null = $state(null);
let sessionConfig: SessionConfig = $state( let sessionConfig: SessionConfig = $state(
create(SessionConfigSchema, { overrideLevel: OverrideLevel.NONE }) create(SessionConfigSchema, { overrideLevel: OverrideLevel.NONE })
); );
let showConfig = $state(false); let showConfig = $state(false);
let showSessionSidebar = $state(false);
const isNonDefaultConfig = $derived( const lineageHref = resolveRoute('/lineage');
sessionConfig.overrideLevel !== OverrideLevel.NONE || const memoryHref = resolveRoute('/memory');
sessionConfig.disabledTools.length > 0 || const auditHref = resolveRoute('/audit');
sessionConfig.grantedPermissions.length > 0 const hasNonDefaultConfig = $derived(isNonDefaultConfig(sessionConfig));
);
function navigateToSession(sessionId: string, replace = false) { function navigateToSession(sessionId: string, replace = false) {
const url = `${resolveRoute('/chat')}?session=${sessionId}`; const url = `${resolveRoute('/chat')}?session=${sessionId}`;
@@ -42,20 +40,32 @@
goto(url, { replaceState: replace }); goto(url, { replaceState: replace });
} }
$effect(() => { onMount(() => {
const sessionParam = $page.url.searchParams.get('session'); const sessionParam = $page.url.searchParams.get('session');
const session = sessionStore.getOrCreateSession(sessionParam ?? undefined); const session = sessionStore.getOrCreateSession(sessionParam ?? undefined);
messages = [...session.messages]; messages = [...session.messages];
if (!sessionParam || sessionParam !== session.id) { if (!sessionParam || sessionParam !== session.id) {
navigateToSession(session.id, true); navigateToSession(session.id, true);
} }
initialized = true;
});
// Sync messages when session changes via sidebar (after initial mount)
$effect(() => {
if (!initialized) return;
const sessionParam = $page.url.searchParams.get('session');
if (sessionParam) {
const session = untrack(() => sessionStore.activeSession);
if (session && session.id === sessionParam) {
messages = [...session.messages];
}
}
}); });
function handleNewChat() { function handleNewChat() {
const session = sessionStore.createSession(); const session = sessionStore.createSession();
messages = []; messages = [];
error = null; orchestration.reset();
finalResult = null;
navigateToSession(session.id); navigateToSession(session.id);
} }
@@ -64,130 +74,150 @@
const session = sessionStore.activeSession; const session = sessionStore.activeSession;
if (session) { if (session) {
messages = [...session.messages]; messages = [...session.messages];
error = null; orchestration.reset();
finalResult = null;
navigateToSession(id); navigateToSession(id);
} }
} }
async function handleSend(content: string) { async function handleSend(content: string) {
error = null;
orchestrationState = OrchestrationState.UNSPECIFIED;
intermediateResult = '';
finalResult = null;
const sessionId = sessionStore.activeSessionId!; const sessionId = sessionStore.activeSessionId!;
messages = await orchestration.send(sessionId, content, sessionConfig, messages);
}
const userMessage: ChatMessage = { function handleRetry() {
id: crypto.randomUUID(), const sessionId = sessionStore.activeSessionId!;
role: 'user', const result = orchestration.retry(sessionId, sessionConfig, messages);
content, messages = result.messages;
timestamp: new Date()
};
messages.push(userMessage);
const assistantMessage: ChatMessage = {
id: crypto.randomUUID(),
role: 'assistant',
content: '',
timestamp: new Date()
};
messages.push(assistantMessage);
sessionStore.updateMessages(sessionId, messages);
isStreaming = true;
try {
for await (const response of processRequest(sessionId, content, sessionConfig)) {
orchestrationState = response.state;
if (response.intermediateResult) {
intermediateResult = response.intermediateResult;
}
if (response.finalResult) {
finalResult = response.finalResult;
}
const idx = messages.length - 1;
messages[idx] = {
...messages[idx],
content: response.message
};
}
} catch (err) {
const msg =
err instanceof OrchestratorError
? `Error (${err.code}): ${err.message}`
: 'An unexpected error occurred';
error = msg;
const idx = messages.length - 1;
messages[idx] = {
...messages[idx],
content: `⚠ ${msg}`
};
} finally {
isStreaming = false;
sessionStore.updateMessages(sessionId, messages);
}
} }
</script> </script>
<div class="flex h-screen"> <div class="flex h-screen overflow-hidden bg-white dark:bg-gray-900">
<SessionSidebar onSelectSession={handleSelectSession} onNewChat={handleNewChat} /> <!-- 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 flex-1 flex-col"> <div class="flex min-w-0 flex-1 flex-col">
<header class="flex items-center justify-between border-b border-gray-200 bg-white px-4 py-3"> <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">
<h1 class="text-lg font-semibold text-gray-900">Chat</h1> <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 <button
type="button" type="button"
onclick={() => (showConfig = !showConfig)} onclick={() => (showConfig = !showConfig)}
class="flex items-center gap-1 rounded-lg px-2.5 py-1.5 text-sm 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' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}" {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 isNonDefaultConfig} {#if hasNonDefaultConfig}
<span class="h-2 w-2 rounded-full bg-amber-500"></span> <span class="h-2 w-2 rounded-full bg-amber-500"></span>
{/if} {/if}
Config <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> </button>
</div>
</header> </header>
<MessageList {messages} /> <MessageList {messages} />
{#if isStreaming} {#if orchestration.isStreaming}
<OrchestrationProgress state={orchestrationState} /> <OrchestrationProgress state={orchestration.orchestrationState} />
<ThinkingSection content={intermediateResult} /> <ThinkingSection content={orchestration.intermediateResult} />
{/if} {/if}
{#if isStreaming && messages.length > 0 && messages[messages.length - 1].content === ''} {#if orchestration.isStreaming && messages.length > 0 && messages[messages.length - 1].content === ''}
<div class="flex justify-start px-4 pb-2"> <div class="flex justify-start px-4 pb-2">
<div class="flex items-center gap-1.5 rounded-2xl bg-gray-200 px-4 py-2.5"> <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 [animation-delay:0ms]" <span class="h-2 w-2 animate-bounce rounded-full bg-gray-500 dark:bg-gray-400 [animation-delay:0ms]"
></span> ></span>
<span <span
class="h-2 w-2 animate-bounce rounded-full bg-gray-500 [animation-delay:150ms]" class="h-2 w-2 animate-bounce rounded-full bg-gray-500 dark:bg-gray-400 [animation-delay:150ms]"
></span> ></span>
<span <span
class="h-2 w-2 animate-bounce rounded-full bg-gray-500 [animation-delay:300ms]" class="h-2 w-2 animate-bounce rounded-full bg-gray-500 dark:bg-gray-400 [animation-delay:300ms]"
></span> ></span>
</div> </div>
</div> </div>
{/if} {/if}
{#if finalResult && !isStreaming} {#if orchestration.finalResult && !orchestration.isStreaming}
<FinalResult result={finalResult} /> <FinalResult result={orchestration.finalResult} />
{/if} {/if}
{#if error} {#if orchestration.inferenceStats && !orchestration.isStreaming}
<div class="mx-4 mb-2 rounded-lg bg-red-50 px-4 py-2 text-sm text-red-600"> <InferenceStatsPanel stats={orchestration.inferenceStats} />
{error} {/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> </div>
{/if} {/if}
<MessageInput onSend={handleSend} disabled={isStreaming} /> <MessageInput onSend={handleSend} disabled={orchestration.isStreaming} />
</div> </div>
{#if showConfig} {#if showConfig}
<ConfigSidebar <ConfigSidebar
config={sessionConfig} config={sessionConfig}
onConfigChange={(c) => (sessionConfig = c)} onConfigChange={(c) => (sessionConfig = c)}
onClose={() => (showConfig = false)}
/> />
{/if} {/if}
</div> </div>

2
src/routes/chat/+page.ts Normal file
View File

@@ -0,0 +1,2 @@
export const prerender = false;
export const ssr = false;

View File

@@ -0,0 +1,136 @@
<script lang="ts">
import { resolveRoute } from '$app/paths';
import { AgentType } from '$lib/proto/llm_multiverse/v1/common_pb';
import LineageTree from '$lib/components/LineageTree.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import Backdrop from '$lib/components/Backdrop.svelte';
import type { LineageNode, SimpleAgentIdentifier } from '$lib/types/lineage';
import {
buildLineageTree,
agentTypeLabel,
agentTypeColor
} from '$lib/types/lineage';
import { themeStore } from '$lib/stores/theme.svelte';
const chatHref = resolveRoute('/chat');
let agents: SimpleAgentIdentifier[] = $state([]);
let treeNodes: LineageNode[] = $derived(buildLineageTree(agents));
let selectedNode: LineageNode | null = $state(null);
function handleSelectNode(node: LineageNode) {
selectedNode = selectedNode?.id === node.id ? null : node;
}
const agentTypeLegend = [
AgentType.ORCHESTRATOR,
AgentType.RESEARCHER,
AgentType.CODER,
AgentType.SYSADMIN,
AgentType.ASSISTANT,
AgentType.UNSPECIFIED
];
</script>
<div class="flex h-screen flex-col overflow-hidden bg-gray-50 dark:bg-gray-900">
<PageHeader title="Agent Lineage" backHref={chatHref} />
<div class="flex flex-1 flex-col md:flex-row overflow-hidden">
<!-- Main tree area -->
<main class="flex-1 overflow-auto p-4 md:p-6">
<LineageTree nodes={treeNodes} onSelectNode={handleSelectNode} />
<!-- Legend -->
<div class="mt-4 flex flex-wrap items-center gap-2 md:gap-3 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-3 py-2 md:px-4 md:py-3">
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">Agent Types:</span>
{#each agentTypeLegend as type (type)}
{@const colors = agentTypeColor(type)}
{@const colorSet = themeStore.isDark ? colors.dark : colors.light}
<span
class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium {colors.badge}"
>
<span
class="h-2 w-2 rounded-full"
style="background-color: {colorSet.stroke}"
></span>
{agentTypeLabel(type)}
</span>
{/each}
</div>
</main>
<!-- Detail panel: overlay on mobile, side panel on desktop -->
{#if selectedNode}
{@const colors = agentTypeColor(selectedNode.agentType)}
{@const colorSet = themeStore.isDark ? colors.dark : colors.light}
<Backdrop onClose={() => (selectedNode = null)} />
<aside
class="fixed inset-y-0 right-0 z-50 w-72 shrink-0 border-l border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-4 overflow-y-auto
md:relative md:z-auto"
>
<div class="mb-4 flex items-center justify-between">
<h2 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Agent Details</h2>
<button
type="button"
onclick={() => (selectedNode = null)}
class="rounded p-1 text-gray-400 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-600 dark:hover:text-gray-300"
aria-label="Close detail panel"
>
&#10005;
</button>
</div>
<div class="space-y-3">
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">Agent ID</p>
<p class="mt-0.5 break-all font-mono text-sm text-gray-900 dark:text-gray-100">{selectedNode.id}</p>
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">Agent Type</p>
<span
class="mt-0.5 inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium {colors.badge}"
>
<span
class="h-2 w-2 rounded-full"
style="background-color: {colorSet.stroke}"
></span>
{agentTypeLabel(selectedNode.agentType)}
</span>
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">Spawn Depth</p>
<p class="mt-0.5 text-sm text-gray-900 dark:text-gray-100">{selectedNode.spawnDepth}</p>
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">Children</p>
<p class="mt-0.5 text-sm text-gray-900 dark:text-gray-100">{selectedNode.children.length}</p>
</div>
{#if selectedNode.children.length > 0}
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">Child Agents</p>
<ul class="mt-1 space-y-1">
{#each selectedNode.children as child (child.id)}
{@const childColors = agentTypeColor(child.agentType)}
{@const childColorSet = themeStore.isDark ? childColors.dark : childColors.light}
<li
class="flex items-center gap-2 rounded-md border border-gray-100 dark:border-gray-700 px-2 py-1.5"
>
<span
class="h-2 w-2 shrink-0 rounded-full"
style="background-color: {childColorSet.stroke}"
></span>
<span class="truncate font-mono text-xs text-gray-700 dark:text-gray-300">{child.id}</span>
</li>
{/each}
</ul>
</div>
{/if}
</div>
</aside>
{/if}
</div>
</div>

View File

@@ -0,0 +1,2 @@
export const prerender = false;
export const ssr = false;

View File

@@ -0,0 +1,120 @@
<script lang="ts">
import { resolveRoute } from '$app/paths';
import { ResultSource } from '$lib/proto/llm_multiverse/v1/common_pb';
import MemoryCandidateCard from '$lib/components/MemoryCandidateCard.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import { memoryStore } from '$lib/stores/memory.svelte';
const chatHref = resolveRoute('/chat');
let sourceFilter = $state<ResultSource | 'all'>('all');
let confidenceThreshold = $state(0);
const allSessions = $derived(memoryStore.getAllBySession());
const filteredSessions = $derived(
allSessions
.map((session) => ({
...session,
candidates: session.candidates.filter((c) => {
if (sourceFilter !== 'all' && c.source !== sourceFilter) return false;
if (c.confidence < confidenceThreshold) return false;
return true;
})
}))
.filter((session) => session.candidates.length > 0)
);
const totalCandidates = $derived(
filteredSessions.reduce((sum, s) => sum + s.candidates.length, 0)
);
const sourceOptions: { value: ResultSource | 'all'; label: string }[] = [
{ value: 'all', label: 'All Sources' },
{ value: ResultSource.TOOL_OUTPUT, label: 'Tool Output' },
{ value: ResultSource.MODEL_KNOWLEDGE, label: 'Model Knowledge' },
{ value: ResultSource.WEB, label: 'Web' },
{ value: ResultSource.UNSPECIFIED, label: 'Unspecified' }
];
</script>
<div class="flex h-screen flex-col overflow-hidden bg-gray-50 dark:bg-gray-900">
<PageHeader title="Memory Candidates" backHref={chatHref} />
<!-- Filters -->
<div class="border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-4 py-3 md:px-6">
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:gap-4">
<div class="flex items-center gap-2">
<label for="source-filter" class="text-xs font-medium text-gray-500 dark:text-gray-400">Source</label>
<select
id="source-filter"
bind:value={sourceFilter}
class="rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-2.5 py-1.5 text-sm text-gray-700 dark:text-gray-300 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
>
{#each sourceOptions as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
<div class="flex items-center gap-2">
<label for="confidence-threshold" class="text-xs font-medium text-gray-500 dark:text-gray-400">
Min Confidence
</label>
<input
id="confidence-threshold"
type="range"
min="0"
max="1"
step="0.05"
bind:value={confidenceThreshold}
class="h-2 w-32 cursor-pointer appearance-none rounded-lg bg-gray-200 dark:bg-gray-700 accent-blue-600"
/>
<span class="w-10 text-right text-xs font-medium text-gray-600 dark:text-gray-400">
{Math.round(confidenceThreshold * 100)}%
</span>
</div>
<span class="text-xs text-gray-400 dark:text-gray-500">
{totalCandidates} candidate{totalCandidates !== 1 ? 's' : ''}
</span>
</div>
</div>
<!-- Content -->
<main class="flex-1 overflow-auto p-4 md:p-6">
{#if filteredSessions.length === 0}
<div class="flex flex-col items-center justify-center py-16 text-center">
<div class="mb-3 text-4xl text-gray-300 dark:text-gray-600">&#128203;</div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">No memory candidates found</p>
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">
{#if sourceFilter !== 'all' || confidenceThreshold > 0}
Try adjusting your filters.
{:else}
Memory candidates will appear here as they are captured from orchestration results.
{/if}
</p>
</div>
{:else}
<div class="space-y-6">
{#each filteredSessions as session (session.sessionId)}
<section>
<div class="mb-3 flex items-center gap-3">
<h2 class="text-sm font-semibold text-gray-900 dark:text-gray-100">
Session: <span class="font-mono text-xs text-gray-600 dark:text-gray-400">{session.sessionId}</span>
</h2>
<span class="rounded-full bg-gray-100 dark:bg-gray-700 px-2 py-0.5 text-xs font-medium text-gray-600 dark:text-gray-400">
{session.candidates.length}
</span>
</div>
<div class="grid gap-3 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
{#each session.candidates as candidate, i (session.sessionId + '-' + i)}
<MemoryCandidateCard {candidate} />
{/each}
</div>
</section>
{/each}
</div>
{/if}
</main>
</div>

View File

@@ -0,0 +1,2 @@
export const prerender = false;
export const ssr = false;

View File

@@ -3,5 +3,13 @@ import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
export default defineConfig({ export default defineConfig({
plugins: [tailwindcss(), sveltekit()] plugins: [tailwindcss(), sveltekit()],
server: {
proxy: {
'/llm_multiverse.v1.OrchestratorService': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
}
}); });