Compare commits

...

11 Commits

Author SHA1 Message Date
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
34 changed files with 995 additions and 1286 deletions

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
<template>
<div class="voice-selector">
<div class="w-full">
<!-- 空状态 -->
<div v-if="userVoiceCards.length === 0" class="empty-voices">
<div class="empty-icon">
<div v-if="userVoiceCards.length === 0" class="py-10 px-6 bg-muted border border-dashed border-border rounded-lg text-center">
<div class="mb-3">
<Icon icon="lucide:mic-off" class="size-10 text-muted-foreground/40" />
</div>
<p class="text-muted-foreground mb-4">还没有配音</p>
@@ -11,68 +11,71 @@
</Button>
</div>
<div v-else class="voice-selector-wrapper">
<div v-else class="flex flex-col gap-4">
<!-- 标题栏 -->
<div class="selector-header">
<div class="header-left">
<span class="header-title">选择音色</span>
<span class="voice-count">{{ userVoiceCards.length }} 个配音</span>
<div class="h-12 flex items-center justify-between pb-3 border-b border-border">
<div class="flex items-center gap-2.5">
<span class="text-base font-semibold text-foreground">选择音色</span>
<span class="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded">{{ userVoiceCards.length }} 个配音</span>
</div>
<Button
v-if="selectedVoiceId"
class="synthesize-btn"
:disabled="isPlayerInitializing"
:loading="previewLoadingVoiceId === selectedVoiceId"
v-show="selectedVoiceId"
class="h-9 shadow-sm transition-all hover:-translate-y-px hover:shadow-md"
:disabled="isPlayerInitializing || isSynthesizing"
@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>
</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
v-for="voice in userVoiceCards"
:key="voice.id"
class="voice-card"
:class="{ 'selected': selectedVoiceId === voice.id }"
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="selectedVoiceId === voice.id ? 'bg-primary/10 border-primary/30' : ''"
@click="handleVoiceSelect(voice)"
>
<!-- 头像区域 -->
<div class="card-avatar">
<div class="avatar-ring"></div>
<div class="avatar-icon">
<Icon icon="lucide:audio-waveform" class="size-6" />
</div>
<!-- 选中指示器 -->
<div v-if="selectedVoiceId === voice.id" class="selected-indicator">
<Icon icon="lucide:check" class="size-3" />
</div>
<!-- 波形图标 -->
<div class="shrink-0 w-8 h-8 rounded-full flex items-center justify-center"
:class="selectedVoiceId === voice.id ? 'bg-primary text-primary-foreground' : 'bg-primary/15 text-primary'"
>
<Icon icon="lucide:audio-waveform" class="size-4" />
</div>
<!-- 信息区域 -->
<div class="card-info">
<div class="voice-name">{{ voice.name }}</div>
<div class="voice-desc">{{ voice.description || '我的配音' }}</div>
<!-- 名称 -->
<div class="flex-1 min-w-0">
<div class="text-sm font-medium truncate"
: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>
<!-- 选中标记 -->
<Icon v-if="selectedVoiceId === voice.id" icon="lucide:check-circle-2" class="size-4 text-primary shrink-0" />
</button>
</div>
<!-- 播放器区域 -->
<transition name="slide-fade">
<div v-if="audioUrl" class="player-section">
<div class="player-header">
<div class="player-info">
<div class="player-icon">
<div v-if="audioUrl" class="bg-muted rounded-lg p-3.5 border border-border">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2.5">
<div class="text-primary">
<Icon icon="lucide:play-circle" class="size-8" />
</div>
<div class="player-meta">
<div class="player-title">{{ currentVoiceName }}</div>
<div class="player-label">合成预览</div>
<div>
<div class="text-base font-semibold text-foreground">{{ currentVoiceName }}</div>
<div class="text-xs text-muted-foreground">合成预览</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" />
下载
</Button>
@@ -114,6 +117,7 @@ const playerContainer = ref(null)
const audioUrl = ref('')
const currentVoiceName = ref('')
const isPlayerInitializing = ref(false)
const isSynthesizing = ref(false)
// 默认封面图片(音频波形图标)
const defaultCover = `data:image/svg+xml;base64,${btoa(`
@@ -184,12 +188,13 @@ const handleVoiceSelect = (voice) => {
}
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)
if (!voice) return
currentVoiceName.value = voice.name
isSynthesizing.value = true
handlePlayVoiceSample(voice)
}
@@ -215,12 +220,13 @@ const handlePlayVoiceSample = (voice) => {
playVoiceSample(
voice,
(data) => {
isSynthesizing.value = false
const url = data.audioUrl || data.objectUrl
if (!url) return
initPlayer(url)
},
() => {
/* 错误已在 useTTS 中处理 */
isSynthesizing.value = false
},
{ autoPlay: false },
)
@@ -331,274 +337,22 @@ onBeforeUnmount(() => {
</script>
<style scoped lang="less">
.voice-selector {
width: 100%;
}
/* 空状态 */
.empty-voices {
padding: var(--space-10) var(--space-6);
background: var(--muted);
border: 1px dashed var(--border);
border-radius: var(--radius-lg);
text-align: center;
.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);
/* 自定义滚动条 */
.voice-grid-scroll {
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 2px;
}
&::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 2px;
}
}
/* APlayer 深层样式覆盖 */
.aplayer-container {
:deep(.aplayer) {
border-radius: var(--radius);
@@ -612,19 +366,21 @@ onBeforeUnmount(() => {
}
/* 动画 */
@keyframes scaleIn {
from { transform: scale(0); }
to { transform: scale(1); }
}
.slide-fade-enter-active {
transition: all var(--duration-base) ease-out;
transition: all 0.2s ease-out;
}
.slide-fade-leave-active {
transition: all var(--duration-fast) ease-in;
transition: all 0.15s ease-in;
}
.slide-fade-enter-from {
transform: translateY(-10px);
opacity: 0;
}
.slide-fade-leave-to {
transform: translateY(-10px);
opacity: 0;

View File

@@ -1,5 +1,5 @@
<script setup>
import { ref, watch, nextTick } from 'vue'
import { ref, computed, watch, nextTick } from 'vue'
import { Icon } from '@iconify/vue'
import { toast } from 'vue-sonner'
import {
@@ -33,7 +33,46 @@ const props = defineProps({
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 inputText = ref('')
const isGenerating = ref(false)
@@ -44,7 +83,6 @@ const abortController = ref(null)
const historyVisible = ref(false)
const showCloseConfirm = ref(false)
// Methods
const handleClose = (open) => {
if (!open) {
if (isGenerating.value) {
@@ -122,28 +160,7 @@ const executeStreamRequest = async (prompt) => {
abortController.value = new AbortController()
try {
await sendChatStream({
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
}
})
await sendChatStream(buildRequestOptions(prompt, abortController.value))
} catch (error) {
if (error.name !== 'AbortError') toast.error('生成失败')
isGenerating.value = false
@@ -170,7 +187,7 @@ watch(() => props.visible, (val) => {
<template>
<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 -->
<SheetHeader class="shrink-0">
<ChatDrawerHeader :agent="agent" @history="openHistory" />
@@ -208,6 +225,7 @@ watch(() => props.visible, (val) => {
<HistoryPanel
:visible="historyVisible"
:agent-id="agent?.id"
:source="agent?.source || 'agent'"
@close="closeHistory"
/>

View File

@@ -1,31 +1,45 @@
<script setup>
import { computed } from 'vue'
import { Icon } from '@iconify/vue'
import { Button } from '@/components/ui/button'
defineProps({
const props = defineProps({
agent: { type: Object, default: null }
})
const emit = defineEmits(['history'])
const isPromptScene = computed(() => props.agent?.source === 'prompt')
const openHistory = () => emit('history')
</script>
<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 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
v-if="agent?.avatar"
:src="agent.avatar"
:alt="agent.name"
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 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>
<Button

View File

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

View File

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

View File

@@ -39,9 +39,9 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
: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',
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'
&& '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'
&& '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'

View File

@@ -53,7 +53,7 @@ const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
<div
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-state="state"
: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> {
if (!text?.trim()) {
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 {
const textarea = document.createElement('textarea')
textarea.value = text
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
await clipboardPolyfill.writeText(text)
return true
} catch {
return false
}

View File

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

View File

@@ -1,18 +1,12 @@
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { ref, reactive, onMounted } from 'vue'
import { toast } from 'vue-sonner'
import { Icon } from '@iconify/vue'
import dayjs from 'dayjs'
import { MaterialService } from '@/api/material'
import { VoiceService } from '@/api/voice'
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 { Spinner } from '@/components/ui/spinner'
import {
Table,
@@ -22,13 +16,6 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogAction,
@@ -40,39 +27,18 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Progress } from '@/components/ui/progress'
import TaskPageLayout from '@/views/system/task-management/components/TaskPageLayout.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: ''
}
import VoiceCopyDialog from './VoiceCopyDialog.vue'
// ========== 响应式数据 ==========
const loading = ref(false)
const submitting = ref(false)
const voiceList = ref([])
const modalVisible = ref(false)
const deleteDialogVisible = ref(false)
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 fileList = ref([])
const extractingText = ref(false)
const fileInputRef = ref(null)
const isDragging = ref(false)
const searchParams = reactive({
name: '',
@@ -87,22 +53,6 @@ const pagination = reactive({
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) => {
if (!value) return '-'
@@ -151,35 +101,15 @@ function handlePageChange(page) {
// ========== CRUD 操作 ==========
function handleCreate() {
formMode.value = 'create'
resetForm()
modalVisible.value = true
dialogMode.value = 'create'
dialogRecord.value = null
dialogVisible.value = true
}
async function handleEdit(record) {
formMode.value = 'edit'
try {
const res = await VoiceService.get(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: record.id,
name: record.name || '',
fileId: record.fileId || null,
note: record.note || ''
})
}
modalVisible.value = true
function handleEdit(record) {
dialogMode.value = 'edit'
dialogRecord.value = record
dialogVisible.value = true
}
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())
</script>
@@ -467,102 +249,12 @@ onMounted(() => loadVoiceList())
<!-- 弹窗 -->
<template #modals>
<!-- 新建/编辑弹窗 -->
<Dialog :open="modalVisible" @update:open="(v) => modalVisible = 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="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>
<VoiceCopyDialog
v-model:open="dialogVisible"
:mode="dialogMode"
:record="dialogRecord"
@success="loadVoiceList"
/>
<!-- 删除确认 -->
<AlertDialog :open="deleteDialogVisible" @update:open="(v) => deleteDialogVisible = v">
@@ -588,98 +280,5 @@ onMounted(() => loadVoiceList())
</TaskPageLayout>
</div>
<audio ref="audioPlayer" class="hidden" />
</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;
display: flex;
flex-direction: column;
min-width: 0;
&:hover {
box-shadow: var(--shadow-lg);

View File

@@ -22,26 +22,14 @@
<!-- 内容区 -->
<div class="popover-body">
<!-- 智能体选择 -->
<!-- 风格选择 -->
<div class="form-item">
<label class="form-label">选择智能体</label>
<Select v-model="selectedAgentId" :disabled="loadingAgents" class="agent-select">
<SelectTrigger class="agent-select-trigger">
<SelectValue :placeholder="loadingAgents ? '加载中...' : '请选择智能体'" />
</SelectTrigger>
<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>
<label class="form-label">选择风格</label>
<StyleSelector
v-model:value="selectedAgentId"
placeholder="选择风格"
storage-key="text_generate_style"
/>
</div>
<!-- 主题输入 -->
@@ -88,14 +76,8 @@
import { ref, computed, watch, onUnmounted } from 'vue'
import { Icon } from '@iconify/vue'
import { toast } from 'vue-sonner'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { getAgentList, sendChatStream } from '@/api/agent'
import StyleSelector from '@/components/StyleSelector.vue'
import { sendChatStream } from '@/api/agent'
// Props
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 theme = ref('')
const generatedText = ref('')
@@ -125,25 +105,6 @@ const canGenerate = computed(() => {
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 = () => {
// 找到触发按钮
@@ -179,9 +140,8 @@ const updatePosition = () => {
const handleGenerate = async () => {
if (!canGenerate.value || isGenerating.value) return
const selectedAgent = agentList.value.find(a => a.id === selectedAgentId.value)
if (!selectedAgent) {
toast.warning('请选择智能体')
if (!selectedAgentId.value) {
toast.warning('请选择风格')
return
}
@@ -198,7 +158,7 @@ const handleGenerate = async () => {
try {
await sendChatStream({
agentId: selectedAgent.id,
agentId: selectedAgentId.value,
content: prompt,
ctrl: abortController.value,
onMessage: (result: { event: string; content?: string; errorMessage?: string }) => {
@@ -243,7 +203,6 @@ const handleClose = () => {
// 监听 visible 变化
watch(() => props.visible, (val) => {
if (val) {
fetchAgents()
updatePosition()
// 监听窗口大小变化
window.addEventListener('resize', updatePosition)
@@ -288,7 +247,7 @@ onUnmounted(() => {
.popover-overlay {
position: fixed;
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);
border: 1px solid @border-color;
overflow: hidden;
z-index: 1001;
z-index: 40;
}
// 头部
@@ -358,45 +317,6 @@ onUnmounted(() => {
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 {
width: 100%;
height: 32px;

View File

@@ -1,94 +1,94 @@
<template>
<div class="timeline-panel">
<div class="timeline-header">
<span class="timeline-title">时间轴对比</span>
<span v-if="showDurations" class="duration-badge">
人脸 {{ formatDuration(faceDurationMs) }}
<div class="bg-[#fafbfc] border border-[#e5e7eb] rounded-md px-4 py-3 mt-2">
<div class="flex justify-between items-center mb-2.5">
<span class="text-[13px] font-semibold text-gray-800">时间轴对比</span>
<span v-if="showDurations" class="flex items-center gap-1.5 text-[13px] text-gray-600 tabular-nums">
<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">
<span class="divider"></span>
音频 {{ formatDuration(audioDurationMs) }}
<span class="text-gray-400 text-xs">/</span>
<span class="flex items-center gap-[5px]">
<span class="w-[7px] h-[7px] rounded-sm bg-emerald-500"></span>
音频 {{ formatDuration(audioDurationMs) }}
</span>
</template>
</span>
</div>
<!-- 刻度尺 -->
<div class="timeline-ruler">
<div class="relative h-5 mb-1.5 ml-12">
<div
v-for="mark in rulerMarks"
:key="mark.time"
class="ruler-mark"
class="absolute -translate-x-1/2 flex flex-col items-center"
:style="{ left: mark.position + '%' }"
>
<span class="ruler-label">{{ mark.label }}</span>
<span class="ruler-tick"></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>
<span class="text-[11px] text-gray-400 leading-none mb-1">{{ mark.label }}</span>
<span class="block w-px h-1 bg-[#e5e7eb]"></span>
</div>
</div>
<!-- 轨道区域 -->
<div class="timeline-tracks">
<div class="flex flex-col gap-1.5">
<!-- 视频轨道 -->
<div class="track">
<div class="track-info">
<span class="track-icon">📹</span>
<span class="track-label">视频</span>
<div class="flex items-center gap-2">
<div class="w-9 shrink-0">
<span class="text-xs text-gray-600 font-medium">视频</span>
</div>
<div class="track-bar">
<div class="flex-1 h-6 bg-[#f1f3f5] rounded relative overflow-hidden flex items-center">
<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 + '%' }"
>
<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 class="track">
<div class="track-info">
<span class="track-icon">🎙</span>
<span class="track-label">音频</span>
<div class="flex items-center gap-2">
<div class="w-9 shrink-0">
<span class="text-xs text-gray-600 font-medium">音频</span>
</div>
<div class="track-bar">
<div class="flex-1 h-6 bg-[#f1f3f5] rounded relative overflow-hidden flex items-center">
<div
v-if="audioDurationMs > 0"
class="track-fill audio-fill"
:class="{ 'audio-exceed': isExceed }"
class="h-full rounded flex items-center justify-center transition-[width] duration-300 min-w-[2px]"
:class="isExceed ? 'bg-red-500' : 'bg-emerald-500'"
: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>
<span v-else class="track-placeholder">等待生成音频</span>
<span v-else class="text-xs text-gray-400 pl-2.5">等待生成音频</span>
</div>
</div>
</div>
<!-- 校验错误提示 -->
<div v-if="validationError" class="timeline-diff error">
<Icon icon="lucide:x-circle" class="diff-icon" />
<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="text-sm shrink-0" />
<span>{{ validationError }}</span>
</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'">
<Icon icon="lucide:check-circle" class="diff-icon" />
<Icon icon="lucide:check-circle" class="text-sm shrink-0" />
<span>时长匹配良好可以生成</span>
</template>
<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>
</template>
<template v-else-if="diffStatus === 'short'">
<Icon icon="lucide:info" class="diff-icon" />
<Icon icon="lucide:info" class="text-sm shrink-0" />
<span>音频较短可适当增加文案</span>
</template>
</div>
@@ -127,18 +127,17 @@ const audioBarWidth = computed(() =>
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 diffMs = computed(() => Math.abs(props.audioDurationMs - props.faceDurationMs))
/** 时长差异阈值3秒内视为匹配 */
const MATCH_THRESHOLD_MS = 3000
const diffStatus = computed(() => {
if (props.audioDurationMs === 0) return 'none'
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'
})
@@ -175,235 +174,3 @@ function calculateInterval(duration: number): number {
const formatDuration = formatDurationMs
</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>
<div class="rate-control">
<Slider
v-model="store.speechRate"
:model-value="[store.speechRate]"
@update:model-value="store.speechRate = $event?.[0] ?? store.speechRate"
:min="0.5"
:max="2.0"
:step="0.1"
@@ -135,13 +136,6 @@ const placeholder = computed(() => {
const canGenerateAudio = computed(() => {
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>
<style scoped lang="less">

View File

@@ -7,7 +7,7 @@
* 3. 时间轴可视化:实时对比视频和音频时长
*/
import { ref, computed } from 'vue'
import { ref, computed, watch } from 'vue'
import { defineStore } from 'pinia'
import { toast } from 'vue-sonner'
import { VoiceService } from '@/api/voice'
@@ -17,6 +17,11 @@ import { useUpload } from '@/composables/useUpload'
import { DEFAULT_VOICE_PROVIDER } from '@/config/voiceConfig'
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 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)
@@ -61,6 +70,9 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
/** 视频预览URL */
const videoPreviewUrl = ref('')
/** 素材库视频的签名URL用于识别API不用于播放 */
let signedVideoUrl = ''
/** 错误信息 */
const error = ref('')
@@ -147,7 +159,7 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
if (!timeline.value || timeline.value.audioDurationMs === 0) return 'none'
const { videoDurationMs, audioDurationMs } = timeline.value
if (audioDurationMs > videoDurationMs) return 'exceed'
if (audioDurationMs < videoDurationMs * 0.3) return 'too-short'
if (videoDurationMs - audioDurationMs > 3000) return 'too-short'
return 'match'
})
@@ -231,6 +243,11 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
return
}
if (file.size > MAX_VIDEO_SIZE) {
toast.error('视频文件不能超过 100MB')
return
}
// 释放旧的 blob URL
if (videoPreviewUrl.value?.startsWith('blob:')) {
URL.revokeObjectURL(videoPreviewUrl.value)
@@ -247,6 +264,12 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
/** 从素材库选择视频(选择后自动识别) */
async function selectVideo(video: Video) {
// 校验视频大小
if (video.fileSize && video.fileSize > MAX_VIDEO_SIZE) {
toast.error('视频文件不能超过 100MB')
return
}
selectedVideo.value = video
videoFile.value = null
videoSource.value = 'select'
@@ -258,7 +281,19 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
if (urlRes.code !== 0 || !urlRes.data) {
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) {
videoStep.value = 'error'
error.value = err.message || '获取播放链接失败'
@@ -330,8 +365,8 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
/** 识别已存在的视频 */
async function recognizeExistingVideo(video: Video): Promise<IdentifyData> {
// 使用已获取的带签名预览URL
return performFaceRecognition(video.id, videoPreviewUrl.value, false)
// 使用签名URL调用识别APIblob URL不可被外部API访问
return performFaceRecognition(video.id, signedVideoUrl, false)
}
/** 执行人脸识别 */
@@ -544,6 +579,7 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
videoFile.value = null
selectedVideo.value = null
videoPreviewUrl.value = ''
signedVideoUrl = ''
videoSelectorVisible.value = false
resetProcess()

View File

@@ -52,6 +52,18 @@ public interface FileApi {
String presignGetUrl(@NotEmpty(message = "URL 不能为空") String url,
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 视频截帧等图片处理场景

View File

@@ -31,7 +31,12 @@ public class FileApiImpl implements FileApi {
@Override
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

View File

@@ -71,6 +71,18 @@ public interface FileClient {
* @return 文件预签名地址
*/
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("不支持的操作");
}

View File

@@ -200,12 +200,19 @@ public class S3FileClient extends AbstractFileClient<S3FileClientConfig> {
}
@Override
public String presignGetUrl(String url, Integer expirationSeconds) {
return presignGetUrlWithProcess(url, expirationSeconds, null);
public String presignGetUrl(String url, Integer expirationSeconds, String contentType) {
return presignGetUrlWithProcess(url, expirationSeconds, null, contentType);
}
@Override
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 原始域名)
String path = extractPathFromUrl(url);
String decodedPath = URLUtil.decode(path, StandardCharsets.UTF_8);
@@ -213,11 +220,17 @@ public class S3FileClient extends AbstractFileClient<S3FileClientConfig> {
// 2. 公开访问:无需签名,直接拼接参数
if (!BooleanUtil.isFalse(config.getEnablePublicAccess())) {
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)) {
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需要将处理参数包含在签名中
@@ -229,10 +242,14 @@ public class S3FileClient extends AbstractFileClient<S3FileClientConfig> {
com.aliyun.oss.model.GeneratePresignedUrlRequest request =
new com.aliyun.oss.model.GeneratePresignedUrlRequest(config.getBucket(), decodedPath, HttpMethod.GET);
request.setExpiration(expirationDate);
// 关键:将 x-oss-process 参数包含在签名中
// 将 x-oss-process 参数包含在签名中
if (StrUtil.isNotBlank(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();
} else {
// 非阿里云不支持 OSS 处理参数,直接返回普通预签名 URL

View File

@@ -54,6 +54,16 @@ public interface FileService {
*/
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 视频截帧等图片处理场景

View File

@@ -142,8 +142,13 @@ public class FileServiceImpl implements FileService {
@Override
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();
return fileClient.presignGetUrl(url, expirationSeconds);
return fileClient.presignGetUrl(url, expirationSeconds, contentType);
}
@Override

View File

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

View File

@@ -53,26 +53,29 @@ public interface DifyService {
Flux<DifyChatRespVO> promptAnalysisStream(PromptAnalysisReqVO reqVO, String userId);
/**
* 获取会话列表
* 获取会话列表(合并 pro + standard 两个 Dify App 的会话)
*
* @param agentId 智能体ID
* @param source 来源类型agent/prompt
* @param userId 用户ID
* @param lastId 上一页最后一条记录ID
* @param cursor 复合游标Base64 编码,首页传 null
* @param limit 返回条数
* @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 source 来源类型agent/prompt
* @param conversationId 会话ID
* @param appSource 来源应用标识pro/standard用于选择 API Key
* @param userId 用户ID
* @param firstId 当前页第一条记录ID
* @param limit 返回条数
* @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.DifyChatRespVO;
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.ForecastRewriteReqVO;
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.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.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
@@ -50,13 +57,22 @@ public class DifyServiceImpl implements DifyService {
AtomicLong pendingRecordId = new AtomicLong();
AtomicReference<String> conversationIdRef = new AtomicReference<>(reqVO.getConversationId());
AtomicReference<DifyChatRespVO> tokenUsageRef = new AtomicReference<>();
String difyUserId = "user-" + userId + "-agent-" + reqVO.getAgentId();
String difyUserId = buildDifyUserId(userId, reqVO.getSource(), reqVO.getAgentId());
String logPrefix = "chatStream";
return Mono.fromCallable(() -> {
AiAgentDO agent = aiAgentService.getAiAgent(reqVO.getAgentId());
if (agent == null) {
throw new RuntimeException("智能体不存在");
// 解析系统提示词:优先使用自定义提示词,否则从智能体获取
String systemPrompt;
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())
@@ -66,12 +82,15 @@ public class DifyServiceImpl implements DifyService {
AiPlatformEnum.DIFY.getPlatform(), modelTypeEnum.getModelCode());
pointsService.checkPoints(userId, config.getConsumePoints());
String serviceRef = reqVO.getAgentId() != null
? reqVO.getAgentId().toString()
: "custom-prompt";
Long recordId = pointsService.createPendingDeduct(
userId, config.getConsumePoints(), "dify_chat",
reqVO.getAgentId().toString(), config.getServiceCode());
serviceRef, config.getServiceCode());
pendingRecordId.set(recordId);
return new DifyChatContext(agent.getSystemPrompt(), config.getApiKey(), config.getConsumePoints());
return new DifyChatContext(systemPrompt, config.getApiKey(), config.getConsumePoints());
})
.flatMapMany(context -> difyClient.chatStream(
context.apiKey(), reqVO.getContent(), context.systemPrompt(),
@@ -366,55 +385,169 @@ public class DifyServiceImpl implements DifyService {
}
@Override
public DifyConversationListRespVO getConversations(Long agentId, String userId, String lastId, Integer limit) {
// 获取智能体配置
AiAgentDO agent = aiAgentService.getAiAgent(agentId);
if (agent == null) {
throw new RuntimeException("智能体不存在");
public DifyConversationListRespVO getConversations(Long agentId, String source, String userId, String cursor, Integer limit) {
if (limit == null || limit <= 0) {
limit = 20;
}
// 获取积分配置(使用标准模式的 API Key
AiServiceConfigDO config = pointsService.getConfig(
AiPlatformEnum.DIFY.getPlatform(),
AiModelTypeEnum.DIFY_WRITING_STANDARD.getModelCode());
AiServiceConfigDO proConfig = getDifyConfig(false);
AiServiceConfigDO standardConfig = getDifyConfig(true);
// Dify 用户标识(按 agentId 隔离会话)
String difyUserId = "user-" + userId + "-agent-" + agentId;
String difyUserId = buildDifyUserId(userId, source, 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
if (result != null && result.getData() != null) {
result.getData().forEach(conv -> {
if (conv.getInputs() != null) {
conv.getInputs().remove("sysPrompt");
}
});
}
pageData.forEach(conv -> {
if (conv.getInputs() != null) {
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;
}
@Override
public DifyMessageListRespVO getMessages(Long agentId, String conversationId, String userId, String firstId, Integer limit) {
// 获取智能体配置
AiAgentDO agent = aiAgentService.getAiAgent(agentId);
if (agent == null) {
throw new RuntimeException("智能体不存在");
/**
* 构建下一页复合游标
*/
private String buildNextCursor(List<DifyConversationRespVO> pageData,
boolean proHasMore, boolean standardHasMore) {
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
AiServiceConfigDO config = pointsService.getConfig(
AiPlatformEnum.DIFY.getPlatform(),
AiModelTypeEnum.DIFY_WRITING_STANDARD.getModelCode());
if (proLastId == null && standardLastId == null) return null;
// Dify 用户标识(按 agentId 隔离会话)
String difyUserId = "user-" + userId + "-agent-" + agentId;
try {
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
if (result != null && result.getData() != null) {
if (result.getData() != null) {
result.getData().forEach(msg -> {
if (msg.getInputs() != null) {
msg.getInputs().remove("sysPrompt");
@@ -425,4 +558,18 @@ public class DifyServiceImpl implements DifyService {
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
public class DifyChatReqVO {
@Schema(description = "智能体ID", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull(message = "智能体ID不能为空")
@Schema(description = "智能体ID(使用 customSystemPrompt 时可不传)")
private Long agentId;
@Schema(description = "用户输入内容", requiredMode = Schema.RequiredMode.REQUIRED)
@@ -26,4 +25,10 @@ public class DifyChatReqVO {
@Schema(description = "模型模式pro-深度版 standard-标准版", example = "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;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@@ -23,4 +24,8 @@ public class DifyConversationListRespVO {
@Schema(description = "会话列表", requiredMode = Schema.RequiredMode.REQUIRED)
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")
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.apilog.core.annotation.ApiAccessLog;
import cn.iocoder.yudao.framework.tenant.core.aop.TenantIgnore;
import static cn.iocoder.yudao.framework.apilog.core.enums.OperateTypeEnum.*;
@Tag(name = "管理后台 - AI第三方服务配置")
@@ -75,6 +76,7 @@ public class AiServiceConfigController {
@Operation(summary = "获得AI服务配置")
@Parameter(name = "id", description = "编号", required = true, example = "1024")
@PreAuthorize("@ss.hasPermission('muye:ai-service-config:query')")
@TenantIgnore
public CommonResult<AiServiceConfigRespVO> getAiServiceConfig(@RequestParam("id") Long id) {
AiServiceConfigDO aiServiceConfig = aiServiceConfigService.getAiServiceConfig(id);
return success(BeanUtils.toBean(aiServiceConfig, AiServiceConfigRespVO.class));
@@ -83,6 +85,7 @@ public class AiServiceConfigController {
@GetMapping("/page")
@Operation(summary = "获得AI服务配置分页")
@PreAuthorize("@ss.hasPermission('muye:ai-service-config:query')")
@TenantIgnore
public CommonResult<PageResult<AiServiceConfigRespVO>> getAiServiceConfigPage(@Valid AiServiceConfigPageReqVO pageReqVO) {
PageResult<AiServiceConfigDO> pageResult = aiServiceConfigService.getAiServiceConfigPage(pageReqVO);
return success(BeanUtils.toBean(pageResult, AiServiceConfigRespVO.class));
@@ -104,6 +107,7 @@ public class AiServiceConfigController {
@GetMapping("/list-enabled")
@Operation(summary = "获取所有启用的服务配置列表(前端积分显示用)")
@PreAuthorize("@ss.hasPermission('muye:ai-service-config:query')")
@TenantIgnore
public CommonResult<Map<String, List<AiServiceConfigService.ServiceConfigSimpleVO>>> getEnabledServiceConfigList() {
return success(aiServiceConfigService.getEnabledServiceConfigList());
}

View File

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