mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 09:37:28 +00:00
🐛 fix(hetero-agent): persist streamed text alongside tool writes; collapse workflow summary (#13968)
* 🐛 fix(hetero-agent): persist accumulated text alongside tools[] writes Carry the latest streamed content/reasoning into the same UPDATE that writes tools[], so the DB row stays in sync with the in-memory stream. Without this, gateway `tool_end → fetchAndReplaceMessages` reads a tools-only row and clobbers the UI's streamed text. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ✨ feat(workflow-summary): collapse summary when many tool kinds When a turn calls >4 distinct tool kinds, list only the top 3 by count and append "+N more · X calls total[ · Y failed]". Keeps the inline summary scannable on long tool-heavy turns instead of running off the line. Short turns keep the existing full list. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * 💄 style(claude-code): use chip style for Skill inspector name Replace the colon+highlight text with a pill-shaped chip containing the SkillsIcon and skill name. Gives the Skill activation readout visual parity with other tool chips and prevents long skill names from overflowing the inspector line. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ✅ test(agent-documents): assert on rendered title, not filename #13940 changed DocumentItem to prefer document.title over filename, but the sidebar test still expected 'brief.md' / 'example.com'. Align the assertions with the current behavior so the suite is green on canary. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * 💄 style(tab-bar): show agent avatar on agent/topic tabs Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ccbb75da06
commit
77fd0f13f0
9 changed files with 197 additions and 39 deletions
|
|
@ -480,6 +480,9 @@
|
|||
"viewMode.wideScreen": "Widescreen",
|
||||
"workflow.awaitingConfirmation": "Awaiting your confirmation",
|
||||
"workflow.failedSuffix": "(failed)",
|
||||
"workflow.summaryFailed": "{{count}} failed",
|
||||
"workflow.summaryMoreTools": "+{{count}} more",
|
||||
"workflow.summaryTotalCalls": "{{count}} calls total",
|
||||
"workflow.thoughtForDuration": "Thought for {{duration}}",
|
||||
"workflow.toolDisplayName.activateDevice": "Activated device",
|
||||
"workflow.toolDisplayName.activateSkill": "Activated a skill",
|
||||
|
|
|
|||
|
|
@ -480,6 +480,9 @@
|
|||
"viewMode.wideScreen": "宽屏",
|
||||
"workflow.awaitingConfirmation": "需要你确认",
|
||||
"workflow.failedSuffix": "(失败)",
|
||||
"workflow.summaryFailed": "{{count}} 次失败",
|
||||
"workflow.summaryMoreTools": "等 {{count}} 种工具",
|
||||
"workflow.summaryTotalCalls": "共 {{count}} 次调用",
|
||||
"workflow.thoughtForDuration": "思考了 {{duration}}",
|
||||
"workflow.toolDisplayName.activateDevice": "激活了设备",
|
||||
"workflow.toolDisplayName.activateSkill": "启用了技能",
|
||||
|
|
|
|||
|
|
@ -1,17 +1,46 @@
|
|||
'use client';
|
||||
|
||||
import {
|
||||
highlightTextStyles,
|
||||
inspectorTextStyles,
|
||||
shinyTextStyles,
|
||||
} from '@lobechat/shared-tool-ui/styles';
|
||||
import { inspectorTextStyles, shinyTextStyles } from '@lobechat/shared-tool-ui/styles';
|
||||
import type { BuiltinInspectorProps } from '@lobechat/types';
|
||||
import { cx } from 'antd-style';
|
||||
import { SkillsIcon } from '@lobehub/ui/icons';
|
||||
import { createStaticStyles, cx } from 'antd-style';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { ClaudeCodeApiName, type SkillArgs } from '../../types';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
chip: css`
|
||||
overflow: hidden;
|
||||
display: inline-flex;
|
||||
flex-shrink: 1;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
|
||||
min-width: 0;
|
||||
margin-inline-start: 6px;
|
||||
padding-block: 2px;
|
||||
padding-inline: 10px;
|
||||
border-radius: 999px;
|
||||
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
`,
|
||||
skillIcon: css`
|
||||
flex-shrink: 0;
|
||||
color: ${cssVar.colorTextDescription};
|
||||
`,
|
||||
skillName: css`
|
||||
overflow: hidden;
|
||||
|
||||
min-width: 0;
|
||||
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorText};
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`,
|
||||
}));
|
||||
|
||||
export const SkillInspector = memo<BuiltinInspectorProps<SkillArgs>>(
|
||||
({ args, partialArgs, isArgumentsStreaming, isLoading }) => {
|
||||
const { t } = useTranslation('plugin');
|
||||
|
|
@ -31,10 +60,10 @@ export const SkillInspector = memo<BuiltinInspectorProps<SkillArgs>>(
|
|||
>
|
||||
<span>{label}</span>
|
||||
{skillName && (
|
||||
<>
|
||||
<span>: </span>
|
||||
<span className={highlightTextStyles.primary}>{skillName}</span>
|
||||
</>
|
||||
<span className={styles.chip}>
|
||||
<SkillsIcon className={styles.skillIcon} size={12} />
|
||||
<span className={styles.skillName}>{skillName}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -384,6 +384,8 @@ export const formatReasoningDuration = (ms: number): string => {
|
|||
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
|
||||
};
|
||||
|
||||
const WORKFLOW_SUMMARY_TOP_N = 3;
|
||||
|
||||
export const getWorkflowSummaryText = (blocks: AssistantContentBlock[]): string => {
|
||||
const tools = blocks.flatMap((b) => b.tools ?? []);
|
||||
|
||||
|
|
@ -395,16 +397,57 @@ export const getWorkflowSummaryText = (blocks: AssistantContentBlock[]): string
|
|||
groups.set(tool.apiName, existing);
|
||||
}
|
||||
|
||||
const toolParts: string[] = [];
|
||||
for (const [apiName, { count, errorCount }] of groups) {
|
||||
let part = getToolDisplayName(apiName);
|
||||
if (count > 1) part += ` (${count})`;
|
||||
if (errorCount > 0)
|
||||
part += ` ${t('workflow.failedSuffix', { defaultValue: '(failed)', ns: 'chat' })}`;
|
||||
toolParts.push(part);
|
||||
}
|
||||
const entries = [...groups.entries()];
|
||||
const totalCalls = entries.reduce((sum, [, { count }]) => sum + count, 0);
|
||||
const totalErrors = entries.reduce((sum, [, { errorCount }]) => sum + errorCount, 0);
|
||||
|
||||
let result = toolParts.join(', ');
|
||||
let result: string;
|
||||
// Few tool kinds: list each one fully (current behavior). "+1 more" reads awkwardly,
|
||||
// so we only collapse when there are at least 2 extra kinds beyond the top N.
|
||||
if (entries.length <= WORKFLOW_SUMMARY_TOP_N + 1) {
|
||||
const toolParts = entries.map(([apiName, { count, errorCount }]) => {
|
||||
let part = getToolDisplayName(apiName);
|
||||
if (count > 1) part += ` (${count})`;
|
||||
if (errorCount > 0)
|
||||
part += ` ${t('workflow.failedSuffix', { defaultValue: '(failed)', ns: 'chat' })}`;
|
||||
return part;
|
||||
});
|
||||
result = toolParts.join(', ');
|
||||
} else {
|
||||
const sorted = [...entries].sort(([, a], [, b]) => b.count - a.count);
|
||||
const top = sorted.slice(0, WORKFLOW_SUMMARY_TOP_N);
|
||||
const remainingKinds = sorted.length - WORKFLOW_SUMMARY_TOP_N;
|
||||
|
||||
const topText = top
|
||||
.map(([apiName, { count }]) => {
|
||||
const name = getToolDisplayName(apiName);
|
||||
return count > 1 ? `${name} (${count})` : name;
|
||||
})
|
||||
.join(', ');
|
||||
|
||||
const segments: string[] = [
|
||||
`${topText} ${t('workflow.summaryMoreTools', {
|
||||
count: remainingKinds,
|
||||
defaultValue: '+{{count}} more',
|
||||
ns: 'chat',
|
||||
})}`,
|
||||
t('workflow.summaryTotalCalls', {
|
||||
count: totalCalls,
|
||||
defaultValue: '{{count}} calls total',
|
||||
ns: 'chat',
|
||||
}),
|
||||
];
|
||||
if (totalErrors > 0) {
|
||||
segments.push(
|
||||
t('workflow.summaryFailed', {
|
||||
count: totalErrors,
|
||||
defaultValue: '{{count}} failed',
|
||||
ns: 'chat',
|
||||
}),
|
||||
);
|
||||
}
|
||||
result = segments.join(' · ');
|
||||
}
|
||||
|
||||
const totalReasoningMs = blocks.reduce((sum, b) => sum + (b.reasoning?.duration ?? 0), 0);
|
||||
if (totalReasoningMs > 0) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
'use client';
|
||||
|
||||
import { ActionIcon, ContextMenuTrigger, Flexbox, type GenericItemType, Icon } from '@lobehub/ui';
|
||||
import {
|
||||
ActionIcon,
|
||||
Avatar,
|
||||
ContextMenuTrigger,
|
||||
Flexbox,
|
||||
type GenericItemType,
|
||||
Icon,
|
||||
} from '@lobehub/ui';
|
||||
import { cx } from 'antd-style';
|
||||
import { X } from 'lucide-react';
|
||||
import { memo, useCallback } from 'react';
|
||||
|
|
@ -91,7 +98,17 @@ const TabItem = memo<TabItemProps>(
|
|||
gap={6}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{item.icon && <Icon className={styles.tabIcon} icon={item.icon} size="small" />}
|
||||
{item.avatar ? (
|
||||
<Avatar
|
||||
emojiScaleWithBackground
|
||||
avatar={item.avatar}
|
||||
background={item.backgroundColor}
|
||||
shape="square"
|
||||
size={16}
|
||||
/>
|
||||
) : (
|
||||
item.icon && <Icon className={styles.tabIcon} icon={item.icon} size="small" />
|
||||
)}
|
||||
<span className={styles.tabTitle}>{item.title}</span>
|
||||
<ActionIcon
|
||||
className={cx('closeIcon', styles.closeIcon)}
|
||||
|
|
|
|||
|
|
@ -529,6 +529,9 @@ export default {
|
|||
'viewMode.normal': 'Standard',
|
||||
'viewMode.wideScreen': 'Widescreen',
|
||||
'workflow.failedSuffix': '(failed)',
|
||||
'workflow.summaryFailed': '{{count}} failed',
|
||||
'workflow.summaryMoreTools': '+{{count}} more',
|
||||
'workflow.summaryTotalCalls': '{{count}} calls total',
|
||||
'workflow.thoughtForDuration': 'Thought for {{duration}}',
|
||||
'workflow.toolDisplayName.activateDevice': 'Activated device',
|
||||
'workflow.toolDisplayName.activateSkill': 'Activated a skill',
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ describe('AgentDocumentsGroup', () => {
|
|||
|
||||
render(<AgentDocumentsGroup />);
|
||||
|
||||
const item = await screen.findByText('brief.md');
|
||||
const item = await screen.findByText('Brief');
|
||||
expect(item).toBeInTheDocument();
|
||||
expect(screen.getByText('A short brief')).toBeInTheDocument();
|
||||
|
||||
|
|
@ -160,18 +160,18 @@ describe('AgentDocumentsGroup', () => {
|
|||
|
||||
render(<AgentDocumentsGroup />);
|
||||
|
||||
expect(screen.getByText('brief.md')).toBeInTheDocument();
|
||||
expect(screen.getByText('example.com')).toBeInTheDocument();
|
||||
expect(screen.getByText('Brief')).toBeInTheDocument();
|
||||
expect(screen.getByText('Example')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText('Web'));
|
||||
|
||||
expect(screen.queryByText('brief.md')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('example.com')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Brief')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Example')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText('Documents'));
|
||||
|
||||
expect(screen.getByText('brief.md')).toBeInTheDocument();
|
||||
expect(screen.queryByText('example.com')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Brief')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Example')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders empty state when no documents', () => {
|
||||
|
|
|
|||
|
|
@ -928,6 +928,41 @@ describe('heterogeneousAgentExecutor DB persistence', () => {
|
|||
expect(glob1Create![0].parentId).toBe(turn5AssistantId);
|
||||
expect(glob2Create![0].parentId).toBe(turn5AssistantId);
|
||||
});
|
||||
|
||||
/**
|
||||
* Regression: when a turn has text BEFORE tool_use under the same message.id,
|
||||
* the tools[] write must carry the accumulated content too. Otherwise the
|
||||
* gateway handler's `tool_end → fetchAndReplaceMessages` reads a tools-only
|
||||
* row and clobbers the in-memory streamed text in the UI.
|
||||
*/
|
||||
it('should persist accumulated text alongside tools when turn has text + tool_use', async () => {
|
||||
const writes: Array<{ assistantId: string; content?: string; toolIds?: string[] }> = [];
|
||||
mockUpdateMessage.mockImplementation(async (id: string, val: any) => {
|
||||
if (val.tools) {
|
||||
writes.push({
|
||||
assistantId: id,
|
||||
content: val.content,
|
||||
toolIds: val.tools.map((t: any) => t.id),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await runWithEvents([
|
||||
ccInit(),
|
||||
// text streams first, then tool_use — same msg.id
|
||||
ccText('msg_01', 'Let me check the file...'),
|
||||
ccToolUse('msg_01', 'toolu_read', 'Read', { file_path: '/a.ts' }),
|
||||
ccToolResult('toolu_read', 'file content'),
|
||||
ccResult(),
|
||||
]);
|
||||
|
||||
const toolWrites = writes.filter((w) => w.toolIds?.includes('toolu_read'));
|
||||
expect(toolWrites.length).toBeGreaterThanOrEqual(1);
|
||||
// Every tools[] write for this assistant must carry the accumulated text
|
||||
for (const w of toolWrites) {
|
||||
expect(w.content).toBe('Let me check the file...');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -135,12 +135,17 @@ interface ToolPersistenceState {
|
|||
* - assistant.tools[].result_msg_id is set to the created tool message id, so
|
||||
* the UI's parse() step can link tool messages back to the assistant turn
|
||||
* (otherwise they render as orphan warnings).
|
||||
* - Carries the latest accumulated text/reasoning into the same UPDATE, so DB
|
||||
* stays in sync with what's been streamed. Without this, gateway handler's
|
||||
* `tool_end → fetchAndReplaceMessages` would read a tools-only/no-content
|
||||
* row and clobber the in-memory streamed text in the UI.
|
||||
*/
|
||||
const persistNewToolCalls = async (
|
||||
incoming: ToolCallPayload[],
|
||||
state: ToolPersistenceState,
|
||||
assistantMessageId: string,
|
||||
context: ConversationContext,
|
||||
snapshot: { content: string; reasoning: string },
|
||||
) => {
|
||||
const freshTools = incoming.filter((t) => !state.persistedIds.has(t.id));
|
||||
if (freshTools.length === 0) return;
|
||||
|
|
@ -149,6 +154,13 @@ const persistNewToolCalls = async (
|
|||
// Claude Code echoing tool_use blocks) are safely deduped.
|
||||
for (const tool of freshTools) state.persistedIds.add(tool.id);
|
||||
|
||||
const buildUpdate = (): Record<string, any> => {
|
||||
const update: Record<string, any> = { tools: state.payloads };
|
||||
if (snapshot.content) update.content = snapshot.content;
|
||||
if (snapshot.reasoning) update.reasoning = { content: snapshot.reasoning };
|
||||
return update;
|
||||
};
|
||||
|
||||
// ─── PHASE 1: Write tools[] to assistant FIRST, WITHOUT result_msg_id ───
|
||||
//
|
||||
// LobeHub's conversation-flow parser filters tool messages by matching
|
||||
|
|
@ -161,11 +173,10 @@ const persistNewToolCalls = async (
|
|||
// No orphan window.
|
||||
for (const tool of freshTools) state.payloads.push({ ...tool } as ChatToolPayload);
|
||||
try {
|
||||
await messageService.updateMessage(
|
||||
assistantMessageId,
|
||||
{ tools: state.payloads },
|
||||
{ agentId: context.agentId, topicId: context.topicId },
|
||||
);
|
||||
await messageService.updateMessage(assistantMessageId, buildUpdate(), {
|
||||
agentId: context.agentId,
|
||||
topicId: context.topicId,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[HeterogeneousAgent] Failed to pre-register assistant tools:', err);
|
||||
}
|
||||
|
|
@ -201,11 +212,10 @@ const persistNewToolCalls = async (
|
|||
// ─── PHASE 3: Re-write assistant.tools[] with the result_msg_ids ───
|
||||
// Without this, the UI can't hydrate tool results back into the inspector.
|
||||
try {
|
||||
await messageService.updateMessage(
|
||||
assistantMessageId,
|
||||
{ tools: state.payloads },
|
||||
{ agentId: context.agentId, topicId: context.topicId },
|
||||
);
|
||||
await messageService.updateMessage(assistantMessageId, buildUpdate(), {
|
||||
agentId: context.agentId,
|
||||
topicId: context.topicId,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[HeterogeneousAgent] Failed to finalize assistant tools:', err);
|
||||
}
|
||||
|
|
@ -508,8 +518,23 @@ export const executeHeterogeneousAgent = async (
|
|||
if (chunk?.chunkType === 'tools_calling') {
|
||||
const tools = chunk.toolsCalling as ToolCallPayload[];
|
||||
if (tools?.length) {
|
||||
// Snapshot accumulators sync — must travel with the same step's
|
||||
// assistantMessageId. A late-bound getter would read NEW step's
|
||||
// content if a step transition lands between scheduling and
|
||||
// execution, while assistantMessageId would still be the OLD
|
||||
// one (also captured sync) → cross-step contamination.
|
||||
const snapshot = {
|
||||
content: accumulatedContent,
|
||||
reasoning: accumulatedReasoning,
|
||||
};
|
||||
persistQueue = persistQueue.then(() =>
|
||||
persistNewToolCalls(tools, toolState, currentAssistantMessageId, context),
|
||||
persistNewToolCalls(
|
||||
tools,
|
||||
toolState,
|
||||
currentAssistantMessageId,
|
||||
context,
|
||||
snapshot,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue