🐛 fix: fix group ux and memory retriever (#11481)

* update topic

* update topic

* memory not block

* remove incorrectly memory fetch

* refactor group switch topic

* improve streaming style for Agent Tool display

* fix group tab active issue

* 🐛 fix: E2E test for switching conversations and TypeScript type errors

- Fix E2E test 'AGENT-CONV-002' that was failing when switching between conversations
  - Add detection for home page vs agent page state
  - Use correct selectors for Recent Topics cards on home page
  - Add proper wait time for messages to load after topic switch
  - Use '.message-wrapper' selector for message verification

- Fix TypeScript type errors in MakedownRender.tsx
  - Add explicit type annotations for a and img component props
  - Import ReactNode type

- Remove unused @ts-expect-error directive in useMarkdown.tsx

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Arvin Xu
2026-01-14 11:02:09 +08:00
committed by GitHub
parent c093cd8ca9
commit 033ca92011
33 changed files with 544 additions and 376 deletions

View File

@@ -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} 条消息`);

View File

@@ -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<AgentItemProps>(({ agent, definition }) => {
const avatar = definition?.avatar;
const description = definition?.description;
const tools = definition?.tools;
return (
<Flexbox align="center" className={styles.item} gap={12} horizontal>
<Avatar avatar={avatar} size={24} style={{ flexShrink: 0 }} title={agent.title} />
<Flexbox flex={1} gap={2} style={{ minWidth: 0, overflow: 'hidden' }}>
<Flexbox align="flex-start" className={styles.item} gap={12} horizontal>
<Avatar
avatar={avatar}
size={24}
style={{ flexShrink: 0, marginTop: 4 }}
title={agent.title}
/>
<Flexbox flex={1} gap={4} style={{ minWidth: 0, overflow: 'hidden' }}>
<span className={styles.title}>{agent.title}</span>
{description && <span className={styles.description}>{description}</span>}
{tools && tools.length > 0 && (
<Flexbox gap={4} horizontal style={{ marginTop: 8 }} wrap={'wrap'}>
{tools.map((tool) => (
<ToolTag identifier={tool} key={tool} variant={'compact'} />
))}
</Flexbox>
)}
</Flexbox>
</Flexbox>
);

View File

@@ -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<BuiltinStreamingProps<BatchCreate
return (
<Block variant={'outlined'} width="100%">
{agents.map((agent, index) => (
<Flexbox className={styles.item} gap={8} horizontal key={index}>
<Flexbox align={'flex-start'} className={styles.item} gap={8} horizontal key={index}>
<div className={styles.index}>{index + 1}.</div>
<Avatar avatar={agent.avatar} size={24} style={{ flexShrink: 0 }} title={agent.title} />
<Avatar
avatar={agent.avatar}
size={24}
style={{ flexShrink: 0, marginTop: 4 }}
title={agent.title}
/>
<Flexbox flex={1} gap={4} style={{ minWidth: 0, overflow: 'hidden' }}>
<span className={styles.title}>{agent.title}</span>
{agent.description && <span className={styles.description}>{agent.description}</span>}
{agent.tools && agent.tools.length > 0 && (
<Flexbox gap={4} horizontal style={{ marginTop: 8 }} wrap={'wrap'}>
{agent.tools.map((tool) => (
<ToolTag identifier={tool} key={tool} />
))}
</Flexbox>
)}
<Divider />
{agent.systemRole && (
<div className={styles.systemRole}>
<Markdown animated variant={'chat'}>

View File

@@ -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<BuiltinStreamingProps<UpdateAgentPromptParams>>(
({ 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 (
<Block padding={4} variant={'outlined'} width="100%">
<Block paddingBlock={8} paddingInline={12} variant={'outlined'} width="100%">
<Markdown animated variant={'chat'}>
{prompt}
</Markdown>

View File

@@ -21,7 +21,7 @@ export const UpdateGroupPromptStreaming = memo<BuiltinStreamingProps<UpdateGroup
if (!prompt) return null;
return (
<Block padding={4} variant={'outlined'} width="100%">
<Block paddingBlock={8} paddingInline={12} variant={'outlined'} width="100%">
<Markdown animated variant={'chat'}>
{prompt}
</Markdown>

View File

@@ -141,29 +141,48 @@ When creating agents (via \`createAgent\` or \`batchCreateAgents\`), you MUST an
</agent_tools_assignment>
<workflow>
**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 \`<current_group_context>\` 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
</workflow>
<guidelines>
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.
</guidelines>
<configuration_knowledge>
@@ -199,113 +218,56 @@ When creating agents (via \`createAgent\` or \`batchCreateAgents\`), you MUST an
</configuration_knowledge>
<examples>
<example>
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
</example>
<example title="Complete Team Setup (Shows Required Order)">
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
</example>
<example>
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
</example>
<example title="Add Agent to Group">
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
</example>
<example>
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
</example>
<example title="Remove Agent">
User: "Remove the coding assistant"
Action:
1. Find agent ID from \`<group_members>\` context
2. Use removeAgent
3. **Auto** - updateAgentPrompt with supervisor's agentId to remove delegation rules
</example>
<example>
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
</example>
<example title="Update Group Prompt (Shared Context)">
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).
</example>
<example>
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
</example>
<example title="Update Agent Prompt">
User: "Change how supervisor coordinates" / "Update the designer's prompt"
Action:
- For supervisor: updateAgentPrompt with supervisor's agentId
- For member: Find agentId from \`<group_members>\`, then updateAgentPrompt with that agentId
</example>
<example>
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
</example>
<example title="Update Configuration">
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
</example>
<example>
User: "What agents are in this group?"
Action: Reference the \`<group_members>\` from the injected context and display the list
</example>
<example>
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
</example>
<example>
User: "Update the group's shared knowledge base"
Action: Use updateGroupPrompt - this is shared content, do NOT include member information (auto-injected)
</example>
<example>
User: "Change how the supervisor coordinates the team"
Action: Use updateAgentPrompt with the supervisor's agentId to update orchestration logic
</example>
<example>
User: "Make the supervisor more proactive in assigning tasks"
Action: Use updateAgentPrompt with supervisor's agentId to update coordination strategy
</example>
<example>
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
</example>
<example>
User: "Modify the designer agent's prompt"
Action: Find the designer agent's agentId from group_members context, then use updateAgentPrompt with that agentId
</example>
<example>
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
</example>
<example>
User: "What can the supervisor agent do?"
Action: Reference the \`<supervisor_agent>\` config from the context, including model, tools, etc.
</example>
<example>
User: "Add some new tools to this group"
Action: Use searchMarketTools to find tools, then use installPlugin for the supervisor agent
</example>
<example>
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." } }
</example>
<example>
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?"] } }
</example>
<example title="Query Information">
User: "What agents are in this group?" / "What can the supervisor do?"
Action: Reference the injected \`<current_group_context>\` directly (group_members, supervisor_agent, etc.)
</example>
</examples>
<response_format>

View File

@@ -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<TopicItemProps>(({ 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<TopicItemProps>(({ id, title, fav, active, threadId }) =>
active={active && !threadId && !isInAgentSubRoute}
contextMenuItems={dropdownMenu}
disabled={editing}
href={href}
icon={
<ActionIcon
color={fav ? cssVar.colorWarning : undefined}

View File

@@ -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, TodoProgress } 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';
@@ -33,27 +27,6 @@ import { useAgentContext } from './useAgentContext';
const Conversation = memo(() => {
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(

View File

@@ -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 }) => {
<Markdown
allowHtml
components={{
a: ({ href, ...rest }) => {
a: ({ href, ...rest }: { children?: ReactNode; href?: string }) => {
if (href && href.startsWith('http'))
return <Link {...rest} href={href} target={'_blank'} />;
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;

View File

@@ -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<GroupMemberProps>(({ 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<GroupMemberProps>(({ 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 (
<>
<Flexbox gap={2}>
@@ -93,7 +100,7 @@ const GroupMember = memo<GroupMemberProps>(({ addModalOpen, onAddModalOpenChange
key={item.id}
onChat={() => handleMemberClick(item.id)}
>
<div>
<div onDoubleClick={() => handleMemberDoubleClick(item.id)}>
<GroupMemberItem
actions={
<ActionIcon

View File

@@ -3,15 +3,10 @@
import { Block } from '@lobehub/ui';
import { memo } from 'react';
import GroupAvatar from '@/features/GroupAvatar';
import SupervisorAvatar from '@/app/[variants]/(main)/group/features/GroupAvatar';
import { useOpenChatSettings } from '@/hooks/useInterceptingRoutes';
import { useAgentGroupStore } from '@/store/agentGroup';
import { agentGroupSelectors } from '@/store/agentGroup/selectors';
const HeaderAvatar = memo<{ size?: number }>(() => {
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}
>
<GroupAvatar
avatars={agents.map((agent) => ({
avatar: agent.avatar,
background: agent.backgroundColor || undefined,
}))}
size={28}
/>
<SupervisorAvatar size={28} />
</Block>
);
});

View File

@@ -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<PropsWithChildren>(() => {
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<PropsWithChildren>(() => {
}}
variant={'borderless'}
>
<GroupAvatar avatars={memberAvatars} size={28} />
<SupervisorAvatar size={28} />
<Text ellipsis weight={500}>
{displayTitle}
</Text>

View File

@@ -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<TopicItemProps>(({ 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<TopicItemProps>(({ 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<TopicItemProps>(({ 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<TopicItemProps>(({ id, title, fav, active, threadId }) =>
if (!id) {
return (
<NavItem
active={active && !isInAgentSubRoute}
active={active}
icon={
<Icon color={cssVar.colorTextDescription} icon={MessageSquareDashed} size={'small'} />
}
@@ -94,9 +102,10 @@ const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId }) =>
<Flexbox style={{ position: 'relative' }}>
<NavItem
actions={<Actions dropdownMenu={dropdownMenu} />}
active={active && !threadId && !isInAgentSubRoute}
active={active && !threadId}
contextMenuItems={dropdownMenu}
disabled={editing}
href={!editing ? href : undefined}
icon={
<ActionIcon
color={fav ? cssVar.colorWarning : undefined}

View File

@@ -1,49 +0,0 @@
import { usePathname } from 'next/navigation';
import { useCallback } from 'react';
import urlJoin from 'url-join';
import { useQueryRoute } from '@/hooks/useQueryRoute';
import { useAgentGroupStore } from '@/store/agentGroup';
import { useChatStore } from '@/store/chat';
import { useGlobalStore } from '@/store/global';
/**
* Hook to handle topic navigation with automatic route detection
* If in agent sub-route (e.g., /agent/:aid/profile), navigate back to chat first
*/
export const useTopicNavigation = () => {
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,
};
};

View File

@@ -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<ConversationAreaProps>(({ 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(

View File

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

View File

@@ -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 <GroupAvatar avatars={memberAvatars} size={size} />;
return (
<AgentGroupAvatar
avatar={groupMeta.avatar}
backgroundColor={groupMeta.backgroundColor}
memberAvatars={memberAvatars}
size={size}
/>
);
});
export default SupervisorAvatar;
export default CurrentAgentGroupAvatar;

View File

@@ -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<TopicSelectorProps>(({ 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

View File

@@ -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<ChromeTabsProps>(({ items, activeId, onChange, onAdd }) => {
const { t } = useTranslation('chat');
const containerRef = useRef<HTMLDivElement>(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 (
<div className={styles.container}>
<div className={styles.container} ref={containerRef}>
{items.map((item) => {
const isActive = item.id === activeId;
return (
<div
className={cx(styles.tab, isActive && styles.tabActive)}
data-tab-id={item.id}
key={item.id}
onClick={() => onChange(item.id)}
>

View File

@@ -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 <SharedAgentTool agentId={agentId} />;
return <SharedAgentTool agentId={agentId} filterAvailableInWeb showWebBrowsing useAllMetaList />;
};
export default AgentTool;

View File

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

View File

@@ -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<AgentGroupAvatarProps>(
({ avatar, backgroundColor, memberAvatars = [], size = 28 }) => {
// If group has custom avatar, show it; otherwise show member avatars composition
if (avatar) {
return <Avatar avatar={avatar} background={backgroundColor} shape="square" size={size} />;
}
return <GroupAvatar avatars={memberAvatars} size={size} />;
},
);
export default AgentGroupAvatar;

View File

@@ -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<GroupMessageProps>(({ id, index, disableEditing, isLat
</>
}
avatar={{ ...avatar, title: groupMeta.title }}
customAvatarRender={() => <GroupAvatar avatars={memberAvatars} />}
customAvatarRender={() => (
<AgentGroupAvatar
avatar={groupMeta.avatar}
backgroundColor={groupMeta.backgroundColor}
memberAvatars={memberAvatars}
/>
)}
newScreen={newScreen}
onMouseEnter={onMouseEnter}
placement={'left'}

View File

@@ -30,12 +30,11 @@ export const useMarkdown = (id: string): Partial<MarkdownProps> => {
() =>
({
components: Object.fromEntries(
// @ts-expect-error
markdownElements.map((element) => {
const Component = element.Component;
return [element.tag, (props: any) => <Component {...props} id={id} />];
}),
),
) as any,
customRender: (dom: ReactNode, { text }: { text: string }) => {
if (text.length > 30_000) return <ContentPreview content={text} id={id} />;
return dom;

View File

@@ -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<EmptyStatusProps>(({ title, onClick, className }) => {
variant={'borderless'}
>
<Center flex={'none'} height={28} width={28}>
<ActionIcon icon={PlusIcon} size={'small'} />
<Icon icon={PlusIcon} size={'small'} />
</Center>
<Text align={'center'} type={'secondary'}>
{title}

View File

@@ -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<BlockProps, 'children' | 'title'> {
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<NavItemProps>(
actions,
contextMenuItems,
active,
href,
icon,
title,
onClick,
@@ -72,7 +78,15 @@ const NavItem = memo<NavItemProps>(
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 = (
<Block
@@ -84,15 +98,25 @@ const NavItem = memo<NavItemProps>(
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 && (
<Center flex={'none'} height={28} width={28}>
<Icon color={iconColor} icon={iconComponent} size={18} spin={loading} />
{loading ? (
<NeuralNetworkLoading size={18} />
) : (
<Icon color={iconColor} icon={icon} size={18} />
)}
</Center>
)}

View File

@@ -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<Pick<KlavisServerType, 'icon' | 'label'>>(({ icon, label }) => {
if (typeof icon === 'string') {
return <img alt={label} height={16} src={icon} style={{ flexShrink: 0 }} width={16} />;
}
return <Icon fill={cssVar.colorText} icon={icon} size={16} />;
});
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<ToolTagProps>(({ 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 <KlavisIcon icon={meta.icon} label={meta.label} />;
}
// Builtin type has avatar
if (meta.type === 'builtin' && 'avatar' in meta && meta.avatar) {
return <Avatar avatar={meta.avatar} shape={'square'} size={16} style={{ flexShrink: 0 }} />;
}
// Plugin type
if ('avatar' in meta) {
return <PluginAvatar avatar={meta.avatar} size={16} />;
}
return null;
};
return (
<Tag
className={isCompact ? styles.compact : styles.tag}
icon={renderIcon()}
variant={isCompact ? 'borderless' : isDarkMode ? 'filled' : 'outlined'}
>
{displayTitle}
</Tag>
);
});
ToolTag.displayName = 'ToolTag';
export default ToolTag;

View File

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

View File

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

View File

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

View File

@@ -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<RetrieveMemoryResult> => {
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;
};
/**

View File

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

View File

@@ -14,10 +14,16 @@ export interface ChatGroupLifecycleAction {
silent?: boolean,
) => Promise<string>;
/**
* @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,
});
},
});