Merge remote-tracking branch 'origin/codex/dev' into codex/dev
commit
3c2ac639b4
|
|
@ -262,7 +262,8 @@ def _build_task_status_model(task_record: Optional[Dict[str, Any]]) -> Optional[
|
||||||
return TranscriptionTaskStatus(
|
return TranscriptionTaskStatus(
|
||||||
task_id=task_record.get('task_id'),
|
task_id=task_record.get('task_id'),
|
||||||
status=task_record.get('status', 'pending') or 'pending',
|
status=task_record.get('status', 'pending') or 'pending',
|
||||||
progress=int(task_record.get('progress') or 0)
|
progress=int(task_record.get('progress') or 0),
|
||||||
|
message=task_record.get('message')
|
||||||
)
|
)
|
||||||
|
|
||||||
def _verify_meeting_owner(cursor, meeting_id: int, current_user_id: int):
|
def _verify_meeting_owner(cursor, meeting_id: int, current_user_id: int):
|
||||||
|
|
@ -499,8 +500,8 @@ def get_meeting_details(meeting_id: int, current_user: dict = Depends(get_curren
|
||||||
attendees_data = _load_attendees_map(cursor, [meeting_id]).get(meeting_id, [])
|
attendees_data = _load_attendees_map(cursor, [meeting_id]).get(meeting_id, [])
|
||||||
attendees = [{'user_id': row['user_id'], 'caption': row['caption']} for row in attendees_data]
|
attendees = [{'user_id': row['user_id'], 'caption': row['caption']} for row in attendees_data]
|
||||||
tags = _load_tags_map(cursor, [meeting]).get(meeting_id, [])
|
tags = _load_tags_map(cursor, [meeting]).get(meeting_id, [])
|
||||||
transcription_task = _load_latest_task_record(cursor, meeting_id, 'transcription')
|
transcription_task = transcription_service.get_meeting_transcription_status(meeting_id)
|
||||||
llm_task = _load_latest_task_record(cursor, meeting_id, 'llm')
|
llm_task = async_meeting_service.get_meeting_llm_status(meeting_id)
|
||||||
overall_status = _build_meeting_overall_status(transcription_task, llm_task)
|
overall_status = _build_meeting_overall_status(transcription_task, llm_task)
|
||||||
cursor.close()
|
cursor.close()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
import uuid
|
import uuid
|
||||||
import time
|
import time
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, Dict, Any, List
|
from typing import Optional, Dict, Any, List
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -96,7 +97,7 @@ class AsyncMeetingService:
|
||||||
|
|
||||||
# 3. 构建提示词
|
# 3. 构建提示词
|
||||||
self._update_task_status_in_redis(task_id, 'processing', 40, message="准备AI提示词...")
|
self._update_task_status_in_redis(task_id, 'processing', 40, message="准备AI提示词...")
|
||||||
full_prompt = self._build_prompt(transcript_text, user_prompt, prompt_id)
|
full_prompt = self._build_prompt(meeting_id, transcript_text, user_prompt, prompt_id)
|
||||||
|
|
||||||
# 4. 调用LLM API(支持指定模型)
|
# 4. 调用LLM API(支持指定模型)
|
||||||
self._update_task_status_in_redis(task_id, 'processing', 50, message="AI正在分析会议内容...")
|
self._update_task_status_in_redis(task_id, 'processing', 50, message="AI正在分析会议内容...")
|
||||||
|
|
@ -345,23 +346,128 @@ class AsyncMeetingService:
|
||||||
print(f"获取会议转录内容错误: {e}")
|
print(f"获取会议转录内容错误: {e}")
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
def _build_prompt(self, transcript_text: str, user_prompt: str, prompt_id: Optional[int] = None) -> str:
|
def _get_meeting_prompt_context(self, meeting_id: int) -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
with get_db_connection() as connection:
|
||||||
|
cursor = connection.cursor(dictionary=True)
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT m.title, m.meeting_time, u.caption AS creator_name
|
||||||
|
FROM meetings m
|
||||||
|
LEFT JOIN sys_users u ON m.user_id = u.user_id
|
||||||
|
WHERE m.meeting_id = %s
|
||||||
|
""",
|
||||||
|
(meeting_id,)
|
||||||
|
)
|
||||||
|
meeting = cursor.fetchone()
|
||||||
|
|
||||||
|
if not meeting:
|
||||||
|
cursor.close()
|
||||||
|
return {}
|
||||||
|
|
||||||
|
meeting_time = meeting.get('meeting_time')
|
||||||
|
meeting_time_text = ''
|
||||||
|
if isinstance(meeting_time, datetime):
|
||||||
|
meeting_time_text = meeting_time.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
elif meeting_time:
|
||||||
|
meeting_time_text = str(meeting_time)
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT u.caption
|
||||||
|
FROM attendees a
|
||||||
|
JOIN sys_users u ON a.user_id = u.user_id
|
||||||
|
WHERE a.meeting_id = %s
|
||||||
|
ORDER BY u.caption ASC
|
||||||
|
""",
|
||||||
|
(meeting_id,)
|
||||||
|
)
|
||||||
|
attendee_rows = cursor.fetchall()
|
||||||
|
attendee_names = [row.get('caption') for row in attendee_rows if row.get('caption')]
|
||||||
|
attendee_text = '、'.join(attendee_names)
|
||||||
|
cursor.close()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'title': meeting.get('title') or '',
|
||||||
|
'meeting_time': meeting_time_text,
|
||||||
|
'meeting_time_value': meeting_time,
|
||||||
|
'creator_name': meeting.get('creator_name') or '',
|
||||||
|
'attendees': attendee_text
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
print(f"获取会议提示词上下文错误: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _format_meeting_time_value(self, meeting_time_value: Any, custom_format: Optional[str] = None) -> str:
|
||||||
|
default_format = '%Y-%m-%d %H:%M:%S'
|
||||||
|
|
||||||
|
if isinstance(meeting_time_value, datetime):
|
||||||
|
try:
|
||||||
|
return meeting_time_value.strftime(custom_format or default_format)
|
||||||
|
except Exception:
|
||||||
|
return meeting_time_value.strftime(default_format)
|
||||||
|
|
||||||
|
if meeting_time_value:
|
||||||
|
meeting_time_text = str(meeting_time_value)
|
||||||
|
if custom_format:
|
||||||
|
try:
|
||||||
|
parsed_time = datetime.fromisoformat(meeting_time_text.replace('Z', '+00:00'))
|
||||||
|
return parsed_time.strftime(custom_format)
|
||||||
|
except Exception:
|
||||||
|
return meeting_time_text
|
||||||
|
return meeting_time_text
|
||||||
|
|
||||||
|
return ''
|
||||||
|
|
||||||
|
def _apply_prompt_variables(self, template: str, variables: Dict[str, Any]) -> str:
|
||||||
|
if not template:
|
||||||
|
return template
|
||||||
|
|
||||||
|
rendered = re.sub(
|
||||||
|
r"\{\{\s*meeting_time\s*:\s*([^{}]+?)\s*\}\}",
|
||||||
|
lambda match: self._format_meeting_time_value(
|
||||||
|
variables.get('meeting_time_value'),
|
||||||
|
match.group(1).strip()
|
||||||
|
),
|
||||||
|
template
|
||||||
|
)
|
||||||
|
|
||||||
|
for key, value in variables.items():
|
||||||
|
if key == 'meeting_time_value':
|
||||||
|
continue
|
||||||
|
rendered = rendered.replace(f"{{{{{key}}}}}", value or '')
|
||||||
|
rendered = rendered.replace(f"{{{{ {key} }}}}", value or '')
|
||||||
|
return rendered
|
||||||
|
|
||||||
|
def _build_prompt(self, meeting_id: int, transcript_text: str, user_prompt: str, prompt_id: Optional[int] = None) -> str:
|
||||||
"""
|
"""
|
||||||
构建完整的提示词
|
构建完整的提示词
|
||||||
使用数据库中配置的MEETING_TASK提示词模板
|
使用数据库中配置的MEETING_TASK提示词模板
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
meeting_id: 会议ID
|
||||||
transcript_text: 会议转录文本
|
transcript_text: 会议转录文本
|
||||||
user_prompt: 用户额外提示词
|
user_prompt: 用户额外提示词
|
||||||
prompt_id: 可选的提示词模版ID,如果不指定则使用默认模版
|
prompt_id: 可选的提示词模版ID,如果不指定则使用默认模版
|
||||||
"""
|
"""
|
||||||
# 从数据库获取会议任务的提示词模板(支持指定prompt_id)
|
# 从数据库获取会议任务的提示词模板(支持指定prompt_id)
|
||||||
system_prompt = self.llm_service.get_task_prompt('MEETING_TASK', prompt_id=prompt_id)
|
system_prompt = self.llm_service.get_task_prompt('MEETING_TASK', prompt_id=prompt_id)
|
||||||
|
meeting_context = self._get_meeting_prompt_context(meeting_id)
|
||||||
|
prompt_variables = {
|
||||||
|
'meeting_id': str(meeting_id),
|
||||||
|
'meeting_title': meeting_context.get('title', ''),
|
||||||
|
'meeting_time': meeting_context.get('meeting_time', ''),
|
||||||
|
'meeting_creator': meeting_context.get('creator_name', ''),
|
||||||
|
'meeting_attendees': meeting_context.get('attendees', ''),
|
||||||
|
'meeting_time_value': meeting_context.get('meeting_time_value')
|
||||||
|
}
|
||||||
|
system_prompt = self._apply_prompt_variables(system_prompt, prompt_variables)
|
||||||
|
rendered_user_prompt = self._apply_prompt_variables(user_prompt, prompt_variables) if user_prompt else ''
|
||||||
|
|
||||||
prompt = f"{system_prompt}\n\n"
|
prompt = f"{system_prompt}\n\n"
|
||||||
|
|
||||||
if user_prompt:
|
if rendered_user_prompt:
|
||||||
prompt += f"用户额外要求:{user_prompt}\n\n"
|
prompt += f"用户额外要求:{rendered_user_prompt}\n\n"
|
||||||
|
|
||||||
prompt += f"会议转录内容:\n{transcript_text}\n\n请根据以上内容生成会议总结:"
|
prompt += f"会议转录内容:\n{transcript_text}\n\n请根据以上内容生成会议总结:"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
# 会议总结模板变量说明
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
会议总结提示词模板支持使用变量占位符。只有在模板内容中显式写入变量时,系统才会替换对应值;未使用的变量不会自动追加到提示词中。
|
||||||
|
|
||||||
|
## 支持的变量
|
||||||
|
|
||||||
|
### 1. 会议 ID
|
||||||
|
|
||||||
|
```text
|
||||||
|
{{meeting_id}}
|
||||||
|
```
|
||||||
|
|
||||||
|
输出当前会议的唯一 ID。
|
||||||
|
|
||||||
|
### 2. 会议标题
|
||||||
|
|
||||||
|
```text
|
||||||
|
{{meeting_title}}
|
||||||
|
```
|
||||||
|
|
||||||
|
输出当前会议标题。
|
||||||
|
|
||||||
|
### 3. 会议时间
|
||||||
|
|
||||||
|
默认格式输出:
|
||||||
|
|
||||||
|
```text
|
||||||
|
{{meeting_time}}
|
||||||
|
```
|
||||||
|
|
||||||
|
默认格式示例:
|
||||||
|
|
||||||
|
```text
|
||||||
|
2026-04-07 14:30:00
|
||||||
|
```
|
||||||
|
|
||||||
|
自定义格式输出:
|
||||||
|
|
||||||
|
```text
|
||||||
|
{{meeting_time:%Y年%m月%d日 %H:%M}}
|
||||||
|
```
|
||||||
|
|
||||||
|
自定义格式示例:
|
||||||
|
|
||||||
|
```text
|
||||||
|
2026年04月07日 14:30
|
||||||
|
```
|
||||||
|
|
||||||
|
如果未设置自定义格式,则使用默认格式。若自定义格式无法解析,系统会回退到默认时间值。
|
||||||
|
|
||||||
|
### 4. 会议创建人
|
||||||
|
|
||||||
|
```text
|
||||||
|
{{meeting_creator}}
|
||||||
|
```
|
||||||
|
|
||||||
|
输出会议创建人名称。
|
||||||
|
|
||||||
|
### 5. 参会人员
|
||||||
|
|
||||||
|
```text
|
||||||
|
{{meeting_attendees}}
|
||||||
|
```
|
||||||
|
|
||||||
|
输出参会人员名称列表,多个名字使用 `、` 连接。
|
||||||
|
|
||||||
|
## 可直接复制的变量列表
|
||||||
|
|
||||||
|
```text
|
||||||
|
{{meeting_id}}
|
||||||
|
{{meeting_title}}
|
||||||
|
{{meeting_time}}
|
||||||
|
{{meeting_time:%Y年%m月%d日 %H:%M}}
|
||||||
|
{{meeting_creator}}
|
||||||
|
{{meeting_attendees}}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 模板示例
|
||||||
|
|
||||||
|
### 示例一:正式会议纪要
|
||||||
|
|
||||||
|
```text
|
||||||
|
请根据会议转录内容生成正式会议纪要。
|
||||||
|
|
||||||
|
会议标题:{{meeting_title}}
|
||||||
|
会议时间:{{meeting_time:%Y年%m月%d日 %H:%M}}
|
||||||
|
创建人:{{meeting_creator}}
|
||||||
|
参会人员:{{meeting_attendees}}
|
||||||
|
|
||||||
|
请输出:
|
||||||
|
1. 会议背景
|
||||||
|
2. 核心讨论
|
||||||
|
3. 决议事项
|
||||||
|
4. 待办事项
|
||||||
|
```
|
||||||
|
|
||||||
|
### 示例二:简洁总结
|
||||||
|
|
||||||
|
```text
|
||||||
|
请用简洁风格总结本次会议。
|
||||||
|
会议:{{meeting_title}}
|
||||||
|
时间:{{meeting_time}}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用规则
|
||||||
|
|
||||||
|
- 变量必须使用双大括号包裹
|
||||||
|
- 仅当模板中写入变量时,系统才会替换对应内容
|
||||||
|
- `meeting_time` 支持自定义时间格式
|
||||||
|
- 未填写的字段可能返回空字符串
|
||||||
|
|
@ -109,6 +109,8 @@ const MeetingDetails = ({ user }) => {
|
||||||
const [editDrawerOpen, setEditDrawerOpen] = useState(false);
|
const [editDrawerOpen, setEditDrawerOpen] = useState(false);
|
||||||
const [showQRModal, setShowQRModal] = useState(false);
|
const [showQRModal, setShowQRModal] = useState(false);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
|
const [uploadStatusMessage, setUploadStatusMessage] = useState('');
|
||||||
const [playbackRate, setPlaybackRate] = useState(1);
|
const [playbackRate, setPlaybackRate] = useState(1);
|
||||||
const [accessPasswordEnabled, setAccessPasswordEnabled] = useState(false);
|
const [accessPasswordEnabled, setAccessPasswordEnabled] = useState(false);
|
||||||
const [accessPasswordDraft, setAccessPasswordDraft] = useState('');
|
const [accessPasswordDraft, setAccessPasswordDraft] = useState('');
|
||||||
|
|
@ -133,11 +135,16 @@ const MeetingDetails = ({ user }) => {
|
||||||
const transcriptRefs = useRef([]);
|
const transcriptRefs = useRef([]);
|
||||||
const statusCheckIntervalRef = useRef(null);
|
const statusCheckIntervalRef = useRef(null);
|
||||||
const summaryPollIntervalRef = useRef(null);
|
const summaryPollIntervalRef = useRef(null);
|
||||||
|
const summaryBootstrapTimeoutRef = useRef(null);
|
||||||
const activeSummaryTaskIdRef = useRef(null);
|
const activeSummaryTaskIdRef = useRef(null);
|
||||||
const isMeetingOwner = user?.user_id === meeting?.creator_id;
|
const isMeetingOwner = user?.user_id === meeting?.creator_id;
|
||||||
const creatorName = meeting?.creator_username || '未知创建人';
|
const creatorName = meeting?.creator_username || '未知创建人';
|
||||||
const hasUploadedAudio = Boolean(audioUrl);
|
const hasUploadedAudio = Boolean(audioUrl);
|
||||||
const isTranscriptionRunning = ['pending', 'processing'].includes(transcriptionStatus?.status);
|
const isTranscriptionRunning = ['pending', 'processing'].includes(transcriptionStatus?.status);
|
||||||
|
const isSummaryRunning = summaryLoading;
|
||||||
|
const displayUploadProgress = Math.max(0, Math.min(uploadProgress, 100));
|
||||||
|
const displayTranscriptionProgress = Math.max(0, Math.min(transcriptionProgress, 100));
|
||||||
|
const displaySummaryProgress = Math.max(0, Math.min(summaryTaskProgress, 100));
|
||||||
const summaryDisabledReason = isUploading
|
const summaryDisabledReason = isUploading
|
||||||
? '音频上传中,暂不允许重新总结'
|
? '音频上传中,暂不允许重新总结'
|
||||||
: !hasUploadedAudio
|
: !hasUploadedAudio
|
||||||
|
|
@ -155,6 +162,7 @@ const MeetingDetails = ({ user }) => {
|
||||||
return () => {
|
return () => {
|
||||||
if (statusCheckIntervalRef.current) clearInterval(statusCheckIntervalRef.current);
|
if (statusCheckIntervalRef.current) clearInterval(statusCheckIntervalRef.current);
|
||||||
if (summaryPollIntervalRef.current) clearInterval(summaryPollIntervalRef.current);
|
if (summaryPollIntervalRef.current) clearInterval(summaryPollIntervalRef.current);
|
||||||
|
if (summaryBootstrapTimeoutRef.current) clearTimeout(summaryBootstrapTimeoutRef.current);
|
||||||
};
|
};
|
||||||
}, [meeting_id]);
|
}, [meeting_id]);
|
||||||
|
|
||||||
|
|
@ -194,6 +202,103 @@ const MeetingDetails = ({ user }) => {
|
||||||
transcriptRefs.current[currentHighlightIndex]?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
transcriptRefs.current[currentHighlightIndex]?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
}, [currentHighlightIndex, transcriptVisibleCount]);
|
}, [currentHighlightIndex, transcriptVisibleCount]);
|
||||||
|
|
||||||
|
const clearSummaryBootstrapPolling = () => {
|
||||||
|
if (summaryBootstrapTimeoutRef.current) {
|
||||||
|
clearTimeout(summaryBootstrapTimeoutRef.current);
|
||||||
|
summaryBootstrapTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyMeetingDetailsState = (meetingData, options = {}) => {
|
||||||
|
const { allowSummaryBootstrap = true } = options;
|
||||||
|
setMeeting(meetingData);
|
||||||
|
if (meetingData.prompt_id) {
|
||||||
|
setSelectedPromptId(meetingData.prompt_id);
|
||||||
|
}
|
||||||
|
setAccessPasswordEnabled(Boolean(meetingData.access_password));
|
||||||
|
setAccessPasswordDraft(meetingData.access_password || '');
|
||||||
|
|
||||||
|
if (meetingData.transcription_status) {
|
||||||
|
const ts = meetingData.transcription_status;
|
||||||
|
setTranscriptionStatus(ts);
|
||||||
|
setTranscriptionProgress(ts.progress || 0);
|
||||||
|
if (['pending', 'processing'].includes(ts.status) && ts.task_id) {
|
||||||
|
startStatusPolling(ts.task_id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setTranscriptionStatus(null);
|
||||||
|
setTranscriptionProgress(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meetingData.llm_status) {
|
||||||
|
const llmStatus = meetingData.llm_status;
|
||||||
|
clearSummaryBootstrapPolling();
|
||||||
|
setSummaryTaskProgress(llmStatus.progress || 0);
|
||||||
|
setSummaryTaskMessage(
|
||||||
|
llmStatus.message
|
||||||
|
|| (llmStatus.status === 'processing'
|
||||||
|
? 'AI 正在分析会议内容...'
|
||||||
|
: llmStatus.status === 'pending'
|
||||||
|
? 'AI 总结任务排队中...'
|
||||||
|
: '')
|
||||||
|
);
|
||||||
|
if (['pending', 'processing'].includes(llmStatus.status) && llmStatus.task_id) {
|
||||||
|
startSummaryPolling(llmStatus.task_id);
|
||||||
|
} else {
|
||||||
|
setSummaryLoading(false);
|
||||||
|
}
|
||||||
|
} else if (meetingData.transcription_status?.status === 'completed' && !meetingData.summary) {
|
||||||
|
if (!activeSummaryTaskIdRef.current) {
|
||||||
|
setSummaryLoading(true);
|
||||||
|
setSummaryTaskProgress(0);
|
||||||
|
setSummaryTaskMessage('转录完成,正在启动 AI 分析...');
|
||||||
|
}
|
||||||
|
if (allowSummaryBootstrap) {
|
||||||
|
scheduleSummaryBootstrapPolling();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
clearSummaryBootstrapPolling();
|
||||||
|
if (!activeSummaryTaskIdRef.current) {
|
||||||
|
setSummaryLoading(false);
|
||||||
|
setSummaryTaskProgress(0);
|
||||||
|
setSummaryTaskMessage('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasAudioFile = Boolean(meetingData.audio_file_path && String(meetingData.audio_file_path).length > 5);
|
||||||
|
setAudioUrl(hasAudioFile ? buildApiUrl(`${API_ENDPOINTS.MEETINGS.AUDIO(meeting_id)}/stream`) : null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleSummaryBootstrapPolling = (attempt = 0) => {
|
||||||
|
if (summaryPollIntervalRef.current || activeSummaryTaskIdRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearSummaryBootstrapPolling();
|
||||||
|
if (attempt >= 10) {
|
||||||
|
setSummaryLoading(false);
|
||||||
|
setSummaryTaskMessage('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
summaryBootstrapTimeoutRef.current = setTimeout(async () => {
|
||||||
|
summaryBootstrapTimeoutRef.current = null;
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.DETAIL(meeting_id)));
|
||||||
|
const meetingData = response.data;
|
||||||
|
applyMeetingDetailsState(meetingData, { allowSummaryBootstrap: false });
|
||||||
|
if (meetingData.llm_status || meetingData.summary) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (attempt >= 9) {
|
||||||
|
setSummaryLoading(false);
|
||||||
|
setSummaryTaskMessage('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scheduleSummaryBootstrapPolling(attempt + 1);
|
||||||
|
}, attempt === 0 ? 1200 : 2000);
|
||||||
|
};
|
||||||
|
|
||||||
const fetchMeetingDetails = async (options = {}) => {
|
const fetchMeetingDetails = async (options = {}) => {
|
||||||
const { showPageLoading = true } = options;
|
const { showPageLoading = true } = options;
|
||||||
try {
|
try {
|
||||||
|
|
@ -201,43 +306,11 @@ const MeetingDetails = ({ user }) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
}
|
}
|
||||||
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.DETAIL(meeting_id)));
|
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.DETAIL(meeting_id)));
|
||||||
setMeeting(response.data);
|
applyMeetingDetailsState(response.data);
|
||||||
if (response.data.prompt_id) {
|
return response.data;
|
||||||
setSelectedPromptId(response.data.prompt_id);
|
|
||||||
}
|
|
||||||
setAccessPasswordEnabled(Boolean(response.data.access_password));
|
|
||||||
setAccessPasswordDraft(response.data.access_password || '');
|
|
||||||
|
|
||||||
if (response.data.transcription_status) {
|
|
||||||
const ts = response.data.transcription_status;
|
|
||||||
setTranscriptionStatus(ts);
|
|
||||||
setTranscriptionProgress(ts.progress || 0);
|
|
||||||
if (['pending', 'processing'].includes(ts.status)) {
|
|
||||||
startStatusPolling(ts.task_id);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setTranscriptionStatus(null);
|
|
||||||
setTranscriptionProgress(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.data.llm_status) {
|
|
||||||
setSummaryTaskProgress(response.data.llm_status.progress || 0);
|
|
||||||
setSummaryTaskMessage(response.data.llm_status.message || '');
|
|
||||||
if (['pending', 'processing'].includes(response.data.llm_status.status)) {
|
|
||||||
startSummaryPolling(response.data.llm_status.task_id);
|
|
||||||
} else {
|
|
||||||
setSummaryLoading(false);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setSummaryLoading(false);
|
|
||||||
setSummaryTaskProgress(0);
|
|
||||||
setSummaryTaskMessage('');
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasAudio = Boolean(response.data.audio_file_path && String(response.data.audio_file_path).length > 5);
|
|
||||||
setAudioUrl(hasAudio ? buildApiUrl(`${API_ENDPOINTS.MEETINGS.AUDIO(meeting_id)}/stream`) : null);
|
|
||||||
} catch {
|
} catch {
|
||||||
message.error('加载会议详情失败');
|
message.error('加载会议详情失败');
|
||||||
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
if (showPageLoading) {
|
if (showPageLoading) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
@ -285,6 +358,7 @@ const MeetingDetails = ({ user }) => {
|
||||||
const status = res.data;
|
const status = res.data;
|
||||||
setTranscriptionStatus(status);
|
setTranscriptionStatus(status);
|
||||||
setTranscriptionProgress(status.progress || 0);
|
setTranscriptionProgress(status.progress || 0);
|
||||||
|
setMeeting(prev => (prev ? { ...prev, transcription_status: status } : prev));
|
||||||
if (['completed', 'failed', 'error', 'cancelled'].includes(status.status)) {
|
if (['completed', 'failed', 'error', 'cancelled'].includes(status.status)) {
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
statusCheckIntervalRef.current = null;
|
statusCheckIntervalRef.current = null;
|
||||||
|
|
@ -350,7 +424,8 @@ const MeetingDetails = ({ user }) => {
|
||||||
const statusRes = await apiClient.get(buildApiUrl(API_ENDPOINTS.TASKS.SUMMARY_STATUS(taskId)));
|
const statusRes = await apiClient.get(buildApiUrl(API_ENDPOINTS.TASKS.SUMMARY_STATUS(taskId)));
|
||||||
const status = statusRes.data;
|
const status = statusRes.data;
|
||||||
setSummaryTaskProgress(status.progress || 0);
|
setSummaryTaskProgress(status.progress || 0);
|
||||||
setSummaryTaskMessage(status.message || '');
|
setSummaryTaskMessage(status.message || 'AI 正在分析会议内容...');
|
||||||
|
setMeeting(prev => (prev ? { ...prev, llm_status: status } : prev));
|
||||||
|
|
||||||
if (status.status === 'completed') {
|
if (status.status === 'completed') {
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
|
|
@ -426,16 +501,33 @@ const MeetingDetails = ({ user }) => {
|
||||||
formData.append('model_code', selectedModelCode);
|
formData.append('model_code', selectedModelCode);
|
||||||
}
|
}
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
|
setUploadProgress(0);
|
||||||
|
setUploadStatusMessage('正在上传音频文件...');
|
||||||
try {
|
try {
|
||||||
await apiClient.post(buildApiUrl(API_ENDPOINTS.MEETINGS.UPLOAD_AUDIO), formData);
|
await apiClient.post(buildApiUrl(API_ENDPOINTS.MEETINGS.UPLOAD_AUDIO), formData, {
|
||||||
|
onUploadProgress: (progressEvent) => {
|
||||||
|
if (progressEvent.total) {
|
||||||
|
setUploadProgress(Math.min(100, Math.round((progressEvent.loaded * 100) / progressEvent.total)));
|
||||||
|
}
|
||||||
|
setUploadStatusMessage('正在上传音频文件...');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setUploadProgress(100);
|
||||||
|
setUploadStatusMessage('上传完成,正在启动转录任务...');
|
||||||
message.success('音频上传成功');
|
message.success('音频上传成功');
|
||||||
setTranscript([]);
|
setTranscript([]);
|
||||||
setSpeakerList([]);
|
setSpeakerList([]);
|
||||||
setEditingSpeakers({});
|
setEditingSpeakers({});
|
||||||
fetchMeetingDetails({ showPageLoading: false });
|
await fetchMeetingDetails({ showPageLoading: false });
|
||||||
fetchTranscript();
|
await fetchTranscript();
|
||||||
} catch { message.error('上传失败'); }
|
} catch (error) {
|
||||||
finally { setIsUploading(false); }
|
message.error(error?.response?.data?.message || error?.response?.data?.detail || '上传失败');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
setUploadProgress(0);
|
||||||
|
setUploadStatusMessage('');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveAccessPassword = async () => {
|
const saveAccessPassword = async () => {
|
||||||
|
|
@ -729,7 +821,7 @@ const MeetingDetails = ({ user }) => {
|
||||||
];
|
];
|
||||||
|
|
||||||
const audioMoreMenuItems = [
|
const audioMoreMenuItems = [
|
||||||
{ key: 'transcribe', icon: <AudioOutlined />, label: '智能转录', disabled: !audioUrl || transcriptionStatus?.status === 'processing', onClick: handleStartTranscription },
|
{ key: 'transcribe', icon: <AudioOutlined />, label: '智能转录', disabled: !audioUrl || isUploading || transcriptionStatus?.status === 'processing', onClick: handleStartTranscription },
|
||||||
{ key: 'upload', icon: <UploadOutlined />, label: isUploading ? '上传中...' : '上传音频', disabled: isUploading, onClick: openAudioUploadPicker },
|
{ key: 'upload', icon: <UploadOutlined />, label: isUploading ? '上传中...' : '上传音频', disabled: isUploading, onClick: openAudioUploadPicker },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -795,14 +887,56 @@ const MeetingDetails = ({ user }) => {
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{isUploading && (
|
||||||
|
<Card
|
||||||
|
variant="borderless"
|
||||||
|
className="console-surface"
|
||||||
|
style={{ marginBottom: 16, border: '1px solid #91caff', background: 'linear-gradient(135deg, #f0f9ff 0%, #ffffff 100%)' }}
|
||||||
|
styles={{ body: { padding: '14px 24px' } }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
||||||
|
<Space size={10}>
|
||||||
|
<UploadOutlined style={{ color: '#1677ff', fontSize: 16 }} />
|
||||||
|
<div>
|
||||||
|
<Text strong style={{ display: 'block' }}>音频上传中</Text>
|
||||||
|
<Text type="secondary">{uploadStatusMessage || '正在上传音频文件...'}</Text>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
<Text strong style={{ color: '#1677ff' }}>{displayUploadProgress}%</Text>
|
||||||
|
</div>
|
||||||
|
<Progress percent={displayUploadProgress} status="active" strokeColor={{ from: '#69b1ff', to: '#1677ff' }} />
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── 转录进度条 ── */}
|
{/* ── 转录进度条 ── */}
|
||||||
{transcriptionStatus && ['pending', 'processing'].includes(transcriptionStatus.status) && (
|
{transcriptionStatus && ['pending', 'processing'].includes(transcriptionStatus.status) && (
|
||||||
<Card variant="borderless" className="console-surface" style={{ marginBottom: 16 }} styles={{ body: { padding: '12px 24px' } }}>
|
<Card variant="borderless" className="console-surface" style={{ marginBottom: 16 }} styles={{ body: { padding: '12px 24px' } }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||||
<Text><SyncOutlined spin style={{ marginRight: 8 }} />正在转录中...</Text>
|
<Text><SyncOutlined spin style={{ marginRight: 8 }} />正在转录中...</Text>
|
||||||
<Text>{transcriptionProgress}%</Text>
|
<Text>{displayTranscriptionProgress}%</Text>
|
||||||
</div>
|
</div>
|
||||||
<Progress percent={transcriptionProgress} status="active" size="small" />
|
<Progress percent={displayTranscriptionProgress} status="active" size="small" />
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isSummaryRunning && (
|
||||||
|
<Card
|
||||||
|
variant="borderless"
|
||||||
|
className="console-surface"
|
||||||
|
style={{ marginBottom: 16, border: '1px solid #d3adf7', background: 'linear-gradient(135deg, #f9f0ff 0%, #ffffff 100%)' }}
|
||||||
|
styles={{ body: { padding: '14px 24px' } }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
||||||
|
<Space size={10}>
|
||||||
|
<RobotOutlined style={{ color: '#722ed1', fontSize: 16 }} />
|
||||||
|
<div>
|
||||||
|
<Text strong style={{ display: 'block' }}>AI 正在分析会议内容</Text>
|
||||||
|
<Text type="secondary">{summaryTaskMessage || '正在生成会议总结,请稍候...'}</Text>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
<Text strong style={{ color: '#722ed1' }}>{displaySummaryProgress}%</Text>
|
||||||
|
</div>
|
||||||
|
<Progress percent={displaySummaryProgress} status="active" strokeColor={{ from: '#b37feb', to: '#722ed1' }} />
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -810,7 +944,14 @@ const MeetingDetails = ({ user }) => {
|
||||||
<Upload
|
<Upload
|
||||||
id="audio-upload-input"
|
id="audio-upload-input"
|
||||||
showUploadList={false}
|
showUploadList={false}
|
||||||
customRequest={({ file }) => handleUploadAudio(file)}
|
customRequest={async ({ file, onSuccess, onError }) => {
|
||||||
|
try {
|
||||||
|
await handleUploadAudio(file);
|
||||||
|
onSuccess?.({}, file);
|
||||||
|
} catch (error) {
|
||||||
|
onError?.(error);
|
||||||
|
}
|
||||||
|
}}
|
||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
>
|
>
|
||||||
<span />
|
<span />
|
||||||
|
|
@ -941,18 +1082,6 @@ const MeetingDetails = ({ user }) => {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* 总结生成中进度条 */}
|
|
||||||
{summaryLoading && (
|
|
||||||
<div style={{ padding: '0 16px 16px', flexShrink: 0 }}>
|
|
||||||
<Card variant="borderless" style={{ borderRadius: 10, background: '#f6f8fa' }} styles={{ body: { padding: '12px 16px' } }}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
|
||||||
<Text><SyncOutlined spin style={{ marginRight: 6 }} />{summaryTaskMessage || 'AI 正在思考中...'}</Text>
|
|
||||||
<Text>{summaryTaskProgress}%</Text>
|
|
||||||
</div>
|
|
||||||
<Progress percent={summaryTaskProgress} status="active" size="small" />
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue