feat(chat): 新增对话线程列表的分页功能,支持limit和offset参数

This commit is contained in:
Wenjie Zhang 2026-03-06 10:14:43 +08:00
parent 1130cabe57
commit 96ce4dbe8a
8 changed files with 137 additions and 97 deletions

View File

@ -706,10 +706,16 @@ async def create_thread(
@chat.get("/threads", response_model=list[ThreadResponse]) @chat.get("/threads", response_model=list[ThreadResponse])
async def list_threads( 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}") @chat.delete("/thread/{thread_id}")

View File

@ -196,7 +196,12 @@ class ConversationRepository:
return await self.get_messages(conversation.id, limit, offset) return await self.get_messages(conversation.id, limit, offset)
async def list_conversations( 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]: ) -> list[Conversation]:
query = select(Conversation).where(Conversation.status == status) query = select(Conversation).where(Conversation.status == status)
@ -206,6 +211,10 @@ class ConversationRepository:
query = query.where(Conversation.agent_id == agent_id) query = query.where(Conversation.agent_id == agent_id)
query = query.order_by(Conversation.updated_at.desc()) query = query.order_by(Conversation.updated_at.desc())
if limit:
query = query.limit(limit).offset(offset)
result = await self.db.execute(query) result = await self.db.execute(query)
return list(result.scalars().all()) return list(result.scalars().all())

View File

