From cc1817078a995e7a43786a7426a90bfe577ebbec Mon Sep 17 00:00:00 2001 From: AlanPaine Date: Thu, 2 Apr 2026 11:07:41 +0000 Subject: [PATCH] =?UTF-8?q?x=E4=BF=AE=E5=A4=8D=E5=89=8D=E7=AB=AF=E6=97=A0?= =?UTF-8?q?=E6=B3=95=E8=87=AA=E5=8A=A8=E6=80=BB=E7=BB=93=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E7=82=B9=E5=87=BB=E8=AF=B4=E8=AF=9D=E4=BA=BA=E4=BF=AE?= =?UTF-8?q?=E6=94=B9=E9=97=AE=E9=A2=98=EF=BC=8C=E6=96=B0=E5=A2=9E=E5=8F=8C?= =?UTF-8?q?=E5=87=BB=E7=BC=96=E8=BE=91=E6=96=87=E6=9C=AC=E5=86=85=E5=AE=B9?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/endpoints/meetings.py | 49 ++- backend/app/models/models.py | 3 +- backend/app/services/async_meeting_service.py | 1 + frontend/src/pages/MeetingDetails.jsx | 318 ++++++++++++++++-- frontend/src/pages/PromptConfigPage.jsx | 2 +- 5 files changed, 329 insertions(+), 44 deletions(-) diff --git a/backend/app/api/endpoints/meetings.py b/backend/app/api/endpoints/meetings.py index 623707f..bf07671 100644 --- a/backend/app/api/endpoints/meetings.py +++ b/backend/app/api/endpoints/meetings.py @@ -370,6 +370,12 @@ def get_meeting_details(meeting_id: int, current_user: dict = Depends(get_curren meeting_data.transcription_status = TranscriptionTaskStatus(**transcription_status_data) except Exception as e: print(f"Warning: Failed to get transcription status for meeting {meeting_id}: {e}") + try: + llm_status_data = async_meeting_service.get_meeting_llm_status(meeting_id) + if llm_status_data: + meeting_data.llm_status = TranscriptionTaskStatus(**llm_status_data) + except Exception as e: + print(f"Warning: Failed to get llm status for meeting {meeting_id}: {e}") return create_api_response(code="200", message="获取会议详情成功", data=meeting_data) @router.get("/meetings/{meeting_id}/transcript") @@ -543,14 +549,21 @@ async def upload_audio( model_code = model_code.strip() if model_code else None - # 0. 如果没有传入 prompt_id,尝试获取默认模版ID + # 0. 如果没有传入 prompt_id,优先使用会议已配置模版,否则回退默认模版 if prompt_id is None: with get_db_connection() as connection: - cursor = connection.cursor() - cursor.execute( - "SELECT id FROM prompts WHERE task_type = 'MEETING_TASK' AND is_default = TRUE AND is_active = TRUE LIMIT 1" - ) - prompt_id = cursor.fetchone()[0] + cursor = connection.cursor(dictionary=True) + cursor.execute("SELECT prompt_id FROM meetings WHERE meeting_id = %s", (meeting_id,)) + meeting_row = cursor.fetchone() + if meeting_row and meeting_row.get('prompt_id') and int(meeting_row['prompt_id']) > 0: + prompt_id = int(meeting_row['prompt_id']) + else: + cursor = connection.cursor() + cursor.execute( + "SELECT id FROM prompts WHERE task_type = 'MEETING_TASK' AND is_default = TRUE AND is_active = TRUE LIMIT 1" + ) + prompt_row = cursor.fetchone() + prompt_id = prompt_row[0] if prompt_row else None # 1. 文件类型验证 file_extension = os.path.splitext(audio_file.filename)[1].lower() @@ -779,12 +792,17 @@ def get_meeting_transcription_status(meeting_id: int, current_user: dict = Depen return create_api_response(code="500", message=f"Failed to get meeting transcription status: {str(e)}") @router.post("/meetings/{meeting_id}/transcription/start") -def start_meeting_transcription(meeting_id: int, current_user: dict = Depends(get_current_user)): +def start_meeting_transcription( + meeting_id: int, + background_tasks: BackgroundTasks, + current_user: dict = Depends(get_current_user) +): try: with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) - cursor.execute("SELECT meeting_id FROM meetings WHERE meeting_id = %s", (meeting_id,)) - if not cursor.fetchone(): + cursor.execute("SELECT meeting_id, prompt_id FROM meetings WHERE meeting_id = %s", (meeting_id,)) + meeting = cursor.fetchone() + if not meeting: return create_api_response(code="404", message="Meeting not found") cursor.execute("SELECT file_path FROM audio_files WHERE meeting_id = %s LIMIT 1", (meeting_id,)) audio_file = cursor.fetchone() @@ -796,6 +814,13 @@ def start_meeting_transcription(meeting_id: int, current_user: dict = Depends(ge "task_id": existing_status['task_id'], "status": existing_status['status'] }) task_id = transcription_service.start_transcription(meeting_id, audio_file['file_path']) + background_tasks.add_task( + async_meeting_service.monitor_and_auto_summarize, + meeting_id, + task_id, + meeting.get('prompt_id') if meeting.get('prompt_id') not in (None, 0) else None, + None + ) return create_api_response(code="200", message="Transcription task started successfully", data={ "task_id": task_id, "meeting_id": meeting_id }) @@ -914,6 +939,12 @@ def generate_meeting_summary_async(meeting_id: int, request: GenerateSummaryRequ cursor.execute("SELECT meeting_id FROM meetings WHERE meeting_id = %s", (meeting_id,)) if not cursor.fetchone(): return create_api_response(code="404", message="Meeting not found") + transcription_status = transcription_service.get_meeting_transcription_status(meeting_id) + if transcription_status and transcription_status.get('status') in ['pending', 'processing']: + return create_api_response(code="409", message="转录进行中,暂不允许重新总结", data={ + "task_id": transcription_status.get('task_id'), + "status": transcription_status.get('status') + }) # 传递 prompt_id 和 model_code 参数给服务层 task_id = async_meeting_service.start_summary_generation(meeting_id, request.user_prompt, request.prompt_id, request.model_code) background_tasks.add_task(async_meeting_service._process_task, task_id) diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 22a8d50..1938628 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -84,6 +84,7 @@ class Meeting(BaseModel): audio_duration: Optional[float] = None summary: Optional[str] = None transcription_status: Optional[TranscriptionTaskStatus] = None + llm_status: Optional[TranscriptionTaskStatus] = None prompt_id: Optional[int] = None prompt_name: Optional[str] = None overall_status: Optional[str] = None @@ -125,7 +126,7 @@ class BatchSpeakerTagUpdateRequest(BaseModel): class TranscriptUpdateRequest(BaseModel): segment_id: int - new_text: str + text_content: str class BatchTranscriptUpdateRequest(BaseModel): updates: List[TranscriptUpdateRequest] diff --git a/backend/app/services/async_meeting_service.py b/backend/app/services/async_meeting_service.py index a44ad3e..297c239 100644 --- a/backend/app/services/async_meeting_service.py +++ b/backend/app/services/async_meeting_service.py @@ -407,6 +407,7 @@ class AsyncMeetingService: 'meeting_id': int(task_data.get('meeting_id', 0)), 'created_at': task_data.get('created_at'), 'updated_at': task_data.get('updated_at'), + 'message': task_data.get('message'), 'result': task_data.get('result'), 'error_message': task_data.get('error_message') } diff --git a/frontend/src/pages/MeetingDetails.jsx b/frontend/src/pages/MeetingDetails.jsx index c22c388..37278f8 100644 --- a/frontend/src/pages/MeetingDetails.jsx +++ b/frontend/src/pages/MeetingDetails.jsx @@ -13,7 +13,7 @@ import { EyeOutlined, FileTextOutlined, PartitionOutlined, SaveOutlined, CloseOutlined, StarFilled, RobotOutlined, DownloadOutlined, - DownOutlined, + DownOutlined, CheckOutlined, MoreOutlined, AudioOutlined } from '@ant-design/icons'; import MarkdownRenderer from '../components/MarkdownRenderer'; @@ -65,6 +65,7 @@ const MeetingDetails = ({ user }) => { const [summaryTaskProgress, setSummaryTaskProgress] = useState(0); const [summaryTaskMessage, setSummaryTaskMessage] = useState(''); const [summaryPollInterval, setSummaryPollInterval] = useState(null); + const [activeSummaryTaskId, setActiveSummaryTaskId] = useState(null); const [llmModels, setLlmModels] = useState([]); const [selectedModelCode, setSelectedModelCode] = useState(null); @@ -87,10 +88,26 @@ const MeetingDetails = ({ user }) => { // 总结内容编辑(同窗口) const [isEditingSummary, setIsEditingSummary] = useState(false); const [editingSummaryContent, setEditingSummaryContent] = useState(''); + const [inlineSpeakerEdit, setInlineSpeakerEdit] = useState(null); + const [inlineSpeakerEditSegmentId, setInlineSpeakerEditSegmentId] = useState(null); + const [inlineSpeakerValue, setInlineSpeakerValue] = useState(''); + const [inlineSegmentEditId, setInlineSegmentEditId] = useState(null); + const [inlineSegmentValue, setInlineSegmentValue] = useState(''); + const [savingInlineEdit, setSavingInlineEdit] = useState(false); const audioRef = useRef(null); const transcriptRefs = useRef([]); const isMeetingOwner = user?.user_id === meeting?.creator_id; + const hasUploadedAudio = Boolean(audioUrl); + const isTranscriptionRunning = ['pending', 'processing'].includes(transcriptionStatus?.status); + const summaryDisabledReason = isUploading + ? '音频上传中,暂不允许重新总结' + : !hasUploadedAudio + ? '请先上传音频后再总结' + : isTranscriptionRunning + ? '转录进行中,完成后会自动总结' + : ''; + const isSummaryActionDisabled = isUploading || !hasUploadedAudio || isTranscriptionRunning || summaryLoading; /* ══════════════════ 数据获取 ══════════════════ */ @@ -109,6 +126,9 @@ 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 || ''); @@ -119,6 +139,17 @@ const MeetingDetails = ({ user }) => { 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); + } } try { @@ -127,6 +158,7 @@ const MeetingDetails = ({ user }) => { } catch { setAudioUrl(null); } fetchTranscript(); + fetchSummaryHistory(); } catch { message.error('加载会议详情失败'); } finally { @@ -160,7 +192,13 @@ const MeetingDetails = ({ user }) => { setTranscriptionProgress(status.progress || 0); if (['completed', 'failed', 'error', 'cancelled'].includes(status.status)) { clearInterval(interval); - if (status.status === 'completed') fetchTranscript(); + if (status.status === 'completed') { + fetchTranscript(); + fetchMeetingDetails(); + setTimeout(() => { + fetchSummaryHistory(); + }, 1000); + } } } catch { clearInterval(interval); } }, 3000); @@ -186,10 +224,66 @@ const MeetingDetails = ({ user }) => { } catch {} }; + const startSummaryPolling = (taskId, options = {}) => { + const { closeDrawerOnComplete = false } = options; + if (!taskId) return; + if (summaryPollInterval && activeSummaryTaskId === taskId) return; + if (summaryPollInterval) clearInterval(summaryPollInterval); + + setActiveSummaryTaskId(taskId); + setSummaryLoading(true); + + const poll = async () => { + try { + const statusRes = await apiClient.get(buildApiUrl(API_ENDPOINTS.TASKS.SUMMARY_STATUS(taskId))); + const status = statusRes.data; + setSummaryTaskProgress(status.progress || 0); + setSummaryTaskMessage(status.message || ''); + + if (status.status === 'completed') { + clearInterval(interval); + setSummaryPollInterval(null); + setActiveSummaryTaskId(null); + setSummaryLoading(false); + if (closeDrawerOnComplete) { + setShowSummaryDrawer(false); + } + fetchSummaryHistory(); + fetchMeetingDetails(); + } else if (status.status === 'failed') { + clearInterval(interval); + setSummaryPollInterval(null); + setActiveSummaryTaskId(null); + setSummaryLoading(false); + message.error(status.error_message || '生成总结失败'); + } + } catch (error) { + clearInterval(interval); + setSummaryPollInterval(null); + setActiveSummaryTaskId(null); + setSummaryLoading(false); + message.error(error?.response?.data?.message || '获取总结状态失败'); + } + }; + + const interval = setInterval(poll, 3000); + setSummaryPollInterval(interval); + poll(); + }; + const fetchSummaryHistory = async () => { try { const res = await apiClient.get(buildApiUrl(`/api/meetings/${meeting_id}/llm-tasks`)); - setSummaryHistory(res.data.tasks?.filter(t => t.status === 'completed') || []); + const tasks = res.data.tasks || []; + setSummaryHistory(tasks.filter(t => t.status === 'completed')); + const latestRunningTask = tasks.find(t => ['pending', 'processing'].includes(t.status)); + if (latestRunningTask) { + startSummaryPolling(latestRunningTask.task_id); + } else if (!activeSummaryTaskId) { + setSummaryLoading(false); + setSummaryTaskProgress(0); + setSummaryTaskMessage(''); + } } catch {} }; @@ -217,6 +311,12 @@ const MeetingDetails = ({ user }) => { formData.append('audio_file', file); formData.append('meeting_id', meeting_id); formData.append('force_replace', 'true'); + if (meeting?.prompt_id) { + formData.append('prompt_id', String(meeting.prompt_id)); + } + if (selectedModelCode) { + formData.append('model_code', selectedModelCode); + } setIsUploading(true); try { await apiClient.post(buildApiUrl(API_ENDPOINTS.MEETINGS.UPLOAD_AUDIO), formData); @@ -264,6 +364,81 @@ const MeetingDetails = ({ user }) => { document.getElementById('audio-upload-input')?.click(); }; + const startInlineSpeakerEdit = (speakerId, currentTag, segmentId) => { + setInlineSpeakerEdit(speakerId); + setInlineSpeakerEditSegmentId(`speaker-${speakerId}-${segmentId}`); + setInlineSpeakerValue(currentTag || `发言人 ${speakerId}`); + }; + + const cancelInlineSpeakerEdit = () => { + setInlineSpeakerEdit(null); + setInlineSpeakerEditSegmentId(null); + setInlineSpeakerValue(''); + }; + + const saveInlineSpeakerEdit = async () => { + if (inlineSpeakerEdit == null) return; + const nextTag = inlineSpeakerValue.trim(); + if (!nextTag) { + message.warning('发言人名称不能为空'); + return; + } + setSavingInlineEdit(true); + try { + await apiClient.put(buildApiUrl(`/api/meetings/${meeting_id}/speaker-tags/batch`), { + updates: [{ speaker_id: inlineSpeakerEdit, new_tag: nextTag }] + }); + setTranscript(prev => prev.map(item => ( + item.speaker_id === inlineSpeakerEdit + ? { ...item, speaker_tag: nextTag } + : item + ))); + setSpeakerList(prev => prev.map(item => ( + item.speaker_id === inlineSpeakerEdit + ? { ...item, speaker_tag: nextTag } + : item + ))); + setEditingSpeakers(prev => ({ ...prev, [inlineSpeakerEdit]: nextTag })); + message.success('发言人名称已更新'); + cancelInlineSpeakerEdit(); + } catch (error) { + message.error(error?.response?.data?.message || '更新发言人名称失败'); + } finally { + setSavingInlineEdit(false); + } + }; + + const startInlineSegmentEdit = (segment) => { + setInlineSegmentEditId(segment.segment_id); + setInlineSegmentValue(segment.text_content || ''); + }; + + const cancelInlineSegmentEdit = () => { + setInlineSegmentEditId(null); + setInlineSegmentValue(''); + }; + + const saveInlineSegmentEdit = async () => { + if (inlineSegmentEditId == null) return; + setSavingInlineEdit(true); + try { + await apiClient.put(buildApiUrl(`/api/meetings/${meeting_id}/transcript/batch`), { + updates: [{ segment_id: inlineSegmentEditId, text_content: inlineSegmentValue }] + }); + setTranscript(prev => prev.map(item => ( + item.segment_id === inlineSegmentEditId + ? { ...item, text_content: inlineSegmentValue } + : item + ))); + message.success('转录内容已更新'); + cancelInlineSegmentEdit(); + } catch (error) { + message.error(error?.response?.data?.message || '更新转录内容失败'); + } finally { + setSavingInlineEdit(false); + } + }; + const changePlaybackRate = (nextRate) => { setPlaybackRate(nextRate); if (audioRef.current) { @@ -298,6 +473,18 @@ const MeetingDetails = ({ user }) => { }; const generateSummary = async () => { + if (isUploading) { + message.warning('音频上传中,暂不允许重新总结'); + return; + } + if (!hasUploadedAudio) { + message.warning('请先上传音频后再总结'); + return; + } + if (isTranscriptionRunning) { + message.warning('转录进行中,暂不允许重新总结'); + return; + } setSummaryLoading(true); setSummaryTaskProgress(0); try { @@ -306,29 +493,26 @@ const MeetingDetails = ({ user }) => { prompt_id: selectedPromptId, model_code: selectedModelCode }); - const taskId = res.data.task_id; - const interval = setInterval(async () => { - const statusRes = await apiClient.get(buildApiUrl(API_ENDPOINTS.TASKS.SUMMARY_STATUS(taskId))); - const s = statusRes.data; - setSummaryTaskProgress(s.progress || 0); - setSummaryTaskMessage(s.message); - if (s.status === 'completed') { - clearInterval(interval); - setSummaryLoading(false); - setShowSummaryDrawer(false); - fetchSummaryHistory(); - fetchMeetingDetails(); - } else if (s.status === 'failed') { - clearInterval(interval); - setSummaryLoading(false); - message.error('生成总结失败'); - } - }, 3000); - setSummaryPollInterval(interval); - } catch { setSummaryLoading(false); } + startSummaryPolling(res.data.task_id, { closeDrawerOnComplete: true }); + } catch (error) { + message.error(error?.response?.data?.message || '生成总结失败'); + setSummaryLoading(false); + } }; const openSummaryDrawer = () => { + if (isUploading) { + message.warning('音频上传中,暂不允许重新总结'); + return; + } + if (!hasUploadedAudio) { + message.warning('请先上传音频后再总结'); + return; + } + if (isTranscriptionRunning) { + message.warning('转录进行中,完成后会自动总结'); + return; + } setShowSummaryDrawer(true); fetchSummaryHistory(); }; @@ -444,7 +628,13 @@ const MeetingDetails = ({ user }) => { - + + + + + + + + + ) : ( + { + e.stopPropagation(); + startInlineSegmentEdit(item); + }} + > + {item.text_content} + + )} ), }; diff --git a/frontend/src/pages/PromptConfigPage.jsx b/frontend/src/pages/PromptConfigPage.jsx index 9d17650..68fe726 100644 --- a/frontend/src/pages/PromptConfigPage.jsx +++ b/frontend/src/pages/PromptConfigPage.jsx @@ -125,7 +125,7 @@ const PromptConfigPage = ({ user }) => { items={[ { key: 'config', - label: '提示词配置', + label: '系统提示词配置', children: (