feat: 添加浅色主题支持
- 配置浅色主题配色方案和 CSS 变量 - 修复组件在浅色模式下的样式适配 - 统一文字颜色类名使用 CSS 变量 - 优化玻璃效果在浅色主题下的显示
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
# DeepSeek API Key
|
||||
DEEPSEEK_API_KEY=your-api-key-here
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
venv/
|
||||
.venv/
|
||||
env/
|
||||
.env
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
.npm
|
||||
.yarn
|
||||
dist/
|
||||
dist-ssr/
|
||||
*.local
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Environment
|
||||
.env.local
|
||||
.env.*.local
|
||||
@@ -0,0 +1,90 @@
|
||||
# 智能会议记录系统
|
||||
|
||||
基于 FunASR + DeepSeek AI 的智能会议记录系统,支持音频转录、自动摘要、Markdown 导出。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **前端**: React 18 + Vite + TailwindCSS + Zustand
|
||||
- **后端**: FastAPI + Uvicorn
|
||||
- **数据库**: SQLite + SQLAlchemy
|
||||
- **语音识别**: FunASR (SenseVoiceSmall)
|
||||
- **摘要生成**: DeepSeek API
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 安装后端依赖
|
||||
|
||||
```bash
|
||||
cd /Users/freedak/Documents/AIDashboard/meeting_recorder
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 2. 启动后端服务
|
||||
|
||||
```bash
|
||||
# 方式一:直接运行
|
||||
python main.py
|
||||
|
||||
# 方式二:使用 uvicorn
|
||||
uvicorn main:app --reload --port 8501
|
||||
```
|
||||
|
||||
### 3. 安装前端依赖并启动
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 4. 访问应用
|
||||
|
||||
- 前端: http://localhost:3000
|
||||
- API 文档: http://localhost:8501/docs
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
meeting_recorder/
|
||||
├── main.py # FastAPI 后端入口
|
||||
├── app.py # Streamlit 旧版界面(已弃用)
|
||||
├── database.py # 数据库模块
|
||||
├── processor.py # FunASR 处理器
|
||||
├── requirements.txt # Python 依赖
|
||||
├── data/
|
||||
│ ├── audio/ # 音频文件目录
|
||||
│ └── meetings.db # SQLite 数据库
|
||||
└── frontend/ # React 前端
|
||||
├── src/
|
||||
│ ├── components/ # UI 组件
|
||||
│ ├── pages/ # 页面组件
|
||||
│ ├── api/ # API 调用
|
||||
│ └── store/ # 状态管理
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## 功能特性
|
||||
|
||||
- [x] 音频文件上传
|
||||
- [x] FunASR 自动转录
|
||||
- [x] 说话人分离
|
||||
- [x] 音频播放与转写对照
|
||||
- [x] 会议摘要生成
|
||||
- [x] Markdown 导出
|
||||
- [x] 会议搜索与筛选
|
||||
- [ ] 实时录音
|
||||
- [ ] 批量处理
|
||||
|
||||
## 环境变量
|
||||
|
||||
可选配置 DeepSeek API:
|
||||
|
||||
```bash
|
||||
export DEEPSEEK_API_KEY="your-api-key"
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 默认使用 CPU 运行,如需 GPU 支持请安装 CUDA 环境
|
||||
- 音频文件大小建议不超过 500MB
|
||||
- 长音频转录可能需要几分钟时间
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
"""
|
||||
音频处理辅助工具
|
||||
支持格式转换、音频切片等
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from typing import Tuple, Optional
|
||||
import numpy as np
|
||||
|
||||
|
||||
def get_audio_info(audio_path: str) -> dict:
|
||||
"""
|
||||
获取音频文件信息
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
"duration": float, # 秒
|
||||
"sample_rate": int,
|
||||
"channels": int,
|
||||
"format": str
|
||||
}
|
||||
"""
|
||||
try:
|
||||
import soundfile as sf
|
||||
info = sf.info(audio_path)
|
||||
return {
|
||||
"duration": info.duration,
|
||||
"sample_rate": info.samplerate,
|
||||
"channels": info.channels,
|
||||
"format": info.format
|
||||
}
|
||||
except ImportError:
|
||||
# 降级方案:使用 ffprobe
|
||||
import json
|
||||
cmd = [
|
||||
"ffprobe", "-v", "quiet",
|
||||
"-print_format", "json",
|
||||
"-show_format", "-show_streams",
|
||||
audio_path
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
data = json.loads(result.stdout)
|
||||
|
||||
audio_stream = next((s for s in data.get("streams", []) if s.get("codec_type") == "audio"), {})
|
||||
duration = float(data.get("format", {}).get("duration", 0))
|
||||
|
||||
return {
|
||||
"duration": duration,
|
||||
"sample_rate": int(audio_stream.get("sample_rate", 16000)),
|
||||
"channels": int(audio_stream.get("channels", 1)),
|
||||
"format": audio_stream.get("codec_name", "unknown")
|
||||
}
|
||||
|
||||
|
||||
def convert_to_wav(input_path: str, output_path: Optional[str] = None, sample_rate: int = 16000) -> str:
|
||||
"""
|
||||
转换音频为 WAV 格式(16kHz 单声道)
|
||||
|
||||
Args:
|
||||
input_path: 输入文件路径
|
||||
output_path: 输出文件路径(可选)
|
||||
sample_rate: 采样率,默认 16000
|
||||
|
||||
Returns:
|
||||
输出文件路径
|
||||
"""
|
||||
if output_path is None:
|
||||
output_path = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name
|
||||
|
||||
cmd = [
|
||||
"ffmpeg", "-y", # 覆盖已存在的文件
|
||||
"-i", input_path,
|
||||
"-ar", str(sample_rate), # 采样率
|
||||
"-ac", "1", # 单声道
|
||||
"-acodec", "pcm_s16le", # 16bit PCM
|
||||
output_path
|
||||
]
|
||||
|
||||
subprocess.run(cmd, capture_output=True, check=True)
|
||||
return output_path
|
||||
|
||||
|
||||
def slice_audio(audio_path: str, start: float, end: float, output_path: Optional[str] = None) -> str:
|
||||
"""
|
||||
切片音频
|
||||
|
||||
Args:
|
||||
audio_path: 输入文件路径
|
||||
start: 开始时间(秒)
|
||||
end: 结束时间(秒)
|
||||
output_path: 输出文件路径
|
||||
|
||||
Returns:
|
||||
输出文件路径
|
||||
"""
|
||||
if output_path is None:
|
||||
output_path = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name
|
||||
|
||||
cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-i", audio_path,
|
||||
"-ss", str(start),
|
||||
"-to", str(end),
|
||||
"-c", "copy", # 无损复制
|
||||
output_path
|
||||
]
|
||||
|
||||
subprocess.run(cmd, capture_output=True, check=True)
|
||||
return output_path
|
||||
|
||||
|
||||
def merge_audio(audio_paths: list, output_path: Optional[str] = None) -> str:
|
||||
"""
|
||||
合并多个音频文件
|
||||
|
||||
Args:
|
||||
audio_paths: 音频文件路径列表
|
||||
output_path: 输出文件路径
|
||||
|
||||
Returns:
|
||||
输出文件路径
|
||||
"""
|
||||
if output_path is None:
|
||||
output_path = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name
|
||||
|
||||
# 创建临时文件列表
|
||||
list_file = tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False)
|
||||
for path in audio_paths:
|
||||
list_file.write(f"file '{path}'\n")
|
||||
list_file.close()
|
||||
|
||||
cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-f", "concat",
|
||||
"-safe", "0",
|
||||
"-i", list_file.name,
|
||||
"-c", "copy",
|
||||
output_path
|
||||
]
|
||||
|
||||
subprocess.run(cmd, capture_output=True, check=True)
|
||||
|
||||
# 清理临时文件
|
||||
os.unlink(list_file.name)
|
||||
|
||||
return output_path
|
||||
|
||||
|
||||
def is_ffmpeg_available() -> bool:
|
||||
"""检查 ffmpeg 是否可用"""
|
||||
try:
|
||||
subprocess.run(["ffmpeg", "-version"], capture_output=True, check=True)
|
||||
return True
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 测试
|
||||
print("FFmpeg 可用:", is_ffmpeg_available())
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+226
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
会议记录数据库 - SQLite 存储
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Dict
|
||||
from contextlib import contextmanager
|
||||
|
||||
from sqlalchemy import create_engine, Column, String, Float, Integer, DateTime, JSON, Text
|
||||
from sqlalchemy.orm import sessionmaker, declarative_base
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
class Meeting(Base):
|
||||
"""会议表"""
|
||||
__tablename__ = "meetings"
|
||||
|
||||
meeting_id = Column(String, primary_key=True)
|
||||
title = Column(String, nullable=False)
|
||||
date = Column(DateTime, default=datetime.now)
|
||||
duration = Column(Float, default=0) # 秒
|
||||
audio_path = Column(String, nullable=True) # 音频文件路径
|
||||
transcript_path = Column(String, nullable=True) # 转录文件路径
|
||||
speaker_count = Column(Integer, default=0)
|
||||
status = Column(String, default="pending") # pending, processing, completed, failed
|
||||
segments_json = Column(Text, nullable=True) # 存储片段 JSON
|
||||
brief_summary = Column(Text, nullable=True) # 简要摘要
|
||||
detailed_summary = Column(Text, nullable=True) # 详细摘要
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
|
||||
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")
|
||||
|
||||
# 确保目录存在
|
||||
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||
|
||||
self.db_path = db_path
|
||||
self.engine = create_engine(
|
||||
f"sqlite:///{db_path}",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool
|
||||
)
|
||||
Base.metadata.create_all(self.engine)
|
||||
self.Session = sessionmaker(bind=self.engine)
|
||||
|
||||
@contextmanager
|
||||
def get_session(self):
|
||||
"""获取数据库会话"""
|
||||
session = self.Session()
|
||||
try:
|
||||
yield session
|
||||
session.commit()
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def create_meeting(
|
||||
self,
|
||||
meeting_id: str,
|
||||
title: str,
|
||||
duration: float = 0,
|
||||
audio_path: str = None,
|
||||
status: str = "pending"
|
||||
) -> Meeting:
|
||||
"""创建会议记录"""
|
||||
meeting = Meeting(
|
||||
meeting_id=meeting_id,
|
||||
title=title,
|
||||
duration=duration,
|
||||
audio_path=audio_path,
|
||||
status=status
|
||||
)
|
||||
with self.get_session() as session:
|
||||
session.add(meeting)
|
||||
return meeting
|
||||
|
||||
def update_meeting(
|
||||
self,
|
||||
meeting_id: str,
|
||||
segments: List[Dict] = None,
|
||||
status: str = None,
|
||||
transcript_path: str = None,
|
||||
speaker_count: int = None,
|
||||
duration: float = None,
|
||||
brief_summary: str = None,
|
||||
detailed_summary: str = None,
|
||||
title: str = None
|
||||
):
|
||||
"""更新会议记录"""
|
||||
with self.get_session() as session:
|
||||
meeting = session.query(Meeting).filter(Meeting.meeting_id == meeting_id).first()
|
||||
if meeting:
|
||||
if title is not None:
|
||||
meeting.title = title
|
||||
if segments is not None:
|
||||
meeting.segments_json = json.dumps(segments, ensure_ascii=False)
|
||||
if status is not None:
|
||||
meeting.status = status
|
||||
if transcript_path is not None:
|
||||
meeting.transcript_path = transcript_path
|
||||
if speaker_count is not None:
|
||||
meeting.speaker_count = speaker_count
|
||||
if duration is not None:
|
||||
meeting.duration = duration
|
||||
if brief_summary is not None:
|
||||
meeting.brief_summary = brief_summary
|
||||
if detailed_summary is not None:
|
||||
meeting.detailed_summary = detailed_summary
|
||||
|
||||
def get_meeting(self, meeting_id: str) -> Optional[Dict]:
|
||||
"""获取单个会议记录"""
|
||||
with self.get_session() as session:
|
||||
meeting = session.query(Meeting).filter(Meeting.meeting_id == meeting_id).first()
|
||||
if meeting:
|
||||
return self._meeting_to_dict(meeting)
|
||||
return None
|
||||
|
||||
def get_all_meetings(self, limit: int = 100) -> List[Dict]:
|
||||
"""获取所有会议记录"""
|
||||
with self.get_session() as session:
|
||||
meetings = session.query(Meeting).order_by(Meeting.created_at.desc()).limit(limit).all()
|
||||
return [self._meeting_to_dict(m) for m in meetings]
|
||||
|
||||
def search_meetings(self, keyword: str) -> List[Dict]:
|
||||
"""搜索会议记录"""
|
||||
with self.get_session() as session:
|
||||
meetings = session.query(Meeting).filter(
|
||||
Meeting.title.like(f"%{keyword}%")
|
||||
).order_by(Meeting.created_at.desc()).all()
|
||||
return [self._meeting_to_dict(m) for m in meetings]
|
||||
|
||||
def delete_meeting(self, meeting_id: str):
|
||||
"""删除会议记录"""
|
||||
with self.get_session() as session:
|
||||
meeting = session.query(Meeting).filter(Meeting.meeting_id == meeting_id).first()
|
||||
if meeting:
|
||||
session.delete(meeting)
|
||||
|
||||
def _meeting_to_dict(self, meeting: Meeting) -> Dict:
|
||||
"""转换为字典"""
|
||||
segments = []
|
||||
if meeting.segments_json:
|
||||
try:
|
||||
segments = json.loads(meeting.segments_json)
|
||||
except json.JSONDecodeError:
|
||||
segments = []
|
||||
|
||||
return {
|
||||
"meeting_id": meeting.meeting_id,
|
||||
"title": meeting.title,
|
||||
"date": meeting.date.isoformat() if meeting.date else None,
|
||||
"duration": meeting.duration,
|
||||
"audio_path": meeting.audio_path,
|
||||
"transcript_path": meeting.transcript_path,
|
||||
"speaker_count": meeting.speaker_count,
|
||||
"status": meeting.status,
|
||||
"segments": segments,
|
||||
"brief_summary": meeting.brief_summary,
|
||||
"detailed_summary": meeting.detailed_summary,
|
||||
"created_at": meeting.created_at.isoformat() if meeting.created_at else None,
|
||||
"updated_at": meeting.updated_at.isoformat() if meeting.updated_at else None
|
||||
}
|
||||
|
||||
def get_meetings_by_date(self, date: str) -> List[Dict]:
|
||||
"""按日期获取会议"""
|
||||
with self.get_session() as session:
|
||||
meetings = session.query(Meeting).filter(
|
||||
Meeting.date >= datetime.fromisoformat(date),
|
||||
Meeting.date < datetime.fromisoformat(date + "T23:59:59")
|
||||
).order_by(Meeting.date.desc()).all()
|
||||
return [self._meeting_to_dict(m) for m in meetings]
|
||||
|
||||
|
||||
"""数据库单例实例"""
|
||||
_db_instance = None
|
||||
|
||||
def init_db(db_path: str = None) -> Database:
|
||||
"""初始化数据库(单例模式)"""
|
||||
global _db_instance
|
||||
if _db_instance is None:
|
||||
_db_instance = Database(db_path)
|
||||
return _db_instance
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 测试
|
||||
db = init_db(":memory:")
|
||||
|
||||
# 创建测试数据
|
||||
meeting_id = "test_001"
|
||||
db.create_meeting(
|
||||
meeting_id=meeting_id,
|
||||
title="测试会议",
|
||||
duration=300
|
||||
)
|
||||
|
||||
db.update_meeting(
|
||||
meeting_id=meeting_id,
|
||||
segments=[
|
||||
{"start": 0, "end": 10, "speaker": 0, "text": "测试文本1"},
|
||||
{"start": 10, "end": 20, "speaker": 1, "text": "测试文本2"}
|
||||
],
|
||||
status="completed",
|
||||
speaker_count=2
|
||||
)
|
||||
|
||||
# 查询
|
||||
meeting = db.get_meeting(meeting_id)
|
||||
print(f"会议: {meeting['title']}")
|
||||
print(f"状态: {meeting['status']}")
|
||||
print(f"片段数: {len(meeting['segments'])}")
|
||||
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>智能会议记录系统</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body class="bg-dark-400 text-slate-100">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3104
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "meeting-recorder-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"axios": "^1.6.2",
|
||||
"zustand": "^4.4.7",
|
||||
"lucide-react": "^0.294.0",
|
||||
"clsx": "^2.0.0",
|
||||
"date-fns": "^2.30.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.3.6",
|
||||
"vite": "^5.0.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Routes, Route } from 'react-router-dom'
|
||||
import Layout from './components/Layout'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import Meetings from './pages/Meetings'
|
||||
import MeetingDetail from './pages/MeetingDetail'
|
||||
import Upload from './pages/Upload'
|
||||
import Settings from './pages/Settings'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/meetings" element={<Meetings />} />
|
||||
<Route path="/meetings/:id" element={<MeetingDetail />} />
|
||||
<Route path="/upload" element={<Upload />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const API_BASE = '/api'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE,
|
||||
timeout: 300000, // 5分钟超时
|
||||
})
|
||||
|
||||
// 获取所有会议
|
||||
export const getMeetings = async () => {
|
||||
const res = await api.get('/meetings')
|
||||
return res.data
|
||||
}
|
||||
|
||||
// 获取单个会议
|
||||
export const getMeeting = async (meetingId) => {
|
||||
const res = await api.get(`/meetings/${meetingId}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
// 上传会议
|
||||
export const uploadMeeting = (file, title, onProgress) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('title', title)
|
||||
|
||||
return api.post('/meetings/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
onUploadProgress: (progressEvent) => {
|
||||
const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total)
|
||||
onProgress?.(percent)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 删除会议
|
||||
export const deleteMeeting = async (meetingId) => {
|
||||
const res = await api.delete(`/meetings/${meetingId}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
// 生成摘要
|
||||
export const generateSummary = async (meetingId) => {
|
||||
const res = await api.post(`/meetings/${meetingId}/summarize`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
// 更新会议标题
|
||||
export const updateMeetingTitle = async (meetingId, title) => {
|
||||
const formData = new FormData()
|
||||
formData.append('title', title)
|
||||
const res = await api.patch(`/meetings/${meetingId}`, formData)
|
||||
return res.data
|
||||
}
|
||||
|
||||
// 搜索会议
|
||||
export const searchMeetings = async (keyword) => {
|
||||
const res = await api.get('/meetings/search', { params: { keyword } })
|
||||
return res.data
|
||||
}
|
||||
|
||||
// 下载 Markdown
|
||||
export const downloadMarkdown = (meeting) => {
|
||||
let content = `# ${meeting.title}\n\n`
|
||||
content += `**日期**: ${meeting.date?.slice(0, 10) || '未知'}\n`
|
||||
content += `**时长**: ${formatDuration(meeting.duration)}\n`
|
||||
content += `**说话人数**: ${meeting.speaker_count || 0} 人\n\n`
|
||||
|
||||
if (meeting.brief_summary) {
|
||||
content += `## 📋 会议摘要\n\n${meeting.brief_summary}\n\n`
|
||||
}
|
||||
|
||||
if (meeting.detailed_summary) {
|
||||
content += `## 📝 详细纪要\n\n${meeting.detailed_summary}\n\n`
|
||||
}
|
||||
|
||||
content += `---\n\n## 🎤 转写内容\n\n`
|
||||
|
||||
let currentSpeaker = null
|
||||
meeting.segments?.forEach(seg => {
|
||||
if (seg.speaker !== currentSpeaker) {
|
||||
content += `### 说话人 ${seg.speaker}\n\n`
|
||||
currentSpeaker = seg.speaker
|
||||
}
|
||||
content += `[${formatTimestamp(seg.start)}] ${seg.text}\n\n`
|
||||
})
|
||||
|
||||
content += `---\n\n*本记录由 FunASR 自动生成*`
|
||||
|
||||
const blob = new Blob([content], { type: 'text/markdown' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${meeting.title}.md`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
// 工具函数
|
||||
export const formatDuration = (seconds = 0) => {
|
||||
if (seconds < 60) return `${seconds.toFixed(0)}秒`
|
||||
if (seconds < 3600) {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${mins}分${secs}秒`
|
||||
}
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const mins = Math.floor((seconds % 3600) / 60)
|
||||
return `${hours}小时${mins}分`
|
||||
}
|
||||
|
||||
export const formatTimestamp = (seconds = 0) => {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export default api
|
||||
@@ -0,0 +1,54 @@
|
||||
import clsx from 'clsx'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
const variants = {
|
||||
primary: [
|
||||
'bg-accent/20 text-accent border-accent/30 hover:bg-accent/30 hover:border-accent/50',
|
||||
],
|
||||
secondary: [
|
||||
'bg-black/5 dark:bg-white/10 text-primary border-black/10 dark:border-white/10 hover:bg-black/10 dark:hover:bg-white/15',
|
||||
],
|
||||
ghost: [
|
||||
'bg-transparent text-secondary border-transparent hover:bg-black/5 dark:hover:bg-white/10 hover:text-primary',
|
||||
],
|
||||
danger: [
|
||||
'bg-error/15 text-error border-error/30 hover:bg-error/25 hover:border-error/50',
|
||||
],
|
||||
}
|
||||
|
||||
const sizes = {
|
||||
sm: 'px-3 py-1.5 text-sm',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-5 py-2.5 text-base',
|
||||
}
|
||||
|
||||
export default function Button({
|
||||
children,
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
className,
|
||||
loading,
|
||||
disabled,
|
||||
...props
|
||||
}) {
|
||||
const baseClass = variants[variant]?.[0] || variants.primary[0]
|
||||
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
'inline-flex items-center justify-center gap-2 font-medium rounded-lg border transition-all duration-200',
|
||||
'disabled:opacity-40 disabled:cursor-not-allowed',
|
||||
baseClass,
|
||||
sizes[size],
|
||||
className
|
||||
)}
|
||||
disabled={disabled || loading}
|
||||
{...props}
|
||||
>
|
||||
{loading && (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
)}
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { LayoutDashboard, FileText, Plus, Settings, Mic } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import ThemeToggle from './ThemeToggle'
|
||||
|
||||
const navItems = [
|
||||
{ path: '/', icon: LayoutDashboard, label: '概览' },
|
||||
{ path: '/meetings', icon: FileText, label: '会议' },
|
||||
{ path: '/upload', icon: Plus, label: '新建' },
|
||||
{ path: '/settings', icon: Settings, label: '设置' },
|
||||
]
|
||||
|
||||
export default function Layout({ children }) {
|
||||
const location = useLocation()
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-primary flex">
|
||||
{/* 侧边栏 */}
|
||||
<aside className="w-56 flex flex-col h-screen sticky top-0 border-r border-theme">
|
||||
{/* Logo 区域 */}
|
||||
<div className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-accent to-accent-muted flex items-center justify-center shadow-glow">
|
||||
<Mic className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-sm font-semibold text-primary tracking-tight">会议记录</h1>
|
||||
<p className="text-[10px] text-muted">智能转录系统</p>
|
||||
</div>
|
||||
</div>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 导航 */}
|
||||
<nav className="flex-1 px-3 space-y-0.5">
|
||||
{navItems.map(({ path, icon: Icon, label }) => {
|
||||
const isActive = location.pathname === path
|
||||
return (
|
||||
<Link
|
||||
key={path}
|
||||
to={path}
|
||||
className={clsx(
|
||||
'flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm transition-all duration-200',
|
||||
isActive
|
||||
? 'bg-accent/15 text-accent font-medium'
|
||||
: 'text-secondary hover:text-primary hover:bg-black/5 dark:hover:bg-white/5'
|
||||
)}
|
||||
>
|
||||
<Icon className={clsx('w-4 h-4', isActive && 'text-accent')} />
|
||||
<span>{label}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* 底部 */}
|
||||
<div className="p-4 border-t border-theme">
|
||||
<div className="text-[10px] text-muted text-center">
|
||||
FunASR + DeepSeek
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* 主内容区 */}
|
||||
<main className="flex-1 min-h-screen">
|
||||
<div className="p-8">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
Calendar, Clock, Users, FileText, CheckCircle2, Loader2, AlertCircle
|
||||
} from 'lucide-react'
|
||||
import { formatDuration } from '../api/meeting'
|
||||
|
||||
const statusConfig = {
|
||||
pending: {
|
||||
label: '待处理',
|
||||
class: 'bg-warning/10 text-warning border-warning/20',
|
||||
icon: AlertCircle
|
||||
},
|
||||
processing: {
|
||||
label: '处理中',
|
||||
class: 'bg-info/10 text-info border-info/20',
|
||||
icon: Loader2
|
||||
},
|
||||
completed: {
|
||||
label: '已完成',
|
||||
class: 'bg-success/10 text-success border-success/20',
|
||||
icon: CheckCircle2
|
||||
},
|
||||
failed: {
|
||||
label: '失败',
|
||||
class: 'bg-error/10 text-error border-error/20',
|
||||
icon: AlertCircle
|
||||
},
|
||||
}
|
||||
|
||||
export default function MeetingCard({ meeting, onClick }) {
|
||||
const status = statusConfig[meeting.status] || statusConfig.pending
|
||||
const StatusIcon = status.icon
|
||||
const hasSummary = meeting.brief_summary || meeting.detailed_summary
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => onClick?.(meeting)}
|
||||
className="glass-card rounded-xl p-4 cursor-pointer
|
||||
hover:glass-card-hover transition-all duration-300
|
||||
group"
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base font-medium text-primary truncate
|
||||
group-hover:text-accent transition-colors">
|
||||
{meeting.title}
|
||||
</h3>
|
||||
{hasSummary && meeting.status === 'completed' && (
|
||||
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5
|
||||
rounded bg-accent/10 text-accent mt-1.5">
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
有摘要
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={clsx(
|
||||
'inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-medium border',
|
||||
status.class
|
||||
)}>
|
||||
<StatusIcon className="w-3.5 h-3.5" />
|
||||
{status.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 元信息 */}
|
||||
<div className="flex items-center gap-5 text-sm text-muted">
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="w-3.5 h-3.5" />
|
||||
{meeting.date?.slice(0, 10) || '未知'}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
{formatDuration(meeting.duration)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="w-3.5 h-3.5" />
|
||||
{meeting.speaker_count || 0} 人
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
{meeting.status === 'processing' && (
|
||||
<div className="mt-3">
|
||||
<div className="h-0.5 bg-black/5 dark:bg-white/10 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-accent rounded-full animate-pulse w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import clsx from 'clsx'
|
||||
import { FileText, CheckCircle2, Loader2, Timer } from 'lucide-react'
|
||||
import { formatDuration } from '../api/meeting'
|
||||
|
||||
const config = {
|
||||
total: {
|
||||
icon: FileText,
|
||||
color: 'text-accent',
|
||||
bgColor: 'bg-accent/10',
|
||||
},
|
||||
completed: {
|
||||
icon: CheckCircle2,
|
||||
color: 'text-success',
|
||||
bgColor: 'bg-success/10',
|
||||
},
|
||||
processing: {
|
||||
icon: Loader2,
|
||||
color: 'text-warning',
|
||||
bgColor: 'bg-warning/10',
|
||||
},
|
||||
duration: {
|
||||
icon: Timer,
|
||||
color: 'text-info',
|
||||
bgColor: 'bg-info/10',
|
||||
},
|
||||
}
|
||||
|
||||
export default function StatsCard({ type, value, label }) {
|
||||
const { icon: Icon, color, bgColor } = config[type] || config.total
|
||||
|
||||
return (
|
||||
<div className="glass-card rounded-xl p-5 hover:glass-card-hover transition-all duration-300">
|
||||
<div className={clsx(
|
||||
'w-10 h-10 rounded-lg flex items-center justify-center mb-4',
|
||||
bgColor
|
||||
)}>
|
||||
<Icon className={clsx('w-5 h-5', color)} />
|
||||
</div>
|
||||
|
||||
<div className="text-2xl font-semibold text-primary tracking-tight mb-1">
|
||||
{type === 'duration' ? formatDuration(value) : value}
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted">
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Sun, Moon } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const [isDark, setIsDark] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
// 从 localStorage 读取主题
|
||||
const saved = localStorage.getItem('theme')
|
||||
if (saved) {
|
||||
setIsDark(saved === 'dark')
|
||||
document.documentElement.classList.toggle('light', saved === 'light')
|
||||
} else {
|
||||
// 检测系统偏好
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
setIsDark(prefersDark)
|
||||
if (!prefersDark) {
|
||||
document.documentElement.classList.add('light')
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const toggleTheme = () => {
|
||||
const newIsDark = !isDark
|
||||
setIsDark(newIsDark)
|
||||
document.documentElement.classList.toggle('light', !newIsDark)
|
||||
localStorage.setItem('theme', newIsDark ? 'dark' : 'light')
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 rounded-lg transition-all duration-200 hover:bg-white/10 text-secondary hover:text-primary"
|
||||
title={isDark ? '切换到浅色模式' : '切换到深色模式'}
|
||||
>
|
||||
{isDark ? (
|
||||
<Sun className="w-4 h-4" />
|
||||
) : (
|
||||
<Moon className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
||||
import Layout from './components/Layout'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import Meetings from './pages/Meetings'
|
||||
import MeetingDetail from './pages/MeetingDetail'
|
||||
import Upload from './pages/Upload'
|
||||
import Settings from './pages/Settings'
|
||||
import './styles/index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter
|
||||
future={{
|
||||
v7_startTransition: true,
|
||||
v7_relativeSplatPath: true,
|
||||
}}
|
||||
>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/meetings" element={<Meetings />} />
|
||||
<Route path="/meetings/:id" element={<MeetingDetail />} />
|
||||
<Route path="/upload" element={<Upload />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useEffect } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { ArrowRight, Plus, FileText } from 'lucide-react'
|
||||
import { useAppStore } from '../store/useAppStore'
|
||||
import StatsCard from '../components/StatsCard'
|
||||
import MeetingCard from '../components/MeetingCard'
|
||||
import Button from '../components/Button'
|
||||
|
||||
export default function Dashboard() {
|
||||
const navigate = useNavigate()
|
||||
const { meetings, stats, loading, refreshMeetings } = useAppStore()
|
||||
|
||||
useEffect(() => {
|
||||
refreshMeetings()
|
||||
}, [])
|
||||
|
||||
const recentMeetings = meetings.slice(0, 4)
|
||||
|
||||
const handleCardClick = (meeting) => {
|
||||
navigate(`/meetings/${meeting.meeting_id}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="animate-in">
|
||||
{/* 页面标题 */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-semibold text-primary tracking-tight">仪表盘</h1>
|
||||
<p className="text-sm text-muted mt-1">会议记录概览</p>
|
||||
</div>
|
||||
<Link to="/upload">
|
||||
<Button size="sm">
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
新建
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-8">
|
||||
<StatsCard type="total" value={stats.total} label="总会议数" />
|
||||
<StatsCard type="completed" value={stats.completed} label="已完成" />
|
||||
<StatsCard type="processing" value={stats.processing} label="处理中" />
|
||||
<StatsCard type="duration" value={stats.totalDuration} label="总时长" />
|
||||
</div>
|
||||
|
||||
{/* 最近会议 */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-sm font-medium text-secondary flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-accent" />
|
||||
最近会议
|
||||
</h2>
|
||||
<Link
|
||||
to="/meetings"
|
||||
className="flex items-center gap-1 text-xs text-accent hover:text-accent-hover transition-colors"
|
||||
>
|
||||
查看全部
|
||||
<ArrowRight className="w-3.5 h-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{[1, 2, 3, 4].map(i => (
|
||||
<div key={i} className="h-24 rounded-xl skeleton" />
|
||||
))}
|
||||
</div>
|
||||
) : recentMeetings.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{recentMeetings.map(meeting => (
|
||||
<MeetingCard key={meeting.meeting_id} meeting={meeting} onClick={handleCardClick} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="glass-card rounded-xl p-8 text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-3 rounded-xl bg-white/[0.04] dark:bg-white/[0.04] flex items-center justify-center">
|
||||
<FileText className="w-5 h-5 text-muted" />
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-primary mb-1">暂无会议记录</h3>
|
||||
<p className="text-xs text-muted mb-4">开始您的第一次会议转录</p>
|
||||
<Link to="/upload">
|
||||
<Button size="sm">
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
新建会议
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
ArrowLeft, Download, Calendar, Clock, Users, Search,
|
||||
FileText, AlignLeft, Loader2, User, Sparkles, Edit2, Check, X,
|
||||
ChevronDown, Copy, CheckCheck
|
||||
} from 'lucide-react'
|
||||
import { useAppStore } from '../store/useAppStore'
|
||||
import { getMeeting, downloadMarkdown, formatDuration, formatTimestamp, generateSummary, updateMeetingTitle } from '../api/meeting'
|
||||
import Button from '../components/Button'
|
||||
|
||||
export default function MeetingDetail() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { currentMeeting, loading, setCurrentMeeting, refreshMeetings } = useAppStore()
|
||||
const [search, setSearch] = useState('')
|
||||
const [summarizing, setSummarizing] = useState(false)
|
||||
const [editingTitle, setEditingTitle] = useState(false)
|
||||
const [titleInput, setTitleInput] = useState('')
|
||||
const [summaryCollapsed, setSummaryCollapsed] = useState(false)
|
||||
const [briefCopied, setBriefCopied] = useState(false)
|
||||
const [detailedCopied, setDetailedCopied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadMeeting()
|
||||
}, [id])
|
||||
|
||||
const loadMeeting = async () => {
|
||||
const data = await getMeeting(id)
|
||||
setCurrentMeeting(data)
|
||||
}
|
||||
|
||||
const handleGenerateSummary = async () => {
|
||||
setSummarizing(true)
|
||||
try {
|
||||
const updated = await generateSummary(id)
|
||||
setCurrentMeeting(updated)
|
||||
} catch (err) {
|
||||
alert('生成摘要失败: ' + err.message)
|
||||
} finally {
|
||||
setSummarizing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditTitle = () => {
|
||||
setTitleInput(currentMeeting?.title || '')
|
||||
setEditingTitle(true)
|
||||
}
|
||||
|
||||
const handleSaveTitle = async () => {
|
||||
if (!titleInput.trim()) return
|
||||
try {
|
||||
const updated = await updateMeetingTitle(id, titleInput.trim())
|
||||
setCurrentMeeting(updated)
|
||||
refreshMeetings()
|
||||
} catch (err) {
|
||||
alert('保存失败: ' + err.message)
|
||||
}
|
||||
setEditingTitle(false)
|
||||
}
|
||||
|
||||
const handleCancelTitle = () => {
|
||||
setEditingTitle(false)
|
||||
setTitleInput('')
|
||||
}
|
||||
|
||||
const copyToClipboard = async (text, type) => {
|
||||
await navigator.clipboard.writeText(text)
|
||||
if (type === 'brief') {
|
||||
setBriefCopied(true)
|
||||
setTimeout(() => setBriefCopied(false), 2000)
|
||||
} else {
|
||||
setDetailedCopied(true)
|
||||
setTimeout(() => setDetailedCopied(false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading || !currentMeeting) {
|
||||
return (
|
||||
<div className="animate-in">
|
||||
<div className="h-6 w-24 skeleton rounded mb-6" />
|
||||
<div className="h-40 skeleton rounded-xl mb-4" />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="h-32 skeleton rounded-xl" />
|
||||
<div className="h-32 skeleton rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meeting = currentMeeting
|
||||
const segments = meeting.segments || []
|
||||
const filteredSegments = search
|
||||
? segments.filter(s => s.text.toLowerCase().includes(search.toLowerCase()))
|
||||
: segments
|
||||
|
||||
return (
|
||||
<div className="animate-in">
|
||||
{/* 返回按钮 */}
|
||||
<button
|
||||
onClick={() => navigate('/meetings')}
|
||||
className="flex items-center gap-1.5 text-xs text-muted hover:text-primary mb-5 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
返回
|
||||
</button>
|
||||
|
||||
{/* 详情头部 */}
|
||||
<div className="glass-card rounded-xl p-5 mb-4 group">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex-1">
|
||||
{editingTitle ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={titleInput}
|
||||
onChange={(e) => setTitleInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSaveTitle()}
|
||||
className="text-lg font-semibold text-primary glass-input rounded-lg px-3 py-1 focus:outline-none w-full max-w-sm"
|
||||
autoFocus
|
||||
/>
|
||||
<button onClick={handleSaveTitle} className="p-1.5 text-success hover:text-success/80 transition-colors">
|
||||
<Check className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={handleCancelTitle} className="p-1.5 text-error hover:text-error/80 transition-colors">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-lg font-semibold text-primary">{meeting.title}</h1>
|
||||
<button
|
||||
onClick={handleEditTitle}
|
||||
className="p-1 text-muted hover:text-accent transition-colors opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<Edit2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-4 text-xs text-muted mt-2">
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
{meeting.date?.slice(0, 10) || '未知'}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatDuration(meeting.duration)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="w-3 h-3" />
|
||||
{meeting.speaker_count || 0} 人
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{meeting.status === 'completed' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={handleGenerateSummary}
|
||||
disabled={summarizing}
|
||||
variant={meeting.brief_summary ? 'secondary' : 'primary'}
|
||||
size="sm"
|
||||
>
|
||||
{summarizing ? (
|
||||
<>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
生成中
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
{meeting.brief_summary ? '重新生成' : '抓取摘要'}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button onClick={() => downloadMarkdown(meeting)} variant="secondary" size="sm">
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 音频播放器 */}
|
||||
{meeting.audio_path && meeting.status === 'completed' && (
|
||||
<div className="mt-3">
|
||||
<audio controls className="w-full" src={`/api/audio/${meeting.meeting_id}`}>
|
||||
您的浏览器不支持音频播放
|
||||
</audio>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 处理中状态 */}
|
||||
{meeting.status === 'processing' && (
|
||||
<div className="glass-card rounded-xl p-8 text-center mb-4">
|
||||
<div className="w-12 h-12 mx-auto mb-3 rounded-xl bg-info/10 flex items-center justify-center">
|
||||
<Loader2 className="w-5 h-5 text-info animate-spin" />
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-primary mb-1">正在转录中...</h3>
|
||||
<p className="text-xs text-muted">请稍候,预计需要几分钟时间</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 摘要区域 */}
|
||||
{(meeting.brief_summary || meeting.detailed_summary) && (
|
||||
<div className="mb-4">
|
||||
<button
|
||||
onClick={() => setSummaryCollapsed(!summaryCollapsed)}
|
||||
className="flex items-center gap-1.5 text-xs text-muted hover:text-primary mb-3 transition-colors"
|
||||
>
|
||||
{summaryCollapsed ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5 rotate-180" />}
|
||||
摘要 {meeting.brief_summary && meeting.detailed_summary ? '(2)' : '(1)'}
|
||||
</button>
|
||||
|
||||
{!summaryCollapsed && (
|
||||
<div className="space-y-3">
|
||||
{meeting.brief_summary && (
|
||||
<div className="glass-card rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium text-primary flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-accent" />
|
||||
简要摘要
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => copyToClipboard(meeting.brief_summary, 'brief')}
|
||||
className="flex items-center gap-1 text-[10px] text-muted hover:text-accent transition-colors"
|
||||
>
|
||||
{briefCopied ? (
|
||||
<>
|
||||
<CheckCheck className="w-3 h-3 text-success" />
|
||||
<span className="text-success">已复制</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-3 h-3" />
|
||||
复制
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-secondary leading-relaxed whitespace-pre-wrap">
|
||||
{meeting.brief_summary}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{meeting.detailed_summary && (
|
||||
<div className="glass-card rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium text-primary flex items-center gap-2">
|
||||
<AlignLeft className="w-4 h-4 text-success" />
|
||||
详细纪要
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => copyToClipboard(meeting.detailed_summary, 'detailed')}
|
||||
className="flex items-center gap-1 text-[10px] text-muted hover:text-accent transition-colors"
|
||||
>
|
||||
{detailedCopied ? (
|
||||
<>
|
||||
<CheckCheck className="w-3 h-3 text-success" />
|
||||
<span className="text-success">已复制</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-3 h-3" />
|
||||
复制
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-secondary leading-relaxed whitespace-pre-wrap">
|
||||
{meeting.detailed_summary}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 转写内容 */}
|
||||
{meeting.status === 'completed' && (
|
||||
<div className="glass-card rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium text-primary flex items-center gap-2">
|
||||
<AlignLeft className="w-4 h-4 text-info" />
|
||||
转写内容
|
||||
</h3>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3 h-3 text-muted" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="pl-8 pr-3 py-1.5 glass-input rounded-md text-xs text-primary
|
||||
placeholder-muted focus:outline-none w-40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-muted mb-3">
|
||||
{filteredSegments.length} / {segments.length} 条
|
||||
</p>
|
||||
|
||||
<div className="space-y-2 max-h-80 overflow-y-auto pr-1">
|
||||
{filteredSegments.map((seg, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-white/[0.02] dark:bg-white/[0.02] rounded-lg p-3 border border-white/[0.04] dark:border-white/[0.04]
|
||||
hover:border-accent/20 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 bg-accent/10 text-accent rounded text-[10px] font-medium">
|
||||
<User className="w-2.5 h-2.5" />
|
||||
说话人 {seg.speaker}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted font-mono">
|
||||
{formatTimestamp(seg.start)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-secondary leading-relaxed">{seg.text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Search, RefreshCw, FileText } from 'lucide-react'
|
||||
import { useAppStore } from '../store/useAppStore'
|
||||
import MeetingCard from '../components/MeetingCard'
|
||||
import Button from '../components/Button'
|
||||
|
||||
export default function Meetings() {
|
||||
const navigate = useNavigate()
|
||||
const { meetings, loading, refreshMeetings } = useAppStore()
|
||||
const [search, setSearch] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState('all')
|
||||
|
||||
useEffect(() => {
|
||||
refreshMeetings()
|
||||
}, [])
|
||||
|
||||
const filteredMeetings = meetings.filter(m => {
|
||||
const matchSearch = m.title.toLowerCase().includes(search.toLowerCase())
|
||||
const matchStatus = statusFilter === 'all' || m.status === statusFilter
|
||||
return matchSearch && matchStatus
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="animate-in">
|
||||
{/* 页面标题 */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-semibold text-primary tracking-tight">会议列表</h1>
|
||||
<p className="text-sm text-muted mt-1">管理所有会议记录</p>
|
||||
</div>
|
||||
|
||||
{/* 搜索和筛选 */}
|
||||
<div className="flex flex-col sm:flex-row gap-2 mb-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索会议..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2 glass-input rounded-lg
|
||||
text-sm text-primary placeholder-muted focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={e => setStatusFilter(e.target.value)}
|
||||
className="px-3 py-2 glass-input rounded-lg text-sm text-primary focus:outline-none cursor-pointer"
|
||||
>
|
||||
<option value="all">全部</option>
|
||||
<option value="pending">待处理</option>
|
||||
<option value="processing">处理中</option>
|
||||
<option value="completed">已完成</option>
|
||||
<option value="failed">失败</option>
|
||||
</select>
|
||||
|
||||
<Button
|
||||
onClick={refreshMeetings}
|
||||
variant="secondary"
|
||||
size="md"
|
||||
className="!p-2"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 结果统计 */}
|
||||
<p className="text-xs text-muted mb-3">
|
||||
共 {filteredMeetings.length} 个会议
|
||||
</p>
|
||||
|
||||
{/* 会议列表 */}
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{[1, 2, 3, 4].map(i => (
|
||||
<div key={i} className="h-24 rounded-xl skeleton" />
|
||||
))}
|
||||
</div>
|
||||
) : filteredMeetings.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{filteredMeetings.map(meeting => (
|
||||
<MeetingCard
|
||||
key={meeting.meeting_id}
|
||||
meeting={meeting}
|
||||
onClick={() => navigate(`/meetings/${meeting.meeting_id}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="glass-card rounded-xl p-8 text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-3 rounded-xl bg-white/[0.04] dark:bg-white/[0.04] flex items-center justify-center">
|
||||
<Search className="w-5 h-5 text-muted" />
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-primary mb-1">未找到匹配的会议</h3>
|
||||
<p className="text-xs text-muted">尝试调整搜索条件</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Cpu, Database, Info, Monitor } from 'lucide-react'
|
||||
|
||||
export default function Settings() {
|
||||
return (
|
||||
<div className="animate-in max-w-xl">
|
||||
{/* 页面标题 */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-semibold text-primary tracking-tight">系统设置</h1>
|
||||
<p className="text-sm text-muted mt-1">系统信息与配置</p>
|
||||
</div>
|
||||
|
||||
{/* 运行环境 */}
|
||||
<div className="glass-card rounded-xl p-4 mb-3">
|
||||
<h2 className="text-sm font-medium text-primary mb-3 flex items-center gap-2">
|
||||
<Cpu className="w-4 h-4 text-accent" />
|
||||
运行环境
|
||||
</h2>
|
||||
|
||||
<div className="space-y-0">
|
||||
<div className="flex items-center justify-between py-2.5 border-b border-white/[0.04] dark:border-white/[0.04]">
|
||||
<span className="text-xs text-muted flex items-center gap-2">
|
||||
<Monitor className="w-3.5 h-3.5" />
|
||||
计算设备
|
||||
</span>
|
||||
<span className="text-xs text-primary">CPU</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2.5 border-b border-white/[0.04] dark:border-white/[0.04]">
|
||||
<span className="text-xs text-muted">ASR 模型</span>
|
||||
<span className="text-xs text-primary">SenseVoiceSmall</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2.5">
|
||||
<span className="text-xs text-muted">FunASR 版本</span>
|
||||
<span className="text-xs text-primary">1.3.14</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 存储信息 */}
|
||||
<div className="glass-card rounded-xl p-4 mb-3">
|
||||
<h2 className="text-sm font-medium text-primary mb-3 flex items-center gap-2">
|
||||
<Database className="w-4 h-4 text-success" />
|
||||
存储信息
|
||||
</h2>
|
||||
|
||||
<div className="space-y-0">
|
||||
<div className="flex items-center justify-between py-2.5 border-b border-white/[0.04] dark:border-white/[0.04]">
|
||||
<span className="text-xs text-muted">数据库类型</span>
|
||||
<span className="text-xs text-primary">SQLite</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2.5">
|
||||
<span className="text-xs text-muted">数据库路径</span>
|
||||
<span className="text-[10px] text-secondary font-mono">data/meetings.db</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 关于 */}
|
||||
<div className="glass-card rounded-xl p-4">
|
||||
<h2 className="text-sm font-medium text-primary mb-3 flex items-center gap-2">
|
||||
<Info className="w-4 h-4 text-info" />
|
||||
关于
|
||||
</h2>
|
||||
|
||||
<div className="space-y-0">
|
||||
<div className="flex items-center justify-between py-2.5 border-b border-white/[0.04] dark:border-white/[0.04]">
|
||||
<span className="text-xs text-muted">版本</span>
|
||||
<span className="text-xs text-primary">v1.0.0</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2.5 border-b border-white/[0.04] dark:border-white/[0.04]">
|
||||
<span className="text-xs text-muted">前端框架</span>
|
||||
<span className="text-xs text-primary">React 18 + Vite</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2.5">
|
||||
<span className="text-xs text-muted">技术栈</span>
|
||||
<span className="text-xs text-primary">FunASR + DeepSeek</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Upload, FileAudio, Mic, X, Square, Circle, Zap, FileText } from 'lucide-react'
|
||||
import { clsx } from 'clsx'
|
||||
import { useAppStore } from '../store/useAppStore'
|
||||
import Button from '../components/Button'
|
||||
import { formatDuration, formatTimestamp } from '../api/meeting'
|
||||
|
||||
const RECORD_MODES = {
|
||||
OFFLINE: 'offline',
|
||||
REALTIME: 'realtime'
|
||||
}
|
||||
|
||||
export default function UploadPage() {
|
||||
const navigate = useNavigate()
|
||||
const { uploadMeeting, pollMeetingStatus, refreshMeetings } = useAppStore()
|
||||
const fileInputRef = useRef()
|
||||
|
||||
const [file, setFile] = useState(null)
|
||||
const [title, setTitle] = useState('')
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
const [transcribing, setTranscribing] = useState(false)
|
||||
const [transcribeProgress, setTranscribeProgress] = useState(0)
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
|
||||
// 录音相关
|
||||
const [recordMode, setRecordMode] = useState(RECORD_MODES.OFFLINE)
|
||||
const [isRecording, setIsRecording] = useState(false)
|
||||
const [recordingTime, setRecordingTime] = useState(0)
|
||||
const [audioLevel, setAudioLevel] = useState(0)
|
||||
const [showRecorder, setShowRecorder] = useState(false)
|
||||
const [recordTitle, setRecordTitle] = useState('')
|
||||
|
||||
// 实时转写
|
||||
const [realtimeSegments, setRealtimeSegments] = useState([])
|
||||
const [wsConnected, setWsConnected] = useState(false)
|
||||
const [savedMeetingId, setSavedMeetingId] = useState(null)
|
||||
|
||||
const mediaRecorderRef = useRef(null)
|
||||
const audioContextRef = useRef(null)
|
||||
const analyserRef = useRef(null)
|
||||
const timerRef = useRef(null)
|
||||
const canvasRef = useRef(null)
|
||||
const streamRef = useRef(null)
|
||||
const animationRef = useRef(null)
|
||||
const wsRef = useRef(null)
|
||||
|
||||
const handleFileSelect = (e) => {
|
||||
const selected = e.target.files?.[0]
|
||||
if (selected) {
|
||||
setFile(selected)
|
||||
if (!title) {
|
||||
setTitle(selected.name.replace(/\.[^/.]+$/, ''))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault()
|
||||
setDragOver(false)
|
||||
const dropped = e.dataTransfer.files?.[0]
|
||||
if (dropped && dropped.type.startsWith('audio/')) {
|
||||
setFile(dropped)
|
||||
if (!title) {
|
||||
setTitle(dropped.name.replace(/\.[^/.]+$/, ''))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!file || !title) return
|
||||
|
||||
setUploading(true)
|
||||
setTranscribing(false)
|
||||
setUploadProgress(0)
|
||||
setTranscribeProgress(0)
|
||||
|
||||
try {
|
||||
const result = await uploadMeeting(file, title, (progress) => {
|
||||
setUploadProgress(progress)
|
||||
})
|
||||
|
||||
setUploading(false)
|
||||
setTranscribing(true)
|
||||
setTranscribeProgress(10)
|
||||
|
||||
await pollMeetingStatus(result.meeting_id, (status) => {
|
||||
if (status === 'processing') {
|
||||
setTranscribeProgress(prev => Math.min(prev + 5, 90))
|
||||
}
|
||||
})
|
||||
|
||||
setTranscribeProgress(100)
|
||||
await refreshMeetings()
|
||||
navigate('/meetings')
|
||||
} catch (err) {
|
||||
alert('处理失败: ' + err.message)
|
||||
setUploading(false)
|
||||
setTranscribing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const clearFile = () => {
|
||||
setFile(null)
|
||||
setTitle('')
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const connectWebSocket = () => {
|
||||
const sessionId = `realtime_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
||||
const ws = new WebSocket(`ws://localhost:8501/ws/realtime/${sessionId}`)
|
||||
|
||||
ws.onopen = () => {
|
||||
setWsConnected(true)
|
||||
const titleMsg = new Uint8Array(8 + title.length)
|
||||
titleMsg.set(new TextEncoder().encode('TYPE'), 0)
|
||||
titleMsg.set(new TextEncoder().encode('TITLE'), 4)
|
||||
titleMsg.set(new TextEncoder().encode(title || '实时录音'), 8)
|
||||
ws.send(titleMsg)
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data)
|
||||
if (data.type === 'segment') {
|
||||
setRealtimeSegments(prev => [...prev, {
|
||||
start: data.start,
|
||||
end: data.end,
|
||||
speaker: data.speaker,
|
||||
text: data.text
|
||||
}])
|
||||
} else if (data.type === 'saved') {
|
||||
setSavedMeetingId(data.meeting_id)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = () => setWsConnected(false)
|
||||
wsRef.current = ws
|
||||
}
|
||||
|
||||
const startRealtimeRecording = async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: { channelCount: 1, sampleRate: 16000, echoCancellation: true, noiseSuppression: true }
|
||||
})
|
||||
streamRef.current = stream
|
||||
|
||||
audioContextRef.current = new (window.AudioContext || window.webkitAudioContext)()
|
||||
const source = audioContextRef.current.createMediaStreamSource(stream)
|
||||
analyserRef.current = audioContextRef.current.createAnalyser()
|
||||
analyserRef.current.fftSize = 256
|
||||
source.connect(analyserRef.current)
|
||||
visualizeAudio()
|
||||
|
||||
connectWebSocket()
|
||||
|
||||
setIsRecording(true)
|
||||
setRecordingTime(0)
|
||||
setRealtimeSegments([])
|
||||
|
||||
timerRef.current = setInterval(() => setRecordingTime(prev => prev + 1), 1000)
|
||||
} catch (err) {
|
||||
alert('无法访问麦克风')
|
||||
}
|
||||
}
|
||||
|
||||
const startOfflineRecording = async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
streamRef.current = stream
|
||||
|
||||
audioContextRef.current = new (window.AudioContext || window.webkitAudioContext)()
|
||||
const source = audioContextRef.current.createMediaStreamSource(stream)
|
||||
analyserRef.current = audioContextRef.current.createAnalyser()
|
||||
analyserRef.current.fftSize = 256
|
||||
source.connect(analyserRef.current)
|
||||
visualizeAudio()
|
||||
|
||||
const mediaRecorder = new MediaRecorder(stream)
|
||||
mediaRecorderRef.current = mediaRecorder
|
||||
const chunks = []
|
||||
|
||||
mediaRecorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) chunks.push(e.data)
|
||||
}
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
const blob = new Blob(chunks, { type: 'audio/webm' })
|
||||
const audioFile = new File([blob], `recording_${Date.now()}.webm`, { type: 'audio/webm' })
|
||||
setFile(audioFile)
|
||||
if (!recordTitle) {
|
||||
setRecordTitle(`录音_${new Date().toLocaleString()}`)
|
||||
}
|
||||
}
|
||||
|
||||
mediaRecorder.start(1000)
|
||||
setIsRecording(true)
|
||||
setRecordingTime(0)
|
||||
|
||||
timerRef.current = setInterval(() => setRecordingTime(prev => prev + 1), 1000)
|
||||
} catch (err) {
|
||||
alert('无法访问麦克风')
|
||||
}
|
||||
}
|
||||
|
||||
const visualizeAudio = () => {
|
||||
const analyser = analyserRef.current
|
||||
const canvas = canvasRef.current
|
||||
if (!analyser || !canvas) return
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
const bufferLength = analyser.frequencyBinCount
|
||||
const dataArray = new Uint8Array(bufferLength)
|
||||
|
||||
const draw = () => {
|
||||
if (!isRecording) return
|
||||
animationRef.current = requestAnimationFrame(draw)
|
||||
analyser.getByteFrequencyData(dataArray)
|
||||
const average = dataArray.reduce((a, b) => a + b, 0) / bufferLength
|
||||
setAudioLevel(average / 255)
|
||||
|
||||
ctx.fillStyle = 'rgba(9, 9, 11, 0.3)'
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
const barWidth = (canvas.width / bufferLength) * 2.5
|
||||
let x = 0
|
||||
for (let i = 0; i < bufferLength; i++) {
|
||||
const barHeight = (dataArray[i] / 255) * canvas.height
|
||||
ctx.fillStyle = `rgba(129, 140, 248, ${0.5 + dataArray[i] / 512})`
|
||||
ctx.fillRect(x, canvas.height - barHeight, barWidth, barHeight)
|
||||
x += barWidth + 1
|
||||
}
|
||||
}
|
||||
draw()
|
||||
}
|
||||
|
||||
const stopRecording = () => {
|
||||
if (recordMode === RECORD_MODES.REALTIME) {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
const endMsg = new Uint8Array(12)
|
||||
endMsg.set(new TextEncoder().encode('TYPE'), 0)
|
||||
endMsg.set(new TextEncoder().encode('END___'), 4)
|
||||
wsRef.current.send(endMsg)
|
||||
}
|
||||
} else {
|
||||
if (mediaRecorderRef.current && isRecording) {
|
||||
mediaRecorderRef.current.stop()
|
||||
}
|
||||
}
|
||||
|
||||
setIsRecording(false)
|
||||
clearInterval(timerRef.current)
|
||||
cancelAnimationFrame(animationRef.current)
|
||||
streamRef.current?.getTracks().forEach(track => track.stop())
|
||||
audioContextRef.current?.close()
|
||||
setAudioLevel(0)
|
||||
}
|
||||
|
||||
const cancelRecording = () => {
|
||||
wsRef.current?.close()
|
||||
stopRecording()
|
||||
setRecordingTime(0)
|
||||
setRealtimeSegments([])
|
||||
}
|
||||
|
||||
const handleSaveRecording = () => {
|
||||
setTitle(recordTitle)
|
||||
setShowRecorder(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearInterval(timerRef.current)
|
||||
cancelAnimationFrame(animationRef.current)
|
||||
streamRef.current?.getTracks().forEach(track => track.stop())
|
||||
wsRef.current?.close()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="animate-in max-w-2xl">
|
||||
{/* 页面标题 */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-semibold text-primary tracking-tight">新建会议</h1>
|
||||
<p className="text-sm text-muted mt-1">上传音频或开始录音</p>
|
||||
</div>
|
||||
|
||||
{/* 上传区域 */}
|
||||
<div
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={clsx(
|
||||
'glass-card rounded-xl p-8 text-center cursor-pointer mb-4 transition-all duration-200',
|
||||
dragOver && 'glass-card-hover border-accent/30'
|
||||
)}
|
||||
>
|
||||
<input ref={fileInputRef} type="file" accept="audio/*" onChange={handleFileSelect} className="hidden" />
|
||||
|
||||
<div className="flex flex-col items-center">
|
||||
<div className={clsx(
|
||||
'w-12 h-12 rounded-xl flex items-center justify-center mb-4 transition-colors',
|
||||
dragOver ? 'bg-accent/20' : 'bg-white/[0.04] dark:bg-white/[0.04]'
|
||||
)}>
|
||||
<Upload className={clsx('w-5 h-5', dragOver ? 'text-accent' : 'text-muted')} />
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-primary mb-1">
|
||||
{dragOver ? '释放以上传' : '拖拽音频文件或点击上传'}
|
||||
</h3>
|
||||
<p className="text-xs text-muted">WAV, MP3, M4A, FLAC, OGG</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 已选文件 */}
|
||||
{file && (
|
||||
<div className="glass-card rounded-xl p-4 mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-accent/10 flex items-center justify-center">
|
||||
<FileAudio className="w-5 h-5 text-accent" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-sm font-medium text-primary truncate">{file.name}</h4>
|
||||
<p className="text-xs text-muted">{(file.size / 1024 / 1024).toFixed(2)} MB</p>
|
||||
</div>
|
||||
<button onClick={clearFile} className="p-1.5 text-muted hover:text-error transition-colors">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<audio controls className="w-full mt-3" src={URL.createObjectURL(file)} />
|
||||
|
||||
<div className="mt-3">
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="会议标题"
|
||||
className="w-full px-3 py-2 glass-input rounded-lg text-sm text-primary
|
||||
placeholder-muted focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{uploading ? (
|
||||
<div className="mt-4">
|
||||
<div className="flex justify-between text-xs text-muted mb-1">
|
||||
<span>上传中...</span>
|
||||
<span className="text-accent">{uploadProgress}%</span>
|
||||
</div>
|
||||
<div className="h-1 bg-white/[0.06] dark:bg-white/[0.06] rounded-full overflow-hidden">
|
||||
<div className="h-full bg-accent rounded-full transition-all" style={{ width: `${uploadProgress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
) : transcribing ? (
|
||||
<div className="mt-4">
|
||||
<div className="flex justify-between text-xs text-muted mb-1">
|
||||
<span>转录中...</span>
|
||||
<span className="text-success">{transcribeProgress}%</span>
|
||||
</div>
|
||||
<div className="h-1 bg-white/[0.06] dark:bg-white/[0.06] rounded-full overflow-hidden">
|
||||
<div className="h-full bg-success rounded-full animate-pulse" style={{ width: `${transcribeProgress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button onClick={handleUpload} disabled={!file || !title} className="w-full mt-4">
|
||||
<Upload className="w-4 h-4" />
|
||||
开始转录
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 录音功能 */}
|
||||
<div className={clsx(
|
||||
'glass-card rounded-xl p-4 transition-all duration-300',
|
||||
showRecorder && 'border-accent/30'
|
||||
)}>
|
||||
{showRecorder ? (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="text-sm font-medium text-primary flex items-center gap-2">
|
||||
<Mic className="w-4 h-4 text-accent" />
|
||||
录音模式
|
||||
</h4>
|
||||
<button onClick={() => { cancelRecording(); setShowRecorder(false); }} className="p-1 text-muted hover:text-primary">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!isRecording && realtimeSegments.length === 0 && (
|
||||
<div className="flex gap-2 mb-4">
|
||||
<button
|
||||
onClick={() => setRecordMode(RECORD_MODES.OFFLINE)}
|
||||
className={clsx(
|
||||
'flex-1 p-3 rounded-lg border text-xs font-medium transition-all',
|
||||
recordMode === RECORD_MODES.OFFLINE
|
||||
? 'bg-accent/15 text-accent border-accent/30'
|
||||
: 'bg-white/[0.04] dark:bg-white/[0.04] text-secondary border-white/[0.08] dark:border-white/[0.08] hover:bg-white/[0.06] dark:hover:bg-white/[0.06]'
|
||||
)}
|
||||
>
|
||||
<FileAudio className="w-4 h-4 mx-auto mb-1" />
|
||||
录音转写
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRecordMode(RECORD_MODES.REALTIME)}
|
||||
className={clsx(
|
||||
'flex-1 p-3 rounded-lg border text-xs font-medium transition-all',
|
||||
recordMode === RECORD_MODES.REALTIME
|
||||
? 'bg-success/15 text-success border-success/30'
|
||||
: 'bg-white/[0.04] dark:bg-white/[0.04] text-secondary border-white/[0.08] dark:border-white/[0.08] hover:bg-white/[0.06] dark:hover:bg-white/[0.06]'
|
||||
)}
|
||||
>
|
||||
<Zap className="w-4 h-4 mx-auto mb-1" />
|
||||
实时转写
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<canvas ref={canvasRef} className="w-full h-14 bg-white/[0.02] dark:bg-white/[0.02] rounded-lg mb-4" />
|
||||
|
||||
<div className="text-center mb-4">
|
||||
<div className={clsx(
|
||||
'text-3xl font-mono font-semibold mb-1 tracking-tight',
|
||||
isRecording ? 'text-error' : 'text-primary'
|
||||
)}>
|
||||
{formatDuration(recordingTime)}
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-2 text-xs text-muted">
|
||||
{isRecording && (
|
||||
<span className="flex items-center gap-1 text-error">
|
||||
<span className="w-1.5 h-1.5 bg-error rounded-full animate-pulse" />
|
||||
{recordMode === RECORD_MODES.REALTIME ? '实时转写中' : '录音中'}
|
||||
</span>
|
||||
)}
|
||||
{recordMode === RECORD_MODES.REALTIME && wsConnected && !isRecording && (
|
||||
<span className="text-success">已保存</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center gap-2">
|
||||
{isRecording ? (
|
||||
<>
|
||||
<Button onClick={stopRecording} variant="danger" size="sm">
|
||||
<Square className="w-3.5 h-3.5" />
|
||||
停止
|
||||
</Button>
|
||||
<Button onClick={cancelRecording} variant="ghost" size="sm">取消</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button onClick={recordMode === RECORD_MODES.REALTIME ? startRealtimeRecording : startOfflineRecording}>
|
||||
<Circle className="w-3.5 h-3.5" />
|
||||
开始录音
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{recordMode === RECORD_MODES.REALTIME && realtimeSegments.length > 0 && (
|
||||
<div className="mt-4 pt-4 border-t border-white/[0.06] dark:border-white/[0.06]">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h5 className="text-xs font-medium text-secondary flex items-center gap-1.5">
|
||||
<FileText className="w-3.5 h-3.5 text-success" />
|
||||
实时转写
|
||||
</h5>
|
||||
<span className="text-[10px] text-muted">{realtimeSegments.length} 条</span>
|
||||
</div>
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||||
{realtimeSegments.map((seg, i) => (
|
||||
<div key={i} className="bg-white/[0.02] dark:bg-white/[0.02] rounded-lg p-2.5">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[10px] text-accent">说话人 {seg.speaker}</span>
|
||||
<span className="text-[10px] text-muted font-mono">{formatTimestamp(seg.start)}</span>
|
||||
</div>
|
||||
<p className="text-xs text-secondary">{seg.text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recordMode === RECORD_MODES.OFFLINE && file && !isRecording && (
|
||||
<div className="mt-4 pt-4 border-t border-white/[0.06] dark:border-white/[0.06]">
|
||||
<p className="text-xs text-muted mb-2">录音预览</p>
|
||||
<audio controls className="w-full mb-3" src={URL.createObjectURL(file)} />
|
||||
<input
|
||||
type="text"
|
||||
value={recordTitle}
|
||||
onChange={(e) => setRecordTitle(e.target.value)}
|
||||
placeholder="录音标题"
|
||||
className="w-full px-3 py-2 glass-input rounded-lg text-sm text-primary
|
||||
placeholder-muted focus:outline-none mb-3"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSaveRecording} disabled={!recordTitle.trim()} className="flex-1">
|
||||
使用此录音
|
||||
</Button>
|
||||
<Button onClick={() => { setFile(null); setRecordTitle(''); }} variant="ghost" size="sm">
|
||||
重录
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recordMode === RECORD_MODES.REALTIME && savedMeetingId && !isRecording && (
|
||||
<div className="mt-4 pt-4 border-t border-white/[0.06] dark:border-white/[0.06]">
|
||||
<div className="flex items-center gap-2 text-success text-xs mb-3">
|
||||
<FileText className="w-4 h-4" />
|
||||
录音已保存,共 {realtimeSegments.length} 条转写
|
||||
</div>
|
||||
<Button onClick={() => navigate(`/meetings/${savedMeetingId}`)} className="w-full">
|
||||
查看会议
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => setShowRecorder(true)}
|
||||
className="flex items-center gap-3 cursor-pointer hover:bg-white/[0.03] dark:hover:bg-white/[0.03] rounded-lg p-2 -m-2 transition-colors"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-lg bg-accent/10 flex items-center justify-center">
|
||||
<Mic className="w-5 h-5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-primary">录音转写</h4>
|
||||
<p className="text-xs text-muted">上传音频或开始录音</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
export const useAppStore = create((set, get) => ({
|
||||
// 会议列表
|
||||
meetings: [],
|
||||
setMeetings: (meetings) => set({ meetings }),
|
||||
|
||||
// 当前选中的会议
|
||||
currentMeeting: null,
|
||||
setCurrentMeeting: (meeting) => set({ currentMeeting: meeting }),
|
||||
|
||||
// 加载状态
|
||||
loading: false,
|
||||
setLoading: (loading) => set({ loading }),
|
||||
|
||||
// 错误信息
|
||||
error: null,
|
||||
setError: (error) => set({ error }),
|
||||
|
||||
// 上传进度
|
||||
uploadProgress: 0,
|
||||
setUploadProgress: (progress) => set({ uploadProgress: progress }),
|
||||
|
||||
// 转录状态
|
||||
isTranscribing: false,
|
||||
setTranscribing: (val) => set({ isTranscribing: val }),
|
||||
|
||||
// 统计数据
|
||||
stats: {
|
||||
total: 0,
|
||||
completed: 0,
|
||||
processing: 0,
|
||||
totalDuration: 0,
|
||||
},
|
||||
setStats: (stats) => set({ stats }),
|
||||
|
||||
// 刷新会议列表
|
||||
refreshMeetings: async () => {
|
||||
set({ loading: true, error: null })
|
||||
try {
|
||||
const res = await fetch('/api/meetings')
|
||||
const data = await res.json()
|
||||
set({
|
||||
meetings: data.meetings || [],
|
||||
stats: {
|
||||
total: data.meetings?.length || 0,
|
||||
completed: data.meetings?.filter(m => m.status === 'completed').length || 0,
|
||||
processing: data.meetings?.filter(m => m.status === 'processing').length || 0,
|
||||
totalDuration: data.meetings?.reduce((acc, m) => acc + (m.duration || 0), 0) || 0,
|
||||
},
|
||||
loading: false
|
||||
})
|
||||
} catch (err) {
|
||||
set({ error: err.message, loading: false })
|
||||
}
|
||||
},
|
||||
|
||||
// 获取单个会议详情
|
||||
fetchMeetingDetail: async (meetingId) => {
|
||||
set({ loading: true, error: null })
|
||||
try {
|
||||
const res = await fetch(`/api/meetings/${meetingId}`)
|
||||
const data = await res.json()
|
||||
set({ currentMeeting: data, loading: false })
|
||||
return data
|
||||
} catch (err) {
|
||||
set({ error: err.message, loading: false })
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
// 上传并处理会议
|
||||
uploadMeeting: async (file, title, onProgress) => {
|
||||
set({ loading: true, error: null, uploadProgress: 0, isTranscribing: false })
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('title', title)
|
||||
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) {
|
||||
const progress = Math.round((e.loaded / e.total) * 100)
|
||||
set({ uploadProgress: progress })
|
||||
onProgress?.(progress)
|
||||
}
|
||||
})
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
xhr.onload = () => {
|
||||
if (xhr.status === 200) {
|
||||
const result = JSON.parse(xhr.responseText)
|
||||
set({ uploadProgress: 100, loading: false, isTranscribing: true })
|
||||
resolve({ ...result, needsPolling: true })
|
||||
} else {
|
||||
set({ loading: false, isTranscribing: false })
|
||||
reject(new Error(xhr.statusText))
|
||||
}
|
||||
}
|
||||
xhr.onerror = () => {
|
||||
set({ loading: false, isTranscribing: false })
|
||||
reject(new Error('Network error'))
|
||||
}
|
||||
xhr.open('POST', '/api/meetings/upload')
|
||||
xhr.send(formData)
|
||||
})
|
||||
} catch (err) {
|
||||
set({ error: err.message, loading: false, isTranscribing: false })
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
||||
// 轮询会议状态直到完成
|
||||
pollMeetingStatus: async (meetingId, onStatusChange) => {
|
||||
const maxAttempts = 60 // 最多轮询60次(约2分钟)
|
||||
let attempts = 0
|
||||
|
||||
const poll = async () => {
|
||||
if (attempts >= maxAttempts) {
|
||||
throw new Error('转录超时')
|
||||
}
|
||||
attempts++
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/meetings/${meetingId}`)
|
||||
const data = await res.json()
|
||||
|
||||
onStatusChange?.(data.status)
|
||||
|
||||
if (data.status === 'completed' || data.status === 'failed') {
|
||||
return data
|
||||
}
|
||||
|
||||
// 继续轮询
|
||||
await new Promise(r => setTimeout(r, 2000)) // 每2秒轮询一次
|
||||
return poll()
|
||||
} catch (err) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
return poll()
|
||||
},
|
||||
}))
|
||||
@@ -0,0 +1,226 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* 主题变量 */
|
||||
:root {
|
||||
--bg-primary: #09090b;
|
||||
--bg-secondary: #18181b;
|
||||
--bg-tertiary: #27272a;
|
||||
--text-primary: #fafafa;
|
||||
--text-secondary: #a1a1aa;
|
||||
--text-muted: #71717a;
|
||||
--border-color: rgba(255, 255, 255, 0.08);
|
||||
--border-hover: rgba(255, 255, 255, 0.15);
|
||||
--accent: #818cf8;
|
||||
--accent-muted: #6366f1;
|
||||
--glass-bg: rgba(255, 255, 255, 0.03);
|
||||
--glass-bg-hover: rgba(255, 255, 255, 0.06);
|
||||
--glass-border: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* 浅色主题 */
|
||||
.light {
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f4f4f5;
|
||||
--bg-tertiary: #e4e4e7;
|
||||
--text-primary: #09090b;
|
||||
--text-secondary: #52525b;
|
||||
--text-muted: #a1a1aa;
|
||||
--border-color: rgba(0, 0, 0, 0.08);
|
||||
--border-hover: rgba(0, 0, 0, 0.15);
|
||||
--glass-bg: rgba(0, 0, 0, 0.02);
|
||||
--glass-bg-hover: rgba(0, 0, 0, 0.04);
|
||||
--glass-border: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
/* 基础样式 */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text',
|
||||
'Helvetica Neue', 'Pretendard', 'Noto Sans SC', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
overflow-x: hidden;
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
/* 文字选择 */
|
||||
::selection {
|
||||
background: rgba(129, 140, 248, 0.3);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* 滚动条 */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--border-hover);
|
||||
}
|
||||
|
||||
/* Firefox */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-color) transparent;
|
||||
}
|
||||
|
||||
/* 焦点样式 */
|
||||
*:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(129, 140, 248, 0.5);
|
||||
}
|
||||
|
||||
/* 玻璃效果 */
|
||||
@layer utilities {
|
||||
.glass {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: 0 4px 24px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.glass-hover {
|
||||
background: var(--glass-bg-hover);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--border-hover);
|
||||
box-shadow: 0 8px 32px -1px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: 0 4px 24px -1px rgba(0, 0, 0, 0.08);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.glass-card-hover {
|
||||
background: var(--glass-bg-hover);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border: 1px solid var(--border-hover);
|
||||
box-shadow: 0 8px 32px -1px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.glass-button {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: 0 2px 12px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.glass-button-hover {
|
||||
background: rgba(129, 140, 248, 0.15);
|
||||
border-color: rgba(129, 140, 248, 0.3);
|
||||
box-shadow: 0 4px 16px -1px rgba(129, 140, 248, 0.2);
|
||||
}
|
||||
|
||||
.glass-input {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.glass-input:focus {
|
||||
background: var(--glass-bg-hover);
|
||||
border-color: rgba(129, 140, 248, 0.5);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5), 0 0 0 3px rgba(129, 140, 248, 0.1);
|
||||
}
|
||||
|
||||
/* 深色主题下内阴影使用白色 */
|
||||
.dark .glass-input {
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.dark .glass-input:focus {
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05), 0 0 0 3px rgba(129, 140, 248, 0.1);
|
||||
}
|
||||
|
||||
/* 文字颜色 */
|
||||
.text-primary { color: var(--text-primary); }
|
||||
.text-secondary { color: var(--text-secondary); }
|
||||
.text-muted { color: var(--text-muted); }
|
||||
.text-accent { color: var(--accent); }
|
||||
.bg-primary { background-color: var(--bg-primary); }
|
||||
.bg-secondary { background-color: var(--bg-secondary); }
|
||||
.bg-tertiary { background-color: var(--bg-tertiary); }
|
||||
.border-theme { border-color: var(--border-color); }
|
||||
}
|
||||
|
||||
/* 动画 */
|
||||
@layer utilities {
|
||||
.animate-in {
|
||||
animation: slideUp 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
}
|
||||
|
||||
/* 加载骨架屏 */
|
||||
.skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--glass-bg) 25%,
|
||||
var(--glass-bg-hover) 50%,
|
||||
var(--glass-bg) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* 音频播放器 */
|
||||
audio {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
audio::-webkit-media-controls-panel {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* 过渡 */
|
||||
.transition-fast {
|
||||
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.transition-base {
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.transition-smooth {
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// 深色主题(默认)
|
||||
dark: {
|
||||
bg: {
|
||||
primary: '#09090b',
|
||||
secondary: '#18181b',
|
||||
tertiary: '#27272a',
|
||||
},
|
||||
text: {
|
||||
primary: '#fafafa',
|
||||
secondary: '#a1a1aa',
|
||||
muted: '#71717a',
|
||||
},
|
||||
},
|
||||
// 浅色主题
|
||||
light: {
|
||||
bg: {
|
||||
primary: '#ffffff',
|
||||
secondary: '#f4f4f5',
|
||||
tertiary: '#e4e4e7',
|
||||
},
|
||||
text: {
|
||||
primary: '#09090b',
|
||||
secondary: '#52525b',
|
||||
muted: '#a1a1aa',
|
||||
},
|
||||
},
|
||||
// 通用主题色
|
||||
accent: {
|
||||
DEFAULT: '#6366f1',
|
||||
hover: '#818cf8',
|
||||
muted: '#4f46e5',
|
||||
},
|
||||
success: '#10b981',
|
||||
warning: '#f59e0b',
|
||||
error: '#ef4444',
|
||||
info: '#3b82f6',
|
||||
},
|
||||
fontFamily: {
|
||||
sans: [
|
||||
'-apple-system',
|
||||
'BlinkMacSystemFont',
|
||||
'SF Pro Display',
|
||||
'SF Pro Text',
|
||||
'Helvetica Neue',
|
||||
'Pretendard',
|
||||
'Noto Sans SC',
|
||||
'Segoe UI',
|
||||
'sans-serif',
|
||||
],
|
||||
},
|
||||
boxShadow: {
|
||||
'glass': '0 4px 24px -1px rgba(0, 0, 0, 0.08)',
|
||||
'glass-hover': '0 8px 32px -1px rgba(0, 0, 0, 0.12)',
|
||||
'glow': '0 0 20px rgba(99, 102, 241, 0.3)',
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.3s ease-out',
|
||||
'slide-up': 'slideUp 0.4s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
slideUp: {
|
||||
'0%': { opacity: '0', transform: 'translateY(20px)' },
|
||||
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
'2xl': '16px',
|
||||
'3xl': '24px',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8501',
|
||||
changeOrigin: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,513 @@
|
||||
"""
|
||||
FastAPI 后端 - 会议记录系统 API
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
import json
|
||||
import asyncio
|
||||
import wave
|
||||
import io
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Dict
|
||||
from contextlib import asynccontextmanager
|
||||
from threading import Thread, Lock
|
||||
from queue import Queue
|
||||
|
||||
from fastapi import FastAPI, UploadFile, File, HTTPException, Form, Query, WebSocket, WebSocketDisconnect
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
import openai
|
||||
|
||||
from database import init_db, Database
|
||||
from processor import MeetingProcessor, get_device, RealtimeTranscriber
|
||||
|
||||
|
||||
# 全局变量
|
||||
db: Database = None
|
||||
processor: MeetingProcessor = None
|
||||
task_queue: Queue = Queue()
|
||||
is_running = True
|
||||
|
||||
# 实时转写会话管理
|
||||
realtime_sessions: Dict[str, Dict] = {}
|
||||
sessions_lock = Lock()
|
||||
|
||||
|
||||
def background_worker():
|
||||
"""后台转录工作线程"""
|
||||
global is_running, processor, db
|
||||
print("🔄 后台转录线程已启动")
|
||||
while is_running:
|
||||
try:
|
||||
meeting_id, audio_path, title = task_queue.get(timeout=1)
|
||||
print(f"📝 开始转录会议: {title} (ID: {meeting_id})")
|
||||
try:
|
||||
result = processor.process_audio(audio_path, title=title)
|
||||
db.update_meeting(
|
||||
meeting_id=meeting_id,
|
||||
segments=[s.__dict__ for s in result.segments],
|
||||
status="completed",
|
||||
speaker_count=result.speaker_count,
|
||||
duration=result.duration
|
||||
)
|
||||
print(f"✅ 转录完成: {title}")
|
||||
except Exception as e:
|
||||
print(f"❌ 转录失败: {str(e)}")
|
||||
db.update_meeting(meeting_id=meeting_id, status="failed")
|
||||
except:
|
||||
pass
|
||||
print("🔄 后台转录线程已停止")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""应用生命周期"""
|
||||
global db, processor, is_running
|
||||
db = init_db()
|
||||
processor = MeetingProcessor(device=get_device())
|
||||
|
||||
# 启动后台线程
|
||||
is_running = True
|
||||
worker_thread = Thread(target=background_worker, daemon=True)
|
||||
worker_thread.start()
|
||||
print("🚀 后台转录线程已启动")
|
||||
|
||||
yield
|
||||
|
||||
# 清理
|
||||
is_running = False
|
||||
print("👋 应用关闭,后台线程将停止")
|
||||
|
||||
|
||||
# 创建 FastAPI 应用
|
||||
app = FastAPI(
|
||||
title="会议记录系统 API",
|
||||
description="智能会议记录系统后端接口",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
# CORS 配置
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# ============ API 路由 ============
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health_check():
|
||||
"""健康检查"""
|
||||
return {"status": "ok", "timestamp": datetime.now().isoformat()}
|
||||
|
||||
|
||||
@app.get("/api/meetings")
|
||||
async def get_meetings():
|
||||
"""获取所有会议"""
|
||||
meetings = db.get_all_meetings(limit=1000)
|
||||
return {"meetings": meetings, "total": len(meetings)}
|
||||
|
||||
|
||||
@app.get("/api/meetings/{meeting_id}")
|
||||
async def get_meeting(meeting_id: str):
|
||||
"""获取单个会议详情"""
|
||||
meeting = db.get_meeting(meeting_id)
|
||||
if not meeting:
|
||||
raise HTTPException(status_code=404, detail="会议不存在")
|
||||
return meeting
|
||||
|
||||
|
||||
@app.get("/api/meetings/search")
|
||||
async def search_meetings(keyword: str = Query(...)):
|
||||
"""搜索会议"""
|
||||
meetings = db.search_meetings(keyword)
|
||||
return {"meetings": meetings, "total": len(meetings)}
|
||||
|
||||
|
||||
@app.post("/api/meetings/upload")
|
||||
async def upload_meeting(
|
||||
file: UploadFile = File(...),
|
||||
title: str = Form(...)
|
||||
):
|
||||
"""上传音频文件,开始异步转录"""
|
||||
# 验证文件类型
|
||||
allowed_types = ['audio/wav', 'audio/mpeg', 'audio/mp3', 'audio/mp4',
|
||||
'audio/x-m4a', 'audio/flac', 'audio/ogg', 'audio/x-wav']
|
||||
content_type = file.content_type or ''
|
||||
|
||||
if not any(ct in content_type for ct in ['audio/', 'video/']) and \
|
||||
not file.filename.endswith(('.wav', '.mp3', '.m4a', '.flac', '.ogg')):
|
||||
raise HTTPException(status_code=400, detail="不支持的文件类型")
|
||||
|
||||
# 保存文件
|
||||
project_root = os.path.dirname(os.path.abspath(__file__))
|
||||
data_dir = os.path.join(project_root, "data", "audio")
|
||||
os.makedirs(data_dir, exist_ok=True)
|
||||
|
||||
ext = os.path.splitext(file.filename)[1] or ".mp3"
|
||||
filename = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}{ext}"
|
||||
filepath = os.path.join(data_dir, filename)
|
||||
|
||||
with open(filepath, "wb") as f:
|
||||
content = await file.read()
|
||||
f.write(content)
|
||||
|
||||
# 创建会议记录(状态: processing)
|
||||
meeting_id = datetime.now().strftime("%Y%m%d_%H%M%S") + "_" + uuid.uuid4().hex[:6]
|
||||
|
||||
db.create_meeting(
|
||||
meeting_id=meeting_id,
|
||||
title=title,
|
||||
audio_path=filepath,
|
||||
status="processing"
|
||||
)
|
||||
|
||||
# 将转录任务加入后台队列
|
||||
task_queue.put((meeting_id, filepath, title))
|
||||
print(f"📋 任务已加入队列: {title} (ID: {meeting_id})")
|
||||
|
||||
# 立即返回会议信息(不等待转录完成)
|
||||
meeting = db.get_meeting(meeting_id)
|
||||
return meeting
|
||||
|
||||
|
||||
@app.delete("/api/meetings/{meeting_id}")
|
||||
async def delete_meeting(meeting_id: str):
|
||||
"""删除会议"""
|
||||
meeting = db.get_meeting(meeting_id)
|
||||
if not meeting:
|
||||
raise HTTPException(status_code=404, detail="会议不存在")
|
||||
|
||||
db.delete_meeting(meeting_id)
|
||||
|
||||
# 删除音频文件
|
||||
audio_path = meeting.get('audio_path')
|
||||
if audio_path and os.path.exists(audio_path):
|
||||
try:
|
||||
os.remove(audio_path)
|
||||
except:
|
||||
pass
|
||||
|
||||
return {"message": "删除成功"}
|
||||
|
||||
|
||||
@app.get("/api/audio/{meeting_id}")
|
||||
async def get_audio(meeting_id: str):
|
||||
"""获取音频文件"""
|
||||
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=404, detail="音频文件不存在")
|
||||
|
||||
return FileResponse(
|
||||
audio_path,
|
||||
media_type="audio/mpeg",
|
||||
filename=os.path.basename(audio_path)
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/stats")
|
||||
async def get_stats():
|
||||
"""获取统计数据"""
|
||||
meetings = db.get_all_meetings(limit=1000)
|
||||
return {
|
||||
"total": len(meetings),
|
||||
"completed": sum(1 for m in meetings if m['status'] == 'completed'),
|
||||
"processing": sum(1 for m in meetings if m['status'] == 'processing'),
|
||||
"failed": sum(1 for m in meetings if m['status'] == 'failed'),
|
||||
"total_duration": sum(m.get('duration', 0) for m in meetings),
|
||||
"queue_size": task_queue.qsize()
|
||||
}
|
||||
|
||||
|
||||
@app.patch("/api/meetings/{meeting_id}")
|
||||
async def update_meeting(meeting_id: str, title: str = Form(...)):
|
||||
"""更新会议标题"""
|
||||
meeting = db.get_meeting(meeting_id)
|
||||
if not meeting:
|
||||
raise HTTPException(status_code=404, detail="会议不存在")
|
||||
|
||||
db.update_meeting(meeting_id, title=title)
|
||||
return db.get_meeting(meeting_id)
|
||||
|
||||
|
||||
@app.get("/api/meetings/{meeting_id}/status")
|
||||
async def get_meeting_status(meeting_id: str):
|
||||
"""获取会议处理状态(用于轮询)"""
|
||||
meeting = db.get_meeting(meeting_id)
|
||||
if not meeting:
|
||||
raise HTTPException(status_code=404, detail="会议不存在")
|
||||
return {
|
||||
"meeting_id": meeting_id,
|
||||
"status": meeting['status'],
|
||||
"title": meeting['title'],
|
||||
"queue_position": None,
|
||||
"segments_count": len(meeting.get('segments', []))
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/meetings/{meeting_id}/summarize")
|
||||
async def generate_summary(meeting_id: str):
|
||||
"""生成会议摘要(使用 AI)"""
|
||||
import openai
|
||||
|
||||
meeting = db.get_meeting(meeting_id)
|
||||
if not meeting:
|
||||
raise HTTPException(status_code=404, detail="会议不存在")
|
||||
|
||||
if meeting['status'] != 'completed':
|
||||
raise HTTPException(status_code=400, detail="会议尚未完成转录")
|
||||
|
||||
segments = meeting.get('segments', [])
|
||||
if not segments:
|
||||
raise HTTPException(status_code=400, detail="无转写内容")
|
||||
|
||||
# 合并转写文本
|
||||
transcript = ""
|
||||
for seg in segments:
|
||||
speaker = f"说话人{seg.get('speaker', 0)}"
|
||||
text = seg.get('text', '')
|
||||
transcript += f"{speaker}: {text}\n"
|
||||
|
||||
# 调用 DeepSeek API
|
||||
api_key = os.environ.get('DEEPSEEK_API_KEY') or 'sk-a8bcfef7ad67444dbdb20e52c854a5c1'
|
||||
|
||||
client = openai.OpenAI(
|
||||
api_key=api_key,
|
||||
base_url="https://api.deepseek.com"
|
||||
)
|
||||
|
||||
brief_prompt = f"""请为以下会议录音生成简洁的摘要(150字以内),概括会议的主要内容和结论:
|
||||
|
||||
{transcript[:3000]}
|
||||
|
||||
请用中文回复,格式如下:
|
||||
【摘要】
|
||||
...
|
||||
"""
|
||||
|
||||
detailed_prompt = f"""请为以下会议录音生成详细的分段纪要,按话题整理要点:
|
||||
|
||||
{transcript[:5000]}
|
||||
|
||||
请用中文回复,格式如下:
|
||||
【详细纪要】
|
||||
1. [话题标题]
|
||||
- 要点1
|
||||
- 要点2
|
||||
2. [话题标题]
|
||||
...
|
||||
"""
|
||||
|
||||
try:
|
||||
# 生成简要摘要
|
||||
brief_response = client.chat.completions.create(
|
||||
model="deepseek-chat",
|
||||
messages=[
|
||||
{"role": "system", "content": "你是一个专业的会议记录助手,擅长总结会议要点。"},
|
||||
{"role": "user", "content": brief_prompt}
|
||||
],
|
||||
max_tokens=500,
|
||||
temperature=0.3
|
||||
)
|
||||
brief_summary = brief_response.choices[0].message.content
|
||||
|
||||
# 生成详细纪要
|
||||
detailed_response = client.chat.completions.create(
|
||||
model="deepseek-chat",
|
||||
messages=[
|
||||
{"role": "system", "content": "你是一个专业的会议记录助手,擅长整理会议纪要。"},
|
||||
{"role": "user", "content": detailed_prompt}
|
||||
],
|
||||
max_tokens=1500,
|
||||
temperature=0.3
|
||||
)
|
||||
detailed_summary = detailed_response.choices[0].message.content
|
||||
|
||||
# 清理格式标记
|
||||
brief_summary = brief_summary.replace('【摘要】', '').strip()
|
||||
detailed_summary = detailed_summary.replace('【详细纪要】', '').strip()
|
||||
|
||||
# 更新数据库
|
||||
db.update_meeting(
|
||||
meeting_id=meeting_id,
|
||||
brief_summary=brief_summary,
|
||||
detailed_summary=detailed_summary
|
||||
)
|
||||
|
||||
# 更新本地变量并返回
|
||||
meeting['brief_summary'] = brief_summary
|
||||
meeting['detailed_summary'] = detailed_summary
|
||||
|
||||
return meeting
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"生成摘要失败: {str(e)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8501)
|
||||
|
||||
|
||||
# ============ 实时转写 WebSocket ============
|
||||
|
||||
@app.websocket("/ws/realtime/{session_id}")
|
||||
async def realtime_transcribe(websocket: WebSocket, session_id: str):
|
||||
"""实时转写 WebSocket"""
|
||||
await websocket.accept()
|
||||
|
||||
transcriber = None
|
||||
title = "实时录音"
|
||||
audio_chunks = []
|
||||
|
||||
try:
|
||||
# 初始化转写器
|
||||
transcriber = processor.create_realtime_transcriber()
|
||||
|
||||
# 保存会话
|
||||
with sessions_lock:
|
||||
realtime_sessions[session_id] = {
|
||||
"transcriber": transcriber,
|
||||
"title": title,
|
||||
"segments": [],
|
||||
"start_time": datetime.now()
|
||||
}
|
||||
|
||||
# 发送就绪消息
|
||||
await websocket.send_json({
|
||||
"type": "ready",
|
||||
"session_id": session_id
|
||||
})
|
||||
|
||||
# 处理音频数据
|
||||
while True:
|
||||
try:
|
||||
data = await websocket.receive_bytes()
|
||||
|
||||
# 检查消息类型
|
||||
if data[:4] == b'TYPE':
|
||||
# 解析消息类型
|
||||
msg_type = data[4:8].decode('utf-8').strip()
|
||||
if msg_type == 'TITLE':
|
||||
title = data[8:].decode('utf-8')
|
||||
with sessions_lock:
|
||||
if session_id in realtime_sessions:
|
||||
realtime_sessions[session_id]['title'] = title
|
||||
elif msg_type == 'END__':
|
||||
# 录音结束,保存会议
|
||||
break
|
||||
continue
|
||||
|
||||
# 音频数据处理
|
||||
audio_chunks.append(data)
|
||||
|
||||
# 转写音频块
|
||||
result = transcriber.process_audio_data(data)
|
||||
|
||||
if result:
|
||||
# 发送转写结果
|
||||
await websocket.send_json({
|
||||
"type": "segment",
|
||||
"start": result.start,
|
||||
"end": result.end,
|
||||
"speaker": result.speaker,
|
||||
"text": result.text
|
||||
})
|
||||
|
||||
# 更新会话
|
||||
with sessions_lock:
|
||||
if session_id in realtime_sessions:
|
||||
realtime_sessions[session_id]['segments'].append({
|
||||
"start": result.start,
|
||||
"end": result.end,
|
||||
"speaker": result.speaker,
|
||||
"text": result.text
|
||||
})
|
||||
|
||||
except WebSocketDisconnect:
|
||||
break
|
||||
|
||||
# 录音结束,保存会议
|
||||
if transcriber and audio_chunks:
|
||||
# 保存录音文件
|
||||
project_root = os.path.dirname(os.path.abspath(__file__))
|
||||
data_dir = os.path.join(project_root, "data", "audio")
|
||||
os.makedirs(data_dir, exist_ok=True)
|
||||
|
||||
audio_filename = f"{datetime.now().strftime('%Y%m%d_%H%M%')}{session_id}.wav"
|
||||
audio_path = os.path.join(data_dir, audio_filename)
|
||||
|
||||
# 合并音频块并保存
|
||||
with wave.open(audio_path, 'wb') as wav_file:
|
||||
wav_file.setnchannels(1)
|
||||
wav_file.setsampwidth(2)
|
||||
wav_file.setframerate(16000)
|
||||
for chunk in audio_chunks:
|
||||
wav_file.writeframes(chunk)
|
||||
|
||||
# 创建会议记录
|
||||
meeting_id = datetime.now().strftime("%Y%m%d_%H%M%S") + "_" + session_id[:6]
|
||||
|
||||
with sessions_lock:
|
||||
session_data = realtime_sessions.get(session_id, {})
|
||||
segments = session_data.get('segments', [])
|
||||
speakers = set(s['speaker'] for s in segments)
|
||||
duration = sum((s['end'] - s['start']) for s in segments) if segments else 0
|
||||
|
||||
db.create_meeting(
|
||||
meeting_id=meeting_id,
|
||||
title=title,
|
||||
audio_path=audio_path,
|
||||
status="completed"
|
||||
)
|
||||
|
||||
db.update_meeting(
|
||||
meeting_id=meeting_id,
|
||||
segments=segments,
|
||||
status="completed",
|
||||
speaker_count=len(speakers),
|
||||
duration=duration
|
||||
)
|
||||
|
||||
# 发送保存完成消息
|
||||
await websocket.send_json({
|
||||
"type": "saved",
|
||||
"meeting_id": meeting_id
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"实时转写错误: {e}")
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"message": str(e)
|
||||
})
|
||||
finally:
|
||||
# 清理会话
|
||||
with sessions_lock:
|
||||
if session_id in realtime_sessions:
|
||||
del realtime_sessions[session_id]
|
||||
|
||||
|
||||
@app.get("/api/realtime/{session_id}/status")
|
||||
async def get_realtime_status(session_id: str):
|
||||
"""获取实时转写状态"""
|
||||
with sessions_lock:
|
||||
if session_id in realtime_sessions:
|
||||
session = realtime_sessions[session_id]
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"title": session['title'],
|
||||
"segments_count": len(session.get('segments', [])),
|
||||
"speaker_count": len(set(s['speaker'] for s in session.get('segments', [])))
|
||||
}
|
||||
return {"error": "会话不存在"}
|
||||
+406
@@ -0,0 +1,406 @@
|
||||
"""
|
||||
会议音频处理器 - 基于 FunASR
|
||||
支持: 音频上传转录、实时录音转录、说话人分离
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
import hashlib
|
||||
import asyncio
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Optional, Tuple, AsyncGenerator
|
||||
from dataclasses import dataclass, asdict
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
|
||||
from funasr import AutoModel
|
||||
from funasr.utils.postprocess_utils import rich_transcription_postprocess
|
||||
|
||||
|
||||
@dataclass
|
||||
class AudioSegment:
|
||||
"""音频片段"""
|
||||
start: float # 秒
|
||||
end: float # 秒
|
||||
speaker: int # 说话人ID
|
||||
text: str # 转录文本
|
||||
emotion: str = "NEUTRAL" # 情感标签
|
||||
|
||||
|
||||
@dataclass
|
||||
class MeetingRecord:
|
||||
"""会议记录"""
|
||||
meeting_id: str
|
||||
title: str
|
||||
date: str
|
||||
duration: float
|
||||
audio_path: str
|
||||
transcript_path: Optional[str]
|
||||
segments: List[AudioSegment]
|
||||
speaker_count: int
|
||||
raw_result: Dict
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
return {
|
||||
"meeting_id": self.meeting_id,
|
||||
"title": self.title,
|
||||
"date": self.date,
|
||||
"duration": self.duration,
|
||||
"audio_path": self.audio_path,
|
||||
"transcript_path": self.transcript_path,
|
||||
"speaker_count": self.speaker_count,
|
||||
"segments": [
|
||||
{
|
||||
"start": s.start,
|
||||
"end": s.end,
|
||||
"speaker": s.speaker,
|
||||
"text": s.text,
|
||||
"emotion": s.emotion
|
||||
} for s in self.segments
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
class RealtimeTranscriber:
|
||||
"""实时转写器"""
|
||||
|
||||
def __init__(self, processor):
|
||||
self.processor = processor
|
||||
self.model = processor.model
|
||||
self._reset()
|
||||
|
||||
def _reset(self):
|
||||
"""重置状态"""
|
||||
self.segments = []
|
||||
self.speakers = set()
|
||||
self.total_duration = 0.0
|
||||
|
||||
def process_audio_data(self, audio_data: bytes, sample_rate: int = 16000) -> Optional[AudioSegment]:
|
||||
"""处理一个音频块"""
|
||||
try:
|
||||
# 将 bytes 转换为 numpy 数组
|
||||
import wave
|
||||
import io
|
||||
|
||||
# 解析 WAV 数据
|
||||
with wave.open(io.BytesIO(audio_data), 'rb') as wav_file:
|
||||
channels = wav_file.getnchannels()
|
||||
sample_width = wav_file.getsampwidth()
|
||||
framerate = wav_file.getframerate()
|
||||
n_frames = wav_file.getnframes()
|
||||
audio_bytes = wav_file.readframes(n_frames)
|
||||
|
||||
# 转换为 numpy 数组
|
||||
audio_array = np.frombuffer(audio_bytes, dtype=np.int16)
|
||||
|
||||
# 如果是多声道,转换为单声道
|
||||
if channels > 1:
|
||||
audio_array = audio_array.reshape(-1, channels).mean(axis=1).astype(np.int16)
|
||||
|
||||
# 重采样为 16kHz(如果需要)
|
||||
if framerate != 16000:
|
||||
import scipy.signal as signal
|
||||
num_samples = int(len(audio_array) * 16000 / framerate)
|
||||
audio_array = signal.resample(audio_array, num_samples).astype(np.int16)
|
||||
|
||||
# 更新总时长
|
||||
self.total_duration += len(audio_array) / sample_rate
|
||||
|
||||
# 保存为临时文件
|
||||
temp_file = tempfile.NamedTemporaryFile(suffix='.wav', delete=False)
|
||||
sf.write(temp_file.name, audio_array, sample_rate)
|
||||
temp_path = temp_file.name
|
||||
|
||||
# 使用模型转录
|
||||
result = self.model.generate(
|
||||
input=temp_path,
|
||||
batch_size_s=60,
|
||||
return_raw_text=True,
|
||||
sentence_timestamp=True
|
||||
)
|
||||
|
||||
# 清理临时文件
|
||||
os.unlink(temp_path)
|
||||
|
||||
if result and len(result) > 0:
|
||||
segment = self._parse_single_result(result[0])
|
||||
if segment:
|
||||
self.speakers.add(segment.speaker)
|
||||
self.segments.append(segment)
|
||||
return segment
|
||||
|
||||
except Exception as e:
|
||||
print(f"处理音频块失败: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def _parse_single_result(self, result) -> Optional[AudioSegment]:
|
||||
"""解析单个转录结果"""
|
||||
try:
|
||||
text = result.get("text", "")
|
||||
if not text:
|
||||
return None
|
||||
|
||||
text = rich_transcription_postprocess(text)
|
||||
|
||||
# 获取时间戳
|
||||
sentence_info = result.get("sentence_info", [])
|
||||
if sentence_info:
|
||||
seg = sentence_info[0]
|
||||
start = seg.get("start", 0) / 1000
|
||||
end = seg.get("end", 0) / 1000
|
||||
speaker = seg.get("spk", 0)
|
||||
else:
|
||||
start = self.total_duration - len(text) / 10 # 估算
|
||||
end = self.total_duration
|
||||
speaker = 0
|
||||
|
||||
return AudioSegment(
|
||||
start=start,
|
||||
end=end,
|
||||
speaker=speaker,
|
||||
text=text,
|
||||
emotion="NEUTRAL"
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"解析结果失败: {e}")
|
||||
return None
|
||||
|
||||
def get_result(self) -> Dict:
|
||||
"""获取当前转写结果"""
|
||||
return {
|
||||
"segments": [
|
||||
{
|
||||
"start": s.start,
|
||||
"end": s.end,
|
||||
"speaker": s.speaker,
|
||||
"text": s.text,
|
||||
"emotion": s.emotion
|
||||
} for s in self.segments
|
||||
],
|
||||
"speaker_count": len(self.speakers),
|
||||
"duration": self.total_duration
|
||||
}
|
||||
|
||||
|
||||
class MeetingProcessor:
|
||||
"""会议处理器"""
|
||||
|
||||
def __init__(self, device: str = "cpu"):
|
||||
"""
|
||||
初始化处理器
|
||||
|
||||
Args:
|
||||
device: "cpu" 或 "cuda"
|
||||
"""
|
||||
self.device = device
|
||||
self.model = None
|
||||
self.model_name = "iic/SenseVoiceSmall"
|
||||
self.realtime_transcriber: Optional[RealtimeTranscriber] = None
|
||||
self._init_model()
|
||||
|
||||
def _init_model(self):
|
||||
"""初始化 FunASR 模型"""
|
||||
print(f"🔄 初始化模型: {self.model_name} (device={self.device})")
|
||||
self.model = AutoModel(
|
||||
model=self.model_name,
|
||||
vad_model="fsmn-vad",
|
||||
spk_model="cam++",
|
||||
device=self.device,
|
||||
disable_update=True
|
||||
)
|
||||
print("✅ 模型加载完成")
|
||||
|
||||
def create_realtime_transcriber(self) -> RealtimeTranscriber:
|
||||
"""创建实时转写器实例"""
|
||||
self.realtime_transcriber = RealtimeTranscriber(self)
|
||||
return self.realtime_transcriber
|
||||
|
||||
def _download_audio(self, url: str) -> Tuple[str, float]:
|
||||
"""下载远程音频"""
|
||||
import urllib.request
|
||||
|
||||
suffix = ".wav"
|
||||
if ".mp3" in url:
|
||||
suffix = ".mp3"
|
||||
elif ".m4a" in url:
|
||||
suffix = ".m4a"
|
||||
|
||||
temp_file = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
|
||||
urllib.request.urlretrieve(url, temp_file.name)
|
||||
|
||||
info = sf.info(temp_file.name)
|
||||
duration = info.duration
|
||||
|
||||
return temp_file.name, duration
|
||||
|
||||
def process_audio(
|
||||
self,
|
||||
audio_path: str,
|
||||
title: Optional[str] = None,
|
||||
is_url: bool = False
|
||||
) -> MeetingRecord:
|
||||
"""
|
||||
处理音频文件
|
||||
|
||||
Args:
|
||||
audio_path: 音频路径或URL
|
||||
title: 会议标题
|
||||
is_url: 是否为URL
|
||||
|
||||
Returns:
|
||||
MeetingRecord: 会议记录
|
||||
"""
|
||||
# 生成会议ID
|
||||
meeting_id = datetime.now().strftime("%Y%m%d_%H%M%S") + "_" + uuid.uuid4().hex[:6]
|
||||
|
||||
# 处理音频路径
|
||||
if is_url:
|
||||
actual_path, duration = self._download_audio(audio_path)
|
||||
else:
|
||||
actual_path = audio_path
|
||||
info = sf.info(actual_path)
|
||||
duration = info.duration
|
||||
|
||||
# 使用标题或默认标题
|
||||
if not title:
|
||||
title = f"会议记录_{datetime.now().strftime('%Y-%m-%d %H:%M')}"
|
||||
|
||||
print(f"🎙️ 开始处理: {title}")
|
||||
print(f"📁 音频: {actual_path}")
|
||||
|
||||
# 执行转录
|
||||
result = self.model.generate(
|
||||
input=actual_path,
|
||||
batch_size_s=300,
|
||||
return_raw_text=True,
|
||||
is_final=True,
|
||||
sentence_timestamp=True
|
||||
)
|
||||
|
||||
# 解析结果
|
||||
segments = self._parse_result(result)
|
||||
|
||||
# 统计说话人数量
|
||||
speakers = set(s.speaker for s in segments)
|
||||
speaker_count = len(speakers)
|
||||
|
||||
# 创建记录
|
||||
record = MeetingRecord(
|
||||
meeting_id=meeting_id,
|
||||
title=title,
|
||||
date=datetime.now().isoformat(),
|
||||
duration=duration,
|
||||
audio_path=actual_path,
|
||||
transcript_path=None,
|
||||
segments=segments,
|
||||
speaker_count=speaker_count,
|
||||
raw_result=result[0] if result else {}
|
||||
)
|
||||
|
||||
print(f"✅ 处理完成: {len(segments)} 个片段, {speaker_count} 位说话人")
|
||||
|
||||
# 清理临时文件
|
||||
if is_url and os.path.exists(actual_path):
|
||||
os.unlink(actual_path)
|
||||
|
||||
return record
|
||||
|
||||
def _parse_result(self, result: List) -> List[AudioSegment]:
|
||||
"""解析 FunASR 返回结果"""
|
||||
segments = []
|
||||
|
||||
if not result or len(result) == 0:
|
||||
return segments
|
||||
|
||||
raw = result[0]
|
||||
|
||||
# 尝试获取 sentence_info
|
||||
sentence_info = raw.get("sentence_info", [])
|
||||
|
||||
if not sentence_info and isinstance(raw, dict):
|
||||
# 兼容不同格式
|
||||
text = raw.get("text", "")
|
||||
if text:
|
||||
segments.append(AudioSegment(
|
||||
start=0,
|
||||
end=0,
|
||||
speaker=0,
|
||||
text=rich_transcription_postprocess(text)
|
||||
))
|
||||
return segments
|
||||
|
||||
for seg in sentence_info:
|
||||
start = seg.get("start", 0) / 1000 # 毫秒转秒
|
||||
end = seg.get("end", 0) / 1000
|
||||
speaker = seg.get("spk", 0)
|
||||
text = seg.get("sentence", "")
|
||||
|
||||
# 后处理:标点、格式
|
||||
text = rich_transcription_postprocess(text)
|
||||
|
||||
# 提取情感标签
|
||||
emotion = self._extract_emotion(text)
|
||||
|
||||
segments.append(AudioSegment(
|
||||
start=start,
|
||||
end=end,
|
||||
speaker=speaker,
|
||||
text=text,
|
||||
emotion=emotion
|
||||
))
|
||||
|
||||
return segments
|
||||
|
||||
def _extract_emotion(self, text: str) -> str:
|
||||
"""从文本中提取情感标签"""
|
||||
emotions = ["NEUTRAL", "happy", "sad", "angry", "surprise"]
|
||||
for emo in emotions:
|
||||
if emo in text:
|
||||
return emo
|
||||
return "NEUTRAL"
|
||||
|
||||
def transcribe_segment(self, audio_path: str, start: float, end: float) -> str:
|
||||
"""转录指定时间段的音频"""
|
||||
data, sr = sf.read(audio_path, start=int(start * sr), stop=int(end * sr))
|
||||
|
||||
# 临时保存片段
|
||||
temp_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
|
||||
sf.write(temp_file.name, data, sr)
|
||||
|
||||
# 转录
|
||||
result = self.model.generate(input=temp_file.name)
|
||||
|
||||
# 清理
|
||||
os.unlink(temp_file.name)
|
||||
|
||||
if result:
|
||||
return rich_transcription_postprocess(result[0].get("text", ""))
|
||||
return ""
|
||||
|
||||
|
||||
def get_device() -> str:
|
||||
"""自动检测可用设备"""
|
||||
try:
|
||||
import torch
|
||||
if torch.cuda.is_available():
|
||||
return "cuda"
|
||||
except ImportError:
|
||||
pass
|
||||
return "cpu"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 测试
|
||||
processor = MeetingProcessor(device=get_device())
|
||||
|
||||
# 测试示例音频
|
||||
test_url = "https://isv-data.oss-cn-hangzhou.aliyuncs.com/ics/MaaS/ASR/test_audio/asr_example_zh.wav"
|
||||
result = processor.process_audio(test_url, title="测试会议", is_url=True)
|
||||
|
||||
print("\n=== 转录结果 ===")
|
||||
for seg in result.segments:
|
||||
print(f"[{seg.start:.1f}s] 说话人{seg.speaker}: {seg.text}")
|
||||
@@ -0,0 +1,18 @@
|
||||
# 核心依赖
|
||||
funasr==1.3.14
|
||||
openai==1.12.0
|
||||
|
||||
# 后端 API
|
||||
fastapi==0.109.0
|
||||
uvicorn[standard]==0.27.0
|
||||
python-multipart==0.0.6
|
||||
|
||||
# 数据库
|
||||
sqlalchemy==2.0.25
|
||||
|
||||
# 音频处理
|
||||
soundfile==0.12.1
|
||||
pydub==0.25.1
|
||||
|
||||
# 工具
|
||||
requests==2.31.0
|
||||
@@ -0,0 +1,30 @@
|
||||
# 会议记录
|
||||
|
||||
## 基本信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| **会议名称** | {title} |
|
||||
| **会议日期** | {date} |
|
||||
| **会议时长** | {duration} |
|
||||
| **参与人数** | {speaker_count} 人 |
|
||||
|
||||
## 与会人员
|
||||
|
||||
{participants}
|
||||
|
||||
---
|
||||
|
||||
## 会议内容
|
||||
|
||||
{content}
|
||||
|
||||
---
|
||||
|
||||
## 摘要
|
||||
|
||||
{summary}
|
||||
|
||||
---
|
||||
|
||||
*本记录由 FunASR 自动生成*
|
||||
Reference in New Issue
Block a user