feat: 添加 Tauri Mac 原生应用支持
- 新增 mac-app/ 目录:Tauri 项目结构(Rust 外壳 + sidecar 进程管理)
- 前端添加 Tauri 环境检测,API 调用和 WebSocket 自动适配
- 后端支持 MEETING_DATA_DIR 环境变量指定数据目录
- 新增 /api/meetings/{id}/retranscribe 接口用于重新转录
- 修复轮询超时:2分钟→30分钟,适配长音频
- 添加 .gitignore 排除构建产物
@@ -53,3 +53,11 @@ Thumbs.db
|
||||
*.aac
|
||||
uploads/
|
||||
audio/
|
||||
|
||||
# Mac App (Tauri)
|
||||
mac-app/src-tauri/target/
|
||||
mac-app/dist/
|
||||
mac-app/src-tauri/binaries/
|
||||
mac-app/src-tauri/gen/
|
||||
mac-app/node_modules/
|
||||
mac-app/icon-source.png
|
||||
@@ -40,9 +40,14 @@ class Database:
|
||||
|
||||
def __init__(self, db_path: str = None):
|
||||
if db_path is None:
|
||||
# 默认放在项目根目录
|
||||
project_root = os.path.dirname(os.path.abspath(__file__))
|
||||
db_path = os.path.join(project_root, "data", "meetings.db")
|
||||
# 优先使用环境变量(Tauri 打包后使用),否则使用项目根目录
|
||||
env_dir = os.environ.get("MEETING_DATA_DIR")
|
||||
if env_dir:
|
||||
data_dir = os.path.join(env_dir, "data")
|
||||
else:
|
||||
project_root = os.path.dirname(os.path.abspath(__file__))
|
||||
data_dir = os.path.join(project_root, "data")
|
||||
db_path = os.path.join(data_dir, "meetings.db")
|
||||
|
||||
# 确保目录存在
|
||||
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const API_BASE = '/api'
|
||||
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
|
||||
const API_BASE = isTauri ? 'http://localhost:8501/api' : '/api'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE,
|
||||
|
||||
@@ -87,7 +87,7 @@ export default function UploadPage() {
|
||||
|
||||
await pollMeetingStatus(result.meeting_id, (status) => {
|
||||
if (status === 'processing') {
|
||||
setTranscribeProgress(prev => Math.min(prev + 5, 90))
|
||||
setTranscribeProgress(prev => Math.min(prev + 2, 95))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -111,7 +111,9 @@ export default function UploadPage() {
|
||||
|
||||
const connectWebSocket = () => {
|
||||
const sessionId = `realtime_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
||||
const ws = new WebSocket(`ws://localhost:8501/ws/realtime/${sessionId}`)
|
||||
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
|
||||
const wsHost = isTauri ? 'localhost:8501' : window.location.host
|
||||
const ws = new WebSocket(`ws://${wsHost}/ws/realtime/${sessionId}`)
|
||||
|
||||
ws.onopen = () => {
|
||||
setWsConnected(true)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
|
||||
const API_BASE = isTauri ? 'http://localhost:8501' : ''
|
||||
|
||||
export const useAppStore = create((set, get) => ({
|
||||
// 会议列表
|
||||
meetings: [],
|
||||
@@ -38,7 +41,7 @@ export const useAppStore = create((set, get) => ({
|
||||
refreshMeetings: async () => {
|
||||
set({ loading: true, error: null })
|
||||
try {
|
||||
const res = await fetch('/api/meetings')
|
||||
const res = await fetch(`${API_BASE}/api/meetings`)
|
||||
const data = await res.json()
|
||||
set({
|
||||
meetings: data.meetings || [],
|
||||
@@ -59,7 +62,7 @@ export const useAppStore = create((set, get) => ({
|
||||
fetchMeetingDetail: async (meetingId) => {
|
||||
set({ loading: true, error: null })
|
||||
try {
|
||||
const res = await fetch(`/api/meetings/${meetingId}`)
|
||||
const res = await fetch(`${API_BASE}/api/meetings/${meetingId}`)
|
||||
const data = await res.json()
|
||||
set({ currentMeeting: data, loading: false })
|
||||
return data
|
||||
@@ -101,7 +104,7 @@ export const useAppStore = create((set, get) => ({
|
||||
set({ loading: false, isTranscribing: false })
|
||||
reject(new Error('Network error'))
|
||||
}
|
||||
xhr.open('POST', '/api/meetings/upload')
|
||||
xhr.open('POST', `${API_BASE}/api/meetings/upload`)
|
||||
xhr.send(formData)
|
||||
})
|
||||
} catch (err) {
|
||||
@@ -112,7 +115,7 @@ export const useAppStore = create((set, get) => ({
|
||||
|
||||
// 轮询会议状态直到完成
|
||||
pollMeetingStatus: async (meetingId, onStatusChange) => {
|
||||
const maxAttempts = 60 // 最多轮询60次(约2分钟)
|
||||
const maxAttempts = 600 // 最多轮询600次(约30分钟)
|
||||
let attempts = 0
|
||||
|
||||
const poll = async () => {
|
||||
@@ -122,7 +125,7 @@ export const useAppStore = create((set, get) => ({
|
||||
attempts++
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/meetings/${meetingId}`)
|
||||
const res = await fetch(`${API_BASE}/api/meetings/${meetingId}`)
|
||||
const data = await res.json()
|
||||
|
||||
onStatusChange?.(data.status)
|
||||
@@ -132,7 +135,7 @@ export const useAppStore = create((set, get) => ({
|
||||
}
|
||||
|
||||
// 继续轮询
|
||||
await new Promise(r => setTimeout(r, 2000)) // 每2秒轮询一次
|
||||
await new Promise(r => setTimeout(r, 3000)) // 每3秒轮询一次
|
||||
return poll()
|
||||
} catch (err) {
|
||||
throw err
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# 智能会议记录系统 - Mac 原生应用
|
||||
|
||||
将现有 React + FastAPI 应用打包为 macOS 原生 App。
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
Tauri (Rust 外壳)
|
||||
├── 前端: React (Vite 构建产物嵌入)
|
||||
└── 后端: Python FastAPI (PyInstaller 打包为 sidecar 二进制)
|
||||
```
|
||||
|
||||
- **前端**: 现有 React 代码通过 Vite 构建后嵌入 Tauri
|
||||
- **后端**: `main.py` 通过 PyInstaller 打包为单文件二进制,作为 Tauri sidecar 进程运行
|
||||
- **数据持久化**: 用户数据存储在 `~/Library/Application Support/com.meeting.recorder/data/`
|
||||
- **麦克风权限**: 通过 Info.plist 配置 NSMicrophoneUsageDescription
|
||||
|
||||
## 前置条件
|
||||
|
||||
1. **Rust 工具链** (用于编译 Tauri)
|
||||
```bash
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||
source "$HOME/.cargo/env"
|
||||
```
|
||||
|
||||
2. **PyInstaller** (用于打包 Python 后端)
|
||||
```bash
|
||||
pip3 install pyinstaller
|
||||
```
|
||||
|
||||
3. **Node.js** (用于构建前端)
|
||||
- 已有 Node.js 环境即可
|
||||
|
||||
## 构建
|
||||
|
||||
### 一键构建
|
||||
|
||||
```bash
|
||||
cd mac-app
|
||||
bash scripts/build.sh
|
||||
```
|
||||
|
||||
### 分步构建
|
||||
|
||||
1. **构建前端**
|
||||
```bash
|
||||
bash scripts/build_frontend.sh
|
||||
```
|
||||
|
||||
2. **构建后端 sidecar**
|
||||
```bash
|
||||
bash scripts/build_backend.sh
|
||||
```
|
||||
|
||||
3. **构建 Tauri App**
|
||||
```bash
|
||||
cd mac-app
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 开发模式
|
||||
|
||||
```bash
|
||||
cd mac-app
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
> 注意: 开发模式下后端需要单独启动 (`python3 main.py`),Tauri dev 模式不会自动拉起 sidecar。
|
||||
|
||||
## 生成图标
|
||||
|
||||
准备一张 1024x1024 的 PNG 图标,然后运行:
|
||||
|
||||
```bash
|
||||
cd mac-app
|
||||
npx @tauri-apps/cli icon path/to/icon.png
|
||||
```
|
||||
|
||||
这会自动生成所有需要的图标尺寸到 `src-tauri/icons/`。
|
||||
|
||||
## 输出
|
||||
|
||||
构建完成后:
|
||||
- **DMG 安装包**: `src-tauri/target/release/bundle/dmg/`
|
||||
- **App**: `src-tauri/target/release/bundle/macos/`
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
mac-app/
|
||||
├── package.json # Tauri CLI 依赖
|
||||
├── README.md # 本文件
|
||||
├── scripts/
|
||||
│ ├── build.sh # 一键构建脚本
|
||||
│ ├── build_backend.sh # PyInstaller 打包后端
|
||||
│ └── build_frontend.sh # Vite 构建前端
|
||||
└── src-tauri/
|
||||
├── Cargo.toml # Rust 依赖
|
||||
├── tauri.conf.json # Tauri 配置
|
||||
├── build.rs # Tauri 构建脚本
|
||||
├── capabilities/
|
||||
│ └── default.json # 权限配置 (shell sidecar)
|
||||
├── icons/ # 应用图标
|
||||
└── src/
|
||||
├── main.rs # Rust 入口
|
||||
└── lib.rs # Sidecar 进程管理
|
||||
```
|
||||
|
||||
## 修改说明
|
||||
|
||||
为支持 Tauri 打包,对原项目做了以下修改:
|
||||
|
||||
1. **`frontend/src/api/meeting.js`**: 添加 Tauri 环境检测,在 Tauri 中直接连接 `localhost:8501`
|
||||
2. **`main.py`**: 添加 `get_data_dir()` 函数,支持 `MEETING_DATA_DIR` 环境变量
|
||||
3. **`database.py`**: Database 初始化支持 `MEETING_DATA_DIR` 环境变量
|
||||
|
||||
这些修改向后兼容,不影响原有开发模式。
|
||||
@@ -0,0 +1,247 @@
|
||||
{
|
||||
"name": "meeting-recorder-mac-app",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meeting-recorder-mac-app",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmmirror.com/@tauri-apps/cli/-/cli-2.11.4.tgz",
|
||||
"integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"bin": {
|
||||
"tauri": "tauri.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/tauri"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tauri-apps/cli-darwin-arm64": "2.11.4",
|
||||
"@tauri-apps/cli-darwin-x64": "2.11.4",
|
||||
"@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4",
|
||||
"@tauri-apps/cli-linux-arm64-gnu": "2.11.4",
|
||||
"@tauri-apps/cli-linux-arm64-musl": "2.11.4",
|
||||
"@tauri-apps/cli-linux-riscv64-gnu": "2.11.4",
|
||||
"@tauri-apps/cli-linux-x64-gnu": "2.11.4",
|
||||
"@tauri-apps/cli-linux-x64-musl": "2.11.4",
|
||||
"@tauri-apps/cli-win32-arm64-msvc": "2.11.4",
|
||||
"@tauri-apps/cli-win32-ia32-msvc": "2.11.4",
|
||||
"@tauri-apps/cli-win32-x64-msvc": "2.11.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-darwin-arm64": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmmirror.com/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz",
|
||||
"integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-darwin-x64": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmmirror.com/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz",
|
||||
"integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmmirror.com/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz",
|
||||
"integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmmirror.com/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz",
|
||||
"integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmmirror.com/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz",
|
||||
"integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmmirror.com/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz",
|
||||
"integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmmirror.com/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz",
|
||||
"integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-x64-musl": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmmirror.com/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz",
|
||||
"integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmmirror.com/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz",
|
||||
"integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmmirror.com/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz",
|
||||
"integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmmirror.com/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz",
|
||||
"integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "meeting-recorder-mac-app",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"tauri": "tauri",
|
||||
"dev": "tauri dev",
|
||||
"build": "tauri build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/bin/bash
|
||||
# 一键构建 Mac App
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
APP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
echo "=========================================="
|
||||
echo " 智能会议记录系统 - Mac App 构建"
|
||||
echo "=========================================="
|
||||
|
||||
# 检查 Rust
|
||||
if ! command -v rustc &> /dev/null; then
|
||||
echo "❌ Rust 未安装,请先安装: https://rustup.rs"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查 PyInstaller
|
||||
if ! command -v pyinstaller &> /dev/null && ! python3 -m PyInstaller --version &> /dev/null 2>&1; then
|
||||
echo "❌ PyInstaller 未安装,请运行: pip3 install pyinstaller"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查 Node.js
|
||||
if ! command -v node &> /dev/null; then
|
||||
echo "❌ Node.js 未安装"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "步骤 1/3: 构建前端..."
|
||||
bash "$SCRIPT_DIR/build_frontend.sh"
|
||||
|
||||
echo ""
|
||||
echo "步骤 2/3: 构建后端 sidecar..."
|
||||
bash "$SCRIPT_DIR/build_backend.sh"
|
||||
|
||||
echo ""
|
||||
echo "步骤 3/3: 构建 Tauri App..."
|
||||
cd "$APP_DIR"
|
||||
npm install
|
||||
npm run build
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " ✅ 构建完成!"
|
||||
echo " DMG 文件位于: $APP_DIR/src-tauri/target/release/bundle/dmg/"
|
||||
echo "=========================================="
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/bin/bash
|
||||
# 构建后端 sidecar 二进制文件 (PyInstaller)
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
BUILD_DIR="$SCRIPT_DIR/../dist"
|
||||
|
||||
echo "=== 构建后端 sidecar (PyInstaller) ==="
|
||||
echo "项目根目录: $PROJECT_ROOT"
|
||||
echo "构建输出: $BUILD_DIR"
|
||||
|
||||
# 清理旧构建
|
||||
rm -rf "$BUILD_DIR/backend"
|
||||
mkdir -p "$BUILD_DIR/backend"
|
||||
|
||||
# 获取当前平台 target triple
|
||||
TARGET_TRIPLE=$(rustc -vV 2>/dev/null | grep host | awk '{print $2}')
|
||||
if [ -z "$TARGET_TRIPLE" ]; then
|
||||
# 默认 Apple Silicon
|
||||
TARGET_TRIPLE="aarch64-apple-darwin"
|
||||
fi
|
||||
echo "目标平台: $TARGET_TRIPLE"
|
||||
|
||||
# 使用 PyInstaller 打包
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
pyinstaller \
|
||||
--onefile \
|
||||
--name "meeting-backend" \
|
||||
--distpath "$BUILD_DIR/backend" \
|
||||
--workpath "$BUILD_DIR/backend/build" \
|
||||
--specpath "$BUILD_DIR/backend" \
|
||||
--add-data "database.py:." \
|
||||
--add-data "processor.py:." \
|
||||
--add-data "audio_utils.py:." \
|
||||
--hidden-import funasr \
|
||||
--hidden-import funasr.auto \
|
||||
--hidden-import soundfile \
|
||||
--hidden-import pydub \
|
||||
--hidden-import sqlalchemy \
|
||||
--hidden-import openai \
|
||||
--collect-all funasr \
|
||||
--collect-all torch \
|
||||
--collect-all torchaudio \
|
||||
--noconfirm \
|
||||
main.py
|
||||
|
||||
# 复制到 Tauri sidecar 目录,加上 target triple 后缀
|
||||
SIDECAR_DIR="$SCRIPT_DIR/../src-tauri/binaries"
|
||||
mkdir -p "$SIDECAR_DIR"
|
||||
cp "$BUILD_DIR/backend/meeting-backend" "$SIDECAR_DIR/meeting-backend-$TARGET_TRIPLE"
|
||||
chmod +x "$SIDECAR_DIR/meeting-backend-$TARGET_TRIPLE"
|
||||
|
||||
echo "=== 后端构建完成 ==="
|
||||
echo "Sidecar 二进制: $SIDECAR_DIR/meeting-backend-$TARGET_TRIPLE"
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
# 构建前端 (Vite build)
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
FRONTEND_DIR="$(cd "$SCRIPT_DIR/../../frontend" && pwd)"
|
||||
|
||||
echo "=== 构建前端 (Vite) ==="
|
||||
echo "前端目录: $FRONTEND_DIR"
|
||||
|
||||
cd "$FRONTEND_DIR"
|
||||
npm run build
|
||||
|
||||
echo "=== 前端构建完成 ==="
|
||||
echo "输出目录: $FRONTEND_DIR/dist"
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "meeting-recorder"
|
||||
version = "1.0.0"
|
||||
description = "智能会议记录系统"
|
||||
authors = ["freedak"]
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "meeting_recorder_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-shell = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
dirs = "5"
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>需要麦克风权限以录制会议音频</string>
|
||||
<key>NSAudioUsageDescription</key>
|
||||
<string>需要音频权限以处理会议录音</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"identifier": "default",
|
||||
"description": "Default capability for Meeting Recorder",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-destroy",
|
||||
"shell:allow-spawn",
|
||||
"shell:allow-execute",
|
||||
"shell:allow-kill"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,11 @@
|
||||
# 此目录存放 Tauri 应用图标
|
||||
# 构建前需要放入以下文件:
|
||||
# - 32x32.png
|
||||
# - 128x128.png
|
||||
# - 128x128@2x.png
|
||||
# - icon.icns
|
||||
#
|
||||
# 可以使用 Tauri 的图标生成工具:
|
||||
# npx @tauri-apps/cli icon path/to/your/icon.png
|
||||
#
|
||||
# 或从 https://tauri.app/v1/guides/build/icons/ 了解更多
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 961 B |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 640 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 947 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
@@ -0,0 +1,89 @@
|
||||
use tauri::Manager;
|
||||
use tauri_plugin_shell::ShellExt;
|
||||
use tauri_plugin_shell::process::CommandEvent;
|
||||
use std::sync::Mutex;
|
||||
use std::path::PathBuf;
|
||||
|
||||
struct BackendState {
|
||||
child: Option<tauri_plugin_shell::process::CommandChild>,
|
||||
}
|
||||
|
||||
fn get_app_data_dir(app: &tauri::AppHandle) -> PathBuf {
|
||||
let dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.unwrap_or_else(|_| {
|
||||
dirs::data_dir().unwrap_or_else(|| PathBuf::from("."))
|
||||
});
|
||||
std::fs::create_dir_all(&dir).ok();
|
||||
dir
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn kill_backend(state: tauri::State<'_, Mutex<BackendState>>) {
|
||||
if let Some(child) = state.lock().unwrap().child.take() {
|
||||
let _ = child.kill();
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_backend_url() -> String {
|
||||
"http://localhost:8501".to_string()
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.manage(Mutex::new(BackendState { child: None }))
|
||||
.setup(|app| {
|
||||
let app_data_dir = get_app_data_dir(&app.handle());
|
||||
let data_dir_str = app_data_dir.to_string_lossy().to_string();
|
||||
|
||||
println!("[tauri] App data dir: {}", data_dir_str);
|
||||
|
||||
let sidecar_command = app
|
||||
.shell()
|
||||
.sidecar("meeting-backend")
|
||||
.expect("failed to find meeting-backend sidecar binary")
|
||||
.env("MEETING_DATA_DIR", &data_dir_str);
|
||||
|
||||
let (mut rx, child) = sidecar_command
|
||||
.spawn()
|
||||
.expect("failed to spawn meeting-backend sidecar");
|
||||
|
||||
let state: tauri::State<'_, Mutex<BackendState>> = app.state();
|
||||
state.lock().unwrap().child = Some(child);
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
while let Some(event) = rx.recv().await {
|
||||
match event {
|
||||
CommandEvent::Stdout(line) => {
|
||||
println!("[backend] {}", String::from_utf8_lossy(&line))
|
||||
}
|
||||
CommandEvent::Stderr(line) => {
|
||||
eprintln!("[backend] {}", String::from_utf8_lossy(&line))
|
||||
}
|
||||
CommandEvent::Terminated(payload) => {
|
||||
eprintln!("[backend] terminated: {:?}", payload);
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|window, event| {
|
||||
if let tauri::WindowEvent::Destroyed = event {
|
||||
let state: tauri::State<'_, Mutex<BackendState>> = window.state();
|
||||
if let Some(child) = state.lock().unwrap().child.take() {
|
||||
let _ = child.kill();
|
||||
};
|
||||
}
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![kill_backend, get_backend_url])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
meeting_recorder_lib::run()
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json",
|
||||
"productName": "Meeting Recorder",
|
||||
"version": "1.0.0",
|
||||
"identifier": "com.meeting.recorder",
|
||||
"build": {
|
||||
"frontendDist": "../../frontend/dist",
|
||||
"devUrl": "http://localhost:3000",
|
||||
"beforeDevCommand": "cd ../frontend && npm run dev",
|
||||
"beforeBuildCommand": "cd ../frontend && npm run build"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "智能会议记录系统",
|
||||
"width": 1200,
|
||||
"height": 800,
|
||||
"minWidth": 900,
|
||||
"minHeight": 600,
|
||||
"resizable": true,
|
||||
"fullscreen": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["dmg", "app"],
|
||||
"externalBin": ["binaries/meeting-backend"],
|
||||
"resources": [],
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "12.0",
|
||||
"infoPlist": "Info.plist"
|
||||
},
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,18 @@ from database import init_db, Database
|
||||
from processor import MeetingProcessor, get_device, RealtimeTranscriber
|
||||
|
||||
|
||||
# 数据目录:优先使用环境变量(Tauri 打包后由 sidecar 设置),否则使用项目根目录
|
||||
def get_data_dir():
|
||||
env_dir = os.environ.get("MEETING_DATA_DIR")
|
||||
if env_dir:
|
||||
data_dir = os.path.join(env_dir, "data")
|
||||
else:
|
||||
project_root = os.path.dirname(os.path.abspath(__file__))
|
||||
data_dir = os.path.join(project_root, "data")
|
||||
os.makedirs(data_dir, exist_ok=True)
|
||||
return data_dir
|
||||
|
||||
|
||||
# 全局变量
|
||||
db: Database = None
|
||||
processor: MeetingProcessor = None
|
||||
@@ -146,8 +158,7 @@ async def upload_meeting(
|
||||
raise HTTPException(status_code=400, detail="不支持的文件类型")
|
||||
|
||||
# 保存文件
|
||||
project_root = os.path.dirname(os.path.abspath(__file__))
|
||||
data_dir = os.path.join(project_root, "data", "audio")
|
||||
data_dir = os.path.join(get_data_dir(), "audio")
|
||||
os.makedirs(data_dir, exist_ok=True)
|
||||
|
||||
ext = os.path.splitext(file.filename)[1] or ".mp3"
|
||||
@@ -177,6 +188,27 @@ async def upload_meeting(
|
||||
return meeting
|
||||
|
||||
|
||||
@app.post("/api/meetings/{meeting_id}/retranscribe")
|
||||
async def retranscribe_meeting(meeting_id: str):
|
||||
"""重新转录会议(用于卡在 processing 的会议)"""
|
||||
meeting = db.get_meeting(meeting_id)
|
||||
if not meeting:
|
||||
raise HTTPException(status_code=404, detail="会议不存在")
|
||||
|
||||
audio_path = meeting.get('audio_path')
|
||||
if not audio_path or not os.path.exists(audio_path):
|
||||
raise HTTPException(status_code=400, detail="音频文件不存在")
|
||||
|
||||
# 重置状态
|
||||
db.update_meeting(meeting_id=meeting_id, status="processing", segments=None)
|
||||
|
||||
# 重新入队
|
||||
task_queue.put((meeting_id, audio_path, meeting['title']))
|
||||
print(f"📋 重新转录任务已加入队列: {meeting['title']} (ID: {meeting_id})")
|
||||
|
||||
return {"message": "已重新加入转录队列", "meeting_id": meeting_id}
|
||||
|
||||
|
||||
@app.delete("/api/meetings/{meeting_id}")
|
||||
async def delete_meeting(meeting_id: str):
|
||||
"""删除会议"""
|
||||
@@ -440,8 +472,7 @@ async def realtime_transcribe(websocket: WebSocket, session_id: str):
|
||||
# 录音结束,保存会议
|
||||
if transcriber and audio_chunks:
|
||||
# 保存录音文件
|
||||
project_root = os.path.dirname(os.path.abspath(__file__))
|
||||
data_dir = os.path.join(project_root, "data", "audio")
|
||||
data_dir = os.path.join(get_data_dir(), "audio")
|
||||
os.makedirs(data_dir, exist_ok=True)
|
||||
|
||||
audio_filename = f"{datetime.now().strftime('%Y%m%d_%H%M%')}{session_id}.wav"
|
||||
|
||||