feat: enhance prompt selector with category tags and improve benchmark task processing

Add visual indicators for prompt categories and source types in the prompt selector component, and refactor benchmark task execution to use Dify streaming analysis with proper error handling and text extraction from Alibaba Cloud transcription results.
This commit is contained in:
2026-03-15 21:38:14 +08:00
parent beea8b45b9
commit 1da5d283a9
11 changed files with 567 additions and 360 deletions

View File

@@ -15,7 +15,13 @@
:value="prompt.id" :value="prompt.id"
> >
<div class="prompt-option"> <div class="prompt-option">
<span class="prompt-option-name">{{ prompt.name }}</span> <div class="prompt-option-left">
<span class="prompt-option-name">{{ prompt.name }}</span>
<span v-if="prompt.category" class="prompt-option-tag category">{{ prompt.category }}</span>
</div>
<span class="prompt-option-tag" :class="prompt.source === 'created' ? 'created' : 'favorite'">
{{ prompt.source === 'created' ? '自建' : '收藏' }}
</span>
</div> </div>
</a-select-option> </a-select-option>
</a-select> </a-select>
@@ -313,6 +319,37 @@ onMounted(() => {
font-weight: 500; font-weight: 500;
} }
.prompt-option-left {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
}
.prompt-option-tag {
font-size: 12px;
padding: 2px 8px;
border-radius: 10px;
white-space: nowrap;
flex-shrink: 0;
}
.prompt-option-tag.category {
background: rgba(24, 144, 255, 0.1);
color: var(--color-primary);
}
.prompt-option-tag.created {
background: rgba(16, 185, 129, 0.1);
color: #10b981;
}
.prompt-option-tag.favorite {
background: rgba(245, 158, 11, 0.1);
color: #f59e0b;
}
/* 标签模式 */ /* 标签模式 */
.prompt-tags-container { .prompt-tags-container {
margin-bottom: 12px; margin-bottom: 12px;

View File

@@ -1,24 +1,16 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import { useRouter } from 'vue-router'
import { resolveId } from '@/utils/url' import { resolveId } from '@/utils/url'
import { exportBenchmarkDataToExcel } from '@/utils/excel' import { exportBenchmarkDataToExcel } from '@/utils/excel'
import { usePromptStore } from '@/stores/prompt'
import GlobalLoading from '@/components/GlobalLoading.vue' import GlobalLoading from '@/components/GlobalLoading.vue'
import TikhubService, { InterfaceType, MethodType } from '@/api/tikhub/index.js' import TikhubService, { InterfaceType, MethodType } from '@/api/tikhub/index.js'
import { useBenchmarkData } from './composables/useBenchmarkData' import { useBenchmarkData } from './composables/useBenchmarkData'
import { useBenchmarkAnalysis } from './composables/useBenchmarkAnalysis' import { useBenchmarkAnalysis } from './composables/useBenchmarkAnalysis'
import { formatTime } from './utils/benchmarkUtils' import { formatTime } from './utils/benchmarkUtils'
import { copyToClipboard } from '@/utils/clipboard'
import BenchmarkForm from './components/BenchmarkForm.vue' import BenchmarkForm from './components/BenchmarkForm.vue'
import BenchmarkTable from './components/BenchmarkTable.vue' import BenchmarkTable from './components/BenchmarkTable.vue'
import BatchAnalyzeModal from './components/BatchAnalyzeModal.vue' import CreateStyleTaskModal from './components/CreateStyleTaskModal.vue'
import SavePromptModal from '@/components/SavePromptModal.vue'
import { BenchmarkTaskApi } from '@/api/benchmarkTask'
const router = useRouter()
const promptStore = usePromptStore()
const { const {
data, data,
@@ -33,10 +25,8 @@ const {
const { const {
loading, loading,
batchAnalyzeLoading,
globalLoading, globalLoading,
globalLoadingText, globalLoadingText,
batchAnalyze,
getVoiceText, getVoiceText,
} = useBenchmarkAnalysis(data, expandedRowKeys, saveTableDataToSession) } = useBenchmarkAnalysis(data, expandedRowKeys, saveTableDataToSession)
@@ -47,12 +37,8 @@ const form = ref({
sort_type: 0, sort_type: 0,
}) })
const modalVisible = ref(false) // 创建风格任务弹窗
const batchPromptMergedText = ref('') const createTaskModalVisible = ref(false)
const batchPromptTextCount = ref(0)
const savePromptModalVisible = ref(false)
const savePromptContent = ref('')
const maxCursor = ref(0) const maxCursor = ref(0)
const hasMore = ref(false) const hasMore = ref(false)
@@ -150,18 +136,6 @@ async function handleExportToExcel() {
} }
} }
async function handleBatchAnalyze() {
try {
await batchAnalyze(selectedRowKeys, async (mergedText, textCount) => {
batchPromptMergedText.value = mergedText
batchPromptTextCount.value = textCount
modalVisible.value = true
})
} finally {
selectedRowKeys.value = []
}
}
async function handleResetForm() { async function handleResetForm() {
form.value = { platform: '抖音', url: '', count: 20, sort_type: 0 } form.value = { platform: '抖音', url: '', count: 20, sort_type: 0 }
maxCursor.value = 0 maxCursor.value = 0
@@ -221,65 +195,34 @@ async function handleLoadMore() {
} }
} }
function validatePrompt(prompt, warningMsg = '没有提示词') { // 获取选中视频的 URL 列表
const isValid = prompt?.trim() function getSelectedVideoUrls() {
if (!isValid) message.warning(warningMsg) return data.value
return !!isValid .filter(item => selectedRowKeys.value.includes(item.id))
.map(row => row.audio_url || row.share_url)
.filter(Boolean)
} }
async function handleCopyBatchPrompt(prompt) { // 打开创建风格任务弹窗
if (!validatePrompt(prompt, '没有提示词可复制')) return function handleCreateAsyncTask() {
const success = await copyToClipboard(prompt)
message[success ? 'success' : 'error'](success ? '提示词已复制到剪贴板' : '复制失败')
}
function handleUseBatchPrompt(prompt) {
if (!validatePrompt(prompt, '暂无批量生成的提示词')) return
promptStore.setPrompt(prompt, { batch: true })
router.push('/content-style/copywriting')
}
function handleOpenSavePromptModal(batchPrompt = null) {
const promptToSave = batchPrompt || batchPromptMergedText.value
if (!validatePrompt(promptToSave, '没有提示词可保存')) return
savePromptContent.value = promptToSave
savePromptModalVisible.value = true
}
// 创建异步任务
async function handleCreateAsyncTask() {
if (!selectedRowKeys.value.length) { if (!selectedRowKeys.value.length) {
message.warning('请先选择要分析的视频') message.warning('请先选择要分析的视频')
return return
} }
if (selectedRowKeys.value.length > 20) {
// 获取选中的视频 URL message.warning('最多只能选择20个视频')
const selectedRows = data.value.filter(item => selectedRowKeys.value.includes(item.id)) return
const videoUrls = selectedRows.map(row => row.audio_url || row.share_url).filter(Boolean) }
if (!getSelectedVideoUrls().length) {
if (!videoUrls.length) {
message.warning('选中的视频没有有效的URL') message.warning('选中的视频没有有效的URL')
return return
} }
createTaskModalVisible.value = true
}
try { // 任务创建成功后清空选择
const response = await BenchmarkTaskApi.createTask({ function handleTaskCreated() {
taskName: `批量分析 ${videoUrls.length} 个视频`, selectedRowKeys.value = []
videoUrls: videoUrls,
})
if (response?.code === 0 || response?.data) {
message.success('任务创建成功!请到任务中心查看进度')
} else {
message.error(response?.message || '创建任务失败')
}
} catch (error) {
console.error('创建任务失败:', error)
message.error('创建任务失败')
}
} }
onMounted(async () => { onMounted(async () => {
@@ -291,63 +234,51 @@ defineOptions({ name: 'ContentStyleBenchmark' })
<template> <template>
<div class="page"> <div class="page">
<a-spin :spinning="batchAnalyzeLoading" tip="批量分析中,请稍候..." wrapperClassName="batch-analyze-spin-wrapper"> <div class="stack">
<div class="stack"> <!-- 表单区域 -->
<!-- 表单区域 --> <BenchmarkForm
<BenchmarkForm v-model="form"
v-model="form" :loading="loading"
:loading="loading" @analyze="handleAnalyzeUser"
@analyze="handleAnalyzeUser" @reset="handleResetForm"
@reset="handleResetForm" />
/>
<!-- 表格区域 --> <!-- 表格区域 -->
<BenchmarkTable <BenchmarkTable
:data="data" :data="data"
v-model:selectedRowKeys="selectedRowKeys" v-model:selectedRowKeys="selectedRowKeys"
:loading="loading" :loading="loading"
:loading-more="loadingMore" :loading-more="loadingMore"
:has-more="hasMore" :has-more="hasMore"
@export="handleExportToExcel" @export="handleExportToExcel"
@batch-analyze="handleBatchAnalyze" @load-more="handleLoadMore"
@load-more="handleLoadMore" @create-async-task="handleCreateAsyncTask"
@create-async-task="handleCreateAsyncTask" />
/>
<section v-if="!data.length" class="card results-card empty-state"> <section v-if="!data.length" class="card results-card empty-state">
<a-empty description="暂无数据,请点击开始分析"> <a-empty description="暂无数据,请点击开始分析">
<template #image> <template #image>
<svg width="120" height="120" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="120" height="120" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="20" y="30" width="80" height="60" rx="4" stroke="currentColor" stroke-width="2" fill="none" opacity="0.3"/> <rect x="20" y="30" width="80" height="60" rx="4" stroke="currentColor" stroke-width="2" fill="none" opacity="0.3"/>
<circle cx="40" cy="50" r="8" fill="currentColor" opacity="0.4"/> <circle cx="40" cy="50" r="8" fill="currentColor" opacity="0.4"/>
<rect x="54" y="47" width="40" height="6" rx="3" fill="currentColor" opacity="0.4"/> <rect x="54" y="47" width="40" height="6" rx="3" fill="currentColor" opacity="0.4"/>
<rect x="54" y="60" width="32" height="6" rx="3" fill="currentColor" opacity="0.4"/> <rect x="54" y="60" width="32" height="6" rx="3" fill="currentColor" opacity="0.4"/>
<line x1="32" y1="75" x2="88" y2="75" stroke="currentColor" stroke-width="2" opacity="0.3"/> <line x1="32" y1="75" x2="88" y2="75" stroke="currentColor" stroke-width="2" opacity="0.3"/>
<line x1="32" y1="82" x2="88" y2="82" stroke="currentColor" stroke-width="2" opacity="0.3"/> <line x1="32" y1="82" x2="88" y2="82" stroke="currentColor" stroke-width="2" opacity="0.3"/>
<line x1="32" y1="89" x2="72" y2="89" stroke="currentColor" stroke-width="2" opacity="0.3"/> <line x1="32" y1="89" x2="72" y2="89" stroke="currentColor" stroke-width="2" opacity="0.3"/>
</svg> </svg>
</template> </template>
</a-empty> </a-empty>
</section> </section>
</div> </div>
</a-spin>
<!-- 批量分析结果弹窗 --> <!-- 创建风格任务弹窗 -->
<BatchAnalyzeModal <CreateStyleTaskModal
v-model:visible="modalVisible" v-model:visible="createTaskModalVisible"
:merged-text="batchPromptMergedText" :video-urls="getSelectedVideoUrls()"
:text-count="batchPromptTextCount" @success="handleTaskCreated"
@copy="handleCopyBatchPrompt"
@save="(prompt) => handleOpenSavePromptModal(prompt)"
@use="handleUseBatchPrompt"
/> />
<!-- 保存提示词弹窗 -->
<SavePromptModal
v-model:visible="savePromptModalVisible"
:prompt-content="savePromptContent"
/>
<!-- 全局 Loading 遮罩 --> <!-- 全局 Loading 遮罩 -->
<GlobalLoading :visible="globalLoading" :text="globalLoadingText" /> <GlobalLoading :visible="globalLoading" :text="globalLoadingText" />
</div> </div>
@@ -399,15 +330,4 @@ defineOptions({ name: 'ContentStyleBenchmark' })
color: var(--color-gray-600); color: var(--color-gray-600);
font-size: var(--font-size-base); font-size: var(--font-size-base);
} }
/* 全屏 Spin 遮罩样式 */
:deep(.batch-analyze-spin-wrapper) {
width: 100%;
min-height: calc(100vh - 120px);
overflow-x: hidden;
}
:deep(.batch-analyze-spin-wrapper .ant-spin-spinning) {
max-height: none;
}
</style> </style>

View File

@@ -11,7 +11,7 @@ defineProps({
hasMore: { type: Boolean, default: false }, hasMore: { type: Boolean, default: false },
}) })
const emit = defineEmits(['update:selectedRowKeys', 'export', 'batchAnalyze', 'loadMore', 'createAsyncTask']) const emit = defineEmits(['update:selectedRowKeys', 'export', 'loadMore', 'createAsyncTask'])
const defaultColumns = [ const defaultColumns = [
{ title: '封面', key: 'cover', dataIndex: 'cover', width: 100 }, { title: '封面', key: 'cover', dataIndex: 'cover', width: 100 },
@@ -56,14 +56,8 @@ function formatNumber(value) {
<GradientButton <GradientButton
:text="`批量分析 (${selectedRowKeys.length}/20)`" :text="`批量分析 (${selectedRowKeys.length}/20)`"
size="small" size="small"
@click="$emit('batchAnalyze')"
:disabled="data.length === 0 || selectedRowKeys.length === 0 || selectedRowKeys.length > 20"
/>
<GradientButton
text="异步任务"
size="small"
@click="$emit('createAsyncTask')" @click="$emit('createAsyncTask')"
:disabled="data.length === 0 || selectedRowKeys.length === 0" :disabled="data.length === 0 || selectedRowKeys.length === 0 || selectedRowKeys.length > 20"
icon="clock-circle" icon="clock-circle"
/> />
</div> </div>

View File

@@ -0,0 +1,135 @@
<script setup>
import { ref, watchEffect } from 'vue'
import { message } from 'ant-design-vue'
import { BenchmarkTaskApi } from '@/api/benchmarkTask'
const props = defineProps({
visible: {
type: Boolean,
default: false,
},
videoUrls: {
type: Array,
default: () => [],
},
})
const emit = defineEmits(['update:visible', 'success'])
const loading = ref(false)
const form = ref({
taskName: '',
promptName: '',
category: '',
})
// 监听 visible 变化,重置表单
watchEffect(() => {
if (props.visible) {
const videoCount = props.videoUrls?.length || 0
form.value = {
taskName: `风格分析 ${videoCount} 个视频`,
promptName: '',
category: '',
}
}
})
async function handleSubmit() {
if (!form.value.taskName.trim()) {
message.warning('请输入任务名称')
return
}
if (!props.videoUrls?.length) {
message.warning('没有选择视频')
return
}
if (!form.value.promptName.trim()) {
message.warning('请输入提示词名称')
return
}
loading.value = true
try {
const payload = {
taskName: form.value.taskName.trim(),
videoUrls: props.videoUrls,
saveAsPrompt: true,
promptName: form.value.promptName.trim(),
}
const response = await BenchmarkTaskApi.createTask(payload)
if (response?.code === 0 || response?.data) {
message.success('任务创建成功!请到任务中心查看进度')
emit('update:visible', false)
emit('success', response.data)
} else {
throw new Error(response?.msg || response?.message || '创建任务失败')
}
} catch (error) {
console.error('创建任务失败:', error)
message.error(error?.message || '创建任务失败')
} finally {
loading.value = false
}
}
function handleCancel() {
emit('update:visible', false)
}
</script>
<template>
<a-modal
:open="visible"
title="创建风格分析任务"
:width="500"
:maskClosable="false"
:confirmLoading="loading"
@ok="handleSubmit"
@cancel="handleCancel"
>
<a-form :model="form" layout="vertical">
<a-form-item label="任务名称" required>
<a-input
v-model:value="form.taskName"
placeholder="请输入任务名称"
:maxlength="100"
/>
</a-form-item>
<a-form-item label="提示词名称" required>
<a-input
v-model:value="form.promptName"
placeholder="任务完成后将保存为提示词"
:maxlength="50"
/>
</a-form-item>
<a-form-item label="分类/标签">
<a-input
v-model:value="form.category"
placeholder="可选:输入分类或标签"
:maxlength="20"
/>
</a-form-item>
<a-form-item label="选中视频">
<a-tag color="blue">{{ videoUrls.length }} 个视频</a-tag>
</a-form-item>
</a-form>
<template #footer>
<a-space>
<a-button @click="handleCancel">取消</a-button>
<a-button type="primary" :loading="loading" @click="handleSubmit">
创建任务
</a-button>
</a-space>
</template>
</a-modal>
</template>

View File

@@ -27,7 +27,7 @@
<script setup> <script setup>
import { ref, computed, defineAsyncComponent, markRaw, onMounted, watch } from 'vue' import { ref, computed, defineAsyncComponent, markRaw, onMounted, watch } from 'vue'
import { VideoCameraOutlined, UserOutlined } from '@ant-design/icons-vue' import { VideoCameraOutlined, UserOutlined, FormOutlined } from '@ant-design/icons-vue'
const STORAGE_KEY = 'task-management-active-tab' const STORAGE_KEY = 'task-management-active-tab'
@@ -50,6 +50,12 @@ const NAV_ITEMS = [
label: '数字人视频任务', label: '数字人视频任务',
icon: UserOutlined, icon: UserOutlined,
component: markRaw(defineAsyncComponent(() => import('../digital-human-task/index.vue'))) component: markRaw(defineAsyncComponent(() => import('../digital-human-task/index.vue')))
},
{
type: 'style-task',
label: '风格任务',
icon: FormOutlined,
component: markRaw(defineAsyncComponent(() => import('../../../task-center/BenchmarkTaskList.vue')))
} }
] ]

