Compare commits

...

13 Commits

Author SHA1 Message Date
e4b0ed3d74 优化
Some checks failed
Build and Deploy / deploy (push) Has been cancelled
2026-04-26 23:49:40 +08:00
0a4c73d39d 优化 2026-04-26 23:48:02 +08:00
be0f0ed4d7 style(agent): 优化历史面板动画性能和视觉效果
Some checks failed
Build and Deploy / deploy (push) Has been cancelled
- 移除 backdrop-filter 以提升性能,调整遮罩透明度
- 添加 will-change 属性优化动画渲染性能
- 简化动画关键帧,移除不必要的 transform 变化
- 调整动画延迟时间,加快整体动画节奏
- 优化悬停效果,减少视觉复杂度
- 添加 contain 属性限制布局重绘范围
- 调整光晕效果尺寸和透明度,更符合设计规范
2026-04-16 02:18:41 +08:00
eaef9a0e4c feat(agent): 支持自建风格与智能体双模式对话系统
Some checks failed
Build and Deploy / deploy (push) Has been cancelled
- 新增 `source` 字段区分智能体(`agent`)与自建风格(`prompt`)两种对话来源
- 前端统一对话组件,根据来源动态构建请求参数、显示不同样式与文案
- 后端重构 Dify 会话与消息获取逻辑,支持合并查询 Pro 与 Standard 两个 Dify App 的会话历史
- 实现复合游标分页机制,支持跨双数据源的高效分页
- 新增 `clipboard-polyfill` 依赖,统一剪贴板复制功能,提升非 HTTPS 环境兼容性
- 扩展历史记录面板,支持按来源加载对应会话与消息
- 调整侧边抽屉宽度,优化大屏显示体验
2026-04-11 18:13:08 +08:00
09a567a542 feat(agent): 支持自定义系统提示词进行对话
Some checks failed
Build and Deploy / deploy (push) Has been cancelled
- 前端 API 新增 customSystemPrompt 参数,agentId 变为可选
- 聊天抽屉支持自定义提示词时传递参数并调整宽度
- 我的收藏模态框返回提示词内容供聊天使用
- 后端 Dify 服务优先使用自定义提示词,支持无 agentId 的对话
- Dify 请求 VO 中 agentId 改为非必填,新增 customSystemPrompt 字段
2026-04-11 16:22:11 +08:00
e169065653 feat(kling): 优化数字人视频播放和识别功能
Some checks failed
Build and Deploy / deploy (push) Has been cancelled
- 前端:新增签名URL存储,将视频下载为blob URL确保浏览器兼容播放
- 后端:移除视频/音频播放URL的Content-Type参数,简化签名URL生成逻辑
- 修复:使用签名URL调用人脸识别API,避免blob URL无法被外部API访问的问题
2026-04-09 01:29:05 +08:00
63d3e7eecb feat(kling): 优化时间轴对比组件样式和交互
Some checks failed
Build and Deploy / deploy (push) Has been cancelled
- 重构 TimelinePanel.vue 组件,使用 Tailwind CSS 替代 Less,简化样式代码
- 改进视觉设计:更新颜色方案、间距和图标,提升用户体验
- 移除音频结束位置标记,优化刻度尺和轨道显示逻辑
- 统一时长差异提示的样式和状态显示

feat(infra): 扩展文件预签名接口支持 Content-Type 参数

- 在 FileApi、FileClient、FileService 接口中新增带 Content-Type 参数的 presignGetUrl 方法
- 实现 S3FileClient 对 Content-Type 参数的支持,确保浏览器正确渲染媒体文件
- 在 TikUserFileServiceImpl 中为音视频文件生成预签名 URL 时自动推断 Content-Type
- 支持公开访问和私有访问两种模式下的 Content-Type 参数传递
2026-04-09 01:04:07 +08:00
c607316f53 Merge branch 'main' of http://8.155.172.147:3001/sion/sionrui
Some checks failed
Build and Deploy / deploy (push) Has been cancelled
2026-04-09 00:35:00 +08:00
155f31121f refactor(kling): 优化时长匹配逻辑,使用固定阈值替代比例计算 2026-04-09 00:32:55 +08:00
d8acb2130d fix: 更新 302AI API 密钥
Some checks failed
Build and Deploy / deploy (push) Has been cancelled
- 更新 LatentsyncProperties.java 中的默认密钥
- 修复 KlingAI 和 LatentSync 数字人功能
- 密钥从 sk-0IZJ2oo... 更新为 sk-0CdsUdm...
2026-04-08 19:55:05 +08:00
c141d895db refactor(kling): 替换智能体选择为风格选择组件
Some checks failed
Build and Deploy / deploy (push) Has been cancelled
2026-04-06 19:41:12 +08:00
868fd0658c feat: add video file size validation with 100MB limit in digital human store
Some checks failed
Build and Deploy / deploy (push) Has been cancelled
Add MAX_VIDEO_SIZE constant and implement file size validation for both upload and selection workflows to comply with 302.ai Kling API limitations. Display error toast when files exceed the 100MB threshold before processing.
2026-04-05 17:30:47 +08:00
f391a8c0d0 feat: 优化 2026-04-05 17:27:31 +08:00
36 changed files with 1024 additions and 1290 deletions

View File

@@ -25,6 +25,7 @@
"ai": "^6.0.39", "ai": "^6.0.39",
"aplayer": "^1.10.1", "aplayer": "^1.10.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clipboard-polyfill": "^4.1.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dayjs": "^1.11.18", "dayjs": "^1.11.18",

View File

