feat: 添加浅色主题支持

- 配置浅色主题配色方案和 CSS 变量
- 修复组件在浅色模式下的样式适配
- 统一文字颜色类名使用 CSS 变量
- 优化玻璃效果在浅色主题下的显示
This commit is contained in:
freedakgmail
2026-07-09 22:10:49 +08:00
commit b43e3725ee
34 changed files with 7911 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
# DeepSeek API Key
DEEPSEEK_API_KEY=your-api-key-here
+44
View File
@@ -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
+90
View File
@@ -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
- 长音频转录可能需要几分钟时间
+1171
View File
File diff suppressed because it is too large Load Diff
+161
View File
@@ -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
View File
@@ -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'])}")
+16
View File
@@ -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>
+3104
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+21
View File
@@ -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>
)
}
+119
View File
@@ -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
+54
View File
@@ -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>
)
}
+74
View File
@@ -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>
)
}
+92
View File
@@ -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>
)
}
+49
View File
@@ -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>
)
}
+44
View File
@@ -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>
)
}
+31
View File
@@ -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>,
)
+91
View File
@@ -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>
)
}
+330
View File
@@ -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>
)
}
+101
View File
@@ -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>
)
}
+81
View File
@@ -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>
)
}
+535
View File
@@ -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>
)
}
+144
View File
@@ -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()
},
}))
+226
View File
@@ -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);
}
+87
View File
@@ -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: [],
}
+15
View File
@@ -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,
}
}
}
})
+513
View File
@@ -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
View File
@@ -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}")
+18
View File
@@ -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
+30
View File
@@ -0,0 +1,30 @@
# 会议记录
## 基本信息
| 项目 | 内容 |
|------|------|
| **会议名称** | {title} |
| **会议日期** | {date} |
| **会议时长** | {duration} |
| **参与人数** | {speaker_count} 人 |
## 与会人员
{participants}
---
## 会议内容
{content}
---
## 摘要
{summary}
---
*本记录由 FunASR 自动生成*