View File

@@ -1,68 +1,69 @@
<template> <template>
<BasicLayout title="对标分析任务"> <div class="task-list-container">
<div class="task-list-container"> <!-- 筛选 -->
<!-- 筛选 --> <div class="filter-section">
<div class="filter-section"> <a-select
<a-select v-model:value="filterStatus"
v-model:value="filterStatus" placeholder="全部状态"
placeholder="全部状态" style="width: 150px"
style="width: 150px" allowClear
allowClear @change="handleFilterChange"
@change="handleFilterChange"
>
<a-select-option :value="0">待处理</a-select-option>
<a-select-option :value="1">处理中</a-select-option>
<a-select-option :value="2">成功</a-select-option>
<a-select-option :value="3">失败</a-select-option>
</a-select>
<a-button type="primary" @click="handleRefresh" :loading="loading">刷新</a-button>
</div>
<!-- 列表 -->
<a-table
:columns="columns"
:data-source="taskList"
:loading="loading"
:pagination="pagination"
@change="handleTableChange"
row-key="id"
> >
<template #bodyCell="{ column, record }"> <a-select-option :value="0">待处理</a-select-option>
<template v-if="column.key === 'status'"> <a-select-option :value="1">处理中</a-select-option>
<a-tag :color="getStatusColor(record.status)">{{ getStatusText(record.status) }}</a-tag> <a-select-option :value="2">成功</a-select-option>
</template> <a-select-option :value="3">失败</a-select-option>
<template v-else-if="column.key === 'progress'"> </a-select>
<a-progress :percent="record.progress" :status="getProgressStatus(record.status)" size="small" /> <a-button type="primary" @click="handleRefresh" :loading="loading">刷新</a-button>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button v-if="record.status === 2 && record.generatedPrompt" type="link" size="small" @click="handleViewPrompt(record)">查看</a-button>
<a-button v-if="record.status === 2 && record.generatedPrompt" type="link" size="small" @click="handleCopyPrompt(record)">复制</a-button>
<a-popconfirm v-if="record.status !== 1" title="确定删除?" @confirm="handleDelete(record)">
<a-button type="link" size="small" danger>删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
<!-- 提示词弹窗 -->
<a-modal v-model:open="promptModalVisible" title="生成的提示词" :footer="null" width="700px">
<div class="prompt-content">{{ currentPrompt }}</div>
<div class="prompt-actions">
<a-button type="primary" @click="handleCopyCurrentPrompt">复制到剪贴板</a-button>
</div>
</a-modal>
</div> </div>
</BasicLayout>
<!-- 列表 -->
<a-table
:columns="columns"
:data-source="taskList"
:loading="loading"
:pagination="pagination"
@change="handleTableChange"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="getStatusColor(record.status)">{{ getStatusText(record.status) }}</a-tag>
</template>
<template v-else-if="column.key === 'progress'">
<a-progress :percent="record.progress" :status="getProgressStatus(record.status)" size="small" />
</template>
<template v-else-if="column.key === 'createTime'">
{{ formatTime(record.createTime) }}
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button v-if="record.status === 2 && record.generatedPrompt" type="link" size="small" @click="handleViewPrompt(record)">查看</a-button>
<a-button v-if="record.status === 2 && record.generatedPrompt" type="link" size="small" @click="handleCopyPrompt(record)">复制</a-button>
<a-popconfirm v-if="record.status !== 1" title="确定删除?" @confirm="handleDelete(record)">
<a-button type="link" size="small" danger>删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
<!-- 提示词弹窗 -->
<a-modal v-model:open="promptModalVisible" title="生成的提示词" :footer="null" width="700px">
<div class="prompt-content">{{ currentPrompt }}</div>
<div class="prompt-actions">
<a-button type="primary" @click="handleCopyCurrentPrompt">复制到剪贴板</a-button>
</div>
</a-modal>
</div>
</template> </template>
<script setup> <script setup>
import { ref, reactive, onMounted, onUnmounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import dayjs from 'dayjs'
import { BenchmarkTaskApi } from '@/api/benchmarkTask' import { BenchmarkTaskApi } from '@/api/benchmarkTask'
import { copyToClipboard } from '@/utils/clipboard' import { copyToClipboard } from '@/utils/clipboard'
import BasicLayout from '@/layouts/components/BasicLayout.vue'
const loading = ref(false) const loading = ref(false)
const taskList = ref([]) const taskList = ref([])
@@ -92,7 +93,10 @@ const STATUS_MAP = {
text: { 0: '待处理', 1: '处理中', 2: '成功', 3: '失败' } text: { 0: '待处理', 1: '处理中', 2: '成功', 3: '失败' }
} }
let refreshTimer = null function formatTime(time) {
if (!time) return '-'
return dayjs(time).format('YYYY-MM-DD HH:mm:ss')
}
async function loadTaskList() { async function loadTaskList() {
loading.value = true loading.value = true
@@ -162,20 +166,33 @@ async function handleDelete(record) {
onMounted(() => { onMounted(() => {
loadTaskList() loadTaskList()
refreshTimer = setInterval(() => {
const hasRunning = taskList.value.some(t => t.status === 0 || t.status === 1)
if (hasRunning) loadTaskList()
}, 5000)
})
onUnmounted(() => {
if (refreshTimer) clearInterval(refreshTimer)
}) })
</script> </script>
<style scoped> <style scoped lang="less">
.task-list-container { padding: 24px; background: var(--bg-primary); border-radius: 8px; } .task-list-container {
.filter-section { display: flex; gap: 16px; margin-bottom: 16px; } padding: 24px;
.prompt-content { padding: 16px; background: var(--bg-secondary); border-radius: 6px; white-space: pre-wrap; max-height: 400px; overflow-y: auto; } height: 100%;
.prompt-actions { margin-top: 16px; text-align: right; } overflow: auto;
}
.filter-section {
display: flex;
gap: 16px;
margin-bottom: 16px;
}
.prompt-content {
padding: 16px;
background: var(--color-bg-secondary, #f5f5f5);
border-radius: 6px;
white-space: pre-wrap;
max-height: 400px;
overflow-y: auto;
}
.prompt-actions {
margin-top: 16px;
text-align: right;
}
</style> </style>

View File

@@ -2,13 +2,20 @@ package cn.iocoder.yudao.module.tik.benchmark.job;
import cn.hutool.core.collection.CollUtil; import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.module.tik.benchmark.dal.dataobject.BenchmarkTaskDO; import cn.iocoder.yudao.module.tik.benchmark.dal.dataobject.BenchmarkTaskDO;
import cn.iocoder.yudao.module.tik.benchmark.dal.mysql.BenchmarkTaskMapper; import cn.iocoder.yudao.module.tik.benchmark.dal.mysql.BenchmarkTaskMapper;
import cn.iocoder.yudao.module.tik.benchmark.enums.BenchmarkTaskStatusEnum; import cn.iocoder.yudao.module.tik.benchmark.enums.BenchmarkTaskStatusEnum;
import cn.iocoder.yudao.module.tik.benchmark.vo.BenchmarkTaskCreateReqVO; import cn.iocoder.yudao.module.tik.benchmark.vo.BenchmarkTaskCreateReqVO;
import cn.iocoder.yudao.module.tik.dify.service.DifyService;
import cn.iocoder.yudao.module.tik.dify.vo.DifyBenchmarkReqVO;
import cn.iocoder.yudao.module.tik.dify.vo.DifyChatRespVO;
import cn.iocoder.yudao.module.tik.tikhup.service.TikHupService; import cn.iocoder.yudao.module.tik.tikhup.service.TikHupService;
import cn.iocoder.yudao.module.tik.userprompt.service.UserPromptService; import cn.iocoder.yudao.module.tik.userprompt.service.UserPromptService;
import cn.iocoder.yudao.module.tik.userprompt.vo.UserPromptSaveReqVO; import cn.iocoder.yudao.module.tik.userprompt.vo.UserPromptSaveReqVO;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.Async;
@@ -17,6 +24,7 @@ import org.springframework.stereotype.Component;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
/** /**
* 对标分析任务异步执行器 * 对标分析任务异步执行器
@@ -31,13 +39,16 @@ public class BenchmarkTaskExecutor {
@Resource @Resource
private TikHupService tikHupService; private TikHupService tikHupService;
@Resource
private DifyService difyService;
@Resource @Resource
private UserPromptService userPromptService; private UserPromptService userPromptService;
/** /**
* 异步执行对标分析任务 * 异步执行对标分析任务
*/ */
@Async("benchmarkTaskExecutor") @Async("benchmarkAsyncExecutor")
public void executeAsync(Long taskId, BenchmarkTaskCreateReqVO createReqVO) { public void executeAsync(Long taskId, BenchmarkTaskCreateReqVO createReqVO) {
log.info("[executeAsync][taskId={}] 开始异步执行对标分析任务", taskId); log.info("[executeAsync][taskId={}] 开始异步执行对标分析任务", taskId);
@@ -51,35 +62,28 @@ public class BenchmarkTaskExecutor {
// 1. 更新状态为执行中 // 1. 更新状态为执行中
updateTaskProgress(taskId, 0, BenchmarkTaskStatusEnum.RUNNING.getStatus()); updateTaskProgress(taskId, 0, BenchmarkTaskStatusEnum.RUNNING.getStatus());
// 2. 获取视频转写 // 2. 批量获取视频转写(使用 DashScope API
updateTaskProgress(taskId, 10, BenchmarkTaskStatusEnum.RUNNING.getStatus()); updateTaskProgress(taskId, 10, BenchmarkTaskStatusEnum.RUNNING.getStatus());
List<String> videoUrls = createReqVO.getVideoUrls(); List<String> videoUrls = createReqVO.getVideoUrls();
List<String> transcriptions = new ArrayList<>(); List<String> transcriptions = new ArrayList<>();
// 批量获取转写(使用 videoToCharacters2
try { try {
updateTaskProgress(taskId, 30, BenchmarkTaskStatusEnum.RUNNING.getStatus());
Object result = tikHupService.videoToCharacters2(videoUrls); Object result = tikHupService.videoToCharacters2(videoUrls);
if (result instanceof List) { log.info("[executeAsync][taskId={}] videoToCharacters2 返回类型: {}",
List<?> resultList = (List<?>) result; taskId, result != null ? result.getClass().getName() : "null");
for (Object item : resultList) {
if (item != null && StrUtil.isNotBlank(item.toString())) { if (result instanceof CommonResult) {
transcriptions.add(item.toString()); CommonResult<?> commonResult = (CommonResult<?>) result;
} if (commonResult.getData() != null) {
} String rawData = commonResult.getData().toString();
} transcriptions = extractBatchTranscriptionTexts(rawData);
} catch (Exception e) { log.info("[executeAsync][taskId={}] 批量转写成功,视频数: {}, 结果数: {}",
log.warn("[executeAsync][taskId={}] 批量转写失败,尝试逐个转写: {}", taskId, e.getMessage()); taskId, videoUrls.size(), transcriptions.size());
// 降级:逐个转写
for (String videoUrl : videoUrls) {
try {
Object result = tikHupService.videoToCharacters(videoUrl);
if (result != null && StrUtil.isNotBlank(result.toString())) {
transcriptions.add(result.toString());
}
} catch (Exception ex) {
log.warn("[executeAsync][taskId={}, videoUrl={}] 转写失败: {}", taskId, videoUrl, ex.getMessage());
} }
} }
} catch (Exception ex) {
log.warn("[executeAsync][taskId={}] 批量转写失败: {}", taskId, ex.getMessage());
} }
updateTaskProgress(taskId, 60, BenchmarkTaskStatusEnum.RUNNING.getStatus()); updateTaskProgress(taskId, 60, BenchmarkTaskStatusEnum.RUNNING.getStatus());
@@ -99,13 +103,39 @@ public class BenchmarkTaskExecutor {
updateTaskProgress(taskId, 70, BenchmarkTaskStatusEnum.RUNNING.getStatus()); updateTaskProgress(taskId, 70, BenchmarkTaskStatusEnum.RUNNING.getStatus());
// 5. 调用 AI 生成提示词 // 5. 调用 Dify 生成提示词(流式)
String generatedPrompt; String generatedPrompt;
try { try {
Object result = tikHupService.deepseekAnalysis("benchmark", mergedText); DifyBenchmarkReqVO difyReq = new DifyBenchmarkReqVO();
generatedPrompt = result != null ? result.toString() : ""; difyReq.setContent(mergedText);
difyReq.setVideoCount(transcriptions.size());
// 收集流式响应
StringBuilder resultBuilder = new StringBuilder();
AtomicReference<String> errorMsgRef = new AtomicReference<>();
difyService.benchmarkAnalyzeStream(difyReq, String.valueOf(task.getUserId()))
.filter(resp -> resp != null)
.doOnNext(resp -> {
if (DifyChatRespVO.EVENT_MESSAGE.equals(resp.getEvent())
&& StrUtil.isNotBlank(resp.getContent())) {
resultBuilder.append(resp.getContent());
} else if (DifyChatRespVO.EVENT_ERROR.equals(resp.getEvent())) {
errorMsgRef.set(resp.getErrorMessage());
}
})
.blockLast();
String errorMsg = errorMsgRef.get();
if (errorMsg != null) {
throw new RuntimeException(errorMsg);
}
generatedPrompt = resultBuilder.toString();
log.info("[executeAsync][taskId={}] Dify 分析完成,生成内容长度: {}", taskId, generatedPrompt.length());
} catch (Exception e) { } catch (Exception e) {
log.warn("[executeAsync][taskId={}] AI 生成失败,使用默认提示词: {}", taskId, e.getMessage()); log.warn("[executeAsync][taskId={}] Dify 分析失败,使用默认提示词: {}", taskId, e.getMessage());
// 降级使用合并文本的前2000字符 // 降级使用合并文本的前2000字符
generatedPrompt = "请参考以下视频文案风格生成内容:\n\n" + generatedPrompt = "请参考以下视频文案风格生成内容:\n\n" +
mergedText.substring(0, Math.min(2000, mergedText.length())); mergedText.substring(0, Math.min(2000, mergedText.length()));
@@ -191,4 +221,157 @@ public class BenchmarkTaskExecutor {
return userPromptService.createUserPrompt(saveReqVO); return userPromptService.createUserPrompt(saveReqVO);
} }
/**
* 从阿里云转写结果中提取纯文本
* 支持多种 JSON 格式:
* 1. {"sentences": [{"text": "xxx"}, ...]}
* 2. {"transcripts": [{"text": "xxx"}]}
* 3. 纯文本
*/
private String extractTranscriptionText(String rawData) {
if (StrUtil.isBlank(rawData)) {
return null;
}
try {
JSONObject json = JSON.parseObject(rawData);
// 格式1&2: sentences 或 transcripts 数组
String result = extractFromArray(json.getJSONArray("sentences"), "text");
if (result == null) {
result = extractFromArray(json.getJSONArray("transcripts"), "text");
}
if (result != null) {
return result;
}
// 格式3: 直接的 text 字段
String text = json.getString("text");
if (StrUtil.isNotBlank(text)) {
return text;
}
// 格式4: Result 字段嵌套
String resultField = json.getString("Result");
if (StrUtil.isNotBlank(resultField)) {
return extractTranscriptionText(resultField);
}
log.warn("[extractTranscriptionText] 未知的 JSON 格式: {}", rawData.substring(0, Math.min(200, rawData.length())));
} catch (Exception e) {
log.info("[extractTranscriptionText] 非JSON格式直接返回原文长度: {}", rawData.length());
return rawData;
}
return null;
}
/**
* 从 JSON 数组中提取文本
*/
private String extractFromArray(JSONArray array, String textField) {
if (array == null || array.isEmpty()) {
return null;
}
StringBuilder sb = new StringBuilder();
for (int i = 0; i < array.size(); i++) {
Object item = array.get(i);
String text = null;
if (item instanceof JSONObject) {
text = ((JSONObject) item).getString(textField);
} else if (item != null) {
text = item.toString();
}
if (StrUtil.isNotBlank(text)) {
if (sb.length() > 0) {
sb.append(" ");
}
sb.append(text);
}
}
return sb.length() > 0 ? sb.toString() : null;
}
/**
* 从 DashScope 批量转写结果中提取所有文本
* 格式: {"results":[{"transcription_url":"https://..."},...]}
*/
private List<String> extractBatchTranscriptionTexts(String rawData) {
List<String> results = new ArrayList<>();
if (StrUtil.isBlank(rawData)) {
return results;
}
try {
JSONObject json = JSON.parseObject(rawData);
JSONArray resultsArray = json.getJSONArray("results");
if (resultsArray == null || resultsArray.isEmpty()) {
log.warn("[extractBatchTranscriptionTexts] 未找到 results 数组");
return results;
}
for (int i = 0; i < resultsArray.size(); i++) {
JSONObject item = resultsArray.getJSONObject(i);
String transcriptionUrl = item.getString("transcription_url");
if (StrUtil.isBlank(transcriptionUrl)) {
continue;
}
try {
// 获取转写结果 URL 的内容
String transcriptionJson = fetchUrlContent(transcriptionUrl);
if (StrUtil.isBlank(transcriptionJson)) {
continue;
}
// 解析转写内容
JSONObject transJson = JSON.parseObject(transcriptionJson);
JSONArray transcripts = transJson.getJSONArray("transcripts");
if (transcripts != null && !transcripts.isEmpty()) {
JSONObject firstTranscript = transcripts.getJSONObject(0);
String text = firstTranscript.getString("text");
if (StrUtil.isNotBlank(text)) {
results.add(text);
log.info("[extractBatchTranscriptionTexts][{}/{}] 提取成功,长度: {}",
i + 1, resultsArray.size(), text.length());
}
}
} catch (Exception ex) {
log.warn("[extractBatchTranscriptionTexts][{}] 获取转写内容失败: {}", i, ex.getMessage());
}
}
} catch (Exception e) {
log.error("[extractBatchTranscriptionTexts] 解析失败: {}", e.getMessage());
}
return results;
}
/**
* 获取 URL 内容
*/
private String fetchUrlContent(String url) {
try {
java.net.HttpURLConnection conn = (java.net.HttpURLConnection) new java.net.URL(url).openConnection();
conn.setConnectTimeout(10000);
conn.setReadTimeout(30000);
conn.setRequestMethod("GET");
if (conn.getResponseCode() == 200) {
try (java.io.InputStream is = conn.getInputStream();
java.io.BufferedReader reader = new java.io.BufferedReader(
new java.io.InputStreamReader(is, java.nio.charset.StandardCharsets.UTF_8))) {
StringBuilder sb = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
sb.append(line);
}
return sb.toString();
}
}
} catch (Exception e) {
log.warn("[fetchUrlContent] 获取 URL 内容失败: {}, error: {}", url, e.getMessage());
}
return null;
}
} }

View File

@@ -7,12 +7,11 @@ import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor; import java.util.concurrent.Executor;
import java.util.concurrent.RejectedExecutionHandler;
import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.ThreadPoolExecutor;
/** /**
* Tik模块异步任务配置 - 限流保护 * Tik模块异步任务配置 - 限流保护
*
* @author 芋道源码
*/ */
@Slf4j @Slf4j
@Configuration @Configuration
@@ -20,83 +19,42 @@ import java.util.concurrent.ThreadPoolExecutor;
public class TikAsyncConfig { public class TikAsyncConfig {
/** /**
* 数字人任务专用线程池 - 限流保护 * 创建线程池执行器
*/ */
@Bean("taskExecutor") private Executor createExecutor(String prefix, int coreSize, int maxSize, int queueCapacity,
public Executor taskExecutor() { int keepAlive, RejectedExecutionHandler handler) {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(coreSize);
// 核心线程数10CPU核心数 executor.setMaxPoolSize(maxSize);
executor.setCorePoolSize(10); executor.setQueueCapacity(queueCapacity);
executor.setThreadNamePrefix(prefix);
// 最大线程数20 executor.setKeepAliveSeconds(keepAlive);
executor.setMaxPoolSize(20);
// 队列长度50超过的请求会被拒绝
executor.setQueueCapacity(50);
// 线程名前缀
executor.setThreadNamePrefix("digital-human-");
// 线程空闲时间60秒
executor.setKeepAliveSeconds(60);
// 关闭时等待任务完成
executor.setWaitForTasksToCompleteOnShutdown(true); executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(keepAlive);
// 等待时间60秒 executor.setRejectedExecutionHandler(handler);
executor.setAwaitTerminationSeconds(60);
// 拒绝策略:抛出异常(也可以改为 CallerRunsPolicy 回退到调用者线程)
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
// 初始化
executor.initialize(); executor.initialize();
log.info("[TikAsyncConfig][数字人任务线程池已初始化][corePoolSize={}, maxPoolSize={}, queueCapacity={}]",
executor.getCorePoolSize(), executor.getMaxPoolSize(), executor.getQueueCapacity());
return executor; return executor;
} }
/** /** 数字人任务专用线程池 */
* Latentsync轮询专用线程池 - 轻量化处理 @Bean("taskExecutor")
*/ public Executor taskExecutor() {
return createExecutor("digital-human-", 10, 20, 50, 60,
new ThreadPoolExecutor.AbortPolicy());
}
/** Latentsync轮询专用线程池 */
@Bean("latentsyncPollingExecutor") @Bean("latentsyncPollingExecutor")
public Executor latentsyncPollingExecutor() { public Executor latentsyncPollingExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); return createExecutor("latentsync-poll-", 5, 10, 100, 30,
new ThreadPoolExecutor.DiscardOldestPolicy());
}
// 核心线程数5轮询任务不需要太多线程 /** 对标分析任务专用线程池 */
executor.setCorePoolSize(5); @Bean("benchmarkAsyncExecutor")
public Executor benchmarkAsyncExecutor() {
// 最大线程数10 return createExecutor("benchmark-task-", 5, 10, 30, 60,
executor.setMaxPoolSize(10); new ThreadPoolExecutor.AbortPolicy());
// 队列长度100
executor.setQueueCapacity(100);
// 线程名前缀
executor.setThreadNamePrefix("latentsync-poll-");
// 线程空闲时间30秒
executor.setKeepAliveSeconds(30);
// 关闭时等待任务完成
executor.setWaitForTasksToCompleteOnShutdown(true);
// 等待时间30秒
executor.setAwaitTerminationSeconds(30);
// 拒绝策略:直接丢弃最旧的任务
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardOldestPolicy());
// 初始化
executor.initialize();
log.info("[TikAsyncConfig][Latentsync轮询线程池已初始化][corePoolSize={}, maxPoolSize={}, queueCapacity={}]",
executor.getCorePoolSize(), executor.getMaxPoolSize(), executor.getQueueCapacity());
return executor;
} }
} }

View File

@@ -32,13 +32,6 @@ public class TikHupController {
return tikHupService.postTikHup(type, methodType, urlParams, paramType); return tikHupService.postTikHup(type, methodType, urlParams, paramType);
} }
@PostMapping("/videoToCharacters")
@Operation(summary = "音频转文字", description = "音频转文字接口")
public Object videoToCharacters(@RequestBody Map<String, Object> fileLinkMap) {
String fileLink = (String) fileLinkMap.get("fileLink");
return tikHupService.videoToCharacters(fileLink);
}
@PostMapping("/videoToCharacters2") @PostMapping("/videoToCharacters2")
@Operation(summary = "批量音频转文字", description = "批量音频转文字接口") @Operation(summary = "批量音频转文字", description = "批量音频转文字接口")
public Object videoToCharacters2(@RequestBody Map<String, Object> fileLinkMap) { public Object videoToCharacters2(@RequestBody Map<String, Object> fileLinkMap) {

View File

@@ -9,13 +9,6 @@ public interface TikHupService {
Object postTikHup(String type, String methodType, String urlParams, String paramType); Object postTikHup(String type, String methodType, String urlParams, String paramType);
/**
* 音频转文字
* @param downloadUrl 下载地址
* @return 语音文字
*/
Object videoToCharacters(String downloadUrl);
/** /**
* 批量音频转文字 * 批量音频转文字
* @param fileLinkList 音频地址 * @param fileLinkList 音频地址

View File

@@ -51,7 +51,6 @@ public class TikHupServiceImpl implements TikHupService {
// 日志前缀 // 日志前缀
private static final String LOG_PREFIX_TIKHUP = "[postTikHup]"; private static final String LOG_PREFIX_TIKHUP = "[postTikHup]";
private static final String LOG_PREFIX_VIDEO = "[videoToCharacters]";
private static final String LOG_PREFIX_VIDEO2 = "[videoToCharacters2]"; private static final String LOG_PREFIX_VIDEO2 = "[videoToCharacters2]";
private static final String LOG_PREFIX_DEEPSEEK = "[deepseekAnalysis]"; private static final String LOG_PREFIX_DEEPSEEK = "[deepseekAnalysis]";
@@ -66,11 +65,6 @@ public class TikHupServiceImpl implements TikHupService {
private static final String DEEPSEEK_API_KEY = "sk-7f666f993b144d279ae375a015e4de56"; private static final String DEEPSEEK_API_KEY = "sk-7f666f993b144d279ae375a015e4de56";
private static final String DEEPSEEK_API_URL = "https://api.deepseek.com/chat/completions"; private static final String DEEPSEEK_API_URL = "https://api.deepseek.com/chat/completions";
// 阿里云语音识别配置
private final String appKey = "sldJ4XSpYp3rKALZ";
private final String accessKeyId = "LTAI5tPV9Ag3csf41GZjaLTA";
private final String accessKeySecret = "kDqlGeJTKw6tJtFYiaY8vQTFuVIQDs";
// 流式处理配置 // 流式处理配置
private static final Duration STREAM_TIMEOUT = Duration.ofMinutes(30); private static final Duration STREAM_TIMEOUT = Duration.ofMinutes(30);
@@ -269,29 +263,6 @@ public class TikHupServiceImpl implements TikHupService {
} }
} }
@Override
public Object videoToCharacters(String fileLink) {
log.info("{} 开始识别,文件链接: {}", LOG_PREFIX_VIDEO, fileLink);
TikFileTransCharacters transCharacters = new TikFileTransCharacters(accessKeyId, accessKeySecret);
String taskId = transCharacters.submitFileTransRequest(appKey, fileLink);
if (taskId == null) {
log.error("{} 提交识别请求失败taskId为nullfileLink: {}", LOG_PREFIX_VIDEO, fileLink);
return CommonResult.error(500, "录音文件识别请求失败!");
}
log.info("{} 提交识别请求成功taskId: {}", LOG_PREFIX_VIDEO, taskId);
String transResult = transCharacters.getFileTransResult(taskId);
if (transResult == null) {
log.error("{} 识别结果查询失败taskId: {}", LOG_PREFIX_VIDEO, taskId);
return CommonResult.error(501, "录音文件识别请求失败!");
}
log.info("{} 识别成功taskId: {},结果长度: {}", LOG_PREFIX_VIDEO, taskId, transResult.length());
return CommonResult.success(transResult);
}
@Override @Override
public Object videoToCharacters2(List<String> fileLinkList) { public Object videoToCharacters2(List<String> fileLinkList) {
log.info("{} 开始识别,文件数量: {}文件URL: {}", log.info("{} 开始识别,文件数量: {}文件URL: {}",