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])
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}")

View File

@ -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())

View File

@ -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 [

View File

@ -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)
},

View File

@ -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, '新的对话')

View File

@ -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);
}
}
}
}
}

View File

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

View File

@ -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)