feat: Add per-chat MCP server overrides
This commit is contained in:
parent
865c28a96d
commit
81ad2d5569
|
|
@ -1,14 +1,20 @@
|
|||
import { hasEnabledMcpServers } from './mcp';
|
||||
import type { SettingsConfigType } from '$lib/types/settings';
|
||||
import type { AgenticConfig } from '$lib/types/agentic';
|
||||
import type { McpServerOverride } from '$lib/types/database';
|
||||
import { DEFAULT_AGENTIC_CONFIG } from '$lib/constants/agentic';
|
||||
import { normalizePositiveNumber } from '$lib/utils/number';
|
||||
|
||||
/**
|
||||
* Gets the current agentic configuration.
|
||||
* Automatically disables agentic mode if no MCP servers are configured.
|
||||
* @param settings - Global settings configuration
|
||||
* @param perChatOverrides - Optional per-chat MCP server overrides
|
||||
*/
|
||||
export function getAgenticConfig(settings: SettingsConfigType): AgenticConfig {
|
||||
export function getAgenticConfig(
|
||||
settings: SettingsConfigType,
|
||||
perChatOverrides?: McpServerOverride[]
|
||||
): AgenticConfig {
|
||||
const maxTurns = normalizePositiveNumber(
|
||||
settings.agenticMaxTurns,
|
||||
DEFAULT_AGENTIC_CONFIG.maxTurns
|
||||
|
|
@ -23,7 +29,7 @@ export function getAgenticConfig(settings: SettingsConfigType): AgenticConfig {
|
|||
: DEFAULT_AGENTIC_CONFIG.filterReasoningAfterFirstTurn;
|
||||
|
||||
return {
|
||||
enabled: hasEnabledMcpServers(settings) && DEFAULT_AGENTIC_CONFIG.enabled,
|
||||
enabled: hasEnabledMcpServers(settings, perChatOverrides) && DEFAULT_AGENTIC_CONFIG.enabled,
|
||||
maxTurns,
|
||||
maxToolPreviewLines,
|
||||
filterReasoningAfterFirstTurn
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { MCPClientConfig, MCPServerConfig, MCPServerSettingsEntry } from '$lib/types/mcp';
|
||||
import type { SettingsConfigType } from '$lib/types/settings';
|
||||
import type { McpServerOverride } from '$lib/types/database';
|
||||
import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp';
|
||||
import { detectMcpTransportFromUrl, generateMcpServerId } from '$lib/utils/mcp';
|
||||
import { normalizePositiveNumber } from '$lib/utils/number';
|
||||
|
|
@ -76,11 +77,33 @@ function buildServerConfig(
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a server is enabled considering per-chat overrides.
|
||||
* Per-chat override takes precedence over global setting.
|
||||
*/
|
||||
function isServerEnabled(
|
||||
server: MCPServerSettingsEntry,
|
||||
perChatOverrides?: McpServerOverride[]
|
||||
): boolean {
|
||||
if (perChatOverrides) {
|
||||
const override = perChatOverrides.find((o) => o.serverId === server.id);
|
||||
if (override !== undefined) {
|
||||
return override.enabled;
|
||||
}
|
||||
}
|
||||
return server.enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds MCP client configuration from settings.
|
||||
* Returns undefined if no valid servers are configured.
|
||||
* @param config - Global settings configuration
|
||||
* @param perChatOverrides - Optional per-chat server overrides
|
||||
*/
|
||||
export function buildMcpClientConfig(config: SettingsConfigType): MCPClientConfig | undefined {
|
||||
export function buildMcpClientConfig(
|
||||
config: SettingsConfigType,
|
||||
perChatOverrides?: McpServerOverride[]
|
||||
): MCPClientConfig | undefined {
|
||||
const rawServers = parseMcpServerSettings(config.mcpServers);
|
||||
|
||||
if (!rawServers.length) {
|
||||
|
|
@ -89,7 +112,7 @@ export function buildMcpClientConfig(config: SettingsConfigType): MCPClientConfi
|
|||
|
||||
const servers: Record<string, MCPServerConfig> = {};
|
||||
for (const [index, entry] of rawServers.entries()) {
|
||||
if (!entry.enabled) continue;
|
||||
if (!isServerEnabled(entry, perChatOverrides)) continue;
|
||||
|
||||
const normalized = buildServerConfig(entry);
|
||||
if (normalized) {
|
||||
|
|
@ -110,6 +133,55 @@ export function buildMcpClientConfig(config: SettingsConfigType): MCPClientConfi
|
|||
};
|
||||
}
|
||||
|
||||
export function hasEnabledMcpServers(config: SettingsConfigType): boolean {
|
||||
return Boolean(buildMcpClientConfig(config));
|
||||
export function hasEnabledMcpServers(
|
||||
config: SettingsConfigType,
|
||||
perChatOverrides?: McpServerOverride[]
|
||||
): boolean {
|
||||
return Boolean(buildMcpClientConfig(config, perChatOverrides));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// MCP Server Usage Stats
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type McpServerUsageStats = Record<string, number>;
|
||||
|
||||
/**
|
||||
* Parse MCP server usage stats from settings.
|
||||
*/
|
||||
export function parseMcpServerUsageStats(rawStats: unknown): McpServerUsageStats {
|
||||
if (!rawStats) return {};
|
||||
|
||||
if (typeof rawStats === 'string') {
|
||||
const trimmed = rawStats.trim();
|
||||
if (!trimmed) return {};
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
||||
return parsed as McpServerUsageStats;
|
||||
}
|
||||
} catch {
|
||||
console.warn('[MCP] Failed to parse mcpServerUsageStats JSON, ignoring value');
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get usage count for a specific server.
|
||||
*/
|
||||
export function getMcpServerUsageCount(config: SettingsConfigType, serverId: string): number {
|
||||
const stats = parseMcpServerUsageStats(config.mcpServerUsageStats);
|
||||
return stats[serverId] || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment usage count for a server and return updated stats JSON.
|
||||
*/
|
||||
export function incrementMcpServerUsage(config: SettingsConfigType, serverId: string): string {
|
||||
const stats = parseMcpServerUsageStats(config.mcpServerUsageStats);
|
||||
stats[serverId] = (stats[serverId] || 0) + 1;
|
||||
return JSON.stringify(stats);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,9 +19,11 @@ export const SETTING_CONFIG_DEFAULT: Record<string, string | number | boolean> =
|
|||
autoShowSidebarOnNewChat: true,
|
||||
autoMicOnEmpty: false,
|
||||
mcpServers: '[]',
|
||||
mcpServerUsageStats: '{}', // JSON object: { [serverId]: usageCount }
|
||||
agenticMaxTurns: 10,
|
||||
agenticMaxToolPreviewLines: 25,
|
||||
agenticFilterReasoningAfterFirstTurn: true,
|
||||
showToolCallInProgress: false,
|
||||
// make sure these default values are in sync with `common.h`
|
||||
samplers: 'top_k;typ_p;top_p;min_p;temperature',
|
||||
backend_sampling: false,
|
||||
|
|
@ -113,12 +115,16 @@ export const SETTING_CONFIG_INFO: Record<string, string> = {
|
|||
'Automatically show microphone button instead of send button when textarea is empty for models with audio modality support.',
|
||||
mcpServers:
|
||||
'Configure MCP servers as a JSON list. Use the form in the MCP Client settings section to edit.',
|
||||
mcpServerUsageStats:
|
||||
'Usage statistics for MCP servers. Tracks how many times tools from each server have been used.',
|
||||
agenticMaxTurns:
|
||||
'Maximum number of tool execution cycles before stopping (prevents infinite loops).',
|
||||
agenticMaxToolPreviewLines:
|
||||
'Number of lines shown in tool output previews (last N lines). Only these previews and the final LLM response persist after the agentic loop completes.',
|
||||
agenticFilterReasoningAfterFirstTurn:
|
||||
'Only show reasoning from the first agentic turn. When disabled, reasoning from all turns is merged in one (WebUI limitation).',
|
||||
showToolCallInProgress:
|
||||
'Automatically expand tool call details while executing and keep them expanded after completion.',
|
||||
pyInterpreterEnabled:
|
||||
'Enable Python interpreter using Pyodide. Allows running Python code in markdown code blocks.',
|
||||
enableContinueGeneration:
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ import {
|
|||
import type { ApiChatCompletionToolCall, ApiChatMessageData } from '$lib/types/api';
|
||||
import type { ChatMessagePromptProgress, ChatMessageTimings } from '$lib/types/chat';
|
||||
import type { MCPToolCall } from '$lib/types/mcp';
|
||||
import type { DatabaseMessage, DatabaseMessageExtra } from '$lib/types/database';
|
||||
import type { DatabaseMessage, DatabaseMessageExtra, McpServerOverride } from '$lib/types/database';
|
||||
import { getAgenticConfig } from '$lib/config/agentic';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { getAuthHeaders } from '$lib/utils';
|
||||
|
|
@ -69,6 +69,8 @@ export interface AgenticFlowParams {
|
|||
options?: AgenticFlowOptions;
|
||||
callbacks: AgenticFlowCallbacks;
|
||||
signal?: AbortSignal;
|
||||
/** Per-chat MCP server overrides */
|
||||
perChatOverrides?: McpServerOverride[];
|
||||
}
|
||||
|
||||
export interface AgenticFlowResult {
|
||||
|
|
@ -126,18 +128,18 @@ class AgenticStore {
|
|||
* @returns AgenticFlowResult indicating if the flow handled the request
|
||||
*/
|
||||
async runAgenticFlow(params: AgenticFlowParams): Promise<AgenticFlowResult> {
|
||||
const { messages, options = {}, callbacks, signal } = params;
|
||||
const { messages, options = {}, callbacks, signal, perChatOverrides } = params;
|
||||
const { onChunk, onReasoningChunk, onToolCallChunk, onModel, onComplete, onError, onTimings } =
|
||||
callbacks;
|
||||
|
||||
// Get agentic configuration
|
||||
const agenticConfig = getAgenticConfig(config());
|
||||
// Get agentic configuration (considering per-chat MCP overrides)
|
||||
const agenticConfig = getAgenticConfig(config(), perChatOverrides);
|
||||
if (!agenticConfig.enabled) {
|
||||
return { handled: false };
|
||||
}
|
||||
|
||||
// Ensure MCP is initialized
|
||||
const hostManager = await mcpStore.ensureInitialized();
|
||||
// Ensure MCP is initialized with per-chat overrides
|
||||
const hostManager = await mcpStore.ensureInitialized(perChatOverrides);
|
||||
if (!hostManager) {
|
||||
console.log('[AgenticStore] MCP not initialized, falling back to standard chat');
|
||||
return { handled: false };
|
||||
|
|
|
|||
|
|
@ -634,8 +634,9 @@ class ChatStore {
|
|||
}
|
||||
};
|
||||
|
||||
// Try agentic flow first if enabled
|
||||
const agenticConfig = getAgenticConfig(config());
|
||||
// Try agentic flow first if enabled (considering per-chat MCP overrides)
|
||||
const perChatOverrides = conversationsStore.activeConversation?.mcpServerOverrides;
|
||||
const agenticConfig = getAgenticConfig(config(), perChatOverrides);
|
||||
if (agenticConfig.enabled) {
|
||||
const agenticResult = await agenticStore.runAgenticFlow({
|
||||
messages: allMessages,
|
||||
|
|
@ -644,7 +645,8 @@ class ChatStore {
|
|||
...(modelOverride ? { model: modelOverride } : {})
|
||||
},
|
||||
callbacks: streamCallbacks,
|
||||
signal: abortController.signal
|
||||
signal: abortController.signal,
|
||||
perChatOverrides
|
||||
});
|
||||
|
||||
if (agenticResult.handled) {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { DatabaseService } from '$lib/services/database';
|
|||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { filterByLeafNodeId, findLeafNode } from '$lib/utils';
|
||||
import { AttachmentType } from '$lib/enums';
|
||||
import type { McpServerOverride } from '$lib/types/database';
|
||||
|
||||
/**
|
||||
* conversationsStore - Persistent conversation data and lifecycle management
|
||||
|
|
@ -62,6 +63,9 @@ class ConversationsStore {
|
|||
/** Whether the store has been initialized */
|
||||
isInitialized = $state(false);
|
||||
|
||||
/** Pending MCP server overrides for new conversations (before first message) */
|
||||
pendingMcpServerOverrides = $state<McpServerOverride[]>([]);
|
||||
|
||||
/** Callback for title update confirmation dialog */
|
||||
titleUpdateConfirmationCallback?: (currentTitle: string, newTitle: string) => Promise<boolean>;
|
||||
|
||||
|
|
@ -170,6 +174,20 @@ class ConversationsStore {
|
|||
const conversationName = name || `Chat ${new Date().toLocaleString()}`;
|
||||
const conversation = await DatabaseService.createConversation(conversationName);
|
||||
|
||||
// Apply any pending MCP server overrides to the new conversation
|
||||
if (this.pendingMcpServerOverrides.length > 0) {
|
||||
// Deep clone to plain objects (Svelte 5 $state uses Proxies which can't be cloned to IndexedDB)
|
||||
const plainOverrides = this.pendingMcpServerOverrides.map((o) => ({
|
||||
serverId: o.serverId,
|
||||
enabled: o.enabled
|
||||
}));
|
||||
conversation.mcpServerOverrides = plainOverrides;
|
||||
await DatabaseService.updateConversation(conversation.id, {
|
||||
mcpServerOverrides: plainOverrides
|
||||
});
|
||||
this.pendingMcpServerOverrides = []; // Clear pending overrides
|
||||
}
|
||||
|
||||
this.conversations.unshift(conversation);
|
||||
this.activeConversation = conversation;
|
||||
this.activeMessages = [];
|
||||
|
|
@ -192,6 +210,8 @@ class ConversationsStore {
|
|||
return false;
|
||||
}
|
||||
|
||||
// Clear pending overrides when switching to an existing conversation
|
||||
this.pendingMcpServerOverrides = [];
|
||||
this.activeConversation = conversation;
|
||||
|
||||
if (conversation.currNode) {
|
||||
|
|
@ -333,6 +353,150 @@ class ConversationsStore {
|
|||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// MCP Server Per-Chat Overrides
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Gets MCP server override for a specific server in the active conversation.
|
||||
* Falls back to pending overrides if no active conversation exists.
|
||||
* @param serverId - The server ID to check
|
||||
* @returns The override if set, undefined if using global setting
|
||||
*/
|
||||
getMcpServerOverride(serverId: string): McpServerOverride | undefined {
|
||||
if (this.activeConversation) {
|
||||
return this.activeConversation.mcpServerOverrides?.find(
|
||||
(o: McpServerOverride) => o.serverId === serverId
|
||||
);
|
||||
}
|
||||
// Fall back to pending overrides if no active conversation
|
||||
return this.pendingMcpServerOverrides.find((o) => o.serverId === serverId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an MCP server is enabled for the active conversation.
|
||||
* Per-chat override takes precedence over global setting.
|
||||
* @param serverId - The server ID to check
|
||||
* @param globalEnabled - The global enabled state from settings
|
||||
* @returns True if server is enabled for this conversation
|
||||
*/
|
||||
isMcpServerEnabledForChat(serverId: string, globalEnabled: boolean): boolean {
|
||||
const override = this.getMcpServerOverride(serverId);
|
||||
return override !== undefined ? override.enabled : globalEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets or removes MCP server override for the active conversation.
|
||||
* If no conversation exists, stores as pending override (applied when conversation is created).
|
||||
* @param serverId - The server ID to override
|
||||
* @param enabled - The enabled state, or undefined to remove override (use global)
|
||||
*/
|
||||
async setMcpServerOverride(serverId: string, enabled: boolean | undefined): Promise<void> {
|
||||
// If no active conversation, store as pending override
|
||||
if (!this.activeConversation) {
|
||||
this.setPendingMcpServerOverride(serverId, enabled);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clone to plain objects to avoid Proxy serialization issues with IndexedDB
|
||||
const currentOverrides = (this.activeConversation.mcpServerOverrides || []).map(
|
||||
(o: McpServerOverride) => ({
|
||||
serverId: o.serverId,
|
||||
enabled: o.enabled
|
||||
})
|
||||
);
|
||||
let newOverrides: McpServerOverride[];
|
||||
|
||||
if (enabled === undefined) {
|
||||
// Remove override - use global setting
|
||||
newOverrides = currentOverrides.filter((o: McpServerOverride) => o.serverId !== serverId);
|
||||
} else {
|
||||
// Set or update override
|
||||
const existingIndex = currentOverrides.findIndex(
|
||||
(o: McpServerOverride) => o.serverId === serverId
|
||||
);
|
||||
if (existingIndex >= 0) {
|
||||
newOverrides = [...currentOverrides];
|
||||
newOverrides[existingIndex] = { serverId, enabled };
|
||||
} else {
|
||||
newOverrides = [...currentOverrides, { serverId, enabled }];
|
||||
}
|
||||
}
|
||||
|
||||
// Update in database (plain objects, not proxies)
|
||||
await DatabaseService.updateConversation(this.activeConversation.id, {
|
||||
mcpServerOverrides: newOverrides.length > 0 ? newOverrides : undefined
|
||||
});
|
||||
|
||||
// Update local state
|
||||
this.activeConversation.mcpServerOverrides = newOverrides.length > 0 ? newOverrides : undefined;
|
||||
|
||||
// Also update in conversations list
|
||||
const convIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id);
|
||||
if (convIndex !== -1) {
|
||||
this.conversations[convIndex].mcpServerOverrides =
|
||||
newOverrides.length > 0 ? newOverrides : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles MCP server enabled state for the active conversation.
|
||||
* Creates a per-chat override that differs from the global setting.
|
||||
* @param serverId - The server ID to toggle
|
||||
* @param globalEnabled - The global enabled state from settings
|
||||
*/
|
||||
async toggleMcpServerForChat(serverId: string, globalEnabled: boolean): Promise<void> {
|
||||
const currentEnabled = this.isMcpServerEnabledForChat(serverId, globalEnabled);
|
||||
await this.setMcpServerOverride(serverId, !currentEnabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets MCP server to use global setting (removes per-chat override).
|
||||
* @param serverId - The server ID to reset
|
||||
*/
|
||||
async resetMcpServerToGlobal(serverId: string): Promise<void> {
|
||||
await this.setMcpServerOverride(serverId, undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets or removes a pending MCP server override (for new conversations).
|
||||
* @param serverId - The server ID to override
|
||||
* @param enabled - The enabled state, or undefined to remove override
|
||||
*/
|
||||
private setPendingMcpServerOverride(serverId: string, enabled: boolean | undefined): void {
|
||||
if (enabled === undefined) {
|
||||
// Remove pending override
|
||||
this.pendingMcpServerOverrides = this.pendingMcpServerOverrides.filter(
|
||||
(o) => o.serverId !== serverId
|
||||
);
|
||||
} else {
|
||||
// Set or update pending override
|
||||
const existingIndex = this.pendingMcpServerOverrides.findIndex(
|
||||
(o) => o.serverId === serverId
|
||||
);
|
||||
if (existingIndex >= 0) {
|
||||
this.pendingMcpServerOverrides[existingIndex] = { serverId, enabled };
|
||||
} else {
|
||||
this.pendingMcpServerOverrides = [...this.pendingMcpServerOverrides, { serverId, enabled }];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a pending MCP server override.
|
||||
* @param serverId - The server ID to check
|
||||
*/
|
||||
private getPendingMcpServerOverride(serverId: string): McpServerOverride | undefined {
|
||||
return this.pendingMcpServerOverrides.find((o) => o.serverId === serverId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all pending MCP server overrides.
|
||||
*/
|
||||
clearPendingMcpServerOverrides(): void {
|
||||
this.pendingMcpServerOverrides = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigates to a specific sibling branch by updating currNode and refreshing messages
|
||||
* @param siblingId - The sibling message ID to navigate to
|
||||
|
|
|
|||
|
|
@ -5,9 +5,10 @@ import {
|
|||
type ServerStatus
|
||||
} from '$lib/mcp/host-manager';
|
||||
import type { ToolExecutionResult } from '$lib/mcp/server-connection';
|
||||
import { buildMcpClientConfig } from '$lib/config/mcp';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { buildMcpClientConfig, incrementMcpServerUsage } from '$lib/config/mcp';
|
||||
import { config, settingsStore } from '$lib/stores/settings.svelte';
|
||||
import type { MCPToolCall } from '$lib/types/mcp';
|
||||
import type { McpServerOverride } from '$lib/types/database';
|
||||
import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp';
|
||||
import { MCPClient } from '$lib/mcp';
|
||||
import { detectMcpTransportFromUrl } from '$lib/utils/mcp';
|
||||
|
|
@ -139,11 +140,14 @@ class MCPStore {
|
|||
* Ensure MCP host manager is initialized with current config.
|
||||
* Returns the host manager if successful, undefined otherwise.
|
||||
* Handles config changes by reinitializing as needed.
|
||||
* @param perChatOverrides - Optional per-chat MCP server overrides
|
||||
*/
|
||||
async ensureInitialized(): Promise<MCPHostManager | undefined> {
|
||||
async ensureInitialized(
|
||||
perChatOverrides?: McpServerOverride[]
|
||||
): Promise<MCPHostManager | undefined> {
|
||||
if (!browser) return undefined;
|
||||
|
||||
const mcpConfig = buildMcpClientConfig(config());
|
||||
const mcpConfig = buildMcpClientConfig(config(), perChatOverrides);
|
||||
const signature = mcpConfig ? JSON.stringify(mcpConfig) : null;
|
||||
|
||||
// No config - shutdown if needed
|
||||
|
|
|
|||
|
|
@ -1,11 +1,22 @@
|
|||
import type { ChatMessageTimings, ChatRole, ChatMessageType } from '$lib/types/chat';
|
||||
import { AttachmentType } from '$lib/enums';
|
||||
|
||||
/**
|
||||
* Per-chat MCP server override - allows enabling/disabling servers for specific conversations.
|
||||
* If undefined for a server, the global setting is used.
|
||||
*/
|
||||
export interface McpServerOverride {
|
||||
serverId: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface DatabaseConversation {
|
||||
currNode: string | null;
|
||||
id: string;
|
||||
lastModified: number;
|
||||
name: string;
|
||||
/** Per-chat MCP server overrides. If not set, global settings are used. */
|
||||
mcpServerOverrides?: McpServerOverride[];
|
||||
}
|
||||
|
||||
export interface DatabaseMessageExtraAudioFile {
|
||||
|
|
|
|||
Loading…
Reference in New Issue