diff --git a/ChatPageMigrationPlan.md b/ChatPageMigrationPlan.md deleted file mode 100644 index 64a1cc7..0000000 --- a/ChatPageMigrationPlan.md +++ /dev/null @@ -1,248 +0,0 @@ -# Chat 对话功能迁移重构计划 - -## 参考来源 -- **源文件**: `ClawX/src/pages/Chat/index.tsx` 及其子组件 -- **目标文件**: `zn-ai/src/pages/home/index.vue`、`zn-ai/src/pages/home/ChatBox.vue` - -## 源实现思路分析 - -`ClawX/src/pages/Chat/index.tsx` 的核心架构: - -1. **状态层集中化**:所有聊天状态(messages、sending、loading、error、streamingMessage 等)托管在 `useChatStore`(Zustand)中,页面组件只负责订阅与渲染。 -2. **组件原子化**: - - `ChatMessage`:单条消息渲染(markdown、thinking、tool_use、附件图片)。 - - `ChatInput`:输入框 + 发送/停止按钮。 - - `ChatToolbar`:会话选择器、thinking 显隐切换、刷新按钮。 - - `ExecutionGraphCard`:用户消息后的任务执行可视化卡片。 -3. **流式消息处理**:通过 `streamingMessage` 和 `streamingTools` 实现未落库前的增量渲染;`pendingFinal` 标识等待最终响应的状态。 -4. **生命周期管理**: - - 切走页面时调用 `cleanupEmptySession()` 清理空会话。 - - 加载历史消息时保留乐观用户消息,避免闪烁。 - - 轮询 + 安全超时机制防止消息卡死。 -5. **错误与加载态**: - - 底部 `error` 条显示全局错误并可一键清除。 - - `minLoading` 在 history 加载时显示透明遮罩 + LoadingSpinner。 - - 发送中显示 `TypingIndicator` / `ActivityIndicator`。 -6. **WelcomeScreen**:当 `messages.length === 0 && !sending` 时展示欢迎页 + 快捷操作按钮。 - ---- - -## 迁移目标 - -将 `ChatBox.vue` 中沉淀的 880+ 行“上帝组件”逻辑解耦,引入 **Pinia Store + 原子组件** 的 ClawX 式架构,同时保留 zn-ai 现有的视觉风格(蓝色主题、头像布局、输入框样式)。 - ---- - -## 实现步骤 - -### 1. 提取 Pinia Chat Store(状态层迁移) - -新建 `zn-ai/src/store/chat.ts`,将 `ChatBox.vue` 中的以下逻辑迁入 Store: - -| ChatBox.vue 现有逻辑 | Store 对应设计 | -|---|---| -| `chatMsgList` | `state.messages` | -| `isSendingMessage` | `state.sending` | -| `isSessionActive` | `state.pendingFinal` / `state.loading` | -| WebSocket 管理(`initWebSocket`、`sendWebSocketMessage`) | Store Actions:`initConnection`、`sendMessage`、`stopRun` | -| `pendingMap` / `pendingTimeouts` 超时回退 | Store 内部 Map + 定时器(不暴露给 UI) | -| `handleWebSocketMessage` | Store Action:`handleStreamEvent` | -| `loadConversationMessages` | Store Action:`loadHistory(sessionId)` | -| `createConversationRequest` | Store Action:`createSession()` | -| `resetConversation` / `cleanup` | Store Action:`resetSession()` | - -**Store 核心 State 设计**: - -```ts -interface ChatState { - messages: ChatMessage[]; // 当前会话消息列表 - sending: boolean; // 是否正在发送/等待回复 - loading: boolean; // 是否正在加载历史 - error: string | null; // 全局错误提示 - streamingMessage: Partial | null; // 流式增量消息 - currentSessionId: string; // 当前会话 ID -} -``` - -> **说明**:zn-ai 当前使用 WebSocket 直联后端,ClawX 使用 Gateway RPC;本次迁移**只搬架构、不搬协议**,Store 内部仍继续使用现有的 `WebSocketManager`。 - -### 2. 组件拆分(UI 层迁移) - -将 `ChatBox.vue` 按职责拆成以下子组件(放置于 `zn-ai/src/pages/home/components/chat/`): - -#### 2.1 `ChatMessage.vue` -职责:渲染单条消息。 -- Props:`msg: ChatMessage`、`isStreaming?: boolean` -- 复用现有 `ChatRoleMe.vue`、`ChatRoleAI.vue`、`ChatAvatar.vue`、`ChatNameTime.vue`、`ChatAttach.vue`、`ChatAIMark.vue`、`ChatOperation.vue` 的能力。 -- **新增**:支持 `streamingMessage` 的渲染(内容可能未完成)。 - -#### 2.2 `ChatInput.vue`(由现有 `ChatInputArea.vue` 升级) -职责:输入框 + 发送/停止按钮。 -- Props:`modelValue: string`、`sending: boolean`、`disabled?: boolean` -- Events:`send`、`stop`、`update:modelValue`、`attach` -- **新增**:当 `sending = true` 时按钮显示停止图标并触发 `stop`。 - -#### 2.3 `ChatEmpty.vue`(WelcomeScreen 等价物) -职责:空会话欢迎页。 -- 迁移现有 `ChatBox.vue` 中的引导页 DOM(大标题“你好,我今天能帮你什么?”)。 -- 可保留现有的 `TaskCenter` 插槽或快捷操作按钮区域。 - -#### 2.4 `ChatErrorBar.vue` -职责:底部错误条。 -- Props:`error: string | null` -- Events:`dismiss` -- 样式参考 ClawX 的红色背景条:`bg-red-50` + 左侧 `AlertCircle` 图标 + 右侧“Dismiss”按钮。 - -#### 2.5 `ChatLoadingOverlay.vue`(可选) -职责:history 加载时透明遮罩 + LoadingSpinner。 -- 若 zn-ai 现有 `ChatLoading.vue` 已满足,可直接复用。 - -### 3. ChatBox.vue 瘦身改造 - -改造后 `ChatBox.vue` 只承担:**Store 订阅 + 组件拼装 + 少量 prop 透传**。 - -```html - -``` - -```ts - -``` - -### 4. 流式消息与 Indicator 支持 - -在 Store 的 WebSocket `onMessage` 回调中: - -1. **首次收到内容**:创建 `streamingMessage`(AI 角色、初始内容)。 -2. **后续收到增量**:`streamingMessage.messageContent += data.content`。 -3. **收到 finish**:将 `streamingMessage` 推入 `messages[]`,然后清空 `streamingMessage`,结束 `sending`。 -4. **异常/超时**:清空 `streamingMessage` 并设置 `error`。 - -**新增 `ChatTypingIndicator.vue`**(参考 ClawX `TypingIndicator`): -- 左侧 AI 头像,右侧三个跳动的圆点,用于 `sending && !streamingMessage` 时占位。 - -### 5. 生命周期与页面级整合(`home/index.vue`) - -在 `home/index.vue` 中补充 Store 生命周期绑定: - -```ts -import { useChatStore } from '@store/chat'; - -const chatStore = useChatStore(); - -onMounted(() => { - chatStore.initConnection(); -}); - -onBeforeUnmount(() => { - chatStore.resetSession(); -}); -``` - -> 当用户从首页切到其他页面时,调用 `resetSession()` 关闭 WebSocket、清理定时器,避免后台继续接收消息。 - -### 6. 历史消息加载与空会话清理 - -- **选择历史会话**:`ChatHistory.vue` 的 `@select-chat` 事件触发 `chatStore.loadHistory(conversationId)`。 -- **新建对话**:`@new-chat` 触发 `chatStore.createSession()`,然后清空 `messages`。 -- **切页清理**:Store 中实现 `cleanupEmptySession()`,如果当前会话没有任何消息且不是历史来源,则自动重置 `conversationId`,避免产生幽灵会话。 - -### 7. 样式与兼容性保持 - -- **颜色主题**:继续使用 zn-ai 现有的 `#2B7FFF` 蓝色高亮、`#E5E8EE` 边框色。 -- **头像布局**:左右头像顺序不变(AI 左、用户右)。 -- **输入框样式**:`ChatInput` 保留现有的圆角、阴影、发送按钮样式。 -- **引导页**:保留现有的 `TaskCenter` 和欢迎文案。 - ---- - -## 涉及文件 - -| 新建/修改 | 文件路径 | 说明 | -|---|---|---| -| 新建 | `zn-ai/src/store/chat.ts` | Pinia Chat Store,承载状态与 WebSocket 逻辑 | -| 新建 | `zn-ai/src/pages/home/components/chat/ChatMessage.vue` | 单条消息渲染(聚合现有原子组件) | -| 新建 | `zn-ai/src/pages/home/components/chat/ChatEmpty.vue` | 空会话欢迎页 | -| 新建 | `zn-ai/src/pages/home/components/chat/ChatErrorBar.vue` | 底部全局错误提示条 | -| 新建 | `zn-ai/src/pages/home/components/chat/ChatTypingIndicator.vue` | 发送中跳动圆点 | -| 升级 | `zn-ai/src/pages/home/components/ChatInputArea.vue` | 增加 `sending` 态与 `stop` 事件 | -| 重构 | `zn-ai/src/pages/home/ChatBox.vue` | 大幅瘦身,仅负责拼装子组件 | -| 调整 | `zn-ai/src/pages/home/index.vue` | 引入 Store 生命周期、事件透传 | - ---- - -## 验收标准 - -- [ ] `ChatBox.vue` 代码量从 800+ 行降至 150 行以内,不再包含 WebSocket 细节。 -- [ ] 新建 `chat.ts` Pinia Store 能正常初始化 WebSocket、发送消息、接收回复。 -- [ ] 用户发送消息后,输入框清空,列表底部出现 `ChatTypingIndicator`;收到首包内容后切换为 `ChatMessage` 流式渲染。 -- [ ] 收到完成标识后,流式消息固化到消息列表,状态恢复正常。 -- [ ] 发生超时或错误时,底部出现 `ChatErrorBar`,可点击清除。 -- [ ] 切换历史会话时,通过 Store 加载历史消息并正确渲染。 -- [ ] 新建对话时,列表回到空状态(`ChatEmpty`)。 -- [ ] 离开首页时 WebSocket 被正确关闭,无后台消息泄漏。 diff --git a/dist-electron/main/main.js b/dist-electron/main/main.js index 458e760..9e1dc48 100644 --- a/dist-electron/main/main.js +++ b/dist-electron/main/main.js @@ -22,12 +22,11 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge mod )); const electron = require("electron"); -const OpenAI = require("openai"); +require("js-base64"); const util = require("util"); const log = require("electron-log"); const path = require("path"); const fs = require("fs"); -const jsBase64 = require("js-base64"); const path$1 = require("node:path"); const crypto = require("crypto"); const started = require("electron-squirrel-startup"); @@ -37,6 +36,8 @@ const child_process = require("child_process"); const events = require("events"); require("bytenode"); const electronUpdater = require("electron-updater"); +const axios = require("axios"); +const OpenAI = require("openai"); function _interopNamespaceDefault(e) { const n = Object.create(null, { [Symbol.toStringTag]: { value: "Module" } }); if (e) { @@ -110,6 +111,8 @@ var IPC_EVENTS = /* @__PURE__ */ ((IPC_EVENTS2) => { IPC_EVENTS2["SCRIPT_RECORD_START"] = "script:record-start"; IPC_EVENTS2["SCRIPT_RECORD_STOP"] = "script:record-stop"; IPC_EVENTS2["SCRIPT_CODEGEN"] = "script:codegen"; + IPC_EVENTS2["GATEWAY_RPC"] = "gateway:rpc"; + IPC_EVENTS2["GATEWAY_EVENT"] = "gateway:event"; IPC_EVENTS2["UPDATE_CHECK"] = "update:check"; IPC_EVENTS2["UPDATE_DOWNLOAD"] = "update:download"; IPC_EVENTS2["UPDATE_INSTALL"] = "update:install"; @@ -172,7 +175,39 @@ var MESSAGE_ITEM_MENU_IDS = /* @__PURE__ */ ((MESSAGE_ITEM_MENU_IDS2) => { MESSAGE_ITEM_MENU_IDS2["SELECT"] = "select"; return MESSAGE_ITEM_MENU_IDS2; })(MESSAGE_ITEM_MENU_IDS || {}); -class BaseProvider { +function debounce(fn, delay) { + let timer = null; + return function(...args) { + if (timer) { + clearTimeout(timer); + } + timer = setTimeout(() => { + fn.apply(this, args); + }, delay); + }; +} +function cloneDeep(obj) { + if (obj === null || typeof obj !== "object") { + return obj; + } + if (Array.isArray(obj)) { + return obj.map((item) => cloneDeep(item)); + } + const clone = Object.assign({}, obj); + for (const key in clone) { + if (Object.prototype.hasOwnProperty.call(clone, key)) { + clone[key] = cloneDeep(clone[key]); + } + } + return clone; +} +function simpleCloneDeep(obj) { + try { + return JSON.parse(JSON.stringify(obj)); + } catch (error) { + console.error("simpleCloneDeep failed:", error); + return obj; + } } const readdirAsync = util.promisify(fs__namespace.readdir); const statAsync = util.promisify(fs__namespace.stat); @@ -301,91 +336,6 @@ class LogService { } } const logManager = LogService.getInstance(); -function _transformChunk(chunk) { - const choice = chunk.choices[0]; - return { - isEnd: choice?.finish_reason === "stop", - result: choice?.delta?.content ?? "" - }; -} -class OpenAIProvider extends BaseProvider { - client; - constructor(apiKey, baseURL) { - super(); - this.client = new OpenAI({ apiKey, baseURL }); - } - async chat(messages2, model) { - const startTime = Date.now(); - const lastMessage = messages2[messages2.length - 1]; - logManager.logApiRequest("chat.completions.create", { - model, - lastMessage: lastMessage?.content?.substring(0, 100) + (lastMessage?.content?.length > 100 ? "..." : ""), - messageCount: messages2.length - }, "POST"); - try { - const chunks = await this.client.chat.completions.create({ - model, - messages: messages2, - stream: true - }); - const responseTime = Date.now() - startTime; - logManager.logApiResponse("chat.completions.create", { success: true }, 200, responseTime); - return { - async *[Symbol.asyncIterator]() { - for await (const chunk of chunks) { - yield _transformChunk(chunk); - } - } - }; - } catch (error) { - const responseTime = Date.now() - startTime; - logManager.logApiResponse("chat.completions.create", { error: error instanceof Error ? error.message : String(error) }, 500, responseTime); - throw error; - } - } -} -function debounce(fn, delay) { - let timer = null; - return function(...args) { - if (timer) { - clearTimeout(timer); - } - timer = setTimeout(() => { - fn.apply(this, args); - }, delay); - }; -} -function cloneDeep(obj) { - if (obj === null || typeof obj !== "object") { - return obj; - } - if (Array.isArray(obj)) { - return obj.map((item) => cloneDeep(item)); - } - const clone = Object.assign({}, obj); - for (const key in clone) { - if (Object.prototype.hasOwnProperty.call(clone, key)) { - clone[key] = cloneDeep(clone[key]); - } - } - return clone; -} -function simpleCloneDeep(obj) { - try { - return JSON.parse(JSON.stringify(obj)); - } catch (error) { - console.error("simpleCloneDeep failed:", error); - return obj; - } -} -function parseOpenAISetting(setting) { - try { - return JSON.parse(jsBase64.decode(setting)); - } catch (error) { - console.error("parseOpenAISetting failed:", error); - return {}; - } -} const DEFAULT_CONFIG = { [CONFIG_KEYS.THEME_MODE]: "system", [CONFIG_KEYS.PRIMARY_COLOR]: "#BB5BE7", @@ -476,100 +426,6 @@ class ConfigService { } } const configManager = ConfigService.getInstance(); -[ - { - id: 1, - name: "bigmodel", - title: "智谱AI", - models: ["glm-4.5-flash"], - openAISetting: { - baseURL: "https://open.bigmodel.cn/api/paas/v4", - apiKey: process.env.BIGMODEL_API_KEY || "" - }, - createdAt: (/* @__PURE__ */ new Date()).getTime(), - updatedAt: (/* @__PURE__ */ new Date()).getTime() - }, - { - id: 2, - name: "deepseek", - title: "深度求索 (DeepSeek)", - models: ["deepseek-chat"], - openAISetting: { - baseURL: "https://api.deepseek.com/v1", - apiKey: process.env.DEEPSEEK_API_KEY || "" - }, - createdAt: (/* @__PURE__ */ new Date()).getTime(), - updatedAt: (/* @__PURE__ */ new Date()).getTime() - }, - { - id: 3, - name: "siliconflow", - title: "硅基流动", - models: ["Qwen/Qwen3-8B", "deepseek-ai/DeepSeek-R1-0528-Qwen3-8B"], - openAISetting: { - baseURL: "https://api.siliconflow.cn/v1", - apiKey: process.env.SILICONFLOW_API_KEY || "" - }, - createdAt: (/* @__PURE__ */ new Date()).getTime(), - updatedAt: (/* @__PURE__ */ new Date()).getTime() - }, - { - id: 4, - name: "qianfan", - title: "百度千帆", - models: ["ernie-speed-128k", "ernie-4.0-8k", "ernie-3.5-8k"], - openAISetting: { - baseURL: "https://qianfan.baidubce.com/v2", - apiKey: process.env.QIANFAN_API_KEY || "" - }, - createdAt: (/* @__PURE__ */ new Date()).getTime(), - updatedAt: (/* @__PURE__ */ new Date()).getTime() - } -]; -const _parseProvider = () => { - let result = []; - let isBase64Parsed = false; - const providerConfig = configManager.get(CONFIG_KEYS.PROVIDER); - const mapCallback = (provider) => ({ - ...provider, - openAISetting: typeof provider.openAISetting === "string" ? parseOpenAISetting(provider.openAISetting ?? "") : provider.openAISetting - }); - try { - result = JSON.parse(jsBase64.decode(providerConfig)); - isBase64Parsed = true; - } catch (error) { - logManager.error(`parse base64 provider failed: ${error}`); - } - if (!isBase64Parsed) try { - result = JSON.parse(providerConfig); - } catch (error) { - logManager.error(`parse provider failed: ${error}`); - } - if (!result.length) return; - return result.map(mapCallback); -}; -const getProviderConfig = () => { - try { - return _parseProvider(); - } catch (error) { - logManager.error(`get provider config failed: ${error}`); - return null; - } -}; -function createProvider(name) { - const providers2 = getProviderConfig(); - if (!providers2) { - throw new Error("provider config not found"); - } - for (const provider of providers2) { - if (provider.name === name) { - if (!provider.openAISetting?.apiKey || !provider.openAISetting?.baseURL) { - throw new Error("apiKey or baseURL not found"); - } - return new OpenAIProvider(provider.openAISetting.apiKey, provider.openAISetting.baseURL); - } - } -} const window$1 = { "minimize": "Minimize", "maximize": "Maximize", "restore": "Restore", "close": "Close" }; const main$1 = { "welcome": { "helloMessage": "Hello, I'm Diona" }, "conversation": { "placeholder": "Type a message...", "newConversation": "New Conversation", "selectModel": "Please select model", "createConversation": "Create Conversation", "searchPlaceholder": "Search conversations...", "goSettings": "Go to", "settings": "Settings Window", "addModel": "to add a model", "dialog": { "title": "Confirm Deletion", "content": "Are you sure you want to delete this conversation?", "content_1": "Are you sure you want to delete the selected conversations? This action cannot be undone." }, "operations": { "pin": "Pin Selected", "del": "Delete Selected", "selectAll": "Select All", "cancel": "Cancel" } }, "sidebar": { "conversations": "Conversations", "settings": "Settings", "help": "Help" }, "message": { "dialog": { "title": "Confirm Deletion", "messageDelete": "Are you sure you want to delete this message?", "batchDelete": "Are you sure you want to delete the selected messages?", "copySuccess": "Copied successfully" }, "batchActions": { "deleteSelected": "Delete Selected" }, "rendering": "Thinking...", "stoppedGeneration": "(Stopped generating)", "sending": "Sending", "stopGeneration": "Stop generating", "send": "Send" } }; const dialog$1 = { "cancel": "Cancel", "confirm": "Confirm" }; @@ -1335,37 +1191,6 @@ function setupMainWindow() { }); }); windowManager.create(WINDOW_NAMES.MAIN, MAIN_WIN_SIZE); - electron.ipcMain.on(IPC_EVENTS.START_A_DIALOGUE, async (_event, props) => { - const { providerName, messages: messages2, messageId, selectedModel } = props; - const mainWindow = windowManager.get(WINDOW_NAMES.MAIN); - if (!mainWindow) { - throw new Error("mainWindow not found"); - } - try { - const provider = createProvider(providerName); - const chunks = await provider?.chat(messages2, selectedModel); - if (!chunks) { - throw new Error("chunks or stream not found"); - } - for await (const chunk of chunks) { - const chunkContent = { - messageId, - data: chunk - }; - mainWindow.webContents.send(IPC_EVENTS.START_A_DIALOGUE + "back" + messageId, chunkContent); - } - } catch (error) { - const errorContent = { - messageId, - data: { - isEnd: true, - isError: true, - result: error instanceof Error ? error.message : String(error) - } - }; - mainWindow.webContents.send(IPC_EVENTS.START_A_DIALOGUE + "back" + messageId, errorContent); - } - }); } function getChromePath() { if (process.platform === "win32") { @@ -2012,11 +1837,706 @@ class AppUpdater { } } const appUpdater = AppUpdater.getInstance(); +const PROVIDER_TYPE_INFO = [ + { + id: "anthropic", + name: "Anthropic", + icon: "🤖", + placeholder: "sk-ant-api03-...", + model: "Claude", + requiresApiKey: true, + docsUrl: "https://platform.claude.com/docs/en/api/overview" + }, + { + id: "openai", + name: "OpenAI", + icon: "💚", + placeholder: "sk-proj-...", + model: "GPT", + requiresApiKey: true, + isOAuth: true, + supportsApiKey: true, + defaultModelId: "gpt-5.4", + showModelId: true, + showModelIdInDevModeOnly: true, + modelIdPlaceholder: "gpt-5.4", + apiKeyUrl: "https://platform.openai.com/api-keys" + }, + { + id: "google", + name: "Google", + icon: "🔷", + placeholder: "AIza...", + model: "Gemini", + requiresApiKey: true, + isOAuth: true, + supportsApiKey: true, + defaultModelId: "gemini-3-pro-preview", + showModelId: true, + showModelIdInDevModeOnly: true, + modelIdPlaceholder: "gemini-3-pro-preview", + apiKeyUrl: "https://aistudio.google.com/app/apikey" + }, + { id: "openrouter", name: "OpenRouter", icon: "🌐", placeholder: "sk-or-v1-...", model: "Multi-Model", requiresApiKey: true, showModelId: true, modelIdPlaceholder: "openai/gpt-5.4", defaultModelId: "openai/gpt-5.4", docsUrl: "https://openrouter.ai/models" }, + { id: "minimax-portal-cn", name: "MiniMax (CN)", icon: "☁️", placeholder: "sk-...", model: "MiniMax", requiresApiKey: false, isOAuth: true, supportsApiKey: true, defaultModelId: "MiniMax-M2.7", showModelId: true, showModelIdInDevModeOnly: true, modelIdPlaceholder: "MiniMax-M2.7", apiKeyUrl: "https://platform.minimaxi.com/" }, + { id: "moonshot", name: "Moonshot (CN)", icon: "🌙", placeholder: "sk-...", model: "Kimi", requiresApiKey: true, defaultBaseUrl: "https://api.moonshot.cn/v1", defaultModelId: "kimi-k2.5", docsUrl: "https://platform.moonshot.cn/" }, + { id: "siliconflow", name: "SiliconFlow (CN)", icon: "🌊", placeholder: "sk-...", model: "Multi-Model", requiresApiKey: true, defaultBaseUrl: "https://api.siliconflow.cn/v1", showModelId: true, showModelIdInDevModeOnly: true, modelIdPlaceholder: "deepseek-ai/DeepSeek-V3", defaultModelId: "deepseek-ai/DeepSeek-V3", docsUrl: "https://docs.siliconflow.cn/cn/userguide/introduction" }, + { id: "deepseek", name: "DeepSeek", icon: "🐋", placeholder: "sk-...", model: "DeepSeek", requiresApiKey: true, defaultBaseUrl: "https://api.deepseek.com/v1", showModelId: true, modelIdPlaceholder: "deepseek-chat", defaultModelId: "deepseek-chat", apiKeyUrl: "https://platform.deepseek.com/api_keys", docsUrl: "https://api-docs.deepseek.com/" }, + { id: "minimax-portal", name: "MiniMax (Global)", icon: "☁️", placeholder: "sk-...", model: "MiniMax", requiresApiKey: false, isOAuth: true, supportsApiKey: true, defaultModelId: "MiniMax-M2.7", showModelId: true, showModelIdInDevModeOnly: true, modelIdPlaceholder: "MiniMax-M2.7", apiKeyUrl: "https://platform.minimax.io" }, + { id: "modelstudio", name: "Model Studio", icon: "☁️", placeholder: "sk-...", model: "Qwen", requiresApiKey: true, defaultBaseUrl: "https://coding.dashscope.aliyuncs.com/v1", showBaseUrl: true, defaultModelId: "qwen3.5-plus", showModelId: true, showModelIdInDevModeOnly: true, modelIdPlaceholder: "qwen3.5-plus", apiKeyUrl: "https://bailian.console.aliyun.com/", hidden: true }, + { id: "ark", name: "ByteDance Ark", icon: "A", placeholder: "your-ark-api-key", model: "Doubao", requiresApiKey: true, defaultBaseUrl: "https://ark.cn-beijing.volces.com/api/v3", showBaseUrl: true, showModelId: true, modelIdPlaceholder: "ep-20260228000000-xxxxx", docsUrl: "https://www.volcengine.com/", codePlanPresetBaseUrl: "https://ark.cn-beijing.volces.com/api/coding/v3", codePlanPresetModelId: "ark-code-latest", codePlanDocsUrl: "https://www.volcengine.com/docs/82379/1928261?lang=zh" }, + { id: "ollama", name: "Ollama", icon: "🦙", placeholder: "Not required", requiresApiKey: false, defaultBaseUrl: "http://localhost:11434/v1", showBaseUrl: true, showModelId: true, modelIdPlaceholder: "qwen3:latest" }, + { + id: "custom", + name: "Custom", + icon: "⚙️", + placeholder: "API key...", + requiresApiKey: true, + showBaseUrl: true, + showModelId: true, + modelIdPlaceholder: "your-provider/model-id", + docsUrl: "https://icnnp7d0dymg.feishu.cn/wiki/BmiLwGBcEiloZDkdYnGc8RWnn6d#Ee1ldfvKJoVGvfxc32mcILwenth", + docsUrlZh: "https://icnnp7d0dymg.feishu.cn/wiki/BmiLwGBcEiloZDkdYnGc8RWnn6d#IWQCdfe5fobGU3xf3UGcgbLynGh" + } +]; +function getProviderTypeInfo(type) { + return PROVIDER_TYPE_INFO.find((t2) => t2.id === type); +} +const defaultStore = { + accounts: [], + defaultAccountId: null +}; +const storePath = path__namespace.join(electron.app.getPath("userData"), "provider-accounts.json"); +const keysPath = path__namespace.join(electron.app.getPath("userData"), "provider-keys.json"); +function readJson(filePath, defaultValue) { + try { + if (fs__namespace.existsSync(filePath)) { + return JSON.parse(fs__namespace.readFileSync(filePath, "utf-8")); + } + } catch (e) { + logManager.error(`Failed to read ${filePath}:`, e); + } + return defaultValue; +} +function writeJson(filePath, data) { + try { + fs__namespace.mkdirSync(path__namespace.dirname(filePath), { recursive: true }); + fs__namespace.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8"); + } catch (e) { + logManager.error(`Failed to write ${filePath}:`, e); + } +} +function getStore() { + return readJson(storePath, defaultStore); +} +function saveStore(store) { + writeJson(storePath, store); +} +function getKeys() { + return readJson(keysPath, {}); +} +function saveKeys(keys) { + writeJson(keysPath, keys); +} +function mapToProviderWithKeyInfo(account) { + const keys = getKeys(); + const hasKey = !!keys[account.id]; + return { + id: account.id, + name: account.label, + type: account.vendorId, + baseUrl: account.baseUrl, + apiProtocol: account.apiProtocol, + headers: account.headers, + model: account.model, + fallbackModels: account.fallbackModels, + fallbackProviderIds: account.fallbackAccountIds, + enabled: account.enabled, + createdAt: account.createdAt, + updatedAt: account.updatedAt, + hasKey, + keyMasked: hasKey ? "••••••••" : null + }; +} +const listeners = []; +function onProviderChange(listener) { + listeners.push(listener); + return () => { + const idx = listeners.indexOf(listener); + if (idx > -1) listeners.splice(idx, 1); + }; +} +function notifyChange() { + listeners.forEach((l) => l()); +} +function mapToVendorInfo(info) { + return { + ...info, + category: info.id === "ollama" ? "local" : info.id === "custom" ? "custom" : "compatible", + supportedAuthModes: info.requiresApiKey ? info.isOAuth ? ["api_key", "oauth_browser"] : ["api_key"] : info.isOAuth ? ["local", "oauth_browser"] : ["local"], + defaultAuthMode: info.requiresApiKey ? "api_key" : "local", + supportsMultipleAccounts: true + }; +} +function sanitizeAccount(account) { + let model = account.model; + if (model) { + if (model === "deepseek-chat/deepseek-reasoner" || model.startsWith("deepseek-chat/")) { + model = "deepseek-chat"; + } else if (model.startsWith("deepseek-reasoner/")) { + model = "deepseek-reasoner"; + } + } + if (model !== account.model) { + return { ...account, model }; + } + return account; +} +const providerApiService = { + getVendors() { + return PROVIDER_TYPE_INFO.map(mapToVendorInfo); + }, + getAccounts() { + return getStore().accounts.map(sanitizeAccount); + }, + getProviders() { + return getStore().accounts.map(sanitizeAccount).map(mapToProviderWithKeyInfo); + }, + getDefault() { + return { accountId: getStore().defaultAccountId }; + }, + createAccount(body) { + const store = getStore(); + const account = { ...body.account, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }; + store.accounts.push(account); + if (body.apiKey) { + const keys = getKeys(); + keys[account.id] = body.apiKey; + saveKeys(keys); + } + saveStore(store); + notifyChange(); + return { success: true }; + }, + updateAccount(accountId, body) { + const store = getStore(); + const idx = store.accounts.findIndex((a) => a.id === accountId); + if (idx === -1) return { success: false, error: "Account not found" }; + store.accounts[idx] = { + ...store.accounts[idx], + ...body.updates, + updatedAt: (/* @__PURE__ */ new Date()).toISOString() + }; + if (body.apiKey) { + const keys = getKeys(); + keys[accountId] = body.apiKey; + saveKeys(keys); + } + saveStore(store); + notifyChange(); + return { success: true }; + }, + deleteAccount(accountId) { + const store = getStore(); + store.accounts = store.accounts.filter((a) => a.id !== accountId); + if (store.defaultAccountId === accountId) store.defaultAccountId = null; + saveStore(store); + const keys = getKeys(); + delete keys[accountId]; + saveKeys(keys); + notifyChange(); + return { success: true }; + }, + setDefault(body) { + const store = getStore(); + const accountExists = store.accounts.some((a) => a.id === body.accountId); + if (!accountExists) { + return { success: false, error: "Account not found" }; + } + store.defaultAccountId = body.accountId; + saveStore(store); + notifyChange(); + return { success: true }; + }, + validateApiKey(body) { + if (!body.apiKey || body.apiKey.trim().length === 0) { + return { valid: false, error: "API key is required" }; + } + return { valid: true }; + }, + getApiKey(providerId) { + const keys = getKeys(); + return { apiKey: keys[providerId] || null }; + }, + deleteApiKey(accountId) { + const keys = getKeys(); + delete keys[accountId]; + saveKeys(keys); + notifyChange(); + return { success: true }; + }, + getUsageHistory() { + return []; + } +}; +class BaseProvider { +} +function _transformChunk(chunk) { + const choice = chunk.choices[0]; + return { + isEnd: choice?.finish_reason != null, + result: choice?.delta?.content ?? "" + }; +} +class OpenAIProvider extends BaseProvider { + client; + constructor(apiKey, baseURL, headers) { + super(); + this.client = new OpenAI({ apiKey, baseURL, defaultHeaders: headers }); + } + async chat(messages2, model, options) { + const startTime = Date.now(); + const lastMessage = messages2[messages2.length - 1]; + logManager.logApiRequest("chat.completions.create", { + model, + lastMessage: lastMessage?.content?.substring(0, 100) + (lastMessage?.content?.length > 100 ? "..." : ""), + messageCount: messages2.length + }, "POST"); + try { + const chunks = await this.client.chat.completions.create({ + model, + messages: messages2, + stream: true + }, { + signal: options?.signal + }); + return { + async *[Symbol.asyncIterator]() { + try { + for await (const chunk of chunks) { + if (options?.signal?.aborted) break; + yield _transformChunk(chunk); + } + const responseTime = Date.now() - startTime; + logManager.logApiResponse("chat.completions.create", { success: true }, 200, responseTime); + } catch (error) { + const responseTime = Date.now() - startTime; + logManager.logApiResponse("chat.completions.create", { error: error instanceof Error ? error.message : String(error) }, 500, responseTime); + throw error; + } + } + }; + } catch (error) { + const responseTime = Date.now() - startTime; + logManager.logApiResponse("chat.completions.create", { error: error instanceof Error ? error.message : String(error) }, 500, responseTime); + throw error; + } + } +} +function createProvider(accountId) { + const account = providerApiService.getAccounts().find((a) => a.id === accountId); + if (!account) { + throw new Error(`Provider account ${accountId} not found`); + } + const apiKeyResult = providerApiService.getApiKey(accountId); + const apiKey = apiKeyResult.apiKey; + if (!apiKey) { + throw new Error(`API key for account ${accountId} not found`); + } + const baseURL = account.baseUrl || getProviderTypeInfo(account.vendorId)?.defaultBaseUrl; + if (!baseURL) { + throw new Error(`Base URL for account ${accountId} not found`); + } + switch (account.apiProtocol) { + case "anthropic-messages": + throw new Error("Anthropic provider not yet implemented"); + case "openai-completions": + case "openai-responses": + default: + return new OpenAIProvider(apiKey, baseURL, account.headers); + } +} +let sessionsFilePath = null; +function getSessionsFilePath() { + if (!sessionsFilePath) { + sessionsFilePath = path__namespace.join(electron.app.getPath("userData"), "chat-sessions.json"); + } + return sessionsFilePath; +} +class SessionStore { + sessions = /* @__PURE__ */ new Map(); + loaded = false; + ensureLoaded() { + if (this.loaded) return; + this.loaded = true; + this.loadFromDisk(); + } + loadFromDisk() { + try { + const filePath = getSessionsFilePath(); + if (fs__namespace.existsSync(filePath)) { + const data = JSON.parse(fs__namespace.readFileSync(filePath, "utf-8")); + for (const [key, entry] of Object.entries(data)) { + this.sessions.set(key, { + ...entry, + activeRun: void 0 + }); + } + } + } catch (e) { + logManager.error("Failed to load sessions from disk:", e); + } + } + saveToDisk() { + try { + const filePath = getSessionsFilePath(); + const data = {}; + for (const [key, entry] of this.sessions) { + data[key] = { + key: entry.key, + messages: entry.messages, + updatedAt: entry.updatedAt + }; + } + fs__namespace.mkdirSync(path__namespace.dirname(filePath), { recursive: true }); + fs__namespace.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8"); + } catch (e) { + logManager.error("Failed to save sessions to disk:", e); + } + } + getOrCreate(key) { + this.ensureLoaded(); + let session = this.sessions.get(key); + if (!session) { + session = { + key, + messages: [], + updatedAt: Date.now() + }; + this.sessions.set(key, session); + } + return session; + } + get(key) { + this.ensureLoaded(); + return this.sessions.get(key); + } + getAllKeys() { + this.ensureLoaded(); + return Array.from(this.sessions.keys()); + } + appendMessage(key, message) { + const session = this.getOrCreate(key); + session.messages.push(message); + session.updatedAt = Date.now(); + this.saveToDisk(); + } + getMessages(key, limit = 50) { + const session = this.get(key); + if (!session) return []; + return session.messages.slice(-limit); + } + setActiveRun(key, runId, abortController) { + const session = this.getOrCreate(key); + session.activeRun = { runId, abortController }; + } + clearActiveRun(key) { + const session = this.sessions.get(key); + if (session) { + session.activeRun = void 0; + } + } + getActiveRun(key) { + return this.sessions.get(key)?.activeRun; + } + deleteSession(key) { + this.sessions.delete(key); + this.saveToDisk(); + } +} +const sessionStore = new SessionStore(); +function buildChatMessages(sessionMessages) { + return sessionMessages.map((msg) => { + if (!msg.role || !msg.content) return null; + const role = msg.role; + if (role === "user" || role === "assistant" || role === "system") { + return { + role, + content: typeof msg.content === "string" ? msg.content : "" + }; + } + return null; + }).filter((m) => m !== null); +} +async function processChatStream(sessionKey, runId, provider, model, messages2, signal, broadcast) { + let assistantContent = ""; + try { + const chunks = await provider.chat(messages2, model, { signal }); + for await (const chunk of chunks) { + if (signal.aborted) break; + if (chunk.result) { + assistantContent += chunk.result; + broadcast({ + type: "chat:delta", + sessionKey, + runId, + delta: chunk.result + }); + } + if (chunk.isEnd) { + break; + } + } + if (!signal.aborted) { + const finalMessage = { + role: "assistant", + content: assistantContent, + timestamp: Date.now() + }; + sessionStore.appendMessage(sessionKey, finalMessage); + sessionStore.clearActiveRun(sessionKey); + broadcast({ + type: "chat:final", + sessionKey, + runId, + message: finalMessage + }); + } + } catch (error) { + sessionStore.clearActiveRun(sessionKey); + broadcast({ + type: "chat:error", + sessionKey, + runId, + error: error instanceof Error ? error.message : String(error) + }); + } +} +function handleChatSend(params, broadcast) { + const { sessionKey, message, options } = params; + const runId = crypto.randomUUID(); + sessionStore.appendMessage(sessionKey, { + ...message, + timestamp: message.timestamp || Date.now() + }); + const accountId = options?.providerAccountId || providerApiService.getDefault().accountId; + if (!accountId) { + throw new Error("No provider account selected"); + } + const account = providerApiService.getAccounts().find((a) => a.id === accountId); + if (!account) { + throw new Error(`Provider account ${accountId} not found`); + } + const model = account.model; + if (!model) { + throw new Error(`Provider account ${accountId} has no model configured`); + } + const session = sessionStore.getOrCreate(sessionKey); + const messages2 = buildChatMessages(session.messages); + const abortController = new AbortController(); + sessionStore.setActiveRun(sessionKey, runId, abortController); + const provider = createProvider(accountId); + processChatStream(sessionKey, runId, provider, model, messages2, abortController.signal, broadcast).catch( + (err) => { + logManager.error("Unexpected error in processChatStream:", err); + sessionStore.clearActiveRun(sessionKey); + broadcast({ + type: "chat:error", + sessionKey, + runId, + error: err instanceof Error ? err.message : String(err) + }); + } + ); + return { runId }; +} +function handleChatHistory(params) { + return sessionStore.getMessages(params.sessionKey, params.limit ?? 50); +} +function handleChatAbort(params, broadcast) { + const activeRun = sessionStore.getActiveRun(params.sessionKey); + if (activeRun) { + activeRun.abortController.abort(); + sessionStore.clearActiveRun(params.sessionKey); + broadcast({ + type: "chat:aborted", + sessionKey: params.sessionKey, + runId: activeRun.runId + }); + } +} +function handleSessionList() { + return sessionStore.getAllKeys(); +} +function handleProviderList() { + return { + accounts: providerApiService.getAccounts(), + defaultAccountId: providerApiService.getDefault().accountId + }; +} +function handleProviderGetDefault() { + return providerApiService.getDefault(); +} +class GatewayManager { + initialized = false; + async init() { + if (this.initialized) return; + this.initialized = true; + logManager.info("GatewayManager initialized"); + this.broadcast({ type: "gateway:status", status: "connected" }); + } + async rpc(method, params) { + if (!this.initialized) { + await this.init(); + } + logManager.info(`Gateway RPC: ${method}`, params); + switch (method) { + case "chat.send": + return handleChatSend(params, (event) => this.broadcast(event)); + case "chat.history": + return handleChatHistory(params); + case "chat.abort": + return handleChatAbort(params, (event) => this.broadcast(event)); + case "session.list": + return handleSessionList(); + case "provider.list": + return handleProviderList(); + case "provider.getDefault": + return handleProviderGetDefault(); + default: + throw new Error(`Unknown gateway RPC method: ${method}`); + } + } + broadcast(event) { + const mainWindow = electron.BrowserWindow.getAllWindows().find( + (win) => windowManager.getName(win) === "main" + ); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send("gateway:event", event); + } + } + reloadProviders() { + logManager.info("GatewayManager reloading providers"); + } +} +const gatewayManager = new GatewayManager(); appUpdater.init(); +const HOST_API_BASE_URL = "http://8.138.234.141/ingress"; +async function handleLocalProviderApi(path2, method, body) { + const parsedBody = typeof body === "string" && body ? JSON.parse(body) : body; + if (path2 === "/api/provider-vendors" && method === "GET") { + return { success: true, ok: true, json: providerApiService.getVendors(), data: providerApiService.getVendors() }; + } + if (path2 === "/api/provider-accounts" && method === "GET") { + return { success: true, ok: true, json: providerApiService.getAccounts(), data: providerApiService.getAccounts() }; + } + if (path2 === "/api/providers" && method === "GET") { + return { success: true, ok: true, json: providerApiService.getProviders(), data: providerApiService.getProviders() }; + } + if (path2 === "/api/provider-accounts/default" && method === "GET") { + return { success: true, ok: true, json: providerApiService.getDefault(), data: providerApiService.getDefault() }; + } + if (path2 === "/api/provider-accounts" && method === "POST") { + const result = providerApiService.createAccount(parsedBody || {}); + return { success: true, ok: true, json: result, data: result }; + } + if (path2 === "/api/provider-accounts/default" && method === "PUT") { + const result = providerApiService.setDefault(parsedBody || {}); + return { success: result.success, ok: result.success, json: result, data: result }; + } + if (path2.startsWith("/api/provider-accounts/") && method === "PUT") { + const id = decodeURIComponent(path2.replace("/api/provider-accounts/", "")); + const result = providerApiService.updateAccount(id, parsedBody || {}); + return { success: result.success, ok: result.success, json: result, data: result }; + } + if (path2.startsWith("/api/provider-accounts/") && method === "DELETE") { + const id = decodeURIComponent(path2.replace("/api/provider-accounts/", "")); + const result = providerApiService.deleteAccount(id); + return { success: result.success, ok: result.success, json: result, data: result }; + } + if (path2 === "/api/providers/default" && method === "PUT") { + const result = providerApiService.setDefault({ accountId: parsedBody?.providerId }); + return { success: result.success, ok: result.success, json: result, data: result }; + } + if (path2.startsWith("/api/providers/") && path2.endsWith("/api-key") && method === "GET") { + const id = decodeURIComponent(path2.replace("/api/providers/", "").replace("/api-key", "")); + const result = providerApiService.getApiKey(id); + return { success: true, ok: true, json: result, data: result }; + } + if (path2.startsWith("/api/providers/") && method === "PUT") { + const id = decodeURIComponent(path2.replace("/api/providers/", "")); + const result = providerApiService.updateAccount(id, parsedBody || {}); + return { success: result.success, ok: result.success, json: result, data: result }; + } + if (path2.startsWith("/api/providers/") && method === "DELETE") { + const [rawId, query] = path2.replace("/api/providers/", "").split("?"); + const id = decodeURIComponent(rawId); + if (query && query.includes("apiKeyOnly=1")) { + const result2 = providerApiService.deleteApiKey(id); + return { success: result2.success, ok: result2.success, json: result2, data: result2 }; + } + const result = providerApiService.deleteAccount(id); + return { success: result.success, ok: result.success, json: result, data: result }; + } + if (path2 === "/api/providers/validate" && method === "POST") { + const result = await providerApiService.validateApiKey(parsedBody || {}); + return { success: true, ok: true, json: result, data: result }; + } + if (path2 === "/api/usage/recent-token-history" && method === "GET") { + return { success: true, ok: true, json: providerApiService.getUsageHistory(), data: providerApiService.getUsageHistory() }; + } + return null; +} +electron.ipcMain.handle("hostapi:fetch", async (_event, { path: path2, method, headers, body }) => { + const localResult = await handleLocalProviderApi(path2, method || "GET", body); + if (localResult) return localResult; + const url = `${HOST_API_BASE_URL}${path2}`; + try { + const response = await axios({ + url, + method: method || "GET", + headers: { + "Content-Type": "application/json", + ...headers + }, + data: body ?? void 0, + timeout: 3e4 + }); + return { + success: true, + ok: true, + json: response.data, + data: response.data + }; + } catch (error) { + if (error.response) { + return { + success: false, + ok: false, + status: error.response.status, + error: error.response.data?.message || error.message, + text: error.response.statusText, + data: error.response.data + }; + } + return { + success: false, + ok: false, + error: error.message || "Unknown error" + }; + } +}); +electron.ipcMain.handle("gateway:rpc", async (_event, method, params) => { + return gatewayManager.rpc(method, params); +}); if (started) { electron.app.quit(); } electron.app.whenReady().then(() => { + gatewayManager.init(); + onProviderChange(() => { + gatewayManager.reloadProviders(); + }); setupMainWindow(); initScriptStoreService(); runTaskOperationService(); diff --git a/dist-electron/main/main.js.bak b/dist-electron/main/main.js.bak deleted file mode 100644 index 9e75660..0000000 --- a/dist-electron/main/main.js.bak +++ /dev/null @@ -1,4 +0,0 @@ -"use strict";var we=Object.create;var re=Object.defineProperty;var Ae=Object.getOwnPropertyDescriptor;var ye=Object.getOwnPropertyNames;var Se=Object.getPrototypeOf,Ee=Object.prototype.hasOwnProperty;var ve=(n,e,t,i)=>{if(e&&typeof e=="object"||typeof e=="function")for(let s of ye(e))!Ee.call(n,s)&&s!==t&&re(n,s,{get:()=>e[s],enumerable:!(i=Ae(e,s))||i.enumerable});return n};var Ce=(n,e,t)=>(t=n!=null?we(Se(n)):{},ve(e||!n||!n.__esModule?re(t,"default",{value:n,enumerable:!0}):t,n));const o=require("electron"),De=require("openai"),K=require("util"),d=require("electron-log"),y=require("path"),A=require("fs"),de=require("js-base64"),N=require("node:path"),Ie=require("crypto"),Me=require("electron-squirrel-startup"),Re=require("net"),Oe=require("http"),X=require("child_process"),be=require("events");require("bytenode");const C=require("electron-updater");function ue(n){const e=Object.create(null,{[Symbol.toStringTag]:{value:"Module"}});if(n){for(const t in n)if(t!=="default"){const i=Object.getOwnPropertyDescriptor(n,t);Object.defineProperty(e,t,i.get?i:{enumerable:!0,get:()=>n[t]})}}return e.default=n,Object.freeze(e)}const W=ue(y),I=ue(A);var c=(n=>(n.EXTERNAL_OPEN="external-open",n.WINDOW_MINIMIZE="window-minimize",n.WINDOW_MAXIMIZE="window-maximize",n.WINDOW_CLOSE="window-close",n.IS_WINDOW_MAXIMIZED="is-window-maximized",n.APP_SET_FRAMELESS="app:set-frameless",n.APP_LOAD_PAGE="app:load-page",n.TAB_CREATE="tab:create",n.TAB_LIST="tab:list",n.TAB_NAVIGATE="tab:navigate",n.TAB_RELOAD="tab:reload",n.TAB_BACK="tab:back",n.TAB_FORWARD="tab:forward",n.TAB_SWITCH="tab:switch",n.TAB_CLOSE="tab:close",n.LOG_TO_MAIN="log-to-main",n.READ_FILE="read-file",n.INVOKE="ipc:invoke",n.INVOKE_ASYNC="ipc:invokeAsync",n.APP_MINIMIZE="app:minimize",n.APP_MAXIMIZE="app:maximize",n.APP_QUIT="app:quit",n.FILE_READ="file:read",n.FILE_WRITE="file:write",n.GET_WINDOW_ID="get-window-id",n.CUSTOM_EVENT="custom:event",n.TIME_UPDATE="time:update",n.RENDERER_IS_READY="renderer-ready",n.SHOW_CONTEXT_MENU="show-context-menu",n.START_A_DIALOGUE="start-a-dialogue",n.OPEN_WINDOW="open-window",n.LOG_DEBUG="log-debug",n.LOG_INFO="log-info",n.LOG_WARN="log-warn",n.LOG_ERROR="log-error",n.CONFIG_UPDATED="config-updated",n.SET_CONFIG="set-config",n.GET_CONFIG="get-config",n.UPDATE_CONFIG="update-config",n.SET_THEME_MODE="set-theme-mode",n.GET_THEME_MODE="get-theme-mode",n.IS_DARK_THEME="is-dark-theme",n.THEME_MODE_UPDATED="theme-mode-updated",n.EXECUTE_SCRIPT="execute-script",n.OPEN_CHANNEL="open-channel",n.SCRIPT_LIST="script:list",n.SCRIPT_GET="script:get",n.SCRIPT_SAVE="script:save",n.SCRIPT_DELETE="script:delete",n.SCRIPT_TOGGLE="script:toggle",n.SCRIPT_RUN="script:run",n.SCRIPT_RECORD_START="script:record-start",n.SCRIPT_RECORD_STOP="script:record-stop",n.SCRIPT_CODEGEN="script:codegen",n.UPDATE_CHECK="update:check",n.UPDATE_DOWNLOAD="update:download",n.UPDATE_INSTALL="update:install",n.UPDATE_VERSION="update:version",n.UPDATE_STATUS_CHANGED="update:status-changed",n))(c||{});const pe={width:1440,height:900,minWidth:1440,minHeight:900};var E=(n=>(n.MAIN="main",n.SETTING="setting",n.DIALOG="dialog",n.LOADING="loading",n))(E||{}),_=(n=>(n.THEME_MODE="themeMode",n.PRIMARY_COLOR="primaryColor",n.LANGUAGE="language",n.FONT_SIZE="fontSize",n.MINIMIZE_TO_TRAY="minimizeToTray",n.PROVIDER="provider",n.DEFAULT_MODEL="defaultModel",n.AUTO_CHECK_UPDATE="autoCheckUpdate",n.AUTO_DOWNLOAD_UPDATE="autoDownloadUpdate",n))(_||{}),D=(n=>(n.CONVERSATION_ITEM="conversation-item",n.CONVERSATION_LIST="conversation-list",n.MESSAGE_ITEM="message-item",n))(D||{}),O=(n=>(n.PIN="pin",n.RENAME="rename",n.DEL="del",n))(O||{}),w=(n=>(n.NEW_CONVERSATION="newConversation",n.SORT_BY="sortBy",n.SORT_BY_CREATE_TIME="sortByCreateTime",n.SORT_BY_UPDATE_TIME="sortByUpdateTime",n.SORT_BY_NAME="sortByName",n.SORT_BY_MODEL="sortByModel",n.SORT_ASCENDING="sortAscending",n.SORT_DESCENDING="sortDescending",n.BATCH_OPERATIONS="batchOperations",n))(w||{}),b=(n=>(n.COPY="copy",n.DELETE="delete",n.SELECT="select",n))(b||{});class Ne{}const Le=K.promisify(I.readdir),Pe=K.promisify(I.stat),We=K.promisify(I.unlink);class J{static _instance;LOG_RETENTION_DAYS=7;CLEANUP_INTERVAL_MS=1440*60*1e3;constructor(){const e=W.join(o.app.getPath("userData"),"logs");try{I.existsSync(e)||I.mkdirSync(e,{recursive:!0})}catch(t){this.error("Failed to create log directory:",t)}d.transports.file.resolvePathFn=()=>{const t=new Date,i=`${t.getFullYear()}-${String(t.getMonth()+1).padStart(2,"0")}-${String(t.getDate()).padStart(2,"0")}`;return W.join(e,`${i}.log`)},d.transports.file.format="[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] {text}",d.transports.file.maxSize=10*1024*1024,d.transports.console.level=process.env.NODE_ENV==="development"?"debug":"info",d.transports.file.level="debug",this._setupIpcEvents(),this._rewriteConsole(),this.info("LogService initialized successfully."),this._cleanupOldLogs(),setInterval(()=>this._cleanupOldLogs(),this.CLEANUP_INTERVAL_MS)}_setupIpcEvents(){o.ipcMain.on(c.LOG_DEBUG,(e,t,...i)=>this.debug(t,...i)),o.ipcMain.on(c.LOG_INFO,(e,t,...i)=>this.info(t,...i)),o.ipcMain.on(c.LOG_WARN,(e,t,...i)=>this.warn(t,...i)),o.ipcMain.on(c.LOG_ERROR,(e,t,...i)=>this.error(t,...i))}_rewriteConsole(){console.debug=d.debug,console.log=d.info,console.info=d.info,console.warn=d.warn,console.error=d.error}async _cleanupOldLogs(){try{const e=W.join(o.app.getPath("userData"),"logs");if(!I.existsSync(e))return;const t=new Date,i=new Date(t.getTime()-this.LOG_RETENTION_DAYS*24*60*60*1e3),s=await Le(e);let r=0;for(const a of s){if(!a.endsWith(".log"))continue;const l=W.join(e,a);try{const g=await Pe(l);g.isFile()&&g.birthtime0&&this.info(`Successfully cleaned up ${r} old log files.`)}catch(e){this.error("Failed to cleanup old logs:",e)}}static getInstance(){return this._instance||(this._instance=new J),this._instance}debug(e,...t){d.debug(e,...t)}info(e,...t){d.info(e,...t)}warn(e,...t){d.warn(e,...t)}error(e,...t){d.error(e,...t)}logApiRequest(e,t={},i="POST"){this.info(`API Request: ${e}, Method: ${i}, Request: ${JSON.stringify(t)}`)}logApiResponse(e,t={},i=200,s=0){i>=400?this.error(`API Error Response: ${e}, Status: ${i}, Response Time: ${s}ms, Response: ${JSON.stringify(t)}`):this.debug(`API Response: ${e}, Status: ${i}, Response Time: ${s}ms, Response: ${JSON.stringify(t)}`)}logUserOperation(e,t="unknown",i={}){this.info(`User Operation: ${e} by ${t}, Details: ${JSON.stringify(i)}`)}}const h=J.getInstance();function Ue(n){const e=n.choices[0];return{isEnd:e?.finish_reason==="stop",result:e?.delta?.content??""}}class ke extends Ne{client;constructor(e,t){super(),this.client=new De({apiKey:e,baseURL:t})}async chat(e,t){const i=Date.now(),s=e[e.length-1];h.logApiRequest("chat.completions.create",{model:t,lastMessage:s?.content?.substring(0,100)+(s?.content?.length>100?"...":""),messageCount:e.length},"POST");try{const r=await this.client.chat.completions.create({model:t,messages:e,stream:!0}),a=Date.now()-i;return h.logApiResponse("chat.completions.create",{success:!0},200,a),{async*[Symbol.asyncIterator](){for await(const l of r)yield Ue(l)}}}catch(r){const a=Date.now()-i;throw h.logApiResponse("chat.completions.create",{error:r instanceof Error?r.message:String(r)},500,a),r}}}function he(n,e){let t=null;return function(...i){t&&clearTimeout(t),t=setTimeout(()=>{n.apply(this,i)},e)}}function Z(n){if(n===null||typeof n!="object")return n;if(Array.isArray(n))return n.map(t=>Z(t));const e=Object.assign({},n);for(const t in e)Object.prototype.hasOwnProperty.call(e,t)&&(e[t]=Z(e[t]));return e}function xe(n){try{return JSON.parse(JSON.stringify(n))}catch(e){return console.error("simpleCloneDeep failed:",e),n}}function Be(n){try{return JSON.parse(de.decode(n))}catch(e){return console.error("parseOpenAISetting failed:",e),{}}}const Ge={[_.THEME_MODE]:"system",[_.PRIMARY_COLOR]:"#BB5BE7",[_.LANGUAGE]:"zh",[_.FONT_SIZE]:14,[_.MINIMIZE_TO_TRAY]:!1,[_.PROVIDER]:"",[_.DEFAULT_MODEL]:null};class Q{static _instance;_config;_configPath;_defaultConfig=Ge;_listeners=[];constructor(){this._configPath=W.join(o.app.getPath("userData"),"config.json"),this._config=this._loadConfig(),this._setupIpcEvents(),h.info("ConfigService initialized successfully.")}_setupIpcEvents(){const t=he(i=>this.update(i),200);o.ipcMain.handle(c.GET_CONFIG,(i,s)=>this.get(s)),o.ipcMain.on(c.SET_CONFIG,(i,s,r)=>this.set(s,r)),o.ipcMain.on(c.UPDATE_CONFIG,(i,s)=>t(s))}static getInstance(){return this._instance||(this._instance=new Q),this._instance}_loadConfig(){try{if(I.existsSync(this._configPath)){const e=I.readFileSync(this._configPath,"utf-8"),t={...this._defaultConfig,...JSON.parse(e)};return h.info("Config loaded successfully from:",this._configPath),t}}catch(e){h.error("Failed to load config:",e)}return{...this._defaultConfig}}_saveConfig(){try{I.mkdirSync(W.dirname(this._configPath),{recursive:!0}),I.writeFileSync(this._configPath,JSON.stringify(this._config,null,2),"utf-8"),this._notifyListeners(),h.info("Config saved successfully to:",this._configPath)}catch(e){h.error("Failed to save config:",e)}}_notifyListeners(){o.BrowserWindow.getAllWindows().forEach(e=>e.webContents.send(c.CONFIG_UPDATED,this._config)),this._listeners.forEach(e=>e({...this._config}))}getConfig(){return xe(this._config)}get(e){return this._config[e]}set(e,t,i=!0){!(e in this._config)||this._config[e]===t||(this._config[e]=t,h.debug(`Config set: ${e} = ${t}`),i&&this._saveConfig())}update(e,t=!0){this._config={...this._config,...e},t&&this._saveConfig()}resetToDefault(){this._config={...this._defaultConfig},h.info("Config reset to default."),this._saveConfig()}onConfigChange(e){return this._listeners.push(e),()=>this._listeners=this._listeners.filter(t=>t!==e)}}const v=Q.getInstance();process.env.BIGMODEL_API_KEY,new Date().getTime(),new Date().getTime(),process.env.DEEPSEEK_API_KEY,new Date().getTime(),new Date().getTime(),process.env.SILICONFLOW_API_KEY,new Date().getTime(),new Date().getTime(),process.env.QIANFAN_API_KEY,new Date().getTime(),new Date().getTime();const $e=()=>{let n=[],e=!1;const t=v.get(_.PROVIDER),i=s=>({...s,openAISetting:typeof s.openAISetting=="string"?Be(s.openAISetting??""):s.openAISetting});try{n=JSON.parse(de.decode(t)),e=!0}catch(s){h.error(`parse base64 provider failed: ${s}`)}if(!e)try{n=JSON.parse(t)}catch(s){h.error(`parse provider failed: ${s}`)}if(n.length)return n.map(i)},He=()=>{try{return $e()}catch(n){return h.error(`get provider config failed: ${n}`),null}};function Fe(n){const e=He();if(!e)throw new Error("provider config not found");for(const t of e)if(t.name===n){if(!t.openAISetting?.apiKey||!t.openAISetting?.baseURL)throw new Error("apiKey or baseURL not found");return new ke(t.openAISetting.apiKey,t.openAISetting.baseURL)}}const ze={minimize:"Minimize",maximize:"Maximize",restore:"Restore",close:"Close"},je={welcome:{helloMessage:"Hello, I'm Diona"},conversation:{placeholder:"Type a message...",newConversation:"New Conversation",selectModel:"Please select model",createConversation:"Create Conversation",searchPlaceholder:"Search conversations...",goSettings:"Go to",settings:"Settings Window",addModel:"to add a model",dialog:{title:"Confirm Deletion",content:"Are you sure you want to delete this conversation?",content_1:"Are you sure you want to delete the selected conversations? This action cannot be undone."},operations:{pin:"Pin Selected",del:"Delete Selected",selectAll:"Select All",cancel:"Cancel"}},sidebar:{conversations:"Conversations",settings:"Settings",help:"Help"},message:{dialog:{title:"Confirm Deletion",messageDelete:"Are you sure you want to delete this message?",batchDelete:"Are you sure you want to delete the selected messages?",copySuccess:"Copied successfully"},batchActions:{deleteSelected:"Delete Selected"},rendering:"Thinking...",stoppedGeneration:"(Stopped generating)",sending:"Sending",stopGeneration:"Stop generating",send:"Send"}},qe={cancel:"Cancel",confirm:"Confirm"},Ye={title:"Settings",base:"Basic Settings",provider:{modelConfig:"Model Configuration"},theme:{label:"Theme Settings",dark:"Dark Theme",light:"Light Theme",system:"System Theme",primaryColor:"Primary Color"},appearance:{fontSize:"Font Size",fontSizeOptions:{10:"Tiny (10px)",12:"Small (12px)",14:"Normal (14px)",16:"Medium (16px)",18:"Large (18px)",20:"Larger (20px)",24:"Extra Large (24px)"}},behavior:{minimizeToTray:"Minimize to tray when closed"},language:{label:"Language"},providers:{defaultModel:"Default Model",apiKey:"API Key",apiUrl:"API URL"}},Xe={conversation:{newConversation:"New Conversation",sortBy:"Sort By",sortByCreateTime:"Sort by Creation Time",sortByUpdateTime:"Sort by Update Time",sortByName:"Sort by Name",sortByModel:"Sort by Model",sortAscending:"Ascending",sortDescending:"Descending",pinConversation:"Pin Conversation",unpinConversation:"Unpin Conversation",renameConversation:"Rename Conversation",delConversation:"Delete Conversation",batchOperations:"Batch Operations"},message:{copyMessage:"Copy Message",deleteMessage:"Delete Message",selectMessage:"Select Message"}},Ze={tooltip:"Diona Application",showWindow:"Show Window",exit:"Exit"},Ke={justNow:"Just now",minutes:"{count} minutes ago",hours:"{count} hours ago",days:"{count} days ago",months:"{count} months ago",years:"{count} years ago",weekday:{sun:"Sunday",mon:"Monday",tue:"Tuesday",wed:"Wednesday",thu:"Thursday",fri:"Friday",sat:"Saturday"}},Je={title:"Diona Application"},Qe={window:ze,main:je,dialog:qe,settings:Ye,menu:Xe,tray:Ze,timeAgo:Ke,app:Je},Ve={minimize:"最小化",maximize:"最大化",restore:"还原",close:"关闭"},et={welcome:{helloMessage:"你好,我是迪奥娜"},conversation:{placeholder:"输入消息...",newConversation:"新对话",selectModel:"请选择模型",createConversation:"创建对话",searchPlaceholder:"搜索对话...",goSettings:"快去",settings:"设置窗口",addModel:"添加模型",dialog:{title:"确认删除",content:"确定要删除这个对话吗?",content_1:"确定要删除选中的对话吗?此操作不可撤销。"},operations:{pin:"置顶所选",del:"删除所选",selectAll:"全选",cancel:"取消"}},sidebar:{conversations:"对话",settings:"设置",help:"帮助"},message:{dialog:{title:"确认删除",messageDelete:"确认删除该条消息?",batchDelete:"确认删除选中的消息?",copySuccess:"复制成功"},batchActions:{deleteSelected:"删除选中项"},rendering:"思考中...",stoppedGeneration:"(已停止生成)",sending:"发送中",stopGeneration:"停止生成",send:"发送"}},tt={cancel:"取消",confirm:"确认"},nt={title:"设置",base:"基础设置",provider:{modelConfig:"模型配置"},providers:{defaultModel:"默认模型",apiKey:"API密钥",apiUrl:"API地址"},theme:{label:"主题设置",dark:"深色主题",light:"浅色主题",system:"跟随系统",primaryColor:"主题颜色"},appearance:{fontSize:"字体大小",fontSizeOptions:{10:"极小 (10px)",12:"小 (12px)",14:"正常 (14px)",16:"中 (16px)",18:"大 (18px)",20:"较大 (20px)",24:"超大 (24px)"}},behavior:{minimizeToTray:"关闭时最小化到托盘"},language:{label:"语言设置"}},it={conversation:{newConversation:"新建对话",sortBy:"排序方式",sortByCreateTime:"按创建时间排序",sortByUpdateTime:"按更新时间排序",sortByName:"按名称排序",sortByModel:"按模型排序",sortAscending:"递增",sortDescending:"递减",pinConversation:"置顶对话",unpinConversation:"取消置顶",renameConversation:"重命名对话",delConversation:"删除对话",batchOperations:"批量操作"},message:{copyMessage:"复制消息",deleteMessage:"删除消息",selectMessage:"选择消息"}},st={tooltip:"迪奥娜",showWindow:"显示窗口",exit:"退出"},rt={justNow:"刚刚",minutes:"{count}分钟前",hours:"{count}小时前",days:"{count}天前",months:"{count}个月前",years:"{count}年前",weekday:{sun:"星期日",mon:"星期一",tue:"星期二",wed:"星期三",thu:"星期四",fri:"星期五",sat:"星期六"}},ot={title:"迪奥娜"},at={window:Ve,main:et,dialog:tt,settings:nt,menu:it,tray:st,timeAgo:rt,app:ot},ct={en:Qe,zh:at};function H(){return n=>{if(n)try{const e=n?.split(".");let t=ct[v.get(_.LANGUAGE)];for(const i of e)t=t[i];return t}catch(e){return h.error("failed to translate key:",n,e),n}}}let G;function ge(){if(G!=null)return G;const n=o.app.getAppPath();return G=N.join(n,"resources","icons","icon.ico"),G}class V{static _instance;_isDark=o.nativeTheme.shouldUseDarkColors;constructor(){const e=v.get(_.THEME_MODE);e&&(o.nativeTheme.themeSource=e,this._isDark=o.nativeTheme.shouldUseDarkColors),this._setupIpcEvent(),h.info("ThemeService initialized successfully.")}_setupIpcEvent(){o.ipcMain.handle(c.SET_THEME_MODE,(e,t)=>(o.nativeTheme.themeSource=t,v.set(_.THEME_MODE,t),o.nativeTheme.shouldUseDarkColors)),o.ipcMain.handle(c.GET_THEME_MODE,()=>o.nativeTheme.themeSource),o.ipcMain.handle(c.IS_DARK_THEME,()=>o.nativeTheme.shouldUseDarkColors),o.nativeTheme.on("updated",()=>{this._isDark=o.nativeTheme.shouldUseDarkColors,o.BrowserWindow.getAllWindows().forEach(e=>e.webContents.send(c.THEME_MODE_UPDATED,this._isDark))})}static getInstance(){return this._instance||(this._instance=new V),this._instance}get isDark(){return this._isDark}get themeMode(){return o.nativeTheme.themeSource}}const oe=V.getInstance(),lt={frame:!1,titleBarStyle:"hidden",trafficLightPosition:{x:-100,y:-100},show:!1,title:"NIANXX",darkTheme:oe.isDark,backgroundColor:oe.isDark?"#2C2C2C":"#FFFFFF",webPreferences:{nodeIntegration:!1,contextIsolation:!0,sandbox:!0,backgroundThrottling:!1,preload:MAIN_WINDOW_VITE_DEV_SERVER_URL?N.join(process.cwd(),"dist-electron/preload/preload.js"):N.join(__dirname,"preload.js")}};class ee{static _instance;_logo=ge();isDev=!!MAIN_WINDOW_VITE_DEV_SERVER_URL;_winStates={main:{instance:void 0,isHidden:!1,onCreate:[],onClosed:[]},setting:{instance:void 0,isHidden:!1,onCreate:[],onClosed:[]},dialog:{instance:void 0,isHidden:!1,onCreate:[],onClosed:[]},login:{instance:void 0,isHidden:!1,onCreate:[],onClosed:[]},loading:{instance:void 0,isHidden:!1,onCreate:[],onClosed:[]}};constructor(){this._setupIpcEvents(),h.info("WindowService initialized successfully.")}_isReallyClose(e){return e===E.MAIN?v.get(_.MINIMIZE_TO_TRAY)===!1:e!==E.SETTING}_setupIpcEvents(){const e=r=>{const a=o.BrowserWindow.fromWebContents(r.sender),l=this.getName(a);this.close(a,this._isReallyClose(l))},t=r=>{o.BrowserWindow.fromWebContents(r.sender)?.minimize()},i=r=>{this.toggleMax(o.BrowserWindow.fromWebContents(r.sender))},s=r=>o.BrowserWindow.fromWebContents(r.sender)?.isMaximized()??!1;o.ipcMain.on(c.WINDOW_CLOSE,e),o.ipcMain.on(c.WINDOW_MINIMIZE,t),o.ipcMain.on(c.WINDOW_MAXIMIZE,i),o.ipcMain.handle(c.IS_WINDOW_MAXIMIZED,s),o.ipcMain.handle(c.APP_LOAD_PAGE,(r,a)=>{const l=o.BrowserWindow.fromWebContents(r.sender);l&&this._loadPage(l,a)})}static getInstance(){return this._instance||(this._instance=new ee),this._instance}create(e,t,i){if(this.get(e))return;const s=this._isHiddenWin(e);let r=this._createWinInstance(e,{...t,...i});return this.isDev&&r.webContents.openDevTools(),!s&&this._setupWinLifecycle(r,e)._loadWindowTemplate(r,e),this._listenWinReady({win:r,isHiddenWin:s,size:t}),s||(this._winStates[e].instance=r,this._winStates[e].onCreate.forEach(a=>a(r))),s&&(this._winStates[e].isHidden=!1,h.info(`Hidden window show: ${e}`)),r}_setupWinLifecycle(e,t){const i=he(()=>!e?.isDestroyed()&&e?.webContents?.send(c.WINDOW_MAXIMIZE+"back",e?.isMaximized()),80);return e.once("closed",()=>{this._winStates[t].onClosed.forEach(s=>s(e)),e?.destroy(),e?.removeListener("resize",i),this._winStates[t].instance=void 0,this._winStates[t].isHidden=!1,h.info(`Window closed: ${t}`)}),e.on("resize",i),this}_listenWinReady(e){const t=()=>{e.win?.once("show",()=>setTimeout(()=>this._applySizeConstraints(e.win,e.size),2)),e.win?.show()};e.isHiddenWin?t():this._addLoadingView(e.win,e.size)?.(t)}_addLoadingView(e,t){let i=!1;const s=r=>{r.sender!==e?.webContents||i||(i=!0,o.ipcMain.removeListener(c.RENDERER_IS_READY,s))};return o.ipcMain.on(c.RENDERER_IS_READY,s),r=>{r()}}_applySizeConstraints(e,t){t.maxHeight&&t.maxWidth&&e.setMaximumSize(t.maxWidth,t.maxHeight),t.minHeight&&t.minWidth&&e.setMinimumSize(t.minWidth,t.minHeight)}_loadPage(e,t){if(MAIN_WINDOW_VITE_DEV_SERVER_URL)return e.loadURL(`${MAIN_WINDOW_VITE_DEV_SERVER_URL}/${t}.html`);e.loadFile(N.join(app.getAppPath(),"dist",`${t}.html`))}_loadWindowTemplate(e,t){this._loadPage(e,"index")}_handleCloseWindowState(e,t){const i=this.getName(e);i&&(t?this._winStates[i].instance=void 0:this._winStates[i].isHidden=!0),setTimeout(()=>{e[t?"close":"hide"]?.(),this._checkAndCloseAllWinodws()},210)}_checkAndCloseAllWinodws(){if(!this._winStates[E.MAIN].instance||this._winStates[E.MAIN].instance?.isDestroyed())return Object.values(this._winStates).forEach(t=>t?.instance?.close());if(!v.get(_.MINIMIZE_TO_TRAY)&&!this.get(E.MAIN)?.isVisible())return Object.values(this._winStates).forEach(t=>!t?.instance?.isVisible()&&t?.instance?.close())}_isHiddenWin(e){return this._winStates[e]&&this._winStates[e].isHidden}_createWinInstance(e,t){return this._isHiddenWin(e)?this._winStates[e].instance:new o.BrowserWindow({...lt,icon:this._logo,...t})}focus(e){if(!e)return;const t=this.getName(e);e?.isMaximized()?(e?.restore(),h.debug(`Window ${t} restored and focused`)):h.debug(`Window ${t} focused`),e?.focus()}close(e,t=!0){if(!e)return;const i=this.getName(e);h.info(`Close window: ${i}, really: ${t}`),this._handleCloseWindowState(e,t)}toggleMax(e){e&&(e.isMaximized()?e.unmaximize():e.maximize())}getName(e){if(e){for(const[t,i]of Object.entries(this._winStates))if(i?.instance===e)return t}}get(e){if(!this._winStates[e].isHidden)return this._winStates[e].instance}onWindowCreate(e,t){this._winStates[e].onCreate.push(t)}onWindowClosed(e,t){this._winStates[e].onClosed.push(t)}}const B=ee.getInstance();let F=H();class te{static _instance;_menuTemplates=new Map;_currentMenu=void 0;constructor(){this._setupIpcListener(),this._setupLanguageChangeListener(),h.info("MenuService initialized successfully.")}_setupIpcListener(){o.ipcMain.handle(c.SHOW_CONTEXT_MENU,(e,t,i)=>new Promise(s=>this.showMenu(t,()=>s(!0),i)))}_setupLanguageChangeListener(){v.onConfigChange(e=>{e[_.LANGUAGE]&&(F=H())})}static getInstance(){return this._instance||(this._instance=new te),this._instance}register(e,t){return this._menuTemplates.set(e,t),e}showMenu(e,t,i){if(this._currentMenu)return;const s=Z(this._menuTemplates.get(e));if(!s){h.warn(`Menu ${e} not found.`),t?.();return}let r=[];try{r=Array.isArray(i)?i:JSON.parse(i??"[]")}catch(u){h.error(`Failed to parse dynamicOptions for menu ${e}: ${u}`)}const a=u=>u.submenu?{...u,label:F(u?.label)??void 0,submenu:u.submenu?.map(f=>a(f))}:{...u,label:F(u?.label)??void 0},l=s.map(u=>{if(!Array.isArray(r)||!r.length)return a(u);const f=r.find(m=>m.id===u.id);if(f){const m={...u,...f};return a(m)}return u.submenu?a({...u,submenu:u.submenu?.map(m=>{const T=r.find(p=>p.id===m.id);return{...m,...T}})}):a(u)}),g=o.Menu.buildFromTemplate(l);this._currentMenu=g,g.popup({callback:()=>{this._currentMenu=void 0,t?.()}})}destroyMenu(e){this._menuTemplates.delete(e)}destroyed(){this._menuTemplates.clear(),this._currentMenu=void 0}}const z=te.getInstance();let x=H();class ne{static _instance;_tray=null;_removeLanguageListener;_setupLanguageChangeListener(){this._removeLanguageListener=v.onConfigChange(e=>{e[_.LANGUAGE]&&(x=H(),this._tray&&this._updateTray())})}_updateTray(){this._tray||(this._tray=new o.Tray(ge()));const e=()=>{const t=B.get(E.MAIN);if(t&&!t?.isDestroyed()&&t?.isVisible()&&!t?.isFocused())return t.focus();if(t?.isMinimized())return t?.restore();t?.isVisible()&&t?.isFocused()||B.create(E.MAIN,pe)};this._tray.setToolTip(x("tray.tooltip")??"Diona Application"),this._tray.setContextMenu(o.Menu.buildFromTemplate([{label:x("tray.showWindow"),accelerator:"CmdOrCtrl+N",click:e},{type:"separator"},{label:x("settings.title"),click:()=>o.ipcMain.emit(`${c.OPEN_WINDOW}:${E.SETTING}`)},{role:"quit",label:x("tray.exit")}])),this._tray.removeAllListeners("click"),this._tray.on("click",e)}constructor(){this._setupLanguageChangeListener(),h.info("TrayService initialized successfully.")}static getInstance(){return this._instance||(this._instance=new ne),this._instance}create(){this._tray||(this._updateTray(),o.app.on("quit",()=>{this.destroy()}))}destroy(){this._tray?.destroy(),this._tray=null,this._removeLanguageListener&&(this._removeLanguageListener(),this._removeLanguageListener=void 0)}}const ae=ne.getInstance();class dt{win;views=new Map;activeId=null;skipNextNavigate=new Map;enabled=!1;constructor(e){this.win=e,this.win.on("resize",()=>this.updateActiveBounds()),this._setupIpcEvents()}_setupIpcEvents(){o.ipcMain.handle(c.TAB_CREATE,(e,t)=>this.create(t)),o.ipcMain.handle(c.TAB_LIST,()=>this.list()),o.ipcMain.handle(c.TAB_NAVIGATE,(e,{tabId:t,url:i})=>{this.navigate(t,i)}),o.ipcMain.handle(c.TAB_RELOAD,(e,t)=>{this.reload(t)}),o.ipcMain.handle(c.TAB_BACK,(e,t)=>{this.goBack(t)}),o.ipcMain.handle(c.TAB_FORWARD,(e,t)=>{this.goForward(t)}),o.ipcMain.handle(c.TAB_SWITCH,(e,t)=>{this.switch(t)}),o.ipcMain.handle(c.TAB_CLOSE,(e,t)=>{this.close(t)})}enable(){this.enabled=!0,this.updateActiveBounds(),this.activeId&&this.attach(this.activeId)}disable(){this.enabled=!1;const e=this.activeId?this.views.get(this.activeId):null;e&&this.win.removeBrowserView(e)}destroy(){this.disable(),this.views.forEach(e=>{e.webContents.destroy()}),this.views.clear(),o.ipcMain.removeHandler(c.TAB_CREATE),o.ipcMain.removeHandler(c.TAB_LIST),o.ipcMain.removeHandler(c.TAB_NAVIGATE),o.ipcMain.removeHandler(c.TAB_RELOAD),o.ipcMain.removeHandler(c.TAB_BACK),o.ipcMain.removeHandler(c.TAB_FORWARD),o.ipcMain.removeHandler(c.TAB_SWITCH),o.ipcMain.removeHandler(c.TAB_CLOSE)}list(){return Array.from(this.views.entries()).map(([e,t])=>this.info(e,t))}create(e,t=!0){const i=Ie.randomUUID(),s=new o.BrowserView({webPreferences:{nodeIntegration:!1,contextIsolation:!0,sandbox:!0,preload:MAIN_WINDOW_VITE_DEV_SERVER_URL?N.join(process.cwd(),"dist-electron/preload/preload.js"):N.join(__dirname,"preload.js")}});this.views.set(i,s),this.enabled&&t&&this.attach(i);const r=e&&e.length>0?e:"about:blank";s.webContents.loadURL(r),this.bindEvents(i,s);const a=this.info(i,s);return this.win.webContents.send("tab-created",a),a}switch(e){this.views.has(e)&&(this.enabled&&this.attach(e),this.win.webContents.send("tab-switched",{tabId:e}))}close(e){const t=this.views.get(e);if(!t)return;this.activeId===e&&(this.win.removeBrowserView(t),this.activeId=null),t.webContents.destroy(),this.views.delete(e),this.win.webContents.send("tab-closed",{tabId:e});const i=this.views.keys().next().value;i&&this.switch(i)}navigate(e,t){const i=this.views.get(e);i&&(this.skipNextNavigate.set(e,!0),i.webContents.loadURL(t))}reload(e){const t=this.views.get(e);t&&t.webContents.reload()}goBack(e){const t=this.views.get(e);t&&t.webContents.canGoBack()&&t.webContents.goBack()}goForward(e){const t=this.views.get(e);t&&t.webContents.canGoForward()&&t.webContents.goForward()}attach(e){if(!this.enabled)return;const t=this.views.get(e);if(t){if(this.activeId&&this.views.get(this.activeId)){const i=this.views.get(this.activeId);this.win.removeBrowserView(i)}this.activeId=e,this.win.addBrowserView(t),this.updateActiveBounds()}}updateActiveBounds(){if(!this.enabled||!this.activeId)return;const e=this.views.get(this.activeId);if(!e)return;const[t,i]=this.win.getContentSize(),s=88,r=8,a=488,l=r,g=s+r,u=t-a-r,f=i-s-r*2;e.setBounds({x:l,y:g,width:Math.max(0,u),height:Math.max(0,f)})}bindEvents(e,t){const i=()=>this.win.webContents.send("tab-updated",this.info(e,t));t.webContents.on("did-start-loading",i),t.webContents.on("did-stop-loading",i),t.webContents.on("did-finish-load",i),t.webContents.on("page-title-updated",i),t.webContents.on("did-navigate",i),t.webContents.on("did-navigate-in-page",i),t.webContents.on("will-navigate",(s,r)=>{if(this.skipNextNavigate.get(e)){this.skipNextNavigate.set(e,!1);return}s.preventDefault(),this.create(r)}),t.webContents.setWindowOpenHandler(({url:s})=>(this.create(s),{action:"deny"}))}info(e,t){const i=t.webContents;return{id:e,url:i.getURL(),title:i.getTitle(),isLoading:i.isLoading(),canGoBack:i.canGoBack(),canGoForward:i.canGoForward()}}}const ce=n=>{if(n){ae.create();return}ae.destroy()},ut=n=>{const e=s=>{h.logUserOperation(`${c.SHOW_CONTEXT_MENU}:${D.CONVERSATION_ITEM}-${s}`),n.webContents.send(`${c.SHOW_CONTEXT_MENU}:${D.CONVERSATION_ITEM}`,s)};z.register(D.CONVERSATION_ITEM,[{id:O.PIN,label:"menu.conversation.pinConversation",click:()=>e(O.PIN)},{id:O.RENAME,label:"menu.conversation.renameConversation",click:()=>e(O.RENAME)},{id:O.DEL,label:"menu.conversation.delConversation",click:()=>e(O.DEL)}]);const t=s=>{h.logUserOperation(`${c.SHOW_CONTEXT_MENU}:${D.CONVERSATION_LIST}-${s}`),n.webContents.send(`${c.SHOW_CONTEXT_MENU}:${D.CONVERSATION_LIST}`,s)};z.register(D.CONVERSATION_LIST,[{id:w.NEW_CONVERSATION,label:"menu.conversation.newConversation",click:()=>t(w.NEW_CONVERSATION)},{type:"separator"},{id:w.SORT_BY,label:"menu.conversation.sortBy",submenu:[{id:w.SORT_BY_CREATE_TIME,label:"menu.conversation.sortByCreateTime",type:"radio",checked:!1,click:()=>t(w.SORT_BY_CREATE_TIME)},{id:w.SORT_BY_UPDATE_TIME,label:"menu.conversation.sortByUpdateTime",type:"radio",checked:!1,click:()=>t(w.SORT_BY_UPDATE_TIME)},{id:w.SORT_BY_NAME,label:"menu.conversation.sortByName",type:"radio",checked:!1,click:()=>t(w.SORT_BY_NAME)},{id:w.SORT_BY_MODEL,label:"menu.conversation.sortByModel",type:"radio",checked:!1,click:()=>t(w.SORT_BY_MODEL)},{type:"separator"},{id:w.SORT_ASCENDING,label:"menu.conversation.sortAscending",type:"radio",checked:!1,click:()=>t(w.SORT_ASCENDING)},{id:w.SORT_DESCENDING,label:"menu.conversation.sortDescending",type:"radio",checked:!1,click:()=>t(w.SORT_DESCENDING)}]},{id:w.BATCH_OPERATIONS,label:"menu.conversation.batchOperations",click:()=>t(w.BATCH_OPERATIONS)}]);const i=s=>{h.logUserOperation(`${c.SHOW_CONTEXT_MENU}:${D.MESSAGE_ITEM}-${s}`),n.webContents.send(`${c.SHOW_CONTEXT_MENU}:${D.MESSAGE_ITEM}`,s)};z.register(D.MESSAGE_ITEM,[{id:b.COPY,label:"menu.message.copyMessage",click:()=>i(b.COPY)},{id:b.SELECT,label:"menu.message.selectMessage",click:()=>i(b.SELECT)},{type:"separator"},{id:b.DELETE,label:"menu.message.deleteMessage",click:()=>i(b.DELETE)}])};function fe(){B.onWindowCreate(E.MAIN,n=>{let e=v.get(_.MINIMIZE_TO_TRAY);v.onConfigChange(i=>{e!==i[_.MINIMIZE_TO_TRAY]&&(e=i[_.MINIMIZE_TO_TRAY],ce(e))}),ce(e),ut(n);const t=new dt(n);t.enable(),n.on("closed",()=>{t.destroy()})}),B.create(E.MAIN,pe),o.ipcMain.on(c.START_A_DIALOGUE,async(n,e)=>{const{providerName:t,messages:i,messageId:s,selectedModel:r}=e,a=B.get(E.MAIN);if(!a)throw new Error("mainWindow not found");try{const g=await Fe(t)?.chat(i,r);if(!g)throw new Error("chunks or stream not found");for await(const u of g){const f={messageId:s,data:u};a.webContents.send(c.START_A_DIALOGUE+"back"+s,f)}}catch(l){const g={messageId:s,data:{isEnd:!0,isError:!0,result:l instanceof Error?l.message:String(l)}};a.webContents.send(c.START_A_DIALOGUE+"back"+s,g)}})}function pt(){if(process.platform==="win32")return"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe";if(process.platform==="darwin")return"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";if(process.platform==="linux")return"google-chrome"}function ht(n){return N.join(o.app.getPath("userData"),"profiles",n)}function gt(n){return new Promise(e=>{const t=Re.createServer();t.once("error",i=>e(!0)),t.once("listening",()=>{t.close(),e(!1)}),t.listen(n)})}async function ft(){try{return new Promise(n=>{const e=Oe.get("http://127.0.0.1:9222/json/version",t=>{n(t.statusCode===200)});e.on("error",()=>n(!1)),e.setTimeout(1e3,()=>{e.destroy(),n(!1)})})}catch{return!1}}async function _t(){const n=pt(),e=ht("default");if(d.info(`Launching Chrome with user data dir: ${e}`),await gt(9222)){d.info("Chrome already running on port 9222, skip launching.");return}if(await ft()){d.info("Chrome already running, skip launching.");return}return new Promise((i,s)=>{X.spawn(n,["--remote-debugging-port=9222","--window-size=1920,1080","--window-position=0,0","--no-first-run",`--user-data-dir=${e}`,"--no-default-browser-check","about:blank"],{detached:!0,stdio:"ignore"}).on("error",s),setTimeout(()=>{i(0)},1e3)})}class _e extends be.EventEmitter{async executeScript(e,t){const s=(r,a)=>{const l=r+a;return l.length>32768?l.slice(l.length-32768):l};return await new Promise(r=>{try{const a=t?.roomType??"",l=t?.startTime??"",g=t?.endTime??"",u=t?.operation??"",f=t?.tabIndex??"",m=t?.channels??"",T=t?.startTabIndex??"",p=o.utilityProcess.fork(e,[],{env:{...process.env,ROOM_TYPE:String(a),START_DATE:String(l),END_DATE:String(g),OPERATION:String(u),TAB_INDEX:String(f),CHANNELS:typeof m=="string"?m:JSON.stringify(m),START_TAB_INDEX:String(T)},stdio:"pipe"});let M="",k="";p.stdout&&p.stdout.on("data",S=>{const R=S.toString();M=s(M,R),d.info(`stdout: ${R}`)}),p.stderr&&p.stderr.on("data",S=>{const R=S.toString();k=s(k,R),d.info(`stderr: ${R}`)}),p.on("exit",S=>{d.info(`子进程退出,退出码 ${S}`),r({success:S===0,exitCode:S,stdoutTail:M,stderrTail:k,...S===0?{}:{error:`Script exited with code ${S}`}})})}catch(a){r({success:!1,exitCode:null,stdoutTail:"",stderrTail:"",error:a?.message||"运行 Node 脚本时出错"})}})}}const mt="scripts.meta.json";function L(){return o.app.isPackaged?y.join(__dirname,"scripts"):y.join(process.cwd(),"electron/scripts")}function me(){const n=L();A.existsSync(n)||A.mkdirSync(n,{recursive:!0})}function ie(){return y.join(L(),mt)}function P(){const n=ie();if(!A.existsSync(n))return{scripts:[]};try{const e=A.readFileSync(n,"utf-8"),t=JSON.parse(e);if(t&&Array.isArray(t.scripts))return t}catch(e){d.warn("[script-store-service] Failed to read meta:",e)}return{scripts:[]}}function U(n){me();const e=ie();A.writeFileSync(e,JSON.stringify(n,null,2),"utf-8")}function Tt(n){return n.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g,"-").replace(/^-+|-+$/g,"")||"script"}function wt(n,e){const t=Tt(n);let i=`${t}.mjs`,s=1;for(;e.has(i);)i=`${t}-${s}.mjs`,s++;return i}function At(){const n=L(),e=ie();if(A.existsSync(e))return;if(!A.existsSync(n)){d.info("[script-store-service] Scripts directory does not exist, skipping seed.");return}const t={scripts:[]},i=A.readdirSync(n).filter(s=>s.endsWith(".mjs"));for(const s of i)try{const r=s.replace(/\.mjs$/,""),a=new Date().toISOString();t.scripts.push({id:`seed-${r}`,name:r,description:"",filename:s,enabled:!0,channel:"",createdAt:a,updatedAt:a})}catch(r){d.warn("[script-store-service] Failed to seed script",s,r)}U(t),d.info("[script-store-service] Seeded scripts:",t.scripts.length)}function yt(){me(),At()}function St(){return P().scripts.map(e=>$(e)).sort((e,t)=>new Date(t.updatedAt).getTime()-new Date(e.updatedAt).getTime())}function j(n){const t=P().scripts.find(i=>i.id===n);return t?$(t):null}function Et(n){const t=P().scripts.find(i=>i.id===n);return t?y.join(L(),t.filename):null}function le(n){const e=P(),t=L(),i=new Set(e.scripts.map(u=>u.filename)),s=new Date().toISOString();if(n.id){const u=e.scripts.findIndex(f=>f.id===n.id);if(u>=0){const f=e.scripts[u],m=y.join(t,f.filename);return A.writeFileSync(m,n.code,"utf-8"),e.scripts[u]={...f,name:n.name,description:n.description,channel:n.channel,enabled:n.enabled,updatedAt:s},U(e),$(e.scripts[u])}}const r=wt(n.name,i),a=y.join(t,r);A.writeFileSync(a,n.code,"utf-8");const g={id:`script-${Date.now()}-${Math.random().toString(36).slice(2,7)}`,name:n.name,description:n.description,filename:r,enabled:n.enabled,channel:n.channel,createdAt:s,updatedAt:s};return e.scripts.push(g),U(e),$(g)}function vt(n){const e=P(),t=e.scripts.findIndex(r=>r.id===n);if(t===-1)return!1;const i=e.scripts[t],s=y.join(L(),i.filename);if(A.existsSync(s))try{A.unlinkSync(s)}catch(r){d.warn("[script-store-service] Failed to delete script file:",r)}return e.scripts.splice(t,1),U(e),!0}function Ct(n,e){const t=P(),i=t.scripts.findIndex(s=>s.id===n);return i===-1?!1:(t.scripts[i].enabled=e,t.scripts[i].updatedAt=new Date().toISOString(),U(t),!0)}function Dt(n,e){const t=P(),i=t.scripts.findIndex(s=>s.id===n);return i===-1?!1:(t.scripts[i].lastRun=e,t.scripts[i].updatedAt=new Date().toISOString(),U(t),!0)}function $(n){const e=L(),t=y.join(e,n.filename);let i="";try{A.existsSync(t)&&(i=A.readFileSync(t,"utf-8"))}catch(s){d.warn("[script-store-service] Failed to read script file:",s)}return{...n,code:i}}const It=new _e;async function Mt(n,e){const t=Et(n);if(!t)return{success:!1,exitCode:null,stdoutTail:"",stderrTail:"",error:"Script not found"};const i=await It.executeScript(t,{SCRIPT_ID:n,CHANNEL:e||""});return Dt(n,{time:new Date().toISOString(),success:i.success,error:i.error}),i}const q=new Map;function Y(){return o.app.isPackaged?y.join(__dirname,"scripts"):y.join(process.cwd(),"electron/scripts")}function Rt(){const n=new _e,e=y.dirname(require.resolve("playwright-core")),t=y.join(e,"cli.js");let i=null;o.ipcMain.handle(c.SCRIPT_LIST,async()=>{try{return St()}catch(s){throw d.error("[SCRIPT_LIST] error:",s),s}}),o.ipcMain.handle(c.SCRIPT_GET,async(s,r)=>{try{return j(r)}catch(a){throw d.error("[SCRIPT_GET] error:",a),a}}),o.ipcMain.handle(c.SCRIPT_SAVE,async(s,r)=>{try{return le(r)}catch(a){throw d.error("[SCRIPT_SAVE] error:",a),a}}),o.ipcMain.handle(c.SCRIPT_DELETE,async(s,r)=>{try{return vt(r)}catch(a){throw d.error("[SCRIPT_DELETE] error:",a),a}}),o.ipcMain.handle(c.SCRIPT_TOGGLE,async(s,r,a)=>{try{return Ct(r,a)}catch(l){throw d.error("[SCRIPT_TOGGLE] error:",l),l}}),o.ipcMain.handle(c.SCRIPT_RUN,async(s,r)=>{try{const a=j(r);return await Mt(r,a?.channel)}catch(a){return d.error("[SCRIPT_RUN] error:",a),{success:!1,exitCode:null,stdoutTail:"",stderrTail:"",error:a?.message||"Run failed"}}}),o.ipcMain.handle(c.SCRIPT_RECORD_START,async(s,r)=>{try{i&&(i.kill("SIGINT"),i=null);const a=r||"about:blank";return i=X.spawn(process.execPath,[t,"codegen","--target","javascript","--viewport-size","1920,1080","--color-scheme","light",a],{env:{...process.env,ELECTRON_RUN_AS_NODE:"1"},stdio:"pipe"}),i.on("error",l=>{d.error("[SCRIPT_RECORD_START] Failed to start codegen process:",l)}),i.stdout?.on("data",l=>{d.info(`[SCRIPT_RECORD_START] stdout: ${l.toString()}`)}),i.stderr?.on("data",l=>{d.error(`[SCRIPT_RECORD_START] stderr: ${l.toString()}`)}),{success:!0}}catch(a){return d.error("[SCRIPT_RECORD_START] error:",a),{success:!1,error:a?.message||"Recording start failed"}}}),o.ipcMain.handle(c.SCRIPT_RECORD_STOP,async()=>{try{return i&&(i.kill("SIGINT"),i=null),{success:!0,code:""}}catch(s){return d.error("[SCRIPT_RECORD_STOP] error:",s),{success:!1,error:s?.message||"Recording stop failed"}}}),o.ipcMain.handle(c.SCRIPT_CODEGEN,async(s,r,a)=>{try{const l=j(r);if(!l)return{success:!1,error:"Script not found"};const g=Y(),u=y.join(g,l.filename),f=a||"about:blank";return d.info(`[SCRIPT_CODEGEN] Starting codegen for script ${r} at ${u} with url ${f}`),await new Promise(m=>{const T=X.spawn(process.execPath,[t,"codegen","--target","javascript","-o",u,f],{env:{...process.env,ELECTRON_RUN_AS_NODE:"1"},stdio:"pipe"});T.on("exit",()=>{try{let p=A.readFileSync(u,"utf-8");p.includes("require('playwright')")&&!p.includes("createRequire")&&(p=`import { createRequire } from 'node:module'; -const require = createRequire(import.meta.url); - -${p}`),A.writeFileSync(u,p,"utf-8"),le({id:r,name:l.name,description:l.description,code:p,channel:l.channel,enabled:l.enabled}),m({success:!0,code:p})}catch(p){d.error("[SCRIPT_CODEGEN] Failed to process generated code:",p),m({success:!1,error:p?.message||"Failed to process generated code"})}}),T.on("error",p=>{d.error("[SCRIPT_CODEGEN] Failed to start codegen:",p),m({success:!1,error:p.message})})})}catch(l){return d.error("[SCRIPT_CODEGEN] error:",l),{success:!1,error:l?.message||"Codegen failed"}}}),o.ipcMain.handle(c.OPEN_CHANNEL,async(s,r)=>{try{await _t();const a=Y(),l=y.join(a,"open_all_channel.js");if(q.clear(),Array.isArray(r))for(let u=0;u{try{const a=r.roomList.find(T=>T.id===r.roomType),g=[["fzName","fg_trace.js"],["mtName","mt_trace.js"],["dyHotelName","dy_hotel_trace.js"],["dyHotSpringName","dy_hot_spring_trace.js"]].filter(([T])=>a?.[T]),u=Y(),f=g.map(([T,p])=>{const M=y.join(u,p);if(!A.existsSync(M))throw new Error(`Script not found for channel ${T}: ${M}`);return{channel:T,scriptPath:M}}),m=[];for(let T=0;Tthis.sendToRenderer(c.UPDATE_STATUS_CHANGED,{status:"checking"})),C.autoUpdater.on("update-available",e=>this.sendToRenderer(c.UPDATE_STATUS_CHANGED,{status:"available",info:e})),C.autoUpdater.on("update-not-available",()=>this.sendToRenderer(c.UPDATE_STATUS_CHANGED,{status:"not-available"})),C.autoUpdater.on("download-progress",e=>this.sendToRenderer(c.UPDATE_STATUS_CHANGED,{status:"downloading",progress:e})),C.autoUpdater.on("update-downloaded",e=>this.sendToRenderer(c.UPDATE_STATUS_CHANGED,{status:"downloaded",info:e})),C.autoUpdater.on("error",e=>this.sendToRenderer(c.UPDATE_STATUS_CHANGED,{status:"error",error:e.message}))}sendToRenderer(e,t){o.BrowserWindow.getAllWindows().forEach(i=>{i.isDestroyed()||i.webContents.send(e,t)})}registerHandlers(){o.ipcMain.handle(c.UPDATE_CHECK,()=>o.app.isPackaged?C.autoUpdater.checkForUpdates():(this.sendToRenderer(c.UPDATE_STATUS_CHANGED,{status:"checking"}),setTimeout(()=>{this.sendToRenderer(c.UPDATE_STATUS_CHANGED,{status:"not-available"})},1500),null)),o.ipcMain.handle(c.UPDATE_DOWNLOAD,()=>o.app.isPackaged?C.autoUpdater.downloadUpdate():null),o.ipcMain.handle(c.UPDATE_INSTALL,()=>o.app.isPackaged?C.autoUpdater.quitAndInstall():null),o.ipcMain.handle(c.UPDATE_VERSION,()=>o.app.getVersion())}}const Ot=se.getInstance();Ot.init();Me&&o.app.quit();o.app.whenReady().then(()=>{fe(),yt(),Rt()});o.app.on("window-all-closed",()=>{process.platform!=="darwin"&&!v.get(_.MINIMIZE_TO_TRAY)&&(d.info("app closing due to all windows being closed"),o.app.quit())});o.app.on("activate",()=>{o.BrowserWindow.getAllWindows().length===0&&fe()}); diff --git a/dist-electron/main/main.jsc b/dist-electron/main/main.jsc deleted file mode 100644 index bd795ec..0000000 Binary files a/dist-electron/main/main.jsc and /dev/null differ diff --git a/dist-electron/preload/preload.js b/dist-electron/preload/preload.js index ab7c747..274c56d 100644 --- a/dist-electron/preload/preload.js +++ b/dist-electron/preload/preload.js @@ -55,6 +55,8 @@ var IPC_EVENTS = /* @__PURE__ */ ((IPC_EVENTS2) => { IPC_EVENTS2["SCRIPT_RECORD_START"] = "script:record-start"; IPC_EVENTS2["SCRIPT_RECORD_STOP"] = "script:record-stop"; IPC_EVENTS2["SCRIPT_CODEGEN"] = "script:codegen"; + IPC_EVENTS2["GATEWAY_RPC"] = "gateway:rpc"; + IPC_EVENTS2["GATEWAY_EVENT"] = "gateway:event"; IPC_EVENTS2["UPDATE_CHECK"] = "update:check"; IPC_EVENTS2["UPDATE_DOWNLOAD"] = "update:download"; IPC_EVENTS2["UPDATE_INSTALL"] = "update:install"; diff --git a/ChatHistorySessionListMigrationPlan.md b/docs/ChatHistorySessionListMigrationPlan.md similarity index 100% rename from ChatHistorySessionListMigrationPlan.md rename to docs/ChatHistorySessionListMigrationPlan.md diff --git a/docs/ChatPageMigrationPlan.md b/docs/ChatPageMigrationPlan.md new file mode 100644 index 0000000..df998f9 --- /dev/null +++ b/docs/ChatPageMigrationPlan.md @@ -0,0 +1,519 @@ +# Chat 对话功能迁移重构计划(v2) + +## 目标 + +彻底抛弃 zn-ai 现有的 `WebSocketManager` 直联方式,**全面对齐 ClawX 的对话架构与协议栈**(Gateway RPC + 结构化消息流)。UI 视觉与交互布局**严格沿用 zn-ai 现有设计**(蓝色主题 `#2B7FFF`、头像左右布局、圆角输入框、欢迎页文案)。 + +--- + +## 参考来源 + +- **ClawX 源文件**: + - `ClawX/src/stores/chat.ts` — Zustand Chat Store(状态、会话、流式、附件缓存、错误恢复) + - `ClawX/src/stores/chat/types.ts` — `RawMessage`、`ContentBlock`、`ToolStatus`、`ChatSession` + - `ClawX/src/pages/Chat/index.tsx` — 页面组装、生命周期、欢迎页、Indicator + - `ClawX/src/pages/Chat/ChatMessage.tsx` — 消息渲染(markdown、thinking、tool、image、attachment) + - `ClawX/src/pages/Chat/ChatInput.tsx` — 输入框(附件、Agent @提及、发送/停止) + - `ClawX/src/pages/Chat/ExecutionGraphCard.tsx` — 任务执行可视化 + +- **zn-ai 目标文件**: + - `zn-ai/src/pages/home/index.vue` + - `zn-ai/src/pages/home/ChatBox.vue` + - `zn-ai/src/pages/home/components/ChatInputArea.vue` + - `zn-ai/src/pages/home/components/ChatRoleAI.vue` + - `zn-ai/src/pages/home/components/ChatRoleMe.vue` + - `zn-ai/src/pages/home/model/ChatModel.ts` + +--- + +## 一、协议与模型层重构 + +### 1.1 消息模型:从 `ChatMessage` class 迁移到 `RawMessage` 协议 + +zn-ai 当前 `ChatMessage` 只有 `messageContent: string` + `messageContentList: string[]`,无法承载 ClawX 的结构化内容。需重构为: + +```ts +// zn-ai/src/pages/home/model/ChatModel.ts +export interface AttachedFileMeta { + fileName: string; + mimeType: string; + fileSize: number; + preview: string | null; + filePath?: string; + source?: 'user-upload' | 'tool-result' | 'message-ref'; +} + +export interface ContentBlock { + type: 'text' | 'image' | 'thinking' | 'tool_use' | 'tool_result' | 'toolCall' | 'toolResult'; + text?: string; + thinking?: string; + source?: { type: string; media_type?: string; data?: string; url?: string }; + data?: string; + mimeType?: string; + id?: string; + name?: string; + input?: unknown; + arguments?: unknown; + content?: unknown; +} + +export interface RawMessage { + role: 'user' | 'assistant' | 'system' | 'toolresult'; + content: string | ContentBlock[]; + timestamp?: number; + id?: string; + toolCallId?: string; + toolName?: string; + details?: unknown; + isError?: boolean; + _attachedFiles?: AttachedFileMeta[]; +} + +export interface ToolStatus { + id?: string; + toolCallId?: string; + name: string; + status: 'running' | 'completed' | 'error'; + durationMs?: number; + summary?: string; + updatedAt: number; +} + +export interface ChatSession { + key: string; + label?: string; + displayName?: string; + thinkingLevel?: string; + model?: string; + updatedAt?: number; +} +``` + +> **说明**: +> - `messageContentList` 和 `isLoading` 字段将被移除。流式渲染不再依赖数组分段,而是通过 `streamingMessage` 增量更新。 +> - 历史消息加载后,UI 直接使用 `md.render()` 对 `extractText(msg)` 的结果做一次性 markdown 渲染。 +> - `question`(问题标签)作为 zn-ai 特有字段,可在 `ChatMessage` 组件的 footer slot 中继续保留。 + +--- + +## 二、Pinia Chat Store(核心状态层) + +新建 `zn-ai/src/store/chat.ts`,**完整复刻 ClawX Chat Store 的语义与行为**,仅将 Zustand API 替换为 Pinia `defineStore`。 + +### 2.1 State 设计 + +```ts +interface ChatState { + // 消息 + messages: RawMessage[]; + loading: boolean; // 历史消息加载中 + error: string | null; // 底部全局错误条 + + // 流式与运行时 + sending: boolean; // 是否正在发送/等待回复 + activeRunId: string | null; // 当前运行 ID(用于事件去重) + streamingText: string; // 纯文本流式缓存 + streamingMessage: unknown | null; // 当前流式消息对象 + streamingTools: ToolStatus[]; // 工具调用实时状态列表 + pendingFinal: boolean; // 是否处于 tool-result 后到最终回复前的等待期 + lastUserMessageAt: number | null; // 用于保留乐观用户消息 + pendingToolImages: AttachedFileMeta[]; // 等待挂到最终 assistant 消息的图片 + + // 会话 + sessions: ChatSession[]; + currentSessionKey: string; + currentAgentId: string; + sessionLabels: Record; + sessionLastActivity: Record; + + // Thinking + showThinking: boolean; + thinkingLevel: string | null; +} +``` + +### 2.2 Actions(与 ClawX 1:1 映射) + +| Action | 职责 | +|--------|------| +| `loadSessions()` | 加载会话列表,去重、排序、补全 label | +| `switchSession(key)` | 切会话,清理旧会话轮询,清空当前流式状态 | +| `newSession()` | 新建会话,清理空会话 | +| `deleteSession(key)` | 删除会话(本地 + 远端 `/api/sessions/delete`) | +| `cleanupEmptySession()` | **页面切走时调用**:若当前会话无消息且无活动,从 sidebar 移除 | +| `loadHistory(quiet?)` | 加载历史消息,保留乐观用户消息、异步加载缺失图片预览 | +| `sendMessage(text, attachments?, targetAgentId?)` | 发送消息,乐观插入用户消息,启动 history poll 与安全超时 | +| `abortRun()` | 中断当前运行,调用 `chat.abort` | +| `handleChatEvent(event)` | **核心**:处理 Gateway 推送的 `started/delta/final/error/aborted` 事件 | +| `toggleThinking()` | 切换 thinking 显隐 | +| `refresh()` | 刷新 sessions + history | +| `clearError()` | 清除错误条 | + +### 2.3 必须移植的内部机制(ClawX 精髓) + +- **History Poll Timer**:发送后若 `streamingMessage` 为空且长时间无事件,定时轮询 `chat.history` 以展示中间 tool-call 进度。 +- **Safety Timeout(90s)**:`_lastChatEventAt` 跟踪最后收到事件的时间,超 90 秒无响应自动报错并终止 `sending`。 +- **Error Recovery Timer(15s)**:收到 `error` 事件后,给 Gateway 内部重试留 15 秒宽限期,期间保持 `sending = true`。 +- **Chat Event Deduplication**:通过 `runId|sessionKey|seq|eventState` 去重,防止重复渲染。 +- **Image Cache & Preview Loader**: + - `localStorage` 缓存图片附件(`clawx:image-cache`)。 + - `loadMissingPreviews()` 异步从 `/api/files/thumbnails` 加载图片缩略图。 +- **Tool Result Image Enrichment**:将 `tool_result` 中的图片/文件收集到 `pendingToolImages`,最终挂载到下一个 `assistant` 消息的 `_attachedFiles`。 + +> **后端依赖**: +> Store 依赖后端提供与 ClawX 等价的 RPC / HTTP 接口: +> - `chat.history` / `chat.send` / `chat.abort` +> - `sessions.list` +> - `/api/sessions/delete` +> - `/api/files/thumbnails` +> - `/api/files/stage-paths` / `/api/files/stage-buffer` +> - `/api/sessions/transcript`(子 Agent 对话加载) +> - Gateway WebSocket 事件流(`started/delta/final/error/aborted`) + +--- + +## 三、组件拆分(UI 层) + +所有新组件放置在 `zn-ai/src/pages/home/components/chat/`。 + +### 3.1 `ChatMessage.vue`(替代旧 `ChatRoleAI.vue` + `ChatRoleMe.vue`) + +**职责**:统一渲染单条消息(用户 / AI / 流式)。 + +**Props**: +```ts +interface Props { + message: RawMessage; + showThinking?: boolean; + suppressToolCards?: boolean; + isStreaming?: boolean; + streamingTools?: ToolStatus[]; +} +``` + +**内部逻辑**(参考 ClawX `ChatMessage.tsx`): +- `extractText(message)` 提取主文本,`md.render()` 渲染 markdown。 +- `extractThinking(message)` 在 `showThinking=true` 时显示可折叠 Thinking 块。 +- `extractToolUse(message)` 渲染 Tool 调用卡片(可展开查看参数)。 +- `extractImages(message)` 渲染图片(用户消息上方缩略图,AI 消息下方大图)。 +- `_attachedFiles` 渲染附件:图片预览 / 文件卡片(可点击打开文件夹)。 +- **Hover Bar**:AI 消息底部显示时间戳 + 复制按钮;用户消息底部显示时间戳。 +- **Streaming Cursor**:`isStreaming=true` 且为 AI 消息时,末尾显示闪烁光标。 + +**样式约定(zn-ai 风格)**: +- 用户气泡:`bg-[#2B7FFF] text-white rounded-2xl px-4 py-3`(或保持 zn-ai 现有 `bg-[#f7f9fc] text-gray-700`,由设计者最终拍板,计划默认采用 `#2B7FFF` 高亮以统一品牌色)。 +- AI 气泡:`bg-white border border-[#E5E8EE] text-gray-800 rounded-2xl px-4 py-3`。 +- 头像布局:AI 左、用户右,与 zn-ai 现有完全一致。 + +### 3.2 `ChatInput.vue`(由 `ChatInputArea.vue` 升级) + +**Props / Events**: +```ts +interface Props { + modelValue: string; + sending: boolean; + disabled?: boolean; + isEmpty?: boolean; +} +// Events: send(text, attachments?, targetAgentId?), stop, update:modelValue +``` + +**新增能力(完整对齐 ClawX `ChatInput.tsx`)**: +- **发送/停止切换**:`sending=true` 时按钮变为停止图标,触发 `stop`。 +- **附件系统**: + - 点击 `Paperclip` 图标选择文件,调用 `/api/files/stage-paths`。 + - 粘贴文件(`Ctrl+V`)调用 `/api/files/stage-buffer`。 + - 拖拽文件到输入框上传。 + - 附件预览条(图片缩略图 + 文件卡片 + staging 旋转遮罩 + 错误状态 + 删除按钮)。 +- **Agent @提及**:`@` 按钮弹出 Agent 选择器(依赖 `useAgentsStore`),选中后在输入框上方显示 chip。 +- **自动增高**:textarea 随内容自动增高,最大高度 200px。 +- **Enter 发送 / Shift+Enter 换行**。 + +### 3.3 `ChatEmpty.vue`(WelcomeScreen 等价物) + +**职责**:当 `messages.length === 0 && !sending` 时展示。 + +**内容**: +- 保留 zn-ai 现有大标题:“你好,我今天能帮你什么?” +- 保留快捷操作按钮(如“智能问数”、“写代码”、“查数据”等),样式沿用 zn-ai 的圆角小按钮。 +- 保留 `TaskCenter` 插槽区域(若 zn-ai 引导页需要)。 + +### 3.4 `ChatErrorBar.vue` + +**职责**:底部固定错误提示条。 + +**样式**:`bg-red-50 border-t border-red-200`,左侧 `AlertCircle` 图标,右侧“Dismiss”按钮,文字 `text-red-600`。 + +### 3.5 `ChatTypingIndicator.vue` + +**职责**:`sending && !pendingFinal && !hasAnyStreamContent` 时显示。 + +**样式**:左侧 AI 头像,右侧圆角气泡内三个跳动圆点,颜色使用 zn-ai 的 `#BEDBFF` / `#2B7FFF` 渐变或纯灰色。 + +### 3.6 `ChatActivityIndicator.vue`(ClawX 遗漏项) + +**职责**:`sending && pendingFinal && !shouldRenderStreaming` 时显示。 + +**样式**:左侧 AI 头像,右侧气泡内显示 “Processing tool results…” + `Loader2` 旋转图标。 + +### 3.7 `AttachmentPreview.vue` + +**职责**:输入框上方附件预览条中的单卡片。 + +**Props**:`attachment: FileAttachment`、`onRemove: () => void`。 + +**状态渲染**: +- `staging`:半透明遮罩 + 旋转 loading。 +- `error`:红色遮罩 + Error 文字。 +- `ready`:图片显示 64×64 缩略图,文件显示图标 + 文件名 + 大小。 + +### 3.8 `ExecutionGraphCard.vue` + +**职责**:在用户消息后展示当前 Agent 运行的任务步骤可视化。 + +**Props**: +```ts +interface Props { + agentLabel: string; + sessionLabel: string; + steps: TaskStep[]; + active: boolean; + onJumpToTrigger?: () => void; + onJumpToReply?: () => void; +} +``` + +> 该组件直接复刻 ClawX 的 `ExecutionGraphCard.tsx` 逻辑,但样式改用 zn-ai 的圆角卡片、蓝色高亮、灰色边框风格。 + +--- + +## 四、`ChatBox.vue` 瘦身改造 + +改造后 `ChatBox.vue` **仅负责 Store 订阅 + 组件拼装 + 事件透传**,代码量目标 150 行以内。 + +```html + +``` + +```ts + +``` + +--- + +## 五、页面级生命周期与事件整合 + +### 5.1 `home/index.vue` + +```ts +import { onMounted, onBeforeUnmount } from 'vue'; +import { useChatStore } from '@store/chat'; + +const chatStore = useChatStore(); + +onMounted(() => { + chatStore.loadSessions(); +}); + +onBeforeUnmount(() => { + chatStore.cleanupEmptySession(); +}); + +// 选择历史会话 +const handleSelectChat = (conversationId: string) => { + guide.value = false; + chatStore.switchSession(conversationId); +}; + +// 新建对话 +const handleNewChat = () => { + guide.value = true; + chatStore.newSession(); +}; +``` + +### 5.2 Gateway 事件监听 + +ClawX 通过 Gateway WebSocket 接收 `chat.event`。zn-ai 需要在前端初始化 Gateway 连接(`useGatewayStore` 或等价实现),并将事件路由到 `chatStore.handleChatEvent(event)`。 + +```ts +// 在 Gateway 连接初始化后 +gateway.onChatEvent = (event: Record) => { + chatStore.handleChatEvent(event); +}; +``` + +--- + +## 六、`ChatHistory.vue` 联动改造 + +当前 `ChatHistory.vue` 自己维护 `groups` 和 `selectedConversationId`。改造后: +- **数据来源**:从 `chatStore.sessions` + `chatStore.sessionLabels` + `chatStore.sessionLastActivity` 读取。 +- **选择事件**:`@select-chat` 触发 `chatStore.switchSession(sessionKey)`。 +- **新建事件**:`@new-chat` 触发 `chatStore.newSession()`。 +- **删除/重命名**:调用 Store Action 或直接走 API,完成后触发 `chatStore.loadSessions()`。 + +--- + +## 七、样式与兼容性保持 + +| 元素 | zn-ai 现有风格 | 迁移后保持 | +|------|---------------|-----------| +| 颜色主题 | `#2B7FFF` 蓝色高亮 | 延续 | +| 边框色 | `#E5E8EE` | 延续 | +| 头像布局 | AI 左、用户右 | 延续 | +| 用户气泡 | `bg-[#f7f9fc]` 或自定义 | 延续 zn-ai 浅灰底,若品牌要求可改为 `#2B7FFF` | +| AI 气泡 | 白色底 + 浅灰边框 | 延续 | +| 输入框 | 圆角、阴影、白色背景 | 延续,增加附件预览条和 Agent chip | +| 欢迎页 | 大标题 + TaskCenter | 延续 | +| Loading | `ChatLoading.vue` 旋转点 | `ChatTypingIndicator` 替换为跳动圆点风格,或统一为 zn-ai 旋转点 | + +--- + +## 八、涉及文件清单 + +| 新建/修改 | 文件路径 | 说明 | +|-----------|----------|------| +| **重构** | `zn-ai/src/pages/home/model/ChatModel.ts` | 从 class 改为 `RawMessage` / `ContentBlock` / `ToolStatus` / `ChatSession` 接口 | +| **新建** | `zn-ai/src/store/chat.ts` | Pinia Chat Store,完整复刻 ClawX 状态与机制 | +| **新建** | `zn-ai/src/pages/home/components/chat/ChatMessage.vue` | 统一消息渲染(markdown、thinking、tool、image、attachment、hover bar) | +| **新建** | `zn-ai/src/pages/home/components/chat/ChatInput.vue` | 输入框 + 附件 + Agent @提及 + 发送/停止 | +| **新建** | `zn-ai/src/pages/home/components/chat/ChatEmpty.vue` | 空会话欢迎页 | +| **新建** | `zn-ai/src/pages/home/components/chat/ChatErrorBar.vue` | 底部错误条 | +| **新建** | `zn-ai/src/pages/home/components/chat/ChatTypingIndicator.vue` | 发送中跳动圆点 | +| **新建** | `zn-ai/src/pages/home/components/chat/ChatActivityIndicator.vue` | tool processing 状态指示器 | +| **新建** | `zn-ai/src/pages/home/components/chat/AttachmentPreview.vue` | 附件预览卡片 | +| **新建** | `zn-ai/src/pages/home/components/chat/ExecutionGraphCard.vue` | 任务执行步骤可视化 | +| **改造** | `zn-ai/src/pages/home/components/ChatRoleAI.vue` | **废弃或大幅简化**:由 `ChatMessage.vue` 接管 | +| **改造** | `zn-ai/src/pages/home/components/ChatRoleMe.vue` | **废弃或大幅简化**:由 `ChatMessage.vue` 接管 | +| **改造** | `zn-ai/src/pages/home/components/ChatInputArea.vue` | **废弃**:由 `ChatInput.vue` 替换 | +| **重构** | `zn-ai/src/pages/home/ChatBox.vue` | 瘦身至 150 行以内,仅负责拼装 | +| **调整** | `zn-ai/src/pages/home/index.vue` | Store 生命周期、事件透传 | +| **调整** | `zn-ai/src/pages/home/ChatHistory.vue` | 数据源改为 `chatStore.sessions`,联动 `switchSession` / `newSession` | + +--- + +## 九、验收标准 + +- [ ] `ChatBox.vue` 代码量降至 150 行以内,不包含任何协议细节或定时器。 +- [ ] 新建 `chat.ts` Pinia Store 能正常初始化、加载会话、加载历史、发送消息、接收流式事件。 +- [ ] 消息模型完全兼容 `RawMessage`(`content` 支持 `string | ContentBlock[]`)。 +- [ ] 用户发送消息后,输入框清空,列表底部出现 `ChatTypingIndicator`;收到首包内容后切换为 `ChatMessage` 流式渲染,末尾带闪烁光标。 +- [ ] Tool-use 期间正确显示 `ChatActivityIndicator` 和 `streamingTools` 状态条。 +- [ ] 收到 `final` 事件后,流式消息固化到 `messages`,状态恢复正常;`tool_result` 中的图片自动附加到最终 AI 消息。 +- [ ] 发生超时或错误时,底部出现 `ChatErrorBar`,可点击清除;90 秒 safety timeout 能自动终止卡死状态。 +- [ ] 切换历史会话时,通过 `switchSession` 加载历史消息并正确渲染;新建对话时回到 `ChatEmpty`。 +- [ ] 离开首页时调用 `cleanupEmptySession`,无 ghost session 残留。 +- [ ] `ExecutionGraphCard` 在用户消息后正确渲染任务步骤(含子 Agent 分支)。 +- [ ] UI 颜色、头像布局、输入框样式与 zn-ai 现有设计保持一致。 diff --git a/Cron-Development-Plan.md b/docs/Cron-Development-Plan.md similarity index 100% rename from Cron-Development-Plan.md rename to docs/Cron-Development-Plan.md diff --git a/Cron-implementation-reference.md b/docs/Cron-implementation-reference.md similarity index 100% rename from Cron-implementation-reference.md rename to docs/Cron-implementation-reference.md diff --git a/Models-Configuration-Analysis.md b/docs/Models-Configuration-Analysis.md similarity index 100% rename from Models-Configuration-Analysis.md rename to docs/Models-Configuration-Analysis.md diff --git a/Script-Development-Plan.md b/docs/Script-Development-Plan.md similarity index 100% rename from Script-Development-Plan.md rename to docs/Script-Development-Plan.md diff --git a/SidebarToggleMigrationPlan.md b/docs/SidebarToggleMigrationPlan.md similarity index 100% rename from SidebarToggleMigrationPlan.md rename to docs/SidebarToggleMigrationPlan.md diff --git a/Skills-Development-Plan.md b/docs/Skills-Development-Plan.md similarity index 100% rename from Skills-Development-Plan.md rename to docs/Skills-Development-Plan.md diff --git a/Skills-implementation-referrnce.md b/docs/Skills-implementation-referrnce.md similarity index 100% rename from Skills-implementation-referrnce.md rename to docs/Skills-implementation-referrnce.md diff --git a/TaskList-Implementation-Plan.md b/docs/TaskList-Implementation-Plan.md similarity index 100% rename from TaskList-Implementation-Plan.md rename to docs/TaskList-Implementation-Plan.md diff --git a/agents.md b/docs/agents.md similarity index 100% rename from agents.md rename to docs/agents.md diff --git a/i18n-implementation-reference.md b/docs/i18n-implementation-reference.md similarity index 100% rename from i18n-implementation-reference.md rename to docs/i18n-implementation-reference.md diff --git a/docs/model-chat-migration-plan.md b/docs/model-chat-migration-plan.md new file mode 100644 index 0000000..e43c525 --- /dev/null +++ b/docs/model-chat-migration-plan.md @@ -0,0 +1,747 @@ +# zn-ai 对话功能重构迁移规划(对齐 ClawX 网关架构) + +> 目标:彻底抛弃 zn-ai 现有的「WebSocket 直连云端后端」对话模型对接方式,全面对齐 ClawX 的「本地 Gateway Service」架构,使对话调用链路完全由本地网关接管。 + +--- + +## 一、问题诊断与架构差异 + +### 1.1 当前现象 +- Agents 页面成功添加 DeepSeek 自定义模型; +- 对话页面提问「你是什么模型?」; +- 返回内容自称「阿里百炼千问」。 + +### 1.2 根因分析 +**zn-ai 对话系统与模型管理完全割裂:** + +| 维度 | Agents 模型管理 | 对话功能 (Chat) | +|------|----------------|-----------------| +| **存储位置** | 本地 JSON (`provider-accounts.json` + `provider-keys.json`) | 无本地模型概念 | +| **调用链路** | `hostapi:fetch` -> `providerApiService` | WebSocket -> `wss://onefeel.brother7.cn` | +| **模型决策** | 前端本地维护账户列表 | 远端后端根据固定 `agentId` 决定 | +| **鉴权方式** | 用户填写的 API Key | 后端统一配置的千问密钥 | + +**结论**:对话消息从未读取本地 provider 配置,而是直接发给 zn-ai 云端后端;该后端绑定的默认模型就是阿里百炼千问。 + +### 1.3 与 ClawX 的核心架构差异 + +**ClawX 架构:** +``` +Renderer Chat Store + │ invokeIpc('gateway:rpc', 'chat.send', { sessionKey, message, ... }) + ▼ +Electron Main: GatewayManager + │ 管理 OpenClaw 子进程生命周期 + WebSocket JSON-RPC 连接 + ▼ +Gateway Process (OpenClaw) + │ 读取 openclaw.json 的 provider 配置 + │ 负责真正的 LLM HTTP 请求、流式响应、工具调用、会话历史 + ▼ +调用实际 LLM API -> 流式返回 chunk -> GatewayManager -> Renderer +``` + +**zn-ai 当前架构:** +``` +Renderer Chat Store + │ WebSocketManager.sendMessage() -> 云端后端 + ▼ +zn-ai Cloud Backend (wss://onefeel.brother7.cn) + │ 固定使用阿里百炼千问 + ▼ +返回响应 +``` + +**关键差异:** +- ClawX 有**本地 Gateway 子进程**,zn-ai 没有; +- ClawX Chat Store **只与 Gateway 通信**(`gateway:rpc`),zn-ai Chat Store 直接与云端 WebSocket 通信; +- ClawX 的模型切换/调用完全由 Gateway 内部决定,zn-ai 由远端后端硬编码决定。 + +--- + +## 二、重构总体目标 + +建立 zn-ai 的**轻量级本地 Gateway Service**,完全替代现有的云端 WebSocket 对话后端: + +1. **Gateway 作为唯一对话入口**:Renderer Chat Store 仅通过 `gateway:rpc` IPC 与 Gateway 通信; +2. **本地 Provider 直连 LLM**:Gateway 读取 `provider-api-service` 的配置,直接调用 OpenAI/Anthropic 等 LLM API; +3. **流式响应本地处理**:Gateway 负责流式接收 chunk,并通过事件通道推回 Renderer; +4. **会话历史本地管理**:Gateway 维护会话历史,支持 `chat.history` 查询; +5. **Provider 变更自动 reload**:修改 provider 配置后,Gateway 自动热重载配置; +6. **未来可扩展 Tool Calling/Agent Loop**:所有复杂逻辑集中在 Gateway,Renderer 保持简单。 + +--- + +## 三、目标架构设计 + +### 3.1 整体数据流 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Renderer 进程 │ +│ ┌──────────────┐ ┌──────────────┐ │ +│ │ ChatBox.vue │◄──►│ chatStore │ │ +│ └──────────────┘ └──────┬───────┘ │ +│ │ │ +│ invokeIpc('gateway:rpc', ...) │ +└─────────────────────────────┼───────────────────────────────────────────────┘ + │ IPC +┌─────────────────────────────┼───────────────────────────────────────────────┐ +│ Electron Main │ +│ ┌──────────────────────────┴────────────────────────────────────┐ │ +│ │ GatewayManager (新建) │ │ +│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │ │ +│ │ │ Gateway RPC │ │ Provider │ │ Session Store │ │ │ +│ │ │ Server │ │ Resolver │ │ (内存/本地持久化) │ │ │ +│ │ └──────┬──────┘ └──────┬──────┘ └─────────────────────┘ │ │ +│ │ │ │ │ │ +│ │ └────────────────┘ │ │ +│ │ │ │ │ +│ │ ┌──────────▼──────────┐ │ │ +│ │ │ ChatHandler │ │ │ +│ │ │ (chat.send/history)│ │ │ +│ │ └──────────┬──────────┘ │ │ +│ │ │ │ │ +│ │ ┌──────────▼──────────┐ │ │ +│ │ │ Provider Factory │ │ │ +│ │ │ (createProvider) │ │ │ +│ │ └──────────┬──────────┘ │ │ +│ │ │ │ │ +│ │ ┌───────────────┼───────────────┐ │ │ +│ │ ▼ ▼ ▼ │ │ +│ │ OpenAIProvider AnthropicProvider OllamaProvider │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌────────────────────┼────────────────────┐ │ +│ ▼ ▼ ▼ │ +│ provider-accounts.json provider-keys.json (可选) IndexedDB │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ +``` + +### 3.2 Gateway 接口契约(对齐 ClawX) + +Renderer 通过 `gateway:rpc` IPC 调用 Gateway 方法: + +```ts +// IPC 调用方式 +window.api.invoke('gateway:rpc', method: string, params: any) + +// 示例 +invokeIpc('gateway:rpc', 'chat.send', { + sessionKey: 'agent:deepseek:xxx', + message: { role: 'user', content: '你好' }, + options: { providerAccountId: 'deepseek-xxx' } +}) + +invokeIpc('gateway:rpc', 'chat.history', { + sessionKey: 'agent:deepseek:xxx', + limit: 50, +}) + +invokeIpc('gateway:rpc', 'chat.abort', { + sessionKey: 'agent:deepseek:xxx', +}) +``` + +Gateway 向 Renderer 推送事件(通过 `ipcMain` -> `mainWindow.webContents.send`): + +```ts +// 事件名称对齐 ClawX +type GatewayEvent = + | { type: 'chat:delta'; sessionKey: string; runId: string; delta: string } + | { type: 'chat:final'; sessionKey: string; runId: string; message: ChatMessage } + | { type: 'chat:error'; sessionKey: string; runId: string; error: string } + | { type: 'chat:aborted'; sessionKey: string; runId: string } + | { type: 'gateway:status'; status: 'connected' | 'disconnected' | 'reconnecting' } +``` + +--- + +## 四、迁移开发规划 + +### Phase 1: 轻量级 Gateway Service 骨架(基础设施) + +#### 4.1.1 新建 `electron/gateway/` 目录 +文件清单: +- `electron/gateway/manager.ts` — GatewayManager 主类 +- `electron/gateway/types.ts` — Gateway RPC 类型定义 +- `electron/gateway/handlers/chat.ts` — Chat RPC 处理器 +- `electron/gateway/handlers/provider.ts` — Provider 查询 RPC 处理器 +- `electron/gateway/session-store.ts` — 会话历史内存存储 + +#### 4.1.2 `GatewayManager` 核心职责 +```ts +class GatewayManager { + // 初始化时加载 provider 配置 + async init(): Promise + + // RPC 入口,被 ipcMain.handle('gateway:rpc') 调用 + async rpc(method: string, params: any): Promise + + // 向所有 renderer 窗口广播事件 + broadcast(event: GatewayEvent): void + + // 监听 provider 配置变化并热重载 + reloadProviders(): void +} +``` + +**设计决策**:zn-ai 不需要像 ClawX 那样启动独立的 OpenClaw 子进程。Gateway 直接以**Electron Main 进程内的 Service 类**形式存在,避免进程间通信的复杂度,同时保持接口与 ClawX 完全一致。 + +#### 4.1.3 注册 `gateway:rpc` IPC Handler +在 `electron/main.ts` 中新增: +```ts +import { gatewayManager } from '@electron/gateway/manager'; + +app.whenReady().then(() => { + gatewayManager.init(); + // ... existing code +}); + +ipcMain.handle('gateway:rpc', async (_event, method: string, params: any) => { + return gatewayManager.rpc(method, params); +}); + +// 转发 Gateway 事件到 Renderer +ipcMain.on('gateway:subscribe', (event) => { + // 可选:若需要按需订阅 +}); +``` + +--- + +### Phase 2: Provider 层改造(能力层) + +#### 4.2.1 重构 `electron/providers/index.ts` +废弃旧的 `CONFIG_KEYS.PROVIDER` base64 解析逻辑,改为从 `providerApiService` 读取: + +```ts +import { providerApiService } from '@electron/service/provider-api-service'; + +export function createProvider(accountId: string): BaseProvider { + const account = providerApiService.getAccounts().find(a => a.id === accountId); + if (!account) throw new Error(`Provider account ${accountId} not found`); + + const apiKey = providerApiService.getApiKey(accountId).apiKey; + if (!apiKey) throw new Error(`API key for account ${accountId} not found`); + + const baseURL = account.baseUrl || getProviderTypeInfo(account.vendorId)?.defaultBaseUrl; + if (!baseURL) throw new Error(`Base URL for account ${accountId} not found`); + + switch (account.apiProtocol) { + case 'anthropic-messages': + // return new AnthropicProvider(apiKey, baseURL, account.headers); + throw new Error('Anthropic provider not yet implemented'); + case 'openai-completions': + case 'openai-responses': + default: + return new OpenAIProvider(apiKey, baseURL, account.headers); + } +} +``` + +#### 4.2.2 扩展 `OpenAIProvider` +- 构造函数增加 `headers?: Record` 参数; +- `chat()` 方法增加 `options?: { signal?: AbortSignal }` 支持; +- 在 `client` 初始化时透传自定义 headers(如 Ark 的额外认证头)。 + +#### 4.2.3 `BaseProvider` 接口统一 +```ts +export abstract class BaseProvider { + abstract chat( + messages: ChatMessage[], + model: string, + options?: { signal?: AbortSignal } + ): Promise>; +} +``` + +--- + +### Phase 3: Gateway Chat 处理器实现(核心链路) + +#### 4.3.1 `electron/gateway/handlers/chat.ts` +实现三个核心 RPC 方法: + +**A. `chat.send`** +```ts +async function handleChatSend(params: { + sessionKey: string; + message: ChatMessage; + options?: { providerAccountId?: string }; +}): Promise<{ runId: string }> { + const runId = crypto.randomUUID(); + const session = sessionStore.getOrCreate(params.sessionKey); + + // 1. 追加用户消息到会话历史 + session.appendMessage(params.message); + + // 2. 解析 Provider + const accountId = params.options?.providerAccountId || providerApiService.getDefault().accountId; + if (!accountId) throw new Error('No provider account selected'); + + const provider = createProvider(accountId); + const account = providerApiService.getAccounts().find(a => a.id === accountId)!; + + // 3. 构建 messages 数组(历史 + 当前消息) + const messages = buildChatMessages(session.messages, account); + + // 4. 启动流式调用 + const abortController = new AbortController(); + session.setActiveRun(runId, abortController); + + // 5. 异步流式处理 + processChatStream(session, runId, provider, account.model!, messages, abortController.signal); + + return { runId }; +} +``` + +**B. `chat.history`** +```ts +function handleChatHistory(params: { sessionKey: string; limit?: number }): ChatMessage[] { + const session = sessionStore.get(params.sessionKey); + return session?.getMessages(params.limit) ?? []; +} +``` + +**C. `chat.abort`** +```ts +function handleChatAbort(params: { sessionKey: string }): void { + const session = sessionStore.get(params.sessionKey); + if (session?.activeRun) { + session.activeRun.abortController.abort(); + session.clearActiveRun(); + gatewayManager.broadcast({ + type: 'chat:aborted', + sessionKey: params.sessionKey, + runId: session.activeRun.runId, + }); + } +} +``` + +#### 4.3.2 流式处理逻辑 `processChatStream` +```ts +async function processChatStream( + session: Session, + runId: string, + provider: BaseProvider, + model: string, + messages: ChatMessage[], + signal: AbortSignal +) { + let assistantContent = ''; + + try { + const chunks = await provider.chat(messages, model, { signal }); + + for await (const chunk of chunks) { + if (signal.aborted) break; + + if (chunk.result) { + assistantContent += chunk.result; + gatewayManager.broadcast({ + type: 'chat:delta', + sessionKey: session.key, + runId, + delta: chunk.result, + }); + } + + if (chunk.isEnd) { + break; + } + } + + if (!signal.aborted) { + const finalMessage: ChatMessage = { + role: 'assistant', + content: assistantContent, + timestamp: Date.now(), + }; + session.appendMessage(finalMessage); + session.clearActiveRun(); + + gatewayManager.broadcast({ + type: 'chat:final', + sessionKey: session.key, + runId, + message: finalMessage, + }); + } + } catch (error) { + session.clearActiveRun(); + gatewayManager.broadcast({ + type: 'chat:error', + sessionKey: session.key, + runId, + error: error instanceof Error ? error.message : String(error), + }); + } +} +``` + +--- + +### Phase 4: Renderer Chat Store 重构(UI 层) + +#### 4.4.1 新增 `src/lib/gateway-client.ts` +封装 `gateway:rpc` 调用,与 ClawX 的 `api-client.ts` 对齐: + +```ts +export async function gatewayRpc(method: string, params?: any): Promise { + if (!window.api?.invoke) { + throw new Error('IPC not available'); + } + return window.api.invoke('gateway:rpc', method, params); +} + +export function onGatewayEvent( + callback: (event: GatewayEvent) => void +): () => void { + const handler = (_event: any, payload: GatewayEvent) => callback(payload); + window.api.on('gateway:event', handler); + return () => window.api.off('gateway:event', handler); +} +``` + +**注意**:需要在 `electron/preload/index.ts` 中暴露对应的 IPC 通道。 + +#### 4.4.2 重构 `src/store/chat.ts` + +**废弃内容:** +- `WebSocketManager` 及其所有生命周期管理(`initConnection`, `_wsManager`, `_adaptWsMessage`); +- 云端 WebSocket URL 和消息格式; +- 基于 HTTP API 的 `getSessionMessages` 历史加载(逐步替换为 `gateway:rpc chat.history`)。 + +**新增/改造内容:** + +**A. 状态扩展** +```ts +interface ChatState { + // ... 现有状态保留 + gatewayStatus: 'connected' | 'disconnected' | 'reconnecting'; +} +``` + +**B. `sendMessage` 重构** +```ts +async sendMessage(text, attachments) { + const trimmed = text.trim(); + if (!trimmed && (!attachments || attachments.length === 0)) return; + + const providerStore = useProviderStore(); + const defaultAccountId = providerStore.defaultAccountId; + if (!defaultAccountId) { + this.error = '请先前往模型管理页面配置并设置一个默认模型'; + return; + } + + // 确保有有效 sessionKey + const sessionKey = this.currentSessionKey; + if (!sessionKey || sessionKey === DEFAULT_SESSION_KEY) { + // 本地会话:使用本地生成的 key + const localKey = `local:${defaultAccountId}:${crypto.randomUUID()}`; + this.currentSessionKey = localKey; + this.sessions = ensureSessionEntry(this.sessions, { key: localKey, displayName: 'New Chat' }); + } + + // 乐观添加用户消息 + const userMsg = buildUserMessage(trimmed, attachments); + this.messages = [...this.messages, userMsg]; + this.sending = true; + this.error = null; + this.streamingText = ''; + this.streamingMessage = null; + + // 通过 Gateway 发送(复用 Gateway 返回的 runId) + try { + const { runId } = await gatewayRpc<{ runId: string }>('chat.send', { + sessionKey: this.currentSessionKey, + message: { role: 'user', content: trimmed }, + options: { + providerAccountId: defaultAccountId, + }, + }); + this.activeRunId = runId; + } catch (err) { + this.error = String(err); + this.sending = false; + this.activeRunId = null; + } +} +``` + +**C. `handleChatEvent` 适配 Gateway 事件** +现有 `handleChatEvent` 的逻辑基本无需改动,只需调整事件来源: +- 在 chat store 初始化时订阅 `gateway:event`; +- 将 Gateway 推送的 `chat:delta` / `chat:final` / `chat:error` / `chat:aborted` 映射到现有状态机。 + +**D. `loadHistory` 重构** +```ts +async loadHistory(quiet = false) { + const { currentSessionKey } = this; + if (!currentSessionKey || currentSessionKey === DEFAULT_SESSION_KEY) { + this.messages = []; + return; + } + + // 本地会话走 Gateway + if (currentSessionKey.startsWith('local:')) { + if (!quiet) this.loading = true; + try { + const messages = await gatewayRpc('chat.history', { + sessionKey: currentSessionKey, + limit: 50, + }); + this.messages = messages; + } catch (err) { + console.warn('Failed to load local history:', err); + } finally { + this.loading = false; + } + return; + } + + // 遗留的云端会话暂时保留原有 HTTP API 加载逻辑(用于兼容) + // ... 现有 getSessionMessages 逻辑 +} +``` + +**E. `abortRun` 重构** +```ts +async abortRun() { + clearHistoryPoll(); + this.sending = false; + this.streamingText = ''; + this.streamingMessage = null; + this.activeRunId = null; + + try { + await gatewayRpc('chat.abort', { + sessionKey: this.currentSessionKey, + }); + } catch (err) { + console.warn('Abort failed:', err); + } +} +``` + +#### 4.4.3 Preload 脚本扩展 +在 `electron/preload/index.ts` 中确保暴露: +```ts +contextBridge.exposeInMainWorld('api', { + invoke: (channel: string, ...args: any[]) => ipcRenderer.invoke(channel, ...args), + on: (channel: string, callback: (...args: any[]) => void) => ipcRenderer.on(channel, callback), + off: (channel: string, callback: (...args: any[]) => void) => ipcRenderer.off(channel, callback), + // ... 现有方法 +}); +``` + +--- + +### Phase 5: Provider Store 与默认模型(UI 层) + +#### 4.5.1 精简 `src/store/providers.ts` +- 移除 `selectedAccountId` 状态及相关 `localStorage` 持久化; +- 对话层不再维护独立的模型选中态,直接复用 `defaultAccountId`。 + +#### 4.5.2 Chat 页面移除模型选择器 +- 从 `ChatBox.vue` 中移除 `ModelSelector` 组件及其引用; +- 对话功能直接使用 provider 管理中「设置为默认」的模型账户; +- 若未设置默认模型,提示用户前往模型管理页面配置。 + +--- + +### Phase 6: 会话历史持久化(数据层) + +#### 4.6.1 会话历史存储策略 +当前 `session-store.ts` 使用内存存储。为支持应用重启后恢复,引入本地持久化: + +**方案 A(推荐,MVP 阶段)**: +- Gateway 的 `sessionStore` 在进程退出时将数据写入 `userData/chat-sessions.json`; +- 启动时从该文件加载; +- 数据结构:`Record`。 + +**方案 B(进阶)**: +- 使用 `electron-store` 或 IndexedDB 存储; +- 支持按会话搜索、导出、删除。 + +#### 4.6.2 会话标识区分 +- **本地 Gateway 会话**:`local:{providerAccountId}:{uuid}`; +- **云端遗留会话**:保持原有的 `agent:{agentId}:main` 或后端分配的 `session_id`。 + +#### 4.6.3 会话列表混合显示 +- `loadSessions()` 中: + - 云端会话继续调用 `getSessionList`; + - 本地会话从 `sessionStore` 读取; + - 合并后按 `updatedAt` 排序显示。 +- 短期可先只显示本地会话(如果决定完全废弃云端)。 + +--- + +### Phase 7: Provider 配置变更与 Gateway 热重载 + +#### 4.7.1 Provider 变更通知机制 +在 `electron/service/provider-api-service/index.ts` 中引入订阅模式: +```ts +type ProviderChangeListener = () => void; +const listeners: ProviderChangeListener[] = []; + +export function onProviderChange(listener: ProviderChangeListener): () => void { + listeners.push(listener); + return () => { + const idx = listeners.indexOf(listener); + if (idx > -1) listeners.splice(idx, 1); + }; +} + +function notifyChange() { + listeners.forEach(l => l()); +} +``` + +在 `createAccount`, `updateAccount`, `deleteAccount`, `setDefault` 等写操作后调用 `notifyChange()`。 + +#### 4.7.2 Gateway 监听并重载 +在 `GatewayManager.init()` 中: +```ts +onProviderChange(() => { + this.reloadProviders(); +}); +``` + +`reloadProviders()` 实现: +- 重新读取所有 provider 账户和 API keys; +- 对于**没有活跃 run** 的 provider,立即替换内存中的配置; +- 对于有活跃 run 的 provider,标记为「待更新」,在 run 结束后应用新配置。 + +--- + +### Phase 8: 多 Provider 适配矩阵(能力扩展) + +| Vendor | 实现类 | 协议 | 优先级 | +|--------|--------|------|--------| +| OpenAI | `OpenAIProvider` | `openai` SDK | P0 | +| DeepSeek | `OpenAIProvider` | `openai` SDK | P0 | +| Moonshot | `OpenAIProvider` | `openai` SDK | P0 | +| SiliconFlow | `OpenAIProvider` | `openai` SDK | P0 | +| Ark (豆包) | `OpenAIProvider` | `openai` SDK + headers | P0 | +| 百度千帆 | `OpenAIProvider` | `openai` SDK | P1 | +| Ollama | `OpenAIProvider` | `openai` SDK | P1 | +| Anthropic | `AnthropicProvider` | `@anthropic-ai/sdk` | P2 | +| Google Gemini | `GeminiProvider` | `@google/generative-ai` | P2 | + +所有 OpenAI-compatible 的厂商优先通过 `OpenAIProvider` + `baseURL` + `headers` 支持。 + +--- + +## 五、实施优先级与里程碑 + +### Milestone 1: Gateway 骨架 + 本地 Provider 可对话(2 周) +- [ ] Phase 1: 创建 `electron/gateway/` 目录与 `GatewayManager` 骨架 +- [ ] Phase 2: 重构 `createProvider` 读取 `provider-api-service` +- [ ] Phase 3: 实现 `chat.send` / `chat.history` / `chat.abort` RPC 处理器 +- [ ] Phase 4: 重构 `chatStore.sendMessage` 走 `gateway:rpc` +- [ ] Phase 5: 对话页面移除模型选择器,直接使用默认模型 +- [ ] 验证:添加 DeepSeek 并设为默认 -> 对话返回 DeepSeek 模型内容 + +### Milestone 2: 历史持久化 + 体验优化(1 周) +- [ ] Phase 6: 实现 `chat-sessions.json` 本地持久化 +- [ ] 支持会话列表混合显示(本地 + 云端遗留) +- [ ] 支持图片附件输入、Abort、重发 +- [ ] API Key 失效、网络超时等错误友好提示 + +### Milestone 3: 完全废弃云端 WebSocket + 架构收尾(1-2 周) +- [ ] Phase 7: Provider 变更自动通知 Gateway 重载 +- [ ] 彻底移除 `WebSocketManager` 及相关代码 +- [ ] 废弃 `START_A_DIALOGUE` IPC(被 `gateway:rpc` 完全替代) +- [ ] 清理云端后端相关的历史兼容代码 +- [ ] 引入 Anthropic/Gemini Provider 支持(可选) + +--- + +## 五之一、Sub Agent 分工与数量估算 + +基于本次更新后的迁移规划,建议分配 **6 个 Sub Agent** 并行作业: + +### 对话功能核心检查(4 个 Agent) + +| Sub Agent | 负责领域 | 对应 Phase | 核心任务 | +|-----------|----------|------------|----------| +| **Agent 1** | Gateway 主进程 | Phase 1, 3, 6, 7 | 检查并修复 `GatewayManager` 骨架;验证 `chat.send` / `chat.history` / `chat.abort` 实现;会话持久化到 `chat-sessions.json`;Provider 变更热重载 | +| **Agent 2** | Provider 层 | Phase 2, 8 | 检查并修复 `createProvider` 本地配置读取;验证 `OpenAIProvider`(headers、AbortSignal、错误处理);扩展 DeepSeek 等内置厂商 | +| **Agent 3** | Renderer Chat Store | Phase 4 | 检查并修复 `src/store/chat.ts`:废弃 WebSocket,接入 `gateway:rpc`;验证事件映射 `chat:delta/final/error/aborted`;维护流式状态机 | +| **Agent 4** | UI 与交互 | Phase 5 | 检查并修复 `ChatBox.vue` 中 `ModelSelector` 移除情况;验证对话直接使用 `defaultAccountId`;空状态、错误提示、附件输入等 UI 细节 | + +### 集成与模型管理(2 个 Agent) + +| Sub Agent | 负责领域 | 核心任务 | +|-----------|----------|----------| +| **Agent 5** | 对话功能集成 | 运行端到端类型检查与编译;验证 `gateway:rpc` → Provider → Renderer 的完整链路;确认流式响应、会话历史、Abort 等功能闭环 | +| **Agent 6** | 模型管理功能 | 检查并修复「设置为默认模型」功能;验证 `defaultAccountId` 在 Provider 管理页的正确设置与持久化;确保对话层能正确读取默认模型 | + +**估算依据:** +- 前 4 个 Agent 分别对应「主进程网关 → Provider 能力 → 渲染层状态 → 前端 UI」四层架构,边界清晰、耦合最小。 +- Agent 5 作为独立集成 Agent,专门负责跨层契约验证(`gateway:rpc` / `gateway:event` / `createProvider()`)和端到端测试,不与其他 4 个 Agent 的职责重叠。 +- Agent 6 单独负责模型管理模块的「设置默认模型」功能,这是对话层能正确调用默认 Provider 的前置依赖。 +- 6 个 Agent 并行可最大化效率:Agent 1-4 检查各自层的实现,Agent 5 等待 1-4 完成后做集成验证,Agent 6 可与 1-4 并行检查模型管理页面。 + +## 六、风险与应对 + +| 风险 | 影响 | 应对策略 | +|------|------|----------| +| **云端后端有其他业务逻辑**(如 Agent 工具链、RAG) | 完全废弃云端后这些能力丢失 | 短期保留云端会话的只读访问;长期将这些能力迁移到本地 Gateway 中实现 | +| **图片/文件输入格式差异** | 不同厂商 vision API 格式不同 | Gateway 内部统一转换为 OpenAI vision 格式再发送 | +| **会话历史分裂** | 本地和云端会话无法统一管理 | 本地会话使用 `local:` 前缀区分;会话列表合并显示 | +| **AbortController 兼容性** | 旧 Electron 版本可能不支持 | Electron 40 基于 Chromium 124,完全支持 `AbortController` | +| **Gateway 阻塞主进程** | 大量并发流式响应可能阻塞 Electron Main | Gateway 使用异步迭代器,不会阻塞事件循环;必要时将来可拆分为 Worker 线程 | + +--- + +## 七、关键文件变更清单 + +### 新建文件 +- `electron/gateway/manager.ts` +- `electron/gateway/types.ts` +- `electron/gateway/handlers/chat.ts` +- `electron/gateway/handlers/provider.ts` +- `electron/gateway/session-store.ts` +- `src/lib/gateway-client.ts` + +### 主进程修改 +- `electron/main.ts` — 注册 `gateway:rpc` IPC,初始化 `GatewayManager` +- `electron/providers/index.ts` — 废弃旧配置读取,改为从 `providerApiService` 读取 +- `electron/providers/OpenAIProvider.ts` — 扩展 headers、AbortSignal 支持 +- `electron/service/provider-api-service/index.ts` — 增加变更通知机制 +- `electron/wins/index.ts` — 废弃 `START_A_DIALOGUE` IPC Handler +- `electron/preload/index.ts` — 确保暴露 `gateway:event` 通道 + +### 渲染进程修改 +- `src/store/chat.ts` — 全面重构:废弃 WebSocket,接入 `gateway:rpc` +- `src/store/providers.ts` — 移除 `selectedAccountId`,对话直接使用 `defaultAccountId` +- `src/pages/home/ChatBox.vue` — 移除模型选择器,简化输入区 + +### 待废弃文件 +- `src/lib/WebSocketManager.ts`(Milestone 3 移除) +- `src/api/SessionsApi.ts` 中的部分云端接口(Milestone 3 评估后移除) + +--- + +## 八、总结 + +**当前问题本质**:zn-ai 的对话系统绕过了本地模型管理,直接连接了一个使用固定模型的云端后端。 + +**对齐 ClawX 的核心路径**: +1. 在 Electron Main 进程内建立**轻量级 Gateway Service**; +2. 让 Gateway 成为 Renderer Chat Store 的**唯一对话入口**(`gateway:rpc`); +3. Gateway 内部读取 `provider-api-service` 配置,**直接调用 LLM API**; +4. 流式响应通过 Gateway 事件通道推回 Renderer; +5. 对话页面**移除模型选择器**,直接使用 Provider 管理中「设置为默认」的模型; +6. 逐步废弃 WebSocket 云端后端和 `START_A_DIALOGUE` 旧 IPC。 + +**最终收益**: +- Renderer 代码大幅简化,与 ClawX 100% 对齐; +- 模型选择、LLM 调用、流式处理、会话历史全部本地化; +- 未来接入 Tool Calling、Agent Loop 可直接在 Gateway 中实现,无需改动 UI。 diff --git a/package_mac_diagnosis_report.md b/docs/package_mac_diagnosis_report.md similarity index 100% rename from package_mac_diagnosis_report.md rename to docs/package_mac_diagnosis_report.md diff --git a/theme-implementation-reference.md b/docs/theme-implementation-reference.md similarity index 100% rename from theme-implementation-reference.md rename to docs/theme-implementation-reference.md diff --git a/electron/gateway/handlers/chat.ts b/electron/gateway/handlers/chat.ts new file mode 100644 index 0000000..57409e7 --- /dev/null +++ b/electron/gateway/handlers/chat.ts @@ -0,0 +1,170 @@ +import { randomUUID } from 'crypto'; +import { createProvider } from '@electron/providers'; +import type { BaseProvider } from '@electron/providers/BaseProvider'; +import { providerApiService } from '@electron/service/provider-api-service'; +import logManager from '@electron/service/logger'; +import type { RawMessage } from '@src/pages/home/model/ChatModel'; +import { sessionStore } from '../session-store'; +import type { GatewayEvent, GatewayRpcParams, GatewayRpcReturns } from '../types'; + +export interface GatewayChatMessage { + role: 'system' | 'user' | 'assistant' | 'tool'; + content: string; +} + +function buildChatMessages(sessionMessages: RawMessage[]): GatewayChatMessage[] { + return sessionMessages + .map((msg): GatewayChatMessage | null => { + if (!msg.role || !msg.content) return null; + const role = msg.role; + if (role === 'user' || role === 'assistant' || role === 'system') { + return { + role, + content: typeof msg.content === 'string' ? msg.content : '', + }; + } + // Skip toolresult and unsupported roles for now + return null; + }) + .filter((m): m is GatewayChatMessage => m !== null); +} + +async function processChatStream( + sessionKey: string, + runId: string, + provider: BaseProvider, + model: string, + messages: GatewayChatMessage[], + signal: AbortSignal, + broadcast: (event: GatewayEvent) => void +) { + let assistantContent = ''; + + try { + const chunks = await provider.chat(messages, model, { signal }); + + for await (const chunk of chunks) { + if (signal.aborted) break; + + if (chunk.result) { + assistantContent += chunk.result; + broadcast({ + type: 'chat:delta', + sessionKey, + runId, + delta: chunk.result, + }); + } + + if (chunk.isEnd) { + break; + } + } + + if (!signal.aborted) { + const finalMessage: RawMessage = { + role: 'assistant', + content: assistantContent, + timestamp: Date.now(), + }; + sessionStore.appendMessage(sessionKey, finalMessage); + sessionStore.clearActiveRun(sessionKey); + + broadcast({ + type: 'chat:final', + sessionKey, + runId, + message: finalMessage, + }); + } + } catch (error) { + sessionStore.clearActiveRun(sessionKey); + broadcast({ + type: 'chat:error', + sessionKey, + runId, + error: error instanceof Error ? error.message : String(error), + }); + } +} + +export function handleChatSend( + params: GatewayRpcParams['chat.send'], + broadcast: (event: GatewayEvent) => void +): GatewayRpcReturns['chat.send'] { + const { sessionKey, message, options } = params; + const runId = randomUUID(); + + // 1. Append user message + sessionStore.appendMessage(sessionKey, { + ...message, + timestamp: message.timestamp || Date.now(), + }); + + // 2. Resolve provider account + const accountId = options?.providerAccountId || providerApiService.getDefault().accountId; + if (!accountId) { + throw new Error('No provider account selected'); + } + + const account = providerApiService.getAccounts().find((a) => a.id === accountId); + if (!account) { + throw new Error(`Provider account ${accountId} not found`); + } + + const model = account.model; + if (!model) { + throw new Error(`Provider account ${accountId} has no model configured`); + } + + // 3. Build messages array from session history + const session = sessionStore.getOrCreate(sessionKey); + const messages = buildChatMessages(session.messages); + + // 4. Start streaming + const abortController = new AbortController(); + sessionStore.setActiveRun(sessionKey, runId, abortController); + + // Run async stream processing in background + const provider = createProvider(accountId); + processChatStream(sessionKey, runId, provider, model, messages, abortController.signal, broadcast).catch( + (err) => { + logManager.error('Unexpected error in processChatStream:', err); + sessionStore.clearActiveRun(sessionKey); + broadcast({ + type: 'chat:error', + sessionKey, + runId, + error: err instanceof Error ? err.message : String(err), + }); + } + ); + + return { runId }; +} + +export function handleChatHistory( + params: GatewayRpcParams['chat.history'] +): GatewayRpcReturns['chat.history'] { + return sessionStore.getMessages(params.sessionKey, params.limit ?? 50); +} + +export function handleChatAbort( + params: GatewayRpcParams['chat.abort'], + broadcast: (event: GatewayEvent) => void +): GatewayRpcReturns['chat.abort'] { + const activeRun = sessionStore.getActiveRun(params.sessionKey); + if (activeRun) { + activeRun.abortController.abort(); + sessionStore.clearActiveRun(params.sessionKey); + broadcast({ + type: 'chat:aborted', + sessionKey: params.sessionKey, + runId: activeRun.runId, + }); + } +} + +export function handleSessionList(): GatewayRpcReturns['session.list'] { + return sessionStore.getAllKeys(); +} diff --git a/electron/gateway/handlers/provider.ts b/electron/gateway/handlers/provider.ts new file mode 100644 index 0000000..a455bf2 --- /dev/null +++ b/electron/gateway/handlers/provider.ts @@ -0,0 +1,13 @@ +import { providerApiService } from '@electron/service/provider-api-service'; +import type { GatewayRpcReturns } from '../types'; + +export function handleProviderList(): GatewayRpcReturns['provider.list'] { + return { + accounts: providerApiService.getAccounts(), + defaultAccountId: providerApiService.getDefault().accountId, + }; +} + +export function handleProviderGetDefault(): GatewayRpcReturns['provider.getDefault'] { + return providerApiService.getDefault(); +} diff --git a/electron/gateway/manager.ts b/electron/gateway/manager.ts new file mode 100644 index 0000000..4938501 --- /dev/null +++ b/electron/gateway/manager.ts @@ -0,0 +1,59 @@ +import { BrowserWindow } from 'electron'; +import { windowManager } from '@electron/service/window-service'; +import logManager from '@electron/service/logger'; +import type { GatewayEvent } from './types'; +import * as chatHandlers from './handlers/chat'; +import * as providerHandlers from './handlers/provider'; + +class GatewayManager { + private initialized = false; + + async init(): Promise { + if (this.initialized) return; + this.initialized = true; + logManager.info('GatewayManager initialized'); + this.broadcast({ type: 'gateway:status', status: 'connected' }); + } + + async rpc(method: string, params: any): Promise { + if (!this.initialized) { + await this.init(); + } + + logManager.info(`Gateway RPC: ${method}`, params); + + switch (method) { + case 'chat.send': + return chatHandlers.handleChatSend(params, (event) => this.broadcast(event)); + case 'chat.history': + return chatHandlers.handleChatHistory(params); + case 'chat.abort': + return chatHandlers.handleChatAbort(params, (event) => this.broadcast(event)); + case 'session.list': + return chatHandlers.handleSessionList(); + case 'provider.list': + return providerHandlers.handleProviderList(); + case 'provider.getDefault': + return providerHandlers.handleProviderGetDefault(); + default: + throw new Error(`Unknown gateway RPC method: ${method}`); + } + } + + broadcast(event: GatewayEvent): void { + const mainWindow = BrowserWindow.getAllWindows().find( + (win) => windowManager.getName(win) === 'main' + ); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('gateway:event', event); + } + } + + reloadProviders(): void { + logManager.info('GatewayManager reloading providers'); + // For now, providers are resolved on each chat.send call, + // so no in-memory cache to invalidate. Future: notify active sessions. + } +} + +export const gatewayManager = new GatewayManager(); diff --git a/electron/gateway/session-store.ts b/electron/gateway/session-store.ts new file mode 100644 index 0000000..aca0508 --- /dev/null +++ b/electron/gateway/session-store.ts @@ -0,0 +1,133 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { app } from 'electron'; +import logManager from '@electron/service/logger'; +import type { RawMessage } from '@src/pages/home/model/ChatModel'; + +let sessionsFilePath: string | null = null; + +function getSessionsFilePath(): string { + if (!sessionsFilePath) { + sessionsFilePath = path.join(app.getPath('userData'), 'chat-sessions.json'); + } + return sessionsFilePath; +} + +export interface SessionEntry { + key: string; + messages: RawMessage[]; + updatedAt: number; + activeRun?: { + runId: string; + abortController: AbortController; + }; +} + +class SessionStore { + private sessions = new Map(); + private loaded = false; + + private ensureLoaded(): void { + if (this.loaded) return; + this.loaded = true; + this.loadFromDisk(); + } + + private loadFromDisk(): void { + try { + const filePath = getSessionsFilePath(); + if (fs.existsSync(filePath)) { + const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as Record< + string, + Omit + >; + for (const [key, entry] of Object.entries(data)) { + this.sessions.set(key, { + ...entry, + activeRun: undefined, + }); + } + } + } catch (e) { + logManager.error('Failed to load sessions from disk:', e); + } + } + + saveToDisk(): void { + try { + const filePath = getSessionsFilePath(); + const data: Record> = {}; + for (const [key, entry] of this.sessions) { + data[key] = { + key: entry.key, + messages: entry.messages, + updatedAt: entry.updatedAt, + }; + } + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8'); + } catch (e) { + logManager.error('Failed to save sessions to disk:', e); + } + } + + getOrCreate(key: string): SessionEntry { + this.ensureLoaded(); + let session = this.sessions.get(key); + if (!session) { + session = { + key, + messages: [], + updatedAt: Date.now(), + }; + this.sessions.set(key, session); + } + return session; + } + + get(key: string): SessionEntry | undefined { + this.ensureLoaded(); + return this.sessions.get(key); + } + + getAllKeys(): string[] { + this.ensureLoaded(); + return Array.from(this.sessions.keys()); + } + + appendMessage(key: string, message: RawMessage): void { + const session = this.getOrCreate(key); + session.messages.push(message); + session.updatedAt = Date.now(); + this.saveToDisk(); + } + + getMessages(key: string, limit = 50): RawMessage[] { + const session = this.get(key); + if (!session) return []; + return session.messages.slice(-limit); + } + + setActiveRun(key: string, runId: string, abortController: AbortController): void { + const session = this.getOrCreate(key); + session.activeRun = { runId, abortController }; + } + + clearActiveRun(key: string): void { + const session = this.sessions.get(key); + if (session) { + session.activeRun = undefined; + } + } + + getActiveRun(key: string): { runId: string; abortController: AbortController } | undefined { + return this.sessions.get(key)?.activeRun; + } + + deleteSession(key: string): void { + this.sessions.delete(key); + this.saveToDisk(); + } +} + +export const sessionStore = new SessionStore(); diff --git a/electron/gateway/types.ts b/electron/gateway/types.ts new file mode 100644 index 0000000..cbca474 --- /dev/null +++ b/electron/gateway/types.ts @@ -0,0 +1,62 @@ +import type { RawMessage } from '@src/pages/home/model/ChatModel'; + +/// Gateway 向 Renderer 推送的事件类型 +export type GatewayEvent = + | { + type: 'chat:delta'; + sessionKey: string; + runId: string; + delta: string; + } + | { + type: 'chat:final'; + sessionKey: string; + runId: string; + message: RawMessage; + } + | { + type: 'chat:error'; + sessionKey: string; + runId: string; + error: string; + } + | { + type: 'chat:aborted'; + sessionKey: string; + runId: string; + } + | { + type: 'gateway:status'; + status: 'connected' | 'disconnected' | 'reconnecting'; + }; + +/// Gateway RPC 方法参数映射 +export interface GatewayRpcParams { + 'chat.send': { + sessionKey: string; + message: RawMessage; + options?: { + providerAccountId?: string; + }; + }; + 'chat.history': { + sessionKey: string; + limit?: number; + }; + 'chat.abort': { + sessionKey: string; + }; + 'session.list': Record; + 'provider.list': Record; + 'provider.getDefault': Record; +} + +/// Gateway RPC 方法返回值映射 +export interface GatewayRpcReturns { + 'chat.send': { runId: string }; + 'chat.history': RawMessage[]; + 'chat.abort': void; + 'session.list': string[]; + 'provider.list': { accounts: any[]; defaultAccountId: string | null }; + 'provider.getDefault': { accountId: string | null }; +} diff --git a/electron/main.ts b/electron/main.ts index d314335..1b8d3d3 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow } from 'electron' +import { app, BrowserWindow, ipcMain } from 'electron' import { CONFIG_KEYS } from '@lib/constants' import { setupMainWindow } from './wins'; import started from 'electron-squirrel-startup' @@ -8,10 +8,133 @@ import { initScriptStoreService } from '@electron/service/script-store-service' import log from 'electron-log'; import 'bytenode'; // Ensure bytenode is bundled/externalized correctly import { appUpdater } from '@electron/service/updater'; +import axios from 'axios'; +import { providerApiService, onProviderChange } from '@electron/service/provider-api-service'; +import { gatewayManager } from '@electron/gateway/manager'; // 初始化 updater,确保在 app ready 之前或者之中注册好 IPC appUpdater.init(); +// 注册 hostapi:fetch IPC 代理 +// 模型管理相关接口在本地处理(对齐 ClawX),其余接口代理到远端后端 +const HOST_API_BASE_URL = process.env.VITE_SERVICE_URL || 'http://8.138.234.141/ingress'; + +async function handleLocalProviderApi(path: string, method: string, body: any) { + const parsedBody = typeof body === 'string' && body ? JSON.parse(body) : body; + + if (path === '/api/provider-vendors' && method === 'GET') { + return { success: true, ok: true, json: providerApiService.getVendors(), data: providerApiService.getVendors() }; + } + if (path === '/api/provider-accounts' && method === 'GET') { + return { success: true, ok: true, json: providerApiService.getAccounts(), data: providerApiService.getAccounts() }; + } + if (path === '/api/providers' && method === 'GET') { + return { success: true, ok: true, json: providerApiService.getProviders(), data: providerApiService.getProviders() }; + } + if (path === '/api/provider-accounts/default' && method === 'GET') { + return { success: true, ok: true, json: providerApiService.getDefault(), data: providerApiService.getDefault() }; + } + if (path === '/api/provider-accounts' && method === 'POST') { + const result = providerApiService.createAccount(parsedBody || {}); + return { success: true, ok: true, json: result, data: result }; + } + if (path === '/api/provider-accounts/default' && method === 'PUT') { + const result = providerApiService.setDefault(parsedBody || {}); + return { success: result.success, ok: result.success, json: result, data: result }; + } + if (path.startsWith('/api/provider-accounts/') && method === 'PUT') { + const id = decodeURIComponent(path.replace('/api/provider-accounts/', '')); + const result = providerApiService.updateAccount(id, parsedBody || {}); + return { success: result.success, ok: result.success, json: result, data: result }; + } + if (path.startsWith('/api/provider-accounts/') && method === 'DELETE') { + const id = decodeURIComponent(path.replace('/api/provider-accounts/', '')); + const result = providerApiService.deleteAccount(id); + return { success: result.success, ok: result.success, json: result, data: result }; + } + if (path === '/api/providers/default' && method === 'PUT') { + const result = providerApiService.setDefault({ accountId: parsedBody?.providerId }); + return { success: result.success, ok: result.success, json: result, data: result }; + } + if (path.startsWith('/api/providers/') && path.endsWith('/api-key') && method === 'GET') { + const id = decodeURIComponent(path.replace('/api/providers/', '').replace('/api-key', '')); + const result = providerApiService.getApiKey(id); + return { success: true, ok: true, json: result, data: result }; + } + if (path.startsWith('/api/providers/') && method === 'PUT') { + // Provider updates are mapped to account updates for local storage + const id = decodeURIComponent(path.replace('/api/providers/', '')); + const result = providerApiService.updateAccount(id, parsedBody || {}); + return { success: result.success, ok: result.success, json: result, data: result }; + } + if (path.startsWith('/api/providers/') && method === 'DELETE') { + const [rawId, query] = path.replace('/api/providers/', '').split('?'); + const id = decodeURIComponent(rawId); + if (query && query.includes('apiKeyOnly=1')) { + const result = providerApiService.deleteApiKey(id); + return { success: result.success, ok: result.success, json: result, data: result }; + } + const result = providerApiService.deleteAccount(id); + return { success: result.success, ok: result.success, json: result, data: result }; + } + if (path === '/api/providers/validate' && method === 'POST') { + const result = await providerApiService.validateApiKey(parsedBody || {}); + return { success: true, ok: true, json: result, data: result }; + } + if (path === '/api/usage/recent-token-history' && method === 'GET') { + return { success: true, ok: true, json: providerApiService.getUsageHistory(), data: providerApiService.getUsageHistory() }; + } + return null; +} + +ipcMain.handle('hostapi:fetch', async (_event, { path, method, headers, body }) => { + // 1. 优先本地处理模型管理接口 + const localResult = await handleLocalProviderApi(path, method || 'GET', body); + if (localResult) return localResult; + + // 2. 其余接口代理到远端后端 + const url = `${HOST_API_BASE_URL}${path}`; + try { + const response = await axios({ + url, + method: method || 'GET', + headers: { + 'Content-Type': 'application/json', + ...headers, + }, + data: body ?? undefined, + timeout: 30000, + }); + return { + success: true, + ok: true, + json: response.data, + data: response.data, + }; + } catch (error: any) { + if (error.response) { + return { + success: false, + ok: false, + status: error.response.status, + error: error.response.data?.message || error.message, + text: error.response.statusText, + data: error.response.data, + }; + } + return { + success: false, + ok: false, + error: error.message || 'Unknown error', + }; + } +}); + +// Gateway RPC IPC handler +ipcMain.handle('gateway:rpc', async (_event, method: string, params: any) => { + return gatewayManager.rpc(method, params); +}); + // import logManager from '@electron/service/logger' @@ -29,6 +152,12 @@ if (started) { // }); app.whenReady().then(() => { + gatewayManager.init(); + + onProviderChange(() => { + gatewayManager.reloadProviders(); + }); + setupMainWindow(); // 初始化脚本存储服务 diff --git a/electron/providers/BaseProvider.ts b/electron/providers/BaseProvider.ts index c6a7afa..69ac8fb 100644 --- a/electron/providers/BaseProvider.ts +++ b/electron/providers/BaseProvider.ts @@ -1,4 +1,12 @@ +export interface ChatOptions { + signal?: AbortSignal; +} + +export interface GatewayChatMessage { + role: 'system' | 'user' | 'assistant' | 'tool'; + content: string; +} export abstract class BaseProvider { - abstract chat(messages: DialogueMessageProps[], modelName: string): Promise> + abstract chat(messages: GatewayChatMessage[], modelName: string, options?: ChatOptions): Promise> } diff --git a/electron/providers/OpenAIProvider.ts b/electron/providers/OpenAIProvider.ts index 2a97150..5be87c4 100644 --- a/electron/providers/OpenAIProvider.ts +++ b/electron/providers/OpenAIProvider.ts @@ -1,13 +1,12 @@ -import { BaseProvider } from "./BaseProvider"; +import { BaseProvider, ChatOptions, GatewayChatMessage } from "./BaseProvider"; import OpenAI from "openai"; import logManager from "@electron/service/logger" - function _transformChunk(chunk: OpenAI.Chat.Completions.ChatCompletionChunk): UniversalChunk { const choice = chunk.choices[0]; return { - isEnd: choice?.finish_reason === 'stop', + isEnd: choice?.finish_reason != null, result: choice?.delta?.content ?? '', } } @@ -15,12 +14,12 @@ function _transformChunk(chunk: OpenAI.Chat.Completions.ChatCompletionChunk): Un export class OpenAIProvider extends BaseProvider { private client: OpenAI; - constructor(apiKey: string, baseURL: string) { + constructor(apiKey: string, baseURL: string, headers?: Record) { super(); - this.client = new OpenAI({ apiKey, baseURL }); + this.client = new OpenAI({ apiKey, baseURL, defaultHeaders: headers }); } - async chat(messages: DialogueMessageProps[], model: string): Promise> { + async chat(messages: GatewayChatMessage[], model: string, options?: ChatOptions): Promise> { const startTime = Date.now(); const lastMessage = messages[messages.length - 1]; @@ -34,17 +33,25 @@ export class OpenAIProvider extends BaseProvider { try { const chunks = await this.client.chat.completions.create({ model, - messages, + messages: messages as any, stream: true, + }, { + signal: options?.signal, }); - const responseTime = Date.now() - startTime; - logManager.logApiResponse('chat.completions.create', { success: true }, 200, responseTime); - // return chunk; return { async *[Symbol.asyncIterator]() { - for await (const chunk of chunks) { - yield _transformChunk(chunk); + try { + for await (const chunk of chunks) { + if (options?.signal?.aborted) break; + yield _transformChunk(chunk); + } + const responseTime = Date.now() - startTime; + logManager.logApiResponse('chat.completions.create', { success: true }, 200, responseTime); + } catch (error) { + const responseTime = Date.now() - startTime; + logManager.logApiResponse('chat.completions.create', { error: error instanceof Error ? error.message : String(error) }, 500, responseTime); + throw error; } } } diff --git a/electron/providers/index.ts b/electron/providers/index.ts index ef72ca4..2a6aaf0 100644 --- a/electron/providers/index.ts +++ b/electron/providers/index.ts @@ -1,123 +1,34 @@ -import type { Provider } from "@lib/types" -import { OpenAIProvider } from "./OpenAIProvider" -import { parseOpenAISetting } from '@lib/utils' -import { decode } from 'js-base64' -import { configManager } from '@electron/service/config-service' -import { logManager } from '@electron/service/logger' -import { CONFIG_KEYS } from "@lib/constants" +import { BaseProvider } from "./BaseProvider"; +import { OpenAIProvider } from "./OpenAIProvider"; +import { providerApiService } from '@electron/service/provider-api-service'; +import { getProviderTypeInfo } from '@lib/providers'; -const providers = [ - { - id: 1, - name: 'bigmodel', - title: '智谱AI', - models: ['glm-4.5-flash'], - openAISetting: { - baseURL: 'https://open.bigmodel.cn/api/paas/v4', - apiKey: process.env.BIGMODEL_API_KEY || '', - }, - createdAt: new Date().getTime(), - updatedAt: new Date().getTime() - }, - { - id: 2, - name: 'deepseek', - title: '深度求索 (DeepSeek)', - models: ['deepseek-chat'], - openAISetting: { - baseURL: 'https://api.deepseek.com/v1', - apiKey: process.env.DEEPSEEK_API_KEY || '', - }, - createdAt: new Date().getTime(), - updatedAt: new Date().getTime() - }, - { - id: 3, - name: 'siliconflow', - title: '硅基流动', - models: ['Qwen/Qwen3-8B', 'deepseek-ai/DeepSeek-R1-0528-Qwen3-8B'], - openAISetting: { - baseURL: 'https://api.siliconflow.cn/v1', - apiKey: process.env.SILICONFLOW_API_KEY || '', - }, - createdAt: new Date().getTime(), - updatedAt: new Date().getTime() - }, - { - id: 4, - name: 'qianfan', - title: '百度千帆', - models: ['ernie-speed-128k', 'ernie-4.0-8k', 'ernie-3.5-8k'], - openAISetting: { - baseURL: 'https://qianfan.baidubce.com/v2', - apiKey: process.env.QIANFAN_API_KEY || '', - }, - createdAt: new Date().getTime(), - updatedAt: new Date().getTime() - }, -]; - -interface _Provider extends Omit { - openAISetting?: { - apiKey: string, - baseURL: string, - }; -} - -const _parseProvider = () => { - let result: Provider[] = []; - let isBase64Parsed = false; - const providerConfig = configManager.get(CONFIG_KEYS.PROVIDER); - - const mapCallback = (provider: Provider) => ({ - ...provider, - openAISetting: typeof provider.openAISetting === 'string' - ? parseOpenAISetting(provider.openAISetting ?? '') - : provider.openAISetting, - }) - - try { - result = JSON.parse(decode(providerConfig)) as Provider[]; - isBase64Parsed = true; - } catch (error) { - logManager.error(`parse base64 provider failed: ${error}`); +export function createProvider(accountId: string): BaseProvider { + const account = providerApiService.getAccounts().find((a) => a.id === accountId); + if (!account) { + throw new Error(`Provider account ${accountId} not found`); } - if (!isBase64Parsed) try { - result = JSON.parse(providerConfig) as Provider[] - } catch (error) { - logManager.error(`parse provider failed: ${error}`); + const apiKeyResult = providerApiService.getApiKey(accountId); + const apiKey = apiKeyResult.apiKey; + if (!apiKey) { + throw new Error(`API key for account ${accountId} not found`); } - if (!result.length) return; + const baseURL = account.baseUrl || getProviderTypeInfo(account.vendorId)?.defaultBaseUrl; + if (!baseURL) { + throw new Error(`Base URL for account ${accountId} not found`); + } - return result.map(mapCallback) as _Provider[] -} - -const getProviderConfig = () => { - try { - return _parseProvider(); - } catch (error) { - logManager.error(`get provider config failed: ${error}`); - return null; + switch (account.apiProtocol) { + case 'anthropic-messages': + throw new Error('Anthropic provider not yet implemented'); + case 'openai-completions': + case 'openai-responses': + default: + return new OpenAIProvider(apiKey, baseURL, account.headers); } } -export function createProvider(name: string) { - const providers = getProviderConfig(); - - if (!providers) { - throw new Error('provider config not found'); - } - - for (const provider of providers) { - if (provider.name === name) { - if (!provider.openAISetting?.apiKey || !provider.openAISetting?.baseURL) { - throw new Error('apiKey or baseURL not found'); - } - // TODO: visible - - return new OpenAIProvider(provider.openAISetting.apiKey, provider.openAISetting.baseURL); - } - } -} +export * from './BaseProvider'; +export { OpenAIProvider }; diff --git a/electron/service/provider-api-service/index.ts b/electron/service/provider-api-service/index.ts new file mode 100644 index 0000000..58da6a5 --- /dev/null +++ b/electron/service/provider-api-service/index.ts @@ -0,0 +1,239 @@ +import { app } from 'electron'; +import * as fs from 'fs'; +import * as path from 'path'; +import logManager from '@electron/service/logger'; +import { PROVIDER_TYPE_INFO } from '@lib/providers'; +import type { + ProviderAccount, + ProviderVendorInfo, + ProviderWithKeyInfo, +} from '@lib/providers'; + +interface ProviderStore { + accounts: ProviderAccount[]; + defaultAccountId: string | null; +} + +const defaultStore: ProviderStore = { + accounts: [], + defaultAccountId: null, +}; + +const storePath = path.join(app.getPath('userData'), 'provider-accounts.json'); +const keysPath = path.join(app.getPath('userData'), 'provider-keys.json'); + +function readJson(filePath: string, defaultValue: T): T { + try { + if (fs.existsSync(filePath)) { + return JSON.parse(fs.readFileSync(filePath, 'utf-8')); + } + } catch (e) { + logManager.error(`Failed to read ${filePath}:`, e); + } + return defaultValue; +} + +function writeJson(filePath: string, data: unknown) { + try { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8'); + } catch (e) { + logManager.error(`Failed to write ${filePath}:`, e); + } +} + +function getStore(): ProviderStore { + return readJson(storePath, defaultStore); +} + +function saveStore(store: ProviderStore) { + writeJson(storePath, store); +} + +function getKeys(): Record { + return readJson(keysPath, {}); +} + +function saveKeys(keys: Record) { + writeJson(keysPath, keys); +} + +function mapToProviderWithKeyInfo(account: ProviderAccount): ProviderWithKeyInfo { + const keys = getKeys(); + const hasKey = !!keys[account.id]; + return { + id: account.id, + name: account.label, + type: account.vendorId as any, + baseUrl: account.baseUrl, + apiProtocol: account.apiProtocol, + headers: account.headers, + model: account.model, + fallbackModels: account.fallbackModels, + fallbackProviderIds: account.fallbackAccountIds, + enabled: account.enabled, + createdAt: account.createdAt, + updatedAt: account.updatedAt, + hasKey, + keyMasked: hasKey ? '••••••••' : null, + }; +} + +type ProviderChangeListener = () => void; +const listeners: ProviderChangeListener[] = []; + +export function onProviderChange(listener: ProviderChangeListener): () => void { + listeners.push(listener); + return () => { + const idx = listeners.indexOf(listener); + if (idx > -1) listeners.splice(idx, 1); + }; +} + +function notifyChange() { + listeners.forEach((l) => l()); +} + +function mapToVendorInfo(info: typeof PROVIDER_TYPE_INFO[number]): ProviderVendorInfo { + return { + ...info, + category: + info.id === 'ollama' + ? 'local' + : info.id === 'custom' + ? 'custom' + : 'compatible', + supportedAuthModes: info.requiresApiKey + ? info.isOAuth + ? ['api_key', 'oauth_browser'] + : ['api_key'] + : info.isOAuth + ? ['local', 'oauth_browser'] + : ['local'], + defaultAuthMode: info.requiresApiKey ? 'api_key' : 'local', + supportsMultipleAccounts: true, + } as ProviderVendorInfo; +} + +function sanitizeAccount(account: ProviderAccount): ProviderAccount { + let model = account.model; + if (model) { + // Fix corrupted DeepSeek model IDs stored in legacy accounts + if (model === 'deepseek-chat/deepseek-reasoner' || model.startsWith('deepseek-chat/')) { + model = 'deepseek-chat'; + } else if (model.startsWith('deepseek-reasoner/')) { + model = 'deepseek-reasoner'; + } + } + if (model !== account.model) { + return { ...account, model }; + } + return account; +} + +export const providerApiService = { + getVendors(): ProviderVendorInfo[] { + return PROVIDER_TYPE_INFO.map(mapToVendorInfo); + }, + + getAccounts(): ProviderAccount[] { + return getStore().accounts.map(sanitizeAccount); + }, + + getProviders(): ProviderWithKeyInfo[] { + return getStore().accounts.map(sanitizeAccount).map(mapToProviderWithKeyInfo); + }, + + getDefault(): { accountId: string | null } { + return { accountId: getStore().defaultAccountId }; + }, + + createAccount(body: { account: ProviderAccount; apiKey?: string }) { + const store = getStore(); + const account = { ...body.account, updatedAt: new Date().toISOString() }; + store.accounts.push(account); + if (body.apiKey) { + const keys = getKeys(); + keys[account.id] = body.apiKey; + saveKeys(keys); + } + saveStore(store); + notifyChange(); + return { success: true }; + }, + + updateAccount( + accountId: string, + body: { updates: Partial; apiKey?: string } + ) { + const store = getStore(); + const idx = store.accounts.findIndex((a) => a.id === accountId); + if (idx === -1) return { success: false, error: 'Account not found' }; + store.accounts[idx] = { + ...store.accounts[idx], + ...body.updates, + updatedAt: new Date().toISOString(), + }; + if (body.apiKey) { + const keys = getKeys(); + keys[accountId] = body.apiKey; + saveKeys(keys); + } + saveStore(store); + notifyChange(); + return { success: true }; + }, + + deleteAccount(accountId: string) { + const store = getStore(); + store.accounts = store.accounts.filter((a) => a.id !== accountId); + if (store.defaultAccountId === accountId) store.defaultAccountId = null; + saveStore(store); + const keys = getKeys(); + delete keys[accountId]; + saveKeys(keys); + notifyChange(); + return { success: true }; + }, + + setDefault(body: { accountId: string }) { + const store = getStore(); + const accountExists = store.accounts.some((a) => a.id === body.accountId); + if (!accountExists) { + return { success: false, error: 'Account not found' }; + } + store.defaultAccountId = body.accountId; + saveStore(store); + notifyChange(); + return { success: true }; + }, + + validateApiKey(body: { + providerId: string; + apiKey: string; + options?: { baseUrl?: string; apiProtocol?: string }; + }) { + if (!body.apiKey || body.apiKey.trim().length === 0) { + return { valid: false, error: 'API key is required' }; + } + // TODO: perform real validation against provider endpoint + return { valid: true }; + }, + + getApiKey(providerId: string) { + const keys = getKeys(); + return { apiKey: keys[providerId] || null }; + }, + + deleteApiKey(accountId: string) { + const keys = getKeys(); + delete keys[accountId]; + saveKeys(keys); + notifyChange(); + return { success: true }; + }, + + getUsageHistory() { + return [] as any[]; + }, +}; diff --git a/electron/wins/index.ts b/electron/wins/index.ts index a46a7c5..7528d8b 100644 --- a/electron/wins/index.ts +++ b/electron/wins/index.ts @@ -1,7 +1,6 @@ import type { BrowserWindow } from 'electron' import { ipcMain } from 'electron'; import { WINDOW_NAMES, MAIN_WIN_SIZE, IPC_EVENTS, MENU_IDS, CONVERSATION_ITEM_MENU_IDS, CONVERSATION_LIST_MENU_IDS, MESSAGE_ITEM_MENU_IDS, CONFIG_KEYS } from '@lib/constants' -import { createProvider } from '../providers' import { windowManager } from '@electron/service/window-service' import { menuManager } from '@electron/service/menu-service' import { logManager } from '@electron/service/logger' @@ -120,43 +119,4 @@ export function setupMainWindow() { }); windowManager.create(WINDOW_NAMES.MAIN, MAIN_WIN_SIZE); - - ipcMain.on(IPC_EVENTS.START_A_DIALOGUE, async (_event, props: CreateDialogueProps) => { - const { providerName, messages, messageId, selectedModel } = props; - const mainWindow = windowManager.get(WINDOW_NAMES.MAIN); - - if (!mainWindow) { - throw new Error('mainWindow not found'); - } - - try { - - const provider = createProvider(providerName); - const chunks = await provider?.chat(messages, selectedModel); - - if (!chunks) { - throw new Error('chunks or stream not found'); - } - - for await (const chunk of chunks) { - const chunkContent = { - messageId, - data: chunk - } - mainWindow.webContents.send(IPC_EVENTS.START_A_DIALOGUE + 'back' + messageId, chunkContent); - } - - } catch (error) { - const errorContent = { - messageId, - data: { - isEnd: true, - isError: true, - result: error instanceof Error ? error.message : String(error), - } - } - - mainWindow.webContents.send(IPC_EVENTS.START_A_DIALOGUE + 'back' + messageId, errorContent); - } - }) } diff --git a/global.d.ts b/global.d.ts index c7e23d5..b554a07 100644 --- a/global.d.ts +++ b/global.d.ts @@ -41,6 +41,12 @@ declare global { params: [message: string] return: void } + // Gateway 事件 + [IPC_EVENTS.GATEWAY_EVENT]: { + params: [event: any] + return: void + } + // 主题事件 [IPC_EVENTS.THEME_MODE_UPDATED]: { params: [isDark: boolean] @@ -92,6 +98,12 @@ declare global { params: [id: string, url?: string] return: Promise<{ success: boolean; code?: string; error?: string }> } + + // Gateway RPC (对齐 ClawX) + [IPC_EVENTS.GATEWAY_RPC]: { + params: [method: string, params?: any] + return: Promise + } } type TabId = string diff --git a/src/lib/WebSocketManager.ts b/src/lib/WebSocketManager.ts deleted file mode 100644 index b082232..0000000 --- a/src/lib/WebSocketManager.ts +++ /dev/null @@ -1,330 +0,0 @@ -/** - * 简化的 WebSocket 管理器(Web 版) - * 专门负责 WebSocket 连接和消息传输,不包含打字机逻辑 - */ - -import { IdUtils, CallbackUtils, MessageUtils, TimerUtils } from './index' - -/* ======================= - * 类型定义 - * ======================= */ - -export interface WebSocketCallbacks { - onConnect?: (event?: Event) => void - onDisconnect?: (event?: CloseEvent) => void - onError?: (error: any) => void - onMessage?: (message: any) => void - getConversationId?: () => string - getAgentId?: () => string -} - -export interface WebSocketManagerOptions extends WebSocketCallbacks { - wsUrl?: string - protocols?: string[] - reconnectInterval?: number - maxReconnectAttempts?: number - heartbeatInterval?: number - messageId?: string - baseDelay?: number - retries?: number - maxDelay?: number - tryReconnect?: boolean -} - -export interface QueuedMessage { - [key: string]: any - retryCount?: number - timestamp?: number -} - -export interface WebSocketStats { - messagesReceived: number - messagesSent: number - messagesDropped: number - reconnectCount: number - connectionStartTime: number | null - lastMessageTime: number | null -} - -/* ======================= - * WebSocketManager - * ======================= */ - -export class WebSocketManager { - private wsUrl = '' - private protocols: string[] = [] - private reconnectInterval = 3000 - private maxReconnectAttempts = 5 - private heartbeatInterval = 30000 - - private ws: WebSocket | null = null - private reconnectAttempts = 0 - private isConnecting = false - private connectionState = false - - private heartbeatTimer: number | null = null - private reconnectTimer: number | null = null - - private callbacks: Required - - private messageQueue: QueuedMessage[] = [] - - private stats: WebSocketStats = { - messagesReceived: 0, - messagesSent: 0, - messagesDropped: 0, - reconnectCount: 0, - connectionStartTime: null, - lastMessageTime: null, - } - - constructor(options: WebSocketManagerOptions = {}) { - this.wsUrl = options.wsUrl ?? '' - this.protocols = options.protocols ?? [] - this.reconnectInterval = options.reconnectInterval ?? 3000 - this.maxReconnectAttempts = options.maxReconnectAttempts ?? 5 - this.heartbeatInterval = options.heartbeatInterval ?? 30000 - - this.callbacks = { - onConnect: options.onConnect ?? (() => { }), - onDisconnect: options.onDisconnect ?? (() => { }), - onError: options.onError ?? (() => { }), - onMessage: options.onMessage ?? (() => { }), - getConversationId: options.getConversationId ?? (() => ''), - getAgentId: options.getAgentId ?? (() => ''), - } - } - - /* ======================= - * 内部工具 - * ======================= */ - - private safeCall(name: keyof WebSocketCallbacks, ...args: any[]): void { - CallbackUtils.safeCall(this.callbacks, name as string, ...args) - } - - /* ======================= - * 连接管理 - * ======================= */ - - async init(wsUrl?: string): Promise { - if (wsUrl) this.wsUrl = wsUrl - if (!this.wsUrl) throw new Error('WebSocket URL is required') - await this.connect() - } - - // 改进方案:让connect()真正等待连接 - async connect(): Promise { - console.log('[WebSocket] connect() called, isConnecting:', this.isConnecting, 'connectionState:', this.connectionState) - if (this.isConnecting || this.connectionState) { - console.log('[WebSocket] Already connecting or connected, returning early') - return - } - this.isConnecting = true - console.log('[WebSocket] Starting connection...') - - return new Promise((resolve, reject) => { - try { - console.log('[WebSocket] About to create new WebSocket with URL:', this.wsUrl) - this.ws = new WebSocket(this.wsUrl, this.protocols) - console.log('[WebSocket] WebSocket object created, readyState:', this.ws?.readyState) - - // 包装handleOpen以resolve Promise - this.ws.onopen = (event: Event) => { - console.log('[WebSocket] onopen event fired') - this.handleOpen(event) - resolve() // ← 真正的连接成功 - } - - this.ws.onmessage = this.handleMessage - this.ws.onclose = (event: CloseEvent) => { - console.log('[WebSocket] onclose event fired, code:', event.code, 'reason:', event.reason) - this.handleClose(event) - } - this.ws.onerror = (error: Event) => { - console.log('[WebSocket] onerror event fired', error) - this.handleError(error) - reject(error) // ← Promise拒绝 - } - } catch (error) { - this.isConnecting = false - this.safeCall('onError', error) - this.scheduleReconnect() - reject(error) - } - }) - } - - private handleOpen = (event: Event): void => { - this.isConnecting = false - this.connectionState = true - this.reconnectAttempts = 0 - this.stats.connectionStartTime = Date.now() - - this.startHeartbeat() - this.safeCall('onConnect', event) - this.processQueue() - } - - private handleMessage = (event: MessageEvent): void => { - const raw = event.data - if (MessageUtils.isPongMessage(raw)) return - - const data = - typeof raw === 'string' - ? MessageUtils.safeParseJSON(raw) - : raw - - if (!data) return - - this.stats.messagesReceived++ - this.stats.lastMessageTime = Date.now() - - this.safeCall('onMessage', data) - } - - private handleClose = (event: CloseEvent): void => { - this.connectionState = false - this.isConnecting = false - this.stopHeartbeat() - - this.safeCall('onDisconnect', event) - - if (event.code !== 1000 && this.reconnectAttempts < this.maxReconnectAttempts) { - this.scheduleReconnect() - } - } - - private handleError = (error: Event): void => { - this.connectionState = false - this.isConnecting = false - - this.safeCall('onError', { - type: 'WEBSOCKET_ERROR', - error, - }) - - if (!this.reconnectTimer) { - this.scheduleReconnect() - } - } - - /* ======================= - * 消息发送 - * ======================= */ - - sendMessage(message: QueuedMessage): boolean { - const data = { - ...message, - timestamp: Date.now(), - retryCount: message.retryCount ?? 0, - } - - if (!this.isConnected()) { - this.messageQueue.push(data) - this.connect() - return false - } - - try { - this.ws!.send(JSON.stringify(data)) - this.stats.messagesSent++ - return true - } catch (error) { - this.messageQueue.push(data) - this.safeCall('onError', error) - return false - } - } - - private processQueue(): void { - while (this.messageQueue.length) { - const msg = this.messageQueue.shift()! - if ((msg.retryCount ?? 0) >= 3) { - this.stats.messagesDropped++ - continue - } - msg.retryCount = (msg.retryCount ?? 0) + 1 - this.sendMessage(msg) - } - } - - /* ======================= - * 心跳 & 重连 - * ======================= */ - - private startHeartbeat(): void { - this.stopHeartbeat() - this.heartbeatTimer = window.setInterval(() => { - if (!this.isConnected()) return - - this.sendMessage({ - messageType: '3', - messageContent: 'heartbeat', - messageId: IdUtils.generateMessageId(), - conversationId: this.callbacks.getConversationId(), - agentId: this.callbacks.getAgentId(), - }) - }, this.heartbeatInterval) - } - - private stopHeartbeat(): void { - this.heartbeatTimer = TimerUtils.clearTimer(this.heartbeatTimer, 'interval') - } - - private scheduleReconnect(): void { - if (this.reconnectAttempts >= this.maxReconnectAttempts) return - - this.reconnectAttempts++ - this.stats.reconnectCount++ - - const delay = Math.min( - this.reconnectInterval * Math.pow(1.5, this.reconnectAttempts - 1), - 30000 - ) - - this.reconnectTimer = window.setTimeout(() => { - this.reconnectTimer = null - this.connect() - }, delay) - } - - /* ======================= - * 对外 API - * ======================= */ - - isConnected(): boolean { - return ( - this.connectionState && - !!this.ws && - this.ws.readyState === WebSocket.OPEN - ) - } - - getStats() { - return { - ...this.stats, - queueLength: this.messageQueue.length, - isConnected: this.isConnected(), - } - } - - close(): void { - this.stopHeartbeat() - TimerUtils.clearTimer(this.reconnectTimer) - this.reconnectTimer = null - - this.ws?.close(1000) - this.ws = null - - this.connectionState = false - this.isConnecting = false - this.messageQueue = [] - } - - destroy(): void { - this.close() - } -} - -export default WebSocketManager diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 55b1355..ee4ce55 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -70,6 +70,10 @@ export enum IPC_EVENTS { SCRIPT_RECORD_STOP = 'script:record-stop', SCRIPT_CODEGEN = 'script:codegen', + // Gateway (对齐 ClawX) + GATEWAY_RPC = 'gateway:rpc', + GATEWAY_EVENT = 'gateway:event', + // 更新 UPDATE_CHECK = 'update:check', UPDATE_DOWNLOAD = 'update:download', diff --git a/src/lib/gateway-client.ts b/src/lib/gateway-client.ts new file mode 100644 index 0000000..e1ff7cf --- /dev/null +++ b/src/lib/gateway-client.ts @@ -0,0 +1,15 @@ +import { IPC_EVENTS } from '@lib/constants'; +import type { GatewayEvent } from '@electron/gateway/types'; + +export async function gatewayRpc(method: string, params?: any): Promise { + if (!window.api?.invoke) { + throw new Error('IPC not available'); + } + return window.api.invoke(IPC_EVENTS.GATEWAY_RPC, method, params); +} + +export function onGatewayEvent( + callback: (event: GatewayEvent) => void +): () => void { + return window.api.on(IPC_EVENTS.GATEWAY_EVENT, callback as (event: any) => void); +} diff --git a/src/lib/host-api.ts b/src/lib/host-api.ts index 133efd7..26e6af4 100644 --- a/src/lib/host-api.ts +++ b/src/lib/host-api.ts @@ -1,15 +1,21 @@ import { IPC_EVENTS } from '@lib/constants'; +import { Session } from '@utils/storage'; export async function hostApiFetch(path: string, init?: RequestInit): Promise { const method = init?.method || 'GET'; - + const token = Session.get('token'); + const authHeaders = token ? { Authorization: `Bearer ${token}` } : {}; + try { // Attempt to call via IPC if window.api exists if ((window as any).api && (window as any).api.invoke) { const response = await (window as any).api.invoke('hostapi:fetch', { path, method, - headers: init?.headers || {}, + headers: { + ...authHeaders, + ...(init?.headers || {}), + }, body: init?.body ?? null, }); diff --git a/src/lib/providers.ts b/src/lib/providers.ts index 0951cf1..93f0098 100644 --- a/src/lib/providers.ts +++ b/src/lib/providers.ts @@ -6,6 +6,7 @@ export const PROVIDER_TYPES = [ 'ark', 'moonshot', 'siliconflow', + 'deepseek', 'minimax-portal', 'minimax-portal-cn', 'modelstudio', @@ -22,6 +23,7 @@ export const BUILTIN_PROVIDER_TYPES = [ 'ark', 'moonshot', 'siliconflow', + 'deepseek', 'minimax-portal', 'minimax-portal-cn', 'modelstudio', @@ -161,6 +163,7 @@ export const PROVIDER_TYPE_INFO: ProviderTypeInfo[] = [ { id: 'minimax-portal-cn', name: 'MiniMax (CN)', icon: '☁️', placeholder: 'sk-...', model: 'MiniMax', requiresApiKey: false, isOAuth: true, supportsApiKey: true, defaultModelId: 'MiniMax-M2.7', showModelId: true, showModelIdInDevModeOnly: true, modelIdPlaceholder: 'MiniMax-M2.7', apiKeyUrl: 'https://platform.minimaxi.com/' }, { id: 'moonshot', name: 'Moonshot (CN)', icon: '🌙', placeholder: 'sk-...', model: 'Kimi', requiresApiKey: true, defaultBaseUrl: 'https://api.moonshot.cn/v1', defaultModelId: 'kimi-k2.5', docsUrl: 'https://platform.moonshot.cn/' }, { id: 'siliconflow', name: 'SiliconFlow (CN)', icon: '🌊', placeholder: 'sk-...', model: 'Multi-Model', requiresApiKey: true, defaultBaseUrl: 'https://api.siliconflow.cn/v1', showModelId: true, showModelIdInDevModeOnly: true, modelIdPlaceholder: 'deepseek-ai/DeepSeek-V3', defaultModelId: 'deepseek-ai/DeepSeek-V3', docsUrl: 'https://docs.siliconflow.cn/cn/userguide/introduction' }, + { id: 'deepseek', name: 'DeepSeek', icon: '🐋', placeholder: 'sk-...', model: 'DeepSeek', requiresApiKey: true, defaultBaseUrl: 'https://api.deepseek.com/v1', showModelId: true, modelIdPlaceholder: 'deepseek-chat', defaultModelId: 'deepseek-chat', apiKeyUrl: 'https://platform.deepseek.com/api_keys', docsUrl: 'https://api-docs.deepseek.com/' }, { id: 'minimax-portal', name: 'MiniMax (Global)', icon: '☁️', placeholder: 'sk-...', model: 'MiniMax', requiresApiKey: false, isOAuth: true, supportsApiKey: true, defaultModelId: 'MiniMax-M2.7', showModelId: true, showModelIdInDevModeOnly: true, modelIdPlaceholder: 'MiniMax-M2.7', apiKeyUrl: 'https://platform.minimax.io' }, { id: 'modelstudio', name: 'Model Studio', icon: '☁️', placeholder: 'sk-...', model: 'Qwen', requiresApiKey: true, defaultBaseUrl: 'https://coding.dashscope.aliyuncs.com/v1', showBaseUrl: true, defaultModelId: 'qwen3.5-plus', showModelId: true, showModelIdInDevModeOnly: true, modelIdPlaceholder: 'qwen3.5-plus', apiKeyUrl: 'https://bailian.console.aliyun.com/', hidden: true }, { id: 'ark', name: 'ByteDance Ark', icon: 'A', placeholder: 'your-ark-api-key', model: 'Doubao', requiresApiKey: true, defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3', showBaseUrl: true, showModelId: true, modelIdPlaceholder: 'ep-20260228000000-xxxxx', docsUrl: 'https://www.volcengine.com/', codePlanPresetBaseUrl: 'https://ark.cn-beijing.volces.com/api/coding/v3', codePlanPresetModelId: 'ark-code-latest', codePlanDocsUrl: 'https://www.volcengine.com/docs/82379/1928261?lang=zh' }, diff --git a/src/pages/home/ChatBox.vue b/src/pages/home/ChatBox.vue index c1b9da8..8d432a0 100644 --- a/src/pages/home/ChatBox.vue +++ b/src/pages/home/ChatBox.vue @@ -1,882 +1,190 @@ diff --git a/src/pages/home/ChatHistory.vue b/src/pages/home/ChatHistory.vue index 43e3a7b..629abd9 100644 --- a/src/pages/home/ChatHistory.vue +++ b/src/pages/home/ChatHistory.vue @@ -1,5 +1,6 @@ + diff --git a/src/pages/home/components/chat/AttachmentPreview.vue b/src/pages/home/components/chat/AttachmentPreview.vue new file mode 100644 index 0000000..3bac133 --- /dev/null +++ b/src/pages/home/components/chat/AttachmentPreview.vue @@ -0,0 +1,75 @@ + + + diff --git a/src/pages/home/components/chat/ChatActivityIndicator.vue b/src/pages/home/components/chat/ChatActivityIndicator.vue new file mode 100644 index 0000000..4c6d9ae --- /dev/null +++ b/src/pages/home/components/chat/ChatActivityIndicator.vue @@ -0,0 +1,25 @@ + + + diff --git a/src/pages/home/components/chat/ChatEmpty.vue b/src/pages/home/components/chat/ChatEmpty.vue new file mode 100644 index 0000000..f32f750 --- /dev/null +++ b/src/pages/home/components/chat/ChatEmpty.vue @@ -0,0 +1,31 @@ + + + diff --git a/src/pages/home/components/chat/ChatErrorBar.vue b/src/pages/home/components/chat/ChatErrorBar.vue new file mode 100644 index 0000000..2c8bc39 --- /dev/null +++ b/src/pages/home/components/chat/ChatErrorBar.vue @@ -0,0 +1,26 @@ + + + diff --git a/src/pages/home/components/chat/ChatInput.vue b/src/pages/home/components/chat/ChatInput.vue new file mode 100644 index 0000000..89cd042 --- /dev/null +++ b/src/pages/home/components/chat/ChatInput.vue @@ -0,0 +1,161 @@ +