From c61e41049fbca41e9896ce8be10e97d3ca8b6da7 Mon Sep 17 00:00:00 2001 From: DEV_DSW <562304744@qq.com> Date: Tue, 14 Apr 2026 17:02:20 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=E5=AF=B9=E8=AF=9D?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ChatPageMigrationPlan.md | 248 ---- dist-electron/main/main.js | 946 ++++++++++--- dist-electron/main/main.js.bak | 4 - dist-electron/main/main.jsc | Bin 134184 -> 0 bytes dist-electron/preload/preload.js | 2 + .../ChatHistorySessionListMigrationPlan.md | 0 docs/ChatPageMigrationPlan.md | 519 +++++++ .../Cron-Development-Plan.md | 0 .../Cron-implementation-reference.md | 0 .../Models-Configuration-Analysis.md | 0 .../Script-Development-Plan.md | 0 .../SidebarToggleMigrationPlan.md | 0 .../Skills-Development-Plan.md | 0 .../Skills-implementation-referrnce.md | 0 .../TaskList-Implementation-Plan.md | 0 agents.md => docs/agents.md | 0 .../i18n-implementation-reference.md | 0 docs/model-chat-migration-plan.md | 747 ++++++++++ .../package_mac_diagnosis_report.md | 0 .../theme-implementation-reference.md | 0 electron/gateway/handlers/chat.ts | 170 +++ electron/gateway/handlers/provider.ts | 13 + electron/gateway/manager.ts | 59 + electron/gateway/session-store.ts | 133 ++ electron/gateway/types.ts | 62 + electron/main.ts | 131 +- electron/providers/BaseProvider.ts | 10 +- electron/providers/OpenAIProvider.ts | 31 +- electron/providers/index.ts | 139 +- .../service/provider-api-service/index.ts | 239 ++++ electron/wins/index.ts | 40 - global.d.ts | 12 + src/lib/WebSocketManager.ts | 330 ----- src/lib/constants.ts | 4 + src/lib/gateway-client.ts | 15 + src/lib/host-api.ts | 10 +- src/lib/providers.ts | 3 + src/pages/home/ChatBox.vue | 1010 +++----------- src/pages/home/ChatHistory.vue | 149 +- .../components/chat/AttachmentPreview.vue | 75 ++ .../components/chat/ChatActivityIndicator.vue | 25 + src/pages/home/components/chat/ChatEmpty.vue | 31 + .../home/components/chat/ChatErrorBar.vue | 26 + src/pages/home/components/chat/ChatInput.vue | 161 +++ .../home/components/chat/ChatMessage.vue | 272 ++++ .../components/chat/ChatTypingIndicator.vue | 44 + .../components/chat/ExecutionGraphCard.vue | 56 + src/pages/home/index.vue | 35 +- src/pages/home/model/ChatModel.ts | 218 ++- src/store/chat.ts | 1198 +++++++++++++++++ src/store/providers.ts | 14 +- tsc-output.txt | 0 vite.config.ts | 1 + 53 files changed, 5200 insertions(+), 1982 deletions(-) delete mode 100644 ChatPageMigrationPlan.md delete mode 100644 dist-electron/main/main.js.bak delete mode 100644 dist-electron/main/main.jsc rename ChatHistorySessionListMigrationPlan.md => docs/ChatHistorySessionListMigrationPlan.md (100%) create mode 100644 docs/ChatPageMigrationPlan.md rename Cron-Development-Plan.md => docs/Cron-Development-Plan.md (100%) rename Cron-implementation-reference.md => docs/Cron-implementation-reference.md (100%) rename Models-Configuration-Analysis.md => docs/Models-Configuration-Analysis.md (100%) rename Script-Development-Plan.md => docs/Script-Development-Plan.md (100%) rename SidebarToggleMigrationPlan.md => docs/SidebarToggleMigrationPlan.md (100%) rename Skills-Development-Plan.md => docs/Skills-Development-Plan.md (100%) rename Skills-implementation-referrnce.md => docs/Skills-implementation-referrnce.md (100%) rename TaskList-Implementation-Plan.md => docs/TaskList-Implementation-Plan.md (100%) rename agents.md => docs/agents.md (100%) rename i18n-implementation-reference.md => docs/i18n-implementation-reference.md (100%) create mode 100644 docs/model-chat-migration-plan.md rename package_mac_diagnosis_report.md => docs/package_mac_diagnosis_report.md (100%) rename theme-implementation-reference.md => docs/theme-implementation-reference.md (100%) create mode 100644 electron/gateway/handlers/chat.ts create mode 100644 electron/gateway/handlers/provider.ts create mode 100644 electron/gateway/manager.ts create mode 100644 electron/gateway/session-store.ts create mode 100644 electron/gateway/types.ts create mode 100644 electron/service/provider-api-service/index.ts delete mode 100644 src/lib/WebSocketManager.ts create mode 100644 src/lib/gateway-client.ts create mode 100644 src/pages/home/components/chat/AttachmentPreview.vue create mode 100644 src/pages/home/components/chat/ChatActivityIndicator.vue create mode 100644 src/pages/home/components/chat/ChatEmpty.vue create mode 100644 src/pages/home/components/chat/ChatErrorBar.vue create mode 100644 src/pages/home/components/chat/ChatInput.vue create mode 100644 src/pages/home/components/chat/ChatMessage.vue create mode 100644 src/pages/home/components/chat/ChatTypingIndicator.vue create mode 100644 src/pages/home/components/chat/ExecutionGraphCard.vue create mode 100644 src/store/chat.ts create mode 100644 tsc-output.txt 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 bd795ec1efbfb5dc5709616b9bbb3bbf079baf9d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 134184 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 z z=VG&ITg`UW3_83@lHpLNl;yTQ3Cp$Z$=a^u71~S}Hip;%hDEEw_MYQxa9J4Cu-xQ@ zw;?x)w

>E(!dQ{0ATwGDL&GBQzb{2+S~3irOkgI+v_~(Zka?4YKrXatYomW$Fg2 z!>Jd$ke8wCV;vQ-gAFOZ0MJRxUJ*fNWp{&sL|N6`Ak>X>i-O4sreZ!#V1^V}wu|6n zU`VF6sb;l7OMDHEeqh@1%L+#bvwhDaTA%n`f%E4Amb0^*=hSJqa^A^^Wm;kb!tY_} zwlG1P)s1aWYl@@e&b2)dB-z`@04a!?ZT8`!-4FI|H{+$4k%J*FW6#3)U?13M6}32e zVnf;D%tD;AmV{n-ZA))Q$C?31uhC}@{1)0S!Hq-PlKP?G(nb`t_Q&H~h4_~|XwM=y z(iCxLb(y21fmMczdoJJO9@^%)0y{8uLkenkyK;}myW3%gMc4>Y{i?LEqGOqd3f1hf6w2bpWNpT7ga5K__k=GFU-~fp#@9doTh-SeeNzFw6nWmS`4Y+)d*I zGdydiAj%|KKgRq{T!}d@LJEK-23s_lc6RGgwKuD;8|lP=C(!g66xoh)id zM6hfUMwVjoN%WBXz!cQ^P#o+gnl*n#XA+d!ecqT;vb|V2B5oK#A06PvV!>k3U zsqWkb{Y+v?h`7JITl21H)V%f{zs3J;jauHc#JhTbU`qUZrEE(zoR_zEq4dDdPNgxx=gr%E?j(Zw?HQJp(9u}k) zwp$XLJk~vFmJK$ES1w)>_WSN8Jgp<8DRF3r-7AZHqR8Lm9@-I?E=Ug*QjUx!54N@- zJ#9hpvYwHc8LgNq17QETB7~Q^VhU=TEYTZ4BB-m0v9+@Vp`_wjEO*?7l1ifJRNV2A z4sS5@0)+E8-3+$+F-4O};9&d@JH6g!S8q!=h(f}6J%oZKjb=q~3GkR37hziJ#QfJ9B|$j)w2RT{-tB1(*0+M) z=;>cmsS6UgT6=HQCFjAIH;O1C-Qe?75df|FpD(_k|-b z#`?>=g?qy~;C1h7OhKDj0?5pbbf^t!(`kMG)08p)=!U1>g5g6-XLZ|0pGbTV~|Fyu?U(b9BHkc_Uw3wIFICs!@rd^-I0`Vzj;1V|I4 zT>7m7N5k4)IgzI0%Kzvut`3T-k`|`XE(C6(q!Vx}_F&V8xg}wM?RsM9I^;T7xjMpWfC%a!HaVfL?Ec8l5!U;H3<~8-Gi0A!Ex}fs%20t9{y1O#EkYzM)Tk z5$f(iK1kYYb^x;lzf^=Wb>~Nz{8uu%Dlz~%npJ2qA(hSa1g3d{xE{+d>Csjtzr@`_ z13-Op6~HYxB?wL?asD&Xs$i5(xWkN+zz&LD1vvuaV6-UN6Uo7?`v-J#dAS)$Ipe%3 zjv*P8{f^8Sow&$^;3Ys%H7&qDw;7Ti02!FLpuyeO83IZ9S8;} z-_rq3@0tFo<_+qaOOo~IHqtr?sAvgsM^^Gh#*)=ZFJq&;ZPe z7*V1a#TZdyB`WUI$1f~6dP_=*gl?7#fIfWv0Iaa~eB-}B);vxzOpNo%cS?jr3Pm%* zr{cFhhhxRQAEUL$aT+wlXtC=X_>U?)@;$w^v!mBR8Dy0Sun}X`E$2aRVO}Cb2hoH> z3*|<&U^ZxS#j`SoP^hh|5bj-%SPBvyAu(Ahf(aXFKNAhPPHza-JdaQ73Gp zS%=ve1Hp<>rmo+w*@FIo{sQkvZ(nZ}Oe(`afMOEm!)qS6@uK^Ew@|W%bnToV4=c)QMO41syLNg7dd|`R+Vh<89ApofX*_3k5Dzz4k3!6;hVF&nCK^%^HdSll*F7u+^)$D& zS_?I^ofWirRDOleh2S#isn%OsV@3 zZZjtv23{$U+kjcm{F(ofz&_GHIYtE()$j#cvWvr!cUN~r+NFZLw2XRo+W`xUUsj(g zw4gYDTJ20Oek{ws{efkKIIA)sjA3UEQ*`Z`M6wqC$wTuAi!ua&`3WgKv*hx?GTG3p z(2Ru&-yYcGQH$HOJq`_AsfNTW{67_-x4Z@kybZgoWaQ1nnr@BTlVx)-=1RhDIOino z_QdVNO(M@x^3HOEdT@*TMChx?Da-t`#+VGpcs%$U^tU3Gy-G*gBClv3BcUpBtLsEs z^PmR1?CUr|3FHS5?F796b}mp#3EOw3kA5yd4H=Y zbx%YH1tvvG1jzbLdaV7yRMfmn#^V!;Rk@WC76x)aiwy;&cEY90%KEs%Ea-Ul13)(V zaRB`gENCwK(SC|%i1tjge|!%asBoH`1tAtx=njdxtz&-lFT!$KMOir;_jzhuk&lg= zJgt24^bwV1qIoj1OjX(3*)srU!Diq?!G0~x++bybH;-vhyVCT$U6&#VT@z>wep;gj zHc}w)1wd5`P#LSrd!7y zUw7||EXNAqKZ$>?-UF2jeciRo&4lfo0h!Q|sC^ z+rL}2jKyslq$Tzq>BiH;bG{c)7D{TNKQLGgI(4-pqeDBncyQ?;j=&P~?wz(U#w~9? z3HAf0fhdOdzy%+ft5->btqWK1Cz*MEKT1{c?g*|%2Au9!rjxFS}a*lp(( zMvuk3TJdkqRt>9n^mfz`+2@#fMMw@x5uL8)OfhL&=Nnie_1&QO0nAi~Q#I*8ay zAT|X6o7UoytoM=M8XbO4DZNZOqPqAuAiQu zCAhdMee~BaPLO=kCGVafz8DctMgOOQUr-x7H2DhkU$xg4LidU!>+h=pjX%Z0EFH{~4h~7|fg`CY zn*^~pm|8>7HXjY25CuCSosI~3{3Ku}VsBEIpg!?)3pQvzU1!-h19IuBia}TlAbl>* zzcT*?6KuxtCH34xmW<;IEf9Et=ISsF`AL#t7?VuK)w{602YFmLLDp8)luJJ?hI7KT z)}ad?;x%1{t8ncfRo$&$Hp zY=?P`P-lVmPHo3XVT=OiHjw%@h=AcRU?sH}5-L=s7Xb=|=^lk!h*##Zz^m(l;?~4Q zkCp$K6CkM`rf#tAi>Z>_=&=>ICW|WW&|~c>d>6gcLt+xc%PTxLcCLmE?bF15^g@yZ zgYOasP2_}lgl2y+^iQA$#w;;1_}+^;w?vJ!Q1GlpvA%R}H)csO%G4Jgwp^YaG1B(D z+XfHuBJAY`xa{?)ma`wolWq$a(MDF$kXQnF}A$=FS#=*9XpK(g}#Te3R zkLB8jp1B1mGdQHE7*kOC>4Syj3u=eDM?hHjx?}9EHiX-jzBH!j93a>G*F4+{W4lBJ}^x@2*BV27RYv%^h<)tpM~$%Um58scjj z6*nW2;=&cZr;rIv=>XA5z-EqIt=U@dz$Ti8W9Ck>9&W=7>YI+zz;NrtgatJuiE&wq4%bscW%gR?tmbAemNvY>U|f8neKzsPsLgY#pY`!QNx zd={YY)i}Ac;sEmf*j)3HU?C^%vxBHdh6(^zBuoZ4o&JJXC(kl%!`Cpucsd^YV+c7- zD&yU1B2tX?-M^i+$D`2f+~wZkdM9wIe`nG79O=`KHuGcaLU1>*~k)i z06_u{FiYI0&WvV63HTII0-Dz*IX++S-0oWcPT=sV`u>gWY z6`x?*9%wyV@82@z*ttty@NC9wjv^MpR+)+H zIFj`J(JlgGoLuiX^{V5P2#gUWC5T`SRsc0eHUK?OxMHSfU)%nQei$SkAw~#jDwrWM zMgnD{<72=DOh|L?$33?Zb#O>FXdIaa?y+^O?FXz4Pua(pT5Q;5yIldnHh6*vDJ9(I zLi<&634LfWGCgu`hPfefK-wBN3ao-9u#rg$azW4 zA5LRll7v!RMf>jCqdFMtiZuou4D)%%ms@OorPdkNNV)@4ROe_2gLrIWrxeNT>;3$~gfA99>2D>l-CmG=S{HFhK34t}1K3=BmrDc(MXcW|Z_$3V0 zlij|P+tUMh>eQ&N4ddL*4R`HDbS-Rmb^LLQZJ1l>Jt3f{fe4|k?R60ZyE5?X5d!9} zndOyp3>z{?Ik?1*I=Aa7C$CMOQ&WSW!PlWmlnXJ)ep5|H2ZFDGnMZ0KaOzR09g$LI zAUlkc9hL^SPjxH7v!mrjR@bIb1U6j4W$DiHAifDH4L&7F5?g&CfWh4OX}>x1!+()em)FOY zR!*G*lUo^?(Wn~Y@Q^v?=-^C$FVrWvzS%1VE43IsV;BEbWagNVUkn4$(?LcCSEcG$ zLlP&8kco9>k*}@8>&3AeAg}vFS70Xt=hAD17-STXSQ7L`0EC~T&X1!oZXz*m5{mW7 z)B(_o)gT9D>bBnj^dhRZbmZ7#NL@n=ac4h{r_sWabwcVYBb^Fyr|kogO0;4iM}gy_kHR%*MOc#m@KfLQ*S^$kX) zLIgcq;Lowwi|mArjOV%A+o3AE7>{0+6l4iQXL4-#U}*`1fXh)~86U!xM*T&1lQ}g4 z7gIyuLGv7kMWl7z%Qx(CwAZvl00!NoAV6AAZ{#az$iU%PChQW8zWf3uyY*M-;q+MP zJA18j6RW>A&TB$AKn|zq8F?{$=P89!$!G?dlm#fcOMf+Vo?f5U3+D*0B!9&meF9e> z{R#sI1^%yp+5#_SI|A9iz?a0}AY`dpw*-mGOOQ-NJY{Kj?S4LEd07y@)dg#aKQ6Z; zZ)B#qcS_F{ST9$Wh?XLb*cDcxNRXhQ*HXF(QanWC#vUuxd!avlDz&DR3xna<=IF@5 zXBYsI>xmSHZUU0EMj)E8Fc8r=LwQ(&#UVD%7@S8?zDtMDG>*T4+(Jyrpa%%}BZi)G z#ju8IIpSr_tV4ZqJdQVifm8cTr#UN1SZ+#L8HIlAtIR&b9LyY`hfyLXAU=KCe0^YI zImxGtfeE6Tx~T!naprxANq%#nYjgc4y9p+e!^VR=@)}R73J1ZOb}Y1eW&CJz%26wA zbyc0HkAE4@D#~h{VRL;0pef*u?RvF~nd|=&*Gmnw6F4Fm43c&o)!s0A#5GYpwAKQN zA@=Ek?675hSO)q=H-wFuin*m2@(cEeL!&mq^2w}eh@>WGMrU^TCG^7n9JlpnXBcUV zjlM)7M~Xxa^1MtC%h6ecR|+Rc=RmTPyn*?JssDb?;{Eb@OTcM^kPpF-)7_x*`2*e1EqW(kxy6KP zTTFv}-hY^SEKy-%v(wB8XoGi83cxeLJJOSqSDble-~%xy&Aydls!_`cd8LvJE}rwN zCyZvWA0LdbVVh-61CE>z(0C5Jd);OkN#vgm+boAnajx)2vsl_&-82lu5g8F)yioYNynCHxN44#*fo@c*mwET7D zc!L;*9MC8gU293T+VHs9b0+bnh^{3f)nsJc&`1c)D4kSJPBWa2cOW7@a*l|IkA3j= zlw~qb_Rg0We~Wn%Ds7y^PaXd{2DM z;w!EC2tBw3iU!k&f=Q<6I??b!iC; zO~GYTy!^yznTWNqfM>NyD&c(F+_^cy#2y2N1=91~FSCFMG+=t(e$~%$ zm22rhLu97&vU>7AwlMxhK<7ro)ObH3i zkhyPXXifkm-y}3YjOLE@rUHr=hv2WHAlUx;{{sl#i>B4%G$6S2TS9OdVDzpIL9zWa zNCSD~b{LkbDHk-%QJM%V!bVoNetch-^fL&O>N-rZQqvQ1pwE&tjpR z&?e$jP!li;;+h%*mtUdu9UU&%Y;xIIJ7m~jSy}A3C?=Rb@)L>)Ce5jsQ8o%*|Aw># zWn4v`j{E6#TrR4ss^?U*%om%ajI~}JSd_@MU^4VO^ysmz7EG4l=ks`%F3yOWIUC;q z?z%^Me)XosyYWp+IHz#Piw;YCRLo24@8D&@)C^6}3$tXYNQ>mOB5iplf~RP}sclcy zS%=64$Djptt1`p`XsMtD%hBd-I^b3&4>FC$@9;iMOK+v4uXL5r;4LAHqfH7u=DZhj zRXNrw2i%NQW=Q#N-8H@CyuE2nfp7-VU`B_CkRIz!`k|e3?u4!o|B*iaTtb?8H@enB zx243_9!l=5G1NTJ21t{_M_IfLR)?iNEi2xKGKYVc!-7>w^Ed@ErT5nAFGC>v1S1gr zg>3T%4UWc%89O~mJNUXY!5tnK|BI;hK@q^XXZQ1qutV!@DeQ1(?Ox2o4xV(D_>uv|n7DH4E)hFcB%>F6?_%67coBU1 zw767k8*z@oS&H+1oGWqeN|iiu?4olfiexVMo5hw5HrN-CX2%ejf-@yHfSm*@SRSXZ zJaV@Z=IpWGGGl~MQD9S;vo{v0O~kdAI7bk*^xtosBGajRHF3Jh+lsZXWA@1hP)0>& ze+6&XkuYL^95J7geUSqFmo{0j;)B2Ulv%;Y$Lx3t(d&ytW^lN$YOLj_Dm;bc#xM0<>{gcqXt zCuad_MEK<}IMXUrs*LJutVnS_`WhCgE_j=alQW|h1|*2DofiaD?j6 zvO8V|<7C8)HN+d5-pU9mP#GEKTDDw5dMVx>mZfidCqfjIH7wDS0|)%nAu+6*2Cwww5SmkjbbfrBv1g#u&#Lt-TT3n?+`3yG1cON@qq>7m4Eh${R8VZR37 zK}*od2Tv43&BBsy$}jU<7799Juh!Pg%3$$30T2q(ON zk3I&kU<@XJ{hv)%Q9ZOB1S@YM#y`b>31mkzQlGGTeK~#oO8zA5!Mk6iqsDbx_J zy=Yioc1}v-Wj#is87pCUP%q66`iWdZFl6oNhWRBCsOZ3e{-l=`vqy#OmxPrMFTVfe z$qr%{V#Ls0Ld1yCUQEG8pvkVaCm)xiCsECNVNgWs)PNp0hvrCHQ^OA&NFE?eo6J_4 z8`0Nn&EQa>7SBR%dK=(wuh+CBvXCZ|Yn+80RyTfiq`VfoZ1eImIn@SlsL<|bVLA%K z73IS-Sg~8EqI`Ie4~MPPAX;q3Wg0e>Dubi=O&j8+BTaVf`Wng4eT0cHaXn^#DA1EZ zn{tsfgTg!>!Wcjp0$^I`pTaNz*CJ^a_k6!a*=8MWIS7?+pWPjgGC035YQ=<#YP`*2 zrNhpeEngQae6*bY`+`XEZkApGnLAK@6H*4gpgs4CwH^ zly~qjZ_Iz#rG2M5t$@tY*%P%yt@^tFxzU{T9MBY!g;%+jV`UshN|aoO}Q|H zjzLHWcr!Y z=|$L|AoR!Cq@d&M73a@oCa60Xy0VQr$*j{0b$Tyyn^e^5qt`O;TV>k#;#2QS)TR=%1)gU`R1~O<* z#j-@hZ2M;xqYkOESb;_Cc90$Dzbre)D9(b@%5nuUE!~@hZ4$|m)cPTnM@S52SJB(Z$lQU;`XX9kOq5){`uHbcqeP6$7=wTi8^suxpLL7W<==D1 zum9K!H*-<^HoE-(7cnkt0nyA2fNCd3GbNI@F$j1FS~7vblW6@k16pETk5y4U0w{%z zot7NG)&*5CX#2%fRKG)yrbqQCHfrZH=utRo=L??-_8B4h z27)^opONHs4OV*8kwuftP{^RYvt`0Qv~t?(VK!PY8PRP92L*g5jfF=c!ob~_CZH@l z?WiKtaq$hbDJ;)zKLm+RBJ@J3*|H5`NlvL-&#jDmaY#|F4Diwg`ztF!If6z`!fCL- zucVdmQq*3A6UvxOoQho6SEJJ3bzs(Tni%9RlJBJ^bxl9%Tcwhhk)FQ7l3NcY@39K$ zpX5zKF$hJV+j)Sn#dRnI{S zjjNjIh{|nmSR={@Dat*7`(xPi>Y5B=hZW;Ki!D8s{e8n@U>ICJg$KarT)uP+F_k>IFzXtbiUq89alkH-D74};{sip1uoEuI9{k_rQ0_q_11xJ& zQrWz21SeVPWM0V6K&7{I2qOw~R6L9jp(y;yq&ypMfMR2TZpZzK+f5hASYguAGXu zQB*4EX(!RlD4@^IzeS)>C~C39d&#bgY(CQ1Ep{oHAB90H&RT2*0)1nb60ukbG%;+F zjm3m!$RSIpuwsy$GmnSS!k2LxXfn%RLfr3iF`>}~rv?|3wfOeBfhK(-8XtlFE2A2} z|D1vTx_xR0{cZm-e;*7#iDoyC@S z5|ui=y_+~=*vi1yhY=q3idYYF9wA^*YYQ~>op=AY))~V$9{qp13mBw;T_ow|Av6s5 zBtH;2gzHfDR|5{n(9Zp*z`qP!(Bbf5)DUiK!$M3L1Y+?|tQ`x2zoI~_rknv7bgI=Q zI!3P#q*_LQ4x%rvH>1Y%TcP8#c<)A|FC)6{OGi{+p6K|xz9hc!1B9i80e00@(A<5$ zzKlaZ?)@Hpc@FP=!|2PHuKSV}h+0{`F#1vu!(vjRaZ~eaKjfZxYSpN!nz^Ml7#g{v ze8;ibh`yZu9{qV9?|ake&y8L8XJ}M^4t**66VgV7NCok1%CCoeI5xBAR@6?O_Wk-& z0HXY3oZpq~y@2=r&FIVRUH9eosJ?9c${3qq%-9HDyNlE8i%*g#(C}^Fqw%o+P&Vm} zPrkxL^3!Px;Dx+3Dt)H6BTX0sXpLOW^~gS~1yO-a-vpO)Sx1-!7%}jiQFRiHI(Ap% zqBvE5jytLjWFzu9@!rNo4zpgz7gfh^)N!UGi<73;$%v{GH0mT~HZDrC=ykHA>f{)8 zT)B;llC653yr?>TjXLi9#zh{RUZ)^Tj_Xaut11ToilnrH%9889*oiuvaEv%({4FWx z2pFM{o=r->K%BP(B=;k9Nn>%FPOawy^Mp1BI}eyS zUVw#~wE(%w70)#IS0VsKV1ls`4jJNT-Tc~hel5YzsddlmTbenwN^5INIkjf^_;CF3 zNXDKCdnpkHLe;QsveLC3>Kg8#;LxwOIm56CD}u$n`1b)Y_MuI99NsfLy>d0*Bexlf zWCn}1h|K^cq&ysC2nkYrH#W3ZG>E$3KccM00GL?5V#TLV0wCdJh%Y>76#yCOAL8{P ze|T2!lK3Fg*#^eTsTtl>0VoN6EzS>1N-+84&+j2kWP+8C^3`v zN&o{9j&(9*r4iPszPt7wY3&keIJab>6?vbivCQtd6uR+cB?4)X$4{5Dkdxp+ySWJM zex!AEq?++aJ6#u}kxrwb-?#9bajz6rKUl2OSS{|V(AbnwDnt~^ec$z$jJa<{*( zNx)@^L!;re56uI9Ydux1kE-=owV|puLe<8o+KsAqyNYnskrR+|4EQmt2rbcT%8g0ZFn+3YDI>V`MX5xRnyth^HE7gy3Fu4Kq7M$C_hE7|gj zBS+km8*wF1UUBvnSMnpS6v!(H1H_eq5myGwD~UtIm7<6%!{n8u;o?eh^l}&}ioOqH zv|tOMSZV|{(?@C;B+*v^ye6Dd%1lXfcw!h6B=(bd5}q{D?&Kwu)f$sV+p81cSate; z3<3<%1;Ruuvr?Jw9+-*E1F~3N>7R!9KYGxBPJ#z1Tj#qMqciDS!@EZ8kD{a{Ff6rg zjhB;RAEE#)J0$H&_fZsb6MCyJ2i?*mMM)nlM{Dk|*PHAY)Ld>N!Ni+$S4=C$8`-fR z<17YZlU1CBa{>7i4u>$VMBxczoIp|ce!>T zoJ8QlX~+3BJ(G7~=O4dK_;sH$6`nHL*CZ64QmxzEyBw#W1#Jpda$daB-h>P9?yR@J z>)h#DzXLbG$ND5>4T;bD-=1=!#r1+`D}1bv!^ip^XT~{ra-W1J_c`QyKeOI(_Elkn zh{!igoYS7tNpf;)o$LT&umb!(fZv$+3F#&5nTRvt_4edvN-pOP*&7ohgOK?rt}r%F7ZU^ z@67}*6q&I3GSxf7*K>V*86|TS%Y0-&Z(65a42z z*J7)D8&=l>_+#EihY_8y&m$%c!B6c2?oI}{-s(ZTssS~unt97DV@3kUWQuU_BTf5= zeUXc8XDu*Anne#RT0@RuZuz9~HRV&v;le6X$+EA0a`D!=7T1|$^4po;N*)3i5W~WW`B{S;_1saSMUK4WAu~REXtJRI1?6S z@T_uvLDb8k>!NNG}bPC0iLsO;Dv?K^ia)U5V*7g}t6c4S%Y4ZYHmH`ulnp0dYp z&x&)tU3els@tr9rPq^OkINqLi`qar2ZiI6SRY_Cd@i=99O3|4Fuj339m_En3nsaAO z{>DNzDIKqLW$c`G?rd9Yuux6TY;ZZ+8=QrzJA3EUb81@-k{u)$c6f4kI_I6sTWf_A zgXT%ujXVdJ;7)HpR65&oX`!0hAJ0(RS_=zR-yr$*GK95A8;V-pF2_Ir6+1nF9eB@~ z^H(~~M+>zhf~VwySZvuwV8SdVic%v1Ls;7R5X?V zp>7dwU&1)FVUXA=V0f@qU__{RGQVI!!2FU!Y4X_iEVR4(K!#k@8s`x~vb%-IU3ed1 zkmo&sbi;Nud`uu3*6FtW<*8@~kMNQ{9*GkTctYo+lg3w2XKoZccM_hX!&LDVz*AUd ztw2?E|5~eUq#$?c4#s^-$p|9UEVd4*4?__ZkeO;gFd-z&7N9P3PiBrgF8e;OhT?P( zNysw{Di~&OTN3>T%SJ!3mHg$ zsemoV9}f-+)*D7%YeOorCK*gfwIUzLJ#7eOlE&dd1q701);VWVqfmym7Is7?FTqp1 zsAThrr}%MCI(}L*@XsFH5wVGKOV}wWaraNZjNfp`A*b3+=kf%n zSCEEuBrv4L^oj~wq#|sQ+(o{Md*?-=LacAghsM+zh^lJCe_3tgBr3#bdq-v{fda01 z0qnVBcad^vlLeK=xIXF;4j=|4<&Xdj$ccpo9z)WQ#fgFvvwd$e1uf>5c!Wp`?-Mn# zPmJ9ta%JVQ2OwI!6Z2xbKYCvHZ2QaHvQEUjC>As((vOlX(flYth+r^Sj}02r0ziay zlj~m&&58IFvFz&-uCQ!D0svh<;-Ai)j5K4H*cjDy7Xui|u0!V>o2V=@O5Wq>qJ0-! zsG~cX5@{cSO)s&*V9Jb1zX2_{H>!g|?_3t{U~+ezh>DSZVSP*og%Cwa4H_dIVv0#b zAt{iVp1f;zUqvQ*a%ttYy7(AWHc<7o00>3{puqqLC?x>WIqpL^a8(L`@fVy+q5;BD zCm+KjL=9083lu1{m7jbqQ2Iv!<%!o~fihZ*eF*)#21-$2K)MsJ5gV1e?d#{ivmilBkGL+%1AKHRrjE(`uJb0wt<152na}?INdP>xFTds zJ!p_iVN;+)tQQmF2YQ6iDRm-Nj|n=>2-&4@A&y=V$dnEr|G7x=igX2$%&-E4YHE)~1FM()Wg8loGHUL`5|xik*h5AqS;ymZq@OD@qX&RMo(3 zwOZ2oi1rLIV|3+n>@cW*;kqGOG-&{rTUUw@n9L_HNRc_cOjcu`hbCcmpkZAVUS1h|6Nny zJQxP-qL)l7#!E)ytjDP1THi?j<3l|-ii%C zy8o~c2pl#Q9=7?{Bo-dF`}~L9TOEfX5!f55mhpAf_9R@`yrn*FvvUg~ydA+ki17A# z;fc5)BD{Sz<+Br+i12m@Vu3@9@bm5g5O}MQw!M~OS>XzPvdH>kNIPW9Pd*b$$%ZK0!YpSK+?MJJs7 z9A}G8BwU)6*ndmmv82MS&dt+~A3J(xP|*q3Wif@PhZdbkzI^JbjuTgO;&lLJaXf*E zGOr2vab3(JJ_H+yEA>j8EMbx^zf%z5y2{>@$=Ei5(?LQuUmfam;&23wP^TMF_S2|| z(@w_si7?~)4Cc*5P_{X1QB{3n2c$;V4oN*t834EPkn3J5zCeB}zd*bzzcd(Mkd8~1 zAP_3P3|0dBG=70M@B$-7P%)=MI$mMw6q$W70Py$sh5QRKdSKWd6`5VTKRueLQ*Rw> zK=uYYAv7U*?8LquR$E!(m1NBGkp?p3E+nN!xn$SE>s93LC+E}G>x`YSG|4HGo8_t@ zY_rZ)>qZvX$AF824Yk(fG1C#Sutl>X4+ay$K!<<+91}A@h!am1OEf-gh=CGutV5?W z{u`YRW$b;WdkL?YQleNJGPlK{VI}3KIR@0S%6ffRm%=8A8Vm0S>xCCpc~h! z5gMzXFkbIxKnR9hQDH`dIn0|PAb0g&jb?famiNDO*M!t;d&3L}AOxrp7t01db4vQ81FFg)1c z$`K{QlE63r{hEiDlJr8vLb=Gu@+qZrX4Haj;)Z@s5LXPIJ#xUCkmZZvX0(QMSqwE# zJ0gvu*sYH@l0}-$l*ltL8m?+C?10-fhNSO=2wdiP6uk#T0QMW=M*|JGz12rJYW5q; zW29*ZSDjb%$^`wvPlgDO4y+l{L*WKwA=UK{0=&X0IUjfFi)Z(YNc)$4A6l)x)Cq~b z!Lj6eisifs*}rI8p`8JsI&Vt3_P7haNT<4l7Krr!pk+*A&t>Ioc3FAld=btk#J)gC zhMYd3twD#jP__1n61Y4eRUJgh9~a=4 zF~*$|6X=J@5t)rOt@b#62FHEOkYc zy7zeOvNR>4)XROfc!DpY)W63Qzk89xgdC#WXQziRIWzVwN`QtP3|dPt#Fwoy^<7et zDSD~z2e3WnqCu&Y{KVMkFpw#E;z3&|Mutg_@-rP$KJE4H&_&}+%aG#a`?!3pLlqr! z#sG6fl<{JesgM7|YAa4HKtxSSk4NGJ*PB^_bB0&BWSGRHuq+B2sABqlaH~WTLh^*D z7q->Fv3g5zz#tW=^TwAw_{zFCvOpEH$Au_bz1FjPy*8pdoNRz~IXlV+6YIcCF=>pl ziiHOY`n+zJg%jv=BOa*TnXzQ)T)vP3S{}5+YFcPg`ygye5#nMOc9<<-ayx8A?OGh< zArAgXC~CJR6?WK?`NM;t<=qN9?5X^bR@80}6n4aQ=Z_vm?QuOF?U5?R$lNbGfV^M+ z4+#wvDbcH1wr&5^EZHChi+X&W(&-d9&1tXcBlQ;Q*BK)v=C#-P$$o+NOuJHWcD5f{WQqwseU;@Q~c+2jzp~&D%lW{5?;XkYYzF0mH z<5t%PnEN=j`=a~IdCLLfA@CBPvGextD0?ufk8h|%!Avl?dMB5nlS{zCBri_HGOvDo z04&T85H#7bM}sG0-#f}O_n0cjXEvKEC*oVtW?P`aF0LXn46iOwEBBZ(x0ptyXP#Fp zmzebbo7BqM%x!Ar7E|Uvwer5qkJaCq9yXzN(dHh353nRU*1`J`PZ*#1lBse+<|b3+ zIurgE{lV7bv`yzW+R66JLm%{rPY6v&sIb>F0Fl1UK=@C{^N{mFmfBo)S5LeqlCQ-X z;kk7Y7`dM(2!MlpsI*>u761^7<4BaLiw|Oy(ec|xO&m4ByHHKthg88}#&SnZ%=x*g z$H#z?oQI)JDBNPS`|Sw8NUyQ^n(af26bt_>$mi)8AmWefqmR6^!r#&i&oN|6qQ6vk z7HAfDj)7cS_@99m@WCb1CRRgh9B{|xKnj4UyAz`BPS``%sq2^)20kqS;PDzDM8%9dagVaU*P>4_+wlrmO$`|`VSbWX7?X;sfu zQ^EMV@9nqwwVtTTI8*sdk5FDSTWFH3=!z-N@p^$W(X)Nmus(RU4o+{3LIY^ zGr*w-j}HY00612DUvPX0Q277xeZcWH`uB_uj<1Kto2PCb(=~pSmCuL-1)f;HLVKfY zXmsxpi5%-r8qf&JDIE!o`tJ*kWq`zU-wPV=pzUi7XmmxXhNAglO7-xm{}rWr;QxP0 z^>=`=`D0M37OTGW8S~6%i;&lT4W-%!kp4jjsrGG@4vij#H=iz8-tjj7b~;_U88126l$JtHBfUxENZq`S4E?yCg@g=Z$Y;pZ3i6I8K{{{rjraOdB!l)2||u&BEMYo`zL)U?@YF4SJl*BH6L-&>a`=0BauC} zjN8fB!{l2bNGX>s@scxI)R+umE7&Be`=VNbF-PHy*? z8BK41CxT%`Sw$5-S@uO%uRm~OByz^Vy=x@kwn~E=J?CcZy7O$*_&xp~WBf`wkKeNI zJAPyT1@;E#cOSnU=*F|g_ZxV6{DWE;sLwl+RA+uEL=o&J+ zq9F6c12%m{?gBF38?(plhY1$W?+zL00hQ;z88QfW{>LrTU!w@_VmNg@7Ud|2)N3Y7 zm$-5!3L?iB7!VoR8CPO>ly5)R2OfZt8qV(yk)7z{^WO{+80MV3_1^=#e+@*ggUQeg zT#tgvaxJcFsB{ZPEzS=cP#N7BDoelb400b-l;JqPJ5-<|Q(pL1PXd9zznKus0@vQ%BIBzRK|6NN({^L?QtdJQ)JJ^`Q4$i8~uFoTR~+?_3IA> zFA9~Bl!L|MnUEAB?eS<)ATSS*J54%eQ_oJ5X6f(zdKYK(a%5XP{Dfl z^_=Z?>07(}N#P>#&JphOL+4lj- z9<=_y3`FUQw|x@L5A(JgR-pO%F1*-#&Xl?-Y8SrE^!2;&NB;lkZT$da-_JoCLmG_l z%G>S*NMACr?2$;0$~ShB9T$zGv#6PqEH15t6)wu#f|vX1w{16vc)6M7vu8v1fX#XJ z{Oja*pyLYdny%4tR}?y~_^pACy1XnrL$}BBvkU_5y%78s{OmTs?;y_aK0~2CQvPJ1 z)dbDhxT)lD;TeQ+sP{PE=#u8I21(LD86$ zo_tIXHl|x99+SPuYvE&Z#A9+3@TSx}r%}as399s4kanr|u5-sie?gz^ycfpEf&N9O z?S+fdt!mNf_(6`-SDZa_S%PWD!tO&uRWgcDW!Rz~HdYy~S5bE?3>Jr~^c;aIS1-!6 zv&uDk74wdTS=WZDWRFIbv5R`evC25Tie<;boa;kXdf$L5H!jMJXO)}vD%Kqf``i+$ zl6Nbr+_vZv2dms6tHAc)T;GX$x6Qj2_WN-NGWmC*+og*xbrLd@WfcM7@5si5utO#9hdU!?hf}aCbX*y#`Dd=q}*rX)wgc-vFw~Kfn zmYw`Ot^8TB=HT@07v%+xI+Hbrby-&8geS^K*SJ`Lt&;eA-@|EjZJ9z#9{1InHNrZpV27XL1nbIB&;!Kh9s{+>Y}E z&g7m_Yqo$g-VJ!PF5?{pdJ2W+s z^2!6LP4MC|wtiOa&TiT#X0bbaiBETeqtKr|22URbg~-fFsH(OWd?2hfrLU z8u=*<9k$d}gfe>@IcbEUEw@v8J4?|{VZ%NEW$Ml~q^d!;BQa^f0uw%Lj)`cf57%US zLK-T(RU4#f_f_+np-V<46|TA>b+w84Y8WE_$7?h66gDiZMZSjWG-AhctrBfs}keC!pqZwV*O$>?7FZDk9(L?W*RCcyYuT4hz+AH1se<9S2 z8-VTlAU%mex(~DJerzEWb1q^$CT(}2&++r z(^DO^d3lMl9!%YoRRLGo3ltgY(8Yp=<80;gIU!jIpc;0 zuN~qq$k(r5yM+4%^XTQ(b$B_gac%^ndU{E<`Azt}6BQ0XxI>E?@&je~{T4_CdwCze zzk+jC0I{p_yQleKOzp|V_<9m&6E5{Nqn}WL*Q~HB8??a&V?(=rYgzzlbmQVTPld^$bN8C$toX#O z9>*5P@tWhuOs-dufi~^Z&C`!|xHmtaRCv^xvSr$_qwTFswY@oT{@5(#nEJ-~jzr~6 z{M#}s?!0NUGvg@CBagirICcgpZ27z`o`lVgV>QQ)ntZPmo=Nlv&K!9@$=^Js`AGT; zsg*A(9ZKPmqzq9I^f%8+YBr%su1sY1ZFc!T3tTvqjBCe^X8R8Y&VM>f`IK+;n(#)i z|5Luwf6$q65byl-RmZ1+Pmc(5&?Bb8Bgr{yepqCk2k23!FQ((3984|EZ&` z=u^ujj&ntyrt}MZ3ir=$m--JD9`yG2f0~x&KTvoewa|aS<10Qe$bZ1mR(xQv|G>0Q z4<0yqS>V)>m5Y_3NDBP9|1gpQf9~`jhRNt>j^;IEMN;6jqUT45q`<@c`vd0>%u)`p zu|5@z_4yBo#_o4!>_=k{yy`drv(kUTtn^=c;^4!g56+CkFf09RJuFP?f3oG#WB#qK z^^W7|#})>L+c%@-M=e(_Odrw5e01;)w ziCY5akIyn6S6_ooEWfJw8nq7GUdMUBW6DuQ$p?xX%UW&;7d+Tx?ytI8R3Q1Ji7Bjv ze8s|2NLmZgYKXp7*7((RYH&&2L`55C&3Q#J!}Qb|uUsX4t_-8JapRHQ6VD6kM0S3V zZvoo#07C0aR$2h$9X_#)t13HI8aY=IU%bI4Zft%;e0-7(kU_=)$mJ2@~oLG8~f{o zB^H#;t*9&{?_?S|zUr%ov*QNg`_)Ag$JbQB$8~0KRK<+)*%Pm=s+u~ZeB#y9kl3Vr zVnlG}iIYW9e)&A~R_KeGM*Va#RbpkOHFz}2Mvt$4aBWBTVJN#a;?CKZ%7+H!L-R|k zt1pG7u$E4ymtI#o`G%XLE3nOX-?nxL+dQM9a?U)y4@<`{v$Dr>>6j{-*iW_xziZfG ztV-g@!U}~f(rS}OuykuJ9`kGAGL38=$}ACOR=vz7Qfx=egh-?q4TLB@4Wt+*k-~^Z z63G!+v0fP#)dn(rghu__KnB6h-A49%B8v6F?~@s6jQrADU~9eP=)VI9qQ>U+#EF z6FTpB648ieHbm}ta#4x)Sq2@wJ+lNQiq=3*$(vkpicFMrsb+BD@*1tbn~Cx$I3CEx zk`vM!dp{a041QRq?%T@EVCVd<#y;n3^1B)XIt}fdhtp`>tjrQ#5<&iqI$RK9_bvZK zS$CacvmbvM^NxtLJ_By+<(0Z6IeQ?+SP&j*fc9%+jDvWBy6tU!{{qJ+B{fBowXnFf z1^b|@&YAQ`!*Z5K?_$tJH}_|q63s*gynGrR+!@_Tyj*sYG>k`Art{D~D;Y*7F~goa z=X;m+Whb$<<bzrCUz%gZLcRPSXQW%adVCESg;Yso%VR_ zk6={H2oEB7l0aq=;vrGIl}*M<%TxR|#YiV$`kN+Xz?U|;O z6`{nZR?KChf)sujv-x9`{KuHh+MStLFzUiRG!td&>TR|R z<8_YcR*_hFF%Q8w{ z!f%s(d3WtYya|uuGNeAdU7yT@6~Du=S{1XoBvBcz^q^OZ#I}NZIl+eMIhn;<`1q^N zA`ZkkK~FG~OJk?86kKTg;446zKJUWYzhvj8)z(%QU3zK%0hi?$;IDtt&;bJmTzdCx z1aY7Rn>2Li#3&|+@Y!VgD0Tg)Nrgj%((Z@P5Bo| zF8nuY=ghtu+Go5RLxuM169w_-0mQvZ!Zziec6_0gAGW~}q&+gG(aY%W=Qs^Ya3r$? z50jIG;NW3c`9(^>4x&6w$207SFGjh#Wkpi1#XGsub*5YtxA8Ehbr;Do#dZACFf?AA zUA32p!h`KL7G(m4{xF}S%&)iC61?e4lOVLQWU3#U8;xTGg#HBd{>-~5LFz~eiCsMAMke!=@%jwXWC3Z4Btlk6gzUhcX4DVG z<%9pVz@Js7^d;q7xVSVMjsnCyj^ENKG3f!6`1A(OW&>HN1AE%QSM33{ZI-EBZ3LDA*(lKoVcFlgQ~QxM^_mADsUUk1HZI(&d2qO`>GxeVmls zNd5!e$8U-8>(TYM+d_oPuo2g`MM1;XE#CGD5@*7_z=WKU zR>KS~NDA0D$e7~68OGD|`Of;Iqd)I`(fd2y5+eAENjl$VBP368KgK{fmK32(UHm`f z8-p449N*ae=HYE-{|nYkEij}=VF)>$ln&t{!c|d^_OAz;C5*SUz{il_gR+|FY7m%; ztP=cEY`+kDEx*0-UhYw5SC`JMWbr4~h~zZcW3kNO;Sf~ay>QK3lJtK47~8G3ykA-Q zq&ZWk0zmx;oht|(+7ceTd%_*5q8|2BWmR+g%|=e6EBX%|P|&x3!H@!lb&8h$yo58U zUq8K0b=B+&I!|3u(6@l^!$#?`!?)eUclYa8Sp^5Z8iWzTfMBMeOf+chg`FK_utkM3 z>Co11l8jo2eO3A7=|H3T>3r5d-#?SYC8b|KB0xVPKtCYB6<7~VorGz&UhzVz_0gY? zS_Nck!`6ilt^31r60wS@l~pyEa&n;vlV8E-607wQ-5Rx8Z2Kq4ufovuV$HVAN-k%! zB3270S2v~e>V{h4cp3xxDo$fCAI&OZd6#@HA8f1^P&F&5I`R5RF@7*WCyx*p<-O1i zydE}F2zg45ZG(CD0!{6;D?!`ovApX=g69p^2a3;{k*GymXtVa&S&p<*Hgb4zdp&U? zn~LVhici?P2&~l}L2hS&=SwpBA_Ae43u2=z zBXApO?M9@He@s==+oc#j@KswtZJlLmBk67Pwhvd3!bX7vzWqw2(PL(k8ft#q6O;`a zTvja_GS|Rm^?~B^)?LgLWAnBbD~(EmHJerA1aVec)i*lO-8b>CaaLT1X}2?@1+uT! zR~@Yl8GM%hB%64IBQZqMz3n6T{)EWKn7;8bcJY{`@ME&#k{cJnHBDbWJfY_!yC+u? z6)TA#fm%hDDY$A1Aw{5)henP~d!3>>>+qWcEaR^l>%wO!xe!_x=3_m0OJ5HjCn@>} zmvzMb42T={F5bT%%fU|&ydMIb{DFg07n(M5=7GGgDhuLECeBGXvoJR#d8Zd@DcQPT z;*|c+hi2U=7#Yr=eDHA(UI4Wb&d;_p@P9@rsT8R^r|g4u@7_f@2_!X>=hT+pJh`T# zx)#5qXun{2CbH?%a7k~v1wsG9Az~jtV6zn?(m_E!LsSIazWmF2Pc*1!hy@>Q@f|#1 zuL|NRM#yM~R!qTkhho}mN>J*sy=*tH?GCAowwHyb>siLWU~ALSYOB|f99%Rm?0UjQ zn#Wl5wh1TtsW-nPtArPXYK;m89xv71hT+37#so2k3QPGN_sKYnt%5~rYB0ijr+cE) z3$T3utQED?C5Nzd{HQoXyL=?h(Kz{?pZv}9HY5xfmY0$_#2Xwi1ip$BaE`+{UY=uD zo4hH0B?l4iiWXS|i*50Ux60F>>rICc;R!YLWw7$Y^V;w)O~e0f>Tw#b?`9-P38E2X zs-6W}{t1}EF7`yGd#qL0<7;@WBC_zbLZbCk;=_sPwaS1~-&JM3m10x`cyOkm`paLT z`t4TRUaQc>QXi`g%BBG6i$i~&Xh*G@Dx@`@UtN9;H}72JCA5(LExY88 z>C|1ozEhO4?#opV1zESsFyq5_KKSJH16T6Fh*LGDvaEa_%a#>nj$qm3X{D8wD>Eh*A&wnDqV9cqlm{CzXAEHY{@|%rUK!G!S>x`$_Lx?@6 zn>sB`+8`?&MPJWi&Ch#I3$SK&MRhskrmLWAVcS1H_()H-{iYjkxbChAw~xOD-&sC* z$=Gq6k2g;kanppmMvj<(3vB(lvq$p+Gdq)1XgwMEP{53a?m@wuBF=i+q zUe-E}o}XAxp=c7*x{TABGhWB=M}gr&b3PDd>XtJ$8}K%@Zw>;N zc+HMnhAi=385nO&?NFd&+cOE6cf=HfOT>GB`L|gjQGtAKQE*m}4;82g?^sMLh&#+o z7stHiP};(72#Z;V(55GtH^^K;dp(v$N5=!jtupR$Tu3z=rU1lk9wlFb0^l^3?V;Hv zEEe;ha&R7$+*pdGQXz6+_#5bH5vWQHNQ1O${ExU3e_C{uBkRhV$PMT6?3Q zb-gu1NMr#myzv{MWe!2h?7i@Ppyk3=;X$0=2U^Dgt)~oVVVbODY8$W5<8nYyVJ8+zD{jP_oeIx)W>Z-{?j=D-Z!9qUZPe`QWx$0b#YTSAs1 zA+!oY@-^i*JKikjG#me;fBedEud?Ke8=V*U-!w0Xfui?|3s+f#pW2Oa(A#26ANtu* zF>lyPia?NR3NqC{KYJT%N+g|7S}{XW#D$tf=b!Zp7!Uoza_vMmuv|Nn4U(v}WSe}; zqFr$wiC;n9JN{O6jt@_jD{7E$EH9J0qse%4aM@1_aMcjLhym19SDq*bSB$M{YFcQv z^x5mY2XKMwxRu`I7CWYk74G8kspbuKIEOpM2kC*&T_V?;Cm4?B4!0O=Fjhd4Sxi|kHv^|TSUZ&Vl~HE0OP1}+d=}9$aw@!xAL@s z8QIIra`Xj-@7RcE-^camSzQf|m86r$4MuAufP57|d<_764Maq?)lhT8RNSOa8+GfK zzar7!^Q&laH7l7tkdlN#^?uWvvJyyx5d} zO1CXJqblz_Z!bKlD$rv;&ouAmCa+PbsiDHXp+bumD%=w)v|2-jjiEvt^TNt!?hX~& z5wl${+!ZQ}W3pITb0<GYFc49wdLv&Q$?nx<~SI33Hr5s+G=d3qHc&$)T} ziQ~6)KdECfIg;_V{WU+tU~|X~MKeDcC^gkrj3B9-6sus;}Dx zb+sYEd&S(O%`56XjGQ{Y_7Gx2|I zakSR9D7zgkQR3r6vwl#AyTn0(6Hjfi&Z_5@u>5N`*w^ecyH29g0;5Y}p%L+XR`6aa z-5HAJ3+WEKq^`R_ofjghX7**Y>AY#-0MvoUjik3B<($-!a#7gv#jNW{`zvOTN7^3P z_LqTb#nGLAnB5XQdj(?bTeX=&>hZ_153S`Y6N|q zwZWg3xWPIHU9cUX>}?bf#QgX_%)JR%6i50$-rW?;zzi@jFu-sPhYX+~f*K`i@W3PSz%wywh56%a5wwS z{+{PQJ}uo<)zwvRz4c!8)?4Y6te6i0gbrK(^%Yt|u;>7@!6zm(MecluKdFK;?95yX z)xaqW`sw3XUe7!@7ojXdMqrhl;{baH<($~F^9ZM-PsP-)6|VgN~q|Hhvb0`py0HT1B_#F<{_-mk%j&+tA)mT1^Wl{ z!5*u#+zRhscq<+kjiX5?ZQweo1(lF}`H;Izjw!$;bkYbU$=J{p75JR$2$C^_J*i{Q z=oGDInCOX<3UEBZm-5`}>R!kX8J4uMZ48sZH|C zq!?vowj`J+7Zv5%W?4o;ZCu^zg@p`VTUe{96-GAQzO<|Qko`vQy&=+` z!WM)T4-Idv_6gTJ=IUOnn?s6n?f3Dv(a<`n(v7M8WsA{g!mgwS{vzt%@BX% z)WtBBLNueCn+qawg=i6y6+}7&MB?gWf22tVS8K5>DfspUm7oPNM7!)Sq)raqu2Nn{ z6g9$&&(-mUmW#n(NZ@riq>LoW5Hbq?OAh+L4#h|w1XgXM?_O^SheTYpwYD%X0C_)n zkqr^mXf3?cY?4V0bbkQkM*>P!H}uj6-=-fK`$$?KL{KFH6&hGK0o14zmVY=e;%?{* z@TfrJ0-(Xyu}NN#Hq~e~eGwtFZ#s*3LQH2pmn!EzI(&7oywZ+iIC5LGq(UrOHS(?? zyg1{YoTU3wk_OJK>}yL&Nt#(Xupp=3teyg*>5x)mUSqkFOe^;!^}Ii+XRMH^xuECa zp`8_Na1lfs15IUz{v>(e#0Xez`NjzixRB_1_)knH3Jyl3hm_^vtR$$8Gx&^PV4`pw z7t3&^Ld0fNu+b7949{`SU{k443+ixp0UKZbUx8!=Y<_VH3m=3u>BJhXW`K_nloHd| zHx~5Bk{aJT)>JJT*NQre%5EGKmuxoo?fBEL^yTFFbITEu9&jp0K`yZ2j}UVEfq88( zIxah46f)YRL*8(`V4@plEW7T$DH^th14b#MO@kxaVRg{(Qkp}$Ytyv{>*EDa1mWsJ zq3LV%y9l^E;SSiicM7f)0M#;5&!`EUe8S_LwF z02yw7q?st4X^`o3FrQwa&5;8TlveN|sy#?vCX^72D>i5l?ZGe)NMtyybeu5{VeDe2 zj1(dfK`o+}SqRE7TW%kb7;7#xuN2KG{?%@i-*=1FjuayP;_;ZYN{iaVV6XgGG+9jl z5+i>m(J#tvz?$N0+$_d57%3@eI8n)`aCzbR6Fkr|L>BI`zAG#+$qPp&G-qkxUy$!FmVAn`a1*~$=1#}IV*R6X&eRug~)+6)yD9ky=F zAWsW{ zv`m4Y!;M`gC!iCb7Jgk#Qo>A{7O_zBHK)K+CrO^1v7y5!v{0hJ^eiYuLrE78=Q3&y za&KreiQ2UNbzr&{8RRoap_bMl05M<3ODy*uNn}W|&ERH=G*Ol!{o}#c67umCAw(sEi z1y6tXmtJ`PCaBcG^XWqtSuh~ieXKM0U;og;xSPK^880|qDTiP<+V6GoW0a?$hx7}{ z+&%X)M1fKGBOM}P7Mlb`wtLRyOV>R%!SyV}*OAb!ea;l(y;VYjo7Zk5+l4|G!L+K~ zee?%Id=dTF!}l{pL~i#(35{{LT`NeuOyGf!+JZum=oz}(f2Uzv=#8^}I}0vf3g5*& z-!F=37ri2%`&-&YUDPiBouL<7DJ1bl#7u_d5Mw_5C+{BcYdK}~F`q0vef8AIxVYA5joa~o=DzHCx z4xTa%fEWF#pmdzCaw>Qb{U0)9>(@Wi<3QpI&_$MheI-XTnaKPD6H)%%{>$g$^@+ zZwxb^gp2yOp;y1NeSeCL2tQr_i7?Xp#`YZz+UVf3dIOZ(2gHlblsQ{nHVvmZT61E-M7qJn9q7o3ZlB|r6*>>XE89PtOHkNu7{#q) zDH#yOYjjHmgo?v^By_0KluGa0kfu@TJ)0UQ)BMi#=6TQ?u5J(wlJ>m-bpsMr0EjZ? zr@FpfKvPmCF~Gl(hv&8nA!G4BIdFBBe^8SDV$stf)Y}2BeB9u)am!gvRGWPrZ)??N zbFpU%k;uD{tdPhL(BzI)&6l@Olf(S=I69kQV-gE`ji4cGN)7=~Awud2WGQPRvWfnP z+O^^A5Ru%5Jo=`z_C*c=-uZP=SlZ_XUDb{nGWGC0tSLK-F3xmyCA!;ys~ftiN`*vU zRY8x2jeNj5hjy+P{U=29G~Wf8Z-9T8 zeCI#IcPR#{vz~a$69f({L+iQFf-2S3(HJa?l5BF2_N`qL=_Ec$Lm@If4PsXD+Ny#FWLUMV0KEGQk{2)dRv{%7;9hA z!T~4+9^4#5Lq|u%MuI)cBjhk+bafyfjdp&DOLZXE?03~8RJQuHj4s?~C7606n-O}T z5gl^G%2y#rL}(pz+Rfp-2@Y(d*{vcd7a>CUh6NRdiYw|;wa9X`YCSG>h{LVfsKC{@ zMm)ANo|EgCzcQ|h1|icF@?IhHCLBISbJq`8rqp90Xon3C5sb4TyiM1q3J2$1YlrpQ zSEW2PG<+oca{5Wbhlx#1m3L06+SJcT;k)?oH<(3PWoo#XNXrI#S%z9Bpw=s^s>B>kC<-QiZm}97y0J>XYlk$7> zZeWh|#2d<3Bg`=Nm;1+qoe0sRaEySzV1^52&;Xx+Q21G3(?y|&A!txU7&f76>AaN4 zu@SM+qmxJBge}vc=-A}3$#*4>P8@CiK{8&K$g9|7hzRrVX&j`9j}9;yLkHnCArgiy z{?bMfodcK;X-AcF)QSQcEe_8pub&l zZxu%O1z24jxL54LhBcyhXO>7)fzKm(D%1l$2Ed0ebxKG62JBNKxjKjkWM|9+c@XF? z5sz+ogGxBgpiPo@2iC3rjaUcslHjXiouXBMZoyiv5I-IfFd7X|rt$0Q%prm(t$mx6 zs4#z{F-)eqj`qs|s?_Ewqz|k_km(;@=0NM}39u5q*mRSg0LA!^j1ZkkAnb^OLG4M$ z{E^L5nor0sHHs@kY)Kp)y#lS|COT6sFII~YsI9METMHGYwxO0ZD6gX>Pm`jg7$c!- zO0aK!?yj7f@63DN?&mX#ILfD^XmvgwgE1#A)%R(Za5|7ZIA^QYPIb34Dn%a#z|FeH1PBUH>6w zx9O6vu+A2?Wd{??1lx0x=D5q8bH*gn*BX{x}xxf3nAFns5{ zix)EAFd3Mg1I}fZkpVDB`z(!A0Xdbo^q(eEL7I zH1y(CWoRN7?!u^CEmuKN$w4vi;?;XqUdvWn#eB_)d#gCY0KE;_eCH|cxFwsMyTRbc zjk}QWPAr##H&QJ4WfajwqZLH%(-`-fYy{@gA9mm(k6%z@V7B7*b~w+dvKR4oy@RCJ zTCu{YkoC(%wH`&bGV?#j%;yg9k5W}jcd10(!YTJmFGDB{R+@O3ddsXlath)d2t7yB zGSeM1`Fg`f2}g$0d3hkXXt`tMp&XSi$3?zkB;h!%JanbNAAE*3#z(=Yd*XEp%Wqlv$pcd669=g*!Cu*U z+|VV6oqRNY#fMiCR1}-aTE|KXT=?&aoOY@&D)^696*>NuVpp+{u_)a=xa?c4-x1Oi zoh6Z0PRm+H3;XA`$lGzTxFNCqBv}l^TX2JQ z-|DMg`(dAG3A-whm7}y0XTvSdJ#-f852w4cS$l8j!<%L$~h?}WwN1Z=dh7i0R^XG~(ch=L~ zIYHIQ96u@^W>;{0Zg`cB9H6-~08>DnJC?YVh%j?6V*tdN(f+<975Voz7V=4=mpQ9r z4|vCPAa+P?I)gzs1v*U!?{0U`+qWDiwkgYNVh ztaAw7%6tH`R_&c&+T8g{RkDerUgKDJP1bJsrf{s{N!Wb}EaZ;k4lH!{Z@7)n;Q-JO zE{Qre5J#Ol67&baJocBhfXC9}oV=`SEBVgH~J=jmy+SZ7%Yv-70Y;kdU z+47p8w$z<2)|%9U%{G9L|VuIsgRa>3`}2;mU)~!pZHv>Nr8ni7%Z0*sQgew zA%c%bR$z6sJh(-S7!j%k>y;hRV^G;hT+2XD$PP|{!l3*RimQ$@kcBhlMIU*2 zzpFuiNRTLO$3-suo!0TeeQy75`BSN=?>}-MzK$=;*D~)x8$R*$TR9pptGp*9Q(mx# zD~7yapXMepI%KW=s>dN5zQ)(=w=&FPcP(F{_u3S|7x6vxjYbVE;$Ls}Imp-Mwa#nv zp5PN+hW$pJV{P6)y!BzXga8=o(%bYVU7KS?x}IB~RSH&uX?33Z{+7Eehpfv+b3vP6 znIkocejSQ6_{z+W9ehMJqF^mVFsOsgZL?=+pzlMnGSDAjphsY!5kCm9IB|l!H)wS= zSQngjT+&!D-UA}~#>Gdk5z}Ml9T$D#O(`-fiO!DBh`uHUt(STd%fP_beNyG9$v`PfT6>)>))x2{f| z={&6gZl`|x2w}*tPWAH({k1JrYF#L{(MG7YkgUdetq61ZoNqM_d4kJ9>q56y>79^i zTP5;JaBHk)?g@M0t=sJGp<|)fuhO!5db36gv7 zqxJ33)@vbC8mqPXgncod-h0Zfv80w%Cs=Od;ctVp8Bu6^VpakDm~#6y6lL@ z#`UPQphXUMod5U!b~6FAuu#m`hh6Qy@ErPW{&p}v3jQw83gkAh!ALim`B?&xjVxw}oyMX|3O^(dB!ILcN_D4MeAKKkgdWvvqL(KyMf5V-9NuG(iin0T2a-#Wkque zE0XePb0~MoB*xu;kJ~O<-_9#8N}dZvkeoSJdoXVun(YiVWQB8%fZ*85};O5@B@Ued+3>u`w<$1-ziZ ze7XwrWTZ)(S_tMNOkrs%%{ zZvNYYRwHUsVLmF;0dj%ET&4Qmz5FrX{adivx4Nc#jcE{ZIW2XMa+%~oK)?&h$k<3NeG5p9R5RQOqieU%LR69oGbWGJw22kbkJ zskpw}Gg;`Jk7codA!DP1XgB*^7=&0b*83A%#5vi-GL`f0NriYB*ETPimy$XT)*?(q zqh*z%7pW9ftOgg>Ub?c_K~bWR5#8N10EL9zaJO*?0^7T43C`gXpeUGbOc0?eFhs<^ai`d*yAE9-l(k?}(^Zl7ne8hE zYbKxN)`d6M2Xw6t4Fmq*lB#nOn|FiG$vpNWfQ0G~uC4a)O4uJF?W1pKbn?b2ZF&pD zp_YB2?a&Weq<5SG9jLe>yApIn^?VPQG7L~vu!Th>$oN7sE=gnbBU!-RK%vrc@3gC) z@CL?Y;TIKemdSWy<6f<0m}3g!BqT<;UVg-rgo3zWa;t3B`MeaD;%`aKPrSiQI^9Wk9%^q_ou|*Hde4a9{#xs{dQYG8>*V`QQd)|FaT~?N zv2$u%g}G!j?K_|p@mcazJ&kK2q&ykn?k8a`9Z87mLO#Xjht`v2Lzss%#Yx&U-@p0* zO{vLaMvWOi1s$&nL#P*48>0l*TvQ*hh-5(utdNycQRo!yrPu8olaa}{hHpMDE+S=> zwuR2ImGHDj9@zd7y`7YVz3n)9n2h(|KmS}g;ojVwMLDu<1MfpHZrZou3mS@~q(w!x zdnrf^j|IcGqkA13K95x7j<_)LJKx{hbd&_zB*@UkrDaLw2;@TNy;08+&5wqfmw%A$?Huzwo-4~b9M#Bv@GknUV3FBvDrp+EYdG`1TBQjY>xUUQ% z9V7xO7*GWa;7)F2E|RX-dD8A+>^NCLgCnaI4i9e#&g&>F>3ds4_5G2pig1%MhfkO^ zVm9CZ?!2jkha#{j(6ua(!vJ7pmGi>s3y6bq!&_AMgI|96r=I zc&wo!B$@VfbPpDFCq72>dGC1^s$M{w@aqHt95?TFprFl7K6jTNwR_WT!;1iki`_sY zKyxCGu#bN3AJgihOdw<>UG}Ym#a8fAA+jZw+pIc+<6ReUY+WydV-tbnIWhEjci4rQ~*6Uo8$ z#)x+zn*tok`#?MKyda#hwka@(45-k3jS}C`B>7sIj=>D(22L0#%H~zp7~|mq4yMuW zTcP#pfzwqGw9^x(sOY@4dvw?7rrHa)C&|nnWy@N?SCmKt;Hvz(dfU=ZWNVF3m7BM4~UlYV|jj{q~ymT zzuFYvVcP@e4RYqZ!DzQ{!hU0jgY)*RHYYhaZ)md2dBf6Md{b`1c|l=+MaU)9hoyt} zt_9y+kFj;aBqCNYsGcZJ<)Fv3;`@_QBC|~kM6%Gyi~O|E4}QE9$DE)XS-k1R&b%4x zeZsFqtSRqdnK!>lgqfJOLYP}I^*Us_BTo}+9uU`8Va;J_Sqg)Ff*9;7yyv3RJb;?N z18+k*XR){7??UibPStsAx@X7>^Yh2KO?T9*A&I9uQxGCc^nupHFU%J2{@(F(HGmv{zyNrta zw6K7!R`LoW;eNFnOK>*UFlS?pJEn?YXME{w*TI^Ea6`qDT4xzJhQO zrT+laOH$CsQgSe93EP7eoLWUVwEzeuD{C~Keb5vm1bLFth_0W*^$hwzl3A47Oi~gO zak~M<m7`G7+n3M{PA$>mAXL=#}j#{t!M0{Z7;Fq4~hWVXP#h zB^%tcTWlp7%8tgXCTiNe0?AN~i8Cl`e(Ixf*J)#mikibA8I8(>Ix?BU7!jRSMHK`m zQXMEzo`lflkDAJ;tCpM!3yFk}u53K-MbC27#j;`xCey=u&O|?^ncRy2Av+ID#{lsa zzG-+ts(ICuoStDUhG0D`zW*k%zcyJGtv_-LUC$7FYNeq+0^hnR;#U^Q!?Ac(h3lP5 z38r*C)Ds2>0@UZOQKg3V%to%@5Q|x6qLUOHdwY(Gd~9MOo0`a`CsH3hvI2Ketx2VV zkVEg&kF3Rf47!1NKRH+vgwQ$&p=@Cy=;&RN@v@XS*8h%COZf_D z=yh?(lp&KN*HUvKzsl>Ngqpi>)q?Z_`p76I&=J9=wa+JY=m_81{3-SkfYh}0eDoJ0 zHD$JimDtx7Z|yK}TN98@BL2vG3O0G;UfxDENiIow#aUH9zE%6}J-9EL&!cDl!QxWL zvcyt===>II3ABs!`K9@l#Ww4$UU{V@vexQ&9b~MBf=*!{y-hbJvV+y0z6l2q z1xQM7^D+elLP1x58kHqHt%3vdwQFXSxNE@OK;T)qZga2&jd!R-_~}j5)aan*Sjz8e9*%1|`6KD>k6F2J}2&3?%yj7Q|bwCYcbL z{6Mv-w}Tl4rO3=EIK4F>6)_$AhF^2qYP1#>r#qJdR{ZxVsL+cTR)yX+WK$}lG@Y1B z>KpyIdz3+d#Ijp(Pszf;Yq$bFrh`DV8?g`FJtf0swShL}VD(MVD6(=*$2KIQZpvKW zfw|tHMXlYYD3#EkqVI~nGw6PWvN^UODY%A!0X~N>_oBJ?Z*a{;s|1P(U2%*88P#(v zn@@8_bvyu!fi$%38s3Mhs)-2JBpVz`b7Su5*F_D&G*O5P7s*hjxTs0^4A6W46j$Y7 zMKY!B6z7aAG99j()Bu7Tmf!@f0yn=+qj|%KX31fa(V@df;9Ndg`I{RE({2i9}Tc=w*>CpdQFHxF^vW(Bs7E=N}J3eO<<0R+@zF z$628!pw-V>uMH!{He5F6IAlgYSzl6ZzKF0YJ|>dm86pO`x)p@pXk8_Rwa8Jb5KIWx zL$`gV7D?DU?`kumu7*f(IB=5^_J+1!UFddIddGBQy4$|FS4pSxaoft^-stWQ8FF{p zWe?5n!mG{KFMA>~kvn8Z4b_HNaawYL)a^5R?V);?WnA&zW7N_A6ttBd^!$PG2RKSz z@rm&^?6dnosnT67yV@-J^N$Nx$$7zpoEJRyk@Lb{eMB>y7p}goyXt5i94vidY1FmC z!IFO)XkiH0YxHwCSemN+gA+)Bf|z^77$Q@afb?cVsIGZWXnXTQw`S=bCuaseE7pO3 zoR|~NM_vG>ki0-CyaZ(*CXR3fJ|G?TNM}1XoE@-78W(+VKgUL74 zE}CW=Uy~y4$JYA+5h?z2(j%W1*NbWKiTQI@P$L|2tv?&TJf88BFq zL!;aZAwcA&;C2eGMr5hdC0X1a=sW%cyh%|sOJ?U37tdBUVAC47<}8As)Y%--Q$~}} z`1R9YY=^6kx>1|XVG}g&-W1rftA!7o8mEkbh<)WjUO1hC-LZ+W^)>KGBdf7yUB&PEmGGuJ?cRHKq0!?JBCnws}E+UZ|t6GLtMfx9mCcD zYvGm4*DfaP4sE}(P`E;k*HTbp?H+3UZhgdMH2%ul*j(N0-_vPY>|Nb#+=tn9HLSTh zASq!Frac;TIZ!b?n9{EVrRc8kTHUTl@9ZU)psNcdPz{B*$SeOuLFv~3nyAIdDV$|Z zHccYVt4a2vAKw8DnoChp+($6XIvmsmJB2%VH9ugL%5+)-<(3P+Zz{6e{EzG2g5;W6j9aQ@2qxuVq+0g2@2k^2tHtAoK03PCa9A z&^{@s2B&)(2bi_~@K#KYy)`yk8~21ba=`ff{_jXZEmE|_lrb#dU)Mi7Ju5v6qWpJm zLn2F`V96QLQ_=_c?-hf#=}og^R`^Eb-=@V>Eu3J%G?o27Nd5ExH1aI6_)F^NS7_)V zkRX!HqugF5^>Y$#n^8X-4U+> zqC6WM%MaS7*3ysfw;~aegcW7S)lDRlXVKa{9eBg0_R6LjTn*x|ArNLbn}4woEV!vW zTCWj(v#IZ5@a?iAjed>%Y`8=Ii=O}Up})UDWU=5rPPzCp$Ijx%KPQq_FlTl}Sx%mf zXrI{4yAP$r*VDW6D>~ktyJAW-y*t0+-@FU5`Nl_!zoByZRihD>znE7*^!MCe@1eAO zlwUQwkTN=IK|eaT0rJ7H_s>1$tJX7lK^ZiDxh}{aLn8YPHaY5=UCwizD#R!&QAv0J zFX9ratcy|ZkadrL0qy>7?G5RRFFfOZ{=zd09n#B~`$A7Ri~T(fZ*02s0* zP(9FB>DCcL1c5PthJI@ck*FfaN1(;fS_F2`ztONa)D0(cT+y{Y7v$Wyxc=f1~a)sN`Yq8~gS*X!q&C(D&knn9v)S(%RhgFPX6)tKLxn+Eg7VOOZ&d$aBmO+ z`T-GIoXlp#K2i$DGa&9`v43#HN1`c6b6$)XWbPYE+m@75jA%@#0xWm(F<6NE6}V^; zkg6T!6hIAAiXTr(W1?(OB-03wvPb8kLZRK9Kjk0^Mx8xrEo4zrIVrCW2p1)fUHQbNLg?8wvZ>cPc%HaU8rFP^2Gx21`X(ne6f&N zV*xt)smLdBkUz)gNe_;=EZt&>f_ro|%#G36K z?EPbp^W+GvaEMlx=i7=ZDsxH%wuL8e_-@YzDvem+Rdg03d$$)0N$Tnw&HR$GQ4G@{ zDVwHMJ*UC2@Ib7@F;)(vCNR!t?He_OaUI9O$XL@2#sPr{yWab0)K)@mOS~H9J>8<& z?XJj~U4sG-j@4{rLu1#nOk87djf-8oroLfa4c{ZHx`$J@I9!&*t;L7dX43+4H#n|} zwe?+P*YV)FPsn^%-hu#z+hwHyx+0ytTjv)gZNw*?#8R%*U6hqN_55APrx$R9E|j_- zl+eRJNCNF7b>sgtX`3AfSR?W-j$MT|xWdDOQLaX_&8`)dbWez@)7`esJ*Pw5-Rqv- z+w5Kv&$@ob_uxZ*2lR*q)zCegdO$pN z+v-bo8^X8YYgZx>c-UKarC)OtMIhF-(zTmwX?|47KW3ZJh?v8uU|GhQ9_BN~0RAVf zB`{7&F!2Q4W#uu5KT=2HDSq!(L@7=?8+S^3H zK!l;NKIx~!($uH$e(9&fZ&9Bj2Bx2mxD|_iry~c`;k2i3GoKA@KetdgM}o31N%_9p zNKiJP)kmC#qX@+!+pzccT_}=+0Ma$xzwWsy$3GAW+1^>!49Xmd*ASz10mX zS*GhcSbLD;>4X2g45%T5Hqz+)8|`{-(E+Uupi+)9Cbf)raHHLan|M&h>{2F7E#n)} zXg5e?20|H&OPOf3j9+Y{-QP_q6X#MUUM*uxY_tb3rA#lEGRbP0z?4S2$z3Uv>Qbh! zS|+G}qdnL|DKo&OOuAYoWKg5stWnAgaVe9bmI)o!Xb;mWWk$G^8L5^DAKhq=@Knm& z?owu~S|)OQqut`Al*#Ilgy5ki&RknbY6wZIe8Ujdi0N;~+VFq62h9XcqkoM54^fK$ zr5%Zsr|w8ZxCm^(#%J&gW=RRbY$~oLxSq$gA6F}`9*M!M5?4L0w{Ts-)uSiM;#z|1 z6I{RG8rTcRALIH2*EhZ7-Hn}hU5`mpv>6hUrSr|6nqEPxM3SX6^ZlF-dn6VUH@Chw z2{0YU!lUg-|MBU&jKq3o7nR^Ntej#ToJFT0Qpe<>V*iYm>mMRU2I7x|`0U$#J8zEvNgHD?E*KDpT)PECV-G<`g1z@D1f?*<`rPGv>u z)CD+J%vQz+^7hjM*HAkseMITKCB>yV`SVE4RR=QdS3%g~g`ilH6e_iwZkR;*m{sU3 z1FyMxbj%vr`QqflQA_bQ|MkM)mf-3Ng#bA6ko-kIno@k4DFa6nW82X`8Dh|ahg=}h zIm-H5fJBGFcDdD4V~JQQ8e?HpM7*$|u?jRCUT|8=ru&f_lV^UNernpeL7(X!G=bJe(t@9>|{8wgrN;hheSl?7KCaS z*o+{g9)MRAEcr^s5)0C#P3@kVLDBd7_?q292N)IPc9<=I+!!L|G))x8FO9lr#PJy7 zp|a~R6dponneIB0U1QoVC|%0SJvAMn%m&k0uA- zhEc)jOIzArAv+W>V_Z&A$({UEKsA3hBS(U)Yeh<@k{CrD(@lh*#~sfoq8-6Il*hn`p63LR6Bp!}iF0~h7_hl8w2 zC*e3NTN$VkbSSs34tt2Mx=>5jI@7h9zY-Fu&6;bW769t>3y~?69ygMu$PGj%9s=5+@F=TY;mT&INf${ieSq4KZ{T&L-om*g?lx zW->vi%i4kr9gz1$h2GCoGaaABJwIqvvyuOqXF-B$ZMX@vu*nv_syu?e#(B07#8h;j z31~In1Y#Ua^xc4zkfr!b5OcOu!WT$*T!k1AtswjbzRz05Eu5WM(Ftmu5$$0YM5}qw z%YkTUpA0bvqA3vT8iAhU6UNIF+T&K5>n@+g^Zx>`Mh+E))=Wiw~mPZ>!i?4Dx`ipZf#i9wu{IdPDthA z0cZkua(|=br8=6CYJ*^0*q!{pLbN#Ok)Pq}4ut;+(cnuZ{MiYFhlhH7Lm*TTP3;Gr zN76kAzj6WLYpYZcek}U`NeZp`??HGj(&r@-qn$tf|6+QYhGV}buKx?v$j|Kw1!}a8 zPozGJ_|{kglT7Nx-p+Ju-(w1361R%cKZ&6mRlqo`-~X0w;UltZM}4scpZE{d3C{HH z={Uxf(CV0PVsfUuZ;pitfVbtC}>L^yZhZd@v6 z_UoOER>;x@5eNFo_Jyvz6JCA7I%Nz>Ehwl4UV2oJfSAguSvdO)$O&T+Jk7XC3d=|g z=*T<{5svaG!e}4|Tb+ml&*`%?1~Nj>92AuEuhhY(9bR_F;1BFA5AYImBZC$*^4c?8`UNImxgO0C3q* z{~a>y?#?o-BUHiT^5XakYUA6~L78?{qKzGH_^Z}=(f%ltArXgKnGggoE}M(2m&J4( z6)#ataap41OKl=c38B|fcM6@srDc-&CjGJ`hSnjuOggf(OjfMR2{msn%(8LG9j9=2 zR%vTh1eIm!KYM8cd;+LpkxNzL+JT_|_A5^&|8_~HE6$=*{P)>N%)L<^I6L17XaAFg z3^xd&K|!4=3#wCzDMwT1qr5_hgeix4VMe)oP)yGPcLD!x2l6cH=tV;|WjLQl%@4Z+L3AH0 z>6cO-F8MraniJ&m2VXa#PU#GLM#iuygkjUDxQt=%V(_?BPvl8j@pLB)i<}~3l!MfD zgHcayn>U?wBUMn4F$(rKt_UN8e!mrd6hB&lhrB1X10o*-8Y!c@A6Ex0;`UCN7Rexy zL6HVftWiO+rF&3(?gEOIXZ{bMcy^(FAAth?)fGca^2fm28m?LYdr&L}7|#43K>;5v zVXX>^*JV)feVM(`a0M&c)hW&GbtgA@Au^}0$p9l&ib`V~Q(DFdve!lLp0|t!0vlJS z!TwZKT7un{U6BCuLnJ>~Nunu32cO+!z*9gwXW~+sk2;YJ&gY&%EaGEO7&HW?ir;h>m>3cC|C2i6d@Mn8F(!{F(wtiP6kETJsAnYqNgLv9b+U z@TS+^ncWi==PX!Y$tx}`rwuy!l{O1bi^Gh@#+wq5yK+mejWVbJG&6-ZP7Jx1%LAMu zBR(2zJQODqPTz~L{&vsip&`8-hd_n_z1%(c(FoWaV8!_*bYg#$MF6ZJSi(i16O zKKM^(pb3Lxd~Y^kvH|CUJG#I+43Xp%P{MsrUIgMeMX+~TdLW(w4+f#e1@2s*%0f|v zHcVA9hI5P;UIp9{B#?{8=CHV6qoI=>h@iqn+|k9(w17^=M4y}~0l?(uaad1K+N|X$ zrV4%?f+^a;e|6~QuU)$u07G;KFCdMfYZu|5A=D~e%*dvjWx|XM#{vvNfiTmmtgIq_ z?LV}B$Co0>ITkmMUkiIbHiBZg(swVp@$ZRY?%1m8u356X+QTzp$d${27eA&?kK;Z! z{V_y(=?_a={@U`|RUiHCR~B5p@_NhVt3F$D4z1Gg!-;j*$;o?{QM>4(AUr=Bl_RdT zr|&M)B}t(8+5CK6zXNi*myyTYZqo&Zwx8eRp=)dKtH#p*HoLbVrJvIU>&|VX2;AoI zOK)7RFBZZ=q=D_)aJj)gkrWxXJqn~(MkSUY643#wa-{VLNb%mdG(ltsu~@wnG2jd} zK^xI~08a6>S62evk#dDBulTVCwd_Gp_PHnX#%%IpJG|KiZ`Q|$^@(B^qF9e;mKn`H zh-MRGSic@@dJk6BgVpz7`+KmnJ(zbKONwL9#Ibkdn5UHuwX!NJJ8EU&@oZ{5tBq&x z#lw&7!`+yV;efagb_ zS!5!`qo5r1;JpuTFIqt+p1I{oxjE&w{(W&z=|Ad5eM9Md8y3Bcdv}X?g!XDb&}*7| z2%gn8CY95+tTJ0M4yweKz{&+w2fnr89~;KIQ607l{+wK%v7OpdSW&TnB>Jx}+_{vj zf_a5S#rd<9%~13%`n+N6XIa!|+ag;@1+^7vO;diDLa@oL!YUGWD^MMKe5Za)v(wQQ z+3r-v4v8J-s?$qUh}DK?XTUDO>cg|Okf>RaQGT`=<>wu4Ok^yFv3kaKGnV8ID{#1P zHZ???safWm>%n;&X@r7s*XZnf07^}fVU&DhGmOlBfKuigG(&x|5%vHMm6|Buoa2## za30qRL6alR5V51cc^0 z40A{sP!50NfE{x3U%Xuae+88ppDkuko8_?C=;vysBP11xh13xZUl{stJF?0m2*c7) z;SvpRF=3q2srBn}b$n^Dn@+Jz4_=(w#Ky??G$w4Kd{2ZA)arguUW(*UZm;7{G;aKm zH2&m|Cz!JMJrO__uI2ZnGqQ>DJ@GKMM7}3!gka9zb!U|x4DtDTXqZjI9@MaBH0%Qn z`&`3Xk*H)yIzZH`7Asja$lZZxpxk)^(YhJz zD=l;PWFKfXQzaI{XiWe4?5;a0v%CsRrSs9lD4zUk)$v}04l%=q^&6IXi@DYrh_O6b# z>)0$kTcT%=>)8i-_PL(@qG!vzSdJHoSp7WsO4F*toW2YjGin?*K98L}G;7T4J2I#9 z*sD1Gipo57;>$hMz7d(3S(7s}?@-Io0^d_{RY!=;Odd0K%7U2_lVCm$tJ?G75dwxXl4rac z`QhC>BWE$sV6OyF`LBh={M6M+Hh!8gi21Oqoa3bDyt13P;c$`#@mEY~aBd*=i#HcY8nV6)`lBxL^8C4owtFqZoj-$WQjI8AMKbG|fA)&l;*|}CU7w>;P{gxN#{ox?e z6*ia8^8863eYcOAU2e-M%PY)s>J;9GTfIlynLR$@n2rPt(Mi zQ##pJLEG@l@h*nSXZ5NG8qQIr5c%5OkV7Yzw2Jce^+4AP{90l~^WoY_sLF zcR7Nd+Z+=_1Hpk~bL8?bM9h8nJL;i~fkVk6!_zl>^tT%NvRsvgXVAbEY^Z#bMgc9( z1FrJHuank>Q`&H+w5P7W(n9mOyeQw6n}fO3X#{ET&h1|H5|_@Lu@CQ1o;hyTJCx^) z`slpCs`^kfUffjoDdCTbTf~ukHa@(b3xBra1@zpOy7p^&23c4D&tx20iLLs?lnBWP zX==rkbuNU6qZM1-hZ-<`_+W_|pv-s~c6b&Muwv&=^ZF&>{x_kOQUNfE1GXt4)Q2cS zZcasBp;9Zw?mGPEU!Ls$o9c8zDW&dm1zpYMD>JF?ywU|#lPmM`K&%9!7Q;>kzr(3T z>4G9Ur&Z1>s4On7;%#bPewzp34sX*?j*{hK@H$rl44AYf{PAG|o;+(*EW@B|=|dcigc<8G;wxtw|A^+)N;3MmrA ztaZ5-JQJolXcpVwhpK&X)Pr2NB87-r@y7ENVbsE5Ipsxp78R)h*nybaoyGxxZr*pq zP5|Wul7A1Kr*S?#WI2s9C#l2~3X0}b%7lXhC*uB~cB0%Ba%elmOr0H%pfO=M9) z)S797b|0tKOrc^OeoLh5$n!{a(EM3CG@|k}6AlpOgiQ69jox3z-cA`#-}wGM`Le@qJQ}D<6%G zFa3B+uQck)_|h_DBeyTT3%i1asz!kihwolQ1;*L(iz?@%K!2q`EEPcY%U3;HN7av| zkcY@0K-I@9Z!h}!1z&obXO>ywQ+d-+eF_x-I(+-WFTaowk+~RK04+)#sd6J|u6#Q7 zSONhq*H)ObsHn6IONcI0p7mj*jK5k`vY!B>%7Mf;QDK$k-a;E#7dP-}F2LE#_3d@^ z390A@Y`LK}mdvRnaSff?eB-J0G%(OR7lAvFuO`^T8W#r7hH_;1=c}{asf#0Q1v!<) z6&8-zcxJ8KiEKzf*b$m?5NR!U*g(sOD4aYojF?f*f}*Kq#i$u+CeP1)R?AJDG*;-m zdxd585f`t??Tgn)lIKYD6=PHTuv1(GD%0o}bE1Msl9pjr0=vLSi28BE7tfF-Y%(S1 z0#!*EBX&Neh$2U#m5dL=^6RNIE7fB1B9L5&{lane$K3D!$k9ltiMBs7cXy5>%isN+ zK%llBCIC)_cjm4fBc(ON z`LYB_5zd*^4=HQS8$_-vOFF+Pqucj~XO$4TO{IL7x8?kXlq<6-AM63$bFN*^FIXJ4 zmf!>zLgc$vK<&O~UD14MH@J_>%ha~Jw`X3bcV&j^`YsFSfQ;Bx_YIBgFk-xx2@Z*h zM1y#uAv6H0fJu1zZR}Uy)5IrkI!>1R`J_x9JmYyPI~?g2C4<@*{ly4AA7$>SzJb0W zucU5q8j)0`eU41ZOLb37rxBJ#Fh>@DZ}Ssh6wrH$80N?n#;5Vy-zPLEFD$)Rb%CYy+!rT!QTHYzT@}qKNEC?tub&_Lg{W!f;-U(1 zbiIqvMDwXRVNWT2X>MhC#rV>D`5c=zyze=h>9->dCzaf*xuD|%bIQ&K^$&Tal_eER zEc1&>Dl2RdeCL!3ns8p^jkxcgCcILM6qZ(&$pxU0yL%Ccnywy8rLj|sE+6$E`G~qr}WY7C1yYok?z(5=y!BpxZ91!qp2;~Cm1R^< zCk0%Vje$~G4Y?MSqv4Kk8#_>14{xY|W z`n%>a+r*XgOA+dq{-JF!A_UatC2wmnQRt{7k>c8~iKrV}gPWtg4dW#j|OLewMKdpY2z7&=py@Zk-Us?#+vo5#b;Hp#9 z;U8xIeKOak>hh$+C8KC?KD3woaov62O=ipRg}S`C^L}EK{L}o6ljzf25${YXWmO!d zb6@--g-Vw!-ek8-%5NYQJhpDmw?8D;5}7GkykW+&hbiOp#}da=yMm@f-av2a`@qFr zl;#!WEgeKMS4Tn5nxye77O6$^=k3p*Il+7UrOBJ}g?cH4J=v$rctI5oSd;gVU)Cw! zl0Pn5pONcXLuxRR6Nh|uJ0(*ac=9WN$C}((M=!2})3zWqOXdtf_I4vo&e=Yji=Q%MefsHTRsxv#E&b_*pDFxerVRG=|@PD#o4Y?;J0(K;8B zN^ni7bHO}iwxzLe`oNf9@aZ=9zY;M}VSCD+Gl!6f1Mn;_viysGHV~Oti^#x}3$ZH- z#=N&D@(}`o_w@dg47pEFV(q{(8c|B57SfDnVycM9eJ?K5|CLaWz(ti+tw)wS&uTpR z*O!Q%glD@gO&Txb7xPXP!|H7jyZQtY^2H0yvu0-H&Wo%xRX1m6wcmb6m4sC;+&mtS95|1vb>O79uns3-xT>j}g% zK*Rg8e)wQRUdi%=x%9yw@61@t2Z|J}T30X(KAvL^rAs|^P`vQqBM zp!q!}wVM;dpqd5L-hESdAp~gku`ZwB_Hig$yer=EvGEfz>vIoO;!&| z&Qz13ees4y4EW?n%J|pn#~OaR|6dP~?gb*0c<$+Vwe~00mn~;SskD?tgpQIqazMF4 z$?;8}PN$?hXH1@8o$fo6Ri<(l+Es(H)E4aA{AfL4?=sM73oxUPkRmJ)77K(IvL*$J z`u2xS_t1ybBB@FdS(`#F%kKyqM78A28*qP&}XdEX-$nD*zvijCNOJ)R-7{#Pr<`X;jgqiME4O5s@`s5wrs& zFaKiJ-zd2%_dqT2nQpZL=7%b1MD|QM!=)LbqeNm^2r?S-zP;=AMzwC99L1B{CcStc zB@+v$WcU-f(vKuwEFxiuiD;uNNua@fc=D@n6{XYLLkiNXICCZ$y&<=$BKZcvNT~U7 zqBwf3D?=HYMn%e^QoX2%d6Ib&7sne44%gcT5tsObkSCN1l|qS7AuJI*COy4&+$1iJ zqjv>O>t5c_wUF=3HX8~-Ah)b5nET_{<%G(v^(0ImHwmarLixIax-X-KbgdAyzFJCR0g3mIuK0n%CkaL6WEljVpN1yGSE1D>n~-7{3uH>tx@=OWhpnB#p7&w< zeOQ$*tM_GZ`LZQGY?e<~PR?tO9W{{VcR?8}1^7FHl*DK68+U>9=!&9>V%xBsvdI-y z#gyr8`F5g2I$-L8fv=E+2_2-MpeT>qC9~kaMm8JKQsY8XHSU`10odw|fI|E)d)|M9 zBI;)4eOS7a2izKuCDps`qA`b6%R2N;!eugyDt1te4z=sYANDLKyG3qJ-n=|*cD$HgE*7> z9WETx9u$Q4p6|dQ8gFIY-cM}E4OD_n(xkb|*)fH^Zg8NG>d!%}n)@{I96}WKt{=ZA zJZGQC_cWW>1%IASb^vxrz9%TKBDoA-y8l4F=S%mW8+m!|P1-8ola-UbB9|eLefGM1 zZ^6C0<&74RzY)!!u(r*gVyRq$TrQ68FUY+hc|vt3NTl>xfxLV#x?dvSCsP^n9jP*~ z#|`XV1FQ05^?t0?k5&1zR)3ahWK~A?yperkWbOfMRsgFCV2=l|9RciY0P_rF34ts# zkl6xRyDvNH%bp38hmbn{dx(Ir8QZsCqlU^vz-b5z*TA!faI!lh@QvIrn)vLLd@qk+ zbFu7v!bfnhi9>>qpnEXC=OfrdzNZm{lhXb`){3r~*pnvqu8AEru?s=WJD6<=WDf?i zF~M?A>BZmE(?;~N=Dluv3Ucl4?8D@Ot4i;9PoI(7-3_kh$aqLS1%0!Hu*zWeIyPH{ zuxCQp=OJt&LWC$CA>8>roR7X4;9mTt9xmM0dmF>?~Cq$_IcN&)$;nX|$Qr zbQ&zIsu$i~QT(2#M93NOc}-EK3#=hN=2e9xzyr~C$=b_w!5pLP$+_k7xo zlgnGE9ZSM^J$%vyf2ufe(WK)OC(6ubnOS8hdpwkV5X#y^S-&v0I*h#@#*Tubg)#4N zmK4s$g|qqL?3r+u8NpgZ*un_5Izq-D>i6#f`_5|sIm{Ref$LTOZyd0GiQ!!#oLMu2 zc!tj>4?Ru*&LkJa%=twX2vNbpd$4a}h>j2W+#`ximU7&%ayj_?+;YA=s)2J+QIgyR z0{-ur0*+8DT{-^DHSkBI_I8IwhmsuKVcyC7o@34qIh{}SOgWuT_1$th;SHwu>Lm7h z68k)fwI;E^WERz%P4CSzli0!}b|{HW3?cYD6dKMICR%8kWXs7X4@HfqfrX?f!3}O- zJ>-1?k?eDxgf+M%#o-*7(+A3KR^H|hmyM!9CQpk+@ReZ`Q~ajtPrx}+4u_2REW-4# z;|NdYrqfs@gH>m+>J3(JgVo1i^)*-x2CJXJ>Tj?b4b}jIHPB!+*|BG$(O?ZSSc45# zv%wl=utpoKF$Qa_9RYHzJq*@3yPG6gt#;-nS>x^QOtL1}J=`U0qQTnJVC`kFCK;^B zc8!N*?QO88*tHtT+Q(o`wR>tMYns8@*I?~uu=Y1tZ!uU07_0*g)^vmQR)ckr-OE$5 z4z}yOB`@lUdZ)p9mpwX4vQD?hL`&AYV`2sK zv9x17Br8*HaUhe5*^|bejk*r?!q*w`?GR?<~ z$>yec$C@xk{HZs8^0(^wQwo0yIK&_N@CTFCn?I%Ur(mlOe@f#|W~(oM>dT+PtOown zk3U6N{rFRV{$#QG^QT+*Q}jXpFn~YAT8;c^Ab*Op2JokJ{uFNwnnHYV*V>d~4GQt)cd6E3`;Z=z`B+HEb8ahnvA))j>f8D!Sk3l~ILZiB zGj6el+J{;xDNIcoXbrOui>IV;HR)DsxP5p6B}J%7gRK$v5s8!(sV3cKjkIU>q$G=) zG}LM_n2*>;^+Kv~q`4{d*wKg-G%Cp_I9D%G_e4_)|iOoG@%AhviUL9A*NLF~TK1hGGi=?t;^ zZzqT?wlBFuhS+@sv1UN*eh#q*ti56$SZc-`{o``_f9Z;tWe=H~%9`2*AZuFF@<({F zRgTn0d1?($t#f(xPiq~88hD}gywCJvQmDW1B~<<%9>Itp##g*Nj-FE~U%u(efvXWC2NwsdYoiUu3q_J!k+4fKho~5e&pkX z&#LX8Xm?ew`c%8KdiA!19o3I+*KV(VY)8Vj>YANE6@(@~R=W!^8jscO)?G?Jwq~#H zV*0VQ`*j!6kJTU0olig3a7gz<`muHY(49*^w*F}7v9r~WA46=$>J7)W=F`=G{yf3_ zef1On)S6FKKlz2$e6sqfFB8n)RX_ce*8FYt#;+61-&8;IFZ$3KeCUMkG(PmL?t6Ub zr0x_x^u6vRK6FO+9X@nU_bonjUiS??bTRbUiRx!B(~D=*k3DxqcOv~*;Z-14ijOsQ z)g}jiZ5~hf^*r$FFB1sAUdST++A@*w>%~dHFJ+!-_&j@QGGR@t{pBe#)?6d3`4_OJ zox|}b^Vij{{GtWIeEsULLi({Ae}&jW_nSP7uQf2^>y-9}Ac;aXqCbiW<|ZNLO~GB< zbZdy8`G}BqMXj5(qxN zi$L(nbedwH-c3_%+YFjw+h@`g+cAr#*v@-sitU68cKd#tWZq_PERf8f+7HZ;%%9i~7E0!i?T3ma^GEi>b0zbK3ZR2Ip#L#X z5(YNim>~p;_9Mjrwoq{7M$?TTkpTXckk)i#mJlMEj|m0;UtL%F(?k%(=K&?qVnm7+ zFh&f~)fgk#AR-!T(5Mj;{h-kp6FwLCOXX1UzQq&7 ztNwm7y9G4<9+2T;mAF7G zu_n|!@_;o^K_P7uyKr<#qsZl&5LsuXZ@fM~VGA_hR^DQi zkMWd`75t3CK=yVQzvNlHS?W&0t-}r^^{68|)dZJdig6Va4;@y@(9-OP(&4qLI>VSq^Jdzmc{ZiG zc_vLcE`uVt=aS;pVe0BF&D<1SjvTJa+Oq^{@|pX$=ANYthoz|3eN)2Z7|p%w=K8Py ztFvdmX$AAinp~7FZIGD!o~STyMcA { 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 @@ +