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"
>
<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>
</a-select-option>
</a-select>
@@ -313,6 +319,37 @@ onMounted(() => {
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 {
margin-bottom: 12px;

View File

@@ -1,24 +1,16 @@
<script setup>
import { ref, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { useRouter } from 'vue-router'
import { resolveId } from '@/utils/url'
import { exportBenchmarkDataToExcel } from '@/utils/excel'
import { usePromptStore } from '@/stores/prompt'
import GlobalLoading from '@/components/GlobalLoading.vue'
import TikhubService, { InterfaceType, MethodType } from '@/api/tikhub/index.js'
import { useBenchmarkData } from './composables/useBenchmarkData'
import { useBenchmarkAnalysis } from './composables/useBenchmarkAnalysis'
import { formatTime } from './utils/benchmarkUtils'
import { copyToClipboard } from '@/utils/clipboard'
import BenchmarkForm from './components/BenchmarkForm.vue'
import BenchmarkTable from './components/BenchmarkTable.vue'
import BatchAnalyzeModal from './components/BatchAnalyzeModal.vue'
import SavePromptModal from '@/components/SavePromptModal.vue'
import { BenchmarkTaskApi } from '@/api/benchmarkTask'
const router = useRouter()
const promptStore = usePromptStore()
import CreateStyleTaskModal from './components/CreateStyleTaskModal.vue'
const {
data,
@@ -33,10 +25,8 @@ const {
const {
loading,
batchAnalyzeLoading,
globalLoading,
globalLoadingText,
batchAnalyze,
getVoiceText,
} = useBenchmarkAnalysis(data, expandedRowKeys, saveTableDataToSession)
@@ -47,12 +37,8 @@ const form = ref({
sort_type: 0,
})
const modalVisible = ref(false)
const batchPromptMergedText = ref('')
const batchPromptTextCount = ref(0)
const savePromptModalVisible = ref(false)
const savePromptContent = ref('')
// 创建风格任务弹窗
const createTaskModalVisible = ref(false)
const maxCursor = ref(0)
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() {
form.value = { platform: '抖音', url: '', count: 20, sort_type: 0 }
maxCursor.value = 0
@@ -221,65 +195,34 @@ async function handleLoadMore() {
}
}
function validatePrompt(prompt, warningMsg = '没有提示词') {
const isValid = prompt?.trim()
if (!isValid) message.warning(warningMsg)
return !!isValid
// 获取选中视频的 URL 列表
function getSelectedVideoUrls() {
return data.value
.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
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() {
// 打开创建风格任务弹窗
function handleCreateAsyncTask() {
if (!selectedRowKeys.value.length) {
message.warning('请先选择要分析的视频')
return
}
// 获取选中的视频 URL
const selectedRows = data.value.filter(item => selectedRowKeys.value.includes(item.id))
const videoUrls = selectedRows.map(row => row.audio_url || row.share_url).filter(Boolean)
if (!videoUrls.length) {
if (selectedRowKeys.value.length > 20) {
message.warning('最多只能选择20个视频')
return
}
if (!getSelectedVideoUrls().length) {
message.warning('选中的视频没有有效的URL')
return
}
createTaskModalVisible.value = true
}
try {
const response = await BenchmarkTaskApi.createTask({
taskName: `批量分析 ${videoUrls.length} 个视频`,
videoUrls: videoUrls,
})
if (response?.code === 0 || response?.data) {
message.success('任务创建成功!请到任务中心查看进度')
} else {
message.error(response?.message || '创建任务失败')
}
} catch (error) {
console.error('创建任务失败:', error)
message.error('创建任务失败')
}
// 任务创建成功后清空选择
function handleTaskCreated() {
selectedRowKeys.value = []
}
onMounted(async () => {
@@ -291,63 +234,51 @@ defineOptions({ name: 'ContentStyleBenchmark' })
<template>
<div class="page">
<a-spin :spinning="batchAnalyzeLoading" tip="批量分析中,请稍候..." wrapperClassName="batch-analyze-spin-wrapper">
<div class="stack">
<!-- 表单区域 -->
<BenchmarkForm
v-model="form"
:loading="loading"
@analyze="handleAnalyzeUser"
@reset="handleResetForm"
/>
<div class="stack">
<!-- 表单区域 -->
<BenchmarkForm
v-model="form"
:loading="loading"
@analyze="handleAnalyzeUser"
@reset="handleResetForm"
/>
<!-- 表格区域 -->
<BenchmarkTable
:data="data"
v-model:selectedRowKeys="selectedRowKeys"
:loading="loading"
:loading-more="loadingMore"
:has-more="hasMore"
@export="handleExportToExcel"
@batch-analyze="handleBatchAnalyze"
@load-more="handleLoadMore"
@create-async-task="handleCreateAsyncTask"
/>
<!-- 表格区域 -->
<BenchmarkTable
:data="data"
v-model:selectedRowKeys="selectedRowKeys"
:loading="loading"
:loading-more="loadingMore"
:has-more="hasMore"
@export="handleExportToExcel"
@load-more="handleLoadMore"
@create-async-task="handleCreateAsyncTask"
/>
<section v-if="!data.length" class="card results-card empty-state">
<a-empty description="暂无数据,请点击开始分析">
<template #image>
<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"/>
<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="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="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"/>
</svg>
</template>
</a-empty>
</section>
</div>
</a-spin>
<section v-if="!data.length" class="card results-card empty-state">
<a-empty description="暂无数据,请点击开始分析">
<template #image>
<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"/>
<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="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="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"/>
</svg>
</template>
</a-empty>
</section>
</div>
<!-- 批量分析结果弹窗 -->
<BatchAnalyzeModal
v-model:visible="modalVisible"
:merged-text="batchPromptMergedText"
:text-count="batchPromptTextCount"
@copy="handleCopyBatchPrompt"
@save="(prompt) => handleOpenSavePromptModal(prompt)"
@use="handleUseBatchPrompt"
<!-- 创建风格任务弹窗 -->
<CreateStyleTaskModal
v-model:visible="createTaskModalVisible"
:video-urls="getSelectedVideoUrls()"
@success="handleTaskCreated"
/>
<!-- 保存提示词弹窗 -->
<SavePromptModal
v-model:visible="savePromptModalVisible"
:prompt-content="savePromptContent"
/>
<!-- 全局 Loading 遮罩 -->
<GlobalLoading :visible="globalLoading" :text="globalLoadingText" />
</div>
@@ -399,15 +330,4 @@ defineOptions({ name: 'ContentStyleBenchmark' })
color: var(--color-gray-600);
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>

View File

@@ -11,7 +11,7 @@ defineProps({
hasMore: { type: Boolean, default: false },
})
const emit = defineEmits(['update:selectedRowKeys', 'export', 'batchAnalyze', 'loadMore', 'createAsyncTask'])
const emit = defineEmits(['update:selectedRowKeys', 'export', 'loadMore', 'createAsyncTask'])
const defaultColumns = [
{ title: '封面', key: 'cover', dataIndex: 'cover', width: 100 },
@@ -56,14 +56,8 @@ function formatNumber(value) {
<GradientButton
:text="`批量分析 (${selectedRowKeys.length}/20)`"
size="small"
@click="$emit('batchAnalyze')"
:disabled="data.length === 0 || selectedRowKeys.length === 0 || selectedRowKeys.length > 20"
/>
<GradientButton
text="异步任务"
size="small"
@click="$emit('createAsyncTask')"
:disabled="data.length === 0 || selectedRowKeys.length === 0"
:disabled="data.length === 0 || selectedRowKeys.length === 0 || selectedRowKeys.length > 20"
icon="clock-circle"
/>
</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>
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'
@@ -50,6 +50,12 @@ const NAV_ITEMS = [
label: '数字人视频任务',
icon: UserOutlined,
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>
<BasicLayout title="对标分析任务">
<div class="task-list-container">
<!-- 筛选 -->
<div class="filter-section">
<a-select
v-model:value="filterStatus"
placeholder="全部状态"
style="width: 150px"
allowClear
@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"
<div class="task-list-container">
<!-- 筛选 -->
<div class="filter-section">
<a-select
v-model:value="filterStatus"
placeholder="全部状态"
style="width: 150px"
allowClear
@change="handleFilterChange"
>
<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 === '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>
<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>
</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>
<script setup>
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { ref, reactive, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import dayjs from 'dayjs'
import { BenchmarkTaskApi } from '@/api/benchmarkTask'
import { copyToClipboard } from '@/utils/clipboard'
import BasicLayout from '@/layouts/components/BasicLayout.vue'
const loading = ref(false)
const taskList = ref([])
@@ -92,7 +93,10 @@ const STATUS_MAP = {
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() {
loading.value = true
@@ -162,20 +166,33 @@ async function handleDelete(record) {
onMounted(() => {
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>
<style scoped>
.task-list-container { padding: 24px; background: var(--bg-primary); border-radius: 8px; }
.filter-section { display: flex; gap: 16px; margin-bottom: 16px; }
.prompt-content { padding: 16px; background: var(--bg-secondary); border-radius: 6px; white-space: pre-wrap; max-height: 400px; overflow-y: auto; }
.prompt-actions { margin-top: 16px; text-align: right; }
<style scoped lang="less">
.task-list-container {
padding: 24px;
height: 100%;
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>

View File

@@ -2,13 +2,20 @@ package cn.iocoder.yudao.module.tik.benchmark.job;
import cn.hutool.core.collection.CollUtil;
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.mysql.BenchmarkTaskMapper;
import cn.iocoder.yudao.module.tik.benchmark.enums.BenchmarkTaskStatusEnum;
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.userprompt.service.UserPromptService;
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 lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
@@ -17,6 +24,7 @@ import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
/**
* 对标分析任务异步执行器
@@ -31,13 +39,16 @@ public class BenchmarkTaskExecutor {
@Resource
private TikHupService tikHupService;
@Resource
private DifyService difyService;
@Resource
private UserPromptService userPromptService;
/**
* 异步执行对标分析任务
*/
@Async("benchmarkTaskExecutor")
@Async("benchmarkAsyncExecutor")
public void executeAsync(Long taskId, BenchmarkTaskCreateReqVO createReqVO) {
log.info("[executeAsync][taskId={}] 开始异步执行对标分析任务", taskId);
@@ -51,35 +62,28 @@ public class BenchmarkTaskExecutor {
// 1. 更新状态为执行中
updateTaskProgress(taskId, 0, BenchmarkTaskStatusEnum.RUNNING.getStatus());
// 2. 获取视频转写
// 2. 批量获取视频转写(使用 DashScope API
updateTaskProgress(taskId, 10, BenchmarkTaskStatusEnum.RUNNING.getStatus());
List<String> videoUrls = createReqVO.getVideoUrls();
List<String> transcriptions = new ArrayList<>();
// 批量获取转写(使用 videoToCharacters2
try {
updateTaskProgress(taskId, 30, BenchmarkTaskStatusEnum.RUNNING.getStatus());
Object result = tikHupService.videoToCharacters2(videoUrls);
if (result instanceof List) {
List<?> resultList = (List<?>) result;
for (Object item : resultList) {
if (item != null && StrUtil.isNotBlank(item.toString())) {
transcriptions.add(item.toString());
}
}
}
} catch (Exception e) {
log.warn("[executeAsync][taskId={}] 批量转写失败,尝试逐个转写: {}", taskId, e.getMessage());
// 降级:逐个转写
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());
log.info("[executeAsync][taskId={}] videoToCharacters2 返回类型: {}",
taskId, result != null ? result.getClass().getName() : "null");
if (result instanceof CommonResult) {
CommonResult<?> commonResult = (CommonResult<?>) result;
if (commonResult.getData() != null) {
String rawData = commonResult.getData().toString();
transcriptions = extractBatchTranscriptionTexts(rawData);
log.info("[executeAsync][taskId={}] 批量转写成功,视频数: {}, 结果数: {}",
taskId, videoUrls.size(), transcriptions.size());
}
}
} catch (Exception ex) {
log.warn("[executeAsync][taskId={}] 批量转写失败: {}", taskId, ex.getMessage());
}
updateTaskProgress(taskId, 60, BenchmarkTaskStatusEnum.RUNNING.getStatus());
@@ -99,13 +103,39 @@ public class BenchmarkTaskExecutor {
updateTaskProgress(taskId, 70, BenchmarkTaskStatusEnum.RUNNING.getStatus());
// 5. 调用 AI 生成提示词
// 5. 调用 Dify 生成提示词(流式)
String generatedPrompt;
try {
Object result = tikHupService.deepseekAnalysis("benchmark", mergedText);
generatedPrompt = result != null ? result.toString() : "";
DifyBenchmarkReqVO difyReq = new DifyBenchmarkReqVO();
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) {
log.warn("[executeAsync][taskId={}] AI 生成失败,使用默认提示词: {}", taskId, e.getMessage());
log.warn("[executeAsync][taskId={}] Dify 分析失败,使用默认提示词: {}", taskId, e.getMessage());
// 降级使用合并文本的前2000字符
generatedPrompt = "请参考以下视频文案风格生成内容:\n\n" +
mergedText.substring(0, Math.min(2000, mergedText.length()));
@@ -191,4 +221,157 @@ public class BenchmarkTaskExecutor {
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 java.util.concurrent.Executor;
import java.util.concurrent.RejectedExecutionHandler;
import java.util.concurrent.ThreadPoolExecutor;
/**
* Tik模块异步任务配置 - 限流保护
*
* @author 芋道源码
*/
@Slf4j
@Configuration
@@ -20,83 +19,42 @@ import java.util.concurrent.ThreadPoolExecutor;
public class TikAsyncConfig {
/**
* 数字人任务专用线程池 - 限流保护
* 创建线程池执行器
*/
@Bean("taskExecutor")
public Executor taskExecutor() {
private Executor createExecutor(String prefix, int coreSize, int maxSize, int queueCapacity,
int keepAlive, RejectedExecutionHandler handler) {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// 核心线程数10CPU核心数
executor.setCorePoolSize(10);
// 最大线程数20
executor.setMaxPoolSize(20);
// 队列长度50超过的请求会被拒绝
executor.setQueueCapacity(50);
// 线程名前缀
executor.setThreadNamePrefix("digital-human-");
// 线程空闲时间60秒
executor.setKeepAliveSeconds(60);
// 关闭时等待任务完成
executor.setCorePoolSize(coreSize);
executor.setMaxPoolSize(maxSize);
executor.setQueueCapacity(queueCapacity);
executor.setThreadNamePrefix(prefix);
executor.setKeepAliveSeconds(keepAlive);
executor.setWaitForTasksToCompleteOnShutdown(true);
// 等待时间60秒
executor.setAwaitTerminationSeconds(60);
// 拒绝策略:抛出异常(也可以改为 CallerRunsPolicy 回退到调用者线程)
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
// 初始化
executor.setAwaitTerminationSeconds(keepAlive);
executor.setRejectedExecutionHandler(handler);
executor.initialize();
log.info("[TikAsyncConfig][数字人任务线程池已初始化][corePoolSize={}, maxPoolSize={}, queueCapacity={}]",
executor.getCorePoolSize(), executor.getMaxPoolSize(), executor.getQueueCapacity());
return executor;
}
/**
* Latentsync轮询专用线程池 - 轻量化处理
*/
/** 数字人任务专用线程池 */
@Bean("taskExecutor")
public Executor taskExecutor() {
return createExecutor("digital-human-", 10, 20, 50, 60,
new ThreadPoolExecutor.AbortPolicy());
}
/** Latentsync轮询专用线程池 */
@Bean("latentsyncPollingExecutor")
public Executor latentsyncPollingExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
return createExecutor("latentsync-poll-", 5, 10, 100, 30,
new ThreadPoolExecutor.DiscardOldestPolicy());
}
// 核心线程数5轮询任务不需要太多线程
executor.setCorePoolSize(5);
// 最大线程数10
executor.setMaxPoolSize(10);
// 队列长度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;
/** 对标分析任务专用线程池 */
@Bean("benchmarkAsyncExecutor")
public Executor benchmarkAsyncExecutor() {
return createExecutor("benchmark-task-", 5, 10, 30, 60,
new ThreadPoolExecutor.AbortPolicy());
}
}

View File

@@ -32,13 +32,6 @@ public class TikHupController {
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")
@Operation(summary = "批量音频转文字", description = "批量音频转文字接口")
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);
/**
* 音频转文字
* @param downloadUrl 下载地址
* @return 语音文字
*/
Object videoToCharacters(String downloadUrl);
/**
* 批量音频转文字
* @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_VIDEO = "[videoToCharacters]";
private static final String LOG_PREFIX_VIDEO2 = "[videoToCharacters2]";
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_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);
@@ -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
public Object videoToCharacters2(List<String> fileLinkList) {
log.info("{} 开始识别,文件数量: {}文件URL: {}",