@@ -21,10 +21,11 @@ export function getAgentList() {
/** /**
* 流式对话SSE * 流式对话SSE
* @param {Object} options - 请求配置 * @param {Object} options - 请求配置
* @param {number} options.agentId - 智能体ID * @param {number} [options.agentId] - 智能体ID(使用 customSystemPrompt 时可不传)
* @param {string} options.content - 用户输入内容 * @param {string} options.content - 用户输入内容
* @param {string} [options.conversationId] - 会话ID可选首次对话不传 * @param {string} [options.conversationId] - 会话ID可选首次对话不传
* @param {string} [options.modelMode] - 模型模式pro-深度版 standard-标准版 * @param {string} [options.modelMode] - 模型模式pro-深度版 standard-标准版
* @param {string} [options.customSystemPrompt] - 自定义系统提示词(使用用户自建风格时传入)
* @param {AbortController} [options.ctrl] - 取消控制器 * @param {AbortController} [options.ctrl] - 取消控制器
* @param {Function} options.onMessage - 消息回调 * @param {Function} options.onMessage - 消息回调
* @param {Function} [options.onError] - 错误回调 * @param {Function} [options.onError] - 错误回调
@@ -36,6 +37,8 @@ export async function sendChatStream(options) {
content, content,
conversationId, conversationId,
modelMode = 'pro', modelMode = 'pro',
customSystemPrompt,
source,
ctrl, ctrl,
onMessage, onMessage,
onError, onError,
@@ -56,7 +59,9 @@ export async function sendChatStream(options) {
agentId, agentId,
content, content,
conversationId, conversationId,
modelMode modelMode,
customSystemPrompt,
source
}), }),
onmessage: (event) => { onmessage: (event) => {
if (typeof onMessage === 'function') { if (typeof onMessage === 'function') {
@@ -86,10 +91,11 @@ export async function sendChatStream(options) {
} }
/** /**
* 获取会话列表 * 获取会话列表(合并 pro + standard 两个 Dify 工作流)
* @param {Object} params - 请求参数 * @param {Object} params - 请求参数
* @param {number} params.agentId - 智能体ID * @param {number} params.agentId - 智能体ID
* @param {string} [params.lastId] - 上一页最后一条记录ID * @param {string} [params.source] - 来源类型agent-智能体 prompt-自建风格
* @param {string} [params.cursor] - 复合游标(首页不传)
* @param {number} [params.limit] - 返回条数默认20 * @param {number} [params.limit] - 返回条数默认20
*/ */
export function getConversations(params) { export function getConversations(params) {
@@ -101,10 +107,12 @@ export function getConversations(params) {
} }
/** /**
* 获取会话历史消息 * 获取会话历史消息(自动定位 pro/standard App
* @param {Object} params - 请求参数 * @param {Object} params - 请求参数
* @param {number} params.agentId - 智能体ID * @param {number} params.agentId - 智能体ID
* @param {string} [params.source] - 来源类型agent-智能体 prompt-自建风格
* @param {string} params.conversationId - 会话ID * @param {string} params.conversationId - 会话ID
* @param {string} [params.appSource] - 来源应用标识pro/standard
* @param {string} [params.firstId] - 当前页第一条记录ID * @param {string} [params.firstId] - 当前页第一条记录ID
* @param {number} [params.limit] - 返回条数默认20 * @param {number} [params.limit] - 返回条数默认20
*/ */

View File

@@ -1,8 +1,8 @@
<template> <template>
<div class="voice-selector"> <div class="w-full">
<!-- 空状态 --> <!-- 空状态 -->
<div v-if="userVoiceCards.length === 0" class="empty-voices"> <div v-if="userVoiceCards.length === 0" class="py-10 px-6 bg-muted border border-dashed border-border rounded-lg text-center">
<div class="empty-icon"> <div class="mb-3">
<Icon icon="lucide:mic-off" class="size-10 text-muted-foreground/40" /> <Icon icon="lucide:mic-off" class="size-10 text-muted-foreground/40" />
</div> </div>
<p class="text-muted-foreground mb-4">还没有配音</p> <p class="text-muted-foreground mb-4">还没有配音</p>
@@ -11,68 +11,71 @@
</Button> </Button>
</div> </div>
<div v-else class="voice-selector-wrapper"> <div v-else class="flex flex-col gap-4">
<!-- 标题栏 --> <!-- 标题栏 -->
<div class="selector-header"> <div class="h-12 flex items-center justify-between pb-3 border-b border-border">
<div class="header-left"> <div class="flex items-center gap-2.5">
<span class="header-title">选择音色</span> <span class="text-base font-semibold text-foreground">选择音色</span>
<span class="voice-count">{{ userVoiceCards.length }} 个配音</span> <span class="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded">{{ userVoiceCards.length }} 个配音</span>
</div> </div>
<Button <Button
v-if="selectedVoiceId" v-show="selectedVoiceId"
class="synthesize-btn" class="h-9 shadow-sm transition-all hover:-translate-y-px hover:shadow-md"
:disabled="isPlayerInitializing" :disabled="isPlayerInitializing || isSynthesizing"
:loading="previewLoadingVoiceId === selectedVoiceId"
@click="handleSynthesize" @click="handleSynthesize"
> >
<Icon icon="lucide:volume-2" class="size-4" /> <Icon v-if="isSynthesizing" icon="lucide:loader-2" class="size-4 animate-spin" />
合成试听 <Icon v-else icon="lucide:volume-2" class="size-4" />
{{ isSynthesizing ? '合成中...' : '合成试听' }}
</Button> </Button>
</div> </div>
<!-- 卡片网格 --> <!-- 卡片列表 -->
<div class="voice-grid" :class="{ 'has-many': userVoiceCards.length > 4 }"> <div
class="flex flex-col gap-1.5"
:class="userVoiceCards.length > 6 && 'max-h-[260px] overflow-y-auto pr-1 voice-grid-scroll'"
>
<button <button
v-for="voice in userVoiceCards" v-for="voice in userVoiceCards"
:key="voice.id" :key="voice.id"
class="voice-card" class="flex items-center gap-3 px-3 py-2.5 rounded-lg cursor-pointer transition-all text-left border border-transparent hover:bg-muted"
:class="{ 'selected': selectedVoiceId === voice.id }" :class="selectedVoiceId === voice.id ? 'bg-primary/10 border-primary/30' : ''"
@click="handleVoiceSelect(voice)" @click="handleVoiceSelect(voice)"
> >
<!-- 头像区域 --> <!-- 波形图标 -->
<div class="card-avatar"> <div class="shrink-0 w-8 h-8 rounded-full flex items-center justify-center"
<div class="avatar-ring"></div> :class="selectedVoiceId === voice.id ? 'bg-primary text-primary-foreground' : 'bg-primary/15 text-primary'"
<div class="avatar-icon"> >
<Icon icon="lucide:audio-waveform" class="size-6" /> <Icon icon="lucide:audio-waveform" class="size-4" />
</div>
<!-- 选中指示器 -->
<div v-if="selectedVoiceId === voice.id" class="selected-indicator">
<Icon icon="lucide:check" class="size-3" />
</div>
</div> </div>
<!-- 信息区域 --> <!-- 名称 -->
<div class="card-info"> <div class="flex-1 min-w-0">
<div class="voice-name">{{ voice.name }}</div> <div class="text-sm font-medium truncate"
<div class="voice-desc">{{ voice.description || '我的配音' }}</div> :class="selectedVoiceId === voice.id ? 'text-primary' : 'text-foreground'"
>{{ voice.name }}</div>
<div v-if="voice.description" class="text-xs text-muted-foreground truncate">{{ voice.description }}</div>
</div> </div>
<!-- 选中标记 -->
<Icon v-if="selectedVoiceId === voice.id" icon="lucide:check-circle-2" class="size-4 text-primary shrink-0" />
</button> </button>
</div> </div>
<!-- 播放器区域 --> <!-- 播放器区域 -->
<transition name="slide-fade"> <transition name="slide-fade">
<div v-if="audioUrl" class="player-section"> <div v-if="audioUrl" class="bg-muted rounded-lg p-3.5 border border-border">
<div class="player-header"> <div class="flex items-center justify-between mb-3">
<div class="player-info"> <div class="flex items-center gap-2.5">
<div class="player-icon"> <div class="text-primary">
<Icon icon="lucide:play-circle" class="size-8" /> <Icon icon="lucide:play-circle" class="size-8" />
</div> </div>
<div class="player-meta"> <div>
<div class="player-title">{{ currentVoiceName }}</div> <div class="text-base font-semibold text-foreground">{{ currentVoiceName }}</div>
<div class="player-label">合成预览</div> <div class="text-xs text-muted-foreground">合成预览</div>
</div> </div>
</div> </div>
<Button variant="ghost" size="sm" @click="downloadAudio" class="download-btn"> <Button variant="ghost" size="sm" @click="downloadAudio" class="text-muted-foreground text-xs hover:text-primary hover:bg-primary/[0.08]">
<Icon icon="lucide:download" class="size-4" /> <Icon icon="lucide:download" class="size-4" />
下载 下载
</Button> </Button>
@@ -114,6 +117,7 @@ const playerContainer = ref(null)
const audioUrl = ref('') const audioUrl = ref('')
const currentVoiceName = ref('') const currentVoiceName = ref('')
const isPlayerInitializing = ref(false) const isPlayerInitializing = ref(false)
const isSynthesizing = ref(false)
// 默认封面图片(音频波形图标) // 默认封面图片(音频波形图标)
const defaultCover = `data:image/svg+xml;base64,${btoa(` const defaultCover = `data:image/svg+xml;base64,${btoa(`
@@ -184,12 +188,13 @@ const handleVoiceSelect = (voice) => {
} }
const handleSynthesize = () => { const handleSynthesize = () => {
if (!selectedVoiceId.value || isPlayerInitializing.value) return if (!selectedVoiceId.value || isPlayerInitializing.value || isSynthesizing.value) return
const voice = userVoiceCards.value.find((v) => v.id === selectedVoiceId.value) const voice = userVoiceCards.value.find((v) => v.id === selectedVoiceId.value)
if (!voice) return if (!voice) return
currentVoiceName.value = voice.name currentVoiceName.value = voice.name
isSynthesizing.value = true
handlePlayVoiceSample(voice) handlePlayVoiceSample(voice)
} }
@@ -215,12 +220,13 @@ const handlePlayVoiceSample = (voice) => {
playVoiceSample( playVoiceSample(
voice, voice,
(data) => { (data) => {
isSynthesizing.value = false
const url = data.audioUrl || data.objectUrl const url = data.audioUrl || data.objectUrl
if (!url) return if (!url) return
initPlayer(url) initPlayer(url)
}, },
() => { () => {
/* 错误已在 useTTS 中处理 */ isSynthesizing.value = false
}, },
{ autoPlay: false }, { autoPlay: false },
) )
@@ -331,274 +337,22 @@ onBeforeUnmount(() => {
</script> </script>
<style scoped lang="less"> <style scoped lang="less">
.voice-selector { /* 自定义滚动条 */
width: 100%; .voice-grid-scroll {
} &::-webkit-scrollbar {
width: 4px;
/* 空状态 */ }
.empty-voices { &::-webkit-scrollbar-track {
padding: var(--space-10) var(--space-6); background: #f1f5f9;
background: var(--muted); border-radius: 2px;
border: 1px dashed var(--border); }
border-radius: var(--radius-lg); &::-webkit-scrollbar-thumb {
text-align: center; background: #cbd5e1;
border-radius: 2px;
.empty-icon {
margin-bottom: var(--space-3);
}
}
/* 主容器 */
.voice-selector-wrapper {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
/* 标题栏 */
.selector-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--border);
}
.header-left {
display: flex;
align-items: center;
gap: var(--space-2-5);
}
.header-title {
font-size: var(--font-size-md);
font-weight: 600;
color: var(--foreground);
}
.voice-count {
font-size: var(--font-size-xs);
color: var(--muted-foreground);
background: var(--muted);
padding: var(--space-0-5) var(--space-2);
border-radius: var(--radius);
}
.synthesize-btn {
height: 36px;
padding: var(--space-2) var(--space-4);
border-radius: var(--radius);
border: none;
background: var(--primary);
color: var(--primary-foreground);
font-weight: 500;
font-size: var(--font-size-sm);
box-shadow: var(--shadow-sm);
transition: all var(--duration-fast);
&:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
&:disabled {
background: var(--muted-foreground);
box-shadow: none;
cursor: not-allowed;
transform: none;
}
}
/* 卡片网格 */
.voice-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 16px;
&.has-many {
max-height: 280px;
overflow-y: auto;
padding-right: 4px;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 2px;
}
&::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 2px;
}
}
}
/* 音色卡片 */
.voice-card {
position: relative;
background: var(--card);
border: 2px solid var(--muted);
border-radius: var(--radius-lg);
padding: var(--space-4) var(--space-3);
cursor: pointer;
transition: all var(--duration-base);
overflow: hidden;
text-align: left;
&:hover {
border-color: var(--border);
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
&.selected {
border-color: var(--primary);
background: oklch(from var(--primary) l c h / 0.1);
box-shadow: var(--shadow-md);
}
}
/* 头像区域 */
.card-avatar {
position: relative;
width: 48px;
height: 48px;
margin: 0 auto var(--space-2-5);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
.avatar-ring {
background: oklch(from var(--primary) l c h / 0.2);
}
.avatar-icon {
color: var(--primary);
}
}
.avatar-ring {
position: absolute;
inset: 0;
border-radius: 50%;
}
.avatar-icon {
position: relative;
z-index: 1;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
}
.selected-indicator {
position: absolute;
top: -4px;
right: -4px;
width: 20px;
height: 20px;
background: var(--primary);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: var(--primary-foreground);
font-size: var(--font-size-xs);
box-shadow: var(--shadow-sm);
animation: scaleIn var(--duration-fast);
}
@keyframes scaleIn {
from {
transform: scale(0);
}
to {
transform: scale(1);
}
}
/* 信息区域 */
.card-info {
text-align: center;
}
.voice-name {
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--foreground);
margin-bottom: var(--space-1);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.voice-desc {
font-size: var(--font-size-xs);
color: var(--muted-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 播放器区域 */
.player-section {
background: var(--muted);
border-radius: var(--radius-lg);
padding: var(--space-3-5);
border: 1px solid var(--border);
}
.player-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-3);
}
.player-info {
display: flex;
align-items: center;
gap: var(--space-2-5);
}
.player-icon {
width: 36px;
height: 36px;
color: var(--primary);
display: flex;
align-items: center;
justify-content: center;
}
.player-meta {
.player-title {
font-size: var(--font-size-base);
font-weight: 600;
color: var(--foreground);
}
.player-label {
font-size: var(--font-size-xs);
color: var(--muted-foreground);
}
}
.download-btn {
color: var(--muted-foreground);
font-size: var(--font-size-xs);
padding: var(--space-2) var(--space-3);
height: auto;
border-radius: var(--radius-sm);
transition: all var(--duration-fast);
&:hover {
color: var(--primary);
background: oklch(from var(--primary) l c h / 0.08);
} }
} }
/* APlayer 深层样式覆盖 */
.aplayer-container { .aplayer-container {
:deep(.aplayer) { :deep(.aplayer) {
border-radius: var(--radius); border-radius: var(--radius);
@@ -612,19 +366,21 @@ onBeforeUnmount(() => {
} }
/* 动画 */ /* 动画 */
@keyframes scaleIn {
from { transform: scale(0); }
to { transform: scale(1); }
}
.slide-fade-enter-active { .slide-fade-enter-active {
transition: all var(--duration-base) ease-out; transition: all 0.2s ease-out;
} }
.slide-fade-leave-active { .slide-fade-leave-active {
transition: all var(--duration-fast) ease-in; transition: all 0.15s ease-in;
} }
.slide-fade-enter-from { .slide-fade-enter-from {
transform: translateY(-10px); transform: translateY(-10px);
opacity: 0; opacity: 0;
} }
.slide-fade-leave-to { .slide-fade-leave-to {
transform: translateY(-10px); transform: translateY(-10px);
opacity: 0; opacity: 0;

View File

@@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref, watch, nextTick } from 'vue' import { ref, computed, watch, nextTick } from 'vue'
import { Icon } from '@iconify/vue' import { Icon } from '@iconify/vue'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
import { import {
@@ -33,7 +33,46 @@ const props = defineProps({
const emit = defineEmits(['update:visible', 'send']) const emit = defineEmits(['update:visible', 'send'])
// State const isPromptScene = computed(() => props.agent?.source === 'prompt')
function buildRequestOptions(prompt, ctrl) {
const base = {
agentId: props.agent?.id,
source: props.agent?.source || 'agent',
content: prompt,
modelMode: modelMode.value,
ctrl,
onMessage: handleStreamMessage,
onError: handleStreamError,
onClose: handleStreamClose
}
if (isPromptScene.value) {
base.customSystemPrompt = props.agent.customSystemPrompt
}
return base
}
function handleStreamMessage(result) {
if (result.event === 'message' && result.content) {
generatedContent.value += result.content
scrollToBottom()
} else if (result.event === 'error') {
toast.error(result.errorMessage || '生成出错')
}
}
function handleStreamError() {
toast.error('生成失败')
if (!generatedContent.value) isGenerating.value = false
}
function handleStreamClose() {
isGenerating.value = false
abortController.value = null
}
const modelMode = ref('pro') const modelMode = ref('pro')
const inputText = ref('') const inputText = ref('')
const isGenerating = ref(false) const isGenerating = ref(false)
@@ -44,7 +83,6 @@ const abortController = ref(null)
const historyVisible = ref(false) const historyVisible = ref(false)
const showCloseConfirm = ref(false) const showCloseConfirm = ref(false)
// Methods
const handleClose = (open) => { const handleClose = (open) => {
if (!open) { if (!open) {
if (isGenerating.value) { if (isGenerating.value) {
@@ -122,28 +160,7 @@ const executeStreamRequest = async (prompt) => {
abortController.value = new AbortController() abortController.value = new AbortController()
try { try {
await sendChatStream({ await sendChatStream(buildRequestOptions(prompt, abortController.value))
agentId: props.agent?.id,
content: prompt,
modelMode: modelMode.value,
ctrl: abortController.value,
onMessage: (result) => {
if (result.event === 'message' && result.content) {
generatedContent.value += result.content
scrollToBottom()
} else if (result.event === 'error') {
toast.error(result.errorMessage || '生成出错')
}
},
onError: () => {
toast.error('生成失败')
if (!generatedContent.value) isGenerating.value = false
},
onClose: () => {
isGenerating.value = false
abortController.value = null
}
})
} catch (error) { } catch (error) {
if (error.name !== 'AbortError') toast.error('生成失败') if (error.name !== 'AbortError') toast.error('生成失败')
isGenerating.value = false isGenerating.value = false
@@ -170,7 +187,7 @@ watch(() => props.visible, (val) => {
<template> <template>
<Sheet :open="visible" @update:open="handleClose"> <Sheet :open="visible" @update:open="handleClose">
<SheetContent side="right" class="w-full sm:w-[420px] md:w-[560px] lg:w-[640px] p-0 flex flex-col bg-background"> <SheetContent side="right" class="w-full sm:w-[520px] md:w-[680px] lg:w-[800px] p-0 flex flex-col bg-background">
<!-- Header --> <!-- Header -->
<SheetHeader class="shrink-0"> <SheetHeader class="shrink-0">
<ChatDrawerHeader :agent="agent" @history="openHistory" /> <ChatDrawerHeader :agent="agent" @history="openHistory" />
@@ -208,6 +225,7 @@ watch(() => props.visible, (val) => {
<HistoryPanel <HistoryPanel
:visible="historyVisible" :visible="historyVisible"
:agent-id="agent?.id" :agent-id="agent?.id"
:source="agent?.source || 'agent'"
@close="closeHistory" @close="closeHistory"
/> />

View File

@@ -1,31 +1,45 @@
<script setup> <script setup>
import { computed } from 'vue'
import { Icon } from '@iconify/vue' import { Icon } from '@iconify/vue'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
defineProps({ const props = defineProps({
agent: { type: Object, default: null } agent: { type: Object, default: null }
}) })
const emit = defineEmits(['history']) const emit = defineEmits(['history'])
const isPromptScene = computed(() => props.agent?.source === 'prompt')
const openHistory = () => emit('history') const openHistory = () => emit('history')
</script> </script>
<template> <template>
<div class="flex items-center justify-between px-4 py-3 border-b border-border/50 shrink-0 bg-gradient-to-r from-background to-muted/30"> <div class="flex items-center justify-between px-4 py-3 border-b border-border/50 shrink-0 bg-gradient-to-r from-background to-muted/30">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="size-10 rounded-xl bg-gradient-to-br from-primary to-violet-500 flex items-center justify-center overflow-hidden shadow-sm shadow-primary/20"> <div
class="size-10 rounded-xl flex items-center justify-center overflow-hidden shadow-sm"
:class="isPromptScene
? 'bg-gradient-to-br from-amber-400 to-orange-500 shadow-amber-200/30'
: 'bg-gradient-to-br from-primary to-violet-500 shadow-primary/20'"
>
<img <img
v-if="agent?.avatar" v-if="agent?.avatar"
:src="agent.avatar" :src="agent.avatar"
:alt="agent.name" :alt="agent.name"
class="w-full h-full object-cover" class="w-full h-full object-cover"
/> />
<Icon v-else icon="lucide:bot" class="text-white text-xl" /> <Icon v-else :icon="isPromptScene ? 'lucide:sparkles' : 'lucide:bot'" class="text-white text-xl" />
</div> </div>
<div> <div>
<div class="font-semibold text-foreground text-sm">{{ agent?.name || 'AI 助手' }}</div> <div class="font-semibold text-foreground text-sm">{{ agent?.name || 'AI 助手' }}</div>
<div class="text-xs text-muted-foreground">{{ agent?.categoryName || '通用' }}</div> <div class="text-xs text-muted-foreground">
<span v-if="isPromptScene" class="inline-flex items-center gap-1">
<Icon icon="lucide:pencil" class="size-3" />
{{ agent?.categoryName || '我的风格' }}
</span>
<span v-else>{{ agent?.categoryName || '通用' }}</span>
</div>
</div> </div>
</div> </div>
<Button <Button

View File

@@ -172,7 +172,8 @@ import { copyToClipboard } from '@/utils/clipboard'
const props = defineProps({ const props = defineProps({
visible: { type: Boolean, default: false }, visible: { type: Boolean, default: false },
agentId: { type: [String, Number], default: null } agentId: { type: [String, Number], default: null },
source: { type: String, default: 'agent' }
}) })
const emit = defineEmits(['close']) const emit = defineEmits(['close'])
@@ -183,7 +184,7 @@ const conversationList = ref([])
const selectedConversation = ref(null) const selectedConversation = ref(null)
const messageList = ref([]) const messageList = ref([])
const messageLoading = ref(false) const messageLoading = ref(false)
const lastId = ref(null) const cursor = ref(null)
const hasMore = ref(true) const hasMore = ref(true)
// 按日期分组(先排序再分组) // 按日期分组(先排序再分组)
@@ -229,9 +230,9 @@ const loadConversations = async (loadMore = false) => {
loading.value = true loading.value = true
try { try {
const params = { agentId: props.agentId, limit: 20 } const params = { agentId: props.agentId, source: props.source, limit: 20 }
if (loadMore && lastId.value) { if (loadMore && cursor.value) {
params.lastId = lastId.value params.cursor = cursor.value
} }
const res = await getConversations(params) const res = await getConversations(params)
if (res.code === 0) { if (res.code === 0) {
@@ -241,9 +242,9 @@ const loadConversations = async (loadMore = false) => {
} else { } else {
conversationList.value = data conversationList.value = data
} }
// 更新分页状态 // 更新分页状态:使用后端返回的复合游标
lastId.value = data.length > 0 ? data[data.length - 1].id : null cursor.value = res.data?.nextCursor || null
hasMore.value = data.length === 20 && res.data?.hasMore !== false hasMore.value = res.data?.hasMore !== false && data.length > 0
} }
} catch (e) { } catch (e) {
console.error('加载会话列表失败:', e) console.error('加载会话列表失败:', e)
@@ -259,7 +260,9 @@ const selectConversation = async (conversation) => {
try { try {
const res = await getMessages({ const res = await getMessages({
agentId: props.agentId, agentId: props.agentId,
source: props.source,
conversationId: conversation.id, conversationId: conversation.id,
appSource: conversation.appSource,
limit: 50 limit: 50
}) })
if (res.code === 0) { if (res.code === 0) {
@@ -318,8 +321,7 @@ watch(() => props.visible, (val) => {
.history-overlay { .history-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: rgba(15, 23, 42, 0.4); background: rgba(15, 23, 42, 0.5);
backdrop-filter: blur(8px);
z-index: 2000; z-index: 2000;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -341,18 +343,19 @@ watch(() => props.visible, (val) => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
will-change: transform, opacity;
} }
.modal-glow { .modal-glow {
position: absolute; position: absolute;
top: -50%; top: 0;
left: -50%; left: 0;
width: 200%; width: 100%;
height: 200%; height: 200px;
background: radial-gradient( background: radial-gradient(
circle at 30% 20%, ellipse at 30% 0%,
rgba(59, 130, 246, 0.08) 0%, rgba(59, 130, 246, 0.06) 0%,
transparent 40% transparent 60%
); );
pointer-events: none; pointer-events: none;
} }
@@ -439,7 +442,8 @@ watch(() => props.visible, (val) => {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition: all 0.2s ease; will-change: transform;
transition: transform 0.2s ease, background 0.2s ease, color 0.2s ease;
&:hover { &:hover {
background: var(--color-error-100); background: var(--color-error-100);
@@ -508,6 +512,7 @@ watch(() => props.visible, (val) => {
inset: 0; inset: 0;
border-radius: 50%; border-radius: 50%;
border: 2px solid var(--color-gray-200); border: 2px solid var(--color-gray-200);
will-change: opacity;
animation: ringPulse 3s ease-in-out infinite; animation: ringPulse 3s ease-in-out infinite;
&.delay-1 { &.delay-1 {
@@ -518,11 +523,9 @@ watch(() => props.visible, (val) => {
@keyframes ringPulse { @keyframes ringPulse {
0%, 100% { 0%, 100% {
transform: scale(1);
opacity: 0.5; opacity: 0.5;
} }
50% { 50% {
transform: scale(1.05);
opacity: 1; opacity: 1;
} }
} }
@@ -562,6 +565,7 @@ watch(() => props.visible, (val) => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 24px; gap: 24px;
contain: layout style;
} }
.load-more { .load-more {
@@ -595,8 +599,9 @@ watch(() => props.visible, (val) => {
} }
.conversation-group { .conversation-group {
animation: groupFadeIn 0.4s ease-out backwards; will-change: opacity;
animation-delay: calc(var(--group-index) * 0.1s); animation: groupFadeIn 0.3s ease-out backwards;
animation-delay: calc(var(--group-index) * 0.06s);
} }
@keyframes groupFadeIn { @keyframes groupFadeIn {
@@ -645,6 +650,7 @@ watch(() => props.visible, (val) => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
contain: layout style;
} }
// ======================================== // ========================================
@@ -661,17 +667,15 @@ watch(() => props.visible, (val) => {
border-radius: 14px; border-radius: 14px;
cursor: pointer; cursor: pointer;
text-align: left; text-align: left;
transition: all 0.25s ease; contain: content;
animation: itemFadeIn 0.3s ease-out backwards; will-change: transform, opacity;
animation-delay: calc(var(--group-index) * 0.1s + var(--item-index) * 0.05s); transition: transform 0.2s ease, border-color 0.2s ease;
animation: itemFadeIn 0.25s ease-out backwards;
animation-delay: calc(var(--group-index) * 0.06s + var(--item-index) * 0.03s);
&:hover { &:hover {
border-color: var(--color-primary-300); border-color: var(--color-primary-300);
background: rgba(59, 130, 246, 0.02); transform: translateY(-1px);
box-shadow:
0 4px 12px rgba(59, 130, 246, 0.08),
0 0 0 1px rgba(59, 130, 246, 0.1);
transform: translateY(-2px);
.item-indicator { .item-indicator {
background: var(--color-primary-500); background: var(--color-primary-500);
@@ -688,11 +692,11 @@ watch(() => props.visible, (val) => {
@keyframes itemFadeIn { @keyframes itemFadeIn {
from { from {
opacity: 0; opacity: 0;
transform: translateX(-8px); transform: translateY(6px);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateX(0); transform: translateY(0);
} }
} }
@@ -702,7 +706,8 @@ watch(() => props.visible, (val) => {
border-radius: 2px; border-radius: 2px;
background: var(--color-gray-200); background: var(--color-gray-200);
flex-shrink: 0; flex-shrink: 0;
transition: all 0.25s ease; will-change: transform;
transition: transform 0.2s ease, background 0.2s ease;
transform: scaleY(0.6); transform: scaleY(0.6);
} }
@@ -755,7 +760,8 @@ watch(() => props.visible, (val) => {
justify-content: center; justify-content: center;
color: var(--color-gray-300); color: var(--color-gray-300);
font-size: 10px; font-size: 10px;
transition: all 0.25s ease; will-change: transform;
transition: transform 0.2s ease, color 0.2s ease;
} }
// ======================================== // ========================================
@@ -769,11 +775,13 @@ watch(() => props.visible, (val) => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 24px; gap: 24px;
contain: layout style;
} }
.timeline-item { .timeline-item {
animation: msgFadeIn 0.35s ease-out backwards; will-change: opacity;
animation-delay: calc(var(--msg-index) * 0.08s); animation: msgFadeIn 0.25s ease-out backwards;
animation-delay: calc(var(--msg-index) * 0.04s);
} }
@keyframes msgFadeIn { @keyframes msgFadeIn {

View File

@@ -60,6 +60,7 @@ async function loadList() {
promptList.value = res.data.map(item => ({ promptList.value = res.data.map(item => ({
id: item.id, id: item.id,
name: item.name, name: item.name,
content: item.content,
category: item.category, category: item.category,
useCount: item.useCount || 0 useCount: item.useCount || 0
})) }))
@@ -93,9 +94,11 @@ async function handleDelete(id) {
function handleUse(item) { function handleUse(item) {
emit('chat', { emit('chat', {
source: 'prompt',
id: item.id, id: item.id,
name: item.name, name: item.name,
categoryName: item.category || '我的风格' categoryName: item.category || '我的风格',
customSystemPrompt: item.content
}) })
handleClose() handleClose()
} }

View File

@@ -39,9 +39,9 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
:class="cn( :class="cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500', 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
side === 'right' side === 'right'
&& 'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm', && 'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-2xl',
side === 'left' side === 'left'
&& 'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm', && 'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-2xl',
side === 'top' side === 'top'
&& 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b', && 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
side === 'bottom' side === 'bottom'

View File

@@ -53,7 +53,7 @@ const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
<div <div
v-else v-else
class="group peer text-sidebar-foreground hidden md:block" class="select-none group peer text-sidebar-foreground hidden md:block"
data-slot="sidebar" data-slot="sidebar"
:data-state="state" :data-state="state"
:data-collapsible="state === 'collapsed' ? collapsible : ''" :data-collapsible="state === 'collapsed' ? collapsible : ''"

View File

@@ -1,41 +1,17 @@
import * as clipboardPolyfill from 'clipboard-polyfill'
/** /**
* 复制文本到剪贴板 * 复制文本到剪贴板
* 兼容非 HTTPS 环境的降级方案 * 使用 clipboard-polyfill 兼容非 HTTPS 环境
*/ */
export async function copyToClipboard(text: string): Promise<boolean> { export async function copyToClipboard(text: string): Promise<boolean> {
if (!text?.trim()) { if (!text?.trim()) {
return false return false
} }
// 优先使用 Clipboard API需要 HTTPS 或 localhost
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
// 降级到 execCommand 方案
}
}
// 降级方案:使用 textarea + execCommand
return fallbackCopy(text)
}
/**
* 降级复制方案
*/
function fallbackCopy(text: string): boolean {
try { try {
const textarea = document.createElement('textarea') await clipboardPolyfill.writeText(text)
textarea.value = text return true
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
textarea.style.left = '-9999px'
document.body.appendChild(textarea)
textarea.select()
const success = document.execCommand('copy')
document.body.removeChild(textarea)
return success
} catch { } catch {
return false return false
} }

View File

@@ -280,6 +280,7 @@ const fetchAgentList = async () => {
const res = await getAgentList() const res = await getAgentList()
if (res.code === 0 && res.data) { if (res.code === 0 && res.data) {
agentList.value = res.data.map(item => ({ agentList.value = res.data.map(item => ({
source: 'agent',
id: item.id, id: item.id,
agentId: item.agentId, agentId: item.agentId,
name: item.agentName, name: item.agentName,

View File

@@ -1,18 +1,12 @@
<script setup> <script setup>
import { ref, reactive, computed, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
import { Icon } from '@iconify/vue' import { Icon } from '@iconify/vue'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { MaterialService } from '@/api/material'
import { VoiceService } from '@/api/voice' import { VoiceService } from '@/api/voice'
import { useUpload } from '@/composables/useUpload'
import useVoiceText from '@/hooks/web/useVoiceText'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Spinner } from '@/components/ui/spinner' import { Spinner } from '@/components/ui/spinner'
import { import {
Table, Table,
@@ -22,13 +16,6 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from '@/components/ui/table' } from '@/components/ui/table'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -40,39 +27,18 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from '@/components/ui/alert-dialog' } from '@/components/ui/alert-dialog'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Progress } from '@/components/ui/progress'
import TaskPageLayout from '@/views/system/task-management/components/TaskPageLayout.vue' import TaskPageLayout from '@/views/system/task-management/components/TaskPageLayout.vue'
import VoiceCopyDialog from './VoiceCopyDialog.vue'
// ========== 常量 ==========
const MAX_FILE_SIZE = 5 * 1024 * 1024
const VALID_AUDIO_TYPES = ['audio/mpeg', 'audio/wav', 'audio/aac', 'audio/mp4', 'audio/flac', 'audio/ogg']
const VALID_AUDIO_EXTENSIONS = ['.mp3', '.wav', '.aac', '.m4a', '.flac', '.ogg']
const DEFAULT_FORM_DATA = {
id: null,
name: '',
fileId: null,
autoTranscribe: true,
language: 'zh-CN',
gender: 'female',
note: '',
text: '',
fileUrl: ''
}
// ========== 响应式数据 ========== // ========== 响应式数据 ==========
const loading = ref(false) const loading = ref(false)
const submitting = ref(false)
const voiceList = ref([]) const voiceList = ref([])
const modalVisible = ref(false)
const deleteDialogVisible = ref(false) const deleteDialogVisible = ref(false)
const deleteTarget = ref(null) const deleteTarget = ref(null)
const formMode = ref('create') const dialogVisible = ref(false)
const dialogMode = ref('create')
const dialogRecord = ref(null)
const audioPlayer = ref(null) const audioPlayer = ref(null)
const fileList = ref([])
const extractingText = ref(false)
const fileInputRef = ref(null)
const isDragging = ref(false)
const searchParams = reactive({ const searchParams = reactive({
name: '', name: '',
@@ -87,22 +53,6 @@ const pagination = reactive({
showSizeChanger: true, showSizeChanger: true,
}) })
const formData = reactive({ ...DEFAULT_FORM_DATA })
// ========== Hooks ==========
const { state: uploadState, upload } = useUpload()
const { getVoiceText } = useVoiceText()
// ========== 计算属性 ==========
const isCreateMode = computed(() => formMode.value === 'create')
const isSubmitDisabled = computed(() => {
if (!isCreateMode.value) return false
if (extractingText.value) return true
if (formData.fileId && !formData.text) return true
return false
})
// ========== 工具函数 ========== // ========== 工具函数 ==========
const formatDateTime = (value) => { const formatDateTime = (value) => {
if (!value) return '-' if (!value) return '-'
@@ -151,35 +101,15 @@ function handlePageChange(page) {
// ========== CRUD 操作 ========== // ========== CRUD 操作 ==========
function handleCreate() { function handleCreate() {
formMode.value = 'create' dialogMode.value = 'create'
resetForm() dialogRecord.value = null
modalVisible.value = true dialogVisible.value = true
} }
async function handleEdit(record) { function handleEdit(record) {
formMode.value = 'edit' dialogMode.value = 'edit'
try { dialogRecord.value = record
const res = await VoiceService.get(record.id) dialogVisible.value = true
if (res.code === 0 && res.data) {
Object.assign(formData, {
id: res.data.id || null,
name: res.data.name || '',
fileId: res.data.fileId || null,
language: res.data.language || 'zh-CN',
gender: res.data.gender || 'female',
note: res.data.note || ''
})
}
} catch (error) {
console.error('获取配音详情失败:', error)
Object.assign(formData, {
id: record.id,
name: record.name || '',
fileId: record.fileId || null,
note: record.note || ''
})
}
modalVisible.value = true
} }
function handleDelete(record) { function handleDelete(record) {
@@ -216,154 +146,6 @@ function handlePlayAudio(record) {
} }
} }
// ========== 文件上传 ==========
function triggerFileInput() {
fileInputRef.value?.click()
}
function handleFileSelect(event) {
const file = event.target.files?.[0]
if (file) processFile(file)
}
function handleDragOver(e) {
e.preventDefault()
isDragging.value = true
}
function handleDragLeave() {
isDragging.value = false
}
function handleDrop(event) {
event.preventDefault()
isDragging.value = false
const file = event.dataTransfer?.files?.[0]
if (file) processFile(file)
}
function validateFile(file) {
if (file.size > MAX_FILE_SIZE) {
toast.error('文件大小不能超过 5MB')
return false
}
const fileName = file.name.toLowerCase()
const isValid = VALID_AUDIO_TYPES.some(t => file.type.includes(t)) ||
VALID_AUDIO_EXTENSIONS.some(ext => fileName.endsWith(ext))
if (!isValid) {
toast.error('请上传音频文件MP3、WAV、AAC 等)')
return false
}
return true
}
async function processFile(file) {
if (!validateFile(file)) return
try {
await upload(file, {
fileCategory: 'voice',
groupId: null,
onSuccess: async (id, fileUrl) => {
formData.fileId = id
formData.fileUrl = fileUrl
fileList.value = [file]
await fetchAudioTextById(id)
},
onError: (error) => {
toast.error(error.message || '上传失败')
}
})
} catch (error) {
console.error('上传失败:', error)
}
}
async function fetchAudioTextById(fileId) {
if (!fileId) return
extractingText.value = true
try {
const res = await MaterialService.getAudioPlayUrl(fileId)
if (res.code === 0 && res.data) {
const results = await getVoiceText([{ audio_url: res.data }])
if (results?.length > 0) {
formData.text = results[0].value || ''
}
}
} catch (error) {
console.error('获取音频文本失败:', error)
toast.error('语音识别失败')
} finally {
extractingText.value = false
}
}
function handleRemoveFile() {
formData.fileId = null
formData.text = ''
formData.fileUrl = ''
fileList.value = []
}
// ========== 表单操作 ==========
async function handleSubmit() {
if (!formData.name.trim()) {
toast.warning('请输入配音名称')
return
}
if (isCreateMode.value && !formData.fileId) {
toast.warning('请上传音频文件')
return
}
submitting.value = true
const params = isCreateMode.value
? {
name: formData.name,
fileId: formData.fileId,
autoTranscribe: formData.autoTranscribe,
language: formData.language,
gender: formData.gender,
note: formData.note,
text: formData.text
}
: {
id: formData.id,
name: formData.name,
language: formData.language,
gender: formData.gender,
note: formData.note
}
try {
const res = isCreateMode.value
? await VoiceService.create(params)
: await VoiceService.update(params)
if (res.code !== 0) {
toast.error(res.msg || '操作失败')
return
}
toast.success(isCreateMode.value ? '创建成功' : '更新成功')
modalVisible.value = false
loadVoiceList()
} catch (error) {
console.error('提交失败:', error)
toast.error('操作失败,请稍后重试')
} finally {
submitting.value = false
}
}
function handleCancel() {
modalVisible.value = false
resetForm()
}
function resetForm() {
Object.assign(formData, { ...DEFAULT_FORM_DATA })
fileList.value = []
}
// ========== 生命周期 ========== // ========== 生命周期 ==========
onMounted(() => loadVoiceList()) onMounted(() => loadVoiceList())
</script> </script>
@@ -467,102 +249,12 @@ onMounted(() => loadVoiceList())
<!-- 弹窗 --> <!-- 弹窗 -->
<template #modals> <template #modals>
<!-- 新建/编辑弹窗 --> <!-- 新建/编辑弹窗 -->
<Dialog :open="modalVisible" @update:open="(v) => modalVisible = v"> <VoiceCopyDialog
<DialogContent class="sm:max-w-lg"> v-model:open="dialogVisible"
<DialogHeader> :mode="dialogMode"
<DialogTitle>{{ isCreateMode ? '新建配音' : '编辑配音' }}</DialogTitle> :record="dialogRecord"
</DialogHeader> @success="loadVoiceList"
/>
<div class="space-y-5 py-2">
<!-- 名称 -->
<div class="space-y-2">
<Label for="name">配音名称 <span class="text-destructive">*</span></Label>
<Input id="name" v-model="formData.name" placeholder="请输入配音名称" />
</div>
<!-- 上传区域 -->
<div v-if="isCreateMode" class="space-y-2">
<Label>音频文件 <span class="text-destructive">*</span></Label>
<!-- 未上传状态 -->
<div
v-if="fileList.length === 0 && !uploadState.uploading && !extractingText"
class="upload-zone"
:class="{ 'upload-zone--dragging': isDragging }"
@click="triggerFileInput"
@dragover="handleDragOver"
@dragleave="handleDragLeave"
@drop="handleDrop"
>
<div class="upload-zone__icon">
<Icon icon="lucide:cloud-upload" />
</div>
<p class="upload-zone__title">点击或拖拽音频文件到此区域</p>
<p class="upload-zone__hint">支持 MP3WAVAACM4AFLACOGG最大 5MB</p>
</div>
<!-- 上传中 -->
<div v-else-if="uploadState.uploading" class="upload-status">
<Progress :value="50" class="w-16" />
<p class="mt-3 text-sm text-muted-foreground">正在上传...</p>
</div>
<!-- 识别中 -->
<div v-else-if="extractingText" class="upload-status">
<Progress :value="50" class="w-16" />
<p class="mt-3 text-sm text-muted-foreground">正在识别语音...</p>
</div>
<!-- 已上传 -->
<div v-else class="upload-preview">
<div class="upload-preview__icon">
<Icon icon="lucide:file-audio" />
</div>
<div class="upload-preview__info">
<span class="upload-preview__name">{{ fileList[0]?.name || '音频文件' }}</span>
<Badge v-if="formData.text" variant="secondary" class="gap-1">
<Icon icon="lucide:check-circle" class="size-3" />
已识别语音
</Badge>
<Badge v-else variant="outline" class="gap-1 text-amber-600">
<Icon icon="lucide:alert-circle" class="size-3" />
未识别到语音
</Badge>
</div>
<Button variant="ghost" size="sm" class="text-destructive" @click="handleRemoveFile">
<Icon icon="lucide:x" class="size-4" />
</Button>
</div>
<input
ref="fileInputRef"
type="file"
accept="audio/*,.mp3,.wav,.aac,.m4a,.flac,.ogg"
class="hidden"
@change="handleFileSelect"
/>
</div>
<!-- 备注 -->
<div class="space-y-2">
<Label for="note">备注</Label>
<Textarea
id="note"
v-model="formData.note"
placeholder="备注信息(选填)"
:rows="2"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="handleCancel">取消</Button>
<Button :disabled="isSubmitDisabled" :loading="submitting" @click="handleSubmit">
保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- 删除确认 --> <!-- 删除确认 -->
<AlertDialog :open="deleteDialogVisible" @update:open="(v) => deleteDialogVisible = v"> <AlertDialog :open="deleteDialogVisible" @update:open="(v) => deleteDialogVisible = v">
@@ -588,98 +280,5 @@ onMounted(() => loadVoiceList())
</TaskPageLayout> </TaskPageLayout>
</div> </div>
<audio ref="audioPlayer" class="hidden" /> <audio ref="audioPlayer" class="hidden" />
</template> </template>
<style scoped lang="less">
// 上传区域
.upload-zone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-8) var(--space-4);
border: 2px dashed var(--border);
border-radius: var(--radius-lg);
background: var(--muted);
cursor: pointer;
transition: all 0.2s ease;
&:hover {
border-color: var(--primary);
background: oklch(0.97 0.01 254.604);
}
&--dragging {
border-color: var(--primary);
background: oklch(0.95 0.02 254.604);
}
&__icon {
font-size: 36px;
color: var(--primary);
margin-bottom: var(--space-3);
opacity: 0.8;
}
&__title {
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--foreground);
margin-bottom: var(--space-1);
}
&__hint {
font-size: var(--font-size-xs);
color: var(--muted-foreground);
}
}
.upload-status {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-8) var(--space-4);
border: 2px solid var(--border);
border-radius: var(--radius-lg);
background: var(--muted);
}
.upload-preview {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-4);
border: 1px solid oklch(0.92 0.02 145);
border-radius: var(--radius-lg);
background: oklch(0.98 0.01 145);
&__icon {
font-size: 28px;
color: var(--primary);
}
&__info {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--space-1);
}
&__name {
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--foreground);
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.hidden {
display: none;
}
</style>

View File

@@ -0,0 +1,372 @@
<script setup>
import { ref, reactive, computed, watch } from 'vue'
import { toast } from 'vue-sonner'
import { Icon } from '@iconify/vue'
import { VoiceService } from '@/api/voice'
import { MaterialService } from '@/api/material'
import { useUpload } from '@/composables/useUpload'
import useVoiceText from '@/hooks/web/useVoiceText'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Progress } from '@/components/ui/progress'
// ========== 常量 ==========
const MAX_FILE_SIZE = 5 * 1024 * 1024
const MAX_DURATION = 30 // 最大音频时长(秒)
const VALID_AUDIO_TYPES = ['audio/mpeg', 'audio/wav', 'audio/aac', 'audio/mp4', 'audio/flac', 'audio/ogg']
const VALID_AUDIO_EXTENSIONS = ['.mp3', '.wav', '.aac', '.m4a', '.flac', '.ogg']
const DEFAULT_FORM_DATA = {
id: null,
name: '',
fileId: null,
autoTranscribe: true,
language: 'zh-CN',
gender: 'female',
note: '',
text: '',
fileUrl: ''
}
// ========== Props & Emits ==========
const props = defineProps({
open: Boolean,
mode: { type: String, default: 'create' },
record: { type: Object, default: null }
})
const emit = defineEmits(['update:open', 'success'])
// ========== 响应式数据 ==========
const submitting = ref(false)
const audioPlayer = ref(null)
const fileList = ref([])
const extractingText = ref(false)
const fileInputRef = ref(null)
const isDragging = ref(false)
const formData = reactive({ ...DEFAULT_FORM_DATA })
// ========== Hooks ==========
const { state: uploadState, upload } = useUpload()
const { getVoiceText } = useVoiceText()
// ========== 计算属性 ==========
const isCreateMode = computed(() => props.mode === 'create')
const isSubmitDisabled = computed(() => {
if (!isCreateMode.value) return false
if (extractingText.value) return true
if (formData.fileId && !formData.text) return true
return false
})
// ========== Watch ==========
watch(() => props.open, (val) => {
if (val) {
if (isCreateMode.value) {
resetForm()
} else {
loadDetail()
}
}
})
// ========== 数据加载 ==========
async function loadDetail() {
if (!props.record) return
try {
const res = await VoiceService.get(props.record.id)
if (res.code === 0 && res.data) {
Object.assign(formData, {
id: res.data.id || null,
name: res.data.name || '',
fileId: res.data.fileId || null,
language: res.data.language || 'zh-CN',
gender: res.data.gender || 'female',
note: res.data.note || ''
})
}
} catch (error) {
console.error('获取配音详情失败:', error)
Object.assign(formData, {
id: props.record.id,
name: props.record.name || '',
fileId: props.record.fileId || null,
note: props.record.note || ''
})
}
}
// ========== 音频时长校验 ==========
function getAudioDuration(file) {
return new Promise((resolve) => {
const url = URL.createObjectURL(file)
const audio = new Audio()
audio.onloadedmetadata = () => {
URL.revokeObjectURL(url)
resolve(audio.duration)
}
audio.onerror = () => {
URL.revokeObjectURL(url)
resolve(0)
}
audio.src = url
})
}
// ========== 文件上传 ==========
function triggerFileInput() {
fileInputRef.value?.click()
}
function handleFileSelect(event) {
const file = event.target.files?.[0]
if (file) processFile(file)
}
function handleDragOver(e) {
e.preventDefault()
isDragging.value = true
}
function handleDragLeave() {
isDragging.value = false
}
function handleDrop(event) {
event.preventDefault()
isDragging.value = false
const file = event.dataTransfer?.files?.[0]
if (file) processFile(file)
}
function validateFile(file) {
if (file.size > MAX_FILE_SIZE) {
toast.error('文件大小不能超过 5MB')
return false
}
const fileName = file.name.toLowerCase()
const isValid = VALID_AUDIO_TYPES.some(t => file.type.includes(t)) ||
VALID_AUDIO_EXTENSIONS.some(ext => fileName.endsWith(ext))
if (!isValid) {
toast.error('请上传音频文件MP3、WAV、AAC 等)')
return false
}
return true
}
async function processFile(file) {
if (!validateFile(file)) return
// 校验音频时长
const duration = await getAudioDuration(file)
if (duration > MAX_DURATION) {
toast.error(`音频时长不能超过 ${MAX_DURATION} 秒(当前 ${Math.ceil(duration)} 秒)`)
return
}
try {
await upload(file, {
fileCategory: 'voice',
groupId: null,
onSuccess: async (id, fileUrl) => {
formData.fileId = id
formData.fileUrl = fileUrl
fileList.value = [file]
await fetchAudioTextById(id)
},
onError: (error) => {
toast.error(error.message || '上传失败')
}
})
} catch (error) {
console.error('上传失败:', error)
}
}
async function fetchAudioTextById(fileId) {
if (!fileId) return
extractingText.value = true
try {
const res = await MaterialService.getAudioPlayUrl(fileId)
if (res.code === 0 && res.data) {
const results = await getVoiceText([{ audio_url: res.data }])
if (results?.length > 0) {
formData.text = results[0].value || ''
}
}
} catch (error) {
console.error('获取音频文本失败:', error)
toast.error('语音识别失败')
} finally {
extractingText.value = false
}
}
function handleRemoveFile() {
formData.fileId = null
formData.text = ''
formData.fileUrl = ''
fileList.value = []
}
// ========== 表单操作 ==========
async function handleSubmit() {
if (!formData.name.trim()) {
toast.warning('请输入配音名称')
return
}
if (isCreateMode.value && !formData.fileId) {
toast.warning('请上传音频文件')
return
}
submitting.value = true
const params = isCreateMode.value
? {
name: formData.name,
fileId: formData.fileId,
autoTranscribe: formData.autoTranscribe,
language: formData.language,
gender: formData.gender,
note: formData.note,
text: formData.text
}
: {
id: formData.id,
name: formData.name,
language: formData.language,
gender: formData.gender,
note: formData.note
}
try {
const res = isCreateMode.value
? await VoiceService.create(params)
: await VoiceService.update(params)
if (res.code !== 0) {
toast.error(res.msg || '操作失败')
return
}
toast.success(isCreateMode.value ? '创建成功' : '更新成功')
emit('update:open', false)
emit('success')
} catch (error) {
console.error('提交失败:', error)
toast.error('操作失败,请稍后重试')
} finally {
submitting.value = false
}
}
function handleCancel() {
emit('update:open', false)
resetForm()
}
function resetForm() {
Object.assign(formData, { ...DEFAULT_FORM_DATA })
fileList.value = []
}
</script>
<template>
<Dialog :open="open" @update:open="(v) => emit('update:open', v)">
<DialogContent class="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{{ isCreateMode ? '新建配音' : '编辑配音' }}</DialogTitle>
</DialogHeader>
<div class="space-y-5 py-2">
<!-- 名称 -->
<div class="space-y-2">
<Label for="name">配音名称 <span class="text-destructive">*</span></Label>
<Input id="name" v-model="formData.name" placeholder="请输入配音名称" />
</div>
<!-- 上传区域 -->
<div v-if="isCreateMode" class="space-y-2">
<Label>音频文件 <span class="text-destructive">*</span></Label>
<!-- 未上传状态 -->
<div
v-if="fileList.length === 0 && !uploadState.uploading && !extractingText"
class="flex flex-col items-center justify-center p-8 px-4 border-2 border-dashed border-border rounded-lg bg-muted cursor-pointer transition-all duration-200 hover:border-primary hover:bg-primary/5"
:class="{ '!border-primary !bg-primary/10': isDragging }"
@click="triggerFileInput"
@dragover="handleDragOver"
@dragleave="handleDragLeave"
@drop="handleDrop"
>
<Icon icon="lucide:cloud-upload" class="text-4xl text-primary mb-3 opacity-80" />
<p class="text-sm font-medium text-foreground mb-1">点击或拖拽音频文件到此区域</p>
<p class="text-xs text-muted-foreground">支持 MP3WAVAACM4AFLACOGG最大 5MB时长不超过 1 分钟</p>
</div>
<!-- 上传中 -->
<div v-else-if="uploadState.uploading" class="flex flex-col items-center justify-center p-8 px-4 border-2 border-solid border-border rounded-lg bg-muted">
<Progress :value="50" class="w-16" />
<p class="mt-3 text-sm text-muted-foreground">正在上传...</p>
</div>
<!-- 识别中 -->
<div v-else-if="extractingText" class="flex flex-col items-center justify-center p-8 px-4 border-2 border-solid border-border rounded-lg bg-muted">
<Progress :value="50" class="w-16" />
<p class="mt-3 text-sm text-muted-foreground">正在识别语音...</p>
</div>
<!-- 已上传 -->
<div v-else class="flex items-center gap-3 p-4 border rounded-lg border-emerald-200 bg-emerald-50">
<Icon icon="lucide:file-audio" class="text-[28px] text-primary shrink-0" />
<div class="flex-1 flex flex-col gap-1 min-w-0">
<span class="text-sm font-medium text-foreground max-w-[220px] truncate">{{ fileList[0]?.name || '音频文件' }}</span>
<Badge v-if="formData.text" variant="secondary" class="gap-1 w-fit">
<Icon icon="lucide:check-circle" class="size-3" />
已识别语音
</Badge>
<Badge v-else variant="outline" class="gap-1 text-amber-600 w-fit">
<Icon icon="lucide:alert-circle" class="size-3" />
未识别到语音
</Badge>
</div>
<Button variant="ghost" size="sm" class="text-destructive shrink-0" @click="handleRemoveFile">
<Icon icon="lucide:x" class="size-4" />
</Button>
</div>
<input
ref="fileInputRef"
type="file"
accept="audio/*,.mp3,.wav,.aac,.m4a,.flac,.ogg"
class="hidden"
@change="handleFileSelect"
/>
</div>
<!-- 备注 -->
<div class="space-y-2">
<Label for="note">备注</Label>
<Textarea
id="note"
v-model="formData.note"
placeholder="备注信息(选填)"
:rows="2"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="handleCancel">取消</Button>
<Button :disabled="isSubmitDisabled" :loading="submitting" @click="handleSubmit">
保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>

View File

@@ -392,6 +392,7 @@ onMounted(async () => {
transition: all 0.3s ease; transition: all 0.3s ease;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: 0;
&:hover { &:hover {
box-shadow: var(--shadow-lg); box-shadow: var(--shadow-lg);

View File

@@ -22,26 +22,14 @@
<!-- 内容区 --> <!-- 内容区 -->
<div class="popover-body"> <div class="popover-body">
<!-- 智能体选择 --> <!-- 风格选择 -->
<div class="form-item"> <div class="form-item">
<label class="form-label">选择智能体</label> <label class="form-label">选择风格</label>
<Select v-model="selectedAgentId" :disabled="loadingAgents" class="agent-select"> <StyleSelector
<SelectTrigger class="agent-select-trigger"> v-model:value="selectedAgentId"
<SelectValue :placeholder="loadingAgents ? '加载中...' : '请选择智能体'" /> placeholder="选择风格"
</SelectTrigger> storage-key="text_generate_style"
<SelectContent> />
<SelectItem
v-for="agent in agentList"
:key="agent.id"
:value="agent.id"
>
<div class="agent-option">
<img v-if="agent.icon" :src="agent.icon" class="agent-icon" />
<span class="agent-name">{{ agent.agentName }}</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div> </div>
<!-- 主题输入 --> <!-- 主题输入 -->
@@ -88,14 +76,8 @@
import { ref, computed, watch, onUnmounted } from 'vue' import { ref, computed, watch, onUnmounted } from 'vue'
import { Icon } from '@iconify/vue' import { Icon } from '@iconify/vue'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
import { import StyleSelector from '@/components/StyleSelector.vue'
Select, import { sendChatStream } from '@/api/agent'
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { getAgentList, sendChatStream } from '@/api/agent'
// Props // Props
const props = defineProps<{ const props = defineProps<{
@@ -111,8 +93,6 @@ const emit = defineEmits<{
}>() }>()
// 状态 // 状态
const agentList = ref<any[]>([])
const loadingAgents = ref(false)
const selectedAgentId = ref<number | null>(null) const selectedAgentId = ref<number | null>(null)
const theme = ref('') const theme = ref('')
const generatedText = ref('') const generatedText = ref('')
@@ -125,25 +105,6 @@ const canGenerate = computed(() => {
return selectedAgentId.value && theme.value.trim().length > 0 return selectedAgentId.value && theme.value.trim().length > 0
}) })
// 获取智能体列表
const fetchAgents = async () => {
loadingAgents.value = true
try {
const res = await getAgentList()
if (res.code === 0 && res.data) {
agentList.value = res.data
// 默认选中第一个
if (res.data.length > 0 && !selectedAgentId.value) {
selectedAgentId.value = res.data[0].id
}
}
} catch (error) {
console.error('获取智能体列表失败:', error)
} finally {
loadingAgents.value = false
}
}
// 更新气泡位置 // 更新气泡位置
const updatePosition = () => { const updatePosition = () => {
// 找到触发按钮 // 找到触发按钮
@@ -179,9 +140,8 @@ const updatePosition = () => {
const handleGenerate = async () => { const handleGenerate = async () => {
if (!canGenerate.value || isGenerating.value) return if (!canGenerate.value || isGenerating.value) return
const selectedAgent = agentList.value.find(a => a.id === selectedAgentId.value) if (!selectedAgentId.value) {
if (!selectedAgent) { toast.warning('请选择风格')
toast.warning('请选择智能体')
return return
} }
@@ -198,7 +158,7 @@ const handleGenerate = async () => {
try { try {
await sendChatStream({ await sendChatStream({
agentId: selectedAgent.id, agentId: selectedAgentId.value,
content: prompt, content: prompt,
ctrl: abortController.value, ctrl: abortController.value,
onMessage: (result: { event: string; content?: string; errorMessage?: string }) => { onMessage: (result: { event: string; content?: string; errorMessage?: string }) => {
@@ -243,7 +203,6 @@ const handleClose = () => {
// 监听 visible 变化 // 监听 visible 变化
watch(() => props.visible, (val) => { watch(() => props.visible, (val) => {
if (val) { if (val) {
fetchAgents()
updatePosition() updatePosition()
// 监听窗口大小变化 // 监听窗口大小变化
window.addEventListener('resize', updatePosition) window.addEventListener('resize', updatePosition)
@@ -288,7 +247,7 @@ onUnmounted(() => {
.popover-overlay { .popover-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 1000; z-index: 40;
} }
// 气泡卡片 // 气泡卡片
@@ -298,7 +257,7 @@ onUnmounted(() => {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 0 0 1px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 0 0 1px rgba(0, 0, 0, 0.1);
border: 1px solid @border-color; border: 1px solid @border-color;
overflow: hidden; overflow: hidden;
z-index: 1001; z-index: 40;
} }
// 头部 // 头部
@@ -358,45 +317,6 @@ onUnmounted(() => {
margin-bottom: 6px; margin-bottom: 6px;
} }
.agent-select {
width: 100%;
:deep(.ant-select-selector) {
background: @bg-hover !important;
border-radius: 6px !important;
padding: 4px 10px !important;
min-height: 32px !important;
display: flex !important;
align-items: center !important;
}
:deep(.ant-select-selection-item) {
font-size: 13px;
color: @text-primary;
line-height: 24px !important;
display: flex !important;
align-items: center !important;
}
}
.agent-option {
display: flex;
align-items: center;
gap: 8px;
}
.agent-icon {
width: 18px;
height: 18px;
border-radius: 4px;
object-fit: cover;
}
.agent-name {
font-size: 13px;
color: @text-primary;
}
.theme-input { .theme-input {
width: 100%; width: 100%;
height: 32px; height: 32px;

View File

@@ -1,94 +1,94 @@
<template> <template>
<div class="timeline-panel"> <div class="bg-[#fafbfc] border border-[#e5e7eb] rounded-md px-4 py-3 mt-2">
<div class="timeline-header"> <div class="flex justify-between items-center mb-2.5">
<span class="timeline-title">时间轴对比</span> <span class="text-[13px] font-semibold text-gray-800">时间轴对比</span>
<span v-if="showDurations" class="duration-badge"> <span v-if="showDurations" class="flex items-center gap-1.5 text-[13px] text-gray-600 tabular-nums">
人脸 {{ formatDuration(faceDurationMs) }} <span class="flex items-center gap-[5px]">
<span class="w-[7px] h-[7px] rounded-sm bg-blue-500"></span>
视频 {{ formatDuration(faceDurationMs) }}
</span>
<template v-if="audioDurationMs > 0"> <template v-if="audioDurationMs > 0">
<span class="divider"></span> <span class="text-gray-400 text-xs">/</span>
音频 {{ formatDuration(audioDurationMs) }} <span class="flex items-center gap-[5px]">
<span class="w-[7px] h-[7px] rounded-sm bg-emerald-500"></span>
音频 {{ formatDuration(audioDurationMs) }}
</span>
</template> </template>
</span> </span>
</div> </div>
<!-- 刻度尺 --> <!-- 刻度尺 -->
<div class="timeline-ruler"> <div class="relative h-5 mb-1.5 ml-12">
<div <div
v-for="mark in rulerMarks" v-for="mark in rulerMarks"
:key="mark.time" :key="mark.time"
class="ruler-mark" class="absolute -translate-x-1/2 flex flex-col items-center"
:style="{ left: mark.position + '%' }" :style="{ left: mark.position + '%' }"
> >
<span class="ruler-label">{{ mark.label }}</span> <span class="text-[11px] text-gray-400 leading-none mb-1">{{ mark.label }}</span>
<span class="ruler-tick"></span> <span class="block w-px h-1 bg-[#e5e7eb]"></span>
</div>
<!-- 音频结束位置标记 -->
<div
v-if="audioDurationMs > 0 && isExceed"
class="audio-end-marker"
:style="{ left: audioEndPosition + '%' }"
>
<span class="audio-marker-label">{{ (audioDurationMs / 1000).toFixed(1) }}s</span>
<span class="audio-marker-line"></span>
</div> </div>
</div> </div>
<!-- 轨道区域 --> <!-- 轨道区域 -->
<div class="timeline-tracks"> <div class="flex flex-col gap-1.5">
<!-- 视频轨道 --> <!-- 视频轨道 -->
<div class="track"> <div class="flex items-center gap-2">
<div class="track-info"> <div class="w-9 shrink-0">
<span class="track-icon">📹</span> <span class="text-xs text-gray-600 font-medium">视频</span>
<span class="track-label">视频</span>
</div> </div>
<div class="track-bar"> <div class="flex-1 h-6 bg-[#f1f3f5] rounded relative overflow-hidden flex items-center">
<div <div
class="track-fill video-fill" class="h-full rounded flex items-center justify-center transition-[width] duration-300 min-w-[2px] bg-blue-500"
:style="{ width: videoBarWidth + '%' }" :style="{ width: videoBarWidth + '%' }"
> >
<span v-if="videoBarWidth > 15" class="track-time">{{ formatDuration(faceDurationMs) }}</span> <span v-if="videoBarWidth > 20" class="text-xs text-white font-medium tracking-wide">{{ formatDuration(faceDurationMs) }}</span>
</div> </div>
</div> </div>
</div> </div>
<!-- 音频轨道 --> <!-- 音频轨道 -->
<div class="track"> <div class="flex items-center gap-2">
<div class="track-info"> <div class="w-9 shrink-0">
<span class="track-icon">🎙</span> <span class="text-xs text-gray-600 font-medium">音频</span>
<span class="track-label">音频</span>
</div> </div>
<div class="track-bar"> <div class="flex-1 h-6 bg-[#f1f3f5] rounded relative overflow-hidden flex items-center">
<div <div
v-if="audioDurationMs > 0" v-if="audioDurationMs > 0"
class="track-fill audio-fill" class="h-full rounded flex items-center justify-center transition-[width] duration-300 min-w-[2px]"
:class="{ 'audio-exceed': isExceed }" :class="isExceed ? 'bg-red-500' : 'bg-emerald-500'"
:style="{ width: audioBarWidth + '%' }" :style="{ width: audioBarWidth + '%' }"
> >
<span v-if="audioBarWidth > 15" class="track-time">{{ formatDuration(audioDurationMs) }}</span> <span v-if="audioBarWidth > 20" class="text-xs text-white font-medium tracking-wide">{{ formatDuration(audioDurationMs) }}</span>
</div> </div>
<span v-else class="track-placeholder">等待生成音频</span> <span v-else class="text-xs text-gray-400 pl-2.5">等待生成音频</span>
</div> </div>
</div> </div>
</div> </div>
<!-- 校验错误提示 --> <!-- 校验错误提示 -->
<div v-if="validationError" class="timeline-diff error"> <div v-if="validationError" class="flex items-center gap-1.5 mt-2.5 px-2.5 py-2 rounded text-xs font-medium text-red-500 bg-red-50 border border-red-200">
<Icon icon="lucide:x-circle" class="diff-icon" /> <Icon icon="lucide:x-circle" class="text-sm shrink-0" />
<span>{{ validationError }}</span> <span>{{ validationError }}</span>
</div> </div>
<!-- 时长差异提示 --> <!-- 时长差异提示 -->
<div v-else-if="audioDurationMs > 0" class="timeline-diff" :class="diffStatus"> <div v-else-if="audioDurationMs > 0" class="flex items-center gap-1.5 mt-2.5 px-2.5 py-2 rounded text-xs font-medium border border-transparent"
:class="{
'bg-[#f0fdf4] border-[#bbf7d0] text-emerald-600': diffStatus === 'match',
'bg-red-50 border-red-200 text-red-500': diffStatus === 'exceed',
'bg-amber-50 border-amber-200 text-amber-500': diffStatus === 'short',
}"
>
<template v-if="diffStatus === 'match'"> <template v-if="diffStatus === 'match'">
<Icon icon="lucide:check-circle" class="diff-icon" /> <Icon icon="lucide:check-circle" class="text-sm shrink-0" />
<span>时长匹配良好可以生成</span> <span>时长匹配良好可以生成</span>
</template> </template>
<template v-else-if="diffStatus === 'exceed'"> <template v-else-if="diffStatus === 'exceed'">
<Icon icon="lucide:alert-circle" class="diff-icon" /> <Icon icon="lucide:alert-circle" class="text-sm shrink-0" />
<span>音频超出 {{ formatDuration(diffMs) }}建议缩短文案</span> <span>音频超出 {{ formatDuration(diffMs) }}建议缩短文案</span>
</template> </template>
<template v-else-if="diffStatus === 'short'"> <template v-else-if="diffStatus === 'short'">
<Icon icon="lucide:info" class="diff-icon" /> <Icon icon="lucide:info" class="text-sm shrink-0" />
<span>音频较短可适当增加文案</span> <span>音频较短可适当增加文案</span>
</template> </template>
</div> </div>
@@ -127,18 +127,17 @@ const audioBarWidth = computed(() =>
Math.min(100, (props.audioDurationMs / maxDuration.value) * 100) Math.min(100, (props.audioDurationMs / maxDuration.value) * 100)
) )
const audioEndPosition = computed(() =>
Math.min(100, (props.audioDurationMs / maxDuration.value) * 100)
)
const isExceed = computed(() => props.audioDurationMs > props.faceDurationMs) const isExceed = computed(() => props.audioDurationMs > props.faceDurationMs)
const diffMs = computed(() => Math.abs(props.audioDurationMs - props.faceDurationMs)) const diffMs = computed(() => Math.abs(props.audioDurationMs - props.faceDurationMs))
/** 时长差异阈值3秒内视为匹配 */
const MATCH_THRESHOLD_MS = 3000
const diffStatus = computed(() => { const diffStatus = computed(() => {
if (props.audioDurationMs === 0) return 'none' if (props.audioDurationMs === 0) return 'none'
if (props.audioDurationMs > props.faceDurationMs) return 'exceed' if (props.audioDurationMs > props.faceDurationMs) return 'exceed'
if (props.audioDurationMs < props.faceDurationMs * 0.5) return 'short' if (props.faceDurationMs - props.audioDurationMs > MATCH_THRESHOLD_MS) return 'short'
return 'match' return 'match'
}) })
@@ -175,235 +174,3 @@ function calculateInterval(duration: number): number {
const formatDuration = formatDurationMs const formatDuration = formatDurationMs
</script> </script>
<style scoped lang="less">
// 蓝紫主题配色 - 与主页面协调
@text-primary: #1e293b;
@text-secondary: #64748b;
@text-tertiary: #94a3b8;
@bg-subtle: rgba(59, 130, 246, 0.04);
@bg-hover: rgba(59, 130, 246, 0.08);
@border-light: rgba(59, 130, 246, 0.1);
@border-medium: rgba(59, 130, 246, 0.15);
@accent-blue: #3b82f6;
@accent-purple: #8b5cf6;
@accent-green: #10b981;
@accent-red: #ef4444;
@accent-orange: #f59e0b;
.timeline-panel {
background: @bg-subtle;
border-radius: 10px;
padding: 14px 18px;
margin-top: 8px;
}
.timeline-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.timeline-title {
font-size: 12px;
font-weight: 600;
color: @text-secondary;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.duration-badge {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: @text-tertiary;
.divider {
width: 4px;
height: 4px;
background: @border-medium;
border-radius: 50%;
}
}
// 刻度尺
.timeline-ruler {
position: relative;
height: 18px;
margin-bottom: 10px;
margin-left: 80px;
}
.ruler-mark {
position: absolute;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
}
.ruler-label {
font-size: 10px;
color: @text-tertiary;
margin-bottom: 2px;
}
.ruler-tick {
display: block;
width: 1px;
height: 4px;
background: @border-medium;
}
// 音频结束位置标记
.audio-end-marker {
position: absolute;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
z-index: 10;
top: 0;
}
.audio-marker-label {
font-size: 9px;
font-weight: 600;
color: @accent-red;
background: rgba(235, 87, 87, 0.1);
padding: 1px 5px;
border-radius: 3px;
white-space: nowrap;
margin-bottom: 2px;
}
.audio-marker-line {
width: 1px;
height: 6px;
background: @accent-red;
}
// 轨道区域
.timeline-tracks {
display: flex;
flex-direction: column;
gap: 8px;
}
.track {
display: flex;
align-items: center;
gap: 12px;
}
.track-info {
display: flex;
align-items: center;
gap: 6px;
width: 68px;
flex-shrink: 0;
}
.track-icon {
font-size: 14px;
line-height: 1;
}
.track-label {
font-size: 12px;
color: @text-secondary;
font-weight: 500;
}
.track-bar {
flex: 1;
height: 22px;
background: rgba(55, 53, 47, 0.06);
border-radius: 4px;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
}
.track-fill {
height: 100%;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
}
.track-time {
font-size: 10px;
color: rgba(255, 255, 255, 0.95);
font-weight: 600;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
letter-spacing: 0.3px;
}
.track-placeholder {
font-size: 11px;
color: @text-tertiary;
padding-left: 14px;
}
.video-fill {
background: linear-gradient(90deg, @accent-blue 0%, @accent-purple 100%);
}
.audio-fill {
background: linear-gradient(90deg, @accent-green 0%, #059669 100%);
&.audio-exceed {
background: linear-gradient(90deg, @accent-red 0%, #dc2626 100%);
animation: pulse-warning 2s ease-in-out infinite;
}
}
@keyframes pulse-warning {
0%, 100% { opacity: 1; }
50% { opacity: 0.75; }
}
// 差异提示
.timeline-diff {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
padding: 10px 14px;
border-radius: 8px;
font-size: 12px;
font-weight: 500;
.diff-icon {
font-size: 14px;
flex-shrink: 0;
}
&.match {
background: rgba(16, 185, 129, 0.08);
color: @accent-green;
}
&.exceed {
background: rgba(239, 68, 68, 0.08);
color: @accent-red;
}
&.short {
background: rgba(245, 158, 11, 0.08);
color: @accent-orange;
}
&.error {
background: rgba(239, 68, 68, 0.12);
color: @accent-red;
}
}
</style>

View File

@@ -36,7 +36,8 @@
<label class="section-label">语速调节</label> <label class="section-label">语速调节</label>
<div class="rate-control"> <div class="rate-control">
<Slider <Slider
v-model="store.speechRate" :model-value="[store.speechRate]"
@update:model-value="store.speechRate = $event?.[0] ?? store.speechRate"
:min="0.5" :min="0.5"
:max="2.0" :max="2.0"
:step="0.1" :step="0.1"
@@ -135,13 +136,6 @@ const placeholder = computed(() => {
const canGenerateAudio = computed(() => { const canGenerateAudio = computed(() => {
return store.text.trim() && store.voice && store.isVideoReady return store.text.trim() && store.voice && store.isVideoReady
}) })
const rateMarks = {
0.5: '0.5x',
1.0: '1.0x',
1.5: '1.5x',
2.0: '2.0x',
}
</script> </script>
<style scoped lang="less"> <style scoped lang="less">

View File

@@ -7,7 +7,7 @@
* 3. 时间轴可视化:实时对比视频和音频时长 * 3. 时间轴可视化:实时对比视频和音频时长
*/ */
import { ref, computed } from 'vue' import { ref, computed, watch } from 'vue'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
import { VoiceService } from '@/api/voice' import { VoiceService } from '@/api/voice'
@@ -17,6 +17,11 @@ import { useUpload } from '@/composables/useUpload'
import { DEFAULT_VOICE_PROVIDER } from '@/config/voiceConfig' import { DEFAULT_VOICE_PROVIDER } from '@/config/voiceConfig'
import type { VoiceMeta, Video, PipelinePhase, VideoStep, AudioStep, CreateStep, TimelineData } from '../types/identify-face' import type { VoiceMeta, Video, PipelinePhase, VideoStep, AudioStep, CreateStep, TimelineData } from '../types/identify-face'
// ========== 常量 ==========
/** 视频文件大小上限 100MB302.ai Kling 接口限制) */
const MAX_VIDEO_SIZE = 100 * 1024 * 1024
// ========== 内部类型定义 ========== // ========== 内部类型定义 ==========
/** 音频数据 */ /** 音频数据 */
@@ -43,8 +48,12 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
/** 文案内容 */ /** 文案内容 */
const text = ref('') const text = ref('')
/** 语速 */ /** 语速(持久化到本地) */
const speechRate = ref(1.0) const speechRate = ref(Number(localStorage.getItem('digitalHuman:speechRate')) || 1.0)
watch(speechRate, (val) => {
localStorage.setItem('digitalHuman:speechRate', String(val))
})
/** 选中的音色 */ /** 选中的音色 */
const voice = ref<VoiceMeta | null>(null) const voice = ref<VoiceMeta | null>(null)
@@ -61,6 +70,9 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
/** 视频预览URL */ /** 视频预览URL */
const videoPreviewUrl = ref('') const videoPreviewUrl = ref('')
/** 素材库视频的签名URL用于识别API不用于播放 */
let signedVideoUrl = ''
/** 错误信息 */ /** 错误信息 */
const error = ref('') const error = ref('')
@@ -147,7 +159,7 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
if (!timeline.value || timeline.value.audioDurationMs === 0) return 'none' if (!timeline.value || timeline.value.audioDurationMs === 0) return 'none'
const { videoDurationMs, audioDurationMs } = timeline.value const { videoDurationMs, audioDurationMs } = timeline.value
if (audioDurationMs > videoDurationMs) return 'exceed' if (audioDurationMs > videoDurationMs) return 'exceed'
if (audioDurationMs < videoDurationMs * 0.3) return 'too-short' if (videoDurationMs - audioDurationMs > 3000) return 'too-short'
return 'match' return 'match'
}) })
@@ -231,6 +243,11 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
return return
} }
if (file.size > MAX_VIDEO_SIZE) {
toast.error('视频文件不能超过 100MB')
return
}
// 释放旧的 blob URL // 释放旧的 blob URL
if (videoPreviewUrl.value?.startsWith('blob:')) { if (videoPreviewUrl.value?.startsWith('blob:')) {
URL.revokeObjectURL(videoPreviewUrl.value) URL.revokeObjectURL(videoPreviewUrl.value)
@@ -247,6 +264,12 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
/** 从素材库选择视频(选择后自动识别) */ /** 从素材库选择视频(选择后自动识别) */
async function selectVideo(video: Video) { async function selectVideo(video: Video) {
// 校验视频大小
if (video.fileSize && video.fileSize > MAX_VIDEO_SIZE) {
toast.error('视频文件不能超过 100MB')
return
}
selectedVideo.value = video selectedVideo.value = video
videoFile.value = null videoFile.value = null
videoSource.value = 'select' videoSource.value = 'select'
@@ -258,7 +281,19 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
if (urlRes.code !== 0 || !urlRes.data) { if (urlRes.code !== 0 || !urlRes.data) {
throw new Error(urlRes.msg || '获取播放链接失败') throw new Error(urlRes.msg || '获取播放链接失败')
} }
videoPreviewUrl.value = urlRes.data // 保存签名URL用于识别API
signedVideoUrl = urlRes.data
// 下载视频到本地生成blob URL确保浏览器能正常播放
videoStep.value = 'uploading'
const response = await fetch(urlRes.data)
if (!response.ok) throw new Error('视频下载失败')
const blob = await response.blob()
// 释放旧的blob URL
if (videoPreviewUrl.value?.startsWith('blob:')) {
URL.revokeObjectURL(videoPreviewUrl.value)
}
videoPreviewUrl.value = URL.createObjectURL(blob)
} catch (err: any) { } catch (err: any) {
videoStep.value = 'error' videoStep.value = 'error'
error.value = err.message || '获取播放链接失败' error.value = err.message || '获取播放链接失败'
@@ -330,8 +365,8 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
/** 识别已存在的视频 */ /** 识别已存在的视频 */
async function recognizeExistingVideo(video: Video): Promise<IdentifyData> { async function recognizeExistingVideo(video: Video): Promise<IdentifyData> {
// 使用已获取的带签名预览URL // 使用签名URL调用识别APIblob URL不可被外部API访问
return performFaceRecognition(video.id, videoPreviewUrl.value, false) return performFaceRecognition(video.id, signedVideoUrl, false)
} }
/** 执行人脸识别 */ /** 执行人脸识别 */
@@ -544,6 +579,7 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
videoFile.value = null videoFile.value = null
selectedVideo.value = null selectedVideo.value = null
videoPreviewUrl.value = '' videoPreviewUrl.value = ''
signedVideoUrl = ''
videoSelectorVisible.value = false videoSelectorVisible.value = false
resetProcess() resetProcess()

View File

@@ -52,6 +52,18 @@ public interface FileApi {
String presignGetUrl(@NotEmpty(message = "URL 不能为空") String url, String presignGetUrl(@NotEmpty(message = "URL 不能为空") String url,
Integer expirationSeconds); Integer expirationSeconds);
/**
* 生成文件预签名地址(带 Content-Type用于读取
*
* @param url 完整的文件访问地址
* @param expirationSeconds 访问有效期,单位秒
* @param contentType 响应的 Content-Type为 null 时不设置
* @return 文件预签名地址
*/
String presignGetUrl(@NotEmpty(message = "URL 不能为空") String url,
Integer expirationSeconds,
String contentType);
/** /**
* 生成文件预签名地址(带 OSS 处理参数),用于读取 * 生成文件预签名地址(带 OSS 处理参数),用于读取
* 用于阿里云 OSS 视频截帧等图片处理场景 * 用于阿里云 OSS 视频截帧等图片处理场景

View File

@@ -31,7 +31,12 @@ public class FileApiImpl implements FileApi {
@Override @Override
public String presignGetUrl(String url, Integer expirationSeconds) { public String presignGetUrl(String url, Integer expirationSeconds) {
return fileService.presignGetUrl(url, expirationSeconds); return presignGetUrl(url, expirationSeconds, null);
}
@Override
public String presignGetUrl(String url, Integer expirationSeconds, String contentType) {
return fileService.presignGetUrl(url, expirationSeconds, contentType);
} }
@Override @Override

View File

@@ -71,6 +71,18 @@ public interface FileClient {
* @return 文件预签名地址 * @return 文件预签名地址
*/ */
default String presignGetUrl(String url, Integer expirationSeconds) { default String presignGetUrl(String url, Integer expirationSeconds) {
return presignGetUrl(url, expirationSeconds, null);
}
/**
* 生成文件预签名地址(带 Content-Type用于读取
*
* @param url 完整的文件访问地址
* @param expirationSeconds 访问有效期,单位秒
* @param contentType 响应的 Content-Type为 null 时不设置
* @return 文件预签名地址
*/
default String presignGetUrl(String url, Integer expirationSeconds, String contentType) {
throw new UnsupportedOperationException("不支持的操作"); throw new UnsupportedOperationException("不支持的操作");
} }

View File

@@ -200,12 +200,19 @@ public class S3FileClient extends AbstractFileClient<S3FileClientConfig> {
} }
@Override @Override
public String presignGetUrl(String url, Integer expirationSeconds) { public String presignGetUrl(String url, Integer expirationSeconds, String contentType) {
return presignGetUrlWithProcess(url, expirationSeconds, null); return presignGetUrlWithProcess(url, expirationSeconds, null, contentType);
} }
@Override @Override
public String presignGetUrlWithProcess(String url, Integer expirationSeconds, String processParam) { public String presignGetUrlWithProcess(String url, Integer expirationSeconds, String processParam) {
return presignGetUrlWithProcess(url, expirationSeconds, processParam, null);
}
/**
* 生成文件预签名地址(带 OSS 处理参数和 Content-Type用于读取
*/
private String presignGetUrlWithProcess(String url, Integer expirationSeconds, String processParam, String contentType) {
// 1. 将 url 转换为 path支持 CDN 域名和 OSS 原始域名) // 1. 将 url 转换为 path支持 CDN 域名和 OSS 原始域名)
String path = extractPathFromUrl(url); String path = extractPathFromUrl(url);
String decodedPath = URLUtil.decode(path, StandardCharsets.UTF_8); String decodedPath = URLUtil.decode(path, StandardCharsets.UTF_8);
@@ -213,11 +220,17 @@ public class S3FileClient extends AbstractFileClient<S3FileClientConfig> {
// 2. 公开访问:无需签名,直接拼接参数 // 2. 公开访问:无需签名,直接拼接参数
if (!BooleanUtil.isFalse(config.getEnablePublicAccess())) { if (!BooleanUtil.isFalse(config.getEnablePublicAccess())) {
String encodedPath = UriUtils.encodePath(decodedPath, StandardCharsets.UTF_8); String encodedPath = UriUtils.encodePath(decodedPath, StandardCharsets.UTF_8);
String resultUrl = config.getDomain() + "/" + encodedPath; StringBuilder resultUrl = new StringBuilder(config.getDomain()).append("/").append(encodedPath);
char separator = '?';
if (StrUtil.isNotBlank(processParam)) { if (StrUtil.isNotBlank(processParam)) {
resultUrl = resultUrl + "?x-oss-process=" + processParam; resultUrl.append(separator).append("x-oss-process=").append(processParam);
separator = '&';
} }
return resultUrl; if (StrUtil.isNotBlank(contentType)) {
resultUrl.append(separator).append("response-content-type=").append(
URLUtil.encode(contentType, StandardCharsets.UTF_8));
}
return resultUrl.toString();
} }
// 3. 私有访问:生成预签名 URL需要将处理参数包含在签名中 // 3. 私有访问:生成预签名 URL需要将处理参数包含在签名中
@@ -229,10 +242,14 @@ public class S3FileClient extends AbstractFileClient<S3FileClientConfig> {
com.aliyun.oss.model.GeneratePresignedUrlRequest request = com.aliyun.oss.model.GeneratePresignedUrlRequest request =
new com.aliyun.oss.model.GeneratePresignedUrlRequest(config.getBucket(), decodedPath, HttpMethod.GET); new com.aliyun.oss.model.GeneratePresignedUrlRequest(config.getBucket(), decodedPath, HttpMethod.GET);
request.setExpiration(expirationDate); request.setExpiration(expirationDate);
// 关键:将 x-oss-process 参数包含在签名中 // 将 x-oss-process 参数包含在签名中
if (StrUtil.isNotBlank(processParam)) { if (StrUtil.isNotBlank(processParam)) {
request.addQueryParameter("x-oss-process", processParam); request.addQueryParameter("x-oss-process", processParam);
} }
// 设置 response-content-type确保浏览器能正确渲染
if (StrUtil.isNotBlank(contentType)) {
request.addQueryParameter("response-content-type", contentType);
}
signedUrl = aliyunOssClient.generatePresignedUrl(request).toString(); signedUrl = aliyunOssClient.generatePresignedUrl(request).toString();
} else { } else {
// 非阿里云不支持 OSS 处理参数,直接返回普通预签名 URL // 非阿里云不支持 OSS 处理参数,直接返回普通预签名 URL

View File

@@ -54,6 +54,16 @@ public interface FileService {
*/ */
String presignGetUrl(String url, Integer expirationSeconds); String presignGetUrl(String url, Integer expirationSeconds);
/**
* 生成文件预签名地址(带 Content-Type用于读取
*
* @param url 完整的文件访问地址
* @param expirationSeconds 访问有效期,单位秒
* @param contentType 响应的 Content-Type为 null 时不设置
* @return 文件预签名地址
*/
String presignGetUrl(String url, Integer expirationSeconds, String contentType);
/** /**
* 生成文件预签名地址(带 OSS 处理参数),用于读取 * 生成文件预签名地址(带 OSS 处理参数),用于读取
* 用于阿里云 OSS 视频截帧等图片处理场景 * 用于阿里云 OSS 视频截帧等图片处理场景

View File

@@ -142,8 +142,13 @@ public class FileServiceImpl implements FileService {
@Override @Override
public String presignGetUrl(String url, Integer expirationSeconds) { public String presignGetUrl(String url, Integer expirationSeconds) {
return presignGetUrl(url, expirationSeconds, null);
}
@Override
public String presignGetUrl(String url, Integer expirationSeconds, String contentType) {
FileClient fileClient = fileConfigService.getMasterFileClient(); FileClient fileClient = fileConfigService.getMasterFileClient();
return fileClient.presignGetUrl(url, expirationSeconds); return fileClient.presignGetUrl(url, expirationSeconds, contentType);
} }
@Override @Override

View File

@@ -15,6 +15,8 @@ import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@@ -56,8 +58,15 @@ public class BenchmarkTaskServiceImpl implements BenchmarkTaskService {
log.info("[createTask][userId={}, taskId={}] 创建对标分析任务成功", userId, taskId); log.info("[createTask][userId={}, taskId={}] 创建对标分析任务成功", userId, taskId);
// 2. 异步执行任务 // 2. 事务提交后再触发异步任务,避免异步线程读不到未提交的数据
benchmarkTaskExecutor.executeAsync(taskId, createReqVO); Long finalTaskId = taskId;
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
log.info("[createTask][taskId={}] 事务已提交,触发异步执行", finalTaskId);
benchmarkTaskExecutor.executeAsync(finalTaskId, createReqVO);
}
});
return taskId; return taskId;
} }

View File

@@ -68,29 +68,35 @@ public class AppDifyController {
} }
@GetMapping("/conversations") @GetMapping("/conversations")
@Operation(summary = "获取会话列表") @Operation(summary = "获取会话列表(合并 pro + standard")
@Parameter(name = "agentId", description = "智能体ID", required = true) @Parameter(name = "agentId", description = "智能体ID", required = true)
@Parameter(name = "lastId", description = "上一页最后一条记录ID") @Parameter(name = "source", description = "来源类型agent-智能体 prompt-自建风格")
@Parameter(name = "cursor", description = "复合游标(首页不传)")
@Parameter(name = "limit", description = "返回条数默认20") @Parameter(name = "limit", description = "返回条数默认20")
public CommonResult<DifyConversationListRespVO> getConversations( public CommonResult<DifyConversationListRespVO> getConversations(
@RequestParam("agentId") Long agentId, @RequestParam("agentId") Long agentId,
@RequestParam(value = "lastId", required = false) String lastId, @RequestParam(value = "source", required = false, defaultValue = "agent") String source,
@RequestParam(value = "cursor", required = false) String cursor,
@RequestParam(value = "limit", required = false) Integer limit) { @RequestParam(value = "limit", required = false) Integer limit) {
return CommonResult.success(difyService.getConversations(agentId, getCurrentUserId(), lastId, limit)); return CommonResult.success(difyService.getConversations(agentId, source, getCurrentUserId(), cursor, limit));
} }
@GetMapping("/messages") @GetMapping("/messages")
@Operation(summary = "获取会话历史消息") @Operation(summary = "获取会话历史消息(自动定位 App")
@Parameter(name = "agentId", description = "智能体ID", required = true) @Parameter(name = "agentId", description = "智能体ID", required = true)
@Parameter(name = "source", description = "来源类型agent-智能体 prompt-自建风格")
@Parameter(name = "conversationId", description = "会话ID", required = true) @Parameter(name = "conversationId", description = "会话ID", required = true)
@Parameter(name = "appSource", description = "来源应用标识pro/standard")
@Parameter(name = "firstId", description = "当前页第一条记录ID") @Parameter(name = "firstId", description = "当前页第一条记录ID")
@Parameter(name = "limit", description = "返回条数默认20") @Parameter(name = "limit", description = "返回条数默认20")
public CommonResult<DifyMessageListRespVO> getMessages( public CommonResult<DifyMessageListRespVO> getMessages(
@RequestParam("agentId") Long agentId, @RequestParam("agentId") Long agentId,
@RequestParam(value = "source", required = false, defaultValue = "agent") String source,
@RequestParam("conversationId") String conversationId, @RequestParam("conversationId") String conversationId,
@RequestParam(value = "appSource", required = false) String appSource,
@RequestParam(value = "firstId", required = false) String firstId, @RequestParam(value = "firstId", required = false) String firstId,
@RequestParam(value = "limit", required = false) Integer limit) { @RequestParam(value = "limit", required = false) Integer limit) {
return CommonResult.success(difyService.getMessages(agentId, conversationId, getCurrentUserId(), firstId, limit)); return CommonResult.success(difyService.getMessages(agentId, source, conversationId, appSource, getCurrentUserId(), firstId, limit));
} }
private String getCurrentUserId() { private String getCurrentUserId() {

View File

@@ -53,26 +53,29 @@ public interface DifyService {
Flux<DifyChatRespVO> promptAnalysisStream(PromptAnalysisReqVO reqVO, String userId); Flux<DifyChatRespVO> promptAnalysisStream(PromptAnalysisReqVO reqVO, String userId);
/** /**
* 获取会话列表 * 获取会话列表(合并 pro + standard 两个 Dify App 的会话)
* *
* @param agentId 智能体ID * @param agentId 智能体ID
* @param source 来源类型agent/prompt
* @param userId 用户ID * @param userId 用户ID
* @param lastId 上一页最后一条记录ID * @param cursor 复合游标Base64 编码,首页传 null
* @param limit 返回条数 * @param limit 返回条数
* @return 会话列表 * @return 会话列表
*/ */
DifyConversationListRespVO getConversations(Long agentId, String userId, String lastId, Integer limit); DifyConversationListRespVO getConversations(Long agentId, String source, String userId, String cursor, Integer limit);
/** /**
* 获取会话历史消息 * 获取会话历史消息(自动定位 pro/standard App
* *
* @param agentId 智能体ID * @param agentId 智能体ID
* @param source 来源类型agent/prompt
* @param conversationId 会话ID * @param conversationId 会话ID
* @param appSource 来源应用标识pro/standard用于选择 API Key
* @param userId 用户ID * @param userId 用户ID
* @param firstId 当前页第一条记录ID * @param firstId 当前页第一条记录ID
* @param limit 返回条数 * @param limit 返回条数
* @return 消息列表 * @return 消息列表
*/ */
DifyMessageListRespVO getMessages(Long agentId, String conversationId, String userId, String firstId, Integer limit); DifyMessageListRespVO getMessages(Long agentId, String source, String conversationId, String appSource, String userId, String firstId, Integer limit);
} }

View File

@@ -6,6 +6,7 @@ import cn.iocoder.yudao.module.tik.dify.vo.DifyChatReqVO;
import cn.iocoder.yudao.module.tik.dify.vo.PromptAnalysisReqVO; import cn.iocoder.yudao.module.tik.dify.vo.PromptAnalysisReqVO;
import cn.iocoder.yudao.module.tik.dify.vo.DifyChatRespVO; import cn.iocoder.yudao.module.tik.dify.vo.DifyChatRespVO;
import cn.iocoder.yudao.module.tik.dify.vo.DifyConversationListRespVO; import cn.iocoder.yudao.module.tik.dify.vo.DifyConversationListRespVO;
import cn.iocoder.yudao.module.tik.dify.vo.DifyConversationRespVO;
import cn.iocoder.yudao.module.tik.dify.vo.DifyMessageListRespVO; import cn.iocoder.yudao.module.tik.dify.vo.DifyMessageListRespVO;
import cn.iocoder.yudao.module.tik.dify.vo.ForecastRewriteReqVO; import cn.iocoder.yudao.module.tik.dify.vo.ForecastRewriteReqVO;
import cn.iocoder.yudao.module.tik.enums.AiModelTypeEnum; import cn.iocoder.yudao.module.tik.enums.AiModelTypeEnum;
@@ -21,7 +22,13 @@ import org.springframework.validation.annotation.Validated;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
@@ -50,13 +57,22 @@ public class DifyServiceImpl implements DifyService {
AtomicLong pendingRecordId = new AtomicLong(); AtomicLong pendingRecordId = new AtomicLong();
AtomicReference<String> conversationIdRef = new AtomicReference<>(reqVO.getConversationId()); AtomicReference<String> conversationIdRef = new AtomicReference<>(reqVO.getConversationId());
AtomicReference<DifyChatRespVO> tokenUsageRef = new AtomicReference<>(); AtomicReference<DifyChatRespVO> tokenUsageRef = new AtomicReference<>();
String difyUserId = "user-" + userId + "-agent-" + reqVO.getAgentId(); String difyUserId = buildDifyUserId(userId, reqVO.getSource(), reqVO.getAgentId());
String logPrefix = "chatStream"; String logPrefix = "chatStream";
return Mono.fromCallable(() -> { return Mono.fromCallable(() -> {
AiAgentDO agent = aiAgentService.getAiAgent(reqVO.getAgentId()); // 解析系统提示词:优先使用自定义提示词,否则从智能体获取
if (agent == null) { String systemPrompt;
throw new RuntimeException("智能体不存在"); if (reqVO.getCustomSystemPrompt() != null && !reqVO.getCustomSystemPrompt().isEmpty()) {
systemPrompt = reqVO.getCustomSystemPrompt();
} else if (reqVO.getAgentId() != null) {
AiAgentDO agent = aiAgentService.getAiAgent(reqVO.getAgentId());
if (agent == null) {
throw new RuntimeException("智能体不存在");
}
systemPrompt = agent.getSystemPrompt();
} else {
throw new RuntimeException("必须提供 agentId 或 customSystemPrompt");
} }
AiModelTypeEnum modelTypeEnum = "standard".equals(reqVO.getModelMode()) AiModelTypeEnum modelTypeEnum = "standard".equals(reqVO.getModelMode())
@@ -66,12 +82,15 @@ public class DifyServiceImpl implements DifyService {
AiPlatformEnum.DIFY.getPlatform(), modelTypeEnum.getModelCode()); AiPlatformEnum.DIFY.getPlatform(), modelTypeEnum.getModelCode());
pointsService.checkPoints(userId, config.getConsumePoints()); pointsService.checkPoints(userId, config.getConsumePoints());
String serviceRef = reqVO.getAgentId() != null
? reqVO.getAgentId().toString()
: "custom-prompt";
Long recordId = pointsService.createPendingDeduct( Long recordId = pointsService.createPendingDeduct(
userId, config.getConsumePoints(), "dify_chat", userId, config.getConsumePoints(), "dify_chat",
reqVO.getAgentId().toString(), config.getServiceCode()); serviceRef, config.getServiceCode());
pendingRecordId.set(recordId); pendingRecordId.set(recordId);
return new DifyChatContext(agent.getSystemPrompt(), config.getApiKey(), config.getConsumePoints()); return new DifyChatContext(systemPrompt, config.getApiKey(), config.getConsumePoints());
}) })
.flatMapMany(context -> difyClient.chatStream( .flatMapMany(context -> difyClient.chatStream(
context.apiKey(), reqVO.getContent(), context.systemPrompt(), context.apiKey(), reqVO.getContent(), context.systemPrompt(),
@@ -366,55 +385,169 @@ public class DifyServiceImpl implements DifyService {
} }
@Override @Override
public DifyConversationListRespVO getConversations(Long agentId, String userId, String lastId, Integer limit) { public DifyConversationListRespVO getConversations(Long agentId, String source, String userId, String cursor, Integer limit) {
// 获取智能体配置 if (limit == null || limit <= 0) {
AiAgentDO agent = aiAgentService.getAiAgent(agentId); limit = 20;
if (agent == null) {
throw new RuntimeException("智能体不存在");
} }
// 获取积分配置(使用标准模式的 API Key AiServiceConfigDO proConfig = getDifyConfig(false);
AiServiceConfigDO config = pointsService.getConfig( AiServiceConfigDO standardConfig = getDifyConfig(true);
AiPlatformEnum.DIFY.getPlatform(),
AiModelTypeEnum.DIFY_WRITING_STANDARD.getModelCode());
// Dify 用户标识(按 agentId 隔离会话) String difyUserId = buildDifyUserId(userId, source, agentId);
String difyUserId = "user-" + userId + "-agent-" + agentId;
DifyConversationListRespVO result = difyClient.getConversations(config.getApiKey(), difyUserId, lastId, limit); String proLastId = null;
String standardLastId = null;
if (cursor != null && !cursor.isEmpty()) {
try {
String json = new String(Base64.getDecoder().decode(cursor));
com.fasterxml.jackson.databind.JsonNode node = JsonUtils.parseTree(json);
proLastId = node.has("pro") ? node.get("pro").asText(null) : null;
standardLastId = node.has("standard") ? node.get("standard").asText(null) : null;
} catch (Exception e) {
log.warn("[getConversations] 游标解析失败,从头开始: {}", cursor, e);
}
}
List<DifyConversationRespVO> proList = Collections.emptyList();
List<DifyConversationRespVO> standardList = Collections.emptyList();
boolean proHasMore = false;
boolean standardHasMore = false;
try {
DifyConversationListRespVO proResult = difyClient.getConversations(
proConfig.getApiKey(), difyUserId, proLastId, limit);
if (proResult != null && proResult.getData() != null) {
proList = proResult.getData();
proList.forEach(c -> c.setAppSource("pro"));
proHasMore = proResult.getHasMore() != null && proResult.getHasMore();
}
} catch (Exception e) {
log.warn("[getConversations] 查询 Pro 会话列表失败", e);
}
try {
DifyConversationListRespVO standardResult = difyClient.getConversations(
standardConfig.getApiKey(), difyUserId, standardLastId, limit);
if (standardResult != null && standardResult.getData() != null) {
standardList = standardResult.getData();
standardList.forEach(c -> c.setAppSource("standard"));
standardHasMore = standardResult.getHasMore() != null && standardResult.getHasMore();
}
} catch (Exception e) {
log.warn("[getConversations] 查询 Standard 会话列表失败", e);
}
// 合并两个列表,按 updatedAt 降序排序,截取 limit 条
List<DifyConversationRespVO> merged = new ArrayList<>();
merged.addAll(proList);
merged.addAll(standardList);
merged.sort((a, b) -> {
long timeA = a.getUpdatedAt() != null ? a.getUpdatedAt() : (a.getCreatedAt() != null ? a.getCreatedAt() : 0);
long timeB = b.getUpdatedAt() != null ? b.getUpdatedAt() : (b.getCreatedAt() != null ? b.getCreatedAt() : 0);
return Long.compare(timeB, timeA); // 降序
});
// 判断是否有更多数据
boolean hasMore = proHasMore || standardHasMore;
List<DifyConversationRespVO> pageData;
if (merged.size() > limit) {
pageData = new ArrayList<>(merged.subList(0, limit));
hasMore = true;
} else {
pageData = merged;
}
// 过滤掉 inputs 中的敏感字段(如 sysPrompt // 过滤掉 inputs 中的敏感字段(如 sysPrompt
if (result != null && result.getData() != null) { pageData.forEach(conv -> {
result.getData().forEach(conv -> { if (conv.getInputs() != null) {
if (conv.getInputs() != null) { conv.getInputs().remove("sysPrompt");
conv.getInputs().remove("sysPrompt"); }
} });
});
}
// 构建下一页游标:收集当前页中 pro 和 standard 的最后一条记录ID
String nextCursor = buildNextCursor(pageData, proHasMore, standardHasMore);
DifyConversationListRespVO result = new DifyConversationListRespVO();
result.setLimit(limit);
result.setHasMore(hasMore);
result.setData(pageData);
result.setNextCursor(nextCursor);
return result; return result;
} }
@Override /**
public DifyMessageListRespVO getMessages(Long agentId, String conversationId, String userId, String firstId, Integer limit) { * 构建下一页复合游标
// 获取智能体配置 */
AiAgentDO agent = aiAgentService.getAiAgent(agentId); private String buildNextCursor(List<DifyConversationRespVO> pageData,
if (agent == null) { boolean proHasMore, boolean standardHasMore) {
throw new RuntimeException("智能体不存在"); if (pageData.isEmpty()) return null;
String proLastId = null;
String standardLastId = null;
for (int i = pageData.size() - 1; i >= 0; i--) {
DifyConversationRespVO conv = pageData.get(i);
if ("pro".equals(conv.getAppSource()) && proLastId == null && proHasMore) {
proLastId = conv.getId();
}
if ("standard".equals(conv.getAppSource()) && standardLastId == null && standardHasMore) {
standardLastId = conv.getId();
}
if (proLastId != null && standardLastId != null) break;
} }
// 获取积分配置(使用标准模式的 API Key if (proLastId == null && standardLastId == null) return null;
AiServiceConfigDO config = pointsService.getConfig(
AiPlatformEnum.DIFY.getPlatform(),
AiModelTypeEnum.DIFY_WRITING_STANDARD.getModelCode());
// Dify 用户标识(按 agentId 隔离会话) try {
String difyUserId = "user-" + userId + "-agent-" + agentId; Map<String, String> cursorMap = new HashMap<>();
if (proLastId != null) cursorMap.put("pro", proLastId);
if (standardLastId != null) cursorMap.put("standard", standardLastId);
String json = JsonUtils.toJsonString(cursorMap);
return Base64.getEncoder().encodeToString(json.getBytes());
} catch (Exception e) {
log.warn("[buildNextCursor] 构建游标失败", e);
return null;
}
}
DifyMessageListRespVO result = difyClient.getMessages(config.getApiKey(), conversationId, difyUserId, firstId, limit); @Override
public DifyMessageListRespVO getMessages(Long agentId, String source, String conversationId,
String appSource, String userId, String firstId, Integer limit) {
String difyUserId = buildDifyUserId(userId, source, agentId);
boolean isStandard = "standard".equals(appSource);
AiServiceConfigDO primaryConfig = getDifyConfig(isStandard);
AiServiceConfigDO fallbackConfig = null;
DifyMessageListRespVO result = null;
try {
result = difyClient.getMessages(primaryConfig.getApiKey(), conversationId, difyUserId, firstId, limit);
} catch (Exception e) {
log.warn("[getMessages] 主 Key 查询失败appSource: {}, 尝试降级", appSource, e);
}
if (result == null || result.getData() == null || result.getData().isEmpty()) {
fallbackConfig = getDifyConfig(!isStandard);
try {
DifyMessageListRespVO fallbackResult = difyClient.getMessages(
fallbackConfig.getApiKey(), conversationId, difyUserId, firstId, limit);
if (fallbackResult != null && fallbackResult.getData() != null && !fallbackResult.getData().isEmpty()) {
result = fallbackResult;
}
} catch (Exception e) {
log.warn("[getMessages] 降级 Key 查询也失败", e);
}
}
if (result == null) {
result = new DifyMessageListRespVO();
result.setData(Collections.emptyList());
result.setHasMore(false);
}
// 过滤掉 inputs 中的敏感字段(如 sysPrompt // 过滤掉 inputs 中的敏感字段(如 sysPrompt
if (result != null && result.getData() != null) { if (result.getData() != null) {
result.getData().forEach(msg -> { result.getData().forEach(msg -> {
if (msg.getInputs() != null) { if (msg.getInputs() != null) {
msg.getInputs().remove("sysPrompt"); msg.getInputs().remove("sysPrompt");
@@ -425,4 +558,18 @@ public class DifyServiceImpl implements DifyService {
return result; return result;
} }
private static String resolveScope(String source) {
return "prompt".equals(source) ? "prompt" : "agent";
}
private String buildDifyUserId(String userId, String source, Long agentId) {
return "user-" + userId + "-" + resolveScope(source) + "-" + agentId;
}
private AiServiceConfigDO getDifyConfig(boolean standard) {
return pointsService.getConfig(
AiPlatformEnum.DIFY.getPlatform(),
(standard ? AiModelTypeEnum.DIFY_WRITING_STANDARD : AiModelTypeEnum.DIFY_WRITING_PRO).getModelCode());
}
} }

View File

@@ -12,8 +12,7 @@ import lombok.Data;
@Data @Data
public class DifyChatReqVO { public class DifyChatReqVO {
@Schema(description = "智能体ID", requiredMode = Schema.RequiredMode.REQUIRED) @Schema(description = "智能体ID(使用 customSystemPrompt 时可不传)")
@NotNull(message = "智能体ID不能为空")
private Long agentId; private Long agentId;
@Schema(description = "用户输入内容", requiredMode = Schema.RequiredMode.REQUIRED) @Schema(description = "用户输入内容", requiredMode = Schema.RequiredMode.REQUIRED)
@@ -26,4 +25,10 @@ public class DifyChatReqVO {
@Schema(description = "模型模式pro-深度版 standard-标准版", example = "pro") @Schema(description = "模型模式pro-深度版 standard-标准版", example = "pro")
private String modelMode = "pro"; private String modelMode = "pro";
@Schema(description = "自定义系统提示词(使用用户自建风格时传入)")
private String customSystemPrompt;
@Schema(description = "来源类型agent-智能体 prompt-自建风格", example = "agent")
private String source;
} }

View File

@@ -1,5 +1,6 @@
package cn.iocoder.yudao.module.tik.dify.vo; package cn.iocoder.yudao.module.tik.dify.vo;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
@@ -23,4 +24,8 @@ public class DifyConversationListRespVO {
@Schema(description = "会话列表", requiredMode = Schema.RequiredMode.REQUIRED) @Schema(description = "会话列表", requiredMode = Schema.RequiredMode.REQUIRED)
private List<DifyConversationRespVO> data; private List<DifyConversationRespVO> data;
@Schema(description = "下一页游标Base64 编码的复合游标)")
@JsonProperty("next_cursor")
private String nextCursor;
} }

View File

@@ -38,4 +38,8 @@ public class DifyConversationRespVO {
@JsonProperty("updated_at") @JsonProperty("updated_at")
private Long updatedAt; private Long updatedAt;
@Schema(description = "来源应用标识pro/standard", example = "pro")
@JsonProperty("app_source")
private String appSource;
} }

View File

@@ -27,6 +27,7 @@ import cn.iocoder.yudao.module.tik.muye.aiserviceconfig.service.AiServiceConfigS
import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils; import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils;
import cn.iocoder.yudao.framework.apilog.core.annotation.ApiAccessLog; import cn.iocoder.yudao.framework.apilog.core.annotation.ApiAccessLog;
import cn.iocoder.yudao.framework.tenant.core.aop.TenantIgnore;
import static cn.iocoder.yudao.framework.apilog.core.enums.OperateTypeEnum.*; import static cn.iocoder.yudao.framework.apilog.core.enums.OperateTypeEnum.*;
@Tag(name = "管理后台 - AI第三方服务配置") @Tag(name = "管理后台 - AI第三方服务配置")
@@ -75,6 +76,7 @@ public class AiServiceConfigController {
@Operation(summary = "获得AI服务配置") @Operation(summary = "获得AI服务配置")
@Parameter(name = "id", description = "编号", required = true, example = "1024") @Parameter(name = "id", description = "编号", required = true, example = "1024")
@PreAuthorize("@ss.hasPermission('muye:ai-service-config:query')") @PreAuthorize("@ss.hasPermission('muye:ai-service-config:query')")
@TenantIgnore
public CommonResult<AiServiceConfigRespVO> getAiServiceConfig(@RequestParam("id") Long id) { public CommonResult<AiServiceConfigRespVO> getAiServiceConfig(@RequestParam("id") Long id) {
AiServiceConfigDO aiServiceConfig = aiServiceConfigService.getAiServiceConfig(id); AiServiceConfigDO aiServiceConfig = aiServiceConfigService.getAiServiceConfig(id);
return success(BeanUtils.toBean(aiServiceConfig, AiServiceConfigRespVO.class)); return success(BeanUtils.toBean(aiServiceConfig, AiServiceConfigRespVO.class));
@@ -83,6 +85,7 @@ public class AiServiceConfigController {
@GetMapping("/page") @GetMapping("/page")
@Operation(summary = "获得AI服务配置分页") @Operation(summary = "获得AI服务配置分页")
@PreAuthorize("@ss.hasPermission('muye:ai-service-config:query')") @PreAuthorize("@ss.hasPermission('muye:ai-service-config:query')")
@TenantIgnore
public CommonResult<PageResult<AiServiceConfigRespVO>> getAiServiceConfigPage(@Valid AiServiceConfigPageReqVO pageReqVO) { public CommonResult<PageResult<AiServiceConfigRespVO>> getAiServiceConfigPage(@Valid AiServiceConfigPageReqVO pageReqVO) {
PageResult<AiServiceConfigDO> pageResult = aiServiceConfigService.getAiServiceConfigPage(pageReqVO); PageResult<AiServiceConfigDO> pageResult = aiServiceConfigService.getAiServiceConfigPage(pageReqVO);
return success(BeanUtils.toBean(pageResult, AiServiceConfigRespVO.class)); return success(BeanUtils.toBean(pageResult, AiServiceConfigRespVO.class));
@@ -104,6 +107,7 @@ public class AiServiceConfigController {
@GetMapping("/list-enabled") @GetMapping("/list-enabled")
@Operation(summary = "获取所有启用的服务配置列表(前端积分显示用)") @Operation(summary = "获取所有启用的服务配置列表(前端积分显示用)")
@PreAuthorize("@ss.hasPermission('muye:ai-service-config:query')") @PreAuthorize("@ss.hasPermission('muye:ai-service-config:query')")
@TenantIgnore
public CommonResult<Map<String, List<AiServiceConfigService.ServiceConfigSimpleVO>>> getEnabledServiceConfigList() { public CommonResult<Map<String, List<AiServiceConfigService.ServiceConfigSimpleVO>>> getEnabledServiceConfigList() {
return success(aiServiceConfigService.getEnabledServiceConfigList()); return success(aiServiceConfigService.getEnabledServiceConfigList());
} }

View File

@@ -18,7 +18,7 @@ public class LatentsyncProperties {
/** /**
* 302AI API Key可通过配置覆盖 * 302AI API Key可通过配置覆盖
*/ */
private String apiKey = "sk-0IZJ2oo7VCkegFuF3JRsSRtyFUsIvLoHNK8OpulnlsStFN78"; private String apiKey = "sk-0CdsUdm0z7JAFgnpyIOObYDvH5mUuCyv87dxbZlzW5yc6pPl";
/** /**
* 默认海外网关 * 默认海外网关

View File

@@ -39,9 +39,12 @@ import cn.iocoder.yudao.module.tik.voice.vo.AppTikVoiceTtsReqVO;
import cn.iocoder.yudao.module.tik.voice.vo.AppTikVoiceTtsRespVO; import cn.iocoder.yudao.module.tik.voice.vo.AppTikVoiceTtsRespVO;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.StringRedisTemplate;
import cn.hutool.extra.spring.SpringUtil;
import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
@@ -325,9 +328,15 @@ public class TikUserVoiceServiceImpl implements TikUserVoiceService {
throw exception(VOICE_FILE_NOT_EXISTS); throw exception(VOICE_FILE_NOT_EXISTS);
} }
// 4. 异步执行识别 // 4. 事务提交后再异步执行识别,避免异步线程读不到未提交的数据
String fileAccessUrl = fileApi.presignGetUrl(fileDO.getUrl(), PRESIGN_URL_EXPIRATION_SECONDS); String fileAccessUrl = fileApi.presignGetUrl(fileDO.getUrl(), PRESIGN_URL_EXPIRATION_SECONDS);
asyncTranscribeVoice(id, fileAccessUrl); TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
log.info("[transcribeVoice][voiceId={}] 事务已提交,触发异步识别", id);
getSelf().asyncTranscribeVoice(id, fileAccessUrl);
}
});
} }
@Override @Override
@@ -970,5 +979,12 @@ public class TikUserVoiceServiceImpl implements TikUserVoiceService {
return "您好,欢迎体验专属音色。"; return "您好,欢迎体验专属音色。";
} }
/**
* 通过 Spring 代理获取自身,确保 @Async 注解生效
*/
private TikUserVoiceServiceImpl getSelf() {
return SpringUtil.getBean(getClass());
}
} }