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])
|
@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}")
|
||||||
|
|||||||
@ -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())
|
||||||
|
|
||||||
|
|||||||
@ -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 [
|
||||||
|
|||||||
@ -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)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@ -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, '新的对话')
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -139,7 +139,6 @@ import {
|
|||||||
ref,
|
ref,
|
||||||
reactive,
|
reactive,
|
||||||
computed,
|
computed,
|
||||||
defineModel,
|
|
||||||
onMounted,
|
onMounted,
|
||||||
onActivated,
|
onActivated,
|
||||||
onUnmounted,
|
onUnmounted,
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user