feat(meeting-details): 增强会议详情页的异步任务状态展示

- 在转录状态模型中添加 message 字段,用于传递任务状态信息
- 重构异步会议服务,支持提示词模板变量替换(会议标题、时间、创建者、参会者)
- 改进前端状态管理,添加上传进度显示和 AI 总结引导轮询
- 优化任务状态轮询逻辑,避免重复请求并提供更流畅的用户体验
codex/dev
AlanPaine 2026-04-07 06:04:56 +00:00
parent ac9c2f5fd4
commit abc5342258
4 changed files with 412 additions and 64 deletions

View File

@ -262,7 +262,8 @@ def _build_task_status_model(task_record: Optional[Dict[str, Any]]) -> Optional[
return TranscriptionTaskStatus(
task_id=task_record.get('task_id'),
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):
@ -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 = [{'user_id': row['user_id'], 'caption': row['caption']} for row in attendees_data]
tags = _load_tags_map(cursor, [meeting]).get(meeting_id, [])
transcription_task = _load_latest_task_record(cursor, meeting_id, 'transcription')
llm_task = _load_latest_task_record(cursor, meeting_id, 'llm')
transcription_task = transcription_service.get_meeting_transcription_status(meeting_id)
llm_task = async_meeting_service.get_meeting_llm_status(meeting_id)
overall_status = _build_meeting_overall_status(transcription_task, llm_task)
cursor.close()

View File

@ -5,6 +5,7 @@
import uuid
import time
import os
import re
from datetime import datetime
from typing import Optional, Dict, Any, List
from pathlib import Path
@ -96,7 +97,7 @@ class AsyncMeetingService:
# 3. 构建提示词
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支持指定模型
self._update_task_status_in_redis(task_id, 'processing', 50, message="AI正在分析会议内容...")
@ -345,23 +346,128 @@ class AsyncMeetingService:
print(f"获取会议转录内容错误: {e}")
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提示词模板
Args:
meeting_id: 会议ID
transcript_text: 会议转录文本
user_prompt: 用户额外提示词
prompt_id: 可选的提示词模版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"
if user_prompt:
prompt += f"用户额外要求:{user_prompt}\n\n"
if rendered_user_prompt:
prompt += f"用户额外要求:{rendered_user_prompt}\n\n"
prompt += f"会议转录内容:\n{transcript_text}\n\n请根据以上内容生成会议总结:"

View File

@ -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` 支持自定义时间格式
- 未填写的字段可能返回空字符串

View File

@ -107,6 +107,8 @@ const MeetingDetails = ({ user }) => {
const [editDrawerOpen, setEditDrawerOpen] = useState(false);
const [showQRModal, setShowQRModal] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadStatusMessage, setUploadStatusMessage] = useState('');
const [playbackRate, setPlaybackRate] = useState(1);
const [accessPasswordEnabled, setAccessPasswordEnabled] = useState(false);
const [accessPasswordDraft, setAccessPasswordDraft] = useState('');
@ -131,11 +133,16 @@ const MeetingDetails = ({ user }) => {
const transcriptRefs = useRef([]);
const statusCheckIntervalRef = useRef(null);
const summaryPollIntervalRef = useRef(null);
const summaryBootstrapTimeoutRef = useRef(null);
const activeSummaryTaskIdRef = useRef(null);
const isMeetingOwner = user?.user_id === meeting?.creator_id;
const creatorName = meeting?.creator_username || '未知创建人';
const hasUploadedAudio = Boolean(audioUrl);
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
? '音频上传中,暂不允许重新总结'
: !hasUploadedAudio
@ -153,6 +160,7 @@ const MeetingDetails = ({ user }) => {
return () => {
if (statusCheckIntervalRef.current) clearInterval(statusCheckIntervalRef.current);
if (summaryPollIntervalRef.current) clearInterval(summaryPollIntervalRef.current);
if (summaryBootstrapTimeoutRef.current) clearTimeout(summaryBootstrapTimeoutRef.current);
};
}, [meeting_id]);
@ -192,6 +200,103 @@ const MeetingDetails = ({ user }) => {
transcriptRefs.current[currentHighlightIndex]?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, [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 { showPageLoading = true } = options;
try {
@ -199,43 +304,11 @@ const MeetingDetails = ({ user }) => {
setLoading(true);
}
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.DETAIL(meeting_id)));
setMeeting(response.data);
if (response.data.prompt_id) {
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);
applyMeetingDetailsState(response.data);
return response.data;
} catch {
message.error('加载会议详情失败');
return null;
} finally {
if (showPageLoading) {
setLoading(false);
@ -283,6 +356,7 @@ const MeetingDetails = ({ user }) => {
const status = res.data;
setTranscriptionStatus(status);
setTranscriptionProgress(status.progress || 0);
setMeeting(prev => (prev ? { ...prev, transcription_status: status } : prev));
if (['completed', 'failed', 'error', 'cancelled'].includes(status.status)) {
clearInterval(interval);
statusCheckIntervalRef.current = null;
@ -348,7 +422,8 @@ const MeetingDetails = ({ user }) => {
const statusRes = await apiClient.get(buildApiUrl(API_ENDPOINTS.TASKS.SUMMARY_STATUS(taskId)));
const status = statusRes.data;
setSummaryTaskProgress(status.progress || 0);
setSummaryTaskMessage(status.message || '');
setSummaryTaskMessage(status.message || 'AI 正在分析会议内容...');
setMeeting(prev => (prev ? { ...prev, llm_status: status } : prev));
if (status.status === 'completed') {
clearInterval(interval);
@ -424,16 +499,33 @@ const MeetingDetails = ({ user }) => {
formData.append('model_code', selectedModelCode);
}
setIsUploading(true);
setUploadProgress(0);
setUploadStatusMessage('正在上传音频文件...');
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('音频上传成功');
setTranscript([]);
setSpeakerList([]);
setEditingSpeakers({});
fetchMeetingDetails({ showPageLoading: false });
fetchTranscript();
} catch { message.error('上传失败'); }
finally { setIsUploading(false); }
await fetchMeetingDetails({ showPageLoading: false });
await fetchTranscript();
} catch (error) {
message.error(error?.response?.data?.message || error?.response?.data?.detail || '上传失败');
throw error;
} finally {
setIsUploading(false);
setUploadProgress(0);
setUploadStatusMessage('');
}
};
const saveAccessPassword = async () => {
@ -727,7 +819,7 @@ const MeetingDetails = ({ user }) => {
];
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 },
];
@ -801,14 +893,56 @@ const MeetingDetails = ({ user }) => {
</div>
</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) && (
<Card variant="borderless" className="console-surface" style={{ marginBottom: 16 }} styles={{ body: { padding: '12px 24px' } }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<Text><SyncOutlined spin style={{ marginRight: 8 }} />正在转录中...</Text>
<Text>{transcriptionProgress}%</Text>
<Text>{displayTranscriptionProgress}%</Text>
</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>
)}
@ -816,7 +950,14 @@ const MeetingDetails = ({ user }) => {
<Upload
id="audio-upload-input"
showUploadList={false}
customRequest={({ file }) => handleUploadAudio(file)}
customRequest={async ({ file, onSuccess, onError }) => {
try {
await handleUploadAudio(file);
onSuccess?.({}, file);
} catch (error) {
onError?.(error);
}
}}
style={{ display: 'none' }}
>
<span />
@ -1081,18 +1222,6 @@ const MeetingDetails = ({ user }) => {
/>
)}
</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>
),
},