mirror of
https://mirror.skon.top/github.com/ILoveBingLu/CipherTalk
synced 2026-05-01 06:15:23 +08:00
merge: sync MACOS with main (MCP & updates)
Made-with: Cursor
This commit is contained in:
@@ -12,7 +12,9 @@
|
||||
"Bash(gh api:*)",
|
||||
"Bash(gh auth:*)",
|
||||
"WebFetch(domain:github.com)",
|
||||
"Read(//d/JiQingzhe/GitHub项目/CipherTalk-wiki/**)"
|
||||
"Read(//d/JiQingzhe/GitHub项目/CipherTalk-wiki/**)",
|
||||
"Bash(npm run type-check)",
|
||||
"Bash(npx tsc --noEmit)"
|
||||
],
|
||||
"additionalDirectories": [
|
||||
"d:\\JiQingzhe\\GitHub项目\\CipherTalk-wiki"
|
||||
|
||||
447
.github/workflows/release.yml
vendored
Normal file
447
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,447 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
prepare-meta:
|
||||
runs-on: windows-latest
|
||||
environment: 软件发布
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
AI_API_URL: ${{ vars.AI_API_URL }}
|
||||
AI_MODEL: ${{ vars.AI_MODEL }}
|
||||
FORCE_UPDATE_MIN_VERSION: ${{ vars.FORCE_UPDATE_MIN_VERSION }}
|
||||
FORCE_UPDATE_BLOCKED_VERSIONS: ${{ vars.FORCE_UPDATE_BLOCKED_VERSIONS }}
|
||||
FORCE_UPDATE_TITLE: ${{ vars.FORCE_UPDATE_TITLE }}
|
||||
FORCE_UPDATE_MESSAGE: ${{ vars.FORCE_UPDATE_MESSAGE }}
|
||||
FORCE_UPDATE_RELEASE_NOTES: ${{ vars.FORCE_UPDATE_RELEASE_NOTES }}
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
tag: ${{ steps.version.outputs.tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22.12.0
|
||||
cache: npm
|
||||
|
||||
- name: Read package version
|
||||
id: version
|
||||
shell: pwsh
|
||||
run: |
|
||||
$pkg = Get-Content package.json -Raw | ConvertFrom-Json
|
||||
"version=$($pkg.version)" >> $env:GITHUB_OUTPUT
|
||||
"tag=${env:GITHUB_REF_NAME}" >> $env:GITHUB_OUTPUT
|
||||
|
||||
- name: Validate tag matches package version
|
||||
shell: pwsh
|
||||
run: |
|
||||
$expectedTag = "v${{ steps.version.outputs.version }}"
|
||||
$actualTag = "${{ steps.version.outputs.tag }}"
|
||||
if ($actualTag -ne $expectedTag) {
|
||||
Write-Error "Tag $actualTag does not match package.json version $expectedTag"
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Generate force update manifest
|
||||
run: npm run build:force-update-manifest
|
||||
|
||||
- name: Generate release context
|
||||
env:
|
||||
RELEASE_TAG: ${{ steps.version.outputs.tag }}
|
||||
run: npm run build:release-context
|
||||
|
||||
- name: Upload release metadata
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-meta
|
||||
path: |
|
||||
release/force-update.json
|
||||
release/release-context.json
|
||||
if-no-files-found: error
|
||||
|
||||
build-windows:
|
||||
runs-on: windows-latest
|
||||
environment: 软件发布
|
||||
needs: prepare-meta
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
AI_API_KEY: ${{ secrets.AI_API_KEY }}
|
||||
AI_API_URL: ${{ vars.AI_API_URL }}
|
||||
AI_MODEL: ${{ vars.AI_MODEL }}
|
||||
FORCE_UPDATE_MIN_VERSION: ${{ vars.FORCE_UPDATE_MIN_VERSION }}
|
||||
FORCE_UPDATE_BLOCKED_VERSIONS: ${{ vars.FORCE_UPDATE_BLOCKED_VERSIONS }}
|
||||
FORCE_UPDATE_TITLE: ${{ vars.FORCE_UPDATE_TITLE }}
|
||||
FORCE_UPDATE_MESSAGE: ${{ vars.FORCE_UPDATE_MESSAGE }}
|
||||
FORCE_UPDATE_RELEASE_NOTES: ${{ vars.FORCE_UPDATE_RELEASE_NOTES }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22.12.0
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Rebuild native modules
|
||||
run: npx electron-rebuild
|
||||
|
||||
- name: Download release metadata
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: release-meta
|
||||
path: release
|
||||
|
||||
- name: Generate embedded release body
|
||||
run: npm run build:release-body
|
||||
|
||||
- name: Build app
|
||||
run: npm run build:ci
|
||||
|
||||
- name: Validate build artifacts
|
||||
shell: pwsh
|
||||
run: |
|
||||
$version = "${{ needs.prepare-meta.outputs.version }}"
|
||||
$installer = "release/CipherTalk-$version-Setup.exe"
|
||||
if (-not (Test-Path $installer)) {
|
||||
Write-Error "Installer not found: $installer"
|
||||
exit 1
|
||||
}
|
||||
if (-not (Test-Path "release/latest.yml")) {
|
||||
Write-Error "latest.yml not found"
|
||||
exit 1
|
||||
}
|
||||
$sizeLines = @(Select-String -Path "release/latest.yml" -Pattern '^\s+size:\s+\d+\s*$')
|
||||
if ($sizeLines.Count -ne 1) {
|
||||
Write-Error "latest.yml should contain exactly one size entry, found $($sizeLines.Count)"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$latestYml = Get-Content "release/latest.yml" -Raw
|
||||
$shaMatch = [regex]::Match($latestYml, '(?m)^sha512:\s*(.+)$')
|
||||
if (-not $shaMatch.Success) {
|
||||
Write-Error "sha512 not found in latest.yml"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$hashHex = (Get-FileHash -Algorithm SHA512 $installer).Hash
|
||||
$hashBytes = [byte[]]::new($hashHex.Length / 2)
|
||||
for ($i = 0; $i -lt $hashHex.Length; $i += 2) {
|
||||
$hashBytes[$i / 2] = [Convert]::ToByte($hashHex.Substring($i, 2), 16)
|
||||
}
|
||||
$actualSha512 = [Convert]::ToBase64String($hashBytes)
|
||||
$expectedSha512 = $shaMatch.Groups[1].Value.Trim()
|
||||
|
||||
if ($actualSha512 -ne $expectedSha512) {
|
||||
Write-Error "latest.yml sha512 does not match installer"
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Upload release binaries
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-binaries
|
||||
path: |
|
||||
release/CipherTalk-${{ needs.prepare-meta.outputs.version }}-Setup.exe
|
||||
release/latest.yml
|
||||
if-no-files-found: error
|
||||
|
||||
generate-release-body:
|
||||
runs-on: windows-latest
|
||||
environment: 软件发布
|
||||
needs: prepare-meta
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
AI_API_KEY: ${{ secrets.AI_API_KEY }}
|
||||
AI_API_URL: ${{ vars.AI_API_URL }}
|
||||
AI_MODEL: ${{ vars.AI_MODEL }}
|
||||
FORCE_UPDATE_MIN_VERSION: ${{ vars.FORCE_UPDATE_MIN_VERSION }}
|
||||
FORCE_UPDATE_BLOCKED_VERSIONS: ${{ vars.FORCE_UPDATE_BLOCKED_VERSIONS }}
|
||||
FORCE_UPDATE_TITLE: ${{ vars.FORCE_UPDATE_TITLE }}
|
||||
FORCE_UPDATE_MESSAGE: ${{ vars.FORCE_UPDATE_MESSAGE }}
|
||||
FORCE_UPDATE_RELEASE_NOTES: ${{ vars.FORCE_UPDATE_RELEASE_NOTES }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22.12.0
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Download release metadata
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: release-meta
|
||||
path: release
|
||||
|
||||
- name: Generate AI release body
|
||||
run: npm run build:release-body
|
||||
|
||||
- name: Validate release body
|
||||
shell: pwsh
|
||||
run: |
|
||||
if (-not (Test-Path "release/release-body.md")) {
|
||||
Write-Error "release-body.md not found"
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Upload release body
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-body
|
||||
path: release/release-body.md
|
||||
if-no-files-found: error
|
||||
|
||||
publish-github-release:
|
||||
runs-on: windows-latest
|
||||
environment: 软件发布
|
||||
needs:
|
||||
- prepare-meta
|
||||
- build-windows
|
||||
- generate-release-body
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
steps:
|
||||
- name: Download release metadata
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: release-meta
|
||||
path: release
|
||||
|
||||
- name: Download release binaries
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: release-binaries
|
||||
path: release
|
||||
|
||||
- name: Download release body
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: release-body
|
||||
path: release
|
||||
|
||||
- name: Validate release package
|
||||
shell: pwsh
|
||||
run: |
|
||||
$version = "${{ needs.prepare-meta.outputs.version }}"
|
||||
$installer = "release/CipherTalk-$version-Setup.exe"
|
||||
if (-not (Test-Path $installer)) {
|
||||
Write-Error "Installer not found: $installer"
|
||||
exit 1
|
||||
}
|
||||
if (-not (Test-Path "release/latest.yml")) {
|
||||
Write-Error "latest.yml not found"
|
||||
exit 1
|
||||
}
|
||||
$sizeLines = @(Select-String -Path "release/latest.yml" -Pattern '^\s+size:\s+\d+\s*$')
|
||||
if ($sizeLines.Count -ne 1) {
|
||||
Write-Error "latest.yml should contain exactly one size entry, found $($sizeLines.Count)"
|
||||
exit 1
|
||||
}
|
||||
if (-not (Test-Path "release/force-update.json")) {
|
||||
Write-Error "force-update.json not found"
|
||||
exit 1
|
||||
}
|
||||
if (-not (Test-Path "release/release-body.md")) {
|
||||
Write-Error "release-body.md not found"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$latestYml = Get-Content "release/latest.yml" -Raw
|
||||
$shaMatch = [regex]::Match($latestYml, '(?m)^sha512:\s*(.+)$')
|
||||
if (-not $shaMatch.Success) {
|
||||
Write-Error "sha512 not found in latest.yml"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$hashHex = (Get-FileHash -Algorithm SHA512 $installer).Hash
|
||||
$hashBytes = [byte[]]::new($hashHex.Length / 2)
|
||||
for ($i = 0; $i -lt $hashHex.Length; $i += 2) {
|
||||
$hashBytes[$i / 2] = [Convert]::ToByte($hashHex.Substring($i, 2), 16)
|
||||
}
|
||||
$actualSha512 = [Convert]::ToBase64String($hashBytes)
|
||||
$expectedSha512 = $shaMatch.Groups[1].Value.Trim()
|
||||
|
||||
if ($actualSha512 -ne $expectedSha512) {
|
||||
Write-Error "latest.yml sha512 does not match installer"
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Create or update GitHub Release
|
||||
uses: softprops/action-gh-release@v2.5.0
|
||||
with:
|
||||
tag_name: ${{ needs.prepare-meta.outputs.tag }}
|
||||
name: CipherTalk v${{ needs.prepare-meta.outputs.version }}
|
||||
body_path: release/release-body.md
|
||||
fail_on_unmatched_files: false
|
||||
overwrite_files: false
|
||||
files: |
|
||||
release/CipherTalk-${{ needs.prepare-meta.outputs.version }}-Setup.exe
|
||||
release/latest.yml
|
||||
release/force-update.json
|
||||
|
||||
mirror-r2:
|
||||
runs-on: windows-latest
|
||||
environment: 软件发布
|
||||
needs:
|
||||
- prepare-meta
|
||||
- build-windows
|
||||
env:
|
||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
steps:
|
||||
- name: Download release metadata
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: release-meta
|
||||
path: release
|
||||
|
||||
- name: Download release binaries
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: release-binaries
|
||||
path: release
|
||||
|
||||
- name: Ensure AWS CLI
|
||||
shell: pwsh
|
||||
run: |
|
||||
if (-not (Get-Command aws -ErrorAction SilentlyContinue)) {
|
||||
choco install awscli -y
|
||||
}
|
||||
aws --version
|
||||
|
||||
- name: Upload mirrored files to R2
|
||||
shell: pwsh
|
||||
run: |
|
||||
if (-not $env:R2_ACCOUNT_ID -or -not $env:R2_BUCKET_NAME -or -not $env:R2_ACCESS_KEY_ID -or -not $env:R2_SECRET_ACCESS_KEY) {
|
||||
Write-Error "R2 secrets are required"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$env:AWS_ACCESS_KEY_ID = $env:R2_ACCESS_KEY_ID
|
||||
$env:AWS_SECRET_ACCESS_KEY = $env:R2_SECRET_ACCESS_KEY
|
||||
$env:AWS_DEFAULT_REGION = "auto"
|
||||
$endpoint = "https://$($env:R2_ACCOUNT_ID).r2.cloudflarestorage.com"
|
||||
$bucket = "s3://$($env:R2_BUCKET_NAME)"
|
||||
$version = "${{ needs.prepare-meta.outputs.version }}"
|
||||
$currentInstaller = "CipherTalk-$version-Setup.exe"
|
||||
|
||||
$existingInstallers = aws s3 ls $bucket --endpoint-url $endpoint | ForEach-Object {
|
||||
$line = $_.ToString().Trim()
|
||||
if ($line -match 'CipherTalk-.*-Setup\.exe$') {
|
||||
($line -split '\s+')[-1]
|
||||
}
|
||||
} | Where-Object { $_ }
|
||||
|
||||
$existingBlockmaps = aws s3 ls $bucket --endpoint-url $endpoint | ForEach-Object {
|
||||
$line = $_.ToString().Trim()
|
||||
if ($line -match '\.blockmap$') {
|
||||
($line -split '\s+')[-1]
|
||||
}
|
||||
} | Where-Object { $_ }
|
||||
|
||||
foreach ($installer in $existingInstallers) {
|
||||
if ($installer -ne $currentInstaller) {
|
||||
aws s3 rm "$bucket/$installer" --endpoint-url $endpoint
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($blockmap in $existingBlockmaps) {
|
||||
aws s3 rm "$bucket/$blockmap" --endpoint-url $endpoint
|
||||
}
|
||||
|
||||
aws s3 cp "release/$currentInstaller" "$bucket/$currentInstaller" --endpoint-url $endpoint
|
||||
aws s3 cp "release/latest.yml" "$bucket/latest.yml" --endpoint-url $endpoint
|
||||
aws s3 cp "release/force-update.json" "$bucket/force-update.json" --endpoint-url $endpoint
|
||||
|
||||
notify-telegram-success:
|
||||
runs-on: windows-latest
|
||||
environment: 软件发布
|
||||
needs:
|
||||
- prepare-meta
|
||||
- generate-release-body
|
||||
- publish-github-release
|
||||
env:
|
||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
TELEGRAM_CHAT_IDS: ${{ vars.TELEGRAM_CHAT_IDS }}
|
||||
TELEGRAM_RELEASE_COVER_URL: ${{ vars.TELEGRAM_RELEASE_COVER_URL }}
|
||||
RELEASE_VERSION: ${{ needs.prepare-meta.outputs.version }}
|
||||
TELEGRAM_NOTIFY_MODE: success
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22.12.0
|
||||
cache: npm
|
||||
|
||||
- name: Download release metadata
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: release-meta
|
||||
path: release
|
||||
|
||||
- name: Download release body
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: release-body
|
||||
path: release
|
||||
|
||||
- name: Notify Telegram success
|
||||
run: npm run notify:telegram
|
||||
continue-on-error: true
|
||||
|
||||
notify-failure:
|
||||
runs-on: windows-latest
|
||||
environment: 软件发布
|
||||
if: failure()
|
||||
env:
|
||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
TELEGRAM_CHAT_IDS: ${{ vars.TELEGRAM_CHAT_IDS }}
|
||||
TELEGRAM_RELEASE_COVER_URL: ${{ vars.TELEGRAM_RELEASE_COVER_URL }}
|
||||
RELEASE_VERSION: ${{ github.ref_name }}
|
||||
TELEGRAM_NOTIFY_MODE: failure
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22.12.0
|
||||
cache: npm
|
||||
|
||||
- name: Notify Telegram failure
|
||||
run: npm run notify:telegram
|
||||
continue-on-error: true
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -28,6 +28,7 @@ dist-electron
|
||||
# Build output
|
||||
out
|
||||
release
|
||||
.tmp/release-announcement.json
|
||||
|
||||
# Database
|
||||
*.db
|
||||
@@ -50,7 +51,7 @@ Docs
|
||||
WeFolw
|
||||
upx
|
||||
native-dlls
|
||||
MyCoolInstaller
|
||||
resources/whisper
|
||||
xkey
|
||||
skills
|
||||
.claude/
|
||||
|
||||
1
.npmrc
1
.npmrc
@@ -1,3 +1,4 @@
|
||||
registry=https://registry.npmmirror.com
|
||||
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
|
||||
ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||
legacy-peer-deps=true
|
||||
|
||||
17
CHANGELOG.md
17
CHANGELOG.md
@@ -7,6 +7,21 @@
|
||||
|
||||
## [未发布]
|
||||
|
||||
### 变更
|
||||
- 禁用 Windows 差分更新,统一改为全量安装包更新
|
||||
- 简化发布链路,移除 `.blockmap` 产物依赖与校验
|
||||
|
||||
## [3.0.1] - 2026-04-04
|
||||
|
||||
### 修复
|
||||
- 修复自动更新流程中重复触发下载的问题
|
||||
- 修复差分更新发布链路缺少安装包哈希强校验的问题
|
||||
|
||||
### 变更
|
||||
- 优化右下角更新提醒 UI,下载中状态改为持续展示进度
|
||||
- 优化顶部下载状态展示,增加实时下载速度与已下载大小
|
||||
- 优化关于页更新状态同步,避免下载中被“检查更新”状态覆盖
|
||||
|
||||
### 新增
|
||||
- 完善的项目文档和贡献指南
|
||||
- 标准化的开源项目结构
|
||||
@@ -70,4 +85,4 @@
|
||||
|
||||
---
|
||||
|
||||
更多详细信息请查看 [GitHub Releases](https://github.com/your-repo/releases)。
|
||||
更多详细信息请查看 [GitHub Releases](https://github.com/your-repo/releases)。
|
||||
|
||||
172
README.md
172
README.md
@@ -7,7 +7,7 @@
|
||||
**一款现代化的微信聊天记录查看与分析工具**
|
||||
|
||||
[](LICENSE)
|
||||
[](package.json)
|
||||
[](package.json)
|
||||
[]()
|
||||
[]()
|
||||
[]()
|
||||
@@ -95,7 +95,7 @@
|
||||
|
||||
### 📋 环境要求
|
||||
|
||||
- **Node.js**: 18.x 或更高版本
|
||||
- **Node.js**: 22.12.0 或更高版本
|
||||
- **操作系统**: Windows 10/11
|
||||
- **内存**: 建议 4GB 以上
|
||||
|
||||
@@ -190,6 +190,172 @@ npm run build:core
|
||||
|
||||
---
|
||||
|
||||
## MCP Server
|
||||
|
||||
CipherTalk 现已提供基于 `stdio` 的独立 MCP Server,可供 Claude Desktop、Codex、Cherry Studio 等 MCP 宿主直接读取本地聊天数据。
|
||||
|
||||
### 开发态启动
|
||||
|
||||
```bash
|
||||
npm run mcp
|
||||
```
|
||||
|
||||
首次运行若缺少 `dist-electron/mcp.js`,会自动执行 `build:mcp` 后再启动。
|
||||
|
||||
### 打包态启动
|
||||
|
||||
安装版会附带 `ciphertalk-mcp.cmd` 伴随启动器,放在安装目录根部,可直接作为宿主的 `command` 使用。
|
||||
|
||||
### 强制更新清单
|
||||
|
||||
当前更新架构:
|
||||
|
||||
- **主更新源**:GitHub Release(安装包、`latest.yml`)
|
||||
- **策略补充源**:`https://miyuapp.aiqji.com`
|
||||
- **策略优先级**:GitHub 优先,自定义源仅在 GitHub 策略不可用时作为回退
|
||||
|
||||
应用启动时会按以下顺序请求 `force-update.json`,用于判定:
|
||||
|
||||
1. `https://github.com/ILoveBingLu/CipherTalk/releases/latest/download/force-update.json`
|
||||
2. `https://miyuapp.aiqji.com/force-update.json`
|
||||
|
||||
策略字段含义:
|
||||
|
||||
- 最低安全版本 `minimumSupportedVersion`
|
||||
- 被封禁版本列表 `blockedVersions`
|
||||
- 强制更新提示文案 `title` / `message`
|
||||
|
||||
可用以下命令在 `release/force-update.json` 生成清单:
|
||||
|
||||
```bash
|
||||
FORCE_UPDATE_MIN_VERSION=2.2.15 npm run build:force-update-manifest
|
||||
```
|
||||
|
||||
示例结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"latestVersion": "2.2.15",
|
||||
"minimumSupportedVersion": "2.2.14",
|
||||
"blockedVersions": ["2.2.13"],
|
||||
"title": "必须更新到最新版本",
|
||||
"message": "当前版本存在安全风险,请立即更新。",
|
||||
"releaseNotes": "修复关键安全问题",
|
||||
"publishedAt": "2026-04-01T00:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
发布要求:
|
||||
|
||||
- **GitHub Release 必须上传**:安装包、`latest.yml`、`force-update.json`
|
||||
- **自定义源可上传**:`force-update.json`
|
||||
- 自定义源不再承担安装包和 `latest.yml` 分发
|
||||
- GitHub Actions 同步到 R2 时只会清理旧安装包 `CipherTalk-*-Setup.exe`,不会删除桶里的其他文件
|
||||
|
||||
### 自动发布
|
||||
|
||||
仓库使用 GitHub Actions 自动发布。
|
||||
|
||||
触发方式:
|
||||
|
||||
1. 修改 `package.json.version`
|
||||
2. 提交并推送代码
|
||||
3. 推送同版本 Git 标签,例如:
|
||||
|
||||
```bash
|
||||
git tag v2.2.14
|
||||
git push origin v2.2.14
|
||||
```
|
||||
|
||||
只有推送 `v*` 标签时才会正式构建并发布,不会在普通 `push main` 时自动发版。
|
||||
|
||||
自动发布内容:
|
||||
|
||||
- GitHub Release:安装包、`latest.yml`、`force-update.json`
|
||||
- Cloudflare R2:安装包、`latest.yml`、`force-update.json`
|
||||
- GitHub Release body:由工作流自动生成标准化中文版本说明
|
||||
- Telegram:自动推送机器人风格的发布通知(支持多个频道/群)
|
||||
|
||||
AI 生成说明的密钥来源:
|
||||
|
||||
- GitHub Environment `软件发布`
|
||||
- Secret 名称:`AI_API_KEY`
|
||||
- 可选 Variable:`AI_API_URL`
|
||||
- 可选 Variable:`AI_MODEL`
|
||||
|
||||
若 AI 不可用,工作流会自动回退为模板化 Release body,不影响正式发布。
|
||||
|
||||
默认情况下,发布说明生成会使用:
|
||||
|
||||
- `AI_API_URL`: `https://api.openai.com/v1/chat/completions`
|
||||
- `AI_MODEL`: `gpt-5.4`
|
||||
|
||||
如配置 Telegram Bot,发布成功后还会自动发送:
|
||||
|
||||
- AI 摘要版发布通知
|
||||
- 强制更新提醒(如存在)
|
||||
- Release / 安装包按钮链接
|
||||
|
||||
若发布失败,也会自动发送失败通知和 Actions 日志链接。
|
||||
|
||||
### v1 工具
|
||||
|
||||
- `health_check`
|
||||
- `get_status`
|
||||
- `list_sessions`
|
||||
- `get_messages`
|
||||
- `list_contacts`
|
||||
- `search_messages`
|
||||
- `get_session_context`
|
||||
- `get_global_statistics`
|
||||
- `get_contact_rankings`
|
||||
- `get_activity_distribution`
|
||||
|
||||
### 宿主配置示例(开发态)
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"ciphertalk": {
|
||||
"command": "npm",
|
||||
"args": ["run", "mcp"],
|
||||
"cwd": "E:/CipherTalk"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 宿主配置示例(打包态)
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"ciphertalk": {
|
||||
"command": "E:/CipherTalk/ciphertalk-mcp.cmd",
|
||||
"args": [],
|
||||
"cwd": "E:/CipherTalk"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 参数示例
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "get_messages",
|
||||
"arguments": {
|
||||
"sessionId": "wxid_xxx",
|
||||
"limit": 20,
|
||||
"order": "asc",
|
||||
"includeMediaPaths": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💻 开发指南
|
||||
|
||||
### 代码规范
|
||||
@@ -351,4 +517,4 @@ export const useChatStore = create<ChatStore>((set) => ({
|
||||
|
||||
<sub>一鲸落,万物生 · 愿每一段对话都被温柔以待 ❤️ by the CipherTalk Team</sub>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
82
TODO.md
82
TODO.md
@@ -1,82 +0,0 @@
|
||||
# EchoTrace 重构进度
|
||||
|
||||
## 基础架构
|
||||
- [x] 项目初始化(React + Electron + TypeScript)
|
||||
- [x] 自定义标题栏 + Windows 原生窗口控件
|
||||
- [x] Zustand 状态管理
|
||||
- [x] Electron IPC 通信封装
|
||||
- [x] 数据库服务(SQLite)
|
||||
- [x] 配置服务
|
||||
- [x] 路由守卫(未解密时跳转欢迎页)
|
||||
|
||||
## 页面
|
||||
- [x] 欢迎页(WelcomePage)
|
||||
- [x] 数据库路径选择
|
||||
- [x] 密钥输入/自动获取
|
||||
- [x] 解密进度显示
|
||||
- [x] 数据管理页(DataManagementPage)
|
||||
- [x] 数据库列表扫描
|
||||
- [x] 解密状态显示
|
||||
- [x] 批量解密功能
|
||||
- [x] 增量更新功能
|
||||
- [x] 图片解密功能
|
||||
- [x] 聊天页(ChatPage)
|
||||
- [x] 会话列表侧边栏
|
||||
- [x] 消息列表
|
||||
- [x] 消息气泡组件
|
||||
- [x] 图片消息
|
||||
- [x] 语音消息
|
||||
- [x] 表情消息
|
||||
- [x] 引用消息
|
||||
- [x] 系统消息
|
||||
- [x] 群聊发送者头像/名称
|
||||
- [x] 日期分隔线
|
||||
- [x] 滚动加载更多
|
||||
- [x] 图片消息查看器
|
||||
- [x] 语音消息播放
|
||||
- [x] 数据分析页(AnalyticsPage)
|
||||
- [x] 消息统计图表
|
||||
- [x] 词云
|
||||
- [x] 活跃时段分析
|
||||
- [x] 年度报告页(AnnualReportPage)
|
||||
- [x] 报告生成
|
||||
- [x] 报告展示
|
||||
- [x] 设置页(SettingsPage)
|
||||
- [x] 数据库配置(密钥、路径、wxid)
|
||||
- [x] 图片解密配置(XOR/AES 密钥,半完成,相当于没完成)
|
||||
- [x] 缓存管理(迁移功能)
|
||||
- [x] 主题切换
|
||||
- [x] 自动获取密钥功能
|
||||
- [x] 自动检测数据库路径
|
||||
|
||||
## 服务
|
||||
- [x] 数据管理服务
|
||||
- [x] 数据库解密服务
|
||||
- [x] 图片解密服务
|
||||
- [x] 图片密钥获取服务
|
||||
- [x] WCDB 数据库服务
|
||||
- [x] 微信密钥获取服务
|
||||
- [x] 聊天服务
|
||||
- [x] 表情包下载缓存服务
|
||||
- [x] 消息解析服务
|
||||
- [x] 语音解码服务
|
||||
- [x] 分析计算服务
|
||||
- [x] 导出服务
|
||||
|
||||
## 数据模型
|
||||
- [x] Message 消息模型
|
||||
- [x] Contact 联系人模型
|
||||
- [x] ChatSession 会话模型
|
||||
- [x] AnalyticsData 分析数据模型
|
||||
|
||||
## 组件
|
||||
- [x] TitleBar 标题栏
|
||||
- [x] Sidebar 侧边导航
|
||||
- [x] RouteGuard 路由守卫
|
||||
- [x] DecryptProgressOverlay 解密进度遮罩
|
||||
- [x] SessionAvatar 会话头像(支持骨架屏)
|
||||
- [x] MessageBubble 消息气泡
|
||||
- [x] ImageViewer 图片查看器
|
||||
- [x] VoicePlayer 语音播放器
|
||||
- [x] LoadingSpinner 加载动画
|
||||
- [x] Toast 提示组件
|
||||
1
WeFlow
Submodule
1
WeFlow
Submodule
Submodule WeFlow added at 4da9f1e6cf
1
WxKey-CC
Submodule
1
WxKey-CC
Submodule
Submodule WxKey-CC added at 6581685d95
311
electron/main.ts
311
electron/main.ts
@@ -1,8 +1,10 @@
|
||||
import { app, BrowserWindow, ipcMain, nativeTheme, protocol, net, Tray, Menu } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { randomBytes } from 'crypto'
|
||||
import { readFileSync, existsSync, mkdirSync } from 'fs'
|
||||
import { autoUpdater } from 'electron-updater'
|
||||
import { DatabaseService } from './services/database'
|
||||
import { appUpdateService } from './services/appUpdateService'
|
||||
|
||||
import { wechatDecryptService } from './services/decryptService'
|
||||
import { ConfigService } from './services/config'
|
||||
@@ -28,6 +30,8 @@ import { windowsHelloService, WindowsHelloResult } from './services/windowsHello
|
||||
import { shortcutService } from './services/shortcutService'
|
||||
import { httpApiService } from './services/httpApiService'
|
||||
import { getBestCachePath, getRuntimePlatformInfo } from './services/platformService'
|
||||
import { getMcpLaunchConfig as getMcpLaunchConfigForUi, getMcpProxyConfig } from './services/mcp/runtime'
|
||||
import { mcpProxyService } from './services/mcp/proxyService'
|
||||
|
||||
// 扩展 app 对象类型,添加 isQuitting 标志
|
||||
declare module 'electron' {
|
||||
@@ -60,30 +64,7 @@ protocol.registerSchemesAsPrivileged([
|
||||
// 配置自动更新
|
||||
autoUpdater.autoDownload = false
|
||||
autoUpdater.autoInstallOnAppQuit = true
|
||||
autoUpdater.disableDifferentialDownload = true // 禁用差分更新,强制全量下载
|
||||
|
||||
/**
|
||||
* 比较两个语义化版本号
|
||||
* @param version1 版本1
|
||||
* @param version2 版本2
|
||||
* @returns version1 > version2 返回 true
|
||||
*/
|
||||
function isNewerVersion(version1: string, version2: string): boolean {
|
||||
const v1Parts = version1.split('.').map(Number)
|
||||
const v2Parts = version2.split('.').map(Number)
|
||||
|
||||
// 补齐版本号位数
|
||||
const maxLength = Math.max(v1Parts.length, v2Parts.length)
|
||||
while (v1Parts.length < maxLength) v1Parts.push(0)
|
||||
while (v2Parts.length < maxLength) v2Parts.push(0)
|
||||
|
||||
for (let i = 0; i < maxLength; i++) {
|
||||
if (v1Parts[i] > v2Parts[i]) return true
|
||||
if (v1Parts[i] < v2Parts[i]) return false
|
||||
}
|
||||
|
||||
return false // 版本相同
|
||||
}
|
||||
autoUpdater.disableDifferentialDownload = true // 禁用差分更新,统一使用全量安装包
|
||||
|
||||
// 单例服务
|
||||
let dbService: DatabaseService | null = null
|
||||
@@ -93,6 +74,7 @@ let logService: LogService | null = null
|
||||
|
||||
// 系统托盘实例
|
||||
let tray: Tray | null = null
|
||||
let isInstallingUpdate = false
|
||||
|
||||
// 聊天窗口实例
|
||||
let chatWindow: BrowserWindow | null = null
|
||||
@@ -112,6 +94,65 @@ let aiSummaryWindow: BrowserWindow | null = null
|
||||
let welcomeWindow: BrowserWindow | null = null
|
||||
// 聊天记录窗口实例
|
||||
let chatHistoryWindow: BrowserWindow | null = null
|
||||
const allowDevTools = !!process.env.VITE_DEV_SERVER_URL
|
||||
|
||||
type ReleaseAnnouncementPayload = {
|
||||
version: string
|
||||
releaseBody?: string
|
||||
releaseNotes?: string
|
||||
generatedAt?: string
|
||||
}
|
||||
|
||||
function getReleaseAnnouncementPath(): string {
|
||||
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||
return isDev
|
||||
? join(__dirname, '../.tmp/release-announcement.json')
|
||||
: join(process.resourcesPath, 'release-announcement.json')
|
||||
}
|
||||
|
||||
function syncPackagedReleaseAnnouncement() {
|
||||
if (!configService) return
|
||||
|
||||
const announcementPath = getReleaseAnnouncementPath()
|
||||
if (!existsSync(announcementPath)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = readFileSync(announcementPath, 'utf8')
|
||||
const payload = JSON.parse(raw) as ReleaseAnnouncementPayload
|
||||
if (!payload || typeof payload !== 'object') return
|
||||
|
||||
const version = String(payload.version || '').trim()
|
||||
if (!version || version !== app.getVersion()) return
|
||||
|
||||
const releaseBody = String(payload.releaseBody || '').trim()
|
||||
const releaseNotes = String(payload.releaseNotes || '').trim()
|
||||
|
||||
const storedVersion = configService.get('releaseAnnouncementVersion')
|
||||
const storedBody = configService.get('releaseAnnouncementBody')
|
||||
const storedNotes = configService.get('releaseAnnouncementNotes')
|
||||
|
||||
if (
|
||||
storedVersion === version &&
|
||||
storedBody === releaseBody &&
|
||||
storedNotes === releaseNotes
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
configService.set('releaseAnnouncementVersion', version)
|
||||
configService.set('releaseAnnouncementBody', releaseBody)
|
||||
configService.set('releaseAnnouncementNotes', releaseNotes)
|
||||
logService?.info('ReleaseAnnouncement', '已同步本地版本公告', {
|
||||
version,
|
||||
hasBody: Boolean(releaseBody),
|
||||
hasNotes: Boolean(releaseNotes)
|
||||
})
|
||||
} catch (error) {
|
||||
logService?.warn('ReleaseAnnouncement', '同步本地版本公告失败', { error: String(error) })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前主题的 URL 查询参数
|
||||
@@ -203,6 +244,7 @@ function createWindow() {
|
||||
icon: iconPath,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, 'preload.js'),
|
||||
devTools: allowDevTools,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
webSecurity: false // 允许加载本地文件
|
||||
@@ -221,6 +263,26 @@ function createWindow() {
|
||||
dbService = new DatabaseService()
|
||||
|
||||
logService = new LogService(configService)
|
||||
syncPackagedReleaseAnnouncement()
|
||||
mcpProxyService.setLogger(logService)
|
||||
autoUpdater.logger = {
|
||||
info(message: string) {
|
||||
logService?.info('AppUpdate', message)
|
||||
appUpdateService.noteUpdaterMessage(String(message), 'info')
|
||||
},
|
||||
warn(message: string) {
|
||||
logService?.warn('AppUpdate', message)
|
||||
appUpdateService.noteUpdaterMessage(String(message), 'warn')
|
||||
},
|
||||
error(message: string) {
|
||||
logService?.error('AppUpdate', message)
|
||||
appUpdateService.noteUpdaterMessage(String(message), 'error')
|
||||
},
|
||||
debug(message: string) {
|
||||
logService?.debug('AppUpdate', message)
|
||||
appUpdateService.noteUpdaterMessage(String(message), 'info')
|
||||
}
|
||||
}
|
||||
|
||||
// 记录应用启动日志
|
||||
logService.info('App', '应用启动', { version: app.getVersion() })
|
||||
@@ -238,6 +300,17 @@ function createWindow() {
|
||||
|
||||
// 监听窗口关闭事件
|
||||
win.on('close', (event) => {
|
||||
const updateInfo = appUpdateService.getCachedUpdateInfo()
|
||||
if (updateInfo?.forceUpdate) {
|
||||
app.isQuitting = true
|
||||
return
|
||||
}
|
||||
|
||||
if (isInstallingUpdate) {
|
||||
app.isQuitting = true
|
||||
return
|
||||
}
|
||||
|
||||
// 如果是真正退出应用,不阻止
|
||||
if (app.isQuitting) {
|
||||
return
|
||||
@@ -314,6 +387,7 @@ function createChatWindow() {
|
||||
icon: iconPath,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, 'preload.js'),
|
||||
devTools: allowDevTools,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
webSecurity: false // 允许加载本地文件
|
||||
@@ -388,6 +462,7 @@ function createGroupAnalyticsWindow() {
|
||||
icon: iconPath,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, 'preload.js'),
|
||||
devTools: allowDevTools,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
webSecurity: false // 允许加载本地文件
|
||||
@@ -465,6 +540,7 @@ function createMomentsWindow(filterUsername?: string) {
|
||||
icon: iconPath,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, 'preload.js'),
|
||||
devTools: allowDevTools,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
webSecurity: false // 允许加载本地文件
|
||||
@@ -553,6 +629,7 @@ function createChatHistoryWindow(sessionId: string, messageId: number) {
|
||||
icon: iconPath,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, 'preload.js'),
|
||||
devTools: allowDevTools,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
webSecurity: false // 允许加载本地文件
|
||||
@@ -618,6 +695,7 @@ function createAnnualReportWindow(year: number) {
|
||||
icon: iconPath,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, 'preload.js'),
|
||||
devTools: allowDevTools,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
webSecurity: false // 允许加载本地文件
|
||||
@@ -689,6 +767,7 @@ function createAgreementWindow() {
|
||||
icon: iconPath,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, 'preload.js'),
|
||||
devTools: allowDevTools,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
webSecurity: false // 允许加载本地文件
|
||||
@@ -750,6 +829,7 @@ function createWelcomeWindow() {
|
||||
icon: iconPath,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, 'preload.js'),
|
||||
devTools: allowDevTools,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
webSecurity: false // 允许加载本地文件
|
||||
@@ -793,6 +873,7 @@ function createPurchaseWindow() {
|
||||
minHeight: 600,
|
||||
icon: iconPath,
|
||||
webPreferences: {
|
||||
devTools: allowDevTools,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
webSecurity: false // 允许加载本地文件
|
||||
@@ -838,6 +919,7 @@ function createImageViewerWindow(
|
||||
icon: iconPath,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, 'preload.js'),
|
||||
devTools: allowDevTools,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
webSecurity: false // 允许加载本地文件
|
||||
@@ -946,6 +1028,7 @@ function createVideoPlayerWindow(videoPath: string, videoWidth?: number, videoHe
|
||||
icon: iconPath,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, 'preload.js'),
|
||||
devTools: allowDevTools,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
webSecurity: false
|
||||
@@ -1008,6 +1091,7 @@ function createBrowserWindow(url: string, title?: string) {
|
||||
icon: iconPath,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, 'preload.js'),
|
||||
devTools: allowDevTools,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
webSecurity: false,
|
||||
@@ -1082,6 +1166,7 @@ function createAISummaryWindow(sessionId: string, sessionName: string) {
|
||||
icon: iconPath,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, 'preload.js'),
|
||||
devTools: allowDevTools,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
webSecurity: false // 允许加载本地文件
|
||||
@@ -1291,26 +1376,31 @@ function registerIpcHandlers() {
|
||||
return getRuntimePlatformInfo()
|
||||
})
|
||||
|
||||
ipcMain.handle('app:checkForUpdates', async () => {
|
||||
try {
|
||||
const result = await autoUpdater.checkForUpdates()
|
||||
if (result && result.updateInfo) {
|
||||
const currentVersion = app.getVersion()
|
||||
const latestVersion = result.updateInfo.version
|
||||
ipcMain.handle('app:getMcpLaunchConfig', async () => {
|
||||
return getMcpLaunchConfigForUi()
|
||||
})
|
||||
|
||||
// 使用语义化版本比较
|
||||
if (isNewerVersion(latestVersion, currentVersion)) {
|
||||
return {
|
||||
hasUpdate: true,
|
||||
version: latestVersion,
|
||||
releaseNotes: result.updateInfo.releaseNotes as string || ''
|
||||
}
|
||||
}
|
||||
}
|
||||
return { hasUpdate: false }
|
||||
} catch (error) {
|
||||
console.error('检查更新失败:', error)
|
||||
return { hasUpdate: false }
|
||||
ipcMain.on('app:getMcpLaunchConfig:request', (event, payload: { requestId?: string } | undefined) => {
|
||||
const requestId = payload?.requestId
|
||||
if (!requestId) return
|
||||
event.sender.send(`app:getMcpLaunchConfig:response:${requestId}`, getMcpLaunchConfigForUi())
|
||||
})
|
||||
|
||||
ipcMain.handle('app:checkForUpdates', async () => {
|
||||
return appUpdateService.checkForUpdates()
|
||||
})
|
||||
|
||||
ipcMain.handle('app:getUpdateState', async () => {
|
||||
return appUpdateService.getCachedUpdateInfo()
|
||||
})
|
||||
|
||||
ipcMain.handle('app:getUpdateSourceInfo', async () => {
|
||||
return {
|
||||
primaryUpdateSource: 'github' as const,
|
||||
githubRepository: appUpdateService.getGithubRepository(),
|
||||
policySources: ['github', 'custom'] as const,
|
||||
policyPrecedence: 'github' as const,
|
||||
forceUpdatePolicyFallbackUrl: appUpdateService.getForceUpdatePolicyFallbackUrl()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1342,21 +1432,93 @@ function registerIpcHandlers() {
|
||||
ipcMain.handle('app:downloadAndInstall', async (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
|
||||
// 监听下载进度
|
||||
autoUpdater.on('download-progress', (progress) => {
|
||||
win?.webContents.send('app:downloadProgress', progress.percent)
|
||||
})
|
||||
if (isInstallingUpdate) {
|
||||
logService?.warn('AppUpdate', '下载更新请求被忽略,当前已有下载任务进行中', {
|
||||
targetVersion: appUpdateService.getCachedUpdateInfo()?.version
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 下载完成后自动安装
|
||||
autoUpdater.on('update-downloaded', () => {
|
||||
autoUpdater.quitAndInstall(false, true)
|
||||
isInstallingUpdate = true
|
||||
const cachedUpdateInfo = appUpdateService.getCachedUpdateInfo()
|
||||
const targetVersion = cachedUpdateInfo?.version
|
||||
|
||||
appUpdateService.updateDiagnostics({
|
||||
phase: 'downloading',
|
||||
targetVersion,
|
||||
lastError: undefined,
|
||||
progressPercent: 0,
|
||||
downloadedBytes: 0,
|
||||
totalBytes: undefined,
|
||||
lastEvent: targetVersion ? `开始下载更新 ${targetVersion}` : '开始下载更新'
|
||||
})
|
||||
logService?.info('AppUpdate', '开始下载更新', { targetVersion, differentialEnabled: !autoUpdater.disableDifferentialDownload })
|
||||
|
||||
const onDownloadProgress = (progress: Electron.ProgressInfo) => {
|
||||
const payload = {
|
||||
percent: progress.percent,
|
||||
transferred: progress.transferred,
|
||||
total: progress.total,
|
||||
bytesPerSecond: progress.bytesPerSecond
|
||||
}
|
||||
BrowserWindow.getAllWindows().forEach(currentWindow => {
|
||||
currentWindow.webContents.send('app:downloadProgress', payload)
|
||||
})
|
||||
appUpdateService.updateDiagnostics({
|
||||
phase: 'downloading',
|
||||
progressPercent: progress.percent,
|
||||
downloadedBytes: progress.transferred,
|
||||
totalBytes: progress.total,
|
||||
lastEvent: `下载中 ${progress.percent.toFixed(1)}%`
|
||||
})
|
||||
}
|
||||
|
||||
const onUpdateDownloaded = () => {
|
||||
appUpdateService.updateDiagnostics({
|
||||
phase: 'downloaded',
|
||||
progressPercent: 100,
|
||||
lastEvent: '更新包下载完成,准备安装'
|
||||
})
|
||||
logService?.info('AppUpdate', '更新包下载完成,准备安装', {
|
||||
targetVersion,
|
||||
fallbackToFull: appUpdateService.getCachedUpdateInfo()?.diagnostics?.fallbackToFull || false
|
||||
})
|
||||
app.isQuitting = true
|
||||
appUpdateService.updateDiagnostics({
|
||||
phase: 'installing',
|
||||
lastEvent: '开始调用安装器'
|
||||
})
|
||||
autoUpdater.quitAndInstall(false, true)
|
||||
}
|
||||
|
||||
const onUpdaterError = (error: Error) => {
|
||||
isInstallingUpdate = false
|
||||
appUpdateService.updateDiagnostics({
|
||||
phase: 'failed',
|
||||
lastError: String(error),
|
||||
lastEvent: '下载或安装更新失败'
|
||||
})
|
||||
logService?.error('AppUpdate', '下载或安装更新失败', {
|
||||
targetVersion,
|
||||
error: String(error),
|
||||
fallbackToFull: appUpdateService.getCachedUpdateInfo()?.diagnostics?.fallbackToFull || false
|
||||
})
|
||||
}
|
||||
|
||||
autoUpdater.on('download-progress', onDownloadProgress)
|
||||
autoUpdater.once('update-downloaded', onUpdateDownloaded)
|
||||
autoUpdater.once('error', onUpdaterError)
|
||||
|
||||
try {
|
||||
await autoUpdater.downloadUpdate()
|
||||
} catch (error) {
|
||||
console.error('下载更新失败:', error)
|
||||
isInstallingUpdate = false
|
||||
onUpdaterError(error as Error)
|
||||
throw error
|
||||
} finally {
|
||||
autoUpdater.removeListener('download-progress', onDownloadProgress)
|
||||
autoUpdater.removeListener('update-downloaded', onUpdateDownloaded)
|
||||
autoUpdater.removeListener('error', onUpdaterError)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -3601,6 +3763,7 @@ function createSplashWindow(): BrowserWindow {
|
||||
show: true, // 直接显示窗口
|
||||
webPreferences: {
|
||||
preload: join(__dirname, 'preload.js'),
|
||||
devTools: allowDevTools,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
webSecurity: false // 允许加载本地文件
|
||||
@@ -3767,21 +3930,18 @@ function checkForUpdatesOnStartup() {
|
||||
// 延迟3秒检测,等待窗口完全加载
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const result = await autoUpdater.checkForUpdates()
|
||||
if (result && result.updateInfo) {
|
||||
const currentVersion = app.getVersion()
|
||||
const latestVersion = result.updateInfo.version
|
||||
|
||||
// 使用语义化版本比较
|
||||
if (isNewerVersion(latestVersion, currentVersion) && mainWindow) {
|
||||
// 通知渲染进程有新版本
|
||||
mainWindow.webContents.send('app:updateAvailable', {
|
||||
version: latestVersion,
|
||||
releaseNotes: result.updateInfo.releaseNotes || ''
|
||||
})
|
||||
}
|
||||
const result = await appUpdateService.checkForUpdates()
|
||||
logService?.info('AppUpdate', '启动时检查更新完成', {
|
||||
hasUpdate: result.hasUpdate,
|
||||
currentVersion: result.currentVersion,
|
||||
version: result.version,
|
||||
diagnostics: result.diagnostics
|
||||
})
|
||||
if (result.hasUpdate && mainWindow) {
|
||||
mainWindow.webContents.send('app:updateAvailable', result)
|
||||
}
|
||||
} catch (error) {
|
||||
logService?.error('AppUpdate', '启动时检查更新失败', { error: String(error) })
|
||||
console.error('启动时检查更新失败:', error)
|
||||
}
|
||||
}, 3000)
|
||||
@@ -3799,6 +3959,14 @@ app.on('certificate-error', (event, webContents, url, error, certificate, callba
|
||||
})
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
if (!configService) {
|
||||
configService = new ConfigService()
|
||||
}
|
||||
|
||||
if (!configService.get('mcpProxyToken')) {
|
||||
configService.set('mcpProxyToken', randomBytes(24).toString('hex'))
|
||||
}
|
||||
|
||||
// 注册自定义协议用于加载本地视频
|
||||
protocol.handle('local-video', (request) => {
|
||||
// 移除协议前缀并解码
|
||||
@@ -3892,6 +4060,18 @@ app.whenReady().then(async () => {
|
||||
console.error('[HttpApi] 启动失败:', httpApiStartResult.error)
|
||||
}
|
||||
|
||||
const mcpProxyConfig = getMcpProxyConfig(configService)
|
||||
mcpProxyService.applySettings({
|
||||
host: mcpProxyConfig.host,
|
||||
port: mcpProxyConfig.port,
|
||||
token: mcpProxyConfig.token
|
||||
})
|
||||
const mcpProxyStartResult = await mcpProxyService.start()
|
||||
if (!mcpProxyStartResult.success) {
|
||||
console.error('[McpProxy] 启动失败:', mcpProxyStartResult.error)
|
||||
logService?.error('McpProxy', '内部 MCP 代理启动失败', { error: mcpProxyStartResult.error })
|
||||
}
|
||||
|
||||
// 只有在配置完整时才创建主窗口
|
||||
// 如果配置不完整,checkAndConnectOnStartup 会创建引导窗口
|
||||
if (shouldShowSplash !== false || configService?.get('myWxid')) {
|
||||
@@ -3933,6 +4113,9 @@ app.on('before-quit', () => {
|
||||
httpApiService.stop().catch((e) => {
|
||||
console.error('[HttpApi] 停止失败:', e)
|
||||
})
|
||||
mcpProxyService.stop().catch((e) => {
|
||||
console.error('[McpProxy] 停止失败:', e)
|
||||
})
|
||||
// 关闭配置数据库连接
|
||||
configService?.close()
|
||||
|
||||
|
||||
3
electron/mcp.ts
Normal file
3
electron/mcp.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { bootstrapCipherTalkMcpServer } from './services/mcp/bootstrap'
|
||||
|
||||
void bootstrapCipherTalkMcpServer()
|
||||
@@ -1,5 +1,28 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
|
||||
function getMcpLaunchConfigSafe(): Promise<{
|
||||
command: string
|
||||
args: string[]
|
||||
cwd: string
|
||||
mode: 'dev' | 'packaged'
|
||||
} | null> {
|
||||
return new Promise((resolve) => {
|
||||
const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
const responseChannel = `app:getMcpLaunchConfig:response:${requestId}`
|
||||
const timeout = setTimeout(() => {
|
||||
ipcRenderer.removeAllListeners(responseChannel)
|
||||
resolve(null)
|
||||
}, 600)
|
||||
|
||||
ipcRenderer.once(responseChannel, (_, payload) => {
|
||||
clearTimeout(timeout)
|
||||
resolve(payload ?? null)
|
||||
})
|
||||
|
||||
ipcRenderer.send('app:getMcpLaunchConfig:request', { requestId })
|
||||
})
|
||||
}
|
||||
|
||||
// 暴露给渲染进程的 API
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
// 配置
|
||||
@@ -48,15 +71,37 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
getDownloadsPath: () => ipcRenderer.invoke('app:getDownloadsPath'),
|
||||
getVersion: () => ipcRenderer.invoke('app:getVersion'),
|
||||
getPlatformInfo: () => ipcRenderer.invoke('app:getPlatformInfo'),
|
||||
getMcpLaunchConfig: () => getMcpLaunchConfigSafe(),
|
||||
getUpdateState: () => ipcRenderer.invoke('app:getUpdateState'),
|
||||
getUpdateSourceInfo: () => ipcRenderer.invoke('app:getUpdateSourceInfo'),
|
||||
getMcpLaunchConfig: () => getMcpLaunchConfigSafe(),
|
||||
checkForUpdates: () => ipcRenderer.invoke('app:checkForUpdates'),
|
||||
downloadAndInstall: () => ipcRenderer.invoke('app:downloadAndInstall'),
|
||||
getStartupDbConnected: () => ipcRenderer.invoke('app:getStartupDbConnected'),
|
||||
setAppIcon: (iconName: string) => ipcRenderer.invoke('app:setAppIcon', iconName),
|
||||
onDownloadProgress: (callback: (progress: number) => void) => {
|
||||
onDownloadProgress: (callback: (progress: {
|
||||
percent: number
|
||||
transferred: number
|
||||
total: number
|
||||
bytesPerSecond: number
|
||||
}) => void) => {
|
||||
ipcRenderer.on('app:downloadProgress', (_, progress) => callback(progress))
|
||||
return () => ipcRenderer.removeAllListeners('app:downloadProgress')
|
||||
},
|
||||
onUpdateAvailable: (callback: (info: { version: string; releaseNotes: string }) => void) => {
|
||||
onUpdateAvailable: (callback: (info: {
|
||||
hasUpdate: boolean
|
||||
forceUpdate: boolean
|
||||
currentVersion: string
|
||||
version?: string
|
||||
releaseNotes?: string
|
||||
title?: string
|
||||
message?: string
|
||||
minimumSupportedVersion?: string
|
||||
reason?: 'minimum-version' | 'blocked-version'
|
||||
checkedAt: number
|
||||
updateSource: 'github' | 'custom' | 'none'
|
||||
policySource: 'github' | 'custom' | 'none'
|
||||
}) => void) => {
|
||||
ipcRenderer.on('app:updateAvailable', (_, info) => callback(info))
|
||||
return () => ipcRenderer.removeAllListeners('app:updateAvailable')
|
||||
}
|
||||
|
||||
@@ -36,6 +36,11 @@ export interface ContactRanking {
|
||||
lastMessageTime: number | null
|
||||
}
|
||||
|
||||
type TimeRangeFilter = {
|
||||
startTimeSec?: number
|
||||
endTimeSec?: number
|
||||
}
|
||||
|
||||
class AnalyticsService {
|
||||
private configService: ConfigService
|
||||
private messageDbCache: Map<string, Database.Database> = new Map()
|
||||
@@ -214,6 +219,39 @@ class AnalyticsService {
|
||||
}
|
||||
}
|
||||
|
||||
private toTimestampSeconds(value?: number | null): number | undefined {
|
||||
if (!value || !Number.isFinite(value) || value <= 0) return undefined
|
||||
return value >= 1_000_000_000_000 ? Math.floor(value / 1000) : Math.floor(value)
|
||||
}
|
||||
|
||||
private normalizeTimeRange(startTime?: number, endTime?: number): TimeRangeFilter {
|
||||
const startTimeSec = this.toTimestampSeconds(startTime)
|
||||
const endTimeSec = this.toTimestampSeconds(endTime)
|
||||
|
||||
if (startTimeSec && endTimeSec && startTimeSec > endTimeSec) {
|
||||
return {
|
||||
startTimeSec: endTimeSec,
|
||||
endTimeSec: startTimeSec
|
||||
}
|
||||
}
|
||||
|
||||
return { startTimeSec, endTimeSec }
|
||||
}
|
||||
|
||||
private buildTimeWhereClause(range: TimeRangeFilter, columnName: string = 'create_time'): string {
|
||||
const clauses: string[] = []
|
||||
|
||||
if (range.startTimeSec) {
|
||||
clauses.push(`${columnName} >= ${range.startTimeSec}`)
|
||||
}
|
||||
|
||||
if (range.endTimeSec) {
|
||||
clauses.push(`${columnName} <= ${range.endTimeSec}`)
|
||||
}
|
||||
|
||||
return clauses.length > 0 ? ` WHERE ${clauses.join(' AND ')}` : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为私聊会话(排除群聊、公众号、系统账号等)
|
||||
*/
|
||||
@@ -266,7 +304,7 @@ class AnalyticsService {
|
||||
}
|
||||
|
||||
|
||||
async getOverallStatistics(): Promise<{ success: boolean; data?: ChatStatistics; error?: string }> {
|
||||
async getOverallStatistics(startTime?: number, endTime?: number): Promise<{ success: boolean; data?: ChatStatistics; error?: string }> {
|
||||
try {
|
||||
const wxid = this.configService.get('myWxid')
|
||||
if (!wxid) {
|
||||
@@ -303,6 +341,8 @@ class AnalyticsService {
|
||||
const getTableHash = (username: string) => {
|
||||
return crypto.createHash('md5').update(username).digest('hex')
|
||||
}
|
||||
const timeRange = this.normalizeTimeRange(startTime, endTime)
|
||||
const timeWhere = this.buildTimeWhereClause(timeRange)
|
||||
|
||||
// 构建私聊表名的 hash 集合
|
||||
const privateTableHashes = new Set(privateUsernames.map(u => getTableHash(u)))
|
||||
@@ -356,7 +396,7 @@ class AnalyticsService {
|
||||
SUM(CASE WHEN real_sender_id != ${myRowId} THEN 1 ELSE 0 END) as received_count,
|
||||
MIN(create_time) as first_time,
|
||||
MAX(create_time) as last_time
|
||||
FROM "${tableName}"
|
||||
FROM "${tableName}"${timeWhere}
|
||||
`
|
||||
} else {
|
||||
statsQuery = `
|
||||
@@ -371,7 +411,7 @@ class AnalyticsService {
|
||||
SUM(CASE WHEN is_send = 0 OR is_send IS NULL THEN 1 ELSE 0 END) as received_count,
|
||||
MIN(create_time) as first_time,
|
||||
MAX(create_time) as last_time
|
||||
FROM "${tableName}"
|
||||
FROM "${tableName}"${timeWhere}
|
||||
`
|
||||
}
|
||||
|
||||
@@ -401,7 +441,7 @@ class AnalyticsService {
|
||||
// 收集该会话的所有活跃日期
|
||||
const dates = db.prepare(`
|
||||
SELECT DISTINCT date(create_time, 'unixepoch', 'localtime') as day
|
||||
FROM "${tableName}"
|
||||
FROM "${tableName}"${timeWhere}
|
||||
`).all() as { day: string }[]
|
||||
|
||||
for (const { day } of dates) {
|
||||
@@ -411,6 +451,7 @@ class AnalyticsService {
|
||||
const typeCounts = db.prepare(`
|
||||
SELECT local_type, COUNT(*) as count
|
||||
FROM "${tableName}"
|
||||
${timeWhere ? timeWhere : ''}
|
||||
GROUP BY local_type
|
||||
`).all() as { local_type: number; count: number }[]
|
||||
|
||||
@@ -450,7 +491,7 @@ class AnalyticsService {
|
||||
}
|
||||
|
||||
|
||||
async getContactRankings(limit: number = 20): Promise<{ success: boolean; data?: ContactRanking[]; error?: string }> {
|
||||
async getContactRankings(limit: number = 20, startTime?: number, endTime?: number): Promise<{ success: boolean; data?: ContactRanking[]; error?: string }> {
|
||||
try {
|
||||
const wxid = this.configService.get('myWxid')
|
||||
if (!wxid) {
|
||||
@@ -492,6 +533,8 @@ class AnalyticsService {
|
||||
const getTableHash = (username: string) => {
|
||||
return crypto.createHash('md5').update(username).digest('hex')
|
||||
}
|
||||
const timeRange = this.normalizeTimeRange(startTime, endTime)
|
||||
const timeWhere = this.buildTimeWhereClause(timeRange)
|
||||
|
||||
for (const username of privateUsernames) {
|
||||
const tableHash = getTableHash(username)
|
||||
@@ -521,7 +564,7 @@ class AnalyticsService {
|
||||
SUM(CASE WHEN real_sender_id = ${myRowId} THEN 1 ELSE 0 END) as sent_count,
|
||||
SUM(CASE WHEN real_sender_id != ${myRowId} THEN 1 ELSE 0 END) as received_count,
|
||||
MAX(create_time) as last_time
|
||||
FROM "${tableName}"
|
||||
FROM "${tableName}"${timeWhere}
|
||||
`
|
||||
} else {
|
||||
statsQuery = `
|
||||
@@ -530,7 +573,7 @@ class AnalyticsService {
|
||||
SUM(CASE WHEN is_send = 1 THEN 1 ELSE 0 END) as sent_count,
|
||||
SUM(CASE WHEN is_send = 0 OR is_send IS NULL THEN 1 ELSE 0 END) as received_count,
|
||||
MAX(create_time) as last_time
|
||||
FROM "${tableName}"
|
||||
FROM "${tableName}"${timeWhere}
|
||||
`
|
||||
}
|
||||
|
||||
@@ -615,7 +658,11 @@ class AnalyticsService {
|
||||
lastMessageTime: stats.lastMessageTime
|
||||
}
|
||||
})
|
||||
.sort((a, b) => b.messageCount - a.messageCount)
|
||||
.sort((a, b) => {
|
||||
const messageCountDelta = b.messageCount - a.messageCount
|
||||
if (messageCountDelta !== 0) return messageCountDelta
|
||||
return (b.lastMessageTime || 0) - (a.lastMessageTime || 0)
|
||||
})
|
||||
.slice(0, limit)
|
||||
|
||||
return { success: true, data: rankings }
|
||||
@@ -625,7 +672,7 @@ class AnalyticsService {
|
||||
}
|
||||
|
||||
|
||||
async getTimeDistribution(): Promise<{ success: boolean; data?: TimeDistribution; error?: string }> {
|
||||
async getTimeDistribution(startTime?: number, endTime?: number): Promise<{ success: boolean; data?: TimeDistribution; error?: string }> {
|
||||
try {
|
||||
const wxid = this.configService.get('myWxid')
|
||||
if (!wxid) {
|
||||
@@ -658,6 +705,8 @@ class AnalyticsService {
|
||||
}
|
||||
|
||||
const privateTableHashes = new Set(privateUsernames.map(u => getTableHash(u)))
|
||||
const timeRange = this.normalizeTimeRange(startTime, endTime)
|
||||
const timeWhere = this.buildTimeWhereClause(timeRange)
|
||||
|
||||
const dbFiles = this.findMessageDbFiles(dbDir)
|
||||
|
||||
@@ -689,7 +738,7 @@ class AnalyticsService {
|
||||
SELECT
|
||||
CAST(strftime('%H', create_time, 'unixepoch', 'localtime') AS INTEGER) as hour,
|
||||
COUNT(*) as count
|
||||
FROM "${tableName}"
|
||||
FROM "${tableName}"${timeWhere}
|
||||
GROUP BY hour
|
||||
`).all() as { hour: number; count: number }[]
|
||||
|
||||
@@ -701,7 +750,7 @@ class AnalyticsService {
|
||||
SELECT
|
||||
CAST(strftime('%w', create_time, 'unixepoch', 'localtime') AS INTEGER) as dow,
|
||||
COUNT(*) as count
|
||||
FROM "${tableName}"
|
||||
FROM "${tableName}"${timeWhere}
|
||||
GROUP BY dow
|
||||
`).all() as { dow: number; count: number }[]
|
||||
|
||||
@@ -714,7 +763,7 @@ class AnalyticsService {
|
||||
SELECT
|
||||
strftime('%Y-%m', create_time, 'unixepoch', 'localtime') as month,
|
||||
COUNT(*) as count
|
||||
FROM "${tableName}"
|
||||
FROM "${tableName}"${timeWhere}
|
||||
GROUP BY month
|
||||
`).all() as { month: string; count: number }[]
|
||||
|
||||
|
||||
299
electron/services/appUpdateService.ts
Normal file
299
electron/services/appUpdateService.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import { app } from 'electron'
|
||||
import { autoUpdater } from 'electron-updater'
|
||||
|
||||
const GITHUB_OWNER = 'ILoveBingLu'
|
||||
const GITHUB_REPO = 'CipherTalk'
|
||||
const GITHUB_FORCE_UPDATE_URL = `https://github.com/${GITHUB_OWNER}/${GITHUB_REPO}/releases/latest/download/force-update.json`
|
||||
const FORCE_UPDATE_POLICY_FALLBACK_URL = 'https://miyuapp.aiqji.com'
|
||||
|
||||
export type ForceUpdateReason = 'minimum-version' | 'blocked-version'
|
||||
export type AppUpdateSource = 'github' | 'custom' | 'none'
|
||||
export type UpdateDownloadPhase = 'idle' | 'checking' | 'available' | 'downloading' | 'downloaded' | 'installing' | 'failed'
|
||||
export type UpdateDownloadStrategy = 'unknown' | 'differential' | 'full'
|
||||
|
||||
export interface ForceUpdateManifest {
|
||||
schemaVersion: number
|
||||
latestVersion?: string
|
||||
minimumSupportedVersion?: string
|
||||
blockedVersions?: string[]
|
||||
title?: string
|
||||
message?: string
|
||||
releaseNotes?: string
|
||||
publishedAt?: string
|
||||
}
|
||||
|
||||
export interface AppUpdateInfo {
|
||||
hasUpdate: boolean
|
||||
forceUpdate: boolean
|
||||
currentVersion: string
|
||||
version?: string
|
||||
releaseNotes?: string
|
||||
title?: string
|
||||
message?: string
|
||||
minimumSupportedVersion?: string
|
||||
reason?: ForceUpdateReason
|
||||
checkedAt: number
|
||||
updateSource: AppUpdateSource
|
||||
policySource: AppUpdateSource
|
||||
diagnostics?: UpdateDiagnostics
|
||||
}
|
||||
|
||||
export interface UpdateDiagnostics {
|
||||
phase: UpdateDownloadPhase
|
||||
strategy: UpdateDownloadStrategy
|
||||
fallbackToFull: boolean
|
||||
lastError?: string
|
||||
lastEvent?: string
|
||||
progressPercent?: number
|
||||
downloadedBytes?: number
|
||||
totalBytes?: number
|
||||
targetVersion?: string
|
||||
lastUpdatedAt: number
|
||||
}
|
||||
|
||||
type ManifestLookupResult = {
|
||||
manifest: ForceUpdateManifest | null
|
||||
source: AppUpdateSource
|
||||
}
|
||||
|
||||
function isNewerVersion(version1: string, version2: string): boolean {
|
||||
const v1Parts = version1.split('.').map(Number)
|
||||
const v2Parts = version2.split('.').map(Number)
|
||||
const maxLength = Math.max(v1Parts.length, v2Parts.length)
|
||||
while (v1Parts.length < maxLength) v1Parts.push(0)
|
||||
while (v2Parts.length < maxLength) v2Parts.push(0)
|
||||
|
||||
for (let i = 0; i < maxLength; i++) {
|
||||
if (v1Parts[i] > v2Parts[i]) return true
|
||||
if (v1Parts[i] < v2Parts[i]) return false
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function isVersionEqual(version1: string, version2: string): boolean {
|
||||
return !isNewerVersion(version1, version2) && !isNewerVersion(version2, version1)
|
||||
}
|
||||
|
||||
function normalizeReleaseNotes(value: unknown): string {
|
||||
if (!value) return ''
|
||||
if (typeof value === 'string') return value
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => {
|
||||
if (typeof item === 'string') return item
|
||||
if (item && typeof item === 'object' && 'note' in item) {
|
||||
return String((item as { note?: unknown }).note || '')
|
||||
}
|
||||
return String(item)
|
||||
}).filter(Boolean).join('\n\n')
|
||||
}
|
||||
if (value && typeof value === 'object' && 'note' in value) {
|
||||
return String((value as { note?: unknown }).note || '')
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
async function fetchManifestFromUrl(url: string): Promise<ForceUpdateManifest | null> {
|
||||
try {
|
||||
const response = await fetch(`${url}${url.includes('?') ? '&' : '?'}t=${Date.now()}`, {
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
}
|
||||
})
|
||||
if (!response.ok) return null
|
||||
|
||||
const data = await response.json() as ForceUpdateManifest
|
||||
if (!data || typeof data !== 'object') return null
|
||||
if (Number(data.schemaVersion || 0) < 1) return null
|
||||
return data
|
||||
} catch (error) {
|
||||
console.warn('[AppUpdate] 获取策略文件失败:', url, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveForceUpdateManifest(): Promise<ManifestLookupResult> {
|
||||
const githubManifest = await fetchManifestFromUrl(GITHUB_FORCE_UPDATE_URL)
|
||||
if (githubManifest) {
|
||||
return { manifest: githubManifest, source: 'github' }
|
||||
}
|
||||
|
||||
const fallbackUrl = `${FORCE_UPDATE_POLICY_FALLBACK_URL.replace(/\/+$/, '')}/force-update.json`
|
||||
const customManifest = await fetchManifestFromUrl(fallbackUrl)
|
||||
if (customManifest) {
|
||||
return { manifest: customManifest, source: 'custom' }
|
||||
}
|
||||
|
||||
return { manifest: null, source: 'none' }
|
||||
}
|
||||
|
||||
class AppUpdateService {
|
||||
private lastInfo: AppUpdateInfo | null = null
|
||||
private diagnostics: UpdateDiagnostics = {
|
||||
phase: 'idle',
|
||||
strategy: 'unknown',
|
||||
fallbackToFull: false,
|
||||
lastUpdatedAt: Date.now()
|
||||
}
|
||||
|
||||
getCachedUpdateInfo(): AppUpdateInfo | null {
|
||||
return this.lastInfo
|
||||
}
|
||||
|
||||
getForceUpdatePolicyFallbackUrl(): string {
|
||||
return FORCE_UPDATE_POLICY_FALLBACK_URL
|
||||
}
|
||||
|
||||
getGithubRepository(): { owner: string; repo: string } {
|
||||
return {
|
||||
owner: GITHUB_OWNER,
|
||||
repo: GITHUB_REPO
|
||||
}
|
||||
}
|
||||
|
||||
private buildInfo(payload: Partial<AppUpdateInfo>): AppUpdateInfo {
|
||||
return {
|
||||
hasUpdate: false,
|
||||
forceUpdate: false,
|
||||
currentVersion: app.getVersion(),
|
||||
checkedAt: Date.now(),
|
||||
updateSource: 'none',
|
||||
policySource: 'none',
|
||||
diagnostics: this.diagnostics,
|
||||
...payload
|
||||
}
|
||||
}
|
||||
|
||||
resetDiagnostics(targetVersion?: string): void {
|
||||
this.diagnostics = {
|
||||
phase: 'idle',
|
||||
strategy: 'unknown',
|
||||
fallbackToFull: false,
|
||||
targetVersion,
|
||||
lastUpdatedAt: Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
updateDiagnostics(patch: Partial<UpdateDiagnostics>): void {
|
||||
this.diagnostics = {
|
||||
...this.diagnostics,
|
||||
...patch,
|
||||
lastUpdatedAt: Date.now()
|
||||
}
|
||||
|
||||
if (this.lastInfo) {
|
||||
this.lastInfo = {
|
||||
...this.lastInfo,
|
||||
diagnostics: this.diagnostics
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
noteUpdaterMessage(message: string, level: 'info' | 'warn' | 'error' = 'info'): void {
|
||||
const normalized = message.toLowerCase()
|
||||
const patch: Partial<UpdateDiagnostics> = { lastEvent: message }
|
||||
|
||||
if (normalized.includes('differential')) {
|
||||
patch.strategy = 'differential'
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.includes('fallback to full') ||
|
||||
normalized.includes('fallback to full download') ||
|
||||
normalized.includes('cannot download differentially') ||
|
||||
normalized.includes('cannot download differentially, fallback to full download')
|
||||
) {
|
||||
patch.strategy = 'full'
|
||||
patch.fallbackToFull = true
|
||||
patch.lastEvent = '差分更新失败,已回退到全量下载'
|
||||
if (this.diagnostics.phase === 'idle') {
|
||||
patch.phase = 'downloading'
|
||||
}
|
||||
}
|
||||
|
||||
if (level === 'error') {
|
||||
patch.lastError = message
|
||||
if (this.diagnostics.phase !== 'downloaded' && this.diagnostics.phase !== 'installing') {
|
||||
patch.phase = 'failed'
|
||||
}
|
||||
}
|
||||
|
||||
this.updateDiagnostics(patch)
|
||||
}
|
||||
|
||||
async checkForUpdates(): Promise<AppUpdateInfo> {
|
||||
const currentVersion = app.getVersion()
|
||||
let latestVersion: string | undefined
|
||||
let releaseNotes = ''
|
||||
let hasUpdate = false
|
||||
let updateSource: AppUpdateSource = 'none'
|
||||
|
||||
this.resetDiagnostics()
|
||||
this.updateDiagnostics({
|
||||
phase: 'checking',
|
||||
lastEvent: '开始检查更新'
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await autoUpdater.checkForUpdates()
|
||||
if (result?.updateInfo?.version) {
|
||||
latestVersion = result.updateInfo.version
|
||||
releaseNotes = normalizeReleaseNotes(result.updateInfo.releaseNotes)
|
||||
hasUpdate = isNewerVersion(latestVersion, currentVersion)
|
||||
updateSource = hasUpdate ? 'github' : 'none'
|
||||
this.updateDiagnostics({
|
||||
phase: hasUpdate ? 'available' : 'idle',
|
||||
targetVersion: latestVersion,
|
||||
lastEvent: hasUpdate ? `检测到新版本 ${latestVersion}` : '当前已是最新版本'
|
||||
})
|
||||
} else {
|
||||
this.updateDiagnostics({
|
||||
phase: 'idle',
|
||||
lastEvent: '未获取到远端版本信息'
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
this.updateDiagnostics({
|
||||
phase: 'failed',
|
||||
lastError: String(error),
|
||||
lastEvent: '检查更新失败'
|
||||
})
|
||||
console.error('[AppUpdate] 检查 GitHub 更新失败:', error)
|
||||
}
|
||||
|
||||
const { manifest, source: policySource } = await resolveForceUpdateManifest()
|
||||
let forceUpdate = false
|
||||
let reason: ForceUpdateReason | undefined
|
||||
|
||||
if (manifest?.minimumSupportedVersion && isNewerVersion(manifest.minimumSupportedVersion, currentVersion)) {
|
||||
forceUpdate = true
|
||||
reason = 'minimum-version'
|
||||
} else if (manifest?.blockedVersions?.some((version) => isVersionEqual(currentVersion, version))) {
|
||||
forceUpdate = true
|
||||
reason = 'blocked-version'
|
||||
}
|
||||
|
||||
const finalVersion = latestVersion || manifest?.latestVersion
|
||||
const finalReleaseNotes = releaseNotes || manifest?.releaseNotes || ''
|
||||
|
||||
const info = this.buildInfo({
|
||||
hasUpdate: hasUpdate || forceUpdate,
|
||||
forceUpdate,
|
||||
currentVersion,
|
||||
version: finalVersion,
|
||||
releaseNotes: finalReleaseNotes,
|
||||
title: manifest?.title || (forceUpdate ? '必须更新到最新版本' : undefined),
|
||||
message: manifest?.message,
|
||||
minimumSupportedVersion: manifest?.minimumSupportedVersion,
|
||||
reason,
|
||||
updateSource,
|
||||
policySource
|
||||
})
|
||||
|
||||
this.lastInfo = info
|
||||
return info
|
||||
}
|
||||
}
|
||||
|
||||
export const appUpdateService = new AppUpdateService()
|
||||
@@ -5,8 +5,8 @@ import * as path from 'path'
|
||||
import * as https from 'https'
|
||||
import * as http from 'http'
|
||||
import * as fzstd from 'fzstd'
|
||||
import { app } from 'electron'
|
||||
import { ConfigService } from './config'
|
||||
import { getAppPath, getDocumentsPath, getExePath, isElectronPackaged } from './runtimePaths'
|
||||
|
||||
export interface ChatSession {
|
||||
username: string
|
||||
@@ -27,6 +27,7 @@ export interface ContactInfo {
|
||||
nickname?: string
|
||||
avatarUrl?: string
|
||||
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
|
||||
lastContactTime?: number
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
@@ -100,6 +101,22 @@ export interface Contact {
|
||||
nickName: string
|
||||
}
|
||||
|
||||
function compareMessageCursorAsc(
|
||||
a: Pick<Message, 'sortSeq' | 'createTime' | 'localId'>,
|
||||
b: Pick<Message, 'sortSeq' | 'createTime' | 'localId'>
|
||||
): number {
|
||||
return Number(a.sortSeq || 0) - Number(b.sortSeq || 0)
|
||||
|| Number(a.createTime || 0) - Number(b.createTime || 0)
|
||||
|| Number(a.localId || 0) - Number(b.localId || 0)
|
||||
}
|
||||
|
||||
function compareMessageCursorDesc(
|
||||
a: Pick<Message, 'sortSeq' | 'createTime' | 'localId'>,
|
||||
b: Pick<Message, 'sortSeq' | 'createTime' | 'localId'>
|
||||
): number {
|
||||
return compareMessageCursorAsc(b, a)
|
||||
}
|
||||
|
||||
// 表情包缓存
|
||||
const emojiCache: Map<string, string> = new Map()
|
||||
const emojiDownloading: Map<string, Promise<string | null>> = new Map()
|
||||
@@ -258,19 +275,19 @@ class ChatService extends EventEmitter {
|
||||
|
||||
// 开发环境使用文档目录
|
||||
if (process.env.VITE_DEV_SERVER_URL) {
|
||||
const documentsPath = app.getPath('documents')
|
||||
const documentsPath = getDocumentsPath()
|
||||
return path.join(documentsPath, 'CipherTalkData')
|
||||
}
|
||||
|
||||
// 生产环境
|
||||
const exePath = app.getPath('exe')
|
||||
const exePath = getExePath()
|
||||
const installDir = path.dirname(exePath)
|
||||
|
||||
// 检查是否安装在 C 盘
|
||||
const isOnCDrive = /^[cC]:/i.test(installDir) || installDir.startsWith('\\')
|
||||
|
||||
if (isOnCDrive) {
|
||||
const documentsPath = app.getPath('documents')
|
||||
const documentsPath = getDocumentsPath()
|
||||
return path.join(documentsPath, 'CipherTalkData')
|
||||
}
|
||||
|
||||
@@ -1001,16 +1018,16 @@ class ChatService extends EventEmitter {
|
||||
n.user_name AS sender_username
|
||||
FROM ${tableName} m
|
||||
LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid
|
||||
ORDER BY m.sort_seq DESC
|
||||
ORDER BY m.sort_seq DESC, m.create_time DESC, m.local_id DESC
|
||||
LIMIT ? OFFSET ?`
|
||||
} else if (hasName2Id) {
|
||||
sql = `SELECT m.*, n.user_name AS sender_username
|
||||
FROM ${tableName} m
|
||||
LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid
|
||||
ORDER BY m.sort_seq DESC
|
||||
ORDER BY m.sort_seq DESC, m.create_time DESC, m.local_id DESC
|
||||
LIMIT ? OFFSET ?`
|
||||
} else {
|
||||
sql = `SELECT * FROM ${tableName} ORDER BY sort_seq DESC LIMIT ? OFFSET ?`
|
||||
sql = `SELECT * FROM ${tableName} ORDER BY sort_seq DESC, create_time DESC, local_id DESC LIMIT ? OFFSET ?`
|
||||
}
|
||||
|
||||
const stmt = db.prepare(sql)
|
||||
@@ -1248,7 +1265,7 @@ class ChatService extends EventEmitter {
|
||||
}
|
||||
|
||||
// 按 sort_seq 降序排序(最新的在前)
|
||||
allMessages.sort((a, b) => b.sortSeq - a.sortSeq)
|
||||
allMessages.sort(compareMessageCursorDesc)
|
||||
|
||||
// 去重(同一条消息可能在多个数据库中)
|
||||
const seen = new Set<string>()
|
||||
@@ -1363,7 +1380,7 @@ class ChatService extends EventEmitter {
|
||||
OR (m.sort_seq = ? AND m.create_time < ?)
|
||||
OR (m.sort_seq = ? AND m.create_time = ? AND m.local_id < ?)
|
||||
)
|
||||
ORDER BY m.sort_seq DESC
|
||||
ORDER BY m.sort_seq DESC, m.create_time DESC, m.local_id DESC
|
||||
LIMIT ?`
|
||||
rows = db.prepare(sql).all(
|
||||
myRowId,
|
||||
@@ -1384,7 +1401,7 @@ class ChatService extends EventEmitter {
|
||||
OR (m.sort_seq = ? AND m.create_time < ?)
|
||||
OR (m.sort_seq = ? AND m.create_time = ? AND m.local_id < ?)
|
||||
)
|
||||
ORDER BY m.sort_seq DESC
|
||||
ORDER BY m.sort_seq DESC, m.create_time DESC, m.local_id DESC
|
||||
LIMIT ?`
|
||||
rows = db.prepare(sql).all(
|
||||
cursorSortSeq,
|
||||
@@ -1402,7 +1419,7 @@ class ChatService extends EventEmitter {
|
||||
OR (sort_seq = ? AND create_time < ?)
|
||||
OR (sort_seq = ? AND create_time = ? AND local_id < ?)
|
||||
)
|
||||
ORDER BY sort_seq DESC
|
||||
ORDER BY sort_seq DESC, create_time DESC, local_id DESC
|
||||
LIMIT ?`
|
||||
rows = db.prepare(sql).all(
|
||||
cursorSortSeq,
|
||||
@@ -1536,7 +1553,7 @@ class ChatService extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
allMessages.sort((a, b) => b.sortSeq - a.sortSeq)
|
||||
allMessages.sort(compareMessageCursorDesc)
|
||||
|
||||
const seen = new Set<string>()
|
||||
allMessages = allMessages.filter(msg => {
|
||||
@@ -1557,6 +1574,277 @@ class ChatService extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于 sortSeq 游标,获取更新的消息(严格大于 cursorSortSeq)
|
||||
*/
|
||||
async getMessagesAfter(
|
||||
sessionId: string,
|
||||
cursorSortSeq: number,
|
||||
limit: number = 50,
|
||||
cursorCreateTime?: number,
|
||||
cursorLocalId?: number
|
||||
): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; error?: string }> {
|
||||
try {
|
||||
if (!this.dbDir) {
|
||||
const connectResult = await this.connect()
|
||||
if (!connectResult.success) {
|
||||
return { success: false, error: connectResult.error || '数据库未连接' }
|
||||
}
|
||||
}
|
||||
|
||||
const myWxid = this.configService.get('myWxid')
|
||||
const cleanedMyWxid = myWxid ? this.cleanAccountDirName(myWxid) : ''
|
||||
|
||||
const dbTablePairs = this.findSessionTables(sessionId)
|
||||
if (dbTablePairs.length === 0) {
|
||||
return { success: false, error: '未找到该会话的消息表' }
|
||||
}
|
||||
|
||||
let allMessages: Message[] = []
|
||||
const fetchLimitPerDb = Math.max(limit + 1, 50)
|
||||
const effectiveCursorCreateTime = cursorCreateTime ?? Number.MIN_SAFE_INTEGER
|
||||
const effectiveCursorLocalId = cursorLocalId ?? Number.MIN_SAFE_INTEGER
|
||||
|
||||
for (const { db, tableName, dbPath } of dbTablePairs) {
|
||||
try {
|
||||
const hasName2IdTable = this.checkTableExists(db, 'Name2Id')
|
||||
|
||||
let myRowId: number | null = null
|
||||
if (myWxid && hasName2IdTable) {
|
||||
const cacheKeyOriginal = `${dbPath}:${myWxid}`
|
||||
const cachedRowIdOriginal = this.myRowIdCache.get(cacheKeyOriginal)
|
||||
|
||||
if (cachedRowIdOriginal !== undefined) {
|
||||
myRowId = cachedRowIdOriginal
|
||||
} else {
|
||||
const row = db.prepare('SELECT rowid FROM Name2Id WHERE user_name = ?').get(myWxid) as any
|
||||
if (row?.rowid) {
|
||||
myRowId = row.rowid
|
||||
this.myRowIdCache.set(cacheKeyOriginal, myRowId)
|
||||
} else if (cleanedMyWxid && cleanedMyWxid !== myWxid) {
|
||||
const cacheKeyCleaned = `${dbPath}:${cleanedMyWxid}`
|
||||
const cachedRowIdCleaned = this.myRowIdCache.get(cacheKeyCleaned)
|
||||
|
||||
if (cachedRowIdCleaned !== undefined) {
|
||||
myRowId = cachedRowIdCleaned
|
||||
} else {
|
||||
const row2 = db.prepare('SELECT rowid FROM Name2Id WHERE user_name = ?').get(cleanedMyWxid) as any
|
||||
myRowId = row2?.rowid ?? null
|
||||
this.myRowIdCache.set(cacheKeyCleaned, myRowId)
|
||||
}
|
||||
} else {
|
||||
this.myRowIdCache.set(cacheKeyOriginal, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let sql: string
|
||||
let rows: any[]
|
||||
|
||||
if (hasName2IdTable && myRowId !== null) {
|
||||
sql = `SELECT m.*,
|
||||
CASE WHEN m.real_sender_id = ? THEN 1 ELSE 0 END AS computed_is_send,
|
||||
n.user_name AS sender_username
|
||||
FROM ${tableName} m
|
||||
LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid
|
||||
WHERE (
|
||||
m.sort_seq > ?
|
||||
OR (m.sort_seq = ? AND m.create_time > ?)
|
||||
OR (m.sort_seq = ? AND m.create_time = ? AND m.local_id > ?)
|
||||
)
|
||||
ORDER BY m.sort_seq ASC, m.create_time ASC, m.local_id ASC
|
||||
LIMIT ?`
|
||||
rows = db.prepare(sql).all(
|
||||
myRowId,
|
||||
cursorSortSeq,
|
||||
cursorSortSeq,
|
||||
effectiveCursorCreateTime,
|
||||
cursorSortSeq,
|
||||
effectiveCursorCreateTime,
|
||||
effectiveCursorLocalId,
|
||||
fetchLimitPerDb
|
||||
) as any[]
|
||||
} else if (hasName2IdTable) {
|
||||
sql = `SELECT m.*, n.user_name AS sender_username
|
||||
FROM ${tableName} m
|
||||
LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid
|
||||
WHERE (
|
||||
m.sort_seq > ?
|
||||
OR (m.sort_seq = ? AND m.create_time > ?)
|
||||
OR (m.sort_seq = ? AND m.create_time = ? AND m.local_id > ?)
|
||||
)
|
||||
ORDER BY m.sort_seq ASC, m.create_time ASC, m.local_id ASC
|
||||
LIMIT ?`
|
||||
rows = db.prepare(sql).all(
|
||||
cursorSortSeq,
|
||||
cursorSortSeq,
|
||||
effectiveCursorCreateTime,
|
||||
cursorSortSeq,
|
||||
effectiveCursorCreateTime,
|
||||
effectiveCursorLocalId,
|
||||
fetchLimitPerDb
|
||||
) as any[]
|
||||
} else {
|
||||
sql = `SELECT * FROM ${tableName}
|
||||
WHERE (
|
||||
sort_seq > ?
|
||||
OR (sort_seq = ? AND create_time > ?)
|
||||
OR (sort_seq = ? AND create_time = ? AND local_id > ?)
|
||||
)
|
||||
ORDER BY sort_seq ASC, create_time ASC, local_id ASC
|
||||
LIMIT ?`
|
||||
rows = db.prepare(sql).all(
|
||||
cursorSortSeq,
|
||||
cursorSortSeq,
|
||||
effectiveCursorCreateTime,
|
||||
cursorSortSeq,
|
||||
effectiveCursorCreateTime,
|
||||
effectiveCursorLocalId,
|
||||
fetchLimitPerDb
|
||||
) as any[]
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const content = this.decodeMessageContent(row.message_content, row.compress_content)
|
||||
const localType = row.local_type || row.type || 1
|
||||
const isSend = row.computed_is_send ?? row.is_send ?? null
|
||||
|
||||
let emojiCdnUrl: string | undefined
|
||||
let emojiMd5: string | undefined
|
||||
let emojiProductId: string | undefined
|
||||
let quotedContent: string | undefined
|
||||
let quotedSender: string | undefined
|
||||
let quotedImageMd5: string | undefined
|
||||
let quotedEmojiMd5: string | undefined
|
||||
let quotedEmojiCdnUrl: string | undefined
|
||||
let imageMd5: string | undefined
|
||||
let imageDatName: string | undefined
|
||||
let isLivePhoto: boolean | undefined
|
||||
let videoMd5: string | undefined
|
||||
let videoDuration: number | undefined
|
||||
let voiceDuration: number | undefined
|
||||
|
||||
if (localType === 47 && content) {
|
||||
const emojiInfo = this.parseEmojiInfo(content)
|
||||
emojiCdnUrl = emojiInfo.cdnUrl
|
||||
emojiMd5 = emojiInfo.md5
|
||||
emojiProductId = emojiInfo.productId
|
||||
} else if (localType === 3 && content) {
|
||||
const imageInfo = this.parseImageInfo(content)
|
||||
imageMd5 = imageInfo.md5
|
||||
imageDatName = this.parseImageDatNameFromRow(row)
|
||||
isLivePhoto = imageInfo.isLivePhoto
|
||||
} else if (localType === 43 && content) {
|
||||
videoMd5 = this.parseVideoMd5(content)
|
||||
videoDuration = this.parseVideoDuration(content)
|
||||
} else if (localType === 34 && content) {
|
||||
voiceDuration = this.parseVoiceDuration(content)
|
||||
} else if (localType === 244813135921 || (content && content.includes('<type>57</type>'))) {
|
||||
const quoteInfo = this.parseQuoteMessage(content)
|
||||
quotedContent = quoteInfo.content
|
||||
quotedSender = quoteInfo.sender
|
||||
quotedImageMd5 = quoteInfo.imageMd5
|
||||
quotedEmojiMd5 = quoteInfo.emojiMd5
|
||||
quotedEmojiCdnUrl = quoteInfo.emojiCdnUrl
|
||||
}
|
||||
|
||||
let fileName: string | undefined
|
||||
let fileSize: number | undefined
|
||||
let fileExt: string | undefined
|
||||
let fileMd5: string | undefined
|
||||
if (localType === 49 && content) {
|
||||
const fileInfo = this.parseFileInfo(content)
|
||||
fileName = fileInfo.fileName
|
||||
fileSize = fileInfo.fileSize
|
||||
fileExt = fileInfo.fileExt
|
||||
fileMd5 = fileInfo.fileMd5
|
||||
}
|
||||
|
||||
let chatRecordList: ChatRecordItem[] | undefined
|
||||
if (content) {
|
||||
const xmlType = this.extractXmlValue(content, 'type')
|
||||
if (xmlType === '19' || localType === 49) {
|
||||
chatRecordList = this.parseChatHistory(content)
|
||||
}
|
||||
}
|
||||
|
||||
let transferPayerUsername: string | undefined
|
||||
let transferReceiverUsername: string | undefined
|
||||
if ((localType === 49 || localType === 8589934592049) && content) {
|
||||
const xmlType = this.extractXmlValue(content, 'type')
|
||||
if (xmlType === '2000') {
|
||||
transferPayerUsername = this.extractXmlValue(content, 'payer_username') || undefined
|
||||
transferReceiverUsername = this.extractXmlValue(content, 'receiver_username') || undefined
|
||||
}
|
||||
}
|
||||
|
||||
const parsedContent = this.parseMessageContent(content, localType)
|
||||
|
||||
allMessages.push({
|
||||
localId: row.local_id || 0,
|
||||
serverId: row.server_id || 0,
|
||||
localType,
|
||||
createTime: row.create_time || 0,
|
||||
sortSeq: row.sort_seq || 0,
|
||||
isSend,
|
||||
senderUsername: row.sender_username || null,
|
||||
parsedContent,
|
||||
rawContent: content,
|
||||
emojiCdnUrl,
|
||||
emojiMd5,
|
||||
productId: emojiProductId,
|
||||
quotedContent,
|
||||
quotedSender,
|
||||
quotedImageMd5,
|
||||
quotedEmojiMd5,
|
||||
quotedEmojiCdnUrl,
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
isLivePhoto,
|
||||
videoMd5,
|
||||
videoDuration,
|
||||
voiceDuration,
|
||||
fileName,
|
||||
fileSize,
|
||||
fileExt,
|
||||
fileMd5,
|
||||
chatRecordList,
|
||||
transferPayerUsername,
|
||||
transferReceiverUsername
|
||||
})
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e?.code === 'SQLITE_CORRUPT' || e?.message?.includes('malformed')) {
|
||||
console.error(`[ChatService] 数据库损坏: ${dbPath}`, e)
|
||||
this.messageDbCache.delete(dbPath)
|
||||
try { db.close() } catch { }
|
||||
this.refreshMessageDbCache()
|
||||
} else {
|
||||
console.error('ChatService: 查询更新消息失败:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
allMessages.sort(compareMessageCursorAsc)
|
||||
|
||||
const seen = new Set<string>()
|
||||
allMessages = allMessages.filter(msg => {
|
||||
const key = `${msg.serverId}-${msg.localId}-${msg.createTime}-${msg.sortSeq}`
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
|
||||
const hasMore = allMessages.length > limit
|
||||
const messages = allMessages.slice(0, limit)
|
||||
|
||||
return { success: true, messages, hasMore }
|
||||
} catch (e) {
|
||||
console.error('ChatService: 获取更新消息失败:', e)
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话的所有语音消息(用于批量转写)
|
||||
* 复用 getMessages 的查询逻辑,只查询语音消息类型
|
||||
@@ -1693,7 +1981,7 @@ class ChatService extends EventEmitter {
|
||||
}
|
||||
|
||||
// 按 sort_seq 降序排序
|
||||
allVoiceMessages.sort((a, b) => b.sortSeq - a.sortSeq)
|
||||
allVoiceMessages.sort(compareMessageCursorDesc)
|
||||
|
||||
// 去重
|
||||
const seen = new Set<string>()
|
||||
@@ -4645,7 +4933,7 @@ class ChatService extends EventEmitter {
|
||||
// 找到 silk-wasm 的 WASM 文件
|
||||
let wasmPath: string
|
||||
|
||||
if (app.isPackaged) {
|
||||
if (isElectronPackaged()) {
|
||||
// 打包后,WASM 文件在 app.asar.unpacked 中
|
||||
wasmPath = path.join(process.resourcesPath, 'app.asar.unpacked', 'node_modules', 'silk-wasm', 'lib', 'silk.wasm')
|
||||
if (!fs.existsSync(wasmPath)) {
|
||||
@@ -4653,7 +4941,7 @@ class ChatService extends EventEmitter {
|
||||
}
|
||||
} else {
|
||||
// 开发环境
|
||||
wasmPath = path.join(app.getAppPath(), 'node_modules', 'silk-wasm', 'lib', 'silk.wasm')
|
||||
wasmPath = path.join(getAppPath(), 'node_modules', 'silk-wasm', 'lib', 'silk.wasm')
|
||||
}
|
||||
|
||||
if (!fs.existsSync(wasmPath)) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Database from 'better-sqlite3'
|
||||
import { app } from 'electron'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { getUserDataPath } from './runtimePaths'
|
||||
|
||||
interface ConfigSchema {
|
||||
// 数据库相关
|
||||
@@ -30,6 +30,10 @@ interface ConfigSchema {
|
||||
themeMode: string
|
||||
appIcon: string
|
||||
language: string
|
||||
releaseAnnouncementVersion: string
|
||||
releaseAnnouncementBody: string
|
||||
releaseAnnouncementNotes: string
|
||||
releaseAnnouncementSeenVersion: string
|
||||
|
||||
// 协议相关
|
||||
agreementVersion: number
|
||||
@@ -77,6 +81,10 @@ interface ConfigSchema {
|
||||
aiEnableCache: boolean
|
||||
aiEnableThinking: boolean // 是否显示思考过程
|
||||
aiMessageLimit: number // 摘要提取的消息条数限制
|
||||
mcpEnabled: boolean
|
||||
mcpExposeMediaPaths: boolean
|
||||
mcpProxyPort: number
|
||||
mcpProxyToken: string
|
||||
}
|
||||
|
||||
const defaults: ConfigSchema = {
|
||||
@@ -95,6 +103,10 @@ const defaults: ConfigSchema = {
|
||||
themeMode: 'light',
|
||||
appIcon: 'default',
|
||||
language: 'zh-CN',
|
||||
releaseAnnouncementVersion: '',
|
||||
releaseAnnouncementBody: '',
|
||||
releaseAnnouncementNotes: '',
|
||||
releaseAnnouncementSeenVersion: '',
|
||||
sttLanguages: ['zh'],
|
||||
sttModelType: 'int8',
|
||||
sttMode: 'cpu', // 默认使用 CPU 模式
|
||||
@@ -120,7 +132,11 @@ const defaults: ConfigSchema = {
|
||||
aiCustomSystemPrompt: '',
|
||||
aiEnableCache: true,
|
||||
aiEnableThinking: true, // 默认显示思考过程
|
||||
aiMessageLimit: 3000 // 默认3000条,用户可调至5000
|
||||
aiMessageLimit: 3000, // 默认3000条,用户可调至5000
|
||||
mcpEnabled: false,
|
||||
mcpExposeMediaPaths: true,
|
||||
mcpProxyPort: 5032,
|
||||
mcpProxyToken: ''
|
||||
}
|
||||
|
||||
export class ConfigService {
|
||||
@@ -128,7 +144,7 @@ export class ConfigService {
|
||||
private dbPath: string
|
||||
|
||||
constructor() {
|
||||
const userDataPath = app.getPath('userData')
|
||||
const userDataPath = getUserDataPath()
|
||||
this.dbPath = path.join(userDataPath, 'ciphertalk-config.db')
|
||||
this.initDatabase()
|
||||
}
|
||||
@@ -389,6 +405,6 @@ export class ConfigService {
|
||||
if (configured && configured.trim().length > 0) {
|
||||
return configured
|
||||
}
|
||||
return path.join(app.getPath('documents'), 'CipherTalk')
|
||||
return path.join(getUserDataPath(), 'CipherTalk')
|
||||
}
|
||||
}
|
||||
|
||||
929
electron/services/httpApiFacade.ts
Normal file
929
electron/services/httpApiFacade.ts
Normal file
@@ -0,0 +1,929 @@
|
||||
import { existsSync, mkdirSync } from 'fs'
|
||||
import { writeFile } from 'fs/promises'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { join } from 'path'
|
||||
import { ConfigService } from './config'
|
||||
import { chatService } from './chatService'
|
||||
import { imageDecryptService } from './imageDecryptService'
|
||||
import { videoService } from './videoService'
|
||||
import { getAppVersion } from './runtimePaths'
|
||||
|
||||
type ContactType = 'friend' | 'group' | 'official' | 'former_friend' | 'other'
|
||||
type SessionTypeFilter = 'friend' | 'group' | 'official' | 'other'
|
||||
|
||||
export interface HttpApiRuntimeStatus {
|
||||
enabled: boolean
|
||||
running: boolean
|
||||
host: string
|
||||
port: number
|
||||
startedAt: number
|
||||
token: string
|
||||
startError: string
|
||||
}
|
||||
|
||||
export interface ApiErrorShape {
|
||||
code: string
|
||||
message: string
|
||||
hint?: string
|
||||
}
|
||||
|
||||
export class ApiQueryError extends Error {
|
||||
statusCode: number
|
||||
code: string
|
||||
hint?: string
|
||||
|
||||
constructor(statusCode: number, code: string, message: string, hint?: string) {
|
||||
super(message)
|
||||
this.name = 'ApiQueryError'
|
||||
this.statusCode = statusCode
|
||||
this.code = code
|
||||
this.hint = hint
|
||||
}
|
||||
|
||||
toResponse(): ApiErrorShape {
|
||||
return {
|
||||
code: this.code,
|
||||
message: this.message,
|
||||
hint: this.hint
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface QuerySessionsInput {
|
||||
q?: string
|
||||
type?: string[] | null
|
||||
unreadOnly?: boolean
|
||||
sort?: string
|
||||
offset?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export interface QueryMessagesInput {
|
||||
sessionId: string
|
||||
offset?: number
|
||||
limit?: number
|
||||
sort?: string
|
||||
keyword?: string
|
||||
msgType?: number[] | null
|
||||
messageKind?: string[] | null
|
||||
appMsgType?: string[] | null
|
||||
startTime?: number | null
|
||||
endTime?: number | null
|
||||
includeRaw?: boolean
|
||||
resolveMediaPath?: boolean
|
||||
resolveVoicePath?: boolean
|
||||
adaptive?: boolean
|
||||
maxScan?: number
|
||||
fields?: string[] | null
|
||||
}
|
||||
|
||||
export interface QueryContactsInput {
|
||||
q?: string
|
||||
type?: string[] | null
|
||||
includeAvatar?: boolean
|
||||
sort?: string
|
||||
offset?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
function parseBoolean(value: string | null | undefined, defaultValue: boolean): boolean {
|
||||
if (value == null) return defaultValue
|
||||
const normalized = value.trim().toLowerCase()
|
||||
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true
|
||||
if (['0', 'false', 'no', 'off'].includes(normalized)) return false
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
function parseIntInRange(value: string | number | null | undefined, defaultValue: number, min: number, max: number): number {
|
||||
if (value == null || value === '') return defaultValue
|
||||
const n = typeof value === 'number' ? value : Number.parseInt(value, 10)
|
||||
if (!Number.isFinite(n)) return defaultValue
|
||||
return Math.max(min, Math.min(max, n))
|
||||
}
|
||||
|
||||
function parseNumberList(value: string | null | undefined): number[] | null {
|
||||
if (!value) return null
|
||||
const nums = value
|
||||
.split(',')
|
||||
.map((x) => Number.parseInt(x.trim(), 10))
|
||||
.filter((x) => Number.isFinite(x))
|
||||
return nums.length > 0 ? nums : null
|
||||
}
|
||||
|
||||
function parseStringSet(value: string | null | undefined): Set<string> | null {
|
||||
if (!value) return null
|
||||
const values = value
|
||||
.split(',')
|
||||
.map((x) => x.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
return values.length > 0 ? new Set(values) : null
|
||||
}
|
||||
|
||||
function parseFields(value: string | null | undefined): Set<string> {
|
||||
const defaults = ['base', 'type', 'time', 'sender', 'metadata', 'media']
|
||||
if (!value || !value.trim()) {
|
||||
return new Set(defaults)
|
||||
}
|
||||
|
||||
const parts = value
|
||||
.split(',')
|
||||
.map((x) => x.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
|
||||
if (parts.includes('all')) {
|
||||
return new Set([
|
||||
'all',
|
||||
'base',
|
||||
'type',
|
||||
'time',
|
||||
'sender',
|
||||
'metadata',
|
||||
'media',
|
||||
'quote',
|
||||
'file',
|
||||
'transfer',
|
||||
'chatrecord',
|
||||
'raw',
|
||||
'schema'
|
||||
])
|
||||
}
|
||||
|
||||
return new Set(parts)
|
||||
}
|
||||
|
||||
function parseTimestampMs(value: string | number | null | undefined): number | null {
|
||||
if (value == null || value === '') return null
|
||||
const raw = typeof value === 'number' ? value : Number.parseInt(value, 10)
|
||||
if (!Number.isFinite(raw) || raw <= 0) return null
|
||||
return raw < 1_000_000_000_000 ? raw * 1000 : raw
|
||||
}
|
||||
|
||||
function normalizeTimestampMs(value: number): number {
|
||||
if (!Number.isFinite(value) || value <= 0) return 0
|
||||
return value < 1_000_000_000_000 ? value * 1000 : value
|
||||
}
|
||||
|
||||
function extractXmlType(content?: string): string | undefined {
|
||||
if (!content) return undefined
|
||||
const match = content.match(/<type>\s*([^<]+)\s*<\/type>/i)
|
||||
return match?.[1]?.trim()
|
||||
}
|
||||
|
||||
function fileUrlToPathMaybe(input?: string | null): string | null {
|
||||
if (!input) return null
|
||||
if (input.startsWith('file:///')) {
|
||||
try {
|
||||
return fileURLToPath(input)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
function sanitizePathPart(value: string): string {
|
||||
return value.replace(/[\\/:*?"<>|]/g, '_')
|
||||
}
|
||||
|
||||
function pruneEmpty(value: any): any {
|
||||
if (value === null || value === undefined) return undefined
|
||||
if (typeof value === 'string') return value === '' ? undefined : value
|
||||
if (Array.isArray(value)) {
|
||||
const next = value
|
||||
.map((v) => pruneEmpty(v))
|
||||
.filter((v) => v !== undefined)
|
||||
return next.length > 0 ? next : undefined
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
const out: Record<string, any> = {}
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
const pruned = pruneEmpty(v)
|
||||
if (pruned !== undefined) out[k] = pruned
|
||||
}
|
||||
return Object.keys(out).length > 0 ? out : undefined
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function detectMessageKind(message: Record<string, any>): {
|
||||
messageKind: string
|
||||
typeLabel: string
|
||||
appMsgType?: string
|
||||
} {
|
||||
const localType = Number(message.localType || 0)
|
||||
const raw = String(message.rawContent || message.parsedContent || '')
|
||||
const appMsgType = extractXmlType(raw)
|
||||
|
||||
if (localType === 1) return { messageKind: 'text', typeLabel: '文本' }
|
||||
if (localType === 3) return { messageKind: 'image', typeLabel: '图片' }
|
||||
if (localType === 34) return { messageKind: 'voice', typeLabel: '语音' }
|
||||
if (localType === 42) return { messageKind: 'contact_card', typeLabel: '名片' }
|
||||
if (localType === 43) return { messageKind: 'video', typeLabel: '视频' }
|
||||
if (localType === 47) return { messageKind: 'emoji', typeLabel: '表情' }
|
||||
if (localType === 48) return { messageKind: 'location', typeLabel: '位置' }
|
||||
if (localType === 50) return { messageKind: 'voip', typeLabel: '音视频通话' }
|
||||
if (localType === 10000) return { messageKind: 'system', typeLabel: '系统消息' }
|
||||
if (localType === 244813135921) return { messageKind: 'quote', typeLabel: '引用消息' }
|
||||
|
||||
if (localType === 49 || appMsgType) {
|
||||
switch (appMsgType) {
|
||||
case '3':
|
||||
return { messageKind: 'app_music', typeLabel: '音乐分享', appMsgType }
|
||||
case '5':
|
||||
case '49':
|
||||
return { messageKind: 'app_link', typeLabel: '链接', appMsgType }
|
||||
case '6':
|
||||
return { messageKind: 'app_file', typeLabel: '文件', appMsgType }
|
||||
case '19':
|
||||
return { messageKind: 'app_chat_record', typeLabel: '聊天记录', appMsgType }
|
||||
case '33':
|
||||
case '36':
|
||||
return { messageKind: 'app_mini_program', typeLabel: '小程序', appMsgType }
|
||||
case '57':
|
||||
return { messageKind: 'app_quote', typeLabel: '引用消息', appMsgType }
|
||||
case '62':
|
||||
return { messageKind: 'app_pat', typeLabel: '拍一拍', appMsgType }
|
||||
case '87':
|
||||
return { messageKind: 'app_announcement', typeLabel: '群公告', appMsgType }
|
||||
case '115':
|
||||
return { messageKind: 'app_gift', typeLabel: '微信礼物', appMsgType }
|
||||
case '2000':
|
||||
return { messageKind: 'app_transfer', typeLabel: '转账', appMsgType }
|
||||
case '2001':
|
||||
return { messageKind: 'app_red_packet', typeLabel: '红包', appMsgType }
|
||||
default:
|
||||
return { messageKind: 'app', typeLabel: '应用消息', appMsgType }
|
||||
}
|
||||
}
|
||||
|
||||
return { messageKind: 'unknown', typeLabel: `未知类型(${localType})` }
|
||||
}
|
||||
|
||||
function parseTypeFilter(values?: string[] | null): Set<ContactType> | null {
|
||||
if (!values?.length) return null
|
||||
const allowed: ContactType[] = ['friend', 'group', 'official', 'former_friend', 'other']
|
||||
const result = new Set<ContactType>()
|
||||
values.forEach((x) => {
|
||||
if (allowed.includes(x as ContactType)) {
|
||||
result.add(x as ContactType)
|
||||
}
|
||||
})
|
||||
return result.size > 0 ? result : null
|
||||
}
|
||||
|
||||
function parseSessionTypeFilter(values?: string[] | null): Set<SessionTypeFilter> | null {
|
||||
if (!values?.length) return null
|
||||
const allowed: SessionTypeFilter[] = ['friend', 'group', 'official', 'other']
|
||||
const result = new Set<SessionTypeFilter>()
|
||||
values.forEach((x) => {
|
||||
if (allowed.includes(x as SessionTypeFilter)) {
|
||||
result.add(x as SessionTypeFilter)
|
||||
}
|
||||
})
|
||||
return result.size > 0 ? result : null
|
||||
}
|
||||
|
||||
function detectSessionType(username: string): SessionTypeFilter {
|
||||
if (username.includes('@chatroom')) return 'group'
|
||||
if (username.startsWith('gh_')) return 'official'
|
||||
if (username) return 'friend'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
function getBaseUrl(status: HttpApiRuntimeStatus): string {
|
||||
return `http://${status.host}:${status.port}/v1`
|
||||
}
|
||||
|
||||
export function queryHealth() {
|
||||
return { status: 'ok' }
|
||||
}
|
||||
|
||||
export function queryStatus(status: HttpApiRuntimeStatus, verbose = false) {
|
||||
const configService = new ConfigService()
|
||||
const hasDbPath = Boolean(configService.get('dbPath'))
|
||||
const hasWxid = Boolean(configService.get('myWxid'))
|
||||
const hasDecryptKey = Boolean(configService.get('decryptKey'))
|
||||
configService.close()
|
||||
|
||||
const isApiEnabled = status.enabled
|
||||
const isApiRunning = status.running
|
||||
const isDbConfigReady = hasDbPath && hasWxid && hasDecryptKey
|
||||
|
||||
let state: 'ready' | 'disabled' | 'starting_or_error' | 'needs_config' = 'ready'
|
||||
let message = 'HTTP API is ready for external calls.'
|
||||
|
||||
if (!isApiEnabled) {
|
||||
state = 'disabled'
|
||||
message = 'HTTP API is disabled. Enable it in Settings > Open API.'
|
||||
} else if (!isApiRunning) {
|
||||
state = 'starting_or_error'
|
||||
message = status.startError || 'HTTP API is enabled but not running. Try restart in settings.'
|
||||
} else if (!isDbConfigReady) {
|
||||
state = 'needs_config'
|
||||
message = 'API is running, but database-related features need dbPath/decryptKey/wxid configuration.'
|
||||
}
|
||||
|
||||
const basePayload = {
|
||||
summary: {
|
||||
state,
|
||||
usable: isApiEnabled && isApiRunning,
|
||||
message
|
||||
},
|
||||
server: {
|
||||
running: isApiRunning,
|
||||
enabled: isApiEnabled,
|
||||
host: status.host,
|
||||
port: status.port,
|
||||
uptimeMs: status.startedAt ? Date.now() - status.startedAt : 0
|
||||
},
|
||||
auth: {
|
||||
required: Boolean(status.token),
|
||||
scheme: 'Authorization: Bearer <token>'
|
||||
},
|
||||
config: {
|
||||
dbConfigReady: isDbConfigReady
|
||||
}
|
||||
}
|
||||
|
||||
if (!verbose) {
|
||||
return basePayload
|
||||
}
|
||||
|
||||
return {
|
||||
...basePayload,
|
||||
usage: {
|
||||
baseUrl: getBaseUrl(status),
|
||||
health: '/v1/health',
|
||||
status: '/v1/status',
|
||||
auth: status.token ? 'Authorization: Bearer <token>' : 'No auth token required'
|
||||
},
|
||||
app: {
|
||||
version: getAppVersion(),
|
||||
electronVersion: process.versions.electron,
|
||||
nodeVersion: process.versions.node,
|
||||
platform: process.platform
|
||||
},
|
||||
debug: {
|
||||
checks: {
|
||||
apiEnabled: isApiEnabled,
|
||||
apiRunning: isApiRunning,
|
||||
dbConfigReady: isDbConfigReady,
|
||||
authRequired: Boolean(status.token)
|
||||
},
|
||||
tokenPreview: status.token ? `${status.token.slice(0, 3)}***${status.token.slice(-3)}` : '',
|
||||
startedAt: status.startedAt ? new Date(status.startedAt).toISOString() : '',
|
||||
lastError: status.startError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function querySessions(input: QuerySessionsInput) {
|
||||
const q = (input.q || '').trim().toLowerCase()
|
||||
const typeFilter = parseSessionTypeFilter(input.type || null)
|
||||
const unreadOnly = Boolean(input.unreadOnly)
|
||||
const sort = (input.sort || 'sortTimestamp_desc').trim()
|
||||
const offset = parseIntInRange(input.offset, 0, 0, 100000)
|
||||
const limit = parseIntInRange(input.limit, 100, 1, 500)
|
||||
|
||||
const sessionsResult = await chatService.getSessions()
|
||||
if (!sessionsResult.success) {
|
||||
throw new ApiQueryError(
|
||||
503,
|
||||
'DB_NOT_CONNECTED',
|
||||
sessionsResult.error || 'Failed to read sessions',
|
||||
'Please complete DB decrypt/setup in Settings and ensure data is available.'
|
||||
)
|
||||
}
|
||||
|
||||
let sessions = (sessionsResult.sessions || []).map((item) => {
|
||||
const sessionType = detectSessionType(item.username || '')
|
||||
return {
|
||||
username: item.username,
|
||||
displayName: item.displayName || item.username,
|
||||
avatarUrl: item.avatarUrl,
|
||||
summary: item.summary,
|
||||
unreadCount: item.unreadCount || 0,
|
||||
sortTimestamp: item.sortTimestamp || 0,
|
||||
lastTimestamp: item.lastTimestamp || 0,
|
||||
lastMsgType: item.lastMsgType || 0,
|
||||
sessionType
|
||||
}
|
||||
})
|
||||
|
||||
if (typeFilter) {
|
||||
sessions = sessions.filter((item) => typeFilter.has(item.sessionType))
|
||||
}
|
||||
|
||||
if (unreadOnly) {
|
||||
sessions = sessions.filter((item) => Number(item.unreadCount || 0) > 0)
|
||||
}
|
||||
|
||||
if (q) {
|
||||
sessions = sessions.filter((item) => {
|
||||
const username = String(item.username || '').toLowerCase()
|
||||
const displayName = String(item.displayName || '').toLowerCase()
|
||||
const summary = String(item.summary || '').toLowerCase()
|
||||
return username.includes(q) || displayName.includes(q) || summary.includes(q)
|
||||
})
|
||||
}
|
||||
|
||||
if (sort === 'name_asc') {
|
||||
sessions.sort((a, b) => String(a.displayName || '').localeCompare(String(b.displayName || ''), 'zh-CN'))
|
||||
} else if (sort === 'name_desc') {
|
||||
sessions.sort((a, b) => String(b.displayName || '').localeCompare(String(a.displayName || ''), 'zh-CN'))
|
||||
} else if (sort === 'lastTimestamp_asc') {
|
||||
sessions.sort((a, b) => Number(a.lastTimestamp || 0) - Number(b.lastTimestamp || 0))
|
||||
} else if (sort === 'lastTimestamp_desc') {
|
||||
sessions.sort((a, b) => Number(b.lastTimestamp || 0) - Number(a.lastTimestamp || 0))
|
||||
} else if (sort === 'unreadCount_desc') {
|
||||
sessions.sort((a, b) => Number(b.unreadCount || 0) - Number(a.unreadCount || 0))
|
||||
} else {
|
||||
sessions.sort((a, b) => Number(b.sortTimestamp || 0) - Number(a.sortTimestamp || 0))
|
||||
}
|
||||
|
||||
const total = sessions.length
|
||||
const paged = sessions.slice(offset, offset + limit)
|
||||
const hasMore = offset + paged.length < total
|
||||
|
||||
return {
|
||||
total,
|
||||
offset,
|
||||
limit,
|
||||
hasMore,
|
||||
sort,
|
||||
filters: {
|
||||
q,
|
||||
type: typeFilter ? Array.from(typeFilter) : null,
|
||||
unreadOnly
|
||||
},
|
||||
sessions: paged
|
||||
}
|
||||
}
|
||||
|
||||
export async function queryMessages(input: QueryMessagesInput) {
|
||||
const sessionId = (input.sessionId || '').trim()
|
||||
if (!sessionId) {
|
||||
throw new ApiQueryError(
|
||||
400,
|
||||
'BAD_REQUEST',
|
||||
'Missing required parameter: sessionId',
|
||||
'Use query parameter: sessionId=<chat_username>'
|
||||
)
|
||||
}
|
||||
|
||||
const offset = parseIntInRange(input.offset, 0, 0, 100000)
|
||||
const limit = parseIntInRange(input.limit, 50, 1, 200)
|
||||
const sort = (input.sort || 'createTime_desc').trim()
|
||||
const keyword = (input.keyword || '').trim().toLowerCase()
|
||||
const msgTypeFilter = input.msgType || null
|
||||
const messageKindFilter = input.messageKind?.length ? new Set(input.messageKind.map((x) => x.toLowerCase())) : null
|
||||
const appMsgTypeFilter = input.appMsgType?.length ? new Set(input.appMsgType.map((x) => x.toLowerCase())) : null
|
||||
const startTimeMs = parseTimestampMs(input.startTime)
|
||||
const endTimeMs = parseTimestampMs(input.endTime)
|
||||
const includeRaw = Boolean(input.includeRaw)
|
||||
const resolveMediaPath = parseBoolean(input.resolveMediaPath === undefined ? undefined : String(input.resolveMediaPath), true)
|
||||
const resolveVoicePath = parseBoolean(input.resolveVoicePath === undefined ? undefined : String(input.resolveVoicePath), false)
|
||||
const adaptive = parseBoolean(input.adaptive === undefined ? undefined : String(input.adaptive), true)
|
||||
const maxScan = parseIntInRange(input.maxScan, 5000, 100, 20000)
|
||||
const fields = parseFields(input.fields?.join(',') || null)
|
||||
if (includeRaw) fields.add('raw')
|
||||
|
||||
const includeField = (name: string): boolean => fields.has('all') || fields.has(name)
|
||||
const needKindForFilter = Boolean(messageKindFilter || appMsgTypeFilter)
|
||||
const needKindForOutput = [includeField('type'), includeField('metadata'), includeField('media')].some(Boolean)
|
||||
const shouldResolveMediaPath = includeField('media') && resolveMediaPath
|
||||
const shouldResolveVoicePath = includeField('media') && resolveVoicePath
|
||||
const includeChatRecordItems = includeField('chatrecord')
|
||||
|
||||
let myWxid = ''
|
||||
let dbPath = ''
|
||||
let cachePath = ''
|
||||
if (shouldResolveVoicePath || shouldResolveMediaPath || includeField('file')) {
|
||||
const runtimeConfig = new ConfigService()
|
||||
myWxid = String(runtimeConfig.get('myWxid') || '')
|
||||
dbPath = String(runtimeConfig.get('dbPath') || '')
|
||||
cachePath = String(runtimeConfig.get('cachePath') || '')
|
||||
runtimeConfig.close()
|
||||
}
|
||||
|
||||
const fetchBatchSize = 200
|
||||
const targetCount = offset + limit
|
||||
let scanOffset = 0
|
||||
let scanned = 0
|
||||
let reachedEnd = false
|
||||
const matched: any[] = []
|
||||
|
||||
while (scanned < maxScan && matched.length < targetCount) {
|
||||
const part = await chatService.getMessages(sessionId, scanOffset, fetchBatchSize)
|
||||
if (!part.success) {
|
||||
throw new ApiQueryError(
|
||||
503,
|
||||
'DB_NOT_CONNECTED',
|
||||
part.error || 'Failed to read messages',
|
||||
'Please complete DB decrypt/setup in Settings and ensure sessionId is correct.'
|
||||
)
|
||||
}
|
||||
|
||||
const chunk = part.messages || []
|
||||
if (chunk.length === 0) {
|
||||
reachedEnd = true
|
||||
break
|
||||
}
|
||||
|
||||
scanned += chunk.length
|
||||
scanOffset += chunk.length
|
||||
|
||||
for (const msg of chunk) {
|
||||
if (msgTypeFilter && !msgTypeFilter.includes(Number(msg.localType || 0))) continue
|
||||
|
||||
if (needKindForFilter) {
|
||||
const kindInfo = detectMessageKind(msg as Record<string, any>)
|
||||
if (messageKindFilter && !messageKindFilter.has(kindInfo.messageKind)) continue
|
||||
if (appMsgTypeFilter) {
|
||||
const appMsgType = (kindInfo.appMsgType || '').toLowerCase()
|
||||
if (!appMsgType || !appMsgTypeFilter.has(appMsgType)) continue
|
||||
}
|
||||
}
|
||||
|
||||
const tMs = normalizeTimestampMs(Number(msg.createTime || 0))
|
||||
if (startTimeMs && tMs < startTimeMs) continue
|
||||
if (endTimeMs && tMs > endTimeMs) continue
|
||||
|
||||
if (keyword) {
|
||||
const parsed = String(msg.parsedContent || '').toLowerCase()
|
||||
const raw = String(msg.rawContent || '').toLowerCase()
|
||||
if (!parsed.includes(keyword) && !raw.includes(keyword)) continue
|
||||
}
|
||||
|
||||
matched.push(msg)
|
||||
}
|
||||
|
||||
if (!part.hasMore) {
|
||||
reachedEnd = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (sort === 'createTime_asc') {
|
||||
matched.sort((a, b) => Number(a.createTime || 0) - Number(b.createTime || 0))
|
||||
} else {
|
||||
matched.sort((a, b) => Number(b.createTime || 0) - Number(a.createTime || 0))
|
||||
}
|
||||
|
||||
const page = matched.slice(offset, offset + limit)
|
||||
const hasMore = reachedEnd ? matched.length > offset + page.length : true
|
||||
|
||||
const enrichOne = async (m: any): Promise<Record<string, any>> => {
|
||||
const base = m as Record<string, any>
|
||||
const kind = needKindForOutput ? detectMessageKind(base) : { messageKind: 'unknown', typeLabel: '未知类型', appMsgType: undefined }
|
||||
const createTimeMs = normalizeTimestampMs(Number(base.createTime || 0))
|
||||
const senderUsername = base.senderUsername || null
|
||||
|
||||
const metadata = {
|
||||
localType: Number(base.localType || 0),
|
||||
messageKind: kind.messageKind,
|
||||
typeLabel: kind.typeLabel,
|
||||
appMsgType: kind.appMsgType || null,
|
||||
direction: Number(base.isSend) === 1 ? 'out' : 'in',
|
||||
isSystem: Number(base.localType || 0) === 10000 || kind.messageKind === 'app_pat',
|
||||
isMedia: ['image', 'voice', 'video', 'emoji'].includes(kind.messageKind),
|
||||
hasRawContent: Boolean(base.rawContent),
|
||||
hasParsedContent: Boolean(base.parsedContent),
|
||||
hasQuote: Boolean(base.quotedContent || base.quotedImageMd5 || base.quotedEmojiMd5),
|
||||
hasFile: Boolean(base.fileName || base.fileMd5),
|
||||
hasTransfer: Boolean(base.transferPayerUsername || base.transferReceiverUsername),
|
||||
hasChatRecord: Array.isArray(base.chatRecordList) && base.chatRecordList.length > 0,
|
||||
isLivePhoto: Boolean(base.isLivePhoto)
|
||||
}
|
||||
|
||||
const media = {
|
||||
imageMd5: base.imageMd5 || null,
|
||||
imageDatName: base.imageDatName || null,
|
||||
imageCachePath: null as string | null,
|
||||
emojiMd5: base.emojiMd5 || null,
|
||||
emojiCdnUrl: base.emojiCdnUrl || null,
|
||||
emojiCachePath: null as string | null,
|
||||
videoMd5: base.videoMd5 || null,
|
||||
videoDuration: base.videoDuration || null,
|
||||
videoCachePath: null as string | null,
|
||||
voiceDuration: base.voiceDuration || null,
|
||||
voiceCachePath: null as string | null
|
||||
}
|
||||
|
||||
if (shouldResolveMediaPath && (kind.messageKind === 'emoji' || kind.messageKind.startsWith('app_')) && (base.emojiMd5 || base.emojiCdnUrl)) {
|
||||
try {
|
||||
const emojiResult = await chatService.downloadEmoji(
|
||||
String(base.emojiCdnUrl || ''),
|
||||
base.emojiMd5,
|
||||
base.productId,
|
||||
Number(base.createTime || 0),
|
||||
base.emojiEncryptUrl,
|
||||
base.emojiAesKey
|
||||
)
|
||||
if (emojiResult.success && emojiResult.cachePath) {
|
||||
media.emojiCachePath = emojiResult.cachePath
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldResolveMediaPath && kind.messageKind === 'image' && (base.imageMd5 || base.imageDatName)) {
|
||||
try {
|
||||
const resolved = await imageDecryptService.resolveCachedImage({
|
||||
sessionId,
|
||||
imageMd5: base.imageMd5,
|
||||
imageDatName: base.imageDatName
|
||||
})
|
||||
|
||||
if (resolved.success && resolved.localPath) {
|
||||
media.imageCachePath = fileUrlToPathMaybe(resolved.localPath)
|
||||
} else {
|
||||
const decrypted = await imageDecryptService.decryptImage({
|
||||
sessionId,
|
||||
imageMd5: base.imageMd5,
|
||||
imageDatName: base.imageDatName,
|
||||
force: false
|
||||
})
|
||||
if (decrypted.success && decrypted.localPath) {
|
||||
media.imageCachePath = fileUrlToPathMaybe(decrypted.localPath)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldResolveMediaPath && kind.messageKind === 'video' && base.videoMd5) {
|
||||
try {
|
||||
const videoInfo = videoService.getVideoInfo(String(base.videoMd5))
|
||||
if (videoInfo.exists && videoInfo.videoUrl) {
|
||||
media.videoCachePath = fileUrlToPathMaybe(videoInfo.videoUrl)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldResolveVoicePath && kind.messageKind === 'voice') {
|
||||
try {
|
||||
const voiceResult = await chatService.getVoiceData(
|
||||
sessionId,
|
||||
String(base.localId || ''),
|
||||
Number(base.createTime || 0)
|
||||
)
|
||||
if (voiceResult.success && voiceResult.data) {
|
||||
const baseCacheDir = cachePath || join(process.cwd(), 'cache')
|
||||
const voiceDir = join(baseCacheDir, 'HttpApiVoices', sanitizePathPart(sessionId))
|
||||
if (!existsSync(voiceDir)) {
|
||||
mkdirSync(voiceDir, { recursive: true })
|
||||
}
|
||||
const fileName = `${Number(base.createTime || 0)}_${Number(base.localId || 0)}.wav`
|
||||
const absPath = join(voiceDir, fileName)
|
||||
await writeFile(absPath, Buffer.from(voiceResult.data, 'base64'))
|
||||
media.voiceCachePath = absPath
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const quote = metadata.hasQuote
|
||||
? {
|
||||
content: base.quotedContent || null,
|
||||
sender: base.quotedSender || null,
|
||||
imageMd5: base.quotedImageMd5 || null,
|
||||
emojiMd5: base.quotedEmojiMd5 || null,
|
||||
emojiCdnUrl: base.quotedEmojiCdnUrl || null
|
||||
}
|
||||
: null
|
||||
|
||||
const file = metadata.hasFile
|
||||
? {
|
||||
name: base.fileName || null,
|
||||
size: base.fileSize || null,
|
||||
ext: base.fileExt || null,
|
||||
md5: base.fileMd5 || null,
|
||||
absolutePath: null as string | null,
|
||||
exists: false
|
||||
}
|
||||
: null
|
||||
|
||||
if (shouldResolveMediaPath && file?.name && dbPath && myWxid) {
|
||||
try {
|
||||
const msgDate = createTimeMs ? new Date(createTimeMs) : new Date()
|
||||
const year = msgDate.getFullYear()
|
||||
const month = String(msgDate.getMonth() + 1).padStart(2, '0')
|
||||
const dateFolder = `${year}-${month}`
|
||||
const abs = join(dbPath, myWxid, 'msg', 'file', dateFolder, String(file.name))
|
||||
file.absolutePath = abs
|
||||
file.exists = existsSync(abs)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const transfer = metadata.hasTransfer
|
||||
? {
|
||||
payerUsername: base.transferPayerUsername || null,
|
||||
receiverUsername: base.transferReceiverUsername || null
|
||||
}
|
||||
: null
|
||||
|
||||
const chatRecord = metadata.hasChatRecord
|
||||
? {
|
||||
count: Array.isArray(base.chatRecordList) ? base.chatRecordList.length : 0,
|
||||
items: includeChatRecordItems ? (base.chatRecordList || []) : undefined
|
||||
}
|
||||
: null
|
||||
|
||||
const out: Record<string, any> = {}
|
||||
|
||||
if (includeField('base')) {
|
||||
out.localId = base.localId || 0
|
||||
out.serverId = base.serverId || 0
|
||||
out.localType = Number(base.localType || 0)
|
||||
out.createTime = Number(base.createTime || 0)
|
||||
out.sortSeq = Number(base.sortSeq || 0)
|
||||
out.isSend = base.isSend ?? null
|
||||
out.senderUsername = senderUsername
|
||||
out.parsedContent = base.parsedContent || ''
|
||||
}
|
||||
|
||||
if (includeField('raw')) out.rawContent = base.rawContent || null
|
||||
if (includeField('type')) {
|
||||
out.messageKind = kind.messageKind
|
||||
out.typeLabel = kind.typeLabel
|
||||
out.appMsgType = kind.appMsgType || null
|
||||
out.direction = metadata.direction
|
||||
}
|
||||
if (includeField('time')) {
|
||||
out.createTimeMs = createTimeMs
|
||||
out.createTimeIso = createTimeMs ? new Date(createTimeMs).toISOString() : null
|
||||
}
|
||||
if (includeField('sender')) {
|
||||
out.sender = {
|
||||
username: senderUsername,
|
||||
isSelf: Number(base.isSend) === 1
|
||||
}
|
||||
}
|
||||
if (includeField('metadata')) out.metadata = metadata
|
||||
if (includeField('media')) out.media = media
|
||||
if (includeField('quote')) out.quote = quote
|
||||
if (includeField('file')) out.file = file
|
||||
if (includeField('transfer')) out.transfer = transfer
|
||||
if (includeField('chatrecord')) out.chatRecord = chatRecord
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
const enrichResults = await Promise.allSettled(page.map((m) => enrichOne(m)))
|
||||
const enrichedMessages = enrichResults
|
||||
.filter((r): r is PromiseFulfilledResult<Record<string, any>> => r.status === 'fulfilled')
|
||||
.map((r) => r.value)
|
||||
|
||||
const normalizedMessages = adaptive
|
||||
? enrichedMessages.map((m) => pruneEmpty(m)).filter(Boolean)
|
||||
: enrichedMessages
|
||||
|
||||
const finalMessages = includeRaw
|
||||
? normalizedMessages
|
||||
: normalizedMessages.map((m) => {
|
||||
const { rawContent, ...rest } = m as Record<string, any>
|
||||
return rest
|
||||
})
|
||||
|
||||
const responsePayload: Record<string, any> = {
|
||||
sessionId,
|
||||
total: reachedEnd ? matched.length : null,
|
||||
offset,
|
||||
limit,
|
||||
hasMore,
|
||||
scanned,
|
||||
maxScan,
|
||||
sort,
|
||||
filters: {
|
||||
keyword,
|
||||
msgType: msgTypeFilter,
|
||||
messageKind: messageKindFilter ? Array.from(messageKindFilter) : null,
|
||||
appMsgType: appMsgTypeFilter ? Array.from(appMsgTypeFilter) : null,
|
||||
startTime: startTimeMs,
|
||||
endTime: endTimeMs,
|
||||
includeRaw,
|
||||
resolveMediaPath,
|
||||
resolveVoicePath,
|
||||
adaptive,
|
||||
fields: Array.from(fields)
|
||||
},
|
||||
messages: finalMessages
|
||||
}
|
||||
|
||||
if (includeField('schema')) {
|
||||
responsePayload.messageTypeSchema = {
|
||||
messageKind: {
|
||||
text: '文本',
|
||||
image: '图片',
|
||||
voice: '语音',
|
||||
video: '视频',
|
||||
emoji: '表情',
|
||||
location: '位置',
|
||||
contact_card: '名片',
|
||||
system: '系统消息',
|
||||
quote: '引用消息',
|
||||
voip: '音视频通话',
|
||||
app_link: '链接',
|
||||
app_file: '文件',
|
||||
app_chat_record: '聊天记录',
|
||||
app_mini_program: '小程序',
|
||||
app_transfer: '转账',
|
||||
app_red_packet: '红包',
|
||||
app_announcement: '群公告',
|
||||
app_pat: '拍一拍',
|
||||
app_gift: '微信礼物',
|
||||
app_music: '音乐分享',
|
||||
app: '应用消息',
|
||||
unknown: '未知类型'
|
||||
},
|
||||
direction: {
|
||||
out: '我发送',
|
||||
in: '我接收'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return responsePayload
|
||||
}
|
||||
|
||||
export async function queryContacts(input: QueryContactsInput) {
|
||||
const q = (input.q || '').trim().toLowerCase()
|
||||
const typeFilter = parseTypeFilter(input.type || null)
|
||||
const includeAvatar = input.includeAvatar !== false
|
||||
const sort = (input.sort || 'lastContactTime_desc').trim()
|
||||
const offset = parseIntInRange(input.offset, 0, 0, 100000)
|
||||
const limit = parseIntInRange(input.limit, 100, 1, 500)
|
||||
|
||||
const contactsResult = await chatService.getContacts()
|
||||
if (!contactsResult.success) {
|
||||
throw new ApiQueryError(
|
||||
503,
|
||||
'DB_NOT_CONNECTED',
|
||||
contactsResult.error || 'Failed to read contacts',
|
||||
'Please complete DB decrypt/setup in Settings and ensure data is available.'
|
||||
)
|
||||
}
|
||||
|
||||
let contacts = (contactsResult.contacts || []) as Array<Record<string, any>>
|
||||
|
||||
if (typeFilter) {
|
||||
contacts = contacts.filter((item) => typeFilter.has((item.type || 'other') as ContactType))
|
||||
}
|
||||
|
||||
if (q) {
|
||||
contacts = contacts.filter((item) => {
|
||||
const username = String(item.username || '').toLowerCase()
|
||||
const displayName = String(item.displayName || '').toLowerCase()
|
||||
const remark = String(item.remark || '').toLowerCase()
|
||||
const nickname = String(item.nickname || '').toLowerCase()
|
||||
return (
|
||||
username.includes(q) ||
|
||||
displayName.includes(q) ||
|
||||
remark.includes(q) ||
|
||||
nickname.includes(q)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (sort === 'name_asc') {
|
||||
contacts.sort((a, b) => String(a.displayName || '').localeCompare(String(b.displayName || ''), 'zh-CN'))
|
||||
} else if (sort === 'name_desc') {
|
||||
contacts.sort((a, b) => String(b.displayName || '').localeCompare(String(a.displayName || ''), 'zh-CN'))
|
||||
} else if (sort === 'lastContactTime_asc') {
|
||||
contacts.sort((a, b) => Number((a as any).lastContactTime || 0) - Number((b as any).lastContactTime || 0))
|
||||
} else {
|
||||
contacts.sort((a, b) => Number((b as any).lastContactTime || 0) - Number((a as any).lastContactTime || 0))
|
||||
}
|
||||
|
||||
const total = contacts.length
|
||||
const paged = contacts.slice(offset, offset + limit)
|
||||
const hasMore = offset + paged.length < total
|
||||
|
||||
const finalContacts = paged.map((item) => {
|
||||
if (includeAvatar) return item
|
||||
const { avatarUrl, ...rest } = item
|
||||
return rest
|
||||
})
|
||||
|
||||
return {
|
||||
total,
|
||||
offset,
|
||||
limit,
|
||||
hasMore,
|
||||
sort,
|
||||
filters: {
|
||||
q,
|
||||
type: typeFilter ? Array.from(typeFilter) : null,
|
||||
includeAvatar
|
||||
},
|
||||
contacts: finalContacts
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
import { BrowserWindow } from 'electron'
|
||||
import { basename, dirname, extname, join } from 'path'
|
||||
import { pathToFileURL } from 'url'
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'fs'
|
||||
@@ -10,6 +10,7 @@ import { execFile } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { ConfigService } from './config'
|
||||
import { getDefaultCachePath as getPlatformDefaultCachePath } from './platformService'
|
||||
import { getDocumentsPath, getExePath } from './runtimePaths'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
@@ -1526,7 +1527,7 @@ export class ImageDecryptService {
|
||||
private getAllCacheRoots(): string[] {
|
||||
const roots: string[] = []
|
||||
const configured = this.configService.get('cachePath')
|
||||
const documentsPath = app.getPath('documents')
|
||||
const documentsPath = getDocumentsPath()
|
||||
|
||||
// 主要路径(当前使用的)
|
||||
const mainRoot = this.getCacheRoot()
|
||||
|
||||
52
electron/services/mcp/bootstrap.ts
Normal file
52
electron/services/mcp/bootstrap.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
||||
import { createCipherTalkMcpServer } from './server'
|
||||
|
||||
let mcpServer: ReturnType<typeof createCipherTalkMcpServer> | null = null
|
||||
let isShuttingDown = false
|
||||
|
||||
async function shutdown(code = 0) {
|
||||
if (isShuttingDown) return
|
||||
isShuttingDown = true
|
||||
|
||||
try {
|
||||
await mcpServer?.close?.()
|
||||
} catch (error) {
|
||||
process.stderr.write(`[CipherTalk MCP] close error: ${String(error)}\n`)
|
||||
} finally {
|
||||
process.exit(code)
|
||||
}
|
||||
}
|
||||
|
||||
function installProcessHandlers() {
|
||||
process.on('SIGINT', () => {
|
||||
void shutdown(0)
|
||||
})
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
void shutdown(0)
|
||||
})
|
||||
|
||||
process.on('uncaughtException', (error) => {
|
||||
process.stderr.write(`[CipherTalk MCP] uncaughtException: ${String(error)}\n`)
|
||||
void shutdown(1)
|
||||
})
|
||||
|
||||
process.on('unhandledRejection', (error) => {
|
||||
process.stderr.write(`[CipherTalk MCP] unhandledRejection: ${String(error)}\n`)
|
||||
void shutdown(1)
|
||||
})
|
||||
}
|
||||
|
||||
export async function bootstrapCipherTalkMcpServer() {
|
||||
installProcessHandlers()
|
||||
|
||||
try {
|
||||
mcpServer = createCipherTalkMcpServer()
|
||||
const transport = new StdioServerTransport()
|
||||
await mcpServer.connect(transport)
|
||||
process.stderr.write('[CipherTalk MCP] stdio server started\n')
|
||||
} catch (error) {
|
||||
process.stderr.write(`[CipherTalk MCP] startup failed: ${String(error)}\n`)
|
||||
await shutdown(1)
|
||||
}
|
||||
}
|
||||
86
electron/services/mcp/dispatcher.ts
Normal file
86
electron/services/mcp/dispatcher.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { getMcpConfigSnapshot, getMcpHealthPayload, getMcpStatusPayload } from './runtime'
|
||||
import { McpReadService } from './readService'
|
||||
import type { McpStreamPartialPayloadMap, McpStreamProgressPayload, McpToolName } from './types'
|
||||
|
||||
const readService = new McpReadService()
|
||||
|
||||
type ExecuteStreamReporter = {
|
||||
progress?: (payload: McpStreamProgressPayload) => void | Promise<void>
|
||||
partial?: <K extends keyof McpStreamPartialPayloadMap>(toolName: K, payload: McpStreamPartialPayloadMap[K]) => void | Promise<void>
|
||||
}
|
||||
|
||||
export async function executeMcpTool(
|
||||
toolName: McpToolName,
|
||||
args: Record<string, unknown> = {},
|
||||
reporter?: ExecuteStreamReporter
|
||||
) {
|
||||
switch (toolName) {
|
||||
case 'health_check': {
|
||||
const payload = getMcpHealthPayload()
|
||||
return { summary: 'CipherTalk MCP health is available.', payload }
|
||||
}
|
||||
case 'get_status': {
|
||||
const payload = getMcpStatusPayload()
|
||||
return { summary: 'CipherTalk MCP status loaded.', payload }
|
||||
}
|
||||
case 'resolve_session': {
|
||||
const payload = await readService.resolveSession(args as any, reporter)
|
||||
return {
|
||||
summary: payload.recommended
|
||||
? `Resolved ${payload.query} to ${payload.recommended.displayName}.`
|
||||
: `Found ${payload.candidates.length} candidates for ${payload.query}.`,
|
||||
payload
|
||||
}
|
||||
}
|
||||
case 'export_chat': {
|
||||
const payload = await readService.exportChat(args as any, reporter)
|
||||
return {
|
||||
summary: payload.success
|
||||
? `Exported chat for ${payload.resolvedSession?.displayName || payload.resolvedSession?.sessionId || 'target session'}.`
|
||||
: payload.success === false
|
||||
? `Failed to export chat for ${payload.resolvedSession?.displayName || payload.resolvedSession?.sessionId || 'target session'}.`
|
||||
: payload.canExport
|
||||
? `Prepared export for ${payload.resolvedSession?.displayName || payload.resolvedSession?.sessionId || 'target session'}.`
|
||||
: 'Export request needs more information.',
|
||||
payload
|
||||
}
|
||||
}
|
||||
case 'get_global_statistics': {
|
||||
const payload = await readService.getGlobalStatistics(args as any)
|
||||
return { summary: 'Loaded global statistics.', payload }
|
||||
}
|
||||
case 'get_contact_rankings': {
|
||||
const payload = await readService.getContactRankings(args as any)
|
||||
return { summary: `Loaded ${payload.items.length} contact rankings.`, payload }
|
||||
}
|
||||
case 'get_activity_distribution': {
|
||||
const payload = await readService.getActivityDistribution(args as any)
|
||||
return { summary: 'Loaded activity distribution.', payload }
|
||||
}
|
||||
case 'list_sessions': {
|
||||
const payload = await readService.listSessions(args as any, reporter)
|
||||
return { summary: `Loaded ${payload.items.length} sessions.`, payload }
|
||||
}
|
||||
case 'get_messages': {
|
||||
const defaults = getMcpConfigSnapshot()
|
||||
const payload = await readService.getMessages(args as any, defaults.mcpExposeMediaPaths, reporter)
|
||||
return { summary: `Loaded ${payload.items.length} messages.`, payload }
|
||||
}
|
||||
case 'list_contacts': {
|
||||
const payload = await readService.listContacts(args as any, reporter)
|
||||
return { summary: `Loaded ${payload.items.length} contacts.`, payload }
|
||||
}
|
||||
case 'search_messages': {
|
||||
const defaults = getMcpConfigSnapshot()
|
||||
const payload = await readService.searchMessages(args as any, defaults.mcpExposeMediaPaths, reporter)
|
||||
return { summary: `Loaded ${payload.hits.length} message hits.`, payload }
|
||||
}
|
||||
case 'get_session_context': {
|
||||
const defaults = getMcpConfigSnapshot()
|
||||
const payload = await readService.getSessionContext(args as any, defaults.mcpExposeMediaPaths, reporter)
|
||||
return { summary: `Loaded ${payload.items.length} context messages.`, payload }
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unsupported MCP tool: ${toolName satisfies never}`)
|
||||
}
|
||||
}
|
||||
359
electron/services/mcp/proxyService.ts
Normal file
359
electron/services/mcp/proxyService.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
import * as http from 'http'
|
||||
import type { Socket } from 'net'
|
||||
import { McpToolError } from './result'
|
||||
import { executeMcpTool } from './dispatcher'
|
||||
import { MCP_TOOL_NAMES, type McpStreamEvent, type McpStreamPartialPayloadMap, type McpStreamProgressPayload, type McpToolName } from './types'
|
||||
|
||||
type ProxySettings = {
|
||||
host: '127.0.0.1'
|
||||
port: number
|
||||
token: string
|
||||
}
|
||||
|
||||
type ProxyLogger = {
|
||||
info(category: string, message: string, data?: any): void
|
||||
warn(category: string, message: string, data?: any): void
|
||||
error(category: string, message: string, data?: any): void
|
||||
}
|
||||
|
||||
class McpProxyService {
|
||||
private server: http.Server | null = null
|
||||
private readonly connections = new Set<Socket>()
|
||||
private logger: ProxyLogger | null = null
|
||||
private startedAt = 0
|
||||
private lastError = ''
|
||||
private settings: ProxySettings = {
|
||||
host: '127.0.0.1',
|
||||
port: 5032,
|
||||
token: ''
|
||||
}
|
||||
|
||||
setLogger(logger: ProxyLogger | null): void {
|
||||
this.logger = logger
|
||||
}
|
||||
|
||||
applySettings(next: Partial<ProxySettings>): void {
|
||||
this.settings = {
|
||||
...this.settings,
|
||||
...next,
|
||||
host: '127.0.0.1'
|
||||
}
|
||||
}
|
||||
|
||||
isRunning(): boolean {
|
||||
return Boolean(this.server)
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
return {
|
||||
running: this.isRunning(),
|
||||
host: this.settings.host,
|
||||
port: this.settings.port,
|
||||
startedAt: this.startedAt,
|
||||
tokenConfigured: Boolean(this.settings.token),
|
||||
lastError: this.lastError
|
||||
}
|
||||
}
|
||||
|
||||
async start(): Promise<{ success: boolean; error?: string }> {
|
||||
if (this.server) {
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const server = http.createServer((req, res) => {
|
||||
void this.handleRequest(req, res)
|
||||
})
|
||||
|
||||
server.on('connection', (socket) => {
|
||||
this.connections.add(socket)
|
||||
socket.on('close', () => this.connections.delete(socket))
|
||||
})
|
||||
|
||||
server.on('error', (err: NodeJS.ErrnoException) => {
|
||||
this.lastError = err.message
|
||||
this.logger?.error('McpProxy', '内部 MCP 代理启动失败', { error: err.message, code: err.code })
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
resolve({ success: false, error: `端口 ${this.settings.port} 已被占用` })
|
||||
return
|
||||
}
|
||||
resolve({ success: false, error: err.message })
|
||||
})
|
||||
|
||||
server.listen(this.settings.port, this.settings.host, () => {
|
||||
this.server = server
|
||||
this.startedAt = Date.now()
|
||||
this.lastError = ''
|
||||
this.logger?.info('McpProxy', '内部 MCP 代理已启动', {
|
||||
host: this.settings.host,
|
||||
port: this.settings.port
|
||||
})
|
||||
resolve({ success: true })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!this.server) return
|
||||
|
||||
const currentServer = this.server
|
||||
this.server = null
|
||||
|
||||
const sockets = Array.from(this.connections)
|
||||
this.connections.clear()
|
||||
sockets.forEach((socket) => {
|
||||
try {
|
||||
socket.destroy()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
currentServer.close(() => resolve())
|
||||
})
|
||||
|
||||
this.logger?.info('McpProxy', '内部 MCP 代理已停止')
|
||||
}
|
||||
|
||||
private async readJson(req: http.IncomingMessage): Promise<Record<string, unknown>> {
|
||||
const chunks: Buffer[] = []
|
||||
for await (const chunk of req) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
|
||||
}
|
||||
|
||||
if (chunks.length === 0) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const raw = Buffer.concat(chunks).toString('utf8').trim()
|
||||
if (!raw) return {}
|
||||
|
||||
const parsed = JSON.parse(raw)
|
||||
return parsed && typeof parsed === 'object' ? parsed : {}
|
||||
}
|
||||
|
||||
private createRequestId(): string {
|
||||
return `mcp_proxy_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
|
||||
private sendJson(res: http.ServerResponse, statusCode: number, payload: unknown): void {
|
||||
res.writeHead(statusCode, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Cache-Control': 'no-store'
|
||||
})
|
||||
res.end(JSON.stringify(payload))
|
||||
}
|
||||
|
||||
private sendSse(res: http.ServerResponse, event: McpStreamEvent): void {
|
||||
res.write(`event: ${event.event}\n`)
|
||||
res.write(`data: ${JSON.stringify(event.data)}\n\n`)
|
||||
}
|
||||
|
||||
private isAuthorized(req: http.IncomingMessage): boolean {
|
||||
if (!this.settings.token) return true
|
||||
|
||||
const authHeader = String(req.headers.authorization || '')
|
||||
if (!authHeader.startsWith('Bearer ')) return false
|
||||
return authHeader.slice('Bearer '.length).trim() === this.settings.token
|
||||
}
|
||||
|
||||
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||
const requestId = this.createRequestId()
|
||||
const pathname = new URL(req.url || '/', `http://${this.settings.host}:${this.settings.port}`).pathname
|
||||
const method = req.method || 'GET'
|
||||
|
||||
if (method === 'GET' && pathname === '/health') {
|
||||
this.sendJson(res, 200, {
|
||||
success: true,
|
||||
data: {
|
||||
ok: true,
|
||||
startedAt: this.startedAt,
|
||||
uptimeMs: this.startedAt ? Date.now() - this.startedAt : 0
|
||||
},
|
||||
meta: { requestId, ts: Date.now() }
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.isAuthorized(req)) {
|
||||
this.logger?.warn('McpProxy', '内部 MCP 代理鉴权失败', { pathname, method })
|
||||
this.sendJson(res, 401, {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: 'Unauthorized MCP proxy request',
|
||||
hint: 'Provide Authorization: Bearer <token>'
|
||||
},
|
||||
meta: { requestId, ts: Date.now() }
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (method === 'GET' && pathname === '/status') {
|
||||
this.sendJson(res, 200, {
|
||||
success: true,
|
||||
data: this.getStatus(),
|
||||
meta: { requestId, ts: Date.now() }
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if ((method !== 'POST' && method !== 'GET') || !pathname.startsWith('/tool/')) {
|
||||
this.sendJson(res, 404, {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Unknown MCP proxy endpoint'
|
||||
},
|
||||
meta: { requestId, ts: Date.now() }
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const isStreamRequest = pathname.endsWith('/stream')
|
||||
const toolPath = isStreamRequest ? pathname.slice(0, -'/stream'.length) : pathname
|
||||
const toolName = toolPath.slice('/tool/'.length) as McpToolName
|
||||
if (!MCP_TOOL_NAMES.includes(toolName)) {
|
||||
this.sendJson(res, 404, {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'BAD_REQUEST',
|
||||
message: `Unsupported MCP tool: ${toolName}`
|
||||
},
|
||||
meta: { requestId, ts: Date.now() }
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const body = method === 'POST' ? await this.readJson(req) : {}
|
||||
const args = body.args && typeof body.args === 'object' ? body.args as Record<string, unknown> : {}
|
||||
if (isStreamRequest) {
|
||||
await this.handleStreamRequest(toolName, args, requestId, res)
|
||||
return
|
||||
}
|
||||
const startedAt = Date.now()
|
||||
const result = await executeMcpTool(toolName, args)
|
||||
this.logger?.info('McpProxy', '内部 MCP 代理查询成功', {
|
||||
toolName,
|
||||
durationMs: Date.now() - startedAt
|
||||
})
|
||||
this.sendJson(res, 200, {
|
||||
success: true,
|
||||
data: result.payload,
|
||||
summary: result.summary,
|
||||
meta: { requestId, ts: Date.now() }
|
||||
})
|
||||
} catch (error) {
|
||||
const payload = error instanceof McpToolError
|
||||
? error.toShape()
|
||||
: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: String(error)
|
||||
}
|
||||
|
||||
this.logger?.error('McpProxy', '内部 MCP 代理查询失败', {
|
||||
toolName,
|
||||
error: payload
|
||||
})
|
||||
|
||||
this.sendJson(res, payload.code === 'BAD_REQUEST' ? 400 : 503, {
|
||||
success: false,
|
||||
error: payload,
|
||||
meta: { requestId, ts: Date.now() }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private async handleStreamRequest(
|
||||
toolName: McpToolName,
|
||||
args: Record<string, unknown>,
|
||||
requestId: string,
|
||||
res: http.ServerResponse
|
||||
): Promise<void> {
|
||||
const startedAt = Date.now()
|
||||
let chunkIndex = 0
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
Connection: 'keep-alive'
|
||||
})
|
||||
|
||||
this.sendSse(res, {
|
||||
event: 'meta',
|
||||
data: {
|
||||
toolName,
|
||||
requestId,
|
||||
startedAt
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await executeMcpTool(toolName, args, {
|
||||
progress: async (payload: McpStreamProgressPayload) => {
|
||||
this.sendSse(res, {
|
||||
event: 'progress',
|
||||
data: payload
|
||||
})
|
||||
},
|
||||
partial: async <K extends keyof McpStreamPartialPayloadMap>(partialToolName: K, payload: McpStreamPartialPayloadMap[K]) => {
|
||||
chunkIndex += 1
|
||||
this.sendSse(res, {
|
||||
event: 'partial',
|
||||
data: {
|
||||
toolName: partialToolName,
|
||||
chunkIndex,
|
||||
payload
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
this.sendSse(res, {
|
||||
event: 'progress',
|
||||
data: {
|
||||
stage: 'completed',
|
||||
message: `Completed ${toolName}.`
|
||||
}
|
||||
})
|
||||
this.sendSse(res, {
|
||||
event: 'complete',
|
||||
data: {
|
||||
toolName,
|
||||
summary: result.summary,
|
||||
payload: result.payload,
|
||||
completedAt: Date.now()
|
||||
}
|
||||
})
|
||||
res.end()
|
||||
} catch (error) {
|
||||
const payload = error instanceof McpToolError
|
||||
? error.toShape()
|
||||
: {
|
||||
code: 'INTERNAL_ERROR' as const,
|
||||
message: String(error)
|
||||
}
|
||||
|
||||
this.sendSse(res, {
|
||||
event: 'progress',
|
||||
data: {
|
||||
stage: 'failed',
|
||||
message: `Failed ${toolName}.`
|
||||
}
|
||||
})
|
||||
this.sendSse(res, {
|
||||
event: 'error',
|
||||
data: {
|
||||
...payload,
|
||||
toolName,
|
||||
failedAt: Date.now()
|
||||
}
|
||||
})
|
||||
res.end()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const mcpProxyService = new McpProxyService()
|
||||
1954
electron/services/mcp/readService.ts
Normal file
1954
electron/services/mcp/readService.ts
Normal file
File diff suppressed because it is too large
Load Diff
52
electron/services/mcp/result.ts
Normal file
52
electron/services/mcp/result.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { McpErrorCode, McpErrorShape } from './types'
|
||||
|
||||
export class McpToolError extends Error {
|
||||
code: McpErrorCode
|
||||
hint?: string
|
||||
|
||||
constructor(code: McpErrorCode, message: string, hint?: string) {
|
||||
super(message)
|
||||
this.name = 'McpToolError'
|
||||
this.code = code
|
||||
this.hint = hint
|
||||
}
|
||||
|
||||
toShape(): McpErrorShape {
|
||||
return {
|
||||
code: this.code,
|
||||
message: this.message,
|
||||
hint: this.hint
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toStructuredContent(data: unknown): Record<string, unknown> {
|
||||
if (data && typeof data === 'object') {
|
||||
return data as Record<string, unknown>
|
||||
}
|
||||
|
||||
return { value: data }
|
||||
}
|
||||
|
||||
export function createToolSuccess(summary: string, data: unknown) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: summary }],
|
||||
structuredContent: toStructuredContent(data),
|
||||
isError: false
|
||||
}
|
||||
}
|
||||
|
||||
export function createToolError(error: unknown) {
|
||||
const payload = error instanceof McpToolError
|
||||
? error.toShape()
|
||||
: {
|
||||
code: 'INTERNAL_ERROR' as const,
|
||||
message: String(error)
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: payload.message }],
|
||||
structuredContent: payload,
|
||||
isError: true
|
||||
}
|
||||
}
|
||||
222
electron/services/mcp/runtime.ts
Normal file
222
electron/services/mcp/runtime.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { dirname, join } from 'path'
|
||||
import { existsSync } from 'fs'
|
||||
import { ConfigService } from '../config'
|
||||
import { getAppPath, getAppVersion, getDocumentsPath, getExePath, isElectronPackaged } from '../runtimePaths'
|
||||
import type { McpHealthPayload, McpLaunchConfig, McpLauncherMode, McpStatusPayload } from './types'
|
||||
import { MCP_TOOL_NAMES } from './types'
|
||||
|
||||
const MCP_SERVICE_NAME = 'ciphertalk-mcp'
|
||||
export const DEFAULT_MCP_PROXY_PORT = 5032
|
||||
export const DEFAULT_MCP_PROXY_TIMEOUT_MS = 30000
|
||||
|
||||
export interface McpProxyConfig {
|
||||
host: '127.0.0.1'
|
||||
port: number
|
||||
url: string
|
||||
token: string
|
||||
timeoutMs: number
|
||||
}
|
||||
|
||||
function cleanAccountDirName(dirName: string): string {
|
||||
const trimmed = dirName.trim()
|
||||
if (!trimmed) return trimmed
|
||||
|
||||
if (trimmed.toLowerCase().startsWith('wxid_')) {
|
||||
const match = trimmed.match(/^(wxid_[a-zA-Z0-9]+)/i)
|
||||
if (match) return match[1]
|
||||
return trimmed
|
||||
}
|
||||
|
||||
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
||||
if (suffixMatch) return suffixMatch[1]
|
||||
return trimmed
|
||||
}
|
||||
|
||||
function findAccountDir(baseDir: string, wxid: string): string | null {
|
||||
if (!existsSync(baseDir)) return null
|
||||
|
||||
const cleanedWxid = cleanAccountDirName(wxid)
|
||||
const directCandidates = [wxid]
|
||||
|
||||
if (cleanedWxid && cleanedWxid !== wxid) {
|
||||
directCandidates.push(cleanedWxid)
|
||||
}
|
||||
|
||||
for (const candidate of directCandidates) {
|
||||
if (existsSync(join(baseDir, candidate))) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const fs = require('fs') as typeof import('fs')
|
||||
const entries = fs.readdirSync(baseDir, { withFileTypes: true })
|
||||
const wxidLower = wxid.toLowerCase()
|
||||
const cleanedLower = cleanedWxid.toLowerCase()
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue
|
||||
const dirName = entry.name
|
||||
const dirLower = dirName.toLowerCase()
|
||||
const cleanedDirLower = cleanAccountDirName(dirName).toLowerCase()
|
||||
|
||||
if (dirLower === wxidLower || dirLower === cleanedLower) return dirName
|
||||
if (dirLower.startsWith(`${wxidLower}_`) || dirLower.startsWith(`${cleanedLower}_`)) return dirName
|
||||
if (cleanedDirLower === wxidLower || cleanedDirLower === cleanedLower) return dirName
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getDecryptedDbDir(configService: ConfigService): string {
|
||||
const cachePath = String(configService.get('cachePath') || '')
|
||||
if (cachePath) return cachePath
|
||||
|
||||
if (!isElectronPackaged()) {
|
||||
return join(getDocumentsPath(), 'CipherTalkData')
|
||||
}
|
||||
|
||||
const installDir = dirname(getExePath())
|
||||
const isOnCDrive = /^[cC]:/i.test(installDir) || installDir.startsWith('\\')
|
||||
if (isOnCDrive) {
|
||||
return join(getDocumentsPath(), 'CipherTalkData')
|
||||
}
|
||||
|
||||
return join(installDir, 'CipherTalkData')
|
||||
}
|
||||
|
||||
function getLauncherMode(): McpLauncherMode {
|
||||
const mode = String(process.env.CIPHERTALK_MCP_LAUNCHER || '').trim()
|
||||
if (mode === 'dev-runner' || mode === 'packaged-launcher') {
|
||||
return mode
|
||||
}
|
||||
|
||||
return 'direct'
|
||||
}
|
||||
|
||||
function getRuntimeWarnings(config: { mcpEnabled: boolean; dbReady: boolean }): string[] {
|
||||
const warnings: string[] = []
|
||||
|
||||
if (!config.mcpEnabled) {
|
||||
warnings.push('MCP is not marked as enabled in Settings. Calls still work, but hosts should treat this as informational.')
|
||||
}
|
||||
|
||||
if (!config.dbReady) {
|
||||
warnings.push('Chat database is not ready yet. Data tools may return DB_NOT_READY until setup is complete.')
|
||||
}
|
||||
|
||||
return warnings
|
||||
}
|
||||
|
||||
function parsePositiveInt(value: unknown, fallback: number): number {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return fallback
|
||||
return Math.floor(parsed)
|
||||
}
|
||||
|
||||
export function getPackagedLauncherPath(): string {
|
||||
return join(dirname(getExePath()), 'ciphertalk-mcp.cmd')
|
||||
}
|
||||
|
||||
export function getMcpLaunchConfig(): McpLaunchConfig {
|
||||
if (isElectronPackaged()) {
|
||||
return {
|
||||
command: getPackagedLauncherPath(),
|
||||
args: [],
|
||||
cwd: dirname(getExePath()),
|
||||
mode: 'packaged'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
command: 'npm',
|
||||
args: ['run', 'mcp'],
|
||||
cwd: getAppPath(),
|
||||
mode: 'dev'
|
||||
}
|
||||
}
|
||||
|
||||
export function getMcpConfigSnapshot() {
|
||||
const configService = new ConfigService()
|
||||
try {
|
||||
const mcpEnabled = Boolean(configService.get('mcpEnabled'))
|
||||
const mcpExposeMediaPaths = configService.get('mcpExposeMediaPaths') !== false
|
||||
const myWxid = String(configService.get('myWxid') || '')
|
||||
const decryptedBaseDir = getDecryptedDbDir(configService)
|
||||
|
||||
let dbReady = false
|
||||
if (myWxid) {
|
||||
const accountDir = findAccountDir(decryptedBaseDir, myWxid)
|
||||
if (accountDir) {
|
||||
dbReady = existsSync(join(decryptedBaseDir, accountDir, 'session.db'))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
mcpEnabled,
|
||||
mcpExposeMediaPaths,
|
||||
dbReady
|
||||
}
|
||||
} finally {
|
||||
configService.close()
|
||||
}
|
||||
}
|
||||
|
||||
export function getMcpProxyConfig(config?: ConfigService): McpProxyConfig {
|
||||
const configService = config || new ConfigService()
|
||||
|
||||
try {
|
||||
const host = '127.0.0.1' as const
|
||||
const port = parsePositiveInt(
|
||||
process.env.CIPHERTALK_MCP_PROXY_PORT || configService.get('mcpProxyPort'),
|
||||
DEFAULT_MCP_PROXY_PORT
|
||||
)
|
||||
const token = String(process.env.CIPHERTALK_MCP_PROXY_TOKEN || configService.get('mcpProxyToken') || '')
|
||||
const timeoutMs = parsePositiveInt(
|
||||
process.env.CIPHERTALK_MCP_PROXY_TIMEOUT_MS,
|
||||
DEFAULT_MCP_PROXY_TIMEOUT_MS
|
||||
)
|
||||
|
||||
return {
|
||||
host,
|
||||
port,
|
||||
url: process.env.CIPHERTALK_MCP_PROXY_URL || `http://${host}:${port}`,
|
||||
token,
|
||||
timeoutMs
|
||||
}
|
||||
} finally {
|
||||
if (!config) {
|
||||
configService.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getMcpHealthPayload(): McpHealthPayload {
|
||||
const config = getMcpConfigSnapshot()
|
||||
return {
|
||||
ok: true,
|
||||
service: MCP_SERVICE_NAME,
|
||||
version: getAppVersion(),
|
||||
warnings: getRuntimeWarnings(config)
|
||||
}
|
||||
}
|
||||
|
||||
export function getMcpStatusPayload(): McpStatusPayload {
|
||||
const config = getMcpConfigSnapshot()
|
||||
return {
|
||||
runtime: {
|
||||
pid: process.pid,
|
||||
platform: process.platform,
|
||||
appMode: isElectronPackaged() ? 'packaged' : 'dev',
|
||||
launcherMode: getLauncherMode()
|
||||
},
|
||||
config,
|
||||
capabilities: {
|
||||
tools: [...MCP_TOOL_NAMES]
|
||||
},
|
||||
warnings: getRuntimeWarnings(config)
|
||||
}
|
||||
}
|
||||
13
electron/services/mcp/server.ts
Normal file
13
electron/services/mcp/server.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import { getAppVersion } from '../runtimePaths'
|
||||
import { registerCipherTalkMcpTools } from './tools'
|
||||
|
||||
export function createCipherTalkMcpServer() {
|
||||
const server = new McpServer({
|
||||
name: 'ciphertalk-mcp',
|
||||
version: getAppVersion()
|
||||
})
|
||||
|
||||
registerCipherTalkMcpTools(server)
|
||||
return server
|
||||
}
|
||||
329
electron/services/mcp/service.ts
Normal file
329
electron/services/mcp/service.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
import { spawn } from 'child_process'
|
||||
import { dirname } from 'path'
|
||||
import { getAppPath, getExePath, isElectronPackaged } from '../runtimePaths'
|
||||
import { McpToolError } from './result'
|
||||
import { getMcpProxyConfig } from './runtime'
|
||||
import type {
|
||||
McpActivityDistributionPayload,
|
||||
McpContactRankingsPayload,
|
||||
McpContactsPayload,
|
||||
McpExportChatPayload,
|
||||
McpGlobalStatisticsPayload,
|
||||
McpHealthPayload,
|
||||
McpMessagesPayload,
|
||||
McpResolveSessionPayload,
|
||||
McpStreamEvent,
|
||||
McpSearchMessagesPayload,
|
||||
McpSessionContextPayload,
|
||||
McpSessionsPayload,
|
||||
McpStatusPayload,
|
||||
McpToolName
|
||||
} from './types'
|
||||
|
||||
type ProxyEnvelopeSuccess<T> = {
|
||||
success: true
|
||||
data: T
|
||||
summary?: string
|
||||
meta?: {
|
||||
requestId: string
|
||||
ts: number
|
||||
}
|
||||
}
|
||||
|
||||
type ProxyEnvelopeError = {
|
||||
success: false
|
||||
error?: {
|
||||
code?: string
|
||||
message?: string
|
||||
hint?: string
|
||||
}
|
||||
}
|
||||
|
||||
type StreamToolOptions = {
|
||||
signal?: AbortSignal
|
||||
onEvent?: (event: McpStreamEvent) => void
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function findSseDelimiterIndex(buffer: string): { index: number; length: number } | null {
|
||||
const crlfIndex = buffer.indexOf('\r\n\r\n')
|
||||
if (crlfIndex >= 0) {
|
||||
return { index: crlfIndex, length: 4 }
|
||||
}
|
||||
|
||||
const lfIndex = buffer.indexOf('\n\n')
|
||||
if (lfIndex >= 0) {
|
||||
return { index: lfIndex, length: 2 }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getSpawnEnv(): NodeJS.ProcessEnv {
|
||||
const env = { ...process.env }
|
||||
delete env.ELECTRON_RUN_AS_NODE
|
||||
delete env.CIPHERTALK_MCP_ENTRY
|
||||
delete env.CIPHERTALK_MCP_LAUNCHER
|
||||
return env
|
||||
}
|
||||
|
||||
async function isProxyHealthy(url: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${url}/health`, {
|
||||
method: 'GET'
|
||||
})
|
||||
if (!response.ok) return false
|
||||
const payload = await response.json() as ProxyEnvelopeSuccess<{ ok: boolean }>
|
||||
return Boolean(payload.success && payload.data?.ok)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function launchMainApplication(): void {
|
||||
if (isElectronPackaged()) {
|
||||
const exePath = getExePath()
|
||||
spawn(exePath, [], {
|
||||
cwd: dirname(exePath),
|
||||
env: getSpawnEnv(),
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true
|
||||
}).unref()
|
||||
return
|
||||
}
|
||||
|
||||
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm'
|
||||
spawn(npmCmd, ['run', 'electron:dev'], {
|
||||
cwd: getAppPath(),
|
||||
env: getSpawnEnv(),
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true
|
||||
}).unref()
|
||||
}
|
||||
|
||||
export class McpReadService {
|
||||
private launchAttempted = false
|
||||
|
||||
private async ensureProxyReady(requireAuth = true) {
|
||||
let proxyConfig = getMcpProxyConfig()
|
||||
if (await isProxyHealthy(proxyConfig.url)) {
|
||||
if (!requireAuth) return proxyConfig
|
||||
if (proxyConfig.token) return proxyConfig
|
||||
}
|
||||
|
||||
if (!this.launchAttempted) {
|
||||
this.launchAttempted = true
|
||||
process.stderr.write('[CipherTalk MCP] proxy unavailable, launching desktop app\n')
|
||||
launchMainApplication()
|
||||
}
|
||||
|
||||
const startedAt = Date.now()
|
||||
while (Date.now() - startedAt < proxyConfig.timeoutMs) {
|
||||
await sleep(500)
|
||||
proxyConfig = getMcpProxyConfig()
|
||||
if (await isProxyHealthy(proxyConfig.url)) {
|
||||
if (!requireAuth || proxyConfig.token) {
|
||||
return proxyConfig
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new McpToolError(
|
||||
'APP_NOT_RUNNING',
|
||||
'CipherTalk 主应用未就绪,无法代理查询。',
|
||||
'已尝试自动拉起主应用,但内部 MCP 代理未在限定时间内就绪。'
|
||||
)
|
||||
}
|
||||
|
||||
private async callProxy<T>(toolName: McpToolName, args: Record<string, unknown> = {}): Promise<T> {
|
||||
const proxyConfig = await this.ensureProxyReady(toolName !== 'health_check')
|
||||
|
||||
const response = await fetch(`${proxyConfig.url}/tool/${toolName}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(proxyConfig.token ? { Authorization: `Bearer ${proxyConfig.token}` } : {})
|
||||
},
|
||||
body: JSON.stringify({ args })
|
||||
})
|
||||
|
||||
let payload: ProxyEnvelopeSuccess<T> | ProxyEnvelopeError
|
||||
try {
|
||||
payload = await response.json() as ProxyEnvelopeSuccess<T> | ProxyEnvelopeError
|
||||
} catch (error) {
|
||||
throw new McpToolError('INTERNAL_ERROR', '内部 MCP 代理返回了无效响应。', String(error))
|
||||
}
|
||||
|
||||
if (!response.ok || !('success' in payload) || !payload.success) {
|
||||
const code = payload && 'error' in payload ? String(payload.error?.code || 'INTERNAL_ERROR') : 'INTERNAL_ERROR'
|
||||
const message = payload && 'error' in payload ? String(payload.error?.message || '内部 MCP 代理请求失败。') : '内部 MCP 代理请求失败。'
|
||||
const hint = payload && 'error' in payload ? payload.error?.hint : undefined
|
||||
throw new McpToolError(
|
||||
code === 'APP_NOT_RUNNING' || code === 'DB_NOT_READY' || code === 'SESSION_NOT_FOUND' || code === 'BAD_REQUEST'
|
||||
? code
|
||||
: 'INTERNAL_ERROR',
|
||||
message,
|
||||
hint
|
||||
)
|
||||
}
|
||||
|
||||
return payload.data
|
||||
}
|
||||
|
||||
async streamTool(
|
||||
toolName: McpToolName,
|
||||
args: Record<string, unknown> = {},
|
||||
options: StreamToolOptions = {}
|
||||
): Promise<unknown> {
|
||||
const proxyConfig = await this.ensureProxyReady(toolName !== 'health_check')
|
||||
const response = await fetch(`${proxyConfig.url}/tool/${toolName}/stream`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'text/event-stream',
|
||||
...(proxyConfig.token ? { Authorization: `Bearer ${proxyConfig.token}` } : {})
|
||||
},
|
||||
body: JSON.stringify({ args }),
|
||||
signal: options.signal
|
||||
})
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
return this.callProxy(toolName, args)
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
const reader = response.body.getReader()
|
||||
let buffer = ''
|
||||
let finalPayload: unknown
|
||||
|
||||
const flushEvent = async (rawBlock: string) => {
|
||||
const lines = rawBlock.split(/\r?\n/)
|
||||
let eventName = 'message'
|
||||
const dataLines: string[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event:')) {
|
||||
eventName = line.slice('event:'.length).trim()
|
||||
} else if (line.startsWith('data:')) {
|
||||
dataLines.push(line.slice('data:'.length).trim())
|
||||
}
|
||||
}
|
||||
|
||||
if (dataLines.length === 0) return
|
||||
const parsed = JSON.parse(dataLines.join('\n')) as McpStreamEvent['data']
|
||||
const event = { event: eventName, data: parsed } as McpStreamEvent
|
||||
options.onEvent?.(event)
|
||||
|
||||
if (event.event === 'error') {
|
||||
throw new McpToolError(event.data.code, event.data.message, event.data.hint)
|
||||
}
|
||||
|
||||
if (event.event === 'complete') {
|
||||
finalPayload = event.data.payload
|
||||
}
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
|
||||
let delimiter = findSseDelimiterIndex(buffer)
|
||||
while (delimiter) {
|
||||
const block = buffer.slice(0, delimiter.index).trim()
|
||||
buffer = buffer.slice(delimiter.index + delimiter.length)
|
||||
if (block) {
|
||||
await flushEvent(block)
|
||||
}
|
||||
delimiter = findSseDelimiterIndex(buffer)
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer.trim()) {
|
||||
await flushEvent(buffer.trim())
|
||||
}
|
||||
|
||||
return finalPayload
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<McpHealthPayload> {
|
||||
const proxyConfig = await this.ensureProxyReady(false)
|
||||
const response = await fetch(`${proxyConfig.url}/status`, {
|
||||
method: 'GET',
|
||||
headers: proxyConfig.token ? { Authorization: `Bearer ${proxyConfig.token}` } : {}
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new McpToolError('APP_NOT_RUNNING', 'CipherTalk 主应用内部 MCP 代理不可用。')
|
||||
}
|
||||
return this.callProxy<McpHealthPayload>('health_check')
|
||||
}
|
||||
|
||||
async getStatus(): Promise<McpStatusPayload> {
|
||||
return this.callProxy<McpStatusPayload>('get_status')
|
||||
}
|
||||
|
||||
async resolveSession(rawArgs: Record<string, unknown>): Promise<McpResolveSessionPayload> {
|
||||
return this.callProxy<McpResolveSessionPayload>('resolve_session', rawArgs)
|
||||
}
|
||||
|
||||
async exportChat(rawArgs: Record<string, unknown>): Promise<McpExportChatPayload> {
|
||||
return this.callProxy<McpExportChatPayload>('export_chat', rawArgs)
|
||||
}
|
||||
|
||||
async listSessions(rawArgs: Record<string, unknown>): Promise<McpSessionsPayload> {
|
||||
return this.callProxy<McpSessionsPayload>('list_sessions', rawArgs)
|
||||
}
|
||||
|
||||
async listContacts(rawArgs: Record<string, unknown>): Promise<McpContactsPayload> {
|
||||
return this.callProxy<McpContactsPayload>('list_contacts', rawArgs)
|
||||
}
|
||||
|
||||
async getGlobalStatistics(rawArgs: Record<string, unknown>): Promise<McpGlobalStatisticsPayload> {
|
||||
return this.callProxy<McpGlobalStatisticsPayload>('get_global_statistics', rawArgs)
|
||||
}
|
||||
|
||||
async getContactRankings(rawArgs: Record<string, unknown>): Promise<McpContactRankingsPayload> {
|
||||
return this.callProxy<McpContactRankingsPayload>('get_contact_rankings', rawArgs)
|
||||
}
|
||||
|
||||
async getActivityDistribution(rawArgs: Record<string, unknown>): Promise<McpActivityDistributionPayload> {
|
||||
return this.callProxy<McpActivityDistributionPayload>('get_activity_distribution', rawArgs)
|
||||
}
|
||||
|
||||
async getMessages(rawArgs: Record<string, unknown>, defaultIncludeMediaPaths: boolean): Promise<McpMessagesPayload> {
|
||||
return this.callProxy<McpMessagesPayload>('get_messages', {
|
||||
...rawArgs,
|
||||
includeMediaPaths: rawArgs.includeMediaPaths ?? defaultIncludeMediaPaths
|
||||
})
|
||||
}
|
||||
|
||||
async searchMessages(rawArgs: Record<string, unknown>, defaultIncludeMediaPaths: boolean): Promise<McpSearchMessagesPayload> {
|
||||
return this.callProxy<McpSearchMessagesPayload>('search_messages', {
|
||||
...rawArgs,
|
||||
includeMediaPaths: rawArgs.includeMediaPaths ?? defaultIncludeMediaPaths
|
||||
})
|
||||
}
|
||||
|
||||
async getSessionContext(rawArgs: Record<string, unknown>, defaultIncludeMediaPaths: boolean): Promise<McpSessionContextPayload> {
|
||||
return this.callProxy<McpSessionContextPayload>('get_session_context', {
|
||||
...rawArgs,
|
||||
includeMediaPaths: rawArgs.includeMediaPaths ?? defaultIncludeMediaPaths
|
||||
})
|
||||
}
|
||||
|
||||
async streamSearchMessages(
|
||||
rawArgs: Record<string, unknown>,
|
||||
defaultIncludeMediaPaths: boolean,
|
||||
options: StreamToolOptions = {}
|
||||
): Promise<McpSearchMessagesPayload> {
|
||||
return this.streamTool('search_messages', {
|
||||
...rawArgs,
|
||||
includeMediaPaths: rawArgs.includeMediaPaths ?? defaultIncludeMediaPaths
|
||||
}, options) as Promise<McpSearchMessagesPayload>
|
||||
}
|
||||
}
|
||||
241
electron/services/mcp/tools.ts
Normal file
241
electron/services/mcp/tools.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { z } from 'zod'
|
||||
import { createToolError, createToolSuccess } from './result'
|
||||
import { getMcpConfigSnapshot } from './runtime'
|
||||
import { McpReadService } from './service'
|
||||
import { MCP_CONTACT_KINDS, MCP_MESSAGE_KINDS } from './types'
|
||||
|
||||
const readService = new McpReadService()
|
||||
|
||||
export function registerCipherTalkMcpTools(server: any) {
|
||||
server.registerTool('health_check', {
|
||||
title: 'Health Check',
|
||||
description: 'Return CipherTalk MCP health information.'
|
||||
}, async () => {
|
||||
try {
|
||||
const payload = await readService.healthCheck()
|
||||
return createToolSuccess('CipherTalk MCP health is available.', payload)
|
||||
} catch (error) {
|
||||
return createToolError(error)
|
||||
}
|
||||
})
|
||||
|
||||
server.registerTool('get_status', {
|
||||
title: 'Get Status',
|
||||
description: 'Return CipherTalk MCP runtime and configuration status.'
|
||||
}, async () => {
|
||||
try {
|
||||
const payload = await readService.getStatus()
|
||||
return createToolSuccess('CipherTalk MCP status loaded.', payload)
|
||||
} catch (error) {
|
||||
return createToolError(error)
|
||||
}
|
||||
})
|
||||
|
||||
server.registerTool('resolve_session', {
|
||||
title: 'Resolve Session',
|
||||
description: 'Resolve a fuzzy person/session clue into the most likely chat session, returning candidates, confidence, and recommended next action.',
|
||||
inputSchema: {
|
||||
query: z.string().trim().min(1).describe('Fuzzy person or session clue. Can be a partial name, nickname, remark fragment, institution fragment, or sessionId.'),
|
||||
limit: z.number().int().positive().optional().describe('Maximum number of candidates to return.')
|
||||
}
|
||||
}, async (args: unknown) => {
|
||||
try {
|
||||
const payload = await readService.resolveSession((args || {}) as any)
|
||||
return createToolSuccess(payload.message, payload)
|
||||
} catch (error) {
|
||||
return createToolError(error)
|
||||
}
|
||||
})
|
||||
|
||||
server.registerTool('export_chat', {
|
||||
title: 'Export Chat',
|
||||
description: 'Validate and export chat history for one resolved session. This tool strictly checks target session, date range, export format, media selections, and output directory before exporting.',
|
||||
inputSchema: {
|
||||
sessionId: z.string().trim().min(1).optional().describe('Resolved sessionId when already known.'),
|
||||
query: z.string().trim().min(1).optional().describe('Fuzzy session clue when sessionId is not yet known.'),
|
||||
format: z.enum(['chatlab', 'chatlab-jsonl', 'json', 'excel', 'html']).optional().describe('Export format.'),
|
||||
dateRange: z.object({
|
||||
start: z.number().int().positive(),
|
||||
end: z.number().int().positive()
|
||||
}).optional().describe('Required export time range in seconds or milliseconds.'),
|
||||
mediaOptions: z.object({
|
||||
exportAvatars: z.boolean().optional(),
|
||||
exportImages: z.boolean().optional(),
|
||||
exportVideos: z.boolean().optional(),
|
||||
exportEmojis: z.boolean().optional(),
|
||||
exportVoices: z.boolean().optional()
|
||||
}).optional().describe('Required explicit media export selections.'),
|
||||
outputDir: z.string().trim().min(1).optional().describe('Optional output directory. If omitted, the configured default export path will be used when available.'),
|
||||
validateOnly: z.boolean().optional().describe('When true, only validate completeness and return missing fields without exporting.')
|
||||
}
|
||||
}, async (args: unknown) => {
|
||||
try {
|
||||
const payload = await readService.exportChat((args || {}) as any)
|
||||
return createToolSuccess(payload.message, payload)
|
||||
} catch (error) {
|
||||
return createToolError(error)
|
||||
}
|
||||
})
|
||||
|
||||
server.registerTool('get_global_statistics', {
|
||||
title: 'Get Global Statistics',
|
||||
description: 'Return global private-chat statistics for agent-side analysis.',
|
||||
inputSchema: {
|
||||
startTime: z.number().int().positive().optional().describe('Optional start timestamp in seconds or milliseconds.'),
|
||||
endTime: z.number().int().positive().optional().describe('Optional end timestamp in seconds or milliseconds.')
|
||||
}
|
||||
}, async (args: unknown) => {
|
||||
try {
|
||||
const payload = await readService.getGlobalStatistics((args || {}) as any)
|
||||
return createToolSuccess('Loaded global statistics.', payload)
|
||||
} catch (error) {
|
||||
return createToolError(error)
|
||||
}
|
||||
})
|
||||
|
||||
server.registerTool('get_contact_rankings', {
|
||||
title: 'Get Contact Rankings',
|
||||
description: 'Return ranked private-chat contacts by message count.',
|
||||
inputSchema: {
|
||||
limit: z.number().int().positive().optional().describe('Maximum number of contacts to return.'),
|
||||
startTime: z.number().int().positive().optional().describe('Optional start timestamp in seconds or milliseconds.'),
|
||||
endTime: z.number().int().positive().optional().describe('Optional end timestamp in seconds or milliseconds.')
|
||||
}
|
||||
}, async (args: unknown) => {
|
||||
try {
|
||||
const payload = await readService.getContactRankings((args || {}) as any)
|
||||
return createToolSuccess(`Loaded ${payload.items.length} contact rankings.`, payload)
|
||||
} catch (error) {
|
||||
return createToolError(error)
|
||||
}
|
||||
})
|
||||
|
||||
server.registerTool('get_activity_distribution', {
|
||||
title: 'Get Activity Distribution',
|
||||
description: 'Return hourly, weekday, and monthly message distributions.',
|
||||
inputSchema: {
|
||||
startTime: z.number().int().positive().optional().describe('Optional start timestamp in seconds or milliseconds.'),
|
||||
endTime: z.number().int().positive().optional().describe('Optional end timestamp in seconds or milliseconds.')
|
||||
}
|
||||
}, async (args: unknown) => {
|
||||
try {
|
||||
const payload = await readService.getActivityDistribution((args || {}) as any)
|
||||
return createToolSuccess('Loaded activity distribution.', payload)
|
||||
} catch (error) {
|
||||
return createToolError(error)
|
||||
}
|
||||
})
|
||||
|
||||
server.registerTool('list_sessions', {
|
||||
title: 'List Sessions',
|
||||
description: 'List chat sessions with search and pagination. Use as a fuzzy discovery entry point when the user only remembers part of a name, remark, institution, or recent clue.',
|
||||
inputSchema: {
|
||||
q: z.string().optional().describe('Optional search keyword.'),
|
||||
offset: z.number().int().nonnegative().optional().describe('Pagination offset.'),
|
||||
limit: z.number().int().positive().optional().describe('Pagination limit.'),
|
||||
unreadOnly: z.boolean().optional().describe('Only return sessions with unread messages.')
|
||||
}
|
||||
}, async (args: unknown) => {
|
||||
try {
|
||||
const payload = await readService.listSessions((args || {}) as any)
|
||||
return createToolSuccess(`Loaded ${payload.items.length} sessions.`, payload)
|
||||
} catch (error) {
|
||||
return createToolError(error)
|
||||
}
|
||||
})
|
||||
|
||||
server.registerTool('get_messages', {
|
||||
title: 'Get Messages',
|
||||
description: 'List messages from one chat session with filters and pagination.',
|
||||
inputSchema: {
|
||||
sessionId: z.string().trim().min(1).describe('Required session identifier. Accepts sessionId, contactId, display name, remark, or nickname when uniquely resolvable.'),
|
||||
offset: z.number().int().nonnegative().optional().describe('Pagination offset.'),
|
||||
limit: z.number().int().positive().optional().describe('Pagination limit.'),
|
||||
order: z.enum(['asc', 'desc']).optional().describe('Message sort order by time.'),
|
||||
keyword: z.string().optional().describe('Optional content keyword filter.'),
|
||||
startTime: z.number().int().positive().optional().describe('Start timestamp in seconds or milliseconds.'),
|
||||
endTime: z.number().int().positive().optional().describe('End timestamp in seconds or milliseconds.'),
|
||||
includeRaw: z.boolean().optional().describe('Include raw message content when true.'),
|
||||
includeMediaPaths: z.boolean().optional().describe('Resolve media local paths when true.')
|
||||
}
|
||||
}, async (args: unknown) => {
|
||||
try {
|
||||
const defaults = getMcpConfigSnapshot()
|
||||
const payload = await readService.getMessages((args || {}) as any, defaults.mcpExposeMediaPaths)
|
||||
return createToolSuccess(`Loaded ${payload.items.length} messages.`, payload)
|
||||
} catch (error) {
|
||||
return createToolError(error)
|
||||
}
|
||||
})
|
||||
|
||||
server.registerTool('list_contacts', {
|
||||
title: 'List Contacts',
|
||||
description: 'List contacts, groups, and official accounts for agent-side resolution. Use as a broad fuzzy lookup entry point before guessing a specific sessionId.',
|
||||
inputSchema: {
|
||||
q: z.string().optional().describe('Optional search keyword.'),
|
||||
offset: z.number().int().nonnegative().optional().describe('Pagination offset.'),
|
||||
limit: z.number().int().positive().optional().describe('Pagination limit.'),
|
||||
types: z.array(z.enum(MCP_CONTACT_KINDS)).optional().describe('Optional contact kinds to include.')
|
||||
}
|
||||
}, async (args: unknown) => {
|
||||
try {
|
||||
const payload = await readService.listContacts((args || {}) as any)
|
||||
return createToolSuccess(`Loaded ${payload.items.length} contacts.`, payload)
|
||||
} catch (error) {
|
||||
return createToolError(error)
|
||||
}
|
||||
})
|
||||
|
||||
server.registerTool('search_messages', {
|
||||
title: 'Search Messages',
|
||||
description: 'Search messages across one or more sessions and return agent-friendly hits. Use for broad clue hunting when the target session or keyword is still uncertain.',
|
||||
inputSchema: {
|
||||
query: z.string().trim().min(1).describe('Required full-text query.'),
|
||||
sessionId: z.string().trim().min(1).optional().describe('Single session identifier to search. Accepts sessionId, contactId, display name, remark, or nickname when uniquely resolvable.'),
|
||||
sessionIds: z.array(z.string().trim().min(1)).max(20).optional().describe('Multiple session identifiers to search. Each item accepts sessionId, contactId, display name, remark, or nickname when uniquely resolvable.'),
|
||||
startTime: z.number().int().positive().optional().describe('Start timestamp in seconds or milliseconds.'),
|
||||
endTime: z.number().int().positive().optional().describe('End timestamp in seconds or milliseconds.'),
|
||||
kinds: z.array(z.enum(MCP_MESSAGE_KINDS)).optional().describe('Optional message kinds to include.'),
|
||||
direction: z.enum(['in', 'out']).optional().describe('Optional direction filter.'),
|
||||
senderUsername: z.string().trim().min(1).optional().describe('Optional sender username filter.'),
|
||||
matchMode: z.enum(['substring', 'exact']).optional().describe('Search match mode.'),
|
||||
limit: z.number().int().positive().optional().describe('Maximum number of hits to return.'),
|
||||
includeRaw: z.boolean().optional().describe('Include raw message content when true.'),
|
||||
includeMediaPaths: z.boolean().optional().describe('Resolve media local paths when true.')
|
||||
}
|
||||
}, async (args: unknown) => {
|
||||
try {
|
||||
const defaults = getMcpConfigSnapshot()
|
||||
const payload = await readService.searchMessages((args || {}) as any, defaults.mcpExposeMediaPaths)
|
||||
return createToolSuccess(`Loaded ${payload.hits.length} message hits.`, payload)
|
||||
} catch (error) {
|
||||
return createToolError(error)
|
||||
}
|
||||
})
|
||||
|
||||
server.registerTool('get_session_context', {
|
||||
title: 'Get Session Context',
|
||||
description: 'Return the latest session context or messages around a cursor anchor.',
|
||||
inputSchema: {
|
||||
sessionId: z.string().trim().min(1).describe('Required session identifier. Accepts sessionId, contactId, display name, remark, or nickname when uniquely resolvable.'),
|
||||
mode: z.enum(['latest', 'around']).describe('Context mode.'),
|
||||
anchorCursor: z.object({
|
||||
sortSeq: z.number().int(),
|
||||
createTime: z.number().int().positive(),
|
||||
localId: z.number().int()
|
||||
}).optional().describe('Required cursor when mode=around.'),
|
||||
beforeLimit: z.number().int().positive().optional().describe('Latest count or before-context count.'),
|
||||
afterLimit: z.number().int().positive().optional().describe('After-context count when mode=around.'),
|
||||
includeRaw: z.boolean().optional().describe('Include raw message content when true.'),
|
||||
includeMediaPaths: z.boolean().optional().describe('Resolve media local paths when true.')
|
||||
}
|
||||
}, async (args: unknown) => {
|
||||
try {
|
||||
const defaults = getMcpConfigSnapshot()
|
||||
const payload = await readService.getSessionContext((args || {}) as any, defaults.mcpExposeMediaPaths)
|
||||
return createToolSuccess(`Loaded ${payload.items.length} context messages.`, payload)
|
||||
} catch (error) {
|
||||
return createToolError(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
420
electron/services/mcp/types.ts
Normal file
420
electron/services/mcp/types.ts
Normal file
@@ -0,0 +1,420 @@
|
||||
export const MCP_TOOL_NAMES = [
|
||||
'health_check',
|
||||
'get_status',
|
||||
'resolve_session',
|
||||
'export_chat',
|
||||
'list_sessions',
|
||||
'get_messages',
|
||||
'list_contacts',
|
||||
'search_messages',
|
||||
'get_session_context',
|
||||
'get_global_statistics',
|
||||
'get_contact_rankings',
|
||||
'get_activity_distribution'
|
||||
] as const
|
||||
|
||||
export const MCP_CONTACT_KINDS = [
|
||||
'friend',
|
||||
'group',
|
||||
'official',
|
||||
'former_friend',
|
||||
'other'
|
||||
] as const
|
||||
|
||||
export const MCP_MESSAGE_KINDS = [
|
||||
'text',
|
||||
'image',
|
||||
'voice',
|
||||
'contact_card',
|
||||
'video',
|
||||
'emoji',
|
||||
'location',
|
||||
'voip',
|
||||
'system',
|
||||
'quote',
|
||||
'app_music',
|
||||
'app_link',
|
||||
'app_file',
|
||||
'app_chat_record',
|
||||
'app_mini_program',
|
||||
'app_quote',
|
||||
'app_pat',
|
||||
'app_announcement',
|
||||
'app_gift',
|
||||
'app_transfer',
|
||||
'app_red_packet',
|
||||
'app',
|
||||
'unknown'
|
||||
] as const
|
||||
|
||||
export type McpToolName = (typeof MCP_TOOL_NAMES)[number]
|
||||
export type McpContactKind = (typeof MCP_CONTACT_KINDS)[number]
|
||||
export type McpMessageKind = (typeof MCP_MESSAGE_KINDS)[number]
|
||||
export type McpSearchMatchMode = 'substring' | 'exact'
|
||||
export type McpStreamEventType = 'meta' | 'progress' | 'partial' | 'complete' | 'error'
|
||||
export type McpStreamProgressStage =
|
||||
| 'resolving_input'
|
||||
| 'searching_contacts'
|
||||
| 'searching_sessions'
|
||||
| 'resolving_candidates'
|
||||
| 'validating_export_request'
|
||||
| 'preparing_export'
|
||||
| 'scanning_messages'
|
||||
| 'exporting'
|
||||
| 'writing'
|
||||
| 'streaming_hits'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
|
||||
export type McpLaunchMode = 'dev' | 'packaged'
|
||||
export type McpLauncherMode = 'dev-runner' | 'packaged-launcher' | 'direct'
|
||||
export type McpSessionKind = 'friend' | 'group' | 'official' | 'other'
|
||||
export type McpMessageMatchField = 'text' | 'raw'
|
||||
export type McpSessionContextMode = 'latest' | 'around'
|
||||
|
||||
export interface McpLaunchConfig {
|
||||
command: string
|
||||
args: string[]
|
||||
cwd: string
|
||||
mode: McpLaunchMode
|
||||
}
|
||||
|
||||
export type McpErrorCode =
|
||||
| 'BAD_REQUEST'
|
||||
| 'APP_NOT_RUNNING'
|
||||
| 'DB_NOT_READY'
|
||||
| 'SESSION_NOT_FOUND'
|
||||
| 'INTERNAL_ERROR'
|
||||
|
||||
export interface McpErrorShape {
|
||||
code: McpErrorCode
|
||||
message: string
|
||||
hint?: string
|
||||
}
|
||||
|
||||
export interface McpHealthPayload {
|
||||
ok: boolean
|
||||
service: string
|
||||
version: string
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export interface McpStatusPayload {
|
||||
runtime: {
|
||||
pid: number
|
||||
platform: NodeJS.Platform
|
||||
appMode: McpLaunchMode
|
||||
launcherMode: McpLauncherMode
|
||||
}
|
||||
config: {
|
||||
mcpEnabled: boolean
|
||||
mcpExposeMediaPaths: boolean
|
||||
dbReady: boolean
|
||||
}
|
||||
capabilities: {
|
||||
tools: McpToolName[]
|
||||
}
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export interface McpSessionRef {
|
||||
sessionId: string
|
||||
displayName: string
|
||||
kind: McpSessionKind
|
||||
}
|
||||
|
||||
export interface McpSessionItem extends McpSessionRef {
|
||||
lastMessagePreview: string
|
||||
unreadCount: number
|
||||
lastTimestamp: number
|
||||
lastTimestampMs: number
|
||||
}
|
||||
|
||||
export interface McpSessionsPayload {
|
||||
items: McpSessionItem[]
|
||||
total: number
|
||||
offset: number
|
||||
limit: number
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
export interface McpResolvedSessionCandidate extends McpSessionRef {
|
||||
score: number
|
||||
confidence: 'high' | 'medium' | 'low'
|
||||
aliases: string[]
|
||||
evidence: string[]
|
||||
}
|
||||
|
||||
export interface McpResolveSessionPayload {
|
||||
query: string
|
||||
resolved: boolean
|
||||
exact: boolean
|
||||
recommended?: McpResolvedSessionCandidate
|
||||
candidates: McpResolvedSessionCandidate[]
|
||||
suggestedNextAction: 'get_messages' | 'get_session_context' | 'search_messages' | 'list_contacts' | 'list_sessions'
|
||||
message: string
|
||||
}
|
||||
|
||||
export type McpExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'excel' | 'html'
|
||||
|
||||
export interface McpExportMediaOptions {
|
||||
exportAvatars: boolean
|
||||
exportImages: boolean
|
||||
exportVideos: boolean
|
||||
exportEmojis: boolean
|
||||
exportVoices: boolean
|
||||
}
|
||||
|
||||
export type McpExportMissingField =
|
||||
| 'session'
|
||||
| 'dateRange'
|
||||
| 'format'
|
||||
| 'mediaOptions'
|
||||
| 'outputDir'
|
||||
|
||||
export interface McpExportDateRange {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
export interface McpExportChatPayload {
|
||||
canExport: boolean
|
||||
validateOnly: boolean
|
||||
missingFields: McpExportMissingField[]
|
||||
nextQuestion?: string
|
||||
followUpQuestions?: Array<{
|
||||
field: McpExportMissingField
|
||||
question: string
|
||||
}>
|
||||
resolvedSession?: McpResolvedSessionCandidate
|
||||
candidates?: McpResolvedSessionCandidate[]
|
||||
outputDir?: string
|
||||
outputPath?: string
|
||||
format?: McpExportFormat
|
||||
dateRange?: McpExportDateRange
|
||||
mediaOptions?: McpExportMediaOptions
|
||||
success?: boolean
|
||||
successCount?: number
|
||||
failCount?: number
|
||||
error?: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface McpContactItem {
|
||||
contactId: string
|
||||
sessionId?: string
|
||||
hasSession?: boolean
|
||||
displayName: string
|
||||
remark?: string
|
||||
nickname?: string
|
||||
kind: McpContactKind
|
||||
lastContactTimestamp: number
|
||||
lastContactTimestampMs: number
|
||||
}
|
||||
|
||||
export interface McpContactsPayload {
|
||||
items: McpContactItem[]
|
||||
total: number
|
||||
offset: number
|
||||
limit: number
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
export interface McpCursor {
|
||||
sortSeq: number
|
||||
createTime: number
|
||||
localId: number
|
||||
}
|
||||
|
||||
export interface McpMessageMedia {
|
||||
type: string
|
||||
localPath?: string | null
|
||||
md5?: string | null
|
||||
durationSeconds?: number | null
|
||||
fileName?: string | null
|
||||
fileSize?: number | null
|
||||
exists?: boolean | null
|
||||
isLivePhoto?: boolean | null
|
||||
}
|
||||
|
||||
export interface McpMessageItem {
|
||||
messageId: number
|
||||
timestamp: number
|
||||
timestampMs: number
|
||||
direction: 'in' | 'out'
|
||||
kind: McpMessageKind
|
||||
text: string
|
||||
sender: {
|
||||
username: string | null
|
||||
isSelf: boolean
|
||||
}
|
||||
cursor: McpCursor
|
||||
media?: McpMessageMedia
|
||||
raw?: string
|
||||
}
|
||||
|
||||
export interface McpMessagesPayload {
|
||||
items: McpMessageItem[]
|
||||
offset: number
|
||||
limit: number
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
export interface McpSearchHit {
|
||||
session: McpSessionRef
|
||||
message: McpMessageItem
|
||||
excerpt: string
|
||||
matchedField: McpMessageMatchField
|
||||
score: number
|
||||
}
|
||||
|
||||
export interface McpSearchMessagesPayload {
|
||||
hits: McpSearchHit[]
|
||||
limit: number
|
||||
sessionsScanned: number
|
||||
messagesScanned: number
|
||||
truncated: boolean
|
||||
sessionSummaries?: Array<{
|
||||
session: McpSessionRef
|
||||
hitCount: number
|
||||
topScore: number
|
||||
sampleExcerpts: string[]
|
||||
}>
|
||||
}
|
||||
|
||||
export interface McpTimeRange {
|
||||
startTime?: number
|
||||
startTimeMs?: number
|
||||
endTime?: number
|
||||
endTimeMs?: number
|
||||
}
|
||||
|
||||
export interface McpGlobalStatisticsPayload {
|
||||
totalMessages: number
|
||||
textMessages: number
|
||||
imageMessages: number
|
||||
voiceMessages: number
|
||||
videoMessages: number
|
||||
emojiMessages: number
|
||||
otherMessages: number
|
||||
sentMessages: number
|
||||
receivedMessages: number
|
||||
firstMessageTime: number | null
|
||||
firstMessageTimeMs: number | null
|
||||
lastMessageTime: number | null
|
||||
lastMessageTimeMs: number | null
|
||||
activeDays: number
|
||||
messageTypeCounts: Record<number, number>
|
||||
timeRange: McpTimeRange
|
||||
}
|
||||
|
||||
export interface McpContactRankingItem {
|
||||
contactId: string
|
||||
displayName: string
|
||||
avatarUrl?: string
|
||||
messageCount: number
|
||||
sentCount: number
|
||||
receivedCount: number
|
||||
lastMessageTime: number | null
|
||||
lastMessageTimeMs: number | null
|
||||
}
|
||||
|
||||
export interface McpContactRankingsPayload {
|
||||
items: McpContactRankingItem[]
|
||||
limit: number
|
||||
timeRange: McpTimeRange
|
||||
}
|
||||
|
||||
export interface McpActivityDistributionPayload {
|
||||
hourlyDistribution: Record<number, number>
|
||||
weekdayDistribution: Record<number, number>
|
||||
monthlyDistribution: Record<string, number>
|
||||
timeRange: McpTimeRange
|
||||
}
|
||||
|
||||
export interface McpSessionContextPayload {
|
||||
session: McpSessionRef
|
||||
mode: McpSessionContextMode
|
||||
anchor?: McpMessageItem
|
||||
items: McpMessageItem[]
|
||||
hasMoreBefore: boolean
|
||||
hasMoreAfter: boolean
|
||||
}
|
||||
|
||||
export interface McpStreamMetaPayload {
|
||||
toolName: McpToolName
|
||||
requestId?: string
|
||||
startedAt: number
|
||||
}
|
||||
|
||||
export interface McpStreamProgressPayload {
|
||||
stage: McpStreamProgressStage
|
||||
message?: string
|
||||
sessionsScanned?: number
|
||||
messagesScanned?: number
|
||||
candidates?: Array<Pick<McpSessionRef, 'sessionId' | 'displayName' | 'kind'>>
|
||||
candidateCount?: number
|
||||
truncated?: boolean
|
||||
}
|
||||
|
||||
export interface McpStreamPartialPayloadMap {
|
||||
resolve_session: Partial<McpResolveSessionPayload>
|
||||
export_chat: Partial<McpExportChatPayload>
|
||||
list_sessions: Partial<McpSessionsPayload>
|
||||
list_contacts: Partial<McpContactsPayload>
|
||||
get_messages: Partial<McpMessagesPayload>
|
||||
search_messages: Partial<McpSearchMessagesPayload>
|
||||
get_session_context: Partial<McpSessionContextPayload>
|
||||
}
|
||||
|
||||
export type McpStreamPartialPayload =
|
||||
| McpStreamPartialPayloadMap['export_chat']
|
||||
| McpStreamPartialPayloadMap['list_sessions']
|
||||
| McpStreamPartialPayloadMap['list_contacts']
|
||||
| McpStreamPartialPayloadMap['get_messages']
|
||||
| McpStreamPartialPayloadMap['search_messages']
|
||||
| McpStreamPartialPayloadMap['get_session_context']
|
||||
|
||||
export interface McpStreamMetaEvent {
|
||||
event: 'meta'
|
||||
data: McpStreamMetaPayload
|
||||
}
|
||||
|
||||
export interface McpStreamProgressEvent {
|
||||
event: 'progress'
|
||||
data: McpStreamProgressPayload
|
||||
}
|
||||
|
||||
export interface McpStreamPartialEvent {
|
||||
event: 'partial'
|
||||
data: {
|
||||
toolName: McpToolName
|
||||
chunkIndex: number
|
||||
payload: McpStreamPartialPayload
|
||||
}
|
||||
}
|
||||
|
||||
export interface McpStreamCompleteEvent {
|
||||
event: 'complete'
|
||||
data: {
|
||||
toolName: McpToolName
|
||||
summary: string
|
||||
payload: unknown
|
||||
completedAt: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface McpStreamErrorEvent {
|
||||
event: 'error'
|
||||
data: McpErrorShape & {
|
||||
toolName: McpToolName
|
||||
failedAt: number
|
||||
}
|
||||
}
|
||||
|
||||
export type McpStreamEvent =
|
||||
| McpStreamMetaEvent
|
||||
| McpStreamProgressEvent
|
||||
| McpStreamPartialEvent
|
||||
| McpStreamCompleteEvent
|
||||
| McpStreamErrorEvent
|
||||
78
electron/services/runtimePaths.ts
Normal file
78
electron/services/runtimePaths.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
|
||||
function getElectronAppSafe(): any | null {
|
||||
try {
|
||||
// In Electron runtime this is the real module; in plain Node it may be a string path.
|
||||
// We treat non-object values as unavailable.
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const electronModule = require('electron')
|
||||
if (electronModule && typeof electronModule === 'object' && electronModule.app) {
|
||||
return electronModule.app
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function getUserDataPath(): string {
|
||||
const app = getElectronAppSafe()
|
||||
if (app?.getPath) {
|
||||
return app.getPath('userData')
|
||||
}
|
||||
|
||||
const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming')
|
||||
return path.join(appData, 'ciphertalk')
|
||||
}
|
||||
|
||||
export function getDocumentsPath(): string {
|
||||
const app = getElectronAppSafe()
|
||||
if (app?.getPath) {
|
||||
return app.getPath('documents')
|
||||
}
|
||||
|
||||
return path.join(os.homedir(), 'Documents')
|
||||
}
|
||||
|
||||
export function getExePath(): string {
|
||||
const app = getElectronAppSafe()
|
||||
if (app?.getPath) {
|
||||
return app.getPath('exe')
|
||||
}
|
||||
|
||||
return process.execPath
|
||||
}
|
||||
|
||||
export function getAppPath(): string {
|
||||
const app = getElectronAppSafe()
|
||||
if (app?.getAppPath) {
|
||||
return app.getAppPath()
|
||||
}
|
||||
|
||||
return process.cwd()
|
||||
}
|
||||
|
||||
export function isElectronPackaged(): boolean {
|
||||
const app = getElectronAppSafe()
|
||||
if (typeof app?.isPackaged === 'boolean') {
|
||||
return app.isPackaged
|
||||
}
|
||||
|
||||
return !process.env.VITE_DEV_SERVER_URL
|
||||
}
|
||||
|
||||
export function getAppVersion(): string {
|
||||
const app = getElectronAppSafe()
|
||||
if (app?.getVersion) {
|
||||
return app.getVersion()
|
||||
}
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const pkg = require('../../package.json')
|
||||
return pkg.version || '0.0.0'
|
||||
} catch {
|
||||
return '0.0.0'
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,10 @@ import { writeFile } from 'fs/promises'
|
||||
import { ConfigService } from './config'
|
||||
import { getDefaultCachePath as getPlatformDefaultCachePath } from './platformService'
|
||||
import Database from 'better-sqlite3'
|
||||
import { app } from 'electron'
|
||||
import { Isaac64 } from './isaac64'
|
||||
import https from 'https'
|
||||
import http from 'http'
|
||||
import { getDocumentsPath, getExePath } from './runtimePaths'
|
||||
|
||||
export interface VideoInfo {
|
||||
videoUrl?: string // 视频文件路径(用<EFBC88>?readFile<6C>?
|
||||
|
||||
1228
package-lock.json
generated
1228
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
49
package.json
49
package.json
@@ -1,25 +1,30 @@
|
||||
{
|
||||
"name": "ciphertalk",
|
||||
"version": "2.2.14",
|
||||
"version": "3.0.1",
|
||||
"description": "密语 - 微信聊天记录查看工具",
|
||||
"author": "ILoveBingLu",
|
||||
"license": "CC-BY-NC-SA-4.0",
|
||||
"main": "dist-electron/main.js",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"prebuild": "node scripts/update-readme-version.js",
|
||||
"native:macos": "bash native-dlls/build-macos.sh",
|
||||
"native:macos:check": "node scripts/check-macos-native.js",
|
||||
"build:full": "node scripts/build-full.js",
|
||||
"prebuild": "node scripts/update-readme-version.js && node scripts/prepare-release-announcement.js",
|
||||
"build": "tsc && vite build && electron-builder && node scripts/add-size-to-yml.js",
|
||||
"build:pro": "node scripts/build-full.js",
|
||||
"build:ci": "node scripts/prepare-release-announcement.js && tsc && vite build && electron-builder --publish never && node scripts/add-size-to-yml.js",
|
||||
"build:mcp": "tsc && vite build",
|
||||
"build:force-update-manifest": "node scripts/generate-force-update-manifest.js",
|
||||
"build:release-context": "node scripts/generate-release-context.js",
|
||||
"build:release-body": "node scripts/generate-release-body.js",
|
||||
"build:release-announcement": "node scripts/prepare-release-announcement.js",
|
||||
"notify:telegram": "node scripts/send-telegram-release.js",
|
||||
"mcp": "node scripts/mcp-runner.js",
|
||||
"mcp:probe": "node scripts/mcp-probe.js",
|
||||
"preview": "vite preview",
|
||||
"electron:dev": "vite --mode electron",
|
||||
"electron:build": "npm run build",
|
||||
"postinstall": "electron-rebuild",
|
||||
"tuisong": "node scripts/push-release.js"
|
||||
"postinstall": "electron-rebuild"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/material": "^7.3.9",
|
||||
@@ -56,6 +61,7 @@
|
||||
"silk-wasm": "^3.7.1",
|
||||
"wechat-emojis": "^1.0.2",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^4.1.12",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -85,11 +91,12 @@
|
||||
"output": "release"
|
||||
},
|
||||
"publish": {
|
||||
"provider": "generic",
|
||||
"url": "https://miyuapp.aiqji.com"
|
||||
"provider": "github",
|
||||
"owner": "ILoveBingLu",
|
||||
"repo": "CipherTalk"
|
||||
},
|
||||
"win": {
|
||||
"icon": "public/xinnian.ico",
|
||||
"icon": "public/icon.ico",
|
||||
"target": "nsis",
|
||||
"requestedExecutionLevel": "asInvoker"
|
||||
},
|
||||
@@ -113,9 +120,9 @@
|
||||
"language": "2052",
|
||||
"displayLanguageSelector": false,
|
||||
"include": "installer.nsh",
|
||||
"installerIcon": "public/xinnian.ico",
|
||||
"uninstallerIcon": "public/xinnian.ico",
|
||||
"installerHeaderIcon": "public/xinnian.ico",
|
||||
"installerIcon": "public/icon.ico",
|
||||
"uninstallerIcon": "public/icon.ico",
|
||||
"installerHeaderIcon": "public/icon.ico",
|
||||
"perMachine": false,
|
||||
"allowElevation": true,
|
||||
"installerSidebar": null,
|
||||
@@ -144,6 +151,20 @@
|
||||
{
|
||||
"from": "public/xinnian.ico",
|
||||
"to": "xinnian.ico"
|
||||
},
|
||||
{
|
||||
"from": ".tmp/release-announcement.json",
|
||||
"to": "release-announcement.json"
|
||||
}
|
||||
],
|
||||
"extraFiles": [
|
||||
{
|
||||
"from": "scripts/ciphertalk-mcp.cmd",
|
||||
"to": "ciphertalk-mcp.cmd"
|
||||
},
|
||||
{
|
||||
"from": "scripts/ciphertalk-mcp-bootstrap.cjs",
|
||||
"to": "ciphertalk-mcp-bootstrap.cjs"
|
||||
}
|
||||
],
|
||||
"files": [
|
||||
|
||||
@@ -1,336 +1,195 @@
|
||||
# 📦 自动发布脚本使用说明
|
||||
# 📦 发布说明
|
||||
|
||||
## 🚀 快速开始
|
||||
## 触发方式
|
||||
|
||||
### 发布新版本(3 步完成)
|
||||
当前仓库不再使用本地 `npm run tuisong` 发布。
|
||||
|
||||
正式发布方式改为:
|
||||
|
||||
1. 修改 `package.json` 中的版本号
|
||||
2. 提交代码并推送到 `main`
|
||||
3. 推送一个与版本号完全一致的 Git tag,例如:
|
||||
|
||||
```bash
|
||||
# 1. 修改 package.json 中的版本号
|
||||
# "version": "2.0.4"
|
||||
|
||||
# 2. 提交所有更改
|
||||
git add .
|
||||
git commit -m "release: v2.0.4"
|
||||
|
||||
# 3. 运行发布脚本
|
||||
npm run tuisong
|
||||
git tag v2.2.14
|
||||
git push origin v2.2.14
|
||||
```
|
||||
|
||||
就这么简单!脚本会自动:
|
||||
- ✅ 检查是否有未提交的更改(有则报错)
|
||||
- ✅ 读取 `package.json` 中的版本号
|
||||
- ✅ 推送到 GitHub
|
||||
- ✅ 创建并推送版本标签(如 `v2.0.4`)
|
||||
- ✅ 触发自动构建和发布
|
||||
只有推送 `v*` 标签时,GitHub Actions 才会自动构建和发布。
|
||||
|
||||
---
|
||||
## 本地测试(不提交密钥)
|
||||
|
||||
## 📝 使用方法
|
||||
发布相关脚本支持从本地私有环境文件读取密钥与模型配置,读取顺序为:
|
||||
|
||||
1. 进程环境变量(例如手动 `set` / CI 注入)
|
||||
2. 仓库根目录 `.release.local.env`
|
||||
3. 仓库根目录 `.env.local`
|
||||
|
||||
可用键(按需填写):
|
||||
|
||||
- `AI_API_KEY`
|
||||
- `AI_API_URL`
|
||||
- `AI_MODEL`
|
||||
- `GH_TOKEN`
|
||||
|
||||
示例(文件不会被提交):
|
||||
|
||||
```env
|
||||
AI_API_KEY=sk-xxxx
|
||||
AI_API_URL=https://api.openai.com/v1/chat/completions
|
||||
AI_MODEL=gpt-5.4
|
||||
GH_TOKEN=ghp_xxxx
|
||||
```
|
||||
|
||||
## GitHub Actions 会做什么
|
||||
|
||||
`.github/workflows/release.yml` 会在 `v*` 标签触发后执行:
|
||||
|
||||
当前工作流已拆成串并行 job:
|
||||
|
||||
- `prepare-meta`
|
||||
- `build-windows`
|
||||
- `generate-release-body`
|
||||
- `publish-github-release`
|
||||
- `mirror-r2`
|
||||
- `notify-telegram-success`
|
||||
- `notify-failure`
|
||||
|
||||
其中:
|
||||
|
||||
1. `prepare-meta` 生成 `force-update.json` 和 `release-context.json`
|
||||
2. `build-windows` 负责构建安装包和 `latest.yml`
|
||||
3. `generate-release-body` 负责 AI / 模板版发布说明
|
||||
4. `publish-github-release` 汇总产物并创建 GitHub Release
|
||||
5. `mirror-r2` 与 `notify-telegram-success` 在发布成功后并行执行
|
||||
|
||||
GitHub Release 上传内容:
|
||||
|
||||
- 安装包
|
||||
- `latest.yml`
|
||||
- `force-update.json`
|
||||
|
||||
Cloudflare R2 同步内容:
|
||||
|
||||
- 安装包
|
||||
- `latest.yml`
|
||||
- `force-update.json`
|
||||
|
||||
Telegram 通知:
|
||||
|
||||
- 成功时发送 AI 摘要通知
|
||||
- 失败时发送失败通知
|
||||
|
||||
GitHub Release 资产包括:
|
||||
- 安装包
|
||||
- `latest.yml`
|
||||
- `force-update.json`
|
||||
10. 向 Telegram 频道/群发送发布通知(AI 摘要 + 强制更新提醒)
|
||||
|
||||
## Windows 全量更新
|
||||
|
||||
当前 Windows 自动更新统一使用全量安装包下载。
|
||||
|
||||
依赖产物为:
|
||||
|
||||
- `CipherTalk-x.y.z-Setup.exe`
|
||||
- `latest.yml`
|
||||
|
||||
工作流会在构建与发布阶段校验安装包和 `latest.yml` 的哈希是否一致,避免元数据与真实安装包不匹配。
|
||||
|
||||
说明:
|
||||
|
||||
- 当前仍是未签名发布
|
||||
- 公开分发时稳定性仍可能受 SmartScreen / 杀软 / 系统策略影响
|
||||
- 当前已禁用差分更新,客户端始终下载完整安装包
|
||||
|
||||
## 版本要求
|
||||
|
||||
标签名必须与 `package.json.version` 完全对应:
|
||||
|
||||
- `package.json.version = 2.2.14`
|
||||
- Git tag 必须是 `v2.2.14`
|
||||
|
||||
如果不一致,工作流会直接失败。
|
||||
|
||||
## 强制更新策略
|
||||
|
||||
工作流会调用:
|
||||
|
||||
```bash
|
||||
npm run tuisong
|
||||
npm run build:force-update-manifest
|
||||
```
|
||||
|
||||
**前提条件:**
|
||||
- ✅ 所有更改已提交(`git commit`)
|
||||
- ✅ `package.json` 中的版本号已更新
|
||||
默认情况下不会触发强制更新。只有在仓库 Variables / Secrets 中提供以下值时,生成的 `force-update.json` 才会带上对应策略:
|
||||
|
||||
**脚本会做什么:**
|
||||
1. 检查是否有未提交的更改(有则退出)
|
||||
2. 显示待推送的提交
|
||||
3. 推送到 GitHub
|
||||
4. 创建版本标签(如 `v2.0.4`)
|
||||
5. 推送标签到 GitHub
|
||||
- `FORCE_UPDATE_MIN_VERSION`
|
||||
- `FORCE_UPDATE_BLOCKED_VERSIONS`
|
||||
- `FORCE_UPDATE_TITLE`
|
||||
- `FORCE_UPDATE_MESSAGE`
|
||||
- `FORCE_UPDATE_RELEASE_NOTES`
|
||||
|
||||
---
|
||||
## Secrets / Variables
|
||||
|
||||
## 🎯 完整发布流程
|
||||
### Cloudflare R2 Secrets
|
||||
|
||||
### 步骤 1:修改版本号
|
||||
需要在 GitHub 仓库配置以下 Secrets:
|
||||
|
||||
编辑 `package.json`:
|
||||
- `R2_ACCOUNT_ID`
|
||||
- `R2_BUCKET_NAME`
|
||||
- `R2_ACCESS_KEY_ID`
|
||||
- `R2_SECRET_ACCESS_KEY`
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "ciphertalk",
|
||||
"version": "2.0.4", // 修改这里
|
||||
...
|
||||
}
|
||||
```
|
||||
### 可选强制更新 Variables / Secrets
|
||||
|
||||
**版本号规范:**
|
||||
- **patch (x.y.Z)** - 修复 bug:`2.0.3` → `2.0.4`
|
||||
- **minor (x.Y.z)** - 新增功能:`2.0.3` → `2.1.0`
|
||||
- **major (X.y.z)** - 重大更新:`2.0.3` → `3.0.0`
|
||||
可以按需配置:
|
||||
|
||||
### 步骤 2:提交更改
|
||||
- `FORCE_UPDATE_MIN_VERSION`
|
||||
- `FORCE_UPDATE_BLOCKED_VERSIONS`
|
||||
- `FORCE_UPDATE_TITLE`
|
||||
- `FORCE_UPDATE_MESSAGE`
|
||||
- `FORCE_UPDATE_RELEASE_NOTES`
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "release: v2.0.4"
|
||||
```
|
||||
不配置时,`force-update.json` 仍会生成,但只包含当前版本信息,不会强制用户升级。
|
||||
|
||||
### 步骤 3:推送发布
|
||||
### AI Release Body 配置
|
||||
|
||||
```bash
|
||||
npm run tuisong
|
||||
```
|
||||
发布工作流会自动生成标准化 Release body。
|
||||
|
||||
脚本会显示:
|
||||
```
|
||||
================================
|
||||
密语 - 自动发布脚本
|
||||
================================
|
||||
需要在 GitHub Environment `软件发布` 中配置:
|
||||
|
||||
📌 当前版本: v2.0.4
|
||||
- `AI_API_KEY`
|
||||
- `AI_API_URL`(可选)
|
||||
- `AI_MODEL`(可选)
|
||||
|
||||
📝 待推送的提交:
|
||||
abc1234 release: v2.0.4
|
||||
用途:
|
||||
- 默认会调用当前配置的 AI 模型生成中文 Release 说明
|
||||
- 自动生成中文 Release 说明
|
||||
- 若 AI 不可用,会自动降级为模板正文,不影响发版
|
||||
|
||||
[1/2] 🚀 推送到 GitHub...
|
||||
✓ 推送成功
|
||||
默认值:
|
||||
|
||||
[2/2] 🏷️ 创建并推送标签 v2.0.4...
|
||||
✓ 标签创建成功
|
||||
- `AI_API_URL`: `https://api.openai.com/v1/chat/completions`
|
||||
- `AI_MODEL`: `gpt-5.4`
|
||||
|
||||
================================
|
||||
✅ 发布流程已启动!
|
||||
================================
|
||||
### Telegram 通知配置
|
||||
|
||||
📦 版本: v2.0.4
|
||||
如果需要自动发 Telegram 通知,请在 GitHub Environment `软件发布` 中配置:
|
||||
|
||||
🔗 查看构建进度:
|
||||
https://github.com/JiQingzhe2004/ciphertalk/actions
|
||||
- Secret:
|
||||
- `TELEGRAM_BOT_TOKEN`
|
||||
|
||||
🔗 发布完成后访问:
|
||||
https://github.com/JiQingzhe2004/ciphertalk/releases/tag/v2.0.4
|
||||
- Variable:
|
||||
- `TELEGRAM_CHAT_IDS`
|
||||
- `TELEGRAM_RELEASE_COVER_URL`(可选)
|
||||
|
||||
⏱️ 预计 10-15 分钟后构建完成
|
||||
```
|
||||
说明:
|
||||
- `TELEGRAM_CHAT_IDS` 支持多个目标,用英文逗号分隔
|
||||
- 可填写频道用户名或群/频道 chat_id
|
||||
- 成功发布时会发送 AI 摘要版通知
|
||||
- 发布失败时会发送失败通知
|
||||
|
||||
### 步骤 4:等待构建完成
|
||||
## 当前更新源角色
|
||||
|
||||
GitHub Actions 会自动:
|
||||
1. 安装依赖
|
||||
2. 重新编译原生模块
|
||||
3. 构建美化安装包
|
||||
4. 创建 GitHub Release
|
||||
5. 上传到 Cloudflare R2
|
||||
6. 上传 `CipherTalk-2.0.4-Setup.exe`
|
||||
|
||||
---
|
||||
|
||||
## 🎨 使用场景示例
|
||||
|
||||
### 场景 1:修复 bug
|
||||
|
||||
```bash
|
||||
# 1. 修改代码
|
||||
# 2. 更新版本号: 2.0.3 → 2.0.4
|
||||
# 3. 提交
|
||||
git add .
|
||||
git commit -m "fix: 修复表情包显示问题"
|
||||
|
||||
# 4. 发布
|
||||
npm run tuisong
|
||||
```
|
||||
|
||||
### 场景 2:添加新功能
|
||||
|
||||
```bash
|
||||
# 1. 开发新功能
|
||||
# 2. 更新版本号: 2.0.3 → 2.1.0
|
||||
# 3. 提交
|
||||
git add .
|
||||
git commit -m "feat: 添加语音转文字功能"
|
||||
|
||||
# 4. 发布
|
||||
npm run tuisong
|
||||
```
|
||||
|
||||
### 场景 3:重大更新
|
||||
|
||||
```bash
|
||||
# 1. 重构代码
|
||||
# 2. 更新版本号: 2.0.3 → 3.0.0
|
||||
# 3. 提交
|
||||
git add .
|
||||
git commit -m "feat!: 全新 UI 设计"
|
||||
|
||||
# 4. 发布
|
||||
npm run tuisong
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 其他构建脚本
|
||||
|
||||
### 完整构建(生产环境)
|
||||
|
||||
```bash
|
||||
npm run build:pro
|
||||
```
|
||||
|
||||
包含:
|
||||
- ✅ 更新 README 版本号
|
||||
- ✅ TypeScript 编译
|
||||
- ✅ Vite 构建前端
|
||||
- ✅ Electron 打包
|
||||
- ✅ 生成美化安装包
|
||||
- ✅ 更新 latest.yml
|
||||
|
||||
### 仅构建外壳(测试用)
|
||||
|
||||
```bash
|
||||
node scripts/build-shell-only.js
|
||||
```
|
||||
|
||||
用于快速测试安装程序界面。
|
||||
|
||||
---
|
||||
|
||||
## 🤖 GitHub Actions 自动化
|
||||
|
||||
推送到 `main` 分支时自动触发:
|
||||
|
||||
1. 📦 安装依赖
|
||||
2. 🔨 重新编译原生模块
|
||||
3. 🏗️ 构建应用程序(`npm run build:pro`)
|
||||
4. 📊 获取版本号(从 `package.json`)
|
||||
5. 🎉 创建 GitHub Release(标签:`v2.0.4`)
|
||||
6. ☁️ 上传到 Cloudflare R2(自动删除旧版本)
|
||||
7. 📤 上传构建产物到 GitHub
|
||||
|
||||
**查看构建状态:**
|
||||
https://github.com/JiQingzhe2004/ciphertalk/actions
|
||||
|
||||
**查看发布版本:**
|
||||
https://github.com/JiQingzhe2004/ciphertalk/releases
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ GitHub Secrets 配置
|
||||
|
||||
需要在 GitHub 仓库设置中配置以下 Secrets:
|
||||
|
||||
### Cloudflare R2 配置
|
||||
|
||||
1. 进入 GitHub 仓库 → Settings → Secrets and variables → Actions
|
||||
2. 点击 "New repository secret" 添加以下密钥:
|
||||
|
||||
| Secret 名称 | 说明 | 示例值 |
|
||||
|------------|------|--------|
|
||||
| `R2_ACCOUNT_ID` | R2 账户 ID | `bf9d655d15b24e8636ef9e61c137785b` |
|
||||
| `R2_BUCKET_NAME` | R2 存储桶名称 | `miyu` |
|
||||
| `R2_ACCESS_KEY_ID` | R2 访问密钥 ID | `3c49eaabd4b1a28f1d6a4eb642942ee7` |
|
||||
| `R2_SECRET_ACCESS_KEY` | R2 桶密访问密钥 | `••••••••••••••••••••••••••••••••` |
|
||||
|
||||
### 邮件通知配置(可选)
|
||||
|
||||
如果需要在构建完成后收到邮件通知,添加以下 Secrets:
|
||||
|
||||
| Secret 名称 | 说明 | 示例值 |
|
||||
|------------|------|--------|
|
||||
| `MAIL_USERNAME` | 发件邮箱(Gmail) | `your-email@gmail.com` |
|
||||
| `MAIL_PASSWORD` | Gmail 应用专用密码 | `abcd efgh ijkl mnop` |
|
||||
| `MAIL_TO` | 收件邮箱 | `your-email@gmail.com` |
|
||||
|
||||
**如何获取 Gmail 应用专用密码:**
|
||||
|
||||
1. 登录 [Google 账户](https://myaccount.google.com/)
|
||||
2. 进入 **安全性** → **两步验证**(需要先启用)
|
||||
3. 进入 **应用专用密码**
|
||||
4. 选择 **邮件** 和 **Windows 计算机**
|
||||
5. 点击 **生成**,复制 16 位密码(格式:`abcd efgh ijkl mnop`)
|
||||
6. 将密码添加到 GitHub Secrets 的 `MAIL_PASSWORD`
|
||||
|
||||
**邮件通知功能:**
|
||||
- ✅ 构建成功时发送邮件(包含下载链接)
|
||||
- ❌ 构建失败时发送邮件(包含错误日志链接)
|
||||
- 📧 邮件发送到你的 GitHub 注册邮箱(或指定邮箱)
|
||||
|
||||
### 如何获取 R2 凭证
|
||||
|
||||
从你的截图中可以看到:
|
||||
- **账户 ID**:在 Cloudflare R2 页面顶部显示
|
||||
- **存储桶名称**:你创建的存储桶名称
|
||||
- **访问密钥 ID**:在 R2 API 令牌页面显示
|
||||
- **桶密访问密钥**:创建 API 令牌时显示(只显示一次,需要保存)
|
||||
|
||||
### R2 上传规则
|
||||
|
||||
- ✅ 自动上传 `CipherTalk-{版本号}-Setup.exe`
|
||||
- ✅ 自动上传 `latest.yml`(用于自动更新)
|
||||
- ✅ 自动删除旧版本的安装包(保留最新版本)
|
||||
- ❌ 不上传 Core 版本(`*-Core-Setup.exe`)
|
||||
- ℹ️ 如果存储桶为空,跳过删除步骤
|
||||
|
||||
---
|
||||
|
||||
## 🔧 故障排查
|
||||
|
||||
### 问题 1:检测到未提交的更改
|
||||
|
||||
**错误:**
|
||||
```
|
||||
❌ 检测到未提交的更改:
|
||||
M package.json
|
||||
M src/App.tsx
|
||||
|
||||
请先提交所有更改后再运行此脚本
|
||||
```
|
||||
|
||||
**解决:**
|
||||
```bash
|
||||
# 提交所有更改
|
||||
git add .
|
||||
git commit -m "你的提交信息"
|
||||
|
||||
# 然后运行脚本
|
||||
npm run tuisong
|
||||
```
|
||||
|
||||
### 问题 2:推送失败
|
||||
|
||||
**错误:**
|
||||
```
|
||||
❌ 推送失败
|
||||
请检查网络连接和 Git 配置
|
||||
```
|
||||
|
||||
**解决:**
|
||||
- 检查网络连接
|
||||
- 检查 Git 配置
|
||||
- 确认 GitHub 账号已登录
|
||||
|
||||
### 问题 3:标签已存在
|
||||
|
||||
**提示:**
|
||||
```
|
||||
⚠️ 标签 v2.0.4 已存在,跳过创建
|
||||
```
|
||||
|
||||
**说明:**
|
||||
- 这是正常提示,不影响推送
|
||||
- 如果需要重新创建标签,先删除远程标签:
|
||||
```bash
|
||||
git push origin :refs/tags/v2.0.4
|
||||
git tag -d v2.0.4
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💡 提示
|
||||
|
||||
1. **推送前先测试** - 确保代码可以正常运行
|
||||
2. **遵循版本号规范** - 便于版本管理([语义化版本](https://semver.org/lang/zh-CN/))
|
||||
3. **写清楚提交信息** - 方便用户了解更新内容
|
||||
4. **等待构建完成** - 大约 10-15 分钟
|
||||
|
||||
---
|
||||
|
||||
## 🔗 相关链接
|
||||
|
||||
- [GitHub Actions 工作流](../.github/workflows/build-release.yml)
|
||||
- [语义化版本规范](https://semver.org/lang/zh-CN/)
|
||||
- [Git 提交规范](https://www.conventionalcommits.org/zh-hans/)
|
||||
- **GitHub Release**:主更新源,负责安装包与 `latest.yml`
|
||||
- **Cloudflare R2**:镜像下载源 + 策略补充源
|
||||
- **force-update.json**:GitHub 优先,R2 回退
|
||||
|
||||
@@ -9,53 +9,114 @@ if (!fs.existsSync(ymlPath)) {
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
// 读取 yml 内容
|
||||
let content = fs.readFileSync(ymlPath, 'utf-8')
|
||||
const lines = content.split('\n')
|
||||
function getExeName(content) {
|
||||
const pathMatch = content.match(/path:\s*(.+\.exe)/)
|
||||
if (pathMatch) {
|
||||
return pathMatch[1].trim()
|
||||
}
|
||||
|
||||
// 从 yml 中提取文件名
|
||||
const match = content.match(/path:\s*(.+\.exe)/)
|
||||
if (!match) {
|
||||
const urlMatch = content.match(/-\s+url:\s*(.+\.exe)/)
|
||||
if (urlMatch) {
|
||||
return urlMatch[1].trim()
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function finalizeFileItem(itemLines, size) {
|
||||
if (itemLines.length === 0) return itemLines
|
||||
|
||||
const cleanedLines = itemLines.filter((line) => !line.trim().startsWith('size:'))
|
||||
const shaIndex = cleanedLines.findIndex((line) => line.trim().startsWith('sha512:'))
|
||||
const itemIndent = `${cleanedLines[0].match(/^\s*/)?.[0] || ' '} `
|
||||
const sizeLine = `${itemIndent}size: ${size}`
|
||||
|
||||
if (shaIndex >= 0) {
|
||||
cleanedLines.splice(shaIndex + 1, 0, sizeLine)
|
||||
} else {
|
||||
cleanedLines.push(sizeLine)
|
||||
}
|
||||
|
||||
return cleanedLines
|
||||
}
|
||||
|
||||
function normalizeLatestYml(content, size) {
|
||||
const lines = content.split(/\r?\n/)
|
||||
const filesIndex = lines.findIndex((line) => line.trim() === 'files:')
|
||||
if (filesIndex === -1) {
|
||||
return { changed: false, content, message: '未找到 files 块' }
|
||||
}
|
||||
|
||||
let blockEnd = lines.length
|
||||
for (let i = filesIndex + 1; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) continue
|
||||
if (!line.startsWith(' ') && !line.startsWith('\t')) {
|
||||
blockEnd = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const before = lines.slice(0, filesIndex + 1)
|
||||
const fileBlock = lines.slice(filesIndex + 1, blockEnd)
|
||||
const after = lines.slice(blockEnd)
|
||||
|
||||
const normalizedBlock = []
|
||||
let currentItem = []
|
||||
let handledFirstItem = false
|
||||
|
||||
const flushItem = () => {
|
||||
if (currentItem.length === 0) return
|
||||
normalizedBlock.push(...(handledFirstItem ? currentItem : finalizeFileItem(currentItem, size)))
|
||||
handledFirstItem = true
|
||||
currentItem = []
|
||||
}
|
||||
|
||||
for (const line of fileBlock) {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed.startsWith('- ')) {
|
||||
flushItem()
|
||||
currentItem.push(line)
|
||||
continue
|
||||
}
|
||||
|
||||
if (currentItem.length > 0) {
|
||||
currentItem.push(line)
|
||||
} else {
|
||||
normalizedBlock.push(line)
|
||||
}
|
||||
}
|
||||
|
||||
flushItem()
|
||||
|
||||
const nextContent = [...before, ...normalizedBlock, ...after].join('\n')
|
||||
return {
|
||||
changed: nextContent !== content,
|
||||
content: nextContent,
|
||||
message: nextContent !== content ? `已规范 latest.yml 中的 size 字段为 ${size}` : 'latest.yml 中的 size 字段已正确'
|
||||
}
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(ymlPath, 'utf-8')
|
||||
const exeName = getExeName(content)
|
||||
|
||||
if (!exeName) {
|
||||
console.log('未找到安装包文件名')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const exeName = match[1].trim()
|
||||
const exePath = path.join(releaseDir, exeName)
|
||||
|
||||
if (!fs.existsSync(exePath)) {
|
||||
console.log(`安装包不存在: ${exeName}`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
// 获取文件大小
|
||||
const stats = fs.statSync(exePath)
|
||||
const size = stats.size
|
||||
const size = fs.statSync(exePath).size
|
||||
const result = normalizeLatestYml(content, size)
|
||||
|
||||
// 找到 files 块内第一个 sha512 行,在其后插入 size
|
||||
const newLines = []
|
||||
let inFiles = false
|
||||
let sizeAdded = false
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
newLines.push(line)
|
||||
|
||||
if (line.startsWith('files:')) {
|
||||
inFiles = true
|
||||
}
|
||||
|
||||
// 在 files 块内的第一个 sha512 后添加 size
|
||||
if (inFiles && !sizeAdded && line.trim().startsWith('sha512:')) {
|
||||
newLines.push(` size: ${size}`)
|
||||
sizeAdded = true
|
||||
inFiles = false
|
||||
}
|
||||
if (result.changed) {
|
||||
fs.writeFileSync(ymlPath, result.content)
|
||||
}
|
||||
|
||||
if (sizeAdded) {
|
||||
fs.writeFileSync(ymlPath, newLines.join('\n'))
|
||||
console.log(`已添加 size: ${size} 到 latest.yml`)
|
||||
} else {
|
||||
console.log('未找到合适位置插入 size')
|
||||
}
|
||||
console.log(result.message)
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
const { execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
//配置区
|
||||
const PROJECT_ROOT = path.join(__dirname, '..');
|
||||
const RELEASE_DIR = path.join(PROJECT_ROOT, 'release');
|
||||
const INSTALLER_PRJ_DIR = path.join(PROJECT_ROOT, 'MyCoolInstaller');
|
||||
const EMBEDDED_NAME = 'EmbeddedInstaller.exe';
|
||||
|
||||
function log(msg) {
|
||||
console.log(`\n\x1b[36m[Build-Full]\x1b[0m ${msg}`);
|
||||
}
|
||||
|
||||
function error(msg) {
|
||||
console.error(`\n\x1b[31m[Error]\x1b[0m ${msg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 构建核心 Electron 应用 (包含 NSIS 打包 + UPX 优化)
|
||||
log('🚀 Step 1: 构建核心 Electron 应用...');
|
||||
execSync('npm run build', { stdio: 'inherit', cwd: PROJECT_ROOT });
|
||||
|
||||
// 2. 找到生成的 NSIS 安装包 (必须匹配当前版本)
|
||||
log('🔍 Step 2: 寻找生成的 NSIS 安装包...');
|
||||
if (!fs.existsSync(RELEASE_DIR)) error('Release 目录不存在,构建可能失败');
|
||||
|
||||
// 读取项目版本
|
||||
const pkgPath = path.join(PROJECT_ROOT, 'package.json');
|
||||
const pkgVersion = require(pkgPath).version;
|
||||
const expectedName = `CipherTalk-${pkgVersion}-Setup.exe`;
|
||||
const nsisPath = path.join(RELEASE_DIR, expectedName);
|
||||
|
||||
if (!fs.existsSync(nsisPath)) {
|
||||
// 尝试模糊搜索作为备选(有时候 electron-builder 不带版本号?)
|
||||
error(`未找到目标版本安装包: ${expectedName}\n请检查 package.json 版本号是否与生成产物一致。`);
|
||||
}
|
||||
|
||||
const version = pkgVersion;
|
||||
log(`✅ 找到安装包: ${expectedName} (v${version})`);
|
||||
|
||||
// 3. 复制到 WPF 工程目录准备嵌入
|
||||
log('🚚 Step 3: 注入到安装器工程...');
|
||||
const targetPayloadPath = path.join(INSTALLER_PRJ_DIR, EMBEDDED_NAME);
|
||||
fs.copyFileSync(nsisPath, targetPayloadPath);
|
||||
|
||||
// 4. 编译 WPF 外壳 (需要系统中装有 .NET SDK)
|
||||
log('🔨 Step 4: 编译 WPF 高颜值外壳...');
|
||||
|
||||
// 动态同步版本号:将 package.json 的 version 同步到 CSPROJ
|
||||
// .NET 版本号遵循 Major.Minor.Build.Revision (4位),所以补个 .0
|
||||
const netVersion = version.split('.').length === 3 ? `${version}.0` : version;
|
||||
const csprojPath = path.join(INSTALLER_PRJ_DIR, 'MyCoolInstaller.csproj');
|
||||
|
||||
let csprojContent = fs.readFileSync(csprojPath, 'utf8');
|
||||
csprojContent = csprojContent.replace(/<AssemblyVersion>.*<\/AssemblyVersion>/g, `<AssemblyVersion>${netVersion}</AssemblyVersion>`);
|
||||
csprojContent = csprojContent.replace(/<FileVersion>.*<\/FileVersion>/g, `<FileVersion>${netVersion}</FileVersion>`);
|
||||
fs.writeFileSync(csprojPath, csprojContent);
|
||||
log(`ℹ️ 已更新安装器元数据版本为: ${netVersion}`);
|
||||
|
||||
// 指向具体的 csproj,避免多项目时的歧义
|
||||
// 不使用 -o 参数,规避 Solution 构建时的路径冲突
|
||||
const publishCmd = `dotnet publish "${csprojPath}" -c Release -r win-x64 --self-contained false -p:PublishSingleFile=true`;
|
||||
|
||||
try {
|
||||
execSync(publishCmd, { stdio: 'inherit' });
|
||||
} catch (e) {
|
||||
error('WPF 编译失败。请确保安装了 .NET 8 SDK。');
|
||||
}
|
||||
|
||||
// 5. 将最终产物移回 release 目录
|
||||
log('🎁 Step 5: 输出最终产物...');
|
||||
|
||||
// 默认发布路径
|
||||
const wpfOutput = path.join(INSTALLER_PRJ_DIR, 'bin', 'Release', 'net8.0-windows', 'win-x64', 'publish', 'MyCoolInstaller.exe');
|
||||
if (!fs.existsSync(wpfOutput)) error(`WPF 产物未找到: ${wpfOutput}`);
|
||||
|
||||
// 记录原始大小用于日志
|
||||
const originalSize = (fs.statSync(nsisPath).size / 1024 / 1024).toFixed(2);
|
||||
|
||||
// A. 备份原版 (改名为 Core-Setup)
|
||||
const coreName = `CipherTalk-${version}-Core-Setup.exe`;
|
||||
const corePath = path.join(RELEASE_DIR, coreName);
|
||||
if (fs.existsSync(corePath)) fs.unlinkSync(corePath); // 覆盖旧备份
|
||||
fs.renameSync(nsisPath, corePath);
|
||||
log(`ℹ️ 原版安装包已重命名备份为: ${coreName}`);
|
||||
|
||||
// B. WPF 外壳上位 (使用标准 Setup 名字)
|
||||
const finalName = `CipherTalk-${version}-Setup.exe`;
|
||||
const finalPath = path.join(RELEASE_DIR, finalName);
|
||||
|
||||
// 复制前先检查占用
|
||||
try {
|
||||
if (fs.existsSync(finalPath)) fs.unlinkSync(finalPath);
|
||||
fs.copyFileSync(wpfOutput, finalPath);
|
||||
} catch (e) {
|
||||
if (e.code === 'EBUSY') error(`目标文件被占用: ${finalPath}\n请关闭文件夹或程序后重试。`);
|
||||
throw e;
|
||||
}
|
||||
|
||||
// 清理临时文件
|
||||
fs.unlinkSync(targetPayloadPath);
|
||||
|
||||
log(`🎉🎉🎉 全流程构建完成!`);
|
||||
log(`📂 最终安装包: ${finalPath}`);
|
||||
log(`📏 原始大小: ${originalSize} MB`);
|
||||
const finalSize = fs.statSync(finalPath).size;
|
||||
log(`📏 最终大小: ${(finalSize / 1024 / 1024).toFixed(2)} MB`);
|
||||
|
||||
// 6. 关键步骤:更新 latest.yml 以匹配新的安装包
|
||||
// 否则自动更新会因为 SHA512 不匹配而失败
|
||||
log('📝 Step 6: 修正 latest.yml 校验信息...');
|
||||
const yamlPath = path.join(RELEASE_DIR, 'latest.yml');
|
||||
|
||||
// A. 必须删除 .blockmap 文件!
|
||||
// 因为我们的 Setup.exe 已经被替换,原有的 blockmap 是针对旧 EXE 的。
|
||||
// 如果不删,Updater 会尝试差分更新,导致校验失败。
|
||||
const blockMapName = `${finalName}.blockmap`;
|
||||
const blockMapPath = path.join(RELEASE_DIR, blockMapName);
|
||||
if (fs.existsSync(blockMapPath)) {
|
||||
fs.unlinkSync(blockMapPath);
|
||||
log(`🗑️ 已删除无效的 BlockMap: ${blockMapName} (禁用差分更新)`);
|
||||
}
|
||||
|
||||
if (fs.existsSync(yamlPath)) {
|
||||
const crypto = require('crypto');
|
||||
|
||||
// 计算新的 SHA512 (Base64格式)
|
||||
const buffer = fs.readFileSync(finalPath);
|
||||
const hash = crypto.createHash('sha512').update(buffer).digest('base64');
|
||||
|
||||
let yamlContent = fs.readFileSync(yamlPath, 'utf8');
|
||||
|
||||
// 简单正则替换 (避免引入 yaml 库依赖)
|
||||
// 1. 替换顶层 sha512
|
||||
yamlContent = yamlContent.replace(/sha512: .+/g, `sha512: ${hash}`);
|
||||
|
||||
// 2. 替换顶层 size
|
||||
yamlContent = yamlContent.replace(/size: \d+/g, `size: ${finalSize}`);
|
||||
|
||||
// 3. 确保 files 列表下的信息也更新 (如果有)
|
||||
// 这比较复杂,通常 electron-updater 主要看顶层,或者 files 里的第一项
|
||||
// 我们假设 electron-builder 生成的标准格式,暴力替换所有匹配的 checksum
|
||||
// 但更安全的是只替换顶部的。标准 latest.yml 结构中 files 下也有 sha512。
|
||||
|
||||
// 重新写入
|
||||
fs.writeFileSync(yamlPath, yamlContent);
|
||||
log(`✅ latest.yml 已更新:\n SHA512: ${hash.substring(0, 20)}...\n Size: ${finalSize}`);
|
||||
} else {
|
||||
log('⚠️ 未找到 latest.yml,跳过元数据更新 (仅本地构建?)');
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
error(err.message);
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
const { execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
//配置区
|
||||
const PROJECT_ROOT = path.join(__dirname, '..');
|
||||
const RELEASE_DIR = path.join(PROJECT_ROOT, 'release');
|
||||
const INSTALLER_PRJ_DIR = path.join(PROJECT_ROOT, 'MyCoolInstaller');
|
||||
const EMBEDDED_NAME = 'EmbeddedInstaller.exe';
|
||||
|
||||
function log(msg) { console.log(`\n\x1b[36m[Build-Shell]\x1b[0m ${msg}`); }
|
||||
function error(msg) { console.error(`\n\x1b[31m[Error]\x1b[0m ${msg}`); process.exit(1); }
|
||||
|
||||
try {
|
||||
// 0. 读取当前项目版本
|
||||
const pkg = require(path.join(PROJECT_ROOT, 'package.json'));
|
||||
const currentVersion = pkg.version;
|
||||
log(`ℹ️ 当前项目版本: v${currentVersion}`);
|
||||
|
||||
// 1. 找到对应的 NSIS 安装包
|
||||
log('🔍 Step 1: 寻找对应的 NSIS 安装包...');
|
||||
if (!fs.existsSync(RELEASE_DIR)) error('Release 目录不存在');
|
||||
|
||||
// 精准匹配当前版本的安装包
|
||||
const targetInstallerName = `CipherTalk-${currentVersion}-Setup.exe`;
|
||||
const nsisPath = path.join(RELEASE_DIR, targetInstallerName);
|
||||
|
||||
if (!fs.existsSync(nsisPath)) {
|
||||
error(`未找到对应版本的安装包: ${targetInstallerName}\n请先运行 npm run build 生成该版本的 Electron 安装包。`);
|
||||
}
|
||||
|
||||
log(`✅ 找到安装包: ${targetInstallerName}`);
|
||||
|
||||
// 不需要正则匹配了,版本就是 currentVersion
|
||||
const version = currentVersion;
|
||||
|
||||
// 2. 复制到 WPF 工程目录准备嵌入
|
||||
log('🚚 Step 2: 注入到安装器工程...');
|
||||
const targetPayloadPath = path.join(INSTALLER_PRJ_DIR, EMBEDDED_NAME);
|
||||
fs.copyFileSync(nsisPath, targetPayloadPath);
|
||||
|
||||
// 3. 编译 WPF 外壳
|
||||
log('🔨 Step 3: 快速编译 WPF 外壳...');
|
||||
const csprojPath = path.join(INSTALLER_PRJ_DIR, 'MyCoolInstaller.csproj');
|
||||
// 使用 PublishSingleFile 确保成单文件
|
||||
const publishCmd = `dotnet publish "${csprojPath}" -c Release -r win-x64 --self-contained false -p:PublishSingleFile=true`;
|
||||
|
||||
try {
|
||||
execSync(publishCmd, { stdio: 'inherit' });
|
||||
} catch (e) {
|
||||
error('WPF 编译失败');
|
||||
}
|
||||
|
||||
// 4. 将最终产物移回 release 目录
|
||||
log('🎁 Step 4: 输出最终产物...');
|
||||
const wpfOutput = path.join(INSTALLER_PRJ_DIR, 'bin', 'Release', 'net8.0-windows', 'win-x64', 'publish', 'MyCoolInstaller.exe');
|
||||
if (!fs.existsSync(wpfOutput)) error(`WPF 产物未找到: ${wpfOutput}`);
|
||||
|
||||
// 使用 Shell-Setup 后缀区分全量构建
|
||||
const finalName = `CipherTalk-${version}-Shell-Setup.exe`;
|
||||
const finalPath = path.join(RELEASE_DIR, finalName);
|
||||
|
||||
try {
|
||||
if (fs.existsSync(finalPath)) {
|
||||
fs.unlinkSync(finalPath); // 尝试先删除旧文件
|
||||
}
|
||||
fs.copyFileSync(wpfOutput, finalPath);
|
||||
} catch (e) {
|
||||
if (e.code === 'EBUSY' || e.code === 'EPERM') {
|
||||
error(`目标文件被占用: ${finalPath}\n请关闭正在运行的安装程序或文件夹,然后重试。`);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// 清理临时文件
|
||||
fs.unlinkSync(targetPayloadPath);
|
||||
|
||||
log(`🎉🎉🎉 外壳构建完成!`);
|
||||
log(`📂 最终安装包: ${finalPath}`);
|
||||
|
||||
} catch (err) {
|
||||
error(err.message);
|
||||
}
|
||||
66
scripts/ciphertalk-mcp-bootstrap.cjs
Normal file
66
scripts/ciphertalk-mcp-bootstrap.cjs
Normal file
@@ -0,0 +1,66 @@
|
||||
"use strict";
|
||||
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const Module = require("module");
|
||||
|
||||
const appDir = path.dirname(process.execPath);
|
||||
const appAsarPath = path.join(appDir, "resources", "app.asar");
|
||||
|
||||
function getUserDataPath() {
|
||||
const appData = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
|
||||
return path.join(appData, "ciphertalk");
|
||||
}
|
||||
|
||||
function getDocumentsPath() {
|
||||
return path.join(os.homedir(), "Documents");
|
||||
}
|
||||
|
||||
const electronShim = {
|
||||
app: {
|
||||
isPackaged: true,
|
||||
getPath(name) {
|
||||
switch (name) {
|
||||
case "userData":
|
||||
return getUserDataPath();
|
||||
case "documents":
|
||||
return getDocumentsPath();
|
||||
case "exe":
|
||||
return process.execPath;
|
||||
default:
|
||||
return appDir;
|
||||
}
|
||||
},
|
||||
getAppPath() {
|
||||
return appAsarPath;
|
||||
},
|
||||
getVersion() {
|
||||
try {
|
||||
return require(path.join(appAsarPath, "package.json")).version || "0.0.0";
|
||||
} catch {
|
||||
return "0.0.0";
|
||||
}
|
||||
},
|
||||
},
|
||||
BrowserWindow: {
|
||||
getAllWindows() {
|
||||
return [];
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const originalLoad = Module._load;
|
||||
Module._load = function patchedLoad(request, parent, isMain) {
|
||||
if (request === "electron") {
|
||||
return electronShim;
|
||||
}
|
||||
return originalLoad.call(this, request, parent, isMain);
|
||||
};
|
||||
|
||||
const entry = String(process.env.CIPHERTALK_MCP_ENTRY || "").trim();
|
||||
if (!entry) {
|
||||
process.stderr.write("[CipherTalk MCP Bootstrap] CIPHERTALK_MCP_ENTRY is not set\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
require(entry);
|
||||
32
scripts/ciphertalk-mcp.cmd
Normal file
32
scripts/ciphertalk-mcp.cmd
Normal file
@@ -0,0 +1,32 @@
|
||||
@echo off
|
||||
setlocal
|
||||
|
||||
set "APP_DIR=%~dp0"
|
||||
set "EXE_PATH=%APP_DIR%CipherTalk.exe"
|
||||
set "MCP_ARCHIVE=%APP_DIR%resources\app.asar"
|
||||
set "MCP_ENTRY_UNPACKED=%APP_DIR%resources\app.asar.unpacked\dist-electron\mcp.js"
|
||||
set "MCP_ENTRY=%MCP_ARCHIVE%\dist-electron\mcp.js"
|
||||
set "MCP_BOOTSTRAP=%APP_DIR%ciphertalk-mcp-bootstrap.cjs"
|
||||
|
||||
if not exist "%EXE_PATH%" (
|
||||
>&2 echo [CipherTalk MCP Launcher] CipherTalk.exe not found at "%EXE_PATH%"
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if not exist "%MCP_BOOTSTRAP%" (
|
||||
>&2 echo [CipherTalk MCP Launcher] MCP bootstrap not found at "%MCP_BOOTSTRAP%"
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if exist "%MCP_ENTRY_UNPACKED%" (
|
||||
set "MCP_ENTRY=%MCP_ENTRY_UNPACKED%"
|
||||
) else if not exist "%MCP_ARCHIVE%" (
|
||||
>&2 echo [CipherTalk MCP Launcher] app.asar not found at "%MCP_ARCHIVE%"
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
set "ELECTRON_RUN_AS_NODE=1"
|
||||
set "CIPHERTALK_MCP_LAUNCHER=packaged-launcher"
|
||||
set "CIPHERTALK_MCP_ENTRY=%MCP_ENTRY%"
|
||||
|
||||
"%EXE_PATH%" "%MCP_BOOTSTRAP%" %*
|
||||
33
scripts/generate-force-update-manifest.js
Normal file
33
scripts/generate-force-update-manifest.js
Normal file
@@ -0,0 +1,33 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const rootDir = path.resolve(__dirname, '..')
|
||||
const releaseDir = path.join(rootDir, 'release')
|
||||
const pkg = require(path.join(rootDir, 'package.json'))
|
||||
|
||||
const parseList = (value) => {
|
||||
if (!value) return []
|
||||
return String(value)
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
schemaVersion: 1,
|
||||
latestVersion: pkg.version,
|
||||
minimumSupportedVersion: process.env.FORCE_UPDATE_MIN_VERSION || undefined,
|
||||
blockedVersions: parseList(process.env.FORCE_UPDATE_BLOCKED_VERSIONS),
|
||||
title: process.env.FORCE_UPDATE_TITLE || '',
|
||||
message: process.env.FORCE_UPDATE_MESSAGE || '',
|
||||
releaseNotes: process.env.FORCE_UPDATE_RELEASE_NOTES || '',
|
||||
publishedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
if (!fs.existsSync(releaseDir)) {
|
||||
fs.mkdirSync(releaseDir, { recursive: true })
|
||||
}
|
||||
|
||||
const outputPath = path.join(releaseDir, 'force-update.json')
|
||||
fs.writeFileSync(outputPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
|
||||
console.log(`✅ force-update.json 已生成: ${outputPath}`)
|
||||
310
scripts/generate-release-body.js
Normal file
310
scripts/generate-release-body.js
Normal file
@@ -0,0 +1,310 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const rootDir = path.resolve(__dirname, '..')
|
||||
const releaseDir = path.join(rootDir, 'release')
|
||||
const contextPath = path.join(releaseDir, 'release-context.json')
|
||||
const outputPath = path.join(releaseDir, 'release-body.md')
|
||||
|
||||
function parseEnvText(content) {
|
||||
const result = {}
|
||||
for (const line of String(content || '').split(/\r?\n/)) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed.startsWith('#')) continue
|
||||
const eqIndex = trimmed.indexOf('=')
|
||||
if (eqIndex <= 0) continue
|
||||
const key = trimmed.slice(0, eqIndex).trim()
|
||||
let value = trimmed.slice(eqIndex + 1).trim()
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1)
|
||||
}
|
||||
result[key] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function loadLocalSecretEnv() {
|
||||
const candidates = [
|
||||
path.join(rootDir, '.release.local.env'),
|
||||
path.join(rootDir, '.env.local')
|
||||
]
|
||||
|
||||
const merged = {}
|
||||
for (const filePath of candidates) {
|
||||
if (!fs.existsSync(filePath)) continue
|
||||
try {
|
||||
const parsed = parseEnvText(fs.readFileSync(filePath, 'utf8'))
|
||||
Object.assign(merged, parsed)
|
||||
console.log(`[ReleaseBody] Loaded local env file: ${path.basename(filePath)}`)
|
||||
} catch (e) {
|
||||
console.warn(`[ReleaseBody] Failed to read local env file: ${filePath}`, String(e))
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
const localSecrets = loadLocalSecretEnv()
|
||||
const aiApiKey = process.env.AI_API_KEY || localSecrets.AI_API_KEY || ''
|
||||
const aiApiUrl = process.env.AI_API_URL || localSecrets.AI_API_URL || 'https://api.openai.com/v1/chat/completions'
|
||||
const aiModel = process.env.AI_MODEL || localSecrets.AI_MODEL || 'gpt-5.4'
|
||||
const PRODUCT_NAME = 'CipherTalk'
|
||||
|
||||
const PRIMARY_AUTHOR_LOGINS = new Set(['ILoveBingLu'])
|
||||
const PRIMARY_AUTHOR_NAMES = new Set(['ILoveBingLu', 'BingLu', 'ILoveBinglu'])
|
||||
|
||||
function isPrimaryAuthor(person) {
|
||||
if (!person) return false
|
||||
const login = String(person.authorLogin || '').trim()
|
||||
const name = String(person.authorName || '').trim()
|
||||
return PRIMARY_AUTHOR_LOGINS.has(login) || PRIMARY_AUTHOR_NAMES.has(name)
|
||||
}
|
||||
|
||||
function classifyCommit(subject) {
|
||||
const normalized = String(subject || '').toLowerCase()
|
||||
if (normalized.startsWith('feat')) return '新增'
|
||||
if (normalized.startsWith('fix')) return '修复'
|
||||
return '调整'
|
||||
}
|
||||
|
||||
function buildThanks(context) {
|
||||
const lines = []
|
||||
|
||||
for (const pr of context.pullRequests || []) {
|
||||
if (!isPrimaryAuthor({ authorLogin: pr.authorLogin, authorName: pr.authorName })) {
|
||||
lines.push(`- 感谢 @${pr.authorLogin} 提交 PR #${pr.number}《${pr.title}》`)
|
||||
}
|
||||
}
|
||||
|
||||
const prNumbers = new Set((context.pullRequests || []).map((pr) => pr.number))
|
||||
for (const commit of context.commits || []) {
|
||||
const hasPrRef = /#(\d+)/.test(commit.subject || '')
|
||||
if (hasPrRef) continue
|
||||
if (!isPrimaryAuthor(commit)) {
|
||||
lines.push(`- 感谢 ${commit.authorName} 提交改动《${commit.subject}》`)
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(new Set(lines))
|
||||
}
|
||||
|
||||
function buildReferences(context) {
|
||||
const lines = []
|
||||
for (const pr of context.pullRequests || []) {
|
||||
lines.push(`- PR #${pr.number}: [${pr.title}](${pr.url})`)
|
||||
}
|
||||
for (const commit of context.commits || []) {
|
||||
lines.push(`- Commit [${commit.shortSha}](${commit.url}): ${commit.subject}`)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
function inferReleaseTone(context) {
|
||||
const subjects = (context.commits || []).map((commit) => String(commit.subject || '').toLowerCase())
|
||||
if (subjects.some((subject) => subject.startsWith('feat'))) return '新功能开始成型'
|
||||
if (subjects.some((subject) => subject.startsWith('fix'))) return '这次重点在修整体验'
|
||||
if (subjects.some((subject) => subject.includes('release') || subject.includes('workflow') || subject.includes('ci'))) {
|
||||
return '发布链路做了一轮收口'
|
||||
}
|
||||
if ((context.commits || []).length >= 5) return '这一版主要在做内部打磨'
|
||||
return '这次更新以稳定和整理为主'
|
||||
}
|
||||
|
||||
function buildReleaseTitle(context) {
|
||||
return `${PRODUCT_NAME} v${context.version} · ${inferReleaseTone(context)}`
|
||||
}
|
||||
|
||||
function buildFallbackBody(context) {
|
||||
const groups = {
|
||||
新增: [],
|
||||
修复: [],
|
||||
调整: []
|
||||
}
|
||||
|
||||
for (const commit of context.commits || []) {
|
||||
groups[classifyCommit(commit.subject)].push(`- ${commit.subject}(${commit.shortSha})`)
|
||||
}
|
||||
|
||||
const thanks = buildThanks(context)
|
||||
const references = buildReferences(context)
|
||||
const blockedVersions = context.forceUpdate?.blockedVersions || []
|
||||
const hasUpgradeReminder = Boolean(context.forceUpdate?.minimumSupportedVersion || blockedVersions.length > 0)
|
||||
const totalCommits = (context.commits || []).length
|
||||
const totalPrs = (context.pullRequests || []).length
|
||||
const touchedAreas = Object.entries(groups)
|
||||
.filter(([, items]) => items.length > 0)
|
||||
.map(([name]) => name)
|
||||
const summary = touchedAreas.length
|
||||
? `这次共整理了 ${totalCommits} 条提交${totalPrs ? `、${totalPrs} 个 PR` : ''},重点落在 ${touchedAreas.join(' / ')}。`
|
||||
: `这次共整理了 ${totalCommits} 条提交${totalPrs ? `、${totalPrs} 个 PR` : ''},整体以维护性调整为主。`
|
||||
|
||||
return [
|
||||
`## ${buildReleaseTitle(context)}`,
|
||||
'',
|
||||
`> ${summary}`,
|
||||
'',
|
||||
'### 这次更新',
|
||||
`- ${inferReleaseTone(context)}。`,
|
||||
`- ${summary}`,
|
||||
'',
|
||||
'### 变更明细',
|
||||
'',
|
||||
'#### 新增',
|
||||
...(groups.新增.length ? groups.新增 : ['- 本次没有单独拎出来的新功能提交']),
|
||||
'',
|
||||
'#### 修复',
|
||||
...(groups.修复.length ? groups.修复 : ['- 本次没有明确归类为缺陷修复的提交']),
|
||||
'',
|
||||
'#### 调整',
|
||||
...(groups.调整.length ? groups.调整 : ['- 本次主要是零散维护项']),
|
||||
'',
|
||||
...(hasUpgradeReminder ? [
|
||||
'### 升级提醒',
|
||||
...(context.forceUpdate.minimumSupportedVersion ? [`- 最低安全版本:${context.forceUpdate.minimumSupportedVersion}`] : []),
|
||||
...(blockedVersions.length ? [`- 封禁版本:${blockedVersions.join(', ')}`] : []),
|
||||
''
|
||||
] : []),
|
||||
'### 感谢贡献者',
|
||||
...(thanks.length ? thanks : ['- 本版本无新增外部贡献']),
|
||||
'',
|
||||
'### 相关提交与 PR',
|
||||
...(references.length ? references : ['- 无']),
|
||||
''
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function isValidAiBody(body) {
|
||||
if (!body) return false
|
||||
return body.includes(`## ${PRODUCT_NAME}`) && body.includes('### 感谢贡献者') && body.includes('### 相关提交与 PR')
|
||||
}
|
||||
|
||||
function logAiConfig() {
|
||||
console.log('[ReleaseBody] AI config:')
|
||||
console.log(` apiUrl=${aiApiUrl}`)
|
||||
console.log(` model=${aiModel}`)
|
||||
console.log(` apiKeyConfigured=${Boolean(aiApiKey)}`)
|
||||
console.log(` usingDefaultApiUrl=${!process.env.AI_API_URL}`)
|
||||
console.log(` usingDefaultModel=${!process.env.AI_MODEL}`)
|
||||
}
|
||||
|
||||
async function generateAiBody(context) {
|
||||
if (!aiApiKey) {
|
||||
throw new Error('AI_API_KEY 未配置')
|
||||
}
|
||||
|
||||
logAiConfig()
|
||||
|
||||
const systemPrompt = [
|
||||
'你是一个发布说明撰写助手。',
|
||||
'只能基于输入中的 commits 和 pull requests 生成,不得编造任何功能或修复。',
|
||||
'输出必须是中文 Markdown,风格要自然,像真实产品版本说明,不要写成死板模板。',
|
||||
'标题必须包含软件名,不能只写版本号。',
|
||||
'第一行使用格式:## CipherTalk vX.Y.Z · 一句简短版本名',
|
||||
'第二段使用一行引用块(>)写一句导语,概括这次更新的重心。',
|
||||
'正文优先使用以下结构:',
|
||||
'### 这次更新',
|
||||
'### 变更明细',
|
||||
'#### 新增',
|
||||
'#### 修复',
|
||||
'#### 调整',
|
||||
'### 感谢贡献者',
|
||||
'### 相关提交与 PR',
|
||||
'如果上方有些内容没有,即可用一些涩话来填充,不要显得很死板或机械。',
|
||||
'如果存在最低安全版本或封禁版本,增加 ### 升级提醒 章节。',
|
||||
'分类建议:可参考提交标题前缀 feat/fix 做粗分类到 新增/修复;其余放到 调整(如果标题无法判断,就放到 调整)。',
|
||||
'如果某个分类为空,不要反复写“无/未检测到”这种机械表达,可以改成更自然但仍然克制的表述。',
|
||||
'如果这次主要是 chore、ci、release、workflow、refactor,也要把这些工程改动翻译成用户能理解的影响,比如“发布链路更稳”“版本分发更顺”“维护成本更低”,但不能编造功能。',
|
||||
'引用规则:',
|
||||
'有 PR 时优先引用 PR 标题;没有 PR 时才引用 commit 标题。',
|
||||
'列表尽量短:最多每类列出 5 条最关键的标题;其余可在导语或“这次更新”里用一句话说明总量。',
|
||||
'感谢规则:只有非主作者的 PR/commit 才出现在感谢段;主作者按代码中的逻辑是 ILoveBingLu(及其大小写/拼写变体)相关。',
|
||||
'不要写猜测:如果输入里没有足够信息,就明确说这次以内部整理、稳定性、发布链路或工程维护为主。',
|
||||
'不要输出代码块,不要输出 JSON,不要套娃标题。'
|
||||
].join('\n')
|
||||
|
||||
const userPrompt = [
|
||||
`请根据以下发布上下文,为 ${PRODUCT_NAME} ${context.tag} 生成一份更有辨识度的发布说明。`,
|
||||
'附加要求:',
|
||||
`- 标题必须带 ${PRODUCT_NAME} 和版本号,并给这次版本起一个简短名字。`,
|
||||
'- 不要每次都复用同一套句式。',
|
||||
'- 如果提交主要是发布流程、CI、脚本、环境变量之类的工程项,也要写出它们对发版和分发稳定性的意义。',
|
||||
'- 保留“感谢贡献者”和“相关提交与 PR”章节。',
|
||||
'- 你是一个类似伤感者的文案大师,写出来的东西要有温度和辨识度,不要死板无趣。',
|
||||
'',
|
||||
JSON.stringify(context, null, 2)
|
||||
].join('\n')
|
||||
const startedAt = Date.now()
|
||||
console.log(`[ReleaseBody] AI request start for ${context.tag}`)
|
||||
|
||||
const response = await fetch(aiApiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${aiApiKey}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: aiModel,
|
||||
temperature: 0.7,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt }
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
const durationMs = Date.now() - startedAt
|
||||
console.log(`[ReleaseBody] AI response received status=${response.status} durationMs=${durationMs}`)
|
||||
|
||||
if (!response.ok) {
|
||||
const raw = await response.text()
|
||||
console.error(`[ReleaseBody] AI response error body=${raw}`)
|
||||
throw new Error(`AI 请求失败: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const content = data?.choices?.[0]?.message?.content
|
||||
console.log(`[ReleaseBody] AI content length=${typeof content === 'string' ? content.length : 0}`)
|
||||
if (typeof content !== 'string' || !content.trim()) {
|
||||
throw new Error('AI 返回内容为空')
|
||||
}
|
||||
|
||||
const body = content.trim()
|
||||
if (!isValidAiBody(body)) {
|
||||
console.error('[ReleaseBody] AI output preview:')
|
||||
console.error(body.slice(0, 1000))
|
||||
throw new Error('AI 返回内容不符合格式要求')
|
||||
}
|
||||
|
||||
console.log('[ReleaseBody] AI output validated successfully')
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!fs.existsSync(contextPath)) {
|
||||
throw new Error(`未找到 release context: ${contextPath}`)
|
||||
}
|
||||
|
||||
const context = JSON.parse(fs.readFileSync(contextPath, 'utf8'))
|
||||
|
||||
let body
|
||||
try {
|
||||
body = await generateAiBody(context)
|
||||
console.log('✅ 已生成 AI Release Body')
|
||||
} catch (error) {
|
||||
console.warn('⚠️ AI 生成失败,回退到模板正文:', String(error))
|
||||
body = buildFallbackBody(context)
|
||||
console.log(`[ReleaseBody] Fallback body length=${body.length}`)
|
||||
}
|
||||
|
||||
fs.writeFileSync(outputPath, `${body.trim()}\n`, 'utf8')
|
||||
console.log(`✅ release-body.md 已生成: ${outputPath}`)
|
||||
console.log(`[ReleaseBody] Final body length=${body.trim().length}`)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('❌ 生成 release-body.md 失败:', error)
|
||||
process.exit(1)
|
||||
})
|
||||
199
scripts/generate-release-context.js
Normal file
199
scripts/generate-release-context.js
Normal file
@@ -0,0 +1,199 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const { execSync } = require('child_process')
|
||||
|
||||
const rootDir = path.resolve(__dirname, '..')
|
||||
const releaseDir = path.join(rootDir, 'release')
|
||||
const owner = process.env.GITHUB_REPOSITORY_OWNER || 'ILoveBingLu'
|
||||
const repo = (process.env.GITHUB_REPOSITORY || `${owner}/CipherTalk`).split('/')[1] || 'CipherTalk'
|
||||
const currentTag = process.env.RELEASE_TAG || process.env.GITHUB_REF_NAME || ''
|
||||
const pkg = require(path.join(rootDir, 'package.json'))
|
||||
|
||||
function parseEnvText(content) {
|
||||
const result = {}
|
||||
for (const line of String(content || '').split(/\r?\n/)) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed.startsWith('#')) continue
|
||||
const eqIndex = trimmed.indexOf('=')
|
||||
if (eqIndex <= 0) continue
|
||||
const key = trimmed.slice(0, eqIndex).trim()
|
||||
let value = trimmed.slice(eqIndex + 1).trim()
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1)
|
||||
}
|
||||
result[key] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function loadLocalSecretEnv() {
|
||||
const candidates = [
|
||||
path.join(rootDir, '.release.local.env'),
|
||||
path.join(rootDir, '.env.local')
|
||||
]
|
||||
const merged = {}
|
||||
for (const filePath of candidates) {
|
||||
if (!fs.existsSync(filePath)) continue
|
||||
try {
|
||||
const parsed = parseEnvText(fs.readFileSync(filePath, 'utf8'))
|
||||
Object.assign(merged, parsed)
|
||||
console.log(`[ReleaseContext] Loaded local env file: ${path.basename(filePath)}`)
|
||||
} catch (e) {
|
||||
console.warn(`[ReleaseContext] Failed to read local env file: ${filePath}`, String(e))
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
const localSecrets = loadLocalSecretEnv()
|
||||
const ghToken = process.env.GH_TOKEN || localSecrets.GH_TOKEN || ''
|
||||
|
||||
function runGit(command) {
|
||||
return execSync(command, {
|
||||
cwd: rootDir,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
}).trim()
|
||||
}
|
||||
|
||||
function safeJsonParse(value, fallback) {
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
function parseList(value) {
|
||||
if (!value) return []
|
||||
return String(value)
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function getPreviousTag() {
|
||||
const tags = runGit('git tag --sort=-version:refname')
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
if (!currentTag) return tags[0] || null
|
||||
|
||||
const currentIndex = tags.indexOf(currentTag)
|
||||
if (currentIndex === -1) return tags[0] || null
|
||||
return tags[currentIndex + 1] || null
|
||||
}
|
||||
|
||||
function getCommitRange(previousTag, tag) {
|
||||
if (!tag) return 'HEAD'
|
||||
// 如果上一标签拿不到(例如 checkout 浅克隆/无 tags),则退化成取 tag 前最近 50 次提交,
|
||||
// 避免 release-context 里 commits/pullRequests 为空,导致后续 AI 发布说明内容很少。
|
||||
if (!previousTag) return `${tag}~50..${tag}`
|
||||
if (previousTag === tag) return tag
|
||||
return `${previousTag}..${tag}`
|
||||
}
|
||||
|
||||
function extractPrNumbers(commits) {
|
||||
const prNumbers = new Set()
|
||||
for (const commit of commits) {
|
||||
const matches = commit.subject.match(/#(\d+)/g)
|
||||
if (!matches) continue
|
||||
for (const match of matches) {
|
||||
prNumbers.add(Number(match.slice(1)))
|
||||
}
|
||||
}
|
||||
return Array.from(prNumbers)
|
||||
}
|
||||
|
||||
async function fetchPullRequest(prNumber) {
|
||||
if (!ghToken) return null
|
||||
|
||||
const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}`, {
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
Authorization: `Bearer ${ghToken}`,
|
||||
'User-Agent': 'CipherTalk-Release-Context'
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) return null
|
||||
const data = await response.json()
|
||||
return {
|
||||
number: data.number,
|
||||
title: data.title,
|
||||
url: data.html_url,
|
||||
authorLogin: data.user?.login || null,
|
||||
authorName: data.user?.login || null,
|
||||
mergedBy: data.merged_by?.login || null
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!fs.existsSync(releaseDir)) {
|
||||
fs.mkdirSync(releaseDir, { recursive: true })
|
||||
}
|
||||
|
||||
const previousTag = getPreviousTag()
|
||||
const commitRange = getCommitRange(previousTag, currentTag || 'HEAD')
|
||||
console.log(`[ReleaseContext] tag=${currentTag || `v${pkg.version}`}`)
|
||||
console.log(`[ReleaseContext] previousTag=${previousTag || 'none'}`)
|
||||
console.log(`[ReleaseContext] commitRange=${commitRange}`)
|
||||
console.log(`[ReleaseContext] ghTokenConfigured=${Boolean(ghToken)}`)
|
||||
|
||||
const commitLines = runGit(`git log ${commitRange} --pretty=format:"%H|%h|%an|%ae|%s"`)
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const commits = commitLines.map((line) => {
|
||||
const [sha, shortSha, authorName, authorEmail, ...subjectParts] = line.split('|')
|
||||
return {
|
||||
sha,
|
||||
shortSha,
|
||||
url: `https://github.com/${owner}/${repo}/commit/${sha}`,
|
||||
authorName,
|
||||
authorEmail,
|
||||
subject: subjectParts.join('|')
|
||||
}
|
||||
})
|
||||
|
||||
const prNumbers = extractPrNumbers(commits)
|
||||
const prs = []
|
||||
for (const prNumber of prNumbers) {
|
||||
const pr = await fetchPullRequest(prNumber)
|
||||
if (pr) prs.push(pr)
|
||||
}
|
||||
console.log(`[ReleaseContext] commits=${commits.length}`)
|
||||
console.log(`[ReleaseContext] detectedPrNumbers=${prNumbers.length}`)
|
||||
console.log(`[ReleaseContext] fetchedPullRequests=${prs.length}`)
|
||||
|
||||
const context = {
|
||||
version: pkg.version,
|
||||
tag: currentTag || `v${pkg.version}`,
|
||||
previousTag,
|
||||
generatedAt: new Date().toISOString(),
|
||||
repository: {
|
||||
owner,
|
||||
repo
|
||||
},
|
||||
forceUpdate: {
|
||||
minimumSupportedVersion: process.env.FORCE_UPDATE_MIN_VERSION || null,
|
||||
blockedVersions: parseList(process.env.FORCE_UPDATE_BLOCKED_VERSIONS)
|
||||
},
|
||||
commits,
|
||||
pullRequests: prs
|
||||
}
|
||||
|
||||
const outputPath = path.join(releaseDir, 'release-context.json')
|
||||
fs.writeFileSync(outputPath, `${JSON.stringify(context, null, 2)}\n`, 'utf8')
|
||||
console.log(`✅ release-context.json 已生成: ${outputPath}`)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('❌ 生成 release-context.json 失败:', error)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -1,61 +0,0 @@
|
||||
const sharp = require('sharp');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const INPUT_LOGO = path.join(__dirname, '../public/xinnian.png');
|
||||
// WPF Project Directory
|
||||
const OUTPUT_DIR = path.join(__dirname, '../MyCoolInstaller');
|
||||
const OUTPUT_BANNER = path.join(OUTPUT_DIR, 'left_banner.png');
|
||||
|
||||
async function generateWpfAssets() {
|
||||
console.log('正在生成 WPF 安装器资源...');
|
||||
|
||||
// 生成左侧通栏图片: 240x520 (Window Height is 520)
|
||||
try {
|
||||
const width = 240;
|
||||
const height = 520;
|
||||
|
||||
// 1. 创建背景 (新年淡红)
|
||||
const banner = await sharp({
|
||||
create: {
|
||||
width: width,
|
||||
height: height,
|
||||
channels: 4,
|
||||
background: '#FFF0F0'
|
||||
}
|
||||
});
|
||||
|
||||
// 2. 准备 Logo (大一点,放在上部)
|
||||
const logoBuffer = await sharp(INPUT_LOGO)
|
||||
.resize(160, 160, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
||||
.toBuffer();
|
||||
|
||||
// 3. 准备底部装饰 (可选,这里简化,只放Logo)
|
||||
// 也可以叠加一些红色圆或者图案来增加氛围,这里简单叠加一个半透明红色块到底部
|
||||
const decorHeight = 100;
|
||||
const decor = await sharp({
|
||||
create: {
|
||||
width: width,
|
||||
height: decorHeight,
|
||||
channels: 4,
|
||||
background: { r: 230, g: 0, b: 18, alpha: 0.1 } // rgba(230, 0, 18, 0.1)
|
||||
}
|
||||
}).png().toBuffer();
|
||||
|
||||
// 合成
|
||||
await banner
|
||||
.composite([
|
||||
{ input: logoBuffer, top: 60, left: 40 }, // Logo 居中 (240-160)/2 = 40
|
||||
{ input: decor, top: height - decorHeight, left: 0 } //底部装饰
|
||||
])
|
||||
.png()
|
||||
.toFile(OUTPUT_BANNER);
|
||||
|
||||
console.log('✅ WPF 侧边栏已生成:', OUTPUT_BANNER);
|
||||
|
||||
} catch (e) {
|
||||
console.error('生成 WPF 资源失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
generateWpfAssets();
|
||||
53
scripts/mcp-probe.js
Normal file
53
scripts/mcp-probe.js
Normal file
@@ -0,0 +1,53 @@
|
||||
const path = require('path')
|
||||
|
||||
async function main() {
|
||||
const { Client } = await import('@modelcontextprotocol/sdk/client/index.js')
|
||||
const { StdioClientTransport } = await import('@modelcontextprotocol/sdk/client/stdio.js')
|
||||
|
||||
const mode = process.argv[2] || 'dev'
|
||||
const cwd = process.cwd()
|
||||
let command
|
||||
let args
|
||||
let transportCwd = cwd
|
||||
|
||||
if (mode === 'packaged') {
|
||||
const launcherPath = process.argv[3] || path.join(cwd, 'ciphertalk-mcp.cmd')
|
||||
command = launcherPath
|
||||
args = []
|
||||
transportCwd = path.dirname(launcherPath)
|
||||
} else {
|
||||
command = process.platform === 'win32' ? 'npm.cmd' : 'npm'
|
||||
args = ['run', 'mcp']
|
||||
}
|
||||
|
||||
const transport = new StdioClientTransport({
|
||||
command,
|
||||
args,
|
||||
cwd: transportCwd,
|
||||
stderr: 'pipe'
|
||||
})
|
||||
|
||||
const client = new Client({
|
||||
name: 'ciphertalk-mcp-probe',
|
||||
version: '1.0.0'
|
||||
})
|
||||
|
||||
try {
|
||||
await client.connect(transport)
|
||||
const tools = await client.listTools()
|
||||
const health = await client.callTool({ name: 'health_check', arguments: {} })
|
||||
|
||||
console.log(JSON.stringify({
|
||||
mode,
|
||||
tools: (tools.tools || []).map((tool) => tool.name),
|
||||
health
|
||||
}, null, 2))
|
||||
} finally {
|
||||
await client.close()
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('[CipherTalk MCP Probe] failed:', error)
|
||||
process.exit(1)
|
||||
})
|
||||
60
scripts/mcp-runner.js
Normal file
60
scripts/mcp-runner.js
Normal file
@@ -0,0 +1,60 @@
|
||||
const { spawn } = require('child_process')
|
||||
const { spawnSync } = require('child_process')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const electronBinary = require('electron')
|
||||
|
||||
const rootDir = path.resolve(__dirname, '..')
|
||||
const entry = path.join(rootDir, 'dist-electron', 'mcp.js')
|
||||
|
||||
if (!fs.existsSync(entry)) {
|
||||
process.stderr.write('[CipherTalk MCP Runner] dist-electron/mcp.js not found, running build:mcp...\n')
|
||||
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm'
|
||||
const build = spawnSync(npmCmd, ['run', 'build:mcp'], {
|
||||
cwd: rootDir,
|
||||
env: process.env,
|
||||
stdio: 'inherit',
|
||||
windowsHide: true
|
||||
})
|
||||
|
||||
if (build.status !== 0 || !fs.existsSync(entry)) {
|
||||
process.stderr.write('[CipherTalk MCP Runner] build:mcp failed, cannot start MCP server\n')
|
||||
process.exit(build.status ?? 1)
|
||||
}
|
||||
}
|
||||
|
||||
const child = spawn(electronBinary, [entry], {
|
||||
cwd: rootDir,
|
||||
env: {
|
||||
...process.env,
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
CIPHERTALK_MCP_LAUNCHER: 'dev-runner'
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true
|
||||
})
|
||||
|
||||
if (process.stdin) {
|
||||
process.stdin.pipe(child.stdin)
|
||||
}
|
||||
|
||||
if (child.stdout) {
|
||||
child.stdout.pipe(process.stdout)
|
||||
}
|
||||
|
||||
if (child.stderr) {
|
||||
child.stderr.pipe(process.stderr)
|
||||
}
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal)
|
||||
return
|
||||
}
|
||||
process.exit(code ?? 0)
|
||||
})
|
||||
|
||||
child.on('error', (error) => {
|
||||
process.stderr.write(`[CipherTalk MCP Runner] failed: ${String(error)}\n`)
|
||||
process.exit(1)
|
||||
})
|
||||
81
scripts/prepare-release-announcement.js
Normal file
81
scripts/prepare-release-announcement.js
Normal file
@@ -0,0 +1,81 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const rootDir = path.resolve(__dirname, '..')
|
||||
const releaseDir = path.join(rootDir, 'release')
|
||||
const tempDir = path.join(rootDir, '.tmp')
|
||||
const bodyPath = path.join(releaseDir, 'release-body.md')
|
||||
const forceUpdatePath = path.join(releaseDir, 'force-update.json')
|
||||
const outputPath = path.join(tempDir, 'release-announcement.json')
|
||||
const packageJsonPath = path.join(rootDir, 'package.json')
|
||||
|
||||
function readJsonIfExists(filePath) {
|
||||
if (!fs.existsSync(filePath)) return null
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
||||
} catch (error) {
|
||||
console.warn(`[ReleaseAnnouncement] 读取 JSON 失败: ${filePath}`, String(error))
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function readTextIfExists(filePath) {
|
||||
if (!fs.existsSync(filePath)) return ''
|
||||
try {
|
||||
return fs.readFileSync(filePath, 'utf8').trim()
|
||||
} catch (error) {
|
||||
console.warn(`[ReleaseAnnouncement] 读取文本失败: ${filePath}`, String(error))
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function buildFallbackBody(version, releaseNotes) {
|
||||
const normalizedNotes = String(releaseNotes || '').trim()
|
||||
const overview = normalizedNotes
|
||||
? normalizedNotes
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => (line.startsWith('-') || line.startsWith('*') ? line : `- ${line}`))
|
||||
.join('\n')
|
||||
: '- 本次版本已完成发布,详细内容将在后续发布说明中补充。'
|
||||
|
||||
return [
|
||||
`## CipherTalk v${version}`,
|
||||
'',
|
||||
'### 概览',
|
||||
overview,
|
||||
'',
|
||||
'### 感谢贡献者',
|
||||
'- 感谢每一位使用与反馈的用户',
|
||||
'',
|
||||
'### 相关提交与 PR',
|
||||
'- 详见本次发布记录',
|
||||
''
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function main() {
|
||||
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'))
|
||||
const version = String(pkg.version || '').trim()
|
||||
if (!version) {
|
||||
throw new Error('package.json 中未找到 version')
|
||||
}
|
||||
|
||||
const releaseBody = readTextIfExists(bodyPath)
|
||||
const forceUpdate = readJsonIfExists(forceUpdatePath) || {}
|
||||
const releaseNotes = String(forceUpdate.releaseNotes || '').trim()
|
||||
|
||||
const payload = {
|
||||
version,
|
||||
releaseBody: releaseBody || buildFallbackBody(version, releaseNotes),
|
||||
releaseNotes: releaseNotes || releaseBody || '',
|
||||
generatedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
fs.mkdirSync(tempDir, { recursive: true })
|
||||
fs.writeFileSync(outputPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8')
|
||||
console.log(`[ReleaseAnnouncement] 已生成 ${outputPath}`)
|
||||
}
|
||||
|
||||
main()
|
||||
183
scripts/send-telegram-release.js
Normal file
183
scripts/send-telegram-release.js
Normal file
@@ -0,0 +1,183 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const rootDir = path.resolve(__dirname, '..')
|
||||
const releaseDir = path.join(rootDir, 'release')
|
||||
const contextPath = path.join(releaseDir, 'release-context.json')
|
||||
const releaseBodyPath = path.join(releaseDir, 'release-body.md')
|
||||
|
||||
const TELEGRAM_BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN || ''
|
||||
const TELEGRAM_CHAT_IDS = String(process.env.TELEGRAM_CHAT_IDS || '')
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
const TELEGRAM_RELEASE_COVER_URL = process.env.TELEGRAM_RELEASE_COVER_URL || ''
|
||||
const mode = process.env.TELEGRAM_NOTIFY_MODE || 'success'
|
||||
|
||||
function escapeHtml(text) {
|
||||
return String(text || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
}
|
||||
|
||||
function markdownToPlainSummary(markdown) {
|
||||
return String(markdown || '')
|
||||
.replace(/^#+\s*/gm, '')
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||
.replace(/[*_`>-]/g, '')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function getContext() {
|
||||
if (!fs.existsSync(contextPath)) return null
|
||||
return JSON.parse(fs.readFileSync(contextPath, 'utf8'))
|
||||
}
|
||||
|
||||
function getReleaseBody() {
|
||||
if (!fs.existsSync(releaseBodyPath)) return ''
|
||||
return fs.readFileSync(releaseBodyPath, 'utf8')
|
||||
}
|
||||
|
||||
function buildButtons(version) {
|
||||
const releaseUrl = `https://github.com/ILoveBingLu/CipherTalk/releases/tag/v${version}`
|
||||
const installerUrl = `https://github.com/ILoveBingLu/CipherTalk/releases/download/v${version}/CipherTalk-${version}-Setup.exe`
|
||||
return {
|
||||
inline_keyboard: [
|
||||
[
|
||||
{ text: '📦 查看 Release', url: releaseUrl },
|
||||
{ text: '⬇️ 下载安装包', url: installerUrl }
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function buildSuccessMessage(context, releaseBody) {
|
||||
const version = context?.version || process.env.RELEASE_VERSION || 'unknown'
|
||||
const blockedVersions = context?.forceUpdate?.blockedVersions || []
|
||||
const minimumSupportedVersion = context?.forceUpdate?.minimumSupportedVersion || ''
|
||||
const hasForceUpdate = Boolean(minimumSupportedVersion || blockedVersions.length > 0)
|
||||
const summary = markdownToPlainSummary(releaseBody)
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.slice(0, 8)
|
||||
.join('\n')
|
||||
|
||||
const thanks = []
|
||||
const primaryLogins = new Set(['ILoveBingLu'])
|
||||
const primaryNames = new Set(['ILoveBingLu', 'BingLu', 'ILoveBinglu'])
|
||||
for (const pr of context?.pullRequests || []) {
|
||||
if (pr?.authorLogin && !primaryLogins.has(pr.authorLogin)) {
|
||||
thanks.push(`🙏 感谢 @${pr.authorLogin} 提交 PR #${pr.number}`)
|
||||
}
|
||||
}
|
||||
for (const commit of context?.commits || []) {
|
||||
const hasPrRef = /#(\d+)/.test(commit.subject || '')
|
||||
const authorName = String(commit.authorName || '').trim()
|
||||
if (!hasPrRef && authorName && !primaryNames.has(authorName)) {
|
||||
thanks.push(`🙏 感谢 ${authorName} 提交改动《${commit.subject}》`)
|
||||
}
|
||||
}
|
||||
|
||||
const lines = [
|
||||
`🚀 <b>CipherTalk v${escapeHtml(version)} 已发布</b>`,
|
||||
'',
|
||||
'📝 <b>本次更新摘要</b>',
|
||||
escapeHtml(summary || '本次版本已完成发布,可点击下方按钮查看完整说明。'),
|
||||
]
|
||||
|
||||
if (hasForceUpdate) {
|
||||
lines.push('', '⚠️ <b>强制更新提醒</b>')
|
||||
if (minimumSupportedVersion) {
|
||||
lines.push(`- 最低安全版本:<code>${escapeHtml(minimumSupportedVersion)}</code>`)
|
||||
}
|
||||
if (blockedVersions.length) {
|
||||
lines.push(`- 封禁版本:<code>${escapeHtml(blockedVersions.join(', '))}</code>`)
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('', '🔗 <b>相关链接</b>', `- GitHub Release:<a href="https://github.com/ILoveBingLu/CipherTalk/releases/tag/v${encodeURIComponent(version)}">查看发布说明</a>`)
|
||||
|
||||
if (thanks.length) {
|
||||
lines.push('', '🌟 <b>感谢贡献者</b>', ...thanks.map((line) => escapeHtml(line)))
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function buildFailureMessage() {
|
||||
const workflowUrl = process.env.GITHUB_SERVER_URL && process.env.GITHUB_REPOSITORY && process.env.GITHUB_RUN_ID
|
||||
? `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`
|
||||
: ''
|
||||
const version = process.env.RELEASE_VERSION || process.env.GITHUB_REF_NAME || 'unknown'
|
||||
const lines = [
|
||||
`❌ <b>CipherTalk ${escapeHtml(version)} 发布失败</b>`,
|
||||
'',
|
||||
'请尽快检查 GitHub Actions 日志。'
|
||||
]
|
||||
if (workflowUrl) {
|
||||
lines.push('', `🔗 <a href="${workflowUrl}">查看失败日志</a>`)
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
async function sendTelegramMessage(chatId, text, replyMarkup) {
|
||||
const body = {
|
||||
chat_id: chatId,
|
||||
text,
|
||||
parse_mode: 'HTML',
|
||||
disable_web_page_preview: false,
|
||||
reply_markup: replyMarkup
|
||||
}
|
||||
|
||||
const endpoint = TELEGRAM_RELEASE_COVER_URL ? 'sendPhoto' : 'sendMessage'
|
||||
const payload = TELEGRAM_RELEASE_COVER_URL
|
||||
? {
|
||||
chat_id: chatId,
|
||||
photo: TELEGRAM_RELEASE_COVER_URL,
|
||||
caption: text,
|
||||
parse_mode: 'HTML',
|
||||
reply_markup: replyMarkup
|
||||
}
|
||||
: body
|
||||
|
||||
const response = await fetch(`https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const raw = await response.text()
|
||||
throw new Error(`Telegram 发送失败 (${response.status}): ${raw}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!TELEGRAM_BOT_TOKEN || TELEGRAM_CHAT_IDS.length === 0) {
|
||||
console.log('ℹ️ Telegram 未配置,跳过通知')
|
||||
return
|
||||
}
|
||||
|
||||
const context = getContext()
|
||||
const releaseBody = getReleaseBody()
|
||||
const version = context?.version || process.env.RELEASE_VERSION || 'unknown'
|
||||
const text = mode === 'failure'
|
||||
? buildFailureMessage()
|
||||
: buildSuccessMessage(context, releaseBody)
|
||||
const replyMarkup = mode === 'failure' ? undefined : buildButtons(version)
|
||||
|
||||
for (const chatId of TELEGRAM_CHAT_IDS) {
|
||||
await sendTelegramMessage(chatId, text, replyMarkup)
|
||||
}
|
||||
|
||||
console.log(`✅ 已发送 Telegram 通知到 ${TELEGRAM_CHAT_IDS.length} 个目标`)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('❌ Telegram 通知失败:', error)
|
||||
process.exit(1)
|
||||
})
|
||||
115
sikll/ct-mcp-copilot/SKILL.md
Normal file
115
sikll/ct-mcp-copilot/SKILL.md
Normal file
@@ -0,0 +1,115 @@
|
||||
---
|
||||
name: ct-mcp-copilot
|
||||
description: Use CipherTalk MCP as an AI copilot for contact lookup, session resolution, message search, context retrieval, and chat analytics. Trigger when the user provides partial, fuzzy, mistaken, or incomplete clues such as nicknames, remarks, organization fragments, typo-prone names, or half-remembered keywords, or wants the AI to proactively dig for more data instead of stopping after one failed query.
|
||||
---
|
||||
|
||||
# ct-mcp-copilot
|
||||
|
||||
Use CipherTalk MCP like a patient investigator, not like a rigid database client.
|
||||
|
||||
## Core behavior
|
||||
|
||||
1. Start broad, then narrow.
|
||||
2. Treat `list_contacts` and `list_sessions` as fuzzy entry points.
|
||||
3. Assume the user may remember only part of the truth.
|
||||
4. Do not stop after the first miss.
|
||||
5. When multiple candidates exist, compare them and keep shrinking the set.
|
||||
|
||||
## Default routing
|
||||
|
||||
1. If the user describes a person loosely, start with both `list_contacts` and `list_sessions`.
|
||||
2. When the clue is especially fuzzy or typo-prone, prefer `resolve_session` first to get candidates, confidence, and the recommended next action.
|
||||
3. If the target is still unclear, compare remark, nickname, display name, recent timestamp, and session kind.
|
||||
4. Once one session becomes the best candidate, switch to `get_messages` or `get_session_context`.
|
||||
5. If the user wants more clues or the session is still uncertain, use `search_messages` across multiple sessions or globally.
|
||||
6. Use analytics tools only after the target scope is reasonably stable.
|
||||
|
||||
## Fuzzy clue strategy
|
||||
|
||||
When the user gives weak clues such as a nickname fragment, an organization fragment, a possibly mistyped name, or a half-remembered phrase:
|
||||
|
||||
- Search contacts and sessions in parallel.
|
||||
- Use fragment matches, nickname matches, remark matches, and organization-name matches.
|
||||
- Prefer candidates with recent activity when the user implies recency.
|
||||
- If a keyword is uncertain, search globally before concluding there is no evidence.
|
||||
- If one query misses, reformulate the clue and try another route.
|
||||
|
||||
## Candidate handling
|
||||
|
||||
When there are multiple plausible candidates:
|
||||
|
||||
- Do not pretend the result is unique.
|
||||
- Read `resolve_session.candidates[*].evidence` before choosing.
|
||||
- Compare the top candidates using recent message preview, session kind, and contact aliases.
|
||||
- Explain which candidate is currently strongest and why.
|
||||
- If needed, inspect each candidate’s latest context before answering.
|
||||
|
||||
When `resolve_session` returns a recommendation:
|
||||
|
||||
- Treat `recommended.confidence` as a hint, not a blind verdict.
|
||||
- Use `recommended.evidence` to explain why this candidate is strongest.
|
||||
- If confidence is only `medium` or `low`, verify with `get_session_context` or `search_messages` before committing.
|
||||
|
||||
When `search_messages` returns global or multi-session hits:
|
||||
|
||||
- Read `sessionSummaries` first.
|
||||
- Use `sessionSummaries` to see which session is accumulating the strongest evidence.
|
||||
- Use `sampleExcerpts` to decide whether to keep narrowing, switch sessions, or confirm the lead.
|
||||
|
||||
## Battle report
|
||||
|
||||
After each meaningful exploration round, produce a very short battle report for yourself or the user:
|
||||
|
||||
- “战报:已锁定 3 个候选,下一步按备注和最近消息区分。”
|
||||
- “战报:会话还不唯一,准备全局搜关键词补证据。”
|
||||
- “战报:已确认目标会话,开始拉最近上下文。”
|
||||
|
||||
Keep it short. It should help trace the reasoning, not overshadow the answer.
|
||||
|
||||
## Export workflow
|
||||
|
||||
When the user asks to export chat history:
|
||||
|
||||
1. Check whether the request already includes:
|
||||
- target session
|
||||
- time range
|
||||
- export format
|
||||
- media selections
|
||||
2. If the target is fuzzy, resolve it first with `resolve_session`.
|
||||
3. If the target is still ambiguous, keep narrowing and do not export yet.
|
||||
4. Use `export_chat(validateOnly=true)` to audit whether the request is complete.
|
||||
5. If `missingFields` is non-empty, prefer `followUpQuestions`; otherwise fall back to `nextQuestion`.
|
||||
6. Ask follow-up questions until the missing fields are all resolved.
|
||||
7. Prefer the configured default export directory when it exists and is writable.
|
||||
8. If the default export directory is unavailable, ask the user for an output directory.
|
||||
9. Only call `export_chat` without `validateOnly` after the request is complete.
|
||||
|
||||
When asking follow-up questions for export:
|
||||
|
||||
- ask only for missing fields
|
||||
- do not ask again for fields the user already confirmed
|
||||
- treat media selections as required and explicit
|
||||
- do not silently assume a time range
|
||||
|
||||
After export finishes, summarize:
|
||||
|
||||
- which session was exported
|
||||
- the time range
|
||||
- the format
|
||||
- which media were included
|
||||
- where the files were written
|
||||
|
||||
## Never do this
|
||||
|
||||
- Do not conclude “没有数据” after a single failed query.
|
||||
- Do not insist on exact `sessionId` when fuzzy resolution is possible.
|
||||
- Do not ignore `hint` or candidate summaries returned by MCP.
|
||||
- Do not ignore `evidence` on resolved candidates or `sessionSummaries` on search results.
|
||||
- Do not lock onto a candidate while ambiguity is still obvious.
|
||||
- Do not start exporting before target session, time range, format, and media selections are all confirmed.
|
||||
- Do not quietly choose a time range or media mix on the user’s behalf.
|
||||
|
||||
## References
|
||||
|
||||
- Read [references/queries.md](references/queries.md) when you need concrete fuzzy-query playbooks, fallback chains, or battle-report examples.
|
||||
- Read [references/export.md](references/export.md) when the user asks to export chat history.
|
||||
104
sikll/ct-mcp-copilot/references/export.md
Normal file
104
sikll/ct-mcp-copilot/references/export.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# CipherTalk MCP Export Playbook
|
||||
|
||||
## Goal
|
||||
|
||||
Turn vague export requests into a complete, executable export plan.
|
||||
|
||||
## Required fields before exporting
|
||||
|
||||
Do not export until all of these are known:
|
||||
|
||||
- target session
|
||||
- time range
|
||||
- export format
|
||||
- media selections
|
||||
|
||||
Output directory may be omitted only if the configured default export directory is available and writable.
|
||||
|
||||
## Export routing
|
||||
|
||||
### 1. User asks to export chat history with incomplete info
|
||||
|
||||
Example:
|
||||
|
||||
- “导出聊天记录”
|
||||
- “把那个人的聊天导出来”
|
||||
|
||||
Use this order:
|
||||
|
||||
1. Resolve the session if needed with `resolve_session`
|
||||
2. Call `export_chat(validateOnly=true)`
|
||||
3. Read `missingFields`
|
||||
4. Prefer `followUpQuestions`; use `nextQuestion` only as fallback
|
||||
5. Ask only for the missing fields
|
||||
6. Repeat `validateOnly` until `canExport=true`
|
||||
7. Call `export_chat(validateOnly=false)`
|
||||
|
||||
Battle report:
|
||||
|
||||
- “战报:导出条件还没齐,先把缺项问全。”
|
||||
|
||||
### 2. User gives target and format but no time range
|
||||
|
||||
Example:
|
||||
|
||||
- “导出这个会话为 html”
|
||||
|
||||
Use this order:
|
||||
|
||||
1. Confirm the target session
|
||||
2. Run `export_chat(validateOnly=true)`
|
||||
3. Ask for time range
|
||||
4. Ask for media selections if still missing
|
||||
5. Export only after validation passes
|
||||
|
||||
### 3. User gives almost everything
|
||||
|
||||
Example:
|
||||
|
||||
- “导出最近三个月的聊天记录为 html,只要图片和视频”
|
||||
|
||||
Use this order:
|
||||
|
||||
1. Resolve the target session if needed
|
||||
2. Run `export_chat(validateOnly=true)`
|
||||
3. If only `outputDir` is missing, prefer the configured default export path
|
||||
4. If validation passes, export directly
|
||||
|
||||
Battle report:
|
||||
|
||||
- “战报:导出参数基本齐了,只差最后确认落盘位置。”
|
||||
|
||||
## How to ask follow-up questions
|
||||
|
||||
Ask in this priority order:
|
||||
|
||||
1. target session
|
||||
2. time range
|
||||
3. format
|
||||
4. media selections
|
||||
5. output directory only if default path is unavailable
|
||||
|
||||
When asking about media selections, be explicit:
|
||||
|
||||
- avatars
|
||||
- images
|
||||
- videos
|
||||
- emojis
|
||||
- voices
|
||||
|
||||
Do not accept vague phrasing like “带媒体” without clarifying the exact set.
|
||||
|
||||
## Answer style after export
|
||||
|
||||
Keep the export completion summary short and operational:
|
||||
|
||||
- exported session
|
||||
- time range
|
||||
- format
|
||||
- included media
|
||||
- output path
|
||||
|
||||
## Local helper
|
||||
|
||||
If you want a local dry-run outside MCP, use `scripts/validate-export-request.cjs` to sanity-check a request payload before wiring it into tool calls.
|
||||
113
sikll/ct-mcp-copilot/references/queries.md
Normal file
113
sikll/ct-mcp-copilot/references/queries.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# CipherTalk MCP Query Playbook
|
||||
|
||||
## Quick playbooks
|
||||
|
||||
### 1. User gives a vague person clue
|
||||
|
||||
Example:
|
||||
|
||||
- “帮我查那个昵称像英文名的人最近聊了什么”
|
||||
- “那个带组织备注的人”
|
||||
|
||||
Use this order:
|
||||
|
||||
1. `list_contacts(q=<clue>)`
|
||||
2. `list_sessions(q=<clue>)`
|
||||
3. If the clue is weak, run `resolve_session(query=<clue>)`
|
||||
4. Read `recommended`, `confidence`, and `evidence`
|
||||
5. Compare candidates
|
||||
6. `get_session_context` on the best candidate
|
||||
7. If still uncertain, `search_messages` with a related keyword
|
||||
|
||||
Battle report:
|
||||
|
||||
- “战报:联系人和会话都已起底,先核对最近上下文。”
|
||||
|
||||
### 2. User may remember the name wrong
|
||||
|
||||
Example:
|
||||
|
||||
- “名字可能记错了,只记得一半”
|
||||
|
||||
Use this order:
|
||||
|
||||
1. Search multiple variants in contacts and sessions
|
||||
2. Run `resolve_session` on the variants if needed
|
||||
3. Prefer overlap between results
|
||||
4. If multiple hits remain, inspect latest context for top candidates
|
||||
5. Tell the user which one looks most plausible and why
|
||||
|
||||
Battle report:
|
||||
|
||||
- “战报:记忆有偏差,先用别名和片段交叉排嫌疑人。”
|
||||
|
||||
### 3. User wants more data, not just one answer
|
||||
|
||||
Example:
|
||||
|
||||
- “你自己多查点”
|
||||
- “再深挖一点”
|
||||
|
||||
Use this order:
|
||||
|
||||
1. Resolve the most likely session
|
||||
2. Read `resolve_session.recommended.evidence`
|
||||
3. Pull latest context
|
||||
4. Search related keywords globally or across nearby candidates
|
||||
5. Use `search_messages.sessionSummaries` to see which session owns most of the evidence
|
||||
6. Add timing clues, active hours, or contact rankings if useful
|
||||
|
||||
Battle report:
|
||||
|
||||
- “战报:主目标已确认,开始扩线索,不只看单条聊天。”
|
||||
|
||||
### 4. Keyword is weak or typo-prone
|
||||
|
||||
Example:
|
||||
|
||||
- user only remembers half a phrase
|
||||
- user remembers a nickname that might be wrong
|
||||
|
||||
Use this order:
|
||||
|
||||
1. Fuzzy person lookup first
|
||||
2. Global `search_messages`
|
||||
3. Read `sessionSummaries` before digging into raw hits
|
||||
4. Narrow back to candidate sessions
|
||||
5. Re-run `search_messages` inside the best session(s)
|
||||
|
||||
Battle report:
|
||||
|
||||
- “战报:关键词不稳,先撒网再回收。”
|
||||
|
||||
## Candidate comparison checklist
|
||||
|
||||
When choosing among multiple sessions, compare:
|
||||
|
||||
- contact remark
|
||||
- nickname
|
||||
- display name
|
||||
- recent message preview
|
||||
- last active timestamp
|
||||
- session kind
|
||||
|
||||
## Answer style
|
||||
|
||||
When the evidence is strong:
|
||||
|
||||
- state the likely target directly
|
||||
- mention the evidence briefly
|
||||
- prefer quoting `resolve_session.evidence` or `sessionSummaries` over hand-wavy justification
|
||||
|
||||
When the evidence is mixed:
|
||||
|
||||
- name the top candidate
|
||||
- mention 1-2 backup candidates
|
||||
- say what you checked to distinguish them
|
||||
- mention which evidence is still missing
|
||||
|
||||
When evidence is still weak:
|
||||
|
||||
- say it is not fully locked yet
|
||||
- continue querying instead of stopping early
|
||||
- say which next tool call is most likely to break the tie
|
||||
61
sikll/ct-mcp-copilot/scripts/validate-export-request.cjs
Normal file
61
sikll/ct-mcp-copilot/scripts/validate-export-request.cjs
Normal file
@@ -0,0 +1,61 @@
|
||||
const fs = require('fs')
|
||||
|
||||
function readInput() {
|
||||
const chunks = []
|
||||
const fd = 0
|
||||
try {
|
||||
const stat = fs.fstatSync(fd)
|
||||
if (stat.size === 0 && process.stdin.isTTY) {
|
||||
return null
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return fs.readFileSync(fd, 'utf8').trim() || null
|
||||
}
|
||||
|
||||
function validate(payload) {
|
||||
const missingFields = []
|
||||
|
||||
if (!payload.sessionId && !payload.query) missingFields.push('session')
|
||||
|
||||
if (!payload.dateRange || !payload.dateRange.start || !payload.dateRange.end) {
|
||||
missingFields.push('dateRange')
|
||||
}
|
||||
|
||||
if (!payload.format) {
|
||||
missingFields.push('format')
|
||||
}
|
||||
|
||||
const media = payload.mediaOptions
|
||||
const completeMedia = media
|
||||
&& typeof media.exportAvatars === 'boolean'
|
||||
&& typeof media.exportImages === 'boolean'
|
||||
&& typeof media.exportVideos === 'boolean'
|
||||
&& typeof media.exportEmojis === 'boolean'
|
||||
&& typeof media.exportVoices === 'boolean'
|
||||
|
||||
if (!completeMedia) {
|
||||
missingFields.push('mediaOptions')
|
||||
}
|
||||
|
||||
return {
|
||||
canExport: missingFields.length === 0,
|
||||
missingFields
|
||||
}
|
||||
}
|
||||
|
||||
const raw = readInput()
|
||||
if (!raw) {
|
||||
console.error('Provide a JSON payload via stdin.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
let payload
|
||||
try {
|
||||
payload = JSON.parse(raw)
|
||||
} catch (error) {
|
||||
console.error(`Invalid JSON: ${error.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
process.stdout.write(`${JSON.stringify(validate(payload), null, 2)}\n`)
|
||||
228
src/App.scss
228
src/App.scss
@@ -12,14 +12,17 @@
|
||||
right: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||
gap: 14px;
|
||||
min-width: 320px;
|
||||
max-width: 420px;
|
||||
padding: 16px 18px;
|
||||
background: color-mix(in srgb, var(--bg-primary) 88%, white 12%);
|
||||
border: 1px solid color-mix(in srgb, var(--primary) 22%, var(--border-color) 78%);
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.16);
|
||||
z-index: 1000;
|
||||
animation: slideUp 0.3s ease;
|
||||
backdrop-filter: blur(18px);
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
@@ -33,15 +36,25 @@
|
||||
}
|
||||
|
||||
.update-toast-icon {
|
||||
font-size: 28px;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 14px;
|
||||
flex-shrink: 0;
|
||||
font-size: 22px;
|
||||
background: color-mix(in srgb, var(--primary) 14%, transparent);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.update-toast-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.update-toast-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
@@ -49,13 +62,30 @@
|
||||
.update-toast-version {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.update-toast-meta {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
|
||||
span {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--bg-tertiary) 75%, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.update-toast-btn {
|
||||
padding: 8px 16px;
|
||||
padding: 0 16px;
|
||||
height: 38px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
@@ -63,7 +93,12 @@
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
}
|
||||
@@ -86,6 +121,144 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.update-toast-progress {
|
||||
width: 88px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.update-toast-progress-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
background: color-mix(in srgb, var(--bg-tertiary) 82%, transparent);
|
||||
}
|
||||
|
||||
.update-toast-progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, var(--primary), color-mix(in srgb, var(--primary) 65%, white));
|
||||
transition: width 0.2s linear;
|
||||
}
|
||||
|
||||
&.is-downloading {
|
||||
min-width: 360px;
|
||||
}
|
||||
}
|
||||
|
||||
.force-update-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 5000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: rgba(6, 10, 16, 0.82);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.force-update-card {
|
||||
width: min(720px, 100%);
|
||||
max-height: 85vh;
|
||||
overflow: auto;
|
||||
padding: 28px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: var(--bg-secondary);
|
||||
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35);
|
||||
|
||||
h2 {
|
||||
margin: 16px 0 10px;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.force-update-desc {
|
||||
margin: 0 0 18px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.force-update-meta {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-bottom: 18px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 14px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.force-update-notes {
|
||||
margin-bottom: 18px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 14px;
|
||||
background: var(--bg-primary);
|
||||
|
||||
.force-update-notes-title {
|
||||
margin-bottom: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: var(--text-secondary);
|
||||
font-family: inherit;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
.force-update-progress {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.force-update-progress-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.force-update-progress-bar {
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-primary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.force-update-progress-fill {
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, #ff7849 0%, #ff4747 100%);
|
||||
}
|
||||
|
||||
.force-update-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.force-update-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 92, 92, 0.12);
|
||||
color: #ff5c5c;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
// 独立聊天窗口容器
|
||||
@@ -260,8 +433,8 @@
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 16px;
|
||||
gap: 14px;
|
||||
padding: 10px 16px;
|
||||
background: rgba(30, 30, 30, 0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
border-radius: 20px;
|
||||
@@ -278,8 +451,31 @@
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
span {
|
||||
white-space: nowrap;
|
||||
.capsule-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
}
|
||||
|
||||
.capsule-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
small {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-bar-bg {
|
||||
|
||||
219
src/App.tsx
219
src/App.tsx
@@ -16,6 +16,7 @@ import GroupAnalyticsPage from './pages/GroupAnalyticsPage'
|
||||
import DataManagementPage from './pages/DataManagementPage'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
import OpenApiPage from './pages/OpenApiPage'
|
||||
import McpPage from './pages/McpPage'
|
||||
import ExportPage from './pages/ExportPage'
|
||||
import ActivationPage from './pages/ActivationPage'
|
||||
import ImageWindow from './pages/ImageWindow'
|
||||
@@ -37,6 +38,40 @@ import { useAuthStore } from './stores/authStore'
|
||||
import { X, Shield, Loader2 } from 'lucide-react'
|
||||
import './App.scss'
|
||||
|
||||
type AppUpdateInfo = {
|
||||
hasUpdate: boolean
|
||||
forceUpdate: boolean
|
||||
currentVersion: string
|
||||
version?: string
|
||||
releaseNotes?: string
|
||||
title?: string
|
||||
message?: string
|
||||
minimumSupportedVersion?: string
|
||||
reason?: 'minimum-version' | 'blocked-version'
|
||||
checkedAt: number
|
||||
updateSource: 'github' | 'custom' | 'none'
|
||||
policySource: 'github' | 'custom' | 'none'
|
||||
diagnostics?: {
|
||||
phase: 'idle' | 'checking' | 'available' | 'downloading' | 'downloaded' | 'installing' | 'failed'
|
||||
strategy: 'unknown' | 'differential' | 'full'
|
||||
fallbackToFull: boolean
|
||||
lastError?: string
|
||||
lastEvent?: string
|
||||
progressPercent?: number
|
||||
downloadedBytes?: number
|
||||
totalBytes?: number
|
||||
targetVersion?: string
|
||||
lastUpdatedAt: number
|
||||
}
|
||||
}
|
||||
|
||||
type UpdateDownloadProgressPayload = {
|
||||
percent: number
|
||||
transferred: number
|
||||
total: number
|
||||
bytesPerSecond: number
|
||||
}
|
||||
|
||||
function App() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
@@ -54,8 +89,26 @@ function App() {
|
||||
const [showActivation, setShowActivation] = useState(false)
|
||||
|
||||
// 更新提示状态
|
||||
const [updateInfo, setUpdateInfo] = useState<{ version: string; releaseNotes: string } | null>(null)
|
||||
const [downloadProgress, setDownloadProgress] = useState<number | null>(null)
|
||||
const [updateInfo, setUpdateInfo] = useState<AppUpdateInfo | null>(null)
|
||||
const [downloadProgress, setDownloadProgress] = useState<UpdateDownloadProgressPayload | null>(null)
|
||||
|
||||
const formatSpeed = (bytesPerSecond: number) => {
|
||||
if (!Number.isFinite(bytesPerSecond) || bytesPerSecond <= 0) return '计算中'
|
||||
if (bytesPerSecond < 1024) return `${bytesPerSecond.toFixed(0)} B/s`
|
||||
if (bytesPerSecond < 1024 * 1024) return `${(bytesPerSecond / 1024).toFixed(1)} KB/s`
|
||||
return `${(bytesPerSecond / (1024 * 1024)).toFixed(1)} MB/s`
|
||||
}
|
||||
|
||||
const formatBytes = (bytes?: number) => {
|
||||
if (!bytes || bytes <= 0) return '0 B'
|
||||
if (bytes < 1024) return `${bytes.toFixed(0)} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
|
||||
}
|
||||
|
||||
const isUpdateDownloading = updateInfo?.diagnostics?.phase === 'downloading' || updateInfo?.diagnostics?.phase === 'installing'
|
||||
const progressPercent = downloadProgress?.percent ?? updateInfo?.diagnostics?.progressPercent ?? null
|
||||
|
||||
// 加载主题配置
|
||||
useEffect(() => {
|
||||
@@ -135,6 +188,15 @@ function App() {
|
||||
|
||||
// 监听启动时的更新通知
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
window.electronAPI.app.getUpdateState?.().then((info) => {
|
||||
if (mounted && info?.hasUpdate) {
|
||||
setUpdateInfo(info)
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.error('获取更新状态失败:', error)
|
||||
})
|
||||
|
||||
const removeUpdateListener = window.electronAPI.app.onUpdateAvailable?.((info) => {
|
||||
setUpdateInfo(info)
|
||||
})
|
||||
@@ -161,6 +223,7 @@ function App() {
|
||||
})
|
||||
|
||||
return () => {
|
||||
mounted = false
|
||||
removeUpdateListener?.()
|
||||
removeSessionsListener?.()
|
||||
removeUpdateAvailableListener?.()
|
||||
@@ -171,6 +234,24 @@ function App() {
|
||||
useEffect(() => {
|
||||
const removeDownloadListener = window.electronAPI.app.onDownloadProgress?.((progress) => {
|
||||
setDownloadProgress(progress)
|
||||
setUpdateInfo((current) => {
|
||||
if (!current) return current
|
||||
return {
|
||||
...current,
|
||||
diagnostics: {
|
||||
phase: 'downloading',
|
||||
strategy: current.diagnostics?.strategy || 'unknown',
|
||||
fallbackToFull: current.diagnostics?.fallbackToFull || false,
|
||||
lastError: current.diagnostics?.lastError,
|
||||
lastEvent: current.diagnostics?.lastEvent,
|
||||
progressPercent: progress.percent,
|
||||
downloadedBytes: progress.transferred,
|
||||
totalBytes: progress.total,
|
||||
targetVersion: current.version || current.diagnostics?.targetVersion,
|
||||
lastUpdatedAt: Date.now()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
return () => {
|
||||
removeDownloadListener?.()
|
||||
@@ -178,9 +259,30 @@ function App() {
|
||||
}, [])
|
||||
|
||||
const dismissUpdate = () => {
|
||||
if (updateInfo?.forceUpdate || isUpdateDownloading) return
|
||||
setUpdateInfo(null)
|
||||
}
|
||||
|
||||
const handleStartUpdate = () => {
|
||||
if (isUpdateDownloading) return
|
||||
setUpdateInfo((current) => current ? {
|
||||
...current,
|
||||
diagnostics: {
|
||||
phase: 'downloading',
|
||||
strategy: current.diagnostics?.strategy || 'unknown',
|
||||
fallbackToFull: current.diagnostics?.fallbackToFull || false,
|
||||
lastError: undefined,
|
||||
lastEvent: '开始下载更新',
|
||||
progressPercent: 0,
|
||||
downloadedBytes: 0,
|
||||
totalBytes: current.diagnostics?.totalBytes,
|
||||
targetVersion: current.version || current.diagnostics?.targetVersion,
|
||||
lastUpdatedAt: Date.now()
|
||||
}
|
||||
} : current)
|
||||
window.electronAPI.app.downloadAndInstall()
|
||||
}
|
||||
|
||||
// 检查是否是独立聊天窗口
|
||||
const isChatWindow = location.pathname === '/chat-window'
|
||||
const isGroupAnalyticsWindow = location.pathname === '/group-analytics-window'
|
||||
@@ -465,22 +567,92 @@ function App() {
|
||||
return (
|
||||
<div className="app-container">
|
||||
<TitleBar />
|
||||
{updateInfo && (
|
||||
<div className="update-toast">
|
||||
<div className="update-toast-icon">🎉</div>
|
||||
{updateInfo && !updateInfo.forceUpdate && (
|
||||
<div className={`update-toast ${isUpdateDownloading ? 'is-downloading' : ''}`}>
|
||||
<div className="update-toast-icon">{isUpdateDownloading ? <Loader2 size={18} className="spin" /> : '🎉'}</div>
|
||||
<div className="update-toast-content">
|
||||
<div className="update-toast-title">发现新版本</div>
|
||||
<div className="update-toast-version">v{updateInfo.version} 已发布</div>
|
||||
<div className="update-toast-title">{isUpdateDownloading ? '正在下载更新' : '发现新版本'}</div>
|
||||
<div className="update-toast-version">
|
||||
{isUpdateDownloading ? `v${updateInfo.version} ${progressPercent !== null ? `${progressPercent.toFixed(0)}%` : ''}` : `v${updateInfo.version} 已发布`}
|
||||
</div>
|
||||
<div className="update-toast-version">
|
||||
{isUpdateDownloading
|
||||
? `${formatBytes(downloadProgress?.transferred ?? updateInfo.diagnostics?.downloadedBytes)} / ${formatBytes(downloadProgress?.total ?? updateInfo.diagnostics?.totalBytes)}`
|
||||
: `更新源:${updateInfo.updateSource === 'github' ? 'GitHub Release' : '未知'}`}
|
||||
</div>
|
||||
{isUpdateDownloading && (
|
||||
<div className="update-toast-meta">
|
||||
<span>速度 {formatSpeed(downloadProgress?.bytesPerSecond ?? 0)}</span>
|
||||
{updateInfo.diagnostics?.fallbackToFull ? <span>已回退全量</span> : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isUpdateDownloading ? (
|
||||
<div className="update-toast-progress">
|
||||
<div className="update-toast-progress-bar">
|
||||
<div className="update-toast-progress-fill" style={{ width: `${progressPercent ?? 0}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<button className="update-toast-btn" onClick={handleStartUpdate} disabled={isUpdateDownloading}>
|
||||
立即更新
|
||||
</button>
|
||||
<button className="update-toast-close" onClick={dismissUpdate}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{updateInfo?.forceUpdate && (
|
||||
<div className="force-update-overlay">
|
||||
<div className="force-update-card">
|
||||
<div className="force-update-badge">
|
||||
<Shield size={18} />
|
||||
<span>强制更新</span>
|
||||
</div>
|
||||
<h2>{updateInfo.title || '必须更新后才能继续使用'}</h2>
|
||||
<p className="force-update-desc">
|
||||
{updateInfo.message || '当前版本已被标记为需要立即升级,应用将限制继续使用,直到安装最新版本。'}
|
||||
</p>
|
||||
|
||||
<div className="force-update-meta">
|
||||
<div>当前版本:v{updateInfo.currentVersion}</div>
|
||||
{updateInfo.version && <div>目标版本:v{updateInfo.version}</div>}
|
||||
{updateInfo.minimumSupportedVersion && <div>最低安全版本:v{updateInfo.minimumSupportedVersion}</div>}
|
||||
<div>更新来源:{updateInfo.updateSource === 'github' ? 'GitHub Release' : '未检测到普通更新源'}</div>
|
||||
<div>策略来源:{updateInfo.policySource === 'github' ? 'GitHub 策略源' : updateInfo.policySource === 'custom' ? '自定义策略源' : '无'}</div>
|
||||
</div>
|
||||
|
||||
{updateInfo.releaseNotes && (
|
||||
<div className="force-update-notes">
|
||||
<div className="force-update-notes-title">更新说明</div>
|
||||
<pre>{updateInfo.releaseNotes}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{progressPercent !== null && (
|
||||
<div className="force-update-progress">
|
||||
<div className="force-update-progress-label">
|
||||
<Loader2 size={16} className="spin" />
|
||||
<span>正在下载更新... {progressPercent.toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="force-update-progress-bar">
|
||||
<div className="force-update-progress-fill" style={{ width: `${progressPercent}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="force-update-actions">
|
||||
<button className="btn btn-primary" onClick={handleStartUpdate} disabled={isUpdateDownloading}>
|
||||
立即更新
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={() => window.electronAPI.window.close()}>
|
||||
退出应用
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button className="update-toast-btn" onClick={() => {
|
||||
window.electronAPI.app.downloadAndInstall()
|
||||
dismissUpdate()
|
||||
}}>
|
||||
立即更新
|
||||
</button>
|
||||
<button className="update-toast-close" onClick={dismissUpdate}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -513,6 +685,7 @@ function App() {
|
||||
<Route path="/data-management" element={<DataManagementPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/open-api" element={<OpenApiPage />} />
|
||||
<Route path="/mcp" element={<McpPage />} />
|
||||
<Route path="/export" element={<ExportPage />} />
|
||||
<Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} />
|
||||
</Routes>
|
||||
@@ -520,12 +693,18 @@ function App() {
|
||||
</Box>
|
||||
</Box>
|
||||
<DecryptProgressOverlay />
|
||||
{downloadProgress !== null && (
|
||||
{progressPercent !== null && (
|
||||
<div className="download-progress-capsule">
|
||||
<Loader2 className="spin" size={14} />
|
||||
<span>正在下载更新... {downloadProgress.toFixed(0)}%</span>
|
||||
<div className="progress-bar-bg">
|
||||
<div className="progress-bar-fill" style={{ width: `${downloadProgress}%` }} />
|
||||
<div className="capsule-copy">
|
||||
<span>正在下载更新... {progressPercent.toFixed(0)}%</span>
|
||||
<small>{formatSpeed(downloadProgress?.bytesPerSecond ?? 0)}</small>
|
||||
</div>
|
||||
<div className="capsule-progress">
|
||||
<div className="progress-bar-bg">
|
||||
<div className="progress-bar-fill" style={{ width: `${progressPercent}%` }} />
|
||||
</div>
|
||||
<small>{formatBytes(downloadProgress?.transferred ?? updateInfo?.diagnostics?.downloadedBytes)} / {formatBytes(downloadProgress?.total ?? updateInfo?.diagnostics?.totalBytes)}</small>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -10,7 +10,7 @@ import ListItemButton from '@mui/material/ListItemButton'
|
||||
import ListItemIcon from '@mui/material/ListItemIcon'
|
||||
import Tooltip from '@mui/material/Tooltip'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, SquareChevronLeft, SquareChevronRight, Download, Aperture, Network } from 'lucide-react'
|
||||
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, SquareChevronLeft, SquareChevronRight, Download, Aperture, Network, Boxes } from 'lucide-react'
|
||||
import { useAppStore } from '../stores/appStore'
|
||||
|
||||
const DRAWER_WIDTH = 220
|
||||
@@ -82,6 +82,7 @@ function Sidebar() {
|
||||
{ key: 'export', label: '导出数据', icon: <Download size={20} />, type: 'route', path: '/export' },
|
||||
{ key: 'data-management', label: '数据管理', icon: <Database size={20} />, type: 'route', path: '/data-management' },
|
||||
{ key: 'open-api', label: '开放接口', icon: <Network size={20} />, type: 'route', path: '/open-api' },
|
||||
{ key: 'mcp', label: 'MCP 服务', icon: <Boxes size={20} />, type: 'route', path: '/mcp' },
|
||||
]
|
||||
|
||||
const navItemSx = {
|
||||
|
||||
@@ -1,87 +1,134 @@
|
||||
//更新说明!!!
|
||||
import { Package, Image, Mic, Filter, Send, Aperture } from 'lucide-react'
|
||||
import { ReactNode } from 'react'
|
||||
import { Aperture, Package, Send, Sparkles, Wand2 } from 'lucide-react'
|
||||
import './WhatsNewModal.scss'
|
||||
|
||||
interface WhatsNewModalProps {
|
||||
onClose: () => void
|
||||
version: string
|
||||
onClose: () => void
|
||||
version: string
|
||||
releaseBody?: string
|
||||
releaseNotes?: string
|
||||
}
|
||||
|
||||
function WhatsNewModal({ onClose, version }: WhatsNewModalProps) {
|
||||
const updates = [
|
||||
{
|
||||
icon: <Package size={20} />,
|
||||
title: '优化',
|
||||
desc: '优化html导出。'
|
||||
},
|
||||
{
|
||||
icon: <Package size={20} />,
|
||||
title: '优化',
|
||||
desc: '优化最小化至托盘功能。'
|
||||
}
|
||||
// {
|
||||
// icon: <Image size={20} />,
|
||||
// title: '聊天内图片',
|
||||
// desc: '支持查看谷歌标准实况图片(iOS端与大疆等实况图片,发送后实况暂不支持)。'
|
||||
// }
|
||||
// {
|
||||
// icon: <Mic size={20} />,
|
||||
// title: '语音导出',
|
||||
// desc: '支持将语音消息解码为 WAV 格式导出,含转写文字。'
|
||||
// },
|
||||
// {
|
||||
// icon: <Filter size={20} />,
|
||||
// title: '新增',
|
||||
// desc: '新增API端点等功能。'
|
||||
// },
|
||||
// {
|
||||
// icon: <Aperture size={20} />,
|
||||
// title: '朋友圈',
|
||||
// desc: '优化样式!'
|
||||
// }
|
||||
]
|
||||
type UpdateItem = {
|
||||
icon: ReactNode
|
||||
title: string
|
||||
desc: string
|
||||
}
|
||||
|
||||
const handleTelegram = () => {
|
||||
window.electronAPI?.shell?.openExternal?.('https://t.me/+p7YzmRMBm-gzNzJl')
|
||||
function inferTitle(text: string): string {
|
||||
if (/[修复|稳定|兼容|解决]/.test(text)) return '修复'
|
||||
if (/[优化|提升|改进|性能]/.test(text)) return '优化'
|
||||
if (/[新增|支持|加入|开放]/.test(text)) return '新增'
|
||||
return '更新'
|
||||
}
|
||||
|
||||
function inferIcon(text: string): ReactNode {
|
||||
if (/[界面|动画|视觉|样式|体验]/.test(text)) return <Aperture size={20} />
|
||||
if (/[新增|支持|加入|开放]/.test(text)) return <Sparkles size={20} />
|
||||
if (/[优化|提升|改进|性能]/.test(text)) return <Wand2 size={20} />
|
||||
return <Package size={20} />
|
||||
}
|
||||
|
||||
function parseAnnouncementText(content?: string): UpdateItem[] {
|
||||
if (!content?.trim()) return []
|
||||
|
||||
const lines = content
|
||||
.split(/\r?\n/)
|
||||
.map(line => line.trim())
|
||||
.filter(line => line && !/^#+\s*/.test(line) && !/^\d+\.\s*$/.test(line))
|
||||
.map(line => line.replace(/^[-*•]\s*/, ''))
|
||||
.filter(Boolean)
|
||||
.slice(0, 5)
|
||||
|
||||
return lines.map((line) => ({
|
||||
icon: inferIcon(line),
|
||||
title: inferTitle(line),
|
||||
desc: line
|
||||
}))
|
||||
}
|
||||
|
||||
function buildFallbackUpdates(version: string): UpdateItem[] {
|
||||
return [
|
||||
{
|
||||
icon: <Sparkles size={20} />,
|
||||
title: '版本上线',
|
||||
desc: `已切换到 ${version},界面与功能会自动按当前版本展示最新内容。`
|
||||
},
|
||||
{
|
||||
icon: <Wand2 size={20} />,
|
||||
title: '体验优化',
|
||||
desc: '我们会持续打磨性能、细节和稳定性,无需再为这条欢迎信息手动改文案。'
|
||||
},
|
||||
{
|
||||
icon: <Package size={20} />,
|
||||
title: '自动适配',
|
||||
desc: '如果发布说明存在,这里会优先自动展示本次更新要点。'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="whats-new-overlay">
|
||||
<div className="whats-new-modal">
|
||||
<div className="modal-header">
|
||||
<span className="version-tag">新版本 {version}</span>
|
||||
<h2>欢迎体验全新的密语</h2>
|
||||
<p>我们为您带来了一些令人兴奋的改进</p>
|
||||
</div>
|
||||
function buildHeadline(version: string, updates: UpdateItem[]) {
|
||||
if (updates.length > 0) {
|
||||
return {
|
||||
title: `密语 ${version} 已就绪`,
|
||||
subtitle: '以下是这次版本自动整理出的更新重点'
|
||||
}
|
||||
}
|
||||
|
||||
<div className="modal-content">
|
||||
<div className="update-list">
|
||||
{updates.map((item, index) => (
|
||||
<div className="update-item" key={index}>
|
||||
<div className="item-icon">
|
||||
{item.icon}
|
||||
</div>
|
||||
<div className="item-info">
|
||||
<h3>{item.title}</h3>
|
||||
<p>{item.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
return {
|
||||
title: `欢迎使用密语 ${version}`,
|
||||
subtitle: '当前版本已安装完成,以下内容会根据版本自动展示'
|
||||
}
|
||||
}
|
||||
|
||||
<div className="modal-footer">
|
||||
<button className="telegram-btn" onClick={handleTelegram}>
|
||||
<Send size={16} />
|
||||
加入 Telegram 频道
|
||||
</button>
|
||||
<button className="start-btn" onClick={onClose}>
|
||||
开启新旅程
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
function WhatsNewModal({ onClose, version, releaseBody, releaseNotes }: WhatsNewModalProps) {
|
||||
const notesUpdates = parseAnnouncementText(releaseNotes)
|
||||
const bodyUpdates = parseAnnouncementText(releaseBody)
|
||||
const parsedUpdates = notesUpdates.length > 0 ? notesUpdates : bodyUpdates
|
||||
const items = parsedUpdates.length > 0 ? parsedUpdates : buildFallbackUpdates(version)
|
||||
const headline = buildHeadline(version, parsedUpdates)
|
||||
|
||||
const handleTelegram = () => {
|
||||
window.electronAPI?.shell?.openExternal?.('https://t.me/+p7YzmRMBm-gzNzJl')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="whats-new-overlay">
|
||||
<div className="whats-new-modal">
|
||||
<div className="modal-header">
|
||||
<span className="version-tag">新版本 {version}</span>
|
||||
<h2>{headline.title}</h2>
|
||||
<p>{headline.subtitle}</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
<div className="modal-content">
|
||||
<div className="update-list">
|
||||
{items.map((item, index) => (
|
||||
<div className="update-item" key={`${item.title}-${index}`}>
|
||||
<div className="item-icon">
|
||||
{item.icon}
|
||||
</div>
|
||||
<div className="item-info">
|
||||
<h3>{item.title}</h3>
|
||||
<p>{item.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button className="telegram-btn" onClick={handleTelegram}>
|
||||
<Send size={16} />
|
||||
加入 Telegram 频道
|
||||
</button>
|
||||
<button className="start-btn" onClick={onClose}>
|
||||
开始使用
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WhatsNewModal
|
||||
|
||||
@@ -254,9 +254,15 @@ function ChatPage(_props: ChatPageProps) {
|
||||
const isUserOperatingRef = useRef<boolean>(false) // 标记用户是否正在操作
|
||||
const [currentOffset, setCurrentOffset] = useState(0)
|
||||
const [isDateJumpMode, setIsDateJumpMode] = useState(false)
|
||||
// 向上滑动游标(最早消息)
|
||||
const [dateJumpCursorSortSeq, setDateJumpCursorSortSeq] = useState<number | null>(null)
|
||||
const [dateJumpCursorCreateTime, setDateJumpCursorCreateTime] = useState<number | null>(null)
|
||||
const [dateJumpCursorLocalId, setDateJumpCursorLocalId] = useState<number | null>(null)
|
||||
// 向下滑动游标(最新消息)
|
||||
const [dateJumpCursorSortSeqEnd, setDateJumpCursorSortSeqEnd] = useState<number | null>(null)
|
||||
const [dateJumpCursorCreateTimeEnd, setDateJumpCursorCreateTimeEnd] = useState<number | null>(null)
|
||||
const [dateJumpCursorLocalIdEnd, setDateJumpCursorLocalIdEnd] = useState<number | null>(null)
|
||||
const [hasMoreMessagesAfter, setHasMoreMessagesAfter] = useState(false)
|
||||
|
||||
// 更新状态管理
|
||||
const setIsUpdating = useUpdateStatusStore(state => state.setIsUpdating)
|
||||
@@ -484,6 +490,10 @@ function ChatPage(_props: ChatPageProps) {
|
||||
setDateJumpCursorSortSeq(null)
|
||||
setDateJumpCursorCreateTime(null)
|
||||
setDateJumpCursorLocalId(null)
|
||||
setDateJumpCursorSortSeqEnd(null)
|
||||
setDateJumpCursorCreateTimeEnd(null)
|
||||
setDateJumpCursorLocalIdEnd(null)
|
||||
setHasMoreMessagesAfter(false)
|
||||
// 标记用户正在操作(首次加载)
|
||||
isUserOperatingRef.current = true
|
||||
} else {
|
||||
@@ -700,6 +710,81 @@ function ChatPage(_props: ChatPageProps) {
|
||||
setLoadingMore
|
||||
])
|
||||
|
||||
// 日期跳转模式:向下滑动加载更新的消息
|
||||
const loadMoreMessagesAfterInDateJumpMode = useCallback(async () => {
|
||||
if (!currentSessionId || dateJumpCursorSortSeqEnd === null || isLoadingMore || !hasMoreMessagesAfter) return
|
||||
|
||||
const listEl = messageListRef.current
|
||||
if (!listEl) return
|
||||
|
||||
// 记录当前滚动位置和高度
|
||||
const oldScrollHeight = listEl.scrollHeight
|
||||
const oldScrollTop = listEl.scrollTop
|
||||
|
||||
setLoadingMore(true)
|
||||
try {
|
||||
const result = await window.electronAPI.chat.getMessagesAfter(
|
||||
currentSessionId,
|
||||
dateJumpCursorSortSeqEnd,
|
||||
50,
|
||||
dateJumpCursorCreateTimeEnd ?? undefined,
|
||||
dateJumpCursorLocalIdEnd ?? undefined
|
||||
)
|
||||
|
||||
if (result.success && result.messages) {
|
||||
const existingKeys = new Set(
|
||||
messagesRef.current.map(m => `${m.serverId}-${m.localId}-${m.createTime}-${m.sortSeq}`)
|
||||
)
|
||||
const uniqueNewerMessages = result.messages.filter(msg =>
|
||||
!existingKeys.has(`${msg.serverId}-${msg.localId}-${msg.createTime}-${msg.sortSeq}`)
|
||||
)
|
||||
|
||||
if (uniqueNewerMessages.length === 0) {
|
||||
setHasMoreMessagesAfter(false)
|
||||
return
|
||||
}
|
||||
|
||||
// 追加到消息列表末尾
|
||||
appendMessages(uniqueNewerMessages, false)
|
||||
|
||||
// 更新向下滑动游标
|
||||
const newestMsg = uniqueNewerMessages[uniqueNewerMessages.length - 1]
|
||||
const newestSortSeq = newestMsg?.sortSeq
|
||||
const newestCreateTime = newestMsg?.createTime
|
||||
const newestLocalId = newestMsg?.localId
|
||||
|
||||
if (typeof newestSortSeq !== 'number' || newestSortSeq <= dateJumpCursorSortSeqEnd) {
|
||||
setHasMoreMessagesAfter(false)
|
||||
} else {
|
||||
setDateJumpCursorSortSeqEnd(newestSortSeq)
|
||||
setDateJumpCursorCreateTimeEnd(typeof newestCreateTime === 'number' ? newestCreateTime : null)
|
||||
setDateJumpCursorLocalIdEnd(typeof newestLocalId === 'number' ? newestLocalId : null)
|
||||
setHasMoreMessagesAfter(result.hasMore ?? false)
|
||||
}
|
||||
|
||||
// 保持滚动位置(向下加载时保持在原位置)
|
||||
requestAnimationFrame(() => {
|
||||
const newScrollHeight = listEl.scrollHeight
|
||||
listEl.scrollTop = oldScrollTop + (newScrollHeight - oldScrollHeight)
|
||||
})
|
||||
} else {
|
||||
setHasMoreMessagesAfter(false)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('日期跳转模式向下加载失败:', e)
|
||||
} finally {
|
||||
setLoadingMore(false)
|
||||
}
|
||||
}, [
|
||||
currentSessionId,
|
||||
dateJumpCursorSortSeqEnd,
|
||||
dateJumpCursorCreateTimeEnd,
|
||||
dateJumpCursorLocalIdEnd,
|
||||
isLoadingMore,
|
||||
hasMoreMessagesAfter,
|
||||
appendMessages
|
||||
])
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!messageListRef.current) return
|
||||
|
||||
@@ -709,18 +794,34 @@ function ChatPage(_props: ChatPageProps) {
|
||||
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
|
||||
setShowScrollToBottom(distanceFromBottom > 300)
|
||||
|
||||
// 预加载:当滚动到顶部 30% 区域时开始加载
|
||||
if (!isLoadingMore && hasMoreMessages && currentSessionId) {
|
||||
const threshold = clientHeight * 0.3
|
||||
if (scrollTop < threshold) {
|
||||
if (!isLoadingMore && currentSessionId) {
|
||||
const topThreshold = clientHeight * 0.3
|
||||
const bottomThreshold = clientHeight * 0.3
|
||||
|
||||
// 向上滑动:加载更早的消息
|
||||
if (scrollTop < topThreshold && hasMoreMessages) {
|
||||
if (isDateJumpMode) {
|
||||
loadMoreMessagesInDateJumpMode()
|
||||
} else {
|
||||
loadMessages(currentSessionId, currentOffset)
|
||||
}
|
||||
}
|
||||
|
||||
// 向下滑动:加载更新的消息(仅在日期跳转模式下)
|
||||
if (isDateJumpMode && distanceFromBottom < bottomThreshold && hasMoreMessagesAfter) {
|
||||
loadMoreMessagesAfterInDateJumpMode()
|
||||
}
|
||||
}
|
||||
}, [isLoadingMore, hasMoreMessages, currentSessionId, currentOffset, isDateJumpMode, loadMoreMessagesInDateJumpMode])
|
||||
}, [
|
||||
isLoadingMore,
|
||||
hasMoreMessages,
|
||||
hasMoreMessagesAfter,
|
||||
currentSessionId,
|
||||
currentOffset,
|
||||
isDateJumpMode,
|
||||
loadMoreMessagesInDateJumpMode,
|
||||
loadMoreMessagesAfterInDateJumpMode
|
||||
])
|
||||
|
||||
// 滚动到底部
|
||||
const scrollToBottom = useCallback((smooth: boolean | React.MouseEvent = true) => {
|
||||
@@ -760,9 +861,16 @@ function ChatPage(_props: ChatPageProps) {
|
||||
setHasMoreMessages(true)
|
||||
setCurrentOffset(result.messages.length)
|
||||
setIsDateJumpMode(true)
|
||||
// 设置向上滑动游标(最早消息)
|
||||
setDateJumpCursorSortSeq(result.messages[0]?.sortSeq ?? null)
|
||||
setDateJumpCursorCreateTime(result.messages[0]?.createTime ?? null)
|
||||
setDateJumpCursorLocalId(result.messages[0]?.localId ?? null)
|
||||
// 设置向下滑动游标(最新消息)
|
||||
const lastMsg = result.messages[result.messages.length - 1]
|
||||
setDateJumpCursorSortSeqEnd(lastMsg?.sortSeq ?? null)
|
||||
setDateJumpCursorCreateTimeEnd(lastMsg?.createTime ?? null)
|
||||
setDateJumpCursorLocalIdEnd(lastMsg?.localId ?? null)
|
||||
setHasMoreMessagesAfter(true)
|
||||
|
||||
// 滚动到顶部显示目标日期的消息
|
||||
requestAnimationFrame(() => {
|
||||
|
||||
@@ -27,6 +27,8 @@ function HomePage() {
|
||||
// 新版本弹窗状态
|
||||
const [showWhatsNew, setShowWhatsNew] = useState(false)
|
||||
const [currentVersion, setCurrentVersion] = useState('')
|
||||
const [releaseBody, setReleaseBody] = useState('')
|
||||
const [releaseNotes, setReleaseNotes] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
checkNewVersion()
|
||||
@@ -61,20 +63,32 @@ function HomePage() {
|
||||
|
||||
const checkNewVersion = async () => {
|
||||
try {
|
||||
// 获取当前应用版本
|
||||
const version = await window.electronAPI.app.getVersion()
|
||||
setCurrentVersion(version)
|
||||
|
||||
// 获取上次查看的版本
|
||||
const lastSeenVersion = localStorage.getItem('lastSeenVersion')
|
||||
const [
|
||||
announcementVersion,
|
||||
announcementBody,
|
||||
announcementNotes,
|
||||
seenVersion
|
||||
] = await Promise.all([
|
||||
window.electronAPI.config.get('releaseAnnouncementVersion'),
|
||||
window.electronAPI.config.get('releaseAnnouncementBody'),
|
||||
window.electronAPI.config.get('releaseAnnouncementNotes'),
|
||||
window.electronAPI.config.get('releaseAnnouncementSeenVersion')
|
||||
])
|
||||
|
||||
// 简单的版本比较逻辑:如果版本不同且未记录,或者是新安装,则显示
|
||||
// 为了防止每次开发时版本号不变也弹,这里只在版本确实不同时弹
|
||||
// 注意:这里假设版本号格式为 x.y.z
|
||||
const normalizedAnnouncementVersion = String(announcementVersion || '').trim()
|
||||
const normalizedBody = String(announcementBody || '').trim()
|
||||
const normalizedNotes = String(announcementNotes || '').trim()
|
||||
const normalizedSeenVersion = String(seenVersion || '').trim()
|
||||
|
||||
if (version !== lastSeenVersion) {
|
||||
// 如果是全新安装(没有 lastSeenVersion),也显示
|
||||
// 实际上这通常用于引导新用户
|
||||
if (normalizedAnnouncementVersion === version) {
|
||||
setReleaseBody(normalizedBody)
|
||||
setReleaseNotes(normalizedNotes)
|
||||
}
|
||||
|
||||
if (normalizedAnnouncementVersion === version && normalizedSeenVersion !== version) {
|
||||
setShowWhatsNew(true)
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -85,7 +99,7 @@ function HomePage() {
|
||||
const handleCloseWhatsNew = () => {
|
||||
setShowWhatsNew(false)
|
||||
if (currentVersion) {
|
||||
localStorage.setItem('lastSeenVersion', currentVersion)
|
||||
window.electronAPI.config.set('releaseAnnouncementSeenVersion', currentVersion)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +133,8 @@ function HomePage() {
|
||||
{showWhatsNew && (
|
||||
<WhatsNewModal
|
||||
version={currentVersion}
|
||||
releaseBody={releaseBody}
|
||||
releaseNotes={releaseNotes}
|
||||
onClose={handleCloseWhatsNew}
|
||||
/>
|
||||
)}
|
||||
|
||||
388
src/pages/McpPage.tsx
Normal file
388
src/pages/McpPage.tsx
Normal file
@@ -0,0 +1,388 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
Container,
|
||||
Snackbar,
|
||||
Stack,
|
||||
Switch,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import { Check, Copy, Save } from 'lucide-react'
|
||||
import * as configService from '../services/config'
|
||||
|
||||
type ToastState = {
|
||||
text: string
|
||||
success: boolean
|
||||
}
|
||||
|
||||
type McpLaunchConfig = {
|
||||
command: string
|
||||
args: string[]
|
||||
cwd: string
|
||||
mode: 'dev' | 'packaged'
|
||||
}
|
||||
|
||||
function formatCommandPart(value: string) {
|
||||
if (!value) return value
|
||||
return /[\s"]/.test(value) ? `"${value.replace(/"/g, '\\"')}"` : value
|
||||
}
|
||||
|
||||
const textFieldSx = {
|
||||
'& .MuiInputLabel-root': {
|
||||
color: 'var(--text-secondary)',
|
||||
},
|
||||
'& .MuiInputLabel-root.Mui-focused': {
|
||||
color: 'var(--primary)',
|
||||
},
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: '14px',
|
||||
color: 'var(--text-primary)',
|
||||
backgroundColor: 'var(--bg-secondary)',
|
||||
'& fieldset': {
|
||||
borderColor: 'var(--border-color)',
|
||||
},
|
||||
'&:hover fieldset': {
|
||||
borderColor: 'var(--primary)',
|
||||
},
|
||||
'&.Mui-focused fieldset': {
|
||||
borderColor: 'var(--primary)',
|
||||
},
|
||||
},
|
||||
'& .MuiInputBase-input': {
|
||||
color: 'var(--text-primary)',
|
||||
},
|
||||
}
|
||||
|
||||
const switchSx = {
|
||||
'& .MuiSwitch-switchBase.Mui-checked': {
|
||||
color: 'var(--primary)',
|
||||
},
|
||||
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': {
|
||||
backgroundColor: 'var(--primary)',
|
||||
},
|
||||
'& .MuiSwitch-track': {
|
||||
backgroundColor: 'var(--text-tertiary)',
|
||||
},
|
||||
}
|
||||
|
||||
const secondaryButtonSx = {
|
||||
borderRadius: '999px',
|
||||
minWidth: 120,
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-primary)',
|
||||
borderColor: 'var(--border-color)',
|
||||
backgroundColor: 'var(--bg-secondary)',
|
||||
'&:hover': {
|
||||
borderColor: 'var(--primary)',
|
||||
backgroundColor: 'var(--primary-light)',
|
||||
},
|
||||
}
|
||||
|
||||
function McpPage() {
|
||||
const [mcpEnabled, setMcpEnabled] = useState(false)
|
||||
const [mcpExposeMediaPaths, setMcpExposeMediaPaths] = useState(true)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [toast, setToast] = useState<ToastState | null>(null)
|
||||
const [launchConfig, setLaunchConfig] = useState<McpLaunchConfig>({
|
||||
command: 'npm',
|
||||
args: ['run', 'mcp'],
|
||||
cwd: 'D:/CipherTalk',
|
||||
mode: 'dev',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const [enabled, exposeMediaPaths] = await Promise.all([
|
||||
configService.getMcpEnabled(),
|
||||
configService.getMcpExposeMediaPaths(),
|
||||
])
|
||||
setMcpEnabled(enabled)
|
||||
setMcpExposeMediaPaths(exposeMediaPaths)
|
||||
|
||||
try {
|
||||
const mcpLaunchConfig = await window.electronAPI.app.getMcpLaunchConfig()
|
||||
if (mcpLaunchConfig?.command && Array.isArray(mcpLaunchConfig.args) && mcpLaunchConfig.cwd) {
|
||||
setLaunchConfig(mcpLaunchConfig)
|
||||
}
|
||||
} catch (innerError) {
|
||||
const message = String(innerError || '')
|
||||
if (!message.includes("No handler registered for 'app:getMcpLaunchConfig'")) {
|
||||
console.error('获取 MCP 启动配置失败:', innerError)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载 MCP 配置失败:', e)
|
||||
setToast({ text: '加载 MCP 配置失败', success: false })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
void load()
|
||||
}, [])
|
||||
|
||||
const mcpRunCommand = useMemo(() => {
|
||||
const parts = [launchConfig.command, ...launchConfig.args].map(formatCommandPart)
|
||||
return parts.join(' ')
|
||||
}, [launchConfig])
|
||||
|
||||
const mcpServerJsonTemplate = useMemo(() => JSON.stringify({
|
||||
mcpServers: {
|
||||
ciphertalk: {
|
||||
command: launchConfig.command,
|
||||
args: launchConfig.args,
|
||||
cwd: launchConfig.cwd
|
||||
}
|
||||
}
|
||||
}, null, 2), [launchConfig])
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await Promise.all([
|
||||
configService.setMcpEnabled(mcpEnabled),
|
||||
configService.setMcpExposeMediaPaths(mcpExposeMediaPaths),
|
||||
])
|
||||
setToast({ text: 'MCP 配置已保存', success: true })
|
||||
} catch (e) {
|
||||
console.error('保存 MCP 配置失败:', e)
|
||||
setToast({ text: '保存 MCP 配置失败', success: false })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const copyText = async (text: string, successText: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setToast({ text: successText, success: true })
|
||||
} catch (e) {
|
||||
console.error('复制失败:', e)
|
||||
setToast({ text: '复制失败,请手动复制', success: false })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ height: '100%', mx: -3, mt: -3, overflowY: 'auto', pb: 3 }}>
|
||||
<Container maxWidth="lg" sx={{ px: { xs: 2, md: 4 }, py: { xs: 3, md: 4 } }}>
|
||||
<Stack spacing={2.2}>
|
||||
<Box sx={{ px: { xs: 0.5, md: 1 }, pt: 0.5 }}>
|
||||
<Typography variant="h4" sx={{ fontSize: 30, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||
MCP Server
|
||||
</Typography>
|
||||
<Typography sx={{ mt: 1, color: 'var(--text-secondary)' }}>
|
||||
使用标准 MCP `stdio` 工具接口为 Claude Desktop、Codex、Cherry Studio 等宿主提供本地聊天数据读取能力。
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Card
|
||||
sx={{
|
||||
borderRadius: '26px',
|
||||
border: '1px solid var(--border-color)',
|
||||
bgcolor: 'var(--bg-secondary)',
|
||||
boxShadow: 'none',
|
||||
}}
|
||||
>
|
||||
<CardHeader
|
||||
title="服务配置"
|
||||
titleTypographyProps={{ fontWeight: 700, fontSize: 18, color: 'var(--text-primary)' }}
|
||||
sx={{ px: { xs: 2, md: 3 }, pb: 0.8 }}
|
||||
/>
|
||||
<CardContent sx={{ px: { xs: 2, md: 3 }, pt: 0.6 }}>
|
||||
<Stack spacing={2.4}>
|
||||
<Alert
|
||||
severity="info"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
borderRadius: '18px',
|
||||
bgcolor: 'var(--bg-primary)',
|
||||
borderColor: 'var(--border-color)',
|
||||
color: 'var(--text-primary)',
|
||||
'& .MuiAlert-message': {
|
||||
color: 'var(--text-primary)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
`mcpEnabled` 现在只作为状态标记和 warning 来源,不阻止宿主拉起 MCP。
|
||||
{launchConfig.mode === 'packaged'
|
||||
? ' 当前展示的是打包版伴随启动器 `ciphertalk-mcp.cmd`。'
|
||||
: ' 当前展示的是开发态入口 `npm run mcp`。'}
|
||||
</Alert>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: '18px',
|
||||
border: '1px solid var(--border-color)',
|
||||
bgcolor: 'var(--bg-primary)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 600, color: 'var(--text-primary)' }}>MCP 状态标记</Typography>
|
||||
<Typography sx={{ mt: 0.5, fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||
仅用于在 `health_check` / `get_status` 中暴露当前配置状态,不会阻止宿主调用工具。
|
||||
</Typography>
|
||||
</Box>
|
||||
<Switch
|
||||
checked={mcpEnabled}
|
||||
onChange={(e) => setMcpEnabled(e.target.checked)}
|
||||
disabled={loading || saving}
|
||||
sx={switchSx}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: '18px',
|
||||
border: '1px solid var(--border-color)',
|
||||
bgcolor: 'var(--bg-primary)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 600, color: 'var(--text-primary)' }}>默认解析媒体本地路径</Typography>
|
||||
<Typography sx={{ mt: 0.5, fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||
控制 `get_messages`、`search_messages`、`get_session_context` 默认是否解析并返回图片、视频、语音、文件等本地路径。
|
||||
</Typography>
|
||||
</Box>
|
||||
<Switch
|
||||
checked={mcpExposeMediaPaths}
|
||||
onChange={(e) => setMcpExposeMediaPaths(e.target.checked)}
|
||||
disabled={loading || saving}
|
||||
sx={switchSx}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography sx={{ mb: 1, fontWeight: 600, color: 'var(--text-primary)' }}>启动命令</Typography>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.2}>
|
||||
<TextField
|
||||
fullWidth
|
||||
value={mcpRunCommand}
|
||||
InputProps={{ readOnly: true }}
|
||||
sx={textFieldSx}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<Copy size={16} />}
|
||||
onClick={() => copyText(mcpRunCommand, 'MCP 启动命令已复制')}
|
||||
sx={secondaryButtonSx}
|
||||
>
|
||||
复制
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography sx={{ mb: 1, fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||
标准 mcpServers 配置(可直接粘贴)
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={9}
|
||||
value={mcpServerJsonTemplate}
|
||||
InputProps={{ readOnly: true }}
|
||||
sx={{
|
||||
...textFieldSx,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: '14px',
|
||||
color: 'var(--text-primary)',
|
||||
backgroundColor: 'var(--bg-secondary)',
|
||||
fontFamily: 'var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.2} sx={{ mt: 1.2 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<Copy size={16} />}
|
||||
onClick={() => copyText(mcpServerJsonTemplate, 'mcpServers 配置已复制')}
|
||||
sx={secondaryButtonSx}
|
||||
>
|
||||
复制配置
|
||||
</Button>
|
||||
<Typography sx={{ alignSelf: 'center', fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||
{launchConfig.mode === 'packaged'
|
||||
? '`cwd` 已指向安装目录,宿主通常无需额外包一层 shell。'
|
||||
: '`cwd` 已自动使用当前仓库目录,通常无需修改。'}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.2} justifyContent="space-between" alignItems={{ xs: 'stretch', sm: 'center' }}>
|
||||
<Typography sx={{ fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||
v2 工具:`health_check`、`get_status`、`list_sessions`、`get_messages`、`list_contacts`、`search_messages`、`get_session_context`、`get_global_statistics`、`get_contact_rankings`、`get_activity_distribution`
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSave}
|
||||
disabled={loading || saving}
|
||||
sx={{
|
||||
minWidth: 42,
|
||||
width: 42,
|
||||
height: 42,
|
||||
borderRadius: '999px',
|
||||
p: 0,
|
||||
textTransform: 'none',
|
||||
fontWeight: 700,
|
||||
background: 'var(--primary-gradient)',
|
||||
'&:hover': {
|
||||
background: 'var(--primary-gradient)',
|
||||
filter: 'brightness(0.98)',
|
||||
},
|
||||
}}
|
||||
title={saving ? '保存中...' : '保存配置'}
|
||||
aria-label={saving ? '保存中' : '保存配置'}
|
||||
>
|
||||
<Save size={16} />
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Container>
|
||||
|
||||
<Snackbar
|
||||
open={!!toast}
|
||||
autoHideDuration={2400}
|
||||
onClose={() => setToast(null)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert
|
||||
icon={toast?.success ? <Check size={16} /> : undefined}
|
||||
severity={toast?.success ? 'success' : 'error'}
|
||||
variant="filled"
|
||||
onClose={() => setToast(null)}
|
||||
sx={{
|
||||
borderRadius: '12px',
|
||||
color: '#fff',
|
||||
bgcolor: toast?.success ? 'var(--primary)' : 'var(--danger)',
|
||||
}}
|
||||
>
|
||||
{toast?.text}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default McpPage
|
||||
@@ -1001,29 +1001,51 @@ to {
|
||||
|
||||
.download-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 200px;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: min(320px, 100%);
|
||||
|
||||
.progress-bar {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
.progress-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--primary);
|
||||
border-radius: 3px;
|
||||
transition: width 0.2s ease;
|
||||
.progress-bar {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--primary);
|
||||
border-radius: 999px;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
> span {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
min-width: 35px;
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
min-width: 35px;
|
||||
.progress-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useSearchParams, useLocation } from 'react-router-dom'
|
||||
import { useAppStore } from '../stores/appStore'
|
||||
import { useThemeStore, themes } from '../stores/themeStore'
|
||||
import { useActivationStore } from '../stores/activationStore'
|
||||
import type { UpdateDownloadProgressPayload } from '../types/electron'
|
||||
import { dialog } from '../services/ipc'
|
||||
import * as configService from '../services/config'
|
||||
import AISummarySettings from '../components/ai/AISummarySettings'
|
||||
@@ -97,8 +98,44 @@ function SettingsPage() {
|
||||
const [isCheckingUpdate, setIsCheckingUpdate] = useState(false)
|
||||
const [isDownloading, setIsDownloading] = useState(false)
|
||||
const [downloadProgress, setDownloadProgress] = useState(0)
|
||||
const [downloadProgressDetail, setDownloadProgressDetail] = useState<UpdateDownloadProgressPayload | null>(null)
|
||||
const [appVersion, setAppVersion] = useState('')
|
||||
const [updateInfo, setUpdateInfo] = useState<{ hasUpdate: boolean; version?: string; releaseNotes?: string } | null>(null)
|
||||
const [updateInfo, setUpdateInfo] = useState<{
|
||||
hasUpdate: boolean
|
||||
forceUpdate: boolean
|
||||
currentVersion: string
|
||||
version?: string
|
||||
releaseNotes?: string
|
||||
title?: string
|
||||
message?: string
|
||||
minimumSupportedVersion?: string
|
||||
reason?: 'minimum-version' | 'blocked-version'
|
||||
checkedAt: number
|
||||
updateSource: 'github' | 'custom' | 'none'
|
||||
policySource: 'github' | 'custom' | 'none'
|
||||
diagnostics?: {
|
||||
phase: 'idle' | 'checking' | 'available' | 'downloading' | 'downloaded' | 'installing' | 'failed'
|
||||
strategy: 'unknown' | 'differential' | 'full'
|
||||
fallbackToFull: boolean
|
||||
lastError?: string
|
||||
lastEvent?: string
|
||||
progressPercent?: number
|
||||
downloadedBytes?: number
|
||||
totalBytes?: number
|
||||
targetVersion?: string
|
||||
lastUpdatedAt: number
|
||||
}
|
||||
} | null>(null)
|
||||
const [updateSourceInfo, setUpdateSourceInfo] = useState<{
|
||||
primaryUpdateSource: 'github'
|
||||
githubRepository: {
|
||||
owner: string
|
||||
repo: string
|
||||
}
|
||||
policySources: Array<'github' | 'custom'>
|
||||
policyPrecedence: 'github'
|
||||
forceUpdatePolicyFallbackUrl: string
|
||||
} | null>(null)
|
||||
const [keyStatus, setKeyStatus] = useState('')
|
||||
const [message, setMessage] = useState<{ text: string; success: boolean } | null>(null)
|
||||
const [showDecryptKey, setShowDecryptKey] = useState(false)
|
||||
@@ -459,22 +496,66 @@ function SettingsPage() {
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
|
||||
}
|
||||
|
||||
const formatSpeed = (bytesPerSecond: number): string => {
|
||||
if (!Number.isFinite(bytesPerSecond) || bytesPerSecond <= 0) return '计算中'
|
||||
if (bytesPerSecond < 1024) return `${bytesPerSecond.toFixed(0)} B/s`
|
||||
if (bytesPerSecond < 1024 * 1024) return `${(bytesPerSecond / 1024).toFixed(1)} KB/s`
|
||||
return `${(bytesPerSecond / (1024 * 1024)).toFixed(1)} MB/s`
|
||||
}
|
||||
|
||||
const syncUpdateState = async () => {
|
||||
try {
|
||||
const state = await window.electronAPI.app.getUpdateState?.()
|
||||
if (!state) return
|
||||
setUpdateInfo(state)
|
||||
const phase = state.diagnostics?.phase
|
||||
setIsDownloading(phase === 'downloading' || phase === 'installing')
|
||||
if (typeof state.diagnostics?.progressPercent === 'number') {
|
||||
setDownloadProgress(state.diagnostics.progressPercent)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('同步更新状态失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听下载进度
|
||||
useEffect(() => {
|
||||
const removeListener = window.electronAPI.app.onDownloadProgress?.((progress: number) => {
|
||||
setDownloadProgress(progress)
|
||||
syncUpdateState()
|
||||
|
||||
const removeListener = window.electronAPI.app.onDownloadProgress?.((progress: UpdateDownloadProgressPayload) => {
|
||||
setDownloadProgress(progress.percent)
|
||||
setDownloadProgressDetail(progress)
|
||||
setIsDownloading(true)
|
||||
setUpdateInfo((current) => {
|
||||
if (!current) return current
|
||||
return {
|
||||
...current,
|
||||
diagnostics: {
|
||||
phase: 'downloading',
|
||||
strategy: current.diagnostics?.strategy || 'unknown',
|
||||
fallbackToFull: current.diagnostics?.fallbackToFull || false,
|
||||
lastError: current.diagnostics?.lastError,
|
||||
lastEvent: current.diagnostics?.lastEvent,
|
||||
progressPercent: progress.percent,
|
||||
downloadedBytes: progress.transferred,
|
||||
totalBytes: progress.total,
|
||||
targetVersion: current.version || current.diagnostics?.targetVersion,
|
||||
lastUpdatedAt: Date.now()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
return () => removeListener?.()
|
||||
}, [])
|
||||
|
||||
const handleCheckUpdate = async () => {
|
||||
if (isDownloading || updateInfo?.diagnostics?.phase === 'installing') return
|
||||
setIsCheckingUpdate(true)
|
||||
setUpdateInfo(null)
|
||||
try {
|
||||
const result = await window.electronAPI.app.checkForUpdates()
|
||||
if (result.hasUpdate) {
|
||||
setUpdateInfo(result)
|
||||
showMessage(`发现新版本 ${result.version}`, true)
|
||||
showMessage(result.forceUpdate ? `检测到强制更新 ${result.version}` : `发现新版本 ${result.version}`, true)
|
||||
} else {
|
||||
showMessage('当前已是最新版本', true)
|
||||
}
|
||||
@@ -571,14 +652,31 @@ function SettingsPage() {
|
||||
}
|
||||
|
||||
const handleUpdateNow = async () => {
|
||||
if (isDownloading) return
|
||||
setIsDownloading(true)
|
||||
setDownloadProgress(0)
|
||||
setUpdateInfo((current) => current ? {
|
||||
...current,
|
||||
diagnostics: {
|
||||
phase: 'downloading',
|
||||
strategy: current.diagnostics?.strategy || 'unknown',
|
||||
fallbackToFull: current.diagnostics?.fallbackToFull || false,
|
||||
lastError: undefined,
|
||||
lastEvent: '开始下载更新',
|
||||
progressPercent: 0,
|
||||
downloadedBytes: 0,
|
||||
totalBytes: current.diagnostics?.totalBytes,
|
||||
targetVersion: current.version || current.diagnostics?.targetVersion,
|
||||
lastUpdatedAt: Date.now()
|
||||
}
|
||||
} : current)
|
||||
try {
|
||||
showMessage('正在下载更新...', true)
|
||||
await window.electronAPI.app.downloadAndInstall()
|
||||
} catch (e) {
|
||||
showMessage(`更新失败: ${e}`, false)
|
||||
setIsDownloading(false)
|
||||
await syncUpdateState()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2731,9 +2829,24 @@ function SettingsPage() {
|
||||
useEffect(() => {
|
||||
if (location.state?.updateInfo) {
|
||||
setUpdateInfo(location.state.updateInfo)
|
||||
const phase = location.state.updateInfo.diagnostics?.phase
|
||||
setIsDownloading(phase === 'downloading' || phase === 'installing')
|
||||
if (typeof location.state.updateInfo.diagnostics?.progressPercent === 'number') {
|
||||
setDownloadProgress(location.state.updateInfo.diagnostics.progressPercent)
|
||||
}
|
||||
} else {
|
||||
syncUpdateState()
|
||||
}
|
||||
}, [location.state])
|
||||
|
||||
useEffect(() => {
|
||||
window.electronAPI.app.getUpdateSourceInfo?.().then((info) => {
|
||||
setUpdateSourceInfo(info)
|
||||
}).catch((error) => {
|
||||
console.error('获取更新源信息失败:', error)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const renderAboutTab = () => (
|
||||
<div className="tab-content about-tab">
|
||||
<div className="about-card">
|
||||
@@ -2745,24 +2858,58 @@ function SettingsPage() {
|
||||
<p className="about-version">v{appVersion || '...'}</p>
|
||||
|
||||
<div className="about-update">
|
||||
{updateSourceInfo && (
|
||||
<div className="update-hint" style={{ marginBottom: '10px' }}>
|
||||
主更新源:GitHub Release ({updateSourceInfo.githubRepository.owner}/{updateSourceInfo.githubRepository.repo})<br />
|
||||
策略补充源:{updateSourceInfo.forceUpdatePolicyFallbackUrl}
|
||||
</div>
|
||||
)}
|
||||
{updateInfo?.hasUpdate ? (
|
||||
<>
|
||||
<p className="update-hint">新版本 v{updateInfo.version} 可用</p>
|
||||
<p className="update-hint">
|
||||
{isDownloading ? `正在下载 v${updateInfo.version}` : updateInfo.forceUpdate ? '检测到强制更新' : `新版本 v${updateInfo.version} 可用`}
|
||||
</p>
|
||||
<p className="update-hint">
|
||||
更新来源:{updateInfo.updateSource === 'github' ? 'GitHub Release' : '未知'} / 策略来源:
|
||||
{updateInfo.policySource === 'github' ? 'GitHub' : updateInfo.policySource === 'custom' ? '自定义源' : '无'}
|
||||
</p>
|
||||
{updateInfo.forceUpdate && updateInfo.minimumSupportedVersion && (
|
||||
<p className="update-hint">最低安全版本:v{updateInfo.minimumSupportedVersion}</p>
|
||||
)}
|
||||
{updateInfo.diagnostics && (
|
||||
<div className="update-hint" style={{ marginTop: '8px' }}>
|
||||
更新诊断:{updateInfo.diagnostics.phase}
|
||||
{updateInfo.diagnostics.fallbackToFull ? ' / 已从差分回退到全量' : ''}
|
||||
{updateInfo.diagnostics.lastEvent ? <><br />最近事件:{updateInfo.diagnostics.lastEvent}</> : null}
|
||||
{updateInfo.diagnostics.lastError ? <><br />最近错误:{updateInfo.diagnostics.lastError}</> : null}
|
||||
<br />
|
||||
详细诊断请查看日志文件中的 AppUpdate 记录。
|
||||
</div>
|
||||
)}
|
||||
{isDownloading ? (
|
||||
<div className="download-progress">
|
||||
<div className="progress-bar">
|
||||
<div className="progress-fill" style={{ width: `${downloadProgress}%` }} />
|
||||
<div className="progress-main">
|
||||
<div className="progress-bar">
|
||||
<div className="progress-fill" style={{ width: `${downloadProgress}%` }} />
|
||||
</div>
|
||||
<span>{downloadProgress.toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="progress-meta">
|
||||
<span>
|
||||
{formatFileSize(downloadProgressDetail?.transferred ?? updateInfo.diagnostics?.downloadedBytes ?? 0)} / {formatFileSize(downloadProgressDetail?.total ?? updateInfo.diagnostics?.totalBytes ?? 0)}
|
||||
</span>
|
||||
<span>速度 {formatSpeed(downloadProgressDetail?.bytesPerSecond ?? 0)}</span>
|
||||
{updateInfo.diagnostics?.fallbackToFull ? <span>已回退全量下载</span> : null}
|
||||
</div>
|
||||
<span>{downloadProgress.toFixed(0)}%</span>
|
||||
</div>
|
||||
) : (
|
||||
<button className="btn btn-primary" onClick={handleUpdateNow}>
|
||||
<button className="btn btn-primary" onClick={handleUpdateNow} disabled={isDownloading}>
|
||||
<Download size={16} /> 立即更新
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<button className="btn btn-secondary" onClick={handleCheckUpdate} disabled={isCheckingUpdate}>
|
||||
<button className="btn btn-secondary" onClick={handleCheckUpdate} disabled={isCheckingUpdate || isDownloading}>
|
||||
<RefreshCw size={16} className={isCheckingUpdate ? 'spin' : ''} />
|
||||
{isCheckingUpdate ? '检查中...' : '检查更新'}
|
||||
</button>
|
||||
|
||||
@@ -1,108 +1,279 @@
|
||||
// 确保启动屏窗口背景透明(让 Electron 的 transparent: true 生效)
|
||||
html,
|
||||
body {
|
||||
background: transparent !important;
|
||||
overflow: hidden !important; // 隐藏滚动条
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.splash-page {
|
||||
width: calc(100% - 24px); // 减去外边距
|
||||
height: calc(100vh - 24px); // 减去外边距
|
||||
margin: 12px; // 添加外边距,让圆角更明显
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #F0EEE9 0%, #E8E6E1 100%);
|
||||
color: #3d3d3d;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
border-radius: 24px; // 大圆角
|
||||
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.15); // 添加阴影效果
|
||||
|
||||
// 入场动画
|
||||
animation: fadeIn 0.4s ease-out;
|
||||
width: calc(100% - 24px);
|
||||
height: calc(100vh - 24px);
|
||||
margin: 12px;
|
||||
overflow: hidden;
|
||||
border-radius: 28px;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(255, 226, 184, 0.72), transparent 38%),
|
||||
radial-gradient(circle at 85% 18%, rgba(255, 153, 111, 0.24), transparent 26%),
|
||||
linear-gradient(145deg, #fbf5ea 0%, #f3eadc 38%, #ece2d4 100%);
|
||||
box-shadow:
|
||||
0 28px 70px rgba(71, 43, 20, 0.18),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.55);
|
||||
animation: splash-enter 0.55s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: radial-gradient(circle at 50% 50%, rgba(139, 115, 85, 0.05) 0%, transparent 70%);
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(125deg, rgba(255, 255, 255, 0.28), transparent 30%, transparent 70%, rgba(122, 71, 35, 0.08)),
|
||||
repeating-linear-gradient(
|
||||
135deg,
|
||||
rgba(122, 71, 35, 0.03) 0,
|
||||
rgba(122, 71, 35, 0.03) 1px,
|
||||
transparent 1px,
|
||||
transparent 18px
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// 退出动画类
|
||||
&.fade-out {
|
||||
animation: fadeOut 0.3s ease-in forwards;
|
||||
animation: splash-exit 0.28s ease-in forwards;
|
||||
}
|
||||
}
|
||||
|
||||
.splash-content {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 44px 42px 34px;
|
||||
}
|
||||
|
||||
.splash-orb {
|
||||
position: absolute;
|
||||
border-radius: 999px;
|
||||
filter: blur(8px);
|
||||
opacity: 0.8;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.splash-orb-left {
|
||||
top: 48px;
|
||||
left: 56px;
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
background: radial-gradient(circle, rgba(255, 255, 255, 0.92) 0%, rgba(255, 232, 197, 0.16) 72%, transparent 100%);
|
||||
animation: float-orb 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.splash-orb-right {
|
||||
right: 38px;
|
||||
bottom: 54px;
|
||||
width: 240px;
|
||||
height: 240px;
|
||||
background: radial-gradient(circle, rgba(222, 140, 80, 0.24) 0%, rgba(222, 140, 80, 0.08) 52%, transparent 100%);
|
||||
animation: float-orb 7.5s ease-in-out infinite reverse;
|
||||
}
|
||||
|
||||
.splash-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 22px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.splash-logo-shell {
|
||||
position: relative;
|
||||
width: 110px;
|
||||
height: 110px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 32px;
|
||||
background: linear-gradient(160deg, rgba(255, 255, 255, 0.74), rgba(247, 233, 210, 0.64));
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.92),
|
||||
0 18px 40px rgba(116, 72, 36, 0.14);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.splash-logo-glow {
|
||||
position: absolute;
|
||||
inset: 16px;
|
||||
border-radius: 22px;
|
||||
background: radial-gradient(circle, rgba(255, 196, 129, 0.42) 0%, transparent 72%);
|
||||
animation: logo-breathe 3.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.splash-logo-image,
|
||||
.splash-logo-fallback {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.splash-logo-image {
|
||||
width: 74px;
|
||||
height: 74px;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 10px 20px rgba(122, 71, 35, 0.14));
|
||||
animation: logo-drift 3.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.splash-logo-fallback {
|
||||
width: 74px;
|
||||
height: 74px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #81512d;
|
||||
letter-spacing: 4px;
|
||||
}
|
||||
|
||||
.splash-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
color: #49311f;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 36px;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: rgba(73, 49, 31, 0.68);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
}
|
||||
|
||||
.splash-eyebrow {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.28em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(129, 81, 45, 0.72);
|
||||
}
|
||||
|
||||
.splash-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
.splash-status::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: min(220px, 34vw);
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, rgba(120, 77, 44, 0.28), rgba(120, 77, 44, 0));
|
||||
}
|
||||
|
||||
.splash-status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.splash-status-dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, #ff9d54, #d96b2d);
|
||||
box-shadow: 0 0 0 6px rgba(217, 107, 45, 0.12);
|
||||
animation: status-pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.splash-status-text {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: rgba(73, 49, 31, 0.82);
|
||||
animation: status-fade 220ms ease-out;
|
||||
}
|
||||
|
||||
.splash-progress-track {
|
||||
position: relative;
|
||||
height: 4px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: rgba(108, 69, 39, 0.12);
|
||||
max-width: 260px;
|
||||
}
|
||||
|
||||
.splash-progress-bar {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 42%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, rgba(255, 161, 98, 0) 0%, #ffb36b 22%, #d96b2d 78%, rgba(217, 107, 45, 0) 100%);
|
||||
animation: progress-slide 1.8s cubic-bezier(0.4, 0, 0.2, 1) infinite;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.splash-page {
|
||||
width: calc(100% - 16px);
|
||||
height: calc(100vh - 16px);
|
||||
margin: 8px;
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.splash-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
z-index: 1;
|
||||
padding: 28px 24px 24px;
|
||||
}
|
||||
|
||||
.splash-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.logo-icon {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 2px;
|
||||
color: #8B7355;
|
||||
background: linear-gradient(135deg, #8B7355 0%, #A68B5B 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
// 每2秒翻转一次
|
||||
animation: flip 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
// logo 图片每2秒翻转一次
|
||||
img {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
object-fit: contain;
|
||||
animation: flip 2s ease-in-out infinite;
|
||||
}
|
||||
.splash-brand {
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.splash-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #666666;
|
||||
.splash-logo-shell {
|
||||
width: 92px;
|
||||
height: 92px;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
color: #8B7355;
|
||||
}
|
||||
.splash-logo-image,
|
||||
.splash-logo-fallback {
|
||||
width: 62px;
|
||||
height: 62px;
|
||||
}
|
||||
|
||||
.splash-copy h1 {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.splash-progress-track {
|
||||
max-width: 180px;
|
||||
}
|
||||
}
|
||||
|
||||
// 入场淡入动画
|
||||
@keyframes fadeIn {
|
||||
@keyframes splash-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
transform: scale(0.97) translateY(10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
// 退出淡出动画
|
||||
@keyframes fadeOut {
|
||||
@keyframes splash-exit {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
@@ -110,34 +281,76 @@ body {
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
transform: scale(0.985) translateY(8px);
|
||||
}
|
||||
}
|
||||
|
||||
// 翻转动画(每2秒翻转360度,中间有停顿)
|
||||
@keyframes flip {
|
||||
|
||||
@keyframes float-orb {
|
||||
0%,
|
||||
25% {
|
||||
transform: rotateY(0deg);
|
||||
100% {
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
|
||||
75%,
|
||||
100% {
|
||||
transform: rotateY(360deg);
|
||||
transform: translate3d(10px, -12px, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
@keyframes logo-breathe {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(0.94);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.04);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes logo-drift {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes status-pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 6px rgba(217, 107, 45, 0.12);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.08);
|
||||
box-shadow: 0 0 0 9px rgba(217, 107, 45, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes status-fade {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes progress-slide {
|
||||
0% {
|
||||
transform: translateX(-135%);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(330%);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import './SplashPage.scss'
|
||||
|
||||
import { useThemeStore } from '../stores/themeStore'
|
||||
const loadingMessages = [
|
||||
'正在校验本地环境',
|
||||
'正在连接数据库',
|
||||
'正在整理聊天索引'
|
||||
]
|
||||
|
||||
function SplashPage() {
|
||||
const [fadeOut, setFadeOut] = useState(false)
|
||||
const appIcon = useThemeStore(state => state.appIcon)
|
||||
const [messageIndex, setMessageIndex] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
// 等待入场动画完成后再通知主进程(入场动画 0.4s + 额外停留 0.6s = 1s)
|
||||
const readyTimer = setTimeout(() => {
|
||||
try {
|
||||
// @ts-ignore - splashReady 方法在运行时可用
|
||||
@@ -19,37 +21,61 @@ function SplashPage() {
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
// 监听淡出事件
|
||||
const messageTimer = setInterval(() => {
|
||||
setMessageIndex((prev) => (prev + 1) % loadingMessages.length)
|
||||
}, 1600)
|
||||
|
||||
const cleanup = window.electronAPI?.window?.onSplashFadeOut?.(() => {
|
||||
setFadeOut(true)
|
||||
})
|
||||
|
||||
return () => {
|
||||
clearTimeout(readyTimer)
|
||||
clearInterval(messageTimer)
|
||||
cleanup?.()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={`splash-page ${fadeOut ? 'fade-out' : ''}`}>
|
||||
<div className="splash-orb splash-orb-left" />
|
||||
<div className="splash-orb splash-orb-right" />
|
||||
|
||||
<div className="splash-content">
|
||||
<div className="splash-logo">
|
||||
{/* 尝试加载logo图片,如果不存在则显示文字 */}
|
||||
<img
|
||||
src={appIcon === 'xinnian' ? "./xinnian.png" : "./logo.png"}
|
||||
alt="密语"
|
||||
onError={(e) => {
|
||||
// 如果图片加载失败,隐藏img,显示文字
|
||||
e.currentTarget.style.display = 'none'
|
||||
const textEl = e.currentTarget.nextElementSibling as HTMLElement
|
||||
if (textEl) textEl.style.display = 'block'
|
||||
}}
|
||||
/>
|
||||
<div className="logo-icon" style={{ display: 'none' }}>密语</div>
|
||||
<div className="splash-brand">
|
||||
<div className="splash-logo-shell">
|
||||
<div className="splash-logo-glow" />
|
||||
<img
|
||||
className="splash-logo-image"
|
||||
src="./logo.png"
|
||||
alt="密语"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none'
|
||||
const textEl = e.currentTarget.nextElementSibling as HTMLElement | null
|
||||
if (textEl) textEl.style.display = 'grid'
|
||||
}}
|
||||
/>
|
||||
<div className="splash-logo-fallback" style={{ display: 'none' }}>密语</div>
|
||||
</div>
|
||||
|
||||
<div className="splash-copy">
|
||||
<span className="splash-eyebrow">CipherTalk</span>
|
||||
<h1>密语</h1>
|
||||
<p>本地聊天记录分析工作台</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="splash-text">
|
||||
<Loader2 size={20} className="spin" />
|
||||
<span>正在连接数据库...</span>
|
||||
|
||||
<div className="splash-status">
|
||||
<div className="splash-status-row">
|
||||
<span className="splash-status-dot" />
|
||||
<span key={loadingMessages[messageIndex]} className="splash-status-text">
|
||||
{loadingMessages[messageIndex]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="splash-progress-track" aria-hidden="true">
|
||||
<div className="splash-progress-bar" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -57,4 +83,3 @@ function SplashPage() {
|
||||
}
|
||||
|
||||
export default SplashPage
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ export const CONFIG_KEYS = {
|
||||
HTTP_API_ENABLED: 'httpApiEnabled',
|
||||
HTTP_API_PORT: 'httpApiPort',
|
||||
HTTP_API_TOKEN: 'httpApiToken',
|
||||
MCP_ENABLED: 'mcpEnabled',
|
||||
MCP_EXPOSE_MEDIA_PATHS: 'mcpExposeMediaPaths',
|
||||
AUTH_ENABLED: 'authEnabled',
|
||||
AUTH_CREDENTIAL_ID: 'authCredentialId',
|
||||
AUTH_PASSWORD_HASH: 'authPasswordHash',
|
||||
@@ -509,6 +511,26 @@ export async function setAiMessageLimit(limit: number): Promise<void> {
|
||||
await config.set('aiMessageLimit', limit)
|
||||
}
|
||||
|
||||
// --- MCP 配置 ---
|
||||
|
||||
export async function getMcpEnabled(): Promise<boolean> {
|
||||
const value = await config.get(CONFIG_KEYS.MCP_ENABLED)
|
||||
return value !== undefined ? (value as boolean) : false
|
||||
}
|
||||
|
||||
export async function setMcpEnabled(enabled: boolean): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.MCP_ENABLED, enabled)
|
||||
}
|
||||
|
||||
export async function getMcpExposeMediaPaths(): Promise<boolean> {
|
||||
const value = await config.get(CONFIG_KEYS.MCP_EXPOSE_MEDIA_PATHS)
|
||||
return value !== undefined ? (value as boolean) : true
|
||||
}
|
||||
|
||||
export async function setMcpExposeMediaPaths(enabled: boolean): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.MCP_EXPOSE_MEDIA_PATHS, enabled)
|
||||
}
|
||||
|
||||
// --- AI 配置预设 ---
|
||||
|
||||
export interface AiConfigPreset {
|
||||
|
||||
147
src/types/electron.d.ts
vendored
147
src/types/electron.d.ts
vendored
@@ -12,6 +12,13 @@ export interface ImageViewerOpenOptions {
|
||||
imageDatName?: string
|
||||
}
|
||||
|
||||
export interface UpdateDownloadProgressPayload {
|
||||
percent: number
|
||||
transferred: number
|
||||
total: number
|
||||
bytesPerSecond: number
|
||||
}
|
||||
|
||||
export interface ElectronAPI {
|
||||
window: {
|
||||
minimize: () => void
|
||||
@@ -76,12 +83,146 @@ export interface ElectronAPI {
|
||||
getDownloadsPath: () => Promise<string>
|
||||
getVersion: () => Promise<string>
|
||||
getPlatformInfo: () => Promise<{ platform: string; arch: string }>
|
||||
checkForUpdates: () => Promise<{ hasUpdate: boolean; version?: string; releaseNotes?: string }>
|
||||
getMcpLaunchConfig: () => Promise<{
|
||||
command: string
|
||||
args: string[]
|
||||
cwd: string
|
||||
mode: 'dev' | 'packaged'
|
||||
} | null>
|
||||
getUpdateState: () => Promise<{
|
||||
hasUpdate: boolean
|
||||
forceUpdate: boolean
|
||||
currentVersion: string
|
||||
version?: string
|
||||
releaseNotes?: string
|
||||
title?: string
|
||||
message?: string
|
||||
minimumSupportedVersion?: string
|
||||
reason?: 'minimum-version' | 'blocked-version'
|
||||
checkedAt: number
|
||||
updateSource: 'github' | 'custom' | 'none'
|
||||
policySource: 'github' | 'custom' | 'none'
|
||||
diagnostics?: {
|
||||
phase: 'idle' | 'checking' | 'available' | 'downloading' | 'downloaded' | 'installing' | 'failed'
|
||||
strategy: 'unknown' | 'differential' | 'full'
|
||||
fallbackToFull: boolean
|
||||
lastError?: string
|
||||
lastEvent?: string
|
||||
progressPercent?: number
|
||||
downloadedBytes?: number
|
||||
totalBytes?: number
|
||||
targetVersion?: string
|
||||
lastUpdatedAt: number
|
||||
}
|
||||
} | null>
|
||||
getUpdateSourceInfo: () => Promise<{
|
||||
primaryUpdateSource: 'github'
|
||||
githubRepository: {
|
||||
owner: string
|
||||
repo: string
|
||||
}
|
||||
policySources: Array<'github' | 'custom'>
|
||||
policyPrecedence: 'github'
|
||||
forceUpdatePolicyFallbackUrl: string
|
||||
}>
|
||||
getMcpLaunchConfig: () => Promise<{
|
||||
command: string
|
||||
args: string[]
|
||||
cwd: string
|
||||
mode: 'dev' | 'packaged'
|
||||
} | null>
|
||||
getUpdateState: () => Promise<{
|
||||
hasUpdate: boolean
|
||||
forceUpdate: boolean
|
||||
currentVersion: string
|
||||
version?: string
|
||||
releaseNotes?: string
|
||||
title?: string
|
||||
message?: string
|
||||
minimumSupportedVersion?: string
|
||||
reason?: 'minimum-version' | 'blocked-version'
|
||||
checkedAt: number
|
||||
updateSource: 'github' | 'custom' | 'none'
|
||||
policySource: 'github' | 'custom' | 'none'
|
||||
diagnostics?: {
|
||||
phase: 'idle' | 'checking' | 'available' | 'downloading' | 'downloaded' | 'installing' | 'failed'
|
||||
strategy: 'unknown' | 'differential' | 'full'
|
||||
fallbackToFull: boolean
|
||||
lastError?: string
|
||||
lastEvent?: string
|
||||
progressPercent?: number
|
||||
downloadedBytes?: number
|
||||
totalBytes?: number
|
||||
targetVersion?: string
|
||||
lastUpdatedAt: number
|
||||
}
|
||||
} | null>
|
||||
getUpdateSourceInfo: () => Promise<{
|
||||
primaryUpdateSource: 'github'
|
||||
githubRepository: {
|
||||
owner: string
|
||||
repo: string
|
||||
}
|
||||
policySources: Array<'github' | 'custom'>
|
||||
policyPrecedence: 'github'
|
||||
forceUpdatePolicyFallbackUrl: string
|
||||
}>
|
||||
checkForUpdates: () => Promise<{
|
||||
hasUpdate: boolean
|
||||
forceUpdate: boolean
|
||||
currentVersion: string
|
||||
version?: string
|
||||
releaseNotes?: string
|
||||
title?: string
|
||||
message?: string
|
||||
minimumSupportedVersion?: string
|
||||
reason?: 'minimum-version' | 'blocked-version'
|
||||
checkedAt: number
|
||||
updateSource: 'github' | 'custom' | 'none'
|
||||
policySource: 'github' | 'custom' | 'none'
|
||||
diagnostics?: {
|
||||
phase: 'idle' | 'checking' | 'available' | 'downloading' | 'downloaded' | 'installing' | 'failed'
|
||||
strategy: 'unknown' | 'differential' | 'full'
|
||||
fallbackToFull: boolean
|
||||
lastError?: string
|
||||
lastEvent?: string
|
||||
progressPercent?: number
|
||||
downloadedBytes?: number
|
||||
totalBytes?: number
|
||||
targetVersion?: string
|
||||
lastUpdatedAt: number
|
||||
}
|
||||
}>
|
||||
downloadAndInstall: () => Promise<void>
|
||||
getStartupDbConnected?: () => Promise<boolean>
|
||||
setAppIcon: (iconName: string) => Promise<void>
|
||||
onDownloadProgress: (callback: (progress: number) => void) => () => void
|
||||
onUpdateAvailable: (callback: (info: { version: string; releaseNotes: string }) => void) => () => void
|
||||
onDownloadProgress: (callback: (progress: UpdateDownloadProgressPayload) => void) => () => void
|
||||
onUpdateAvailable: (callback: (info: {
|
||||
hasUpdate: boolean
|
||||
forceUpdate: boolean
|
||||
currentVersion: string
|
||||
version?: string
|
||||
releaseNotes?: string
|
||||
title?: string
|
||||
message?: string
|
||||
minimumSupportedVersion?: string
|
||||
reason?: 'minimum-version' | 'blocked-version'
|
||||
checkedAt: number
|
||||
updateSource: 'github' | 'custom' | 'none'
|
||||
policySource: 'github' | 'custom' | 'none'
|
||||
diagnostics?: {
|
||||
phase: 'idle' | 'checking' | 'available' | 'downloading' | 'downloaded' | 'installing' | 'failed'
|
||||
strategy: 'unknown' | 'differential' | 'full'
|
||||
fallbackToFull: boolean
|
||||
lastError?: string
|
||||
lastEvent?: string
|
||||
progressPercent?: number
|
||||
downloadedBytes?: number
|
||||
totalBytes?: number
|
||||
targetVersion?: string
|
||||
lastUpdatedAt: number
|
||||
}
|
||||
}) => void) => () => void
|
||||
}
|
||||
httpApi: {
|
||||
getStatus: () => Promise<{
|
||||
|
||||
@@ -63,6 +63,15 @@ export default defineConfig({
|
||||
rollupOptions: { external }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
entry: 'electron/mcp.ts',
|
||||
vite: {
|
||||
build: {
|
||||
outDir: 'dist-electron',
|
||||
rollupOptions: { external }
|
||||
}
|
||||
}
|
||||
}
|
||||
]),
|
||||
renderer()
|
||||
|
||||
Reference in New Issue
Block a user