v1.1.1
parent
861d7e3463
commit
27fa9317c5
|
|
@ -319,6 +319,7 @@ async def monitor_tasks(
|
||||||
m.title as meeting_title,
|
m.title as meeting_title,
|
||||||
t.status,
|
t.status,
|
||||||
t.progress,
|
t.progress,
|
||||||
|
t.result,
|
||||||
t.error_message,
|
t.error_message,
|
||||||
t.created_at,
|
t.created_at,
|
||||||
t.completed_at,
|
t.completed_at,
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ from typing import Optional, Dict, Any, List
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import redis
|
import redis
|
||||||
from app.core.config import REDIS_CONFIG, TRANSCRIPTION_POLL_CONFIG, BACKGROUND_TASK_CONFIG, AUDIO_DIR
|
from app.core.config import REDIS_CONFIG, TRANSCRIPTION_POLL_CONFIG, BACKGROUND_TASK_CONFIG, AUDIO_DIR, BASE_DIR
|
||||||
from app.core.database import get_db_connection
|
from app.core.database import get_db_connection
|
||||||
from app.services.async_transcription_service import AsyncTranscriptionService
|
from app.services.async_transcription_service import AsyncTranscriptionService
|
||||||
from app.services.background_task_runner import KeyedBackgroundTaskRunner
|
from app.services.background_task_runner import KeyedBackgroundTaskRunner
|
||||||
|
|
@ -164,7 +164,7 @@ class AsyncMeetingService:
|
||||||
|
|
||||||
# 6. 导出MD文件到音频同目录
|
# 6. 导出MD文件到音频同目录
|
||||||
self._update_task_status_in_redis(task_id, 'processing', 95, message="导出Markdown文件...")
|
self._update_task_status_in_redis(task_id, 'processing', 95, message="导出Markdown文件...")
|
||||||
md_path = self._export_summary_md(meeting_id, summary_content)
|
md_path = self._export_summary_md(meeting_id, summary_content, task_id=task_id)
|
||||||
|
|
||||||
# 7. 任务完成,result保存MD文件路径
|
# 7. 任务完成,result保存MD文件路径
|
||||||
self._update_task_in_db(task_id, 'completed', 100, result=md_path)
|
self._update_task_in_db(task_id, 'completed', 100, result=md_path)
|
||||||
|
|
@ -337,8 +337,8 @@ class AsyncMeetingService:
|
||||||
print(f"使用模型 {model_code} 调用失败: {e}")
|
print(f"使用模型 {model_code} 调用失败: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _export_summary_md(self, meeting_id: int, summary_content: str) -> Optional[str]:
|
def _export_summary_md(self, meeting_id: int, summary_content: str, task_id: Optional[str] = None) -> Optional[str]:
|
||||||
"""将总结内容导出为MD文件,保存到音频同目录,返回文件路径"""
|
"""将总结内容导出为MD文件,保存到音频同目录,返回 /uploads/... 相对路径"""
|
||||||
try:
|
try:
|
||||||
with get_db_connection() as connection:
|
with get_db_connection() as connection:
|
||||||
cursor = connection.cursor(dictionary=True)
|
cursor = connection.cursor(dictionary=True)
|
||||||
|
|
@ -348,16 +348,9 @@ class AsyncMeetingService:
|
||||||
audio = cursor.fetchone()
|
audio = cursor.fetchone()
|
||||||
|
|
||||||
title = meeting['title'] if meeting else f"meeting_{meeting_id}"
|
title = meeting['title'] if meeting else f"meeting_{meeting_id}"
|
||||||
# 始终以 AUDIO_DIR 为基准,避免数据库中的绝对路径指向不可写目录
|
|
||||||
if audio and audio.get('file_path'):
|
if audio and audio.get('file_path'):
|
||||||
audio_path = Path(audio['file_path'])
|
audio_path = BASE_DIR / str(audio['file_path']).lstrip('/')
|
||||||
# 提取 meeting_id 层级的子目录(如 "226" 或 "226/sub")
|
md_dir = audio_path.parent
|
||||||
try:
|
|
||||||
rel = audio_path.relative_to(AUDIO_DIR)
|
|
||||||
md_dir = AUDIO_DIR / rel.parent
|
|
||||||
except ValueError:
|
|
||||||
# file_path 不在 AUDIO_DIR 下(如 Docker 绝对路径),取最后一级目录名
|
|
||||||
md_dir = AUDIO_DIR / audio_path.parent.name
|
|
||||||
else:
|
else:
|
||||||
md_dir = AUDIO_DIR / str(meeting_id)
|
md_dir = AUDIO_DIR / str(meeting_id)
|
||||||
|
|
||||||
|
|
@ -365,11 +358,15 @@ class AsyncMeetingService:
|
||||||
safe_title = "".join(c for c in title if c.isalnum() or c in (' ', '-', '_', '.')).strip()
|
safe_title = "".join(c for c in title if c.isalnum() or c in (' ', '-', '_', '.')).strip()
|
||||||
if not safe_title:
|
if not safe_title:
|
||||||
safe_title = f"meeting_{meeting_id}"
|
safe_title = f"meeting_{meeting_id}"
|
||||||
md_path = md_dir / f"{safe_title}_总结.md"
|
timestamp_suffix = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
task_suffix = ""
|
||||||
|
if task_id:
|
||||||
|
task_suffix = f"_{str(task_id).replace('-', '')[:8]}"
|
||||||
|
md_path = md_dir / f"{safe_title}_总结_{timestamp_suffix}{task_suffix}.md"
|
||||||
md_path.write_text(summary_content, encoding='utf-8')
|
md_path.write_text(summary_content, encoding='utf-8')
|
||||||
md_path_str = str(md_path)
|
relative_md_path = "/" + str(md_path.relative_to(BASE_DIR)).replace("\\", "/")
|
||||||
print(f"总结MD文件已保存: {md_path_str}")
|
print(f"总结MD文件已保存: {relative_md_path}")
|
||||||
return md_path_str
|
return relative_md_path
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"导出总结MD文件失败: {e}")
|
print(f"导出总结MD文件失败: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
-- 将 llm_tasks.result 统一为 /uploads/... 相对路径
|
||||||
|
-- 仅处理能明确识别出 /uploads/ 前缀的历史绝对路径记录
|
||||||
|
|
||||||
|
UPDATE `llm_tasks`
|
||||||
|
SET `result` = SUBSTRING(`result`, LOCATE('/uploads/', `result`))
|
||||||
|
WHERE `result` IS NOT NULL
|
||||||
|
AND `result` <> ''
|
||||||
|
AND `result` NOT LIKE '/uploads/%'
|
||||||
|
AND LOCATE('/uploads/', `result`) > 0;
|
||||||
|
|
@ -7,4 +7,6 @@
|
||||||
# 升级前确认
|
# 升级前确认
|
||||||
+ 后端运行环境需提供 `ffmpeg` 与 `ffprobe`
|
+ 后端运行环境需提供 `ffmpeg` 与 `ffprobe`
|
||||||
+ 本次数据库升级包含 `backend/sql/migrations/cleanup_audio_model_config_and_drop_legacy_ai_tables.sql`
|
+ 本次数据库升级包含 `backend/sql/migrations/cleanup_audio_model_config_and_drop_legacy_ai_tables.sql`
|
||||||
|
+ 本次数据库升级还需执行 `backend/sql/migrations/normalize_llm_task_result_paths.sql`
|
||||||
+ 升级后 `audio_model_config` 将新增 `request_timeout_seconds`,并清理旧的 ASR/声纹冗余列
|
+ 升级后 `audio_model_config` 将新增 `request_timeout_seconds`,并清理旧的 ASR/声纹冗余列
|
||||||
|
+ 升级后 `llm_tasks.result` 将统一为 `/uploads/...` 相对路径,与音频文件路径保持一致
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ export default function useAdminDashboardPage() {
|
||||||
const [showMeetingModal, setShowMeetingModal] = useState(false);
|
const [showMeetingModal, setShowMeetingModal] = useState(false);
|
||||||
const [meetingDetails, setMeetingDetails] = useState(null);
|
const [meetingDetails, setMeetingDetails] = useState(null);
|
||||||
const [meetingLoading, setMeetingLoading] = useState(false);
|
const [meetingLoading, setMeetingLoading] = useState(false);
|
||||||
|
const [selectedTaskRecord, setSelectedTaskRecord] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mountedRef.current = true;
|
mountedRef.current = true;
|
||||||
|
|
@ -155,9 +156,10 @@ export default function useAdminDashboardPage() {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleViewMeeting = async (meetingId) => {
|
const handleViewMeeting = async (meetingId, taskRecord = null) => {
|
||||||
setMeetingLoading(true);
|
setMeetingLoading(true);
|
||||||
setShowMeetingModal(true);
|
setShowMeetingModal(true);
|
||||||
|
setSelectedTaskRecord(taskRecord);
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.DETAIL(meetingId)));
|
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.DETAIL(meetingId)));
|
||||||
if (response.code === '200') {
|
if (response.code === '200') {
|
||||||
|
|
@ -190,35 +192,45 @@ export default function useAdminDashboardPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDownloadAudio = async (meetingId, audioFilePath) => {
|
const downloadResourceByPath = useCallback((resourcePath, fallbackFileName) => {
|
||||||
try {
|
const normalizedPath = typeof resourcePath === 'string' ? resourcePath.trim() : '';
|
||||||
const response = await fetch(buildApiUrl(`${API_ENDPOINTS.MEETINGS.AUDIO(meetingId)}/stream`), {
|
if (!normalizedPath) {
|
||||||
credentials: 'include',
|
message.error('文件路径不存在');
|
||||||
});
|
return;
|
||||||
if (!response.ok) {
|
}
|
||||||
throw new Error(`audio download failed: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = await response.blob();
|
const href = buildApiUrl(normalizedPath.startsWith('/') ? normalizedPath : `/${normalizedPath}`);
|
||||||
const url = URL.createObjectURL(blob);
|
const fileNameFromPath = normalizedPath.split('/').pop();
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
const fileNameFromPath = audioFilePath?.split('/').pop();
|
link.href = href;
|
||||||
const fallbackExtension = fileNameFromPath?.includes('.') ? '' : '.mp3';
|
link.download = fileNameFromPath || fallbackFileName;
|
||||||
link.href = url;
|
document.body.appendChild(link);
|
||||||
link.download = fileNameFromPath || `meeting_audio_${meetingId}${fallbackExtension}`;
|
link.click();
|
||||||
document.body.appendChild(link);
|
document.body.removeChild(link);
|
||||||
link.click();
|
}, [message]);
|
||||||
document.body.removeChild(link);
|
|
||||||
URL.revokeObjectURL(url);
|
const handleDownloadAudio = (meetingId, audioFilePath) => {
|
||||||
|
try {
|
||||||
|
downloadResourceByPath(audioFilePath, `meeting_audio_${meetingId}.mp3`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('下载音频失败:', error);
|
console.error('下载音频失败:', error);
|
||||||
message.error('下载音频失败');
|
message.error('下载音频失败');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDownloadSummaryResult = (taskId, resultPath) => {
|
||||||
|
try {
|
||||||
|
downloadResourceByPath(resultPath, `summary_${taskId}.md`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('下载总结结果失败:', error);
|
||||||
|
message.error('下载总结结果失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const closeMeetingModal = () => {
|
const closeMeetingModal = () => {
|
||||||
setShowMeetingModal(false);
|
setShowMeetingModal(false);
|
||||||
setMeetingDetails(null);
|
setMeetingDetails(null);
|
||||||
|
setSelectedTaskRecord(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const taskCompletionRate = useMemo(() => {
|
const taskCompletionRate = useMemo(() => {
|
||||||
|
|
@ -246,12 +258,14 @@ export default function useAdminDashboardPage() {
|
||||||
showMeetingModal,
|
showMeetingModal,
|
||||||
meetingDetails,
|
meetingDetails,
|
||||||
meetingLoading,
|
meetingLoading,
|
||||||
|
selectedTaskRecord,
|
||||||
fetchAllData,
|
fetchAllData,
|
||||||
fetchOnlineUsers,
|
fetchOnlineUsers,
|
||||||
handleKickUser,
|
handleKickUser,
|
||||||
handleViewMeeting,
|
handleViewMeeting,
|
||||||
handleDownloadTranscript,
|
handleDownloadTranscript,
|
||||||
handleDownloadAudio,
|
handleDownloadAudio,
|
||||||
|
handleDownloadSummaryResult,
|
||||||
closeMeetingModal,
|
closeMeetingModal,
|
||||||
taskCompletionRate,
|
taskCompletionRate,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -86,11 +86,13 @@ const AdminDashboard = () => {
|
||||||
showMeetingModal,
|
showMeetingModal,
|
||||||
meetingDetails,
|
meetingDetails,
|
||||||
meetingLoading,
|
meetingLoading,
|
||||||
|
selectedTaskRecord,
|
||||||
fetchAllData,
|
fetchAllData,
|
||||||
handleKickUser,
|
handleKickUser,
|
||||||
handleViewMeeting,
|
handleViewMeeting,
|
||||||
handleDownloadTranscript,
|
handleDownloadTranscript,
|
||||||
handleDownloadAudio,
|
handleDownloadAudio,
|
||||||
|
handleDownloadSummaryResult,
|
||||||
closeMeetingModal,
|
closeMeetingModal,
|
||||||
taskCompletionRate,
|
taskCompletionRate,
|
||||||
} = useAdminDashboardPage();
|
} = useAdminDashboardPage();
|
||||||
|
|
@ -172,7 +174,7 @@ const AdminDashboard = () => {
|
||||||
render: (text, record) => (
|
render: (text, record) => (
|
||||||
<Space>
|
<Space>
|
||||||
{record.meeting_id ? (
|
{record.meeting_id ? (
|
||||||
<ActionButton tone="view" variant="iconSm" tooltip="查看详情" icon={<SearchOutlined />} onClick={() => handleViewMeeting(record.meeting_id)} />
|
<ActionButton tone="view" variant="iconSm" tooltip="查看详情" icon={<SearchOutlined />} onClick={() => handleViewMeeting(record.meeting_id, record)} />
|
||||||
) : null}
|
) : null}
|
||||||
<span>{text || '-'}</span>
|
<span>{text || '-'}</span>
|
||||||
</Space>
|
</Space>
|
||||||
|
|
@ -407,25 +409,38 @@ const AdminDashboard = () => {
|
||||||
{meetingDetails.meeting_time ? new Date(meetingDetails.meeting_time).toLocaleString() : '-'}
|
{meetingDetails.meeting_time ? new Date(meetingDetails.meeting_time).toLocaleString() : '-'}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="使用模版">{meetingDetails.prompt_name || '默认模版'}</Descriptions.Item>
|
<Descriptions.Item label="使用模版">{meetingDetails.prompt_name || '默认模版'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="音频信息">
|
{selectedTaskRecord?.task_type !== 'summary' ? (
|
||||||
<Space size="middle">
|
<Descriptions.Item label="音频信息">
|
||||||
<span>{formatAudioDuration(meetingDetails.audio_duration)}</span>
|
<Space size="middle">
|
||||||
{meetingDetails.audio_file_path ? (
|
<span>{formatAudioDuration(meetingDetails.audio_duration)}</span>
|
||||||
<Button
|
{meetingDetails.audio_file_path ? (
|
||||||
type="link"
|
<Button
|
||||||
size="small"
|
type="link"
|
||||||
style={{ padding: 0 }}
|
size="small"
|
||||||
onClick={() => handleDownloadAudio(meetingDetails.meeting_id, meetingDetails.audio_file_path)}
|
style={{ padding: 0 }}
|
||||||
>
|
onClick={() => handleDownloadAudio(meetingDetails.meeting_id, meetingDetails.audio_file_path)}
|
||||||
下载音频
|
>
|
||||||
</Button>
|
下载音频
|
||||||
) : null}
|
</Button>
|
||||||
</Space>
|
) : null}
|
||||||
</Descriptions.Item>
|
</Space>
|
||||||
|
</Descriptions.Item>
|
||||||
|
) : null}
|
||||||
<Descriptions.Item label="操作">
|
<Descriptions.Item label="操作">
|
||||||
<ActionButton tone="view" variant="textLg" icon={<FileTextOutlined />} onClick={() => handleDownloadTranscript(meetingDetails.meeting_id)}>
|
{selectedTaskRecord?.task_type === 'summary' ? (
|
||||||
下载转录结果 (JSON)
|
<ActionButton
|
||||||
</ActionButton>
|
tone="view"
|
||||||
|
variant="textLg"
|
||||||
|
icon={<FileTextOutlined />}
|
||||||
|
onClick={() => handleDownloadSummaryResult(selectedTaskRecord.task_id, selectedTaskRecord.result)}
|
||||||
|
>
|
||||||
|
下载总结结果(md)
|
||||||
|
</ActionButton>
|
||||||
|
) : (
|
||||||
|
<ActionButton tone="view" variant="textLg" icon={<FileTextOutlined />} onClick={() => handleDownloadTranscript(meetingDetails.meeting_id)}>
|
||||||
|
下载转录结果 (JSON)
|
||||||
|
</ActionButton>
|
||||||
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue