Compare commits

...

32 Commits

Author SHA1 Message Date
Aleksander Grygier 61389bbd28 refactor : reorder class members to match new ESLint rule
Apply the sort-classes rule across stores, services, hooks and utils.
Pure reordering - verified no logic changes by comparing sorted line
multisets before/after. All tests and svelte-check pass.
2026-08-20 12:57:08 +02:00
Aleksander Grygier 894ef9ab1c chore : add ESLint rule for class member ordering
Enforce structural order (public fields -> private fields -> constructor ->
getters -> setters -> public methods -> private methods) with alphabetical
sorting within each group via perfectionist/sort-classes. Dependency
detection keeps Svelte $derived fields in a valid dependency order instead
of alphabetizing them, since Svelte rejects forward references.

Assisted-by: Claude
2026-08-20 12:57:08 +02:00
Aleksander Grygier 880f7d8416 refactor: Clean up comments in stores' and services' code 2026-08-20 12:57:08 +02:00
Aleksander Grygier a10f9a87c0 refactor : prefix lookup methods with get in agentic and chat stores
Unify bare-name lookup methods with the get* prefix used across the
other stores (mcp, models, tools, settings). Renames currentTurn,
totalToolCalls, lastError, streamingToolCall, executingToolCallId,
pendingPermissionRequest, pendingContinueRequest,
pendingSteeringMessageContent, pendingSteeringMessageExtras in the
agentic store and pendingMessageContent, pendingMessageExtras in the
chat store. Updates the two consuming components and a doc comment.
2026-08-20 12:57:08 +02:00
Aleksander Grygier 177b9dcea2 refactor : reorder store members and unify naming
Order store class members as public fields, private fields, constructor,
getters, public methods, then private methods. Normalize private naming
to the `private` keyword (drop `#` and the `_` prefix where there is no
matching public getter). Rename conversationsStore.init() to
initialize() to match the other stores.
2026-08-20 12:57:08 +02:00
Aleksander Grygier 11078864eb chore : add ESLint rule for blank lines between accessors
Enforce a blank line between consecutive class accessors. The core
padding-line-between-statements rule does not cover class members, so a
local rule is needed.
2026-08-20 12:57:08 +02:00
Aleksander Grygier 319f0d4d9f docs : rewrite architecture sections in README
Update the high-level diagram, routes, hooks, stores, services and data
flow tables to match the current UI structure (mcp/settings/search
routes, agentic/tools/mcp stores, MCPService/ToolsService/SandboxService,
/tools API). Fix stale architectural patterns for per-conversation state
and modality validation.
2026-08-20 12:57:08 +02:00
Aleksander Grygier 01441295dd refactor: Api Fetch util 2026-08-20 12:57:08 +02:00
Aleksander Grygier 5eee75b7fd fix: pagehide event from window 2026-08-20 12:57:08 +02:00
Aleksander Grygier e28765a07a chore: Lint/format 2026-08-20 12:57:08 +02:00
Aleksander Grygier c368a65fda database : share message parent-child bookkeeping
- addChildToParent() dedups the append-to-children update in createMessageBranch
  and createSystemMessage
- removeChildFromParent() dedups the remove-from-children cleanup in deleteMessage
  and deleteMessageCascading
- bulkAdd the cloned messages when forking a conversation instead of one add
  per message

Assisted-by: Claude
2026-08-20 12:57:08 +02:00
Aleksander Grygier 01839d5353 mcp : share cursor pagination and tool indexing
- MCPService.paginate() collapses the identical do-while loops in
  listAllResources and listAllResourceTemplates
- promoteHealthCheckToConnection now uses indexServerTools like the other
  connect paths

Assisted-by: Claude
2026-08-20 12:57:08 +02:00
Aleksander Grygier 8608a15388 mcp : dedupe tool execution, server parsing and tool indexing
- executeTool delegates to executeToolByName (only diff was argument parsing)
- drop the private #parseServerSettings copy; use parseMcpServerSettings
- cache getServers() keyed on the raw config value (hot path)
- indexServerTools() unifies the three identical toolsIndex rebuild loops

Assisted-by: Claude
2026-08-20 12:57:08 +02:00
Aleksander Grygier 02aedbea2d conversations : centralize conversation update mirroring
rename, pin, mcp override, reasoning effort and cwd all repeated the same
write-DB-then-mirror-into-list-and-active dance. A single
applyConversationUpdate(id, updates) on the host collapses all five and
removes the forgot-to-mirror-one-field bug class. Drops the redundant
array reassignment in setCwd (deep  field assignment is reactive).
2026-08-20 12:57:08 +02:00
Aleksander Grygier d7bbd86e77 chat flows : dedupe title, timings and cleanup handling
- conversationsStore.applyTitleFromContent centralizes the title-from-first-
  message logic duplicated in 5 places
- ChatProcessingStore.applyStreamTimings centralizes the onTimings handler
  shared by the chat and continue flows
- host.cleanupStreaming centralizes the loading/streaming/processing reset
  repeated across the continue flow's exit paths
2026-08-20 12:57:08 +02:00
Aleksander Grygier 3b62d01b49 api : delegate apiFetchWithParams to apiFetch
apiFetchWithParams duplicated apiFetch's headers/fetch/error handling
body-for-body; it only differs in URL construction. Build the URL and
delegate.
2026-08-20 12:57:08 +02:00
Aleksander Grygier 828d7a957a sse : share record splitting and data extraction
splitSseRecords and extractSseDataPayload centralize the record-boundary
splitting and data: line extraction used by parseSseJsonStream and the
models status feed. chat.service keeps its own line-based parser for
resume support.
2026-08-20 12:57:08 +02:00
Aleksander Grygier 3c02c62695 chat : extract ChatService.normalizeMessagesForApi
The DB->API message normalization (convert + drop empty system messages)
was duplicated in sendMessage, preEncode and the agentic flow. Extract it
into one shared method and call it from all three.
2026-08-20 12:57:08 +02:00
Aleksander Grygier a1f08f03da agentic : clear session state when a conversation is deleted
Every conversation that ran an agentic flow left an AgenticSession in the
store forever; clearSession was never called. conversationsStore now
notifies deletion listeners and agenticStore drops the matching sessions,
avoiding a circular import back into conversationsStore.
2026-08-20 12:57:08 +02:00
Aleksander Grygier bce4130af8 ui: Compute context gauge timing stats in one pass
currentRead/Fresh/Cache/Output were separate deriveds, each running a
full reverse scan of activeMessages for the last assistant timings,
and cumulative ran its own forward scan plus an agentic filter - 4-5
O(n) passes per chunk while streaming. Replace with a single
summarizeAssistantTimings() pass (last assistant timings, last
agentic llm totals and the cumulative sums) feeding a shared derived
snapshot. Semantics unchanged, including the live-stats overrides and
the agentic llm-totals branch.
2026-08-20 12:57:08 +02:00
Aleksander Grygier 30560893cf ui: Throttle per-chunk stream state writes to localStorage
saveStreamState ran JSON.stringify + a synchronous localStorage.setItem
on every decoded chunk of the stream. The read loop now goes through a
new saveStreamStateThrottled (one write per conversation per 500ms,
latest value held pending); the public saveStreamState keeps its
immediate-write contract for stream start and pre-fetch, and also
resets the throttle window.

A pending offset is force-flushed at resume boundaries (resumeStream
reads the offset back from localStorage), on visibilitychange->hidden
and on pagehide, so a reload always finds a usable offset. The resume
offset only needs to be roughly current since the server retransmits
from a line boundary and the client discards its partial line.

Adds unit tests for the throttled/flush/clear interplay.
2026-08-20 12:57:08 +02:00
Aleksander Grygier e5f9595c1b ui: Memoize findMessageIndex for the streaming hot path
Streaming looks up the same message index on every chunk, a linear
scan of activeMessages each time. Cache the last lookup and reuse it
after validating the id still sits at the same position (O(1)); any
structural change to the array fails validation and falls back to a
full scan.
2026-08-20 12:57:08 +02:00
Aleksander Grygier a36d7866bf chore: Remove legacy architecture docs 2026-08-20 12:57:08 +02:00
Aleksander Grygier 0257f469fe refactor: Cleanup 2026-08-20 12:57:08 +02:00
Aleksander Grygier 14687f2241 test: Chat Activity store test 2026-08-20 12:57:08 +02:00
Aleksander Grygier 857d717b2b ui: Give store collaborators narrow host interfaces
Collaborators took 'host: typeof <store>', i.e. the store's entire
public surface, which is how chatStore's streamChatCompletion,
createAssistantMessage, getApiOptions and setStreamingActive got
widened to public. Replace with per-collaborator interfaces carrying
only the members each one drives:

- ChatStreamHost (chat/streams) - activity, processing, streaming
  states, abort controller, loading/streaming setters
- ChatFlowsHost (chat/flows) - streaming core, message creation,
  per-conv state setters
- McpHealthHost (mcp/health) - connection registry + reconnection
- ModelPropsHost / ModelStatusHost (models) - model rows, feed
  updates; the managers write modalities/status back onto the host's
  rows, so those members stay writable
- ConversationsPreferencesHost (conversations) - the active row and
  the conversation list

The store classes now declare 'implements <Host>' so the contract is
visible at the class level, and the 'import type { <store> }' back
references in the collaborators disappear entirely - the host
contract is local to each collaborator file, and collaborators can
no longer reach around their slice. Members stay public (structural
typing), but the collaborator side is now compiler-enforced.
2026-08-20 12:57:08 +02:00
Aleksander Grygier 0212cbb772 ui: Consolidate conv running-state into a chat activity ledger
Running-state was split across chatStore.chatLoadingStates (local
pipes), ChatStreamManager.remoteRunningConvs (backend sessions) and
attachingConvs (attach lifecycle), unioned by hand in
getAllLoadingChats and cross-cleaned by setChatLoading calling
streams.clearRemoteRunning - the 'spinner ghosts until tab toggle'
workaround.

chatActivityStore now owns both sets with one transition per event:
markLocal / localEnded (local pipe end also drops the stale remote
hint, no cross-owner call) / applyRemoteSnapshot (diffed). The
sidebar reads chatStore.activity.loadingConvs through the unchanged
getAllLoadingChats entry point.

Consequences:
- isStreamingActive and its five manual writers are gone; isStreaming()
  now reports whether the active conversation has a live streaming
  pipe, which is what all four consumers (assistant row, stop action,
  context gauge, chat screen) actually check
- isLoading/isReasoning become derived from the per-conv maps plus
  the active conversation, dropping the manual resync in
  syncLoadingStateForChat and clearUIState
- attachingConvs and the last-attach coordination disappear from
  ChatStreamManager
- getAllStreamingChats (no consumers) is removed
2026-08-20 12:57:08 +02:00
Aleksander Grygier 9cc5e0e136 fix: Update stale doc comments 2026-08-20 12:57:08 +02:00
Aleksander Grygier 93d6031697 ui: Reorganize stores into domain namespaces 2026-08-20 12:57:08 +02:00
Aleksander Grygier eaf5404091 ui: Compose MCP resources under mcpStore.resources
Resource state was a second import scope next to mcpStore. Consumers
now go through mcpStore.resources, so the MCP surface is one store;
mcp-resources.svelte.ts stays a separate file owned by mcpStore.
2026-08-20 12:57:08 +02:00
Aleksander Grygier 6c0c31a42a ui: Extract user interaction gates from agenticStore into AgenticGates
Tool permission requests, turn-limit continue prompts and queued
steering messages are the state the loop waits on between turns. They
had no coupling to session state, so they now live in
agentic-gates.svelte.ts; agenticStore keeps delegates so components
are unchanged. agenticStore: 1196 -> 1073 lines.
2026-08-20 12:57:08 +02:00
Aleksander Grygier 1f5b6d0bf7 ui: Extract server stream lifecycle from chatStore into ChatStreamManager
Discovery, attach/replay, resume retry and the remote-running snapshot
formed a cohesive cluster inside chatStore. It now lives in
chat-streams.svelte.ts as ChatStreamManager, owned by chatStore, which
keeps the public entry points as delegates so components are
unchanged. chatStore: 2877 -> 2418 lines.
2026-08-20 12:57:08 +02:00
120 changed files with 11220 additions and 12829 deletions
+106 -60
View File
@@ -239,31 +239,44 @@ Routes → Components → Hooks → Stores → Services → Storage/API
### High-Level Architecture
See: [`docs/architecture/high-level-architecture-simplified.md`](docs/architecture/high-level-architecture-simplified.md)
```mermaid
flowchart TB
subgraph Routes["📍 Routes"]
R1["/ (Welcome)"]
R2["/chat/[id]"]
R3["/mcp-servers"]
R4["/search"]
R5["/settings"]
RL["+layout.svelte"]
end
subgraph Components["🧩 Components"]
C_Sidebar["ChatSidebar"]
C_Screen["ChatScreen"]
C_Form["ChatForm"]
C_Messages["ChatMessages"]
C_ModelsSelector["ModelsSelector"]
C_Sidebar["ChatSidebar"]
C_Models["ModelsSelector"]
C_Settings["ChatSettings"]
C_Mcp["McpServers"]
end
subgraph Hooks["🔌 Hooks"]
H1["use-chat-screen-active-model"]
H2["use-processing-state"]
H3["use-context-gauge"]
H4["use-models-selector"]
H5["use-tools-panel"]
end
subgraph Stores["🗄️ Stores"]
S1["chatStore"]
S2["conversationsStore"]
S3["modelsStore"]
S4["serverStore"]
S5["settingsStore"]
S4["mcpStore"]
S5["agenticStore"]
S6["serverStore"]
S7["settingsStore"]
S8["toolsStore"]
end
subgraph Services["⚙️ Services"]
@@ -271,6 +284,9 @@ flowchart TB
SV2["ModelsService"]
SV3["PropsService"]
SV4["DatabaseService"]
SV5["MCPService"]
SV6["ToolsService"]
SV7["SandboxService"]
end
subgraph Storage["💾 Storage"]
@@ -282,19 +298,28 @@ flowchart TB
API1["/v1/chat/completions"]
API2["/props"]
API3["/models/*"]
API4["/tools"]
end
R1 & R2 --> C_Screen
RL --> C_Sidebar
C_Screen --> C_Form & C_Messages & C_Settings
C_Screen --> S1 & S2
C_ModelsSelector --> S3 & S4
C_Screen --> H1 & H2 & H3
C_Models --> H4
C_Mcp --> S4
C_Screen --> S1 & S2 & S3
C_Models --> S3
H1 --> S3
S1 --> SV1 & SV4
S2 --> SV4
S3 --> SV2 & SV3
S4 --> SV5
S5 --> SV1 & SV5 & SV6 & SV7
SV4 --> ST1
SV1 --> API1
SV2 --> API3
SV3 --> API2
SV6 --> API4
```
### Layer Breakdown
@@ -303,6 +328,9 @@ flowchart TB
- **`/`** - Welcome screen, creates new conversation
- **`/chat/[id]`** - Active chat interface
- **`/mcp-servers`** - MCP server management
- **`/search`** - Conversation search
- **`/settings`** - Settings (optional `[[section]]`)
- **`+layout.svelte`** - Sidebar, navigation, global initialization
#### Components (`src/lib/components/`)
@@ -348,28 +376,68 @@ Components are organized in `app/` (application-specific) and `ui/` (shadcn-svel
#### Hooks (`src/lib/hooks/`)
- **`useModelChangeValidation`** - Validates model switch against conversation modalities
- **`useProcessingState`** - Tracks streaming progress and token generation
Hooks are the thin view-layer between components and stores: they own UI concerns (scroll, drag-and-drop, keyboard shortcuts, pickers, selection) and translate store state into view state.
| Hook | Responsibility |
| ------------------------------- | -------------------------------------------------------------- |
| `use-chat-screen-active-model` | Active model resolution + modality capability detection |
| `use-processing-state` | View over `chatStore.processing` for streaming progress/tokens |
| `use-context-gauge` | View over `contextStatsStore` for the context usage gauge |
| `use-models-selector` | Model selector dropdown state (loaded/available groups) |
| `use-tools-panel` | Tools panel state |
| `use-reasoning-menu` | Reasoning-effort menu state |
| `use-attachment-menu` | Attachment menu + modality flags |
| `use-draft-messages` | Per-chat draft message/files persistence |
| `use-chat-form-pickers` | Chat form pickers (commands, mentions) |
| `use-debounced-search` | Shared debounced async search for pickers |
| `use-picker-navigation` | Picker keyboard navigation |
| `use-chat-message-edit-context` | Message edit context (content + extras) |
| `use-chat-screen-drag-and-drop` | Drag-and-drop state machine |
| `use-chat-screen-file-upload` | File upload queue + capability validation |
| `use-chat-screen-scroll` | Scroll container binding + navigation guard |
| `use-auto-scroll` | Auto-scroll controller for streaming |
| `use-marquee-selection` | Shift+click / marquee range selection |
| `use-keyboard-shortcuts` | Global keyboard shortcuts |
| `use-settings-navigation` | Settings section navigation |
| `use-pwa` | PWA install/update + version mismatch detection |
#### Stores (`src/lib/stores/`)
| Store | Responsibility |
| -------------------- | --------------------------------------------------------- |
| `chatStore` | Message sending, streaming, abort control, error handling |
| `conversationsStore` | CRUD for conversations, message branching, navigation |
| `modelsStore` | Model list, selection, loading/unloading (ROUTER) |
| `serverStore` | Server properties, role detection, modalities |
| `settingsStore` | User preferences, parameter sync with server defaults |
Stores own reactive application state as Svelte 5 runes. Larger stores are split into directories and compose focused sub-stores behind a narrow host interface (see Architectural Patterns).
| Store | Responsibility |
| -------------------- | --------------------------------------------------------------------------------------------------------------- |
| `chatStore` | Chat lifecycle, streaming, abort control, error handling; composes `processing`, `activity`, `streams`, `flows` |
| `conversationsStore` | Conversation CRUD, message branching, navigation, import/export; composes `preferences` |
| `modelsStore` | Model list, selection, loading/unloading (ROUTER); composes `props`, `status` |
| `mcpStore` | MCP host role: multi-server lifecycle, tool routing; composes `health`, `resources` |
| `agenticStore` | Multi-turn agentic loop orchestration, tool execution; composes `gates` |
| `serverStore` | Server connection state, `/props`, role detection, modalities |
| `settingsStore` | User preferences, theme, parameter sync with server defaults |
| `toolsStore` | Tool registry: server + MCP tools, enabled set for the LLM |
| `permissionsStore` | Persisted tool permission grants |
| `contextStatsStore` | Context window usage for the active conversation |
| `draftMessagesStore` | Per-chat draft message/files |
| `deviceStore` | Browser environment signals (mobile, OS, theme) |
| `versionStore` | Build version information |
#### Services (`src/lib/services/`)
| Service | Responsibility |
| ---------------------- | ----------------------------------------------- |
| `ChatService` | API calls to`/v1/chat/completions`, SSE parsing |
| `ModelsService` | `/models`, `/models/load`, `/models/unload` |
| `PropsService` | `/props`, `/props?model=` |
| `DatabaseService` | IndexedDB operations via Dexie |
| `ParameterSyncService` | Syncs settings with server defaults |
Services are a stateless protocol layer: static methods, pure I/O, no reactive state. Stores consume them for all API and storage access.
| Service | Responsibility |
| ----------------------------- | ------------------------------------------------------------------------- |
| `ChatService` | `/v1/chat/completions` streaming + SSE parsing, message format conversion |
| `ModelsService` | `/models`, `/models/load`, `/models/unload` |
| `PropsService` | `/props`, `/props?model=` |
| `DatabaseService` | IndexedDB operations via Dexie |
| `MCPService` | MCP protocol: transports, connect, list/execute tools, prompts, resources |
| `ToolsService` | Server tool list/execute/stream (`/tools`) |
| `SandboxService` | Browser JS execution in a sandboxed worker |
| `ParameterSyncService` | Syncs settings with server defaults |
| `ConversationTransferService` | Conversation import/export JSONL + ZIP format |
| `MigrationService` | Non-destructive localStorage/IndexedDB migrations |
| `RouterService` | Dynamic route URL construction |
---
@@ -377,8 +445,6 @@ Components are organized in `app/` (application-specific) and `ui/` (shadcn-svel
### MODEL Mode (Single Model)
See: [`docs/flows/data-flow-simplified-model-mode.md`](docs/flows/data-flow-simplified-model-mode.md)
```mermaid
sequenceDiagram
participant User
@@ -388,8 +454,9 @@ sequenceDiagram
participant API as llama-server
Note over User,API: Initialization
UI->>Stores: initialize()
Stores->>DB: load conversations
UI->>Stores: initStores() (awaited by route loads)
Stores->>Stores: run migrations
Stores->>DB: load conversations (background)
Stores->>API: GET /props
API-->>Stores: server config
Stores->>API: GET /v1/models
@@ -408,8 +475,6 @@ sequenceDiagram
### ROUTER Mode (Multi-Model)
See: [`docs/flows/data-flow-simplified-router-mode.md`](docs/flows/data-flow-simplified-router-mode.md)
```mermaid
sequenceDiagram
participant User
@@ -441,17 +506,6 @@ sequenceDiagram
end
```
### Detailed Flow Diagrams
| Flow | Description | File |
| ------------- | ------------------------------------------ | ----------------------------------------------------------- |
| Chat | Message lifecycle, streaming, regeneration | [`chat-flow.md`](docs/flows/chat-flow.md) |
| Models | Loading, unloading, modality caching | [`models-flow.md`](docs/flows/models-flow.md) |
| Server | Props fetching, role detection | [`server-flow.md`](docs/flows/server-flow.md) |
| Conversations | CRUD, branching, import/export | [`conversations-flow.md`](docs/flows/conversations-flow.md) |
| Database | IndexedDB schema, operations | [`database-flow.md`](docs/flows/database-flow.md) |
| Settings | Parameter sync, user overrides | [`settings-flow.md`](docs/flows/settings-flow.md) |
---
## Architectural Patterns
@@ -505,13 +559,14 @@ Components dispatch actions to stores, stores coordinate with services for I/O,
### 3. Per-Conversation State
Enables concurrent streaming across multiple conversations:
Enables concurrent streaming across multiple conversations. Loading is tracked
per conversation by the activity ledger (`chatStore.activity`), while streaming
state and abort controllers live in per-conversation maps:
```typescript
class ChatStore {
chatLoadingStates = new Map<string, boolean>();
chatStreamingStates = new Map<string, { response: string; messageId: string }>();
abortControllers = new Map<string, AbortController>();
chatStreamingStates = new SvelteMap<string, { response: string; messageId: string }>();
abortControllers = new SvelteMap<string, AbortController>();
}
```
@@ -567,20 +622,14 @@ get isRouterMode() {
### 7. Modality Validation
Prevents sending attachments to incompatible models:
Prevents sending attachments to incompatible models. The
`use-chat-screen-active-model` hook derives the active model's capabilities
from `modelsStore.props`:
```typescript
// useModelChangeValidation hook
const validate = (modelId: string) => {
const modelModalities = modelsStore.getModelModalities(modelId);
const conversationModalities = conversationsStore.usedModalities;
// Check if model supports all used modalities
if (conversationModalities.hasImages && !modelModalities.vision) {
return { valid: false, reason: 'Model does not support images' };
}
// ...
};
// use-chat-screen-active-model hook
const hasVisionModality = $derived.by(() => modelsStore.props.modelSupportsVision(activeModelId));
const hasAudioModality = $derived.by(() => modelsStore.props.modelSupportsAudio(activeModelId));
```
### 8. Persistent Storage Strategy
@@ -673,9 +722,6 @@ tools/ui/
│ └── styles/ # Global styles
├── static/ # Static assets
├── tests/ # Test files
├── docs/ # Architecture diagrams
│ ├── architecture/ # High-level architecture
│ └── flows/ # Feature-specific flows
└── .storybook/ # Storybook configuration
```
@@ -1,145 +0,0 @@
```mermaid
flowchart TB
subgraph Routes["📍 Routes"]
R1["/ (Welcome)"]
R2["/chat/[id]"]
RL["+layout.svelte"]
end
subgraph Components["🧩 Components"]
C_Sidebar["ChatSidebar"]
C_Screen["ChatScreen"]
C_Form["ChatForm"]
C_Messages["ChatMessages"]
C_Message["ChatMessage"]
C_ChatMessageAgenticContent["ChatMessageAgenticContent"]
C_MessageEditForm["ChatMessageEditForm"]
C_ModelsSelector["ModelsSelector"]
C_Settings["ChatSettings"]
C_McpSettings["McpServersSettings"]
C_McpResourceBrowser["McpResourceBrowser"]
C_McpServersSelector["McpServersSelector"]
end
subgraph Hooks["🪝 Hooks"]
H1["useModelChangeValidation"]
H2["useProcessingState"]
end
subgraph Stores["🗄️ Stores"]
S1["chatStore<br/><i>Chat interactions & streaming</i>"]
SA["agenticStore<br/><i>Multi-turn agentic loop orchestration</i>"]
S2["conversationsStore<br/><i>Conversation data, messages & MCP overrides</i>"]
S3["modelsStore<br/><i>Model selection & loading</i>"]
S4["serverStore<br/><i>Server props & role detection</i>"]
S5["settingsStore<br/><i>User configuration incl. MCP</i>"]
S6["mcpStore<br/><i>MCP servers, tools, prompts</i>"]
S7["mcpResourceStore<br/><i>MCP resources & attachments</i>"]
end
subgraph Services["⚙️ Services"]
SV1["ChatService"]
SV2["ModelsService"]
SV3["PropsService"]
SV4["DatabaseService"]
SV5["ParameterSyncService"]
SV6["MCPService<br/><i>protocol operations</i>"]
end
subgraph Storage["💾 Storage"]
ST1["IndexedDB<br/><i>conversations, messages</i>"]
ST2["LocalStorage<br/><i>config, userOverrides, mcpServers</i>"]
end
subgraph APIs["🌐 llama-server API"]
API1["/v1/chat/completions"]
API2["/props"]
API3["/models/*"]
API4["/v1/models"]
end
subgraph ExternalMCP["🔌 External MCP Servers"]
EXT1["MCP Server 1<br/><i>WebSocket/HTTP/SSE</i>"]
EXT2["MCP Server N"]
end
%% Routes → Components
R1 & R2 --> C_Screen
RL --> C_Sidebar
%% Layout runs MCP health checks
RL --> S6
%% Component hierarchy
C_Screen --> C_Form & C_Messages & C_Settings
C_Messages --> C_Message
C_Message --> C_ChatMessageAgenticContent
C_Message --> C_MessageEditForm
C_Form & C_MessageEditForm --> C_ModelsSelector
C_Form --> C_McpServersSelector
C_Settings --> C_McpSettings
C_McpSettings --> C_McpResourceBrowser
%% Components → Hooks → Stores
C_Form & C_Messages --> H1 & H2
H1 --> S3 & S4
H2 --> S1 & S5
%% Components → Stores
C_Screen --> S1 & S2
C_Sidebar --> S2
C_ModelsSelector --> S3 & S4
C_Settings --> S5
C_McpSettings --> S6
C_McpResourceBrowser --> S6 & S7
C_McpServersSelector --> S6
C_Form --> S6
%% chatStore → agenticStore → mcpStore (agentic loop)
S1 --> SA
SA --> SV1
SA --> S6
%% Stores → Services
S1 --> SV1 & SV4
S2 --> SV4
S3 --> SV2 & SV3
S4 --> SV3
S5 --> SV5
S6 --> SV6
S7 --> SV6
%% Services → Storage
SV4 --> ST1
SV5 --> ST2
%% Services → APIs
SV1 --> API1
SV2 --> API3 & API4
SV3 --> API2
%% MCP → External Servers
SV6 --> EXT1 & EXT2
%% Styling
classDef routeStyle fill:#e1f5fe,stroke:#01579b,stroke-width:2px
classDef componentStyle fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px
classDef hookStyle fill:#fff8e1,stroke:#ff8f00,stroke-width:2px
classDef storeStyle fill:#fff3e0,stroke:#e65100,stroke-width:2px
classDef serviceStyle fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
classDef storageStyle fill:#fce4ec,stroke:#c2185b,stroke-width:2px
classDef apiStyle fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
classDef mcpStyle fill:#e0f2f1,stroke:#00695c,stroke-width:2px
classDef agenticStyle fill:#e8eaf6,stroke:#283593,stroke-width:2px
classDef externalStyle fill:#f3e5f5,stroke:#6a1b9a,stroke-width:2px,stroke-dasharray: 5 5
class R1,R2,RL routeStyle
class C_Sidebar,C_Screen,C_Form,C_Messages,C_Message,C_ChatMessageAgenticContent,C_MessageEditForm,C_ModelsSelector,C_Settings componentStyle
class C_McpSettings,C_McpResourceBrowser,C_McpServersSelector componentStyle
class H1,H2 hookStyle
class S1,S2,S3,S4,S5,SA,S6,S7 storeStyle
class SV1,SV2,SV3,SV4,SV5,SV6 serviceStyle
class ST1,ST2 storageStyle
class API1,API2,API3,API4 apiStyle
class EXT1,EXT2 externalStyle
```
@@ -1,373 +0,0 @@
```mermaid
flowchart TB
subgraph Routes["📍 Routes"]
R1["/ (+page.svelte)"]
R2["/chat/[id]"]
RL["+layout.svelte"]
end
subgraph Components["🧩 Components"]
direction TB
subgraph LayoutComponents["Layout"]
C_Sidebar["ChatSidebar"]
C_Screen["ChatScreen"]
end
subgraph ChatUIComponents["Chat UI"]
C_Form["ChatForm"]
C_Messages["ChatMessages"]
C_Message["ChatMessage"]
C_MessageUser["ChatMessageUser"]
C_MessageEditForm["ChatMessageEditForm"]
C_Attach["ChatAttachments"]
C_ModelsSelector["ModelsSelector"]
C_Settings["ChatSettings"]
end
subgraph MCPComponents["MCP UI"]
C_McpSettings["McpServersSettings"]
C_McpServerCard["McpServerCard"]
C_McpResourceBrowser["McpResourceBrowser"]
C_McpResourcePreview["McpResourcePreview"]
C_McpServersSelector["McpServersSelector"]
end
end
subgraph Hooks["🪝 Hooks"]
H1["useModelChangeValidation"]
H2["useProcessingState"]
H3["isMobile"]
end
subgraph Stores["🗄️ Stores"]
direction TB
subgraph S1["chatStore"]
S1State["<b>State:</b><br/>isLoading, currentResponse<br/>errorDialogState<br/>activeProcessingState<br/>chatLoadingStates<br/>chatStreamingStates<br/>abortControllers<br/>processingStates<br/>activeConversationId<br/>isStreamingActive"]
S1LoadState["<b>Loading State:</b><br/>setChatLoading()<br/>isChatLoading()<br/>syncLoadingStateForChat()<br/>clearUIState()<br/>isChatLoadingPublic()<br/>getAllLoadingChats()<br/>getAllStreamingChats()"]
S1ProcState["<b>Processing State:</b><br/>setActiveProcessingConversation()<br/>getProcessingState()<br/>clearProcessingState()<br/>getActiveProcessingState()<br/>updateProcessingStateFromTimings()<br/>getCurrentProcessingStateSync()<br/>restoreProcessingStateFromMessages()"]
S1Stream["<b>Streaming:</b><br/>streamChatCompletion()<br/>startStreaming()<br/>stopStreaming()<br/>stopGeneration()<br/>isStreaming()"]
S1Error["<b>Error Handling:</b><br/>showErrorDialog()<br/>dismissErrorDialog()<br/>isAbortError()"]
S1Msg["<b>Message Operations:</b><br/>addMessage()<br/>sendMessage()<br/>updateMessage()<br/>deleteMessage()<br/>getDeletionInfo()"]
S1Regen["<b>Regeneration:</b><br/>regenerateMessage()<br/>regenerateMessageWithBranching()<br/>continueAssistantMessage()"]
S1Edit["<b>Editing:</b><br/>editAssistantMessage()<br/>editUserMessagePreserveResponses()<br/>editMessageWithBranching()<br/>clearEditMode()<br/>isEditModeActive()<br/>getAddFilesHandler()<br/>setEditModeActive()"]
S1Utils["<b>Utilities:</b><br/>getApiOptions()<br/>parseTimingData()<br/>getOrCreateAbortController()<br/>getConversationModel()"]
end
subgraph SA["agenticStore"]
SAState["<b>State:</b><br/>sessions (Map)<br/>isAnyRunning"]
SASession["<b>Session Management:</b><br/>getSession()<br/>updateSession()<br/>clearSession()<br/>getActiveSessions()<br/>isRunning()<br/>currentTurn()<br/>totalToolCalls()<br/>lastError()<br/>streamingToolCall()"]
SAConfig["<b>Configuration:</b><br/>getConfig()<br/>maxTurns, maxToolPreviewLines"]
SAFlow["<b>Agentic Loop:</b><br/>runAgenticFlow()<br/>executeAgenticLoop()<br/>normalizeToolCalls()<br/>emitToolCallResult()<br/>extractBase64Attachments()"]
end
subgraph S2["conversationsStore"]
S2State["<b>State:</b><br/>conversations<br/>activeConversation<br/>activeMessages<br/>isInitialized<br/>pendingMcpServerOverrides<br/>titleUpdateConfirmationCallback"]
S2Lifecycle["<b>Lifecycle:</b><br/>initialize()<br/>loadConversations()<br/>clearActiveConversation()"]
S2ConvCRUD["<b>Conversation CRUD:</b><br/>createConversation()<br/>loadConversation()<br/>deleteConversation()<br/>deleteAll()<br/>updateConversationName()<br/>updateConversationTitleWithConfirmation()"]
S2MsgMgmt["<b>Message Management:</b><br/>refreshActiveMessages()<br/>addMessageToActive()<br/>updateMessageAtIndex()<br/>findMessageIndex()<br/>sliceActiveMessages()<br/>removeMessageAtIndex()<br/>getConversationMessages()"]
S2Nav["<b>Navigation:</b><br/>navigateToSibling()<br/>updateCurrentNode()<br/>updateConversationTimestamp()"]
S2McpOverrides["<b>MCP Per-Chat Overrides:</b><br/>getMcpServerOverride()<br/>getAllMcpServerOverrides()<br/>setMcpServerOverride()<br/>toggleMcpServerForChat()<br/>removeMcpServerOverride()<br/>isMcpServerEnabledForChat()<br/>clearPendingMcpServerOverrides()"]
S2Export["<b>Import/Export:</b><br/>downloadConversation()<br/>exportAllConversations()<br/>importConversations()<br/>importConversationsData()<br/>triggerDownload()"]
S2Utils["<b>Utilities:</b><br/>setTitleUpdateConfirmationCallback()"]
end
subgraph S3["modelsStore"]
S3State["<b>State:</b><br/>models, routerModels<br/>selectedModelId<br/>selectedModelName<br/>loading, updating, error<br/>modelLoadingStates<br/>modelPropsCache<br/>modelPropsFetching<br/>propsCacheVersion"]
S3Getters["<b>Computed Getters:</b><br/>selectedModel<br/>loadedModelIds<br/>loadingModelIds<br/>singleModelName"]
S3Modal["<b>Modalities:</b><br/>getModelModalities()<br/>modelSupportsVision()<br/>modelSupportsAudio()<br/>getModelModalitiesArray()<br/>getModelProps()<br/>updateModelModalities()"]
S3Status["<b>Status Queries:</b><br/>isModelLoaded()<br/>isModelOperationInProgress()<br/>getModelStatus()<br/>isModelPropsFetching()"]
S3Fetch["<b>Data Fetching:</b><br/>fetch()<br/>fetchRouterModels()<br/>fetchModelProps()<br/>fetchModalitiesForLoadedModels()"]
S3Select["<b>Model Selection:</b><br/>selectModelById()<br/>selectModelByName()<br/>clearSelection()<br/>findModelByName()<br/>findModelById()<br/>hasModel()"]
S3LoadUnload["<b>Loading/Unloading Models:</b><br/>loadModel()<br/>unloadModel()<br/>ensureModelLoaded()<br/>waitForModelStatus()<br/>pollForModelStatus()"]
S3Utils["<b>Utilities:</b><br/>toDisplayName()<br/>clear()"]
end
subgraph S4["serverStore"]
S4State["<b>State:</b><br/>props<br/>loading, error<br/>role<br/>fetchPromise"]
S4Getters["<b>Getters:</b><br/>defaultParams<br/>contextSize<br/>isRouterMode<br/>isModelMode"]
S4Data["<b>Data Handling:</b><br/>fetch()<br/>getErrorMessage()<br/>clear()"]
S4Utils["<b>Utilities:</b><br/>detectRole()"]
end
subgraph S5["settingsStore"]
S5State["<b>State:</b><br/>config<br/>theme<br/>isInitialized<br/>userOverrides"]
S5Lifecycle["<b>Lifecycle:</b><br/>initialize()<br/>loadConfig()<br/>saveConfig()<br/>loadTheme()<br/>saveTheme()"]
S5Update["<b>Config Updates:</b><br/>updateConfig()<br/>updateMultipleConfig()<br/>updateTheme()"]
S5Reset["<b>Reset:</b><br/>resetConfig()<br/>resetTheme()<br/>resetAll()<br/>resetParameterToServerDefault()"]
S5Sync["<b>Server Sync:</b><br/>syncWithServerDefaults()<br/>forceSyncWithServerDefaults()"]
S5Utils["<b>Utilities:</b><br/>getConfig()<br/>getAllConfig()<br/>getParameterInfo()<br/>getParameterDiff()<br/>getServerDefaults()<br/>clearAllUserOverrides()"]
end
subgraph S6["mcpStore"]
S6State["<b>State:</b><br/>isInitializing, error<br/>toolCount, connectedServers<br/>healthChecks (Map)<br/>connections (Map)<br/>toolsIndex (Map)"]
S6Lifecycle["<b>Lifecycle:</b><br/>ensureInitialized()<br/>initialize()<br/>shutdown()<br/>acquireConnection()<br/>releaseConnection()"]
S6Health["<b>Health Checks:</b><br/>runHealthCheck()<br/>runHealthChecksForServers()<br/>updateHealthCheck()<br/>getHealthCheckState()<br/>clearHealthCheck()"]
S6Servers["<b>Server Management:</b><br/>getServers()<br/>addServer()<br/>updateServer()<br/>removeServer()<br/>getServerById()<br/>getServerDisplayName()"]
S6Tools["<b>Tool Operations:</b><br/>getToolDefinitionsForLLM()<br/>getToolNames()<br/>hasTool()<br/>getToolServer()<br/>executeTool()<br/>executeToolByName()"]
S6Prompts["<b>Prompt Operations:</b><br/>getAllPrompts()<br/>getPrompt()<br/>hasPromptsCapability()<br/>getPromptCompletions()"]
end
subgraph S7["mcpResourceStore"]
S7State["<b>State:</b><br/>serverResources (Map)<br/>cachedResources (Map)<br/>subscriptions (Map)<br/>attachments[]<br/>isLoading"]
S7Resources["<b>Resource Discovery:</b><br/>setServerResources()<br/>getServerResources()<br/>getAllResourceInfos()<br/>getAllTemplateInfos()<br/>clearServerResources()"]
S7Cache["<b>Caching:</b><br/>cacheResourceContent()<br/>getCachedContent()<br/>invalidateCache()<br/>clearCache()"]
S7Subs["<b>Subscriptions:</b><br/>addSubscription()<br/>removeSubscription()<br/>isSubscribed()<br/>handleResourceUpdate()"]
S7Attach["<b>Attachments:</b><br/>addAttachment()<br/>updateAttachmentContent()<br/>removeAttachment()<br/>clearAttachments()<br/>toMessageExtras()"]
end
subgraph ReactiveExports["⚡ Reactive Exports"]
direction LR
subgraph ChatExports["chatStore"]
RE1["isLoading()"]
RE2["currentResponse()"]
RE3["errorDialog()"]
RE4["activeProcessingState()"]
RE5["isChatStreaming()"]
RE6["isChatLoading()"]
RE7["getChatStreaming()"]
RE8["getAllLoadingChats()"]
RE9["getAllStreamingChats()"]
RE9a["isEditModeActive()"]
RE9b["getAddFilesHandler()"]
RE9c["setEditModeActive()"]
RE9d["clearEditMode()"]
end
subgraph AgenticExports["agenticStore"]
REA1["agenticIsRunning()"]
REA2["agenticCurrentTurn()"]
REA3["agenticTotalToolCalls()"]
REA4["agenticLastError()"]
REA5["agenticStreamingToolCall()"]
REA6["agenticIsAnyRunning()"]
end
subgraph ConvExports["conversationsStore"]
RE10["conversations()"]
RE11["activeConversation()"]
RE12["activeMessages()"]
RE13["isConversationsInitialized()"]
end
subgraph ModelsExports["modelsStore"]
RE15["modelOptions()"]
RE16["routerModels()"]
RE17["modelsLoading()"]
RE18["modelsUpdating()"]
RE19["modelsError()"]
RE20["selectedModelId()"]
RE21["selectedModelName()"]
RE22["selectedModelOption()"]
RE23["loadedModelIds()"]
RE24["loadingModelIds()"]
RE25["propsCacheVersion()"]
RE26["singleModelName()"]
end
subgraph ServerExports["serverStore"]
RE27["serverProps()"]
RE28["serverLoading()"]
RE29["serverError()"]
RE30["serverRole()"]
RE31["defaultParams()"]
RE32["contextSize()"]
RE33["isRouterMode()"]
RE34["isModelMode()"]
end
subgraph SettingsExports["settingsStore"]
RE35["config()"]
RE36["theme()"]
RE37["isInitialized()"]
end
subgraph MCPExports["mcpStore / mcpResourceStore"]
RE38["mcpResources()"]
RE39["mcpResourceAttachments()"]
RE40["mcpHasResourceAttachments()"]
RE41["mcpTotalResourceCount()"]
RE42["mcpResourcesLoading()"]
end
end
end
subgraph Services["⚙️ Services"]
direction TB
subgraph SV1["ChatService"]
SV1Msg["<b>Messaging:</b><br/>sendMessage()"]
SV1Stream["<b>Streaming:</b><br/>handleStreamResponse()<br/>handleNonStreamResponse()"]
SV1Convert["<b>Conversion:</b><br/>convertDbMessageToApiChatMessageData()<br/>mergeToolCallDeltas()"]
SV1Utils["<b>Utilities:</b><br/>stripReasoningContent()<br/>extractModelName()<br/>parseErrorResponse()"]
end
subgraph SV2["ModelsService"]
SV2List["<b>Listing:</b><br/>list()<br/>listRouter()"]
SV2LoadUnload["<b>Load/Unload:</b><br/>load()<br/>unload()"]
SV2Status["<b>Status:</b><br/>isModelLoaded()<br/>isModelLoading()"]
end
subgraph SV3["PropsService"]
SV3Fetch["<b>Fetching:</b><br/>fetch()<br/>fetchForModel()"]
end
subgraph SV4["DatabaseService"]
SV4Conv["<b>Conversations:</b><br/>createConversation()<br/>getConversation()<br/>getAllConversations()<br/>updateConversation()<br/>deleteConversation()"]
SV4Msg["<b>Messages:</b><br/>createMessageBranch()<br/>createRootMessage()<br/>createSystemMessage()<br/>getConversationMessages()<br/>updateMessage()<br/>deleteMessage()<br/>deleteMessageCascading()"]
SV4Node["<b>Navigation:</b><br/>updateCurrentNode()"]
SV4Import["<b>Import:</b><br/>importConversations()"]
end
subgraph SV5["ParameterSyncService"]
SV5Extract["<b>Extraction:</b><br/>extractServerDefaults()"]
SV5Merge["<b>Merging:</b><br/>mergeWithServerDefaults()"]
SV5Info["<b>Info:</b><br/>getParameterInfo()<br/>canSyncParameter()<br/>getSyncableParameterKeys()<br/>validateServerParameter()"]
SV5Diff["<b>Diff:</b><br/>createParameterDiff()"]
end
subgraph SV6["MCPService"]
SV6Transport["<b>Transport:</b><br/>createTransport()<br/>WebSocket / StreamableHTTP / SSE"]
SV6Conn["<b>Connection:</b><br/>connect()<br/>disconnect()"]
SV6Tools["<b>Tools:</b><br/>listTools()<br/>callTool()"]
SV6Prompts["<b>Prompts:</b><br/>listPrompts()<br/>getPrompt()"]
SV6Resources["<b>Resources:</b><br/>listResources()<br/>listResourceTemplates()<br/>readResource()<br/>subscribeResource()<br/>unsubscribeResource()"]
SV6Complete["<b>Completions:</b><br/>complete()"]
end
end
subgraph ExternalMCP["🔌 External MCP Servers"]
EXT1["MCP Server 1<br/>(WebSocket/StreamableHTTP/SSE)"]
EXT2["MCP Server N"]
end
subgraph Storage["💾 Storage"]
ST1["IndexedDB"]
ST2["conversations"]
ST3["messages"]
ST5["LocalStorage"]
ST6["config"]
ST7["userOverrides"]
ST8["mcpServers"]
end
subgraph APIs["🌐 llama-server API"]
API1["/v1/chat/completions"]
API2["/props<br/>/props?model="]
API3["/models<br/>/models/load<br/>/models/unload"]
API4["/v1/models"]
end
%% Routes render Components
R1 --> C_Screen
R2 --> C_Screen
RL --> C_Sidebar
%% Layout runs MCP health checks on startup
RL --> S6
%% Component hierarchy
C_Screen --> C_Form & C_Messages & C_Settings
C_Messages --> C_Message
C_Message --> C_MessageUser
C_MessageUser --> C_MessageEditForm
C_MessageEditForm --> C_ModelsSelector
C_MessageEditForm --> C_Attach
C_Form --> C_ModelsSelector
C_Form --> C_Attach
C_Form --> C_McpServersSelector
C_Message --> C_Attach
%% MCP Components hierarchy
C_Settings --> C_McpSettings
C_McpSettings --> C_McpServerCard
C_McpServerCard --> C_McpResourceBrowser
C_McpResourceBrowser --> C_McpResourcePreview
%% Components use Hooks
C_Form --> H1
C_Message --> H1 & H2
C_MessageEditForm --> H1
C_Screen --> H2
%% Hooks use Stores
H1 --> S3 & S4
H2 --> S1 & S5
%% Components use Stores
C_Screen --> S1 & S2
C_Messages --> S2
C_Message --> S1 & S2 & S3
C_Form --> S1 & S3 & S6
C_Sidebar --> S2
C_ModelsSelector --> S3 & S4
C_Settings --> S5
C_McpSettings --> S6
C_McpServerCard --> S6
C_McpResourceBrowser --> S6 & S7
C_McpServersSelector --> S6
%% Stores export Reactive State
S1 -. exports .-> ChatExports
SA -. exports .-> AgenticExports
S2 -. exports .-> ConvExports
S3 -. exports .-> ModelsExports
S4 -. exports .-> ServerExports
S5 -. exports .-> SettingsExports
S6 -. exports .-> MCPExports
S7 -. exports .-> MCPExports
%% chatStore → agenticStore (agentic loop orchestration)
S1 --> SA
SA --> SV1
SA --> S6
%% Stores use Services
S1 --> SV1 & SV4
S2 --> SV4
S3 --> SV2 & SV3
S4 --> SV3
S5 --> SV5
S6 --> SV6
S7 --> SV6
%% Services to Storage
SV4 --> ST1
ST1 --> ST2 & ST3
SV5 --> ST5
ST5 --> ST6 & ST7 & ST8
%% Services to APIs
SV1 --> API1
SV2 --> API3 & API4
SV3 --> API2
%% MCP → External Servers
SV6 --> EXT1 & EXT2
%% Styling
classDef routeStyle fill:#e1f5fe,stroke:#01579b,stroke-width:2px
classDef componentStyle fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px
classDef componentGroupStyle fill:#e1bee7,stroke:#7b1fa2,stroke-width:1px
classDef hookStyle fill:#fff8e1,stroke:#ff8f00,stroke-width:2px
classDef storeStyle fill:#fff3e0,stroke:#e65100,stroke-width:2px
classDef stateStyle fill:#ffe0b2,stroke:#e65100,stroke-width:1px
classDef methodStyle fill:#ffecb3,stroke:#e65100,stroke-width:1px
classDef reactiveStyle fill:#fffde7,stroke:#f9a825,stroke-width:1px
classDef serviceStyle fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
classDef serviceMStyle fill:#c8e6c9,stroke:#2e7d32,stroke-width:1px
classDef externalStyle fill:#f3e5f5,stroke:#6a1b9a,stroke-width:2px,stroke-dasharray: 5 5
classDef storageStyle fill:#fce4ec,stroke:#c2185b,stroke-width:2px
classDef apiStyle fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
class R1,R2,RL routeStyle
class C_Sidebar,C_Screen,C_Form,C_Messages,C_Message,C_MessageUser,C_MessageEditForm componentStyle
class C_ModelsSelector,C_Settings componentStyle
class C_Attach componentStyle
class C_McpSettings,C_McpServerCard,C_McpResourceBrowser,C_McpResourcePreview,C_McpServersSelector componentStyle
class H1,H2,H3 hookStyle
class LayoutComponents,ChatUIComponents,MCPComponents componentGroupStyle
class Hooks hookStyle
classDef agenticStyle fill:#e8eaf6,stroke:#283593,stroke-width:2px
classDef agenticMethodStyle fill:#c5cae9,stroke:#283593,stroke-width:1px
class S1,S2,S3,S4,S5,SA,S6,S7 storeStyle
class S1State,S2State,S3State,S4State,S5State,SAState,S6State,S7State stateStyle
class S1Msg,S1Regen,S1Edit,S1Stream,S1LoadState,S1ProcState,S1Error,S1Utils methodStyle
class SASession,SAConfig,SAFlow methodStyle
class S2Lifecycle,S2ConvCRUD,S2MsgMgmt,S2Nav,S2McpOverrides,S2Export,S2Utils methodStyle
class S3Getters,S3Modal,S3Status,S3Fetch,S3Select,S3LoadUnload,S3Utils methodStyle
class S4Getters,S4Data,S4Utils methodStyle
class S5Lifecycle,S5Update,S5Reset,S5Sync,S5Utils methodStyle
class S6Lifecycle,S6Health,S6Servers,S6Tools,S6Prompts methodStyle
class S7Resources,S7Cache,S7Subs,S7Attach methodStyle
class ChatExports,AgenticExports,ConvExports,ModelsExports,ServerExports,SettingsExports,MCPExports reactiveStyle
class SV1,SV2,SV3,SV4,SV5,SV6 serviceStyle
class SV6Transport,SV6Conn,SV6Tools,SV6Prompts,SV6Resources,SV6Complete serviceMStyle
class EXT1,EXT2 externalStyle
class SV1Msg,SV1Stream,SV1Convert,SV1Utils serviceMStyle
class SV2List,SV2LoadUnload,SV2Status serviceMStyle
class SV3Fetch serviceMStyle
class SV4Conv,SV4Msg,SV4Node,SV4Import serviceMStyle
class SV5Extract,SV5Merge,SV5Info,SV5Diff serviceMStyle
class ST1,ST2,ST3,ST5,ST6,ST7,ST8 storageStyle
class API1,API2,API3,API4 apiStyle
```
-228
View File
@@ -1,228 +0,0 @@
```mermaid
sequenceDiagram
participant UI as 🧩 ChatForm / ChatMessage
participant chatStore as 🗄️ chatStore
participant agenticStore as 🗄️ agenticStore
participant convStore as 🗄️ conversationsStore
participant settingsStore as 🗄️ settingsStore
participant mcpStore as 🗄️ mcpStore
participant ChatSvc as ⚙️ ChatService
participant DbSvc as ⚙️ DatabaseService
participant API as 🌐 /v1/chat/completions
Note over chatStore: State:<br/>isLoading, currentResponse<br/>errorDialogState, activeProcessingState<br/>chatLoadingStates (Map)<br/>chatStreamingStates (Map)<br/>abortControllers (Map)<br/>processingStates (Map)
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,API: 💬 SEND MESSAGE
%% ═══════════════════════════════════════════════════════════════════════════
UI->>chatStore: sendMessage(content, extras)
activate chatStore
chatStore->>chatStore: setChatLoading(convId, true)
chatStore->>chatStore: clearChatStreaming(convId)
alt no active conversation
chatStore->>convStore: createConversation()
Note over convStore: → see conversations-flow.mmd
end
chatStore->>mcpStore: consumeResourceAttachmentsAsExtras()
Note right of mcpStore: Converts pending MCP resource<br/>attachments into message extras
chatStore->>chatStore: addMessage("user", content, extras)
chatStore->>DbSvc: createMessageBranch(userMsg, parentId)
chatStore->>convStore: addMessageToActive(userMsg)
chatStore->>convStore: updateCurrentNode(userMsg.id)
chatStore->>chatStore: createAssistantMessage(userMsg.id)
chatStore->>DbSvc: createMessageBranch(assistantMsg, userMsg.id)
chatStore->>convStore: addMessageToActive(assistantMsg)
chatStore->>chatStore: streamChatCompletion(messages, assistantMsg)
deactivate chatStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,API: 🌊 STREAMING (with agentic flow detection)
%% ═══════════════════════════════════════════════════════════════════════════
activate chatStore
chatStore->>chatStore: startStreaming()
Note right of chatStore: isStreamingActive = true
chatStore->>chatStore: setActiveProcessingConversation(convId)
chatStore->>chatStore: getOrCreateAbortController(convId)
Note right of chatStore: abortControllers.set(convId, new AbortController())
chatStore->>chatStore: getApiOptions()
Note right of chatStore: Merge from settingsStore.config:<br/>temperature, max_tokens, top_p, etc.
alt agenticConfig.enabled && mcpStore has connected servers
chatStore->>agenticStore: runAgenticFlow(convId, messages, assistantMsg, options, signal)
Note over agenticStore: Multi-turn agentic loop:<br/>1. Call ChatService.sendMessage()<br/>2. If response has tool_calls → execute via mcpStore<br/>3. Append tool results as messages<br/>4. Loop until no more tool_calls or maxTurns<br/>→ see agentic flow details below
agenticStore-->>chatStore: final response with timings
else standard (non-agentic) flow
chatStore->>ChatSvc: sendMessage(messages, options, signal)
end
activate ChatSvc
ChatSvc->>ChatSvc: convertDbMessageToApiChatMessageData(messages)
Note right of ChatSvc: DatabaseMessage[] → ApiChatMessageData[]<br/>Process attachments (images, PDFs, audio)
ChatSvc->>API: POST /v1/chat/completions
Note right of API: {messages, model?, stream: true, ...params}
loop SSE chunks
API-->>ChatSvc: data: {"choices":[{"delta":{...}}]}
ChatSvc->>ChatSvc: handleStreamResponse(response)
alt content chunk
ChatSvc-->>chatStore: onChunk(content)
chatStore->>chatStore: setChatStreaming(convId, response, msgId)
Note right of chatStore: currentResponse = $state(accumulated)
chatStore->>convStore: updateMessageAtIndex(idx, {content})
end
alt reasoning chunk
ChatSvc-->>chatStore: onReasoningChunk(reasoning)
chatStore->>convStore: updateMessageAtIndex(idx, {thinking})
end
alt tool_calls chunk
ChatSvc-->>chatStore: onToolCallChunk(toolCalls)
chatStore->>convStore: updateMessageAtIndex(idx, {toolCalls})
end
alt model info
ChatSvc-->>chatStore: onModel(modelName)
chatStore->>chatStore: recordModel(modelName)
chatStore->>DbSvc: updateMessage(msgId, {model})
end
alt timings (during stream)
ChatSvc-->>chatStore: onTimings(timings, promptProgress)
chatStore->>chatStore: updateProcessingStateFromTimings()
end
chatStore-->>UI: reactive $state update
end
API-->>ChatSvc: data: [DONE]
ChatSvc-->>chatStore: onComplete(content, reasoning, timings, toolCalls)
deactivate ChatSvc
chatStore->>chatStore: stopStreaming()
chatStore->>DbSvc: updateMessage(msgId, {content, timings, model})
chatStore->>convStore: updateCurrentNode(msgId)
chatStore->>chatStore: setChatLoading(convId, false)
chatStore->>chatStore: clearChatStreaming(convId)
chatStore->>chatStore: clearProcessingState(convId)
deactivate chatStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,API: ⏹️ STOP GENERATION
%% ═══════════════════════════════════════════════════════════════════════════
UI->>chatStore: stopGeneration()
activate chatStore
chatStore->>chatStore: savePartialResponseIfNeeded(convId)
Note right of chatStore: Save currentResponse to DB if non-empty
chatStore->>chatStore: abortControllers.get(convId).abort()
Note right of chatStore: fetch throws AbortError → caught by isAbortError()
chatStore->>chatStore: stopStreaming()
chatStore->>chatStore: setChatLoading(convId, false)
chatStore->>chatStore: clearChatStreaming(convId)
chatStore->>chatStore: clearProcessingState(convId)
deactivate chatStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,API: 🔁 REGENERATE
%% ═══════════════════════════════════════════════════════════════════════════
UI->>chatStore: regenerateMessageWithBranching(msgId, model?)
activate chatStore
chatStore->>convStore: findMessageIndex(msgId)
chatStore->>chatStore: Get parent of target message
chatStore->>chatStore: createAssistantMessage(parentId)
chatStore->>DbSvc: createMessageBranch(newAssistantMsg, parentId)
chatStore->>convStore: refreshActiveMessages()
Note right of chatStore: Same streaming flow
chatStore->>chatStore: streamChatCompletion(...)
deactivate chatStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,API: ➡️ CONTINUE
%% ═══════════════════════════════════════════════════════════════════════════
UI->>chatStore: continueAssistantMessage(msgId)
activate chatStore
chatStore->>chatStore: Get existing content from message
chatStore->>chatStore: streamChatCompletion(..., existingContent)
Note right of chatStore: Appends to existing message content
deactivate chatStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,API: ✏️ EDIT USER MESSAGE
%% ═══════════════════════════════════════════════════════════════════════════
UI->>chatStore: editMessageWithBranching(msgId, newContent, extras)
activate chatStore
chatStore->>chatStore: Get parent of target message
chatStore->>DbSvc: createMessageBranch(editedMsg, parentId)
chatStore->>convStore: refreshActiveMessages()
Note right of chatStore: Creates new branch, original preserved
chatStore->>chatStore: createAssistantMessage(editedMsg.id)
chatStore->>chatStore: streamChatCompletion(...)
Note right of chatStore: Automatically regenerates response
deactivate chatStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,API: ❌ ERROR HANDLING
%% ═══════════════════════════════════════════════════════════════════════════
Note over chatStore: On stream error (non-abort):
chatStore->>chatStore: showErrorDialog(type, message)
Note right of chatStore: errorDialogState = {type: 'timeout'|'server', message}
chatStore->>convStore: removeMessageAtIndex(failedMsgIdx)
chatStore->>DbSvc: deleteMessage(failedMsgId)
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,API: 🤖 AGENTIC LOOP (when agenticConfig.enabled)
%% ═══════════════════════════════════════════════════════════════════════════
Note over agenticStore: agenticStore.runAgenticFlow(convId, messages, assistantMsg, options, signal)
activate agenticStore
agenticStore->>agenticStore: getSession(convId) or create new
agenticStore->>agenticStore: updateSession(turn: 0, running: true)
loop executeAgenticLoop (until no tool_calls or maxTurns)
agenticStore->>agenticStore: turn++
agenticStore->>ChatSvc: sendMessage(messages, options, signal)
ChatSvc->>API: POST /v1/chat/completions
API-->>ChatSvc: response with potential tool_calls
ChatSvc-->>agenticStore: onComplete(content, reasoning, timings, toolCalls)
alt response has tool_calls
agenticStore->>agenticStore: normalizeToolCalls(toolCalls)
loop for each tool_call
agenticStore->>agenticStore: updateSession(streamingToolCall)
agenticStore->>mcpStore: executeTool(mcpCall, signal)
mcpStore-->>agenticStore: tool result
agenticStore->>agenticStore: extractBase64Attachments(result)
agenticStore->>agenticStore: emitToolCallResult(convId, ...)
agenticStore->>convStore: addMessageToActive(toolResultMsg)
agenticStore->>DbSvc: createMessageBranch(toolResultMsg)
end
agenticStore->>agenticStore: Create new assistantMsg for next turn
Note right of agenticStore: Continue loop with updated messages
else no tool_calls (final response)
agenticStore->>agenticStore: buildFinalTimings(allTurns)
Note right of agenticStore: Break loop, return final response
end
end
agenticStore->>agenticStore: updateSession(running: false)
agenticStore-->>chatStore: final content, timings, model
deactivate agenticStore
```
-183
View File
@@ -1,183 +0,0 @@
```mermaid
sequenceDiagram
participant UI as 🧩 ChatSidebar / ChatScreen
participant convStore as 🗄️ conversationsStore
participant chatStore as 🗄️ chatStore
participant DbSvc as ⚙️ DatabaseService
participant IDB as 💾 IndexedDB
Note over convStore: State:<br/>conversations: DatabaseConversation[]<br/>activeConversation: DatabaseConversation | null<br/>activeMessages: DatabaseMessage[]<br/>isInitialized: boolean<br/>pendingMcpServerOverrides: Map&lt;string, McpServerOverride&gt;
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,IDB: 🚀 INITIALIZATION
%% ═══════════════════════════════════════════════════════════════════════════
Note over convStore: Auto-initialized in constructor (browser only)
convStore->>convStore: initialize()
activate convStore
convStore->>convStore: loadConversations()
convStore->>DbSvc: getAllConversations()
DbSvc->>IDB: SELECT * FROM conversations ORDER BY lastModified DESC
IDB-->>DbSvc: Conversation[]
DbSvc-->>convStore: conversations
convStore->>convStore: conversations = $state(data)
convStore->>convStore: isInitialized = true
deactivate convStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,IDB: CREATE CONVERSATION
%% ═══════════════════════════════════════════════════════════════════════════
UI->>convStore: createConversation(name?)
activate convStore
convStore->>DbSvc: createConversation(name || "New Chat")
DbSvc->>IDB: INSERT INTO conversations
IDB-->>DbSvc: conversation {id, name, lastModified, currNode: ""}
DbSvc-->>convStore: conversation
convStore->>convStore: conversations.unshift(conversation)
convStore->>convStore: activeConversation = $state(conversation)
convStore->>convStore: activeMessages = $state([])
alt pendingMcpServerOverrides has entries
loop each pending override
convStore->>DbSvc: Store MCP server override for new conversation
end
convStore->>convStore: clearPendingMcpServerOverrides()
end
deactivate convStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,IDB: 📂 LOAD CONVERSATION
%% ═══════════════════════════════════════════════════════════════════════════
UI->>convStore: loadConversation(convId)
activate convStore
convStore->>DbSvc: getConversation(convId)
DbSvc->>IDB: SELECT * FROM conversations WHERE id = ?
IDB-->>DbSvc: conversation
convStore->>convStore: activeConversation = $state(conversation)
convStore->>convStore: refreshActiveMessages()
convStore->>DbSvc: getConversationMessages(convId)
DbSvc->>IDB: SELECT * FROM messages WHERE convId = ?
IDB-->>DbSvc: allMessages[]
convStore->>convStore: filterByLeafNodeId(allMessages, currNode)
Note right of convStore: Filter to show only current branch path
convStore->>convStore: activeMessages = $state(filtered)
Note right of convStore: Route (+page.svelte) then calls:<br/>chatStore.syncLoadingStateForChat(convId)
deactivate convStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,IDB: 🌳 MESSAGE BRANCHING MODEL
%% ═══════════════════════════════════════════════════════════════════════════
Note over IDB: Message Tree Structure:<br/>- Each message has parent (null for root)<br/>- Each message has children[] array<br/>- Conversation.currNode points to active leaf<br/>- filterByLeafNodeId() traverses from root to currNode
rect rgb(240, 240, 255)
Note over convStore: Example Branch Structure:
Note over convStore: root → user1 → assistant1 → user2 → assistant2a (currNode)<br/> ↘ assistant2b (alt branch)
end
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,IDB: ↔️ BRANCH NAVIGATION
%% ═══════════════════════════════════════════════════════════════════════════
UI->>convStore: navigateToSibling(msgId, direction)
activate convStore
convStore->>convStore: Find message in activeMessages
convStore->>convStore: Get parent message
convStore->>convStore: Find sibling in parent.children[]
convStore->>convStore: findLeafNode(siblingId, allMessages)
Note right of convStore: Navigate to leaf of sibling branch
convStore->>convStore: updateCurrentNode(leafId)
convStore->>DbSvc: updateCurrentNode(convId, leafId)
DbSvc->>IDB: UPDATE conversations SET currNode = ?
convStore->>convStore: refreshActiveMessages()
deactivate convStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,IDB: 📝 UPDATE CONVERSATION
%% ═══════════════════════════════════════════════════════════════════════════
UI->>convStore: updateConversationName(convId, newName)
activate convStore
convStore->>DbSvc: updateConversation(convId, {name: newName})
DbSvc->>IDB: UPDATE conversations SET name = ?
convStore->>convStore: Update in conversations array
deactivate convStore
Note over convStore: Auto-title update (after first response):
convStore->>convStore: updateConversationTitleWithConfirmation()
convStore->>convStore: titleUpdateConfirmationCallback?()
Note right of convStore: Shows dialog if title would change
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,IDB: 🗑️ DELETE CONVERSATION
%% ═══════════════════════════════════════════════════════════════════════════
UI->>convStore: deleteConversation(convId)
activate convStore
convStore->>DbSvc: deleteConversation(convId)
DbSvc->>IDB: DELETE FROM conversations WHERE id = ?
DbSvc->>IDB: DELETE FROM messages WHERE convId = ?
convStore->>convStore: conversations.filter(c => c.id !== convId)
alt deleted active conversation
convStore->>convStore: clearActiveConversation()
end
deactivate convStore
UI->>convStore: deleteAll()
activate convStore
convStore->>DbSvc: Delete all conversations and messages
convStore->>convStore: conversations = []
convStore->>convStore: clearActiveConversation()
deactivate convStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,IDB: MCP SERVER PER-CHAT OVERRIDES
%% ═══════════════════════════════════════════════════════════════════════════
Note over convStore: Conversations can override which MCP servers are enabled.
Note over convStore: Uses pendingMcpServerOverrides before conversation<br/>is created, then persists to conversation metadata.
UI->>convStore: setMcpServerOverride(convId, serverName, override)
Note right of convStore: override = {enabled: boolean}
UI->>convStore: toggleMcpServerForChat(convId, serverName, enabled)
activate convStore
convStore->>convStore: setMcpServerOverride(convId, serverName, {enabled})
deactivate convStore
UI->>convStore: isMcpServerEnabledForChat(convId, serverName)
Note right of convStore: Check override → fall back to global MCP config
UI->>convStore: getAllMcpServerOverrides(convId)
Note right of convStore: Returns all overrides for a conversation
UI->>convStore: removeMcpServerOverride(convId, serverName)
UI->>convStore: getMcpServerOverride(convId, serverName)
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,IDB: 📤 EXPORT / 📥 IMPORT
%% ═══════════════════════════════════════════════════════════════════════════
UI->>convStore: exportAllConversations()
activate convStore
convStore->>DbSvc: getAllConversations()
loop each conversation
convStore->>DbSvc: getConversationMessages(convId)
end
convStore->>convStore: triggerDownload(JSON blob)
deactivate convStore
UI->>convStore: importConversations(file)
activate convStore
convStore->>convStore: Parse JSON file
convStore->>convStore: importConversationsData(parsed)
convStore->>DbSvc: importConversations(parsed)
Note right of DbSvc: Skips duplicate conversations<br/>(checks existing by ID)
DbSvc->>IDB: INSERT conversations + messages (skip existing)
convStore->>convStore: loadConversations()
deactivate convStore
```
@@ -1,45 +0,0 @@
```mermaid
%% MODEL Mode Data Flow (single model)
%% Detailed flows: ./flows/server-flow.mmd, ./flows/models-flow.mmd, ./flows/chat-flow.mmd
sequenceDiagram
participant User as 👤 User
participant UI as 🧩 UI
participant Stores as 🗄️ Stores
participant DB as 💾 IndexedDB
participant API as 🌐 llama-server
Note over User,API: 🚀 Initialization (see: server-flow.mmd, models-flow.mmd)
UI->>Stores: initialize()
Stores->>DB: load conversations
Stores->>API: GET /props
API-->>Stores: server config + modalities
Stores->>API: GET /v1/models
API-->>Stores: single model (auto-selected)
Note over User,API: 💬 Chat Flow (see: chat-flow.mmd)
User->>UI: send message
UI->>Stores: sendMessage()
Stores->>DB: save user message
Stores->>API: POST /v1/chat/completions (stream)
loop streaming
API-->>Stores: SSE chunks
Stores-->>UI: reactive update
end
API-->>Stores: done + timings
Stores->>DB: save assistant message
Note over User,API: 🔁 Regenerate
User->>UI: regenerate
Stores->>DB: create message branch
Note right of Stores: same streaming flow
Note over User,API: ⏹️ Stop
User->>UI: stop
Stores->>Stores: abort stream
Stores->>DB: save partial response
```
@@ -1,77 +0,0 @@
```mermaid
%% ROUTER Mode Data Flow (multi-model)
%% Detailed flows: ./flows/server-flow.mmd, ./flows/models-flow.mmd, ./flows/chat-flow.mmd
sequenceDiagram
participant User as 👤 User
participant UI as 🧩 UI
participant Stores as 🗄️ Stores
participant DB as 💾 IndexedDB
participant API as 🌐 llama-server
Note over User,API: 🚀 Initialization (see: server-flow.mmd, models-flow.mmd)
UI->>Stores: initialize()
Stores->>DB: load conversations
Stores->>API: GET /props
API-->>Stores: {role: "router"}
Stores->>API: GET /v1/models
API-->>Stores: models[] with status (loaded/available)
loop each loaded model
Stores->>API: GET /props?model=X
API-->>Stores: modalities (vision/audio)
end
Note over User,API: 🔄 Model Selection (see: models-flow.mmd)
User->>UI: select model
alt model not loaded
Stores->>API: POST /models/load
loop poll status
Stores->>API: GET /v1/models
API-->>Stores: check if loaded
end
Stores->>API: GET /props?model=X
API-->>Stores: cache modalities
end
Stores->>Stores: validate modalities vs conversation
alt valid
Stores->>Stores: select model
else invalid
Stores->>API: POST /models/unload
UI->>User: show error toast
end
Note over User,API: 💬 Chat Flow (see: chat-flow.mmd)
User->>UI: send message
UI->>Stores: sendMessage()
Stores->>DB: save user message
Stores->>API: POST /v1/chat/completions {model: X}
Note right of API: router forwards to model
loop streaming
API-->>Stores: SSE chunks + model info
Stores-->>UI: reactive update
end
API-->>Stores: done + timings
Stores->>DB: save assistant message + model used
Note over User,API: 🔁 Regenerate (optional: different model)
User->>UI: regenerate
Stores->>Stores: validate modalities up to this message
Stores->>DB: create message branch
Note right of Stores: same streaming flow
Note over User,API: ⏹️ Stop
User->>UI: stop
Stores->>Stores: abort stream
Stores->>DB: save partial response
Note over User,API: 🗑️ LRU Unloading
Note right of API: Server auto-unloads LRU models<br/>when cache full
User->>UI: select unloaded model
Note right of Stores: triggers load flow again
```
-174
View File
@@ -1,174 +0,0 @@
```mermaid
sequenceDiagram
participant Store as 🗄️ Stores
participant DbSvc as ⚙️ DatabaseService
participant Dexie as 📦 Dexie ORM
participant IDB as 💾 IndexedDB
Note over DbSvc: Stateless service - all methods static<br/>Database: "LlamacppWebui"
%% ═══════════════════════════════════════════════════════════════════════════
Note over Store,IDB: 📊 SCHEMA
%% ═══════════════════════════════════════════════════════════════════════════
rect rgb(240, 248, 255)
Note over IDB: conversations table:<br/>id (PK), lastModified, currNode, name
end
rect rgb(255, 248, 240)
Note over IDB: messages table:<br/>id (PK), convId (FK), type, role, timestamp,<br/>parent, children[], content, thinking,<br/>toolCalls, extra[], model, timings
end
%% ═══════════════════════════════════════════════════════════════════════════
Note over Store,IDB: 💬 CONVERSATIONS CRUD
%% ═══════════════════════════════════════════════════════════════════════════
Store->>DbSvc: createConversation(name)
activate DbSvc
DbSvc->>DbSvc: Generate UUID
DbSvc->>Dexie: db.conversations.add({id, name, lastModified, currNode: ""})
Dexie->>IDB: INSERT
IDB-->>Dexie: success
DbSvc-->>Store: DatabaseConversation
deactivate DbSvc
Store->>DbSvc: getConversation(convId)
DbSvc->>Dexie: db.conversations.get(convId)
Dexie->>IDB: SELECT WHERE id = ?
IDB-->>DbSvc: DatabaseConversation
Store->>DbSvc: getAllConversations()
DbSvc->>Dexie: db.conversations.orderBy('lastModified').reverse().toArray()
Dexie->>IDB: SELECT ORDER BY lastModified DESC
IDB-->>DbSvc: DatabaseConversation[]
Store->>DbSvc: updateConversation(convId, updates)
DbSvc->>Dexie: db.conversations.update(convId, {...updates, lastModified})
Dexie->>IDB: UPDATE
Store->>DbSvc: deleteConversation(convId)
activate DbSvc
DbSvc->>Dexie: db.conversations.delete(convId)
Dexie->>IDB: DELETE FROM conversations
DbSvc->>Dexie: db.messages.where('convId').equals(convId).delete()
Dexie->>IDB: DELETE FROM messages WHERE convId = ?
deactivate DbSvc
%% ═══════════════════════════════════════════════════════════════════════════
Note over Store,IDB: 📝 MESSAGES CRUD
%% ═══════════════════════════════════════════════════════════════════════════
Store->>DbSvc: createRootMessage(convId)
activate DbSvc
DbSvc->>DbSvc: Create root message {type: "root", parent: null}
DbSvc->>Dexie: db.messages.add(rootMsg)
Dexie->>IDB: INSERT
DbSvc-->>Store: rootMessageId
deactivate DbSvc
Store->>DbSvc: createSystemMessage(convId, content, parentId)
activate DbSvc
DbSvc->>DbSvc: Create message {role: "system", parent: parentId}
DbSvc->>Dexie: db.messages.add(systemMsg)
Dexie->>IDB: INSERT
DbSvc-->>Store: DatabaseMessage
deactivate DbSvc
Store->>DbSvc: createMessageBranch(message, parentId)
activate DbSvc
DbSvc->>DbSvc: Generate UUID for new message
DbSvc->>Dexie: db.messages.add({...message, id, parent: parentId})
Dexie->>IDB: INSERT message
alt parentId exists
DbSvc->>Dexie: db.messages.get(parentId)
Dexie->>IDB: SELECT parent
DbSvc->>DbSvc: parent.children.push(newId)
DbSvc->>Dexie: db.messages.update(parentId, {children})
Dexie->>IDB: UPDATE parent.children
end
DbSvc->>Dexie: db.conversations.update(convId, {currNode: newId})
Dexie->>IDB: UPDATE conversation.currNode
DbSvc-->>Store: DatabaseMessage
deactivate DbSvc
Store->>DbSvc: getConversationMessages(convId)
DbSvc->>Dexie: db.messages.where('convId').equals(convId).toArray()
Dexie->>IDB: SELECT WHERE convId = ?
IDB-->>DbSvc: DatabaseMessage[]
Store->>DbSvc: updateMessage(msgId, updates)
DbSvc->>Dexie: db.messages.update(msgId, updates)
Dexie->>IDB: UPDATE
Store->>DbSvc: deleteMessage(msgId)
DbSvc->>Dexie: db.messages.delete(msgId)
Dexie->>IDB: DELETE
%% ═══════════════════════════════════════════════════════════════════════════
Note over Store,IDB: 🌳 BRANCHING OPERATIONS
%% ═══════════════════════════════════════════════════════════════════════════
Store->>DbSvc: updateCurrentNode(convId, nodeId)
DbSvc->>Dexie: db.conversations.update(convId, {currNode: nodeId, lastModified})
Dexie->>IDB: UPDATE
Store->>DbSvc: deleteMessageCascading(msgId)
activate DbSvc
DbSvc->>DbSvc: findDescendantMessages(msgId, allMessages)
Note right of DbSvc: Recursively find all children
loop each descendant
DbSvc->>Dexie: db.messages.delete(descendantId)
Dexie->>IDB: DELETE
end
DbSvc->>Dexie: db.messages.delete(msgId)
Dexie->>IDB: DELETE target message
alt target message has a parent
DbSvc->>Dexie: db.messages.get(parentId)
DbSvc->>DbSvc: parent.children.filter(id !== msgId)
DbSvc->>Dexie: db.messages.update(parentId, {children})
Note right of DbSvc: Remove deleted message from parent's children[]
end
deactivate DbSvc
%% ═══════════════════════════════════════════════════════════════════════════
Note over Store,IDB: 📥 IMPORT
%% ═══════════════════════════════════════════════════════════════════════════
Store->>DbSvc: importConversations(data)
activate DbSvc
loop each conversation in data
DbSvc->>Dexie: db.conversations.get(conv.id)
alt conversation already exists
Note right of DbSvc: Skip duplicate (keep existing)
else conversation is new
DbSvc->>Dexie: db.conversations.add(conversation)
Dexie->>IDB: INSERT conversation
loop each message
DbSvc->>Dexie: db.messages.add(message)
Dexie->>IDB: INSERT message
end
end
end
deactivate DbSvc
%% ═══════════════════════════════════════════════════════════════════════════
Note over Store,IDB: 🔗 MESSAGE TREE UTILITIES
%% ═══════════════════════════════════════════════════════════════════════════
Note over DbSvc: Used by stores (imported from utils):
rect rgb(240, 255, 240)
Note over DbSvc: filterByLeafNodeId(messages, leafId)<br/>→ Returns path from root to leaf<br/>→ Used to display current branch
end
rect rgb(240, 255, 240)
Note over DbSvc: findLeafNode(startId, messages)<br/>→ Traverse to deepest child<br/>→ Used for branch navigation
end
rect rgb(240, 255, 240)
Note over DbSvc: findDescendantMessages(msgId, messages)<br/>→ Find all children recursively<br/>→ Used for cascading deletes
end
```
-226
View File
@@ -1,226 +0,0 @@
```mermaid
sequenceDiagram
participant UI as 🧩 McpServersSettings / ChatForm
participant chatStore as 🗄️ chatStore
participant mcpStore as 🗄️ mcpStore
participant mcpResStore as 🗄️ mcpResourceStore
participant convStore as 🗄️ conversationsStore
participant MCPSvc as ⚙️ MCPService
participant LS as 💾 LocalStorage
participant ExtMCP as 🔌 External MCP Server
Note over mcpStore: State:<br/>isInitializing, error<br/>toolCount, connectedServers<br/>healthChecks (Map)<br/>connections (Map)<br/>toolsIndex (Map)<br/>serverConfigs (Map)
Note over mcpResStore: State:<br/>serverResources (Map)<br/>cachedResources (Map)<br/>subscriptions (Map)<br/>attachments[]
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,ExtMCP: 🚀 INITIALIZATION (App Startup)
%% ═══════════════════════════════════════════════════════════════════════════
UI->>mcpStore: ensureInitialized()
activate mcpStore
mcpStore->>LS: get(MCP_SERVERS_LOCALSTORAGE_KEY)
LS-->>mcpStore: MCPServerSettingsEntry[]
mcpStore->>mcpStore: parseServerSettings(servers)
Note right of mcpStore: Filter enabled servers<br/>Build MCPServerConfig objects<br/>Per-chat overrides checked via convStore
loop For each enabled server
mcpStore->>mcpStore: runHealthCheck(serverId)
mcpStore->>mcpStore: updateHealthCheck(id, CONNECTING)
mcpStore->>MCPSvc: connect(serverName, config, clientInfo, capabilities, onPhase)
activate MCPSvc
MCPSvc->>MCPSvc: createTransport(config)
Note right of MCPSvc: WebSocket / StreamableHTTP / SSE<br/>with optional CORS proxy
MCPSvc->>ExtMCP: Transport handshake
ExtMCP-->>MCPSvc: Connection established
MCPSvc->>ExtMCP: Initialize request
Note right of ExtMCP: Exchange capabilities<br/>Server info, protocol version
ExtMCP-->>MCPSvc: InitializeResult (serverInfo, capabilities)
MCPSvc->>ExtMCP: listTools()
ExtMCP-->>MCPSvc: Tool[]
MCPSvc-->>mcpStore: MCPConnection
deactivate MCPSvc
mcpStore->>mcpStore: connections.set(serverName, connection)
mcpStore->>mcpStore: indexTools(connection.tools, serverName)
Note right of mcpStore: toolsIndex.set(toolName, serverName)<br/>Handle name conflicts with prefixes
mcpStore->>mcpStore: updateHealthCheck(id, SUCCESS)
mcpStore->>mcpStore: _connectedServers.push(serverName)
alt Server supports resources
mcpStore->>MCPSvc: listAllResources(connection)
MCPSvc->>ExtMCP: listResources()
ExtMCP-->>MCPSvc: MCPResource[]
MCPSvc-->>mcpStore: resources
mcpStore->>MCPSvc: listAllResourceTemplates(connection)
MCPSvc->>ExtMCP: listResourceTemplates()
ExtMCP-->>MCPSvc: MCPResourceTemplate[]
MCPSvc-->>mcpStore: templates
mcpStore->>mcpResStore: setServerResources(serverName, resources, templates)
end
end
mcpStore->>mcpStore: _isInitializing = false
deactivate mcpStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,ExtMCP: 🔧 TOOL EXECUTION (Chat with Tools)
%% ═══════════════════════════════════════════════════════════════════════════
UI->>mcpStore: executeTool(mcpCall: MCPToolCall, signal?)
activate mcpStore
mcpStore->>mcpStore: toolsIndex.get(mcpCall.function.name)
Note right of mcpStore: Resolve serverName from toolsIndex<br/>MCPToolCall = {id, type, function: {name, arguments}}
mcpStore->>mcpStore: acquireConnection()
Note right of mcpStore: activeFlowCount++<br/>Prevent shutdown during execution
mcpStore->>mcpStore: connection = connections.get(serverName)
mcpStore->>MCPSvc: callTool(connection, {name, arguments}, signal)
activate MCPSvc
MCPSvc->>MCPSvc: throwIfAborted(signal)
MCPSvc->>ExtMCP: callTool(name, arguments)
alt Tool execution success
ExtMCP-->>MCPSvc: ToolCallResult (content, isError)
MCPSvc->>MCPSvc: formatToolResult(result)
Note right of MCPSvc: Handle text, image (base64),<br/>embedded resource content
MCPSvc-->>mcpStore: ToolExecutionResult
else Tool execution error
ExtMCP-->>MCPSvc: Error
MCPSvc-->>mcpStore: throw Error
else Aborted
MCPSvc-->>mcpStore: throw AbortError
end
deactivate MCPSvc
mcpStore->>mcpStore: releaseConnection()
Note right of mcpStore: activeFlowCount--
mcpStore-->>UI: ToolExecutionResult
deactivate mcpStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,ExtMCP: RESOURCE ATTACHMENT CONSUMPTION
%% ═══════════════════════════════════════════════════════════════════════════
chatStore->>mcpStore: consumeResourceAttachmentsAsExtras()
activate mcpStore
mcpStore->>mcpResStore: getAttachments()
mcpResStore-->>mcpStore: MCPResourceAttachment[]
mcpStore->>mcpStore: Convert attachments to message extras
mcpStore->>mcpResStore: clearAttachments()
mcpStore-->>chatStore: MessageExtra[] (for user message)
deactivate mcpStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,ExtMCP: 📝 PROMPT OPERATIONS
%% ═══════════════════════════════════════════════════════════════════════════
UI->>mcpStore: getAllPrompts()
activate mcpStore
loop For each connected server with prompts capability
mcpStore->>MCPSvc: listPrompts(connection)
MCPSvc->>ExtMCP: listPrompts()
ExtMCP-->>MCPSvc: Prompt[]
MCPSvc-->>mcpStore: prompts
end
mcpStore-->>UI: MCPPromptInfo[] (with serverName)
deactivate mcpStore
UI->>mcpStore: getPrompt(serverName, promptName, args?)
activate mcpStore
mcpStore->>MCPSvc: getPrompt(connection, name, args)
MCPSvc->>ExtMCP: getPrompt({name, arguments})
ExtMCP-->>MCPSvc: GetPromptResult (messages)
MCPSvc-->>mcpStore: GetPromptResult
mcpStore-->>UI: GetPromptResult
deactivate mcpStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,ExtMCP: 📁 RESOURCE OPERATIONS
%% ═══════════════════════════════════════════════════════════════════════════
UI->>mcpResStore: addAttachment(resourceInfo)
activate mcpResStore
mcpResStore->>mcpResStore: Create MCPResourceAttachment (loading: true)
mcpResStore-->>UI: attachment
UI->>mcpStore: readResource(serverName, uri)
activate mcpStore
mcpStore->>MCPSvc: readResource(connection, uri)
MCPSvc->>ExtMCP: readResource({uri})
ExtMCP-->>MCPSvc: MCPReadResourceResult (contents)
MCPSvc-->>mcpStore: contents
mcpStore-->>UI: MCPResourceContent[]
deactivate mcpStore
UI->>mcpResStore: updateAttachmentContent(attachmentId, content)
mcpResStore->>mcpResStore: cacheResourceContent(resource, content)
deactivate mcpResStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,ExtMCP: 🔄 AUTO-RECONNECTION
%% ═══════════════════════════════════════════════════════════════════════════
Note over mcpStore: On WebSocket close or connection error:
mcpStore->>mcpStore: autoReconnect(serverName, attempt)
activate mcpStore
mcpStore->>mcpStore: Calculate backoff delay
Note right of mcpStore: delay = min(30s, 1s * 2^attempt)
mcpStore->>mcpStore: Wait for delay
mcpStore->>mcpStore: reconnectServer(serverName)
alt Reconnection success
mcpStore->>mcpStore: updateHealthCheck(id, SUCCESS)
else Max attempts reached
mcpStore->>mcpStore: updateHealthCheck(id, ERROR)
end
deactivate mcpStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,ExtMCP: 🛑 SHUTDOWN
%% ═══════════════════════════════════════════════════════════════════════════
UI->>mcpStore: shutdown()
activate mcpStore
mcpStore->>mcpStore: Wait for activeFlowCount == 0
loop For each connection
mcpStore->>MCPSvc: disconnect(connection)
MCPSvc->>MCPSvc: transport.onclose = undefined
MCPSvc->>ExtMCP: close()
end
mcpStore->>mcpStore: connections.clear()
mcpStore->>mcpStore: toolsIndex.clear()
mcpStore->>mcpStore: _connectedServers = []
mcpStore->>mcpResStore: clear()
deactivate mcpStore
```
-181
View File
@@ -1,181 +0,0 @@
```mermaid
sequenceDiagram
participant UI as 🧩 ModelsSelector
participant Hooks as 🪝 useModelChangeValidation
participant modelsStore as 🗄️ modelsStore
participant serverStore as 🗄️ serverStore
participant convStore as 🗄️ conversationsStore
participant ModelsSvc as ⚙️ ModelsService
participant PropsSvc as ⚙️ PropsService
participant API as 🌐 llama-server
Note over modelsStore: State:<br/>models: ModelOption[]<br/>routerModels: ApiModelDataEntry[]<br/>selectedModelId, selectedModelName<br/>loading, updating, error<br/>modelLoadingStates (Map)<br/>modelPropsCache (Map)<br/>propsCacheVersion
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,API: 🚀 INITIALIZATION (MODEL mode)
%% ═══════════════════════════════════════════════════════════════════════════
UI->>modelsStore: fetch()
activate modelsStore
modelsStore->>modelsStore: loading = true
alt serverStore.props not loaded
modelsStore->>serverStore: fetch()
Note over serverStore: → see server-flow.mmd
end
modelsStore->>ModelsSvc: list()
ModelsSvc->>API: GET /v1/models
API-->>ModelsSvc: ApiModelListResponse {data: [model]}
modelsStore->>modelsStore: models = $state(mapped)
Note right of modelsStore: Map to ModelOption[]:<br/>{id, name, model, description, capabilities}
Note over modelsStore: MODEL mode: Get modalities from serverStore.props
modelsStore->>modelsStore: modelPropsCache.set(model.id, serverStore.props)
modelsStore->>modelsStore: models[0].modalities = props.modalities
modelsStore->>modelsStore: Auto-select single model
Note right of modelsStore: selectedModelId = models[0].id
modelsStore->>modelsStore: loading = false
deactivate modelsStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,API: 🚀 INITIALIZATION (ROUTER mode)
%% ═══════════════════════════════════════════════════════════════════════════
UI->>modelsStore: fetch()
activate modelsStore
modelsStore->>ModelsSvc: list()
ModelsSvc->>API: GET /v1/models
API-->>ModelsSvc: ApiModelListResponse
modelsStore->>modelsStore: models = $state(mapped)
deactivate modelsStore
Note over UI: After models loaded, layout triggers:
UI->>modelsStore: fetchRouterModels()
activate modelsStore
modelsStore->>ModelsSvc: listRouter()
ModelsSvc->>API: GET /v1/models
API-->>ModelsSvc: ApiRouterModelsListResponse
Note right of API: {data: [{id, status, path, in_cache}]}
modelsStore->>modelsStore: routerModels = $state(data)
modelsStore->>modelsStore: fetchModalitiesForLoadedModels()
loop each model where status === "loaded"
modelsStore->>PropsSvc: fetchForModel(modelId)
PropsSvc->>API: GET /props?model={modelId}
API-->>PropsSvc: ApiLlamaCppServerProps
modelsStore->>modelsStore: modelPropsCache.set(modelId, props)
end
modelsStore->>modelsStore: propsCacheVersion++
deactivate modelsStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,API: 🔄 MODEL SELECTION (ROUTER mode)
%% ═══════════════════════════════════════════════════════════════════════════
UI->>Hooks: useModelChangeValidation({getRequiredModalities, onSuccess?, onValidationFailure?})
Note over Hooks: Hook configured per-component:<br/>ChatForm: getRequiredModalities = usedModalities<br/>ChatMessage: getRequiredModalities = getModalitiesUpToMessage(msgId)
UI->>Hooks: handleModelChange(modelId, modelName)
activate Hooks
Hooks->>Hooks: previousSelectedModelId = modelsStore.selectedModelId
Hooks->>modelsStore: isModelLoaded(modelName)?
alt model NOT loaded
Hooks->>modelsStore: loadModel(modelName)
Note over modelsStore: → see LOAD MODEL section below
end
Note over Hooks: Always fetch props (from cache or API)
Hooks->>modelsStore: fetchModelProps(modelName)
modelsStore-->>Hooks: props
Hooks->>convStore: getRequiredModalities()
convStore-->>Hooks: {vision, audio}
Hooks->>Hooks: Validate: model.modalities ⊇ required?
alt validation PASSED
Hooks->>modelsStore: selectModelById(modelId)
Hooks-->>UI: return true
else validation FAILED
Hooks->>UI: toast.error("Model doesn't support required modalities")
alt model was just loaded
Hooks->>modelsStore: unloadModel(modelName)
end
alt onValidationFailure provided
Hooks->>modelsStore: selectModelById(previousSelectedModelId)
end
Hooks-->>UI: return false
end
deactivate Hooks
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,API: ⬆️ LOAD MODEL (ROUTER mode)
%% ═══════════════════════════════════════════════════════════════════════════
modelsStore->>modelsStore: loadModel(modelId)
activate modelsStore
alt already loaded
modelsStore-->>modelsStore: return (no-op)
end
modelsStore->>modelsStore: modelLoadingStates.set(modelId, true)
modelsStore->>ModelsSvc: load(modelId)
ModelsSvc->>API: POST /models/load {model: modelId}
API-->>ModelsSvc: {status: "loading"}
modelsStore->>modelsStore: pollForModelStatus(modelId, LOADED)
loop poll every 500ms (max 60 attempts)
modelsStore->>modelsStore: fetchRouterModels()
modelsStore->>ModelsSvc: listRouter()
ModelsSvc->>API: GET /v1/models
API-->>ModelsSvc: models[]
modelsStore->>modelsStore: getModelStatus(modelId)
alt status === LOADED
Note right of modelsStore: break loop
else status === LOADING
Note right of modelsStore: wait 500ms, continue
end
end
modelsStore->>modelsStore: updateModelModalities(modelId)
modelsStore->>PropsSvc: fetchForModel(modelId)
PropsSvc->>API: GET /props?model={modelId}
API-->>PropsSvc: props with modalities
modelsStore->>modelsStore: modelPropsCache.set(modelId, props)
modelsStore->>modelsStore: propsCacheVersion++
modelsStore->>modelsStore: modelLoadingStates.set(modelId, false)
deactivate modelsStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,API: ⬇️ UNLOAD MODEL (ROUTER mode)
%% ═══════════════════════════════════════════════════════════════════════════
modelsStore->>modelsStore: unloadModel(modelId)
activate modelsStore
modelsStore->>modelsStore: modelLoadingStates.set(modelId, true)
modelsStore->>ModelsSvc: unload(modelId)
ModelsSvc->>API: POST /models/unload {model: modelId}
modelsStore->>modelsStore: pollForModelStatus(modelId, UNLOADED)
loop poll until unloaded
modelsStore->>ModelsSvc: listRouter()
ModelsSvc->>API: GET /v1/models
end
modelsStore->>modelsStore: modelLoadingStates.set(modelId, false)
deactivate modelsStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,API: 📊 COMPUTED GETTERS
%% ═══════════════════════════════════════════════════════════════════════════
Note over modelsStore: Getters:<br/>- selectedModel: ModelOption | null<br/>- loadedModelIds: string[] (from routerModels)<br/>- loadingModelIds: string[] (from modelLoadingStates)<br/>- singleModelName: string | null (MODEL mode only)
Note over modelsStore: Modality helpers:<br/>- getModelModalities(modelId): {vision, audio}<br/>- modelSupportsVision(modelId): boolean<br/>- modelSupportsAudio(modelId): boolean
```
-76
View File
@@ -1,76 +0,0 @@
```mermaid
sequenceDiagram
participant UI as 🧩 +layout.svelte
participant serverStore as 🗄️ serverStore
participant PropsSvc as ⚙️ PropsService
participant API as 🌐 llama-server
Note over serverStore: State:<br/>props: ApiLlamaCppServerProps | null<br/>loading, error<br/>role: ServerRole | null (MODEL | ROUTER)<br/>fetchPromise (deduplication)
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,API: 🚀 INITIALIZATION
%% ═══════════════════════════════════════════════════════════════════════════
UI->>serverStore: fetch()
activate serverStore
alt fetchPromise exists (already fetching)
serverStore-->>UI: return fetchPromise
Note right of serverStore: Deduplicate concurrent calls
end
serverStore->>serverStore: loading = true
serverStore->>serverStore: fetchPromise = new Promise()
serverStore->>PropsSvc: fetch()
PropsSvc->>API: GET /props
API-->>PropsSvc: ApiLlamaCppServerProps
Note right of API: {role, model_path, model_alias,<br/>modalities, default_generation_settings, ...}
PropsSvc-->>serverStore: props
serverStore->>serverStore: props = $state(data)
serverStore->>serverStore: detectRole(props)
Note right of serverStore: role = props.role === "router"<br/> ? ServerRole.ROUTER<br/> : ServerRole.MODEL
serverStore->>serverStore: loading = false
serverStore->>serverStore: fetchPromise = null
deactivate serverStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,API: 📊 COMPUTED GETTERS
%% ═══════════════════════════════════════════════════════════════════════════
Note over serverStore: Getters from props:
rect rgb(240, 255, 240)
Note over serverStore: defaultParams<br/>→ props.default_generation_settings.params<br/>(temperature, top_p, top_k, etc.)
end
rect rgb(240, 255, 240)
Note over serverStore: contextSize<br/>→ props.default_generation_settings.n_ctx
end
rect rgb(255, 240, 240)
Note over serverStore: isRouterMode<br/>→ role === ServerRole.ROUTER
end
rect rgb(255, 240, 240)
Note over serverStore: isModelMode<br/>→ role === ServerRole.MODEL
end
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,API: 🔗 RELATIONSHIPS
%% ═══════════════════════════════════════════════════════════════════════════
Note over serverStore: Used by:
Note right of serverStore: - modelsStore: role detection, MODEL mode modalities<br/>- settingsStore: syncWithServerDefaults (defaultParams)<br/>- chatStore: contextSize for processing state<br/>- UI components: isRouterMode for conditional rendering
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,API: ❌ ERROR HANDLING
%% ═══════════════════════════════════════════════════════════════════════════
Note over serverStore: getErrorMessage(): string | null<br/>Returns formatted error for UI display
Note over serverStore: clear(): void<br/>Resets all state (props, error, loading, role)
```
-156
View File
@@ -1,156 +0,0 @@
```mermaid
sequenceDiagram
participant UI as 🧩 ChatSettings
participant settingsStore as 🗄️ settingsStore
participant serverStore as 🗄️ serverStore
participant ParamSvc as ⚙️ ParameterSyncService
participant LS as 💾 LocalStorage
Note over settingsStore: State:<br/>config: SettingsConfigType<br/>theme: string ("auto" | "light" | "dark")<br/>isInitialized: boolean<br/>userOverrides: Set&lt;string&gt;
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,LS: 🚀 INITIALIZATION
%% ═══════════════════════════════════════════════════════════════════════════
Note over settingsStore: Auto-initialized in constructor (browser only)
settingsStore->>settingsStore: initialize()
activate settingsStore
settingsStore->>settingsStore: loadConfig()
settingsStore->>LS: get("llama-config")
LS-->>settingsStore: StoredConfig | null
alt config exists
settingsStore->>settingsStore: Merge with SETTING_CONFIG_DEFAULT
Note right of settingsStore: Fill missing keys with defaults
else no config
settingsStore->>settingsStore: config = SETTING_CONFIG_DEFAULT
end
settingsStore->>LS: get("llama-userOverrides")
LS-->>settingsStore: string[] | null
settingsStore->>settingsStore: userOverrides = new Set(data)
settingsStore->>settingsStore: loadTheme()
settingsStore->>LS: get("llama-theme")
LS-->>settingsStore: theme | "auto"
settingsStore->>settingsStore: isInitialized = true
deactivate settingsStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,LS: 🔄 SYNC WITH SERVER DEFAULTS
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI: Triggered from +layout.svelte when serverStore.props loaded
UI->>settingsStore: syncWithServerDefaults()
activate settingsStore
settingsStore->>serverStore: defaultParams
serverStore-->>settingsStore: {temperature, top_p, top_k, ...}
loop each SYNCABLE_PARAMETER
alt key NOT in userOverrides
settingsStore->>settingsStore: config[key] = serverDefault[key]
Note right of settingsStore: Non-overridden params adopt server default
else key in userOverrides
Note right of settingsStore: Keep user value, skip server default
end
end
alt serverStore.props has uiSettings
settingsStore->>settingsStore: Apply uiSettings from server
Note right of settingsStore: Server-provided UI settings<br/>(e.g. showRawOutputSwitch)
end
settingsStore->>settingsStore: saveConfig()
deactivate settingsStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,LS: ⚙️ UPDATE CONFIG
%% ═══════════════════════════════════════════════════════════════════════════
UI->>settingsStore: updateConfig(key, value)
activate settingsStore
settingsStore->>settingsStore: config[key] = value
alt value matches server default for key
settingsStore->>settingsStore: userOverrides.delete(key)
Note right of settingsStore: Matches server default, remove override
else value differs from server default
settingsStore->>settingsStore: userOverrides.add(key)
Note right of settingsStore: Mark as user-modified (won't be overwritten)
end
settingsStore->>settingsStore: saveConfig()
settingsStore->>LS: set(CONFIG_LOCALSTORAGE_KEY, config)
settingsStore->>LS: set(USER_OVERRIDES_LOCALSTORAGE_KEY, [...userOverrides])
deactivate settingsStore
UI->>settingsStore: updateMultipleConfig({key1: val1, key2: val2})
activate settingsStore
Note right of settingsStore: Batch update, single save
settingsStore->>settingsStore: For each key: config[key] = value
settingsStore->>settingsStore: For each key: userOverrides.add(key)
settingsStore->>settingsStore: saveConfig()
deactivate settingsStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,LS: 🔄 RESET
%% ═══════════════════════════════════════════════════════════════════════════
UI->>settingsStore: resetConfig()
activate settingsStore
settingsStore->>settingsStore: config = {...SETTING_CONFIG_DEFAULT}
settingsStore->>settingsStore: userOverrides.clear()
Note right of settingsStore: All params reset to defaults<br/>Next syncWithServerDefaults will adopt server values
settingsStore->>settingsStore: saveConfig()
deactivate settingsStore
UI->>settingsStore: resetParameterToServerDefault(key)
activate settingsStore
settingsStore->>settingsStore: userOverrides.delete(key)
settingsStore->>serverStore: defaultParams[key]
settingsStore->>settingsStore: config[key] = serverDefault
settingsStore->>settingsStore: saveConfig()
deactivate settingsStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,LS: 🎨 THEME
%% ═══════════════════════════════════════════════════════════════════════════
UI->>settingsStore: updateTheme(newTheme)
activate settingsStore
settingsStore->>settingsStore: theme = newTheme
settingsStore->>settingsStore: saveTheme()
settingsStore->>LS: set("llama-theme", theme)
deactivate settingsStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,LS: 📊 PARAMETER INFO
%% ═══════════════════════════════════════════════════════════════════════════
UI->>settingsStore: getParameterInfo(key)
settingsStore->>ParamSvc: getParameterInfo(key, config, serverDefaults, userOverrides)
ParamSvc-->>settingsStore: ParameterInfo
Note right of ParamSvc: {<br/> currentValue,<br/> serverDefault,<br/> isUserOverride: boolean,<br/> canSync: boolean,<br/> isDifferentFromServer: boolean<br/>}
UI->>settingsStore: getParameterDiff()
settingsStore->>ParamSvc: createParameterDiff(config, serverDefaults, userOverrides)
ParamSvc-->>settingsStore: ParameterDiff[]
Note right of ParamSvc: Array of parameters where user != server
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,LS: 📋 CONFIG CATEGORIES
%% ═══════════════════════════════════════════════════════════════════════════
Note over settingsStore: Syncable with server (from /props):
rect rgb(240, 255, 240)
Note over settingsStore: temperature, top_p, top_k, min_p<br/>repeat_penalty, presence_penalty, frequency_penalty<br/>dynatemp_range, dynatemp_exponent<br/>typ_p, xtc_probability, xtc_threshold<br/>dry_multiplier, dry_base, dry_allowed_length, dry_penalty_last_n
end
Note over settingsStore: UI-only (not synced):
rect rgb(255, 240, 240)
Note over settingsStore: systemMessage, custom (JSON)<br/>showStatistics, enableContinueGeneration<br/>autoMicOnEmpty, disableAutoScroll<br/>apiKey, pdfAsImage, disableReasoningParsing, showRawOutputSwitch
end
```
+83 -1
View File
@@ -12,6 +12,49 @@ import { fileURLToPath } from 'node:url';
import ts from 'typescript-eslint';
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
// Require a blank line between consecutive class accessors (get/set). The core
// `padding-line-between-statements` rule only handles statements, not class
// members, so this is enforced with a small custom rule.
const blankLineBetweenAccessors = {
create(context) {
return {
MethodDefinition(node) {
if (node.kind !== 'get' && node.kind !== 'set') return;
const body = node.parent;
if (!body || body.type !== 'ClassBody') return;
const index = body.body.indexOf(node);
if (index <= 0) return;
const prev = body.body[index - 1];
if (prev.type !== 'MethodDefinition' || (prev.kind !== 'get' && prev.kind !== 'set'))
return;
if (node.loc.start.line - prev.loc.end.line <= 1) {
context.report({
fix(fixer) {
// Insert after the previous accessor's closing brace so the blank
// line keeps the current accessor's indentation.
return fixer.insertTextAfter(prev, '\n');
},
message: 'Expected a blank line between class accessors (get/set).',
node
});
}
}
};
},
meta: {
docs: { description: 'Require a blank line between consecutive class accessors (get/set).' },
fixable: 'whitespace',
schema: [],
type: 'layout'
}
};
export default ts.config(
includeIgnoreFile(gitignorePath),
@@ -22,7 +65,11 @@ export default ts.config(
...svelte.configs.prettier,
{
languageOptions: { globals: { ...globals.browser, ...globals.node } },
plugins: { perfectionist, 'simple-import-sort': simpleImportSort },
plugins: {
local: { rules: { 'blank-line-between-accessors': blankLineBetweenAccessors } },
perfectionist,
'simple-import-sort': simpleImportSort
},
rules: {
// Snippet bodies often ignore one or more of the parent's params
// (e.g. `{#snippet children(_meta, ctx)}` when only ctx is read).
@@ -30,8 +77,11 @@ export default ts.config(
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' }
],
// Enforce empty line at end of file
'eol-last': 'error',
// Enforce a blank line between consecutive get/set accessors
'local/blank-line-between-accessors': 'error',
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off',
@@ -61,6 +111,38 @@ export default ts.config(
{ blankLine: 'always', next: ['return', 'throw', 'break', 'continue'], prev: '*' }
],
// Class member order: public fields -> private fields -> constructor -> getters
// -> setters -> public methods -> private methods, alphabetical within each.
// Svelte $derived fields must stay in dependency order (forward references are
// rejected), so the two stores that rely on that are exempted below.
'perfectionist/sort-classes': [
'error',
{
customGroups: [
{ groupName: 'public-field', modifiers: ['public'], selector: 'property' },
{ groupName: 'private-field', modifiers: ['private'], selector: 'property' },
{ groupName: 'get-method', selector: 'get-method' },
{ groupName: 'set-method', selector: 'set-method' },
{ groupName: 'public-method', modifiers: ['public'], selector: 'method' },
{ groupName: 'private-method', modifiers: ['private'], selector: 'method' }
],
groups: [
'public-field',
'private-field',
'constructor',
'get-method',
'set-method',
'public-method',
'private-method',
'unknown'
],
type: 'natural',
// Keep members in dependency order (Svelte rejects forward references in
// $derived fields), while still sorting the rest alphabetically.
useExperimentalDependencyDetection: true
}
],
// Alphabetical order for enum members
'perfectionist/sort-enums': ['error', { type: 'natural' }],
@@ -139,7 +139,7 @@
let fileSize = $derived(currentItem?.size ? formatFileSize(currentItem.size) : '');
let hasVisionModality = $derived(
currentItem && activeModelId ? modelsStore.modelSupportsVision(activeModelId) : false
currentItem && activeModelId ? modelsStore.props.modelSupportsVision(activeModelId) : false
);
let audioSrc = $derived(
@@ -28,7 +28,6 @@
import {
chatStore,
conversationsStore,
mcpResourceStore,
mcpStore,
modelsStore,
serverStore,
@@ -140,7 +139,9 @@
// float above the box.
let mentionAnchor: HTMLDivElement | null = $state(null);
let cwd = $derived(conversationsStore.activeConversation?.cwd ?? conversationsStore.pendingCwd);
let cwd = $derived(
conversationsStore.activeConversation?.cwd ?? conversationsStore.preferences.pendingCwd
);
const pickers = useChatFormPickers({
focusInput: refocusInput,
@@ -151,7 +152,8 @@
getShowModelSelector: () => showModelSelector,
getValue: () => value,
hasCwdTools: () => toolsStore.hasEnabledCwdTools,
hasPrompts: () => mcpStore.hasPromptsCapability(conversationsStore.getAllMcpServerOverrides()),
hasPrompts: () =>
mcpStore.hasPromptsCapability(conversationsStore.preferences.getAllMcpServerOverrides()),
openModelSelector: () => chatFormActionsRef?.openModelSelector(),
setCaretOffset: (offset) => inputRef?.setCaretOffset(offset),
setValue: (v) => {
@@ -170,7 +172,7 @@
onValueChange?.('');
}
await conversationsStore.setCwd(newDir);
await conversationsStore.preferences.setCwd(newDir);
if (conversationsStore.activeConversation) {
await chatStore.recordCwdChange(newDir?.trim() || null);
@@ -595,7 +597,7 @@
{useRichInput}
/>
{#if mcpResourceStore.hasAttachments}
{#if mcpStore.resources.hasAttachments}
<ChatFormMcpResourcesList
class="mb-3"
onResourceClick={(uri) => {
@@ -38,11 +38,11 @@
}
function isServerEnabledForChat(serverId: string): boolean {
return conversationsStore.isMcpServerEnabledForChat(serverId);
return conversationsStore.preferences.isMcpServerEnabledForChat(serverId);
}
async function toggleServerForChat(serverId: string) {
await conversationsStore.toggleMcpServerForChat(serverId);
await conversationsStore.preferences.toggleMcpServerForChat(serverId);
}
function handleMcpSubMenuOpen(open: boolean) {
@@ -218,12 +218,15 @@
{@const hasError = healthState.status === HealthCheckStatus.ERROR}
{@const displayName = mcpStore.getServerLabel(server)}
{@const faviconUrl = mcpStore.getServerFavicon(server.id)}
{@const isEnabled = conversationsStore.isMcpServerEnabledForChat(server.id)}
{@const isEnabled = conversationsStore.preferences.isMcpServerEnabledForChat(
server.id
)}
<button
type="button"
class={sheetItemRowClass}
onclick={() => !hasError && conversationsStore.toggleMcpServerForChat(server.id)}
onclick={() =>
!hasError && conversationsStore.preferences.toggleMcpServerForChat(server.id)}
disabled={hasError}
>
<div class="flex min-w-0 flex-1 items-center gap-2">
@@ -250,7 +253,8 @@
{:else}
<Switch
checked={isEnabled}
onCheckedChange={() => conversationsStore.toggleMcpServerForChat(server.id)}
onCheckedChange={() =>
conversationsStore.preferences.toggleMcpServerForChat(server.id)}
/>
{/if}
</button>
@@ -81,10 +81,10 @@
$effect(() => {
if (activeModelId) {
const cached = modelsStore.getModelProps(activeModelId);
const cached = modelsStore.props.getModelProps(activeModelId);
if (!cached) {
modelsStore.fetchModelProps(activeModelId).then(() => {
modelsStore.props.fetchModelProps(activeModelId).then(() => {
modelPropsVersion++;
});
}
@@ -94,19 +94,21 @@
$effect(() => {
void modelPropsVersion;
hasAudioModality = activeModelId ? modelsStore.modelSupportsAudio(activeModelId) : false;
hasAudioModality = activeModelId ? modelsStore.props.modelSupportsAudio(activeModelId) : false;
});
$effect(() => {
void modelPropsVersion;
hasVideoModality = activeModelId ? modelsStore.modelSupportsVideo(activeModelId) : false;
hasVideoModality = activeModelId ? modelsStore.props.modelSupportsVideo(activeModelId) : false;
});
$effect(() => {
void modelPropsVersion;
hasVisionModality = activeModelId ? modelsStore.modelSupportsVision(activeModelId) : false;
hasVisionModality = activeModelId
? modelsStore.props.modelSupportsVision(activeModelId)
: false;
});
$effect(() => {
@@ -58,13 +58,13 @@
let currentConfig = $derived(settingsStore.config);
let hasMcpPromptsSupport = $derived.by(() => {
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
const perChatOverrides = conversationsStore.preferences.getAllMcpServerOverrides();
return mcpStore.hasPromptsCapability(perChatOverrides);
});
let hasMcpResourcesSupport = $derived.by(() => {
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
const perChatOverrides = conversationsStore.preferences.getAllMcpServerOverrides();
return mcpStore.hasResourcesCapability(perChatOverrides);
});
@@ -121,7 +121,7 @@
if (!chatStore.isLoading && !chatStore.isStreaming()) return false;
const processingState = chatStore.activeProcessingState;
const processingState = chatStore.processing.activeState;
if (!processingState) return false;
@@ -16,7 +16,7 @@
$effect(() => {
const conv = conversationsStore.activeConversation;
untrack(() => chatStore.setActiveProcessingConversation(conv?.id ?? null));
untrack(() => chatStore.processing.setActiveConversation(conv?.id ?? null));
});
$effect(() => {
@@ -28,12 +28,12 @@
if (chatStore.isLoading || chatStore.isStreaming()) return;
if (messages.length === 0) {
untrack(() => chatStore.clearProcessingState(conv.id));
untrack(() => chatStore.processing.setState(conv.id, null));
return;
}
untrack(() => chatStore.restoreProcessingStateFromMessages(messages, conv.id));
untrack(() => chatStore.processing.restoreFromMessages(messages, conv.id));
});
$effect(() => {
@@ -3,7 +3,7 @@
ChatAttachmentsListItemMcpResource,
HorizontalScrollCarousel
} from '$lib/components/app';
import { mcpResourceStore, mcpStore } from '$lib/stores';
import { mcpStore } from '$lib/stores';
interface Props {
class?: string;
@@ -12,8 +12,8 @@
let { class: className, onResourceClick }: Props = $props();
const attachments = $derived(mcpResourceStore.attachments);
const hasAttachments = $derived(mcpResourceStore.hasAttachments);
const attachments = $derived(mcpStore.resources.attachments);
const hasAttachments = $derived(mcpStore.resources.hasAttachments);
function handleRemove(attachmentId: string) {
mcpStore.removeResourceAttachment(attachmentId);
@@ -87,7 +87,7 @@
isLoading = true;
try {
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
const perChatOverrides = conversationsStore.preferences.getAllMcpServerOverrides();
const initialized = await mcpStore.ensureInitialized(perChatOverrides);
if (!initialized) {
@@ -59,7 +59,7 @@
message.model ?? chatStore.getResumeModel(message.convId) ?? modelsStore.selectedModelName
);
let modelLoadProgress = $derived(
isRouter && loadTargetModel ? modelsStore.getLoadProgress(loadTargetModel) : null
isRouter && loadTargetModel ? modelsStore.status.getLoadProgress(loadTargetModel) : null
);
let modelLoadingText = $derived(modelLoadProgressText(modelLoadProgress));
@@ -31,7 +31,7 @@
pendingModel = modelId;
try {
await modelsStore.loadModel(modelId);
await modelsStore.status.load(modelId);
} finally {
pendingModel = null;
}
@@ -43,14 +43,14 @@
);
const hasReasoningError = $derived(
isLastAssistantMessage ? !!agenticStore.lastError(message.convId) : false
isLastAssistantMessage ? !!agenticStore.getLastError(message.convId) : false
);
let permissionDismissed = $state(false);
const pendingPermission = $derived(
isStreaming && isLastAssistantMessage
? agenticStore.pendingPermissionRequest(message.convId)
? agenticStore.getPendingPermissionRequest(message.convId)
: null
);
@@ -74,7 +74,7 @@
const pendingContinue = $derived(
isStreaming && isLastAssistantMessage
? agenticStore.pendingContinueRequest(message.convId)
? agenticStore.getPendingContinueRequest(message.convId)
: false
);
@@ -97,7 +97,7 @@
const sections = $derived(deriveAgenticSections(message, toolMessages, [], isStreaming));
const currentlyExecutingToolCallId = $derived(
isStreaming ? agenticStore.executingToolCallId(message.convId) : null
isStreaming ? agenticStore.getExecutingToolCallId(message.convId) : null
);
type TurnGroup = {
@@ -238,30 +238,30 @@
/>
{/each}
{#if conversationsStore.activeConversation && agenticStore.pendingSteeringMessageContent(conversationsStore.activeConversation!.id)}
{#if conversationsStore.activeConversation && agenticStore.getPendingSteeringMessageContent(conversationsStore.activeConversation!.id)}
{@const convId = conversationsStore.activeConversation!.id}
{@const pendingContent = agenticStore.pendingSteeringMessageContent(convId)}
{@const pendingContent = agenticStore.getPendingSteeringMessageContent(convId)}
{#if pendingContent}
<ChatMessageUserPending
class="mx-auto mt-12 w-full max-w-[48rem]"
content={pendingContent}
extras={agenticStore.pendingSteeringMessageExtras(convId)}
extras={agenticStore.getPendingSteeringMessageExtras(convId)}
onSendImmediately={() => chatStore.abortCurrentFlow(convId)}
onEdit={(newContent, extras) =>
agenticStore.injectSteeringMessage(convId, newContent, extras)}
onDelete={() => agenticStore.clearSteeringMessage(convId)}
/>
{/if}
{:else if conversationsStore.activeConversation && chatStore.pendingMessageContent(conversationsStore.activeConversation!.id)}
{:else if conversationsStore.activeConversation && chatStore.getPendingMessageContent(conversationsStore.activeConversation!.id)}
{@const convId = conversationsStore.activeConversation!.id}
{@const pendingContent = chatStore.pendingMessageContent(convId)}
{@const pendingContent = chatStore.getPendingMessageContent(convId)}
{#if pendingContent}
<ChatMessageUserPending
class="mx-auto mt-12 w-full max-w-[48rem]"
content={pendingContent}
extras={chatStore.pendingMessageExtras(convId)}
extras={chatStore.getPendingMessageExtras(convId)}
onSendImmediately={() => chatStore.abortCurrentFlow(convId)}
onEdit={(newContent, extras) => chatStore.injectPendingMessage(convId, newContent, extras)}
onDelete={() => chatStore.clearPendingMessage(convId)}
@@ -8,7 +8,7 @@
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
import { conversationsStore, mcpResourceStore, mcpStore } from '$lib/stores';
import { conversationsStore, mcpStore } from '$lib/stores';
import type { MCPResourceContent, MCPResourceInfo, MCPResourceTemplateInfo } from '$lib/types';
import { getResourceDisplayName } from '$lib/utils';
import { SvelteSet } from 'svelte/reactivity';
@@ -33,7 +33,7 @@
let templatePreviewLoading = $state(false);
let templatePreviewError = $state<string | null>(null);
const totalCount = $derived(mcpResourceStore.totalResourceCount);
const totalCount = $derived(mcpStore.resources.totalResourceCount);
$effect(() => {
if (open) {
@@ -48,7 +48,7 @@
});
async function loadResources() {
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
const perChatOverrides = conversationsStore.preferences.getAllMcpServerOverrides();
const initialized = await mcpStore.ensureInitialized(perChatOverrides);
if (initialized) {
@@ -126,16 +126,16 @@
isAttaching = true;
try {
const knownResource = mcpResourceStore.findResourceByUri(templatePreviewUri);
const knownResource = mcpStore.resources.findResourceByUri(templatePreviewUri);
if (knownResource) {
if (!mcpResourceStore.isAttached(knownResource.uri)) {
if (!mcpStore.resources.isAttached(knownResource.uri)) {
await mcpStore.attachResource(knownResource.uri);
}
toast.success(`Resource attached: ${knownResource.title || knownResource.name}`);
} else {
if (mcpResourceStore.isAttached(templatePreviewUri)) {
if (mcpStore.resources.isAttached(templatePreviewUri)) {
toast.info('Resource already attached');
handleOpenChange(false);
@@ -147,9 +147,9 @@
serverName: selectedTemplate.serverName,
uri: templatePreviewUri
};
const attachment = mcpResourceStore.addAttachment(resourceInfo);
const attachment = mcpStore.resources.addAttachment(resourceInfo);
mcpResourceStore.updateAttachmentContent(attachment.id, templatePreviewContent);
mcpStore.resources.updateAttachmentContent(attachment.id, templatePreviewContent);
toast.success(`Resource attached: ${resourceInfo.name}`);
}
@@ -199,7 +199,7 @@
function getAllResourcesFlatInTreeOrder(): MCPResourceInfo[] {
const allResources: MCPResourceInfo[] = [];
const resourcesMap = mcpResourceStore.serverResources;
const resourcesMap = mcpStore.resources.serverResources;
for (const [serverName, serverRes] of resourcesMap.entries()) {
for (const resource of serverRes.resources) {
@@ -234,7 +234,7 @@
useProxy: newServerUseProxy
});
conversationsStore.setMcpServerOverride(newServerId, true);
conversationsStore.preferences.setMcpServerOverride(newServerId, true);
handleOpenChange(false);
}
@@ -42,7 +42,7 @@
let modalities = $derived.by(() => {
if (!firstModel?.id) return [];
return modelsStore.getModelModalitiesArray(firstModel.id);
return modelsStore.props.getModelModalitiesArray(firstModel.id);
});
// Ensure models are fetched when dialog opens
@@ -56,7 +56,7 @@
$effect(() => {
if (open && isRouter && modelId) {
isLoadingRouterProps = true;
modelsStore
modelsStore.props
.fetchModelProps(modelId)
.then((props) => {
routerModelProps = props;
@@ -14,7 +14,9 @@
let mcpServers = $derived(mcpStore.getServers().filter((s) => s.enabled));
let enabledMcpServersForChat = $derived(
mcpServers.filter((s) => conversationsStore.isMcpServerEnabledForChat(s.id) && s.url.trim())
mcpServers.filter(
(s) => conversationsStore.preferences.isMcpServerEnabledForChat(s.id) && s.url.trim()
)
);
let healthyEnabledMcpServers = $derived(
enabledMcpServersForChat.filter((s) => {
@@ -2,7 +2,7 @@
import McpResourcesBrowserEmptyState from './McpResourcesBrowserEmptyState.svelte';
import McpResourcesBrowserHeader from './McpResourcesBrowserHeader.svelte';
import McpResourcesBrowserServerItem from './McpResourcesBrowserServerItem.svelte';
import { mcpResourceStore, mcpStore } from '$lib/stores';
import { mcpStore } from '$lib/stores';
import type { MCPResourceInfo, MCPResourceTemplateInfo, MCPServerResources } from '$lib/types';
import { parseResourcePath } from '$lib/utils';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
@@ -31,8 +31,8 @@
let expandedFolders = new SvelteSet<string>();
let searchQuery = $state('');
const resources = $derived(mcpResourceStore.serverResources);
const isLoading = $derived(mcpResourceStore.isLoading);
const resources = $derived(mcpStore.resources.serverResources);
const isLoading = $derived(mcpStore.resources.isLoading);
const filteredResources = $derived.by(() => {
if (!searchQuery.trim()) {
@@ -116,7 +116,7 @@
if (status === ServerModelStatus.LOADING) return;
await modelsStore.unloadModel(modelId);
await modelsStore.status.unload(modelId);
}
export function open() {
@@ -174,9 +174,9 @@
{@const triggerLoading =
!!triggerModel &&
(triggerStatus === ServerModelStatus.LOADING ||
modelsStore.isModelOperationInProgress(triggerModel))}
modelsStore.status.isOperationInProgress(triggerModel))}
{@const triggerLoadPercent = triggerLoading
? Math.round(modelLoadFraction(modelsStore.getLoadProgress(triggerModel)) * 100)
? Math.round(modelLoadFraction(modelsStore.status.getLoadProgress(triggerModel)) * 100)
: 0}
{#if ms.isRouter}
@@ -47,7 +47,7 @@
return (model?.status?.value as ServerModelStatus) ?? null;
});
let isOperationInProgress = $derived(modelsStore.isModelOperationInProgress(option.model));
let isOperationInProgress = $derived(modelsStore.status.isOperationInProgress(option.model));
let isFailed = $derived(serverStatus === ServerModelStatus.FAILED);
let isSleeping = $derived(serverStatus === ServerModelStatus.SLEEPING);
let isLoaded = $derived(
@@ -55,7 +55,7 @@
);
let isLoading = $derived(serverStatus === ServerModelStatus.LOADING || isOperationInProgress);
let loadProgress = $derived(isLoading ? modelsStore.getLoadProgress(option.model) : null);
let loadProgress = $derived(isLoading ? modelsStore.status.getLoadProgress(option.model) : null);
let loadPercent = $derived(Math.round(modelLoadFraction(loadProgress) * 100));
let loadTitle = $derived(modelLoadProgressText(loadProgress));
</script>
@@ -138,7 +138,7 @@
icon={RotateCw}
tooltip="Retry loading model"
class="h-3 w-3 text-red-500 hover:text-foreground"
onclick={() => modelsStore.loadModel(option.model)}
onclick={() => modelsStore.status.load(option.model)}
stopPropagationOnClick
/>
</div>
@@ -157,7 +157,7 @@
class="h-3 w-3 text-red-500 hover:text-red-600 [@media(pointer:coarse)]:text-amber-500 [@media(pointer:coarse)]:hover:text-amber-600"
onclick={(e) => {
e?.stopPropagation();
modelsStore.unloadModel(option.model);
modelsStore.status.unload(option.model);
}}
/>
</div>
@@ -174,7 +174,7 @@
icon={PowerOff}
tooltip="Unload model"
class="h-3 w-3 text-red-500 hover:text-red-600 [@media(pointer:coarse)]:text-green-500 [@media(pointer:coarse)]:hover:text-green-600"
onclick={() => modelsStore.unloadModel(option.model)}
onclick={() => modelsStore.status.unload(option.model)}
stopPropagationOnClick
/>
</div>
@@ -191,7 +191,7 @@
icon={Power}
tooltip="Load model"
class="h-3 w-3 [@media(pointer:coarse)]:text-muted-foreground"
onclick={() => modelsStore.loadModel(option.model)}
onclick={() => modelsStore.status.load(option.model)}
stopPropagationOnClick
/>
</div>
@@ -72,9 +72,9 @@
{@const triggerLoading =
!!triggerModel &&
(triggerStatus === ServerModelStatus.LOADING ||
modelsStore.isModelOperationInProgress(triggerModel))}
modelsStore.status.isOperationInProgress(triggerModel))}
{@const triggerLoadPercent = triggerLoading
? Math.round(modelLoadFraction(modelsStore.getLoadProgress(triggerModel)) * 100)
? Math.round(modelLoadFraction(modelsStore.status.getLoadProgress(triggerModel)) * 100)
: 0}
{#if ms.isRouter}
@@ -52,7 +52,7 @@
void modelsStore
.fetch()
.then(() => modelsStore.fetchRouterModels())
.then(() => modelsStore.fetchModalitiesForLoadedModels())
.then(() => modelsStore.props.fetchModalitiesForLoadedModels())
.then(() => modelsStore.ensureFirstModelSelected());
}
});
@@ -23,13 +23,13 @@
let { fields, localConfig, onConfigChange, onThemeChange }: Props = $props();
let currentModelParams = $derived.by(() => {
void modelsStore.propsCacheVersion;
void modelsStore.props.cacheVersion;
if (serverStore.isRouterMode) {
const currentModelName = modelsStore.selectedModelName;
if (currentModelName) {
const currentModelProps = modelsStore.getModelProps(currentModelName);
const currentModelProps = modelsStore.props.getModelProps(currentModelName);
return (currentModelProps?.default_generation_settings?.params ?? {}) as Record<
string,
@@ -121,11 +121,13 @@
{:else}
<McpServerCard
{server}
enabled={conversationsStore.isMcpServerEnabledForChat(server.id)}
enabled={conversationsStore.preferences.isMcpServerEnabledForChat(server.id)}
onToggle={async () => {
const wasEnabled = conversationsStore.isMcpServerEnabledForChat(server.id);
const wasEnabled = conversationsStore.preferences.isMcpServerEnabledForChat(
server.id
);
await conversationsStore.toggleMcpServerForChat(server.id);
await conversationsStore.preferences.toggleMcpServerForChat(server.id);
if (!wasEnabled) {
// Promote the connection so tools/prompts/resources become
@@ -74,7 +74,7 @@ export const ATTACHMENT_PROMPT_ITEMS: AttachmentMenuItem[] = [
enabledWhen: AttachmentItemEnabledWhen.ALWAYS,
icon: Zap,
id: AttachmentMenuItemId.MCP_PROMPT,
label: 'MCP Prompt',
label: 'MCP Prompts',
visibleWhen: AttachmentItemVisibleWhen.HAS_MCP_PROMPTS_SUPPORT
}
];
@@ -32,13 +32,3 @@ export const MCP_RESOURCE_CACHE = {
/** TTL for MCP resource cache entries in milliseconds (5 minutes) */
TTL_MS: 5 * 60 * 1000
} as const;
/**
* Limits for pruning inactive conversation states held in memory.
*/
export const INACTIVE_CONVERSATION = {
/** Maximum age (in ms) for inactive conversation states before cleanup (30 minutes) */
MAX_AGE_MS: 30 * 60 * 1000,
/** Maximum number of inactive conversation states to keep in memory */
MAX_STATES: 10
} as const;
@@ -1,3 +1,5 @@
import { UrlProtocol } from '$lib/enums';
const STD = ['com', 'net', 'org', 'gov', 'edu'] as const;
const STD_MIL = [...STD, 'mil'] as const;
const ccTLD_PREFIXES: Record<string, readonly string[]> = {
@@ -184,3 +186,7 @@ export const WILDCARD_PUBLIC_SUFFIXES = buildSuffixSet(WILDCARD_BASES);
// Matches one or more trailing "/" characters at the end of a URL/path.
export const TRAILING_SLASHES_REGEX = /\/+$/;
// Protocols that apiFetch treats as absolute and passes through untouched.
// Add a protocol here when a caller needs to fetch an absolute URL with it.
export const API_ABSOLUTE_URL_PROTOCOLS = [UrlProtocol.HTTP, UrlProtocol.HTTPS] as const;
@@ -14,18 +14,14 @@ export interface AutoScrollOptions {
*/
export class AutoScrollController {
private _autoScrollEnabled = $state(true);
private _userScrolledUp = $state(false);
private _lastScrollTop = $state(0);
private _scrollInterval: ReturnType<typeof setInterval> | undefined;
private _container: HTMLElement | undefined;
private _disabled: boolean;
private _lastScrollTop = $state(0);
private _mutationObserver: MutationObserver | null = null;
private _rafPending = false;
private _observerEnabled = false;
constructor(options: AutoScrollOptions = {}) {
this._disabled = options.disabled ?? false;
}
private _rafPending = false;
private _scrollInterval: ReturnType<typeof setInterval> | undefined;
private _userScrolledUp = $state(false);
get autoScrollEnabled(): boolean {
return this._autoScrollEnabled;
}
@@ -34,6 +30,71 @@ export class AutoScrollController {
return this._userScrolledUp;
}
constructor(options: AutoScrollOptions = {}) {
this._disabled = options.disabled ?? false;
}
/**
* Cleans up resources. Call this in onDestroy or when the component unmounts.
*/
destroy(): void {
this.stopInterval();
this._doStopObserving();
}
/**
* Enables auto-scroll (e.g., when user sends a message).
*/
enable(): void {
if (this._disabled) return;
this._userScrolledUp = false;
this._autoScrollEnabled = true;
}
/**
* Handles scroll events to detect user scroll direction and toggle auto-scroll.
*/
handleScroll(): void {
if (this._disabled || !this._container) return;
const { clientHeight, scrollHeight, scrollTop } = this._container;
const distanceFromBottom = scrollHeight - clientHeight - scrollTop;
const isScrollingUp = scrollTop < this._lastScrollTop;
const isAtBottom = distanceFromBottom < AUTO_SCROLL_AT_BOTTOM_THRESHOLD;
if (isScrollingUp && !isAtBottom) {
this._userScrolledUp = true;
this._autoScrollEnabled = false;
} else if (isAtBottom && this._userScrolledUp) {
this._userScrolledUp = false;
this._autoScrollEnabled = true;
}
this._lastScrollTop = scrollTop;
}
/**
* Resets scroll state when switching conversations.
*/
resetScrollState(): void {
this._userScrolledUp = false;
this._autoScrollEnabled = !this._disabled;
if (this._container) {
this._lastScrollTop = this._container.scrollTop;
}
}
/**
* Scrolls the container to the bottom instantly.
*/
scrollToBottom(): void {
if (this._disabled || !this._container) return;
this._container.scrollTop = this._container.scrollHeight;
}
/**
* Binds the controller to a scrollable container element.
*/
@@ -63,59 +124,6 @@ export class AutoScrollController {
}
}
/**
* Handles scroll events to detect user scroll direction and toggle auto-scroll.
*/
handleScroll(): void {
if (this._disabled || !this._container) return;
const { clientHeight, scrollHeight, scrollTop } = this._container;
const distanceFromBottom = scrollHeight - clientHeight - scrollTop;
const isScrollingUp = scrollTop < this._lastScrollTop;
const isAtBottom = distanceFromBottom < AUTO_SCROLL_AT_BOTTOM_THRESHOLD;
if (isScrollingUp && !isAtBottom) {
this._userScrolledUp = true;
this._autoScrollEnabled = false;
} else if (isAtBottom && this._userScrolledUp) {
this._userScrolledUp = false;
this._autoScrollEnabled = true;
}
this._lastScrollTop = scrollTop;
}
/**
* Scrolls the container to the bottom instantly.
*/
scrollToBottom(): void {
if (this._disabled || !this._container) return;
this._container.scrollTop = this._container.scrollHeight;
}
/**
* Enables auto-scroll (e.g., when user sends a message).
*/
enable(): void {
if (this._disabled) return;
this._userScrolledUp = false;
this._autoScrollEnabled = true;
}
/**
* Resets scroll state when switching conversations.
*/
resetScrollState(): void {
this._userScrolledUp = false;
this._autoScrollEnabled = !this._disabled;
if (this._container) {
this._lastScrollTop = this._container.scrollTop;
}
}
/**
* Starts the auto-scroll interval for continuous scrolling during streaming.
*/
@@ -127,6 +135,18 @@ export class AutoScrollController {
}, AUTO_SCROLL_INTERVAL);
}
/**
* Starts a MutationObserver on the container that auto-scrolls to bottom
* on content changes. More responsive than interval-based polling.
*/
startObserving(): void {
this._observerEnabled = true;
if (this._container && !this._disabled && !this._mutationObserver) {
this._doStartObserving();
}
}
/**
* Stops the auto-scroll interval.
*/
@@ -137,6 +157,14 @@ export class AutoScrollController {
}
}
/**
* Stops the MutationObserver.
*/
stopObserving(): void {
this._observerEnabled = false;
this._doStopObserving();
}
/**
* Updates the auto-scroll interval based on streaming state.
* Call this in a $effect to automatically manage the interval.
@@ -157,34 +185,6 @@ export class AutoScrollController {
}
}
/**
* Cleans up resources. Call this in onDestroy or when the component unmounts.
*/
destroy(): void {
this.stopInterval();
this._doStopObserving();
}
/**
* Starts a MutationObserver on the container that auto-scrolls to bottom
* on content changes. More responsive than interval-based polling.
*/
startObserving(): void {
this._observerEnabled = true;
if (this._container && !this._disabled && !this._mutationObserver) {
this._doStartObserving();
}
}
/**
* Stops the MutationObserver.
*/
stopObserving(): void {
this._observerEnabled = false;
this._doStopObserving();
}
private _doStartObserving(): void {
if (!this._container || this._mutationObserver) return;
@@ -22,10 +22,10 @@ export function useChatScreenActiveModel() {
$effect(() => {
if (activeModelId) {
const cached = modelsStore.getModelProps(activeModelId);
const cached = modelsStore.props.getModelProps(activeModelId);
if (!cached) {
modelsStore.fetchModelProps(activeModelId).then(() => {
modelsStore.props.fetchModelProps(activeModelId).then(() => {
modelPropsVersion++;
});
}
@@ -36,7 +36,7 @@ export function useChatScreenActiveModel() {
if (activeModelId) {
void modelPropsVersion;
return modelsStore.modelSupportsAudio(activeModelId);
return modelsStore.props.modelSupportsAudio(activeModelId);
}
return false;
@@ -45,7 +45,7 @@ export function useChatScreenActiveModel() {
if (activeModelId) {
void modelPropsVersion;
return modelsStore.modelSupportsVideo(activeModelId);
return modelsStore.props.modelSupportsVideo(activeModelId);
}
return false;
@@ -54,7 +54,7 @@ export function useChatScreenActiveModel() {
if (activeModelId) {
void modelPropsVersion;
return modelsStore.modelSupportsVision(activeModelId);
return modelsStore.props.modelSupportsVision(activeModelId);
}
return false;
@@ -54,10 +54,10 @@ export function useContextGauge(): UseContextGaugeReturn {
const modelId = contextStatsStore.activeModelId;
if (modelId && contextStatsStore.isActiveModelLoaded) {
const cached = modelsStore.getModelProps(modelId);
const cached = modelsStore.props.getModelProps(modelId);
if (!cached) {
void modelsStore.fetchModelProps(modelId);
void modelsStore.props.fetchModelProps(modelId);
}
}
});
@@ -80,9 +80,9 @@ export function useContextGauge(): UseContextGaugeReturn {
if (!modelId || contextStatsStore.isActiveModelLoading) return;
try {
await modelsStore.loadModel(modelId);
await modelsStore.status.load(modelId);
} catch {
// toast already surfaced by modelsStore.loadModel
// toast already surfaced by modelsStore.status.load
}
}
@@ -47,7 +47,7 @@ export interface UseModelsSelectorReturn {
export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSelectorReturn {
const options = $derived(
modelsStore.models.filter((option) => {
const modelProps = modelsStore.getModelProps(option.model);
const modelProps = modelsStore.props.getModelProps(option.model);
return modelProps?.ui !== false;
})
@@ -103,7 +103,7 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
if (open) {
modelsStore.fetchRouterModels().then(() => {
modelsStore.fetchModalitiesForLoadedModels();
modelsStore.props.fetchModalitiesForLoadedModels();
});
}
@@ -143,8 +143,8 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
if (!onModelChange && isRouter && !modelsStore.isModelLoaded(option.model)) {
isLoadingModel = true;
modelsStore
.loadModel(option.model)
modelsStore.status
.load(option.model)
.catch((error) => console.error('Failed to load model:', error))
.finally(() => (isLoadingModel = false));
}
@@ -43,7 +43,7 @@ export function useProcessingState(): UseProcessingStateReturn {
}
// Read directly from the reactive state
return chatStore.activeProcessingState;
return chatStore.processing.activeState;
});
$effect(() => {
@@ -42,19 +42,20 @@ export function useReasoningMenu(): UseReasoningMenuReturn {
});
const modelSupportsThinking = $derived.by(() => {
void modelsStore.loadedModelIds;
void modelsStore.propsCacheVersion;
void modelsStore.props.cacheVersion;
if (serverStore.isRouterMode) {
const modelId = modelsStore.selectedModelName || conversationModel;
return (
modelsStore.checkModelSupportsThinking(modelId ?? '') || modelSupportsThinkingFromMessages
modelsStore.props.checkModelSupportsThinking(modelId ?? '') ||
modelSupportsThinkingFromMessages
);
}
return modelsStore.supportsThinking || modelSupportsThinkingFromMessages;
return modelsStore.props.supportsThinking || modelSupportsThinkingFromMessages;
});
const currentEffort = $derived(conversationsStore.getReasoningEffort());
const currentEffort = $derived(conversationsStore.preferences.getReasoningEffort());
const thinkingEnabled = $derived(
currentEffort !== ReasoningEffort.OFF && currentEffort !== ReasoningEffort.DEFAULT
);
@@ -76,7 +77,7 @@ export function useReasoningMenu(): UseReasoningMenuReturn {
return modelSupportsThinking;
},
select(level: ReasoningEffortLevel): void {
conversationsStore.setReasoningEffort(level.value as ReasoningEffort);
conversationsStore.preferences.setReasoningEffort(level.value as ReasoningEffort);
},
get thinkingEnabled() {
return thinkingEnabled;
@@ -35,7 +35,7 @@ export function useToolsPanel(): UseToolsPanelReturn {
(g) =>
g.source !== ToolSource.MCP ||
!g.serverId ||
conversationsStore.isMcpServerEnabledForChat(g.serverId)
conversationsStore.preferences.isMcpServerEnabledForChat(g.serverId)
)
);
const totalToolCount = $derived(activeGroups.reduce((n, g) => n + g.tools.length, 0));
@@ -73,7 +73,7 @@ export function useToolsPanel(): UseToolsPanelReturn {
return (
group.source === ToolSource.MCP &&
!!group.serverId &&
!conversationsStore.isMcpServerEnabledForChat(group.serverId)
!conversationsStore.preferences.isMcpServerEnabledForChat(group.serverId)
);
}
File diff suppressed because it is too large Load Diff
@@ -16,187 +16,6 @@ import {
import { strFromU8, strToU8, unzipSync, zipSync } from 'fflate';
export class ConversationTransferService {
/**
*
*
* JSONL Session Format
*
*
*/
/**
* Serializes a session (a conversation with its messages) as JSONL.
* The first line is the session header (a `SessionRecordType.SESSION` record
* carrying the conversation properties); each subsequent line is a single message.
* @param data - The exported conversation payload
* @returns The JSONL string (one record per line)
*/
static serializeSessionToJsonl(data: ExportedConversation): string {
const { conv, messages } = data;
const sessionLine = JSON.stringify({
harness: EXPORT_CONV.HARNESS,
type: SessionRecordType.SESSION,
...conv
});
const messageLines = messages.map((message: DatabaseMessage) => {
// `toolCalls` is stored as a JSON string; drop it when empty, otherwise parse it.
const { toolCalls, ...rest } = message;
const normalized = toolCalls ? { ...rest, toolCalls: JSON.parse(toolCalls) } : rest;
return JSON.stringify({ message: normalized, type: SessionRecordType.MESSAGE });
});
return [sessionLine, ...messageLines].join(NEWLINE);
}
/**
* Parses the JSONL session format produced by {@link serializeSessionToJsonl}.
* A `SessionRecordType.SESSION` line starts a new session; following
* `SessionRecordType.MESSAGE` lines are appended to it. Supports multiple
* sessions in a single file.
* @param text - The JSONL file contents
* @returns The parsed conversations with their messages
*/
static parseSessionsJsonl(text: string): ExportedConversation[] {
const sessions: ExportedConversation[] = [];
let current: ExportedConversation | null = null;
for (const line of text.split(NEWLINE)) {
const trimmed = line.trim();
if (!trimmed) continue;
const record = JSON.parse(trimmed);
if (record.type === SessionRecordType.SESSION) {
// Drop the discriminator and harness marker; the rest is the conversation.
const conv = { ...record };
delete conv.type;
delete conv.harness;
current = { conv: conv as DatabaseConversation, messages: [] };
sessions.push(current);
} else if (record.type === SessionRecordType.MESSAGE) {
if (!current) {
throw new Error('Invalid JSONL: message record before any session record');
}
const message = record.message as DatabaseMessage;
// `toolCalls` is parsed to an array on export; the DB stores it as a string.
if (message.toolCalls !== undefined && typeof message.toolCalls !== 'string') {
message.toolCalls = JSON.stringify(message.toolCalls);
}
current.messages.push(message);
}
// Ignore unknown record types for forward compatibility.
}
return sessions;
}
/**
* Reports whether the text is the JSONL session format, whose first non-empty
* line is a `SessionRecordType.SESSION` record. A legacy JSON export starts
* with an array or an object that has no such discriminator.
* @param text - The file contents
*/
private static isSessionsJsonl(text: string): boolean {
const trimmed = text.trimStart();
const lineEnd = trimmed.indexOf(NEWLINE);
const firstLine = lineEnd === -1 ? trimmed : trimmed.slice(0, lineEnd);
try {
return JSON.parse(firstLine).type === SessionRecordType.SESSION;
} catch {
// Not a standalone JSON record, so not the JSONL format.
return false;
}
}
/**
* Parses an import file into conversations, accepting the current JSONL and
* ZIP formats as well as the legacy JSON format. The format comes from the
* contents, so an import works whatever the file is named.
* @param file - The user-selected file
* @returns The parsed conversations with their messages
*/
static async parseImportFile(file: File): Promise<ExportedConversation[]> {
const bytes = new Uint8Array(await file.arrayBuffer());
if (ZIP_MAGIC.every((byte, index) => bytes[index] === byte)) {
const entries = unzipSync(bytes);
const sessions: ExportedConversation[] = [];
for (const [entryName, entryBytes] of Object.entries(entries)) {
if (!entryName.toLowerCase().endsWith(FileExtensionText.JSONL)) continue;
sessions.push(...ConversationTransferService.parseSessionsJsonl(strFromU8(entryBytes)));
}
return sessions;
}
const text = strFromU8(bytes);
if (ConversationTransferService.isSessionsJsonl(text)) {
return ConversationTransferService.parseSessionsJsonl(text);
}
// Legacy JSON format: an array of conversations or a single conversation object.
const parsed = JSON.parse(text);
if (Array.isArray(parsed)) {
return parsed;
}
if (parsed && typeof parsed === 'object' && 'conv' in parsed && 'messages' in parsed) {
return [parsed];
}
throw new Error(
'Invalid file format: expected array of conversations or single conversation object'
);
}
/**
*
*
* Downloads
*
*
*/
/**
* Generates a sanitized filename for a conversation export
* @param conversation - The conversation metadata
* @param msgs - Optional array of messages belonging to the conversation
* @returns The generated filename string
*/
static generateConversationFilename(
conversation: { id?: string; name?: string },
msgs?: DatabaseMessage[]
): string {
const conversationName = (conversation.name ?? '').trim().toLowerCase();
const sanitizedName = conversationName
.replace(EXPORT_CONV.NON_ALPHANUMERIC_REGEX, EXPORT_CONV.NONALNUM_REPLACEMENT)
.replace(EXPORT_CONV.MULTIPLE_UNDERSCORE_REGEX, '_')
.substring(0, EXPORT_CONV.NAME_SUFFIX_MAX_LENGTH);
// If we have messages, use the timestamp of the newest message
const referenceDate = msgs?.length
? new Date(Math.max(...msgs.map((m) => m.timestamp)))
: new Date();
const iso = referenceDate.toISOString().slice(0, EXPORT_CONV.ISO_TIMESTAMP_SLICE);
const formattedDate = iso
.replace(EXPORT_CONV.ISO_DATE_TIME_SEPARATOR, EXPORT_CONV.ISO_DATE_TIME_SEPARATOR_REPLACEMENT)
.replaceAll(EXPORT_CONV.ISO_TIME_SEPARATOR, EXPORT_CONV.ISO_TIME_SEPARATOR_REPLACEMENT);
const trimmedConvId = conversation.id?.slice(0, EXPORT_CONV.ID_TRIM_LENGTH) ?? '';
return `${formattedDate}_conv_${trimmedConvId}_${sanitizedName}${FileExtensionText.JSONL}`;
}
/**
* Triggers a browser download of the provided exported conversation data
* @param data - The exported conversation payload (a single conversation with its messages)
@@ -262,6 +81,171 @@ export class ConversationTransferService {
ConversationTransferService.triggerDownload(blob, archiveName);
}
/**
* Generates a sanitized filename for a conversation export
* @param conversation - The conversation metadata
* @param msgs - Optional array of messages belonging to the conversation
* @returns The generated filename string
*/
static generateConversationFilename(
conversation: { id?: string; name?: string },
msgs?: DatabaseMessage[]
): string {
const conversationName = (conversation.name ?? '').trim().toLowerCase();
const sanitizedName = conversationName
.replace(EXPORT_CONV.NON_ALPHANUMERIC_REGEX, EXPORT_CONV.NONALNUM_REPLACEMENT)
.replace(EXPORT_CONV.MULTIPLE_UNDERSCORE_REGEX, '_')
.substring(0, EXPORT_CONV.NAME_SUFFIX_MAX_LENGTH);
// If we have messages, use the timestamp of the newest message
const referenceDate = msgs?.length
? new Date(Math.max(...msgs.map((m) => m.timestamp)))
: new Date();
const iso = referenceDate.toISOString().slice(0, EXPORT_CONV.ISO_TIMESTAMP_SLICE);
const formattedDate = iso
.replace(EXPORT_CONV.ISO_DATE_TIME_SEPARATOR, EXPORT_CONV.ISO_DATE_TIME_SEPARATOR_REPLACEMENT)
.replaceAll(EXPORT_CONV.ISO_TIME_SEPARATOR, EXPORT_CONV.ISO_TIME_SEPARATOR_REPLACEMENT);
const trimmedConvId = conversation.id?.slice(0, EXPORT_CONV.ID_TRIM_LENGTH) ?? '';
return `${formattedDate}_conv_${trimmedConvId}_${sanitizedName}${FileExtensionText.JSONL}`;
}
/**
* Parses an import file into conversations, accepting the current JSONL and
* ZIP formats as well as the legacy JSON format. The format comes from the
* contents, so an import works whatever the file is named.
* @param file - The user-selected file
* @returns The parsed conversations with their messages
*/
static async parseImportFile(file: File): Promise<ExportedConversation[]> {
const bytes = new Uint8Array(await file.arrayBuffer());
if (ZIP_MAGIC.every((byte, index) => bytes[index] === byte)) {
const entries = unzipSync(bytes);
const sessions: ExportedConversation[] = [];
for (const [entryName, entryBytes] of Object.entries(entries)) {
if (!entryName.toLowerCase().endsWith(FileExtensionText.JSONL)) continue;
sessions.push(...ConversationTransferService.parseSessionsJsonl(strFromU8(entryBytes)));
}
return sessions;
}
const text = strFromU8(bytes);
if (ConversationTransferService.isSessionsJsonl(text)) {
return ConversationTransferService.parseSessionsJsonl(text);
}
// Legacy JSON format: an array of conversations or a single conversation object.
const parsed = JSON.parse(text);
if (Array.isArray(parsed)) {
return parsed;
}
if (parsed && typeof parsed === 'object' && 'conv' in parsed && 'messages' in parsed) {
return [parsed];
}
throw new Error(
'Invalid file format: expected array of conversations or single conversation object'
);
}
/**
* Parses the JSONL session format produced by {@link serializeSessionToJsonl}.
* A `SessionRecordType.SESSION` line starts a new session; following
* `SessionRecordType.MESSAGE` lines are appended to it. Supports multiple
* sessions in a single file.
* @param text - The JSONL file contents
* @returns The parsed conversations with their messages
*/
static parseSessionsJsonl(text: string): ExportedConversation[] {
const sessions: ExportedConversation[] = [];
let current: ExportedConversation | null = null;
for (const line of text.split(NEWLINE)) {
const trimmed = line.trim();
if (!trimmed) continue;
const record = JSON.parse(trimmed);
if (record.type === SessionRecordType.SESSION) {
// Drop the discriminator and harness marker; the rest is the conversation.
const conv = { ...record };
delete conv.type;
delete conv.harness;
current = { conv: conv as DatabaseConversation, messages: [] };
sessions.push(current);
} else if (record.type === SessionRecordType.MESSAGE) {
if (!current) {
throw new Error('Invalid JSONL: message record before any session record');
}
const message = record.message as DatabaseMessage;
// `toolCalls` is parsed to an array on export; the DB stores it as a string.
if (message.toolCalls !== undefined && typeof message.toolCalls !== 'string') {
message.toolCalls = JSON.stringify(message.toolCalls);
}
current.messages.push(message);
}
// Ignore unknown record types for forward compatibility.
}
return sessions;
}
/**
* Serializes a session (a conversation with its messages) as JSONL.
* The first line is the session header (a `SessionRecordType.SESSION` record
* carrying the conversation properties); each subsequent line is a single message.
* @param data - The exported conversation payload
* @returns The JSONL string (one record per line)
*/
static serializeSessionToJsonl(data: ExportedConversation): string {
const { conv, messages } = data;
const sessionLine = JSON.stringify({
harness: EXPORT_CONV.HARNESS,
type: SessionRecordType.SESSION,
...conv
});
const messageLines = messages.map((message: DatabaseMessage) => {
// `toolCalls` is stored as a JSON string; drop it when empty, otherwise parse it.
const { toolCalls, ...rest } = message;
const normalized = toolCalls ? { ...rest, toolCalls: JSON.parse(toolCalls) } : rest;
return JSON.stringify({ message: normalized, type: SessionRecordType.MESSAGE });
});
return [sessionLine, ...messageLines].join(NEWLINE);
}
/**
* Reports whether the text is the JSONL session format, whose first non-empty
* line is a `SessionRecordType.SESSION` record. A legacy JSON export starts
* with an array or an object that has no such discriminator.
* @param text - The file contents
*/
private static isSessionsJsonl(text: string): boolean {
const trimmed = text.trimStart();
const lineEnd = trimmed.indexOf(NEWLINE);
const firstLine = lineEnd === -1 ? trimmed : trimmed.slice(0, lineEnd);
try {
return JSON.parse(firstLine).type === SessionRecordType.SESSION;
} catch {
// Not a standalone JSON record, so not the JSONL format.
return false;
}
}
/**
* Triggers a browser download of a blob under the given filename.
*/
+366 -399
View File
@@ -1,3 +1,11 @@
/**
* DatabaseService - IndexedDB persistence for conversations and messages
*
* Thin Dexie layer over the conversations/messages tables: CRUD, tree
* navigation (descendants, reparenting) and cascading deletes. No reactive
* state; consumed by conversationsStore and the chat flows.
*/
import { IDXDB_STORES, IDXDB_TABLES, STORAGE_APP_NAME } from '$lib/constants';
import { MessageRole } from '$lib/enums';
import type { McpServerOverride } from '$lib/types/database';
@@ -20,12 +28,99 @@ const db = new LlamaUiDatabase();
export class DatabaseService {
/**
* Deletes multiple conversations in a single transaction. Each deleted
* conversation has its direct children reparented to the nearest surviving
* ancestor (or promoted to top-level). Children also in `ids` are dropped
* entirely rather than reparented.
*
*
* Conversations
*
*
* @param ids - Conversation IDs to delete
*/
static async bulkDeleteConversations(ids: string[]): Promise<void> {
const cleanIds = ids.filter((id): id is string => typeof id === 'string' && id.length > 0);
if (cleanIds.length === 0) return;
const idSet = new Set(cleanIds);
await db.transaction(
'rw',
[db[IDXDB_TABLES.conversations], db[IDXDB_TABLES.messages]],
async () => {
// Pre-load each to-delete conversation so the per-id reparent
// walk-up doesn't ping-pong the same ancestry chain.
const prefetched = new Map<string, DatabaseConversation>();
let frontier = [...cleanIds];
const requested = new Set<string>(frontier);
while (frontier.length > 0) {
const fetched = await db[IDXDB_TABLES.conversations].bulkGet(frontier);
frontier = [];
for (let i = 0; i < fetched.length; i++) {
const conv = fetched[i];
if (!conv || !conv.id) continue;
prefetched.set(conv.id, conv);
const ancestor = conv.forkedFromConversationId;
if (ancestor && !prefetched.has(ancestor) && !requested.has(ancestor)) {
frontier.push(ancestor);
requested.add(ancestor);
}
}
}
for (const id of cleanIds) {
await this.reparentDirectChildren(id, idSet, prefetched);
}
await db[IDXDB_TABLES.conversations].bulkDelete(cleanIds);
await db[IDXDB_TABLES.messages].where('convId').anyOf(cleanIds).delete();
}
);
}
/**
* Toggles the pinned status of each conversation in `ids` inside a single
* transaction. Treats `pinned === undefined` as `false`, matching the
* semantics of {@link toggleConversationPin} where `!undefined` evaluates
* to `true`. Returns the resulting pinned state for every id that was
* updated; missing ids are omitted from the map.
*
* @param ids - Conversation IDs to toggle
* @returns Map of id -> new pinned state
*/
static async bulkToggleConversationPins(ids: string[]): Promise<Map<string, boolean>> {
const cleanIds = ids.filter((id): id is string => typeof id === 'string' && id.length > 0);
const result = new Map<string, boolean>();
if (cleanIds.length === 0) return result;
await db.transaction('rw', db[IDXDB_TABLES.conversations], async () => {
const convs = await db[IDXDB_TABLES.conversations].bulkGet(cleanIds);
const updates: DatabaseConversation[] = [];
for (let i = 0; i < cleanIds.length; i++) {
const conv = convs[i];
if (!conv) continue;
const newPinned = !conv.pinned;
updates.push({ ...conv, pinned: newPinned });
result.set(cleanIds[i], newPinned);
}
if (updates.length === 0) return;
await db[IDXDB_TABLES.conversations].bulkPut(updates);
});
return result;
}
/**
* Creates a new conversation.
@@ -51,14 +146,6 @@ export class DatabaseService {
return conversation;
}
/**
*
*
* Messages
*
*
*/
/**
* Creates a new message branch by adding a message and updating parent/child relationships.
* Also updates the conversation's currNode to point to the new message.
@@ -96,13 +183,7 @@ export class DatabaseService {
// Update parent's children array if parent exists
if (parentId !== null) {
const parentMessage = await db[IDXDB_TABLES.messages].get(parentId);
if (parentMessage) {
await db[IDXDB_TABLES.messages].update(parentId, {
children: [...parentMessage.children, newMessage.id]
});
}
await this.addChildToParent(parentId, newMessage.id);
}
await this.updateConversation(message.convId, {
@@ -178,9 +259,7 @@ export class DatabaseService {
};
await db[IDXDB_TABLES.messages].add(systemMessage);
await db[IDXDB_TABLES.messages].update(parentId, {
children: [...parentMessage.children, systemMessage.id]
});
await this.addChildToParent(parentId, systemMessage.id);
return systemMessage;
});
@@ -230,121 +309,6 @@ export class DatabaseService {
);
}
/**
* Reparents direct children of `parentId` to the nearest surviving
* ancestor (or promotes them to top-level when the immediate parent was
* top-level). Walking skips any ancestor listed in `excludeIds`, since
* those will be deleted in the same batch leaving a grandchild pointing
* at an `excludeIds` entry would orphan it. Children whose own id is in
* `excludeIds` are dropped from the updates (the bulk-delete pass will
* remove them). `prefetched` may carry a pre-fetched ancestor map to
* avoid repeat reads inside a bulk transaction.
*/
private static async reparentDirectChildren(
parentId: string,
excludeIds: ReadonlySet<string> = new Set(),
prefetched?: ReadonlyMap<string, DatabaseConversation>
): Promise<void> {
const conv = prefetched?.get(parentId) ?? (await db[IDXDB_TABLES.conversations].get(parentId));
if (!conv) return;
let newParent = conv.forkedFromConversationId;
const visited = new Set<string>([parentId]);
while (newParent && excludeIds.has(newParent)) {
if (visited.has(newParent)) {
newParent = undefined;
break;
}
visited.add(newParent);
const next =
prefetched?.get(newParent) ?? (await db[IDXDB_TABLES.conversations].get(newParent));
if (!next) {
newParent = undefined;
break;
}
newParent = next.forkedFromConversationId;
}
const directChildren = await db[IDXDB_TABLES.conversations]
.filter((c) => c.forkedFromConversationId === parentId)
.toArray();
const updates: DatabaseConversation[] = [];
for (const child of directChildren) {
if (excludeIds.has(child.id)) continue;
updates.push({ ...child, forkedFromConversationId: newParent });
}
if (updates.length === 0) return;
await db[IDXDB_TABLES.conversations].bulkPut(updates);
}
/**
* Deletes multiple conversations in a single transaction. Each deleted
* conversation has its direct children reparented to the nearest surviving
* ancestor (or promoted to top-level). Children also in `ids` are dropped
* entirely rather than reparented.
*
* @param ids - Conversation IDs to delete
*/
static async bulkDeleteConversations(ids: string[]): Promise<void> {
const cleanIds = ids.filter((id): id is string => typeof id === 'string' && id.length > 0);
if (cleanIds.length === 0) return;
const idSet = new Set(cleanIds);
await db.transaction(
'rw',
[db[IDXDB_TABLES.conversations], db[IDXDB_TABLES.messages]],
async () => {
// Pre-load each to-delete conversation so the per-id reparent
// walk-up doesn't ping-pong the same ancestry chain.
const prefetched = new Map<string, DatabaseConversation>();
let frontier = [...cleanIds];
const requested = new Set<string>(frontier);
while (frontier.length > 0) {
const fetched = await db[IDXDB_TABLES.conversations].bulkGet(frontier);
frontier = [];
for (let i = 0; i < fetched.length; i++) {
const conv = fetched[i];
if (!conv || !conv.id) continue;
prefetched.set(conv.id, conv);
const ancestor = conv.forkedFromConversationId;
if (ancestor && !prefetched.has(ancestor) && !requested.has(ancestor)) {
frontier.push(ancestor);
requested.add(ancestor);
}
}
}
for (const id of cleanIds) {
await this.reparentDirectChildren(id, idSet, prefetched);
}
await db[IDXDB_TABLES.conversations].bulkDelete(cleanIds);
await db[IDXDB_TABLES.messages].where('convId').anyOf(cleanIds).delete();
}
);
}
/**
* Deletes a message and removes it from its parent's children array.
*
@@ -356,17 +320,8 @@ export class DatabaseService {
if (!message) return;
// Remove this message from its parent's children array
if (message.parent) {
const parent = await db[IDXDB_TABLES.messages].get(message.parent);
await this.removeChildFromParent(messageId);
if (parent) {
parent.children = parent.children.filter((childId: string) => childId !== messageId);
await db[IDXDB_TABLES.messages].put(parent);
}
}
// Delete the message
await db[IDXDB_TABLES.messages].delete(messageId);
});
}
@@ -389,20 +344,10 @@ export class DatabaseService {
.where('convId')
.equals(conversationId)
.toArray();
// Find all descendant messages
const descendants = findDescendantMessages(allMessages, messageId);
const allToDelete = [messageId, ...descendants];
// Get the message to delete for parent cleanup
const message = await db[IDXDB_TABLES.messages].get(messageId);
if (message && message.parent) {
const parent = await db[IDXDB_TABLES.messages].get(message.parent);
if (parent) {
parent.children = parent.children.filter((childId: string) => childId !== messageId);
await db[IDXDB_TABLES.messages].put(parent);
}
}
await this.removeChildFromParent(messageId);
// Delete all messages in the branch
await db[IDXDB_TABLES.messages].bulkDelete(allToDelete);
@@ -411,243 +356,6 @@ export class DatabaseService {
});
}
/**
* Gets all conversations, sorted by last modified time (newest first).
*
* @returns Array of conversations
*/
static async getAllConversations(): Promise<DatabaseConversation[]> {
return await db[IDXDB_TABLES.conversations].orderBy('lastModified').reverse().toArray();
}
/**
* Gets a conversation by ID.
*
* @param id - Conversation ID
* @returns The conversation if found, otherwise undefined
*/
static async getConversation(id: string): Promise<DatabaseConversation | undefined> {
return await db[IDXDB_TABLES.conversations].get(id);
}
/**
* Gets all messages in a conversation, sorted by timestamp (oldest first).
*
* @param convId - Conversation ID
* @returns Array of messages in the conversation
*/
static async getConversationMessages(convId: string): Promise<DatabaseMessage[]> {
return await db[IDXDB_TABLES.messages].where('convId').equals(convId).sortBy('timestamp');
}
/**
* Loads multiple conversations with all of their messages in two bulk
* reads. Missing conversations are silently omitted from the result.
*
* @param convIds - Conversation IDs to load
* @returns Map of id -> { conv, messages }. Messages are sorted ascending by timestamp.
*/
static async getConversationsWithMessages(
convIds: string[]
): Promise<Map<string, ExportedConversation>> {
const result = new Map<string, ExportedConversation>();
const cleanIds = convIds.filter((id): id is string => typeof id === 'string' && id.length > 0);
if (cleanIds.length === 0) return result;
const [convs, allMessages] = await Promise.all([
db[IDXDB_TABLES.conversations].bulkGet(cleanIds),
db[IDXDB_TABLES.messages].where('convId').anyOf(cleanIds).toArray()
]);
const messagesByConv = new Map<string, DatabaseMessage[]>();
for (const msg of allMessages) {
const bucket = messagesByConv.get(msg.convId);
if (bucket) bucket.push(msg);
else messagesByConv.set(msg.convId, [msg]);
}
for (let i = 0; i < cleanIds.length; i++) {
const conv = convs[i];
if (!conv) continue;
const messages = (messagesByConv.get(conv.id) ?? []).sort(
(a, b) => a.timestamp - b.timestamp
);
result.set(conv.id, { conv, messages });
}
return result;
}
/**
* Updates a conversation. `lastModified` is never stamped implicitly;
* pass it in `updates` to bump the conversation in recency ordering.
*
* @param id - Conversation ID
* @param updates - Partial updates to apply
* @returns Promise that resolves when the conversation is updated
*/
static async updateConversation(
id: string,
updates: Partial<Omit<DatabaseConversation, 'id'>>
): Promise<void> {
await db[IDXDB_TABLES.conversations].update(id, updates);
}
/**
*
*
* Navigation
*
*
*/
/**
* Toggles the pinned status of a conversation.
*
* @param id - Conversation ID
* @returns The new pinned status
*/
static async toggleConversationPin(id: string): Promise<boolean> {
const conversation = await db[IDXDB_TABLES.conversations].get(id);
if (!conversation) {
throw new Error(`Conversation ${id} not found`);
}
const newPinnedState = !conversation.pinned;
await this.updateConversation(id, { pinned: newPinnedState });
return newPinnedState;
}
/**
* Toggles the pinned status of each conversation in `ids` inside a single
* transaction. Treats `pinned === undefined` as `false`, matching the
* semantics of {@link toggleConversationPin} where `!undefined` evaluates
* to `true`. Returns the resulting pinned state for every id that was
* updated; missing ids are omitted from the map.
*
* @param ids - Conversation IDs to toggle
* @returns Map of id -> new pinned state
*/
static async bulkToggleConversationPins(ids: string[]): Promise<Map<string, boolean>> {
const cleanIds = ids.filter((id): id is string => typeof id === 'string' && id.length > 0);
const result = new Map<string, boolean>();
if (cleanIds.length === 0) return result;
await db.transaction('rw', db[IDXDB_TABLES.conversations], async () => {
const convs = await db[IDXDB_TABLES.conversations].bulkGet(cleanIds);
const updates: DatabaseConversation[] = [];
for (let i = 0; i < cleanIds.length; i++) {
const conv = convs[i];
if (!conv) continue;
const newPinned = !conv.pinned;
updates.push({ ...conv, pinned: newPinned });
result.set(cleanIds[i], newPinned);
}
if (updates.length === 0) return;
await db[IDXDB_TABLES.conversations].bulkPut(updates);
});
return result;
}
/**
* Updates the conversation's current node (active branch).
* This determines which conversation path is currently being viewed.
*
* @param convId - Conversation ID
* @param nodeId - Message ID to set as current node
*/
static async updateCurrentNode(convId: string, nodeId: string): Promise<void> {
await this.updateConversation(convId, {
currNode: nodeId
});
}
/**
* Updates a message.
*
* @param id - Message ID
* @param updates - Partial updates to apply
* @returns Promise that resolves when the message is updated
*/
static async updateMessage(
id: string,
updates: Partial<Omit<DatabaseMessage, 'id'>>
): Promise<void> {
await db[IDXDB_TABLES.messages].update(id, updates);
}
/**
*
*
* Import
*
*
*/
/**
* Imports multiple conversations and their messages.
* Skips conversations that already exist.
*
* @param data - Array of { conv, messages } objects
* @returns The conversations written to the database and the ones skipped
*/
static async importConversations(
data: { conv: DatabaseConversation; messages: DatabaseMessage[] }[]
): Promise<{ imported: DatabaseConversation[]; skipped: DatabaseConversation[] }> {
const imported: DatabaseConversation[] = [];
const skipped: DatabaseConversation[] = [];
return await db.transaction(
'rw',
[db[IDXDB_TABLES.conversations], db[IDXDB_TABLES.messages]],
async () => {
for (const item of data) {
const { conv, messages } = item;
const existing = await db[IDXDB_TABLES.conversations].get(conv.id);
if (existing) {
skipped.push(conv);
continue;
}
await db[IDXDB_TABLES.conversations].add(conv);
for (const msg of messages) {
await db[IDXDB_TABLES.messages].put(msg);
}
imported.push(conv);
}
return { imported, skipped };
}
);
}
/**
*
*
* Forking
*
*
*/
/**
* Forks a conversation at a specific message, creating a new conversation
* containing all messages from the root up to (and including) the target message.
@@ -726,13 +434,272 @@ export class DatabaseService {
};
await db[IDXDB_TABLES.conversations].add(newConv);
for (const msg of clonedMessages) {
await db[IDXDB_TABLES.messages].add(msg);
}
await db[IDXDB_TABLES.messages].bulkAdd(clonedMessages);
return newConv;
}
);
}
/**
* Gets all conversations, sorted by last modified time (newest first).
*
* @returns Array of conversations
*/
static async getAllConversations(): Promise<DatabaseConversation[]> {
return await db[IDXDB_TABLES.conversations].orderBy('lastModified').reverse().toArray();
}
/**
* Gets a conversation by ID.
*
* @param id - Conversation ID
* @returns The conversation if found, otherwise undefined
*/
static async getConversation(id: string): Promise<DatabaseConversation | undefined> {
return await db[IDXDB_TABLES.conversations].get(id);
}
/**
* Gets all messages in a conversation, sorted by timestamp (oldest first).
*
* @param convId - Conversation ID
* @returns Array of messages in the conversation
*/
static async getConversationMessages(convId: string): Promise<DatabaseMessage[]> {
return await db[IDXDB_TABLES.messages].where('convId').equals(convId).sortBy('timestamp');
}
/**
* Loads multiple conversations with all of their messages in two bulk
* reads. Missing conversations are silently omitted from the result.
*
* @param convIds - Conversation IDs to load
* @returns Map of id -> { conv, messages }. Messages are sorted ascending by timestamp.
*/
static async getConversationsWithMessages(
convIds: string[]
): Promise<Map<string, ExportedConversation>> {
const result = new Map<string, ExportedConversation>();
const cleanIds = convIds.filter((id): id is string => typeof id === 'string' && id.length > 0);
if (cleanIds.length === 0) return result;
const [convs, allMessages] = await Promise.all([
db[IDXDB_TABLES.conversations].bulkGet(cleanIds),
db[IDXDB_TABLES.messages].where('convId').anyOf(cleanIds).toArray()
]);
const messagesByConv = new Map<string, DatabaseMessage[]>();
for (const msg of allMessages) {
const bucket = messagesByConv.get(msg.convId);
if (bucket) bucket.push(msg);
else messagesByConv.set(msg.convId, [msg]);
}
for (let i = 0; i < cleanIds.length; i++) {
const conv = convs[i];
if (!conv) continue;
const messages = (messagesByConv.get(conv.id) ?? []).sort(
(a, b) => a.timestamp - b.timestamp
);
result.set(conv.id, { conv, messages });
}
return result;
}
/**
* Imports multiple conversations and their messages.
* Skips conversations that already exist.
*
* @param data - Array of { conv, messages } objects
* @returns The conversations written to the database and the ones skipped
*/
static async importConversations(
data: { conv: DatabaseConversation; messages: DatabaseMessage[] }[]
): Promise<{ imported: DatabaseConversation[]; skipped: DatabaseConversation[] }> {
const imported: DatabaseConversation[] = [];
const skipped: DatabaseConversation[] = [];
return await db.transaction(
'rw',
[db[IDXDB_TABLES.conversations], db[IDXDB_TABLES.messages]],
async () => {
for (const item of data) {
const { conv, messages } = item;
const existing = await db[IDXDB_TABLES.conversations].get(conv.id);
if (existing) {
skipped.push(conv);
continue;
}
await db[IDXDB_TABLES.conversations].add(conv);
for (const msg of messages) {
await db[IDXDB_TABLES.messages].put(msg);
}
imported.push(conv);
}
return { imported, skipped };
}
);
}
/**
* Toggles the pinned status of a conversation.
*
* @param id - Conversation ID
* @returns The new pinned status
*/
static async toggleConversationPin(id: string): Promise<boolean> {
const conversation = await db[IDXDB_TABLES.conversations].get(id);
if (!conversation) {
throw new Error(`Conversation ${id} not found`);
}
const newPinnedState = !conversation.pinned;
await this.updateConversation(id, { pinned: newPinnedState });
return newPinnedState;
}
/**
* Updates a conversation. `lastModified` is never stamped implicitly;
* pass it in `updates` to bump the conversation in recency ordering.
*
* @param id - Conversation ID
* @param updates - Partial updates to apply
* @returns Promise that resolves when the conversation is updated
*/
static async updateConversation(
id: string,
updates: Partial<Omit<DatabaseConversation, 'id'>>
): Promise<void> {
await db[IDXDB_TABLES.conversations].update(id, updates);
}
/**
* Updates the conversation's current node (active branch).
* This determines which conversation path is currently being viewed.
*
* @param convId - Conversation ID
* @param nodeId - Message ID to set as current node
*/
static async updateCurrentNode(convId: string, nodeId: string): Promise<void> {
await this.updateConversation(convId, {
currNode: nodeId
});
}
/**
* Updates a message.
*
* @param id - Message ID
* @param updates - Partial updates to apply
* @returns Promise that resolves when the message is updated
*/
static async updateMessage(
id: string,
updates: Partial<Omit<DatabaseMessage, 'id'>>
): Promise<void> {
await db[IDXDB_TABLES.messages].update(id, updates);
}
/**
* Appends a child id to a parent message's children array.
*/
private static async addChildToParent(parentId: string, childId: string): Promise<void> {
const parent = await db[IDXDB_TABLES.messages].get(parentId);
if (!parent) return;
await db[IDXDB_TABLES.messages].update(parentId, {
children: [...parent.children, childId]
});
}
/**
* Removes a child id from its parent message's children array.
*/
private static async removeChildFromParent(messageId: string): Promise<void> {
const message = await db[IDXDB_TABLES.messages].get(messageId);
if (!message?.parent) return;
const parent = await db[IDXDB_TABLES.messages].get(message.parent);
if (!parent) return;
parent.children = parent.children.filter((childId: string) => childId !== messageId);
await db[IDXDB_TABLES.messages].put(parent);
}
/**
* Reparents direct children of `parentId` to the nearest surviving
* ancestor (or promotes them to top-level when the immediate parent was
* top-level). Walking skips any ancestor listed in `excludeIds`, since
* those will be deleted in the same batch leaving a grandchild pointing
* at an `excludeIds` entry would orphan it. Children whose own id is in
* `excludeIds` are dropped from the updates (the bulk-delete pass will
* remove them). `prefetched` may carry a pre-fetched ancestor map to
* avoid repeat reads inside a bulk transaction.
*/
private static async reparentDirectChildren(
parentId: string,
excludeIds: ReadonlySet<string> = new Set(),
prefetched?: ReadonlyMap<string, DatabaseConversation>
): Promise<void> {
const conv = prefetched?.get(parentId) ?? (await db[IDXDB_TABLES.conversations].get(parentId));
if (!conv) return;
let newParent = conv.forkedFromConversationId;
const visited = new Set<string>([parentId]);
while (newParent && excludeIds.has(newParent)) {
if (visited.has(newParent)) {
newParent = undefined;
break;
}
visited.add(newParent);
const next =
prefetched?.get(newParent) ?? (await db[IDXDB_TABLES.conversations].get(newParent));
if (!next) {
newParent = undefined;
break;
}
newParent = next.forkedFromConversationId;
}
const directChildren = await db[IDXDB_TABLES.conversations]
.filter((c) => c.forkedFromConversationId === parentId)
.toArray();
const updates: DatabaseConversation[] = [];
for (const child of directChildren) {
if (excludeIds.has(child.id)) continue;
updates.push({ ...child, forkedFromConversationId: newParent });
}
if (updates.length === 0) return;
await db[IDXDB_TABLES.conversations].bulkPut(updates);
}
}
+14 -14
View File
@@ -53,9 +53,9 @@
* - Reasoning content stripping from prompt history to avoid KV cache pollution
* - Error translation (network, timeout, server errors user-friendly messages)
*
* @see chatStore in stores/chat.svelte.ts primary consumer for chat state management
* @see agenticStore in stores/agentic.svelte.ts uses ChatService for agentic loop streaming
* @see conversationsStore in stores/conversations.svelte.ts provides message context
* @see chatStore in stores/chat/index.svelte.ts primary consumer for chat state management
* @see agenticStore in stores/agentic/index.svelte.ts uses ChatService for agentic loop streaming
* @see conversationsStore in stores/conversations/index.svelte.ts provides message context
*/
export { ChatService } from './chat.service';
@@ -98,8 +98,8 @@ export { ChatService } from './chat.service';
* enabling conversation branching and alternative response paths. The conversation's
* `currNode` tracks the currently active branch endpoint.
*
* @see conversationsStore in stores/conversations.svelte.ts reactive layer on top of DatabaseService
* @see chatStore in stores/chat.svelte.ts uses DatabaseService directly for message CRUD during streaming
* @see conversationsStore in stores/conversations/index.svelte.ts reactive layer on top of DatabaseService
* @see chatStore in stores/chat/index.svelte.ts uses DatabaseService directly for message CRUD during streaming
*/
export { DatabaseService } from './database.service';
@@ -143,7 +143,7 @@ export { ConversationTransferService } from './conversation-transfer.service';
* - `POST /models/load` Load a model (ROUTER mode only)
* - `POST /models/unload` Unload a model (ROUTER mode only)
*
* @see modelsStore in stores/models.svelte.ts primary consumer for reactive model state
* @see modelsStore in stores/models/index.svelte.ts primary consumer for reactive model state
*/
export { ModelsService } from './models.service';
@@ -174,8 +174,8 @@ export { ModelsService } from './models.service';
* - `&autoload=false` Prevents model auto-loading when querying props
*
* @see serverStore in stores/server.svelte.ts consumes global server props
* @see modelsStore in stores/models.svelte.ts consumes per-model props for modalities
* @see settingsStore in stores/settings.svelte.ts syncs default generation params from props
* @see modelsStore in stores/models/index.svelte.ts consumes per-model props for modalities
* @see settingsStore in stores/settings/index.svelte.ts syncs default generation params from props
*/
export { PropsService } from './props.service';
@@ -217,7 +217,7 @@ export { PropsService } from './props.service';
* - `ParameterSyncService` class static methods for sync logic
* - `SYNCABLE_PARAMETERS` mapping of UI setting keys to server parameter keys
*
* @see settingsStore in stores/settings.svelte.ts primary consumer for settings sync
* @see settingsStore in stores/settings/index.svelte.ts primary consumer for settings sync
* @see SettingsChatParameterSourceIndicator displays parameter source badges in UI
*/
export { ParameterSyncService } from './parameter-sync.service';
@@ -241,7 +241,7 @@ export { ParameterSyncService } from './parameter-sync.service';
* - Manages connection lifecycle, health checks, reconnection
* - Handles tool name conflict resolution and server coordination
*
* - **mcpResourceStore**: Reactive resource state
* - **mcpResourceStore** (composed as mcpStore.resources): Reactive resource state
* - Receives resource data fetched via MCPService
* - Manages resource caching, subscriptions, and attachments
*
@@ -263,9 +263,9 @@ export { ParameterSyncService } from './parameter-sync.service';
* 2. **StreamableHTTP** modern HTTP-based, supports CORS proxy
* 3. **SSE** legacy fallback, supports CORS proxy
*
* @see mcpStore in stores/mcp.svelte.ts reactive business logic facade on top of MCPService
* @see mcpResourceStore in stores/mcp-resources.svelte.ts reactive resource state management
* @see agenticStore in stores/agentic.svelte.ts uses MCPService (via mcpStore) for tool execution
* @see mcpStore in stores/mcp/index.svelte.ts reactive business logic facade on top of MCPService
* @see mcpStore.resources in stores/mcp/resources.svelte.ts reactive resource state management
* @see agenticStore in stores/agentic/index.svelte.ts uses MCPService (via mcpStore) for tool execution
* @see MCP Protocol Specification: https://modelcontextprotocol.io/specification/2025-06-18
*/
export { MCPService } from './mcp.service';
@@ -286,7 +286,7 @@ export { MCPService } from './mcp.service';
* - **agenticStore**: Dispatches ToolSource.BROWSER calls here
*
* @see buildSandboxToolDefinition in utils/sandbox-tool - tool schema sent to the LLM
* @see agenticStore in stores/agentic.svelte.ts - tool dispatch
* @see agenticStore in stores/agentic/index.svelte.ts - tool dispatch
*/
export { SandboxService } from './sandbox.service';
File diff suppressed because it is too large Load Diff
+6 -15
View File
@@ -1,20 +1,11 @@
/**
* Migration Service - Unified data migration hook
* MigrationService - Unified data migration hook
*
* Centralizes all data migrations (localStorage, IndexedDB, legacy formats) into a single
* initialization point. Each migration copies data to new format WITHOUT deleting the old.
*
* **Architecture:**
* - Migrations are defined as objects with `id` and `run()` methods
* - Migration state is tracked in localStorage to avoid re-running
* - `runAllMigrations()` should be called once at app startup
* - All migrations are NON-DESTRUCTIVE - legacy data is preserved for downgrade compatibility
*
* **Current Migrations:**
* 1. localStorage prefix: Copy LlamaCppWebui.* LlamaUi.* (both preserved)
* 2. IndexedDB database: Copy LlamacppWebui LlamaUi (both preserved)
* 3. Legacy message format: Transform in-place (preserves structure, migrates markers)
* 4. Theme key: Copy standalone `theme` config object (both preserved)
* Centralizes all data migrations (localStorage, IndexedDB, legacy formats)
* into a single initialization point. Each migration copies data to the new
* format WITHOUT deleting the old, and state is tracked in localStorage so
* `runAllMigrations()` (called once at startup) never re-runs a completed
* migration. All migrations are non-destructive for downgrade compatibility.
*/
import {
+119 -148
View File
@@ -1,25 +1,55 @@
/**
* ModelsService - Stateless model management API layer
*
* Wraps the /models endpoints (list, load, unload) and the /models/sse
* status feed in MODEL and ROUTER modes. No reactive state; consumed by
* modelsStore and its status manager.
*/
import { base } from '$app/paths';
import {
API_MODELS,
MODEL_ID,
SSE_DATA_PREFIX,
SSE_LINE_SEPARATOR,
SSE_RECORD_SEPARATOR
} from '$lib/constants';
import { API_MODELS, MODEL_ID } from '$lib/constants';
import { ServerModelStatus } from '$lib/enums';
import type { ParsedModelId } from '$lib/types/models';
import { apiFetch, apiPost, normalizeModelName } from '$lib/utils';
import {
apiFetch,
apiPost,
extractSseDataPayload,
normalizeModelName,
splitSseRecords
} from '$lib/utils';
import { getAuthHeaders } from '$lib/utils/api-headers';
export class ModelsService {
private static readonly SSE_RECONNECT_MS = 1000;
/**
* Check if a model is loaded based on its metadata.
*
* @param model - Model data entry from the API response
* @returns True if the model status is LOADED
*/
static isModelLoaded(model: ApiModelDataEntry): boolean {
return model.status.value === ServerModelStatus.LOADED;
}
/**
*
*
* Listing
* Load/Unload
*
*
*/
/**
* Check if a model is currently loading.
*
* @param model - Model data entry from the API response
* @returns True if the model status is LOADING
*/
static isModelLoading(model: ApiModelDataEntry): boolean {
return model.status.value === ServerModelStatus.LOADING;
}
/**
* Fetch list of models from OpenAI-compatible endpoint.
* Works in both MODEL and ROUTER modes.
@@ -41,14 +71,6 @@ export class ModelsService {
return apiFetch<ApiRouterModelsListResponse>(API_MODELS.LIST);
}
/**
*
*
* Load/Unload
*
*
*/
/**
* Load a model (ROUTER mode only).
* Sends POST request to `/models/load`. Note: the endpoint returns success
@@ -68,137 +90,6 @@ export class ModelsService {
return apiPost<ApiRouterModelsLoadResponse>(API_MODELS.LOAD, payload);
}
/**
* Unload a model (ROUTER mode only).
* Sends POST request to `/models/unload`. Note: the endpoint returns success
* before unloading completes use polling to await actual unload status.
*
* @param modelId - Model identifier to unload
* @returns Unload response from the server
*/
static async unload(modelId: string): Promise<ApiRouterModelsUnloadResponse> {
return apiPost<ApiRouterModelsUnloadResponse>(API_MODELS.UNLOAD, { model: modelId });
}
/**
*
*
* Status
*
*
*/
/**
* Check if a model is loaded based on its metadata.
*
* @param model - Model data entry from the API response
* @returns True if the model status is LOADED
*/
static isModelLoaded(model: ApiModelDataEntry): boolean {
return model.status.value === ServerModelStatus.LOADED;
}
/**
* Check if a model is currently loading.
*
* @param model - Model data entry from the API response
* @returns True if the model status is LOADING
*/
static isModelLoading(model: ApiModelDataEntry): boolean {
return model.status.value === ServerModelStatus.LOADING;
}
/**
*
*
* Status Feed
*
*
*/
private static readonly SSE_RECONNECT_MS = 1000;
/**
* Read the /models/sse feed and invoke onEvent for each parsed envelope.
* Reconnects on network drops until the signal aborts. Splits the byte
* stream into SSE records on the blank line boundary; the payload rides in
* the data lines as a JSON envelope with its own model, event and data fields.
*/
static async watchModelEvents(
signal: AbortSignal,
onEvent: (event: ApiModelsSseEvent) => void
): Promise<void> {
const decoder = new TextDecoder();
while (!signal.aborted) {
try {
const response = await fetch(`${base}${API_MODELS.SSE}`, {
headers: getAuthHeaders(),
signal
});
if (response.ok && response.body) {
const reader = response.body.getReader();
let buffer = '';
while (!signal.aborted) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let boundary = buffer.indexOf(SSE_RECORD_SEPARATOR);
while (boundary !== -1) {
const event = ModelsService.parseStatusRecord(buffer.slice(0, boundary));
if (event) onEvent(event);
buffer = buffer.slice(boundary + SSE_RECORD_SEPARATOR.length);
boundary = buffer.indexOf(SSE_RECORD_SEPARATOR);
}
}
}
} catch {
// network drop or abort falls through to the reconnect delay
}
if (signal.aborted) return;
await new Promise((resolve) => setTimeout(resolve, ModelsService.SSE_RECONNECT_MS));
}
}
/**
* Parse one SSE record into its JSON envelope, or null when the record
* carries no data payload or malformed JSON.
*/
private static parseStatusRecord(record: string): ApiModelsSseEvent | null {
const payload = record
.split(SSE_LINE_SEPARATOR)
.filter((line) => line.startsWith(SSE_DATA_PREFIX))
.map((line) => line.slice(SSE_DATA_PREFIX.length).trim())
.join(SSE_LINE_SEPARATOR);
if (payload.length === 0) return null;
try {
return JSON.parse(payload) as ApiModelsSseEvent;
} catch {
return null;
}
}
/**
*
*
* Parsing
*
*
*/
/**
* Parse a model ID string into its structured components.
*
@@ -311,4 +202,84 @@ export class ModelsService {
return result;
}
/**
* Unload a model (ROUTER mode only).
* Sends POST request to `/models/unload`. Note: the endpoint returns success
* before unloading completes use polling to await actual unload status.
*
* @param modelId - Model identifier to unload
* @returns Unload response from the server
*/
static async unload(modelId: string): Promise<ApiRouterModelsUnloadResponse> {
return apiPost<ApiRouterModelsUnloadResponse>(API_MODELS.UNLOAD, { model: modelId });
}
/**
* Read the /models/sse feed and invoke onEvent for each parsed envelope.
* Reconnects on network drops until the signal aborts. Splits the byte
* stream into SSE records on the blank line boundary; the payload rides in
* the data lines as a JSON envelope with its own model, event and data fields.
*/
static async watchModelEvents(
signal: AbortSignal,
onEvent: (event: ApiModelsSseEvent) => void
): Promise<void> {
const decoder = new TextDecoder();
while (!signal.aborted) {
try {
const response = await fetch(`${base}${API_MODELS.SSE}`, {
headers: getAuthHeaders(),
signal
});
if (response.ok && response.body) {
const reader = response.body.getReader();
let buffer = '';
while (!signal.aborted) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const { records, rest } = splitSseRecords(buffer);
buffer = rest;
for (const record of records) {
const event = ModelsService.parseStatusRecord(record);
if (event) onEvent(event);
}
}
}
} catch {
// network drop or abort falls through to the reconnect delay
}
if (signal.aborted) return;
await new Promise((resolve) => setTimeout(resolve, ModelsService.SSE_RECONNECT_MS));
}
}
/**
* Parse one SSE record into its JSON envelope, or null when the record
* carries no data payload or malformed JSON.
*/
private static parseStatusRecord(record: string): ApiModelsSseEvent | null {
const payload = extractSseDataPayload(record);
if (payload.length === 0) return null;
try {
return JSON.parse(payload) as ApiModelsSseEvent;
} catch {
return null;
}
}
}
@@ -1,3 +1,11 @@
/**
* ParameterSyncService - Syncs sampling parameters with the server
*
* Decides for each sampling parameter whether the user's setting is an
* override of the server default, and normalizes floating-point values.
* No reactive state; consumed by settingsStore.
*/
import { SETTINGS_KEYS, SYNCABLE_PARAMETERS } from '$lib/constants';
import { ParameterSource, SyncableParameterType } from '$lib/enums';
import type { ParameterInfo, ParameterRecord, ParameterValue } from '$lib/types';
@@ -5,22 +13,47 @@ import { normalizeFloatingPoint } from '$lib/utils';
export class ParameterSyncService {
/**
* Check if a parameter can be synced from server.
*
*
* Extraction
*
*
* @param key - The parameter key to check
* @returns True if the parameter is in the syncable parameters list
*/
static canSyncParameter(key: string): boolean {
return SYNCABLE_PARAMETERS.some((param) => param.key === key && param.canSync);
}
/**
* Round floating-point numbers to avoid JavaScript precision issues.
* E.g., 0.1 + 0.2 = 0.30000000000000004 0.3
* Create a diff between current settings and server defaults.
* Shows which parameters differ from server values, useful for debugging
* and for the "Reset to defaults" functionality.
*
* @param value - Parameter value to normalize
* @returns Precision-normalized value
* @param currentSettings - Current parameter values in the settings store
* @param serverDefaults - Default values extracted from server props
* @returns Record of parameter diffs with current value, server value, and whether they differ
*/
private static roundFloatingPoint(value: ParameterValue): ParameterValue {
return normalizeFloatingPoint(value) as ParameterValue;
static createParameterDiff(
currentSettings: ParameterRecord,
serverDefaults: ParameterRecord
): Record<string, { current: ParameterValue; server: ParameterValue; differs: boolean }> {
const diff: Record<
string,
{ current: ParameterValue; server: ParameterValue; differs: boolean }
> = {};
for (const key of this.getSyncableParameterKeys()) {
const currentValue = currentSettings[key];
const serverValue = serverDefaults[key];
if (serverValue !== undefined) {
diff[key] = {
current: currentValue,
differs: currentValue !== serverValue,
server: serverValue
};
}
}
return diff;
}
/**
@@ -59,49 +92,6 @@ export class ParameterSyncService {
return extracted;
}
/**
*
*
* Merging
*
*
*/
/**
* Merge server defaults with current user settings.
* User overrides always take priority only parameters not in `userOverrides`
* set will be updated from server defaults.
*
* @param currentSettings - Current parameter values in the settings store
* @param serverDefaults - Default values extracted from server props
* @param userOverrides - Set of parameter keys explicitly overridden by the user
* @returns Merged parameter record with user overrides preserved
*/
static mergeWithServerDefaults(
currentSettings: ParameterRecord,
serverDefaults: ParameterRecord,
userOverrides: Set<string> = new Set()
): ParameterRecord {
const merged = { ...currentSettings };
for (const [key, serverValue] of Object.entries(serverDefaults)) {
// Only update if user hasn't explicitly overridden this parameter
if (!userOverrides.has(key)) {
merged[key] = this.roundFloatingPoint(serverValue);
}
}
return merged;
}
/**
*
*
* Info
*
*
*/
/**
* Get parameter information including source and values.
* Used by SettingsChatParameterSourceIndicator to display the correct badge
@@ -132,16 +122,6 @@ export class ParameterSyncService {
};
}
/**
* Check if a parameter can be synced from server.
*
* @param key - The parameter key to check
* @returns True if the parameter is in the syncable parameters list
*/
static canSyncParameter(key: string): boolean {
return SYNCABLE_PARAMETERS.some((param) => param.key === key && param.canSync);
}
/**
* Get all syncable parameter keys.
*
@@ -151,6 +131,33 @@ export class ParameterSyncService {
return SYNCABLE_PARAMETERS.filter((param) => param.canSync).map((param) => param.key);
}
/**
* Merge server defaults with current user settings.
* User overrides always take priority only parameters not in `userOverrides`
* set will be updated from server defaults.
*
* @param currentSettings - Current parameter values in the settings store
* @param serverDefaults - Default values extracted from server props
* @param userOverrides - Set of parameter keys explicitly overridden by the user
* @returns Merged parameter record with user overrides preserved
*/
static mergeWithServerDefaults(
currentSettings: ParameterRecord,
serverDefaults: ParameterRecord,
userOverrides: Set<string> = new Set()
): ParameterRecord {
const merged = { ...currentSettings };
for (const [key, serverValue] of Object.entries(serverDefaults)) {
// Only update if user hasn't explicitly overridden this parameter
if (!userOverrides.has(key)) {
merged[key] = this.roundFloatingPoint(serverValue);
}
}
return merged;
}
/**
* Validate a server parameter value against its expected type.
*
@@ -176,44 +183,13 @@ export class ParameterSyncService {
}
/**
* Round floating-point numbers to avoid JavaScript precision issues.
* E.g., 0.1 + 0.2 = 0.30000000000000004 0.3
*
*
* Diff
*
*
* @param value - Parameter value to normalize
* @returns Precision-normalized value
*/
/**
* Create a diff between current settings and server defaults.
* Shows which parameters differ from server values, useful for debugging
* and for the "Reset to defaults" functionality.
*
* @param currentSettings - Current parameter values in the settings store
* @param serverDefaults - Default values extracted from server props
* @returns Record of parameter diffs with current value, server value, and whether they differ
*/
static createParameterDiff(
currentSettings: ParameterRecord,
serverDefaults: ParameterRecord
): Record<string, { current: ParameterValue; server: ParameterValue; differs: boolean }> {
const diff: Record<
string,
{ current: ParameterValue; server: ParameterValue; differs: boolean }
> = {};
for (const key of this.getSyncableParameterKeys()) {
const currentValue = currentSettings[key];
const serverValue = serverDefaults[key];
if (serverValue !== undefined) {
diff[key] = {
current: currentValue,
differs: currentValue !== serverValue,
server: serverValue
};
}
}
return diff;
private static roundFloatingPoint(value: ParameterValue): ParameterValue {
return normalizeFloatingPoint(value) as ParameterValue;
}
}
+8 -8
View File
@@ -1,14 +1,14 @@
/**
* PropsService - Fetches server properties from /props
*
* Returns global server settings and capabilities, including per-model
* modalities in MODEL mode. No reactive state; consumed by serverStore and
* the model props manager.
*/
import { apiFetchWithParams } from '$lib/utils';
export class PropsService {
/**
*
*
* Fetching
*
*
*/
/**
* Fetches global server properties from the `/props` endpoint.
* In MODEL mode, returns modalities for the single loaded model.
@@ -1,3 +1,10 @@
/**
* ReadMediaService - Reads local media files for the read_media tool
*
* Encodes image and audio files as base64 data URLs with the metadata the
* model needs. No reactive state; consumed by toolsStore.
*/
import { ToolsService } from './tools.service';
import {
FILE_EXTENSION_SEPARATOR,
@@ -40,7 +47,7 @@ function fileExtension(path: string): string {
* actually use the result - the server has no idea which model is selected.
*
* @see buildReadMediaToolDefinition in constants/read-media.ts - tool schema sent to the LLM
* @see agenticStore in stores/agentic.svelte.ts - tool dispatch and attachment extraction
* @see agenticStore in stores/agentic/index.svelte.ts - tool dispatch and attachment extraction
*/
export class ReadMediaService {
static async executeTool(
@@ -1,3 +1,10 @@
/**
* RouterService - Builds app route paths
*
* Returns chat and settings route strings from a single source of truth
* (ROUTES). No state.
*/
import { ROUTES } from '$lib/constants';
export class RouterService {
@@ -1,3 +1,10 @@
/**
* Sandbox harness - builds the srcdoc document for the sandboxed iframe
*
* Produces the HTML/CSP/worker shim that runs untrusted model code in an
* opaque origin. Consumed by sandbox.service.
*/
import WORKER_SHIM from './sandbox-worker.js?raw';
import { NEWLINE } from '$lib/constants';
+9 -1
View File
@@ -1,3 +1,11 @@
/**
* SandboxService - Runs untrusted code in a sandboxed worker
*
* Executes model-generated code inside a CSP-restricted, opaque-origin
* iframe worker with output and timeout limits. No reactive state; consumed
* by toolsStore for code-execution tools.
*/
import { buildSandboxHarness } from './sandbox-harness';
import {
NEWLINE,
@@ -8,7 +16,7 @@ import {
SANDBOX_TOOL_NAME,
SANDBOX_TRUNCATION_NOTICE
} from '$lib/constants';
import { settingsStore } from '$lib/stores/settings.svelte';
import { settingsStore } from '$lib/stores/settings/index.svelte';
import type { ToolExecutionResult } from '$lib/types';
/** Cached harnesses keyed by whether nerdamer is included. */
+16 -9
View File
@@ -1,3 +1,10 @@
/**
* ToolsService - Stateless server tools API layer
*
* Fetches the server's /tools listing and streams tool execution results.
* No reactive state; consumed by toolsStore.
*/
import { base } from '$app/paths';
import { API_TOOLS, HEADERS } from '$lib/constants';
import { ToolResponseField } from '$lib/enums';
@@ -7,15 +14,6 @@ import { getJsonHeaders } from '$lib/utils/api-headers';
import { parseSseJsonStream, type SseJsonEvent } from '$lib/utils/sse';
export class ToolsService {
/**
* Fetch the list of server tools from the server.
*
* @returns Array of tool definitions in OpenAI-compatible format
*/
static async list(): Promise<ServerToolInfo[]> {
return apiFetch<ServerToolInfo[]>(API_TOOLS.LIST);
}
/**
* Execute a server tool on the server.
*
@@ -76,6 +74,15 @@ export class ToolsService {
});
}
/**
* Fetch the list of server tools from the server.
*
* @returns Array of tool definitions in OpenAI-compatible format
*/
static async list(): Promise<ServerToolInfo[]> {
return apiFetch<ServerToolInfo[]>(API_TOOLS.LIST);
}
/**
* Stream a server tool's output chunks from the server. The server
* `POST /tools` endpoint with `{stream: true}` emits `data: {"chunk": "..."}`
@@ -0,0 +1,208 @@
/**
* AgenticGates - User interaction gates for the agentic loop
*
* Owns the state the loop waits on between turns: tool permission requests,
* turn-limit continue prompts and queued steering messages. The loop awaits
* requestPermission/requestContinue; the UI resolves them through
* resolvePermission/resolveContinue. Owned by agenticStore, no host coupling.
*/
import { ToolPermissionDecision } from '$lib/enums';
// direct imports between stores, not via the barrel, to avoid circular deps
import { permissionsStore } from '$lib/stores/permissions.svelte';
import { toolsStore } from '$lib/stores/tools.svelte';
import type { DatabaseMessageExtra, SteeringMessage } from '$lib/types';
import { SvelteMap } from 'svelte/reactivity';
export class AgenticGates {
/** Resolve functions for pending continue Promises; nothing derives from this map */
private continueResolvers = new SvelteMap<string, (shouldContinue: boolean) => void>();
/** Dedicated reactive state for pending continue requests (turn limit reached) */
private pendingContinueRequests = new SvelteMap<string, boolean>();
/** Dedicated reactive state for pending permission requests (ensures immediate UI updates) */
private pendingPermissions = new SvelteMap<
string,
{ toolName: string; serverLabel: string } | null
>();
/** Resolve functions for pending permission Promises; nothing derives from this map */
private permissionResolvers = new SvelteMap<string, (decision: ToolPermissionDecision) => void>();
/** Reactive: queued steering messages to inject between turns */
private steeringMessages = new SvelteMap<string, SteeringMessage>();
/**
* Drop all pending gate state for a conversation, e.g. when a flow exits.
*/
clear(conversationId: string): void {
this.pendingPermissions.set(conversationId, null);
this.permissionResolvers.delete(conversationId);
this.pendingContinueRequests.set(conversationId, false);
this.continueResolvers.delete(conversationId);
this.steeringMessages.delete(conversationId);
}
/**
* Clear the pending steering message without consuming it.
*/
clearSteeringMessage(conversationId: string): void {
this.steeringMessages.delete(conversationId);
}
/**
* Consume and return the pending steering message for re-sending.
* Called by chatStore after the agentic flow exits.
*/
consumePendingSteeringMessage(conversationId: string): SteeringMessage | null {
const msg = this.steeringMessages.get(conversationId);
if (!msg) return null;
this.steeringMessages.delete(conversationId);
return msg;
}
getPendingContinueRequest(conversationId: string): boolean {
return this.pendingContinueRequests.get(conversationId) ?? false;
}
getPendingPermissionRequest(
conversationId: string
): { toolName: string; serverLabel: string } | null {
return this.pendingPermissions.get(conversationId) ?? null;
}
getPendingSteeringMessageContent(conversationId: string): string | null {
return this.steeringMessages.get(conversationId)?.content ?? null;
}
getPendingSteeringMessageExtras(conversationId: string): DatabaseMessageExtra[] | undefined {
return this.steeringMessages.get(conversationId)?.extras;
}
hasPendingSteeringMessage(conversationId: string): boolean {
return this.steeringMessages.has(conversationId);
}
/**
* Queue a steering message. When the current agentic turn completes,
* the flow exits and the caller re-sends the message as a normal chat message.
*/
injectSteeringMessage(
conversationId: string,
content: string,
extras?: DatabaseMessageExtra[]
): void {
this.steeringMessages.set(conversationId, { content, extras });
}
async requestContinue(conversationId: string, signal?: AbortSignal): Promise<boolean> {
this.pendingContinueRequests.set(conversationId, true);
return new Promise<boolean>((resolve) => {
if (signal?.aborted) {
this.pendingContinueRequests.set(conversationId, false);
resolve(false);
return;
}
this.continueResolvers.set(conversationId, (shouldContinue) => {
this.pendingContinueRequests.set(conversationId, false);
resolve(shouldContinue);
});
signal?.addEventListener(
'abort',
() => {
const resolver = this.continueResolvers.get(conversationId);
if (resolver) {
this.continueResolvers.delete(conversationId);
this.pendingContinueRequests.set(conversationId, false);
resolve(false);
}
},
{ once: true }
);
});
}
async requestPermission(
conversationId: string,
toolName: string,
serverLabel: string,
signal?: AbortSignal
): Promise<ToolPermissionDecision> {
const permissionKey = toolsStore.getPermissionKey(toolName);
if (permissionKey && permissionsStore.hasTool(permissionKey)) {
return ToolPermissionDecision.ONCE;
}
this.pendingPermissions.set(conversationId, { serverLabel, toolName });
return new Promise<ToolPermissionDecision>((resolve) => {
if (signal?.aborted) {
this.pendingPermissions.set(conversationId, null);
resolve(ToolPermissionDecision.DENY);
return;
}
this.permissionResolvers.set(conversationId, (decision) => {
this.pendingPermissions.set(conversationId, null);
if (decision === ToolPermissionDecision.ALWAYS && permissionKey) {
permissionsStore.allowTool(permissionKey);
} else if (decision === ToolPermissionDecision.ALWAYS_SERVER) {
const serverToolKeys = toolsStore.allTools
.filter((t) =>
t.serverName
? t.serverName === serverLabel
: toolsStore.getToolServerLabel(t.definition.function.name) === serverLabel
)
.map((t) => toolsStore.getPermissionKey(t.definition.function.name)!)
.filter((k): k is string => k !== null);
permissionsStore.allowTools(serverToolKeys);
}
resolve(decision);
});
signal?.addEventListener(
'abort',
() => {
const resolver = this.permissionResolvers.get(conversationId);
if (resolver) {
this.permissionResolvers.delete(conversationId);
this.pendingPermissions.set(conversationId, null);
resolve(ToolPermissionDecision.DENY);
}
},
{ once: true }
);
});
}
resolveContinue(conversationId: string, shouldContinue: boolean): void {
const resolver = this.continueResolvers.get(conversationId);
if (resolver) {
this.continueResolvers.delete(conversationId);
resolver(shouldContinue);
}
}
resolvePermission(conversationId: string, decision: ToolPermissionDecision): void {
const resolver = this.permissionResolvers.get(conversationId);
if (resolver) {
this.permissionResolvers.delete(conversationId);
resolver(decision);
}
}
}
@@ -1,23 +1,13 @@
/**
* agenticStore - Reactive State Store for Agentic Loop Orchestration
* AgenticStore - Multi-turn agentic loop orchestration
*
* Manages multi-turn agentic loop with MCP tools:
* - LLM streaming with tool call detection
* - Tool execution via mcpStore
* - Session state management
* - Turn limit enforcement
* Drives the agentic loop over MCP tools: streams each LLM turn, detects
* tool calls, executes them via mcpStore, and enforces the turn limit. Each
* turn produces one assistant message (with tool_calls) and one tool result
* message per executed call, persisted as separate DB rows.
*
* Each agentic turn produces separate DB messages:
* - One assistant message per LLM turn (with tool_calls if any)
* - One tool result message per tool call execution
*
* **Architecture & Relationships:**
* - **ChatService**: Stateless API layer (sendMessage, streaming)
* - **mcpStore**: MCP connection management and tool execution
* - **agenticStore** (this): Reactive state + business logic
*
* @see ChatService in services/chat.service.ts for API operations
* @see mcpStore in stores/mcp.svelte.ts for MCP operations
* Uses ChatService for streaming and mcpStore for tool execution; waits on
* the permission/continue/steering gates owned by {@link AgenticGates}.
*/
import { DEFAULT_AGENTIC_CONFIG, NEWLINE } from '$lib/constants';
@@ -43,11 +33,11 @@ import { ReadMediaService } from '$lib/services/read-media.service';
import { SandboxService } from '$lib/services/sandbox.service';
import { ToolsService } from '$lib/services/tools.service';
// direct imports between stores, not via the barrel, to avoid circular deps
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { modelsStore } from '$lib/stores/models.svelte';
import { permissionsStore } from '$lib/stores/permissions.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { AgenticGates } from '$lib/stores/agentic/gates.svelte';
import { conversationsStore } from '$lib/stores/conversations/index.svelte';
import { mcpStore } from '$lib/stores/mcp/index.svelte';
import { modelsStore } from '$lib/stores/models/index.svelte';
import { settingsStore } from '$lib/stores/settings/index.svelte';
import { toolsStore } from '$lib/stores/tools.svelte';
import type {
AgenticConfig,
@@ -152,160 +142,45 @@ function toAgenticMessages(messages: ApiChatMessageData[]): AgenticMessage[] {
}
class AgenticStore {
private _sessions = new SvelteMap<string, AgenticSession>();
/** Dedicated reactive state for pending permission requests (ensures immediate UI updates) */
private _pendingPermissions = new SvelteMap<
string,
{ toolName: string; serverLabel: string } | null
>();
/** Non-reactive: stores resolve functions for pending permission Promises */
private _permissionResolvers = new Map<string, (decision: ToolPermissionDecision) => void>();
// permission, continue and steering gates the loop waits on between turns
private gates = new AgenticGates();
private sessions = new SvelteMap<string, AgenticSession>();
/** Dedicated reactive state for pending continue requests (turn limit reached) */
private _pendingContinueRequests = new SvelteMap<string, boolean>();
/** Non-reactive: stores resolve functions for pending continue Promises */
private _continueResolvers = new Map<string, (shouldContinue: boolean) => void>();
/** Reactive: queued steering messages to inject between turns */
private _steeringMessages = new SvelteMap<string, SteeringMessage>();
get isReady(): boolean {
return true;
}
get isAnyRunning(): boolean {
for (const session of this._sessions.values()) {
for (const session of this.sessions.values()) {
if (session.isRunning) return true;
}
return false;
}
getSession(conversationId: string): AgenticSession {
let session = this._sessions.get(conversationId);
if (!session) {
session = createDefaultSession();
this._sessions.set(conversationId, session);
}
return session;
}
private updateSession(conversationId: string, update: Partial<AgenticSession>): void {
const session = this.getSession(conversationId);
this._sessions.set(conversationId, { ...session, ...update });
}
clearSession(conversationId: string): void {
this._sessions.delete(conversationId);
}
getActiveSessions(): Array<{ conversationId: string; session: AgenticSession }> {
const active: Array<{ conversationId: string; session: AgenticSession }> = [];
for (const [conversationId, session] of this._sessions.entries()) {
if (session.isRunning) active.push({ conversationId, session });
}
return active;
}
isRunning(conversationId: string): boolean {
return this._sessions.get(conversationId)?.isRunning ?? false;
}
// read-only: safe to call from derivations, unlike getSession
getLiveLlmTotals(conversationId: string): AgenticSession['liveLlm'] {
return this._sessions.get(conversationId)?.liveLlm ?? null;
}
// read-only: safe to call from derivations, unlike getSession
getFlowRootMessageId(conversationId: string): string | null {
return this._sessions.get(conversationId)?.flowRootMessageId ?? null;
}
currentTurn(conversationId: string): number {
return this._sessions.get(conversationId)?.currentTurn ?? 0;
}
totalToolCalls(conversationId: string): number {
return this._sessions.get(conversationId)?.totalToolCalls ?? 0;
}
lastError(conversationId: string): Error | null {
return this._sessions.get(conversationId)?.lastError ?? null;
}
streamingToolCall(conversationId: string): { name: string; arguments: string } | null {
return this._sessions.get(conversationId)?.streamingToolCall ?? null;
}
executingToolCallId(conversationId: string): string | null {
return this._sessions.get(conversationId)?.executingToolCallId ?? null;
}
pendingPermissionRequest(
conversationId: string
): { toolName: string; serverLabel: string } | null {
return this._pendingPermissions.get(conversationId) ?? null;
}
pendingContinueRequest(conversationId: string): boolean {
return this._pendingContinueRequests.get(conversationId) ?? false;
}
resolveContinue(conversationId: string, shouldContinue: boolean): void {
const resolver = this._continueResolvers.get(conversationId);
if (resolver) {
this._continueResolvers.delete(conversationId);
resolver(shouldContinue);
}
}
resolvePermission(conversationId: string, decision: ToolPermissionDecision): void {
const resolver = this._permissionResolvers.get(conversationId);
if (resolver) {
this._permissionResolvers.delete(conversationId);
resolver(decision);
}
get isReady(): boolean {
return true;
}
clearError(conversationId: string): void {
this.updateSession(conversationId, { lastError: null });
}
hasPendingSteeringMessage(conversationId: string): boolean {
return this._steeringMessages.has(conversationId);
}
pendingSteeringMessageContent(conversationId: string): string | null {
return this._steeringMessages.get(conversationId)?.content ?? null;
}
pendingSteeringMessageExtras(conversationId: string): DatabaseMessageExtra[] | undefined {
return this._steeringMessages.get(conversationId)?.extras;
}
/**
* Queue a steering message. When the current agentic turn completes,
* the flow exits and the caller re-sends the message as a normal chat message.
*/
injectSteeringMessage(
conversationId: string,
content: string,
extras?: DatabaseMessageExtra[]
): void {
this._steeringMessages.set(conversationId, { content, extras });
clearSession(conversationId: string): void {
this.sessions.delete(conversationId);
}
/**
* Clear the pending steering message without consuming it.
*/
clearSteeringMessage(conversationId: string): void {
this._steeringMessages.delete(conversationId);
this.gates.clearSteeringMessage(conversationId);
}
constructor() {
// drop per-conversation session state when the conversation is deleted,
// otherwise every conversation that ever ran a flow leaks a session here
conversationsStore.onConversationsDeleted((convIds) => {
for (const convId of convIds) {
this.sessions.delete(convId);
}
});
}
/**
@@ -313,13 +188,17 @@ class AgenticStore {
* Called by chatStore after the agentic flow exits.
*/
consumePendingSteeringMessage(conversationId: string): SteeringMessage | null {
const msg = this._steeringMessages.get(conversationId);
return this.gates.consumePendingSteeringMessage(conversationId);
}
if (!msg) return null;
getActiveSessions(): Array<{ conversationId: string; session: AgenticSession }> {
const active: Array<{ conversationId: string; session: AgenticSession }> = [];
this._steeringMessages.delete(conversationId);
for (const [conversationId, session] of this.sessions.entries()) {
if (session.isRunning) active.push({ conversationId, session });
}
return msg;
return active;
}
getConfig(settings: SettingsConfigType, perChatOverrides?: McpServerOverride[]): AgenticConfig {
@@ -336,105 +215,91 @@ class AgenticStore {
};
}
private parseToolArguments(args: string | Record<string, unknown>): Record<string, unknown> {
if (typeof args === 'object') return args;
const trimmed = args.trim();
if (trimmed === '') return {};
return JSON.parse(trimmed) as Record<string, unknown>;
getCurrentTurn(conversationId: string): number {
return this.sessions.get(conversationId)?.currentTurn ?? 0;
}
private async requestPermission(
conversationId: string,
toolName: string,
serverLabel: string,
signal?: AbortSignal
): Promise<ToolPermissionDecision> {
const permissionKey = toolsStore.getPermissionKey(toolName);
getExecutingToolCallId(conversationId: string): string | null {
return this.sessions.get(conversationId)?.executingToolCallId ?? null;
}
if (permissionKey && permissionsStore.hasTool(permissionKey)) {
return ToolPermissionDecision.ONCE;
// read-only: safe to call from derivations, unlike getSession
getFlowRootMessageId(conversationId: string): string | null {
return this.sessions.get(conversationId)?.flowRootMessageId ?? null;
}
getLastError(conversationId: string): Error | null {
return this.sessions.get(conversationId)?.lastError ?? null;
}
// read-only: safe to call from derivations, unlike getSession
getLiveLlmTotals(conversationId: string): AgenticSession['liveLlm'] {
return this.sessions.get(conversationId)?.liveLlm ?? null;
}
getPendingContinueRequest(conversationId: string): boolean {
return this.gates.getPendingContinueRequest(conversationId);
}
getPendingPermissionRequest(
conversationId: string
): { toolName: string; serverLabel: string } | null {
return this.gates.getPendingPermissionRequest(conversationId);
}
getPendingSteeringMessageContent(conversationId: string): string | null {
return this.gates.getPendingSteeringMessageContent(conversationId);
}
getPendingSteeringMessageExtras(conversationId: string): DatabaseMessageExtra[] | undefined {
return this.gates.getPendingSteeringMessageExtras(conversationId);
}
getSession(conversationId: string): AgenticSession {
let session = this.sessions.get(conversationId);
if (!session) {
session = createDefaultSession();
this.sessions.set(conversationId, session);
}
this._pendingPermissions.set(conversationId, { serverLabel, toolName });
return new Promise<ToolPermissionDecision>((resolve) => {
if (signal?.aborted) {
this._pendingPermissions.set(conversationId, null);
resolve(ToolPermissionDecision.DENY);
return;
}
this._permissionResolvers.set(conversationId, (decision) => {
this._pendingPermissions.set(conversationId, null);
if (decision === ToolPermissionDecision.ALWAYS && permissionKey) {
permissionsStore.allowTool(permissionKey);
} else if (decision === ToolPermissionDecision.ALWAYS_SERVER) {
const serverToolKeys = toolsStore.allTools
.filter((t) =>
t.serverName
? t.serverName === serverLabel
: toolsStore.getToolServerLabel(t.definition.function.name) === serverLabel
)
.map((t) => toolsStore.getPermissionKey(t.definition.function.name)!)
.filter((k): k is string => k !== null);
permissionsStore.allowTools(serverToolKeys);
}
resolve(decision);
});
signal?.addEventListener(
'abort',
() => {
const resolver = this._permissionResolvers.get(conversationId);
if (resolver) {
this._permissionResolvers.delete(conversationId);
this._pendingPermissions.set(conversationId, null);
resolve(ToolPermissionDecision.DENY);
}
},
{ once: true }
);
});
return session;
}
private async requestContinue(conversationId: string, signal?: AbortSignal): Promise<boolean> {
this._pendingContinueRequests.set(conversationId, true);
getStreamingToolCall(conversationId: string): { name: string; arguments: string } | null {
return this.sessions.get(conversationId)?.streamingToolCall ?? null;
}
return new Promise<boolean>((resolve) => {
if (signal?.aborted) {
this._pendingContinueRequests.set(conversationId, false);
resolve(false);
getTotalToolCalls(conversationId: string): number {
return this.sessions.get(conversationId)?.totalToolCalls ?? 0;
}
return;
}
hasPendingSteeringMessage(conversationId: string): boolean {
return this.gates.hasPendingSteeringMessage(conversationId);
}
this._continueResolvers.set(conversationId, (shouldContinue) => {
this._pendingContinueRequests.set(conversationId, false);
resolve(shouldContinue);
});
/**
* Queue a steering message. When the current agentic turn completes,
* the flow exits and the caller re-sends the message as a normal chat message.
*/
injectSteeringMessage(
conversationId: string,
content: string,
extras?: DatabaseMessageExtra[]
): void {
this.gates.injectSteeringMessage(conversationId, content, extras);
}
signal?.addEventListener(
'abort',
() => {
const resolver = this._continueResolvers.get(conversationId);
isRunning(conversationId: string): boolean {
return this.sessions.get(conversationId)?.isRunning ?? false;
}
if (resolver) {
this._continueResolvers.delete(conversationId);
this._pendingContinueRequests.set(conversationId, false);
resolve(false);
}
},
{ once: true }
);
});
resolveContinue(conversationId: string, shouldContinue: boolean): void {
this.gates.resolveContinue(conversationId, shouldContinue);
}
resolvePermission(conversationId: string, decision: ToolPermissionDecision): void {
this.gates.resolvePermission(conversationId, decision);
}
async runAgenticFlow(params: AgenticFlowParams): Promise<AgenticFlowResult> {
@@ -449,11 +314,7 @@ class AgenticStore {
} = params;
// Clear any pending permissions/continue requests for this conversation when starting a new flow
this._pendingPermissions.set(conversationId, null);
this._permissionResolvers.delete(conversationId);
this._pendingContinueRequests.set(conversationId, false);
this._continueResolvers.delete(conversationId);
this._steeringMessages.delete(conversationId);
this.gates.clear(conversationId);
// Ensure server tools are fetched before checking if agentic is enabled
if (toolsStore.serverTools.length === 0 && !toolsStore.loading) {
@@ -482,26 +343,8 @@ class AgenticStore {
console.log(`[AgenticStore] Starting agentic flow with ${tools.length} tools`);
const normalizedMessages: ApiChatMessageData[] = (
await Promise.all(
messages.map((msg) => {
if ('id' in msg && 'convId' in msg && 'timestamp' in msg)
return ChatService.convertDbMessageToApiChatMessageData(
msg as DatabaseMessage & { extra?: DatabaseMessageExtra[] }
);
return msg as ApiChatMessageData;
})
)
).filter((msg: { role: ChatRole; content: string | ApiChatMessageContentPart[] }) => {
if (msg.role === MessageRole.SYSTEM) {
const content = typeof msg.content === 'string' ? msg.content : '';
return content.trim().length > 0;
}
return true;
});
const normalizedMessages: ApiChatMessageData[] =
await ChatService.normalizeMessagesForApi(messages);
this.updateSession(conversationId, {
currentTurn: 0,
@@ -550,6 +393,30 @@ class AgenticStore {
}
}
private buildAttachmentName(mimeType: string, index: number): string {
const extension = mimeType.startsWith(MimeTypePrefix.AUDIO)
? (AUDIO_MIME_TO_EXTENSION[mimeType] ?? DEFAULT_AUDIO_EXTENSION)
: (IMAGE_MIME_TO_EXTENSION[mimeType] ?? DEFAULT_IMAGE_EXTENSION);
return `${MCP_ATTACHMENT_NAME_PREFIX}-${Date.now()}-${index}.${extension}`;
}
private buildFinalTimings(
capturedTimings: ChatMessageTimings | undefined,
agenticTimings: ChatMessageAgenticTimings
): ChatMessageTimings | undefined {
if (agenticTimings.toolCallsCount === 0) return capturedTimings;
return {
agentic: agenticTimings,
cache_n: capturedTimings?.cache_n,
predicted_ms: capturedTimings?.predicted_ms,
predicted_n: capturedTimings?.predicted_n,
prompt_ms: capturedTimings?.prompt_ms,
prompt_n: capturedTimings?.prompt_n
};
}
private async executeAgenticLoop(params: {
conversationId: string;
messages: ApiChatMessageData[];
@@ -596,7 +463,7 @@ class AgenticStore {
while (true) {
if (turn >= maxTurns) {
// Turn limit reached - ask user whether to continue
const shouldContinue = await this.requestContinue(conversationId, signal);
const shouldContinue = await this.gates.requestContinue(conversationId, signal);
// Yield to allow Svelte to flush the UI update
await new Promise((r) => setTimeout(r, 0));
@@ -769,7 +636,7 @@ class AgenticStore {
// === Steering check: if a user message was queued during this turn, exit the flow.
// The caller (chatStore) will consume the pending message and re-send it normally.
if (this._steeringMessages.has(conversationId)) {
if (this.gates.hasPendingSteeringMessage(conversationId)) {
console.log('[AgenticStore] Steering message detected after turn, exiting agentic flow');
await onAssistantTurnComplete?.(
turnContent,
@@ -847,7 +714,7 @@ class AgenticStore {
}
// Check for pending steering message - skip remaining tool calls
if (this._steeringMessages.has(conversationId)) {
if (this.gates.hasPendingSteeringMessage(conversationId)) {
console.log(
`[AgenticStore] Steering message detected, skipping ${normalizedCalls.length - i} remaining tool call(s)`
);
@@ -872,7 +739,7 @@ class AgenticStore {
const toolName = toolCall.function.name;
const serverLabel = toolsStore.getToolServerLabel(toolName);
// Ask for permission before executing the tool
const permission = await this.requestPermission(
const permission = await this.gates.requestPermission(
conversationId,
toolName,
serverLabel,
@@ -959,8 +826,8 @@ class AgenticStore {
executionResult = await ReadMediaService.executeTool(
args,
{
audio: modelsStore.modelSupportsAudio(effectiveModel),
vision: modelsStore.modelSupportsVision(effectiveModel)
audio: modelsStore.props.modelSupportsAudio(effectiveModel),
vision: modelsStore.props.modelSupportsVision(effectiveModel)
},
signal,
conversationsStore.activeConversation?.cwd
@@ -1058,7 +925,7 @@ class AgenticStore {
for (const attachment of attachments) {
if (attachment.type === AttachmentType.AUDIO) {
if (modelsStore.modelSupportsAudio(effectiveModel)) {
if (modelsStore.props.modelSupportsAudio(effectiveModel)) {
contentParts.push({
input_audio: {
data: (attachment as DatabaseMessageExtraAudioFile).base64Data,
@@ -1070,7 +937,7 @@ class AgenticStore {
});
}
} else if (attachment.type === AttachmentType.IMAGE) {
if (modelsStore.modelSupportsVision(effectiveModel)) {
if (modelsStore.props.modelSupportsVision(effectiveModel)) {
contentParts.push({
image_url: {
url: (attachment as DatabaseMessageExtraImageFile).base64Url
@@ -1101,7 +968,7 @@ class AgenticStore {
}
// If tools were interrupted by a steering message, exit now instead of starting another LLM turn
if (this._steeringMessages.has(conversationId)) {
if (this.gates.hasPendingSteeringMessage(conversationId)) {
console.log(
'[AgenticStore] Steering message detected after tool execution, exiting agentic flow'
);
@@ -1114,35 +981,6 @@ class AgenticStore {
}
}
private buildFinalTimings(
capturedTimings: ChatMessageTimings | undefined,
agenticTimings: ChatMessageAgenticTimings
): ChatMessageTimings | undefined {
if (agenticTimings.toolCallsCount === 0) return capturedTimings;
return {
agentic: agenticTimings,
cache_n: capturedTimings?.cache_n,
predicted_ms: capturedTimings?.predicted_ms,
predicted_n: capturedTimings?.predicted_n,
prompt_ms: capturedTimings?.prompt_ms,
prompt_n: capturedTimings?.prompt_n
};
}
private normalizeToolCalls(toolCalls: ApiChatCompletionToolCall[]): AgenticToolCallList {
if (!toolCalls) return [];
return toolCalls.map((call, index) => ({
function: {
arguments: call?.function?.arguments ?? '',
name: call?.function?.name ?? ''
},
id: call?.id ?? `tool_${index}`,
type: (call?.type as ToolCallType.FUNCTION) ?? ToolCallType.FUNCTION
}));
}
private extractBase64Attachments(result: string): {
cleanedResult: string;
attachments: DatabaseMessageExtra[];
@@ -1198,12 +1036,33 @@ class AgenticStore {
return { attachments, cleanedResult: cleanedLines.join(NEWLINE) };
}
private buildAttachmentName(mimeType: string, index: number): string {
const extension = mimeType.startsWith(MimeTypePrefix.AUDIO)
? (AUDIO_MIME_TO_EXTENSION[mimeType] ?? DEFAULT_AUDIO_EXTENSION)
: (IMAGE_MIME_TO_EXTENSION[mimeType] ?? DEFAULT_IMAGE_EXTENSION);
private normalizeToolCalls(toolCalls: ApiChatCompletionToolCall[]): AgenticToolCallList {
if (!toolCalls) return [];
return `${MCP_ATTACHMENT_NAME_PREFIX}-${Date.now()}-${index}.${extension}`;
return toolCalls.map((call, index) => ({
function: {
arguments: call?.function?.arguments ?? '',
name: call?.function?.name ?? ''
},
id: call?.id ?? `tool_${index}`,
type: (call?.type as ToolCallType.FUNCTION) ?? ToolCallType.FUNCTION
}));
}
private parseToolArguments(args: string | Record<string, unknown>): Record<string, unknown> {
if (typeof args === 'object') return args;
const trimmed = args.trim();
if (trimmed === '') return {};
return JSON.parse(trimmed) as Record<string, unknown>;
}
private updateSession(conversationId: string, update: Partial<AgenticSession>): void {
const session = this.getSession(conversationId);
this.sessions.set(conversationId, { ...session, ...update });
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,74 @@
/**
* ChatActivityStore - Conversation activity ledger
*
* Single owner of the "is this conversation doing something" state:
* - `local` - this browser is piping a stream (send, server-stream attach,
* or resume-wait while the owning model loads)
* - `remote` - the backend reports a running session, no local pipe yet
* (global snapshot on mount / visibilitychange)
*
* The union of both drives the sidebar spinners (`loadingConvs`); `local`
* drives the per-conversation loading flags. When a local pipe ends it is
* the authoritative observer of session end, so it also drops the stale
* remote hint in the same call - no cross-owner cleanup, no ghosted
* spinners waiting for the next visibilitychange snapshot.
*
* Composed under chatStore.activity; not exported from the stores barrel.
*/
import { SvelteSet } from 'svelte/reactivity';
export class ChatActivityStore {
/** Convs this browser is piping a stream for (send, attach, resume-wait). */
private local = new SvelteSet<string>();
/** Convs the backend reports as having a running session (snapshot sync). */
private remote = new SvelteSet<string>();
/** Convs with any activity, the union the sidebar spinners render. */
loadingConvs = $derived.by(() => {
const out = new SvelteSet<string>(this.local);
for (const id of this.remote) out.add(id);
return Array.from(out);
});
/**
* Apply a backend snapshot of running sessions (mount / visibilitychange).
* Diffed so unchanged entries do not re-trigger reactivity.
*/
applyRemoteSnapshot(running: Iterable<string>): void {
const next = new SvelteSet<string>(running);
for (const id of Array.from(this.remote)) {
if (!next.has(id)) this.remote.delete(id);
}
for (const id of next) this.remote.add(id);
}
isLocal(convId: string): boolean {
return this.local.has(convId);
}
isRemote(convId: string): boolean {
return this.remote.has(convId);
}
/**
* A local pipe ended for the conv. Also drops the remote hint: the local
* pipe is the authoritative observer of session end, so the sidebar hint
* goes away right away instead of ghosting until the next snapshot.
*/
localEnded(convId: string): void {
this.local.delete(convId);
this.remote.delete(convId);
}
/** A local pipe (send, attach or resume-wait) started for the conv. */
markLocal(convId: string): void {
this.local.add(convId);
}
}
export const chatActivityStore = new ChatActivityStore();
@@ -1,5 +1,5 @@
/**
* contextStatsStore - Context window usage stats for the active conversation
* ContextStatsStore - Context window usage stats for the active conversation
*
* Combines token usage persisted in message timings metadata with
* server-originating data: model context size from /props (modelsStore)
@@ -8,12 +8,17 @@
import { MessageRole } from '$lib/enums';
// direct imports between stores, not via the barrel, to avoid circular deps
import { agenticStore } from '$lib/stores/agentic.svelte';
import { chatStore } from '$lib/stores/chat.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { modelsStore } from '$lib/stores/models.svelte';
import { agenticStore } from '$lib/stores/agentic/index.svelte';
import { chatStore } from '$lib/stores/chat/index.svelte';
import { conversationsStore } from '$lib/stores/conversations/index.svelte';
import { modelsStore } from '$lib/stores/models/index.svelte';
import { serverStore } from '$lib/stores/server.svelte';
import type { ApiProcessingState, ChatMessageTimings, DatabaseMessage } from '$lib/types';
import type {
ApiProcessingState,
ChatMessageAgenticTimings,
ChatMessageTimings,
DatabaseMessage
} from '$lib/types';
interface LiveStats {
freshTokens: number;
@@ -22,14 +27,46 @@ interface LiveStats {
outputTokens: number;
}
function lastAssistantTimings(messages: DatabaseMessage[]): ChatMessageTimings | undefined {
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i];
interface AssistantTimingsSummary {
lastAgenticLlm: ChatMessageAgenticTimings['llm'] | undefined;
lastTimings: ChatMessageTimings | undefined;
cacheTotal: number;
output: number;
outputMs: number;
read: number;
}
if (m.role === MessageRole.ASSISTANT && m.timings) return m.timings;
/**
* One forward pass over the messages computing everything the deriveds
* below need: the last assistant timings (per-turn gauges), the last
* agentic llm totals (cumulative gauge) and the cumulative sums. During
* streaming activeMessages churns every chunk, and each of these used to be
* its own O(n) scan re-run per chunk.
*/
function summarizeAssistantTimings(messages: DatabaseMessage[]): AssistantTimingsSummary {
let lastAgenticLlm: ChatMessageAgenticTimings['llm'] | undefined;
let lastTimings: ChatMessageTimings | undefined;
let read = 0;
let cacheTotal = 0;
let output = 0;
let outputMs = 0;
for (const m of messages) {
if (m.role !== MessageRole.ASSISTANT || !m.timings) continue;
lastTimings = m.timings;
if (m.timings.agentic?.llm?.predicted_n != null) {
lastAgenticLlm = m.timings.agentic.llm;
}
read += m.timings.prompt_n ?? 0;
cacheTotal += m.timings.cache_n ?? 0;
output += m.timings.predicted_n ?? 0;
outputMs += m.timings.predicted_ms ?? 0;
}
return undefined;
return { cacheTotal, lastAgenticLlm, lastTimings, output, outputMs, read };
}
function deriveLiveStats(state: ApiProcessingState | null): LiveStats | null {
@@ -52,83 +89,14 @@ class ContextStatsStore {
// The canonical resolution lives in modelsStore.activeModelId.
activeModelId = $derived(modelsStore.activeModelId);
isActiveModelLoaded = $derived(
this.activeModelId !== null &&
(!serverStore.isRouterMode || modelsStore.isModelLoaded(this.activeModelId))
// shared by currentRead/Fresh/Cache/Output and cumulative so a per-chunk
// churn of activeMessages triggers exactly one scan instead of one per
// derived
private assistantTimings = $derived.by(() =>
summarizeAssistantTimings(conversationsStore.activeMessages as DatabaseMessage[])
);
isActiveModelLoading = $derived(
this.activeModelId !== null && modelsStore.isModelOperationInProgress(this.activeModelId)
);
contextTotal = $derived.by(() => {
void modelsStore.propsCacheVersion;
return this.activeModelId ? modelsStore.getModelContextSize(this.activeModelId) : null;
});
private liveStats = $derived(deriveLiveStats(chatStore.activeProcessingState));
currentRead = $derived.by(() => {
const timings = lastAssistantTimings(conversationsStore.activeMessages as DatabaseMessage[]);
let read = 0;
if (timings) {
read = (timings.prompt_n ?? 0) + (timings.cache_n ?? 0);
}
// live.promptTokens is already the combined reading (prompt + cache),
// so do not also add live.cacheTokens.
if (this.liveStats && this.liveStats.promptTokens > 0) {
read = Math.max(read, this.liveStats.promptTokens);
}
return read;
});
currentFresh = $derived.by(() => {
const timings = lastAssistantTimings(conversationsStore.activeMessages as DatabaseMessage[]);
const fresh = timings?.prompt_n ?? 0;
return Math.max(fresh, this.liveStats?.freshTokens ?? 0);
});
currentCache = $derived.by(() => {
const timings = lastAssistantTimings(conversationsStore.activeMessages as DatabaseMessage[]);
const cached = timings?.cache_n ?? 0;
if (this.liveStats && this.liveStats.promptTokens > 0) {
return Math.max(cached, this.liveStats.cacheTokens);
}
return cached;
});
currentOutput = $derived.by(() => {
if (this.liveStats && this.liveStats.outputTokens > 0) return this.liveStats.outputTokens;
const timings = lastAssistantTimings(conversationsStore.activeMessages as DatabaseMessage[]);
return timings?.predicted_n ?? 0;
});
kvTotal = $derived(this.currentRead + this.currentOutput);
contextUsed = $derived(this.currentRead + this.currentOutput);
contextAvailable = $derived(
this.contextTotal !== null ? this.contextTotal - this.contextUsed : null
);
contextPercent = $derived.by(() => {
if (this.contextTotal === null || this.contextTotal <= 0) return null;
return Math.round((this.contextUsed / this.contextTotal) * 100);
});
private cumulative = $derived.by(() => {
const messages = conversationsStore.activeMessages as DatabaseMessage[];
const convId = conversationsStore.activeConversation?.id;
// A running agentic flow stamps llm totals on messages only when it
// exits, so read its live session totals instead.
@@ -147,51 +115,107 @@ class ContextStatsStore {
};
}
const { cacheTotal, lastAgenticLlm, output, outputMs, read } = this.assistantTimings;
// Agentic sessions stamp the same agentic.llm totals onto every
// assistant message; cache_n is never per-turn so cache_total stays 0.
const agenticMessages = messages.filter(
(m) => m.role === MessageRole.ASSISTANT && m.timings?.agentic?.llm?.predicted_n != null
);
if (agenticMessages.length > 0) {
const llm = agenticMessages[agenticMessages.length - 1].timings!.agentic!.llm;
const output = llm.predicted_n ?? 0;
const outputMs = llm.predicted_ms ?? 0;
const averageTokensPerSecond = outputMs > 0 && output > 0 ? (output / outputMs) * 1000 : null;
if (lastAgenticLlm) {
const averageTokensPerSecond =
lastAgenticLlm.predicted_ms > 0 && lastAgenticLlm.predicted_n > 0
? (lastAgenticLlm.predicted_n / lastAgenticLlm.predicted_ms) * 1000
: null;
return {
averageTokensPerSecond,
cacheTotal: 0,
output,
read: llm.prompt_n ?? 0
output: lastAgenticLlm.predicted_n ?? 0,
read: lastAgenticLlm.prompt_n ?? 0
};
}
let read = 0;
let output = 0;
let outputMs = 0;
let cacheTotal = 0;
for (const m of messages) {
if (m.role !== MessageRole.ASSISTANT || !m.timings) continue;
read += m.timings.prompt_n ?? 0;
cacheTotal += m.timings.cache_n ?? 0;
output += m.timings.predicted_n ?? 0;
outputMs += m.timings.predicted_ms ?? 0;
}
const averageTokensPerSecond = outputMs > 0 && output > 0 ? (output / outputMs) * 1000 : null;
return { averageTokensPerSecond, cacheTotal, output, read };
});
cumulativeRead = $derived(this.cumulative.read);
averageTokensPerSecond = $derived(this.cumulative.averageTokensPerSecond);
cumulativeOutput = $derived(this.cumulative.output);
contextTotal = $derived.by(() => {
void modelsStore.props.cacheVersion;
return this.activeModelId ? modelsStore.props.getModelContextSize(this.activeModelId) : null;
});
private liveStats = $derived(deriveLiveStats(chatStore.processing.activeState));
currentOutput = $derived.by(() => {
if (this.liveStats && this.liveStats.outputTokens > 0) return this.liveStats.outputTokens;
return this.assistantTimings.lastTimings?.predicted_n ?? 0;
});
currentRead = $derived.by(() => {
const timings = this.assistantTimings.lastTimings;
let read = 0;
if (timings) {
read = (timings.prompt_n ?? 0) + (timings.cache_n ?? 0);
}
// live.promptTokens is already the combined reading (prompt + cache),
// so do not also add live.cacheTokens.
if (this.liveStats && this.liveStats.promptTokens > 0) {
read = Math.max(read, this.liveStats.promptTokens);
}
return read;
});
contextUsed = $derived(this.currentRead + this.currentOutput);
contextAvailable = $derived(
this.contextTotal !== null ? this.contextTotal - this.contextUsed : null
);
contextPercent = $derived.by(() => {
if (this.contextTotal === null || this.contextTotal <= 0) return null;
return Math.round((this.contextUsed / this.contextTotal) * 100);
});
cumulativeCacheTotal = $derived(this.cumulative.cacheTotal);
averageTokensPerSecond = $derived(this.cumulative.averageTokensPerSecond);
cumulativeOutput = $derived(this.cumulative.output);
cumulativeRead = $derived(this.cumulative.read);
currentCache = $derived.by(() => {
const cached = this.assistantTimings.lastTimings?.cache_n ?? 0;
if (this.liveStats && this.liveStats.promptTokens > 0) {
return Math.max(cached, this.liveStats.cacheTokens);
}
return cached;
});
currentFresh = $derived.by(() => {
const fresh = this.assistantTimings.lastTimings?.prompt_n ?? 0;
return Math.max(fresh, this.liveStats?.freshTokens ?? 0);
});
isActiveModelLoaded = $derived(
this.activeModelId !== null &&
(!serverStore.isRouterMode || modelsStore.isModelLoaded(this.activeModelId))
);
isActiveModelLoading = $derived(
this.activeModelId !== null && modelsStore.status.isOperationInProgress(this.activeModelId)
);
kvTotal = $derived(this.currentRead + this.currentOutput);
}
export const contextStatsStore = new ContextStatsStore();
@@ -1,3 +1,11 @@
/**
* DraftMessagesStore - Per-conversation input drafts
*
* Keeps in-memory drafts (message text + files) keyed by conversation id,
* plus a dedicated key for the new-chat screen, so the input box restores
* its content when switching conversations.
*/
import { NEW_CHAT_DRAFT_KEY } from '$lib/constants';
interface DraftMessage {
@@ -8,6 +16,12 @@ interface DraftMessage {
class DraftMessagesStore {
private drafts = new Map<string, DraftMessage>();
clearDraftMessage(chatId: string | undefined): void {
const key = chatId ?? NEW_CHAT_DRAFT_KEY;
this.drafts.delete(key);
}
getDraftMessage(chatId: string | undefined): DraftMessage {
const key = chatId ?? NEW_CHAT_DRAFT_KEY;
@@ -23,12 +37,6 @@ class DraftMessagesStore {
this.drafts.delete(key);
}
}
clearDraftMessage(chatId: string | undefined): void {
const key = chatId ?? NEW_CHAT_DRAFT_KEY;
this.drafts.delete(key);
}
}
export const draftMessagesStore = new DraftMessagesStore();
@@ -0,0 +1,794 @@
/**
* ChatMessageFlows - Message-level flows for the active conversation
*
* Owns the operations that mutate chat history and (re)stream a response:
* editing, regeneration, continuation and deletion of messages. Created and
* owned by chatStore; the host exposes the streaming core and the
* per-conversation state setters these flows drive.
*/
import {
ContinueIntentKind,
ErrorDialogType,
MessageRole,
MessageType,
StreamConnectionState
} from '$lib/enums';
import { ChatService } from '$lib/services/chat.service';
import { DatabaseService } from '$lib/services/database.service';
import type { ChatProcessingStore } from '$lib/stores/chat/processing.svelte';
// direct imports between stores, not via the barrel, to avoid circular deps
import { conversationsStore } from '$lib/stores/conversations/index.svelte';
import type {
ChatMessagePromptProgress,
ChatMessageTimings,
DatabaseMessage,
DatabaseMessageExtra,
ErrorDialogState
} from '$lib/types';
import {
classifyContinueIntent,
filterByLeafNodeId,
findDescendantMessages,
findLeafNode,
findMessageById,
isAbortError
} from '$lib/utils';
/**
* The slice of chatStore the flows drive. Kept narrow on purpose so the flows
* cannot reach around the host's full surface; chatStore implements this
* structurally.
*/
export interface ChatFlowsHost {
processing: ChatProcessingStore;
streamConnectionState: StreamConnectionState;
cancelPreEncode(): void;
clearChatStreaming(convId: string, messageId?: string): void;
cleanupStreaming(convId: string): void;
createAssistantMessage(parentId?: string): Promise<DatabaseMessage>;
getApiOptions(): Record<string, unknown>;
getOrCreateAbortController(convId: string): AbortController;
isChatLoadingInternal(convId: string): boolean;
setChatLoading(convId: string, loading: boolean): void;
setChatReasoning(convId: string, reasoning: boolean): void;
setChatStreaming(
convId: string,
response: string,
messageId: string,
model?: string | null
): void;
showErrorDialog(state: ErrorDialogState | null): void;
stopGeneration(): Promise<void>;
streamChatCompletion(
allMessages: DatabaseMessage[],
assistantMessage: DatabaseMessage,
onComplete?: (content: string) => Promise<void>,
onError?: (error: Error) => void,
modelOverride?: string | null,
firstUserMessageContent?: string
): Promise<void>;
}
export class ChatMessageFlows {
constructor(private host: ChatFlowsHost) {}
async continueAssistantMessage(messageId: string): Promise<void> {
const activeConv = conversationsStore.activeConversation;
if (!activeConv || this.host.isChatLoadingInternal(activeConv.id)) return;
const result = this.getMessageByIdWithRole(messageId, MessageRole.ASSISTANT);
if (!result) return;
const { index: idx, message: msg } = result;
// Decide which resume path applies. tool_calls without tool results can
// not be resumed mid sequence by continue_final_message, branch instead.
// tool_calls already paired with tool results need a fresh next turn,
// not a token level continuation of the target assistant.
const intent = classifyContinueIntent(conversationsStore.activeMessages, idx);
if (intent.kind === ContinueIntentKind.RERUN_TURN) {
return this.regenerateMessageWithBranching(messageId);
}
if (intent.kind === ContinueIntentKind.NEXT_TURN) {
return this.continueAsNextAgenticTurn(intent.truncateAfter);
}
try {
this.host.showErrorDialog(null);
this.host.setChatLoading(activeConv.id, true);
this.host.clearChatStreaming(activeConv.id);
const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
const dbMessage = findMessageById(allMessages, messageId);
if (!dbMessage) {
this.host.setChatLoading(activeConv.id, false);
return;
}
const originalContent = dbMessage.content;
const originalReasoning = dbMessage.reasoningContent || '';
// Hand the persisted DatabaseMessage straight to sendMessage so its
// internal converter preserves tool_calls and extras when present.
// Reconstructing a bare {role, content} here would drop those fields
// and break continue_final_message for messages with tool calls.
const contextWithContinue = conversationsStore.activeMessages.slice(0, idx + 1);
let appendedContent = '';
let appendedReasoning = '';
let hasReceivedContent = false;
const updateStreamingContent = (fullContent: string) => {
this.host.setChatStreaming(msg.convId, fullContent, msg.id);
// resolve the row by id on every write, switching to another conv mid continue makes
// this a no op instead of writing positionally into the now displayed conversation
conversationsStore.updateMessageAtIndex(conversationsStore.findMessageIndex(msg.id), {
content: fullContent
});
};
const abortController = this.host.getOrCreateAbortController(msg.convId);
await ChatService.sendMessage(
contextWithContinue,
{
...this.host.getApiOptions(),
continueFinalMessage: true,
onChunk: (chunk: string) => {
appendedContent += chunk;
hasReceivedContent = true;
updateStreamingContent(originalContent + appendedContent);
this.host.setChatReasoning(msg.convId, false);
},
onComplete: async (
finalContent?: string,
reasoningContent?: string,
timings?: ChatMessageTimings
) => {
const finalAppendedContent = hasReceivedContent ? appendedContent : finalContent || '';
const finalAppendedReasoning = hasReceivedContent
? appendedReasoning
: reasoningContent || '';
const fullContent = originalContent + finalAppendedContent;
const fullReasoning = originalReasoning + finalAppendedReasoning || undefined;
await DatabaseService.updateMessage(msg.id, {
content: fullContent,
reasoningContent: fullReasoning,
timestamp: Date.now(),
timings
});
conversationsStore.updateMessageAtIndex(conversationsStore.findMessageIndex(msg.id), {
content: fullContent,
reasoningContent: fullReasoning,
timestamp: Date.now(),
timings
});
conversationsStore.updateConversationTimestamp(msg.convId);
this.host.cleanupStreaming(msg.convId);
},
onCompletionId: (id: string) => {
if (!id) return;
// refresh the message id so a later skip targets the live slot after a continue
conversationsStore.updateMessageAtIndex(conversationsStore.findMessageIndex(msg.id), {
completionId: id
});
DatabaseService.updateMessage(msg.id, { completionId: id }).catch(() => {});
},
onConnectionState: (state: StreamConnectionState) => {
if (msg.convId === conversationsStore.activeConversation?.id) {
this.host.streamConnectionState = state;
}
},
onError: async (error: Error) => {
if (isAbortError(error)) {
if (hasReceivedContent && appendedContent) {
await DatabaseService.updateMessage(msg.id, {
content: originalContent + appendedContent,
reasoningContent: originalReasoning + appendedReasoning || undefined,
timestamp: Date.now()
});
conversationsStore.updateMessageAtIndex(
conversationsStore.findMessageIndex(msg.id),
{
content: originalContent + appendedContent,
reasoningContent: originalReasoning + appendedReasoning || undefined,
timestamp: Date.now()
}
);
}
this.host.cleanupStreaming(msg.convId);
return;
}
console.error('Continue generation error:', error);
// keep whatever was appended so far, the message stays in memory and in DB
await DatabaseService.updateMessage(msg.id, {
content: originalContent + appendedContent,
reasoningContent: originalReasoning + appendedReasoning || undefined,
timestamp: Date.now()
});
conversationsStore.updateMessageAtIndex(conversationsStore.findMessageIndex(msg.id), {
content: originalContent + appendedContent,
reasoningContent: originalReasoning + appendedReasoning || undefined,
timestamp: Date.now()
});
this.host.cleanupStreaming(msg.convId);
this.host.showErrorDialog({
message: error.message,
type: error.name === 'TimeoutError' ? ErrorDialogType.TIMEOUT : ErrorDialogType.SERVER
});
},
onReasoningChunk: (chunk: string) => {
appendedReasoning += chunk;
hasReceivedContent = true;
// mark streaming state so a stop mid-thinking can persist the partial reasoning
this.host.setChatStreaming(msg.convId, originalContent + appendedContent, msg.id);
conversationsStore.updateMessageAtIndex(conversationsStore.findMessageIndex(msg.id), {
reasoningContent: originalReasoning + appendedReasoning
});
this.host.setChatReasoning(msg.convId, true);
},
onTimings: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => {
this.host.processing.applyStreamTimings(timings, promptProgress, msg.convId);
}
},
msg.convId,
abortController.signal
);
} catch (error) {
if (!isAbortError(error)) console.error('Failed to continue message:', error);
if (activeConv) this.host.setChatLoading(activeConv.id, false);
}
}
async deleteMessage(messageId: string): Promise<void> {
const activeConv = conversationsStore.activeConversation;
if (!activeConv) return;
try {
const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
const messageToDelete = findMessageById(allMessages, messageId);
if (!messageToDelete) return;
const currentPath = filterByLeafNodeId(allMessages, activeConv.currNode || '', false);
const isInCurrentPath = currentPath.some((m) => m.id === messageId);
if (isInCurrentPath && messageToDelete.parent) {
const siblings = allMessages.filter(
(m) => m.parent === messageToDelete.parent && m.id !== messageId
);
if (siblings.length > 0) {
const latestSibling = siblings.reduce((latest, sibling) =>
sibling.timestamp > latest.timestamp ? sibling : latest
);
await conversationsStore.updateCurrentNode(findLeafNode(allMessages, latestSibling.id));
} else if (messageToDelete.parent) {
await conversationsStore.updateCurrentNode(
findLeafNode(allMessages, messageToDelete.parent)
);
}
}
await DatabaseService.deleteMessageCascading(activeConv.id, messageId);
await conversationsStore.refreshActiveMessages();
conversationsStore.updateConversationTimestamp();
} catch (error) {
console.error('Failed to delete message:', error);
}
}
async editAssistantMessage(
messageId: string,
newContent: string,
shouldBranch: boolean
): Promise<void> {
const activeConv = conversationsStore.activeConversation;
if (!activeConv || this.host.isChatLoadingInternal(activeConv.id)) return;
const result = this.getMessageByIdWithRole(messageId, MessageRole.ASSISTANT);
if (!result) return;
const { index: idx, message: msg } = result;
try {
if (shouldBranch) {
const newMessage = await DatabaseService.createMessageBranch(
{
children: [],
content: newContent,
convId: msg.convId,
model: msg.model,
role: msg.role,
timestamp: Date.now(),
toolCalls: msg.toolCalls || '',
type: msg.type
},
msg.parent!
);
await conversationsStore.updateCurrentNode(newMessage.id);
} else {
await DatabaseService.updateMessage(msg.id, { content: newContent });
conversationsStore.updateMessageAtIndex(idx, { content: newContent });
}
conversationsStore.updateConversationTimestamp();
await conversationsStore.refreshActiveMessages();
} catch (error) {
console.error('Failed to edit assistant message:', error);
}
}
async editMessageWithBranching(
messageId: string,
newContent: string,
newExtras?: DatabaseMessageExtra[]
): Promise<void> {
const activeConv = conversationsStore.activeConversation;
if (!activeConv || this.host.isChatLoadingInternal(activeConv.id)) return;
let result = this.getMessageByIdWithRole(messageId, MessageRole.USER);
if (!result) result = this.getMessageByIdWithRole(messageId, MessageRole.SYSTEM);
if (!result) return;
const { index: idx, message: msg } = result;
try {
const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
const isFirstUserMessage =
msg.role === MessageRole.USER && rootMessage && msg.parent === rootMessage.id;
const extrasToUse =
newExtras !== undefined
? JSON.parse(JSON.stringify(newExtras))
: msg.extra
? JSON.parse(JSON.stringify(msg.extra))
: undefined;
let messageIdForResponse: string;
const dbMsg = findMessageById(allMessages, msg.id);
const hasChildren = dbMsg ? dbMsg.children.length > 0 : msg.children.length > 0;
if (!hasChildren) {
// No responses after this message - update in place instead of branching
const updates: Partial<DatabaseMessage> = {
content: newContent,
extra: extrasToUse,
timestamp: Date.now()
};
await DatabaseService.updateMessage(msg.id, updates);
conversationsStore.updateMessageAtIndex(idx, updates);
messageIdForResponse = msg.id;
} else {
// Has children - create a new branch as sibling
const parentId = msg.parent || rootMessage?.id;
if (!parentId) return;
const newMessage = await DatabaseService.createMessageBranch(
{
children: [],
content: newContent,
convId: msg.convId,
extra: extrasToUse,
model: msg.model,
role: msg.role,
timestamp: Date.now(),
toolCalls: msg.toolCalls || '',
type: msg.type
},
parentId
);
await conversationsStore.updateCurrentNode(newMessage.id);
messageIdForResponse = newMessage.id;
}
conversationsStore.updateConversationTimestamp();
if (isFirstUserMessage && newContent.trim())
await conversationsStore.applyTitleFromContent(activeConv.id, newContent);
await conversationsStore.refreshActiveMessages();
if (msg.role === MessageRole.USER)
await this.generateResponseForMessage(messageIdForResponse);
} catch (error) {
console.error('Failed to edit message with branching:', error);
}
}
async editUserMessagePreserveResponses(
messageId: string,
newContent: string,
newExtras?: DatabaseMessageExtra[]
): Promise<void> {
const activeConv = conversationsStore.activeConversation;
if (!activeConv) return;
const result = this.getMessageByIdWithRole(messageId, MessageRole.USER);
if (!result) return;
const { index: idx, message: msg } = result;
try {
const updateData: Partial<DatabaseMessage> = { content: newContent };
if (newExtras !== undefined) updateData.extra = JSON.parse(JSON.stringify(newExtras));
await DatabaseService.updateMessage(messageId, updateData);
conversationsStore.updateMessageAtIndex(idx, updateData);
const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
if (rootMessage && msg.parent === rootMessage.id && newContent.trim()) {
await conversationsStore.applyTitleFromContent(activeConv.id, newContent);
}
conversationsStore.updateConversationTimestamp();
} catch (error) {
console.error('Failed to edit user message:', error);
}
}
async getDeletionInfo(messageId: string): Promise<{
totalCount: number;
userMessages: number;
assistantMessages: number;
messageTypes: string[];
}> {
const activeConv = conversationsStore.activeConversation;
if (!activeConv)
return { assistantMessages: 0, messageTypes: [], totalCount: 0, userMessages: 0 };
const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
const messageToDelete = findMessageById(allMessages, messageId);
// For system messages, don't count descendants as they will be preserved (reparented to root)
if (messageToDelete?.role === MessageRole.SYSTEM) {
const messagesToDelete = allMessages.filter((m) => m.id === messageId);
let assistantMessages = 0,
userMessages = 0;
const messageTypes: string[] = [];
for (const msg of messagesToDelete) {
if (msg.role === MessageRole.USER) {
userMessages++;
if (!messageTypes.includes('user message')) messageTypes.push('user message');
} else if (msg.role === MessageRole.ASSISTANT) {
assistantMessages++;
if (!messageTypes.includes('assistant response')) messageTypes.push('assistant response');
}
}
return { assistantMessages, messageTypes, totalCount: 1, userMessages };
}
const descendants = findDescendantMessages(allMessages, messageId);
const allToDelete = [messageId, ...descendants];
const messagesToDelete = allMessages.filter((m) => allToDelete.includes(m.id));
let assistantMessages = 0,
userMessages = 0;
const messageTypes: string[] = [];
for (const msg of messagesToDelete) {
if (msg.role === MessageRole.USER) {
userMessages++;
if (!messageTypes.includes('user message')) messageTypes.push('user message');
} else if (msg.role === MessageRole.ASSISTANT) {
assistantMessages++;
if (!messageTypes.includes('assistant response')) messageTypes.push('assistant response');
}
}
return { assistantMessages, messageTypes, totalCount: allToDelete.length, userMessages };
}
async regenerateMessage(messageId: string): Promise<void> {
const activeConv = conversationsStore.activeConversation;
if (!activeConv || this.host.isChatLoadingInternal(activeConv.id)) return;
this.host.cancelPreEncode();
const result = this.getMessageByIdWithRole(messageId, MessageRole.ASSISTANT);
if (!result) return;
const { index: messageIndex } = result;
try {
const messagesToRemove = conversationsStore.activeMessages.slice(messageIndex);
await DatabaseService.deleteMessageCascading(activeConv.id, messagesToRemove[0].id);
conversationsStore.sliceActiveMessages(messageIndex);
conversationsStore.updateConversationTimestamp();
this.host.setChatLoading(activeConv.id, true);
this.host.clearChatStreaming(activeConv.id);
const parentMessageId =
conversationsStore.activeMessages.length > 0
? conversationsStore.activeMessages[conversationsStore.activeMessages.length - 1].id
: undefined;
const assistantMessage = await this.host.createAssistantMessage(parentMessageId);
conversationsStore.addMessageToActive(assistantMessage);
await this.host.streamChatCompletion(
conversationsStore.activeMessages.slice(0, -1),
assistantMessage
);
} catch (error) {
if (!isAbortError(error)) console.error('Failed to regenerate message:', error);
this.host.setChatLoading(activeConv?.id || '', false);
}
}
async regenerateMessageWithBranching(messageId: string, modelOverride?: string): Promise<void> {
const activeConv = conversationsStore.activeConversation;
if (!activeConv || this.host.isChatLoadingInternal(activeConv.id)) return;
this.host.cancelPreEncode();
try {
const idx = conversationsStore.findMessageIndex(messageId);
if (idx === -1) return;
const msg = conversationsStore.activeMessages[idx];
if (msg.role !== MessageRole.ASSISTANT) return;
const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
const parentMessage = findMessageById(allMessages, msg.parent);
if (!parentMessage) return;
this.host.setChatLoading(activeConv.id, true);
this.host.clearChatStreaming(activeConv.id);
const newAssistantMessage = await DatabaseService.createMessageBranch(
{
children: [],
content: '',
convId: msg.convId,
model: null,
role: msg.role,
timestamp: Date.now(),
toolCalls: '',
type: msg.type
},
parentMessage.id
);
await conversationsStore.updateCurrentNode(newAssistantMessage.id);
conversationsStore.updateConversationTimestamp();
await conversationsStore.refreshActiveMessages();
const conversationPath = filterByLeafNodeId(
allMessages,
parentMessage.id,
false
) as DatabaseMessage[];
const modelToUse = modelOverride || msg.model || undefined;
await this.host.streamChatCompletion(
conversationPath,
newAssistantMessage,
undefined,
undefined,
modelToUse
);
} catch (error) {
if (!isAbortError(error))
console.error('Failed to regenerate message with branching:', error);
this.host.setChatLoading(activeConv?.id || '', false);
}
}
async updateMessage(messageId: string, newContent: string): Promise<void> {
const activeConv = conversationsStore.activeConversation;
if (!activeConv) return;
if (this.host.isChatLoadingInternal(activeConv.id)) await this.host.stopGeneration();
const result = this.getMessageByIdWithRole(messageId, MessageRole.USER);
if (!result) return;
const { index: messageIndex, message: messageToUpdate } = result;
const originalContent = messageToUpdate.content;
try {
const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
const isFirstUserMessage = rootMessage && messageToUpdate.parent === rootMessage.id;
conversationsStore.updateMessageAtIndex(messageIndex, { content: newContent });
await DatabaseService.updateMessage(messageId, { content: newContent });
if (isFirstUserMessage && newContent.trim())
await conversationsStore.applyTitleFromContent(activeConv.id, newContent);
const messagesToRemove = conversationsStore.activeMessages.slice(messageIndex + 1);
if (messagesToRemove.length > 0)
await DatabaseService.deleteMessageCascading(activeConv.id, messagesToRemove[0].id);
conversationsStore.sliceActiveMessages(messageIndex + 1);
conversationsStore.updateConversationTimestamp();
this.host.setChatLoading(activeConv.id, true);
this.host.clearChatStreaming(activeConv.id);
const assistantMessage = await this.host.createAssistantMessage();
conversationsStore.addMessageToActive(assistantMessage);
await conversationsStore.updateCurrentNode(assistantMessage.id);
await this.host.streamChatCompletion(
conversationsStore.activeMessages.slice(0, -1),
assistantMessage,
undefined,
() => {
conversationsStore.updateMessageAtIndex(conversationsStore.findMessageIndex(messageId), {
content: originalContent
});
}
);
} catch (error) {
if (!isAbortError(error)) console.error('Failed to update message:', error);
}
}
/**
* Open a fresh assistant turn anchored at the last tool result of a resolved
* agentic round and let streamChatCompletion route through runAgenticFlow.
* Used by continueAssistantMessage when classifyContinueIntent returns
* next_turn, meaning the target assistant already has its tool_calls paired
* with trailing tool results and the next thing to generate is a brand new
* turn rather than a token level continuation.
*/
private async continueAsNextAgenticTurn(anchorIndex: number): Promise<void> {
const activeConv = conversationsStore.activeConversation;
if (!activeConv) return;
const anchor = conversationsStore.activeMessages[anchorIndex];
if (!anchor) return;
this.host.cancelPreEncode();
this.host.setChatLoading(activeConv.id, true);
this.host.clearChatStreaming(activeConv.id);
try {
const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
const anchorMessage = findMessageById(allMessages, anchor.id);
if (!anchorMessage) {
this.host.setChatLoading(activeConv.id, false);
return;
}
const newAssistantMessage = await DatabaseService.createMessageBranch(
{
children: [],
content: '',
convId: activeConv.id,
model: null,
role: MessageRole.ASSISTANT,
timestamp: Date.now(),
toolCalls: '',
type: MessageType.TEXT
},
anchorMessage.id
);
await conversationsStore.updateCurrentNode(newAssistantMessage.id);
conversationsStore.updateConversationTimestamp();
await conversationsStore.refreshActiveMessages();
const conversationPath = filterByLeafNodeId(
allMessages,
anchorMessage.id,
false
) as DatabaseMessage[];
await this.host.streamChatCompletion(conversationPath, newAssistantMessage);
} catch (error) {
if (!isAbortError(error)) console.error('Failed to continue agentic turn:', error);
this.host.setChatLoading(activeConv.id, false);
}
}
private async generateResponseForMessage(userMessageId: string): Promise<void> {
const activeConv = conversationsStore.activeConversation;
if (!activeConv) return;
this.host.showErrorDialog(null);
this.host.setChatLoading(activeConv.id, true);
this.host.clearChatStreaming(activeConv.id);
try {
const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
const conversationPath = filterByLeafNodeId(
allMessages,
userMessageId,
false
) as DatabaseMessage[];
const assistantMessage = await DatabaseService.createMessageBranch(
{
children: [],
content: '',
convId: activeConv.id,
model: null,
role: MessageRole.ASSISTANT,
timestamp: Date.now(),
toolCalls: '',
type: MessageType.TEXT
},
userMessageId
);
conversationsStore.addMessageToActive(assistantMessage);
await this.host.streamChatCompletion(conversationPath, assistantMessage);
} catch (error) {
console.error('Failed to generate response:', error);
this.host.setChatLoading(activeConv.id, false);
}
}
private getMessageByIdWithRole(
messageId: string,
expectedRole?: MessageRole
): { message: DatabaseMessage; index: number } | null {
const index = conversationsStore.findMessageIndex(messageId);
if (index === -1) return null;
const message = conversationsStore.activeMessages[index];
if (expectedRole && message.role !== expectedRole) return null;
return { index, message };
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,188 @@
/**
* chatProcessingStore - Per-conversation processing state
*
* Owns the live processing snapshot shown while a conversation streams:
* token counts, tokens/sec, prompt progress. Updated from stream timings,
* restored from persisted message timings when a conversation loads.
*
* Composed under chatStore.processing; not exported from the stores barrel.
*/
import { MessageRole } from '$lib/enums';
// direct imports between stores, not via the barrel, to avoid circular deps
import { modelsStore } from '$lib/stores/models/index.svelte';
import { serverStore } from '$lib/stores/server.svelte';
import { settingsStore } from '$lib/stores/settings/index.svelte';
import type {
ApiProcessingState,
ChatMessagePromptProgress,
ChatMessageTimings,
DatabaseMessage
} from '$lib/types';
import { SvelteMap } from 'svelte/reactivity';
interface ProcessingTimingData {
cache_n: number;
predicted_n: number;
predicted_per_second: number;
prompt_ms?: number;
prompt_n: number;
prompt_progress?: ChatMessagePromptProgress;
}
export class ChatProcessingStore {
private _activeConversationId = $state<string | null>(null);
private states = new SvelteMap<string, ApiProcessingState>();
/** Processing state of the conversation currently shown in the UI. */
activeState = $derived(
this._activeConversationId ? (this.states.get(this._activeConversationId) ?? null) : null
);
get activeConversationId(): string | null {
return this._activeConversationId;
}
/**
* Applies a stream timings event (tokens/sec + token counts) to the given
* conversation's processing state. Shared by the chat and continue flows.
*/
applyStreamTimings(
timings?: ChatMessageTimings,
promptProgress?: ChatMessagePromptProgress,
conversationId?: string
): void {
const tokensPerSecond =
timings?.predicted_ms && timings?.predicted_n
? (timings.predicted_n / timings.predicted_ms) * 1000
: 0;
this.updateFromTimings(
{
cache_n: timings?.cache_n || 0,
predicted_n: timings?.predicted_n || 0,
predicted_per_second: tokensPerSecond,
prompt_ms: timings?.prompt_ms,
prompt_n: timings?.prompt_n || 0,
prompt_progress: promptProgress
},
conversationId
);
}
getConversationIds(): string[] {
return Array.from(this.states.keys());
}
getState(conversationId: string): ApiProcessingState | null {
return this.states.get(conversationId) ?? null;
}
restoreFromMessages(messages: DatabaseMessage[], conversationId: string): void {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (message.role === MessageRole.ASSISTANT && message.timings) {
this.setState(
conversationId,
this.parseTimingData({
cache_n: message.timings.cache_n || 0,
predicted_n: message.timings.predicted_n || 0,
predicted_per_second:
message.timings.predicted_n && message.timings.predicted_ms
? (message.timings.predicted_n / message.timings.predicted_ms) * 1000
: 0,
prompt_ms: message.timings.prompt_ms,
prompt_n: message.timings.prompt_n || 0
})
);
return;
}
}
}
setActiveConversation(conversationId: string | null): void {
this._activeConversationId = conversationId;
}
/** Passing null clears the state for the conversation. */
setState(conversationId: string, state: ApiProcessingState | null): void {
if (state === null) this.states.delete(conversationId);
else this.states.set(conversationId, state);
}
updateFromTimings(timingData: ProcessingTimingData, conversationId?: string): void {
const targetId = conversationId || this._activeConversationId;
if (targetId) {
this.setState(targetId, this.parseTimingData(timingData));
}
}
private getContextTotal(): number | null {
const activeConvId = this._activeConversationId;
const activeState = activeConvId ? this.getState(activeConvId) : null;
if (activeState && typeof activeState.contextTotal === 'number' && activeState.contextTotal > 0)
return activeState.contextTotal;
if (serverStore.isRouterMode) {
const modelContextSize = modelsStore.selectedModelContextSize;
if (typeof modelContextSize === 'number' && modelContextSize > 0) {
return modelContextSize;
}
} else {
const propsContextSize = serverStore.contextSize;
if (typeof propsContextSize === 'number' && propsContextSize > 0) {
return propsContextSize;
}
}
return null;
}
private parseTimingData(timingData: ProcessingTimingData): ApiProcessingState {
const cacheTokens = timingData.cache_n || 0,
predictedTokens = timingData.predicted_n || 0,
promptMs = timingData.prompt_ms || undefined,
promptTokens = timingData.prompt_n || 0,
tokensPerSecond = timingData.predicted_per_second || 0;
const promptProgress = timingData.prompt_progress;
const contextTotal = this.getContextTotal();
const currentConfig = settingsStore.config;
const outputTokensMax = currentConfig.max_tokens || -1;
const contextUsed = promptTokens + cacheTokens + predictedTokens,
outputTokensUsed = predictedTokens;
const progressCache = promptProgress?.cache || 0,
progressActualDone = (promptProgress?.processed ?? 0) - progressCache,
progressActualTotal = (promptProgress?.total ?? 0) - progressCache;
const progressPercent = promptProgress
? Math.round((progressActualDone / progressActualTotal) * 100)
: undefined;
return {
cacheTokens,
contextTotal,
contextUsed,
hasNextToken: predictedTokens > 0,
outputTokensMax,
outputTokensUsed,
progressPercent,
promptMs,
promptProgress,
promptTokens,
speculative: false,
status: predictedTokens > 0 ? 'generating' : promptProgress ? 'preparing' : 'idle',
temperature: currentConfig.temperature ?? 0.8,
tokensDecoded: predictedTokens,
tokensPerSecond,
tokensRemaining: outputTokensMax - predictedTokens,
topP: currentConfig.top_p ?? 0.95
};
}
}
export const chatProcessingStore = new ChatProcessingStore();
@@ -0,0 +1,494 @@
/**
* ChatStreamManager - Server-side stream sessions for conversations
*
* Owns the attach lifecycle for streams that live on the server: discovery,
* replay from byte 0, and resume retry while the owning model loads. The
* remote-running snapshot it produces feeds the chat activity ledger
* (chatStore.activity), which owns the actual running-conv state. Created
* and owned by chatStore; the host exposes the per-conversation state setters.
*/
import { CONVERSATION_ID_SEPARATOR, STREAM_RESUME_RETRY_MS } from '$lib/constants';
import { MessageRole, MessageType, StreamConnectionState } from '$lib/enums';
import { ChatService } from '$lib/services/chat.service';
import { DatabaseService } from '$lib/services/database.service';
import type { ChatActivityStore } from '$lib/stores/chat/activity.svelte';
import type { ChatProcessingStore } from '$lib/stores/chat/processing.svelte';
// direct imports between stores, not via the barrel, to avoid circular deps
import { conversationsStore } from '$lib/stores/conversations/index.svelte';
import { modelsStore } from '$lib/stores/models/index.svelte';
import type { ApiStreamSession, ChatMessageTimings, DatabaseMessage } from '$lib/types';
import { streamIdentity } from '$lib/utils';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
/**
* The slice of chatStore the manager drives. Kept narrow on purpose so the
* manager cannot reach around the host's full surface; chatStore implements
* this structurally.
*/
export interface ChatStreamHost {
activity: ChatActivityStore;
processing: ChatProcessingStore;
chatStreamingStates: SvelteMap<
string,
{ response: string; messageId: string; model?: string | null }
>;
streamConnectionState: StreamConnectionState;
getOrCreateAbortController(convId: string): AbortController;
setChatLoading(convId: string, loading: boolean): void;
setChatStreaming(
convId: string,
response: string,
messageId: string,
model?: string | null
): void;
clearChatStreaming(convId: string, messageId?: string): void;
}
export class ChatStreamManager {
// in-flight discoverActiveStream guard, keyed by conv id
private discoveringConvs = new SvelteSet<string>();
// convs whose resume waits on a model load: their loading state belongs to the retry loop,
// so discoverActiveStream must not treat it as a live send and bail
private resumePendingConvs = new SvelteSet<string>();
// pending resume retry timers while an owning model loads, one per conv
private resumeRetryTimers = new SvelteMap<string, ReturnType<typeof setTimeout>>();
/** Kill a pending resume retry, e.g. on explicit stop. */
cancelResumeRetry(convId: string): void {
const timer = this.resumeRetryTimers.get(convId);
if (timer !== undefined) {
clearTimeout(timer);
this.resumeRetryTimers.delete(convId);
}
this.resumePendingConvs.delete(convId);
}
constructor(private host: ChatStreamHost) {}
async discoverActiveStream(convId: string): Promise<void> {
if (!convId) return;
if (this.host.chatStreamingStates.has(convId)) return;
if (this.host.activity.isLocal(convId) && !this.resumePendingConvs.has(convId)) return;
// concurrency guard: another discover may already be running for this conv (typical race
// between mount and visibilitychange on tab switch). a second concurrent fetch on the same
// /v1/stream would duplicate every byte into the DB message, this guard bounces it
if (this.discoveringConvs.has(convId)) return;
this.discoveringConvs.add(convId);
try {
// the model is frozen at POST time, rebuild the exact conv::model identity from the
// persisted state so the lookup key matches what the server stored. null means a single
// model conv with no ::suffix, only guess from the dropdown with no persisted state
const localState = ChatService.getStreamState(convId);
const streamId = ChatService.resumeStreamIdentity(
convId,
localState,
modelsStore.selectedModelName
);
// primary path: ask the server which sessions exist for this identity
const serverTarget = await this.probeServerStream(streamId);
if (serverTarget) {
// pass the full server side identity (may carry a ::model suffix) so the GET routes
// straight to the owning session, no probe or fan out
await this.attachServerStream(convId, serverTarget.conversation_id);
return;
}
// fallback: local state remembers an interrupted byte offset for this conv, the server may
// still have a live session matching that identity (we just lost the bytes mid stream). retry
// with the frozen identity, the server probe inside attachServerStream tells us if it exists
if (!localState) {
return;
}
// quiet status probe first: a full attach flips the loading UI on every try, probing
// keeps the retry loop invisible while the owning model is still loading (503)
const status = await ChatService.probeResumeStatus(streamId);
if (status === 503) {
// make the wait visible: the empty assistant row persisted at send time renders
// the processing info, whose model load percentage flows from the models feed
this.resumePendingConvs.add(convId);
this.host.setChatLoading(convId, true);
if (!this.resumeRetryTimers.has(convId)) {
this.resumeRetryTimers.set(
convId,
setTimeout(() => {
this.resumeRetryTimers.delete(convId);
void this.discoverActiveStream(convId);
}, STREAM_RESUME_RETRY_MS)
);
}
return;
}
if (this.resumePendingConvs.delete(convId) && status !== 200) {
// the wait is over without a session to attach, drop the visible loading state
this.host.setChatLoading(convId, false);
}
if (status === 0) {
// transient network failure, the next mount or visibility change retries
return;
}
if (status !== 200) {
// the session is gone (stopped, TTL expired), nothing to resume anymore
ChatService.clearStreamState(convId);
return;
}
await this.attachServerStream(convId, streamId);
// if attachServerStream failed (session gone, TTL expired), clear the local state to avoid retrying forever
if (!this.host.chatStreamingStates.has(convId) && !this.host.activity.isLocal(convId)) {
ChatService.clearStreamState(convId);
}
} finally {
this.discoveringConvs.delete(convId);
}
}
/**
* Model frozen at send time for a stream awaiting resume, from the persisted stream state.
* The load progress indicator targets it after a reload, when the message row has no model
* yet and the dropdown selection may not be restored.
*/
getResumeModel(convId: string): string | null {
return ChatService.getStreamState(convId)?.model ?? null;
}
/**
* Resync the activity ledger's remote set from the backend. Called by the layout at mount and
* on visibilitychange, no polling. A snapshot semantic: stale entries for sessions that
* finalized while the browser was elsewhere are dropped naturally.
*/
async syncRemoteRunningStreams(): Promise<void> {
// the conversations store loads from IndexedDB asynchronously, the +layout onMount caller
// fires before that finishes. read ids straight from the DB so the result does not depend
// on the store init race, and the sidebar spinners light up at first paint for every conv
// the user owns even if it has not been hydrated into the store yet
let ids: string[];
try {
const all = await DatabaseService.getAllConversations();
ids = all.map((c) => c.id).filter((id) => !!id);
} catch (e) {
console.warn('syncRemoteRunningStreams DB read failed:', e);
return;
}
// only ask about conv ids the user already owns
if (ids.length === 0) {
this.host.activity.applyRemoteSnapshot([]);
return;
}
// rebuild the frozen conv::model identity per conv so a session started with a model still
// matches. the server response is mapped back to the bare id below for the sidebar set
const lookupIds = ids.map((id) =>
ChatService.resumeStreamIdentity(id, ChatService.getStreamState(id), null)
);
let sessions: ApiStreamSession[];
try {
sessions = await ChatService.lookupStreamSessions(lookupIds);
} catch (e) {
console.warn('syncRemoteRunningStreams lookup failed:', e);
return;
}
const running = new SvelteSet<string>();
for (const s of sessions) {
if (s && !s.is_done && typeof s.conversation_id === 'string' && s.conversation_id) {
// strip the optional ::model suffix, the sidebar set is keyed by the bare conv id
const sepIdx = s.conversation_id.indexOf(CONVERSATION_ID_SEPARATOR);
const bareId = sepIdx === -1 ? s.conversation_id : s.conversation_id.slice(0, sepIdx);
running.add(bareId);
}
}
this.host.activity.applyRemoteSnapshot(running);
}
private async attachServerStream(convId: string, streamId?: string): Promise<void> {
if (!convId) return;
if (this.host.chatStreamingStates.has(convId)) return;
// flip the spinner immediately, the user sees activity as soon as the conv becomes active
this.host.setChatLoading(convId, true);
// only set the active processing conv if we are looking at it, otherwise a background
// attach would steal the indicator from the conv the user is currently viewing
if (convId === conversationsStore.activeConversation?.id) {
this.host.processing.setActiveConversation(convId);
}
const unlock = () => {
this.host.setChatLoading(convId, false);
this.host.clearChatStreaming(convId);
};
// fetch the replay stream from byte 0, rebuild the assistant message from scratch.
// resolve the server side identity, fall back to streamIdentity when the caller does not
// pass a streamId. probeServerStream returns the full id (with ::model suffix when present)
const id = streamId || streamIdentity(convId, modelsStore.selectedModelName);
let response: Response;
try {
response = await ChatService.fetchStreamReplay(id);
} catch (e) {
console.error(`attachServerStream replay failed for conv ${convId}:`, e);
unlock();
return;
}
// load the target conversation messages by id, not via the active store. when multiple
// attaches run in parallel the active store may reflect another conv and writing through
// its index mixes content across convs (CoT flicker, message bleed). by going through the
// DB we stay isolated, and only mirror into the active store when the attached conv is
// the one currently displayed
let messages: DatabaseMessage[];
try {
messages = await DatabaseService.getConversationMessages(convId);
} catch (e) {
console.error('attachServerStream load messages failed:', e);
unlock();
return;
}
// locate the slot to splice into, create a placeholder assistant message if there is none.
// we use the conv-scoped findLastAssistantIdx helpers, they only depend on the array
let targetIdx = this.findLastAssistantIdx(messages);
if (targetIdx === -1) {
const lastUserIdx = this.findLastUserIdx(messages);
if (lastUserIdx === -1) {
console.warn(
`attachServerStream: conv ${convId} has no user or assistant message, cannot splice`
);
unlock();
return;
}
try {
const placeholder = await DatabaseService.createMessageBranch(
{
children: [],
content: '',
convId,
parent: messages[lastUserIdx].id,
role: MessageRole.ASSISTANT,
timestamp: Date.now(),
toolCalls: '',
type: MessageType.TEXT
} as Omit<DatabaseMessage, 'id'>,
messages[lastUserIdx].id
);
messages = [...messages, placeholder];
targetIdx = messages.length - 1;
// only push into the active store when this conv is the one displayed right now
if (convId === conversationsStore.activeConversation?.id) {
conversationsStore.addMessageToActive(placeholder);
}
} catch (e) {
console.error('attachServerStream placeholder creation failed:', e);
unlock();
return;
}
}
if (targetIdx === -1) {
unlock();
return;
}
const targetMessage = messages[targetIdx];
const targetMessageId = targetMessage.id;
// when the assistant slot already has content, the running session is a continue or
// another append flow and its buffer holds only the appended deltas. preserve the prefix
// and let the replay add to it. when the slot is empty the session buffer holds the whole
// message so we wipe and rebuild from byte 0
const existingContent = targetMessage.content ?? '';
const existingReasoning = targetMessage.reasoningContent ?? '';
const isAppendMode = existingContent.length > 0;
// helper: write to the active store only when the attached conv is currently displayed.
// the lookup by message id is robust to reordering of activeMessages, two parallel attaches
// can no longer step on each other's indices
const writeActive = (updates: Partial<DatabaseMessage>) => {
if (convId !== conversationsStore.activeConversation?.id) {
return;
}
const liveIdx = conversationsStore.findMessageIndex(targetMessageId);
if (liveIdx === -1) return;
conversationsStore.updateMessageAtIndex(liveIdx, updates);
};
if (!isAppendMode) {
writeActive({ content: '', reasoningContent: undefined });
}
// extract the model suffix, the resume calls in handleStreamResponse must reuse the model
// the session was tagged with, not the live dropdown
const sepIdx = id.indexOf(CONVERSATION_ID_SEPARATOR);
const attachedModel: string | null = sepIdx === -1 ? null : id.slice(sepIdx + 2);
this.host.setChatStreaming(convId, existingContent, targetMessageId, attachedModel);
const abortController = this.host.getOrCreateAbortController(convId);
let streamedContent = '';
let streamedReasoningContent = '';
const cleanup = () => {
unlock();
this.host.processing.setState(convId, null);
};
try {
await ChatService.handleStreamResponse(
response,
(chunk: string) => {
streamedContent += chunk;
const displayed = isAppendMode ? existingContent + streamedContent : streamedContent;
writeActive({ content: displayed });
this.host.setChatStreaming(convId, displayed, targetMessageId);
},
async (
finalContent?: string,
reasoningContent?: string,
timings?: ChatMessageTimings,
toolCalls?: string
) => {
const streamed = streamedContent || finalContent || '';
const streamedR = streamedReasoningContent || reasoningContent || '';
const content = isAppendMode ? existingContent + streamed : streamed;
const reasoning = isAppendMode ? existingReasoning + streamedR : streamedR;
// the DB write is the source of truth, mirror to the active store only when
// the conv is currently displayed
await DatabaseService.updateMessage(targetMessageId, {
content,
reasoningContent: reasoning || undefined,
timings,
toolCalls: toolCalls || ''
});
writeActive({
content,
reasoningContent: reasoning || undefined,
timings
});
cleanup();
},
(err: Error) => {
console.error('attachServerStream pipe error:', err);
cleanup();
},
(chunk: string) => {
streamedReasoningContent += chunk;
const displayed = isAppendMode
? existingReasoning + streamedReasoningContent
: streamedReasoningContent;
writeActive({ reasoningContent: displayed });
},
undefined,
undefined,
undefined,
undefined,
convId,
abortController.signal,
(connState: StreamConnectionState) => {
if (convId === conversationsStore.activeConversation?.id) {
this.host.streamConnectionState = connState;
}
},
attachedModel
);
} catch (e) {
console.error('attachServerStream pipe crashed:', e);
cleanup();
}
}
private findLastAssistantIdx(messages: DatabaseMessage[]): number {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === MessageRole.ASSISTANT) return i;
}
return -1;
}
private findLastUserIdx(messages: DatabaseMessage[]): number {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === MessageRole.USER) return i;
}
return -1;
}
/**
* Server side stream discovery, split in three pieces:
*
* probeServerStream(convId) -> hits POST /v1/streams/lookup with the conv id, returns the session to attach
* to or null. Pure read, no side effect, no UI lock. Safe to fire in parallel with anything.
*
* attachServerStream(convId) -> flips the spinner immediately, fetches the replay stream
* from byte 0, finds the assistant slot to splice into (creates a placeholder if the conv has
* no assistant message yet, for cross device or fresh local DB cases), and pipes the SSE bytes
* into the message via handleStreamResponse.
*
* discoverActiveStream(convId) -> probe + attach in one call. Used by callers that do not need
* to overlap the probe with other async work.
*
* The chat page in +page.svelte calls discoverActiveStream once the conversation is active
* (immediately if it already is, after loadConversation settles otherwise), and re-runs it on
* visibilitychange. Attaching only after the conversation is loaded gives the earliest
* possible time to spinner and avoids racing against an empty activeMessages array.
*/
private async probeServerStream(convId: string): Promise<ApiStreamSession | null> {
if (!convId) return null;
let sessions: ApiStreamSession[];
try {
sessions = await ChatService.lookupStreamSessions([convId]);
} catch (e) {
console.warn(`probeServerStream failed for conv ${convId}:`, e);
return null;
}
return ChatService.selectActiveStream(sessions);
}
}
@@ -0,0 +1,254 @@
/**
* ConversationPreferences - Per-chat options with global fallback
*
* Owns the options that resolve per conversation: MCP server overrides,
* reasoning effort, and the working directory. Cwd and reasoning effort are
* buffered as pending state and threaded into the next created conversation
* by the host; MCP server overrides edit the sparse `mcpServerOverrides`
* list on the active row (new-chat toggles edit the server's global flag).
* Created and owned by conversationsStore; the host owns the conversation
* rows these options persist onto.
*/
import { REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY } from '$lib/constants';
import { ReasoningEffort } from '$lib/enums';
import { DatabaseService } from '$lib/services/database.service';
// direct imports between stores, not via the barrel, to avoid circular deps
import { mcpStore } from '$lib/stores/mcp/index.svelte';
import type { McpServerOverride } from '$lib/types/database';
/** Load reasoning effort default from localStorage, DEFAULT defers to the server */
function loadReasoningEffortDefault(): ReasoningEffort {
if (typeof globalThis.localStorage === 'undefined') return ReasoningEffort.DEFAULT;
try {
const raw = localStorage.getItem(REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY);
return (raw as ReasoningEffort) || ReasoningEffort.DEFAULT;
} catch {
return ReasoningEffort.DEFAULT;
}
}
/** Persist reasoning effort default to localStorage */
function saveReasoningEffortDefault(effort: ReasoningEffort): void {
if (typeof globalThis.localStorage === 'undefined') return;
localStorage.setItem(REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY, effort);
}
/**
* The slice of conversationsStore the preferences read and write. Kept narrow
* on purpose so they cannot reach around the host's full surface;
* conversationsStore implements this structurally.
*/
export interface ConversationsPreferencesHost {
activeConversation: DatabaseConversation | null;
conversations: DatabaseConversation[];
applyConversationUpdate(id: string, updates: Partial<DatabaseConversation>): void;
}
export class ConversationPreferences {
/**
* Working directory picked on the empty new-chat screen, before any
* conversation exists. Consumed by `chatStore.sendMessage()`, which
* records it into chat history as a synthetic message on first send.
* Cleared by `loadConversation` and `clearActiveConversation` so a
* stale pick can't bleed onto an unrelated chat.
*/
pendingCwd = $state<string | null>(null);
/** Global (non-conversation-specific) reasoning effort default */
pendingReasoningEffort = $state<ReasoningEffort>(loadReasoningEffortDefault());
constructor(private host: ConversationsPreferencesHost) {}
/**
* Gets the effective override list for the current conversation:
* one entry per configured server, resolved per server. The stored
* per-conversation list is sparse and only holds explicit toggles.
*/
getAllMcpServerOverrides(): McpServerOverride[] {
const overrides = this.host.activeConversation?.mcpServerOverrides;
return mcpStore.getServers().map((s) => {
const override = overrides?.find((o: McpServerOverride) => o.serverId === s.id);
return { enabled: override?.enabled ?? s.enabled, serverId: s.id };
});
}
/**
* Gets the effective MCP server override for a specific server.
* A per-conversation override wins when present; a server without one
* resolves to its `mcpServers[i].enabled` default.
*/
getMcpServerOverride(serverId: string): McpServerOverride | undefined {
const override = this.host.activeConversation?.mcpServerOverrides?.find(
(o: McpServerOverride) => o.serverId === serverId
);
if (override) return override;
return this.getDefaultOverride(serverId);
}
/**
* Gets the effective reasoning effort for the active conversation.
* Returns the conversation override if set, otherwise the global default.
* DEFAULT means no override is sent and the server decides.
*/
getReasoningEffort(): ReasoningEffort {
if (this.host.activeConversation) {
if (this.host.activeConversation.reasoningEffort !== undefined) {
return this.host.activeConversation.reasoningEffort;
}
// conversations created before the tri-state store an explicit
// opt-out only as thinkingEnabled = false
if (this.host.activeConversation.thinkingEnabled === false) {
return ReasoningEffort.OFF;
}
}
return this.pendingReasoningEffort;
}
/** Checks if an MCP server is enabled for the active conversation. */
isMcpServerEnabledForChat(serverId: string): boolean {
const override = this.getMcpServerOverride(serverId);
return override?.enabled ?? false;
}
/** Removes MCP server override for the active conversation. */
async removeMcpServerOverride(serverId: string): Promise<void> {
await this.setMcpServerOverride(serverId, undefined);
}
/** Reload persisted defaults, e.g. when the active conversation is cleared. */
resetPending(): void {
this.pendingReasoningEffort = loadReasoningEffortDefault();
this.pendingCwd = null;
}
/**
* Sets the working directory for the active conversation. Pass `null` or
* an empty string to clear it, which restores the picker's empty state.
*
* On the empty new-chat screen (no active conversation yet), the value
* is buffered into `pendingCwd` so the user can pick before
* sending the first message; `createConversation()` consumes it.
*
* @param value - Absolute server-side path to the working directory, or null to clear
*/
async setCwd(value: string | null): Promise<void> {
const trimmed = value?.trim() || undefined;
// No chat yet - buffer for the first chat the user creates.
if (!this.host.activeConversation) {
this.pendingCwd = trimmed ?? null;
return;
}
this.host.applyConversationUpdate(this.host.activeConversation.id, {
cwd: trimmed
});
await DatabaseService.updateConversation(this.host.activeConversation.id, {
cwd: trimmed
});
this.pendingCwd = null;
}
/**
* Sets or removes MCP server override for the active conversation.
* If no conversation exists, persists `enabled` onto `mcpServers[i].enabled`
* (the single source of truth for new-chat defaults).
*/
async setMcpServerOverride(serverId: string, enabled: boolean | undefined): Promise<void> {
if (!this.host.activeConversation) {
if (enabled !== undefined) {
mcpStore.updateServer(serverId, { enabled });
}
return;
}
// Clone to plain objects to avoid Proxy serialization issues with IndexedDB
const currentOverrides = (this.host.activeConversation.mcpServerOverrides || []).map(
(o: McpServerOverride) => ({
enabled: o.enabled,
serverId: o.serverId
})
);
let newOverrides: McpServerOverride[];
if (enabled === undefined) {
newOverrides = currentOverrides.filter((o: McpServerOverride) => o.serverId !== serverId);
} else {
const existingIndex = currentOverrides.findIndex(
(o: McpServerOverride) => o.serverId === serverId
);
if (existingIndex >= 0) {
newOverrides = [...currentOverrides];
newOverrides[existingIndex] = { enabled, serverId };
} else {
newOverrides = [...currentOverrides, { enabled, serverId }];
}
}
await DatabaseService.updateConversation(this.host.activeConversation.id, {
mcpServerOverrides: newOverrides.length > 0 ? newOverrides : undefined
});
this.host.applyConversationUpdate(this.host.activeConversation.id, {
mcpServerOverrides: newOverrides.length > 0 ? newOverrides : undefined
});
}
/**
* Sets the reasoning effort for the active conversation.
* If no conversation exists, stores the global default.
* @param effort - The effort level ('default' | 'off' | 'low' | 'medium' | 'high' | 'max')
*/
async setReasoningEffort(effort: ReasoningEffort): Promise<void> {
if (!this.host.activeConversation) {
this.pendingReasoningEffort = effort;
saveReasoningEffortDefault(effort);
return;
}
this.host.applyConversationUpdate(this.host.activeConversation.id, {
reasoningEffort: effort
});
await DatabaseService.updateConversation(this.host.activeConversation.id, {
reasoningEffort: effort
});
}
/** Toggles MCP server enabled state for the active conversation. */
async toggleMcpServerForChat(serverId: string): Promise<void> {
const currentEnabled = this.isMcpServerEnabledForChat(serverId);
await this.setMcpServerOverride(serverId, !currentEnabled);
}
/**
* Resolve the default enabled value for a server: its own `enabled`
* flag in `mcpServers`, so the global on/off state lives in one place.
*/
private getDefaultOverride(serverId: string): McpServerOverride | undefined {
const server = mcpStore.getServers().find((s) => s.id === serverId);
if (!server) return undefined;
return { enabled: server.enabled, serverId };
}
}
+2 -2
View File
@@ -34,11 +34,11 @@ class DeviceStore {
readonly isIOSDevice: boolean = false;
/** The Safari browser app on iOS, excluding other iOS browsers and WKWebViews. */
readonly isIOSSafari: boolean = false;
/** PWA standalone mode: the page was launched from the home screen icon. */
isStandalone = $state(false);
/** Any WKWebView context on iOS: in-app browsers, embedded web views, and the
* third-party iOS browsers (all of which share the WKWebView engine). */
readonly isWKWebView: boolean = false;
/** PWA standalone mode: the page was launched from the home screen icon. */
isStandalone = $state(false);
/** OS color scheme preference; the user override lives in settingsStore. */
readonly systemTheme = $state({ isDark: false });
+13 -15
View File
@@ -18,34 +18,32 @@
*/
// CHAT / MESSAGING
export { chatStore } from './chat.svelte';
export { chatStore } from './chat/index.svelte';
export { draftMessagesStore } from './draft-messages.svelte';
// AGENTIC (multi-turn tool orchestration)
export { agenticStore } from './agentic.svelte';
// CONVERSATIONS
export { conversationsStore } from './conversations.svelte';
export { draftMessagesStore } from './chat/drafts.svelte';
// CONTEXT STATS (active conversation context window usage)
export { contextStatsStore } from './context-stats.svelte';
export { contextStatsStore } from './chat/context-stats.svelte';
// AGENTIC (multi-turn tool orchestration)
export { agenticStore } from './agentic/index.svelte';
// CONVERSATIONS
export { conversationsStore } from './conversations/index.svelte';
// MCP
export { mcpStore } from './mcp.svelte';
export { mcpResourceStore } from './mcp-resources.svelte';
export { mcpStore } from './mcp/index.svelte';
// MODELS
export { modelsStore } from './models.svelte';
export { modelsStore } from './models/index.svelte';
// SERVER
export { serverStore } from './server.svelte';
// SETTINGS / UI PREFERENCES
export { settingsStore } from './settings.svelte';
export { settingsStore } from './settings/index.svelte';
export { settingsReferrer } from './settings-referrer.svelte';
export { settingsReferrer } from './settings/referrer.svelte';
export { permissionsStore } from './permissions.svelte';
+3 -3
View File
@@ -13,9 +13,9 @@
*/
// direct imports, not via the barrel, to avoid circular deps
import { conversationsStore } from './conversations.svelte';
import { conversationsStore } from './conversations/index.svelte';
import { permissionsStore } from './permissions.svelte';
import { settingsStore } from './settings.svelte';
import { settingsStore } from './settings/index.svelte';
import { toolsStore } from './tools.svelte';
import { versionStore } from './version.svelte';
import { browser } from '$app/environment';
@@ -33,7 +33,7 @@ export function initStores(): Promise<void> {
permissionsStore.initialize();
toolsStore.initialize();
void versionStore.initialize();
void conversationsStore.init();
void conversationsStore.initialize();
})();
return startup;
@@ -0,0 +1,298 @@
/**
* MCPHealthCheckManager - Health checks for MCP servers
*
* Owns per-server connectivity probes: connection reuse, capability
* snapshots, and promotion of a successful check to an active connection.
* Created and owned by mcpStore; the host owns the connection registry the
* probes draw from and promote into.
*/
import { DEFAULT_MCP_CONFIG } from '$lib/constants';
import { HealthCheckStatus, MCPConnectionPhase, MCPLogLevel } from '$lib/enums';
import { MCPService } from '$lib/services/mcp.service';
import type {
ClientCapabilities,
HealthCheckParams,
HealthCheckState,
MCPCapabilitiesInfo,
MCPConnection,
MCPConnectionLog,
MCPServerConfig,
ServerCapabilities
} from '$lib/types';
import { detectMcpTransportFromUrl } from '$lib/utils';
// module-level so the timestamp is not flagged as reactive state by prefer-svelte-reactivity
function createConnectionErrorLog(message: string): MCPConnectionLog {
return {
level: MCPLogLevel.ERROR,
message: `Connection failed: ${message}`,
phase: MCPConnectionPhase.ERROR,
timestamp: new Date()
};
}
/**
* The slice of mcpStore the probes drive. Kept narrow on purpose so the
* probes cannot reach around the host's full surface; mcpStore implements
* this structurally.
*/
export interface McpHealthHost {
autoReconnect(serverName: string): Promise<void>;
getExistingConnection(serverId: string): MCPConnection | undefined;
getRequestTimeoutMs(): number;
promoteHealthCheckToConnection(serverId: string, connection: MCPConnection): void;
registerServerConfig(name: string, config: MCPServerConfig): void;
removeConnection(serverId: string): void;
}
export class MCPHealthCheckManager {
private _checks = $state<Record<string, HealthCheckState>>({});
/** Raw per-server check states, for host-side capability scans. */
get checks(): Record<string, HealthCheckState> {
return this._checks;
}
clear(serverId: string): void {
const { [serverId]: _removed, ...rest } = this._checks;
this._checks = rest;
}
constructor(private host: McpHealthHost) {}
getState(serverId: string): HealthCheckState {
return this._checks[serverId] ?? { status: HealthCheckStatus.IDLE };
}
hasState(serverId: string): boolean {
return serverId in this._checks && this._checks[serverId].status !== HealthCheckStatus.IDLE;
}
/**
* Run a health check for a server.
* If the server already has an active connection, reuses it instead of creating a new one.
* If promoteToActive is true and server is enabled, the connection will be kept
* and promoted to an active connection instead of being disconnected.
*/
async run(server: HealthCheckParams, promoteToActive = false): Promise<void> {
const existingConnection = this.host.getExistingConnection(server.id);
if (existingConnection) {
// Reuse existing connection - just refresh tools list
try {
const tools = await MCPService.listTools(existingConnection);
const capabilities = this.buildCapabilitiesInfo(
existingConnection.serverCapabilities,
existingConnection.clientCapabilities
);
this.setState(server.id, {
capabilities,
connectionTimeMs: existingConnection.connectionTimeMs,
instructions: existingConnection.instructions,
logs: [],
protocolVersion: existingConnection.protocolVersion,
serverInfo: existingConnection.serverInfo,
status: HealthCheckStatus.SUCCESS,
tools: tools.map((tool) => ({
description: tool.description,
name: tool.name,
title: tool.title
})),
transportType: existingConnection.transportType
});
return;
} catch (error) {
console.warn(
`[MCPStore] Failed to reuse connection for ${server.id}, creating new one:`,
error
);
// Connection may be stale, remove it and create new one
this.host.removeConnection(server.id);
}
}
const trimmedUrl = server.url.trim();
const logs: MCPConnectionLog[] = [];
let currentPhase: MCPConnectionPhase = MCPConnectionPhase.IDLE;
if (!trimmedUrl) {
this.setState(server.id, {
logs: [],
message: 'Please enter a server URL first.',
status: HealthCheckStatus.ERROR
});
return;
}
this.setState(server.id, {
logs: [],
phase: MCPConnectionPhase.TRANSPORT_CREATING,
status: HealthCheckStatus.CONNECTING
});
const timeoutMs = this.host.getRequestTimeoutMs();
const headers = this.parseHeaders(server.headers);
try {
const serverConfig: MCPServerConfig = {
handshakeTimeoutMs: DEFAULT_MCP_CONFIG.connectionTimeoutMs,
headers,
requestTimeoutMs: timeoutMs,
transport: detectMcpTransportFromUrl(trimmedUrl),
url: trimmedUrl,
useProxy: server.useProxy
};
this.host.registerServerConfig(server.id, serverConfig);
const connection = await MCPService.connect(
server.id,
serverConfig,
DEFAULT_MCP_CONFIG.clientInfo,
DEFAULT_MCP_CONFIG.capabilities,
(phase, log) => {
currentPhase = phase;
logs.push(log);
this.setState(server.id, {
logs: [...logs],
phase,
status: HealthCheckStatus.CONNECTING
});
if (phase === MCPConnectionPhase.DISCONNECTED && promoteToActive) {
console.log(
`[MCPStore][${server.id}] Connection lost during health check, starting auto-reconnect`
);
this.host.autoReconnect(server.id);
}
}
);
const tools = connection.tools.map((tool) => ({
description: tool.description,
name: tool.name,
title: tool.title
}));
const capabilities = this.buildCapabilitiesInfo(
connection.serverCapabilities,
connection.clientCapabilities
);
this.setState(server.id, {
capabilities,
connectionTimeMs: connection.connectionTimeMs,
instructions: connection.instructions,
logs,
protocolVersion: connection.protocolVersion,
serverInfo: connection.serverInfo,
status: HealthCheckStatus.SUCCESS,
tools,
transportType: connection.transportType
});
if (promoteToActive && server.enabled) {
this.host.promoteHealthCheckToConnection(server.id, connection);
} else {
await MCPService.disconnect(connection);
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error occurred';
if (logs.at(-1)?.phase !== MCPConnectionPhase.ERROR) {
logs.push(createConnectionErrorLog(message));
}
this.setState(server.id, {
logs,
message,
phase: currentPhase,
status: HealthCheckStatus.ERROR
});
}
}
async runForServers(
servers: {
id: string;
enabled: boolean;
url: string;
headers?: string;
}[],
skipIfChecked = true,
promoteToActive = false
): Promise<void> {
const serversToCheck = skipIfChecked
? servers.filter((s) => !this.hasState(s.id) && s.url.trim())
: servers.filter((s) => s.url.trim());
if (serversToCheck.length === 0) {
return;
}
const BATCH_SIZE = 5;
for (let i = 0; i < serversToCheck.length; i += BATCH_SIZE) {
const batch = serversToCheck.slice(i, i + BATCH_SIZE);
await Promise.allSettled(batch.map((server) => this.run(server, promoteToActive)));
}
}
/**
* Builds capabilities info from server and client capabilities.
*/
private buildCapabilitiesInfo(
serverCaps?: ServerCapabilities,
clientCaps?: ClientCapabilities
): MCPCapabilitiesInfo {
return {
client: {
elicitation: clientCaps?.elicitation
? { form: !!clientCaps.elicitation.form, url: !!clientCaps.elicitation.url }
: undefined,
roots: clientCaps?.roots ? { listChanged: clientCaps.roots.listChanged } : undefined,
sampling: !!clientCaps?.sampling,
tasks: !!clientCaps?.tasks
},
server: {
completions: !!serverCaps?.completions,
logging: !!serverCaps?.logging,
prompts: serverCaps?.prompts ? { listChanged: serverCaps.prompts.listChanged } : undefined,
resources: serverCaps?.resources
? {
listChanged: serverCaps.resources.listChanged,
subscribe: serverCaps.resources.subscribe
}
: undefined,
tasks: !!serverCaps?.tasks,
tools: serverCaps?.tools ? { listChanged: serverCaps.tools.listChanged } : undefined
}
};
}
private parseHeaders(headersJson?: string): Record<string, string> | undefined {
if (!headersJson?.trim()) {
return undefined;
}
try {
const parsed = JSON.parse(headersJson);
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed))
return parsed as Record<string, string>;
} catch {
console.warn('[MCPStore] Failed to parse custom headers JSON:', headersJson);
}
return undefined;
}
private setState(serverId: string, state: HealthCheckState): void {
this._checks = { ...this._checks, [serverId]: state };
}
}
File diff suppressed because it is too large Load Diff
@@ -38,32 +38,40 @@ function generateAttachmentId(): string {
}
class MCPResourceStore {
private _serverResources = $state<SvelteMap<string, MCPServerResources>>(new SvelteMap());
private _cachedResources = $state<SvelteMap<string, MCPCachedResource>>(new SvelteMap());
private _subscriptions = $state<SvelteMap<string, MCPResourceSubscription>>(new SvelteMap());
private _attachments = $state<MCPResourceAttachment[]>([]);
private _cachedResources = $state<SvelteMap<string, MCPCachedResource>>(new SvelteMap());
private _isLoading = $state(false);
private _serverResources = $state<SvelteMap<string, MCPServerResources>>(new SvelteMap());
private _subscriptions = $state<SvelteMap<string, MCPResourceSubscription>>(new SvelteMap());
get serverResources(): Map<string, MCPServerResources> {
return this._serverResources;
}
get cachedResources(): Map<string, MCPCachedResource> {
return this._cachedResources;
}
get subscriptions(): Map<string, MCPResourceSubscription> {
return this._subscriptions;
get attachmentCount(): number {
return this._attachments.length;
}
get attachments(): MCPResourceAttachment[] {
return this._attachments;
}
get cachedResources(): Map<string, MCPCachedResource> {
return this._cachedResources;
}
get hasAttachments(): boolean {
return this._attachments.length > 0;
}
get isLoading(): boolean {
return this._isLoading;
}
get serverResources(): Map<string, MCPServerResources> {
return this._serverResources;
}
get subscriptions(): Map<string, MCPResourceSubscription> {
return this._subscriptions;
}
get totalResourceCount(): number {
let count = 0;
@@ -84,86 +92,183 @@ class MCPResourceStore {
return count;
}
get attachmentCount(): number {
return this._attachments.length;
}
/**
* Add a resource attachment to the current chat context
*/
addAttachment(resource: MCPResourceInfo): MCPResourceAttachment {
const attachment: MCPResourceAttachment = {
id: generateAttachmentId(),
loading: true,
resource
};
get hasAttachments(): boolean {
return this._attachments.length > 0;
this._attachments = [...this._attachments, attachment];
console.log(`[MCPResources] Added attachment: ${resource.uri}`);
return attachment;
}
/**
*
*
* Server Resources Management
*
*
* Register a subscription for a resource
*/
/**
* Set resources for a server (called after listResources)
*/
setServerResources(
serverName: string,
resources: MCPResource[],
templates: MCPResourceTemplate[]
): void {
this._serverResources.set(serverName, {
error: undefined,
lastFetched: new Date(),
loading: false,
resources,
addSubscription(uri: string, serverName: string): void {
this._subscriptions.set(uri, {
serverName,
templates
subscribedAt: new Date(),
uri
});
console.log(
`[MCPResources][${serverName}] Set ${resources.length} resources, ${templates.length} templates`
);
}
/**
* Set loading state for a server's resources
*/
setServerLoading(serverName: string, loading: boolean): void {
const existing = this._serverResources.get(serverName);
const cached = this._cachedResources.get(uri);
if (existing) {
this._serverResources.set(serverName, { ...existing, loading });
} else {
this._serverResources.set(serverName, {
error: undefined,
loading,
resources: [],
serverName,
templates: []
});
if (cached) {
this._cachedResources.set(uri, { ...cached, subscribed: true });
}
console.log(`[MCPResources] Added subscription: ${uri}`);
}
/**
* Set error state for a server's resources
* Cache resource content after reading
*/
setServerError(serverName: string, error: string): void {
const existing = this._serverResources.get(serverName);
cacheResourceContent(resource: MCPResourceInfo, content: MCPResourceContent[]): void {
// Enforce cache size limit
if (this._cachedResources.size >= MCP_RESOURCE_CACHE.MAX_ENTRIES) {
const oldestKey = this._cachedResources.keys().next().value;
if (existing) {
this._serverResources.set(serverName, { ...existing, error, loading: false });
} else {
this._serverResources.set(serverName, {
error,
loading: false,
resources: [],
serverName,
templates: []
});
if (oldestKey) {
this._cachedResources.delete(oldestKey);
}
}
this._cachedResources.set(resource.uri, {
content,
fetchedAt: new Date(),
resource,
subscribed: this._subscriptions.has(resource.uri)
});
console.log(`[MCPResources] Cached content for: ${resource.uri}`);
}
/**
* Get resources for a specific server
* Clear all state (e.g., on full reset)
*/
getServerResources(serverName: string): MCPServerResources | undefined {
return this._serverResources.get(serverName);
clear(): void {
this._serverResources.clear();
this._cachedResources.clear();
this._subscriptions.clear();
this._attachments = [];
this._isLoading = false;
console.log(`[MCPResources] Cleared all state`);
}
/**
* Clear all attachments
*/
clearAttachments(): void {
this._attachments = [];
console.log(`[MCPResources] Cleared all attachments`);
}
/**
* Clear all cached content
*/
clearCache(): void {
this._cachedResources.clear();
console.log(`[MCPResources] Cleared all cached content`);
}
/**
* Clear resources for a server (e.g., when disconnected)
*/
clearServerResources(serverName: string): void {
this._serverResources.delete(serverName);
for (const [uri, cached] of this._cachedResources) {
if (cached.resource.serverName === serverName) {
this._cachedResources.delete(uri);
}
}
for (const [uri, sub] of this._subscriptions) {
if (sub.serverName === serverName) {
this._subscriptions.delete(uri);
}
}
console.log(`[MCPResources][${serverName}] Cleared all resources`);
}
/**
* Find resource info by URI across all servers
*/
findResourceByUri(uri: string): MCPResourceInfo | undefined {
const normalizedUri = normalizeResourceUri(uri);
for (const [serverName, serverRes] of this._serverResources) {
const resource =
serverRes.resources.find((r) => r.uri === uri) ??
serverRes.resources.find((r) => normalizeResourceUri(r.uri) === normalizedUri);
if (resource) {
return {
annotations: resource.annotations,
description: resource.description,
icons: resource.icons,
mimeType: resource.mimeType,
name: resource.name,
serverName,
title: resource.title,
uri: resource.uri
};
}
}
return undefined;
}
/**
* Find server name for a resource URI
*/
findServerForUri(uri: string): string | undefined {
for (const [serverName, serverRes] of this._serverResources) {
if (serverRes.resources.some((r) => r.uri === uri)) {
return serverName;
}
}
return undefined;
}
/**
* Get resource content as text for chat context
* Formats content for inclusion in LLM prompts
*/
formatAttachmentsForContext(): string {
if (this._attachments.length === 0) return '';
const parts: string[] = [];
for (const attachment of this._attachments) {
if (attachment.error) continue;
if (!attachment.content || attachment.content.length === 0) continue;
const resourceName = attachment.resource.title || attachment.resource.name;
const serverName = attachment.resource.serverName;
for (const content of attachment.content) {
if ('text' in content && content.text) {
parts.push(`\n\n--- Resource: ${resourceName} (from ${serverName}) ---\n${content.text}`);
} else if ('blob' in content && content.blob) {
// For binary content, just note it exists
parts.push(
`\n\n--- Resource: ${resourceName} (from ${serverName}) ---\n[${BINARY_CONTENT_LABEL}: ${content.mimeType || RESOURCE_UNKNOWN_TYPE}]`
);
}
}
}
return parts.join('');
}
/**
@@ -215,57 +320,10 @@ class MCPResourceStore {
}
/**
* Clear resources for a server (e.g., when disconnected)
* Get attachment by ID
*/
clearServerResources(serverName: string): void {
this._serverResources.delete(serverName);
// Also clear cached content for this server's resources
for (const [uri, cached] of this._cachedResources) {
if (cached.resource.serverName === serverName) {
this._cachedResources.delete(uri);
}
}
// Clear subscriptions for this server
for (const [uri, sub] of this._subscriptions) {
if (sub.serverName === serverName) {
this._subscriptions.delete(uri);
}
}
console.log(`[MCPResources][${serverName}] Cleared all resources`);
}
/**
*
*
* Resource Content Caching
*
*
*/
/**
* Cache resource content after reading
*/
cacheResourceContent(resource: MCPResourceInfo, content: MCPResourceContent[]): void {
// Enforce cache size limit
if (this._cachedResources.size >= MCP_RESOURCE_CACHE.MAX_ENTRIES) {
// Remove oldest entry
const oldestKey = this._cachedResources.keys().next().value;
if (oldestKey) {
this._cachedResources.delete(oldestKey);
}
}
this._cachedResources.set(resource.uri, {
content,
fetchedAt: new Date(),
resource,
subscribed: this._subscriptions.has(resource.uri)
});
console.log(`[MCPResources] Cached content for: ${resource.uri}`);
getAttachment(attachmentId: string): MCPResourceAttachment | undefined {
return this._attachments.find((att) => att.id === attachmentId);
}
/**
@@ -276,7 +334,6 @@ class MCPResourceStore {
if (!cached) return undefined;
// Check if cache is still valid
const age = Date.now() - cached.fetchedAt.getTime();
if (age > MCP_RESOURCE_CACHE.TTL_MS && !cached.subscribed) {
@@ -290,100 +347,22 @@ class MCPResourceStore {
}
/**
* Invalidate cached content for a resource (e.g., on update notification)
* Get resources for a specific server
*/
invalidateCache(uri: string): void {
this._cachedResources.delete(uri);
console.log(`[MCPResources] Invalidated cache for: ${uri}`);
}
/**
* Clear all cached content
*/
clearCache(): void {
this._cachedResources.clear();
console.log(`[MCPResources] Cleared all cached content`);
}
/**
*
*
* Subscriptions
*
*
*/
/**
* Register a subscription for a resource
*/
addSubscription(uri: string, serverName: string): void {
this._subscriptions.set(uri, {
serverName,
subscribedAt: new Date(),
uri
});
// Update cached resource if exists
const cached = this._cachedResources.get(uri);
if (cached) {
this._cachedResources.set(uri, { ...cached, subscribed: true });
}
console.log(`[MCPResources] Added subscription: ${uri}`);
}
/**
* Remove a subscription for a resource
*/
removeSubscription(uri: string): void {
this._subscriptions.delete(uri);
// Update cached resource if exists
const cached = this._cachedResources.get(uri);
if (cached) {
this._cachedResources.set(uri, { ...cached, subscribed: false });
}
console.log(`[MCPResources] Removed subscription: ${uri}`);
}
/**
* Check if a resource is subscribed
*/
isSubscribed(uri: string): boolean {
return this._subscriptions.has(uri);
}
/**
* Handle resource update notification
*/
handleResourceUpdate(uri: string): void {
// Invalidate cache so next read gets fresh content
this.invalidateCache(uri);
// Update subscription last update time
const sub = this._subscriptions.get(uri);
if (sub) {
this._subscriptions.set(uri, { ...sub, lastUpdate: new Date() });
}
console.log(`[MCPResources] Resource updated: ${uri}`);
getServerResources(serverName: string): MCPServerResources | undefined {
return this._serverResources.get(serverName);
}
/**
* Handle resources list changed notification
*/
handleResourcesListChanged(serverName: string): void {
// Mark server resources as needing refresh
const existing = this._serverResources.get(serverName);
if (existing) {
this._serverResources.set(serverName, {
...existing,
lastFetched: undefined // Mark as stale
lastFetched: undefined
});
}
@@ -399,60 +378,27 @@ class MCPResourceStore {
*/
/**
* Add a resource attachment to the current chat context
* Handle resource update notification
*/
addAttachment(resource: MCPResourceInfo): MCPResourceAttachment {
const attachment: MCPResourceAttachment = {
id: generateAttachmentId(),
loading: true,
resource
};
handleResourceUpdate(uri: string): void {
// Invalidate cache so next read gets fresh content
this.invalidateCache(uri);
this._attachments = [...this._attachments, attachment];
console.log(`[MCPResources] Added attachment: ${resource.uri}`);
const sub = this._subscriptions.get(uri);
return attachment;
if (sub) {
this._subscriptions.set(uri, { ...sub, lastUpdate: new Date() });
}
console.log(`[MCPResources] Resource updated: ${uri}`);
}
/**
* Update attachment with fetched content
* Invalidate cached content for a resource (e.g., on update notification)
*/
updateAttachmentContent(attachmentId: string, content: MCPResourceContent[]): void {
this._attachments = this._attachments.map((att) =>
att.id === attachmentId ? { ...att, content, error: undefined, loading: false } : att
);
}
/**
* Update attachment with error
*/
updateAttachmentError(attachmentId: string, error: string): void {
this._attachments = this._attachments.map((att) =>
att.id === attachmentId ? { ...att, error, loading: false } : att
);
}
/**
* Remove an attachment
*/
removeAttachment(attachmentId: string): void {
this._attachments = this._attachments.filter((att) => att.id !== attachmentId);
console.log(`[MCPResources] Removed attachment: ${attachmentId}`);
}
/**
* Clear all attachments
*/
clearAttachments(): void {
this._attachments = [];
console.log(`[MCPResources] Cleared all attachments`);
}
/**
* Get attachment by ID
*/
getAttachment(attachmentId: string): MCPResourceAttachment | undefined {
return this._attachments.find((att) => att.id === attachmentId);
invalidateCache(uri: string): void {
this._cachedResources.delete(uri);
console.log(`[MCPResources] Invalidated cache for: ${uri}`);
}
/**
@@ -467,12 +413,34 @@ class MCPResourceStore {
}
/**
*
*
* Utility Methods
*
*
* Check if a resource is subscribed
*/
isSubscribed(uri: string): boolean {
return this._subscriptions.has(uri);
}
/**
* Remove an attachment
*/
removeAttachment(attachmentId: string): void {
this._attachments = this._attachments.filter((att) => att.id !== attachmentId);
console.log(`[MCPResources] Removed attachment: ${attachmentId}`);
}
/**
* Remove a subscription for a resource
*/
removeSubscription(uri: string): void {
this._subscriptions.delete(uri);
const cached = this._cachedResources.get(uri);
if (cached) {
this._cachedResources.set(uri, { ...cached, subscribed: false });
}
console.log(`[MCPResources] Removed subscription: ${uri}`);
}
/**
* Set global loading state
@@ -482,88 +450,62 @@ class MCPResourceStore {
}
/**
* Find resource info by URI across all servers
* Set error state for a server's resources
*/
findResourceByUri(uri: string): MCPResourceInfo | undefined {
const normalizedUri = normalizeResourceUri(uri);
setServerError(serverName: string, error: string): void {
const existing = this._serverResources.get(serverName);
for (const [serverName, serverRes] of this._serverResources) {
const resource =
serverRes.resources.find((r) => r.uri === uri) ??
serverRes.resources.find((r) => normalizeResourceUri(r.uri) === normalizedUri);
if (resource) {
return {
annotations: resource.annotations,
description: resource.description,
icons: resource.icons,
mimeType: resource.mimeType,
name: resource.name,
serverName,
title: resource.title,
uri: resource.uri
};
}
if (existing) {
this._serverResources.set(serverName, { ...existing, error, loading: false });
} else {
this._serverResources.set(serverName, {
error,
loading: false,
resources: [],
serverName,
templates: []
});
}
return undefined;
}
/**
* Find server name for a resource URI
* Set loading state for a server's resources
*/
findServerForUri(uri: string): string | undefined {
for (const [serverName, serverRes] of this._serverResources) {
if (serverRes.resources.some((r) => r.uri === uri)) {
return serverName;
}
}
setServerLoading(serverName: string, loading: boolean): void {
const existing = this._serverResources.get(serverName);
return undefined;
if (existing) {
this._serverResources.set(serverName, { ...existing, loading });
} else {
this._serverResources.set(serverName, {
error: undefined,
loading,
resources: [],
serverName,
templates: []
});
}
}
/**
* Clear all state (e.g., on full reset)
* Set resources for a server (called after listResources)
*/
clear(): void {
this._serverResources.clear();
this._cachedResources.clear();
this._subscriptions.clear();
this._attachments = [];
this._isLoading = false;
console.log(`[MCPResources] Cleared all state`);
}
/**
* Get resource content as text for chat context
* Formats content for inclusion in LLM prompts
*/
formatAttachmentsForContext(): string {
if (this._attachments.length === 0) return '';
const parts: string[] = [];
for (const attachment of this._attachments) {
if (attachment.error) continue;
if (!attachment.content || attachment.content.length === 0) continue;
const resourceName = attachment.resource.title || attachment.resource.name;
const serverName = attachment.resource.serverName;
for (const content of attachment.content) {
if ('text' in content && content.text) {
parts.push(`\n\n--- Resource: ${resourceName} (from ${serverName}) ---\n${content.text}`);
} else if ('blob' in content && content.blob) {
// For binary content, just note it exists
parts.push(
`\n\n--- Resource: ${resourceName} (from ${serverName}) ---\n[${BINARY_CONTENT_LABEL}: ${content.mimeType || RESOURCE_UNKNOWN_TYPE}]`
);
}
}
}
return parts.join('');
setServerResources(
serverName: string,
resources: MCPResource[],
templates: MCPResourceTemplate[]
): void {
this._serverResources.set(serverName, {
error: undefined,
lastFetched: new Date(),
loading: false,
resources,
serverName,
templates
});
console.log(
`[MCPResources][${serverName}] Set ${resources.length} resources, ${templates.length} templates`
);
}
/**
@@ -605,6 +547,24 @@ class MCPResourceStore {
return extras;
}
/**
* Update attachment with fetched content
*/
updateAttachmentContent(attachmentId: string, content: MCPResourceContent[]): void {
this._attachments = this._attachments.map((att) =>
att.id === attachmentId ? { ...att, content, error: undefined, loading: false } : att
);
}
/**
* Update attachment with error
*/
updateAttachmentError(attachmentId: string, error: string): void {
this._attachments = this._attachments.map((att) =>
att.id === attachmentId ? { ...att, error, loading: false } : att
);
}
}
export const mcpResourceStore = new MCPResourceStore();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,451 @@
/**
* modelsStore - Model management for MODEL and ROUTER modes
*
* Owns model lists, selection, favorites and load/unload state. Composes the
* per-model props cache (modalities, thinking detection) as
* {@link ModelsStore.props} and the /models/sse status feed as
* {@link ModelsStore.status}; tracks which conversations use which models.
*/
import { FAVORITE_MODELS_LOCALSTORAGE_KEY } from '$lib/constants';
import { ServerModelStatus } from '$lib/enums';
import { ModelsService } from '$lib/services/models.service';
// direct imports between stores, not via the barrel, to avoid circular deps
import { conversationsStore } from '$lib/stores/conversations/index.svelte';
import { type ModelPropsHost, ModelPropsManager } from '$lib/stores/models/props.svelte';
import { type ModelStatusHost, ModelStatusManager } from '$lib/stores/models/status.svelte';
import { serverStore } from '$lib/stores/server.svelte';
import { getConversationModel } from '$lib/utils/conversation-utils';
import { SvelteSet } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
class ModelsStore implements ModelPropsHost, ModelStatusHost {
error = $state<string | null>(null);
favoriteModelIds = $state<Set<string>>(this.loadFavoritesFromStorage());
loading = $state(false);
models = $state<ModelOption[]>([]);
routerModels = $state<ApiModelDataEntry[]>([]);
selectedModelId = $state<string | null>(null);
selectedModelName = $state<string | null>(null);
updating = $state(false);
/** Per-model props cache, modalities and thinking detection, composed here. */
private _props = new ModelPropsManager(this);
/** Load/unload operations and the /models/sse status feed, composed here. */
private _status = new ModelStatusManager(this);
// Dedup concurrent fetch() callers — all awaiters share the same inflight promise.
// Without this, ?model=<name> URL handler races an in-progress fetch and sees an empty list.
private inflightFetch: Promise<void> | null = null;
/**
* Model the active conversation view resolves to. Router mode: the user's
* selection first, then the conversation's own model. Otherwise the single
* served model, from the models list or the server props as a fallback.
*/
get activeModelId(): string | null {
if (!serverStore.isRouterMode) {
return this.models.length > 0 ? this.models[0].model : this.singleModelName;
}
if (this.selectedModelId) {
const selected = this.models.find((m) => m.id === this.selectedModelId);
if (selected) return selected.model;
}
const conversationModel = getConversationModel(conversationsStore.activeMessages);
if (conversationModel) {
const model = this.models.find((m) => m.model === conversationModel);
if (model) return model.model;
}
return null;
}
get loadedModelIds(): string[] {
return this.routerModels
.filter(
(m) =>
m.status.value === ServerModelStatus.LOADED ||
m.status.value === ServerModelStatus.SLEEPING
)
.map((m) => m.id);
}
get props() {
return this._props;
}
get selectedModel(): ModelOption | null {
if (!this.selectedModelId) return null;
return this.models.find((m) => m.id === this.selectedModelId) ?? null;
}
get selectedModelContextSize(): number | null {
if (!this.selectedModelName) return null;
return this.props.getModelContextSize(this.selectedModelName);
}
/**
* Get model name in MODEL mode (single model).
* Extracts from model_path or model_alias from server props.
* In ROUTER mode, returns null (model is per-conversation).
*/
get singleModelName(): string | null {
if (serverStore.isRouterMode) return null;
const props = serverStore.props;
if (props?.model_alias) return props.model_alias;
if (!props?.model_path) return null;
return props.model_path.split(/(\\|\/)/).pop() || null;
}
get status() {
return this._status;
}
clearSelection(): void {
this.selectedModelId = null;
this.selectedModelName = null;
}
/**
* Auto-selects the first available model if none is selected.
* Prioritizes:
* 1. Model from active conversation's last assistant response (if loaded)
* 2. Model from active conversation's last assistant response (if not loaded)
* 3. First loaded model (not from active conversation)
* 4. A favorite model
* 5. First available model
*/
async ensureFirstModelSelected(): Promise<void> {
if (this.selectedModelName) return;
const availableModels = this.getVisibleModels();
if (availableModels.length === 0) return;
// Try to select model from last assistant response first
const lastModel = this.getModelFromLastAssistantResponse();
if (lastModel) {
const lastModelOption = availableModels.find((m) => m.model === lastModel);
if (lastModelOption) {
await this.selectModelById(lastModelOption.id);
if (this.isModelLoaded(lastModel)) {
await this.props.fetchModelProps(lastModel);
}
return;
}
}
// Try a loaded model first
const loadedModel = availableModels.find((m) => this.isModelLoaded(m.model));
if (loadedModel) {
await this.selectModelById(loadedModel.id);
await this.props.fetchModelProps(loadedModel.model);
return;
}
// Try loading a favorite model
const favorite = this.favoriteModelIds.values().next()?.value;
if (favorite) {
await this.selectModelById(favorite);
return;
}
// Fall back to the first available model
await this.selectModelById(availableModels[0].id);
}
/**
* Fetch list of models from server and detect server role.
* Also fetches modalities for MODEL mode (single model).
*/
async fetch(force = false): Promise<void> {
if (this.inflightFetch) return this.inflightFetch;
if (this.models.length > 0 && !force) return;
this.inflightFetch = this.runFetch();
try {
await this.inflightFetch;
} finally {
this.inflightFetch = null;
}
}
/**
* Fetch router models with full metadata (ROUTER mode only).
* No-op in router mode fetch() already calls listRouter() internally.
* Kept for API compatibility (e.g. handleOpenChange dropdown open handler).
*/
async fetchRouterModels(): Promise<void> {
if (!serverStore.isRouterMode) return;
try {
const response = await ModelsService.listRouter();
this.routerModels = response.data;
await this.props.fetchModalitiesForLoadedModels();
const visible = this.getVisibleModels();
if (visible.length === 1 && this.isModelLoaded(visible[0].model)) {
this.selectModelById(visible[0].id);
}
} catch (error) {
console.warn('Failed to fetch router models:', error);
this.routerModels = [];
}
}
findModelById(modelId: string): ModelOption | null {
return this.models.find((model) => model.id === modelId) ?? null;
}
findModelByName(modelName: string): ModelOption | null {
return (
this.models.find(
(model) =>
model.model === modelName || model.id === modelName || model.aliases?.includes(modelName)
) ?? null
);
}
/**
* Gets the model name from the last assistant message in the active conversation.
* Used by both the chat page and settings page to maintain model consistency.
*/
getModelFromLastAssistantResponse(): string | null {
const messages = conversationsStore.activeMessages;
if (!messages || messages.length === 0) return null;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].model) {
return messages[i].model;
}
}
return null;
}
getModelStatus(modelId: string): ServerModelStatus | null {
const model = this.routerModels.find((m) => m.id === modelId);
return model?.status.value ?? null;
}
hasModel(modelName: string): boolean {
return this.models.some((model) => model.model === modelName);
}
isFavorite(modelId: string): boolean {
return this.favoriteModelIds.has(modelId);
}
isModelLoaded(modelId: string): boolean {
const model = this.routerModels.find((m) => m.id === modelId);
return (
model?.status.value === ServerModelStatus.LOADED ||
model?.status.value === ServerModelStatus.SLEEPING
);
}
async selectModelById(modelId: string): Promise<void> {
if (!modelId || this.updating) return;
if (this.selectedModelId === modelId) return;
const option = this.models.find((model) => model.id === modelId);
if (!option) throw new Error('Selected model is not available');
this.updating = true;
this.error = null;
try {
this.selectedModelId = option.id;
this.selectedModelName = option.model;
} finally {
this.updating = false;
}
}
/**
* Select a model by its model name (used for syncing with conversation model).
*/
selectModelByName(modelName: string): void {
const option = this.models.find((model) => model.model === modelName);
if (option) {
this.selectedModelId = option.id;
this.selectedModelName = option.model;
}
}
/**
* Auto-selects the model from the last assistant response if available and loaded.
* Returns true if a model was selected, false otherwise.
*/
async selectModelFromLastAssistantResponse(): Promise<boolean> {
const lastModel = this.getModelFromLastAssistantResponse();
if (!lastModel || this.selectedModelName === lastModel) return false;
const matchingModel = this.models.find((option) => option.model === lastModel);
if (!matchingModel || !this.isModelLoaded(lastModel)) return false;
try {
await this.selectModelById(matchingModel.id);
console.log(`[modelsStore] Automatically selected model: ${lastModel} from last message`);
return true;
} catch (error) {
console.warn('[modelsStore] Failed to automatically select model from last message:', error);
return false;
}
}
toDisplayName(id: string): string {
const segments = id.split(/\\|\//);
const candidate = segments.pop();
return candidate && candidate.trim().length > 0 ? candidate : id;
}
toggleFavorite(modelId: string): void {
const next = new SvelteSet(this.favoriteModelIds);
if (next.has(modelId)) {
next.delete(modelId);
} else {
next.add(modelId);
}
this.favoriteModelIds = next;
try {
localStorage.setItem(FAVORITE_MODELS_LOCALSTORAGE_KEY, JSON.stringify([...next]));
} catch {
toast.error('Failed to save favorite models to local storage');
}
}
/**
* Build ModelOption[] from an API response.
* Both MODEL and ROUTER modes share the same mapping logic;
* they differ only in which endpoint is called.
*/
private buildModelOptions(
response: ApiModelListResponse | ApiRouterModelsListResponse
): ModelOption[] {
return response.data.map((item: ApiModelDataEntry, index: number) => {
const details = response.models?.[index];
const rawCapabilities = Array.isArray(details?.capabilities) ? details?.capabilities : [];
const displayNameSource =
details?.name && details.name.trim().length > 0 ? details.name : item.id;
const modelId = details?.model || item.id;
return {
aliases: item.aliases ?? [],
capabilities: rawCapabilities.filter((value: unknown): value is string => Boolean(value)),
description: details?.description,
details: details?.details,
id: item.id,
meta: item.meta ?? null,
modalities: this.props.buildArchitectureModalities(item.architecture),
model: modelId,
name: this.toDisplayName(displayNameSource),
parsedId: ModelsService.parseModelId(modelId),
tags: item.tags ?? []
};
});
}
/** Fetch models in MODEL mode (single model, standard OpenAI-compatible). */
private async fetchModelModeInternal(): Promise<ModelOption[]> {
const response = await ModelsService.list();
return this.buildModelOptions(response);
}
/**
* Filter to models visible in the UI (ui !== false).
*/
private getVisibleModels(): ModelOption[] {
return this.models.filter((option) => this.props.getModelProps(option.model)?.ui !== false);
}
private loadFavoritesFromStorage(): Set<string> {
try {
const raw = localStorage.getItem(FAVORITE_MODELS_LOCALSTORAGE_KEY);
return raw ? new Set(JSON.parse(raw) as string[]) : new Set();
} catch {
toast.error('Failed to load favorite models from local storage');
return new Set();
}
}
private async runFetch(): Promise<void> {
this.loading = true;
this.error = null;
try {
if (!serverStore.props) {
await serverStore.fetch();
}
const router = serverStore.isRouterMode;
if (router) {
const response = await ModelsService.listRouter();
this.routerModels = response.data;
this.models = this.buildModelOptions(response);
await this.props.fetchModalitiesForLoadedModels();
const visible = this.getVisibleModels();
if (visible.length === 1 && this.isModelLoaded(visible[0].model)) {
this.selectModelById(visible[0].id);
}
} else {
this.models = await this.fetchModelModeInternal();
}
} catch (error) {
this.models = [];
this.error = error instanceof Error ? error.message : 'Failed to load models';
throw error;
} finally {
this.loading = false;
}
}
}
export const modelsStore = new ModelsStore();
@@ -0,0 +1,273 @@
/**
* ModelPropsManager - Per-model props cache, modalities and thinking detection
*
* Owns the /props?model=<id> cache with TTL, the modality views over it,
* and chat-template thinking detection. Created and owned by modelsStore;
* the host owns the model lists that fetched modalities are mirrored onto.
*
* **API Inconsistency Workaround:**
* In MODEL mode, `/props` returns modalities for the single model.
* In ROUTER mode, `/props` has no modalities - must use `/props?model=<id>` per model.
*/
import { MODEL_PROPS_CACHE } from '$lib/constants';
import { FileTypeCategory, ModelModality } from '$lib/enums';
import { PropsService } from '$lib/services/props.service';
// direct imports between stores, not via the barrel, to avoid circular deps
import { serverStore } from '$lib/stores/server.svelte';
// deep imports, not the '$lib/utils' barrel: it re-exports modules that reach back
// into the stores, and going through it here would read a half-built module
import { TTLCache } from '$lib/utils/cache-ttl';
import { detectThinkingSupport } from '$lib/utils/chat-template-thinking-detector';
import { SvelteSet } from 'svelte/reactivity';
/**
* The slice of modelsStore the manager reads. Kept narrow on purpose so it
* cannot reach around the host's full surface; modelsStore implements this
* structurally.
*/
export interface ModelPropsHost {
/** Model rows the manager mirrors fetched modalities onto. */
models: ModelOption[];
readonly selectedModelName: string | null;
readonly loadedModelIds: string[];
isModelLoaded(modelId: string): boolean;
}
export class ModelPropsManager {
/** Version counter for the cache - bumped on writes so $derived consumers recompute. */
cacheVersion = $state(0);
/**
* Model-specific props cache with TTL.
* Key: modelId, Value: props data including modalities.
*/
private cache = new TTLCache<string, ApiLlamaCppServerProps>({
maxEntries: MODEL_PROPS_CACHE.MAX_ENTRIES,
ttlMs: MODEL_PROPS_CACHE.TTL_MS
});
private fetching = new SvelteSet<string>();
/**
* Whether the selected model's chat template supports thinking/reasoning.
* Uses heuristic detection on the model's chat_template from /props.
*
* - MODEL mode: the global /props already describes the single loaded model,
* so its chat_template is used directly and no per-model cache is involved
* - ROUTER mode: fetches /props?model=<id> for the selected model (cached),
* triggering an async fetch if not yet cached
*/
get supportsThinking(): boolean {
if (!serverStore.isRouterMode) {
return detectThinkingSupport(serverStore.props?.chat_template ?? '');
}
const modelId = this.host.selectedModelName;
if (!modelId) return false;
if (!this.cache.get(modelId)) {
this.fetchModelProps(modelId);
}
const props = this.getModelProps(modelId);
return detectThinkingSupport(props?.chat_template ?? '');
}
/** Map the router modalities, the only source available while a model is not loaded. */
buildArchitectureModalities(
architecture: ApiModelDataEntry['architecture']
): ModelModalities | undefined {
if (!architecture) return undefined;
const inputs = architecture.input_modalities;
return {
audio: inputs.includes(FileTypeCategory.AUDIO),
video: inputs.includes(FileTypeCategory.VIDEO),
vision: inputs.includes(FileTypeCategory.IMAGE)
};
}
/**
* Check if a specific model supports thinking.
* In MODEL mode the global /props describes the single loaded model.
* In ROUTER mode, fetches model props if not cached.
*/
checkModelSupportsThinking(modelId: string): boolean {
if (!serverStore.isRouterMode) {
return detectThinkingSupport(serverStore.props?.chat_template ?? '');
}
if (!modelId) return false;
if (!this.cache.get(modelId)) {
this.fetchModelProps(modelId);
}
const props = this.getModelProps(modelId);
return detectThinkingSupport(props?.chat_template ?? '');
}
constructor(private host: ModelPropsHost) {}
/** Fetch modalities for all loaded models from /props endpoint. */
async fetchModalitiesForLoadedModels(): Promise<void> {
const loadedModelIds = this.host.loadedModelIds;
if (loadedModelIds.length === 0) return;
const propsPromises = loadedModelIds.map((modelId) => this.fetchModelProps(modelId));
try {
const results = await Promise.all(propsPromises);
this.host.models = this.host.models.map((model) => {
const modelIndex = loadedModelIds.indexOf(model.model);
if (modelIndex === -1) return model;
const props = results[modelIndex];
if (!props?.modalities) return model;
return { ...model, modalities: this.buildModalities(props.modalities) };
});
this.cacheVersion++;
} catch (error) {
console.warn('Failed to fetch modalities for loaded models:', error);
}
}
/**
* Fetch props for a specific model from /props endpoint.
* Uses caching to avoid redundant requests.
*
* In ROUTER mode, this only fetches props if the model is loaded,
* since unloaded models return 400 from /props endpoint.
*
* @param modelId - Model identifier to fetch props for
* @returns Props data or null if fetch failed or model not loaded
*/
async fetchModelProps(modelId: string): Promise<ApiLlamaCppServerProps | null> {
const cached = this.cache.get(modelId);
if (cached) return cached;
if (serverStore.isRouterMode && !this.host.isModelLoaded(modelId)) {
return null;
}
if (this.fetching.has(modelId)) return null;
this.fetching.add(modelId);
try {
const props = await PropsService.fetchForModel(modelId);
this.cache.set(modelId, props);
this.cacheVersion++;
return props;
} catch (error) {
console.warn(`Failed to fetch props for model ${modelId}:`, error);
return null;
} finally {
this.fetching.delete(modelId);
}
}
getModelContextSize(modelId: string): number | null {
const props = this.getModelProps(modelId);
const nCtx = props?.default_generation_settings?.n_ctx;
return typeof nCtx === 'number' ? nCtx : null;
}
getModelModalities(modelId: string): ModelModalities | null {
if (!serverStore.isRouterMode && serverStore.props?.modalities) {
return this.buildModalities(serverStore.props.modalities);
}
const model = this.host.models.find((m) => m.model === modelId || m.id === modelId);
if (model?.modalities) {
return model.modalities;
}
const props = this.cache.get(modelId);
if (props?.modalities) {
return this.buildModalities(props.modalities);
}
return null;
}
getModelModalitiesArray(modelId: string): ModelModality[] {
const modalities = this.getModelModalities(modelId);
if (!modalities) return [];
const result: ModelModality[] = [];
if (modalities.vision) result.push(ModelModality.VISION);
if (modalities.audio) result.push(ModelModality.AUDIO);
if (modalities.video) result.push(ModelModality.VIDEO);
return result;
}
getModelProps(modelId: string): ApiLlamaCppServerProps | null {
return this.cache.get(modelId);
}
isModelPropsFetching(modelId: string): boolean {
return this.fetching.has(modelId);
}
modelSupportsAudio(modelId: string): boolean {
return this.getModelModalities(modelId)?.audio ?? false;
}
modelSupportsVideo(modelId: string): boolean {
return this.getModelModalities(modelId)?.video ?? false;
}
modelSupportsVision(modelId: string): boolean {
return this.getModelModalities(modelId)?.vision ?? false;
}
/**
* Update modalities for a specific model.
* Called when a model is loaded or when we need fresh modality data.
*/
async updateModelModalities(modelId: string): Promise<void> {
const props = await this.fetchModelProps(modelId);
if (!props?.modalities) return;
this.host.models = this.host.models.map((model) =>
model.model === modelId
? { ...model, modalities: this.buildModalities(props.modalities!) }
: model
);
this.cacheVersion++;
}
private buildModalities(
modalities: NonNullable<ApiLlamaCppServerProps['modalities']>
): ModelModalities {
return {
audio: modalities.audio ?? false,
video: modalities.video ?? false,
vision: modalities.vision ?? false
};
}
}
@@ -0,0 +1,278 @@
/**
* ModelStatusManager - Model load/unload operations and the /models/sse feed
*
* Owns the status feed subscription, load progress tracking, and the
* awaiters that settle load/unload operations. The feed drives status and
* progress, so it replaces any post-operation polling. Created and owned by
* modelsStore; the host owns the router model rows the feed updates.
*/
import { ServerModelsSseEventType, ServerModelStatus } from '$lib/enums';
import { ModelsService } from '$lib/services/models.service';
import type { ModelPropsManager } from '$lib/stores/models/props.svelte';
// direct imports between stores, not via the barrel, to avoid circular deps
import { serverStore } from '$lib/stores/server.svelte';
import { SvelteMap } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
/**
* The slice of modelsStore the manager drives. Kept narrow on purpose so it
* cannot reach around the host's full surface; modelsStore implements this
* structurally.
*/
export interface ModelStatusHost {
error: string | null;
readonly props: ModelPropsManager;
/** Router model rows the status feed updates. */
routerModels: ApiModelDataEntry[];
fetchRouterModels(): Promise<void>;
isModelLoaded(modelId: string): boolean;
toDisplayName(id: string): string;
}
export class ModelStatusManager {
private loadingStates = new SvelteMap<string, boolean>();
private loadProgress = new SvelteMap<string, ModelLoadProgress>();
// /models/sse feed state, the single source of truth for status and load progress
private statusAbort: AbortController | null = null;
private statusReaderActive = false;
private statusWaiters = new SvelteMap<
string,
{ target: ServerModelStatus; resolve: () => void; reject: (e: Error) => void }
>();
constructor(private host: ModelStatusHost) {}
async ensureLoaded(modelId: string): Promise<void> {
if (this.host.isModelLoaded(modelId)) return;
await this.load(modelId);
}
/**
* Current load progress for a model, or null when not loading.
*/
getLoadProgress(modelId: string): ModelLoadProgress | null {
return this.loadProgress.get(modelId) ?? null;
}
isOperationInProgress(modelId: string): boolean {
return this.loadingStates.get(modelId) ?? false;
}
async load(modelId: string): Promise<void> {
if (this.host.isModelLoaded(modelId)) return;
if (this.loadingStates.get(modelId)) return;
this.loadingStates.set(modelId, true);
this.host.error = null;
// the feed drives completion, so it must be live before the request
this.subscribe();
const reachedLoaded = this.waitForStatus(modelId, ServerModelStatus.LOADED);
reachedLoaded.catch(() => {});
try {
await ModelsService.load(modelId);
await reachedLoaded;
toast.success(`Model loaded: ${this.host.toDisplayName(modelId)}`);
} catch (error) {
this.rejectStatus(modelId, error instanceof Error ? error : new Error('load failed'));
this.host.error = error instanceof Error ? error.message : 'Failed to load model';
toast.error(`Failed to load model: ${this.host.toDisplayName(modelId)}`);
throw error;
} finally {
this.loadingStates.set(modelId, false);
}
}
/**
* Open the /models/sse feed and keep it live with auto reconnect.
* Idempotent and router mode only.
*/
subscribe(): void {
if (this.statusReaderActive) return;
if (!serverStore.isRouterMode) return;
this.statusReaderActive = true;
this.statusAbort = new AbortController();
void this.runStatusReader(this.statusAbort.signal);
}
async unload(modelId: string): Promise<void> {
if (!this.host.isModelLoaded(modelId)) return;
if (this.loadingStates.get(modelId)) return;
this.loadingStates.set(modelId, true);
this.host.error = null;
this.subscribe();
const reachedUnloaded = this.waitForStatus(modelId, ServerModelStatus.UNLOADED);
reachedUnloaded.catch(() => {});
try {
await ModelsService.unload(modelId);
await reachedUnloaded;
toast.info(`Model unloaded: ${this.host.toDisplayName(modelId)}`);
} catch (error) {
this.rejectStatus(modelId, error instanceof Error ? error : new Error('unload failed'));
this.host.error = error instanceof Error ? error.message : 'Failed to unload model';
toast.error(`Failed to unload model: ${this.host.toDisplayName(modelId)}`);
throw error;
} finally {
this.loadingStates.set(modelId, false);
}
}
/**
* Close the /models/sse feed and drop transient progress.
*/
unsubscribe(): void {
this.statusReaderActive = false;
this.statusAbort?.abort();
this.statusAbort = null;
this.loadProgress.clear();
}
/**
* Apply a status envelope: update the model row, track or clear progress,
* settle any pending load or unload awaiter.
*/
private applyModelStatus(event: ApiModelsSseEvent): void {
const model = event.model;
const data = event.data;
if (!model || !data?.status) return;
const status = data.status;
this.setRouterModelStatus(model, status);
if (status === ServerModelStatus.LOADING) {
if (data.progress) this.loadProgress.set(model, data.progress);
} else {
this.loadProgress.delete(model);
}
if (status === ServerModelStatus.LOADED) {
void this.host.props.updateModelModalities(model);
}
const failed =
status === ServerModelStatus.FAILED ||
(status === ServerModelStatus.UNLOADED && (data.exit_code ?? 0) !== 0);
if (failed) {
this.rejectStatus(model, new Error(`Model failed: ${this.host.toDisplayName(model)}`));
return;
}
this.settleStatus(model, status);
}
/**
* Route one feed record by event kind. Only the status_* events carry a
* status payload, models_reload triggers a list refresh, model_remove drops
* the row, download_* belong to the download surface, not here.
*/
private applyStatusEvent(event: ApiModelsSseEvent): void {
switch (event.event) {
case ServerModelsSseEventType.STATUS_CHANGE:
case ServerModelsSseEventType.MODEL_STATUS:
case ServerModelsSseEventType.STATUS_UPDATE:
this.applyModelStatus(event);
break;
case ServerModelsSseEventType.MODELS_RELOAD:
void this.host.fetchRouterModels();
break;
case ServerModelsSseEventType.MODEL_REMOVE:
this.removeRouterModel(event.model);
break;
case ServerModelsSseEventType.DOWNLOAD_PROGRESS:
break;
}
}
/**
* Reject and drop the awaiter for a model.
*/
private rejectStatus(modelId: string, error: Error): void {
const waiter = this.statusWaiters.get(modelId);
if (waiter) {
this.statusWaiters.delete(modelId);
waiter.reject(error);
}
}
/**
* Drop a model row reported gone by the feed and settle its awaiters.
*/
private removeRouterModel(modelId: string): void {
if (this.host.routerModels.findIndex((m) => m.id === modelId) === -1) return;
this.host.routerModels = this.host.routerModels.filter((m) => m.id !== modelId);
this.loadProgress.delete(modelId);
this.rejectStatus(modelId, new Error(`Model removed: ${this.host.toDisplayName(modelId)}`));
}
/**
* Read the feed and reconnect until unsubscribed.
*/
private async runStatusReader(signal: AbortSignal): Promise<void> {
await ModelsService.watchModelEvents(signal, (event) => this.applyStatusEvent(event));
}
/**
* Update one model row status in place, reassigning to trigger reactivity.
*/
private setRouterModelStatus(modelId: string, status: ServerModelStatus): void {
const idx = this.host.routerModels.findIndex((m) => m.id === modelId);
if (idx === -1) return;
const current = this.host.routerModels[idx];
if (current.status.value === status) return;
const next = [...this.host.routerModels];
next[idx] = { ...current, status: { ...current.status, value: status } };
this.host.routerModels = next;
}
/**
* Resolve and drop the awaiter when the model reaches its target status.
*/
private settleStatus(modelId: string, status: ServerModelStatus): void {
const waiter = this.statusWaiters.get(modelId);
if (waiter && waiter.target === status) {
this.statusWaiters.delete(modelId);
waiter.resolve();
}
}
/**
* Register an awaiter that resolves when the feed reports target status.
* One operation runs per model at a time, so one awaiter per model is kept.
*/
private waitForStatus(modelId: string, target: ServerModelStatus): Promise<void> {
return new Promise((resolve, reject) => {
this.statusWaiters.set(modelId, { reject, resolve, target });
});
}
}
+28 -20
View File
@@ -1,3 +1,11 @@
/**
* permissionsStore - Allowed tool permissions
*
* Owns the set of tools the user has permanently allowed, persisted to
* localStorage. The agentic loop's permission gates consult it to run a
* tool without prompting.
*/
import { browser } from '$app/environment';
import { ALWAYS_ALLOWED_TOOLS_LOCALSTORAGE_KEY } from '$lib/constants';
import { SvelteSet } from 'svelte/reactivity';
@@ -5,6 +13,24 @@ import { SvelteSet } from 'svelte/reactivity';
class PermissionsStore {
private _tools = $state(new SvelteSet<string>());
get tools(): ReadonlySet<string> {
return this._tools;
}
allowTool(key: string): void {
this._tools.add(key);
this.persist();
}
allowTools(keys: string[]): void {
for (const key of keys) this._tools.add(key);
this.persist();
}
hasTool(key: string): boolean {
return this._tools.has(key);
}
/**
* Load persisted permissions. Called by initStores() after migrations
* have run.
@@ -29,30 +55,12 @@ class PermissionsStore {
}
}
get tools(): ReadonlySet<string> {
return this._tools;
}
hasTool(key: string): boolean {
return this._tools.has(key);
}
allowTool(key: string): void {
this._tools.add(key);
this._persist();
}
allowTools(keys: string[]): void {
for (const key of keys) this._tools.add(key);
this._persist();
}
revokeTool(key: string): void {
this._tools.delete(key);
this._persist();
this.persist();
}
private _persist(): void {
private persist(): void {
try {
localStorage.setItem(ALWAYS_ALLOWED_TOOLS_LOCALSTORAGE_KEY, JSON.stringify([...this._tools]));
} catch (err) {
+44 -84
View File
@@ -1,79 +1,57 @@
/**
* serverStore - Server connection state, configuration and role detection
*
* Owns the connection state and properties fetched from /props, plus MODEL
* vs ROUTER role detection and server-wide generation defaults. Uses
* PropsService for the /props fetch.
*/
import { ServerRole } from '$lib/enums';
import { PropsService } from '$lib/services/props.service';
import { ApiError } from '$lib/utils';
const LOADING_RETRY_INTERVAL_MS = 1000;
/**
* serverStore - Server connection state, configuration, and role detection
*
* This store manages the server connection state and properties fetched from `/props`.
* It provides reactive state for server configuration and role detection.
*
* **Architecture & Relationships:**
* - **PropsService**: Stateless service for fetching `/props` data
* - **serverStore** (this class): Reactive store for server state
* - **modelsStore**: Independent store for model management (uses PropsService directly)
*
* **Key Features:**
* - **Server State**: Connection status, loading, error handling
* - **Role Detection**: MODEL (single model) vs ROUTER (multi-model)
* - **Default Params**: Server-wide generation defaults
*/
class ServerStore {
/**
*
*
* State
*
*
*/
props = $state<ApiLlamaCppServerProps | null>(null);
loading = $state(false);
error = $state<string | null>(null);
status = $state<number | null>(null);
loading = $state(false);
props = $state<ApiLlamaCppServerProps | null>(null);
role = $state<ServerRole | null>(null);
status = $state<number | null>(null);
private fetchPromise: Promise<void> | null = null;
private retryTimer: ReturnType<typeof setTimeout> | null = null;
/**
*
*
* Getters
*
*
*/
get defaultParams(): ApiLlamaCppServerProps['default_generation_settings']['params'] | null {
return this.props?.default_generation_settings?.params || null;
}
get contextSize(): number | null {
const nCtx = this.props?.default_generation_settings?.n_ctx;
return typeof nCtx === 'number' ? nCtx : null;
}
get uiSettings(): Record<string, string | number | boolean> | undefined {
return this.props?.ui_settings ?? this.props?.webui_settings;
}
get isRouterMode(): boolean {
return this.role === ServerRole.ROUTER;
get defaultParams(): ApiLlamaCppServerProps['default_generation_settings']['params'] | null {
return this.props?.default_generation_settings?.params || null;
}
get isModelMode(): boolean {
return this.role === ServerRole.MODEL;
}
/**
*
*
* Data Handling
*
*
*/
get isRouterMode(): boolean {
return this.role === ServerRole.ROUTER;
}
get uiSettings(): Record<string, string | number | boolean> | undefined {
return this.props?.ui_settings ?? this.props?.webui_settings;
}
clear(): void {
this.clearRetryTimer();
this.props = null;
this.error = null;
this.status = null;
this.loading = false;
this.role = null;
this.fetchPromise = null;
}
/**
* @param background - Set by the automatic "still loading" poll. Skips the
@@ -124,14 +102,20 @@ class ServerStore {
await fetchPromise;
}
clear(): void {
this.clearRetryTimer();
this.props = null;
this.error = null;
this.status = null;
this.loading = false;
this.role = null;
this.fetchPromise = null;
private clearRetryTimer(): void {
if (this.retryTimer) {
clearTimeout(this.retryTimer);
this.retryTimer = null;
}
}
private detectRole(props: ApiLlamaCppServerProps): void {
const newRole = props?.role === ServerRole.ROUTER ? ServerRole.ROUTER : ServerRole.MODEL;
if (this.role !== newRole) {
this.role = newRole;
console.info(`Server running in ${newRole === ServerRole.ROUTER ? 'ROUTER' : 'MODEL'} mode`);
}
}
private scheduleRetry(): void {
@@ -142,30 +126,6 @@ class ServerStore {
this.fetch({ background: true });
}, LOADING_RETRY_INTERVAL_MS);
}
private clearRetryTimer(): void {
if (this.retryTimer) {
clearTimeout(this.retryTimer);
this.retryTimer = null;
}
}
/**
*
*
* Utilities
*
*
*/
private detectRole(props: ApiLlamaCppServerProps): void {
const newRole = props?.role === ServerRole.ROUTER ? ServerRole.ROUTER : ServerRole.MODEL;
if (this.role !== newRole) {
this.role = newRole;
console.info(`Server running in ${newRole === ServerRole.ROUTER ? 'ROUTER' : 'MODEL'} mode`);
}
}
}
export const serverStore = new ServerStore();
@@ -1,34 +1,10 @@
/**
* settingsStore - Application configuration and theme management
*
* This store manages all application settings including AI model parameters, UI preferences,
* and theme configuration. It provides persistent storage through localStorage with reactive
* state management using Svelte 5 runes.
*
* **Architecture & Relationships:**
* - **settingsStore** (this class): Configuration state management
* - Manages AI model parameters (temperature, max tokens, etc.)
* - Handles theme switching and persistence
* - Provides localStorage synchronization
* - Offers reactive configuration access
*
* - **ChatService**: Reads model parameters for API requests
* - **UI Components**: Subscribe to theme and configuration changes
*
* **Key Features:**
* - **Model Parameters**: Temperature, max tokens, top-p, top-k, repeat penalty
* - **Theme Management**: Auto, light, dark theme switching
* - **Persistence**: Automatic localStorage synchronization
* - **Reactive State**: Svelte 5 runes for automatic UI updates
* - **Default Handling**: Graceful fallback to defaults for missing settings
* - **Batch Updates**: Efficient multi-setting updates
* - **Reset Functionality**: Restore defaults for individual or all settings
*
* **Configuration Categories:**
* - Generation parameters (temperature, tokens, sampling)
* - UI preferences (theme, display options)
* - System settings (model selection, prompts)
* - Advanced options (seed, penalties, context handling)
* Owns generation parameters, UI preferences and theme, persisted to
* localStorage with Svelte 5 runes. Applies the admin's server ui_settings
* as defaults on first visit; sampling parameters sync with the server via
* ParameterSyncService.
*/
import { browser } from '$app/environment';
@@ -53,14 +29,6 @@ import {
import { setMode } from 'mode-watcher';
class SettingsStore {
/**
*
*
* State
*
*
*/
config = $state<SettingsConfigType>({ ...SETTING_CONFIG_DEFAULT });
isInitialized = $state(false);
userOverrides = $state<Set<string>>(new Set());
@@ -69,29 +37,182 @@ class SettingsStore {
// application of server ui_settings defaults for new users.
private isFirstVisit = false;
canSyncParameter(key: string): boolean {
return ParameterSyncService.canSyncParameter(key);
}
/**
*
*
* Utilities (private helpers)
*
*
* Clear all user overrides (for debugging)
*/
/**
* Helper method to get server defaults with null safety
* Centralizes the pattern of getting and extracting server defaults
*/
private getServerDefaults(): Record<string, string | number | boolean> {
return ParameterSyncService.extractServerDefaults(serverStore.defaultParams);
clearAllUserOverrides(): void {
this.userOverrides.clear();
this.saveConfig();
console.log('Cleared all user overrides');
}
/**
*
*
* Lifecycle
*
*
* Export all settings as a versioned JSON-compatible object.
* The export captures the full config (excluding sensitive values like API key)
* and user overrides. Sensitive fields are filtered out for security by default.
* @param includeSensitiveData - If true, include sensitive fields (apiKey, MCP server headers) in export
*/
exportSettings(includeSensitiveData: boolean = false): SettingsExportType {
// Build config excluding sensitive data unless user opts in
const configToExport: Record<string, string | number | boolean | undefined> =
includeSensitiveData
? { ...this.config }
: Object.fromEntries(Object.entries(this.config).filter(([key]) => key !== 'apiKey'));
// Handle MCP servers: exclude custom headers unless user opts in
if ('mcpServers' in configToExport && !includeSensitiveData) {
try {
const mcpServers = JSON.parse(configToExport.mcpServers as string) as Array<
Record<string, unknown>
>;
const safeServers = mcpServers.map((server) => {
delete server.headers;
return server;
});
configToExport.mcpServers = JSON.stringify(safeServers);
} catch {
// If parsing fails, just exclude the entire mcpServers field
delete (configToExport as Record<string, unknown>).mcpServers;
}
}
return {
config: configToExport,
timestamp: Date.now(),
userOverrides: Array.from(this.userOverrides),
version: 1
};
}
/**
* Reset all parameters to their default values (from props)
* This is used by the "Reset to Default" functionality
* Prioritizes Server defaults from /props, falls back to UI defaults
*/
forceSyncWithServerDefaults(): void {
const propsDefaults = this.getServerDefaults();
const uiSettings = serverStore.uiSettings;
for (const key of ParameterSyncService.getSyncableParameterKeys()) {
if (uiSettings && key in uiSettings) {
// UI setting from admin config: write actual value
setConfigValue(this.config, key, uiSettings[key]);
} else if (propsDefaults[key] !== undefined) {
// sampling param: clear it, let server decide
setConfigValue(this.config, key, '');
} else if (key in SETTING_CONFIG_DEFAULT) {
setConfigValue(this.config, key, getConfigValue(SETTING_CONFIG_DEFAULT, key));
}
this.userOverrides.delete(key);
}
// Non-syncable keys: reset is a full return to the instance state, the
// admin baseline value when defined, the factory default otherwise.
for (const key of Object.keys(SETTING_CONFIG_DEFAULT)) {
if (ParameterSyncService.canSyncParameter(key)) {
continue;
}
const value =
uiSettings && key in uiSettings && uiSettings[key] !== undefined
? uiSettings[key]
: getConfigValue(SETTING_CONFIG_DEFAULT, key);
setConfigValue(this.config, key, value);
if (key === SETTINGS_KEYS.THEME) {
setMode(value as ColorMode);
}
this.userOverrides.delete(key);
}
this.saveConfig();
}
/**
* Get the entire configuration object
* @returns The complete configuration object
*/
getAllConfig(): SettingsConfigType {
return { ...this.config };
}
/**
* Get a specific configuration value
* @param key - The configuration key to get
* @returns The configuration value
*/
getConfig<K extends keyof SettingsConfigType>(key: K): SettingsConfigType[K] {
return this.config[key];
}
/**
* Get diff between current settings and server defaults
*/
getParameterDiff() {
const serverDefaults = this.getServerDefaults();
if (Object.keys(serverDefaults).length === 0) return {};
const configAsRecord = configToParameterRecord(
this.config,
ParameterSyncService.getSyncableParameterKeys()
);
return ParameterSyncService.createParameterDiff(configAsRecord, serverDefaults);
}
/**
* Get parameter information including source for a specific parameter
*/
getParameterInfo(key: string) {
const propsDefaults = this.getServerDefaults();
const currentValue = getConfigValue(this.config, key);
return ParameterSyncService.getParameterInfo(
key,
currentValue ?? '',
propsDefaults,
this.userOverrides
);
}
/**
* Import settings from a previously exported object.
* Restores config (including theme) and user overrides.
* @param data - The exported settings object
*/
importSettings(data: SettingsExportType): void {
if (!browser) return;
if (!data || !data.config) {
throw new Error('Invalid settings data: missing config');
}
// Restore config (theme is included in config)
this.config = {
...SETTING_CONFIG_DEFAULT,
...data.config
};
// Restore user overrides (derived state — may be stale if server defaults differ)
this.userOverrides = new Set(data.userOverrides ?? []);
// Persist to localStorage
this.saveConfig();
// Apply theme for immediate visual feedback
setMode(this.config[SETTINGS_KEYS.THEME] as ColorMode);
console.log('Settings imported successfully');
}
/**
* Initialize the settings store by loading from localStorage.
@@ -111,6 +232,201 @@ class SettingsStore {
}
}
/**
* Reset all settings to defaults.
*/
resetAll() {
this.resetConfig();
this.resetTheme();
}
/**
* Reset configuration to defaults
*/
resetConfig() {
this.config = { ...SETTING_CONFIG_DEFAULT };
this.saveConfig();
}
/**
* Reset a parameter to Server default (or UI default if no Server default)
*/
resetParameterToServerDefault(key: string): void {
const serverDefaults = this.getServerDefaults();
const uiSettings = serverStore.uiSettings;
if (uiSettings && key in uiSettings) {
// UI setting from admin config: write actual value
setConfigValue(this.config, key, uiSettings[key]);
} else if (serverDefaults[key] !== undefined) {
// sampling param known by server: clear it, let server decide
setConfigValue(this.config, key, '');
} else if (key in SETTING_CONFIG_DEFAULT) {
setConfigValue(this.config, key, getConfigValue(SETTING_CONFIG_DEFAULT, key));
}
this.userOverrides.delete(key);
this.saveConfig();
}
/**
* Reset theme to default value.
* Theme is now stored inside the config object.
*/
resetTheme() {
this.updateConfig(SETTINGS_KEYS.THEME, SETTING_CONFIG_DEFAULT[SETTINGS_KEYS.THEME]);
setMode(SETTING_CONFIG_DEFAULT[SETTINGS_KEYS.THEME] as ColorMode);
}
/**
* Initialize settings with props defaults when server properties are first loaded
* This sets up the default values from /props endpoint
*/
syncWithServerDefaults(): void {
const propsDefaults = this.getServerDefaults();
if (Object.keys(propsDefaults).length === 0) return;
const uiSettings = serverStore.uiSettings;
const uiSettingsKeys = new Set(uiSettings ? Object.keys(uiSettings) : []);
for (const [key, propsValue] of Object.entries(propsDefaults)) {
const currentValue = getConfigValue(this.config, key);
const normalizedCurrent = normalizeFloatingPoint(currentValue);
const normalizedDefault = normalizeFloatingPoint(propsValue);
// if user value matches server, it's not a real override
if (normalizedCurrent === normalizedDefault) {
this.userOverrides.delete(key);
if (!uiSettingsKeys.has(key) && getConfigValue(SETTING_CONFIG_DEFAULT, key) === undefined) {
setConfigValue(this.config, key, undefined);
}
}
}
// UI settings are the admin's defaults for new users: applied once on
// the first visit, never on later loads, so the user's config can
// diverge. "Reset to Default" is the explicit way back to the baseline.
// A first visit config carries factory values only, so a key that
// already diverges here was set by the user before the baseline could
// be reached, through the API key splash, and stays theirs.
if (uiSettings && this.isFirstVisit) {
this.isFirstVisit = false;
for (const [key, value] of Object.entries(uiSettings)) {
if (value === undefined || this.userOverrides.has(key)) continue;
if (getConfigValue(this.config, key) !== getConfigValue(SETTING_CONFIG_DEFAULT, key)) {
continue;
}
setConfigValue(this.config, key, value);
// theme lives in mode-watcher, not just in config -> propagate
if (key === SETTINGS_KEYS.THEME) {
setMode(value as ColorMode);
}
}
}
this.saveConfig();
console.log('User overrides after sync:', Array.from(this.userOverrides));
}
/**
* Update a specific configuration setting
* @param key - The configuration key to update
* @param value - The new value for the configuration key
*/
updateConfig<K extends keyof SettingsConfigType>(key: K, value: SettingsConfigType[K]): void {
this.config[key] = value;
if (ParameterSyncService.canSyncParameter(key as string)) {
const propsDefaults = this.getServerDefaults();
const propsDefault = propsDefaults[key as string];
if (propsDefault !== undefined) {
const normalizedValue = normalizeFloatingPoint(value);
const normalizedDefault = normalizeFloatingPoint(propsDefault);
if (normalizedValue === normalizedDefault) {
this.userOverrides.delete(key as string);
} else {
this.userOverrides.add(key as string);
}
}
}
this.saveConfig();
}
/**
*
*
* Import / Export
*
*
*/
/**
* Update multiple configuration settings at once
* @param updates - Object containing the configuration updates
*/
updateMultipleConfig(updates: Partial<SettingsConfigType>) {
Object.assign(this.config, updates);
const propsDefaults = this.getServerDefaults();
for (const [key, value] of Object.entries(updates)) {
if (ParameterSyncService.canSyncParameter(key)) {
const propsDefault = propsDefaults[key];
if (propsDefault !== undefined) {
const normalizedValue = normalizeFloatingPoint(value);
const normalizedDefault = normalizeFloatingPoint(propsDefault);
if (normalizedValue === normalizedDefault) {
this.userOverrides.delete(key);
} else {
this.userOverrides.add(key);
}
}
}
}
this.saveConfig();
}
/**
* Update the theme setting.
* @param newTheme - The new theme value
*/
updateTheme(newTheme: string) {
this.updateConfig(SETTINGS_KEYS.THEME, newTheme);
setMode(newTheme as ColorMode);
}
/**
*
*
* Utilities (private helpers)
*
*
*/
/**
* Helper method to get server defaults with null safety
* Centralizes the pattern of getting and extracting server defaults
*/
private getServerDefaults(): Record<string, string | number | boolean> {
return ParameterSyncService.extractServerDefaults(serverStore.defaultParams);
}
/**
* Load configuration from localStorage
* Returns default values for missing keys to prevent breaking changes
@@ -171,69 +487,6 @@ class SettingsStore {
setMode(legacyTheme as ColorMode);
}
}
/**
*
*
* Config Updates
*
*
*/
/**
* Update a specific configuration setting
* @param key - The configuration key to update
* @param value - The new value for the configuration key
*/
updateConfig<K extends keyof SettingsConfigType>(key: K, value: SettingsConfigType[K]): void {
this.config[key] = value;
if (ParameterSyncService.canSyncParameter(key as string)) {
const propsDefaults = this.getServerDefaults();
const propsDefault = propsDefaults[key as string];
if (propsDefault !== undefined) {
const normalizedValue = normalizeFloatingPoint(value);
const normalizedDefault = normalizeFloatingPoint(propsDefault);
if (normalizedValue === normalizedDefault) {
this.userOverrides.delete(key as string);
} else {
this.userOverrides.add(key as string);
}
}
}
this.saveConfig();
}
/**
* Update multiple configuration settings at once
* @param updates - Object containing the configuration updates
*/
updateMultipleConfig(updates: Partial<SettingsConfigType>) {
Object.assign(this.config, updates);
const propsDefaults = this.getServerDefaults();
for (const [key, value] of Object.entries(updates)) {
if (ParameterSyncService.canSyncParameter(key)) {
const propsDefault = propsDefaults[key];
if (propsDefault !== undefined) {
const normalizedValue = normalizeFloatingPoint(value);
const normalizedDefault = normalizeFloatingPoint(propsDefault);
if (normalizedValue === normalizedDefault) {
this.userOverrides.delete(key);
} else {
this.userOverrides.add(key);
}
}
}
}
this.saveConfig();
}
/**
* Save the current configuration to localStorage
@@ -252,331 +505,6 @@ class SettingsStore {
console.error('Failed to save config to localStorage:', error);
}
}
/**
* Update the theme setting.
* @param newTheme - The new theme value
*/
updateTheme(newTheme: string) {
this.updateConfig(SETTINGS_KEYS.THEME, newTheme);
setMode(newTheme as ColorMode);
}
/**
*
*
* Reset
*
*
*/
/**
* Reset configuration to defaults
*/
resetConfig() {
this.config = { ...SETTING_CONFIG_DEFAULT };
this.saveConfig();
}
/**
* Reset theme to default value.
* Theme is now stored inside the config object.
*/
resetTheme() {
this.updateConfig(SETTINGS_KEYS.THEME, SETTING_CONFIG_DEFAULT[SETTINGS_KEYS.THEME]);
setMode(SETTING_CONFIG_DEFAULT[SETTINGS_KEYS.THEME] as ColorMode);
}
/**
* Reset all settings to defaults.
*/
resetAll() {
this.resetConfig();
this.resetTheme();
}
/**
* Reset a parameter to Server default (or UI default if no Server default)
*/
resetParameterToServerDefault(key: string): void {
const serverDefaults = this.getServerDefaults();
const uiSettings = serverStore.uiSettings;
if (uiSettings && key in uiSettings) {
// UI setting from admin config: write actual value
setConfigValue(this.config, key, uiSettings[key]);
} else if (serverDefaults[key] !== undefined) {
// sampling param known by server: clear it, let server decide
setConfigValue(this.config, key, '');
} else if (key in SETTING_CONFIG_DEFAULT) {
setConfigValue(this.config, key, getConfigValue(SETTING_CONFIG_DEFAULT, key));
}
this.userOverrides.delete(key);
this.saveConfig();
}
/**
*
*
* Server Sync
*
*
*/
/**
* Initialize settings with props defaults when server properties are first loaded
* This sets up the default values from /props endpoint
*/
syncWithServerDefaults(): void {
const propsDefaults = this.getServerDefaults();
if (Object.keys(propsDefaults).length === 0) return;
const uiSettings = serverStore.uiSettings;
const uiSettingsKeys = new Set(uiSettings ? Object.keys(uiSettings) : []);
for (const [key, propsValue] of Object.entries(propsDefaults)) {
const currentValue = getConfigValue(this.config, key);
const normalizedCurrent = normalizeFloatingPoint(currentValue);
const normalizedDefault = normalizeFloatingPoint(propsValue);
// if user value matches server, it's not a real override
if (normalizedCurrent === normalizedDefault) {
this.userOverrides.delete(key);
if (!uiSettingsKeys.has(key) && getConfigValue(SETTING_CONFIG_DEFAULT, key) === undefined) {
setConfigValue(this.config, key, undefined);
}
}
}
// UI settings are the admin's defaults for new users: applied once on
// the first visit, never on later loads, so the user's config can
// diverge. "Reset to Default" is the explicit way back to the baseline.
// A first visit config carries factory values only, so a key that
// already diverges here was set by the user before the baseline could
// be reached, through the API key splash, and stays theirs.
if (uiSettings && this.isFirstVisit) {
this.isFirstVisit = false;
for (const [key, value] of Object.entries(uiSettings)) {
if (value === undefined || this.userOverrides.has(key)) continue;
if (getConfigValue(this.config, key) !== getConfigValue(SETTING_CONFIG_DEFAULT, key)) {
continue;
}
setConfigValue(this.config, key, value);
// theme lives in mode-watcher, not just in config -> propagate
if (key === SETTINGS_KEYS.THEME) {
setMode(value as ColorMode);
}
}
}
this.saveConfig();
console.log('User overrides after sync:', Array.from(this.userOverrides));
}
/**
* Reset all parameters to their default values (from props)
* This is used by the "Reset to Default" functionality
* Prioritizes Server defaults from /props, falls back to UI defaults
*/
forceSyncWithServerDefaults(): void {
const propsDefaults = this.getServerDefaults();
const uiSettings = serverStore.uiSettings;
for (const key of ParameterSyncService.getSyncableParameterKeys()) {
if (uiSettings && key in uiSettings) {
// UI setting from admin config: write actual value
setConfigValue(this.config, key, uiSettings[key]);
} else if (propsDefaults[key] !== undefined) {
// sampling param: clear it, let server decide
setConfigValue(this.config, key, '');
} else if (key in SETTING_CONFIG_DEFAULT) {
setConfigValue(this.config, key, getConfigValue(SETTING_CONFIG_DEFAULT, key));
}
this.userOverrides.delete(key);
}
// Non-syncable keys: reset is a full return to the instance state, the
// admin baseline value when defined, the factory default otherwise.
for (const key of Object.keys(SETTING_CONFIG_DEFAULT)) {
if (ParameterSyncService.canSyncParameter(key)) {
continue;
}
const value =
uiSettings && key in uiSettings && uiSettings[key] !== undefined
? uiSettings[key]
: getConfigValue(SETTING_CONFIG_DEFAULT, key);
setConfigValue(this.config, key, value);
if (key === SETTINGS_KEYS.THEME) {
setMode(value as ColorMode);
}
this.userOverrides.delete(key);
}
this.saveConfig();
}
/**
*
*
* Utilities
*
*
*/
/**
* Get a specific configuration value
* @param key - The configuration key to get
* @returns The configuration value
*/
getConfig<K extends keyof SettingsConfigType>(key: K): SettingsConfigType[K] {
return this.config[key];
}
/**
* Get the entire configuration object
* @returns The complete configuration object
*/
getAllConfig(): SettingsConfigType {
return { ...this.config };
}
canSyncParameter(key: string): boolean {
return ParameterSyncService.canSyncParameter(key);
}
/**
* Get parameter information including source for a specific parameter
*/
getParameterInfo(key: string) {
const propsDefaults = this.getServerDefaults();
const currentValue = getConfigValue(this.config, key);
return ParameterSyncService.getParameterInfo(
key,
currentValue ?? '',
propsDefaults,
this.userOverrides
);
}
/**
* Get diff between current settings and server defaults
*/
getParameterDiff() {
const serverDefaults = this.getServerDefaults();
if (Object.keys(serverDefaults).length === 0) return {};
const configAsRecord = configToParameterRecord(
this.config,
ParameterSyncService.getSyncableParameterKeys()
);
return ParameterSyncService.createParameterDiff(configAsRecord, serverDefaults);
}
/**
* Clear all user overrides (for debugging)
*/
clearAllUserOverrides(): void {
this.userOverrides.clear();
this.saveConfig();
console.log('Cleared all user overrides');
}
/**
*
*
* Import / Export
*
*
*/
/**
* Export all settings as a versioned JSON-compatible object.
* The export captures the full config (excluding sensitive values like API key)
* and user overrides. Sensitive fields are filtered out for security by default.
* @param includeSensitiveData - If true, include sensitive fields (apiKey, MCP server headers) in export
*/
exportSettings(includeSensitiveData: boolean = false): SettingsExportType {
// Build config excluding sensitive data unless user opts in
const configToExport: Record<string, string | number | boolean | undefined> =
includeSensitiveData
? { ...this.config }
: Object.fromEntries(Object.entries(this.config).filter(([key]) => key !== 'apiKey'));
// Handle MCP servers: exclude custom headers unless user opts in
if ('mcpServers' in configToExport && !includeSensitiveData) {
try {
const mcpServers = JSON.parse(configToExport.mcpServers as string) as Array<
Record<string, unknown>
>;
const safeServers = mcpServers.map((server) => {
delete server.headers;
return server;
});
configToExport.mcpServers = JSON.stringify(safeServers);
} catch {
// If parsing fails, just exclude the entire mcpServers field
delete (configToExport as Record<string, unknown>).mcpServers;
}
}
return {
config: configToExport,
timestamp: Date.now(),
userOverrides: Array.from(this.userOverrides),
version: 1
};
}
/**
* Import settings from a previously exported object.
* Restores config (including theme) and user overrides.
* @param data - The exported settings object
*/
importSettings(data: SettingsExportType): void {
if (!browser) return;
if (!data || !data.config) {
throw new Error('Invalid settings data: missing config');
}
// Restore config (theme is included in config)
this.config = {
...SETTING_CONFIG_DEFAULT,
...data.config
};
// Restore user overrides (derived state — may be stale if server defaults differ)
this.userOverrides = new Set(data.userOverrides ?? []);
// Persist to localStorage
this.saveConfig();
// Apply theme for immediate visual feedback
setMode(this.config[SETTINGS_KEYS.THEME] as ColorMode);
console.log('Settings imported successfully');
}
}
export const settingsStore = new SettingsStore();
@@ -1,3 +1,10 @@
/**
* settingsReferrer - Remembers the settings route to return to after exit
*
* Tracks the last settings section the user was on so the app can return
* there after a fallback exit. Standalone reactive value, no host.
*/
import { SETTINGS_FALLBACK_EXIT_ROUTE } from '$lib/constants';
let _url = $state<string>(SETTINGS_FALLBACK_EXIT_ROUTE);
+436 -427
View File
@@ -1,3 +1,12 @@
/**
* toolsStore - Tool registry and enablement
*
* Owns the server tool listing (with working-directory resolution), built-in
* browser tools, MCP tools and per-tool enablement, exposed as a unified
* tool set for the LLM and the tools UI. Consumed by the agentic loop and
* the chat flows.
*/
import { browser } from '$app/environment';
import {
buildBrowserInfoToolDefinition,
@@ -18,9 +27,9 @@ import {
} from '$lib/enums';
import { ToolsService } from '$lib/services/tools.service';
// direct imports between stores, not via the barrel, to avoid circular deps
import { mcpStore } from '$lib/stores/mcp.svelte';
import { modelsStore } from '$lib/stores/models.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { mcpStore } from '$lib/stores/mcp/index.svelte';
import { modelsStore } from '$lib/stores/models/index.svelte';
import { settingsStore } from '$lib/stores/settings/index.svelte';
import type { OpenAIToolDefinition, ToolEntry, ToolGroup } from '$lib/types';
import { buildSandboxToolDefinition } from '$lib/utils';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
@@ -28,273 +37,18 @@ import { SvelteMap, SvelteSet } from 'svelte/reactivity';
/** Stable selection identity for a tool, shared by the disabled set and the permission store */
class ToolsStore {
private _serverTools = $state<OpenAIToolDefinition[]>([]);
private _loading = $state(false);
private _error = $state<string | null>(null);
private _disabledTools = $state(new SvelteSet<string>());
private _error = $state<string | null>(null);
private _loading = $state(false);
private _serverHome = $state<string | null | undefined>(undefined);
private _serverTools = $state<OpenAIToolDefinition[]>([]);
private _toolsEndpointUnreachable = $state(false);
// server tools that resolve their paths against the working directory,
// as declared by the server in its `/tools` listing
private _cwdAwareTools = $state(new SvelteSet<string>());
private _toolsEndpointUnreachable = $state(false);
private _serverHome = $state<string | null | undefined>(undefined);
private cwdAwareTools = $state(new SvelteSet<string>());
/**
* Load persisted disabled tools and fetch the builtin tool list.
* Called by initStores() after migrations have run.
*/
initialize(): void {
// browser-only init: skip on SSR to avoid localStorage/fetch side effects
if (!browser) return;
try {
const stored = localStorage.getItem(DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
if (Array.isArray(parsed)) {
for (const key of parsed) {
if (typeof key === 'string') this._disabledTools.add(key);
}
}
}
} catch (err) {
console.error('[ToolsStore] Failed to load disabled tools from localStorage:', err);
}
this.fetchServerTools();
}
private persistDisabledTools(): void {
try {
localStorage.setItem(
DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY,
JSON.stringify([...this._disabledTools])
);
} catch {
// ignore storage errors
}
}
private toolKey(source: ToolSource, name: string, serverId?: string): string {
switch (source) {
case ToolSource.MCP:
return serverId ? `mcp-${serverId}:${name}` : `mcp:${name}`;
case ToolSource.CUSTOM:
return `custom:${name}`;
case ToolSource.BROWSER:
return `browser:${name}`;
default:
return `server:${name}`;
}
}
private inferTypeFromDefault(value: unknown): string | undefined {
if (typeof value === 'string') return 'string';
if (typeof value === 'boolean') return 'boolean';
if (typeof value === 'number') return Number.isInteger(value) ? 'integer' : 'number';
if (Array.isArray(value)) return 'array';
if (value !== null && typeof value === 'object') return 'object';
return undefined;
}
/**
* Recursively normalize a JSON Schema object: infers `type` from `default`
* for properties / items that omit it, and descends into nested `properties`
* and `items`. Returns a new object -- does not mutate the input.
*/
private normalizeJsonSchema(schema: Record<string, unknown>): Record<string, unknown> {
if (!schema || typeof schema !== 'object') return schema;
const normalized: Record<string, unknown> = { ...schema };
if (normalized.properties && typeof normalized.properties === 'object') {
const props = normalized.properties as Record<string, Record<string, unknown>>;
const normalizedProps: Record<string, Record<string, unknown>> = {};
for (const [key, prop] of Object.entries(props)) {
if (!prop || typeof prop !== 'object') {
normalizedProps[key] = prop;
continue;
}
const normalizedProp: Record<string, unknown> = { ...prop };
if (!normalizedProp.type && normalizedProp.default !== undefined) {
const inferred = this.inferTypeFromDefault(normalizedProp.default);
if (inferred) normalizedProp.type = inferred;
}
if (normalizedProp.properties) {
Object.assign(
normalizedProp,
this.normalizeJsonSchema(normalizedProp as Record<string, unknown>)
);
}
if (normalizedProp.items && typeof normalizedProp.items === 'object') {
normalizedProp.items = this.normalizeJsonSchema(
normalizedProp.items as Record<string, unknown>
);
}
normalizedProps[key] = normalizedProp;
}
normalized.properties = normalizedProps;
}
return normalized;
}
private mcpDefinition(
name: string,
description: string | undefined,
schema?: Record<string, unknown>
): OpenAIToolDefinition {
return {
function: {
description,
name,
parameters: schema ?? { properties: {}, required: [], type: JsonSchemaType.OBJECT }
},
type: ToolCallType.FUNCTION
};
}
get serverTools(): OpenAIToolDefinition[] {
return this._serverTools;
}
get serverHome(): string | null {
return this._serverHome ?? null;
}
get mcpTools(): OpenAIToolDefinition[] {
return this.mcpEntries().map((e) => e.definition);
}
get browserTools(): OpenAIToolDefinition[] {
const tools: OpenAIToolDefinition[] = [buildGetDatetimeToolDefinition()];
if (settingsStore.config.jsSandboxEnabled) {
tools.push(buildSandboxToolDefinition(!!settingsStore.config.symbolicMathEnabled));
}
const readMedia = this.readMediaTool();
if (readMedia) tools.push(readMedia);
// provide browser's get_info tool if server doesn't provide one
if (!this.hasServerTool(BuiltInTool.SERVER_GET_INFO)) {
tools.push(buildBrowserInfoToolDefinition());
}
return tools;
}
private hasServerTool(name: BuiltInTool): boolean {
return this._serverTools.some((def) => def.function.name === name);
}
/**
* `read_media` runs in the browser on top of the server's `read_file`, so it
* exists only when that tool is served and the active model can perceive the
* bytes. The server cannot make this call - it does not know which model the
* conversation uses.
*/
private readMediaTool(): OpenAIToolDefinition | null {
if (!this.hasServerTool(BuiltInTool.SERVER_READ_FILE)) return null;
const model = modelsStore.selectedModelName ?? modelsStore.models[0]?.model ?? '';
if (!model) return null;
const vision = modelsStore.modelSupportsVision(model);
const audio = modelsStore.modelSupportsAudio(model);
if (!vision && !audio) return null;
return buildReadMediaToolDefinition(vision, audio);
}
get customTools(): OpenAIToolDefinition[] {
const raw = settingsStore.config.customJson;
if (!raw || typeof raw !== 'string') return [];
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter(
(t: unknown): t is OpenAIToolDefinition =>
typeof t === 'object' &&
t !== null &&
'type' in t &&
(t as OpenAIToolDefinition).type === 'function' &&
'function' in t &&
typeof (t as OpenAIToolDefinition).function?.name === 'string'
);
} catch {
return [];
}
}
/** Normalize MCP tools from live connections when available, fall back to health check data */
private mcpEntries(): {
serverId: string;
serverName: string;
definition: OpenAIToolDefinition;
}[] {
const out: { serverId: string; serverName: string; definition: OpenAIToolDefinition }[] = [];
const connections = mcpStore.getConnections();
if (connections.size > 0) {
for (const [serverId, connection] of connections) {
const serverName = mcpStore.getServerDisplayName(serverId);
for (const tool of connection.tools) {
const rawSchema = (tool.inputSchema as Record<string, unknown>) ?? {
properties: {},
required: [],
type: JsonSchemaType.OBJECT
};
out.push({
definition: {
function: {
description: tool.description,
name: tool.name,
parameters: this.normalizeJsonSchema(rawSchema)
},
type: ToolCallType.FUNCTION
},
serverId,
serverName
});
}
}
} else {
for (const { serverId, serverName, tools } of this.getMcpToolsFromHealthChecks()) {
for (const tool of tools) {
out.push({
definition: this.mcpDefinition(tool.name, tool.description),
serverId,
serverName
});
}
}
}
return out;
get allToolDefinitions(): OpenAIToolDefinition[] {
return this.allTools.map((t) => t.definition);
}
/** Canonical flat list of tool entries with source metadata and stable keys, deduped by key */
@@ -353,6 +107,97 @@ class ToolsStore {
return entries;
}
get browserTools(): OpenAIToolDefinition[] {
const tools: OpenAIToolDefinition[] = [buildGetDatetimeToolDefinition()];
if (settingsStore.config.jsSandboxEnabled) {
tools.push(buildSandboxToolDefinition(!!settingsStore.config.symbolicMathEnabled));
}
const readMedia = this.readMediaTool();
if (readMedia) tools.push(readMedia);
// provide browser's get_info tool if server doesn't provide one
if (!this.hasServerTool(BuiltInTool.SERVER_GET_INFO)) {
tools.push(buildBrowserInfoToolDefinition());
}
return tools;
}
get customTools(): OpenAIToolDefinition[] {
const raw = settingsStore.config.customJson;
if (!raw || typeof raw !== 'string') return [];
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter(
(t: unknown): t is OpenAIToolDefinition =>
typeof t === 'object' &&
t !== null &&
'type' in t &&
(t as OpenAIToolDefinition).type === 'function' &&
'function' in t &&
typeof (t as OpenAIToolDefinition).function?.name === 'string'
);
} catch {
return [];
}
}
get disabledTools(): SvelteSet<string> {
return this._disabledTools;
}
get error(): string | null {
return this._error;
}
/**
* Check if a working directory is worth setting: at least one server tool
* that reads it is both served and left enabled by the user.
*/
get hasEnabledCwdTools(): boolean {
return this._serverTools.some((def) => {
const name = def.function.name;
return (
this.cwdAwareTools.has(name) &&
!this._disabledTools.has(this.toolKey(ToolSource.SERVER, name))
);
});
}
/** Check if there are any enabled tools available (server, MCP, or custom) */
get hasEnabledTools(): boolean {
return this.getEnabledToolsForLLM().length > 0;
}
get isToolsEndpointUnreachable(): boolean {
return this._toolsEndpointUnreachable;
}
get loading(): boolean {
return this._loading;
}
get mcpTools(): OpenAIToolDefinition[] {
return this.mcpEntries().map((e) => e.definition);
}
get serverHome(): string | null {
return this._serverHome ?? null;
}
get serverTools(): OpenAIToolDefinition[] {
return this._serverTools;
}
/** Tools grouped by category for tree display, derived from the canonical entries */
get toolGroups(): ToolGroup[] {
const groups: ToolGroup[] = [];
@@ -382,16 +227,47 @@ class ToolsStore {
return groups;
}
private groupLabel(entry: ToolEntry): string {
switch (entry.source) {
case ToolSource.MCP:
return entry.serverName ?? '';
case ToolSource.CUSTOM:
return TOOL_GROUP_LABELS[ToolSource.CUSTOM];
case ToolSource.BROWSER:
return TOOL_GROUP_LABELS[ToolSource.BROWSER];
default:
return TOOL_GROUP_LABELS[ToolSource.SERVER];
/** Enable all tools belonging to a specific MCP server */
enableAllToolsForServer(serverId: string): void {
const connection = mcpStore.getConnections().get(serverId);
if (!connection) return;
for (const tool of connection.tools) {
this._disabledTools.delete(this.toolKey(ToolSource.MCP, tool.name, serverId));
}
this.persistDisabledTools();
}
async fetchServerTools(): Promise<void> {
if (this._loading) return;
this._loading = true;
this._error = null;
this._toolsEndpointUnreachable = false;
try {
const toolInfos = await ToolsService.list();
this._serverTools = toolInfos.map((info) => info.definition);
this.cwdAwareTools = new SvelteSet(
toolInfos.filter((info) => info.uses_cwd).map((info) => info.tool)
);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
this._error = errorMessage;
// 403 from /tools means the server was started without --tools
// TODO: check status code instead of relying on message
if (errorMessage.includes('this feature is disabled')) {
this._toolsEndpointUnreachable = true;
console.info('[ToolsStore] Server tools are disabled on the server');
} else {
console.error('[ToolsStore] Failed to fetch server tools:', err);
}
} finally {
this._loading = false;
}
}
@@ -430,112 +306,9 @@ class ToolsStore {
return result;
}
get allToolDefinitions(): OpenAIToolDefinition[] {
return this.allTools.map((t) => t.definition);
}
get loading(): boolean {
return this._loading;
}
get error(): string | null {
return this._error;
}
get isToolsEndpointUnreachable(): boolean {
return this._toolsEndpointUnreachable;
}
get disabledTools(): SvelteSet<string> {
return this._disabledTools;
}
isToolEnabled(key: string): boolean {
return !this._disabledTools.has(key);
}
toggleTool(key: string): void {
if (this._disabledTools.has(key)) {
this._disabledTools.delete(key);
} else {
this._disabledTools.add(key);
}
this.persistDisabledTools();
}
setToolEnabled(key: string, enabled: boolean): void {
if (enabled) {
this._disabledTools.delete(key);
} else {
this._disabledTools.add(key);
}
}
/** Enable all tools belonging to a specific MCP server */
enableAllToolsForServer(serverId: string): void {
const connection = mcpStore.getConnections().get(serverId);
if (!connection) return;
for (const tool of connection.tools) {
this._disabledTools.delete(this.toolKey(ToolSource.MCP, tool.name, serverId));
}
this.persistDisabledTools();
}
toggleGroup(group: ToolGroup): void {
const allEnabled = group.tools.every((t) => this.isToolEnabled(t.key));
const target = !allEnabled;
for (const tool of group.tools) {
if (target) this._disabledTools.delete(tool.key);
else this._disabledTools.add(tool.key);
}
this.persistDisabledTools();
}
isGroupFullyEnabled(group: ToolGroup): boolean {
return group.tools.length > 0 && group.tools.every((t) => this.isToolEnabled(t.key));
}
/** Get MCP tools from health check data, used when live connections aren't established yet */
private getMcpToolsFromHealthChecks(): {
serverId: string;
serverName: string;
tools: { name: string; description?: string }[];
}[] {
const result: ReturnType<ToolsStore['getMcpToolsFromHealthChecks']> = [];
for (const server of mcpStore.getServers()) {
if (!server.enabled) continue;
const health = mcpStore.getHealthCheckState(server.id);
if (health.status === HealthCheckStatus.SUCCESS && health.tools.length > 0) {
result.push({
serverId: server.id,
serverName: mcpStore.getServerLabel(server),
tools: health.tools
});
}
}
return result;
}
/** First canonical entry matching a tool name, runtime tool calls resolve by name */
private findEntryByName(toolName: string): ToolEntry | null {
for (const entry of this.allTools) {
if (entry.definition.function.name === toolName) return entry;
}
return null;
}
/** Determine the source of a tool by its name */
getToolSource(toolName: string): ToolSource | null {
return this.findEntryByName(toolName)?.source ?? null;
/** Permission key for a tool name, identical to the selection key */
getPermissionKey(toolName: string): string | null {
return this.findEntryByName(toolName)?.key ?? null;
}
/** Get the display label for the server that owns a given tool */
@@ -555,61 +328,44 @@ class ToolsStore {
return '';
}
/** Permission key for a tool name, identical to the selection key */
getPermissionKey(toolName: string): string | null {
return this.findEntryByName(toolName)?.key ?? null;
}
/** Check if there are any enabled tools available (server, MCP, or custom) */
get hasEnabledTools(): boolean {
return this.getEnabledToolsForLLM().length > 0;
/** Determine the source of a tool by its name */
getToolSource(toolName: string): ToolSource | null {
return this.findEntryByName(toolName)?.source ?? null;
}
/**
* Check if a working directory is worth setting: at least one server tool
* that reads it is both served and left enabled by the user.
* Load persisted disabled tools and fetch the builtin tool list.
* Called by initStores() after migrations have run.
*/
get hasEnabledCwdTools(): boolean {
return this._serverTools.some((def) => {
const name = def.function.name;
return (
this._cwdAwareTools.has(name) &&
!this._disabledTools.has(this.toolKey(ToolSource.SERVER, name))
);
});
}
async fetchServerTools(): Promise<void> {
if (this._loading) return;
this._loading = true;
this._error = null;
this._toolsEndpointUnreachable = false;
initialize(): void {
// browser-only init: skip on SSR to avoid localStorage/fetch side effects
if (!browser) return;
try {
const toolInfos = await ToolsService.list();
const stored = localStorage.getItem(DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY);
this._serverTools = toolInfos.map((info) => info.definition);
this._cwdAwareTools = new SvelteSet(
toolInfos.filter((info) => info.uses_cwd).map((info) => info.tool)
);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
if (stored) {
const parsed = JSON.parse(stored);
this._error = errorMessage;
// 403 from /tools means the server was started without --tools
// TODO: check status code instead of relying on message
if (errorMessage.includes('this feature is disabled')) {
this._toolsEndpointUnreachable = true;
console.info('[ToolsStore] Server tools are disabled on the server');
} else {
console.error('[ToolsStore] Failed to fetch server tools:', err);
if (Array.isArray(parsed)) {
for (const key of parsed) {
if (typeof key === 'string') this._disabledTools.add(key);
}
}
}
} finally {
this._loading = false;
} catch (err) {
console.error('[ToolsStore] Failed to load disabled tools from localStorage:', err);
}
this.fetchServerTools();
}
isGroupFullyEnabled(group: ToolGroup): boolean {
return group.tools.length > 0 && group.tools.every((t) => this.isToolEnabled(t.key));
}
isToolEnabled(key: string): boolean {
return !this._disabledTools.has(key);
}
/**
@@ -637,6 +393,259 @@ class ToolsStore {
return this._serverHome;
}
setToolEnabled(key: string, enabled: boolean): void {
if (enabled) {
this._disabledTools.delete(key);
} else {
this._disabledTools.add(key);
}
}
toggleGroup(group: ToolGroup): void {
const allEnabled = group.tools.every((t) => this.isToolEnabled(t.key));
const target = !allEnabled;
for (const tool of group.tools) {
if (target) this._disabledTools.delete(tool.key);
else this._disabledTools.add(tool.key);
}
this.persistDisabledTools();
}
toggleTool(key: string): void {
if (this._disabledTools.has(key)) {
this._disabledTools.delete(key);
} else {
this._disabledTools.add(key);
}
this.persistDisabledTools();
}
/** First canonical entry matching a tool name, runtime tool calls resolve by name */
private findEntryByName(toolName: string): ToolEntry | null {
for (const entry of this.allTools) {
if (entry.definition.function.name === toolName) return entry;
}
return null;
}
/** Get MCP tools from health check data, used when live connections aren't established yet */
private getMcpToolsFromHealthChecks(): {
serverId: string;
serverName: string;
tools: { name: string; description?: string }[];
}[] {
const result: ReturnType<ToolsStore['getMcpToolsFromHealthChecks']> = [];
for (const server of mcpStore.getServers()) {
if (!server.enabled) continue;
const health = mcpStore.getHealthCheckState(server.id);
if (health.status === HealthCheckStatus.SUCCESS && health.tools.length > 0) {
result.push({
serverId: server.id,
serverName: mcpStore.getServerLabel(server),
tools: health.tools
});
}
}
return result;
}
private groupLabel(entry: ToolEntry): string {
switch (entry.source) {
case ToolSource.MCP:
return entry.serverName ?? '';
case ToolSource.CUSTOM:
return TOOL_GROUP_LABELS[ToolSource.CUSTOM];
case ToolSource.BROWSER:
return TOOL_GROUP_LABELS[ToolSource.BROWSER];
default:
return TOOL_GROUP_LABELS[ToolSource.SERVER];
}
}
private hasServerTool(name: BuiltInTool): boolean {
return this._serverTools.some((def) => def.function.name === name);
}
private inferTypeFromDefault(value: unknown): string | undefined {
if (typeof value === 'string') return 'string';
if (typeof value === 'boolean') return 'boolean';
if (typeof value === 'number') return Number.isInteger(value) ? 'integer' : 'number';
if (Array.isArray(value)) return 'array';
if (value !== null && typeof value === 'object') return 'object';
return undefined;
}
private mcpDefinition(
name: string,
description: string | undefined,
schema?: Record<string, unknown>
): OpenAIToolDefinition {
return {
function: {
description,
name,
parameters: schema ?? { properties: {}, required: [], type: JsonSchemaType.OBJECT }
},
type: ToolCallType.FUNCTION
};
}
/** Normalize MCP tools from live connections when available, fall back to health check data */
private mcpEntries(): {
serverId: string;
serverName: string;
definition: OpenAIToolDefinition;
}[] {
const out: { serverId: string; serverName: string; definition: OpenAIToolDefinition }[] = [];
const connections = mcpStore.getConnections();
if (connections.size > 0) {
for (const [serverId, connection] of connections) {
const serverName = mcpStore.getServerDisplayName(serverId);
for (const tool of connection.tools) {
const rawSchema = (tool.inputSchema as Record<string, unknown>) ?? {
properties: {},
required: [],
type: JsonSchemaType.OBJECT
};
out.push({
definition: {
function: {
description: tool.description,
name: tool.name,
parameters: this.normalizeJsonSchema(rawSchema)
},
type: ToolCallType.FUNCTION
},
serverId,
serverName
});
}
}
} else {
for (const { serverId, serverName, tools } of this.getMcpToolsFromHealthChecks()) {
for (const tool of tools) {
out.push({
definition: this.mcpDefinition(tool.name, tool.description),
serverId,
serverName
});
}
}
}
return out;
}
/**
* Recursively normalize a JSON Schema object: infers `type` from `default`
* for properties / items that omit it, and descends into nested `properties`
* and `items`. Returns a new object -- does not mutate the input.
*/
private normalizeJsonSchema(schema: Record<string, unknown>): Record<string, unknown> {
if (!schema || typeof schema !== 'object') return schema;
const normalized: Record<string, unknown> = { ...schema };
if (normalized.properties && typeof normalized.properties === 'object') {
const props = normalized.properties as Record<string, Record<string, unknown>>;
const normalizedProps: Record<string, Record<string, unknown>> = {};
for (const [key, prop] of Object.entries(props)) {
if (!prop || typeof prop !== 'object') {
normalizedProps[key] = prop;
continue;
}
const normalizedProp: Record<string, unknown> = { ...prop };
if (!normalizedProp.type && normalizedProp.default !== undefined) {
const inferred = this.inferTypeFromDefault(normalizedProp.default);
if (inferred) normalizedProp.type = inferred;
}
if (normalizedProp.properties) {
Object.assign(
normalizedProp,
this.normalizeJsonSchema(normalizedProp as Record<string, unknown>)
);
}
if (normalizedProp.items && typeof normalizedProp.items === 'object') {
normalizedProp.items = this.normalizeJsonSchema(
normalizedProp.items as Record<string, unknown>
);
}
normalizedProps[key] = normalizedProp;
}
normalized.properties = normalizedProps;
}
return normalized;
}
private persistDisabledTools(): void {
try {
localStorage.setItem(
DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY,
JSON.stringify([...this._disabledTools])
);
} catch {
// ignore storage errors
}
}
/**
* `read_media` runs in the browser on top of the server's `read_file`, so it
* exists only when that tool is served and the active model can perceive the
* bytes. The server cannot make this call - it does not know which model the
* conversation uses.
*/
private readMediaTool(): OpenAIToolDefinition | null {
if (!this.hasServerTool(BuiltInTool.SERVER_READ_FILE)) return null;
const model = modelsStore.selectedModelName ?? modelsStore.models[0]?.model ?? '';
if (!model) return null;
const vision = modelsStore.props.modelSupportsVision(model);
const audio = modelsStore.props.modelSupportsAudio(model);
if (!vision && !audio) return null;
return buildReadMediaToolDefinition(vision, audio);
}
private toolKey(source: ToolSource, name: string, serverId?: string): string {
switch (source) {
case ToolSource.MCP:
return serverId ? `mcp-${serverId}:${name}` : `mcp:${name}`;
case ToolSource.CUSTOM:
return `custom:${name}`;
case ToolSource.BROWSER:
return `browser:${name}`;
default:
return `server:${name}`;
}
}
}
export const toolsStore = new ToolsStore();
+1 -1
View File
@@ -205,7 +205,7 @@ export interface AgenticSection {
/** ID of the model-side tool call (matches tool_calls[i].id). Lets
* downstream consumers correlate a section with the agentic loop's
* currently-executing tool, e.g. to drive live-streaming UI state
* by matching against agenticStore.executingToolCallId. */
* by matching against agenticStore.getExecutingToolCallId. */
toolCallId?: string;
wasInterrupted?: boolean;
}
+4 -28
View File
@@ -1,7 +1,6 @@
import { getAuthHeaders, getJsonHeaders } from './api-headers';
import { base } from '$app/paths';
import { ERROR_MESSAGES, HTTP_CODE_TO_STRING } from '$lib/constants';
import { UrlProtocol } from '$lib/enums';
import { API_ABSOLUTE_URL_PROTOCOLS, ERROR_MESSAGES, HTTP_CODE_TO_STRING } from '$lib/constants';
/**
* API Fetch Utilities
@@ -63,10 +62,8 @@ export async function apiFetch<T>(path: string, options: ApiFetchOptions = {}):
const { authOnly = false, headers: customHeaders, ...fetchOptions } = options;
const baseHeaders = authOnly ? getAuthHeaders() : getJsonHeaders();
const headers = { ...baseHeaders, ...customHeaders };
const url =
path.startsWith(UrlProtocol.HTTP) || path.startsWith(UrlProtocol.HTTPS)
? path
: `${base}${path}`;
// absolute URLs with an allowed protocol pass through untouched; relative paths get the base prefix
const url = API_ABSOLUTE_URL_PROTOCOLS.some((p) => path.startsWith(p)) ? path : `${base}${path}`;
let response;
@@ -117,28 +114,7 @@ export async function apiFetchWithParams<T>(
}
}
const { authOnly = false, headers: customHeaders, ...fetchOptions } = options;
const baseHeaders = authOnly ? getAuthHeaders() : getJsonHeaders();
const headers = { ...baseHeaders, ...customHeaders };
let response;
try {
response = await fetch(url.toString(), {
...fetchOptions,
headers
});
} catch (e) {
throw new Error(beautifyNetworkError(e));
}
if (!response.ok) {
const errorMessage = await parseErrorMessage(response);
throw new ApiError(errorMessage, response.status);
}
return response.json() as Promise<T>;
return apiFetch<T>(url.toString(), options);
}
/**
+1 -1
View File
@@ -1,7 +1,7 @@
import { redactValue } from './redact';
import { CORS_PROXY, HEADERS } from '$lib/constants';
import { MimeTypeApplication } from '$lib/enums';
import { settingsStore } from '$lib/stores/settings.svelte';
import { settingsStore } from '$lib/stores/settings/index.svelte';
/**
* Get authorization headers for API requests
+1 -1
View File
@@ -3,7 +3,7 @@ import { browser } from '$app/environment';
import { base } from '$app/paths';
import { HEADERS } from '$lib/constants';
import { MimeTypeApplication } from '$lib/enums';
import { settingsStore } from '$lib/stores/settings.svelte';
import { settingsStore } from '$lib/stores/settings/index.svelte';
/**
* Validates API key by making a request to the server props endpoint
+29 -29
View File
@@ -14,10 +14,37 @@ import { MimeTypeAudio } from '$lib/enums';
* - Proper cleanup and resource management
*/
export class AudioRecorder {
private mediaRecorder: MediaRecorder | null = null;
private audioChunks: Blob[] = [];
private stream: MediaStream | null = null;
private mediaRecorder: MediaRecorder | null = null;
private recordingState: boolean = false;
private stream: MediaStream | null = null;
cancelRecording(): void {
const recorder = this.mediaRecorder;
const stream = this.stream;
this.mediaRecorder = null;
this.audioChunks = [];
this.stream = null;
this.recordingState = false;
if (recorder && recorder.state !== 'inactive') {
// Drop the original handlers so the pending stop event does not touch the instance
recorder.onstop = null;
recorder.onerror = null;
recorder.stop();
}
if (stream) {
for (const track of stream.getTracks()) {
track.stop();
}
}
}
isRecording(): boolean {
return this.recordingState;
}
async startRecording(): Promise<void> {
try {
@@ -90,33 +117,6 @@ export class AudioRecorder {
});
}
isRecording(): boolean {
return this.recordingState;
}
cancelRecording(): void {
const recorder = this.mediaRecorder;
const stream = this.stream;
this.mediaRecorder = null;
this.audioChunks = [];
this.stream = null;
this.recordingState = false;
if (recorder && recorder.state !== 'inactive') {
// Drop the original handlers so the pending stop event does not touch the instance
recorder.onstop = null;
recorder.onerror = null;
recorder.stop();
}
if (stream) {
for (const track of stream.getTracks()) {
track.stop();
}
}
}
private initializeRecorder(stream: MediaStream): void {
const options: MediaRecorderOptions = {};
+102 -102
View File
@@ -31,9 +31,29 @@ interface CacheEntry<T> {
export class TTLCache<K extends string, V> {
private cache = new Map<K, CacheEntry<V>>();
private readonly ttlMs: number;
private readonly maxEntries: number;
private readonly onEvict?: (key: string, value: unknown) => void;
private readonly ttlMs: number;
/**
* Get the number of entries (including potentially expired ones).
*/
get size(): number {
return this.cache.size;
}
/**
* Clear all entries from cache.
*/
clear(): void {
if (this.onEvict) {
for (const [key, entry] of this.cache) {
this.onEvict(key, entry.value);
}
}
this.cache.clear();
}
constructor(options: TTLCacheOptions = {}) {
this.ttlMs = options.ttlMs ?? CACHE.DEFAULT_TTL_MS;
@@ -41,6 +61,19 @@ export class TTLCache<K extends string, V> {
this.onEvict = options.onEvict;
}
/**
* Delete a specific key from cache.
*/
delete(key: K): boolean {
const entry = this.cache.get(key);
if (entry && this.onEvict) {
this.onEvict(key, entry.value);
}
return this.cache.delete(key);
}
/**
* Get a value from cache. Returns null if expired or not found.
*/
@@ -61,25 +94,6 @@ export class TTLCache<K extends string, V> {
return entry.value;
}
/**
* Set a value in cache with TTL.
*/
set(key: K, value: V, customTtlMs?: number): void {
// Evict oldest entries if at capacity
if (this.cache.size >= this.maxEntries && !this.cache.has(key)) {
this.evictOldest();
}
const ttl = customTtlMs ?? this.ttlMs;
const now = Date.now();
this.cache.set(key, {
expiresAt: now + ttl,
lastAccessed: now,
value
});
}
/**
* Check if key exists and is not expired.
*/
@@ -98,36 +112,19 @@ export class TTLCache<K extends string, V> {
}
/**
* Delete a specific key from cache.
* Get all valid (non-expired) keys.
*/
delete(key: K): boolean {
const entry = this.cache.get(key);
keys(): K[] {
const now = Date.now();
const validKeys: K[] = [];
if (entry && this.onEvict) {
this.onEvict(key, entry.value);
}
return this.cache.delete(key);
}
/**
* Clear all entries from cache.
*/
clear(): void {
if (this.onEvict) {
for (const [key, entry] of this.cache) {
this.onEvict(key, entry.value);
for (const [key, entry] of this.cache) {
if (now <= entry.expiresAt) {
validKeys.push(key);
}
}
this.cache.clear();
}
/**
* Get the number of entries (including potentially expired ones).
*/
get size(): number {
return this.cache.size;
return validKeys;
}
/**
@@ -150,38 +147,22 @@ export class TTLCache<K extends string, V> {
}
/**
* Get all valid (non-expired) keys.
* Set a value in cache with TTL.
*/
keys(): K[] {
set(key: K, value: V, customTtlMs?: number): void {
// Evict oldest entries if at capacity
if (this.cache.size >= this.maxEntries && !this.cache.has(key)) {
this.evictOldest();
}
const ttl = customTtlMs ?? this.ttlMs;
const now = Date.now();
const validKeys: K[] = [];
for (const [key, entry] of this.cache) {
if (now <= entry.expiresAt) {
validKeys.push(key);
}
}
return validKeys;
}
/**
* Evict the oldest (least recently accessed) entry.
*/
private evictOldest(): void {
let oldestKey: K | null = null;
let oldestTime = Infinity;
for (const [key, entry] of this.cache) {
if (entry.lastAccessed < oldestTime) {
oldestTime = entry.lastAccessed;
oldestKey = key;
}
}
if (oldestKey !== null) {
this.delete(oldestKey);
}
this.cache.set(key, {
expiresAt: now + ttl,
lastAccessed: now,
value
});
}
/**
@@ -205,6 +186,25 @@ export class TTLCache<K extends string, V> {
return true;
}
/**
* Evict the oldest (least recently accessed) entry.
*/
private evictOldest(): void {
let oldestKey: K | null = null;
let oldestTime = Infinity;
for (const [key, entry] of this.cache) {
if (entry.lastAccessed < oldestTime) {
oldestTime = entry.lastAccessed;
oldestKey = key;
}
}
if (oldestKey !== null) {
this.delete(oldestKey);
}
}
}
/**
@@ -213,14 +213,26 @@ export class TTLCache<K extends string, V> {
*/
export class ReactiveTTLMap<K extends string, V> {
private entries = $state<Map<K, CacheEntry<V>>>(new Map());
private readonly ttlMs: number;
private readonly maxEntries: number;
private readonly ttlMs: number;
get size(): number {
return this.entries.size;
}
clear(): void {
this.entries.clear();
}
constructor(options: TTLCacheOptions = {}) {
this.ttlMs = options.ttlMs ?? CACHE.DEFAULT_TTL_MS;
this.maxEntries = options.maxEntries ?? CACHE.DEFAULT_MAX_ENTRIES;
}
delete(key: K): boolean {
return this.entries.delete(key);
}
get(key: K): V | null {
const entry = this.entries.get(key);
@@ -237,21 +249,6 @@ export class ReactiveTTLMap<K extends string, V> {
return entry.value;
}
set(key: K, value: V, customTtlMs?: number): void {
if (this.entries.size >= this.maxEntries && !this.entries.has(key)) {
this.evictOldest();
}
const ttl = customTtlMs ?? this.ttlMs;
const now = Date.now();
this.entries.set(key, {
expiresAt: now + ttl,
lastAccessed: now,
value
});
}
has(key: K): boolean {
const entry = this.entries.get(key);
@@ -266,18 +263,6 @@ export class ReactiveTTLMap<K extends string, V> {
return true;
}
delete(key: K): boolean {
return this.entries.delete(key);
}
clear(): void {
this.entries.clear();
}
get size(): number {
return this.entries.size;
}
prune(): number {
const now = Date.now();
@@ -293,6 +278,21 @@ export class ReactiveTTLMap<K extends string, V> {
return pruned;
}
set(key: K, value: V, customTtlMs?: number): void {
if (this.entries.size >= this.maxEntries && !this.entries.has(key)) {
this.evictOldest();
}
const ttl = customTtlMs ?? this.ttlMs;
const now = Date.now();
this.entries.set(key, {
expiresAt: now + ttl,
lastAccessed: now,
value
});
}
private evictOldest(): void {
let oldestKey: K | null = null;
let oldestTime = Infinity;
@@ -38,7 +38,7 @@ import {
SETTINGS_KEYS
} from '$lib/constants';
import { BooleanString, ChatFormInputRichTokenKind } from '$lib/enums';
import { settingsStore } from '$lib/stores/settings.svelte';
import { settingsStore } from '$lib/stores/settings/index.svelte';
import { toolsStore } from '$lib/stores/tools.svelte';
import type { ChatFormInputRichToken } from '$lib/types/chat-form-input-rich';
@@ -4,8 +4,8 @@ import { isLikelyTextFile, readFileAsText } from './text-files';
import { isWebpMimeType, webpBase64UrlToPngDataURL } from './webp-to-png';
import { SETTINGS_KEYS } from '$lib/constants';
import { AttachmentType, FileTypeCategory, SpecialFileType } from '$lib/enums';
import { modelsStore } from '$lib/stores/models.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { modelsStore } from '$lib/stores/models/index.svelte';
import { settingsStore } from '$lib/stores/settings/index.svelte';
import type { ChatUploadedFile, DatabaseMessageExtra, FileProcessingResult } from '$lib/types';
import { getFileTypeCategory } from '$lib/utils';
import { toast } from 'svelte-sonner';
@@ -112,7 +112,7 @@ export async function parseFilesToMessageExtras(
const currentConfig = settingsStore.config;
// Use per-model vision check for router mode
const hasVisionSupport = activeModelId
? modelsStore.modelSupportsVision(activeModelId)
? modelsStore.props.modelSupportsVision(activeModelId)
: false;
// Force PDF-to-text for non-vision models
+5 -2
View File
@@ -130,7 +130,7 @@ export { getImageErrorFallbackHtml } from './image-error-fallback';
// SSE-with-JSON stream iterator (used by server tool streaming, decoupled
// from chat.service.ts which embeds its own SSE parser for resume support)
export { parseSseJsonStream } from './sse';
export { extractSseDataPayload, parseSseJsonStream, splitSseRecords } from './sse';
// Stream session identity (conversation-id based)
export { streamIdentity } from './stream-identity';
@@ -150,7 +150,10 @@ export {
getResourceIcon,
getResourceTextContent,
getResourceBlobContent,
downloadResourceContent
downloadResourceContent,
getMcpIconUrl,
getMcpServerFaviconFallback,
getMcpServerLabel
} from './mcp';
// URI Template utilities
+148 -2
View File
@@ -1,3 +1,4 @@
import { extractRootDomain } from './url';
import {
AlertTriangle,
Code,
@@ -12,8 +13,10 @@ import {
CODE_FILE_EXTENSION_REGEX,
DEFAULT_RESOURCE_FILENAME,
DISPLAY_NAME_SEPARATOR_REGEX,
EXPECTED_THEMED_ICON_PAIR_COUNT,
FILE_EXTENSION_REGEX,
IMAGE_FILE_EXTENSION_REGEX,
MCP_ALLOWED_ICON_MIME_TYPES,
MCP_SERVER_ID_PREFIX,
MCP_SSE,
MIME_TYPE_PREFIXES,
@@ -24,8 +27,22 @@ import {
TEXT_FILE_EXTENSION_REGEX,
URI_PATTERNS
} from '$lib/constants';
import { MCPLogLevel, MCPTransportType, MimeTypeText, UrlProtocol } from '$lib/enums';
import type { MCPResourceContent, MCPResourceInfo, MCPServerSettingsEntry } from '$lib/types';
import {
ColorMode,
HealthCheckStatus,
MCPLogLevel,
MCPTransportType,
MimeTypeText,
UrlProtocol
} from '$lib/enums';
import type {
HealthCheckState,
MCPResourceContent,
MCPResourceIcon,
MCPResourceInfo,
MCPServerDisplayInfo,
MCPServerSettingsEntry
} from '$lib/types';
import type { MimeTypeUnion } from '$lib/types/common';
import type { Component } from 'svelte';
@@ -316,3 +333,132 @@ export function downloadResourceContent(
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/**
* Validates that an icon URI uses a safe scheme (https: or data:).
*/
function isValidMcpIconUri(src: string): boolean {
try {
if (src.startsWith(UrlProtocol.DATA)) return true;
const url = new URL(src);
return url.protocol === UrlProtocol.HTTPS;
} catch {
return false;
}
}
/**
* Selects the best icon URL from an MCP icons array.
* Follows security guidelines from the MCP specification:
* - Only allows https: and data: URIs
* - Filters to supported MIME types
*
* Selection priority:
* 1. Icon matching the current color scheme (dark/light)
* 2. Universal icon (no theme specified); if exactly 2, assumes [0]=light, [1]=dark
* 3. First valid icon as last resort
*/
export function getMcpIconUrl(icons: MCPResourceIcon[] | undefined, isDark = false): string | null {
if (!icons?.length) return null;
const validIcons = icons.filter((icon) => {
if (!icon.src || !isValidMcpIconUri(icon.src)) return false;
if (icon.mimeType && !MCP_ALLOWED_ICON_MIME_TYPES.has(icon.mimeType)) return false;
return true;
});
if (validIcons.length === 0) return null;
const preferredTheme = isDark ? ColorMode.DARK : ColorMode.LIGHT;
// 1. Prefer icon explicitly matching the current color scheme
const themedIcon = validIcons.find((icon) => icon.theme === preferredTheme);
if (themedIcon) return themedIcon.src;
// 2. Handle universal icons (no theme specified)
const universalIcons = validIcons.filter((icon) => !icon.theme);
if (universalIcons.length === EXPECTED_THEMED_ICON_PAIR_COUNT) {
// Heuristic: two theme-less icons → assume [0] = light, [1] = dark
return universalIcons[isDark ? 1 : 0].src;
}
if (universalIcons.length > 0) {
return universalIcons[0].src;
}
// 3. Last resort: use opposite-theme icon
return validIcons[0].src;
}
/**
* Construct a fallback favicon URL from the MCP server URL.
* e.g. https://mcp.example.com/sse -> https://example.com/favicon.ico
*/
export function getMcpServerFaviconFallback(serverUrl: string): string | null {
try {
const url = new URL(serverUrl);
const rootDomain = extractRootDomain(url);
if (!rootDomain) return null;
const origin = `${url.protocol}//${rootDomain}`;
const candidates = ['favicon.ico', 'favicon.png'];
for (const path of candidates) {
const faviconUrl = `${origin}/${path}`;
if (isValidMcpIconUri(faviconUrl)) {
return faviconUrl;
}
}
} catch {
// Invalid URL, return null
}
return null;
}
/**
* Resolves the raw label for a server: user-defined display name first,
* then server-reported title or name when the health check succeeded,
* then the configured name (admin baseline or legacy data), then URL.
*/
function getMcpServerBaseLabel(
server: MCPServerDisplayInfo,
healthState?: HealthCheckState
): string {
if (server.displayName) return server.displayName;
if (healthState?.status === HealthCheckStatus.SUCCESS)
return (
healthState.serverInfo?.title || healthState.serverInfo?.name || server.name || server.url
);
return server.name || server.url;
}
/**
* Returns the display label for a server, suffixed with a positional
* counter when several configured servers resolve to the same base label
* (e.g. two endpoints of the same host reporting an identical name).
* Numbering follows config order, so it is stable across renders.
*/
export function getMcpServerLabel(
server: MCPServerDisplayInfo,
servers: MCPServerDisplayInfo[],
healthChecks: Record<string, HealthCheckState>
): string {
const label = getMcpServerBaseLabel(server, healthChecks[server.id]);
const twins = servers.filter((s) => getMcpServerBaseLabel(s, healthChecks[s.id]) === label);
if (twins.length < 2) return label;
const position = twins.findIndex((s) => s.id === server.id);
return position < 0 ? label : `${label} (${position + 1})`;
}
@@ -4,8 +4,8 @@ import { isSvgMimeType, svgBase64UrlToPngDataURL } from './svg-to-png';
import { isWebpMimeType, webpBase64UrlToPngDataURL } from './webp-to-png';
import { SETTINGS_KEYS } from '$lib/constants';
import { FileTypeCategory } from '$lib/enums';
import { modelsStore } from '$lib/stores/models.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { modelsStore } from '$lib/stores/models/index.svelte';
import { settingsStore } from '$lib/stores/settings/index.svelte';
import { getFileTypeCategory } from '$lib/utils';
import { toast } from 'svelte-sonner';
@@ -108,7 +108,7 @@ export async function processFilesToChatUploaded(
// Show suggestion toast if vision model is available but PDF as image is disabled
const hasVisionSupport = activeModelId
? modelsStore.modelSupportsVision(activeModelId)
? modelsStore.props.modelSupportsVision(activeModelId)
: false;
const currentConfig = settingsStore.config;
+13 -13
View File
@@ -12,9 +12,9 @@ export interface SourceHistoryEntry {
}
export class SourceHistory {
private undoStack: SourceHistoryEntry[] = [];
private redoStack: SourceHistoryEntry[] = [];
private lastPush = 0;
private redoStack: SourceHistoryEntry[] = [];
private undoStack: SourceHistoryEntry[] = [];
constructor(
private limit = 100,
@@ -32,17 +32,6 @@ export class SourceHistory {
this.redoStack = [];
}
undo(current: SourceHistoryEntry): SourceHistoryEntry | null {
const entry = this.undoStack.pop();
if (!entry) return null;
this.redoStack.push(current);
this.lastPush = 0; // the next edit after an undo starts a new group
return entry;
}
redo(current: SourceHistoryEntry): SourceHistoryEntry | null {
const entry = this.redoStack.pop();
@@ -53,4 +42,15 @@ export class SourceHistory {
return entry;
}
undo(current: SourceHistoryEntry): SourceHistoryEntry | null {
const entry = this.undoStack.pop();
if (!entry) return null;
this.redoStack.push(current);
this.lastPush = 0; // the next edit after an undo starts a new group
return entry;
}
}

Some files were not shown because too many files have changed in this diff Show More