diff --git a/.gitignore b/.gitignore index ff4bfe9..fc2a935 100644 --- a/.gitignore +++ b/.gitignore @@ -5,10 +5,17 @@ frontend/node_modules/ # Frontend build output frontend/dist/ +# Packaging output (绿色免安装版) +dist/ + # Wails / build output build/bin/ build/tmp/ build/windows/installer/runtime/ +# Wails 打包时自动生成/下载的中间产物,每次构建都会变 +build/windows/installer/tmp/ +build/windows/installer/wails_tools.nsh +build/windows/installer/MicrosoftEdgeWebview2Setup.exe *.syso # Go cache and test output diff --git a/build/windows/installer/wails_tools.nsh b/build/windows/installer/wails_tools.nsh deleted file mode 100644 index cfc19b4..0000000 --- a/build/windows/installer/wails_tools.nsh +++ /dev/null @@ -1,236 +0,0 @@ -# DO NOT EDIT - Generated automatically by `wails build` - -!include "x64.nsh" -!include "WinVer.nsh" -!include "FileFunc.nsh" - -!ifndef INFO_PROJECTNAME - !define INFO_PROJECTNAME "qiweimanager" -!endif -!ifndef INFO_COMPANYNAME - !define INFO_COMPANYNAME "灵泽万川" -!endif -!ifndef INFO_PRODUCTNAME - !define INFO_PRODUCTNAME "灵泽万川企微售后客服" -!endif -!ifndef INFO_PRODUCTVERSION - !define INFO_PRODUCTVERSION "2.1.3" -!endif -!ifndef INFO_COPYRIGHT - !define INFO_COPYRIGHT "Copyright © 灵泽万川" -!endif -!ifndef PRODUCT_EXECUTABLE - !define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe" -!endif -!ifndef UNINST_KEY_NAME - !define UNINST_KEY_NAME "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}" -!endif -!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINST_KEY_NAME}" - -!ifndef REQUEST_EXECUTION_LEVEL - !define REQUEST_EXECUTION_LEVEL "admin" -!endif - -RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}" - -!ifdef ARG_WAILS_AMD64_BINARY - !define SUPPORTS_AMD64 -!endif - -!ifdef ARG_WAILS_ARM64_BINARY - !define SUPPORTS_ARM64 -!endif - -!ifdef SUPPORTS_AMD64 - !ifdef SUPPORTS_ARM64 - !define ARCH "amd64_arm64" - !else - !define ARCH "amd64" - !endif -!else - !ifdef SUPPORTS_ARM64 - !define ARCH "arm64" - !else - !error "Wails: Undefined ARCH, please provide at least one of ARG_WAILS_AMD64_BINARY or ARG_WAILS_ARM64_BINARY" - !endif -!endif - -!macro wails.checkArchitecture - !ifndef WAILS_WIN10_REQUIRED - !define WAILS_WIN10_REQUIRED "This product is only supported on Windows 10 (Server 2016) and later." - !endif - - !ifndef WAILS_ARCHITECTURE_NOT_SUPPORTED - !define WAILS_ARCHITECTURE_NOT_SUPPORTED "This product can't be installed on the current Windows architecture. Supports: ${ARCH}" - !endif - - ${If} ${AtLeastWin10} - !ifdef SUPPORTS_AMD64 - ${if} ${IsNativeAMD64} - Goto ok - ${EndIf} - !endif - - !ifdef SUPPORTS_ARM64 - ${if} ${IsNativeARM64} - Goto ok - ${EndIf} - !endif - - IfSilent silentArch notSilentArch - silentArch: - SetErrorLevel 65 - Abort - notSilentArch: - MessageBox MB_OK "${WAILS_ARCHITECTURE_NOT_SUPPORTED}" - Quit - ${else} - IfSilent silentWin notSilentWin - silentWin: - SetErrorLevel 64 - Abort - notSilentWin: - MessageBox MB_OK "${WAILS_WIN10_REQUIRED}" - Quit - ${EndIf} - - ok: -!macroend - -!macro wails.files - !ifdef SUPPORTS_AMD64 - ${if} ${IsNativeAMD64} - File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_AMD64_BINARY}" - ${EndIf} - !endif - - !ifdef SUPPORTS_ARM64 - ${if} ${IsNativeARM64} - File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_ARM64_BINARY}" - ${EndIf} - !endif -!macroend - -!macro wails.writeUninstaller - WriteUninstaller "$INSTDIR\uninstall.exe" - - SetRegView 64 - WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}" - WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}" - WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}" - WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}" - WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\"" - WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S" - - ${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2 - IntFmt $0 "0x%08X" $0 - WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0" -!macroend - -!macro wails.deleteUninstaller - Delete "$INSTDIR\uninstall.exe" - - SetRegView 64 - DeleteRegKey HKLM "${UNINST_KEY}" -!macroend - -!macro wails.setShellContext - ${If} ${REQUEST_EXECUTION_LEVEL} == "admin" - SetShellVarContext all - ${else} - SetShellVarContext current - ${EndIf} -!macroend - -# Install webview2 by launching the bootstrapper -# See https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#online-only-deployment -!macro wails.webview2runtime - !ifndef WAILS_INSTALL_WEBVIEW_DETAILPRINT - !define WAILS_INSTALL_WEBVIEW_DETAILPRINT "Installing: WebView2 Runtime" - !endif - - SetRegView 64 - # If the admin key exists and is not empty then webview2 is already installed - ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv" - ${If} $0 != "" - Goto ok - ${EndIf} - - ${If} ${REQUEST_EXECUTION_LEVEL} == "user" - # If the installer is run in user level, check the user specific key exists and is not empty then webview2 is already installed - ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv" - ${If} $0 != "" - Goto ok - ${EndIf} - ${EndIf} - - SetDetailsPrint both - DetailPrint "${WAILS_INSTALL_WEBVIEW_DETAILPRINT}" - SetDetailsPrint listonly - - InitPluginsDir - CreateDirectory "$pluginsdir\webview2bootstrapper" - SetOutPath "$pluginsdir\webview2bootstrapper" - File "tmp\MicrosoftEdgeWebview2Setup.exe" - ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install' - - SetDetailsPrint both - ok: -!macroend - -# Copy of APP_ASSOCIATE and APP_UNASSOCIATE macros from here https://gist.github.com/nikku/281d0ef126dbc215dd58bfd5b3a5cd5b -!macro APP_ASSOCIATE EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND - ; Backup the previously associated file class - ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" "" - WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "${FILECLASS}_backup" "$R0" - - WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "${FILECLASS}" - - WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}" "" `${DESCRIPTION}` - WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\DefaultIcon" "" `${ICON}` - WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell" "" "open" - WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open" "" `${COMMANDTEXT}` - WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open\command" "" `${COMMAND}` -!macroend - -!macro APP_UNASSOCIATE EXT FILECLASS - ; Backup the previously associated file class - ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" `${FILECLASS}_backup` - WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "$R0" - - DeleteRegKey SHELL_CONTEXT `Software\Classes\${FILECLASS}` -!macroend - -!macro wails.associateFiles - ; Create file associations - -!macroend - -!macro wails.unassociateFiles - ; Delete app associations - -!macroend - -!macro CUSTOM_PROTOCOL_ASSOCIATE PROTOCOL DESCRIPTION ICON COMMAND - DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}" - WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "" "${DESCRIPTION}" - WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "URL Protocol" "" - WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\DefaultIcon" "" "${ICON}" - WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell" "" "" - WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open" "" "" - WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open\command" "" "${COMMAND}" -!macroend - -!macro CUSTOM_PROTOCOL_UNASSOCIATE PROTOCOL - DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}" -!macroend - -!macro wails.associateCustomProtocols - ; Create custom protocols associations - -!macroend - -!macro wails.unassociateCustomProtocols - ; Delete app custom protocol associations - -!macroend diff --git a/config/docs/万川平台-登录到获取模型信息-流程说明.md b/config/docs/万川平台-登录到获取模型信息-流程说明.md new file mode 100644 index 0000000..e446bd4 --- /dev/null +++ b/config/docs/万川平台-登录到获取模型信息-流程说明.md @@ -0,0 +1,272 @@ +# 万川 AI 平台:从账号密码登录到获取模型信息 + +> 本文基于当前未提交的代码梳理,描述用户在「设置」页填入平台地址 + 账号密码后, +> 系统如何登录、拉取模型配置并写入后端 AI 设置的完整链路。 + +## 一、整体链路总览 + +``` +用户填写平台地址 / 账号 / 密码 + │ + ▼ +[1] POST /api/login ───────────────► 返回 token(缓存到 localStorage) + │ + ▼ +[2] 登录成功后并行触发: + ├─ GET /api/system/kb/relation/query 拉取岗位知识库列表 + └─ syncModel() 同步模型配置 + │ + ▼ +[3] 按模型编码 code 分别拉取(携带 token): + ├─ GET /api/system/model/getByCode/generic 通用/聊天模型(必拉) + ├─ GET /api/system/model/getByCode/vision 视觉模型(可选) + └─ GET /api/system/model/getByCode/voice 语音模型(可选) + │ + ▼ +[4] 解析 providerModels.encryptedConfig(JSON 字符串) + → { modelName, apiKey, endpointUrl } + │ + ▼ +[5] PUT /api/settings 写入后端 SQLite(app_settings 表) + │ + ▼ +[6] 后端聊天/视觉/语音调用时按用途读取这些配置(带回退) +``` + +前端实现:[chatlab-web/frontend/src/api/wanchuan.js](chatlab-web/frontend/src/api/wanchuan.js) +触发位置:[chatlab-web/frontend/src/pages/SettingsPage.jsx](chatlab-web/frontend/src/pages/SettingsPage.jsx) +后端设置:[chatlog_fastAPI/routers/settings.py](chatlog_fastAPI/routers/settings.py) + +--- + +## 二、第 1 步:登录获取 token + +### 接口 + +``` +POST {platformUrl}/api/login +Content-Type: application/json;charset=UTF-8 +``` + +### 请求体 + +```json +{ + "username": "用户名", + "password": "密码", + "loginType": "user" +} +``` + +### 响应(兼容多种结构) + +代码用以下顺序提取 token,任一命中即可: + +``` +result.token +result.data.token +result.data.access_token +result.access_token +``` + +典型返回结构: + +```json +{ + "code": 200, + "msg": "success", + "token": "eyJhbGci...", + "data": { + "token": "eyJhbGci...", + "modules": [ /* 平台模块列表,用于前端展示 */ ] + } +} +``` + +### token 的存储与使用 + +- 登录成功后 token 写入 `localStorage`,key 为 `chatlab_wanchuan_token`。 +- 后续所有平台接口都带两个头(平台同时认这两种): + ``` + Authorization: Bearer {token} + token: Bearer {token} + ``` + +对应函数:`wanchuanLogin(baseUrl, username, password)` +([wanchuan.js:39](chatlab-web/frontend/src/api/wanchuan.js#L39)) + +--- + +## 三、第 2 步:登录的触发与自动恢复 + +登录在 [SettingsPage.jsx](chatlab-web/frontend/src/pages/SettingsPage.jsx) 的 `WanchuanPlatformForm` 组件里触发,分两种场景: + +### 场景 A:用户点「测试连接」(`handleTestConnection`) + +1. 调用 `wanchuanLogin()` 登录。 +2. 成功后并行执行: + - `fetchKnowledgeBases()` 拉知识库列表 + - `syncModel()` 同步平台模型配置 → 写入后端 AI 设置 + +### 场景 B:打开设置页自动恢复(`autoLogin`) + +- 配置(平台地址/账号/密码/已选知识库)以**后端 SQLite 为唯一数据源**, + 通过 `GET /api/settings/wanchuan` 读取,不依赖前端 origin(exe 端口变化也能恢复)。 +- 挂载时若已有保存的账号密码,自动登录并拉知识库。 +- **注意**:自动恢复时**不**自动 `syncModel`,避免覆盖用户在「AI 模型配置」里手改并保存过的值。模型同步只在用户显式操作(点「测试连接」或「从平台获取」)时进行。 + +--- + +## 四、第 3-4 步:按模型编码获取模型信息 + +这是「获取模型信息」的核心。平台按**用途**用不同的模型编码(code)区分: + +| code | 用途 | 同步到的后端字段 | +|-----------|------------|-----------------------------------------------| +| `generic` | 通用/聊天 | `ai_*`(话题分析)+ `summary_*`(报告生成) | +| `vision` | 视觉 | `vision_*`(图片/视频描述) | +| `voice` | 语音 | `voice_*`(语音转文字) | + +### 接口 + +``` +GET {platformUrl}/api/system/model/getByCode/{code} +Authorization: Bearer {token} +token: Bearer {token} +``` + +### 响应结构 + +```json +{ + "code": 200, + "msg": "success", + "data": { + "providerModels": { + "modelName": "qwen-plus", + "encryptedConfig": "{\"apiKey\":\"sk-xxx\",\"endpointUrl\":\"https://...../v1\"}" + } + } +} +``` + +关键点:`data.providerModels.encryptedConfig` 是一个 **JSON 字符串**,需二次 `JSON.parse` 才能拿到 `apiKey` 和 `endpointUrl`。 + +### 解析后得到 + +```js +{ + modelName: pm.modelName, // 模型名 + apiKey: cfg.apiKey, // 来自 encryptedConfig + endpointUrl: cfg.endpointUrl, // 来自 encryptedConfig +} +``` + +对应函数:`getWanchuanModelConfig(baseUrl, code)` +([wanchuan.js:110](chatlab-web/frontend/src/api/wanchuan.js#L110)) + +错误处理: +- HTTP 非 2xx → 抛 `获取模型[{code}]失败 HTTP {status}`(便于排查 404 路径错 / 401 token 失效)。 +- 无 `providerModels` → 抛 `平台未返回模型[{code}]配置`。 + +--- + +## 五、第 5 步:写入后端 AI 设置 + +`syncWanchuanModelToSettings()` 负责编排三次拉取并合并写入: + +1. **generic 必拉**:拿到 `endpointUrl / apiKey / modelName` 后,同时写入两组字段: + - 话题分析:`ai_base_url` / `ai_api_key` / `ai_model` + - 报告生成:`summary_base_url` / `summary_api_key` / `summary_model` + (两者同网关,但分开存便于各组独立回显与覆盖。) + +2. **vision / voice 可选**:用 `Promise.all` 并行拉取,**失败则跳过对应字段**(`.catch(() => {})`),不打断聊天模型同步: + - `vision_base_url` / `vision_api_key` / `vision_model` + - `voice_base_url` / `voice_api_key` / `voice_model` + +3. 仅写入**非空字段**;若最终 payload 为空则抛 `平台模型配置为空`。 + +4. 最后 `PUT /api/settings` 保存到后端: + +``` +PUT /api/settings +Content-Type: application/json +``` +```json +{ + "ai_base_url": "...", "ai_api_key": "...", "ai_model": "...", + "summary_base_url": "...", "summary_api_key": "...", "summary_model": "...", + "vision_base_url": "...", "vision_api_key": "...", "vision_model": "...", + "voice_base_url": "...", "voice_api_key": "...", "voice_model": "..." +} +``` + +对应函数:`syncWanchuanModelToSettings(baseUrl, codes)` +([wanchuan.js:151](chatlab-web/frontend/src/api/wanchuan.js#L151)) + +--- + +## 六、后端 /api/settings 的处理 + +实现在 [chatlog_fastAPI/routers/settings.py](chatlog_fastAPI/routers/settings.py)。 + +### 可编辑字段(EDITABLE_KEYS) + +``` +ai_base_url, ai_api_key, ai_model, summary_model, +vision_model, voice_model, topic_analysis_prompt, +voice_base_url, voice_api_key, vision_base_url, vision_api_key, +summary_base_url, summary_api_key +``` + +### 密钥脱敏(SECRET_KEYS) + +`ai_api_key / voice_api_key / vision_api_key / summary_api_key` 四个字段: +- **GET** 返回时打码(`_mask_key`,只保留首尾、中间打 `*`)。 +- **PUT** 时若值里含 `*`,说明是 GET 返回的打码值(用户没真改),**跳过不写**,避免把真实密钥覆盖成打码串。 + +### 存储 + +所有字段以 key-value 形式存进 SQLite 的 `app_settings` 表(`INSERT ... ON CONFLICT DO UPDATE`)。 + +--- + +## 七、第 6 步:后端运行时如何使用这些配置 + +实现在 [chatlog_fastAPI/services/ai_client.py](chatlog_fastAPI/services/ai_client.py) 与 +[chatlog_fastAPI/services/runtime_settings.py](chatlog_fastAPI/services/runtime_settings.py)。 + +- `get_ai_settings()`:从 SQLite 读出全部 AI 字段(带默认值),并缓存。 +- `get_openai_client()`:聊天类调用(话题分析 / 报告 / 总结 / 对话)用全局 + `ai_base_url` + `ai_api_key`。 +- `get_client_for(purpose)`:`purpose` 为 `voice` / `vision`, + 优先用 `{purpose}_base_url` / `{purpose}_api_key`,**为空则回退**到 + `ai_base_url` / `ai_api_key`(单网关场景无需重复配。 +- 客户端按 `(base_url, api_key)` 缓存,聊天/视觉/语音可指向不同网关与密钥, + 最多累积 3 个,配置变更后自然生成新客户端。 + +--- + +## 八、相关接口速查表 + +| 步骤 | 方法 | 路径 | 用途 | +|------|------|------|------| +| 登录 | POST | `{platformUrl}/api/login` | 账号密码换 token | +| 知识库 | GET | `{platformUrl}/api/system/kb/relation/query` | 拉岗位知识库列表 | +| 模型信息 | GET | `{platformUrl}/api/system/model/getByCode/{code}` | 按 code 取模型配置 | +| 上传 | POST | `{platformUrl}/api/system/kb/file/upload/async/{datasetId}` | 上传文件到知识库 | +| 读平台配置 | GET | `/api/settings/wanchuan` | 后端存的平台地址/账号/密码/已选库 | +| 存平台配置 | PUT | `/api/settings/wanchuan` | 保存平台配置 | +| 读 AI 配置 | GET | `/api/settings` | 回显 AI 模型配置(密钥脱敏) | +| 写 AI 配置 | PUT | `/api/settings` | 保存/同步 AI 模型配置 | + +--- + +## 九、关键数据结构小结 + +**登录请求**:`{ username, password, loginType: "user" }` +**登录响应取 token**:`result.token` 等多路径兜底 +**模型接口响应**:`data.providerModels.{ modelName, encryptedConfig }` +**encryptedConfig**(JSON 字符串):`{ apiKey, endpointUrl, ... }` +**最终归一化模型对象**:`{ modelName, apiKey, endpointUrl }` +**写入后端的 12 个字段**:`{ai,summary,vision,voice}_{base_url,api_key,model}` diff --git a/config/docs/万川平台对接-实施计划.md b/config/docs/万川平台对接-实施计划.md new file mode 100644 index 0000000..c6f573f --- /dev/null +++ b/config/docs/万川平台对接-实施计划.md @@ -0,0 +1,108 @@ +# 万川平台对接 — 实施计划 + +> 本文为对接万川 AI 平台(登录 → 拉取模型配置)的实施计划。 +> 接口契约见同目录 [万川平台-登录到获取模型信息-流程说明.md](万川平台-登录到获取模型信息-流程说明.md)。 + +## 范围 + +### 本期做 +登录平台 → 拉模型配置 → **log 打印**。平台凭证可编辑、可持久化、可重置。 + +### 本期不做(留第二期) +- 回填 AI 表单(`form.ai.*`) +- 调 `SaveAutoReplyConfig` 落盘 + 推 helper 重载 + +理由:先打印确认平台返回的数据结构与解析结果正确,再接回填与保存。 + +## 架构决策 + +- **Go 只做透传代理 + 凭证存取**,不碰业务解析。仅用于绕开 WebView 的 CORS 限制。 +- **业务逻辑全在前端**:取 token、按 code 拉取、解析 `encryptedConfig`、字段映射。 +- **持久化存 `config/config.json`**(exe 同级,重启自动恢复,与浏览器无关),新增 `platformConfig` 段。不使用 localStorage。 + - ⚠️ 密码以明文存在 config.json(本地桌面工具常见做法)。如需更稳可加简单混淆,但非真加密。 + +## 一、Go 端 + +新文件:`wanchuan_proxy.go`(与 [app.go](../../app.go) 同级,main 包)。 + +### 1.1 透传代理方法(绑定到 App) + +| 方法 | 行为 | +|------|------| +| `WanchuanLogin(baseUrl, username, password string) string` | POST `{baseUrl}/api/login`,body `{username, password, loginType:"user"}`;**原样返回**平台响应 JSON 字符串;日志打印(密码/token 打码) | +| `WanchuanGetModel(baseUrl, code, token string) string` | GET `{baseUrl}/api/system/model/getByCode/{code}`;带头 `Authorization: Bearer {token}` 和 `token: Bearer {token}`;**原样返回**响应 JSON;日志打印 | + +不解析 token、不合并字段、不存盘——平台返什么就返什么。 + +### 1.2 凭证存取方法(绑定到 App) + +| 方法 | 行为 | +|------|------| +| `GetPlatformConfig() interface{}` | 读 config.json 的 `platformConfig` 段回前端 | +| `SavePlatformConfig(jsonData string) (bool, string)` | 存 `platformConfig` 段(重置 = 存空值) | + +### 1.3 config 结构改动 + +[config/types.go](../types.go) 新增结构并挂到 `Config`: + +```go +type PlatformConfig struct { + BaseURL string `json:"baseUrl"` + Username string `json:"username"` + Password string `json:"password"` +} + +// Config 增加字段: +// PlatformConfig PlatformConfig `json:"platformConfig"` +``` + +[config/config_manager.go](../config_manager.go) 新增 `UpdatePlatformConfig`,复用现有 `SaveGlobalConfig`。 + +> 改了 App 方法后需跑 `wails dev` / `wails build` 重新生成 `frontend/wailsjs/go/main/App.js` 绑定。 + +## 二、前端 + +文件:[frontend/src/components/AutoReply.vue](../../frontend/src/components/AutoReply.vue),在 AI 配置区(`#auto-section-ai`,02)顶部新增「万川平台」卡片。 + +### 2.1 UI + +- 输入框:平台地址 / 账号 / 密码(绑 reactive,挂载时 `GetPlatformConfig` 回填,可编辑) +- 按钮「登录并获取模型」 +- 按钮「重置」:清空凭证(`SavePlatformConfig` 存空)后重新走一次流程 + +### 2.2 「登录并获取模型」逻辑 + +1. `SavePlatformConfig(当前输入)` 先存凭证 +2. `WanchuanLogin(url, user, pwd)` → `JSON.parse` → 多路径取 token: + `token` / `data.token` / `data.access_token` / `access_token` +3. 依次 `WanchuanGetModel(url, code, token)`: + - `generic`(必拉) + - `vision`、`voice`(可选,失败跳过) +4. 每个响应取 `data.providerModels`,对 `encryptedConfig` 做**二次 `JSON.parse`**, + 得 `{ modelName, apiKey, endpointUrl }` +5. **`console.log` + `LogFrontend` 打印**原始响应与解析结果 —— **到此为止。** + +### 2.3 字段映射(供第二期参考) + +| 万川 code | 解析字段 | AIConfig 字段 | +|-----------|---------|---------------| +| `generic` | endpointUrl / apiKey / modelName | `baseUrl` / `apiKey` / `model` | +| `vision` | endpointUrl / apiKey / modelName | `visionBaseUrl` / `visionApiKey` / `visionModel` | +| `voice` | endpointUrl / apiKey / modelName | `audioBaseUrl` / `audioApiKey` / `audioModel` | + +(`summary_*` 无对应业务,忽略;知识库列表不拉。) + +## 三、验证 + +- `go build` 通过;`wails dev` 起来后在 AI 配置区填真实平台地址/账号/密码,点「登录并获取模型」, + 看浏览器控制台和后端日志打印出 generic/vision/voice 的解析结果,确认 `endpointUrl/apiKey/modelName` 拿对。 +- 重启应用,确认账号密码自动回填(持久化生效)。 +- 点「重置」,确认凭证清空。 + +## 四、第二期(确认数据无误后) + +把 2.2 第 5 步从"打印"改为: +- 按 2.3 映射回填 `form.ai.*` +- 调现有 `SaveAutoReplyConfig(form)` 落盘 + 推 helper 重载 + +至此 AI 配置实现平台动态下发。 diff --git a/config/materials/产品图片/外观/产品全景图.jpg b/config/materials/产品图片/外观/产品全景图.jpg new file mode 100644 index 0000000..64d6166 Binary files /dev/null and b/config/materials/产品图片/外观/产品全景图.jpg differ diff --git a/config/materials/产品图片/外观/产品外观图.jpg b/config/materials/产品图片/外观/产品外观图.jpg new file mode 100644 index 0000000..fa9a20f Binary files /dev/null and b/config/materials/产品图片/外观/产品外观图.jpg differ diff --git a/config/materials/产品图片/细节/局部放大.png b/config/materials/产品图片/细节/局部放大.png new file mode 100644 index 0000000..10f17e8 Binary files /dev/null and b/config/materials/产品图片/细节/局部放大.png differ diff --git a/config/materials/产品图片/细节/细节特写.png b/config/materials/产品图片/细节/细节特写.png new file mode 100644 index 0000000..9817cfc Binary files /dev/null and b/config/materials/产品图片/细节/细节特写.png differ diff --git a/config/materials/公司简介图.jpg b/config/materials/公司简介图.jpg new file mode 100644 index 0000000..aeac8c5 Binary files /dev/null and b/config/materials/公司简介图.jpg differ diff --git a/config/materials/宣传海报/产品宣传图.jpg b/config/materials/宣传海报/产品宣传图.jpg new file mode 100644 index 0000000..7e4f823 Binary files /dev/null and b/config/materials/宣传海报/产品宣传图.jpg differ diff --git a/config/materials/宣传海报/小红书样式.webp b/config/materials/宣传海报/小红书样式.webp new file mode 100644 index 0000000..d3f33ad Binary files /dev/null and b/config/materials/宣传海报/小红书样式.webp differ diff --git a/config/materials/宣传海报/活动海报.png b/config/materials/宣传海报/活动海报.png new file mode 100644 index 0000000..65b31ec Binary files /dev/null and b/config/materials/宣传海报/活动海报.png differ diff --git a/config/materials/工作流图/标准作业流程.png b/config/materials/工作流图/标准作业流程.png new file mode 100644 index 0000000..50a2b6c Binary files /dev/null and b/config/materials/工作流图/标准作业流程.png differ diff --git a/config/materials/教程截图/安装步骤/第一步开箱.png b/config/materials/教程截图/安装步骤/第一步开箱.png new file mode 100644 index 0000000..931f308 Binary files /dev/null and b/config/materials/教程截图/安装步骤/第一步开箱.png differ diff --git a/config/materials/教程截图/安装步骤/第三步调试.png b/config/materials/教程截图/安装步骤/第三步调试.png new file mode 100644 index 0000000..419cfa8 Binary files /dev/null and b/config/materials/教程截图/安装步骤/第三步调试.png differ diff --git a/config/materials/教程截图/安装步骤/第二步组装.png b/config/materials/教程截图/安装步骤/第二步组装.png new file mode 100644 index 0000000..1de5784 Binary files /dev/null and b/config/materials/教程截图/安装步骤/第二步组装.png differ diff --git a/config/materials/案例展示/装修案例/卧室效果图.jpg b/config/materials/案例展示/装修案例/卧室效果图.jpg new file mode 100644 index 0000000..b015678 Binary files /dev/null and b/config/materials/案例展示/装修案例/卧室效果图.jpg differ diff --git a/config/materials/案例展示/装修案例/客厅效果图.jpg b/config/materials/案例展示/装修案例/客厅效果图.jpg new file mode 100644 index 0000000..1b21cb4 Binary files /dev/null and b/config/materials/案例展示/装修案例/客厅效果图.jpg differ diff --git a/config/materials/案例展示/门店实拍.jpg b/config/materials/案例展示/门店实拍.jpg new file mode 100644 index 0000000..5895b16 Binary files /dev/null and b/config/materials/案例展示/门店实拍.jpg differ diff --git a/config/materials/联系方式卡片.png b/config/materials/联系方式卡片.png new file mode 100644 index 0000000..33c436a Binary files /dev/null and b/config/materials/联系方式卡片.png differ diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 3f1b15e..7e7736e 100644 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -db031e671111b343255373ca05cff100 \ No newline at end of file +3ae7140e534eadc042a92a3db16fd10c \ No newline at end of file diff --git a/frontend/wailsjs/runtime/runtime.d.ts b/frontend/wailsjs/runtime/runtime.d.ts index 4445dac..3bbea84 100644 --- a/frontend/wailsjs/runtime/runtime.d.ts +++ b/frontend/wailsjs/runtime/runtime.d.ts @@ -246,4 +246,85 @@ export function OnFileDropOff() :void export function CanResolveFilePaths(): boolean; // Resolves file paths for an array of files -export function ResolveFilePaths(files: File[]): void \ No newline at end of file +export function ResolveFilePaths(files: File[]): void + +// Notification types +export interface NotificationOptions { + id: string; + title: string; + subtitle?: string; // macOS and Linux only + body?: string; + categoryId?: string; + data?: { [key: string]: any }; +} + +export interface NotificationAction { + id?: string; + title?: string; + destructive?: boolean; // macOS-specific +} + +export interface NotificationCategory { + id?: string; + actions?: NotificationAction[]; + hasReplyField?: boolean; + replyPlaceholder?: string; + replyButtonTitle?: string; +} + +// [InitializeNotifications](https://wails.io/docs/reference/runtime/notification#initializenotifications) +// Initializes the notification service for the application. +// This must be called before sending any notifications. +export function InitializeNotifications(): Promise; + +// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications) +// Cleans up notification resources and releases any held connections. +export function CleanupNotifications(): Promise; + +// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable) +// Checks if notifications are available on the current platform. +export function IsNotificationAvailable(): Promise; + +// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization) +// Requests notification authorization from the user (macOS only). +export function RequestNotificationAuthorization(): Promise; + +// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization) +// Checks the current notification authorization status (macOS only). +export function CheckNotificationAuthorization(): Promise; + +// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification) +// Sends a basic notification with the given options. +export function SendNotification(options: NotificationOptions): Promise; + +// [SendNotificationWithActions](https://wails.io/docs/reference/runtime/notification#sendnotificationwithactions) +// Sends a notification with action buttons. Requires a registered category. +export function SendNotificationWithActions(options: NotificationOptions): Promise; + +// [RegisterNotificationCategory](https://wails.io/docs/reference/runtime/notification#registernotificationcategory) +// Registers a notification category that can be used with SendNotificationWithActions. +export function RegisterNotificationCategory(category: NotificationCategory): Promise; + +// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory) +// Removes a previously registered notification category. +export function RemoveNotificationCategory(categoryId: string): Promise; + +// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications) +// Removes all pending notifications from the notification center. +export function RemoveAllPendingNotifications(): Promise; + +// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification) +// Removes a specific pending notification by its identifier. +export function RemovePendingNotification(identifier: string): Promise; + +// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications) +// Removes all delivered notifications from the notification center. +export function RemoveAllDeliveredNotifications(): Promise; + +// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification) +// Removes a specific delivered notification by its identifier. +export function RemoveDeliveredNotification(identifier: string): Promise; + +// [RemoveNotification](https://wails.io/docs/reference/runtime/notification#removenotification) +// Removes a notification by its identifier (cross-platform convenience function). +export function RemoveNotification(identifier: string): Promise; \ No newline at end of file diff --git a/frontend/wailsjs/runtime/runtime.js b/frontend/wailsjs/runtime/runtime.js index 623397b..556621e 100644 --- a/frontend/wailsjs/runtime/runtime.js +++ b/frontend/wailsjs/runtime/runtime.js @@ -48,6 +48,10 @@ export function EventsOff(eventName, ...additionalEventNames) { return window.runtime.EventsOff(eventName, ...additionalEventNames); } +export function EventsOffAll() { + return window.runtime.EventsOffAll(); +} + export function EventsOnce(eventName, callback) { return EventsOnMultiple(eventName, callback, 1); } @@ -235,4 +239,60 @@ export function CanResolveFilePaths() { export function ResolveFilePaths(files) { return window.runtime.ResolveFilePaths(files); +} + +export function InitializeNotifications() { + return window.runtime.InitializeNotifications(); +} + +export function CleanupNotifications() { + return window.runtime.CleanupNotifications(); +} + +export function IsNotificationAvailable() { + return window.runtime.IsNotificationAvailable(); +} + +export function RequestNotificationAuthorization() { + return window.runtime.RequestNotificationAuthorization(); +} + +export function CheckNotificationAuthorization() { + return window.runtime.CheckNotificationAuthorization(); +} + +export function SendNotification(options) { + return window.runtime.SendNotification(options); +} + +export function SendNotificationWithActions(options) { + return window.runtime.SendNotificationWithActions(options); +} + +export function RegisterNotificationCategory(category) { + return window.runtime.RegisterNotificationCategory(category); +} + +export function RemoveNotificationCategory(categoryId) { + return window.runtime.RemoveNotificationCategory(categoryId); +} + +export function RemoveAllPendingNotifications() { + return window.runtime.RemoveAllPendingNotifications(); +} + +export function RemovePendingNotification(identifier) { + return window.runtime.RemovePendingNotification(identifier); +} + +export function RemoveAllDeliveredNotifications() { + return window.runtime.RemoveAllDeliveredNotifications(); +} + +export function RemoveDeliveredNotification(identifier) { + return window.runtime.RemoveDeliveredNotification(identifier); +} + +export function RemoveNotification(identifier) { + return window.runtime.RemoveNotification(identifier); } \ No newline at end of file diff --git a/go.mod b/go.mod index aa4b0d6..3e3423d 100644 --- a/go.mod +++ b/go.mod @@ -5,13 +5,14 @@ go 1.24.1 require ( github.com/google/uuid v1.6.0 github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728 - github.com/wailsapp/wails/v2 v2.10.2 + github.com/wailsapp/wails/v2 v2.12.0 github.com/xuri/excelize/v2 v2.10.1 golang.org/x/sys v0.41.0 golang.org/x/text v0.34.0 ) require ( + git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect github.com/bep/debounce v1.2.1 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect @@ -35,7 +36,7 @@ require ( github.com/tkrajina/go-reflector v0.5.8 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect - github.com/wailsapp/go-webview2 v1.0.19 // indirect + github.com/wailsapp/go-webview2 v1.0.22 // indirect github.com/wailsapp/mimetype v1.4.1 // indirect github.com/xuri/efp v0.0.1 // indirect github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect diff --git a/go.sum b/go.sum index 8f039a3..359cce4 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA= +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -61,12 +63,12 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= -github.com/wailsapp/go-webview2 v1.0.19 h1:7U3QcDj1PrBPaxJNCui2k1SkWml+Q5kvFUFyTImA6NU= -github.com/wailsapp/go-webview2 v1.0.19/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= +github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58= +github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= -github.com/wailsapp/wails/v2 v2.10.2 h1:29U+c5PI4K4hbx8yFbFvwpCuvqK9VgNv8WGobIlKlXk= -github.com/wailsapp/wails/v2 v2.10.2/go.mod h1:XuN4IUOPpzBrHUkEd7sCU5ln4T/p1wQedfxP7fKik+4= +github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c= +github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg= github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8= github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0= diff --git a/helper/auto_reply.go b/helper/auto_reply.go index 237dac6..21325e4 100644 --- a/helper/auto_reply.go +++ b/helper/auto_reply.go @@ -351,9 +351,56 @@ func extractAutoReplyMessage(clientID int32, raw map[string]interface{}) autoRep } else { msg.MessageType = "non_text" } + logEmptyMediaDiagnostics(msg, raw) return msg } +// logEmptyMediaDiagnostics 仅在"图片/视频/表情等非文本媒体消息,且 URL/本地路径/FileID 三个来源全空" +// 时触发,把媒体相关字段(已脱敏)打到日志,用于定位企微 DLL 回调实际下发了哪些字段。 +// 文本消息(11041)与字段已成功填充的消息都不会触发,避免刷屏与泄露正常聊天内容。 +func logEmptyMediaDiagnostics(msg autoReplyMessage, raw map[string]interface{}) { + if globalLogger == nil { + return + } + // 只关心需要拉取媒体文件来识别的类型;纯文本/位置等不在此列。 + switch msg.RawType { + case 11042, 11043, 11047: // image / video / link(图片/表情) + default: + return + } + hasMediaSource := strings.TrimSpace(msg.MediaURL) != "" || + strings.TrimSpace(msg.MediaLocalPath) != "" || + strings.TrimSpace(msg.MediaFileID) != "" + if hasMediaSource { + return // 字段已填到,下载链路可以走,无需诊断 + } + + // 只提取诊断相关的字段,避免泄露用户昵称、群名、会话内容等敏感信息 + diagnosticFields := make(map[string]interface{}) + diagnosticFields["event"] = raw["event"] + diagnosticFields["type"] = raw["type"] + if data, ok := raw["data"].(map[string]interface{}); ok { + // 只记录媒体相关字段和类型标识 + for _, key := range []string{"event", "content_type", "contentType", "messageType", "message_type", + "image_url", "imageUrl", "preview_img_url", "previewImgUrl", + "md_url", "mdUrl", "ld_url", "ldUrl", "url", + "file_id", "fileId", "local_path", "localPath", + "media_kind", "mediaKind", "aes_key", "aesKey", "auth_key", "authKey"} { + if val, exists := data[key]; exists && val != nil { + diagnosticFields[key] = val + } + } + } + + diagJSON, err := json.Marshal(diagnosticFields) + if err != nil { + globalLogger.Warn("[媒体诊断] rawType=%d 媒体字段全空,且诊断数据序列化失败: %v", msg.RawType, err) + return + } + globalLogger.Warn("[媒体诊断] rawType=%d mediaKind=%s 媒体字段(URL/本地路径/FileID)全空,无法识别。诊断字段: %s", + msg.RawType, msg.MediaKind, string(diagJSON)) +} + func rawTypeFromEvent(raw map[string]interface{}) int { event := strings.TrimSpace(stringFromAny(raw["event"])) if event == "" { @@ -361,6 +408,7 @@ func rawTypeFromEvent(raw map[string]interface{}) int { event = strings.TrimSpace(stringFromAny(data["event"])) } } + // 优先使用 event 字段(DLL 真实事件):20002=文本 20003=图片 20004=视频 20012=语音 20005=文件 20014=链接 switch event { case "20002": return 11041 @@ -375,18 +423,24 @@ func rawTypeFromEvent(raw map[string]interface{}) int { case "20014": return 11047 } + // event 为空时才用 content_type(模拟事件或老版本 DLL):2=文本 101=图片 103=视频 16=语音 102=文件 6=位置 13=链接 + // 注意不能把文本(2)误判成图片,否则会触发图片识别并回退"无法识别"话术。 if data, ok := raw["data"].(map[string]interface{}); ok { - switch intFromAny(firstNonNil(data["messageType"], data["content_type"], data["contentType"])) { + switch intFromAny(firstNonNil(data["content_type"], data["contentType"])) { case 2: + return 11041 + case 101: return 11042 - case 4: + case 103: return 11043 case 16: return 11044 - case 6: + case 102: return 11045 - case 48: + case 6: return 11046 + case 13: + return 11047 } } return 0 diff --git a/helper/auto_reply_materials.go b/helper/auto_reply_materials.go index 9d7ea51..ad6eb54 100644 --- a/helper/auto_reply_materials.go +++ b/helper/auto_reply_materials.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "io/fs" "os" "path/filepath" "sort" @@ -286,36 +287,54 @@ func syncAutoReplyMaterials(root string, indexPath string) (autoReplyMaterialSyn func discoverAutoReplyMaterials(root string) []AutoReplyMaterial { dir := resolveAutoReplyPath(root) - entries, err := os.ReadDir(dir) - if err != nil { - return nil - } - items := make([]AutoReplyMaterial, 0, len(entries)) - for _, entry := range entries { - if entry.IsDir() { - continue + items := make([]AutoReplyMaterial, 0, 8) + // 递归遍历子目录(filepath.WalkDir):支持 config/materials 下任意层级嵌套。 + // Path 存相对 root 的子路径并统一为 / 分隔;顶层文件相对路径即文件名,向后兼容旧索引。 + _ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil // 单个条目出错跳过,不中断整体扫描 } - name := entry.Name() + if d.IsDir() { + return nil + } + name := d.Name() if strings.EqualFold(name, "materials.json") { - continue + return nil } materialType := inferMaterialType(name) if materialType == "" { - continue + return nil } + rel, relErr := filepath.Rel(dir, path) + if relErr != nil { + rel = name + } + rel = filepath.ToSlash(rel) title := strings.TrimSuffix(name, filepath.Ext(name)) + keywords := defaultMaterialKeywords(title, materialType) + // 把子目录名也并入关键词,便于"发我<目录名>的图/文件"命中。 + // 只添加目录名本身,不再分词,避免关键词过度膨胀。 + if dirPart := filepath.ToSlash(filepath.Dir(rel)); dirPart != "." && dirPart != "" { + for _, seg := range strings.Split(dirPart, "/") { + if seg = strings.TrimSpace(seg); seg != "" { + keywords = append(keywords, seg) + } + } + keywords = dedupeNonEmptyStrings(keywords) + } items = append(items, AutoReplyMaterial{ - ID: materialIDFromTitle(title), + ID: materialIDFromTitle(strings.TrimSuffix(rel, filepath.Ext(rel))), Title: title, - Keywords: defaultMaterialKeywords(title, materialType), + Keywords: keywords, QuestionPatterns: defaultMaterialQuestionPatterns(title), MaterialType: materialType, - Path: name, + Path: rel, Caption: defaultMaterialCaption(materialType), Priority: 1, Enabled: true, }) - } + return nil + }) return normalizeAutoReplyMaterials(items) } diff --git a/install-nsis.ps1 b/install-nsis.ps1 new file mode 100644 index 0000000..ef4c3be --- /dev/null +++ b/install-nsis.ps1 @@ -0,0 +1,60 @@ +# 自动下载安装 NSIS +$ErrorActionPreference = "Stop" + +Write-Host "==> 检查 NSIS 是否已安装..." -ForegroundColor Cyan +$existingNsis = Get-Command makensis.exe -ErrorAction SilentlyContinue +if ($existingNsis) { + Write-Host " NSIS 已安装: $($existingNsis.Source)" -ForegroundColor Green + Write-Host " 跳过安装,可以直接执行打包命令。" -ForegroundColor Yellow + exit 0 +} + +$nsisVersion = "3.10" +$nsisUrl = "https://sourceforge.net/projects/nsis/files/NSIS%203/$nsisVersion/nsis-$nsisVersion-setup.exe/download" +$installerPath = "$env:TEMP\nsis-$nsisVersion-setup.exe" + +Write-Host "==> 下载 NSIS $nsisVersion 安装程序..." -ForegroundColor Cyan +Write-Host " 从: $nsisUrl" -ForegroundColor Gray +Write-Host " 到: $installerPath" -ForegroundColor Gray + +try { + $ProgressPreference = 'SilentlyContinue' + Invoke-WebRequest -Uri $nsisUrl -OutFile $installerPath -UseBasicParsing -TimeoutSec 300 + Write-Host " 下载完成 ($('{0:N2}' -f ((Get-Item $installerPath).Length / 1MB)) MB)" -ForegroundColor Green +} catch { + Write-Host " 下载失败: $($_.Exception.Message)" -ForegroundColor Red + Write-Host "" + Write-Host "请手动下载并安装 NSIS:" -ForegroundColor Yellow + Write-Host " 1. 访问: https://nsis.sourceforge.io/Download" -ForegroundColor Cyan + Write-Host " 2. 下载 NSIS 3.10 或更高版本" -ForegroundColor Cyan + Write-Host " 3. 运行安装程序(默认安装路径即可)" -ForegroundColor Cyan + exit 1 +} + +Write-Host "" +Write-Host "==> 正在安装 NSIS..." -ForegroundColor Cyan +Write-Host " 安装位置: C:\Program Files (x86)\NSIS" -ForegroundColor Gray +Write-Host " (如果弹出 UAC 提示,请点击'是'授权)" -ForegroundColor Yellow + +try { + Start-Process -FilePath $installerPath -ArgumentList "/S" -Wait -Verb RunAs + Write-Host " 安装完成" -ForegroundColor Green +} catch { + Write-Host " 安装失败: $($_.Exception.Message)" -ForegroundColor Red + Write-Host " 请手动运行: $installerPath" -ForegroundColor Yellow + exit 1 +} + +Write-Host "" +Write-Host "==> 清理临时文件..." -ForegroundColor Cyan +Remove-Item $installerPath -Force -ErrorAction SilentlyContinue + +Write-Host "" +Write-Host "==> NSIS 安装完成!" -ForegroundColor Green +Write-Host "" +Write-Host "下一步操作:" -ForegroundColor Yellow +Write-Host " 1. 关闭当前 PowerShell 窗口" -ForegroundColor Cyan +Write-Host " 2. 重新打开 PowerShell(让 PATH 环境变量生效)" -ForegroundColor Cyan +Write-Host " 3. 执行打包命令: .\打包.bat -Installer" -ForegroundColor Cyan +Write-Host "" +Write-Host "打包完成后,安装包会在 build\bin\ 目录,文件名带时间戳" -ForegroundColor Gray diff --git a/main.go b/main.go index c474f8b..f39c54f 100644 --- a/main.go +++ b/main.go @@ -238,9 +238,14 @@ func shutdownHelperProgram() { globalLogger.Info("使用Windows API成功终止辅助程序") // 等待一小段时间确保进程完全终止 time.Sleep(300 * time.Millisecond) - return } } + // Windows 平台到此为止:helperProcess 是用 &os.Process{Pid:...} 手工构造的, + // 没有有效的内部进程句柄。Go 1.25+ 对这种对象调用 Signal/Kill 会触发 + // panic: handleTransientAcquire called in invalid mode,进而导致主程序在退出 + // (含正常关闭窗口)时整体崩溃。Windows API 的 TerminateProcess 已是最终手段, + // 直接返回,绝不能跌落到下面的通用 Signal/Kill 分支。 + return } // 通用方法:先尝试优雅地终止进程(发送终止信号) diff --git a/scripts/build.ps1 b/scripts/build.ps1 new file mode 100644 index 0000000..a8a8762 --- /dev/null +++ b/scripts/build.ps1 @@ -0,0 +1,209 @@ +<# +.SYNOPSIS + 一键打包脚本。 + +.DESCRIPTION + 默认产出「绿色免安装版」到 dist\qiweimanager\(不需要 NSIS,解压即用): + 1. 定位 Go / Wails 工具链并注入 PATH。 + 2. 32 位编译 helper.exe;编译 silk 音频解码器。 + 3. wails build 生成主程序 qiweimanager.exe(前端一并编译)。 + 4. 汇总主程序 + helper + DLL + 工具 + 默认配置到 dist 目录。 + + 指定 -Installer 时,额外调用现有的 scripts\package-windows.ps1 走 NSIS 生成安装包 + (需本机已安装 NSIS)。 + +.PARAMETER Installer + 额外生成 NSIS 安装包(委托 package-windows.ps1,需要 makensis)。 + +.PARAMETER SkipTests + 跳过 go test。 + +.PARAMETER GoPath / WailsPath + 手动指定工具路径(自动探测失败时使用)。 + +.EXAMPLE + powershell -ExecutionPolicy Bypass -File scripts\build.ps1 + powershell -ExecutionPolicy Bypass -File scripts\build.ps1 -Installer +#> +[CmdletBinding()] +param( + [switch]$Installer, + [switch]$SkipTests, + [string]$GoPath, + [string]$WailsPath +) + +$ErrorActionPreference = "Stop" + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$repoRoot = (Resolve-Path (Join-Path $scriptDir "..")).Path +Set-Location $repoRoot + +function Write-Step([string]$msg) { Write-Host "==> $msg" -ForegroundColor Cyan } +function Write-Ok([string]$msg) { Write-Host " $msg" -ForegroundColor Green } +function Write-Warn2([string]$msg){ Write-Host " $msg" -ForegroundColor Yellow } + +function Resolve-Go { + param([string]$Hint) + $candidates = @() + if ($Hint) { $candidates += $Hint } + $cmd = Get-Command go.exe -ErrorAction SilentlyContinue + if ($cmd) { $candidates += $cmd.Source } + $candidates += "$env:LOCALAPPDATA\Programs\Go\bin\go.exe", "C:\Program Files\Go\bin\go.exe", "C:\Go\bin\go.exe" + foreach ($c in $candidates) { if ($c -and (Test-Path $c)) { return (Resolve-Path $c).Path } } + throw "未找到 Go。请安装 Go 1.24+ 后重试,或用 -GoPath 指定。" +} + +function Resolve-Wails { + param([string]$Hint) + $candidates = @() + if ($Hint) { $candidates += $Hint } + $cmd = Get-Command wails.exe -ErrorAction SilentlyContinue + if ($cmd) { $candidates += $cmd.Source } + $candidates += "$env:USERPROFILE\go\bin\wails.exe" + foreach ($c in $candidates) { if ($c -and (Test-Path $c)) { return (Resolve-Path $c).Path } } + throw "未找到 Wails CLI。请先运行 scripts\dev.ps1 安装,或用 -WailsPath 指定。" +} + +function Copy-Required { + param([string]$Source, [string]$Destination) + if (-not (Test-Path -LiteralPath $Source)) { throw "缺少资源: $Source" } + $dir = Split-Path -Parent $Destination + if ($dir -and -not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null } + Copy-Item -LiteralPath $Source -Destination $Destination -Force +} + +# ---- 委托 NSIS 安装包构建 ---- +if ($Installer) { + Write-Step "委托 package-windows.ps1 生成 NSIS 安装包" + $pkg = Join-Path $scriptDir "package-windows.ps1" + $pkgArgs = @{} + if ($SkipTests) { $pkgArgs["SkipTests"] = $true } + if ($WailsPath) { $pkgArgs["WailsPath"] = $WailsPath } + & $pkg @pkgArgs + return +} + +# ---- 工具链 ---- +Write-Step "定位 Go / Wails 工具链" +$go = Resolve-Go -Hint $GoPath +$goBin = Split-Path -Parent $go +$env:PATH = "$goBin;$env:USERPROFILE\go\bin;$env:PATH" +$wails = Resolve-Wails -Hint $WailsPath +Write-Ok "Go: $go" +Write-Ok "Wails: $wails" + +$goCache = Join-Path $repoRoot ".gocache" +New-Item -ItemType Directory -Force -Path $goCache | Out-Null +$env:GOCACHE = $goCache + +$binDir = Join-Path $repoRoot "build\bin" +$distDir = Join-Path $repoRoot "dist\qiweimanager" + +# ---- 测试 ---- +if (-not $SkipTests) { + Write-Step "运行 Go 测试 (go test ./...)" + & $go test ./... + Write-Ok "测试通过" +} + +# ---- 结束运行中的旧进程,避免覆盖失败 ---- +$running = Get-Process -Name "qiweimanager", "qiweimanager-dev", "helper", "helper_auto_reply" -ErrorAction SilentlyContinue +foreach ($p in $running) { Write-Warn2 "结束运行中的进程: $($p.ProcessName) ($($p.Id))"; Stop-Process -Id $p.Id -Force } + +New-Item -ItemType Directory -Force -Path $binDir | Out-Null + +# ---- 32 位 helper.exe ---- +Write-Step "编译 32 位 helper.exe" +$helperOut = Join-Path $binDir "helper.exe" +$oldArch = $env:GOARCH +try { + $env:GOARCH = "386" + Push-Location (Join-Path $repoRoot "helper") + & $go build -trimpath -ldflags "-H windowsgui -s -w" -o $helperOut . + if ($LASTEXITCODE -ne 0) { throw "helper.exe 编译失败 (exit $LASTEXITCODE)" } +} finally { + Pop-Location + if ($null -eq $oldArch) { Remove-Item Env:GOARCH -ErrorAction SilentlyContinue } else { $env:GOARCH = $oldArch } +} +Write-Ok "helper.exe 完成" + +# ---- silk 音频解码器(尽力而为,需要 cgo/gcc;缺失则跳过,仅影响语音消息转码)---- +Write-Step "编译 silk 音频解码器" +$silkOut = Join-Path $binDir "tools\audio\silkdecode.exe" +$silkBuilt = $false +$hasGcc = [bool](Get-Command gcc -ErrorAction SilentlyContinue) +if (-not $hasGcc) { + Write-Warn2 "未检测到 gcc,silkdecode 依赖 cgo 无法编译,跳过。语音消息自动转码功能在此构建中不可用。" + Write-Warn2 "如需启用:安装 MinGW-w64(如 winget install -e --id mstorsjo.llvm-mingw 或 choco install mingw),再重新打包。" +} else { + New-Item -ItemType Directory -Force -Path (Split-Path $silkOut) | Out-Null + Push-Location (Join-Path $repoRoot "tools\audio\silkdecode") + try { + $oldCgo = $env:CGO_ENABLED + $env:CGO_ENABLED = "1" + & $go build -trimpath -ldflags "-s -w" -o $silkOut . + if ($LASTEXITCODE -eq 0 -and (Test-Path $silkOut)) { $silkBuilt = $true; Write-Ok "silkdecode.exe 完成" } + else { Write-Warn2 "silkdecode 编译失败,已跳过(不影响主程序)。" } + } finally { + Pop-Location + if ($null -eq $oldCgo) { Remove-Item Env:CGO_ENABLED -ErrorAction SilentlyContinue } else { $env:CGO_ENABLED = $oldCgo } + } +} + +# ---- 主程序 + 前端 ---- +Write-Step "构建主程序 (wails build)" +& $wails build -trimpath -webview2 embed +if ($LASTEXITCODE -ne 0) { throw "wails build 失败 (exit $LASTEXITCODE)" } +$mainExe = Join-Path $binDir "qiweimanager.exe" +if (-not (Test-Path $mainExe)) { throw "主程序未生成: $mainExe" } +Write-Ok "qiweimanager.exe 完成" + +# ---- 汇总绿色免安装版 ---- +$timestamp = Get-Date -Format "yyyyMMdd_HHmmss" +$distDirWithTime = Join-Path $repoRoot "dist\qiweimanager_$timestamp" +Write-Step "汇总绿色免安装版到 dist\qiweimanager_$timestamp" +if (Test-Path $distDirWithTime) { Remove-Item $distDirWithTime -Recurse -Force } +New-Item -ItemType Directory -Force -Path $distDirWithTime | Out-Null +New-Item -ItemType Directory -Force -Path (Join-Path $distDirWithTime "config\knowledge") | Out-Null +New-Item -ItemType Directory -Force -Path (Join-Path $distDirWithTime "config\materials") | Out-Null + +$mainExeWithTime = "qiweimanager_$timestamp.exe" +Copy-Required -Source $mainExe -Destination (Join-Path $distDirWithTime $mainExeWithTime) +Copy-Required -Source $helperOut -Destination (Join-Path $distDirWithTime "helper.exe") +if ($silkBuilt) { + Copy-Required -Source $silkOut -Destination (Join-Path $distDirWithTime "tools\audio\silkdecode.exe") +} else { + Write-Warn2 "dist 不含 silkdecode.exe(语音转码功能不可用)。" +} +Copy-Required -Source (Join-Path $repoRoot "Helper_4.1.33.6009.dll") -Destination (Join-Path $distDirWithTime "Helper_4.1.33.6009.dll") +Copy-Required -Source (Join-Path $repoRoot "Loader_4.1.33.6009.dll") -Destination (Join-Path $distDirWithTime "Loader_4.1.33.6009.dll") +Copy-Item -LiteralPath (Join-Path $repoRoot "requestdata") -Destination (Join-Path $distDirWithTime "requestdata") -Recurse -Force +Copy-Item -LiteralPath (Join-Path $repoRoot "eventdata") -Destination (Join-Path $distDirWithTime "eventdata") -Recurse -Force + +# 默认配置(不含真实密钥) +$defaultConfig = @' +{ + "callbackConfig": { + "callbackUrl": "", + "callbackToken": "", + "httpPort": "10001", + "enableCallback": false, + "enableCloudAuth": false, + "fileUploadUrl": "", + "deviceCode": "" + }, + "lastUpdated": 0 +} +'@ +$utf8NoBom = New-Object System.Text.UTF8Encoding($false) +[System.IO.File]::WriteAllText((Join-Path $distDirWithTime "config\config.json"), $defaultConfig, $utf8NoBom) +[System.IO.File]::WriteAllText((Join-Path $distDirWithTime "config\client_status.json"), "{}", $utf8NoBom) +[System.IO.File]::WriteAllText((Join-Path $distDirWithTime "config\materials\materials.json"), '{"materials":[]}', $utf8NoBom) + +Write-Host "" +Write-Step "打包完成" +Write-Ok "绿色免安装版: $distDirWithTime" +Write-Ok "主程序: $mainExeWithTime" +Write-Ok "直接进入该目录双击 $mainExeWithTime 即可运行。" +Write-Host " 如需 NSIS 安装包,请安装 NSIS 后运行: scripts\build.ps1 -Installer" -ForegroundColor DarkGray diff --git a/scripts/dev.ps1 b/scripts/dev.ps1 new file mode 100644 index 0000000..43d1b54 --- /dev/null +++ b/scripts/dev.ps1 @@ -0,0 +1,213 @@ +<# +.SYNOPSIS + 一键本地开发启动脚本。 + +.DESCRIPTION + 自动完成以下工作,然后运行 `wails dev`: + 1. 定位 Go 与 Wails CLI(即使未写入 PATH 也能找到常见安装位置)。 + 2. 把 Go / Wails 注入当前会话 PATH,并设置 GOCACHE 到仓库内的 .gocache。 + 3. 首次运行时自动安装前端依赖(frontend/node_modules)与 Go 模块。 + 4. 校验企业微信调试所需的 helper.exe 与 DLL 是否在运行目录。 + 5. 启动 wails dev(前端热更新 + Go 热重载)。 + +.PARAMETER SkipInstall + 跳过依赖安装检查,直接启动(依赖已装好时更快)。 + +.PARAMETER GoPath + 手动指定 go.exe 路径(自动探测失败时使用)。 + +.PARAMETER WailsPath + 手动指定 wails.exe 路径(自动探测失败时使用)。 + +.EXAMPLE + powershell -ExecutionPolicy Bypass -File scripts\dev.ps1 +#> +[CmdletBinding()] +param( + [switch]$SkipInstall, + [string]$GoPath, + [string]$WailsPath +) + +$ErrorActionPreference = "Stop" + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$repoRoot = (Resolve-Path (Join-Path $scriptDir "..")).Path +Set-Location $repoRoot + +function Write-Step([string]$msg) { Write-Host "==> $msg" -ForegroundColor Cyan } +function Write-Ok([string]$msg) { Write-Host " $msg" -ForegroundColor Green } +function Write-Warn2([string]$msg){ Write-Host " $msg" -ForegroundColor Yellow } + +# ---- 定位 Go ---- +function Resolve-Go { + param([string]$Hint) + $candidates = @() + if ($Hint) { $candidates += $Hint } + $cmd = Get-Command go.exe -ErrorAction SilentlyContinue + if ($cmd) { $candidates += $cmd.Source } + $candidates += "$env:LOCALAPPDATA\Programs\Go\bin\go.exe" + $candidates += "C:\Program Files\Go\bin\go.exe" + $candidates += "C:\Go\bin\go.exe" + foreach ($c in $candidates) { if ($c -and (Test-Path $c)) { return (Resolve-Path $c).Path } } + throw "未找到 Go。请安装 Go 1.24+ 后重试,或用 -GoPath 指定 go.exe。" +} + +# ---- 定位 Wails CLI ---- +function Resolve-Wails { + param([string]$Hint) + $candidates = @() + if ($Hint) { $candidates += $Hint } + $cmd = Get-Command wails.exe -ErrorAction SilentlyContinue + if ($cmd) { $candidates += $cmd.Source } + $candidates += "$env:USERPROFILE\go\bin\wails.exe" + foreach ($c in $candidates) { if ($c -and (Test-Path $c)) { return (Resolve-Path $c).Path } } + return $null +} + +# ---- 环境准备 ---- +Write-Step "定位 Go / Wails 工具链" +$go = Resolve-Go -Hint $GoPath +$goBin = Split-Path -Parent $go +$goPathBin = Join-Path $env:USERPROFILE "go\bin" +$env:PATH = "$goBin;$goPathBin;$env:PATH" +Write-Ok "Go: $go" + +$wails = Resolve-Wails -Hint $WailsPath +if (-not $wails) { + Write-Warn2 "未找到 Wails CLI,正在安装(go install)..." + & $go install github.com/wailsapp/wails/v2/cmd/wails@latest + $wails = Resolve-Wails + if (-not $wails) { throw "Wails CLI 安装失败,请检查网络或 GOPROXY。" } +} +Write-Ok "Wails: $wails" + +# GOCACHE 放仓库内,避免污染系统缓存 +$goCache = Join-Path $repoRoot ".gocache" +New-Item -ItemType Directory -Force -Path $goCache | Out-Null +$env:GOCACHE = $goCache + +# ---- 依赖安装 ---- +if (-not $SkipInstall) { + $nodeModules = Join-Path $repoRoot "frontend\node_modules" + if (-not (Test-Path $nodeModules)) { + Write-Step "安装前端依赖 (npm install)" + Push-Location (Join-Path $repoRoot "frontend") + try { & npm install } finally { Pop-Location } + Write-Ok "前端依赖安装完成" + } else { + Write-Ok "前端依赖已存在,跳过 npm install" + } + + Write-Step "同步 Go 模块 (go mod download)" + & $go mod download + Write-Ok "Go 模块就绪" +} + +# ---- 编译 32 位 helper.exe(统一用正确链接参数,杜绝手动编错导致主程序拉起即崩)---- +# helper 必须 GOARCH=386 且带 -H windowsgui:主程序是 GUI 子系统,用 CreateProcess 拉起 helper 时 +# 不分配控制台;若 helper 误编成默认 console 子系统(漏掉 -H windowsgui),被主程序拉起后会因标准 +# 句柄处理立即崩溃——表现为主程序日志「辅助程序已成功启动 PID=xxxx」但该 PID 秒退、不写日志、 +# 10001 端口无人监听、前端“启动企微”无反应、满屏 dial tcp [::1]:10001 拒绝连接。诡异之处在于 +# console 版 helper 在终端手动运行一切正常,只有被主程序拉起才崩,极易误判为二进制本身没问题。 +# 这里每次 dev 启动都用与 scripts\build.ps1 完全一致的参数重编 helper,既保证 helper 源码改动生效, +# 又把“唯一正确的编译方式”固化进脚本,从源头消除编错风险。 +Write-Step "编译 32 位 helper.exe" +$binDir = Join-Path $repoRoot "build\bin" +New-Item -ItemType Directory -Force -Path $binDir | Out-Null +$helperOut = Join-Path $binDir "helper.exe" + +# 先停掉可能占用 helper.exe 的残留进程,否则无法覆盖输出文件。 +Get-Process -Name "helper", "helper_auto_reply" -ErrorAction SilentlyContinue | ForEach-Object { + Write-Warn2 "结束残留 helper 进程 (PID $($_.Id))" + Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue +} + +$oldArch = $env:GOARCH +$oldCgo = $env:CGO_ENABLED +try { + $env:GOARCH = "386" + $env:CGO_ENABLED = "0" + Push-Location (Join-Path $repoRoot "helper") + & $go build -trimpath -ldflags "-H windowsgui -s -w" -o $helperOut . + $helperExit = $LASTEXITCODE +} finally { + Pop-Location + if ($null -eq $oldArch) { Remove-Item Env:GOARCH -ErrorAction SilentlyContinue } else { $env:GOARCH = $oldArch } + if ($null -eq $oldCgo) { Remove-Item Env:CGO_ENABLED -ErrorAction SilentlyContinue } else { $env:CGO_ENABLED = $oldCgo } +} +if ($helperExit -ne 0) { throw "helper.exe 编译失败 (exit $helperExit)。企业微信链路将不可用,请检查 helper 目录源码。" } + +# 子系统自检:windowsgui 版的 PE 头 Subsystem 字段为 2(GUI),console 版为 3。若意外编出 console 版直接拦下。 +try { + $bytes = [System.IO.File]::ReadAllBytes($helperOut) + $peOff = [System.BitConverter]::ToInt32($bytes, 0x3C) # e_lfanew → PE 头偏移 + $subsystem = [System.BitConverter]::ToUInt16($bytes, $peOff + 0x5C) # OptionalHeader.Subsystem + if ($subsystem -eq 2) { + Write-Ok "helper.exe 已编译 (GOARCH=386, GUI 子系统,可被主程序安全拉起)" + } else { + throw "helper.exe 子系统为 $subsystem(期望 2=GUI)。这会导致主程序拉起即崩,请确认编译参数含 -H windowsgui。" + } +} catch [System.Management.Automation.RuntimeException] { + throw +} catch { + Write-Warn2 "helper.exe 子系统自检跳过:$($_.Exception.Message)" +} + +$wxDlls = @("Helper_4.1.33.6009.dll", "Loader_4.1.33.6009.dll") +foreach ($dll in $wxDlls) { + if (Test-Path (Join-Path $repoRoot $dll)) { Write-Ok "$dll 已就绪" } else { Write-Warn2 "缺少 $dll,企业微信链路可能无法工作" } +} + +# wails dev 把主程序与 helper 跑在 build\bin 下,helper 需要在自身目录找到这两个 DLL。 +# 仓库根目录有 DLL 但 build\bin 没有 → "启动企微"会失败并提示 No Helper_*.dll found。 +# 这里把根目录 DLL 同步到 build\bin(build\bin 由 wails 在编译时创建,缺失则跳过,启动后会自动建好; +# 若本次启动后仍报缺 DLL,再次运行本脚本即可补齐)。 +$binDir = Join-Path $repoRoot "build\bin" +if (Test-Path $binDir) { + foreach ($dll in $wxDlls) { + $src = Join-Path $repoRoot $dll + if (-not (Test-Path $src)) { continue } + $dst = Join-Path $binDir $dll + # 目标已是同样大小则无需复制(避免覆盖正被 helper 占用的 DLL 而报错) + if ((Test-Path $dst) -and ((Get-Item $dst).Length -eq (Get-Item $src).Length)) { + Write-Ok "$dll 已在 build\bin(无需同步)" + continue + } + try { + Copy-Item -LiteralPath $src -Destination $dst -Force + Write-Ok "已同步 $dll 到 build\bin" + } catch { + if (Test-Path $dst) { + Write-Warn2 "$dll 正被占用无法更新,但 build\bin 已有可用副本,继续。" + } else { + Write-Warn2 "同步 $dll 到 build\bin 失败且目标缺失:$($_.Exception.Message)。请先结束残留 helper.exe 再重试。" + } + } + } +} else { + Write-Warn2 "build\bin 尚不存在(首次编译后才会创建)。若启动后“启动企微”提示缺少 DLL,请重新运行本脚本以同步。" +} + +# helper 运行时要在自身目录(build\bin)下按消息类型号读取 eventdata\.json 模板来翻译企微事件。 +# 若 build\bin 缺少 eventdata,TransformData 会走兜底分支并丢掉顶层 type 字段,导致文本消息被误判成图片、 +# 触发图片识别失败并反复回复“无法识别这条图片/视频内容”。requestdata 同理为运行时资源。 +# 这里每次 dev 启动都把仓库根目录的 eventdata/requestdata 同步到 build\bin,从源头杜绝该问题复发。 +if (Test-Path $binDir) { + foreach ($resource in @("eventdata", "requestdata")) { + $src = Join-Path $repoRoot $resource + if (-not (Test-Path $src)) { Write-Warn2 "仓库缺少 $resource 目录,跳过同步"; continue } + $dst = Join-Path $binDir $resource + try { + Copy-Item -LiteralPath $src -Destination $binDir -Recurse -Force + Write-Ok "已同步 $resource 到 build\bin" + } catch { + Write-Warn2 "同步 $resource 到 build\bin 失败:$($_.Exception.Message)" + } + } +} + +# ---- 启动 ---- +Write-Step "启动 wails dev(Ctrl+C 退出)" +Write-Host "" +& $wails dev diff --git a/scripts/package-windows.ps1 b/scripts/package-windows.ps1 index 9f608b5..93cbf4a 100644 --- a/scripts/package-windows.ps1 +++ b/scripts/package-windows.ps1 @@ -112,7 +112,20 @@ $env:GOCACHE = (Resolve-Path (Join-Path $repoRoot ".gocache")).Path $wailsFallback = if ($WailsPath) { $WailsPath } else { Join-Path $env:USERPROFILE "go\bin\wails.exe" } $wails = Resolve-RequiredTool -Name "wails.exe" -FallbackPath $wailsFallback -InstallHint "Install Wails CLI on the build machine first." -$makensis = Resolve-RequiredTool -Name "makensis.exe" -FallbackPath $MakensisPath -InstallHint "Install NSIS on the build machine first, e.g. run as Administrator: choco install nsis -y; or pass -MakensisPath." + +# 自动探测 NSIS 标准安装路径(未在 PATH 时也能找到),优先使用显式传入的 -MakensisPath +$makensisFallback = $null +$makensisCandidates = @() +if ($MakensisPath) { $makensisCandidates += $MakensisPath } +$makensisCandidates += @( + (Join-Path ${env:ProgramFiles(x86)} "NSIS\makensis.exe"), + (Join-Path $env:ProgramFiles "NSIS\makensis.exe"), + (Join-Path $env:LOCALAPPDATA "Programs\NSIS\makensis.exe") +) +foreach ($candidate in $makensisCandidates) { + if ($candidate -and (Test-Path -LiteralPath $candidate)) { $makensisFallback = $candidate; break } +} +$makensis = Resolve-RequiredTool -Name "makensis.exe" -FallbackPath $makensisFallback -InstallHint "Install NSIS on the build machine first, e.g. run as Administrator: choco install nsis -y; or pass -MakensisPath." $npm = Resolve-RequiredTool -Name "npm.cmd" -InstallHint "Install Node.js on the build machine first." $go = Resolve-RequiredTool -Name "go.exe" -InstallHint "Install Go on the build machine first." $pdftoppm = Get-Command "pdftoppm.exe" -ErrorAction SilentlyContinue @@ -149,12 +162,20 @@ try { Write-Host "==> Building bundled silk decoder" $silkDecoderOut = Join-Path $binDir "tools\audio\silkdecode.exe" -New-Item -ItemType Directory -Force -Path (Split-Path $silkDecoderOut) | Out-Null -Push-Location (Join-Path $repoRoot "tools\audio\silkdecode") -try { - & $go build -trimpath -ldflags "-s -w" -o $silkDecoderOut . -} finally { - Pop-Location +$silkBuilt = $false +if (-not (Get-Command gcc -ErrorAction SilentlyContinue)) { + Write-Warning "gcc not found; silkdecode requires cgo and will be skipped. Voice message transcoding will be unavailable." +} else { + New-Item -ItemType Directory -Force -Path (Split-Path $silkDecoderOut) | Out-Null + Push-Location (Join-Path $repoRoot "tools\audio\silkdecode") + try { + $oldCgo = $env:CGO_ENABLED; $env:CGO_ENABLED = "1" + & $go build -trimpath -ldflags "-s -w" -o $silkDecoderOut . + if ($LASTEXITCODE -eq 0) { $silkBuilt = $true } else { Write-Warning "silkdecode build failed, skipping." } + } finally { + Pop-Location + if ($null -eq $oldCgo) { Remove-Item Env:CGO_ENABLED -ErrorAction SilentlyContinue } else { $env:CGO_ENABLED = $oldCgo } + } } if ($pdftoppm) { @@ -185,7 +206,11 @@ if (-not $SkipFrontendBuild) { Write-Host "==> Building frontend" Push-Location (Join-Path $repoRoot "frontend") try { - & $npm run build + # 原生命令往 stderr 写警告会在 Stop 策略下被误判为失败,这里改按退出码判断 + $oldEap = $ErrorActionPreference; $ErrorActionPreference = "Continue" + & $npm run build 2>&1 | ForEach-Object { "$_" } + $ErrorActionPreference = $oldEap + if ($LASTEXITCODE -ne 0) { throw "frontend build failed (exit $LASTEXITCODE)" } } finally { Pop-Location } @@ -197,7 +222,11 @@ New-Item -ItemType Directory -Force -Path (Join-Path $runtimeDir "config\knowled New-Item -ItemType Directory -Force -Path (Join-Path $runtimeDir "config\materials") | Out-Null Copy-RequiredFile -Source $helperOut -Destination (Join-Path $runtimeDir "helper.exe") -Copy-RequiredFile -Source $silkDecoderOut -Destination (Join-Path $runtimeDir "tools\audio\silkdecode.exe") +if ($silkBuilt -and (Test-Path -LiteralPath $silkDecoderOut)) { + Copy-RequiredFile -Source $silkDecoderOut -Destination (Join-Path $runtimeDir "tools\audio\silkdecode.exe") +} else { + Write-Warning "silkdecode.exe not bundled (gcc unavailable); voice transcoding disabled in this installer." +} if (Test-Path -LiteralPath (Join-Path $binDir "tools\pdf\pdftoppm.exe")) { Copy-RequiredFile -Source (Join-Path $binDir "tools\pdf\pdftoppm.exe") -Destination (Join-Path $runtimeDir "tools\pdf\pdftoppm.exe") } @@ -239,11 +268,17 @@ if (Test-Path -LiteralPath $materialsIndex) { } Write-Host "==> Building Wails NSIS installer" -& $wails build --nsis -webview2 embed -trimpath +$oldEap = $ErrorActionPreference; $ErrorActionPreference = "Continue" +& $wails build --nsis -webview2 embed -trimpath 2>&1 | ForEach-Object { "$_" } +$ErrorActionPreference = $oldEap $installer = Join-Path $binDir "qiweimanager-amd64-installer.exe" if (-not (Test-Path -LiteralPath $installer)) { throw "Installer was not generated: $installer" } -Write-Host "==> Release complete: $installer" +# 添加时间戳到安装包文件名 +$timestamp = Get-Date -Format "yyyyMMdd_HHmmss" +$installerWithTime = Join-Path $binDir "qiweimanager-amd64-installer_$timestamp.exe" +Move-Item -LiteralPath $installer -Destination $installerWithTime -Force +Write-Host "==> Release complete: $installerWithTime" diff --git a/启动开发.bat b/启动开发.bat new file mode 100644 index 0000000..82fbbb6 --- /dev/null +++ b/启动开发.bat @@ -0,0 +1,6 @@ +@echo off +chcp 65001 >nul +rem 一键本地开发:自动配置 Go/Wails 环境、安装依赖并启动 wails dev +cd /d "%~dp0" +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\dev.ps1" %* +pause diff --git a/打包.bat b/打包.bat new file mode 100644 index 0000000..32527a2 --- /dev/null +++ b/打包.bat @@ -0,0 +1,7 @@ +@echo off +chcp 65001 >nul +rem 一键打包:编译 helper + 主程序,产出 dist\qiweimanager 绿色免安装版 +rem 如需 NSIS 安装包,改为运行: 打包.bat -Installer (需先安装 NSIS) +cd /d "%~dp0" +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\build.ps1" %* +pause