mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-08-21 20:22:34 +02:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 61389bbd28 | |||
| 894ef9ab1c | |||
| 880f7d8416 | |||
| a10f9a87c0 | |||
| 177b9dcea2 | |||
| 11078864eb | |||
| 319f0d4d9f | |||
| 01441295dd | |||
| 5eee75b7fd | |||
| e28765a07a | |||
| c368a65fda | |||
| 01839d5353 | |||
| 8608a15388 | |||
| 02aedbea2d | |||
| d7bbd86e77 | |||
| 3b62d01b49 | |||
| 828d7a957a | |||
| 3c02c62695 | |||
| a1f08f03da | |||
| bce4130af8 | |||
| 30560893cf | |||
| e5f9595c1b | |||
| a36d7866bf | |||
| 0257f469fe | |||
| 14687f2241 | |||
| 857d717b2b | |||
| 0212cbb772 | |||
| 9cc5e0e136 | |||
| 93d6031697 | |||
| eaf5404091 | |||
| 6c0c31a42a | |||
| 1f5b6d0bf7 |
+106
-60
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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<string, McpServerOverride>
|
||||
|
||||
%% ═══════════════════════════════════════════════════════════════════════════
|
||||
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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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)
|
||||
```
|
||||
@@ -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<string>
|
||||
|
||||
%% ═══════════════════════════════════════════════════════════════════════════
|
||||
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
|
||||
```
|
||||
@@ -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' }],
|
||||
|
||||
|
||||
+1
-1
@@ -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) => {
|
||||
|
||||
+2
-2
@@ -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) {
|
||||
|
||||
+7
-3
@@ -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>
|
||||
|
||||
+7
-5
@@ -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(() => {
|
||||
|
||||
+3
-3
@@ -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;
|
||||
|
||||
|
||||
+3
-3
@@ -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);
|
||||
|
||||
+1
-1
@@ -87,7 +87,7 @@
|
||||
isLoading = true;
|
||||
|
||||
try {
|
||||
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
|
||||
const perChatOverrides = conversationsStore.preferences.getAllMcpServerOverrides();
|
||||
const initialized = await mcpStore.ensureInitialized(perChatOverrides);
|
||||
|
||||
if (!initialized) {
|
||||
|
||||
+1
-1
@@ -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));
|
||||
|
||||
|
||||
+1
-1
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+174
-315
@@ -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();
|
||||
+136
-112
@@ -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();
|
||||
+14
-6
@@ -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);
|
||||
}
|
||||
}
|
||||
+475
-720
File diff suppressed because it is too large
Load Diff
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
+1050
-1497
File diff suppressed because it is too large
Load Diff
+288
-328
@@ -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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
+369
-441
@@ -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();
|
||||
+7
@@ -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);
|
||||
@@ -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();
|
||||
|
||||
Vendored
+1
-1
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user