diff --git a/e2e/src/steps/agent/conversation-mgmt.steps.ts b/e2e/src/steps/agent/conversation-mgmt.steps.ts index ed2e6aedfc..347053f00c 100644 --- a/e2e/src/steps/agent/conversation-mgmt.steps.ts +++ b/e2e/src/steps/agent/conversation-mgmt.steps.ts @@ -124,9 +124,41 @@ When('用户点击新建对话按钮', async function (this: CustomWorld) { When('用户点击另一个对话', async function (this: CustomWorld) { console.log(' 📍 Step: 点击另一个对话...'); - // Find topic items in the sidebar + // Check if we're on the home page (has Recent Topics section) + const recentTopicsSection = this.page.locator('text=Recent Topics'); + const isOnHomePage = (await recentTopicsSection.count()) > 0; + console.log(` 📍 Is on home page: ${isOnHomePage}`); + + if (isOnHomePage) { + // Click the second topic card in Recent Topics section + // Cards are wrapped in Link components and contain "Hello! I am a mock AI" text from the mock + const recentTopicCards = this.page.locator('a[href*="topic="]'); + const cardCount = await recentTopicCards.count(); + console.log(` 📍 Found ${cardCount} recent topic cards (by href)`); + + if (cardCount >= 2) { + // Click the second card (different from current topic) + await recentTopicCards.nth(1).click(); + console.log(' ✅ 已点击首页 Recent Topics 中的另一个对话'); + await this.page.waitForTimeout(2000); + return; + } + + // Fallback: try to find by text content + const topicTextCards = this.page.locator('text=Hello! I am a mock AI'); + const textCardCount = await topicTextCards.count(); + console.log(` 📍 Found ${textCardCount} topic cards by text`); + + if (textCardCount >= 2) { + await topicTextCards.nth(1).click(); + console.log(' ✅ 已点击首页 Recent Topics 中的另一个对话 (by text)'); + await this.page.waitForTimeout(2000); + return; + } + } + + // Fallback: try to find topic items in the sidebar // Topics are displayed with star icons (lucide-star) in the left sidebar - // Each topic item has a star icon as part of it const sidebarTopics = this.page.locator('svg.lucide-star').locator('..').locator('..'); let topicCount = await sidebarTopics.count(); console.log(` 📍 Found ${topicCount} topics with star icons`); @@ -505,8 +537,20 @@ Then('应该切换到该对话', async function (this: CustomWorld) { Then('显示该对话的历史消息', async function (this: CustomWorld) { console.log(' 📍 Step: 验证显示历史消息...'); + // Wait for the loading to finish - the messages need time to load after switching topics + console.log(' 📍 等待消息加载...'); + await this.page.waitForTimeout(2000); + + // Wait for the message wrapper to appear (ChatItem component uses message-wrapper class) + const messageSelector = '.message-wrapper'; + try { + await this.page.waitForSelector(messageSelector, { timeout: 10_000 }); + } catch { + console.log(' ⚠️ 等待消息选择器超时,尝试备用选择器...'); + } + // There should be messages in the chat area - const messages = this.page.locator('[class*="message"], [data-role]'); + const messages = this.page.locator(messageSelector); const messageCount = await messages.count(); console.log(` 📍 找到 ${messageCount} 条消息`); diff --git a/packages/builtin-tool-group-agent-builder/src/client/Render/BatchCreateAgents.tsx b/packages/builtin-tool-group-agent-builder/src/client/Render/BatchCreateAgents.tsx index fe9cbbf939..c821b31455 100644 --- a/packages/builtin-tool-group-agent-builder/src/client/Render/BatchCreateAgents.tsx +++ b/packages/builtin-tool-group-agent-builder/src/client/Render/BatchCreateAgents.tsx @@ -7,6 +7,8 @@ import { Users } from 'lucide-react'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; +import ToolTag from '@/features/ToolTag'; + import type { BatchCreateAgentsParams, BatchCreateAgentsState } from '../../types'; const styles = createStaticStyles(({ css, cssVar }) => ({ @@ -58,19 +60,33 @@ interface AgentItemProps { avatar?: string; description?: string; title: string; + tools?: string[]; }; } const AgentItem = memo(({ agent, definition }) => { const avatar = definition?.avatar; const description = definition?.description; + const tools = definition?.tools; return ( - - - + + + {agent.title} {description && {description}} + {tools && tools.length > 0 && ( + + {tools.map((tool) => ( + + ))} + + )} ); diff --git a/packages/builtin-tool-group-agent-builder/src/client/Streaming/BatchCreateAgents/index.tsx b/packages/builtin-tool-group-agent-builder/src/client/Streaming/BatchCreateAgents/index.tsx index 9dde1ed271..671e05dc3d 100644 --- a/packages/builtin-tool-group-agent-builder/src/client/Streaming/BatchCreateAgents/index.tsx +++ b/packages/builtin-tool-group-agent-builder/src/client/Streaming/BatchCreateAgents/index.tsx @@ -2,9 +2,12 @@ import type { BuiltinStreamingProps } from '@lobechat/types'; import { Avatar, Block, Flexbox, Markdown } from '@lobehub/ui'; +import { Divider } from 'antd'; import { createStaticStyles } from 'antd-style'; import { memo } from 'react'; +import ToolTag from '@/features/ToolTag'; + import type { BatchCreateAgentsParams } from '../../../types'; const styles = createStaticStyles(({ css, cssVar }) => ({ @@ -12,9 +15,8 @@ const styles = createStaticStyles(({ css, cssVar }) => ({ overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; - -webkit-line-clamp: 2; + -webkit-line-clamp: 1; - font-size: 12px; line-height: 1.5; color: ${cssVar.colorTextDescription}; text-overflow: ellipsis; @@ -62,12 +64,25 @@ export const BatchCreateAgentsStreaming = memo {agents.map((agent, index) => ( - +
{index + 1}.
- + {agent.title} {agent.description && {agent.description}} + {agent.tools && agent.tools.length > 0 && ( + + {agent.tools.map((tool) => ( + + ))} + + )} + {agent.systemRole && (
diff --git a/packages/builtin-tool-group-agent-builder/src/client/Streaming/UpdateAgentPrompt/index.tsx b/packages/builtin-tool-group-agent-builder/src/client/Streaming/UpdateAgentPrompt/index.tsx index 9de9c5b53c..a736f52376 100644 --- a/packages/builtin-tool-group-agent-builder/src/client/Streaming/UpdateAgentPrompt/index.tsx +++ b/packages/builtin-tool-group-agent-builder/src/client/Streaming/UpdateAgentPrompt/index.tsx @@ -2,28 +2,18 @@ import type { BuiltinStreamingProps } from '@lobechat/types'; import { Block, Markdown } from '@lobehub/ui'; -import { memo, useEffect } from 'react'; - -import { useGroupProfileStore } from '@/store/groupProfile'; +import { memo } from 'react'; import type { UpdateAgentPromptParams } from '../../../types'; export const UpdateAgentPromptStreaming = memo>( ({ args }) => { - const { agentId, prompt } = args || {}; - const setActiveTabId = useGroupProfileStore((s) => s.setActiveTabId); - - // Switch to agent tab when streaming agent prompt - useEffect(() => { - if (agentId) { - setActiveTabId(agentId); - } - }, [agentId, setActiveTabId]); + const { prompt } = args || {}; if (!prompt) return null; return ( - + {prompt} diff --git a/packages/builtin-tool-group-agent-builder/src/client/Streaming/UpdateGroupPrompt/index.tsx b/packages/builtin-tool-group-agent-builder/src/client/Streaming/UpdateGroupPrompt/index.tsx index e9a4a9b516..f981ac35b8 100644 --- a/packages/builtin-tool-group-agent-builder/src/client/Streaming/UpdateGroupPrompt/index.tsx +++ b/packages/builtin-tool-group-agent-builder/src/client/Streaming/UpdateGroupPrompt/index.tsx @@ -21,7 +21,7 @@ export const UpdateGroupPromptStreaming = memo + {prompt} diff --git a/packages/builtin-tool-group-agent-builder/src/systemRole.ts b/packages/builtin-tool-group-agent-builder/src/systemRole.ts index d04d75add4..c2f887606d 100644 --- a/packages/builtin-tool-group-agent-builder/src/systemRole.ts +++ b/packages/builtin-tool-group-agent-builder/src/systemRole.ts @@ -141,29 +141,48 @@ When creating agents (via \`createAgent\` or \`batchCreateAgents\`), you MUST an +**CRITICAL: Follow this execution order strictly when setting up or modifying a group:** + 1. **Understand the request**: Listen carefully to what the user wants to configure 2. **Reference injected context**: Use the \`\` to understand current state - no need to call read APIs -3. **Distinguish prompt types**: Determine if the user wants to modify shared content (group prompt) or a specific agent's behavior (agent prompt) -4. **Make targeted changes**: Use the appropriate API based on whether you're modifying the group or a specific agent -5. **Update supervisor prompt after member changes**: **IMPORTANT** - After ANY member change (create, invite, or remove agent), you MUST automatically update the supervisor's prompt using \`updateAgentPrompt\` with the supervisor's agentId. Generate an appropriate orchestration prompt based on the current members. -6. **Confirm changes**: Report what was changed and the new values + +**Execution Order (MUST follow this sequence):** + +3. **Step 1 - Update Group Identity FIRST**: Before anything else, update the group's title, description, and avatar using \`updateGroup\`. This establishes the group's identity and purpose. + +4. **Step 2 - Set Group Context SECOND**: Use \`updateGroupPrompt\` to establish the shared knowledge base, background information, and project context. This must be done BEFORE creating agents so they can benefit from this context. + +5. **Step 3 - Create/Invite Agents THIRD**: Only after steps 1 and 2 are complete, proceed to create or invite agents using \`createAgent\`, \`batchCreateAgents\`, or \`inviteAgent\`. + +6. **Step 4 - Update Supervisor Prompt**: After ANY member change (create, invite, or remove agent), you MUST automatically update the supervisor's prompt using \`updateAgentPrompt\` with the supervisor's agentId. Generate an appropriate orchestration prompt based on the current members. + +7. **Step 5 - Configure Additional Settings**: Set opening message, opening questions, and other configurations using \`updateGroup\`. + +8. **Confirm changes**: Report what was changed and the new values + +**Why this order matters:** +- Group identity (title/avatar) helps users understand the group's purpose immediately +- Group context provides the foundation that all agents will reference +- Agents created after context is set can leverage that shared knowledge +- Supervisor prompt should reflect the final team composition -1. **Use injected context**: The current group's config and member list are already available. Reference them directly instead of calling read APIs. -2. **Distinguish group vs agent prompts**: +1. **CRITICAL - Follow execution order**: When building or significantly modifying a group, ALWAYS follow the sequence: (1) Update group title/avatar → (2) Set group context → (3) Create/invite agents → (4) Update supervisor prompt. Never create agents before setting the group identity and context. +2. **Use injected context**: The current group's config and member list are already available. Reference them directly instead of calling read APIs. +3. **Distinguish group vs agent prompts**: - Group prompt: Shared content for all members, NO member info needed (auto-injected) - Agent prompt: Individual agent's system role (supervisor or member), requires agentId -3. **Distinguish group vs agent operations**: +4. **Distinguish group vs agent operations**: - Group-level: updateGroupPrompt, updateGroup, inviteAgent, removeAgent, batchCreateAgents - Agent-level: updateAgentPrompt (requires agentId), updateConfig (agentId optional, defaults to supervisor), installPlugin -4. **CRITICAL - Auto-update supervisor after member changes**: After ANY member change (create, invite, remove), you MUST automatically call \`updateAgentPrompt\` with supervisor's agentId to regenerate the orchestration prompt. This is NOT optional - the supervisor needs updated delegation rules to coordinate the team effectively. -5. **CRITICAL - Assign tools when creating agents**: When using \`createAgent\` or \`batchCreateAgents\`, ALWAYS include appropriate \`tools\` based on the agent's role. Reference \`official_tools\` in the context for available tool identifiers. An agent without proper tools cannot perform specialized tasks. -6. **Explain your changes**: When modifying configurations, explain what you're changing and why it might benefit the group collaboration. -7. **Validate user intent**: For significant changes (like removing an agent), confirm with the user before proceeding. -8. **Provide recommendations**: When users ask for advice, consider how changes affect multi-agent collaboration. -9. **Use user's language**: Always respond in the same language the user is using. -10. **Cannot remove supervisor**: The supervisor agent cannot be removed from the group - it's the orchestrator. +5. **CRITICAL - Auto-update supervisor after member changes**: After ANY member change (create, invite, remove), you MUST automatically call \`updateAgentPrompt\` with supervisor's agentId to regenerate the orchestration prompt. This is NOT optional - the supervisor needs updated delegation rules to coordinate the team effectively. +6. **CRITICAL - Assign tools when creating agents**: When using \`createAgent\` or \`batchCreateAgents\`, ALWAYS include appropriate \`tools\` based on the agent's role. Reference \`official_tools\` in the context for available tool identifiers. An agent without proper tools cannot perform specialized tasks. +7. **Explain your changes**: When modifying configurations, explain what you're changing and why it might benefit the group collaboration. +8. **Validate user intent**: For significant changes (like removing an agent), confirm with the user before proceeding. +9. **Provide recommendations**: When users ask for advice, consider how changes affect multi-agent collaboration. +10. **Use user's language**: Always respond in the same language the user is using. +11. **Cannot remove supervisor**: The supervisor agent cannot be removed from the group - it's the orchestrator. @@ -199,113 +218,56 @@ When creating agents (via \`createAgent\` or \`batchCreateAgents\`), you MUST an - -User: "Invite an agent to the group" -Action: -1. Use searchAgent to find available agents, show the results to user -2. Use inviteAgent with the selected agent ID -3. **Then automatically** use updateAgentPrompt with supervisor's agentId to update orchestration prompt with the newly invited agent's delegation rules - + + User: "Help me build a development team" + Action (MUST follow this order): + 1. **First** - updateGroup: { meta: { title: "Development Team", avatar: "👨‍💻" } } + 2. **Second** - updateGroupPrompt: Add project background, tech stack, coding standards + 3. **Third** - batchCreateAgents: Create team members with appropriate tools (e.g., Developer with ["lobe-cloud-sandbox"], Researcher with ["web-crawler"]) + 4. **Fourth** - updateAgentPrompt: Update supervisor with delegation rules + 5. **Finally** - updateGroup: Set openingMessage and openingQuestions + - -User: "Add a developer agent to help with coding" -Action: -1. Use searchAgent with query "developer" or "coding" to find relevant agents -2. Use inviteAgent or createAgent if no suitable agent exists. If creating, include tools: ["lobe-cloud-sandbox"] for code execution -3. **Then automatically** use updateAgentPrompt with supervisor's agentId to update orchestration prompt with the new developer agent's delegation rules - + + User: "Add a developer agent" / "Invite an agent" + Action: + 1. Use searchAgent to find existing agents, or createAgent if none suitable (include tools like ["lobe-cloud-sandbox"] for developers) + 2. Use inviteAgent with the agent ID + 3. **Auto** - updateAgentPrompt with supervisor's agentId to add delegation rules + - -User: "Create a marketing expert for this group" -Action: -1. Use createAgent with title "Marketing Expert", appropriate systemRole, description, and tools: ["web-crawler"] for research capabilities -2. **Then automatically** use updateAgentPrompt with supervisor's agentId to update orchestration prompt, adding delegation rules for marketing-related tasks - + + User: "Remove the coding assistant" + Action: + 1. Find agent ID from \`\` context + 2. Use removeAgent + 3. **Auto** - updateAgentPrompt with supervisor's agentId to remove delegation rules + - -User: "Create 3 expert agents for me" -Action: -1. Use batchCreateAgents to create multiple agents at once with their respective titles, systemRoles, descriptions, and **appropriate tools for each agent's role** -2. **Then automatically** use updateAgentPrompt with supervisor's agentId to generate orchestration prompt that includes delegation rules for all 3 new experts - + + User: "Add project background" / "Update shared knowledge" + Action: Use updateGroupPrompt - this is shared content accessible by ALL members. Do NOT include member info (auto-injected). + - -User: "Create a quant trading team" -Action: -1. Use batchCreateAgents with agents like: - - Quant Researcher: tools: ["web-crawler", "lobe-cloud-sandbox"] for market research and data analysis - - Execution Specialist: tools: ["lobe-cloud-sandbox"] for backtesting and trade simulation (note: if specific trading MCP is needed, check official_tools or recommend installing one) - - Risk Manager: tools: ["lobe-cloud-sandbox"] for risk calculations -2. **Then automatically** use updateAgentPrompt with supervisor's agentId to generate orchestration prompt with delegation rules for quant workflows - + + User: "Change how supervisor coordinates" / "Update the designer's prompt" + Action: + - For supervisor: updateAgentPrompt with supervisor's agentId + - For member: Find agentId from \`\`, then updateAgentPrompt with that agentId + - -User: "Remove the coding assistant from the group" -Action: -1. Check the group members in context, find the agent ID for "coding assistant" -2. Use removeAgent to remove the agent -3. **Then automatically** use updateAgentPrompt with supervisor's agentId to update orchestration prompt, removing the delegation rules for the removed agent - + + User: "Change model to Claude" / "Set welcome message" + Action: + - Model: updateConfig with { config: { model: "claude-sonnet-4-5-20250929", provider: "anthropic" } } + - Welcome/Questions: updateGroup with { config: { openingMessage: "...", openingQuestions: [...] } } + - Tools: searchMarketTools then installPlugin + - -User: "What agents are in this group?" -Action: Reference the \`\` from the injected context and display the list - - - -User: "Add some background information about our project to the group" -Action: Use updateGroupPrompt to add the project context as shared content for all members - - - -User: "Update the group's shared knowledge base" -Action: Use updateGroupPrompt - this is shared content, do NOT include member information (auto-injected) - - - -User: "Change how the supervisor coordinates the team" -Action: Use updateAgentPrompt with the supervisor's agentId to update orchestration logic - - - -User: "Make the supervisor more proactive in assigning tasks" -Action: Use updateAgentPrompt with supervisor's agentId to update coordination strategy - - - -User: "Update the coding assistant's prompt to focus more on Python" -Action: Find the coding assistant's agentId from group_members context, then use updateAgentPrompt with that agentId - - - -User: "Modify the designer agent's prompt" -Action: Find the designer agent's agentId from group_members context, then use updateAgentPrompt with that agentId - - - -User: "Change the supervisor's model to Claude" -Action: Use updateConfig with { config: { model: "claude-sonnet-4-5-20250929", provider: "anthropic" } } for the supervisor agent - - - -User: "What can the supervisor agent do?" -Action: Reference the \`\` config from the context, including model, tools, etc. - - - -User: "Add some new tools to this group" -Action: Use searchMarketTools to find tools, then use installPlugin for the supervisor agent - - - -User: "Set a welcome message for this group" -Action: Use updateGroup with { config: { openingMessage: "Welcome to the team! We're here to help you with your project." } } - - - -User: "Set some opening questions" -Action: Use updateGroup with { config: { openingQuestions: ["What project are you working on?", "How can we help you today?", "Do you have any specific questions?"] } } - + + User: "What agents are in this group?" / "What can the supervisor do?" + Action: Reference the injected \`\` directly (group_members, supervisor_agent, etc.) + diff --git a/src/app/[variants]/(main)/agent/_layout/Sidebar/Topic/List/Item/index.tsx b/src/app/[variants]/(main)/agent/_layout/Sidebar/Topic/List/Item/index.tsx index e781a5cc2e..2b742a7d52 100644 --- a/src/app/[variants]/(main)/agent/_layout/Sidebar/Topic/List/Item/index.tsx +++ b/src/app/[variants]/(main)/agent/_layout/Sidebar/Topic/List/Item/index.tsx @@ -1,8 +1,9 @@ import { ActionIcon, Flexbox, Icon, Skeleton, Tag } from '@lobehub/ui'; import { cssVar } from 'antd-style'; import { MessageSquareDashed, Star } from 'lucide-react'; -import { Suspense, memo, useCallback } from 'react'; +import { Suspense, memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import urlJoin from 'url-join'; import { isDesktop } from '@/const/version'; import NavItem from '@/features/NavPanel/components/NavItem'; @@ -29,6 +30,12 @@ const TopicItem = memo(({ id, title, fav, active, threadId }) => const openTopicInNewWindow = useGlobalStore((s) => s.openTopicInNewWindow); const activeAgentId = useAgentStore((s) => s.activeAgentId); + // Construct href for cmd+click support + const href = useMemo(() => { + if (!activeAgentId || !id) return undefined; + return urlJoin('/chat', `?agent=${activeAgentId}&topic=${id}`); + }, [activeAgentId, id]); + const [editing, isLoading] = useChatStore((s) => [ id ? s.topicRenamingId === id : false, id ? s.topicLoadingIds.includes(id) : false, @@ -97,6 +104,7 @@ const TopicItem = memo(({ id, title, fav, active, threadId }) => active={active && !threadId && !isInAgentSubRoute} contextMenuItems={dropdownMenu} disabled={editing} + href={href} icon={ { const context = useAgentContext(); - const [useFetchUserMemory, setActiveMemoryContext] = useUserMemoryStore((s) => [ - s.useFetchUserMemory, - s.setActiveMemoryContext, - ]); - const [currentAgentMeta, activeTopic] = [ - useAgentStore(agentSelectors.currentAgentMeta), - useChatStore(topicSelectors.currentActiveTopic), - ]; - const enableUserMemories = useUserStore(settingsSelectors.memoryEnabled); - - useEffect(() => { - if (!enableUserMemories) { - setActiveMemoryContext(undefined); - return; - } - - setActiveMemoryContext({ agent: currentAgentMeta, topic: activeTopic }); - }, [activeTopic, currentAgentMeta, enableUserMemories, setActiveMemoryContext]); - - useFetchUserMemory(Boolean(enableUserMemories && context.agentId)); - // Get raw dbMessages from ChatStore for this context // ConversationStore will parse them internally to generate displayMessages const chatKey = useMemo( diff --git a/src/app/[variants]/(main)/community/(detail)/features/MakedownRender.tsx b/src/app/[variants]/(main)/community/(detail)/features/MakedownRender.tsx index c17c1dc9b8..3d83c6f4e3 100644 --- a/src/app/[variants]/(main)/community/(detail)/features/MakedownRender.tsx +++ b/src/app/[variants]/(main)/community/(detail)/features/MakedownRender.tsx @@ -3,7 +3,7 @@ import { Center, Empty, Markdown } from '@lobehub/ui'; import { FileText } from 'lucide-react'; import Link from 'next/link'; -import { memo } from 'react'; +import { type ReactNode, memo } from 'react'; import { useTranslation } from 'react-i18next'; import { H1, H2, H3, H4, H5 } from './Toc/Heading'; @@ -26,7 +26,7 @@ const MarkdownRender = memo<{ children?: string }>(({ children }) => { { + a: ({ href, ...rest }: { children?: ReactNode; href?: string }) => { if (href && href.startsWith('http')) return ; return rest?.children; @@ -36,7 +36,7 @@ const MarkdownRender = memo<{ children?: string }>(({ children }) => { h3: H3, h4: H4, h5: H5, - img: ({ src, ...rest }) => { + img: ({ src, ...rest }: { alt?: string; src?: string | Blob }) => { // FIXME ignore experimental blob image prop passing if (typeof src !== 'string') return null; if (src.includes('glama.ai')) return null; diff --git a/src/app/[variants]/(main)/group/_layout/Sidebar/GroupConfig/GroupMember.tsx b/src/app/[variants]/(main)/group/_layout/Sidebar/GroupConfig/GroupMember.tsx index 92c4b3229d..f0f646bbfe 100644 --- a/src/app/[variants]/(main)/group/_layout/Sidebar/GroupConfig/GroupMember.tsx +++ b/src/app/[variants]/(main)/group/_layout/Sidebar/GroupConfig/GroupMember.tsx @@ -8,6 +8,7 @@ import { useTranslation } from 'react-i18next'; import { DEFAULT_AVATAR } from '@/const/meta'; import NavItem from '@/features/NavPanel/components/NavItem'; import UserAvatar from '@/features/User/UserAvatar'; +import { useQueryRoute } from '@/hooks/useQueryRoute'; import { useAgentGroupStore } from '@/store/agentGroup'; import { agentGroupSelectors } from '@/store/agentGroup/selectors'; import { useChatStore } from '@/store/chat'; @@ -30,6 +31,7 @@ interface GroupMemberProps { */ const GroupMember = memo(({ addModalOpen, onAddModalOpenChange, groupId }) => { const { t } = useTranslation('chat'); + const router = useQueryRoute(); const [nickname, username] = useUserStore((s) => [ userProfileSelectors.nickName(s), userProfileSelectors.username(s), @@ -80,6 +82,11 @@ const GroupMember = memo(({ addModalOpen, onAddModalOpenChange pushPortalView({ agentId, type: PortalViewType.GroupThread }); }; + const handleMemberDoubleClick = (agentId: string) => { + if (!groupId) return; + router.push(`/group/${groupId}/profile`, { query: { tab: agentId }, replace: true }); + }; + return ( <> @@ -93,7 +100,7 @@ const GroupMember = memo(({ addModalOpen, onAddModalOpenChange key={item.id} onChat={() => handleMemberClick(item.id)} > -
+
handleMemberDoubleClick(item.id)}> (() => { - const currentGroup = useAgentGroupStore(agentGroupSelectors.currentGroup); - const agents = currentGroup?.agents || []; - const openChatSettings = useOpenChatSettings(); return ( @@ -30,13 +25,7 @@ const HeaderAvatar = memo<{ size?: number }>(() => { variant={'borderless'} width={32} > - ({ - avatar: agent.avatar, - background: agent.backgroundColor || undefined, - }))} - size={28} - /> + ); }); diff --git a/src/app/[variants]/(main)/group/_layout/Sidebar/Header/Agent/index.tsx b/src/app/[variants]/(main)/group/_layout/Sidebar/Header/Agent/index.tsx index 628ef29d6b..35ac6b2940 100644 --- a/src/app/[variants]/(main)/group/_layout/Sidebar/Header/Agent/index.tsx +++ b/src/app/[variants]/(main)/group/_layout/Sidebar/Header/Agent/index.tsx @@ -5,7 +5,7 @@ import { ChevronsUpDownIcon } from 'lucide-react'; import React, { type PropsWithChildren, memo } from 'react'; import { useTranslation } from 'react-i18next'; -import GroupAvatar from '@/features/GroupAvatar'; +import SupervisorAvatar from '@/app/[variants]/(main)/group/features/GroupAvatar'; import { SkeletonItem } from '@/features/NavPanel/components/SkeletonList'; import { useAgentGroupStore } from '@/store/agentGroup'; import { agentGroupSelectors } from '@/store/agentGroup/selectors'; @@ -15,10 +15,9 @@ import SwitchPanel from './SwitchPanel'; const Agent = memo(() => { const { t } = useTranslation(['chat', 'common']); - const [isGroupsInit, groupMeta, memberAvatars] = useAgentGroupStore((s) => [ + const [isGroupsInit, groupMeta] = useAgentGroupStore((s) => [ agentGroupSelectors.isGroupsInit(s), agentGroupSelectors.currentGroupMeta(s), - agentGroupSelectors.currentGroupMemberAvatars(s), ]); const displayTitle = groupMeta?.title || t('untitledGroup', { ns: 'chat' }); @@ -39,7 +38,7 @@ const Agent = memo(() => { }} variant={'borderless'} > - + {displayTitle} diff --git a/src/app/[variants]/(main)/group/_layout/Sidebar/Topic/List/Item/index.tsx b/src/app/[variants]/(main)/group/_layout/Sidebar/Topic/List/Item/index.tsx index e781a5cc2e..f294bc4ee7 100644 --- a/src/app/[variants]/(main)/group/_layout/Sidebar/Topic/List/Item/index.tsx +++ b/src/app/[variants]/(main)/group/_layout/Sidebar/Topic/List/Item/index.tsx @@ -1,17 +1,18 @@ import { ActionIcon, Flexbox, Icon, Skeleton, Tag } from '@lobehub/ui'; import { cssVar } from 'antd-style'; import { MessageSquareDashed, Star } from 'lucide-react'; -import { Suspense, memo, useCallback } from 'react'; +import { Suspense, memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import urlJoin from 'url-join'; import { isDesktop } from '@/const/version'; import NavItem from '@/features/NavPanel/components/NavItem'; import { useAgentStore } from '@/store/agent'; +import { useAgentGroupStore } from '@/store/agentGroup'; import { useChatStore } from '@/store/chat'; import { useGlobalStore } from '@/store/global'; import ThreadList from '../../TopicListContent/ThreadList'; -import { useTopicNavigation } from '../../hooks/useTopicNavigation'; import Actions from './Actions'; import Editing from './Editing'; import { useTopicItemDropdownMenu } from './useDropdownMenu'; @@ -27,7 +28,15 @@ interface TopicItemProps { const TopicItem = memo(({ id, title, fav, active, threadId }) => { const { t } = useTranslation('topic'); const openTopicInNewWindow = useGlobalStore((s) => s.openTopicInNewWindow); + const toggleMobileTopic = useGlobalStore((s) => s.toggleMobileTopic); const activeAgentId = useAgentStore((s) => s.activeAgentId); + const [activeGroupId, switchTopic] = useAgentGroupStore((s) => [s.activeGroupId, s.switchTopic]); + + // Construct href for cmd+click support + const href = useMemo(() => { + if (!activeGroupId || !id) return undefined; + return urlJoin('/group', activeGroupId, `?topic=${id}`); + }, [activeGroupId, id]); const [editing, isLoading] = useChatStore((s) => [ id ? s.topicRenamingId === id : false, @@ -36,8 +45,6 @@ const TopicItem = memo(({ id, title, fav, active, threadId }) => const [favoriteTopic] = useChatStore((s) => [s.favoriteTopic]); - const { navigateToTopic, isInAgentSubRoute } = useTopicNavigation(); - const toggleEditing = useCallback( (visible?: boolean) => { useChatStore.setState({ topicRenamingId: visible && id ? id : '' }); @@ -47,8 +54,9 @@ const TopicItem = memo(({ id, title, fav, active, threadId }) => const handleClick = useCallback(() => { if (editing) return; - navigateToTopic(id); - }, [editing, id, navigateToTopic]); + switchTopic(id); + toggleMobileTopic(false); + }, [editing, id, switchTopic, toggleMobileTopic]); const handleDoubleClick = useCallback(() => { if (!id || !activeAgentId) return; @@ -66,7 +74,7 @@ const TopicItem = memo(({ id, title, fav, active, threadId }) => if (!id) { return ( } @@ -94,9 +102,10 @@ const TopicItem = memo(({ id, title, fav, active, threadId }) => } - active={active && !threadId && !isInAgentSubRoute} + active={active && !threadId} contextMenuItems={dropdownMenu} disabled={editing} + href={!editing ? href : undefined} icon={ { - const pathname = usePathname(); - const activeGroupId = useAgentGroupStore((s) => s.activeGroupId); - const router = useQueryRoute(); - const toggleConfig = useGlobalStore((s) => s.toggleMobileTopic); - const switchTopic = useChatStore((s) => s.switchTopic); - - const isInAgentSubRoute = useCallback(() => { - if (!activeGroupId) return false; - const agentBasePath = `/group/${activeGroupId}`; - // If pathname has more segments after /agent/:aid, it's a sub-route - return ( - pathname.startsWith(agentBasePath) && - pathname !== agentBasePath && - pathname !== `${agentBasePath}/` - ); - }, [pathname, activeGroupId]); - - const navigateToTopic = useCallback( - (topicId?: string) => { - // If in agent sub-route, navigate back to agent chat first - if (isInAgentSubRoute() && activeGroupId) { - router.push(urlJoin('/group', activeGroupId as string)); - } - - switchTopic(topicId); - toggleConfig(false); - }, - [activeGroupId, router, switchTopic, toggleConfig, isInAgentSubRoute], - ); - - return { - isInAgentSubRoute: isInAgentSubRoute(), - navigateToTopic, - }; -}; diff --git a/src/app/[variants]/(main)/group/features/Conversation/ConversationArea.tsx b/src/app/[variants]/(main)/group/features/Conversation/ConversationArea.tsx index 9ae08f09d5..5e3c6b5fd5 100644 --- a/src/app/[variants]/(main)/group/features/Conversation/ConversationArea.tsx +++ b/src/app/[variants]/(main)/group/features/Conversation/ConversationArea.tsx @@ -1,20 +1,14 @@ 'use client'; import { Flexbox } from '@lobehub/ui'; -import { Suspense, memo, useEffect, useMemo } from 'react'; +import { Suspense, memo, useMemo } from 'react'; import ChatMiniMap from '@/features/ChatMiniMap'; import { ChatList, ConversationProvider } from '@/features/Conversation'; import ZenModeToast from '@/features/ZenModeToast'; import { useOperationState } from '@/hooks/useOperationState'; -import { useAgentStore } from '@/store/agent'; -import { agentSelectors } from '@/store/agent/selectors'; import { useChatStore } from '@/store/chat'; -import { topicSelectors } from '@/store/chat/selectors'; import { messageMapKey } from '@/store/chat/utils/messageMapKey'; -import { useUserStore } from '@/store/user'; -import { settingsSelectors } from '@/store/user/selectors'; -import { useUserMemoryStore } from '@/store/userMemory'; import WelcomeChatItem from './AgentWelcome'; import ChatHydration from './ChatHydration'; @@ -39,28 +33,6 @@ interface ConversationAreaProps { const Conversation = memo(({ mobile = false }) => { const context = useGroupContext(); - const [useFetchUserMemory, setActiveMemoryContext] = useUserMemoryStore((s) => [ - s.useFetchUserMemory, - s.setActiveMemoryContext, - ]); - const [currentAgentMeta, activeTopic] = [ - useAgentStore(agentSelectors.currentAgentMeta), - useChatStore(topicSelectors.currentActiveTopic), - ]; - - const enableUserMemories = useUserStore(settingsSelectors.memoryEnabled); - - useEffect(() => { - if (!enableUserMemories) { - setActiveMemoryContext(undefined); - return; - } - - setActiveMemoryContext({ agent: currentAgentMeta, topic: activeTopic }); - }, [activeTopic, currentAgentMeta, enableUserMemories, setActiveMemoryContext]); - - useFetchUserMemory(Boolean(enableUserMemories && context.agentId)); - // Get raw dbMessages from ChatStore for this context // ConversationStore will parse them internally to generate displayMessages const chatKey = useMemo( diff --git a/src/app/[variants]/(main)/group/features/Conversation/Header/ShareButton/index.tsx b/src/app/[variants]/(main)/group/features/Conversation/Header/ShareButton/index.tsx index 6c14506e66..7baaf3956e 100644 --- a/src/app/[variants]/(main)/group/features/Conversation/Header/ShareButton/index.tsx +++ b/src/app/[variants]/(main)/group/features/Conversation/Header/ShareButton/index.tsx @@ -11,8 +11,6 @@ import { DESKTOP_HEADER_ICON_SIZE, MOBILE_HEADER_ICON_SIZE } from '@/const/layou import { useWorkspaceModal } from '@/hooks/useWorkspaceModal'; import { useChatStore } from '@/store/chat'; -console.log('ENABLE_TOPIC_LINK_SHARE', ENABLE_TOPIC_LINK_SHARE); - const ShareModal = dynamic(() => import('@/features/ShareModal')); const SharePopover = dynamic(() => import('@/features/SharePopover')); diff --git a/src/app/[variants]/(main)/group/features/GroupAvatar.tsx b/src/app/[variants]/(main)/group/features/GroupAvatar.tsx index b6dc547dc3..1770c6a16c 100644 --- a/src/app/[variants]/(main)/group/features/GroupAvatar.tsx +++ b/src/app/[variants]/(main)/group/features/GroupAvatar.tsx @@ -1,19 +1,27 @@ 'use client'; import isEqual from 'fast-deep-equal'; -import React, { memo } from 'react'; +import { memo } from 'react'; -import GroupAvatar from '@/features/GroupAvatar'; +import AgentGroupAvatar from '@/features/AgentGroupAvatar'; import { useAgentGroupStore } from '@/store/agentGroup'; import { agentGroupSelectors } from '@/store/agentGroup/selectors'; -const SupervisorAvatar = memo<{ size?: number }>(({ size = 28 }) => { - const memberAvatars = useAgentGroupStore( - (s) => agentGroupSelectors.currentGroupMemberAvatars(s), - isEqual, - ); +/** + * Connected AgentGroupAvatar that reads from agentGroup store + */ +const CurrentAgentGroupAvatar = memo<{ size?: number }>(({ size = 28 }) => { + const groupMeta = useAgentGroupStore(agentGroupSelectors.currentGroupMeta, isEqual); + const memberAvatars = useAgentGroupStore(agentGroupSelectors.currentGroupMemberAvatars, isEqual); - return ; + return ( + + ); }); -export default SupervisorAvatar; +export default CurrentAgentGroupAvatar; diff --git a/src/app/[variants]/(main)/group/profile/features/AgentBuilder/TopicSelector.tsx b/src/app/[variants]/(main)/group/profile/features/AgentBuilder/TopicSelector.tsx index b94c846d63..30885469f0 100644 --- a/src/app/[variants]/(main)/group/profile/features/AgentBuilder/TopicSelector.tsx +++ b/src/app/[variants]/(main)/group/profile/features/AgentBuilder/TopicSelector.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'; import { DESKTOP_HEADER_ICON_SIZE } from '@/const/layoutTokens'; import NavHeader from '@/features/NavHeader'; +import { useQueryState } from '@/hooks/useQueryParam'; import { useChatStore } from '@/store/chat'; import { topicSelectors } from '@/store/chat/slices/topic/selectors'; @@ -18,16 +19,18 @@ const TopicSelector = memo(({ agentId }) => { // Fetch topics for the group agent builder useChatStore((s) => s.useFetchTopics)(true, { agentId }); - // Use activeTopicId from chatStore (synced with URL query 'bt' via ProfileHydration) - const [activeTopicId, switchTopic] = useChatStore((s) => [s.activeTopicId, s.switchTopic]); + // Use activeTopicId from chatStore (synced from URL query 'bt' via ProfileHydration) + const activeTopicId = useChatStore((s) => s.activeTopicId); const topics = useChatStore((s) => topicSelectors.getTopicsByAgentId(agentId)(s)); - // Switch topic - ProfileHydration handles URL sync automatically + // Directly update URL query 'bt' to switch topic in profile page + const [, setBuilderTopicId] = useQueryState('bt'); + const handleSwitchTopic = useCallback( (topicId?: string) => { - switchTopic(topicId); + setBuilderTopicId(topicId ?? null); }, - [switchTopic], + [setBuilderTopicId], ); // Find active topic from the agent's topics list directly diff --git a/src/app/[variants]/(main)/group/profile/features/Header/ChromeTabs/index.tsx b/src/app/[variants]/(main)/group/profile/features/Header/ChromeTabs/index.tsx index 7df6582a3d..955375a363 100644 --- a/src/app/[variants]/(main)/group/profile/features/Header/ChromeTabs/index.tsx +++ b/src/app/[variants]/(main)/group/profile/features/Header/ChromeTabs/index.tsx @@ -3,7 +3,7 @@ import { Avatar, Flexbox } from '@lobehub/ui'; import { createStaticStyles, cx } from 'antd-style'; import { Plus } from 'lucide-react'; -import { ReactNode, memo } from 'react'; +import { ReactNode, memo, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; const styles = createStaticStyles(({ css, cssVar: cv }) => ({ @@ -109,15 +109,33 @@ interface ChromeTabsProps { const ChromeTabs = memo(({ items, activeId, onChange, onAdd }) => { const { t } = useTranslation('chat'); + const containerRef = useRef(null); + + useEffect(() => { + if (!containerRef.current || !activeId) return; + + const activeTab = containerRef.current.querySelector(`[data-tab-id="${activeId}"]`); + if (!activeTab) return; + + const containerRect = containerRef.current.getBoundingClientRect(); + const tabRect = activeTab.getBoundingClientRect(); + + const isVisible = tabRect.left >= containerRect.left && tabRect.right <= containerRect.right; + + if (!isVisible) { + activeTab.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }); + } + }, [activeId]); return ( -
+
{items.map((item) => { const isActive = item.id === activeId; return (
onChange(item.id)} > diff --git a/src/app/[variants]/(main)/group/profile/features/MemberProfile/AgentTool.tsx b/src/app/[variants]/(main)/group/profile/features/MemberProfile/AgentTool.tsx index 648a3eeb97..c009817ca4 100644 --- a/src/app/[variants]/(main)/group/profile/features/MemberProfile/AgentTool.tsx +++ b/src/app/[variants]/(main)/group/profile/features/MemberProfile/AgentTool.tsx @@ -5,12 +5,14 @@ import { useGroupProfileStore } from '@/store/groupProfile'; /** * AgentTool for group profile editor - * - Uses default settings (no web browsing, no filterAvailableInWeb, uses metaList) + * - showWebBrowsing: Group member profile supports web browsing toggle + * - filterAvailableInWeb: Filter out desktop-only tools in web version + * - useAllMetaList: Use allMetaList to include hidden tools * - Passes agentId from group profile store to display the correct member's plugins */ const AgentTool = () => { const agentId = useGroupProfileStore((s) => s.activeTabId); - return ; + return ; }; export default AgentTool; diff --git a/src/app/[variants]/(main)/group/profile/features/ProfileHydration.tsx b/src/app/[variants]/(main)/group/profile/features/ProfileHydration.tsx index dd2f16ebad..32344b6aff 100644 --- a/src/app/[variants]/(main)/group/profile/features/ProfileHydration.tsx +++ b/src/app/[variants]/(main)/group/profile/features/ProfileHydration.tsx @@ -2,7 +2,7 @@ import { useEditor, useEditorState } from '@lobehub/editor/react'; import { useUnmount } from 'ahooks'; -import { memo, useEffect, useRef } from 'react'; +import { memo, useEffect } from 'react'; import { createStoreUpdater } from 'zustand-utils'; import { useRegisterFilesHotkeys, useSaveDocumentHotkey } from '@/hooks/useHotkeys'; @@ -25,34 +25,15 @@ const ProfileHydration = memo(() => { const [activeTabId] = useQueryState('tab', parseAsString.withDefault('group')); storeUpdater('activeTabId', activeTabId); - // Bidirectional sync between URL query 'bt' and chatStore.activeTopicId - const [builderTopicId, setBuilderTopicId] = useQueryState('bt'); - const activeTopicId = useChatStore((s) => s.activeTopicId); + // Sync URL query 'bt' → chatStore.activeTopicId (one-way only) + // Store → URL sync is handled directly by TopicSelector using setBuilderTopicId + const [builderTopicId] = useQueryState('bt'); - // Track if the change came from URL to prevent sync loops - const isUrlChangeRef = useRef(false); - - // Sync URL → Store (when URL changes) useEffect(() => { const urlTopicId = builderTopicId ?? undefined; - if (urlTopicId !== activeTopicId) { - isUrlChangeRef.current = true; - useChatStore.setState({ activeTopicId: urlTopicId }); - } + useChatStore.setState({ activeTopicId: urlTopicId }); }, [builderTopicId]); - // Sync Store → URL (when store changes, but not from URL) - useEffect(() => { - if (isUrlChangeRef.current) { - isUrlChangeRef.current = false; - return; - } - const urlTopicId = builderTopicId ?? undefined; - if (activeTopicId !== urlTopicId) { - setBuilderTopicId(activeTopicId ?? null); - } - }, [activeTopicId]); - // Register hotkeys useRegisterFilesHotkeys(); useSaveDocumentHotkey(flushSave); @@ -65,7 +46,6 @@ const ProfileHydration = memo(() => { editorState: undefined, saveStateMap: {}, }); - useChatStore.setState({ activeTopicId: undefined }); }); return null; diff --git a/src/features/AgentGroupAvatar/index.tsx b/src/features/AgentGroupAvatar/index.tsx new file mode 100644 index 0000000000..ca2ff7d8ef --- /dev/null +++ b/src/features/AgentGroupAvatar/index.tsx @@ -0,0 +1,38 @@ +'use client'; + +import { Avatar } from '@lobehub/ui'; +import { memo } from 'react'; + +import GroupAvatar from '@/features/GroupAvatar'; + +export interface AgentGroupAvatarProps { + /** + * Custom avatar for the group (emoji or url) + */ + avatar?: string; + /** + * Background color for custom avatar + */ + backgroundColor?: string; + /** + * Member avatars to display when no custom avatar + */ + memberAvatars?: { avatar?: string; background?: string }[]; + /** + * Avatar size + */ + size?: number; +} + +const AgentGroupAvatar = memo( + ({ avatar, backgroundColor, memberAvatars = [], size = 28 }) => { + // If group has custom avatar, show it; otherwise show member avatars composition + if (avatar) { + return ; + } + + return ; + }, +); + +export default AgentGroupAvatar; diff --git a/src/features/Conversation/Messages/Supervisor/index.tsx b/src/features/Conversation/Messages/Supervisor/index.tsx index 960626973c..eb4f44de27 100644 --- a/src/features/Conversation/Messages/Supervisor/index.tsx +++ b/src/features/Conversation/Messages/Supervisor/index.tsx @@ -6,9 +6,9 @@ import { type MouseEventHandler, memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { MESSAGE_ACTION_BAR_PORTAL_ATTRIBUTES } from '@/const/messageActionPortal'; +import AgentGroupAvatar from '@/features/AgentGroupAvatar'; import { ChatItem } from '@/features/Conversation/ChatItem'; import { useNewScreen } from '@/features/Conversation/Messages/components/useNewScreen'; -import GroupAvatar from '@/features/GroupAvatar'; import { useAgentGroupStore } from '@/store/agentGroup'; import { agentGroupSelectors } from '@/store/agentGroup/selectors'; @@ -90,7 +90,13 @@ const GroupMessage = memo(({ id, index, disableEditing, isLat } avatar={{ ...avatar, title: groupMeta.title }} - customAvatarRender={() => } + customAvatarRender={() => ( + + )} newScreen={newScreen} onMouseEnter={onMouseEnter} placement={'left'} diff --git a/src/features/Conversation/Messages/User/useMarkdown.tsx b/src/features/Conversation/Messages/User/useMarkdown.tsx index bad67c7b09..f2805ce042 100644 --- a/src/features/Conversation/Messages/User/useMarkdown.tsx +++ b/src/features/Conversation/Messages/User/useMarkdown.tsx @@ -30,12 +30,11 @@ export const useMarkdown = (id: string): Partial => { () => ({ components: Object.fromEntries( - // @ts-expect-error markdownElements.map((element) => { const Component = element.Component; return [element.tag, (props: any) => ]; }), - ), + ) as any, customRender: (dom: ReactNode, { text }: { text: string }) => { if (text.length > 30_000) return ; return dom; diff --git a/src/features/NavPanel/components/EmptyNavItem.tsx b/src/features/NavPanel/components/EmptyNavItem.tsx index eb38739285..edd0993df7 100644 --- a/src/features/NavPanel/components/EmptyNavItem.tsx +++ b/src/features/NavPanel/components/EmptyNavItem.tsx @@ -1,4 +1,4 @@ -import { ActionIcon, Block, Center, Text } from '@lobehub/ui'; +import { Block, Center, Icon, Text } from '@lobehub/ui'; import { PlusIcon } from 'lucide-react'; import { memo } from 'react'; @@ -22,7 +22,7 @@ const EmptyNavItem = memo(({ title, onClick, className }) => { variant={'borderless'} >
- +
{title} diff --git a/src/features/NavPanel/components/NavItem.tsx b/src/features/NavPanel/components/NavItem.tsx index 1988595fd5..a7c9ef8332 100644 --- a/src/features/NavPanel/components/NavItem.tsx +++ b/src/features/NavPanel/components/NavItem.tsx @@ -12,9 +12,10 @@ import { Text, } from '@lobehub/ui'; import { createStaticStyles, cssVar, cx } from 'antd-style'; -import { Loader2Icon } from 'lucide-react'; import { type ReactNode, memo } from 'react'; +import NeuralNetworkLoading from '@/components/NeuralNetworkLoading'; + const ACTION_CLASS_NAME = 'nav-item-actions'; const styles = createStaticStyles(({ css }) => ({ @@ -50,6 +51,10 @@ export interface NavItemProps extends Omit { contextMenuItems?: GenericItemType[] | (() => GenericItemType[]); disabled?: boolean; extra?: ReactNode; + /** + * Optional href for cmd+click to open in new tab + */ + href?: string; icon?: IconProps['icon']; loading?: boolean; title: ReactNode; @@ -61,6 +66,7 @@ const NavItem = memo( actions, contextMenuItems, active, + href, icon, title, onClick, @@ -72,7 +78,15 @@ const NavItem = memo( const iconColor = active ? cssVar.colorText : cssVar.colorTextDescription; const textColor = active ? cssVar.colorText : cssVar.colorTextSecondary; const variant = active ? 'filled' : 'borderless'; - const iconComponent = loading ? Loader2Icon : icon; + + // Link props for cmd+click support + const linkProps = href + ? { + as: 'a' as const, + href, + style: { color: 'inherit', textDecoration: 'none' }, + } + : {}; const Content = ( ( horizontal onClick={(e) => { if (disabled || loading) return; + // Prevent default link behavior for normal clicks (let onClick handle it) + // But allow cmd+click to open in new tab + if (href && !e.metaKey && !e.ctrlKey) { + e.preventDefault(); + } onClick?.(e); }} paddingInline={4} variant={variant} + {...linkProps} {...rest} > {icon && (
- + {loading ? ( + + ) : ( + + )}
)} diff --git a/src/features/ToolTag/index.tsx b/src/features/ToolTag/index.tsx new file mode 100644 index 0000000000..db8c810b5b --- /dev/null +++ b/src/features/ToolTag/index.tsx @@ -0,0 +1,167 @@ +'use client'; + +import { KLAVIS_SERVER_TYPES, type KlavisServerType } from '@lobechat/const'; +import { Avatar, Icon, Tag } from '@lobehub/ui'; +import { createStaticStyles, cssVar } from 'antd-style'; +import isEqual from 'fast-deep-equal'; +import { memo, useMemo } from 'react'; + +import PluginAvatar from '@/components/Plugins/PluginAvatar'; +import { useIsDark } from '@/hooks/useIsDark'; +import { useDiscoverStore } from '@/store/discover'; +import { serverConfigSelectors, useServerConfigStore } from '@/store/serverConfig'; +import { useToolStore } from '@/store/tool'; +import { + builtinToolSelectors, + klavisStoreSelectors, + pluginSelectors, +} from '@/store/tool/selectors'; + +/** + * Klavis server icon component + */ +const KlavisIcon = memo>(({ icon, label }) => { + if (typeof icon === 'string') { + return {label}; + } + + return ; +}); + +const styles = createStaticStyles(({ css, cssVar }) => ({ + compact: css` + height: auto !important; + padding: 0 !important; + border: none !important; + background: transparent !important; + `, + tag: css` + height: 24px !important; + border-radius: ${cssVar.borderRadiusSM} !important; + `, +})); + +export interface ToolTagProps { + /** + * The tool identifier to display + */ + identifier: string; + /** + * Variant style of the tag + * - 'default': normal tag with background and border + * - 'compact': no padding, no background, no border (text only with icon) + * @default 'default' + */ + variant?: 'compact' | 'default'; +} + +/** + * A readonly tag component that displays tool information based on identifier. + * Unlike PluginTag, this component is not closable and is designed for display-only purposes. + */ +const ToolTag = memo(({ identifier, variant = 'default' }) => { + const isDarkMode = useIsDark(); + const isCompact = variant === 'compact'; + + // Get local plugin lists + const builtinList = useToolStore(builtinToolSelectors.metaList, isEqual); + const installedPluginList = useToolStore(pluginSelectors.installedPluginMetaList, isEqual); + + // Klavis related state + const allKlavisServers = useToolStore(klavisStoreSelectors.getServers, isEqual); + const isKlavisEnabledInEnv = useServerConfigStore(serverConfigSelectors.enableKlavis); + + // Check if plugin is installed + const isInstalled = useToolStore(pluginSelectors.isPluginInstalled(identifier)); + + // Try to find in local lists first (including Klavis) + const localMeta = useMemo(() => { + // Check if it's a Klavis server type + if (isKlavisEnabledInEnv) { + const klavisType = KLAVIS_SERVER_TYPES.find((type) => type.identifier === identifier); + if (klavisType) { + const connectedServer = allKlavisServers.find((s) => s.identifier === identifier); + return { + icon: klavisType.icon, + isInstalled: !!connectedServer, + label: klavisType.label, + title: klavisType.label, + type: 'klavis' as const, + }; + } + } + + const builtinMeta = builtinList.find((p) => p.identifier === identifier); + if (builtinMeta) { + return { + avatar: builtinMeta.meta.avatar, + isInstalled: true, + title: builtinMeta.meta.title, + type: 'builtin' as const, + }; + } + + const installedMeta = installedPluginList.find((p) => p.identifier === identifier); + if (installedMeta) { + return { + avatar: installedMeta.avatar, + isInstalled: true, + title: installedMeta.title, + type: 'plugin' as const, + }; + } + + return null; + }, [identifier, builtinList, installedPluginList, isKlavisEnabledInEnv, allKlavisServers]); + + // Fetch from remote if not found locally + const usePluginDetail = useDiscoverStore((s) => s.usePluginDetail); + const { data: remoteData, isLoading } = usePluginDetail({ + identifier: !localMeta && !isInstalled ? identifier : undefined, + withManifest: false, + }); + + // Determine final metadata + const meta = localMeta || { + avatar: remoteData?.avatar, + isInstalled: false, + title: remoteData?.title || identifier, + type: 'plugin' as const, + }; + + const displayTitle = isLoading ? 'Loading...' : meta.title; + + // Render icon based on type + const renderIcon = () => { + // Klavis type has icon property + if (meta.type === 'klavis' && 'icon' in meta && 'label' in meta) { + return ; + } + + // Builtin type has avatar + if (meta.type === 'builtin' && 'avatar' in meta && meta.avatar) { + return ; + } + + // Plugin type + if ('avatar' in meta) { + return ; + } + + return null; + }; + + return ( + + {displayTitle} + + ); +}); + +ToolTag.displayName = 'ToolTag'; + +export default ToolTag; diff --git a/src/server/routers/lambda/topic.ts b/src/server/routers/lambda/topic.ts index d4007ddac0..fc5731e33f 100644 --- a/src/server/routers/lambda/topic.ts +++ b/src/server/routers/lambda/topic.ts @@ -3,6 +3,7 @@ import { type RecentTopicGroup, type RecentTopicGroupMember, } from '@lobechat/types'; +import { cleanObject } from '@lobechat/utils'; import { eq, inArray } from 'drizzle-orm'; import { after } from 'next/server'; import { z } from 'zod'; @@ -410,8 +411,14 @@ export const topicRouter = router({ const agentId = topicAgentIdMap.get(topic.id); const agentInfo = agentId ? agentInfoMap.get(agentId) : null; + // Clean agent info - if avatar/title are all null, return null + const cleanedAgent = agentInfo ? cleanObject(agentInfo) : null; + // Only return agent if it has meaningful display info (avatar or title) + const validAgent = + cleanedAgent && (cleanedAgent.avatar || cleanedAgent.title) ? cleanedAgent : null; + return { - agent: agentInfo ?? null, + agent: validAgent, group: null, id: topic.id, title: topic.title, diff --git a/src/services/chat/mecha/contextEngineering.test.ts b/src/services/chat/mecha/contextEngineering.test.ts index fb1c1249db..db1502a0d4 100644 --- a/src/services/chat/mecha/contextEngineering.test.ts +++ b/src/services/chat/mecha/contextEngineering.test.ts @@ -448,7 +448,7 @@ describe('contextEngineering', () => { ]; // Mock topic memories and global identities separately - vi.spyOn(memoryManager, 'resolveTopicMemories').mockResolvedValue({ + vi.spyOn(memoryManager, 'resolveTopicMemories').mockReturnValue({ contexts: [ { accessedAt: new Date('2024-01-01T00:00:00.000Z'), diff --git a/src/services/chat/mecha/contextEngineering.ts b/src/services/chat/mecha/contextEngineering.ts index 0892fb8479..79e85ecbe4 100644 --- a/src/services/chat/mecha/contextEngineering.ts +++ b/src/services/chat/mecha/contextEngineering.ts @@ -252,12 +252,11 @@ export const contextEngineering = async ({ .map((kb) => ({ description: kb.description, id: kb.id, name: kb.name })); // Resolve user memories: topic memories and global identities are independent layers + // Both functions now read from cache only (no network requests) to avoid blocking sendMessage let userMemoryData; if (enableUserMemories) { - const [topicMemories, globalIdentities] = await Promise.all([ - resolveTopicMemories(), - Promise.resolve(resolveGlobalIdentities()), - ]); + const topicMemories = resolveTopicMemories(); + const globalIdentities = resolveGlobalIdentities(); userMemoryData = combineUserMemoryData(topicMemories, globalIdentities); } diff --git a/src/services/chat/mecha/memoryManager.ts b/src/services/chat/mecha/memoryManager.ts index b61c49acfa..525c18ae71 100644 --- a/src/services/chat/mecha/memoryManager.ts +++ b/src/services/chat/mecha/memoryManager.ts @@ -1,10 +1,8 @@ import type { UserMemoryData, UserMemoryIdentityItem } from '@lobechat/context-engine'; import type { RetrieveMemoryResult } from '@lobechat/types'; -import { mutate } from '@/libs/swr'; -import { userMemoryService } from '@/services/userMemory'; import { getChatStoreState } from '@/store/chat'; -import { getUserMemoryStoreState, useUserMemoryStore } from '@/store/userMemory'; +import { getUserMemoryStoreState } from '@/store/userMemory'; import { agentMemorySelectors, identitySelectors } from '@/store/userMemory/selectors'; const EMPTY_MEMORIES: RetrieveMemoryResult = { @@ -39,17 +37,13 @@ export interface TopicMemoryResolverContext { } /** - * Resolves topic-based memories (contexts, experiences, preferences) + * Resolves topic-based memories (contexts, experiences, preferences) from cache only. * - * This function handles: - * 1. Getting the topic ID from context or active topic - * 2. Checking if memories are already cached for the topic - * 3. Fetching memories from the service if not cached - * 4. Caching the fetched memories by topic ID + * This function only reads from cache and does NOT trigger network requests. + * Memory data is pre-loaded by SWR in ChatList via useFetchTopicMemories hook. + * This ensures sendMessage is not blocked by memory retrieval network calls. */ -export const resolveTopicMemories = async ( - ctx?: TopicMemoryResolverContext, -): Promise => { +export const resolveTopicMemories = (ctx?: TopicMemoryResolverContext): RetrieveMemoryResult => { // Get topic ID from context or active topic const topicId = ctx?.topicId ?? getChatStoreState().activeTopicId; @@ -60,34 +54,11 @@ export const resolveTopicMemories = async ( const userMemoryStoreState = getUserMemoryStoreState(); - // Check if already have cached memories for this topic + // Only read from cache, do not trigger network request + // Memory data is pre-loaded by SWR in ChatList const cachedMemories = agentMemorySelectors.topicMemories(topicId)(userMemoryStoreState); - if (cachedMemories) { - return cachedMemories; - } - - // Fetch memories for this topic - try { - const result = await userMemoryService.retrieveMemoryForTopic(topicId); - const memories = result ?? EMPTY_MEMORIES; - - // Cache the fetched memories by topic ID - useUserMemoryStore.setState((state) => ({ - topicMemoriesMap: { - ...state.topicMemoriesMap, - [topicId]: memories, - }, - })); - - // Also trigger SWR mutate to keep in sync - await mutate(['useFetchMemoriesForTopic', topicId]); - - return memories; - } catch (error) { - console.error('Failed to retrieve memories for topic:', error); - return EMPTY_MEMORIES; - } + return cachedMemories ?? EMPTY_MEMORIES; }; /** diff --git a/src/store/agentGroup/initialState.ts b/src/store/agentGroup/initialState.ts index deaf27f658..37a740de8b 100644 --- a/src/store/agentGroup/initialState.ts +++ b/src/store/agentGroup/initialState.ts @@ -4,7 +4,7 @@ import type { ParsedQuery } from 'query-string'; import type { ChatGroupItem } from '@/database/schemas/chatGroup'; export interface QueryRouter { - push: (url: string, options?: { query?: ParsedQuery }) => void; + push: (url: string, options?: { query?: ParsedQuery; replace?: boolean }) => void; } export interface ChatGroupState { diff --git a/src/store/agentGroup/slices/lifecycle.ts b/src/store/agentGroup/slices/lifecycle.ts index 610d57fe23..b45d2461a8 100644 --- a/src/store/agentGroup/slices/lifecycle.ts +++ b/src/store/agentGroup/slices/lifecycle.ts @@ -14,10 +14,16 @@ export interface ChatGroupLifecycleAction { silent?: boolean, ) => Promise; /** + * @deprecated Use switchTopic(undefined) instead * Switch to a new topic in the group * Clears activeTopicId and navigates to group root */ switchToNewTopic: () => void; + /** + * Switch to a topic in the group with proper route handling + * @param topicId - Topic ID to switch to, or undefined/null for new topic + */ + switchTopic: (topicId?: string | null) => void; } export const chatGroupLifecycleSlice: StateCreator< @@ -57,13 +63,20 @@ export const chatGroupLifecycleSlice: StateCreator< }, switchToNewTopic: () => { + get().switchTopic(undefined); + }, + + switchTopic: (topicId) => { const { activeGroupId, router } = get(); if (!activeGroupId || !router) return; - useChatStore.setState({ activeTopicId: undefined }); + // Update chat store's activeTopicId + useChatStore.getState().switchTopic(topicId ?? undefined); + // Navigate with replace to avoid stale query params router.push(urlJoin('/group', activeGroupId), { - query: { bt: null, tab: null, thread: null, topic: null }, + query: { topic: topicId ?? null }, + replace: true, }); }, });