diff --git a/frontend/app/web-gold/src/components/agents/ChatDrawer.vue b/frontend/app/web-gold/src/components/agents/ChatDrawer.vue index dfdeb8ad68..a7143270a1 100644 --- a/frontend/app/web-gold/src/components/agents/ChatDrawer.vue +++ b/frontend/app/web-gold/src/components/agents/ChatDrawer.vue @@ -20,6 +20,7 @@ import { AlertDialogTitle } from '@/components/ui/alert-dialog' import { sendChatStream } from '@/api/agent' +import { copyToClipboard } from '@/utils/clipboard' import HistoryPanel from './HistoryPanel.vue' import ChatDrawerHeader from './ChatDrawerHeader.vue' import ChatDrawerEmpty from './ChatDrawerEmpty.vue' @@ -90,10 +91,10 @@ const handleGenerate = async () => { } const handleCopy = async () => { - try { - await navigator.clipboard.writeText(generatedContent.value) + const success = await copyToClipboard(generatedContent.value) + if (success) { toast.success('已复制') - } catch { + } else { toast.error('复制失败') } } diff --git a/frontend/app/web-gold/src/components/agents/HistoryPanel.vue b/frontend/app/web-gold/src/components/agents/HistoryPanel.vue index 62458862d3..3c28d86b54 100644 --- a/frontend/app/web-gold/src/components/agents/HistoryPanel.vue +++ b/frontend/app/web-gold/src/components/agents/HistoryPanel.vue @@ -89,6 +89,18 @@ + + +
+ +
@@ -171,8 +183,10 @@ const conversationList = ref([]) const selectedConversation = ref(null) const messageList = ref([]) const messageLoading = ref(false) +const lastId = ref(null) +const hasMore = ref(true) -// 按日期分组 +// 按日期分组(先排序再分组) const groupedConversations = computed(() => { const today = dayjs().startOf('day') const yesterday = dayjs().subtract(1, 'day').startOf('day') @@ -185,7 +199,14 @@ const groupedConversations = computed(() => { older: { label: '更早', items: [] } } - conversationList.value.forEach(item => { + // 按时间降序排序 + const sortedList = [...conversationList.value].sort((a, b) => { + const timeA = (a.updatedAt || a.createdAt) * 1000 + const timeB = (b.updatedAt || b.createdAt) * 1000 + return timeB - timeA + }) + + sortedList.forEach(item => { const date = dayjs((item.updatedAt || item.createdAt) * 1000) if (date.isAfter(today)) { groups.today.items.push(item) @@ -202,13 +223,27 @@ const groupedConversations = computed(() => { }) // Methods -const loadConversations = async () => { +const loadConversations = async (loadMore = false) => { if (!props.agentId) return + if (loadMore && !hasMore.value) return + loading.value = true try { - const res = await getConversations({ agentId: props.agentId, limit: 50 }) + const params = { agentId: props.agentId, limit: 20 } + if (loadMore && lastId.value) { + params.lastId = lastId.value + } + const res = await getConversations(params) if (res.code === 0) { - conversationList.value = res.data?.data || [] + const data = res.data?.data || [] + if (loadMore) { + conversationList.value = [...conversationList.value, ...data] + } else { + conversationList.value = data + } + // 更新分页状态 + lastId.value = data.length > 0 ? data[data.length - 1].id : null + hasMore.value = data.length === 20 && res.data?.hasMore !== false } } catch (e) { console.error('加载会话列表失败:', e) @@ -249,7 +284,11 @@ const handleClose = () => { const copyContent = async (content) => { const success = await copyToClipboard(content) - success ? toast.success('已复制到剪贴板') : toast.error('复制失败') + if (success) { + toast.success('已复制到剪贴板') + } else { + toast.error('复制失败') + } } const formatTime = (timestamp) => { @@ -257,15 +296,10 @@ const formatTime = (timestamp) => { const date = dayjs(timestamp * 1000) const now = dayjs() - if (date.isSame(now, 'day')) { - return date.format('HH:mm') - } else if (date.isSame(now.subtract(1, 'day'), 'day')) { - return '昨天 ' + date.format('HH:mm') - } else if (date.isAfter(now.subtract(7, 'day'))) { - return date.format('dddd HH:mm') - } else { - return date.format('MM-DD HH:mm') - } + if (date.isSame(now, 'day')) return date.format('HH:mm') + if (date.isSame(now.subtract(1, 'day'), 'day')) return '昨天 ' + date.format('HH:mm') + if (date.isAfter(now.subtract(7, 'day'))) return date.format('dddd HH:mm') + return date.format('MM-DD HH:mm') } // Watch @@ -530,6 +564,36 @@ watch(() => props.visible, (val) => { gap: 24px; } +.load-more { + display: flex; + justify-content: center; + padding-top: 8px; +} + +.load-more-btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 20px; + border: 1px solid var(--color-gray-200); + background: var(--color-bg-card); + border-radius: 10px; + font-size: 14px; + color: var(--color-gray-600); + cursor: pointer; + transition: all 0.2s ease; + + &:hover:not(:disabled) { + border-color: var(--color-primary-300); + color: var(--color-primary-500); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } +} + .conversation-group { animation: groupFadeIn 0.4s ease-out backwards; animation-delay: calc(var(--group-index) * 0.1s); diff --git a/sql/mysql/redeem_code_menu.sql b/sql/mysql/redeem_code_menu.sql index 59bdd03b40..19781d9b53 100644 --- a/sql/mysql/redeem_code_menu.sql +++ b/sql/mysql/redeem_code_menu.sql @@ -8,7 +8,7 @@ -- 1. 兑换码管理菜单(目录) INSERT INTO `system_menu` (`name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) -VALUES ('兑换码管理', '', 2, 20, 0, 'redeemcode', 'ep:tickets', 'muye/redeemcode/index', 'RedeemCode', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'); +VALUES ('兑换码管理', '', 2, 20, 0, '/redeemcode', 'ep:tickets', 'muye/redeemcode/index', 'RedeemCode', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'); -- 获取刚插入的菜单ID(兑换码管理) SET @redeem_code_menu_id = LAST_INSERT_ID(); @@ -31,7 +31,7 @@ VALUES ('兑换码导出', 'muye:redeem-code:export', 3, 4, @redeem_code_menu_id -- 2. 兑换记录菜单(目录) INSERT INTO `system_menu` (`name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) -VALUES ('兑换记录', '', 2, 21, 0, 'redeemrecord', 'ep:document', 'muye/redeemrecord/index', 'RedeemRecord', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'); +VALUES ('兑换记录', '', 2, 21, 0, '/redeemrecord', 'ep:document', 'muye/redeemrecord/index', 'RedeemRecord', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'); -- 获取刚插入的菜单ID(兑换记录) SET @redeem_record_menu_id = LAST_INSERT_ID(); diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/dify/service/DifyServiceImpl.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/dify/service/DifyServiceImpl.java index 16c22d1ae0..39452e4639 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/dify/service/DifyServiceImpl.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/dify/service/DifyServiceImpl.java @@ -25,6 +25,7 @@ import java.util.HashMap; import java.util.Map; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; /** * Dify 服务实现类 @@ -53,8 +54,8 @@ public class DifyServiceImpl implements DifyService { AtomicReference conversationIdRef = new AtomicReference<>(reqVO.getConversationId()); // 用于存储 token 使用信息 AtomicReference tokenUsageRef = new AtomicReference<>(); - // Dify 用户标识(固定格式) - String difyUserId = "user-" + userId; + // Dify 用户标识(按 agentId 隔离会话) + String difyUserId = "user-" + userId + "-agent-" + reqVO.getAgentId(); return Mono.fromCallable(() -> { // 1. 获取智能体配置 @@ -179,8 +180,10 @@ public class DifyServiceImpl implements DifyService { AtomicReference conversationIdRef = new AtomicReference<>(""); // 用于存储 token 使用信息 AtomicReference tokenUsageRef = new AtomicReference<>(); - // Dify 用户标识(固定格式) - String difyUserId = "user-" + userId; + // Dify 用户标识(按 agentId 隔离会话,无 agentId 时使用默认) + String difyUserId = reqVO.getAgentId() != null + ? "user-" + userId + "-agent-" + reqVO.getAgentId() + : "user-" + userId; return Mono.fromCallable(() -> { // 1. 获取系统提示词 @@ -653,6 +656,78 @@ public class DifyServiceImpl implements DifyService { */ private record PromptAnalysisContext(String prompt, String apiKey, Integer consumePoints) {} + /** + * 通用的流式响应扣费处理 + */ + private void handlePointsDeduction(AtomicLong pendingRecordId, + AtomicReference tokenUsageRef, + String logPrefix) { + if (pendingRecordId.get() <= 0) return; + try { + DifyChatRespVO tokenUsage = tokenUsageRef.get(); + if (tokenUsage != null) { + pointsService.confirmPendingDeductWithTokens( + pendingRecordId.get(), + tokenUsage.getInputTokens(), + tokenUsage.getOutputTokens(), + tokenUsage.getTotalTokens() + ); + log.info("[{}] 流结束,确认扣费(带token),记录ID: {}, tokens: {}", + logPrefix, pendingRecordId.get(), tokenUsage.getTotalTokens()); + } else { + pointsService.confirmPendingDeduct(pendingRecordId.get()); + log.info("[{}] 流结束,确认扣费(无token),记录ID: {}", logPrefix, pendingRecordId.get()); + } + } catch (Exception e) { + log.error("[{}] 确认扣费失败", logPrefix, e); + } + } + + /** + * 通用的取消预扣处理 + */ + private void handlePointsCancellation(AtomicLong pendingRecordId, String logPrefix) { + if (pendingRecordId.get() <= 0) return; + try { + pointsService.cancelPendingDeduct(pendingRecordId.get()); + log.info("[{}] 流出错,取消预扣,记录ID: {}", logPrefix, pendingRecordId.get()); + } catch (Exception e) { + log.error("[{}] 取消预扣失败", logPrefix, e); + } + } + + /** + * 通用的用户取消扣费处理 + */ + private void handleUserCancellation(AtomicLong pendingRecordId, String logPrefix) { + if (pendingRecordId.get() <= 0) return; + try { + pointsService.confirmPendingDeduct(pendingRecordId.get()); + log.info("[{}] 用户取消,确认扣费,记录ID: {}", logPrefix, pendingRecordId.get()); + } catch (Exception e) { + log.error("[{}] 用户取消后扣费失败", logPrefix, e); + } + } + + /** + * 构建 done 事件 Mono + */ + private Mono buildDoneMono(AtomicReference conversationIdRef, + AtomicReference tokenUsageRef) { + return Mono.defer(() -> { + DifyChatRespVO tokenUsage = tokenUsageRef.get(); + if (tokenUsage != null) { + return Mono.just(DifyChatRespVO.doneWithTokens( + conversationIdRef.get(), null, + tokenUsage.getInputTokens(), + tokenUsage.getOutputTokens(), + tokenUsage.getTotalTokens() + )); + } + return Mono.just(DifyChatRespVO.done(conversationIdRef.get(), null)); + }); + } + @Override public DifyConversationListRespVO getConversations(Long agentId, String userId, String lastId, Integer limit) { // 获取智能体配置 @@ -666,8 +741,8 @@ public class DifyServiceImpl implements DifyService { AiPlatformEnum.DIFY.getPlatform(), AiModelTypeEnum.DIFY_WRITING_STANDARD.getModelCode()); - // Dify 用户标识 - String difyUserId = "user-" + userId; + // Dify 用户标识(按 agentId 隔离会话) + String difyUserId = "user-" + userId + "-agent-" + agentId; DifyConversationListRespVO result = difyClient.getConversations(config.getApiKey(), difyUserId, lastId, limit); @@ -696,8 +771,8 @@ public class DifyServiceImpl implements DifyService { AiPlatformEnum.DIFY.getPlatform(), AiModelTypeEnum.DIFY_WRITING_STANDARD.getModelCode()); - // Dify 用户标识 - String difyUserId = "user-" + userId; + // Dify 用户标识(按 agentId 隔离会话) + String difyUserId = "user-" + userId + "-agent-" + agentId; DifyMessageListRespVO result = difyClient.getMessages(config.getApiKey(), conversationId, difyUserId, firstId, limit);