From fdae2cc23ae3418816265aa066b41694436b8dbd Mon Sep 17 00:00:00 2001 From: leisvip Date: Mon, 4 May 2026 03:06:47 +0800 Subject: [PATCH 1/5] chore: bump version to 1.3.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5b56e25..22aea21 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "chat2api", - "version": "1.2.0", + "version": "1.3.0", "description": "Chat2API 管理器 - 统一管理多个 AI 服务提供商,提供 OpenAI 兼容 API 接口", "main": "./out/main/index.js", "author": { From 1a066c6a0008fb39e6431e6783ab0fab9d1df597 Mon Sep 17 00:00:00 2001 From: leisvip Date: Mon, 4 May 2026 03:10:30 +0800 Subject: [PATCH 2/5] fix: correct publish owner to leisvip --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 22aea21..98fdd42 100644 --- a/package.json +++ b/package.json @@ -239,7 +239,7 @@ }, "publish": { "provider": "github", - "owner": "xiaoY233", + "owner": "leisvip", "repo": "Chat2API" } } From 03619ada6f34adf1446c245e7efdcc4d0eafd4e4 Mon Sep 17 00:00:00 2001 From: leisvip Date: Mon, 4 May 2026 03:31:27 +0800 Subject: [PATCH 3/5] feat: merge 9 PRs for v1.4.0 Merged PRs from xiaoY233/Chat2API: - #74: DeepSeek V4 Pro/Flash model support - #92: Credential encryption toggle - #93: Kimi K2.6 upgrade - #84: Account weight-based load balancing - #83: Qwen silent content drop fix - #85: data.json performance optimization (log buffering) - #87: Multi-turn conversation session reuse - #77: Settings page draft mode with save button - #106: Qwen context loss fix (messagesToPrompt) --- package-lock.json | 4 +- package.json | 2 +- pr-diffs/pr-106.diff | 338 ++++++ pr-diffs/pr-74.diff | 131 ++ pr-diffs/pr-77.diff | 511 ++++++++ pr-diffs/pr-83.diff | 64 + pr-diffs/pr-84.diff | 149 +++ pr-diffs/pr-85.diff | 959 +++++++++++++++ pr-diffs/pr-87.diff | 1052 +++++++++++++++++ pr-diffs/pr-92.diff | 76 ++ pr-diffs/pr-93.diff | 65 + src/main/index.ts | 2 + src/main/ipc/handlers.ts | 14 + src/main/logger/manager.ts | 47 +- src/main/providers/builtin/deepseek.ts | 20 + src/main/providers/builtin/kimi.ts | 6 +- src/main/proxy/adapters/deepseek.ts | 21 +- src/main/proxy/adapters/kimi.ts | 20 +- src/main/proxy/adapters/qwen-ai.ts | 63 +- src/main/proxy/adapters/qwen.ts | 47 +- src/main/proxy/adapters/zai.ts | 43 +- src/main/proxy/forwarder.ts | 15 +- src/main/proxy/loadbalancer.ts | 57 +- src/main/proxy/routes/chat.ts | 39 +- src/main/proxy/routes/completions.ts | 145 ++- src/main/proxy/server.ts | 14 +- src/main/proxy/sessionManager.ts | 102 +- src/main/proxy/types.ts | 4 + src/main/proxy/utils/messageToPrompt.ts | 113 ++ src/main/store/store.ts | 60 +- src/main/store/types.ts | 12 + .../src/components/models/ModelEditor.tsx | 2 +- .../providers/AddProviderDialog.tsx | 12 +- .../components/proxy/LoadBalanceConfig.tsx | 7 + .../settings/AppearanceSettings.tsx | 59 +- .../components/settings/DataManagement.tsx | 60 +- .../components/settings/GeneralSettings.tsx | 85 +- src/renderer/src/i18n/locales/en-US.json | 16 +- src/renderer/src/i18n/locales/zh-CN.json | 16 +- src/renderer/src/stores/dashboardStore.ts | 15 +- src/renderer/src/stores/logsStore.ts | 54 +- src/renderer/src/stores/proxyStore.ts | 8 + src/renderer/src/stores/settingsStore.ts | 67 +- src/renderer/src/types/electron.d.ts | 7 +- src/shared/types.ts | 3 + tests/session-manager.test.ts | 472 ++++++++ tests/store/log-buffering.test.ts | 236 ++++ tsconfig.node.json | 1 - 48 files changed, 5071 insertions(+), 244 deletions(-) create mode 100644 pr-diffs/pr-106.diff create mode 100644 pr-diffs/pr-74.diff create mode 100644 pr-diffs/pr-77.diff create mode 100644 pr-diffs/pr-83.diff create mode 100644 pr-diffs/pr-84.diff create mode 100644 pr-diffs/pr-85.diff create mode 100644 pr-diffs/pr-87.diff create mode 100644 pr-diffs/pr-92.diff create mode 100644 pr-diffs/pr-93.diff create mode 100644 src/main/proxy/utils/messageToPrompt.ts create mode 100644 tests/session-manager.test.ts create mode 100644 tests/store/log-buffering.test.ts diff --git a/package-lock.json b/package-lock.json index 7a5ef13..2cad45c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "chat2api", - "version": "1.1.4", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "chat2api", - "version": "1.1.4", + "version": "1.3.0", "hasInstallScript": true, "license": "GPL-3.0", "dependencies": { diff --git a/package.json b/package.json index 98fdd42..e2a258d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "chat2api", - "version": "1.3.0", + "version": "1.4.0", "description": "Chat2API 管理器 - 统一管理多个 AI 服务提供商,提供 OpenAI 兼容 API 接口", "main": "./out/main/index.js", "author": { diff --git a/pr-diffs/pr-106.diff b/pr-diffs/pr-106.diff new file mode 100644 index 0000000..1c5b981 --- /dev/null +++ b/pr-diffs/pr-106.diff @@ -0,0 +1,338 @@ +diff --git a/src/main/proxy/adapters/qwen-ai.ts b/src/main/proxy/adapters/qwen-ai.ts +index 631639d..9b53e2f 100644 +--- a/src/main/proxy/adapters/qwen-ai.ts ++++ b/src/main/proxy/adapters/qwen-ai.ts +@@ -9,6 +9,7 @@ import { PassThrough } from 'stream' + import { createParser } from 'eventsource-parser' + import { Account, Provider } from '../../store/types' + import { hasToolUse, parseToolUse, ToolCall } from '../promptToolUse' ++import { messagesToPrompt } from '../utils/messageToPrompt' + + const QWEN_AI_BASE = 'https://chat.qwen.ai' + +@@ -260,25 +261,10 @@ export class QwenAiAdapter { + const chatId = await this.createChat(modelId, 'OpenAI_API_Chat') + console.log('[QwenAI] Created new chat:', chatId) + +- const messages = request.messages +- +- // Extract system message and user message +- let systemContent = '' +- let userContent = '' +- +- // Single-turn mode: extract all messages +- for (const msg of messages) { +- if (msg.role === 'system') { +- systemContent += (systemContent ? '\n\n' : '') + msg.content +- } else if (msg.role === 'user') { +- userContent = msg.content +- } +- } +- +- // If system prompt exists, prepend it to user content +- if (systemContent) { +- userContent = `${systemContent}\n\nUser: ${userContent}` +- } ++ // Convert entire conversation history to a single prompt string ++ // This ensures multi-turn context is preserved across requests ++ const userContent = messagesToPrompt(request.messages as any) ++ console.log('[QwenAI] Converted conversation history to prompt, length:', userContent.length) + + const fid = uuid() + const childId = uuid() +diff --git a/src/main/proxy/adapters/qwen.ts b/src/main/proxy/adapters/qwen.ts +index 5f289e0..e157b3f 100644 +--- a/src/main/proxy/adapters/qwen.ts ++++ b/src/main/proxy/adapters/qwen.ts +@@ -20,6 +20,7 @@ import { + createBaseChunk, + ToolCallState + } from '../utils/streamToolHandler' ++import { messagesToPrompt, extractTextContent as extractTextContentUtil } from '../utils/messageToPrompt' + + /** + * Check if content contains tool calls (both bracket and XML formats) +@@ -70,7 +71,8 @@ interface ChatCompletionRequest { + web_search?: boolean + reasoning_effort?: 'low' | 'medium' | 'high' + enableThinking?: boolean +- enableWebSearch?: boolean ++ enableWeb?: boolean ++ originalModel?: string + } + + function uuid(separator: boolean = true): string { +@@ -152,15 +154,16 @@ export class QwenAdapter { + const modelLower = modelForDetection.toLowerCase() + + let enableThinking = request.enableThinking ?? false +- let enableWebSearch = request.enableWebSearch ?? false ++ // Use web_search from request (OpenAI standard) and fallback to model name detection ++ let enableWeb = request.web_search ?? false + + // Auto-enable based on model name (if not explicitly set) + if (!enableThinking && (modelLower.includes('think') || modelLower.includes('r1'))) { + enableThinking = true + console.log('[Qwen] Thinking mode enabled (from model name)') + } +- if (!enableWebSearch && modelLower.includes('search')) { +- enableWebSearch = true ++ if (!enableWeb && modelLower.includes('')) { ++ enableWeb = true + console.log('[Qwen] Web search enabled (from model name)') + } + +@@ -179,38 +182,21 @@ export class QwenAdapter { + }) + console.log('[Qwen] Using model:', actualModel) + +- // Find system message and user message +- let systemPrompt = '' +- let userContent = '' +- +- for (const msg of request.messages) { +- if (msg.role === 'system') { +- systemPrompt = extractTextContent(msg.content) +- } else if (msg.role === 'user') { +- userContent = extractTextContent(msg.content) +- } +- } ++ // Convert entire conversation history to a single prompt string ++ // This ensures multi-turn context is preserved across requests ++ let finalContent = messagesToPrompt(request.messages as any) + + // Inject tools prompt if tools are provided and not already injected by client + if (request.tools && request.tools.length > 0 && !hasToolPromptInjected(request.messages)) { + const toolsPrompt = toolsToSystemPrompt(request.tools) +- systemPrompt = systemPrompt +- ? systemPrompt + '\n\n' + toolsPrompt +- : toolsPrompt +- // Add tool wrap hint to user content +- userContent = userContent + TOOL_WRAP_HINT ++ finalContent = toolsPrompt + '\n\n' + finalContent + TOOL_WRAP_HINT + } + +- // If system prompt exists, prepend it to user content +- const finalContent = systemPrompt +- ? `${systemPrompt}\n\nUser: ${userContent}` +- : userContent +- + const timestamp = Date.now() + const nonce = generateNonce() + + const requestBody = { +- deep_search: (enableWebSearch || enableThinking) ? '1' : '0', ++ deep_search: (enableWeb || enableThinking) ? '1' : '0', + req_id: reqId, + model: actualModel, + scene: 'chat', +@@ -228,7 +214,7 @@ export class QwenAdapter { + ], + from: 'default', + parent_req_id: '0', +- enable_search: enableWebSearch, ++ enable_search: enableWeb, + biz_data: '{"entryPoint":"tongyigw"}', + scene_param: 'first_turn', + chat_client: 'h5', +@@ -398,7 +384,9 @@ export class QwenStreamHandler { + + console.log('[Qwen] Starting stream handler...') + +- const contentEncoding = response?.headers?.['content-encoding'] ++ const contentEncoding = typeof response?.headers?.['content-encoding'] === 'string' ++ ? response.headers['content-encoding'] as string ++ : undefined + console.log('[Qwen] Content-Encoding:', contentEncoding) + + let buffer = '' +@@ -899,7 +887,8 @@ export class QwenStreamHandler { + + let decompressStream: any = stream + +- const contentEncoding = response?.headers?.['content-encoding']?.toLowerCase() ++ const rawEncoding = response?.headers?.['content-encoding'] ++ const contentEncoding = typeof rawEncoding === 'string' ? rawEncoding.toLowerCase() : undefined + if (contentEncoding === 'gzip') { + console.log('[Qwen] Decompressing gzip stream...') + decompressStream = stream.pipe(createGunzip()) +diff --git a/src/main/proxy/adapters/zai.ts b/src/main/proxy/adapters/zai.ts +index 967b77b..d8e91fc 100644 +--- a/src/main/proxy/adapters/zai.ts ++++ b/src/main/proxy/adapters/zai.ts +@@ -107,6 +107,8 @@ function uuid(separator: boolean = true): string { + } + + export class ZaiAdapter { ++ private static chatIdCache: Map = new Map() ++ + private provider: Provider + private account: Account + private token: string | null = null +@@ -391,12 +393,41 @@ export class ZaiAdapter { + + const signaturePrompt = this.extractLastUserMessage(processedMessages) + +- // Always create a new chat (single-turn mode only) +- const chatResult = await this.createChat(mappedModel, signaturePrompt) +- const chatId = chatResult.chatId +- const messageId = chatResult.messageId +- const parentMessageId = null +- console.log('[Z.ai] Created new chat:', chatId) ++ // Reuse chat ID across requests for the same account when possible ++ let chatId: string ++ let messageId: string ++ let parentMessageId: string | null = null ++ ++ // If client provides an existing chat ID, use it (multi-turn support) ++ if (request.chatId) { ++ chatId = request.chatId ++ // For existing chat, we still need to generate a new message ID. ++ // The API expects a new message ID for each turn, and optionally a parent message ID. ++ messageId = uuid() ++ // We don't know the parent message ID for existing chat, but we can set it to null. ++ parentMessageId = null ++ console.log('[Z.ai] Using existing chat ID from request:', chatId) ++ } else { ++ // Check cache for this account ++ const cachedChatId = ZaiAdapter.chatIdCache.get(this.account.id) ++ if (cachedChatId) { ++ chatId = cachedChatId ++ messageId = uuid() ++ parentMessageId = null ++ console.log('[Z.ai] Reusing cached chat ID for account:', chatId) ++ } else { ++ // Create a new chat ++ const chatResult = await this.createChat(mappedModel, signaturePrompt) ++ chatId = chatResult.chatId ++ messageId = chatResult.messageId ++ // Cache it for future requests from this account ++ ZaiAdapter.chatIdCache.set(this.account.id, chatId) ++ console.log('[Z.ai] Created new chat and cached:', chatId) ++ } ++ } ++ ++ // Note: The processedMessages array already contains the full conversation history, ++ // so multi-turn context is preserved even when reusing the same chat_id. + + const requestId = uuid() + const timestamp = Date.now() +diff --git a/src/main/proxy/utils/messageToPrompt.ts b/src/main/proxy/utils/messageToPrompt.ts +new file mode 100644 +index 0000000..e60ba7c +--- /dev/null ++++ b/src/main/proxy/utils/messageToPrompt.ts +@@ -0,0 +1,113 @@ ++/** ++ * Utility to convert an array of chat messages into a single prompt string ++ * that can be sent to any API, preserving full conversation context. ++ * ++ * This approach ensures multi-turn conversation works across all providers ++ * without relying on provider-specific session management. ++ */ ++ ++import type { ChatMessage } from '../types' ++ ++export interface MessageContent { ++ type: string ++ text?: string ++} ++ ++/** ++ * Extract plain text content from a message, regardless of format ++ */ ++export function extractTextContent(content: string | MessageContent[] | null | undefined): string { ++ if (!content) return '' ++ if (typeof content === 'string') return content ++ if (Array.isArray(content)) { ++ return content ++ .filter(part => part.type === 'text' && part.text) ++ .map(part => part.text) ++ .join(' ') ++ } ++ return '' ++} ++ ++/** ++ * Convert an array of messages into a single prompt string. ++ * Format: ++ * System: ++ * ++ * User: ++ * Assistant: ++ * User: ++ * ... ++ */ ++export function messagesToPrompt(messages: ChatMessage[]): string { ++ const parts: string[] = [] ++ ++ for (const msg of messages) { ++ const content = extractTextContent(msg.content) ++ if (!content) continue ++ ++ switch (msg.role) { ++ case 'system': ++ parts.push(`System: ${content}`) ++ break ++ case 'user': ++ parts.push(`User: ${content}`) ++ break ++ case 'assistant': ++ parts.push(`Assistant: ${content}`) ++ break ++ case 'tool': ++ // Tool responses are typically handled separately; include as user context ++ parts.push(`Tool result: ${content}`) ++ break ++ default: ++ // Unknown role, treat as user message to be safe ++ parts.push(`User: ${content}`) ++ break ++ } ++ } ++ ++ // Join with double newline for clear separation ++ return parts.join('\n\n') ++} ++ ++/** ++ * Convert messages to prompt but preserve a system message separately ++ * for providers that have a dedicated system prompt field. ++ * Returns { systemPrompt: string, userPrompt: string } ++ */ ++export function splitSystemAndUserMessages(messages: ChatMessage[]): { ++ systemPrompt: string ++ userPrompt: string ++} { ++ let systemPrompt = '' ++ const userParts: string[] = [] ++ ++ for (const msg of messages) { ++ const content = extractTextContent(msg.content) ++ if (!content) continue ++ ++ if (msg.role === 'system') { ++ systemPrompt = systemPrompt ? `${systemPrompt}\n\n${content}` : content ++ } else if (msg.role === 'user') { ++ userParts.push(`User: ${content}`) ++ } else if (msg.role === 'assistant') { ++ userParts.push(`Assistant: ${content}`) ++ } else if (msg.role === 'tool') { ++ userParts.push(`Tool result: ${content}`) ++ } else { ++ userParts.push(`User: ${content}`) ++ } ++ } ++ ++ return { ++ systemPrompt, ++ userPrompt: userParts.join('\n\n'), ++ } ++} ++ ++/** ++ * Estimate token count for a prompt string (rough approximation: 3 chars per token) ++ */ ++export function estimatePromptTokens(prompt: string): number { ++ return Math.ceil(prompt.length / 3) ++} diff --git a/pr-diffs/pr-74.diff b/pr-diffs/pr-74.diff new file mode 100644 index 0000000..a86a297 --- /dev/null +++ b/pr-diffs/pr-74.diff @@ -0,0 +1,131 @@ +diff --git a/src/main/providers/builtin/deepseek.ts b/src/main/providers/builtin/deepseek.ts +index 46245bf..4a23eb3 100644 +--- a/src/main/providers/builtin/deepseek.ts ++++ b/src/main/providers/builtin/deepseek.ts +@@ -29,12 +29,32 @@ export const deepseekConfig: BuiltinProviderConfig = { + enabled: true, + description: 'DeepSeek AI assistant, supports deep thinking and web search', + supportedModels: [ ++ 'deepseek-v4-pro', ++ 'deepseek-v4-pro-think', ++ 'deepseek-v4-pro-search', ++ 'deepseek-v4-pro-think-search', ++ 'deepseek-v4-flash', ++ 'deepseek-v4-flash-think', ++ 'deepseek-v4-flash-search', ++ 'deepseek-v4-flash-think-search', ++ 'deepseek-chat', ++ 'deepseek-reasoner', + 'DeepSeek-V3.2', + 'DeepSeek-Search', + 'DeepSeek-R1', + 'DeepSeek-R1-Search', + ], + modelMappings: { ++ 'deepseek-v4-pro': 'deepseek-chat', ++ 'deepseek-v4-pro-think': 'deepseek-chat', ++ 'deepseek-v4-pro-search': 'deepseek-chat', ++ 'deepseek-v4-pro-think-search': 'deepseek-chat', ++ 'deepseek-v4-flash': 'deepseek-chat', ++ 'deepseek-v4-flash-think': 'deepseek-chat', ++ 'deepseek-v4-flash-search': 'deepseek-chat', ++ 'deepseek-v4-flash-think-search': 'deepseek-chat', ++ 'deepseek-chat': 'deepseek-chat', ++ 'deepseek-reasoner': 'deepseek-chat', + 'DeepSeek-V3.2': 'deepseek-chat', + 'DeepSeek-Search': 'deepseek-chat', + 'DeepSeek-R1': 'deepseek-chat', +diff --git a/src/main/proxy/adapters/deepseek.ts b/src/main/proxy/adapters/deepseek.ts +index 29349fc..7f5a45c 100644 +--- a/src/main/proxy/adapters/deepseek.ts ++++ b/src/main/proxy/adapters/deepseek.ts +@@ -403,7 +403,7 @@ ${message.content || ''} + // Fallback: check model name for backward compatibility + const modelLower = request.model.toLowerCase() + +- if (modelLower.includes('expert')) { ++ if (modelLower.includes('pro') || modelLower.includes('expert')) { + modelType = 'expert' + } + if (!searchEnabled && modelLower.includes('search')) { +@@ -424,11 +424,13 @@ ${message.content || ''} + `${DEEPSEEK_API_BASE}/v0/chat/completion`, + { + chat_session_id: sessionId, ++ parent_message_id: null, + prompt, ++ model_type: modelType, + ref_file_ids: [], + search_enabled: searchEnabled, + thinking_enabled: thinkingEnabled, +- model_type: modelType, ++ preempt: false, + }, + { + headers: { +diff --git a/src/renderer/src/components/providers/AddProviderDialog.tsx b/src/renderer/src/components/providers/AddProviderDialog.tsx +index a3ad157..7bbc9b6 100644 +--- a/src/renderer/src/components/providers/AddProviderDialog.tsx ++++ b/src/renderer/src/components/providers/AddProviderDialog.tsx +@@ -208,8 +208,18 @@ export function AddProviderDialog({ + apiEndpoint: 'https://chat.deepseek.com/api', + enabled: true, + description: t('deepseek.description'), +- supportedModels: ['DeepSeek-V3.2', 'DeepSeek-R1', 'DeepSeek-Search', 'DeepSeek-R1-Search'], ++ supportedModels: ['deepseek-v4-pro', 'deepseek-v4-pro-think', 'deepseek-v4-pro-search', 'deepseek-v4-pro-think-search', 'deepseek-v4-flash', 'deepseek-v4-flash-think', 'deepseek-v4-flash-search', 'deepseek-v4-flash-think-search', 'deepseek-chat', 'deepseek-reasoner', 'DeepSeek-V3.2', 'DeepSeek-R1', 'DeepSeek-Search', 'DeepSeek-R1-Search'], + modelMappings: { ++ 'deepseek-v4-pro': 'deepseek-chat', ++ 'deepseek-v4-pro-think': 'deepseek-chat', ++ 'deepseek-v4-pro-search': 'deepseek-chat', ++ 'deepseek-v4-pro-think-search': 'deepseek-chat', ++ 'deepseek-v4-flash': 'deepseek-chat', ++ 'deepseek-v4-flash-think': 'deepseek-chat', ++ 'deepseek-v4-flash-search': 'deepseek-chat', ++ 'deepseek-v4-flash-think-search': 'deepseek-chat', ++ 'deepseek-chat': 'deepseek-chat', ++ 'deepseek-reasoner': 'deepseek-chat', + 'DeepSeek-V3.2': 'deepseek-chat', + 'DeepSeek-R1': 'deepseek-chat', + 'DeepSeek-Search': 'deepseek-chat', +diff --git a/src/renderer/src/i18n/locales/en-US.json b/src/renderer/src/i18n/locales/en-US.json +index 8dd3ffa..8b640fe 100644 +--- a/src/renderer/src/i18n/locales/en-US.json ++++ b/src/renderer/src/i18n/locales/en-US.json +@@ -243,6 +243,16 @@ + "userTokenPlaceholder": "Enter DeepSeek User Token", + "userTokenHelp": "Authentication Token from DeepSeek web version, found in browser DevTools Application -> Local Storage -> userToken", + "models": { ++ "deepseek-v4-pro": "DeepSeek V4 Pro", ++ "deepseek-v4-pro-think": "DeepSeek V4 Pro - Think", ++ "deepseek-v4-pro-search": "DeepSeek V4 Pro - Search", ++ "deepseek-v4-pro-think-search": "DeepSeek V4 Pro - Think+Search", ++ "deepseek-v4-flash": "DeepSeek V4 Flash", ++ "deepseek-v4-flash-think": "DeepSeek V4 Flash - Think", ++ "deepseek-v4-flash-search": "DeepSeek V4 Flash - Search", ++ "deepseek-v4-flash-think-search": "DeepSeek V4 Flash - Think+Search", ++ "deepseek-chat": "DeepSeek Chat (legacy)", ++ "deepseek-reasoner": "DeepSeek Reasoner (legacy)", + "DeepSeek-V3.2": "DeepSeek V3.2 - Standard Chat", + "DeepSeek-Search": "DeepSeek Search - Web Search", + "DeepSeek-R1": "DeepSeek R1 - Deep Thinking", +diff --git a/src/renderer/src/i18n/locales/zh-CN.json b/src/renderer/src/i18n/locales/zh-CN.json +index 07e1248..8b12185 100644 +--- a/src/renderer/src/i18n/locales/zh-CN.json ++++ b/src/renderer/src/i18n/locales/zh-CN.json +@@ -243,6 +243,16 @@ + "userTokenPlaceholder": "请输入 DeepSeek 用户 Token", + "userTokenHelp": "从 DeepSeek 网页版获取的认证 Token,可在浏览器开发者工具 Application -> Local Storage -> userToken 中找到", + "models": { ++ "deepseek-v4-pro": "DeepSeek V4 Pro", ++ "deepseek-v4-pro-think": "DeepSeek V4 Pro - 思考", ++ "deepseek-v4-pro-search": "DeepSeek V4 Pro - 搜索", ++ "deepseek-v4-pro-think-search": "DeepSeek V4 Pro - 思考+搜索", ++ "deepseek-v4-flash": "DeepSeek V4 Flash", ++ "deepseek-v4-flash-think": "DeepSeek V4 Flash - 思考", ++ "deepseek-v4-flash-search": "DeepSeek V4 Flash - 搜索", ++ "deepseek-v4-flash-think-search": "DeepSeek V4 Flash - 思考+搜索", ++ "deepseek-chat": "DeepSeek Chat (兼容旧名)", ++ "deepseek-reasoner": "DeepSeek Reasoner (兼容旧名)", + "DeepSeek-V3.2": "DeepSeek V3.2 - 标准对话", + "DeepSeek-Search": "DeepSeek Search - 联网搜索", + "DeepSeek-R1": "DeepSeek R1 - 深度思考", diff --git a/pr-diffs/pr-77.diff b/pr-diffs/pr-77.diff new file mode 100644 index 0000000..53525df --- /dev/null +++ b/pr-diffs/pr-77.diff @@ -0,0 +1,511 @@ +diff --git a/src/renderer/src/components/settings/AppearanceSettings.tsx b/src/renderer/src/components/settings/AppearanceSettings.tsx +index 32349d4..9ccdbab 100644 +--- a/src/renderer/src/components/settings/AppearanceSettings.tsx ++++ b/src/renderer/src/components/settings/AppearanceSettings.tsx +@@ -3,20 +3,50 @@ import { Button } from '@/components/ui/button' + import { Label } from '@/components/ui/label' + import { Switch } from '@/components/ui/switch' + import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +-import { useTheme } from '@/hooks/useTheme' + import { useSettingsStore, Theme, Language } from '@/stores/settingsStore' +-import { Sun, Moon, PanelLeft, Languages } from 'lucide-react' ++import { Sun, Moon, PanelLeft, Languages, Save } from 'lucide-react' + import { useTranslation } from 'react-i18next' ++import { useState, useEffect } from 'react' ++import { useToast } from '@/hooks/use-toast' + + export function AppearanceSettings() { + const { t } = useTranslation() +- const { theme, setTheme } = useTheme() +- const { +- sidebarCollapsed, +- setSidebarCollapsed, +- language, +- setLanguage ++ const { ++ theme: savedTheme, ++ setTheme: saveTheme, ++ sidebarCollapsed: savedCollapsed, ++ setSidebarCollapsed: saveCollapsed, ++ language: savedLanguage, ++ setLanguage: saveLanguage, ++ saveSettings, + } = useSettingsStore() ++ const { toast } = useToast() ++ ++ const [theme, setThemeDraft] = useState(savedTheme) ++ const [language, setLanguageDraft] = useState(savedLanguage) ++ const [sidebarCollapsed, setSidebarCollapsedDraft] = useState(savedCollapsed) ++ const [isSaving, setIsSaving] = useState(false) ++ ++ useEffect(() => { ++ setThemeDraft(savedTheme) ++ setLanguageDraft(savedLanguage) ++ setSidebarCollapsedDraft(savedCollapsed) ++ }, [savedTheme, savedLanguage, savedCollapsed]) ++ ++ const handleSave = async () => { ++ setIsSaving(true) ++ try { ++ saveTheme(theme) ++ saveLanguage(language) ++ saveCollapsed(sidebarCollapsed) ++ await saveSettings() ++ toast({ title: t('common.success'), description: t('settings.saveSuccess') }) ++ } catch { ++ toast({ title: t('common.error'), description: t('settings.saveFailed'), variant: 'destructive' }) ++ } finally { ++ setIsSaving(false) ++ } ++ } + + return ( +
+@@ -40,7 +70,7 @@ export function AppearanceSettings() { +
+ setLogLevel(value as LogLevel)}> ++ setCloseBehavior(value as CloseBehavior)} ++ onValueChange={(value) => setCloseBehaviorDraft(value as CloseBehavior)} + > + + +@@ -118,7 +158,7 @@ export function GeneralSettings() { + + + +@@ -139,7 +179,7 @@ export function GeneralSettings() { + + setLanguage(value as Language)} + onValueChange={(value) => setLanguageDraft(value as Language)} > @@ -101,11 +131,18 @@ export function AppearanceSettings() { + +
+ +
) } diff --git a/src/renderer/src/components/settings/DataManagement.tsx b/src/renderer/src/components/settings/DataManagement.tsx index eafd55d..0196b80 100644 --- a/src/renderer/src/components/settings/DataManagement.tsx +++ b/src/renderer/src/components/settings/DataManagement.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' @@ -6,7 +6,7 @@ import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { useSettingsStore, LogLevel } from '@/stores/settingsStore' import { useToast } from '@/hooks/use-toast' -import { Database, Download, Upload, Trash2, RotateCcw, AlertTriangle } from 'lucide-react' +import { Database, Download, Upload, Trash2, RotateCcw, AlertTriangle, Save } from 'lucide-react' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Switch } from '@/components/ui/switch' import { @@ -22,21 +22,33 @@ import { export function DataManagement() { const { t } = useTranslation() const { - logLevel, - setLogLevel, - logRetentionDays, - setLogRetentionDays, - maxLogs, - setMaxLogs, + logLevel: savedLogLevel, + setLogLevel: saveLogLevel, + logRetentionDays: savedLogRetentionDays, + setLogRetentionDays: saveLogRetentionDays, + maxLogs: savedMaxLogs, + setMaxLogs: saveMaxLogs, config, updateConfig, + saveSettings, } = useSettingsStore() const { toast } = useToast() + + const [logLevel, setLogLevelDraft] = useState(savedLogLevel) + const [logRetentionDays, setLogRetentionDaysDraft] = useState(savedLogRetentionDays) + const [maxLogs, setMaxLogsDraft] = useState(savedMaxLogs) + const [isSaving, setIsSaving] = useState(false) const [isExporting, setIsExporting] = useState(false) const [isImporting, setIsImporting] = useState(false) const [isClearing, setIsClearing] = useState(false) const [isResetting, setIsResetting] = useState(false) + useEffect(() => { + setLogLevelDraft(savedLogLevel) + setLogRetentionDaysDraft(savedLogRetentionDays) + setMaxLogsDraft(savedMaxLogs) + }, [savedLogLevel, savedLogRetentionDays, savedMaxLogs]) + const requestLogConfig = config?.requestLogConfig ?? { enabled: true, maxEntries: 200, @@ -142,11 +154,11 @@ export function DataManagement() { try { localStorage.clear() sessionStorage.clear() - + if (window.electronAPI?.store?.clearAll) { await window.electronAPI.store.clearAll() } - + toast({ title: t('common.success'), description: t('settings.resetSuccess'), @@ -165,6 +177,21 @@ export function DataManagement() { } } + const handleSave = async () => { + setIsSaving(true) + try { + saveLogLevel(logLevel) + saveLogRetentionDays(logRetentionDays) + saveMaxLogs(maxLogs) + await saveSettings() + toast({ title: t('common.success'), description: t('settings.saveSuccess') }) + } catch { + toast({ title: t('common.error'), description: t('settings.saveFailed'), variant: 'destructive' }) + } finally { + setIsSaving(false) + } + } + return (
@@ -181,7 +208,7 @@ export function DataManagement() {
- setLogLevelDraft(value as LogLevel)}> @@ -202,7 +229,7 @@ export function DataManagement() { min={1} max={365} value={logRetentionDays} - onChange={(e) => setLogRetentionDays(parseInt(e.target.value) || 30)} + onChange={(e) => setLogRetentionDaysDraft(parseInt(e.target.value) || 30)} />

{t('settings.logRetentionHelp')}

@@ -214,7 +241,7 @@ export function DataManagement() { min={100} max={100000} value={maxLogs} - onChange={(e) => setMaxLogs(parseInt(e.target.value) || 10000)} + onChange={(e) => setMaxLogsDraft(parseInt(e.target.value) || 10000)} />

{t('settings.maxLogsHelp')}

@@ -397,6 +424,13 @@ export function DataManagement() {
+ +
+ +
) } diff --git a/src/renderer/src/components/settings/GeneralSettings.tsx b/src/renderer/src/components/settings/GeneralSettings.tsx index d1d0412..d1415d2 100644 --- a/src/renderer/src/components/settings/GeneralSettings.tsx +++ b/src/renderer/src/components/settings/GeneralSettings.tsx @@ -5,24 +5,64 @@ import { Switch } from '@/components/ui/switch' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Separator } from '@/components/ui/separator' import { useSettingsStore, CloseBehavior, OAuthProxyMode } from '@/stores/settingsStore' -import { Bell, Minimize2, Power, Globe } from 'lucide-react' +import { Bell, Minimize2, Power, Globe, Save } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { useState, useEffect } from 'react' +import { useToast } from '@/hooks/use-toast' export function GeneralSettings() { const { t } = useTranslation() const { - autoStart, - setAutoStart, - autoStartProxy, - setAutoStartProxy, - minimizeToTray, - setMinimizeToTray, - closeBehavior, - setCloseBehavior, - enableNotifications, - setEnableNotifications, - oauthProxyMode, - setOauthProxyMode, + autoStart: savedAutoStart, + setAutoStart: saveAutoStart, + autoStartProxy: savedAutoStartProxy, + setAutoStartProxy: saveAutoStartProxy, + minimizeToTray: savedMinimizeToTray, + setMinimizeToTray: saveMinimizeToTray, + closeBehavior: savedCloseBehavior, + setCloseBehavior: saveCloseBehavior, + enableNotifications: savedEnableNotifications, + setEnableNotifications: saveEnableNotifications, + oauthProxyMode: savedOauthProxyMode, + setOauthProxyMode: saveOauthProxyMode, + saveSettings, } = useSettingsStore() + const { toast } = useToast() + + const [autoStart, setAutoStartDraft] = useState(savedAutoStart) + const [autoStartProxy, setAutoStartProxyDraft] = useState(savedAutoStartProxy) + const [minimizeToTray, setMinimizeToTrayDraft] = useState(savedMinimizeToTray) + const [closeBehavior, setCloseBehaviorDraft] = useState(savedCloseBehavior) + const [enableNotifications, setEnableNotificationsDraft] = useState(savedEnableNotifications) + const [oauthProxyMode, setOauthProxyModeDraft] = useState(savedOauthProxyMode) + const [isSaving, setIsSaving] = useState(false) + + useEffect(() => { + setAutoStartDraft(savedAutoStart) + setAutoStartProxyDraft(savedAutoStartProxy) + setMinimizeToTrayDraft(savedMinimizeToTray) + setCloseBehaviorDraft(savedCloseBehavior) + setEnableNotificationsDraft(savedEnableNotifications) + setOauthProxyModeDraft(savedOauthProxyMode) + }, [savedAutoStart, savedAutoStartProxy, savedMinimizeToTray, savedCloseBehavior, savedEnableNotifications, savedOauthProxyMode]) + + const handleSave = async () => { + setIsSaving(true) + try { + saveAutoStart(autoStart) + saveAutoStartProxy(autoStartProxy) + saveMinimizeToTray(minimizeToTray) + saveCloseBehavior(closeBehavior) + saveEnableNotifications(enableNotifications) + saveOauthProxyMode(oauthProxyMode) + await saveSettings() + toast({ title: t('common.success'), description: t('settings.saveSuccess') }) + } catch { + toast({ title: t('common.error'), description: t('settings.saveFailed'), variant: 'destructive' }) + } finally { + setIsSaving(false) + } + } return (
@@ -42,7 +82,7 @@ export function GeneralSettings() {
@@ -54,7 +94,7 @@ export function GeneralSettings() { @@ -76,7 +116,7 @@ export function GeneralSettings() { @@ -87,7 +127,7 @@ export function GeneralSettings() { setOauthProxyMode(value as OAuthProxyMode)} + onValueChange={(value) => setOauthProxyModeDraft(value as OAuthProxyMode)} > @@ -152,6 +192,13 @@ export function GeneralSettings() { + +
+ +
) } diff --git a/src/renderer/src/i18n/locales/en-US.json b/src/renderer/src/i18n/locales/en-US.json index 8dd3ffa..8764dbc 100644 --- a/src/renderer/src/i18n/locales/en-US.json +++ b/src/renderer/src/i18n/locales/en-US.json @@ -243,6 +243,16 @@ "userTokenPlaceholder": "Enter DeepSeek User Token", "userTokenHelp": "Authentication Token from DeepSeek web version, found in browser DevTools Application -> Local Storage -> userToken", "models": { + "deepseek-v4-pro": "DeepSeek V4 Pro", + "deepseek-v4-pro-think": "DeepSeek V4 Pro - Think", + "deepseek-v4-pro-search": "DeepSeek V4 Pro - Search", + "deepseek-v4-pro-think-search": "DeepSeek V4 Pro - Think+Search", + "deepseek-v4-flash": "DeepSeek V4 Flash", + "deepseek-v4-flash-think": "DeepSeek V4 Flash - Think", + "deepseek-v4-flash-search": "DeepSeek V4 Flash - Search", + "deepseek-v4-flash-think-search": "DeepSeek V4 Flash - Think+Search", + "deepseek-chat": "DeepSeek Chat (legacy)", + "deepseek-reasoner": "DeepSeek Reasoner (legacy)", "DeepSeek-V3.2": "DeepSeek V3.2 - Standard Chat", "DeepSeek-Search": "DeepSeek Search - Web Search", "DeepSeek-R1": "DeepSeek R1 - Deep Thinking", @@ -261,7 +271,7 @@ "description": "Kimi AI assistant, supports long text processing and web search", "accessToken": "Access Token", "accessTokenPlaceholder": "Enter Kimi Access Token or Refresh Token", - "accessTokenHelp": "Supports JWT Token (starts with eyJ) or refresh_token" + "accessTokenHelp": "The kimi-auth value from browser cookies (recommended), or JWT Token / refresh_token" }, "minimax": { "name": "MiniMax", @@ -629,6 +639,10 @@ "settings": { "title": "Settings", "description": "Manage application settings and preferences", + "save": "Save Settings", + "saving": "Saving...", + "saveSuccess": "Settings saved successfully", + "saveFailed": "Failed to save settings", "appearance": "Appearance", "theme": "Theme", "themeSettings": "Theme Settings", diff --git a/src/renderer/src/i18n/locales/zh-CN.json b/src/renderer/src/i18n/locales/zh-CN.json index 07e1248..c92e52a 100644 --- a/src/renderer/src/i18n/locales/zh-CN.json +++ b/src/renderer/src/i18n/locales/zh-CN.json @@ -243,6 +243,16 @@ "userTokenPlaceholder": "请输入 DeepSeek 用户 Token", "userTokenHelp": "从 DeepSeek 网页版获取的认证 Token,可在浏览器开发者工具 Application -> Local Storage -> userToken 中找到", "models": { + "deepseek-v4-pro": "DeepSeek V4 Pro", + "deepseek-v4-pro-think": "DeepSeek V4 Pro - 思考", + "deepseek-v4-pro-search": "DeepSeek V4 Pro - 搜索", + "deepseek-v4-pro-think-search": "DeepSeek V4 Pro - 思考+搜索", + "deepseek-v4-flash": "DeepSeek V4 Flash", + "deepseek-v4-flash-think": "DeepSeek V4 Flash - 思考", + "deepseek-v4-flash-search": "DeepSeek V4 Flash - 搜索", + "deepseek-v4-flash-think-search": "DeepSeek V4 Flash - 思考+搜索", + "deepseek-chat": "DeepSeek Chat (兼容旧名)", + "deepseek-reasoner": "DeepSeek Reasoner (兼容旧名)", "DeepSeek-V3.2": "DeepSeek V3.2 - 标准对话", "DeepSeek-Search": "DeepSeek Search - 联网搜索", "DeepSeek-R1": "DeepSeek R1 - 深度思考", @@ -261,7 +271,7 @@ "description": "Kimi AI 助手,支持长文本处理和联网搜索", "accessToken": "访问令牌", "accessTokenPlaceholder": "请输入 Kimi 访问令牌或刷新令牌", - "accessTokenHelp": "支持 JWT Token(以 eyJ 开头)或 refresh_token" + "accessTokenHelp": "浏览器 Cookie 中的 kimi-auth 字段值(推荐),也可使用 JWT Token 或 refresh_token" }, "minimax": { "name": "MiniMax", @@ -629,6 +639,10 @@ "settings": { "title": "设置", "description": "管理应用程序设置和偏好", + "save": "保存设置", + "saving": "保存中...", + "saveSuccess": "设置保存成功", + "saveFailed": "设置保存失败", "appearance": "外观", "theme": "主题", "themeSettings": "主题设置", diff --git a/src/renderer/src/stores/dashboardStore.ts b/src/renderer/src/stores/dashboardStore.ts index e0d2b73..93dc38d 100644 --- a/src/renderer/src/stores/dashboardStore.ts +++ b/src/renderer/src/stores/dashboardStore.ts @@ -1,5 +1,5 @@ import { create } from 'zustand' -import type { ProxyStatus, ProxyStatistics, Provider, Account, ProviderCheckResult, LogEntry } from '@/types/electron' +import type { ProxyStatus, ProxyStatistics, Provider, Account, LogEntry } from '@/types/electron' import type { ProviderStats, ActivityItem, ChartDataPoint } from '@/components/dashboard' interface DashboardStats { @@ -150,22 +150,20 @@ export const useDashboardStore = create((set, get) => ({ const persistentStatsPromise = window.electronAPI?.statistics?.get?.() ?? Promise.resolve(null) const providersPromise = window.electronAPI?.providers?.getAll?.() ?? Promise.resolve([]) const accountsPromise = window.electronAPI?.accounts?.getAll?.() ?? Promise.resolve([]) - const providerStatusPromise = window.electronAPI?.providers?.checkAllStatus?.() ?? Promise.resolve({}) const logsPromise = window.electronAPI?.logs?.get?.({ limit: 10 }) ?? Promise.resolve([]) const trendPromise = window.electronAPI?.logs?.getTrend?.(7) ?? Promise.resolve([]) const requestLogTrendPromise = window.electronAPI?.requestLogs?.getTrend?.(7) ?? Promise.resolve([]) - const [proxyStatus, statistics, persistentStats, providers, accounts, providerStatuses, logs, trends, requestLogTrends] = await Promise.all([ + const [proxyStatus, statistics, persistentStats, providers, accounts, logs, trends, requestLogTrends] = await Promise.all([ proxyStatusPromise, statisticsPromise, persistentStatsPromise, providersPromise, accountsPromise, - providerStatusPromise, logsPromise, trendPromise, requestLogTrendPromise, - ]) as [ProxyStatus | null, ProxyStatistics | null, any, Provider[], Account[], Record, LogEntry[], LogTrend[], any[]] + ]) as [ProxyStatus | null, ProxyStatistics | null, any, Provider[], Account[], LogEntry[], LogTrend[], any[]] setProxyStatus(proxyStatus) setStatistics(statistics) @@ -242,18 +240,17 @@ export const useDashboardStore = create((set, get) => ({ } const providerStats: ProviderStats[] = (providers ?? []).map((provider: Provider) => { - const status = providerStatuses?.[provider.id] const usage = providerUsage[provider.id] ?? 0 const successCount = providerSuccessCount[provider.id] ?? usage const totalCount = providerTotalCount[provider.id] ?? usage - + return { id: provider.id, name: provider.name, - status: status?.status ?? 'unknown', + status: provider.status ?? 'unknown', requestCount: totalCount, successCount: successCount, - latency: status?.latency, + latency: undefined, } }) setProviders(providerStats) diff --git a/src/renderer/src/stores/logsStore.ts b/src/renderer/src/stores/logsStore.ts index 9e029c0..2f0a489 100644 --- a/src/renderer/src/stores/logsStore.ts +++ b/src/renderer/src/stores/logsStore.ts @@ -51,6 +51,31 @@ interface LogsState { refresh: () => Promise } +function filterLogs(logs: LogEntry[], filter: LogFilter): LogEntry[] { + let filtered = [...logs] + + if (filter.level !== 'all') { + filtered = filtered.filter((log) => log.level === filter.level) + } + + if (filter.keyword) { + const keyword = filter.keyword.toLowerCase() + filtered = filtered.filter((log) => + log.message.toLowerCase().includes(keyword) + ) + } + + if (filter.startTime) { + filtered = filtered.filter((log) => log.timestamp >= filter.startTime!) + } + + if (filter.endTime) { + filtered = filtered.filter((log) => log.timestamp <= filter.endTime!) + } + + return filtered +} + export const useLogsStore = create((set, get) => ({ logs: [], filteredLogs: [], @@ -130,28 +155,7 @@ export const useLogsStore = create((set, get) => ({ applyFilter: () => { const { logs, filter } = get() - let filtered = [...logs] - - if (filter.level !== 'all') { - filtered = filtered.filter((log) => log.level === filter.level) - } - - if (filter.keyword) { - const keyword = filter.keyword.toLowerCase() - filtered = filtered.filter((log) => - log.message.toLowerCase().includes(keyword) - ) - } - - if (filter.startTime) { - filtered = filtered.filter((log) => log.timestamp >= filter.startTime!) - } - - if (filter.endTime) { - filtered = filtered.filter((log) => log.timestamp <= filter.endTime!) - } - - set({ filteredLogs: filtered }) + set({ filteredLogs: filterLogs(logs, filter) }) }, clearLogs: () => { @@ -194,7 +198,7 @@ export const useLogsStore = create((set, get) => ({ }, refresh: async () => { - const { pageSize } = get() + const { pageSize, filter } = get() set({ isLoading: true }) if (!window.electronAPI?.logs) { @@ -211,14 +215,14 @@ export const useLogsStore = create((set, get) => ({ set({ logs, + filteredLogs: filterLogs(logs, filter), stats, trend, hasMore: logs.length >= pageSize, + isLoading: false, }) - get().applyFilter() } catch (error) { console.error('Failed to refresh logs:', error) - } finally { set({ isLoading: false }) } }, diff --git a/src/renderer/src/stores/proxyStore.ts b/src/renderer/src/stores/proxyStore.ts index 7390e6f..4424894 100644 --- a/src/renderer/src/stores/proxyStore.ts +++ b/src/renderer/src/stores/proxyStore.ts @@ -167,9 +167,17 @@ export const useProxyStore = create((set, get) => ({ set({ isLoading: true }) const config = await window.electronAPI.store.get('config') if (config) { + // Restore account weights from backend + const weights = config.accountWeights || {} + const accountWeights = Object.entries(weights).map(([accountId, weight]) => ({ + accountId, + weight, + })) + set({ appConfig: config, loadBalanceStrategy: config.loadBalanceStrategy, + accountWeights, modelMappings: Object.values(config.modelMappings || {}), proxyConfig: { ...DEFAULT_PROXY_CONFIG, diff --git a/src/renderer/src/stores/settingsStore.ts b/src/renderer/src/stores/settingsStore.ts index 3f77c59..d116feb 100644 --- a/src/renderer/src/stores/settingsStore.ts +++ b/src/renderer/src/stores/settingsStore.ts @@ -45,6 +45,7 @@ interface SettingsState { setConfig: (config: AppConfig) => void updateConfig: (updates: Partial) => Promise fetchConfig: () => Promise + saveSettings: () => Promise } export const useSettingsStore = create()( @@ -58,42 +59,16 @@ export const useSettingsStore = create()( proxyEnabled: false, setProxyEnabled: (enabled) => set({ proxyEnabled: enabled }), oauthProxyMode: 'system', - setOauthProxyMode: async (mode) => { - set({ oauthProxyMode: mode }) - try { - await window.electronAPI.config.update({ oauthProxyMode: mode }) - } catch (error) { - console.error('Failed to update oauthProxyMode:', error) - } - }, + setOauthProxyMode: (mode) => set({ oauthProxyMode: mode }), language: 'en-US', - setLanguage: async (language) => { + setLanguage: (language) => { set({ language }) - await i18n.changeLanguage(language) - try { - await window.electronAPI.config.update({ language: language }) - } catch (error) { - console.error('Failed to update language:', error) - } + i18n.changeLanguage(language) }, autoStart: false, - setAutoStart: async (enabled) => { - set({ autoStart: enabled }) - try { - await window.electronAPI.config.update({ autoStart: enabled }) - } catch (error) { - console.error('Failed to update autoStart:', error) - } - }, + setAutoStart: (enabled) => set({ autoStart: enabled }), autoStartProxy: false, - setAutoStartProxy: async (enabled) => { - set({ autoStartProxy: enabled }) - try { - await window.electronAPI.config.update({ autoStartProxy: enabled }) - } catch (error) { - console.error('Failed to update autoStartProxy:', error) - } - }, + setAutoStartProxy: (enabled) => set({ autoStartProxy: enabled }), minimizeToTray: true, setMinimizeToTray: (enabled) => set({ minimizeToTray: enabled }), closeBehavior: 'minimize', @@ -107,7 +82,14 @@ export const useSettingsStore = create()( maxLogs: 10000, setMaxLogs: (count) => set({ maxLogs: count }), credentialEncryption: true, - setCredentialEncryption: (enabled) => set({ credentialEncryption: enabled }), + setCredentialEncryption: async (enabled) => { + set({ credentialEncryption: enabled }) + try { + await window.electronAPI.config.update({ credentialEncryption: enabled }) + } catch (error) { + console.error('Failed to update credentialEncryption:', error) + } + }, logDesensitization: true, setLogDesensitization: (enabled) => set({ logDesensitization: enabled }), config: null, @@ -138,17 +120,36 @@ export const useSettingsStore = create()( fetchConfig: async () => { try { const config = await window.electronAPI.config.get() - set({ + set({ config, autoStart: config.autoStart, autoStartProxy: config.autoStartProxy, oauthProxyMode: config.oauthProxyMode || 'system', language: config.language || 'en-US', + credentialEncryption: config.credentialEncryption ?? true, }) } catch (error) { console.error('Failed to fetch config:', error) } }, + saveSettings: async () => { + const state = get() + try { + await window.electronAPI.config.update({ + theme: state.theme, + language: state.language, + autoStart: state.autoStart, + autoStartProxy: state.autoStartProxy, + minimizeToTray: state.minimizeToTray, + oauthProxyMode: state.oauthProxyMode, + logLevel: state.logLevel, + logRetentionDays: state.logRetentionDays, + }) + } catch (error) { + console.error('Failed to save settings:', error) + throw error + } + }, }), { name: 'chat2api-settings', diff --git a/src/renderer/src/types/electron.d.ts b/src/renderer/src/types/electron.d.ts index 91ebc24..67257e9 100644 --- a/src/renderer/src/types/electron.d.ts +++ b/src/renderer/src/types/electron.d.ts @@ -299,14 +299,19 @@ interface SessionRecord { id: string providerId: string accountId: string - providerSessionId: string + providerSessionId?: string parentMessageId?: string + historyHash?: string sessionType: 'chat' | 'agent' messages: any[] createdAt: number lastActiveAt: number status: 'active' | 'expired' | 'deleted' model?: string + metadata?: { + title?: string + tokenCount?: number + } } interface SessionAPI { diff --git a/src/shared/types.ts b/src/shared/types.ts index e6a649f..d0f17dd 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -87,6 +87,8 @@ export interface AppConfig { proxyPort: number proxyHost: string loadBalanceStrategy: LoadBalanceStrategy + /** Account weights for weighted random selection (accountId → weight, 0-100) */ + accountWeights: Record modelMappings: Record theme: Theme autoStart: boolean @@ -103,6 +105,7 @@ export interface AppConfig { sessionConfig: SessionConfig toolPromptConfig: ToolPromptConfig language: 'zh-CN' | 'en-US' + credentialEncryption: boolean } export type LogLevel = 'debug' | 'info' | 'warn' | 'error' diff --git a/tests/session-manager.test.ts b/tests/session-manager.test.ts new file mode 100644 index 0000000..012a0e6 --- /dev/null +++ b/tests/session-manager.test.ts @@ -0,0 +1,472 @@ +/** + * Session Manager Test Suite + * + * Tests computeHistoryHash and sessionManager multi-turn conversation flow. + * Mocks storeManager since Electron is not available in test environment. + * + * Run: npx -y tsx tests/session-manager.test.ts + */ + +// ─── Mock StoreManager ────────────────────────────────────────────────────────── + +interface MockSessionRecord { + id: string + providerId: string + accountId: string + sessionType: 'chat' | 'agent' + messages: Array<{ role: string; content: string | any[]; timestamp: number; providerMessageId?: string; toolCallId?: string }> + createdAt: number + lastActiveAt: number + status: 'active' | 'expired' | 'deleted' + model?: string + metadata?: { title?: string; tokenCount?: number } + providerSessionId?: string + parentMessageId?: string + historyHash?: string +} + +const mockSessions: MockSessionRecord[] = [] +let mockConfig = { + sessionTimeout: 30, + maxMessagesPerSession: 50, + deleteAfterTimeout: false, + maxSessionsPerAccount: 3, +} + +const mockStoreManager = { + getSessionConfig: () => mockConfig, + updateSessionConfig: (updates: Partial) => { + Object.assign(mockConfig, updates) + return mockConfig + }, + getSessionsByProviderId: (providerId: string) => + mockSessions.filter(s => s.providerId === providerId), + getSessionById: (id: string) => + mockSessions.find(s => s.id === id), + getActiveSessions: () => + mockSessions.filter(s => s.status === 'active'), + addSession: (session: MockSessionRecord) => { + mockSessions.push(session) + }, + deleteSession: (id: string) => { + const idx = mockSessions.findIndex(s => s.id === id) + if (idx !== -1) { + mockSessions.splice(idx, 1) + return true + } + return false + }, + cleanExpiredSessions: () => { + const timeoutMs = mockConfig.sessionTimeout * 60 * 1000 + const now = Date.now() + const before = mockSessions.length + for (let i = mockSessions.length - 1; i >= 0; i--) { + const s = mockSessions[i] + if (s.status === 'active' && (now - s.lastActiveAt) >= timeoutMs) { + s.status = 'expired' + } + } + return before - mockSessions.filter(s => s.status === 'active').length + }, + clearAllSessions: () => { mockSessions.length = 0 }, + getSessionsByAccountId: (accountId: string) => + mockSessions.filter(s => s.accountId === accountId), + getSessions: () => mockSessions, +} + +// ─── Import computeHistoryHash (pure function, no Electron dependency) ────────── + +// We can't directly import from src/main due to Electron deps, +// so we copy the function here for pure testing. + +import { createHash } from 'crypto' + +interface ChatMessage { + role: 'user' | 'assistant' | 'system' | 'tool' + content: string | any[] + timestamp: number + providerMessageId?: string + toolCallId?: string +} + +function computeHistoryHash(messages: ChatMessage[]): string | undefined { + if (!messages || messages.length === 0) return undefined + + // Hash the first user message as a stable conversation identifier. + const firstUserMsg = messages.find(m => m.role === 'user') + if (!firstUserMsg) return undefined + + const content = typeof firstUserMsg.content === 'string' + ? firstUserMsg.content + : JSON.stringify(firstUserMsg.content) + + return createHash('md5').update(`${firstUserMsg.role}:${content}`).digest('hex') +} + +// ─── Tests ───────────────────────────────────────────────────────────────────── + +const passed: string[] = [] +const failed: string[] = [] + +function assert(condition: boolean, name: string, detail?: string): void { + if (condition) { + passed.push(name) + } else { + failed.push(`FAIL: ${name}${detail ? ` — ${detail}` : ''}`) + } +} + +function assertEqual(actual: T, expected: T, name: string): void { + const ok = JSON.stringify(actual) === JSON.stringify(expected) + if (ok) { + passed.push(name) + } else { + failed.push(`FAIL: ${name} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`) + } +} + +// ─── 1) computeHistoryHash ───────────────────────────────────────────────────── + +// Empty array returns undefined +assert( + computeHistoryHash([]) === undefined, + 'empty array → undefined' +) + +// Single user message returns hash of that message +const h1 = computeHistoryHash([ + { role: 'user', content: 'Hello', timestamp: 1 }, +]) +assert( + h1 !== undefined && h1.length === 32, + 'single user message → valid MD5 hash' +) + +// Multiple messages — still hashes first user message +const h2a = computeHistoryHash([ + { role: 'system', content: 'You are helpful', timestamp: 1 }, + { role: 'user', content: 'Hello', timestamp: 2 }, +]) +const h2b = computeHistoryHash([ + { role: 'user', content: 'Hello', timestamp: 1 }, + { role: 'assistant', content: 'Hi!', timestamp: 2 }, + { role: 'user', content: 'How are you?', timestamp: 3 }, +]) +assertEqual(h2a, h2b, 'same first user message → same hash regardless of later messages') + +// Different first user message → different hash +const h3 = computeHistoryHash([ + { role: 'user', content: 'Different', timestamp: 1 }, +]) +assert( + h3 !== h2a, + 'different first user message → different hash' +) + +// No user messages → undefined +const h4 = computeHistoryHash([ + { role: 'system', content: 'System prompt', timestamp: 1 }, + { role: 'assistant', content: 'Response', timestamp: 2 }, +]) +assert( + h4 === undefined, + 'no user message → undefined' +) + +// Content is array (multimodal) +const h5 = computeHistoryHash([ + { role: 'user', content: [{ type: 'text', text: 'Describe this' }], timestamp: 1 }, +]) +assert( + h5 !== undefined && h5.length === 32, + 'multimodal content (array) → valid hash' +) + +// Null content +const h6 = computeHistoryHash([ + { role: 'user', content: null as any, timestamp: 1 }, +]) +assert( + h6 !== undefined, + 'null content → still produces hash (from stringified)' +) + +// ─── 2) Session flow simulation (mock-based) ──────────────────────────────────── + +// Replicate sessionManager logic with mock store + +let nextId = 1 +function generateSessionId(): string { + return `session-test-${nextId++}` +} + +function getActiveSession(providerId: string, accountId: string): MockSessionRecord | undefined { + const sessions = mockStoreManager.getSessionsByProviderId(providerId) + const accountSessions = sessions.filter(s => s.accountId === accountId) + const config = mockStoreManager.getSessionConfig() + const timeoutMs = config.sessionTimeout * 60 * 1000 + const now = Date.now() + + return accountSessions.find(s => + s.status === 'active' && + (now - s.lastActiveAt) < timeoutMs + ) +} + +function getOrCreateSession(options: { + providerId: string + accountId: string + model?: string + messages?: ChatMessage[] +}): { + sessionId: string + providerSessionId: string | undefined + parentMessageId: string | undefined + messages: ChatMessage[] + isNew: boolean +} { + const { providerId, accountId, model, messages } = options + const hash = computeHistoryHash(messages || []) + + // 1) Hash lookup + if (hash) { + const sessions = mockStoreManager.getSessionsByProviderId(providerId) + const config = mockStoreManager.getSessionConfig() + const timeoutMs = config.sessionTimeout * 60 * 1000 + const now = Date.now() + + const matched = sessions.find(s => + s.accountId === accountId && + s.status === 'active' && + s.historyHash === hash && + (now - s.lastActiveAt) < timeoutMs + ) + + if (matched) { + matched.lastActiveAt = now + matched.messages = messages || matched.messages + return { + sessionId: matched.id, + providerSessionId: matched.providerSessionId, + parentMessageId: matched.parentMessageId, + messages: matched.messages, + isNew: false, + } + } + } + + // 2) Fallback to active session + const existingSession = getActiveSession(providerId, accountId) + if (existingSession) { + existingSession.messages = messages || existingSession.messages + if (hash) { + existingSession.historyHash = hash + } + return { + sessionId: existingSession.id, + providerSessionId: existingSession.providerSessionId, + parentMessageId: existingSession.parentMessageId, + messages: existingSession.messages, + isNew: false, + } + } + + // 3) Create new + const session: MockSessionRecord = { + id: generateSessionId(), + providerId, + accountId, + sessionType: 'chat', + messages: messages || [], + createdAt: Date.now(), + lastActiveAt: Date.now(), + status: 'active', + model, + } + if (hash) { + session.historyHash = hash + } + + mockStoreManager.addSession(session) + return { + sessionId: session.id, + providerSessionId: undefined, + parentMessageId: undefined, + messages: session.messages, + isNew: true, + } +} + +function updateProviderSession( + sessionId: string, + providerSessionId: string | undefined, + parentMessageId: string | undefined, + messages?: ChatMessage[], +): void { + const session = mockStoreManager.getSessionById(sessionId) + if (!session) return + + session.providerSessionId = providerSessionId || session.providerSessionId + session.parentMessageId = parentMessageId || session.parentMessageId + if (messages) { + session.messages = messages + const hash = computeHistoryHash(messages) + if (hash) { + session.historyHash = hash + } + } + session.lastActiveAt = Date.now() +} + +// Reset state before session flow tests +mockSessions.length = 0 +nextId = 1 +mockConfig.sessionTimeout = 30 + +// Test: First request creates new session +{ + const ctx1 = getOrCreateSession({ + providerId: 'deepseek', + accountId: 'account-1', + model: 'deepseek-chat', + messages: [{ role: 'user', content: 'Hello', timestamp: 1 }], + }) + assert(ctx1.isNew, 'first request → new session') + assert(ctx1.providerSessionId === undefined, 'first request → no providerSessionId') + assert(mockSessions.length === 1, 'first request → one session in store') +} + +// Test: Second request with same first user message → hash match (or fallback) +{ + const ctx2 = getOrCreateSession({ + providerId: 'deepseek', + accountId: 'account-1', + model: 'deepseek-chat', + messages: [ + { role: 'user', content: 'Hello', timestamp: 1 }, + { role: 'assistant', content: 'Hi!', timestamp: 2 }, + { role: 'user', content: 'How are you?', timestamp: 3 }, + ], + }) + assert(!ctx2.isNew, 'second request → reused session') + assert(mockSessions.length === 1, 'second request → still one session') +} + +// Test: After response, updateProviderSession stores provider IDs +{ + const session = mockSessions[0] + updateProviderSession( + session.id, + 'upstream-session-123', + 'upstream-msg-456', + [ + { role: 'user', content: 'Hello', timestamp: 1 }, + { role: 'assistant', content: 'Hi!', timestamp: 2 }, + { role: 'user', content: 'How are you?', timestamp: 3 }, + ], + ) + assert(session.providerSessionId === 'upstream-session-123', 'update stores providerSessionId') + assert(session.parentMessageId === 'upstream-msg-456', 'update stores parentMessageId') + assert(session.historyHash !== undefined, 'update sets historyHash') +} + +// Test: Third request with same first user message → hash match returns stored IDs +{ + const ctx3 = getOrCreateSession({ + providerId: 'deepseek', + accountId: 'account-1', + model: 'deepseek-chat', + messages: [ + { role: 'user', content: 'Hello', timestamp: 1 }, + { role: 'assistant', content: 'Hi!', timestamp: 2 }, + { role: 'user', content: 'How are you?', timestamp: 3 }, + { role: 'assistant', content: 'I am fine!', timestamp: 4 }, + { role: 'user', content: 'Tell me a joke', timestamp: 5 }, + ], + }) + assert(!ctx3.isNew, 'third request → reused session') + assert(ctx3.providerSessionId === 'upstream-session-123', 'third request → has stored providerSessionId') + assert(ctx3.parentMessageId === 'upstream-msg-456', 'third request → has stored parentMessageId') +} + +// Test: Different account → different session +{ + const ctx4 = getOrCreateSession({ + providerId: 'deepseek', + accountId: 'account-2', + model: 'deepseek-chat', + messages: [{ role: 'user', content: 'Hello', timestamp: 1 }], + }) + assert(ctx4.isNew, 'different account → new session') + assert(ctx4.providerSessionId === undefined, 'different account → no providerSessionId') + assert(mockSessions.length === 2, 'different account → two sessions total') +} + +// Test: Different provider → different session +{ + const ctx5 = getOrCreateSession({ + providerId: 'kimi', + accountId: 'account-1', + model: 'kimi-k2', + messages: [{ role: 'user', content: 'Hello', timestamp: 1 }], + }) + assert(ctx5.isNew, 'different provider → new session') + assert(mockSessions.length === 3, 'different provider → three sessions total') +} + +// Test: updateProviderSession with falsy IDs preserves existing +{ + const session = mockSessions[0] + const before = { ...session } + updateProviderSession(session.id, undefined, undefined) + assert(session.providerSessionId === before.providerSessionId, 'undefined providerSessionId → preserves existing') + assert(session.parentMessageId === before.parentMessageId, 'undefined parentMessageId → preserves existing') +} + +// Test: updateProviderSession with non-existent session does nothing +{ + updateProviderSession('non-existent-id', 'foo', 'bar') + assert(true, 'update of non-existent session → no crash') // should not throw +} + +// Test: Session timeout — expired session not matched +{ + // Create a session in the past + const oldSession: MockSessionRecord = { + id: 'old-session', + providerId: 'deepseek', + accountId: 'account-3', + sessionType: 'chat', + messages: [{ role: 'user', content: 'Old', timestamp: 1 }], + createdAt: Date.now() - 3600000, + lastActiveAt: Date.now() - 3600000, + status: 'active', + historyHash: computeHistoryHash([{ role: 'user', content: 'Old', timestamp: 1 }]), + } + mockSessions.push(oldSession) + + const ctx = getOrCreateSession({ + providerId: 'deepseek', + accountId: 'account-3', + messages: [{ role: 'user', content: 'Old', timestamp: 1 }], + }) + // The old session should be filtered out due to timeout, so a new one is created + // But with matching hash on a timed-out session, we fall through to getActiveSession + // which also filters by timeout, so we create new + assert(ctx.isNew, 'expired session → new session created') +} + +// ─── Report ───────────────────────────────────────────────────────────────────── + +console.log('') +console.log(`Passed: ${passed.length}`) +console.log(`Failed: ${failed.length}`) +if (failed.length > 0) { + console.log('') + for (const f of failed) { + console.log(` ${f}`) + } + process.exit(1) +} else { + console.log('All session manager tests passed!') +} diff --git a/tests/store/log-buffering.test.ts b/tests/store/log-buffering.test.ts new file mode 100644 index 0000000..5626d4b --- /dev/null +++ b/tests/store/log-buffering.test.ts @@ -0,0 +1,236 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +// Test LogManager buffering behavior (same pattern as StoreManager log buffering) +// Since LogManager uses raw fs and is importable without Electron, +// we mock the Electron app module and test the buffering/flush logic. + +test('LogManager buffers writes and flushes to disk', async (t) => { + const root = mkdtempSync(join(tmpdir(), 'log-manager-test-')) + t.after(() => rmSync(root, { recursive: true, force: true })) + + const logFile = join(root, 'app.log') + const pendingLogs: string[] = [] + + // Simulate the LogManager buffering pattern + function addLog(line: string): void { + pendingLogs.push(line) + } + + function flushSync(): void { + if (pendingLogs.length === 0) return + const { appendFileSync } = require('fs') + appendFileSync(logFile, pendingLogs.map(l => l).join('\n') + '\n', 'utf-8') + pendingLogs.length = 0 + } + + // Buffer 3 logs + addLog('{"level":"info","message":"log1"}') + addLog('{"level":"warn","message":"log2"}') + addLog('{"level":"error","message":"log3"}') + + // Not yet written + assert.equal(existsSync(logFile), false) + + // Flush + flushSync() + + // Now written + assert.equal(existsSync(logFile), true) + const content = readFileSync(logFile, 'utf-8') + const lines = content.trim().split('\n') + assert.equal(lines.length, 3) + assert.match(lines[0], /log1/) + assert.match(lines[1], /log2/) + assert.match(lines[2], /log3/) + + // Flush again should be a no-op + flushSync() + const content2 = readFileSync(logFile, 'utf-8') + assert.equal(content, content2) +}) + +test('LogManager trim keeps only the newest entries', async (t) => { + const root = mkdtempSync(join(tmpdir(), 'log-manager-trim-')) + t.after(() => rmSync(root, { recursive: true, force: true })) + + const logFile = join(root, 'app.log') + const maxEntries = 3 + let entries: string[] = [] + + function addLog(line: string): void { + entries.push(line) + if (entries.length > maxEntries) { + entries = entries.slice(-maxEntries) + } + } + + function flushSync(): void { + const { writeFileSync } = require('fs') + writeFileSync(logFile, entries.join('\n') + '\n', 'utf-8') + } + + addLog('entry-1') + addLog('entry-2') + addLog('entry-3') + addLog('entry-4') + addLog('entry-5') + + flushSync() + + const content = readFileSync(logFile, 'utf-8') + const lines = content.trim().split('\n') + assert.equal(lines.length, 3) + assert.match(lines[0], /entry-3/) + assert.match(lines[1], /entry-4/) + assert.match(lines[2], /entry-5/) +}) + +test('StoreManager log migration moves logs to separate store', async (t) => { + const root = mkdtempSync(join(tmpdir(), 'store-migration-')) + t.after(() => rmSync(root, { recursive: true, force: true })) + + const Store = require('electron-store').default + + // Create main store with logs + const mainStore = new Store({ + name: 'data', + cwd: root, + defaults: { logs: [], config: {} }, + }) + + // Create logs store (initially empty) + const logsStore = new Store({ + name: 'logs-data', + cwd: root, + defaults: { logs: [] }, + }) + + // Add logs to main store + const testLogs = [ + { id: '1', timestamp: 1, level: 'info', message: 'test1' }, + { id: '2', timestamp: 2, level: 'warn', message: 'test2' }, + { id: '3', timestamp: 3, level: 'error', message: 'test3' }, + ] + mainStore.set('logs', testLogs) + + // Simulate migration + const oldLogs = (mainStore.get('logs') as unknown[]) || [] + const existingLogs = (logsStore.get('logs') as unknown[]) || [] + const merged = [...existingLogs, ...oldLogs] + logsStore.set('logs', merged) + mainStore.set('logs', []) + + // Verify main store logs cleared + assert.deepEqual(mainStore.get('logs'), []) + + // Verify logs moved to logs store + const migratedLogs = logsStore.get('logs') as unknown[] + assert.equal(migratedLogs.length, 3) + + // Verify idempotent: re-run migration should not duplicate + const oldLogs2 = (mainStore.get('logs') as unknown[]) || [] + const existingLogs2 = (logsStore.get('logs') as unknown[]) || [] + const merged2 = [...existingLogs2, ...oldLogs2] + logsStore.set('logs', merged2) + + const afterIdempotent = logsStore.get('logs') as unknown[] + assert.equal(afterIdempotent.length, 3, 'migration should be idempotent') +}) + +test('StoreManager log buffering flushes on demand', async (t) => { + const root = mkdtempSync(join(tmpdir(), 'store-buffer-')) + t.after(() => rmSync(root, { recursive: true, force: true })) + + const Store = require('electron-store').default + + const logsStore = new Store({ + name: 'logs-data', + cwd: root, + defaults: { logs: [] }, + }) + + const pendingLogs: unknown[] = [] + + // Simulate addLog buffering + function addLog(entry: unknown): void { + pendingLogs.push(entry) + } + + function flushLogsSync(): void { + if (pendingLogs.length === 0) return + const logs = ((logsStore.get('logs') as unknown[]) || []).concat(pendingLogs) + logsStore.set('logs', logs) + pendingLogs.length = 0 + } + + // Buffer some logs + addLog({ id: '1', level: 'info', message: 'buffered1' }) + addLog({ id: '2', level: 'warn', message: 'buffered2' }) + + // Not yet persisted + assert.deepEqual(logsStore.get('logs'), []) + + // Flush + flushLogsSync() + + // Now persisted + const persisted = logsStore.get('logs') as unknown[] + assert.equal(persisted.length, 2) + + // Pending cleared + assert.equal(pendingLogs.length, 0) + + // Flush again is no-op + flushLogsSync() + const persisted2 = logsStore.get('logs') as unknown[] + assert.equal(persisted2.length, 2) +}) + +test('StoreManager log count cap respects max entries', async (t) => { + const root = mkdtempSync(join(tmpdir(), 'store-cap-')) + t.after(() => rmSync(root, { recursive: true, force: true })) + + const Store = require('electron-store').default + + const logsStore = new Store({ + name: 'logs-data', + cwd: root, + defaults: { logs: [] }, + }) + + const maxLogs = 3 + let pendingLogs: unknown[] = [] + + function addLog(entry: unknown): void { + pendingLogs.push(entry) + if (pendingLogs.length > maxLogs) { + pendingLogs = pendingLogs.slice(-maxLogs) + } + } + + function flushLogsSync(): void { + if (pendingLogs.length === 0) return + const logs = ((logsStore.get('logs') as unknown[]) || []).concat(pendingLogs) + const trimmed = logs.length > maxLogs ? logs.slice(-maxLogs) : logs + logsStore.set('logs', trimmed) + pendingLogs.length = 0 + } + + // Add more than max + addLog({ id: '1' }) + addLog({ id: '2' }) + addLog({ id: '3' }) + addLog({ id: '4' }) + addLog({ id: '5' }) + + flushLogsSync() + + const persisted = logsStore.get('logs') as unknown[] + assert.equal(persisted.length, 3) + const ids = persisted.map((e: any) => e.id) + assert.deepEqual(ids, ['3', '4', '5']) +}) diff --git a/tsconfig.node.json b/tsconfig.node.json index 40b6586..d877c54 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -15,7 +15,6 @@ "electron.vite.config.ts", "src/main/**/*.ts", "src/preload/**/*.ts", - "src/shared/**/*.ts", "src/types/**/*.d.ts", "src/main/types/**/*.d.ts" ] From c2990674d611b9ec14d287186ae94cce479ce232 Mon Sep 17 00:00:00 2001 From: leisvip Date: Mon, 4 May 2026 03:37:07 +0800 Subject: [PATCH 4/5] docs: update README for v1.4.0 - new providers, features, and repo URL --- README.md | 20 ++++++++++++-------- README_CN.md | 20 ++++++++++++-------- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 15ae5d5..6a7de7c 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

- Release + Release License
Electron @@ -31,27 +31,31 @@ ## ✨ Features - OpenAI Compatible API: Provides standard OpenAI-compatible API endpoints for seamless integration -- Multi-Provider Support: Connect DeepSeek, GLM, Kimi, MiniMax, Perplexity 🆕, Qwen, Z.ai and more +- Multi-Provider Support: Connect DeepSeek (V4 Pro/Flash), GLM, Kimi (K2.6), MiniMax, Perplexity 🆕, Qwen, Z.ai and more - 🆕 Context Management: Intelligent conversation context management with sliding window, token limit, and summary strategies +- 🆕 Multi-Turn Sessions: Automatic session reuse for multi-turn conversations across all providers - 🆕 Function Calling Support: Universal tool calling capability for all models via prompt engineering, compatible with Cherry Studio, Kilo Code, and other clients - 🆕 Model Mapping: Flexible model name mapping with wildcard support and preferred provider/account selection - 🆕 Custom Parameters: Support for custom HTTP headers to enable web search, thinking mode, and deep research features +- 🆕 Weighted Load Balancing: Account-level weighted random selection for fine-grained traffic control +- 🆕 Credential Encryption: Toggle credential encryption in stored configuration - Dashboard Monitoring: Real-time request traffic, token usage, and success rates - API Key Management: Generate and manage keys for your local proxy - Model Management: View and manage available models from all providers -- Request Logs: Detailed request logging for debugging and analysis +- Request Logs: Detailed request logging with separate storage for performance - Proxy Configuration: Flexible proxy settings and routing strategies - System Tray Integration: Quick access to status from menu bar - Multilingual: English and Simplified Chinese support - Modern UI: Clean, responsive interface with dark/light theme support +- Settings Draft Mode: Edit settings with preview before saving ## 🤖 Supported Providers | Provider | Auth Type | OAuth | Models | | ---------------- | ------------- | ----- | ------------------------------------------------------------------------------- | -| DeepSeek | User Token | Yes | DeepSeek-V3.2 | +| DeepSeek | User Token | Yes | DeepSeek V4 Pro, DeepSeek V4 Flash, DeepSeek-V3.2, DeepSeek-R1 | | GLM | Refresh Token | Yes | GLM-5 | -| Kimi | JWT Token | Yes | kimi-k2.5 | +| Kimi | Cookie/Auth | Yes | Kimi-K2.6, Kimi-K2.5 | | MiniMax | JWT Token | Yes | MiniMax-M2.5 | | 🆕 Perplexity | JWT Token | Yes | Sonar, Sonar Pro, Sonar Deep Research | | Qwen (CN) | SSO Ticket | Yes | Qwen3.5-Plus, Qwen3-Max, Qwen3-Flash, Qwen3-Coder, qwen-max-latest | @@ -62,7 +66,7 @@ ### Download -Download the latest release from [GitHub Releases](https://github.com/xiaoY233/Chat2API/releases): +Download the latest release from [GitHub Releases](https://github.com/leisvip/Chat2API/releases): | Platform | Download | | --------------------- | --------------------------------------- | @@ -81,7 +85,7 @@ Download the latest release from [GitHub Releases](https://github.com/xiaoY233/C ```bash # Clone the repository -git clone https://github.com/xiaoY233/Chat2API.git +git clone https://github.com/leisvip/Chat2API.git cd Chat2API # Install dependencies @@ -239,7 +243,7 @@ sudo xattr -rd com.apple.quarantine "/Applications/Chat2API.app" ### How to update? -Check for updates in the **About** page, or download the latest version from [GitHub Releases](https://github.com/xiaoY233/Chat2API/releases). +Check for updates in the **About** page, or download the latest version from [GitHub Releases](https://github.com/leisvip/Chat2API/releases). ## 🤝 Contributing diff --git a/README_CN.md b/README_CN.md index 98e5e3f..f8dfa87 100644 --- a/README_CN.md +++ b/README_CN.md @@ -5,7 +5,7 @@

- Release + Release License
Electron @@ -31,27 +31,31 @@ ## ✨ 功能特性 - OpenAI 兼容 API:提供标准 OpenAI 兼容接口,无缝对接现有工具 -- 多服务商支持:支持 DeepSeek、GLM、Kimi、MiniMax、Perplexity 🆕、Qwen、Z.ai 等 +- 多服务商支持:支持 DeepSeek (V4 Pro/Flash)、GLM、Kimi (K2.6)、MiniMax、Perplexity 🆕、Qwen、Z.ai 等 - 🆕 上下文管理:智能对话上下文管理,支持滑动窗口、Token 限制和总结压缩策略 +- 🆕 多轮会话:自动会话复用,支持所有服务商的多轮对话 - 🆕 工具调用支持:通过提示词工程为所有模型提供通用工具调用能力,兼容 Cherry Studio、Kilo Code 等客户端 - 🆕 模型映射:灵活的模型名称映射,支持通配符和首选服务商/账户选择 - 🆕 自定义参数:支持自定义 HTTP Header 开启联网搜索、深度思考、深度研究等功能 +- 🆕 权重负载均衡:账户级别加权随机选择,精细化流量控制 +- 🆕 凭证加密:可切换凭证加密存储 - 仪表盘监控:实时请求流量、Token 使用量和成功率统计 - API Key 管理:为本地代理生成和管理密钥 - 模型管理:查看和管理所有服务商的可用模型 -- 请求日志:详细的请求日志记录,便于调试和分析 +- 请求日志:独立存储的详细请求日志,性能更优 - 代理配置:灵活的代理设置和路由策略 - 系统托盘集成:从菜单栏快速访问状态 - 多语言支持:支持英文和简体中文 - 现代界面:简洁响应式界面,支持深色/浅色主题 +- 设置草稿模式:预览设置变更后再保存 ## 🤖 支持的服务商 | 服务商 | 认证类型 | OAuth | 模型 | | ------------- | ------------- | ----- | ------------------------------------------------------------------------------- | -| DeepSeek | User Token | 是 | DeepSeek-V3.2 | +| DeepSeek | User Token | 是 | DeepSeek V4 Pro, DeepSeek V4 Flash, DeepSeek-V3.2, DeepSeek-R1 | | GLM | Refresh Token | 是 | GLM-5 | -| Kimi | JWT Token | 是 | kimi-k2.5 | +| Kimi | Cookie/Auth | 是 | Kimi-K2.6, Kimi-K2.5 | | MiniMax | JWT Token | 是 | MiniMax-M2.5 | | 🆕 Perplexity | JWT Token | 是 | Sonar, Sonar Pro, Sonar Deep Research | | Qwen (国内版) | SSO Ticket | 是 | Qwen3.5-Plus, Qwen3-Max, Qwen3-Flash, Qwen3-Coder, qwen-max-latest | @@ -62,7 +66,7 @@ ### 下载安装 -从 [GitHub Releases](https://github.com/xiaoY233/Chat2API/releases) 下载最新版本: +从 [GitHub Releases](https://github.com/leisvip/Chat2API/releases) 下载最新版本: | 平台 | 下载文件 | | --------------------- | -------------------------------------- | @@ -81,7 +85,7 @@ ```bash # 克隆仓库 -git clone https://github.com/xiaoY233/Chat2API.git +git clone https://github.com/leisvip/Chat2API.git cd Chat2API # 安装依赖 @@ -239,7 +243,7 @@ sudo xattr -rd com.apple.quarantine "/Applications/Chat2API.app" ### 如何更新? -在 **关于** 页面检查更新,或从 [GitHub Releases](https://github.com/xiaoY233/Chat2API/releases) 下载最新版本。 +在 **关于** 页面检查更新,或从 [GitHub Releases](https://github.com/leisvip/Chat2API/releases) 下载最新版本。 ## 🤝 贡献 From 4cd8376f6d4f2634d661892b9d917a460c55b3a3 Mon Sep 17 00:00:00 2001 From: fumorn Date: Mon, 4 May 2026 18:27:38 +0800 Subject: [PATCH 5/5] perf: replace electron-store with SQLite for better performance; fix credential encryption warnings --- package-lock.json | 73 +- package.json | 2 + src/main/store/sqlite.ts | 623 +++++++++++++++++ src/main/store/store.ts | 1371 +++++++++++--------------------------- 4 files changed, 1062 insertions(+), 1007 deletions(-) create mode 100644 src/main/store/sqlite.ts diff --git a/package-lock.json b/package-lock.json index 2cad45c..e57b33a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "chat2api", - "version": "1.3.0", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "chat2api", - "version": "1.3.0", + "version": "1.4.0", "hasInstallScript": true, "license": "GPL-3.0", "dependencies": { @@ -24,8 +24,10 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-toast": "^1.2.15", "@radix-ui/react-tooltip": "^1.2.8", + "@types/better-sqlite3": "^7.6.13", "ali-oss": "^6.23.0", "axios": "^1.7.7", + "better-sqlite3": "^12.9.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "electron-store": "^10.0.0", @@ -3123,6 +3125,15 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -3395,7 +3406,6 @@ "version": "22.19.11", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -4168,7 +4178,6 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -4195,6 +4204,20 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/better-sqlite3": { + "version": "12.9.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.9.0.tgz", + "integrity": "sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -4208,11 +4231,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, "license": "MIT", "dependencies": { "buffer": "^5.5.0", @@ -4316,7 +4347,6 @@ "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, "funding": [ { "type": "github", @@ -5576,7 +5606,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dev": true, "license": "MIT", "dependencies": { "mimic-response": "^3.1.0" @@ -5592,7 +5621,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -5611,7 +5639,6 @@ "version": "0.6.0", "resolved": "https://registry.npmmirror.com/deep-extend/-/deep-extend-0.6.0.tgz", "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "dev": true, "license": "MIT", "engines": { "node": ">=4.0.0" @@ -5728,7 +5755,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -6527,7 +6553,6 @@ "version": "2.0.3", "resolved": "https://registry.npmmirror.com/expand-template/-/expand-template-2.0.3.tgz", "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "dev": true, "license": "(MIT OR WTFPL)", "engines": { "node": ">=6" @@ -6663,6 +6688,12 @@ "pend": "~1.2.0" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/filelist": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", @@ -6842,7 +6873,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "dev": true, "license": "MIT" }, "node_modules/fs-extra": { @@ -7026,7 +7056,6 @@ "version": "0.0.0", "resolved": "https://registry.npmmirror.com/github-from-package/-/github-from-package-0.0.0.tgz", "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "dev": true, "license": "MIT" }, "node_modules/glob": { @@ -7472,7 +7501,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, "funding": [ { "type": "github", @@ -7557,7 +7585,6 @@ "version": "1.3.8", "resolved": "https://registry.npmmirror.com/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "dev": true, "license": "ISC" }, "node_modules/internmap": { @@ -8647,7 +8674,6 @@ "version": "0.5.3", "resolved": "https://registry.npmmirror.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "dev": true, "license": "MIT" }, "node_modules/ms": { @@ -8690,7 +8716,6 @@ "version": "2.0.0", "resolved": "https://registry.npmmirror.com/napi-build-utils/-/napi-build-utils-2.0.0.tgz", "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "dev": true, "license": "MIT" }, "node_modules/negotiator": { @@ -8706,7 +8731,6 @@ "version": "3.87.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", - "dev": true, "license": "MIT", "dependencies": { "semver": "^7.3.5" @@ -8719,7 +8743,6 @@ "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -9397,7 +9420,6 @@ "resolved": "https://registry.npmmirror.com/prebuild-install/-/prebuild-install-7.1.3.tgz", "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", - "dev": true, "license": "MIT", "dependencies": { "detect-libc": "^2.0.0", @@ -9592,7 +9614,6 @@ "version": "1.2.8", "resolved": "https://registry.npmmirror.com/rc/-/rc-1.2.8.tgz", "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "dev": true, "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", "dependencies": { "deep-extend": "^0.6.0", @@ -9834,7 +9855,6 @@ "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, "license": "MIT", "dependencies": { "inherits": "^2.0.3", @@ -10419,7 +10439,6 @@ "version": "1.0.1", "resolved": "https://registry.npmmirror.com/simple-concat/-/simple-concat-1.0.1.tgz", "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "dev": true, "funding": [ { "type": "github", @@ -10440,7 +10459,6 @@ "version": "4.0.1", "resolved": "https://registry.npmmirror.com/simple-get/-/simple-get-4.0.1.tgz", "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "dev": true, "funding": [ { "type": "github", @@ -10685,7 +10703,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.2.0" @@ -10753,7 +10770,6 @@ "version": "2.0.1", "resolved": "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz", "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -10917,7 +10933,6 @@ "version": "2.1.4", "resolved": "https://registry.npmmirror.com/tar-fs/-/tar-fs-2.1.4.tgz", "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", - "dev": true, "license": "MIT", "dependencies": { "chownr": "^1.1.1", @@ -10930,14 +10945,12 @@ "version": "1.1.4", "resolved": "https://registry.npmmirror.com/chownr/-/chownr-1.1.4.tgz", "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "dev": true, "license": "ISC" }, "node_modules/tar-stream": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "dev": true, "license": "MIT", "dependencies": { "bl": "^4.0.3", @@ -11187,7 +11200,6 @@ "version": "0.6.0", "resolved": "https://registry.npmmirror.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "dev": true, "license": "Apache-2.0", "dependencies": { "safe-buffer": "^5.0.1" @@ -11272,7 +11284,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/unescape": { diff --git a/package.json b/package.json index e2a258d..ff481a3 100644 --- a/package.json +++ b/package.json @@ -42,8 +42,10 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-toast": "^1.2.15", "@radix-ui/react-tooltip": "^1.2.8", + "@types/better-sqlite3": "^7.6.13", "ali-oss": "^6.23.0", "axios": "^1.7.7", + "better-sqlite3": "^12.9.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "electron-store": "^10.0.0", diff --git a/src/main/store/sqlite.ts b/src/main/store/sqlite.ts new file mode 100644 index 0000000..872bde2 --- /dev/null +++ b/src/main/store/sqlite.ts @@ -0,0 +1,623 @@ +/** + * SQLite Database Layer + * Replaces electron-store for better performance and lower CPU usage + */ + +import Database from 'better-sqlite3' +import { join } from 'path' +import { homedir } from 'os' +import { existsSync, readFileSync, renameSync } from 'fs' +import { safeStorage } from 'electron' +import type { + Provider, + Account, + SessionRecord, + SystemPrompt, + LogEntry, + AppConfig, + PersistentStatistics, + UserModelOverrides, + StoreSchema, +} from './types' +import { DEFAULT_CONFIG, DEFAULT_STATISTICS, DEFAULT_USER_MODEL_OVERRIDES, BUILTIN_PROVIDERS } from './types' +import { BUILTIN_PROMPTS } from '../data/builtin-prompts' + +const MIGRATION_BACKUP_SUFFIX = '.migrated' + +export class SQLiteStore { + private db!: Database.Database + private storagePath: string + private dbPath: string + + constructor() { + this.storagePath = join(homedir(), '.chat2api') + this.dbPath = join(this.storagePath, 'store.db') + } + + /** + * Initialize database: create tables, run migrations, import from JSON if needed + */ + async initialize(): Promise { + const dbExists = existsSync(this.dbPath) + + // If database doesn't exist but old JSON files exist, migrate + if (!dbExists && this.hasOldElectronStore()) { + await this.migrateFromElectronStore() + } + + this.db = new Database(this.dbPath) + this.db.pragma('journal_mode = WAL') + this.db.pragma('synchronous = NORMAL') + this.db.pragma('cache_size = -64000') // 64MB cache + + this.createTables() + + // If no config exists (fresh install), initialize default data + const config = this.getConfig() + if (!config) { + this.initDefaultData() + } + } + + private hasOldElectronStore(): boolean { + const dataJson = join(this.storagePath, 'data.json') + const logsJson = join(this.storagePath, 'logs-data.json') + return existsSync(dataJson) || existsSync(logsJson) + } + + /** + * Migrate data from electron-store JSON files to SQLite using electron-store API + */ + private async migrateFromElectronStore(): Promise { + console.log('[SQLite] Starting migration from electron-store...') + + const dataJsonPath = join(this.storagePath, 'data.json') + const logsJsonPath = join(this.storagePath, 'logs-data.json') + + // Default values + let providers: Provider[] = [] + let accounts: Account[] = [] + let config: AppConfig = DEFAULT_CONFIG + let systemPrompts: SystemPrompt[] = [] + let sessions: SessionRecord[] = [] + let statistics: PersistentStatistics = DEFAULT_STATISTICS + let userModelOverrides: UserModelOverrides = DEFAULT_USER_MODEL_OVERRIDES + let logs: LogEntry[] = [] + + // Use electron-store API to read encrypted JSON files + try { + // Dynamically import electron-store (ESM module) + const electronStoreModule = await import('electron-store') + const Store = electronStoreModule.default + + // Check if data.json exists and is readable via electron-store + if (existsSync(dataJsonPath)) { + console.log('[SQLite] Reading data.json via electron-store API...') + const store = new Store({ + name: 'data', + cwd: this.storagePath, + encryptionKey: 'chat2api-fixed-encryption-key-v1', // Matches original + }) + const data = store.store as unknown as StoreSchema + providers = data.providers || [] + accounts = data.accounts || [] + config = data.config || DEFAULT_CONFIG + systemPrompts = (data.systemPrompts || []).filter((p: SystemPrompt) => !p.isBuiltin) + sessions = data.sessions || [] + statistics = data.statistics || DEFAULT_STATISTICS + userModelOverrides = data.userModelOverrides || DEFAULT_USER_MODEL_OVERRIDES + console.log('[SQLite] Successfully read data via electron-store') + } + } catch (err) { + console.error('[SQLite] Failed to read data.json using electron-store API, falling back to file read', err) + // Fallback to direct file read (should not happen, but keep for safety) + try { + const content = readFileSync(dataJsonPath, 'utf-8') + const data = JSON.parse(content) as StoreSchema + providers = data.providers || [] + accounts = data.accounts || [] + config = data.config || DEFAULT_CONFIG + systemPrompts = (data.systemPrompts || []).filter((p: SystemPrompt) => !p.isBuiltin) + sessions = data.sessions || [] + statistics = data.statistics || DEFAULT_STATISTICS + userModelOverrides = data.userModelOverrides || DEFAULT_USER_MODEL_OVERRIDES + } catch (fallbackErr) { + console.error('[SQLite] Fallback parse also failed', fallbackErr) + } + } + + // Parse logs-data.json using electron-store API + if (existsSync(logsJsonPath)) { + try { + const electronStoreModule = await import('electron-store') + const Store = electronStoreModule.default + const logsStore = new Store({ + name: 'logs-data', + cwd: this.storagePath, + encryptionKey: 'chat2api-fixed-encryption-key-v1', + }) + logs = logsStore.get('logs') as LogEntry[] || [] + console.log('[SQLite] Successfully read logs-data via electron-store') + } catch (err) { + console.error('[SQLite] Failed to read logs-data.json using electron-store API', err) + // Fallback to direct file read + try { + const content = readFileSync(logsJsonPath, 'utf-8') + const logsData = JSON.parse(content) + logs = logsData.logs || [] + } catch (fallbackErr) { + console.error('[SQLite] Fallback parse for logs also failed', fallbackErr) + } + } + } + + // Create database connection if not already created + this.db = new Database(this.dbPath) + this.db.pragma('journal_mode = WAL') + this.createTables() + + // Insert data + const insertConfig = this.db.prepare('INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)') + insertConfig.run('app', JSON.stringify(config)) + + const insertProvider = this.db.prepare('INSERT OR REPLACE INTO providers (id, data) VALUES (?, ?)') + for (const p of providers) { + insertProvider.run(p.id, JSON.stringify(p)) + } + + const insertAccount = this.db.prepare('INSERT OR REPLACE INTO accounts (id, provider_id, data) VALUES (?, ?, ?)') + // Normalize credentials based on encryption setting + const shouldEncrypt = config.credentialEncryption && safeStorage.isEncryptionAvailable() + for (const a of accounts) { + let accountToStore = { ...a } + if (shouldEncrypt && accountToStore.credentials) { + const encryptedCreds: Record = {} + for (const [key, value] of Object.entries(accountToStore.credentials)) { + try { + const encrypted = safeStorage.encryptString(value) + encryptedCreds[key] = encrypted.toString('base64') + } catch (err) { + console.error(`[SQLite] Failed to encrypt credential ${key} for account ${a.id}:`, err) + encryptedCreds[key] = value // fallback to plaintext + } + } + accountToStore = { ...accountToStore, credentials: encryptedCreds } + } + insertAccount.run(a.id, a.providerId, JSON.stringify(accountToStore)) + } + + const insertSession = this.db.prepare('INSERT OR REPLACE INTO sessions (id, provider_id, account_id, data) VALUES (?, ?, ?, ?)') + for (const s of sessions) { + insertSession.run(s.id, s.providerId, s.accountId, JSON.stringify(s)) + } + + const insertPrompt = this.db.prepare('INSERT OR REPLACE INTO system_prompts (id, data) VALUES (?, ?)') + for (const p of systemPrompts) { + insertPrompt.run(p.id, JSON.stringify(p)) + } + + const insertOverride = this.db.prepare('INSERT OR REPLACE INTO model_overrides (provider_id, data) VALUES (?, ?)') + for (const [providerId, overrides] of Object.entries(userModelOverrides)) { + insertOverride.run(providerId, JSON.stringify(overrides)) + } + + const insertLog = this.db.prepare('INSERT OR REPLACE INTO logs (id, timestamp, level, message, account_id, provider_id, request_id, data) VALUES (?, ?, ?, ?, ?, ?, ?, ?)') + for (const log of logs) { + insertLog.run( + log.id, + log.timestamp, + log.level, + log.message, + log.accountId || null, + log.providerId || null, + log.requestId || null, + log.data ? JSON.stringify(log.data) : null + ) + } + + const insertStat = this.db.prepare('INSERT OR REPLACE INTO statistics (key, data) VALUES (?, ?)') + insertStat.run('main', JSON.stringify(statistics)) + + // Rename old files to .migrated backup + try { + renameSync(dataJsonPath, dataJsonPath + MIGRATION_BACKUP_SUFFIX) + if (existsSync(logsJsonPath)) { + renameSync(logsJsonPath, logsJsonPath + MIGRATION_BACKUP_SUFFIX) + } + console.log('[SQLite] Migration completed and old files backed up') + } catch (err) { + console.error('[SQLite] Failed to rename old files', err) + } + } + + private createTables(): void { + this.db.exec(` + CREATE TABLE IF NOT EXISTS config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS providers ( + id TEXT PRIMARY KEY, + data TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS accounts ( + id TEXT PRIMARY KEY, + provider_id TEXT NOT NULL, + data TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_accounts_provider ON accounts(provider_id); + + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + provider_id TEXT NOT NULL, + account_id TEXT NOT NULL, + data TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_sessions_provider ON sessions(provider_id); + CREATE INDEX IF NOT EXISTS idx_sessions_account ON sessions(account_id); + + CREATE TABLE IF NOT EXISTS system_prompts ( + id TEXT PRIMARY KEY, + data TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS model_overrides ( + provider_id TEXT PRIMARY KEY, + data TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS logs ( + id TEXT PRIMARY KEY, + timestamp INTEGER NOT NULL, + level TEXT NOT NULL, + message TEXT NOT NULL, + account_id TEXT, + provider_id TEXT, + request_id TEXT, + data TEXT + ); + CREATE INDEX IF NOT EXISTS idx_logs_timestamp ON logs(timestamp); + CREATE INDEX IF NOT EXISTS idx_logs_level ON logs(level); + + CREATE TABLE IF NOT EXISTS statistics ( + key TEXT PRIMARY KEY, + data TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS kv_store ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + `) + } + + private initDefaultData(): void { + // Insert default config + this.db.prepare('INSERT INTO config (key, value) VALUES (?, ?)').run('app', JSON.stringify(DEFAULT_CONFIG)) + + // Insert built-in providers + const insertProvider = this.db.prepare('INSERT OR REPLACE INTO providers (id, data) VALUES (?, ?)') + for (const provider of BUILTIN_PROVIDERS) { + insertProvider.run(provider.id, JSON.stringify(provider)) + } + + // Default statistics + this.db.prepare('INSERT INTO statistics (key, data) VALUES (?, ?)').run('main', JSON.stringify(DEFAULT_STATISTICS)) + } + + // ==================== Config ==================== + + getConfig(): AppConfig | null { + const row = this.db.prepare('SELECT value FROM config WHERE key = ?').get('app') as { value: string } | undefined + return row ? JSON.parse(row.value) : null + } + + setConfig(config: AppConfig): void { + this.db.prepare('INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)').run('app', JSON.stringify(config)) + } + + // ==================== Providers ==================== + + getProviders(): Provider[] { + const rows = this.db.prepare('SELECT data FROM providers').all() as { data: string }[] + return rows.map(row => JSON.parse(row.data)) + } + + getProviderById(id: string): Provider | undefined { + const row = this.db.prepare('SELECT data FROM providers WHERE id = ?').get(id) as { data: string } | undefined + return row ? JSON.parse(row.data) : undefined + } + + addProvider(provider: Provider): void { + this.db.prepare('INSERT OR REPLACE INTO providers (id, data) VALUES (?, ?)').run(provider.id, JSON.stringify(provider)) + } + + updateProvider(id: string, updates: Partial): Provider | null { + const existing = this.getProviderById(id) + if (!existing) return null + const updated = { ...existing, ...updates, updatedAt: Date.now() } + this.db.prepare('INSERT OR REPLACE INTO providers (id, data) VALUES (?, ?)').run(id, JSON.stringify(updated)) + return updated + } + + deleteProvider(id: string): boolean { + const result = this.db.prepare('DELETE FROM providers WHERE id = ?').run(id) + if (result.changes > 0) { + // Also delete associated accounts + this.db.prepare('DELETE FROM accounts WHERE provider_id = ?').run(id) + return true + } + return false + } + + // ==================== Accounts ==================== + + getAccounts(): Account[] { + const rows = this.db.prepare('SELECT data FROM accounts').all() as { data: string }[] + return rows.map(row => JSON.parse(row.data)) + } + + getAccountById(id: string): Account | undefined { + const row = this.db.prepare('SELECT data FROM accounts WHERE id = ?').get(id) as { data: string } | undefined + return row ? JSON.parse(row.data) : undefined + } + + getAccountsByProviderId(providerId: string): Account[] { + const rows = this.db.prepare('SELECT data FROM accounts WHERE provider_id = ?').all(providerId) as { data: string }[] + return rows.map(row => JSON.parse(row.data)) + } + + addAccount(account: Account): void { + this.db.prepare('INSERT OR REPLACE INTO accounts (id, provider_id, data) VALUES (?, ?, ?)').run( + account.id, + account.providerId, + JSON.stringify(account) + ) + } + + updateAccount(id: string, updates: Partial): Account | null { + const existing = this.getAccountById(id) + if (!existing) return null + const updated = { ...existing, ...updates, updatedAt: Date.now() } + this.db.prepare('INSERT OR REPLACE INTO accounts (id, provider_id, data) VALUES (?, ?, ?)').run( + id, + updated.providerId, + JSON.stringify(updated) + ) + return updated + } + + deleteAccount(id: string): boolean { + const result = this.db.prepare('DELETE FROM accounts WHERE id = ?').run(id) + return result.changes > 0 + } + + // ==================== Sessions ==================== + + getSessions(): SessionRecord[] { + const rows = this.db.prepare('SELECT data FROM sessions').all() as { data: string }[] + return rows.map(row => JSON.parse(row.data)) + } + + getSessionById(id: string): SessionRecord | undefined { + const row = this.db.prepare('SELECT data FROM sessions WHERE id = ?').get(id) as { data: string } | undefined + return row ? JSON.parse(row.data) : undefined + } + + getSessionsByProviderId(providerId: string): SessionRecord[] { + const rows = this.db.prepare('SELECT data FROM sessions WHERE provider_id = ?').all(providerId) as { data: string }[] + return rows.map(row => JSON.parse(row.data)) + } + + getSessionsByAccountId(accountId: string): SessionRecord[] { + const rows = this.db.prepare('SELECT data FROM sessions WHERE account_id = ?').all(accountId) as { data: string }[] + return rows.map(row => JSON.parse(row.data)) + } + + addSession(session: SessionRecord): void { + this.db.prepare('INSERT OR REPLACE INTO sessions (id, provider_id, account_id, data) VALUES (?, ?, ?, ?)').run( + session.id, + session.providerId, + session.accountId, + JSON.stringify(session) + ) + } + + updateSession(id: string, updates: Partial): SessionRecord | null { + const existing = this.getSessionById(id) + if (!existing) return null + const updated = { ...existing, ...updates } + this.db.prepare('INSERT OR REPLACE INTO sessions (id, provider_id, account_id, data) VALUES (?, ?, ?, ?)').run( + id, + updated.providerId, + updated.accountId, + JSON.stringify(updated) + ) + return updated + } + + deleteSession(id: string): boolean { + const result = this.db.prepare('DELETE FROM sessions WHERE id = ?').run(id) + return result.changes > 0 + } + + clearAllSessions(): void { + this.db.prepare('DELETE FROM sessions').run() + } + + // ==================== System Prompts ==================== + + getSystemPrompts(): SystemPrompt[] { + const rows = this.db.prepare('SELECT data FROM system_prompts').all() as { data: string }[] + const custom = rows.map(row => JSON.parse(row.data)) + // Merge with built-in prompts (always returned from memory) + return [...BUILTIN_PROMPTS, ...custom] + } + + getCustomPrompts(): SystemPrompt[] { + const rows = this.db.prepare('SELECT data FROM system_prompts').all() as { data: string }[] + return rows.map(row => JSON.parse(row.data)) + } + + addSystemPrompt(prompt: Omit): SystemPrompt { + const newPrompt: SystemPrompt = { + ...prompt, + id: this.generateId(), + isBuiltin: false, + createdAt: Date.now(), + updatedAt: Date.now(), + } + this.db.prepare('INSERT OR REPLACE INTO system_prompts (id, data) VALUES (?, ?)').run(newPrompt.id, JSON.stringify(newPrompt)) + return newPrompt + } + + updateSystemPrompt(id: string, updates: Partial): SystemPrompt | null { + const existing = this.getCustomPrompts().find(p => p.id === id) + if (!existing) return null + const updated = { ...existing, ...updates, updatedAt: Date.now() } + this.db.prepare('INSERT OR REPLACE INTO system_prompts (id, data) VALUES (?, ?)').run(id, JSON.stringify(updated)) + return updated + } + + deleteSystemPrompt(id: string): boolean { + const result = this.db.prepare('DELETE FROM system_prompts WHERE id = ?').run(id) + return result.changes > 0 + } + + // ==================== Model Overrides ==================== + + getUserModelOverrides(): UserModelOverrides { + const rows = this.db.prepare('SELECT provider_id, data FROM model_overrides').all() as { provider_id: string; data: string }[] + const overrides: UserModelOverrides = {} + for (const row of rows) { + overrides[row.provider_id] = JSON.parse(row.data) + } + return overrides + } + + setUserModelOverrides(overrides: UserModelOverrides): void { + // Clear existing + this.db.prepare('DELETE FROM model_overrides').run() + const insert = this.db.prepare('INSERT INTO model_overrides (provider_id, data) VALUES (?, ?)') + for (const [providerId, data] of Object.entries(overrides)) { + insert.run(providerId, JSON.stringify(data)) + } + } + + // ==================== Logs (control logs) ==================== + + addLog(log: LogEntry): void { + this.db.prepare( + `INSERT INTO logs (id, timestamp, level, message, account_id, provider_id, request_id, data) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + log.id, + log.timestamp, + log.level, + log.message, + log.accountId || null, + log.providerId || null, + log.requestId || null, + log.data ? JSON.stringify(log.data) : null + ) + } + + addLogsBatch(logs: LogEntry[]): void { + if (logs.length === 0) return + const insert = this.db.prepare( + `INSERT INTO logs (id, timestamp, level, message, account_id, provider_id, request_id, data) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ) + const insertMany = this.db.transaction((logs: LogEntry[]) => { + for (const log of logs) { + insert.run( + log.id, + log.timestamp, + log.level, + log.message, + log.accountId || null, + log.providerId || null, + log.requestId || null, + log.data ? JSON.stringify(log.data) : null + ) + } + }) + insertMany(logs) + } + + getLogs(limit?: number, level?: string): LogEntry[] { + let sql = 'SELECT id, timestamp, level, message, account_id, provider_id, request_id, data FROM logs' + const params: any[] = [] + if (level) { + sql += ' WHERE level = ?' + params.push(level) + } + sql += ' ORDER BY timestamp DESC' + if (limit) { + sql += ' LIMIT ?' + params.push(limit) + } + const rows = this.db.prepare(sql).all(...params) as any[] + return rows.map(row => ({ + id: row.id, + timestamp: row.timestamp, + level: row.level, + message: row.message, + accountId: row.account_id, + providerId: row.provider_id, + requestId: row.request_id, + data: row.data ? JSON.parse(row.data) : undefined, + })) + } + + clearLogs(): void { + this.db.prepare('DELETE FROM logs').run() + } + + cleanExpiredLogs(retentionDays: number): void { + const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000 + this.db.prepare('DELETE FROM logs WHERE timestamp < ?').run(cutoff) + } + + // ==================== Statistics ==================== + + getStatistics(): PersistentStatistics | null { + const row = this.db.prepare('SELECT data FROM statistics WHERE key = ?').get('main') as { data: string } | undefined + return row ? JSON.parse(row.data) : null + } + + setStatistics(stats: PersistentStatistics): void { + this.db.prepare('INSERT OR REPLACE INTO statistics (key, data) VALUES (?, ?)').run('main', JSON.stringify(stats)) + } + + // ==================== Generic Key-Value Store ==================== + + getGeneric(key: string): unknown { + const row = this.db.prepare('SELECT value FROM kv_store WHERE key = ?').get(key) as { value: string } | undefined + return row ? JSON.parse(row.value) : undefined + } + + setGeneric(key: string, value: unknown): void { + this.db.prepare('INSERT OR REPLACE INTO kv_store (key, value) VALUES (?, ?)').run(key, JSON.stringify(value)) + } + + deleteGeneric(key: string): void { + this.db.prepare('DELETE FROM kv_store WHERE key = ?').run(key) + } + + // ==================== Utility ==================== + + generateId(): string { + return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}` + } + + close(): void { + if (this.db) { + this.db.close() + } + } +} + +export const sqliteStore = new SQLiteStore() diff --git a/src/main/store/store.ts b/src/main/store/store.ts index 7dac7b9..9c06155 100644 --- a/src/main/store/store.ts +++ b/src/main/store/store.ts @@ -1,6 +1,6 @@ /** * Credential Storage Module - Core Storage Implementation - * Uses electron-store for persistent storage + * Uses SQLite for persistent storage (replaced electron-store for better performance) * Uses Electron's safeStorage API for sensitive data encryption */ @@ -36,29 +36,23 @@ import { import { BUILTIN_PROMPTS } from '../data/builtin-prompts' import { RequestLogManager } from '../requestLogs/manager' import { normalizeRequestLogConfig } from '../requestLogs/types' - -// Dynamically import electron-store (ESM module) -let Store: any = null - -/** - * Storage Instance Type Definition - */ -type StoreType = any +import { sqliteStore, SQLiteStore } from './sqlite' /** * Storage Manager Class * Responsible for data persistence and encryption + * Now backed by SQLite instead of electron-store */ class StoreManager { - private store: StoreType | null = null - private logsStore: StoreType | null = null private isInitialized: boolean = false private mainWindow: BrowserWindow | null = null private initializationError: Error | null = null private requestLogManager: RequestLogManager | null = null - private pendingLogs: LogEntry[] = [] - private logFlushTimer: NodeJS.Timeout | null = null - private readonly logFlushDelayMs = 2000 + private db: SQLiteStore + + constructor() { + this.db = sqliteStore + } setMainWindow(window: BrowserWindow | null): void { this.mainWindow = window @@ -80,259 +74,45 @@ class StoreManager { /** * Initialize Storage - * Create storage instance and initialize default data + * Initialize SQLite database and migrate from electron-store if needed */ async initialize(): Promise { if (this.isInitialized) { return } - // Dynamically import electron-store (ESM module) - if (!Store) { - const module = await import('electron-store') - Store = module.default - } - - const storagePath = this.getStoragePath() - try { - this.store = new Store({ - name: 'data', - cwd: storagePath, - defaults: this.getDefaultData(), - encryptionKey: this.getEncryptionKey(), - }) - - this.logsStore = new Store({ - name: 'logs-data', - cwd: storagePath, - defaults: { logs: [] }, - encryptionKey: this.getEncryptionKey(), - }) - - await this.migrateLogsToSeparateStore() - await this.initializeRequestLogManager(storagePath) - await this.initializeDefaultProviders() + await this.db.initialize() + // Get config directly from db to avoid ensureInitialized check + const config = this.db.getConfig() + if (!config) { + throw new Error('Failed to load configuration after database initialization') + } + await this.initializeRequestLogManager(config) this.isInitialized = true this.initializationError = null + + // Ensure credentials encryption consistency after migration + await this.ensureCredentialsEncryptionConsistency() } catch (error) { console.error('[Store] Failed to initialize storage:', error) this.initializationError = error instanceof Error ? error : new Error(String(error)) - - // Try to recover by backing up corrupted data and reinitializing - try { - await this.recoverFromCorruptedData(storagePath) - this.store = new Store({ - name: 'data', - cwd: storagePath, - defaults: this.getDefaultData(), - encryptionKey: this.getEncryptionKey(), - }) - this.logsStore = new Store({ - name: 'logs-data', - cwd: storagePath, - defaults: { logs: [] }, - encryptionKey: this.getEncryptionKey(), - }) - await this.migrateLogsToSeparateStore() - await this.initializeRequestLogManager(storagePath) - this.isInitialized = true - this.initializationError = null - console.log('[Store] Successfully recovered from corrupted data') - } catch (recoveryError) { - console.error('[Store] Failed to recover from corrupted data:', recoveryError) - throw this.initializationError - } - } - } - - private async migrateLogsToSeparateStore(): Promise { - if (!this.store || !this.logsStore) return - - const oldLogs = (this.store.get('logs') as LogEntry[]) || [] - if (oldLogs.length === 0) return - - const existingLogs = (this.logsStore.get('logs') as LogEntry[]) || [] - const merged = [...existingLogs, ...oldLogs] - this.logsStore.set('logs', merged) - this.store.set('logs', []) - console.log(`[Store] Migrated ${oldLogs.length} logs to logs-data.json`) - } - - /** - * Recover from corrupted data file - * Backup the corrupted file and create a new one - */ - private async recoverFromCorruptedData(storagePath: string): Promise { - const { renameSync, existsSync } = await import('fs') - const { join } = await import('path') - - const dataPath = join(storagePath, 'data.json') - const backupPath = join(storagePath, `data.corrupted.${Date.now()}.json`) - - if (existsSync(dataPath)) { - console.log('[Store] Backing up corrupted data file to:', backupPath) - try { - renameSync(dataPath, backupPath) - console.log('[Store] Corrupted data file backed up successfully') - } catch (backupError) { - console.error('[Store] Failed to backup corrupted data:', backupError) - throw backupError - } - } - } - - /** - * Get Storage Path - * Storage path: ~/.chat2api/ - */ - private getStoragePath(): string { - return join(homedir(), '.chat2api') - } - - /** - * Get Encryption Key - * Returns a fixed encryption key for electron-store - * Note: electron-store uses this key to encrypt/decrypt the data file, - * so it must be stable across app restarts - */ - private getEncryptionKey(): string | undefined { - try { - if (safeStorage.isEncryptionAvailable()) { - // Use a fixed key - electron-store will use this to encrypt/decrypt data - // The key itself is not stored in the data file, only used for encryption - return 'chat2api-fixed-encryption-key-v1' - } - } catch (error) { - console.warn('Encryption unavailable, using unencrypted storage:', error) - } - return undefined - } - - /** - * Get Default Data Structure - */ - private getDefaultData(): StoreSchema { - return { - providers: [], - accounts: [], - config: DEFAULT_CONFIG, - logs: [], - requestLogs: [], - systemPrompts: [], - sessions: [], - statistics: DEFAULT_STATISTICS, - userModelOverrides: DEFAULT_USER_MODEL_OVERRIDES, + throw this.initializationError } } - private async initializeRequestLogManager(storagePath: string): Promise { - const config = this.normalizeConfig(this.store?.get('config') || DEFAULT_CONFIG) + private async initializeRequestLogManager(config: AppConfig): Promise { + const storagePath = join(homedir(), '.chat2api') this.requestLogManager = new RequestLogManager({ storageDir: join(storagePath, 'request-logs'), config: config.requestLogConfig, }) await this.requestLogManager.initialize() - - const legacyRequestLogs = this.store?.get('requestLogs') || [] - if (legacyRequestLogs.length > 0) { - await this.requestLogManager.migrateLegacyLogs(legacyRequestLogs) - this.store?.set('requestLogs', []) - } - } - - private normalizeConfig(config: AppConfig): AppConfig { - return { - ...DEFAULT_CONFIG, - ...config, - requestLogConfig: normalizeRequestLogConfig( - config.requestLogConfig || DEFAULT_REQUEST_LOG_CONFIG, - ), - } - } - - /** - * Initialize Default Providers - * Clear provider list, users create providers by adding accounts - */ - private async initializeDefaultProviders(): Promise { - const providers = this.store?.get('providers') || [] - const builtinIds = BUILTIN_PROVIDERS.map(p => p.id) - - const validProviders = providers.filter((p: Provider) => { - if (p.type === 'builtin') { - return builtinIds.includes(p.id) - } - return true - }) - - const userModelOverrides = this.store?.get('userModelOverrides') || {} - - const updatedProviders = validProviders.map((p: Provider) => { - if (p.type === 'builtin') { - const builtinConfig = BUILTIN_PROVIDERS.find(bp => bp.id === p.id) - if (builtinConfig) { - const hasUserOverrides = userModelOverrides[p.id] && - ((userModelOverrides[p.id].addedModels && userModelOverrides[p.id].addedModels.length > 0) || - (userModelOverrides[p.id].excludedModels && userModelOverrides[p.id].excludedModels.length > 0)) - - return { - ...p, - apiEndpoint: builtinConfig.apiEndpoint, - chatPath: builtinConfig.chatPath, - supportedModels: hasUserOverrides ? p.supportedModels : builtinConfig.supportedModels, - modelMappings: hasUserOverrides ? p.modelMappings : builtinConfig.modelMappings, - headers: builtinConfig.headers, - description: builtinConfig.description, - } - } - } - return p - }) - - this.store?.set('providers', updatedProviders) - } - - /** - * Ensure provider exists, create if not - */ - ensureProviderExists(providerId: string): void { - this.ensureInitialized() - const providers = this.store!.get('providers') || [] - const exists = providers.some((p: Provider) => p.id === providerId) - - if (!exists) { - const builtinConfig = BUILTIN_PROVIDERS.find(bp => bp.id === providerId) - if (builtinConfig) { - const now = Date.now() - const newProvider: Provider = { - id: builtinConfig.id, - name: builtinConfig.name, - type: 'builtin', - authType: builtinConfig.authType, - apiEndpoint: builtinConfig.apiEndpoint, - chatPath: builtinConfig.chatPath, - headers: builtinConfig.headers, - enabled: true, - createdAt: now, - updatedAt: now, - description: builtinConfig.description, - supportedModels: builtinConfig.supportedModels, - modelMappings: builtinConfig.modelMappings, - } - providers.push(newProvider) - this.store!.set('providers', providers) - console.log('[Store] Created missing provider:', providerId) - } - } } - /** - * Ensure Storage is Initialized - */ private ensureInitialized(): void { - if (!this.isInitialized || !this.store) { - const errorMsg = this.initializationError + if (!this.isInitialized) { + const errorMsg = this.initializationError ? `Storage initialization failed: ${this.initializationError.message}` : 'Storage not initialized, please call initialize() first' throw new Error(errorMsg) @@ -355,45 +135,8 @@ class StoreManager { } private shouldRecordLog(level: LogLevel): boolean { - const config = this.normalizeConfig(this.store!.get('config') || DEFAULT_CONFIG) - return this.getLogPriority(level) >= this.getLogPriority(config.logLevel) - } - - private scheduleLogFlush(): void { - if (this.logFlushTimer) { - clearTimeout(this.logFlushTimer) - } - - this.logFlushTimer = setTimeout(() => { - this.logFlushTimer = null - this.flushLogsSync() - }, this.logFlushDelayMs) - } - - private getCombinedLogs(): LogEntry[] { - if (!this.logsStore) return [...this.pendingLogs] - const persistedLogs = (this.logsStore.get('logs') as LogEntry[]) || [] - return persistedLogs.concat(this.pendingLogs) - } - - flushPendingWrites(): void { - this.flushLogsSync() - this.requestLogManager?.flushSync() - } - - private flushLogsSync(): void { - if (!this.isInitialized || !this.logsStore || this.pendingLogs.length === 0) { - return - } - - const logs = ((this.logsStore.get('logs') as LogEntry[]) || []).concat(this.pendingLogs) const config = this.getConfig() - const maxLogs = config.logRetentionDays * 1000 - - const trimmedLogs = logs.length > maxLogs ? logs.slice(-maxLogs) : logs - - this.logsStore.set('logs', trimmedLogs) - this.pendingLogs = [] + return this.getLogPriority(level) >= this.getLogPriority(config.logLevel) } /** @@ -403,22 +146,12 @@ class StoreManager { */ encryptData(data: string): string { try { - console.log('[Store] encryptData input length:', data.length, 'content:', data.substring(0, 20) + '...') if (safeStorage.isEncryptionAvailable()) { if (!this.getConfig().credentialEncryption) { - console.log('[Store] Credential encryption disabled, returning plaintext') return data } - // Create new Buffer to store encryption result const encrypted = Buffer.from(safeStorage.encryptString(data)) - const result = encrypted.toString('base64') - console.log('[Store] encryptData output length:', result.length, 'content:', result.substring(0, 20) + '...') - // Verify encryption is correct - const decrypted = safeStorage.decryptString(encrypted) - console.log('[Store] encryptData verify decryption:', decrypted.substring(0, 20) + '...', 'match:', decrypted === data) - return result - } else { - console.log('[Store] Encryption unavailable, returning original data') + return encrypted.toString('base64') } } catch (error) { console.error('Failed to encrypt data:', error) @@ -432,15 +165,27 @@ class StoreManager { * @returns Decrypted string */ decryptData(encryptedData: string): string { + // If global credential encryption is disabled, return as plaintext directly + const config = this.getConfig() + if (!config.credentialEncryption || !safeStorage.isEncryptionAvailable()) { + return encryptedData + } + + // If not a base64 string, assume it's already plaintext + if (!encryptedData || !/^[A-Za-z0-9+/]*={0,2}$/.test(encryptedData)) { + return encryptedData + } + try { - if (safeStorage.isEncryptionAvailable()) { - const buffer = Buffer.from(encryptedData, 'base64') - return safeStorage.decryptString(buffer) - } + const buffer = Buffer.from(encryptedData, 'base64') + return safeStorage.decryptString(buffer) } catch (error) { - console.error('Failed to decrypt data:', error) + // Only log if it looks like it should have been encrypted + if (encryptedData.length > 20) { + console.warn('[Store] Failed to decrypt data, returning as plaintext:', error instanceof Error ? error.message : String(error)) + } + return encryptedData } - return encryptedData } /** @@ -450,11 +195,9 @@ class StoreManager { */ encryptCredentials(credentials: Record): Record { const encrypted: Record = {} - for (const [key, value] of Object.entries(credentials)) { encrypted[key] = this.encryptData(value) } - return encrypted } @@ -465,294 +208,153 @@ class StoreManager { */ decryptCredentials(encryptedCredentials: Record): Record { const decrypted: Record = {} - for (const [key, value] of Object.entries(encryptedCredentials)) { decrypted[key] = this.decryptData(value) } - return decrypted } // ==================== Provider Operations ==================== - /** - * Get All Providers - */ getProviders(): Provider[] { this.ensureInitialized() - return this.store!.get('providers') || [] + return this.db.getProviders() } - /** - * Get Provider By ID - */ getProviderById(id: string): Provider | undefined { this.ensureInitialized() - const providers = this.store!.get('providers') as Provider[] || [] - return providers.find((p: Provider) => p.id === id) + return this.db.getProviderById(id) } - /** - * Add Provider - */ addProvider(provider: Provider): void { this.ensureInitialized() - const providers = this.store!.get('providers') as Provider[] || [] - providers.push(provider) - this.store!.set('providers', providers) + this.db.addProvider(provider) } - /** - * Update Provider - */ updateProvider(id: string, updates: Partial): Provider | null { this.ensureInitialized() - const providers = this.store!.get('providers') as Provider[] || [] - const index = providers.findIndex((p: Provider) => p.id === id) - - if (index === -1) { - return null - } - - providers[index] = { - ...providers[index], - ...updates, - updatedAt: Date.now(), - } - - this.store!.set('providers', providers) - return providers[index] + return this.db.updateProvider(id, updates) } - /** - * Delete Provider - */ deleteProvider(id: string): boolean { this.ensureInitialized() - const providers = this.store!.get('providers') as Provider[] || [] - const index = providers.findIndex((p: Provider) => p.id === id) - - if (index === -1) { - return false - } - - providers.splice(index, 1) - this.store!.set('providers', providers) - - const accounts = this.store!.get('accounts') as Account[] || [] - const filteredAccounts = accounts.filter((a: Account) => a.providerId !== id) - this.store!.set('accounts', filteredAccounts) - - return true + return this.db.deleteProvider(id) } // ==================== Model Overrides Operations ==================== - /** - * Get Model Overrides for a Provider - * Returns user customizations to built-in provider models - */ getModelOverrides(providerId: string): ProviderModelOverrides | undefined { this.ensureInitialized() - const userModelOverrides = this.store!.get('userModelOverrides') || DEFAULT_USER_MODEL_OVERRIDES - return userModelOverrides[providerId] + const overrides = this.db.getUserModelOverrides() + return overrides[providerId] } - /** - * Check if Provider has Model Overrides - * Returns true if provider has user-added models or excluded models - */ hasModelOverrides(providerId: string): boolean { const overrides = this.getModelOverrides(providerId) - if (!overrides) return false - - return ( - (overrides.addedModels && overrides.addedModels.length > 0) || - (overrides.excludedModels && overrides.excludedModels.length > 0) - ) + return !!(overrides && (overrides.addedModels?.length || overrides.excludedModels?.length)) } // ==================== Account Operations ==================== - /** - * Get All Accounts - * @param includeCredentials Whether to include decrypted credentials - */ getAccounts(includeCredentials: boolean = false): Account[] { this.ensureInitialized() - const accounts = this.store!.get('accounts') as Account[] || [] - + const accounts = this.db.getAccounts() if (includeCredentials) { - return accounts.map((account: Account) => ({ + return accounts.map(account => ({ ...account, credentials: this.decryptCredentials(account.credentials), })) } - return accounts } - /** - * Get Account By ID - * @param includeCredentials Whether to include decrypted credentials - */ getAccountById(id: string, includeCredentials: boolean = false): Account | undefined { this.ensureInitialized() - const accounts = this.store!.get('accounts') as Account[] || [] - const account = accounts.find((a: Account) => a.id === id) - + const account = this.db.getAccountById(id) if (account && includeCredentials) { return { ...account, credentials: this.decryptCredentials(account.credentials), } } - return account } - /** - * Get Accounts By Provider ID - */ getAccountsByProviderId(providerId: string, includeCredentials: boolean = false): Account[] { this.ensureInitialized() - const accounts = this.store!.get('accounts') as Account[] || [] - const filtered = accounts.filter((a: Account) => a.providerId === providerId) - + const accounts = this.db.getAccountsByProviderId(providerId) if (includeCredentials) { - return filtered.map((account: Account) => ({ + return accounts.map(account => ({ ...account, credentials: this.decryptCredentials(account.credentials), })) } - - return filtered + return accounts } - /** - * Add Account - * Credentials are automatically encrypted before storage - */ addAccount(account: Account): void { this.ensureInitialized() - const accounts = this.store!.get('accounts') || [] - const encryptedAccount: Account = { ...account, credentials: this.encryptCredentials(account.credentials), } - - accounts.push(encryptedAccount) - this.store!.set('accounts', accounts) + this.db.addAccount(encryptedAccount) } - /** - * Update Account - */ updateAccount(id: string, updates: Partial): Account | null { this.ensureInitialized() - const accounts = this.store!.get('accounts') as Account[] || [] - const index = accounts.findIndex((a: Account) => a.id === id) - - if (index === -1) { - return null - } - - console.log('[Store] Update account:', { - id, - updatesCredentials: updates.credentials, - oldCredentials: accounts[index].credentials, - oldCredentialsDecrypted: this.decryptCredentials(accounts[index].credentials), - }) - - const updatedAccount: Account = { - ...accounts[index], - ...updates, - updatedAt: Date.now(), - } - + const existing = this.db.getAccountById(id) + if (!existing) return null + + let encryptedUpdates = { ...updates } if (updates.credentials) { - updatedAccount.credentials = this.encryptCredentials(updates.credentials) - console.log('[Store] Encrypted credentials:', updatedAccount.credentials) - console.log('[Store] Old credentials:', accounts[index].credentials) - console.log('[Store] Credentials match:', JSON.stringify(updatedAccount.credentials) === JSON.stringify(accounts[index].credentials)) + encryptedUpdates.credentials = this.encryptCredentials(updates.credentials) } - - accounts[index] = updatedAccount - this.store!.set('accounts', accounts) - - // Verify save was successful - const savedAccounts = this.store!.get('accounts') as Account[] - const savedAccount = savedAccounts.find(a => a.id === id) - console.log('[Store] Verify after save:', { - id, - savedCredentials: savedAccount?.credentials, - }) - + + const updated = { ...existing, ...encryptedUpdates, updatedAt: Date.now() } + this.db.updateAccount(id, updated) return { - ...updatedAccount, - credentials: updates.credentials || this.decryptCredentials(accounts[index].credentials), + ...updated, + credentials: updates.credentials || this.decryptCredentials(existing.credentials), } } - /** - * Delete Account - */ deleteAccount(id: string): boolean { this.ensureInitialized() - const accounts = this.store!.get('accounts') as Account[] || [] - const index = accounts.findIndex((a: Account) => a.id === id) - - if (index === -1) { - return false - } - - accounts.splice(index, 1) - this.store!.set('accounts', accounts) - return true + return this.db.deleteAccount(id) } - /** - * Get Active Accounts - */ getActiveAccounts(includeCredentials: boolean = false): Account[] { this.ensureInitialized() - const accounts = this.store!.get('accounts') as Account[] || [] - const active = accounts.filter((a: Account) => a.status === 'active') - + const accounts = this.db.getAccounts() + const active = accounts.filter(a => a.status === 'active') if (includeCredentials) { - return active.map((account: Account) => ({ + return active.map(account => ({ ...account, credentials: this.decryptCredentials(account.credentials), })) } - return active } // ==================== Configuration Operations ==================== - /** - * Get Application Configuration - */ getConfig(): AppConfig { this.ensureInitialized() - return this.normalizeConfig(this.store!.get('config') || DEFAULT_CONFIG) + const config = this.db.getConfig() + if (!config) { + // This should not happen as db initializes default config + return DEFAULT_CONFIG + } + return config } - /** - * Set Application Configuration - */ setConfig(config: AppConfig): void { this.ensureInitialized() - const normalized = this.normalizeConfig(config) - this.store!.set('config', normalized) - this.requestLogManager?.setConfig(normalized.requestLogConfig) + this.db.setConfig(config) + this.requestLogManager?.setConfig(config.requestLogConfig) } - /** - * Update Application Configuration - */ updateConfig(updates: Partial): AppConfig { this.ensureInitialized() const currentConfig = this.getConfig() @@ -760,15 +362,14 @@ class StoreManager { ...currentConfig, ...updates, } - - // Deep merge for nested objects + if (updates.toolPromptConfig && currentConfig.toolPromptConfig) { newConfig.toolPromptConfig = { ...currentConfig.toolPromptConfig, ...updates.toolPromptConfig, } } - + if (updates.sessionConfig && currentConfig.sessionConfig) { newConfig.sessionConfig = { ...currentConfig.sessionConfig, @@ -783,27 +384,29 @@ class StoreManager { }) } - const normalized = this.normalizeConfig(newConfig) - this.store!.set('config', normalized) - this.requestLogManager?.setConfig(normalized.requestLogConfig) - return normalized + this.db.setConfig(newConfig) + this.requestLogManager?.setConfig(newConfig.requestLogConfig) + + // If credential encryption setting changed, re-normalize credentials + if (updates.credentialEncryption !== undefined && updates.credentialEncryption !== currentConfig.credentialEncryption) { + // Run asynchronously without awaiting to avoid blocking the config update + this.ensureCredentialsEncryptionConsistency().catch(err => { + console.error('[Store] Failed to re-normalize credentials after encryption setting change:', err) + }) + } + + return newConfig } - /** - * Reset Configuration to Default Values - */ resetConfig(): AppConfig { this.ensureInitialized() - this.store!.set('config', DEFAULT_CONFIG) + this.db.setConfig(DEFAULT_CONFIG) this.requestLogManager?.setConfig(DEFAULT_CONFIG.requestLogConfig) return DEFAULT_CONFIG } // ==================== Log Operations ==================== - /** - * Add Log Entry - */ addLog( level: LogLevel, message: string, @@ -832,74 +435,35 @@ class StoreManager { return entry } - this.pendingLogs.push(entry) - - const config = this.getConfig() - const maxLogs = config.logRetentionDays * 1000 - if (this.pendingLogs.length > maxLogs) { - this.pendingLogs = this.pendingLogs.slice(-maxLogs) - } - - this.scheduleLogFlush() - + this.db.addLog(entry) return entry } - /** - * Get Logs - * @param limit Limit count - * @param level Log level filter - */ getLogs(limit?: number, level?: LogLevel): LogEntry[] { this.ensureInitialized() - let logs = this.getCombinedLogs() - - if (level) { - logs = logs.filter((l: LogEntry) => l.level === level) - } - - if (limit && logs.length > limit) { - logs = logs.slice(-limit) - } - - return logs + return this.db.getLogs(limit, level) } - /** - * Clear Logs - */ clearLogs(): void { this.ensureInitialized() - if (this.logFlushTimer) { - clearTimeout(this.logFlushTimer) - this.logFlushTimer = null - } - this.pendingLogs = [] - this.logsStore?.set('logs', []) + this.db.clearLogs() } - /** - * Get Log Statistics - */ getLogStats(): { total: number; info: number; warn: number; error: number; debug: number } { this.ensureInitialized() - const logs = this.getCombinedLogs() - + const logs = this.db.getLogs(undefined, undefined) return { total: logs.length, - info: logs.filter((l: LogEntry) => l.level === 'info').length, - warn: logs.filter((l: LogEntry) => l.level === 'warn').length, - error: logs.filter((l: LogEntry) => l.level === 'error').length, - debug: logs.filter((l: LogEntry) => l.level === 'debug').length, + info: logs.filter(l => l.level === 'info').length, + warn: logs.filter(l => l.level === 'warn').length, + error: logs.filter(l => l.level === 'error').length, + debug: logs.filter(l => l.level === 'debug').length, } } - /** - * Get Log Trend - */ getLogTrend(days: number = 7): { date: string; total: number; info: number; warn: number; error: number }[] { this.ensureInitialized() - const logs = this.getCombinedLogs() + const logs = this.db.getLogs(undefined, undefined) const now = Date.now() const dayMs = 24 * 60 * 60 * 1000 const trends: { date: string; total: number; info: number; warn: number; error: number }[] = [] @@ -909,30 +473,24 @@ class StoreManager { const dayEnd = now - i * dayMs const date = new Date(dayStart).toISOString().split('T')[0] - const dayLogs = logs.filter( - (l: LogEntry) => l.timestamp >= dayStart && l.timestamp < dayEnd - ) + const dayLogs = logs.filter(l => l.timestamp >= dayStart && l.timestamp < dayEnd) trends.push({ date, total: dayLogs.length, - info: dayLogs.filter((l: LogEntry) => l.level === 'info').length, - warn: dayLogs.filter((l: LogEntry) => l.level === 'warn').length, - error: dayLogs.filter((l: LogEntry) => l.level === 'error').length, + info: dayLogs.filter(l => l.level === 'info').length, + warn: dayLogs.filter(l => l.level === 'warn').length, + error: dayLogs.filter(l => l.level === 'error').length, }) } return trends } - /** - * Get Log Trend for specific account - * Only counts successful API requests (logs with requestId) to match requestCount - */ getAccountLogTrend(accountId: string, days: number = 7): { date: string; total: number; info: number; warn: number; error: number }[] { this.ensureInitialized() - const logs = this.getCombinedLogs() - const accountLogs = logs.filter((l: LogEntry) => l.accountId === accountId && l.requestId) + const logs = this.db.getLogs(undefined, undefined) + const accountLogs = logs.filter(l => l.accountId === accountId && l.requestId) const now = Date.now() const dayMs = 24 * 60 * 60 * 1000 const trends: { date: string; total: number; info: number; warn: number; error: number }[] = [] @@ -942,139 +500,88 @@ class StoreManager { const dayEnd = now - i * dayMs const date = new Date(dayStart).toISOString().split('T')[0] - const dayLogs = accountLogs.filter( - (l: LogEntry) => l.timestamp >= dayStart && l.timestamp < dayEnd - ) - - const infoCount = dayLogs.filter((l: LogEntry) => l.level === 'info').length - const warnCount = dayLogs.filter((l: LogEntry) => l.level === 'warn').length - const errorCount = dayLogs.filter((l: LogEntry) => l.level === 'error').length + const dayLogs = accountLogs.filter(l => l.timestamp >= dayStart && l.timestamp < dayEnd) trends.push({ date, - total: infoCount, - info: infoCount, - warn: warnCount, - error: errorCount, + total: dayLogs.length, + info: dayLogs.filter(l => l.level === 'info').length, + warn: dayLogs.filter(l => l.level === 'warn').length, + error: dayLogs.filter(l => l.level === 'error').length, }) } return trends } - /** - * Export Logs - */ exportLogs(format: 'json' | 'txt' = 'json'): string { this.ensureInitialized() - const logs = this.getCombinedLogs() + const logs = this.db.getLogs(undefined, undefined) if (format === 'json') { return JSON.stringify(logs, null, 2) } return logs - .map((log: LogEntry) => { + .map(log => { const time = new Date(log.timestamp).toISOString() const level = log.level.toUpperCase().padEnd(5) let line = `[${time}] [${level}] ${log.message}` - - if (log.providerId) { - line += ` | Provider: ${log.providerId}` - } - if (log.accountId) { - line += ` | Account: ${log.accountId}` - } - if (log.requestId) { - line += ` | Request: ${log.requestId}` - } - if (log.data) { - line += ` | Data: ${JSON.stringify(log.data)}` - } - + if (log.providerId) line += ` | Provider: ${log.providerId}` + if (log.accountId) line += ` | Account: ${log.accountId}` + if (log.requestId) line += ` | Request: ${log.requestId}` + if (log.data) line += ` | Data: ${JSON.stringify(log.data)}` return line }) .join('\n') } - /** - * Get Log By ID - */ getLogById(id: string): LogEntry | undefined { this.ensureInitialized() - const logs = this.getCombinedLogs() - return logs.find((l: LogEntry) => l.id === id) + const logs = this.db.getLogs(undefined, undefined) + return logs.find(l => l.id === id) } - /** - * Clear Expired Logs - */ cleanExpiredLogs(): void { this.ensureInitialized() const config = this.getConfig() - const logs = this.getCombinedLogs() - const cutoff = Date.now() - config.logRetentionDays * 24 * 60 * 60 * 1000 - - const filtered = logs.filter((l: LogEntry) => l.timestamp >= cutoff) - this.pendingLogs = [] - this.logsStore?.set('logs', filtered) + this.db.cleanExpiredLogs(config.logRetentionDays) } // ==================== Request Log Operations ==================== - /** - * Add Request Log Entry - */ addRequestLog(entry: Omit): RequestLogEntry { this.ensureInitialized() - const newEntry = this.getRequestLogManager().addRequestLog(entry) - return newEntry + return this.getRequestLogManager().addRequestLog(entry) } - /** - * Update Request Log Entry - */ updateRequestLog(id: string, updates: Partial): boolean { this.ensureInitialized() return this.getRequestLogManager().updateRequestLog(id, updates) } - /** - * Get Request Logs - */ getRequestLogs(limit?: number, filter?: { status?: 'success' | 'error'; providerId?: string }): RequestLogEntry[] { this.ensureInitialized() return this.getRequestLogManager().getRequestLogs(limit, filter) } - /** - * Get Request Log By ID - */ getRequestLogById(id: string): RequestLogEntry | undefined { this.ensureInitialized() return this.getRequestLogManager().getRequestLogById(id) } - /** - * Clear Request Logs - */ clearRequestLogs(): void { this.ensureInitialized() this.getRequestLogManager().clearRequestLogs() - this.store!.set('statistics', DEFAULT_STATISTICS) + // Reset statistics as well + this.db.setStatistics(DEFAULT_STATISTICS) } - /** - * Get Request Log Statistics - */ getRequestLogStats(): { total: number; success: number; error: number; todayTotal: number; todaySuccess: number; todayError: number } { this.ensureInitialized() return this.getRequestLogManager().getRequestLogStats() } - /** - * Get Request Log Trend - */ getRequestLogTrend(days: number = 7): { date: string; total: number; success: number; error: number; avgLatency: number }[] { this.ensureInitialized() return this.getRequestLogManager().getRequestLogTrend(days) @@ -1082,32 +589,24 @@ class StoreManager { // ==================== Statistics Operations ==================== - /** - * Get Persistent Statistics - */ getStatistics(): PersistentStatistics { this.ensureInitialized() - return this.store!.get('statistics') || DEFAULT_STATISTICS + const stats = this.db.getStatistics() + return stats || DEFAULT_STATISTICS } - /** - * Update Statistics - */ updateStatistics(updates: Partial): PersistentStatistics { this.ensureInitialized() - const currentStats = this.store!.get('statistics') || DEFAULT_STATISTICS + const currentStats = this.getStatistics() const newStats = { ...currentStats, ...updates, lastUpdated: Date.now(), } - this.store!.set('statistics', newStats) + this.db.setStatistics(newStats) return newStats } - /** - * Record Request in Statistics - */ recordRequestInStats( success: boolean, latency: number, @@ -1116,9 +615,9 @@ class StoreManager { accountId?: string ): PersistentStatistics { this.ensureInitialized() - const stats = this.store!.get('statistics') || DEFAULT_STATISTICS + const stats = this.getStatistics() const today = new Date().toISOString().split('T')[0] - + const newStats: PersistentStatistics = { ...stats, totalRequests: stats.totalRequests + 1, @@ -1131,19 +630,17 @@ class StoreManager { accountUsage: { ...stats.accountUsage }, dailyStats: { ...stats.dailyStats }, } - + if (model) { newStats.modelUsage[model] = (newStats.modelUsage[model] || 0) + 1 } - if (providerId) { newStats.providerUsage[providerId] = (newStats.providerUsage[providerId] || 0) + 1 } - if (accountId) { newStats.accountUsage[accountId] = (newStats.accountUsage[accountId] || 0) + 1 } - + if (!newStats.dailyStats[today]) { newStats.dailyStats[today] = { date: today, @@ -1155,7 +652,7 @@ class StoreManager { providerUsage: {}, } } - + newStats.dailyStats[today].totalRequests++ if (success) { newStats.dailyStats[today].successRequests++ @@ -1163,25 +660,21 @@ class StoreManager { } else { newStats.dailyStats[today].failedRequests++ } - + if (model) { newStats.dailyStats[today].modelUsage[model] = (newStats.dailyStats[today].modelUsage[model] || 0) + 1 } - if (providerId) { newStats.dailyStats[today].providerUsage[providerId] = (newStats.dailyStats[today].providerUsage[providerId] || 0) + 1 } - - this.store!.set('statistics', newStats) + + this.db.setStatistics(newStats) return newStats } - /** - * Get Today Statistics - */ getTodayStatistics(): DailyStatistics { this.ensureInitialized() - const stats = this.store!.get('statistics') || DEFAULT_STATISTICS + const stats = this.getStatistics() const today = new Date().toISOString().split('T')[0] return stats.dailyStats[today] || { date: today, @@ -1194,319 +687,159 @@ class StoreManager { } } - /** - * Clean Old Daily Statistics (older than 30 days) - */ cleanOldDailyStats(): void { this.ensureInitialized() - const stats = this.store!.get('statistics') || DEFAULT_STATISTICS + const stats = this.getStatistics() const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000 const cutoffDate = new Date(cutoff).toISOString().split('T')[0] - + const filteredDailyStats: Record = {} for (const [date, dayStats] of Object.entries(stats.dailyStats)) { if (date >= cutoffDate) { filteredDailyStats[date] = dayStats as DailyStatistics } } - + if (Object.keys(filteredDailyStats).length !== Object.keys(stats.dailyStats).length) { stats.dailyStats = filteredDailyStats - this.store!.set('statistics', stats) + this.db.setStatistics(stats) } } // ==================== System Prompts Operations ==================== - /** - * Get All System Prompts - * Merges built-in prompts with custom prompts - */ getSystemPrompts(): SystemPrompt[] { this.ensureInitialized() - const customPrompts = this.store!.get('systemPrompts') || [] - return [...BUILTIN_PROMPTS, ...customPrompts] + return this.db.getSystemPrompts() } - /** - * Get Built-in System Prompts - */ getBuiltinPrompts(): SystemPrompt[] { return BUILTIN_PROMPTS } - /** - * Get Custom System Prompts - */ getCustomPrompts(): SystemPrompt[] { this.ensureInitialized() - return this.store!.get('systemPrompts') || [] + return this.db.getCustomPrompts() } - /** - * Get System Prompt By ID - */ getSystemPromptById(id: string): SystemPrompt | undefined { return this.getSystemPrompts().find(p => p.id === id) } - /** - * Add Custom System Prompt - */ addSystemPrompt(prompt: Omit): SystemPrompt { this.ensureInitialized() - const prompts = this.store!.get('systemPrompts') || [] - - const newPrompt: SystemPrompt = { - ...prompt, - id: this.generateId(), - isBuiltin: false, - createdAt: Date.now(), - updatedAt: Date.now(), - } - - prompts.push(newPrompt) - this.store!.set('systemPrompts', prompts) - - return newPrompt + return this.db.addSystemPrompt(prompt) } - /** - * Update Custom System Prompt - * Cannot update built-in prompts - */ updateSystemPrompt(id: string, updates: Partial): SystemPrompt | null { this.ensureInitialized() - - // Check if it's a built-in prompt - if (BUILTIN_PROMPTS.some(p => p.id === id)) { - console.warn('Cannot update built-in prompt:', id) - return null - } - - const prompts = this.store!.get('systemPrompts') || [] - const index = prompts.findIndex((p: SystemPrompt) => p.id === id) - - if (index === -1) { - return null - } - - prompts[index] = { - ...prompts[index], - ...updates, - updatedAt: Date.now(), - } - - this.store!.set('systemPrompts', prompts) - return prompts[index] + return this.db.updateSystemPrompt(id, updates) } - /** - * Delete Custom System Prompt - * Cannot delete built-in prompts - */ deleteSystemPrompt(id: string): boolean { this.ensureInitialized() - - // Check if it's a built-in prompt - if (BUILTIN_PROMPTS.some(p => p.id === id)) { - console.warn('Cannot delete built-in prompt:', id) - return false - } - - const prompts = this.store!.get('systemPrompts') || [] - const index = prompts.findIndex((p: SystemPrompt) => p.id === id) - - if (index === -1) { - return false - } - - prompts.splice(index, 1) - this.store!.set('systemPrompts', prompts) - - return true + return this.db.deleteSystemPrompt(id) } - /** - * Get System Prompts By Type - */ getSystemPromptsByType(type: SystemPrompt['type']): SystemPrompt[] { return this.getSystemPrompts().filter(p => p.type === type) } // ==================== Session Operations ==================== - /** - * Get Session Configuration - */ getSessionConfig(): SessionConfig { this.ensureInitialized() - const config = this.store!.get('config') || DEFAULT_CONFIG + const config = this.getConfig() return config.sessionConfig || DEFAULT_SESSION_CONFIG } - /** - * Update Session Configuration - */ updateSessionConfig(updates: Partial): SessionConfig { this.ensureInitialized() - const currentConfig = this.store!.get('config') || DEFAULT_CONFIG + const currentConfig = this.getConfig() const newSessionConfig = { ...(currentConfig.sessionConfig || DEFAULT_SESSION_CONFIG), ...updates, } - const newConfig = { - ...currentConfig, - sessionConfig: newSessionConfig, - } - this.store!.set('config', newConfig) + this.updateConfig({ sessionConfig: newSessionConfig }) return newSessionConfig } - /** - * Get All Sessions - */ getSessions(): SessionRecord[] { this.ensureInitialized() - return this.store!.get('sessions') || [] + return this.db.getSessions() } - /** - * Get Session By ID - */ getSessionById(id: string): SessionRecord | undefined { this.ensureInitialized() - const sessions = this.store!.get('sessions') || [] - return sessions.find((s: SessionRecord) => s.id === id) + return this.db.getSessionById(id) } - /** - * Get Active Sessions - */ getActiveSessions(): SessionRecord[] { this.ensureInitialized() - const sessions = this.store!.get('sessions') || [] const config = this.getSessionConfig() const timeoutMs = config.sessionTimeout * 60 * 1000 const now = Date.now() - - return sessions.filter((s: SessionRecord) => - s.status === 'active' && - (now - s.lastActiveAt) < timeoutMs - ) + const sessions = this.db.getSessions() + return sessions.filter(s => s.status === 'active' && (now - s.lastActiveAt) < timeoutMs) } - /** - * Add Session - */ addSession(session: SessionRecord): void { this.ensureInitialized() - const sessions = this.store!.get('sessions') || [] - sessions.push(session) - this.store!.set('sessions', sessions) + this.db.addSession(session) } - /** - * Update Session - */ updateSession(id: string, updates: Partial): SessionRecord | null { this.ensureInitialized() - const sessions = this.store!.get('sessions') || [] - const index = sessions.findIndex((s: SessionRecord) => s.id === id) - - if (index === -1) { - return null - } - - sessions[index] = { - ...sessions[index], - ...updates, - } - - this.store!.set('sessions', sessions) - return sessions[index] + return this.db.updateSession(id, updates) } - /** - * Add Message to Session - */ addMessageToSession(sessionId: string, message: ChatMessage): SessionRecord | null { this.ensureInitialized() - const sessions = this.store!.get('sessions') || [] - const index = sessions.findIndex((s: SessionRecord) => s.id === sessionId) - - if (index === -1) { - return null - } - + const session = this.db.getSessionById(sessionId) + if (!session) return null + const config = this.getSessionConfig() - const session = sessions[index] - - if (session.messages.length >= config.maxMessagesPerSession) { - session.messages = session.messages.slice(-config.maxMessagesPerSession + 1) + let messages = session.messages + if (messages.length >= config.maxMessagesPerSession) { + messages = messages.slice(-config.maxMessagesPerSession + 1) } - - session.messages.push(message) - session.lastActiveAt = Date.now() - - sessions[index] = session - this.store!.set('sessions', sessions) - return session + messages.push(message) + + const updated = { ...session, messages, lastActiveAt: Date.now() } + this.db.updateSession(sessionId, updated) + return updated } - /** - * Delete Session - */ deleteSession(id: string): boolean { this.ensureInitialized() - const sessions = this.store!.get('sessions') || [] - const index = sessions.findIndex((s: SessionRecord) => s.id === id) - - if (index === -1) { - return false - } - - sessions.splice(index, 1) - this.store!.set('sessions', sessions) - return true + return this.db.deleteSession(id) } - /** - * Mark Session as Expired - */ expireSession(id: string): SessionRecord | null { return this.updateSession(id, { status: 'expired' }) } - /** - * Clean Expired Sessions - * Always delete sessions with 'expired' status - * For timed-out active sessions, behavior depends on deleteAfterTimeout config: - * - If true: Delete them from storage - * - If false: Mark them as 'expired' (will be deleted on next clean) - */ cleanExpiredSessions(): number { this.ensureInitialized() - const sessions = this.store!.get('sessions') || [] + const sessions = this.db.getSessions() const config = this.getSessionConfig() const timeoutMs = config.sessionTimeout * 60 * 1000 const now = Date.now() - + let removedCount = 0 - - // Always delete sessions that are already expired - let remainingSessions = sessions.filter((s: SessionRecord) => { + + // Delete sessions with expired status directly + let remaining = sessions.filter(s => { if (s.status === 'expired') { removedCount++ return false } return true }) - - // Handle timed-out active sessions based on config + + // Handle timed-out active sessions if (config.deleteAfterTimeout) { - // Delete timed-out sessions from storage - remainingSessions = remainingSessions.filter((s: SessionRecord) => { + remaining = remaining.filter(s => { if (s.status === 'active' && (now - s.lastActiveAt) >= timeoutMs) { removedCount++ return false @@ -1514,8 +847,7 @@ class StoreManager { return true }) } else { - // Mark timed-out sessions as expired (will be deleted on next clean) - remainingSessions = remainingSessions.map((s: SessionRecord) => { + remaining = remaining.map(s => { if (s.status === 'active' && (now - s.lastActiveAt) >= timeoutMs) { removedCount++ return { ...s, status: 'expired' as const } @@ -1523,78 +855,52 @@ class StoreManager { return s }) } - - this.store!.set('sessions', remainingSessions) - + + // Clear and re-insert + this.db.clearAllSessions() + for (const s of remaining) { + this.db.addSession(s) + } + return removedCount } - /** - * Get Sessions By Account ID - */ getSessionsByAccountId(accountId: string): SessionRecord[] { this.ensureInitialized() - const sessions = this.store!.get('sessions') || [] - return sessions.filter((s: SessionRecord) => s.accountId === accountId) + return this.db.getSessionsByAccountId(accountId) } - /** - * Get Sessions By Provider ID - */ getSessionsByProviderId(providerId: string): SessionRecord[] { this.ensureInitialized() - const sessions = this.store!.get('sessions') || [] - return sessions.filter((s: SessionRecord) => s.providerId === providerId) + return this.db.getSessionsByProviderId(providerId) } - /** - * Clear All Sessions - */ clearAllSessions(): void { this.ensureInitialized() - this.store!.set('sessions', []) + this.db.clearAllSessions() } // ==================== Model Management Operations ==================== - /** - * Get User Model Overrides - */ private getUserModelOverrides(): UserModelOverrides { this.ensureInitialized() - return this.store!.get('userModelOverrides') || DEFAULT_USER_MODEL_OVERRIDES + return this.db.getUserModelOverrides() } - /** - * Set User Model Overrides - */ private setUserModelOverrides(overrides: UserModelOverrides): void { this.ensureInitialized() - this.store!.set('userModelOverrides', overrides) + this.db.setUserModelOverrides(overrides) } - /** - * Get Provider Model Overrides - */ private getProviderModelOverrides(providerId: string): ProviderModelOverrides { const overrides = this.getUserModelOverrides() - return overrides[providerId] || { - addedModels: [], - excludedModels: [], - } + return overrides[providerId] || { addedModels: [], excludedModels: [] } } - /** - * Get Effective Models for a Provider - * Merges default models with user overrides - */ getEffectiveModels(providerId: string): EffectiveModel[] { this.ensureInitialized() - const provider = this.getProviderById(providerId) - if (!provider) { - return [] - } + if (!provider) return [] const defaultModels = provider.supportedModels || [] const modelMappings = provider.modelMappings || {} @@ -1605,11 +911,7 @@ class StoreManager { defaultModels.forEach(displayName => { if (!overrides.excludedModels.includes(displayName)) { const actualModelId = modelMappings[displayName] || displayName - effectiveModels.push({ - displayName, - actualModelId, - isCustom: false, - }) + effectiveModels.push({ displayName, actualModelId, isCustom: false }) } }) @@ -1624,55 +926,33 @@ class StoreManager { return effectiveModels } - /** - * Add Custom Model to Provider - */ addCustomModel(providerId: string, model: CustomModel): EffectiveModel[] { this.ensureInitialized() - const overrides = this.getUserModelOverrides() - if (!overrides[providerId]) { - overrides[providerId] = { - addedModels: [], - excludedModels: [], - } + overrides[providerId] = { addedModels: [], excludedModels: [] } } - const existingModel = overrides[providerId].addedModels.find( + const existing = overrides[providerId].addedModels.find( m => m.displayName === model.displayName || m.actualModelId === model.actualModelId ) - - if (existingModel) { + if (existing) { throw new Error(`Model with display name "${model.displayName}" or actual ID "${model.actualModelId}" already exists`) } overrides[providerId].addedModels.push(model) this.setUserModelOverrides(overrides) - return this.getEffectiveModels(providerId) } - /** - * Remove Model from Provider - * For default models: add to excludedModels - * For custom models: remove from addedModels - */ removeModel(providerId: string, modelName: string): EffectiveModel[] { this.ensureInitialized() - const provider = this.getProviderById(providerId) - if (!provider) { - throw new Error('Provider not found') - } + if (!provider) throw new Error('Provider not found') const overrides = this.getUserModelOverrides() - if (!overrides[providerId]) { - overrides[providerId] = { - addedModels: [], - excludedModels: [], - } + overrides[providerId] = { addedModels: [], excludedModels: [] } } const defaultModels = provider.supportedModels || [] @@ -1683,91 +963,152 @@ class StoreManager { overrides[providerId].excludedModels.push(modelName) } } else { - overrides[providerId].addedModels = overrides[providerId].addedModels.filter( - m => m.displayName !== modelName - ) + overrides[providerId].addedModels = overrides[providerId].addedModels.filter(m => m.displayName !== modelName) } this.setUserModelOverrides(overrides) - return this.getEffectiveModels(providerId) } - /** - * Reset Provider Models to Default - * Removes all user overrides for the provider - */ resetModels(providerId: string): EffectiveModel[] { this.ensureInitialized() - const overrides = this.getUserModelOverrides() - if (overrides[providerId]) { delete overrides[providerId] this.setUserModelOverrides(overrides) } - return this.getEffectiveModels(providerId) } + // ==================== Generic Key-Value Store (for backward compatibility) ==================== + + async getItem(key: string): Promise { + this.ensureInitialized() + // Handle known keys + switch (key) { + case 'providers': + return this.db.getProviders() + case 'accounts': + return this.db.getAccounts() + case 'config': + return this.db.getConfig() || DEFAULT_CONFIG + case 'systemPrompts': + return this.db.getCustomPrompts() + case 'sessions': + return this.db.getSessions() + case 'statistics': + return this.db.getStatistics() || DEFAULT_STATISTICS + case 'userModelOverrides': + return this.db.getUserModelOverrides() + case 'logs': + return this.db.getLogs(undefined, undefined) + case 'requestLogs': + return this.requestLogManager?.getRequestLogs(undefined, undefined) || [] + default: + // For unknown keys, try generic table + return this.db.getGeneric(key) + } + } + + async setItem(key: string, value: unknown): Promise { + this.ensureInitialized() + switch (key) { + case 'providers': + // Not implemented (use addProvider/updateProvider) + console.warn('[Store] Direct set of providers not supported') + break + case 'accounts': + console.warn('[Store] Direct set of accounts not supported') + break + case 'config': + this.db.setConfig(value as AppConfig) + break + case 'systemPrompts': + console.warn('[Store] Direct set of systemPrompts not supported') + break + case 'sessions': + console.warn('[Store] Direct set of sessions not supported') + break + case 'statistics': + this.db.setStatistics(value as PersistentStatistics) + break + case 'userModelOverrides': + this.db.setUserModelOverrides(value as UserModelOverrides) + break + case 'logs': + // Not supported, use addLog + console.warn('[Store] Direct set of logs not supported') + break + case 'requestLogs': + console.warn('[Store] Direct set of requestLogs not supported') + break + default: + await this.db.setGeneric(key, value) + } + } + + async deleteItem(key: string): Promise { + this.ensureInitialized() + switch (key) { + case 'logs': + this.db.clearLogs() + break + case 'requestLogs': + this.requestLogManager?.clearRequestLogs() + break + default: + await this.db.deleteGeneric(key) + } + } + // ==================== Utility Methods ==================== - /** - * Generate Unique ID - */ generateId(): string { return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}` } - /** - * Get Storage Instance (for internal use only) - */ - getStore(): StoreType | null { - return this.store - } - - /** - * Get Logs Storage Instance - */ - getLogsStore(): StoreType | null { - return this.logsStore + flushPendingWrites(): void { + // No-op for SQLite (writes are immediate) } - /** - * Clear All Data - */ clearAll(): void { this.ensureInitialized() - if (this.logFlushTimer) { - clearTimeout(this.logFlushTimer) - this.logFlushTimer = null + // Clear all tables + this.db.clearLogs() + this.db.clearAllSessions() + this.db.setUserModelOverrides({}) + // Delete all accounts and providers but keep built-in providers? + // Simpler: reinitialize with defaults + const providers = this.db.getProviders() + const builtinIds = BUILTIN_PROVIDERS.map(p => p.id) + for (const p of providers) { + if (!builtinIds.includes(p.id)) { + this.db.deleteProvider(p.id) + } } - this.pendingLogs = [] - this.store!.clear() - this.logsStore?.clear() + for (const acc of this.db.getAccounts()) { + this.db.deleteAccount(acc.id) + } + this.db.setStatistics(DEFAULT_STATISTICS) + this.db.setConfig(DEFAULT_CONFIG) this.requestLogManager?.clearRequestLogs() - this.requestLogManager?.flushSync() } - /** - * Export Data (for backup) - * Does not include encrypted credential data - */ exportData(): Omit & { accounts: Omit[] } { this.ensureInitialized() - const providers = this.store!.get('providers') || [] - const accounts = (this.store!.get('accounts') || []).map((a: Account) => { + const providers = this.db.getProviders() + const accounts = this.db.getAccounts().map(a => { const { credentials, ...rest } = a return rest }) - const config = this.store!.get('config') || DEFAULT_CONFIG - const logs = (this.logsStore?.get('logs') as LogEntry[]) || [] - const requestLogs = this.getRequestLogManager().exportRequestLogs() - const systemPrompts = this.store!.get('systemPrompts') || [] - const sessions = this.store!.get('sessions') || [] - const statistics = this.store!.get('statistics') || DEFAULT_STATISTICS - const userModelOverrides = this.store!.get('userModelOverrides') || DEFAULT_USER_MODEL_OVERRIDES - + const config = this.db.getConfig() || DEFAULT_CONFIG + const logs = this.db.getLogs(undefined, undefined) + const requestLogs = this.requestLogManager?.exportRequestLogs() || [] + const systemPrompts = this.db.getCustomPrompts() + const sessions = this.db.getSessions() + const statistics = this.db.getStatistics() || DEFAULT_STATISTICS + const userModelOverrides = this.db.getUserModelOverrides() + return { providers, accounts, @@ -1781,11 +1122,92 @@ class StoreManager { } } + getStorePath(): string { + return join(homedir(), '.chat2api') + } + /** - * Get Storage Path + * Get Store instance (for backward compatibility with electron-store) + * Returns null since SQLite doesn't have a store instance */ - getStorePath(): string { - return this.getStoragePath() + getStore(): null { + return null + } + + /** + * Get Logs Store instance (for backward compatibility with electron-store) + * Returns null since SQLite doesn't have a separate logs store + */ + getLogsStore(): null { + return null + } + + /** + * Ensure credentials encryption consistency with current config + * Converts all stored credentials to match the current encryption setting + */ + private async ensureCredentialsEncryptionConsistency(): Promise { + const config = this.getConfig() + const shouldEncrypt = config.credentialEncryption && safeStorage.isEncryptionAvailable() + const accounts = this.db.getAccounts() + let modifiedCount = 0 + + for (const account of accounts) { + const storedCreds = account.credentials + const newCreds: Record = {} + let needsUpdate = false + + for (const [key, value] of Object.entries(storedCreds)) { + // Determine if the current value is encrypted + let isEncrypted = false + if (safeStorage.isEncryptionAvailable() && /^[A-Za-z0-9+/]*={0,2}$/.test(value) && value.length > 20) { + // Attempt to decrypt to see if it's valid encrypted data + try { + const buffer = Buffer.from(value, 'base64') + safeStorage.decryptString(buffer) + isEncrypted = true + } catch { + // Not valid encrypted data + } + } + + if (shouldEncrypt && !isEncrypted) { + // Need to encrypt plaintext + try { + const encrypted = safeStorage.encryptString(value) + newCreds[key] = encrypted.toString('base64') + needsUpdate = true + } catch (err) { + console.error(`[Store] Failed to encrypt credential ${key} for account ${account.id}:`, err) + newCreds[key] = value // keep as plaintext + } + } else if (!shouldEncrypt && isEncrypted) { + // Need to decrypt to plaintext + try { + const buffer = Buffer.from(value, 'base64') + const decrypted = safeStorage.decryptString(buffer) + newCreds[key] = decrypted + needsUpdate = true + } catch (err) { + console.error(`[Store] Failed to decrypt credential ${key} for account ${account.id}:`, err) + newCreds[key] = value // keep as is + } + } else { + // Already correct format + newCreds[key] = value + } + } + + if (needsUpdate) { + const updatedAccount = { ...account, credentials: newCreds, updatedAt: Date.now() } + this.db.updateAccount(account.id, updatedAccount) + modifiedCount++ + } + } + + if (modifiedCount > 0) { + console.log(`[Store] Normalized credentials encryption for ${modifiedCount} accounts (encryption=${shouldEncrypt})`) + } } private getRequestLogManager(): RequestLogManager { @@ -1798,6 +1220,3 @@ class StoreManager { // Export singleton instance export const storeManager = new StoreManager() - -// Export types -export type { StoreType }