feat(chat): 新增对话线程列表的分页功能,支持limit和offset参数
This commit is contained in:
parent
1130cabe57
commit
96ce4dbe8a
@ -706,10 +706,16 @@ async def create_thread(
|
||||
|
||||
@chat.get("/threads", response_model=list[ThreadResponse])
|
||||
async def list_threads(
|
||||
agent_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user)
|
||||
agent_id: str,
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_required_user),
|
||||
):
|
||||
"""获取用户的所有对话线程 (使用新存储系统)"""
|
||||
return await list_threads_view(agent_id=agent_id, db=db, current_user_id=str(current_user.id))
|
||||
return await list_threads_view(
|
||||
agent_id=agent_id, db=db, current_user_id=str(current_user.id), limit=limit, offset=offset
|
||||
)
|
||||
|
||||
|
||||
@chat.delete("/thread/{thread_id}")
|
||||
|
||||
@ -196,7 +196,12 @@ class ConversationRepository:
|
||||
return await self.get_messages(conversation.id, limit, offset)
|
||||
|
||||
async def list_conversations(
|
||||
self, user_id: str | None = None, agent_id: str | None = None, status: str = "active"
|
||||
self,
|
||||
user_id: str | None = None,
|
||||
agent_id: str | None = None,
|
||||
status: str = "active",
|
||||
limit: int | None = None,
|
||||
offset: int = 0,
|
||||
) -> list[Conversation]:
|
||||
query = select(Conversation).where(Conversation.status == status)
|
||||
|
||||
@ -206,6 +211,10 @@ class ConversationRepository:
|
||||
query = query.where(Conversation.agent_id == agent_id)
|
||||
|
||||
query = query.order_by(Conversation.updated_at.desc())
|
||||
|
||||
if limit:
|
||||
query = query.limit(limit).offset(offset)
|
||||
|
||||
result = await self.db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@ -156,6 +156,8 @@ async def list_threads_view(
|
||||
agent_id: str,
|
||||
db: AsyncSession,
|
||||
current_user_id: str,
|
||||
limit: int | None = None,
|
||||
offset: int = 0,
|
||||
) -> list[dict]:
|
||||
if not agent_id:
|
||||
raise HTTPException(status_code=422, detail="agent_id 不能为空")
|
||||
@ -165,6 +167,8 @@ async def list_threads_view(
|
||||
user_id=str(current_user_id),
|
||||
agent_id=agent_id,
|
||||
status="active",
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
return [
|
||||
|
||||
@ -286,10 +286,12 @@ export const threadApi = {
|
||||
/**
|
||||
* 获取对话线程列表
|
||||
* @param {string} agentId - 智能体ID
|
||||
* @param {number} limit - 返回数量限制,默认100
|
||||
* @param {number} offset - 偏移量,默认0
|
||||
* @returns {Promise} - 对话线程列表
|
||||
*/
|
||||
getThreads: (agentId) => {
|
||||
const url = `/api/chat/threads?agent_id=${agentId}`
|
||||
getThreads: (agentId, limit = 100, offset = 0) => {
|
||||
const url = `/api/chat/threads?agent_id=${agentId}&limit=${limit}&offset=${offset}`
|
||||
return apiGet(url)
|
||||
},
|
||||
|
||||
|
||||
@ -9,12 +9,15 @@
|
||||
:agents="agents"
|
||||
:selected-agent-id="currentAgentId"
|
||||
:is-creating-new-chat="chatUIStore.creatingNewChat"
|
||||
:has-more-chats="hasMoreChats"
|
||||
:is-loading-more="isLoadingMoreChats"
|
||||
@create-chat="createNewChat"
|
||||
@select-chat="selectChat"
|
||||
@delete-chat="deleteChat"
|
||||
@rename-chat="renameChat"
|
||||
@toggle-sidebar="toggleSidebar"
|
||||
@open-agent-modal="openAgentModal"
|
||||
@load-more-chats="loadMoreChats"
|
||||
:class="{
|
||||
'sidebar-open': chatUIStore.isSidebarOpen,
|
||||
'no-transition': localUIState.isInitialRender
|
||||
@ -286,6 +289,8 @@ const chatState = reactive({
|
||||
// 组件级别的线程和消息状态
|
||||
const threads = ref([])
|
||||
const threadMessages = ref({})
|
||||
const hasMoreChats = ref(true) // 是否还有更多对话可加载
|
||||
const isLoadingMoreChats = ref(false) // 加载更多对话中
|
||||
|
||||
// 本地 UI 状态(仅在本组件使用)
|
||||
const localUIState = reactive({
|
||||
@ -588,8 +593,10 @@ const fetchThreads = async (agentId = null) => {
|
||||
|
||||
chatUIStore.isLoadingThreads = true
|
||||
try {
|
||||
const fetchedThreads = await threadApi.getThreads(targetAgentId)
|
||||
const fetchedThreads = await threadApi.getThreads(targetAgentId, 100, 0)
|
||||
threads.value = fetchedThreads || []
|
||||
// 如果返回的数量小于limit,说明没有更多了
|
||||
hasMoreChats.value = fetchedThreads && fetchedThreads.length >= 100
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch threads:', error)
|
||||
handleChatError(error, 'fetch')
|
||||
@ -599,6 +606,31 @@ const fetchThreads = async (agentId = null) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 加载更多对话
|
||||
const loadMoreChats = async () => {
|
||||
if (isLoadingMoreChats.value || !hasMoreChats.value) return
|
||||
|
||||
const targetAgentId = currentAgentId.value
|
||||
if (!targetAgentId) return
|
||||
|
||||
isLoadingMoreChats.value = true
|
||||
try {
|
||||
const offset = threads.value.length
|
||||
const fetchedThreads = await threadApi.getThreads(targetAgentId, 100, offset)
|
||||
if (fetchedThreads && fetchedThreads.length > 0) {
|
||||
threads.value = [...threads.value, ...fetchedThreads]
|
||||
hasMoreChats.value = fetchedThreads.length >= 100
|
||||
} else {
|
||||
hasMoreChats.value = false
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load more chats:', error)
|
||||
handleChatError(error, 'fetch')
|
||||
} finally {
|
||||
isLoadingMoreChats.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 创建新线程
|
||||
const createThread = async (agentId, title = '新的对话') => {
|
||||
if (!agentId) return null
|
||||
@ -1234,10 +1266,6 @@ const createNewChat = async () => {
|
||||
// 如果第一个对话为空,直接切换到第一个对话而不是创建新对话
|
||||
if (await switchToFirstChatIfEmpty()) return
|
||||
|
||||
// 只有当当前对话是第一个对话且为空时,才阻止创建新对话
|
||||
const currentThreadIndex = threads.value.findIndex((thread) => thread.id === currentChatId.value)
|
||||
if (currentChatId.value && conversations.value.length === 0 && currentThreadIndex === 0) return
|
||||
|
||||
chatUIStore.creatingNewChat = true
|
||||
try {
|
||||
const newThread = await createThread(currentAgentId.value, '新的对话')
|
||||
|
||||
@ -29,54 +29,61 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="conversation-list">
|
||||
<template v-if="Object.keys(groupedChats).length > 0">
|
||||
<div v-for="(group, groupName) in groupedChats" :key="groupName" class="chat-group">
|
||||
<div class="chat-group-title">{{ groupName }}</div>
|
||||
<div
|
||||
v-for="chat in group"
|
||||
:key="chat.id"
|
||||
class="conversation-item"
|
||||
:class="{ active: currentChatId === chat.id }"
|
||||
@click="selectChat(chat)"
|
||||
>
|
||||
<div class="conversation-title">{{ chat.title || '新的对话' }}</div>
|
||||
<div class="actions-mask"></div>
|
||||
<div class="conversation-actions">
|
||||
<a-dropdown :trigger="['click']" @click.stop>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item
|
||||
key="rename"
|
||||
@click.stop="renameChat(chat.id)"
|
||||
:icon="h(EditOutlined)"
|
||||
>
|
||||
重命名
|
||||
</a-menu-item>
|
||||
<a-menu-item
|
||||
key="delete"
|
||||
@click.stop="deleteChat(chat.id)"
|
||||
:icon="h(DeleteOutlined)"
|
||||
>
|
||||
删除
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
<a-button type="text" class="more-btn" @click.stop>
|
||||
<MoreOutlined />
|
||||
</a-button>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
<template v-if="sortedChats.length > 0">
|
||||
<div
|
||||
v-for="chat in sortedChats"
|
||||
:key="chat.id"
|
||||
class="conversation-item"
|
||||
:class="{ active: currentChatId === chat.id }"
|
||||
@click="selectChat(chat)"
|
||||
>
|
||||
<div class="conversation-title">{{ chat.title || '新的对话' }}</div>
|
||||
<div class="actions-mask"></div>
|
||||
<div class="conversation-actions">
|
||||
<a-dropdown :trigger="['click']" @click.stop>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item
|
||||
key="rename"
|
||||
@click.stop="renameChat(chat.id)"
|
||||
:icon="h(EditOutlined)"
|
||||
>
|
||||
重命名
|
||||
</a-menu-item>
|
||||
<a-menu-item
|
||||
key="delete"
|
||||
@click.stop="deleteChat(chat.id)"
|
||||
:icon="h(DeleteOutlined)"
|
||||
>
|
||||
删除
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
<a-button type="text" class="more-btn" @click.stop>
|
||||
<MoreOutlined />
|
||||
</a-button>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="empty-list">暂无对话历史</div>
|
||||
<div v-if="hasMoreChats" class="load-more-wrapper">
|
||||
<a-button
|
||||
type="text"
|
||||
class="load-more-btn"
|
||||
:loading="isLoadingMore"
|
||||
@click="handleLoadMore"
|
||||
>
|
||||
{{ isLoadingMore ? '加载中...' : '加载更多' }}
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, h } from 'vue'
|
||||
import { computed, h, ref } from 'vue'
|
||||
import { DeleteOutlined, EditOutlined, MoreOutlined } from '@ant-design/icons-vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import { PanelLeftClose, MessageSquarePlus, LoaderCircle } from 'lucide-vue-next'
|
||||
@ -119,6 +126,14 @@ const props = defineProps({
|
||||
selectedAgentId: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
hasMoreChats: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
isLoadingMore: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
@ -128,59 +143,18 @@ const emit = defineEmits([
|
||||
'delete-chat',
|
||||
'rename-chat',
|
||||
'toggle-sidebar',
|
||||
'open-agent-modal'
|
||||
'open-agent-modal',
|
||||
'load-more-chats'
|
||||
])
|
||||
|
||||
const groupedChats = computed(() => {
|
||||
const groups = {
|
||||
今天: [],
|
||||
七天内: [],
|
||||
三十天内: []
|
||||
}
|
||||
|
||||
// 确保使用北京时间进行比较
|
||||
const now = dayjs().tz('Asia/Shanghai')
|
||||
const today = now.startOf('day')
|
||||
const sevenDaysAgo = now.subtract(7, 'day').startOf('day')
|
||||
const thirtyDaysAgo = now.subtract(30, 'day').startOf('day')
|
||||
|
||||
// Sort chats by creation date, newest first
|
||||
const sortedChats = [...props.chatsList].sort((a, b) => {
|
||||
// 按时间倒序排列的对话列表
|
||||
const sortedChats = computed(() => {
|
||||
return [...props.chatsList].sort((a, b) => {
|
||||
const dateA = parseToShanghai(b.created_at)
|
||||
const dateB = parseToShanghai(a.created_at)
|
||||
if (!dateA || !dateB) return 0
|
||||
return dateA.diff(dateB)
|
||||
})
|
||||
|
||||
sortedChats.forEach((chat) => {
|
||||
// 将后端时间当作UTC时间处理,然后转换为北京时间
|
||||
const chatDate = parseToShanghai(chat.created_at)
|
||||
if (!chatDate) {
|
||||
return
|
||||
}
|
||||
if (chatDate.isAfter(today)) {
|
||||
groups['今天'].push(chat)
|
||||
} else if (chatDate.isAfter(sevenDaysAgo)) {
|
||||
groups['七天内'].push(chat)
|
||||
} else if (chatDate.isAfter(thirtyDaysAgo)) {
|
||||
groups['三十天内'].push(chat)
|
||||
} else {
|
||||
const monthKey = chatDate.format('YYYY-MM')
|
||||
if (!groups[monthKey]) {
|
||||
groups[monthKey] = []
|
||||
}
|
||||
groups[monthKey].push(chat)
|
||||
}
|
||||
})
|
||||
|
||||
// Remove empty groups
|
||||
for (const key in groups) {
|
||||
if (groups[key].length === 0) {
|
||||
delete groups[key]
|
||||
}
|
||||
}
|
||||
|
||||
return groups
|
||||
})
|
||||
|
||||
const createNewChat = () => {
|
||||
@ -237,6 +211,10 @@ const renameChat = async (chatId) => {
|
||||
const toggleCollapse = () => {
|
||||
emit('toggle-sidebar')
|
||||
}
|
||||
|
||||
const handleLoadMore = () => {
|
||||
emit('load-more-chats')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
@ -453,6 +431,19 @@ const toggleCollapse = () => {
|
||||
color: var(--gray-500);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.load-more-wrapper {
|
||||
text-align: center;
|
||||
padding: 12px 8px;
|
||||
|
||||
.load-more-btn {
|
||||
color: var(--main-color);
|
||||
font-size: 13px;
|
||||
&:hover {
|
||||
color: var(--main-600);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -139,7 +139,6 @@ import {
|
||||
ref,
|
||||
reactive,
|
||||
computed,
|
||||
defineModel,
|
||||
onMounted,
|
||||
onActivated,
|
||||
onUnmounted,
|
||||
|
||||
@ -42,12 +42,13 @@ const coerceDayjs = (value) => {
|
||||
}
|
||||
|
||||
// 解析 ISO 字符串(dayjs 会自动识别时区信息,如 Z 后缀表示 UTC)
|
||||
// 需要先转换为 UTC 再设置时区,否则 .tz() 只会改变显示而不会正确转换
|
||||
const parsed = dayjs(stringValue)
|
||||
if (!parsed.isValid()) {
|
||||
return null
|
||||
}
|
||||
// 转换为上海时区
|
||||
return parsed.tz(DEFAULT_TZ)
|
||||
// 先转换为 UTC(保留原始时间值),再转换到上海时区
|
||||
return parsed.utc().tz(DEFAULT_TZ)
|
||||
}
|
||||
|
||||
export const parseToShanghai = (value) => coerceDayjs(value)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user