From b420f7f69c8a97bececd98dc9242917fd89c4ba5 Mon Sep 17 00:00:00 2001 From: Wenjie Zhang Date: Fri, 6 Mar 2026 20:29:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=AF=B9=E8=AF=9D?= =?UTF-8?q?=E7=BD=AE=E9=A1=B6=E5=8A=9F=E8=83=BD=EF=BC=8C=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E5=88=97=E8=A1=A8=E6=8E=92=E5=BA=8F=E5=92=8C?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/chat_router.py | 3 + src/repositories/conversation_repository.py | 55 +++++++++++--- src/services/conversation_service.py | 7 +- src/storage/postgres/models_business.py | 2 + web/src/apis/agent_api.js | 6 +- web/src/components/AgentChatComponent.vue | 80 ++++++++++++++++----- web/src/components/AgentPanel.vue | 4 +- web/src/components/ChatSidebarComponent.vue | 48 +++++++++++-- web/src/components/McpServersComponent.vue | 11 ++- 9 files changed, 171 insertions(+), 45 deletions(-) diff --git a/server/routers/chat_router.py b/server/routers/chat_router.py index 0f29ef43..1187c31c 100644 --- a/server/routers/chat_router.py +++ b/server/routers/chat_router.py @@ -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), ) diff --git a/src/repositories/conversation_repository.py b/src/repositories/conversation_repository.py index 7e0fabcd..7f3a1746 100644 --- a/src/repositories/conversation_repository.py +++ b/src/repositories/conversation_repository.py @@ -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 {} diff --git a/src/services/conversation_service.py b/src/services/conversation_service.py index 7c9743b1..6bd6aa55 100644 --- a/src/services/conversation_service.py +++ b/src/services/conversation_service.py @@ -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(), } diff --git a/src/storage/postgres/models_business.py b/src/storage/postgres/models_business.py index 41651e35..9f9d3c98 100644 --- a/src/storage/postgres/models_business.py +++ b/src/storage/postgres/models_business.py @@ -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 {}, diff --git a/web/src/apis/agent_api.js b/web/src/apis/agent_api.js index 4dcce5d9..6b1c2ce7 100644 --- a/web/src/apis/agent_api.js +++ b/web/src/apis/agent_api.js @@ -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 }), /** diff --git a/web/src/components/AgentChatComponent.vue b/web/src/components/AgentChatComponent.vue index cf2c836b..b0625afe 100644 --- a/web/src/components/AgentChatComponent.vue +++ b/web/src/components/AgentChatComponent.vue @@ -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 diff --git a/web/src/components/AgentPanel.vue b/web/src/components/AgentPanel.vue index 4984c21b..68b77903 100644 --- a/web/src/components/AgentPanel.vue +++ b/web/src/components/AgentPanel.vue @@ -85,7 +85,9 @@ -
{{ chat.title || '新的对话' }}
+
+ + {{ chat.title || '新的对话' }} +
- +
@@ -84,9 +95,8 @@