- 为聊天历史面板添加加载更多功能,支持分页加载会话列表 - 优化会话列表按时间降序排序和日期分组逻辑 - 统一复制功能使用工具函数,改进错误处理 - 修复兑换码管理菜单路径缺少斜杠的问题 - 优化Dify服务用户标识生成,按agentId隔离会话 - 重构Dify服务扣费逻辑,提取通用处理方法
This commit is contained in:
@@ -20,6 +20,7 @@ import {
|
|||||||
AlertDialogTitle
|
AlertDialogTitle
|
||||||
} from '@/components/ui/alert-dialog'
|
} from '@/components/ui/alert-dialog'
|
||||||
import { sendChatStream } from '@/api/agent'
|
import { sendChatStream } from '@/api/agent'
|
||||||
|
import { copyToClipboard } from '@/utils/clipboard'
|
||||||
import HistoryPanel from './HistoryPanel.vue'
|
import HistoryPanel from './HistoryPanel.vue'
|
||||||
import ChatDrawerHeader from './ChatDrawerHeader.vue'
|
import ChatDrawerHeader from './ChatDrawerHeader.vue'
|
||||||
import ChatDrawerEmpty from './ChatDrawerEmpty.vue'
|
import ChatDrawerEmpty from './ChatDrawerEmpty.vue'
|
||||||
@@ -90,10 +91,10 @@ const handleGenerate = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleCopy = async () => {
|
const handleCopy = async () => {
|
||||||
try {
|
const success = await copyToClipboard(generatedContent.value)
|
||||||
await navigator.clipboard.writeText(generatedContent.value)
|
if (success) {
|
||||||
toast.success('已复制')
|
toast.success('已复制')
|
||||||
} catch {
|
} else {
|
||||||
toast.error('复制失败')
|
toast.error('复制失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,6 +89,18 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Load More -->
|
||||||
|
<div v-if="hasMore" class="load-more">
|
||||||
|
<button
|
||||||
|
class="load-more-btn"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="loadConversations(true)"
|
||||||
|
>
|
||||||
|
<Icon v-if="loading" icon="lucide:loader-2" class="size-4 animate-spin" />
|
||||||
|
<span>{{ loading ? '加载中...' : '加载更多' }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -171,8 +183,10 @@ const conversationList = ref([])
|
|||||||
const selectedConversation = ref(null)
|
const selectedConversation = ref(null)
|
||||||
const messageList = ref([])
|
const messageList = ref([])
|
||||||
const messageLoading = ref(false)
|
const messageLoading = ref(false)
|
||||||
|
const lastId = ref(null)
|
||||||
|
const hasMore = ref(true)
|
||||||
|
|
||||||
// 按日期分组
|
// 按日期分组(先排序再分组)
|
||||||
const groupedConversations = computed(() => {
|
const groupedConversations = computed(() => {
|
||||||
const today = dayjs().startOf('day')
|
const today = dayjs().startOf('day')
|
||||||
const yesterday = dayjs().subtract(1, 'day').startOf('day')
|
const yesterday = dayjs().subtract(1, 'day').startOf('day')
|
||||||
@@ -185,7 +199,14 @@ const groupedConversations = computed(() => {
|
|||||||
older: { label: '更早', items: [] }
|
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)
|
const date = dayjs((item.updatedAt || item.createdAt) * 1000)
|
||||||
if (date.isAfter(today)) {
|
if (date.isAfter(today)) {
|
||||||
groups.today.items.push(item)
|
groups.today.items.push(item)
|
||||||
@@ -202,13 +223,27 @@ const groupedConversations = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Methods
|
// Methods
|
||||||
const loadConversations = async () => {
|
const loadConversations = async (loadMore = false) => {
|
||||||
if (!props.agentId) return
|
if (!props.agentId) return
|
||||||
|
if (loadMore && !hasMore.value) return
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
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) {
|
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) {
|
} catch (e) {
|
||||||
console.error('加载会话列表失败:', e)
|
console.error('加载会话列表失败:', e)
|
||||||
@@ -249,7 +284,11 @@ const handleClose = () => {
|
|||||||
|
|
||||||
const copyContent = async (content) => {
|
const copyContent = async (content) => {
|
||||||
const success = await copyToClipboard(content)
|
const success = await copyToClipboard(content)
|
||||||
success ? toast.success('已复制到剪贴板') : toast.error('复制失败')
|
if (success) {
|
||||||
|
toast.success('已复制到剪贴板')
|
||||||
|
} else {
|
||||||
|
toast.error('复制失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatTime = (timestamp) => {
|
const formatTime = (timestamp) => {
|
||||||
@@ -257,15 +296,10 @@ const formatTime = (timestamp) => {
|
|||||||
const date = dayjs(timestamp * 1000)
|
const date = dayjs(timestamp * 1000)
|
||||||
const now = dayjs()
|
const now = dayjs()
|
||||||
|
|
||||||
if (date.isSame(now, 'day')) {
|
if (date.isSame(now, 'day')) return date.format('HH:mm')
|
||||||
return date.format('HH:mm')
|
if (date.isSame(now.subtract(1, 'day'), 'day')) return '昨天 ' + date.format('HH:mm')
|
||||||
} else if (date.isSame(now.subtract(1, 'day'), 'day')) {
|
if (date.isAfter(now.subtract(7, 'day'))) return date.format('dddd HH:mm')
|
||||||
return '昨天 ' + date.format('HH:mm')
|
return date.format('MM-DD HH:mm')
|
||||||
} else if (date.isAfter(now.subtract(7, 'day'))) {
|
|
||||||
return date.format('dddd HH:mm')
|
|
||||||
} else {
|
|
||||||
return date.format('MM-DD HH:mm')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Watch
|
// Watch
|
||||||
@@ -530,6 +564,36 @@ watch(() => props.visible, (val) => {
|
|||||||
gap: 24px;
|
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 {
|
.conversation-group {
|
||||||
animation: groupFadeIn 0.4s ease-out backwards;
|
animation: groupFadeIn 0.4s ease-out backwards;
|
||||||
animation-delay: calc(var(--group-index) * 0.1s);
|
animation-delay: calc(var(--group-index) * 0.1s);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
-- 1. 兑换码管理菜单(目录)
|
-- 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`)
|
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(兑换码管理)
|
-- 获取刚插入的菜单ID(兑换码管理)
|
||||||
SET @redeem_code_menu_id = LAST_INSERT_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. 兑换记录菜单(目录)
|
-- 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`)
|
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(兑换记录)
|
-- 获取刚插入的菜单ID(兑换记录)
|
||||||
SET @redeem_record_menu_id = LAST_INSERT_ID();
|
SET @redeem_record_menu_id = LAST_INSERT_ID();
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import java.util.HashMap;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.atomic.AtomicLong;
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dify 服务实现类
|
* Dify 服务实现类
|
||||||
@@ -53,8 +54,8 @@ public class DifyServiceImpl implements DifyService {
|
|||||||
AtomicReference<String> conversationIdRef = new AtomicReference<>(reqVO.getConversationId());
|
AtomicReference<String> conversationIdRef = new AtomicReference<>(reqVO.getConversationId());
|
||||||
// 用于存储 token 使用信息
|
// 用于存储 token 使用信息
|
||||||
AtomicReference<DifyChatRespVO> tokenUsageRef = new AtomicReference<>();
|
AtomicReference<DifyChatRespVO> tokenUsageRef = new AtomicReference<>();
|
||||||
// Dify 用户标识(固定格式)
|
// Dify 用户标识(按 agentId 隔离会话)
|
||||||
String difyUserId = "user-" + userId;
|
String difyUserId = "user-" + userId + "-agent-" + reqVO.getAgentId();
|
||||||
|
|
||||||
return Mono.fromCallable(() -> {
|
return Mono.fromCallable(() -> {
|
||||||
// 1. 获取智能体配置
|
// 1. 获取智能体配置
|
||||||
@@ -179,8 +180,10 @@ public class DifyServiceImpl implements DifyService {
|
|||||||
AtomicReference<String> conversationIdRef = new AtomicReference<>("");
|
AtomicReference<String> conversationIdRef = new AtomicReference<>("");
|
||||||
// 用于存储 token 使用信息
|
// 用于存储 token 使用信息
|
||||||
AtomicReference<DifyChatRespVO> tokenUsageRef = new AtomicReference<>();
|
AtomicReference<DifyChatRespVO> tokenUsageRef = new AtomicReference<>();
|
||||||
// Dify 用户标识(固定格式)
|
// Dify 用户标识(按 agentId 隔离会话,无 agentId 时使用默认)
|
||||||
String difyUserId = "user-" + userId;
|
String difyUserId = reqVO.getAgentId() != null
|
||||||
|
? "user-" + userId + "-agent-" + reqVO.getAgentId()
|
||||||
|
: "user-" + userId;
|
||||||
|
|
||||||
return Mono.fromCallable(() -> {
|
return Mono.fromCallable(() -> {
|
||||||
// 1. 获取系统提示词
|
// 1. 获取系统提示词
|
||||||
@@ -653,6 +656,78 @@ public class DifyServiceImpl implements DifyService {
|
|||||||
*/
|
*/
|
||||||
private record PromptAnalysisContext(String prompt, String apiKey, Integer consumePoints) {}
|
private record PromptAnalysisContext(String prompt, String apiKey, Integer consumePoints) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用的流式响应扣费处理
|
||||||
|
*/
|
||||||
|
private void handlePointsDeduction(AtomicLong pendingRecordId,
|
||||||
|
AtomicReference<DifyChatRespVO> 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<DifyChatRespVO> buildDoneMono(AtomicReference<String> conversationIdRef,
|
||||||
|
AtomicReference<DifyChatRespVO> 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
|
@Override
|
||||||
public DifyConversationListRespVO getConversations(Long agentId, String userId, String lastId, Integer limit) {
|
public DifyConversationListRespVO getConversations(Long agentId, String userId, String lastId, Integer limit) {
|
||||||
// 获取智能体配置
|
// 获取智能体配置
|
||||||
@@ -666,8 +741,8 @@ public class DifyServiceImpl implements DifyService {
|
|||||||
AiPlatformEnum.DIFY.getPlatform(),
|
AiPlatformEnum.DIFY.getPlatform(),
|
||||||
AiModelTypeEnum.DIFY_WRITING_STANDARD.getModelCode());
|
AiModelTypeEnum.DIFY_WRITING_STANDARD.getModelCode());
|
||||||
|
|
||||||
// Dify 用户标识
|
// Dify 用户标识(按 agentId 隔离会话)
|
||||||
String difyUserId = "user-" + userId;
|
String difyUserId = "user-" + userId + "-agent-" + agentId;
|
||||||
|
|
||||||
DifyConversationListRespVO result = difyClient.getConversations(config.getApiKey(), difyUserId, lastId, limit);
|
DifyConversationListRespVO result = difyClient.getConversations(config.getApiKey(), difyUserId, lastId, limit);
|
||||||
|
|
||||||
@@ -696,8 +771,8 @@ public class DifyServiceImpl implements DifyService {
|
|||||||
AiPlatformEnum.DIFY.getPlatform(),
|
AiPlatformEnum.DIFY.getPlatform(),
|
||||||
AiModelTypeEnum.DIFY_WRITING_STANDARD.getModelCode());
|
AiModelTypeEnum.DIFY_WRITING_STANDARD.getModelCode());
|
||||||
|
|
||||||
// Dify 用户标识
|
// Dify 用户标识(按 agentId 隔离会话)
|
||||||
String difyUserId = "user-" + userId;
|
String difyUserId = "user-" + userId + "-agent-" + agentId;
|
||||||
|
|
||||||
DifyMessageListRespVO result = difyClient.getMessages(config.getApiKey(), conversationId, difyUserId, firstId, limit);
|
DifyMessageListRespVO result = difyClient.getMessages(config.getApiKey(), conversationId, difyUserId, firstId, limit);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user