mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-27 13:29:15 +07:00
💄 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:
@@ -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 })] : []),
|
||||
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -92,7 +92,7 @@ const CouncilMember = memo<CouncilMemberProps>(({ item, index }) => {
|
||||
showTitle
|
||||
time={createdAt}
|
||||
>
|
||||
<AutoScrollShadow>
|
||||
<AutoScrollShadow content={content} streaming={generating}>
|
||||
<MessageContent {...item} />
|
||||
</AutoScrollShadow>
|
||||
</ChatItem>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user