feat: 新增对话置顶功能,优化对话列表排序和更新逻辑

This commit is contained in:
Wenjie Zhang 2026-03-06 20:29:58 +08:00
parent 230f69bf16
commit b420f7f69c
9 changed files with 171 additions and 45 deletions

View File

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

View File

@ -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 {}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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