x修复前端无法自动总结,修复点击说话人修改问题,新增双击编辑文本内容问题

codex/dev
AlanPaine 2026-04-02 11:07:41 +00:00
parent a3ae293d42
commit cc1817078a
5 changed files with 329 additions and 44 deletions

View File

@ -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(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_id = cursor.fetchone()[0]
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)

View File

@ -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]

View File

@ -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')
}

View File

@ -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);
startSummaryPolling(res.data.task_id, { closeDrawerOnComplete: true });
} catch (error) {
message.error(error?.response?.data?.message || '生成总结失败');
setSummaryLoading(false);
setShowSummaryDrawer(false);
fetchSummaryHistory();
fetchMeetingDetails();
} else if (s.status === 'failed') {
clearInterval(interval);
setSummaryLoading(false);
message.error('生成总结失败');
}
}, 3000);
setSummaryPollInterval(interval);
} catch { 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 }) => {
</div>
<Space style={{ flexShrink: 0 }}>
<Button icon={<SyncOutlined />} onClick={openSummaryDrawer}>重新总结</Button>
<Tooltip title={summaryDisabledReason}>
<span>
<Button icon={<SyncOutlined />} onClick={openSummaryDrawer} disabled={isSummaryActionDisabled}>
重新总结
</Button>
</span>
</Tooltip>
<Button icon={<DownloadOutlined />} onClick={downloadSummaryMd}>下载总结</Button>
<Tooltip title="分享二维码">
<Button icon={<QrcodeOutlined />} onClick={() => setShowQRModal(true)} />
@ -574,16 +764,78 @@ const MeetingDetails = ({ user }) => {
icon={<UserOutlined />}
style={{ backgroundColor: getSpeakerColor(item.speaker_id), flexShrink: 0 }}
/>
{inlineSpeakerEdit === item.speaker_id && inlineSpeakerEditSegmentId === `speaker-${item.speaker_id}-${item.segment_id}` ? (
<Space.Compact onClick={(e) => e.stopPropagation()}>
<Input
size="small"
autoFocus
value={inlineSpeakerValue}
onChange={(e) => setInlineSpeakerValue(e.target.value)}
onPressEnter={saveInlineSpeakerEdit}
style={{ width: 180 }}
/>
<Button
size="small"
type="text"
icon={<CheckOutlined />}
loading={savingInlineEdit}
onClick={saveInlineSpeakerEdit}
/>
<Button
size="small"
type="text"
icon={<CloseOutlined />}
disabled={savingInlineEdit}
onClick={cancelInlineSpeakerEdit}
/>
</Space.Compact>
) : (
<Text
strong
style={{ color: '#1677ff', cursor: 'pointer', fontSize: 13 }}
onClick={e => { e.stopPropagation(); openTranscriptEditDrawer(index); }}
onClick={e => {
e.stopPropagation();
startInlineSpeakerEdit(item.speaker_id, item.speaker_tag, item.segment_id);
}}
>
{item.speaker_tag || `发言人 ${item.speaker_id}`}
<EditOutlined style={{ fontSize: 11, marginLeft: 3 }} />
</Text>
)}
</div>
<Text style={{ fontSize: 14, lineHeight: 1.7, color: '#333' }}>{item.text_content}</Text>
{inlineSegmentEditId === item.segment_id ? (
<div onClick={(e) => e.stopPropagation()}>
<Input.TextArea
autoFocus
autoSize={{ minRows: 2, maxRows: 6 }}
value={inlineSegmentValue}
onChange={(e) => setInlineSegmentValue(e.target.value)}
onPressEnter={(e) => {
if (e.ctrlKey || e.metaKey) {
saveInlineSegmentEdit();
}
}}
/>
<Space style={{ marginTop: 8 }}>
<Button size="small" type="primary" icon={<CheckOutlined />} loading={savingInlineEdit} onClick={saveInlineSegmentEdit}>
保存
</Button>
<Button size="small" icon={<CloseOutlined />} disabled={savingInlineEdit} onClick={cancelInlineSegmentEdit}>
取消
</Button>
</Space>
</div>
) : (
<Text
style={{ fontSize: 14, lineHeight: 1.7, color: '#333', cursor: 'text' }}
onDoubleClick={(e) => {
e.stopPropagation();
startInlineSegmentEdit(item);
}}
>
{item.text_content}
</Text>
)}
</div>
),
};

View File

@ -125,7 +125,7 @@ const PromptConfigPage = ({ user }) => {
items={[
{
key: 'config',
label: '提示词配置',
label: '系统提示词配置',
children: (
<div className="console-tab-panel">
<div className="console-tab-toolbar">