💄 style: improve auto scroll and group profile (#11725)

* refactor the group context injector

* improve agent tool

* refactor AutoScroll to fix auto scroll in tool use

* fix broadcast mode

* update

* improve

* fix lobe-ai builtin tools issue
This commit is contained in:
Arvin Xu
2026-01-23 14:50:00 +08:00
committed by GitHub
parent 395595a2c8
commit 550acc293e
15 changed files with 527 additions and 187 deletions

View File

@@ -162,6 +162,15 @@ export class MessagesEngine {
// 2. System role injection (agent's system role)
new SystemRoleInjector({ systemRole }),
// =============================================
// Phase 2.5: First User Message Context Injection
// These providers inject content before the first user message
// Order matters: first executed = first in content
// =============================================
// 4. User memory injection (conditionally added, injected first)
...(isUserMemoryEnabled ? [new UserMemoryInjector(userMemory)] : []),
// 3. Group context injection (agent identity and group info for multi-agent chat)
new GroupContextInjector({
currentAgentId: agentGroup?.currentAgentId,
@@ -173,15 +182,6 @@ export class MessagesEngine {
systemPrompt: agentGroup?.systemPrompt,
}),
// =============================================
// Phase 2.5: First User Message Context Injection
// These providers inject content before the first user message
// Order matters: first executed = first in content
// =============================================
// 4. User memory injection (conditionally added, injected first)
...(isUserMemoryEnabled ? [new UserMemoryInjector(userMemory)] : []),
// 4.5. GTD Plan injection (conditionally added, after user memory, before knowledge)
...(isGTDPlanEnabled ? [new GTDPlanInjector({ enabled: true, plan: gtd.plan })] : []),

View File

@@ -5,7 +5,7 @@ import {
} from '@lobechat/prompts';
import debug from 'debug';
import { BaseProvider } from '../base/BaseProvider';
import { BaseFirstUserContentProvider } from '../base/BaseFirstUserContentProvider';
import type { PipelineContext, ProcessorOptions } from '../types';
const log = debug('context-engine:provider:GroupContextInjector');
@@ -59,13 +59,13 @@ export interface GroupContextInjectorConfig {
/**
* Group Context Injector
*
* Responsible for injecting group context information into the system role
* Responsible for injecting group context information before the first user message
* for multi-agent group chat scenarios. This helps the model understand:
* - Its own identity within the group
* - The group composition and other members
* - Rules for handling system metadata
*
* The injector appends a GROUP CONTEXT block at the end of the system message,
* The injector creates a system injection message before the first user message,
* containing:
* - Agent's identity (name, role, ID)
* - Group info (name, member list)
@@ -87,7 +87,7 @@ export interface GroupContextInjectorConfig {
* });
* ```
*/
export class GroupContextInjector extends BaseProvider {
export class GroupContextInjector extends BaseFirstUserContentProvider {
readonly name = 'GroupContextInjector';
constructor(
@@ -97,44 +97,32 @@ export class GroupContextInjector extends BaseProvider {
super(options);
}
protected async doProcess(context: PipelineContext): Promise<PipelineContext> {
const clonedContext = this.cloneContext(context);
// Skip if not enabled or missing required config
protected buildContent(): string | null {
// Skip if not enabled
if (!this.config.enabled) {
log('Group context injection disabled, skipping');
return this.markAsExecuted(clonedContext);
return null;
}
// Find the system message to append to
const systemMessageIndex = clonedContext.messages.findIndex((msg) => msg.role === 'system');
const content = this.buildGroupContextBlock();
log('Group context prepared for injection');
if (systemMessageIndex === -1) {
log('No system message found, skipping group context injection');
return this.markAsExecuted(clonedContext);
}
return content;
}
const systemMessage = clonedContext.messages[systemMessageIndex];
const groupContext = this.buildGroupContextBlock();
// Append group context to system message content
if (typeof systemMessage.content === 'string') {
clonedContext.messages[systemMessageIndex] = {
...systemMessage,
content: systemMessage.content + groupContext,
};
log('Group context injected into system message');
}
protected async doProcess(context: PipelineContext): Promise<PipelineContext> {
const result = await super.doProcess(context);
// Update metadata
clonedContext.metadata.groupContextInjected = true;
if (this.config.enabled) {
result.metadata.groupContextInjected = true;
}
return this.markAsExecuted(clonedContext);
return result;
}
/**
* Build the group context block to append to system message
* Build the group context block
* Uses template from @lobechat/prompts with direct variable replacement
*/
private buildGroupContextBlock(): string {
@@ -159,9 +147,7 @@ export class GroupContextInjector extends BaseProvider {
.replace('{{SYSTEM_PROMPT}}', systemPrompt || '')
.replace('{{GROUP_MEMBERS}}', membersText);
return `
<group_context>
return `<group_context>
${groupContextContent}
</group_context>`;
}

View File

@@ -12,7 +12,7 @@ describe('GroupContextInjector', () => {
});
describe('Basic Scenarios', () => {
it('should inject group context into system message', async () => {
it('should inject group context before first user message', async () => {
const injector = new GroupContextInjector({
currentAgentId: 'agt_editor',
currentAgentName: 'Editor',
@@ -35,31 +35,39 @@ describe('GroupContextInjector', () => {
const context = createContext(input);
const result = await injector.process(context);
const systemContent = result.messages[0].content;
// System message should be unchanged
expect(result.messages[0].content).toBe('You are a helpful editor.');
// Original content should be preserved
expect(systemContent).toContain('You are a helpful editor.');
// Should have 3 messages now (system, injected, user)
expect(result.messages).toHaveLength(3);
// Check injected message (second message)
const injectedContent = result.messages[1].content;
expect(result.messages[1].role).toBe('user');
// Agent identity (plain text, no wrapper)
expect(systemContent).toContain('You are "Editor"');
expect(systemContent).toContain('acting as a participant');
expect(systemContent).toContain('"Writing Team"');
expect(systemContent).toContain('agt_editor');
expect(systemContent).not.toContain('<agent_identity>');
expect(injectedContent).toContain('You are "Editor"');
expect(injectedContent).toContain('acting as a participant');
expect(injectedContent).toContain('"Writing Team"');
expect(injectedContent).toContain('agt_editor');
expect(injectedContent).not.toContain('<agent_identity>');
// Group context section with system prompt
expect(systemContent).toContain('<group_context>');
expect(systemContent).toContain('A team for collaborative writing');
expect(injectedContent).toContain('<group_context>');
expect(injectedContent).toContain('A team for collaborative writing');
// Participants section with XML format
expect(systemContent).toContain('<group_participants>');
expect(systemContent).toContain('<member name="Supervisor" id="agt_supervisor" />');
expect(systemContent).toContain('<member name="Writer" id="agt_writer" />');
expect(systemContent).toContain('<member name="Editor" id="agt_editor" you="true" />');
expect(injectedContent).toContain('<group_participants>');
expect(injectedContent).toContain('<member name="Supervisor" id="agt_supervisor" />');
expect(injectedContent).toContain('<member name="Writer" id="agt_writer" />');
expect(injectedContent).toContain('<member name="Editor" id="agt_editor" you="true" />');
// Identity rules
expect(systemContent).toContain('<identity_rules>');
expect(systemContent).toContain('NEVER expose or display agent IDs');
expect(injectedContent).toContain('<identity_rules>');
expect(injectedContent).toContain('NEVER expose or display agent IDs');
// Original user message should be third
expect(result.messages[2].content).toBe('Please review this.');
// Metadata should be updated
expect(result.metadata.groupContextInjected).toBe(true);
@@ -72,35 +80,37 @@ describe('GroupContextInjector', () => {
enabled: false, // Disabled
});
const input: any[] = [{ role: 'system', content: 'You are a helpful editor.' }];
const input: any[] = [
{ role: 'system', content: 'You are a helpful editor.' },
{ role: 'user', content: 'Hello' },
];
const context = createContext(input);
const result = await injector.process(context);
// Should be unchanged
// Should be unchanged - no injection
expect(result.messages).toHaveLength(2);
expect(result.messages[0].content).toBe('You are a helpful editor.');
expect(result.messages[1].content).toBe('Hello');
expect(result.metadata.groupContextInjected).toBeUndefined();
});
it('should skip injection when no system message exists', async () => {
it('should skip injection when no user message exists', async () => {
const injector = new GroupContextInjector({
currentAgentId: 'agt_editor',
currentAgentName: 'Editor',
enabled: true,
});
const input: any[] = [
{ role: 'user', content: 'Hello' },
{ role: 'assistant', content: 'Hi there!' },
];
const input: any[] = [{ role: 'system', content: 'You are a helpful editor.' }];
const context = createContext(input);
const result = await injector.process(context);
// Messages should be unchanged
expect(result.messages[0].content).toBe('Hello');
expect(result.messages[1].content).toBe('Hi there!');
expect(result.metadata.groupContextInjected).toBeUndefined();
// Messages should be unchanged - no user message to inject before
expect(result.messages).toHaveLength(1);
expect(result.messages[0].content).toBe('You are a helpful editor.');
expect(result.metadata.groupContextInjected).toBe(true);
});
});
@@ -113,12 +123,16 @@ describe('GroupContextInjector', () => {
enabled: true,
});
const input: any[] = [{ content: 'You are an editor.', role: 'system' }];
const input: any[] = [
{ content: 'You are an editor.', role: 'system' },
{ content: 'Hello', role: 'user' },
];
const context = createContext(input);
const result = await injector.process(context);
expect(result.messages[0].content).toMatchSnapshot();
// Check injected message content
expect(result.messages[1].content).toMatchSnapshot();
});
it('should handle config with only group info', async () => {
@@ -129,12 +143,16 @@ describe('GroupContextInjector', () => {
systemPrompt: 'Test group description',
});
const input: any[] = [{ content: 'System prompt.', role: 'system' }];
const input: any[] = [
{ content: 'System prompt.', role: 'system' },
{ content: 'Hello', role: 'user' },
];
const context = createContext(input);
const result = await injector.process(context);
expect(result.messages[0].content).toMatchSnapshot();
// Check injected message content
expect(result.messages[1].content).toMatchSnapshot();
});
it('should handle empty config', async () => {
@@ -142,12 +160,16 @@ describe('GroupContextInjector', () => {
enabled: true,
});
const input: any[] = [{ content: 'Base prompt.', role: 'system' }];
const input: any[] = [
{ content: 'Base prompt.', role: 'system' },
{ content: 'Hello', role: 'user' },
];
const context = createContext(input);
const result = await injector.process(context);
expect(result.messages[0].content).toMatchSnapshot();
// Check injected message content
expect(result.messages[1].content).toMatchSnapshot();
});
});
@@ -158,15 +180,19 @@ describe('GroupContextInjector', () => {
// Minimal config
});
const input: any[] = [{ role: 'system', content: 'Base prompt.' }];
const input: any[] = [
{ role: 'system', content: 'Base prompt.' },
{ role: 'user', content: 'Hello' },
];
const context = createContext(input);
const result = await injector.process(context);
const systemContent = result.messages[0].content;
// Check injected message content
const injectedContent = result.messages[1].content;
// Even with minimal config, identity rules should be present
expect(systemContent).toMatchSnapshot();
expect(injectedContent).toMatchSnapshot();
});
});
@@ -179,12 +205,16 @@ describe('GroupContextInjector', () => {
systemPrompt: 'Empty group description',
});
const input: any[] = [{ content: 'Prompt.', role: 'system' }];
const input: any[] = [
{ content: 'Prompt.', role: 'system' },
{ content: 'Hello', role: 'user' },
];
const context = createContext(input);
const result = await injector.process(context);
expect(result.messages[0].content).toMatchSnapshot();
// Check injected message content
expect(result.messages[1].content).toMatchSnapshot();
});
it('should preserve other messages unchanged', async () => {
@@ -204,10 +234,16 @@ describe('GroupContextInjector', () => {
const context = createContext(input);
const result = await injector.process(context);
// Only system message should be modified
expect(result.messages[0].content).toContain('<group_context>');
expect(result.messages[1].content).toBe('User message.');
expect(result.messages[2].content).toBe('Assistant response.');
// System message should be unchanged
expect(result.messages[0].content).toBe('System prompt.');
// Injected message should be second
expect(result.messages[1].role).toBe('user');
expect(result.messages[1].content).toContain('<group_context>');
// Original messages should be preserved
expect(result.messages[2].content).toBe('User message.');
expect(result.messages[3].content).toBe('Assistant response.');
});
});
});

View File

@@ -1,9 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`GroupContextInjector > Edge Cases > should handle empty members array 1`] = `
"Prompt.
<group_context>
"<group_context>
You are "", acting as a in the multi-agent group "Empty Group".
Your internal agent ID is (for system use only, never expose to users).
@@ -24,9 +22,7 @@ Empty group description
`;
exports[`GroupContextInjector > Identity Rules Section > should always include identity rules 1`] = `
"Base prompt.
<group_context>
"<group_context>
You are "", acting as a in the multi-agent group "".
Your internal agent ID is (for system use only, never expose to users).
@@ -47,9 +43,7 @@ Your internal agent ID is (for system use only, never expose to users).
`;
exports[`GroupContextInjector > Variable Replacement > should handle config with only group info 1`] = `
"System prompt.
<group_context>
"<group_context>
You are "", acting as a in the multi-agent group "Test Group".
Your internal agent ID is (for system use only, never expose to users).
@@ -70,9 +64,7 @@ Test group description
`;
exports[`GroupContextInjector > Variable Replacement > should handle config with only identity info 1`] = `
"You are an editor.
<group_context>
"<group_context>
You are "Editor", acting as a participant in the multi-agent group "".
Your internal agent ID is agt_editor (for system use only, never expose to users).
@@ -93,9 +85,7 @@ Your internal agent ID is agt_editor (for system use only, never expose to users
`;
exports[`GroupContextInjector > Variable Replacement > should handle empty config 1`] = `
"Base prompt.
<group_context>
"<group_context>
You are "", acting as a in the multi-agent group "".
Your internal agent ID is (for system use only, never expose to users).

View File

@@ -1,25 +0,0 @@
'use client';
import { memo, useEffect } from 'react';
import { messageStateSelectors, useConversationStore, virtuaListSelectors } from '../../store';
import BackBottom from './BackBottom';
const AutoScroll = memo(() => {
const atBottom = useConversationStore(virtuaListSelectors.atBottom);
const isScrolling = useConversationStore(virtuaListSelectors.isScrolling);
const isGenerating = useConversationStore(messageStateSelectors.isAIGenerating);
const scrollToBottom = useConversationStore((s) => s.scrollToBottom);
useEffect(() => {
if (atBottom && isGenerating && !isScrolling) {
scrollToBottom(false);
}
}, [atBottom, isGenerating, isScrolling, scrollToBottom]);
return <BackBottom onScrollToBottom={() => scrollToBottom(true)} visible={!atBottom} />;
});
AutoScroll.displayName = 'ConversationAutoScroll';
export default AutoScroll;

View File

@@ -0,0 +1,166 @@
'use client';
import { memo } from 'react';
import { createPortal } from 'react-dom';
import { messageStateSelectors, useConversationStore, virtuaListSelectors } from '../../../store';
/**
* 判断是否在底部的阈值单位px
* 当距离底部小于等于此值时,认为在底部
*/
export const AT_BOTTOM_THRESHOLD = 300;
/**
* 是否开启调试面板
* 设为 true 可以显示滚动位置调试信息
*/
export const OPEN_DEV_INSPECTOR = false;
const DebugInspector = memo(() => {
const atBottom = useConversationStore(virtuaListSelectors.atBottom);
const isScrolling = useConversationStore(virtuaListSelectors.isScrolling);
const isGenerating = useConversationStore(messageStateSelectors.isAIGenerating);
const virtuaScrollMethods = useConversationStore((s) => s.virtuaScrollMethods);
const shouldAutoScroll = atBottom && isGenerating && !isScrolling;
const scrollOffset = virtuaScrollMethods?.getScrollOffset?.() ?? 0;
const scrollSize = virtuaScrollMethods?.getScrollSize?.() ?? 0;
const viewportSize = virtuaScrollMethods?.getViewportSize?.() ?? 0;
const distanceToBottom = scrollSize - scrollOffset - viewportSize;
// 可视化计算
const visualHeight = 120;
const scale = scrollSize > 0 ? visualHeight / scrollSize : 0;
const viewportVisualHeight = Math.max(viewportSize * scale, 10);
const scrollVisualOffset = scrollOffset * scale;
const thresholdVisualHeight = Math.min(AT_BOTTOM_THRESHOLD * scale, visualHeight * 0.3);
const panel = (
<div
style={{
background: 'rgba(0,0,0,0.9)',
borderRadius: 8,
bottom: 80,
display: 'flex',
fontFamily: 'monospace',
fontSize: 11,
gap: 16,
left: 12,
padding: '10px 14px',
position: 'fixed',
zIndex: 9999,
}}
>
{/* 滚动条可视化 */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ color: '#9ca3af', fontSize: 10 }}>Scroll Position</div>
<div
style={{
background: '#374151',
borderRadius: 3,
height: visualHeight,
position: 'relative',
width: 24,
}}
>
{/* threshold 区域 (底部 200px) */}
<div
style={{
background: atBottom ? 'rgba(34, 197, 94, 0.3)' : 'rgba(239, 68, 68, 0.3)',
borderRadius: '0 0 3px 3px',
bottom: 0,
height: thresholdVisualHeight,
left: 0,
position: 'absolute',
right: 0,
}}
/>
{/* 当前视口位置 */}
<div
style={{
background: atBottom ? '#22c55e' : '#3b82f6',
borderRadius: 2,
height: viewportVisualHeight,
left: 2,
position: 'absolute',
right: 2,
top: scrollVisualOffset,
transition: 'top 0.1s',
}}
/>
{/* threshold 线 */}
<div
style={{
background: '#f59e0b',
bottom: thresholdVisualHeight,
height: 1,
left: 0,
position: 'absolute',
right: 0,
}}
/>
</div>
<div style={{ color: '#f59e0b', fontSize: 9, textAlign: 'center' }}>
{AT_BOTTOM_THRESHOLD}px
</div>
</div>
{/* 数值信息 */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ color: '#9ca3af', fontSize: 10 }}>
scrollSize: <span style={{ color: 'white' }}>{Math.round(scrollSize)}px</span>
</div>
<div style={{ color: '#9ca3af', fontSize: 10 }}>
viewport: <span style={{ color: 'white' }}>{Math.round(viewportSize)}px</span>
</div>
<div style={{ color: '#9ca3af', fontSize: 10 }}>
offset: <span style={{ color: 'white' }}>{Math.round(scrollOffset)}px</span>
</div>
<div
style={{
color: atBottom ? '#22c55e' : '#ef4444',
fontSize: 10,
fontWeight: 'bold',
}}
>
toBottom: {Math.round(distanceToBottom)}px
{distanceToBottom <= AT_BOTTOM_THRESHOLD ? ' ≤' : ' >'} {AT_BOTTOM_THRESHOLD}
</div>
<div style={{ borderTop: '1px solid #374151', marginTop: 4, paddingTop: 4 }}>
<div style={{ color: atBottom ? '#22c55e' : '#ef4444', fontSize: 10 }}>
atBottom: {atBottom ? 'YES' : 'NO'}
</div>
<div style={{ color: isGenerating ? '#3b82f6' : '#6b7280', fontSize: 10 }}>
generating: {isGenerating ? 'YES' : 'NO'}
</div>
<div style={{ color: isScrolling ? '#f59e0b' : '#6b7280', fontSize: 10 }}>
scrolling: {isScrolling ? 'YES' : 'NO'}
</div>
</div>
<div
style={{
background: shouldAutoScroll ? '#22c55e' : '#ef4444',
borderRadius: 3,
color: 'white',
fontSize: 10,
marginTop: 4,
padding: '2px 6px',
textAlign: 'center',
}}
>
autoScroll: {shouldAutoScroll ? 'YES' : 'NO'}
</div>
</div>
</div>
);
if (typeof document === 'undefined') return null;
return createPortal(panel, document.body);
});
DebugInspector.displayName = 'DebugInspector';
export default DebugInspector;

View File

@@ -0,0 +1,86 @@
'use client';
import { memo, useEffect } from 'react';
import {
dataSelectors,
messageStateSelectors,
useConversationStore,
virtuaListSelectors,
} from '../../../store';
import BackBottom from '../BackBottom';
import { AT_BOTTOM_THRESHOLD, OPEN_DEV_INSPECTOR } from './DebugInspector';
const AutoScroll = memo(() => {
const atBottom = useConversationStore(virtuaListSelectors.atBottom);
const isScrolling = useConversationStore(virtuaListSelectors.isScrolling);
const isGenerating = useConversationStore(messageStateSelectors.isAIGenerating);
const scrollToBottom = useConversationStore((s) => s.scrollToBottom);
const dbMessages = useConversationStore(dataSelectors.dbMessages);
const shouldAutoScroll = atBottom && isGenerating && !isScrolling;
// 获取最后一条消息的 content 长度,用于监听流式输出
const lastMessage = dbMessages.at(-1);
const lastMessageContentLength =
typeof lastMessage?.content === 'string' ? lastMessage.content.length : 0;
useEffect(() => {
if (shouldAutoScroll) {
scrollToBottom(false);
}
}, [shouldAutoScroll, scrollToBottom, dbMessages.length, lastMessageContentLength]);
return (
<div style={{ position: 'relative', width: '100%' }}>
{OPEN_DEV_INSPECTOR && (
<>
{/* Threshold 区域顶部边界线 */}
<div
style={{
background: atBottom ? '#22c55e' : '#ef4444',
height: 2,
left: 0,
opacity: 0.5,
pointerEvents: 'none',
position: 'absolute',
right: 0,
top: -AT_BOTTOM_THRESHOLD,
}}
/>
{/* Threshold 区域 mask - 显示在指示线上方 */}
<div
style={{
background: atBottom
? 'linear-gradient(to top, rgba(34, 197, 94, 0.15), transparent)'
: 'linear-gradient(to top, rgba(239, 68, 68, 0.1), transparent)',
height: AT_BOTTOM_THRESHOLD,
left: 0,
pointerEvents: 'none',
position: 'absolute',
right: 0,
top: -AT_BOTTOM_THRESHOLD,
}}
/>
{/* AutoScroll 位置指示线(底部) */}
<div
style={{
background: atBottom ? '#22c55e' : '#ef4444',
height: 2,
position: 'relative',
width: '100%',
}}
/>
</>
)}
<BackBottom onScrollToBottom={() => scrollToBottom(true)} visible={!atBottom} />
</div>
);
});
AutoScroll.displayName = 'ConversationAutoScroll';
export default AutoScroll;

View File

@@ -7,6 +7,10 @@ import { VList, type VListHandle } from 'virtua';
import WideScreenContainer from '../../../WideScreenContainer';
import { useConversationStore, virtuaListSelectors } from '../../store';
import AutoScroll from './AutoScroll';
import DebugInspector, {
AT_BOTTOM_THRESHOLD,
OPEN_DEV_INSPECTOR,
} from './AutoScroll/DebugInspector';
interface VirtualizedListProps {
dataSource: string[];
@@ -23,15 +27,11 @@ const VirtualizedList = memo<VirtualizedListProps>(({ dataSource, itemContent })
const prevDataLengthRef = useRef(dataSource.length);
const scrollEndTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const atBottomThreshold = 200;
// Store actions
const registerVirtuaScrollMethods = useConversationStore((s) => s.registerVirtuaScrollMethods);
const setScrollState = useConversationStore((s) => s.setScrollState);
const resetVisibleItems = useConversationStore((s) => s.resetVisibleItems);
const scrollToBottom = useConversationStore((s) => s.scrollToBottom);
const setActiveIndex = useConversationStore((s) => s.setActiveIndex);
const atBottom = useConversationStore(virtuaListSelectors.atBottom);
const activeIndex = useConversationStore(virtuaListSelectors.activeIndex);
// Check if at bottom based on scroll position
@@ -43,8 +43,8 @@ const VirtualizedList = memo<VirtualizedListProps>(({ dataSource, itemContent })
const scrollSize = ref.scrollSize;
const viewportSize = ref.viewportSize;
return scrollSize - scrollOffset - viewportSize <= atBottomThreshold;
}, [atBottomThreshold]);
return scrollSize - scrollOffset - viewportSize <= AT_BOTTOM_THRESHOLD;
}, [AT_BOTTOM_THRESHOLD]);
// Handle scroll events
const handleScroll = useCallback(() => {
@@ -131,6 +131,8 @@ const VirtualizedList = memo<VirtualizedListProps>(({ dataSource, itemContent })
return (
<>
{/* Debug Inspector - 放在 VList 外面,不会被虚拟列表回收 */}
{OPEN_DEV_INSPECTOR && <DebugInspector />}
<VList
bufferSize={typeof window !== 'undefined' ? window.innerHeight : 0}
data={dataSource}
@@ -142,12 +144,14 @@ const VirtualizedList = memo<VirtualizedListProps>(({ dataSource, itemContent })
{(messageId, index): ReactElement => {
const isAgentCouncil = messageId.includes('agentCouncil');
const content = itemContent(index, messageId);
const isLast = index === dataSource.length - 1;
if (isAgentCouncil) {
// AgentCouncil needs full width for horizontal scroll
return (
<div key={messageId} style={{ position: 'relative', width: '100%' }}>
{content}
{isLast && <AutoScroll />}
</div>
);
}
@@ -155,21 +159,11 @@ const VirtualizedList = memo<VirtualizedListProps>(({ dataSource, itemContent })
return (
<WideScreenContainer key={messageId} style={{ position: 'relative' }}>
{content}
{isLast && <AutoScroll />}
</WideScreenContainer>
);
}}
</VList>
<WideScreenContainer
onChange={() => {
if (!atBottom) return;
setTimeout(() => scrollToBottom(true), 100);
}}
style={{
position: 'relative',
}}
>
<AutoScroll />
</WideScreenContainer>
</>
);
}, isEqual);

View File

@@ -1,27 +1,38 @@
import { ScrollShadow } from '@lobehub/ui';
import { type PropsWithChildren, type RefObject, memo, useEffect, useRef } from 'react';
import { type PropsWithChildren, type RefObject, memo, useEffect } from 'react';
const AutoScrollShadow = memo<PropsWithChildren>(({ children }) => {
const contentRef = useRef<HTMLDivElement | null>(null);
import { useAutoScroll } from '@/hooks/useAutoScroll';
interface AutoScrollShadowProps extends PropsWithChildren {
/**
* Content string to track for auto-scrolling
*/
content?: string;
/**
* Whether the content is currently streaming/generating
*/
streaming?: boolean;
}
const AutoScrollShadow = memo<AutoScrollShadowProps>(({ children, content, streaming }) => {
const { ref, handleScroll, resetScrollLock } = useAutoScroll<HTMLDivElement>({
deps: [content],
enabled: streaming,
});
// Reset scroll lock when content is cleared (new stream starts)
useEffect(() => {
const container = contentRef.current;
if (!container) return;
const distanceToBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
const isNearBottom = distanceToBottom < 120;
if (isNearBottom) {
requestAnimationFrame(() => {
container.scrollTop = container.scrollHeight;
});
if (!content) {
resetScrollLock();
}
}, []);
}, [content, resetScrollLock]);
return (
<ScrollShadow
height={'max(33vh, 480px)'}
hideScrollBar
ref={contentRef as unknown as RefObject<HTMLDivElement>}
onScroll={handleScroll}
ref={ref as RefObject<HTMLDivElement>}
size={16}
>
{children}

View File

@@ -92,7 +92,7 @@ const CouncilMember = memo<CouncilMemberProps>(({ item, index }) => {
showTitle
time={createdAt}
>
<AutoScrollShadow>
<AutoScrollShadow content={content} streaming={generating}>
<MessageContent {...item} />
</AutoScrollShadow>
</ChatItem>

View File

@@ -554,20 +554,7 @@ const AgentTool = memo<AgentToolProps>(
<>
{/* Plugin Selector and Tags */}
<Flexbox align="center" gap={8} horizontal wrap={'wrap'}>
{/* Second Row: Selected Plugins as Tags */}
{allEnabledTools.map((pluginId) => {
return (
<PluginTag
key={pluginId}
onRemove={handleRemovePlugin(pluginId)}
pluginId={pluginId}
showDesktopOnlyLabel={filterAvailableInWeb}
useAllMetaList={useAllMetaList}
/>
);
})}
{/* Plugin Selector Dropdown - Using Action component pattern */}
<Suspense fallback={button}>
<ActionDropdown
maxHeight={500}
@@ -620,6 +607,18 @@ const AgentTool = memo<AgentToolProps>(
{button}
</ActionDropdown>
</Suspense>
{/* Selected Plugins as Tags */}
{allEnabledTools.map((pluginId) => {
return (
<PluginTag
key={pluginId}
onRemove={handleRemovePlugin(pluginId)}
pluginId={pluginId}
showDesktopOnlyLabel={filterAvailableInWeb}
useAllMetaList={useAllMetaList}
/>
);
})}
</Flexbox>
</>
);

View File

@@ -444,6 +444,49 @@ describe('resolveAgentConfig', () => {
expect(result.plugins).toContain(NotebookIdentifier);
expect(result.plugins).toContain('user-plugin');
});
it('should use basePlugins from agentConfig when ctx.plugins is not provided', () => {
// This test verifies the fix for the issue where INBOX agent lost user-configured plugins
// when resolveAgentConfig was called without the plugins parameter.
// The runtime function should receive basePlugins (from agentConfig) as fallback.
const userConfiguredPlugins = ['web-search', 'memory', 'custom-tool'];
vi.spyOn(agentSelectors.agentSelectors, 'getAgentConfigById').mockReturnValue(
() =>
({
...mockAgentConfig,
plugins: userConfiguredPlugins,
}) as any,
);
// Simulate INBOX runtime behavior: merges builtin tools with ctx.plugins
const getAgentRuntimeConfigSpy = vi
.spyOn(builtinAgents, 'getAgentRuntimeConfig')
.mockImplementation((slug, ctx) => ({
// This simulates the actual INBOX runtime: [GTDIdentifier, NotebookIdentifier, ...(ctx.plugins || [])]
plugins: [GTDIdentifier, NotebookIdentifier, ...(ctx.plugins || [])],
systemRole: 'Inbox system role',
}));
// Call WITHOUT plugins parameter - this is how internal_createAgentState calls it
const result = resolveAgentConfig({ agentId: 'inbox-agent' });
// Verify getAgentRuntimeConfig received basePlugins as fallback
expect(getAgentRuntimeConfigSpy).toHaveBeenCalledWith(
'inbox',
expect.objectContaining({
plugins: userConfiguredPlugins,
}),
);
// Verify final plugins include both builtin tools AND user-configured plugins
expect(result.plugins).toContain(GTDIdentifier);
expect(result.plugins).toContain(NotebookIdentifier);
expect(result.plugins).toContain('web-search');
expect(result.plugins).toContain('memory');
expect(result.plugins).toContain('custom-tool');
expect(result.plugins).toHaveLength(5); // 2 builtin + 3 user plugins
});
});
});

View File

@@ -297,11 +297,13 @@ export const resolveAgentConfig = (ctx: AgentConfigResolverContext): ResolvedAge
}
// Builtin agent - merge runtime config
// Use basePlugins as fallback when ctx.plugins is not provided
// This ensures builtin agents (e.g., INBOX) receive user-configured plugins for merging
const runtimeConfig = getAgentRuntimeConfig(slug, {
documentContent,
groupSupervisorContext,
model,
plugins,
plugins: plugins || basePlugins,
targetAgentConfig,
});

View File

@@ -446,18 +446,20 @@ describe('StreamingExecutor actions', () => {
});
describe('effectiveAgentId for group orchestration', () => {
it('should pass pre-resolved config for sub-agent when subAgentId is set in operation context', async () => {
it('should use subAgentId as agentId when groupId is present (group orchestration)', async () => {
const { result } = renderHook(() => useChatStore());
const messages = [createMockMessage({ role: 'user' })];
const supervisorAgentId = 'supervisor-agent-id';
const subAgentId = 'sub-agent-id';
const groupId = 'test-group-id';
// Create operation with subAgentId in context (simulating group orchestration)
// Create operation with groupId and subAgentId (group orchestration scenario)
const { operationId } = result.current.startOperation({
type: 'execAgentRuntime',
context: {
agentId: supervisorAgentId,
subAgentId: subAgentId,
groupId: groupId, // groupId present = group orchestration
topicId: TEST_IDS.TOPIC_ID,
messageId: TEST_IDS.ASSISTANT_MESSAGE_ID,
},
@@ -484,14 +486,12 @@ describe('StreamingExecutor actions', () => {
});
});
// With the new architecture:
// - agentId param is for context/tracing (supervisor ID)
// - resolvedAgentConfig contains the sub-agent's config (passed in by caller)
// In group orchestration (groupId present), subAgentId should be used as agentId
expect(streamSpy).toHaveBeenCalledWith(
expect.objectContaining({
params: expect.objectContaining({
agentId: supervisorAgentId, // For context/tracing purposes
resolvedAgentConfig: subAgentConfig, // Pre-resolved sub-agent config
agentId: subAgentId, // subAgentId used for context injection in group orchestration
resolvedAgentConfig: subAgentConfig,
}),
}),
);
@@ -499,6 +499,52 @@ describe('StreamingExecutor actions', () => {
streamSpy.mockRestore();
});
it('should use agentId when subAgentId is present but groupId is not (non-group scenario)', async () => {
const { result } = renderHook(() => useChatStore());
const messages = [createMockMessage({ role: 'user' })];
const agentId = 'normal-agent-id';
const subAgentId = 'sub-agent-id';
// Create operation with subAgentId but NO groupId (not a group orchestration scenario)
const { operationId } = result.current.startOperation({
type: 'execAgentRuntime',
context: {
agentId: agentId,
subAgentId: subAgentId, // subAgentId present but no groupId
topicId: TEST_IDS.TOPIC_ID,
messageId: TEST_IDS.ASSISTANT_MESSAGE_ID,
},
label: 'Test Non-Group with SubAgentId',
});
const streamSpy = vi
.spyOn(chatService, 'createAssistantMessageStream')
.mockImplementation(async ({ onFinish }) => {
await onFinish?.(TEST_CONTENT.AI_RESPONSE, {});
});
await act(async () => {
await result.current.internal_fetchAIChatMessage({
messages,
messageId: TEST_IDS.ASSISTANT_MESSAGE_ID,
model: 'gpt-4o-mini',
provider: 'openai',
operationId,
agentConfig: createMockResolvedAgentConfig(),
});
});
// Without groupId, should use agentId even if subAgentId is present
expect(streamSpy).toHaveBeenCalledWith(
expect.objectContaining({
// agentId used since no groupId
params: expect.objectContaining({ agentId }),
}),
);
streamSpy.mockRestore();
});
it('should pass agentId to chatService when no subAgentId is set (normal chat)', async () => {
const { result } = renderHook(() => useChatStore());
const messages = [createMockMessage({ role: 'user' })];
@@ -545,7 +591,7 @@ describe('StreamingExecutor actions', () => {
streamSpy.mockRestore();
});
it('should pass resolvedAgentConfig through chatService when subAgentId is present', async () => {
it('should pass resolvedAgentConfig through chatService in group orchestration speak scenario', async () => {
const { result } = renderHook(() => useChatStore());
const messages = [createMockMessage({ role: 'user' })];
const supervisorAgentId = 'supervisor-agent-id';
@@ -588,15 +634,13 @@ describe('StreamingExecutor actions', () => {
});
});
// With the new architecture, config is pre-resolved and passed via resolvedAgentConfig.
// The agentId param is for context/tracing only.
// The speaking agent's config is ensured by the caller (internal_createAgentState)
// resolving config with subAgentId and passing it as agentConfig param.
// In group orchestration (groupId present), subAgentId is used as agentId for context injection
// The speaking agent's config is passed via resolvedAgentConfig
expect(streamSpy).toHaveBeenCalledWith(
expect.objectContaining({
params: expect.objectContaining({
// agentId is supervisor for context purposes
agentId: supervisorAgentId,
// subAgentId used as agentId in group orchestration
agentId: subAgentId,
// resolvedAgentConfig contains the speaking agent's config
resolvedAgentConfig: speakingAgentConfig,
}),

View File

@@ -344,13 +344,21 @@ export const streamingExecutor: StateCreator<
log('[internal_fetchAIChatMessage] ERROR: Operation not found: %s', operationId);
throw new Error(`Operation not found: ${operationId}`);
}
agentId = operation.context.agentId!;
subAgentId = operation.context.subAgentId;
topicId = operation.context.topicId;
threadId = operation.context.threadId ?? undefined;
groupId = operation.context.groupId;
scope = operation.context.scope;
subAgentId = operation.context.subAgentId;
abortController = operation.abortController; // 👈 Use operation's abortController
// In group orchestration scenarios (has groupId), subAgentId is the actual responding agent
// Use it for context injection instead of the session agentId
if (groupId && subAgentId) {
agentId = subAgentId;
} else {
agentId = operation.context.agentId!;
}
log(
'[internal_fetchAIChatMessage] get context from operation %s: agentId=%s, subAgentId=%s, topicId=%s, groupId=%s, aborted=%s',
operationId,