@ -156,6 +156,8 @@ async def list_threads_view(
agent_id: str, agent_id: str,
db: AsyncSession, db: AsyncSession,
current_user_id: str, current_user_id: str,
limit: int | None = None,
offset: int = 0,
) -> list[dict]: ) -> list[dict]:
if not agent_id: if not agent_id:
raise HTTPException(status_code=422, detail="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), user_id=str(current_user_id),
agent_id=agent_id, agent_id=agent_id,
status="active", status="active",
limit=limit,
offset=offset,
) )
return [ return [

View File

@ -286,10 +286,12 @@ export const threadApi = {
/** /**
* 获取对话线程列表 * 获取对话线程列表
* @param {string} agentId - 智能体ID * @param {string} agentId - 智能体ID
* @param {number} limit - 返回数量限制默认100
* @param {number} offset - 偏移量默认0
* @returns {Promise} - 对话线程列表 * @returns {Promise} - 对话线程列表
*/ */
getThreads: (agentId) => { getThreads: (agentId, limit = 100, offset = 0) => {
const url = `/api/chat/threads?agent_id=${agentId}` const url = `/api/chat/threads?agent_id=${agentId}&limit=${limit}&offset=${offset}`
return apiGet(url) return apiGet(url)
}, },

View File

@ -9,12 +9,15 @@
:agents="agents" :agents="agents"
:selected-agent-id="currentAgentId" :selected-agent-id="currentAgentId"
:is-creating-new-chat="chatUIStore.creatingNewChat" :is-creating-new-chat="chatUIStore.creatingNewChat"
:has-more-chats="hasMoreChats"
:is-loading-more="isLoadingMoreChats"
@create-chat="createNewChat" @create-chat="createNewChat"
@select-chat="selectChat" @select-chat="selectChat"
@delete-chat="deleteChat" @delete-chat="deleteChat"
@rename-chat="renameChat" @rename-chat="renameChat"
@toggle-sidebar="toggleSidebar" @toggle-sidebar="toggleSidebar"
@open-agent-modal="openAgentModal" @open-agent-modal="openAgentModal"
@load-more-chats="loadMoreChats"
:class="{ :class="{
'sidebar-open': chatUIStore.isSidebarOpen, 'sidebar-open': chatUIStore.isSidebarOpen,
'no-transition': localUIState.isInitialRender 'no-transition': localUIState.isInitialRender
@ -286,6 +289,8 @@ const chatState = reactive({
// 线 // 线
const threads = ref([]) const threads = ref([])
const threadMessages = ref({}) const threadMessages = ref({})
const hasMoreChats = ref(true) //
const isLoadingMoreChats = ref(false) //
// UI 使 // UI 使
const localUIState = reactive({ const localUIState = reactive({
@ -588,8 +593,10 @@ const fetchThreads = async (agentId = null) => {
chatUIStore.isLoadingThreads = true chatUIStore.isLoadingThreads = true
try { try {
const fetchedThreads = await threadApi.getThreads(targetAgentId) const fetchedThreads = await threadApi.getThreads(targetAgentId, 100, 0)
threads.value = fetchedThreads || [] threads.value = fetchedThreads || []
// limit
hasMoreChats.value = fetchedThreads && fetchedThreads.length >= 100
} catch (error) { } catch (error) {
console.error('Failed to fetch threads:', error) console.error('Failed to fetch threads:', error)
handleChatError(error, 'fetch') 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 = '新的对话') => { const createThread = async (agentId, title = '新的对话') => {
if (!agentId) return null if (!agentId) return null
@ -1234,10 +1266,6 @@ const createNewChat = async () => {
// //
if (await switchToFirstChatIfEmpty()) return 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 chatUIStore.creatingNewChat = true
try { try {
const newThread = await createThread(currentAgentId.value, '新的对话') const newThread = await createThread(currentAgentId.value, '新的对话')

View File

@ -29,54 +29,61 @@
</button> </button>
</div> </div>
<div class="conversation-list"> <div class="conversation-list">
<template v-if="Object.keys(groupedChats).length > 0"> <template v-if="sortedChats.length > 0">
<div v-for="(group, groupName) in groupedChats" :key="groupName" class="chat-group"> <div
<div class="chat-group-title">{{ groupName }}</div> v-for="chat in sortedChats"
<div :key="chat.id"
v-for="chat in group" class="conversation-item"
:key="chat.id" :class="{ active: currentChatId === chat.id }"
class="conversation-item" @click="selectChat(chat)"
:class="{ active: currentChatId === chat.id }" >
@click="selectChat(chat)" <div class="conversation-title">{{ chat.title || '新的对话' }}</div>
> <div class="actions-mask"></div>
<div class="conversation-title">{{ chat.title || '新的对话' }}</div> <div class="conversation-actions">
<div class="actions-mask"></div> <a-dropdown :trigger="['click']" @click.stop>
<div class="conversation-actions"> <template #overlay>
<a-dropdown :trigger="['click']" @click.stop> <a-menu>
<template #overlay> <a-menu-item
<a-menu> key="rename"
<a-menu-item @click.stop="renameChat(chat.id)"
key="rename" :icon="h(EditOutlined)"
@click.stop="renameChat(chat.id)" >
:icon="h(EditOutlined)" 重命名
> </a-menu-item>
重命名 <a-menu-item
</a-menu-item> key="delete"
<a-menu-item @click.stop="deleteChat(chat.id)"
key="delete" :icon="h(DeleteOutlined)"
@click.stop="deleteChat(chat.id)" >
:icon="h(DeleteOutlined)" 删除
> </a-menu-item>
删除 </a-menu>
</a-menu-item> </template>
</a-menu> <a-button type="text" class="more-btn" @click.stop>
</template> <MoreOutlined />
<a-button type="text" class="more-btn" @click.stop> </a-button>
<MoreOutlined /> </a-dropdown>
</a-button>
</a-dropdown>
</div>
</div> </div>
</div> </div>
</template> </template>
<div v-else class="empty-list">暂无对话历史</div> <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> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed, h } from 'vue' import { computed, h, ref } from 'vue'
import { DeleteOutlined, EditOutlined, MoreOutlined } from '@ant-design/icons-vue' import { DeleteOutlined, EditOutlined, MoreOutlined } from '@ant-design/icons-vue'
import { message, Modal } from 'ant-design-vue' import { message, Modal } from 'ant-design-vue'
import { PanelLeftClose, MessageSquarePlus, LoaderCircle } from 'lucide-vue-next' import { PanelLeftClose, MessageSquarePlus, LoaderCircle } from 'lucide-vue-next'
@ -119,6 +126,14 @@ const props = defineProps({
selectedAgentId: { selectedAgentId: {
type: String, type: String,
default: null default: null
},
hasMoreChats: {
type: Boolean,
default: false
},
isLoadingMore: {
type: Boolean,
default: false
} }
}) })
@ -128,59 +143,18 @@ const emit = defineEmits([
'delete-chat', 'delete-chat',
'rename-chat', 'rename-chat',
'toggle-sidebar', 'toggle-sidebar',
'open-agent-modal' 'open-agent-modal',
'load-more-chats'
]) ])
const groupedChats = computed(() => { //
const groups = { const sortedChats = computed(() => {
今天: [], return [...props.chatsList].sort((a, b) => {
七天内: [],
三十天内: []
}
// 使
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 dateA = parseToShanghai(b.created_at) const dateA = parseToShanghai(b.created_at)
const dateB = parseToShanghai(a.created_at) const dateB = parseToShanghai(a.created_at)
if (!dateA || !dateB) return 0 if (!dateA || !dateB) return 0
return dateA.diff(dateB) 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 = () => { const createNewChat = () => {
@ -237,6 +211,10 @@ const renameChat = async (chatId) => {
const toggleCollapse = () => { const toggleCollapse = () => {
emit('toggle-sidebar') emit('toggle-sidebar')
} }
const handleLoadMore = () => {
emit('load-more-chats')
}
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
@ -453,6 +431,19 @@ const toggleCollapse = () => {
color: var(--gray-500); color: var(--gray-500);
font-size: 14px; 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);
}
}
}
} }
} }

View File

@ -139,7 +139,6 @@ import {
ref, ref,
reactive, reactive,
computed, computed,
defineModel,
onMounted, onMounted,
onActivated, onActivated,
onUnmounted, onUnmounted,

View File

@ -42,12 +42,13 @@ const coerceDayjs = (value) => {
} }
// 解析 ISO 字符串dayjs 会自动识别时区信息,如 Z 后缀表示 UTC // 解析 ISO 字符串dayjs 会自动识别时区信息,如 Z 后缀表示 UTC
// 需要先转换为 UTC 再设置时区,否则 .tz() 只会改变显示而不会正确转换
const parsed = dayjs(stringValue) const parsed = dayjs(stringValue)
if (!parsed.isValid()) { if (!parsed.isValid()) {
return null return null
} }
// 转换为上海时区 // 转换为 UTC保留原始时间值再转换到上海时区
return parsed.tz(DEFAULT_TZ) return parsed.utc().tz(DEFAULT_TZ)
} }
export const parseToShanghai = (value) => coerceDayjs(value) export const parseToShanghai = (value) => coerceDayjs(value)