feat: 新增对话置顶功能,优化对话列表排序和更新逻辑
This commit is contained in:
parent
230f69bf16
commit
b420f7f69c
@ -661,6 +661,7 @@ class ThreadResponse(BaseModel):
|
||||
user_id: str
|
||||
agent_id: str
|
||||
title: str | None = None
|
||||
is_pinned: bool = False
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
@ -728,6 +729,7 @@ async def delete_thread(
|
||||
|
||||
class ThreadUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
is_pinned: bool | None = None
|
||||
|
||||
|
||||
@chat.put("/thread/{thread_id}", response_model=ThreadResponse)
|
||||
@ -741,6 +743,7 @@ async def update_thread(
|
||||
return await update_thread_view(
|
||||
thread_id=thread_id,
|
||||
title=thread_update.title,
|
||||
is_pinned=thread_update.is_pinned,
|
||||
db=db,
|
||||
current_user_id=str(current_user.id),
|
||||
)
|
||||
|
||||
@ -203,20 +203,56 @@ class ConversationRepository:
|
||||
limit: int | None = None,
|
||||
offset: int = 0,
|
||||
) -> list[Conversation]:
|
||||
query = select(Conversation).where(Conversation.status == status)
|
||||
"""List conversations with pinned conversations always included first.
|
||||
|
||||
The limit applies only to non-pinned conversations to ensure pinned
|
||||
conversations are always visible in the list.
|
||||
"""
|
||||
from sqlalchemy import or_
|
||||
|
||||
base_conditions = [Conversation.status == status]
|
||||
if user_id:
|
||||
query = query.where(Conversation.user_id == str(user_id))
|
||||
base_conditions.append(Conversation.user_id == str(user_id))
|
||||
if agent_id:
|
||||
query = query.where(Conversation.agent_id == agent_id)
|
||||
base_conditions.append(Conversation.agent_id == agent_id)
|
||||
|
||||
query = query.order_by(Conversation.updated_at.desc())
|
||||
# First, get all pinned conversations (no limit)
|
||||
pinned_query = (
|
||||
select(Conversation)
|
||||
.where(*base_conditions)
|
||||
.where(Conversation.is_pinned == True)
|
||||
.order_by(Conversation.updated_at.desc())
|
||||
)
|
||||
result = await self.db.execute(pinned_query)
|
||||
pinned_conversations = list(result.scalars().all())
|
||||
|
||||
if limit:
|
||||
query = query.limit(limit).offset(offset)
|
||||
# Then, get non-pinned conversations with limit/offset
|
||||
remaining_limit = None
|
||||
remaining_offset = offset
|
||||
|
||||
result = await self.db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
if limit is not None:
|
||||
# Calculate how many slots are taken by pinned conversations
|
||||
pinned_count = len(pinned_conversations)
|
||||
if pinned_count >= limit:
|
||||
# All slots taken by pinned conversations
|
||||
return pinned_conversations[:limit]
|
||||
remaining_limit = limit - pinned_count
|
||||
|
||||
if remaining_limit is not None and remaining_limit > 0:
|
||||
non_pinned_query = (
|
||||
select(Conversation)
|
||||
.where(*base_conditions)
|
||||
.where(Conversation.is_pinned == False)
|
||||
.order_by(Conversation.updated_at.desc())
|
||||
.limit(remaining_limit)
|
||||
.offset(remaining_offset)
|
||||
)
|
||||
result = await self.db.execute(non_pinned_query)
|
||||
non_pinned_conversations = list(result.scalars().all())
|
||||
else:
|
||||
non_pinned_conversations = []
|
||||
|
||||
return pinned_conversations + non_pinned_conversations
|
||||
|
||||
async def update_conversation(
|
||||
self,
|
||||
@ -224,6 +260,7 @@ class ConversationRepository:
|
||||
title: str | None = None,
|
||||
status: str | None = None,
|
||||
metadata: dict | None = None,
|
||||
is_pinned: bool | None = None,
|
||||
) -> Conversation | None:
|
||||
conversation = await self.get_conversation_by_thread_id(thread_id)
|
||||
if not conversation:
|
||||
@ -234,6 +271,8 @@ class ConversationRepository:
|
||||
conversation.title = normalized_title
|
||||
if status is not None:
|
||||
conversation.status = status
|
||||
if is_pinned is not None:
|
||||
conversation.is_pinned = is_pinned
|
||||
|
||||
if metadata is not None:
|
||||
current_metadata = conversation.extra_metadata or {}
|
||||
|
||||
@ -177,6 +177,7 @@ async def list_threads_view(
|
||||
"user_id": conv.user_id,
|
||||
"agent_id": conv.agent_id,
|
||||
"title": conv.title,
|
||||
"is_pinned": bool(conv.is_pinned),
|
||||
"created_at": conv.created_at.isoformat(),
|
||||
"updated_at": conv.updated_at.isoformat(),
|
||||
}
|
||||
@ -201,13 +202,14 @@ async def delete_thread_view(
|
||||
async def update_thread_view(
|
||||
*,
|
||||
thread_id: str,
|
||||
title: str | None,
|
||||
title: str | None = None,
|
||||
is_pinned: bool | None = None,
|
||||
db: AsyncSession,
|
||||
current_user_id: str,
|
||||
) -> dict:
|
||||
conv_repo = ConversationRepository(db)
|
||||
await require_user_conversation(conv_repo, thread_id, str(current_user_id))
|
||||
updated_conv = await conv_repo.update_conversation(thread_id, title=title)
|
||||
updated_conv = await conv_repo.update_conversation(thread_id, title=title, is_pinned=is_pinned)
|
||||
if not updated_conv:
|
||||
raise HTTPException(status_code=500, detail="更新失败")
|
||||
return {
|
||||
@ -215,6 +217,7 @@ async def update_thread_view(
|
||||
"user_id": updated_conv.user_id,
|
||||
"agent_id": updated_conv.agent_id,
|
||||
"title": updated_conv.title,
|
||||
"is_pinned": bool(updated_conv.is_pinned),
|
||||
"created_at": updated_conv.created_at.isoformat(),
|
||||
"updated_at": updated_conv.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
@ -217,6 +217,7 @@ class Conversation(Base):
|
||||
agent_id = Column(String(64), index=True, nullable=False, comment="Agent ID")
|
||||
title = Column(String(255), nullable=True, comment="Conversation title")
|
||||
status = Column(String(20), default="active", comment="Status: active/archived/deleted")
|
||||
is_pinned = Column(Boolean, default=False, nullable=False, index=True, comment="Is pinned to top")
|
||||
created_at = Column(DateTime, default=utc_now_naive, comment="Creation time")
|
||||
updated_at = Column(DateTime, default=utc_now_naive, onupdate=utc_now_naive, comment="Update time")
|
||||
extra_metadata = Column(JSON, nullable=True, comment="Additional metadata")
|
||||
@ -235,6 +236,7 @@ class Conversation(Base):
|
||||
"agent_id": self.agent_id,
|
||||
"title": self.title,
|
||||
"status": self.status,
|
||||
"is_pinned": bool(self.is_pinned),
|
||||
"created_at": format_utc_datetime(self.created_at),
|
||||
"updated_at": format_utc_datetime(self.updated_at),
|
||||
"metadata": self.extra_metadata or {},
|
||||
|
||||
@ -313,13 +313,13 @@ export const threadApi = {
|
||||
* 更新对话线程
|
||||
* @param {string} threadId - 对话线程ID
|
||||
* @param {string} title - 对话标题
|
||||
* @param {string} description - 对话描述
|
||||
* @param {boolean} is_pinned - 是否置顶
|
||||
* @returns {Promise} - 更新结果
|
||||
*/
|
||||
updateThread: (threadId, title, description) =>
|
||||
updateThread: (threadId, title, is_pinned) =>
|
||||
apiPut(`/api/chat/thread/${threadId}`, {
|
||||
title,
|
||||
description
|
||||
is_pinned
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
@select-chat="selectChat"
|
||||
@delete-chat="deleteChat"
|
||||
@rename-chat="renameChat"
|
||||
@toggle-pin="togglePinChat"
|
||||
@toggle-sidebar="toggleSidebar"
|
||||
@open-agent-modal="openAgentModal"
|
||||
@load-more-chats="loadMoreChats"
|
||||
@ -618,8 +619,12 @@ const loadMoreChats = async () => {
|
||||
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
|
||||
// 去除重复的置顶对话(后端每次都返回所有置顶对话)
|
||||
const existingIds = new Set(threads.value.map((t) => t.id))
|
||||
const newThreads = fetchedThreads.filter((t) => !existingIds.has(t.id))
|
||||
|
||||
threads.value = [...threads.value, ...newThreads]
|
||||
hasMoreChats.value = newThreads.length >= 100
|
||||
} else {
|
||||
hasMoreChats.value = false
|
||||
}
|
||||
@ -675,25 +680,43 @@ const deleteThread = async (threadId) => {
|
||||
}
|
||||
|
||||
// 更新线程标题
|
||||
const updateThread = async (threadId, title) => {
|
||||
if (!threadId || !title) return
|
||||
const updateThread = async (threadId, title, is_pinned) => {
|
||||
if (!threadId) return
|
||||
|
||||
const normalizedTitle = String(title).replace(/\s+/g, ' ').trim().slice(0, 255)
|
||||
if (!normalizedTitle) return
|
||||
if (title) {
|
||||
const normalizedTitle = String(title).replace(/\s+/g, ' ').trim().slice(0, 255)
|
||||
if (!normalizedTitle) return
|
||||
|
||||
chatState.isRenamingThread = true
|
||||
try {
|
||||
await threadApi.updateThread(threadId, normalizedTitle)
|
||||
const thread = threads.value.find((t) => t.id === threadId)
|
||||
if (thread) {
|
||||
thread.title = normalizedTitle
|
||||
chatState.isRenamingThread = true
|
||||
try {
|
||||
await threadApi.updateThread(threadId, normalizedTitle, is_pinned)
|
||||
const thread = threads.value.find((t) => t.id === threadId)
|
||||
if (thread) {
|
||||
thread.title = normalizedTitle
|
||||
if (is_pinned !== undefined) {
|
||||
thread.is_pinned = is_pinned
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update thread:', error)
|
||||
handleChatError(error, 'update')
|
||||
throw error
|
||||
} finally {
|
||||
chatState.isRenamingThread = false
|
||||
}
|
||||
} else if (is_pinned !== undefined) {
|
||||
// 只更新置顶状态
|
||||
try {
|
||||
await threadApi.updateThread(threadId, null, is_pinned)
|
||||
const thread = threads.value.find((t) => t.id === threadId)
|
||||
if (thread) {
|
||||
thread.is_pinned = is_pinned
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update thread pin status:', error)
|
||||
handleChatError(error, 'update')
|
||||
throw error
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update thread:', error)
|
||||
handleChatError(error, 'update')
|
||||
throw error
|
||||
} finally {
|
||||
chatState.isRenamingThread = false
|
||||
}
|
||||
}
|
||||
|
||||
@ -1372,6 +1395,27 @@ const renameChat = async (data) => {
|
||||
}
|
||||
}
|
||||
|
||||
const togglePinChat = async (chatId) => {
|
||||
const chat = chatsList.value.find((c) => c.id === chatId)
|
||||
if (!chat) return
|
||||
try {
|
||||
// 保存当前选中的对话ID
|
||||
const prevChatId = currentChatId.value
|
||||
|
||||
await updateThread(chatId, null, !chat.is_pinned)
|
||||
|
||||
// 刷新对话列表
|
||||
await loadChatsList()
|
||||
|
||||
// 恢复当前选中的对话
|
||||
if (prevChatId) {
|
||||
chatState.currentThreadId = prevChatId
|
||||
}
|
||||
} catch (error) {
|
||||
handleChatError(error, 'pin')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSendMessage = async ({ image } = {}) => {
|
||||
const text = userInput.value.trim()
|
||||
if ((!text && !image) || !currentAgent.value || isProcessing.value) return
|
||||
|
||||
@ -85,7 +85,9 @@
|
||||
<!-- 文件内容 Modal -->
|
||||
<a-modal
|
||||
v-model:open="modalVisible"
|
||||
width="80%"
|
||||
width="800px"
|
||||
:style="{ maxWidth: '90vw', top: '5vh' }"
|
||||
:bodyStyle="{ maxHeight: '90vh', overflow: 'auto' }"
|
||||
:footer="null"
|
||||
:closable="false"
|
||||
@cancel="closeModal"
|
||||
|
||||
@ -36,31 +36,42 @@
|
||||
class="conversation-item"
|
||||
:class="{ active: currentChatId === chat.id }"
|
||||
@click="selectChat(chat)"
|
||||
@click.middle="handleMiddleClickDelete(chat.id)"
|
||||
>
|
||||
<div class="conversation-title">{{ chat.title || '新的对话' }}</div>
|
||||
<div class="conversation-title">
|
||||
<Pin v-if="chat.is_pinned" :size="14" class="pin-icon" />
|
||||
<span>{{ chat.title || '新的对话' }}</span>
|
||||
</div>
|
||||
<div class="actions-mask"></div>
|
||||
<div class="conversation-actions">
|
||||
<a-dropdown :trigger="['click']" @click.stop>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item
|
||||
key="pin"
|
||||
@click.stop="togglePin(chat.id)"
|
||||
:icon="h(chat.is_pinned ? PinOff : Pin, { size: 14 })"
|
||||
>
|
||||
{{ chat.is_pinned ? '取消置顶' : '置顶' }}
|
||||
</a-menu-item>
|
||||
<a-menu-item
|
||||
key="rename"
|
||||
@click.stop="renameChat(chat.id)"
|
||||
:icon="h(EditOutlined)"
|
||||
:icon="h(Pencil, { size: 14 })"
|
||||
>
|
||||
重命名
|
||||
</a-menu-item>
|
||||
<a-menu-item
|
||||
key="delete"
|
||||
@click.stop="deleteChat(chat.id)"
|
||||
:icon="h(DeleteOutlined)"
|
||||
:icon="h(Trash2, { size: 14 })"
|
||||
>
|
||||
删除
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
<a-button type="text" class="more-btn" @click.stop>
|
||||
<MoreOutlined />
|
||||
<MoreVertical :size="16" />
|
||||
</a-button>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
@ -84,9 +95,8 @@
|
||||
|
||||
<script setup>
|
||||
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'
|
||||
import { PanelLeftClose, MessageSquarePlus, LoaderCircle, Pin, PinOff, Pencil, Trash2, MoreVertical } from 'lucide-vue-next'
|
||||
import dayjs, { parseToShanghai } from '@/utils/time'
|
||||
import { useChatUIStore } from '@/stores/chatUI'
|
||||
import { useInfoStore } from '@/stores/info'
|
||||
@ -142,14 +152,19 @@ const emit = defineEmits([
|
||||
'select-chat',
|
||||
'delete-chat',
|
||||
'rename-chat',
|
||||
'toggle-pin',
|
||||
'toggle-sidebar',
|
||||
'open-agent-modal',
|
||||
'load-more-chats'
|
||||
])
|
||||
|
||||
// 按时间倒序排列的对话列表
|
||||
// 按置顶和时间倒序排列的对话列表
|
||||
const sortedChats = computed(() => {
|
||||
return [...props.chatsList].sort((a, b) => {
|
||||
// 置顶的排在前面
|
||||
if (a.is_pinned !== b.is_pinned) {
|
||||
return a.is_pinned ? -1 : 1
|
||||
}
|
||||
const dateA = parseToShanghai(b.created_at)
|
||||
const dateB = parseToShanghai(a.created_at)
|
||||
if (!dateA || !dateB) return 0
|
||||
@ -169,6 +184,10 @@ const deleteChat = (chatId) => {
|
||||
emit('delete-chat', chatId)
|
||||
}
|
||||
|
||||
const handleMiddleClickDelete = (chatId) => {
|
||||
emit('delete-chat', chatId)
|
||||
}
|
||||
|
||||
const renameChat = async (chatId) => {
|
||||
try {
|
||||
const chat = props.chatsList.find((c) => c.id === chatId)
|
||||
@ -215,6 +234,10 @@ const toggleCollapse = () => {
|
||||
const handleLoadMore = () => {
|
||||
emit('load-more-chats')
|
||||
}
|
||||
|
||||
const togglePin = (chatId) => {
|
||||
emit('toggle-pin', chatId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
@ -364,6 +387,14 @@ const handleLoadMore = () => {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition: color 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.pin-icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--main-color);
|
||||
}
|
||||
}
|
||||
|
||||
.actions-mask {
|
||||
@ -391,6 +422,9 @@ const handleLoadMore = () => {
|
||||
.more-btn {
|
||||
color: var(--gray-600);
|
||||
background-color: transparent !important;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
&:hover {
|
||||
color: var(--main-500);
|
||||
|
||||
@ -536,9 +536,12 @@ const form = reactive({
|
||||
|
||||
// 计算属性
|
||||
const filteredServers = computed(() => {
|
||||
if (!searchQuery.value) return servers.value
|
||||
const sorted = [...servers.value].sort((a, b) => {
|
||||
return new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
|
||||
})
|
||||
if (!searchQuery.value) return sorted
|
||||
const q = searchQuery.value.toLowerCase()
|
||||
return servers.value.filter(
|
||||
return sorted.filter(
|
||||
(s) => s.name.toLowerCase().includes(q) || (s.description || '').toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
@ -945,10 +948,6 @@ defineExpose({
|
||||
}
|
||||
|
||||
.list-item {
|
||||
&.disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.server-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user