chore(build): 更新.gitignore配置和清理Wails临时文件
- 添加dist/目录到.gitignore,用于排除打包输出的绿色免安装版 - 添加Wails打包过程中的临时文件和自动生成文件到.gitignore - 删除build/windows/installer/wails_tools.nsh自动生成文件 - 添加Windows安装器临时目录和Webview2安装文件到忽略列表 feat(docs): 添加万川平台对接文档和产品素材 - 创建万川平台登录到获取模型信息的流程说明文档 - 添加万川平台对接实施计划文档 - 新增产品图片、公司简介图、宣传海报、教程截图、案例展示等素材文件 refactor(runtime): 扩展通知功能类型定义 - 添加NotificationOptions接口定义 - 添加NotificationAction接口定义 - 添加NotificationCategory接口定义 - 扩展通知相关的运行时API类型声明,包括初始化、发送、注册分类等功能
7
.gitignore
vendored
@@ -5,10 +5,17 @@ frontend/node_modules/
|
|||||||
# Frontend build output
|
# Frontend build output
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
|
|
||||||
|
# Packaging output (绿色免安装版)
|
||||||
|
dist/
|
||||||
|
|
||||||
# Wails / build output
|
# Wails / build output
|
||||||
build/bin/
|
build/bin/
|
||||||
build/tmp/
|
build/tmp/
|
||||||
build/windows/installer/runtime/
|
build/windows/installer/runtime/
|
||||||
|
# Wails 打包时自动生成/下载的中间产物,每次构建都会变
|
||||||
|
build/windows/installer/tmp/
|
||||||
|
build/windows/installer/wails_tools.nsh
|
||||||
|
build/windows/installer/MicrosoftEdgeWebview2Setup.exe
|
||||||
*.syso
|
*.syso
|
||||||
|
|
||||||
# Go cache and test output
|
# Go cache and test output
|
||||||
|
|||||||
@@ -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
|
|
||||||
272
config/docs/万川平台-登录到获取模型信息-流程说明.md
Normal file
@@ -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}`
|
||||||
108
config/docs/万川平台对接-实施计划.md
Normal file
@@ -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 配置实现平台动态下发。
|
||||||
BIN
config/materials/产品图片/外观/产品全景图.jpg
Normal file
|
After Width: | Height: | Size: 1017 KiB |
BIN
config/materials/产品图片/外观/产品外观图.jpg
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
config/materials/产品图片/细节/局部放大.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
config/materials/产品图片/细节/细节特写.png
Normal file
|
After Width: | Height: | Size: 212 KiB |
BIN
config/materials/公司简介图.jpg
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
config/materials/宣传海报/产品宣传图.jpg
Normal file
|
After Width: | Height: | Size: 187 KiB |
BIN
config/materials/宣传海报/小红书样式.webp
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
config/materials/宣传海报/活动海报.png
Normal file
|
After Width: | Height: | Size: 971 KiB |
BIN
config/materials/工作流图/标准作业流程.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
config/materials/教程截图/安装步骤/第一步开箱.png
Normal file
|
After Width: | Height: | Size: 231 KiB |
BIN
config/materials/教程截图/安装步骤/第三步调试.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
config/materials/教程截图/安装步骤/第二步组装.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
config/materials/案例展示/装修案例/卧室效果图.jpg
Normal file
|
After Width: | Height: | Size: 283 KiB |
BIN
config/materials/案例展示/装修案例/客厅效果图.jpg
Normal file
|
After Width: | Height: | Size: 286 KiB |
BIN
config/materials/案例展示/门店实拍.jpg
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
config/materials/联系方式卡片.png
Normal file
|
After Width: | Height: | Size: 93 KiB |
@@ -1 +1 @@
|
|||||||
db031e671111b343255373ca05cff100
|
3ae7140e534eadc042a92a3db16fd10c
|
||||||
81
frontend/wailsjs/runtime/runtime.d.ts
vendored
@@ -247,3 +247,84 @@ export function CanResolveFilePaths(): boolean;
|
|||||||
|
|
||||||
// Resolves file paths for an array of files
|
// Resolves file paths for an array of files
|
||||||
export function ResolveFilePaths(files: File[]): void
|
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<void>;
|
||||||
|
|
||||||
|
// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications)
|
||||||
|
// Cleans up notification resources and releases any held connections.
|
||||||
|
export function CleanupNotifications(): Promise<void>;
|
||||||
|
|
||||||
|
// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable)
|
||||||
|
// Checks if notifications are available on the current platform.
|
||||||
|
export function IsNotificationAvailable(): Promise<boolean>;
|
||||||
|
|
||||||
|
// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization)
|
||||||
|
// Requests notification authorization from the user (macOS only).
|
||||||
|
export function RequestNotificationAuthorization(): Promise<boolean>;
|
||||||
|
|
||||||
|
// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization)
|
||||||
|
// Checks the current notification authorization status (macOS only).
|
||||||
|
export function CheckNotificationAuthorization(): Promise<boolean>;
|
||||||
|
|
||||||
|
// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification)
|
||||||
|
// Sends a basic notification with the given options.
|
||||||
|
export function SendNotification(options: NotificationOptions): Promise<void>;
|
||||||
|
|
||||||
|
// [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<void>;
|
||||||
|
|
||||||
|
// [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<void>;
|
||||||
|
|
||||||
|
// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory)
|
||||||
|
// Removes a previously registered notification category.
|
||||||
|
export function RemoveNotificationCategory(categoryId: string): Promise<void>;
|
||||||
|
|
||||||
|
// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications)
|
||||||
|
// Removes all pending notifications from the notification center.
|
||||||
|
export function RemoveAllPendingNotifications(): Promise<void>;
|
||||||
|
|
||||||
|
// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification)
|
||||||
|
// Removes a specific pending notification by its identifier.
|
||||||
|
export function RemovePendingNotification(identifier: string): Promise<void>;
|
||||||
|
|
||||||
|
// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications)
|
||||||
|
// Removes all delivered notifications from the notification center.
|
||||||
|
export function RemoveAllDeliveredNotifications(): Promise<void>;
|
||||||
|
|
||||||
|
// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification)
|
||||||
|
// Removes a specific delivered notification by its identifier.
|
||||||
|
export function RemoveDeliveredNotification(identifier: string): Promise<void>;
|
||||||
|
|
||||||
|
// [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<void>;
|
||||||
@@ -48,6 +48,10 @@ export function EventsOff(eventName, ...additionalEventNames) {
|
|||||||
return window.runtime.EventsOff(eventName, ...additionalEventNames);
|
return window.runtime.EventsOff(eventName, ...additionalEventNames);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function EventsOffAll() {
|
||||||
|
return window.runtime.EventsOffAll();
|
||||||
|
}
|
||||||
|
|
||||||
export function EventsOnce(eventName, callback) {
|
export function EventsOnce(eventName, callback) {
|
||||||
return EventsOnMultiple(eventName, callback, 1);
|
return EventsOnMultiple(eventName, callback, 1);
|
||||||
}
|
}
|
||||||
@@ -236,3 +240,59 @@ export function CanResolveFilePaths() {
|
|||||||
export function ResolveFilePaths(files) {
|
export function ResolveFilePaths(files) {
|
||||||
return window.runtime.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);
|
||||||
|
}
|
||||||
5
go.mod
@@ -5,13 +5,14 @@ go 1.24.1
|
|||||||
require (
|
require (
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728
|
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
|
github.com/xuri/excelize/v2 v2.10.1
|
||||||
golang.org/x/sys v0.41.0
|
golang.org/x/sys v0.41.0
|
||||||
golang.org/x/text v0.34.0
|
golang.org/x/text v0.34.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
|
||||||
github.com/bep/debounce v1.2.1 // indirect
|
github.com/bep/debounce v1.2.1 // indirect
|
||||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
github.com/godbus/dbus/v5 v5.1.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/tkrajina/go-reflector v0.5.8 // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fasttemplate v1.2.2 // 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/wailsapp/mimetype v1.4.1 // indirect
|
||||||
github.com/xuri/efp v0.0.1 // indirect
|
github.com/xuri/efp v0.0.1 // indirect
|
||||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
|
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
|
||||||
|
|||||||
10
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 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
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/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 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
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.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
|
||||||
github.com/wailsapp/go-webview2 v1.0.19/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
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 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
||||||
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
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.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
|
||||||
github.com/wailsapp/wails/v2 v2.10.2/go.mod h1:XuN4IUOPpzBrHUkEd7sCU5ln4T/p1wQedfxP7fKik+4=
|
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 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
|
||||||
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
|
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
|
||||||
github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0=
|
github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0=
|
||||||
|
|||||||
@@ -351,9 +351,56 @@ func extractAutoReplyMessage(clientID int32, raw map[string]interface{}) autoRep
|
|||||||
} else {
|
} else {
|
||||||
msg.MessageType = "non_text"
|
msg.MessageType = "non_text"
|
||||||
}
|
}
|
||||||
|
logEmptyMediaDiagnostics(msg, raw)
|
||||||
return msg
|
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 {
|
func rawTypeFromEvent(raw map[string]interface{}) int {
|
||||||
event := strings.TrimSpace(stringFromAny(raw["event"]))
|
event := strings.TrimSpace(stringFromAny(raw["event"]))
|
||||||
if event == "" {
|
if event == "" {
|
||||||
@@ -361,6 +408,7 @@ func rawTypeFromEvent(raw map[string]interface{}) int {
|
|||||||
event = strings.TrimSpace(stringFromAny(data["event"]))
|
event = strings.TrimSpace(stringFromAny(data["event"]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 优先使用 event 字段(DLL 真实事件):20002=文本 20003=图片 20004=视频 20012=语音 20005=文件 20014=链接
|
||||||
switch event {
|
switch event {
|
||||||
case "20002":
|
case "20002":
|
||||||
return 11041
|
return 11041
|
||||||
@@ -375,18 +423,24 @@ func rawTypeFromEvent(raw map[string]interface{}) int {
|
|||||||
case "20014":
|
case "20014":
|
||||||
return 11047
|
return 11047
|
||||||
}
|
}
|
||||||
|
// event 为空时才用 content_type(模拟事件或老版本 DLL):2=文本 101=图片 103=视频 16=语音 102=文件 6=位置 13=链接
|
||||||
|
// 注意不能把文本(2)误判成图片,否则会触发图片识别并回退"无法识别"话术。
|
||||||
if data, ok := raw["data"].(map[string]interface{}); ok {
|
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:
|
case 2:
|
||||||
|
return 11041
|
||||||
|
case 101:
|
||||||
return 11042
|
return 11042
|
||||||
case 4:
|
case 103:
|
||||||
return 11043
|
return 11043
|
||||||
case 16:
|
case 16:
|
||||||
return 11044
|
return 11044
|
||||||
case 6:
|
case 102:
|
||||||
return 11045
|
return 11045
|
||||||
case 48:
|
case 6:
|
||||||
return 11046
|
return 11046
|
||||||
|
case 13:
|
||||||
|
return 11047
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -286,36 +287,54 @@ func syncAutoReplyMaterials(root string, indexPath string) (autoReplyMaterialSyn
|
|||||||
|
|
||||||
func discoverAutoReplyMaterials(root string) []AutoReplyMaterial {
|
func discoverAutoReplyMaterials(root string) []AutoReplyMaterial {
|
||||||
dir := resolveAutoReplyPath(root)
|
dir := resolveAutoReplyPath(root)
|
||||||
entries, err := os.ReadDir(dir)
|
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 {
|
if err != nil {
|
||||||
|
return nil // 单个条目出错跳过,不中断整体扫描
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
items := make([]AutoReplyMaterial, 0, len(entries))
|
name := d.Name()
|
||||||
for _, entry := range entries {
|
|
||||||
if entry.IsDir() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
name := entry.Name()
|
|
||||||
if strings.EqualFold(name, "materials.json") {
|
if strings.EqualFold(name, "materials.json") {
|
||||||
continue
|
return nil
|
||||||
}
|
}
|
||||||
materialType := inferMaterialType(name)
|
materialType := inferMaterialType(name)
|
||||||
if materialType == "" {
|
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))
|
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{
|
items = append(items, AutoReplyMaterial{
|
||||||
ID: materialIDFromTitle(title),
|
ID: materialIDFromTitle(strings.TrimSuffix(rel, filepath.Ext(rel))),
|
||||||
Title: title,
|
Title: title,
|
||||||
Keywords: defaultMaterialKeywords(title, materialType),
|
Keywords: keywords,
|
||||||
QuestionPatterns: defaultMaterialQuestionPatterns(title),
|
QuestionPatterns: defaultMaterialQuestionPatterns(title),
|
||||||
MaterialType: materialType,
|
MaterialType: materialType,
|
||||||
Path: name,
|
Path: rel,
|
||||||
Caption: defaultMaterialCaption(materialType),
|
Caption: defaultMaterialCaption(materialType),
|
||||||
Priority: 1,
|
Priority: 1,
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
})
|
})
|
||||||
}
|
return nil
|
||||||
|
})
|
||||||
return normalizeAutoReplyMaterials(items)
|
return normalizeAutoReplyMaterials(items)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
60
install-nsis.ps1
Normal file
@@ -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
|
||||||
9
main.go
@@ -238,10 +238,15 @@ func shutdownHelperProgram() {
|
|||||||
globalLogger.Info("使用Windows API成功终止辅助程序")
|
globalLogger.Info("使用Windows API成功终止辅助程序")
|
||||||
// 等待一小段时间确保进程完全终止
|
// 等待一小段时间确保进程完全终止
|
||||||
time.Sleep(300 * time.Millisecond)
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Windows 平台到此为止:helperProcess 是用 &os.Process{Pid:...} 手工构造的,
|
||||||
|
// 没有有效的内部进程句柄。Go 1.25+ 对这种对象调用 Signal/Kill 会触发
|
||||||
|
// panic: handleTransientAcquire called in invalid mode,进而导致主程序在退出
|
||||||
|
// (含正常关闭窗口)时整体崩溃。Windows API 的 TerminateProcess 已是最终手段,
|
||||||
|
// 直接返回,绝不能跌落到下面的通用 Signal/Kill 分支。
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 通用方法:先尝试优雅地终止进程(发送终止信号)
|
// 通用方法:先尝试优雅地终止进程(发送终止信号)
|
||||||
err := helperProcess.Signal(syscall.SIGTERM)
|
err := helperProcess.Signal(syscall.SIGTERM)
|
||||||
|
|||||||
209
scripts/build.ps1
Normal file
@@ -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
|
||||||
213
scripts/dev.ps1
Normal file
@@ -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\<type>.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
|
||||||
@@ -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" }
|
$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."
|
$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."
|
$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."
|
$go = Resolve-RequiredTool -Name "go.exe" -InstallHint "Install Go on the build machine first."
|
||||||
$pdftoppm = Get-Command "pdftoppm.exe" -ErrorAction SilentlyContinue
|
$pdftoppm = Get-Command "pdftoppm.exe" -ErrorAction SilentlyContinue
|
||||||
@@ -149,12 +162,20 @@ try {
|
|||||||
|
|
||||||
Write-Host "==> Building bundled silk decoder"
|
Write-Host "==> Building bundled silk decoder"
|
||||||
$silkDecoderOut = Join-Path $binDir "tools\audio\silkdecode.exe"
|
$silkDecoderOut = Join-Path $binDir "tools\audio\silkdecode.exe"
|
||||||
New-Item -ItemType Directory -Force -Path (Split-Path $silkDecoderOut) | Out-Null
|
$silkBuilt = $false
|
||||||
Push-Location (Join-Path $repoRoot "tools\audio\silkdecode")
|
if (-not (Get-Command gcc -ErrorAction SilentlyContinue)) {
|
||||||
try {
|
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 .
|
& $go build -trimpath -ldflags "-s -w" -o $silkDecoderOut .
|
||||||
} finally {
|
if ($LASTEXITCODE -eq 0) { $silkBuilt = $true } else { Write-Warning "silkdecode build failed, skipping." }
|
||||||
|
} finally {
|
||||||
Pop-Location
|
Pop-Location
|
||||||
|
if ($null -eq $oldCgo) { Remove-Item Env:CGO_ENABLED -ErrorAction SilentlyContinue } else { $env:CGO_ENABLED = $oldCgo }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($pdftoppm) {
|
if ($pdftoppm) {
|
||||||
@@ -185,7 +206,11 @@ if (-not $SkipFrontendBuild) {
|
|||||||
Write-Host "==> Building frontend"
|
Write-Host "==> Building frontend"
|
||||||
Push-Location (Join-Path $repoRoot "frontend")
|
Push-Location (Join-Path $repoRoot "frontend")
|
||||||
try {
|
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 {
|
} finally {
|
||||||
Pop-Location
|
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
|
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 $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")) {
|
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")
|
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"
|
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"
|
$installer = Join-Path $binDir "qiweimanager-amd64-installer.exe"
|
||||||
if (-not (Test-Path -LiteralPath $installer)) {
|
if (-not (Test-Path -LiteralPath $installer)) {
|
||||||
throw "Installer was not generated: $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"
|
||||||
|
|||||||
6
启动开发.bat
Normal file
@@ -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
|
||||||