🐛 fix(chat): refine workflow collapse headline (#13717)

* 🐛 fix(chat): refine workflow collapse headline

* 🐛 fix(chat): use state machine for workflow headline

* 🐛 fix(chat): backtrack workflow headline state

* ♻️ refactor(chat): simplify workflow headline selector

* 💄 style(chat): use lucide workflow collapse arrow

* ♻️ refactor(chat): use accordion indicator layout

* Move workflow duration text beside the title

* Localize workflow tool display labels

* Update Page workflow localization labels

* fix: sort imports in toolDisplayNames.test.ts
This commit is contained in:
Innei
2026-04-11 00:49:25 +08:00
committed by GitHub
parent 5d135b3ae1
commit 48d0a759a8
7 changed files with 558 additions and 117 deletions
+74
View File
@@ -465,6 +465,80 @@
"viewMode.fullWidth": "Full Width",
"viewMode.normal": "Standard",
"viewMode.wideScreen": "Widescreen",
"workflow.failedSuffix": "(failed)",
"workflow.thoughtForDuration": "Thought for {{duration}}",
"workflow.toolDisplayName.activateDevice": "Activated device",
"workflow.toolDisplayName.activateSkill": "Activated a skill",
"workflow.toolDisplayName.activateTools": "Activated tools",
"workflow.toolDisplayName.addActivityMemory": "Saved memory",
"workflow.toolDisplayName.addContextMemory": "Saved memory",
"workflow.toolDisplayName.addExperienceMemory": "Saved memory",
"workflow.toolDisplayName.addIdentityMemory": "Saved memory",
"workflow.toolDisplayName.addPreferenceMemory": "Saved memory",
"workflow.toolDisplayName.calculate": "Calculated",
"workflow.toolDisplayName.callAgent": "Called an agent",
"workflow.toolDisplayName.clearTodos": "Cleared todos",
"workflow.toolDisplayName.copyDocument": "Copied a document",
"workflow.toolDisplayName.crawlMultiPages": "Crawled pages",
"workflow.toolDisplayName.crawlSinglePage": "Crawled a page",
"workflow.toolDisplayName.createAgent": "Created an agent",
"workflow.toolDisplayName.createDocument": "Created a document",
"workflow.toolDisplayName.createPlan": "Created a plan",
"workflow.toolDisplayName.createTodos": "Created todos",
"workflow.toolDisplayName.deleteAgent": "Deleted an agent",
"workflow.toolDisplayName.deleteDocument": "Deleted a document",
"workflow.toolDisplayName.editDocument": "Edited a document",
"workflow.toolDisplayName.editLocalFile": "Edited a file",
"workflow.toolDisplayName.editTitle": "Edited title",
"workflow.toolDisplayName.evaluate": "Evaluated expression",
"workflow.toolDisplayName.execScript": "Executed a script",
"workflow.toolDisplayName.execTask": "Executed a task",
"workflow.toolDisplayName.execTasks": "Executed tasks",
"workflow.toolDisplayName.execute": "Executed calculation",
"workflow.toolDisplayName.executeCode": "Executed code",
"workflow.toolDisplayName.finishOnboarding": "Finished onboarding",
"workflow.toolDisplayName.getCommandOutput": "Read command output",
"workflow.toolDisplayName.getDocument": "Read a document",
"workflow.toolDisplayName.getOnboardingState": "Checked onboarding state",
"workflow.toolDisplayName.getPageContent": "Read Page content",
"workflow.toolDisplayName.getTopicContext": "Read topic context",
"workflow.toolDisplayName.globLocalFiles": "Searched files",
"workflow.toolDisplayName.grepContent": "Searched content",
"workflow.toolDisplayName.importFromMarket": "Imported from market",
"workflow.toolDisplayName.importSkill": "Imported a skill",
"workflow.toolDisplayName.initPage": "Initialized Page",
"workflow.toolDisplayName.killCommand": "Stopped a command",
"workflow.toolDisplayName.listDocuments": "Listed documents",
"workflow.toolDisplayName.listLocalFiles": "Listed files",
"workflow.toolDisplayName.listOnlineDevices": "Listed devices",
"workflow.toolDisplayName.modifyNodes": "Modified Page",
"workflow.toolDisplayName.moveLocalFiles": "Moved files",
"workflow.toolDisplayName.readDocument": "Read a document",
"workflow.toolDisplayName.readDocumentByFilename": "Read a document",
"workflow.toolDisplayName.readKnowledge": "Read knowledge",
"workflow.toolDisplayName.readLocalFile": "Read a file",
"workflow.toolDisplayName.removeDocument": "Removed a document",
"workflow.toolDisplayName.removeIdentityMemory": "Removed memory",
"workflow.toolDisplayName.renameDocument": "Renamed a document",
"workflow.toolDisplayName.renameLocalFile": "Renamed a file",
"workflow.toolDisplayName.replaceText": "Replaced text",
"workflow.toolDisplayName.runCommand": "Ran a command",
"workflow.toolDisplayName.search": "Searched the web",
"workflow.toolDisplayName.searchAgent": "Searched agents",
"workflow.toolDisplayName.searchKnowledgeBase": "Searched knowledge base",
"workflow.toolDisplayName.searchLocalFiles": "Searched files",
"workflow.toolDisplayName.searchSkill": "Searched skills",
"workflow.toolDisplayName.searchUserMemory": "Searched memory",
"workflow.toolDisplayName.solve": "Solved equation",
"workflow.toolDisplayName.updateAgent": "Updated an agent",
"workflow.toolDisplayName.updateDocument": "Updated a document",
"workflow.toolDisplayName.updateIdentityMemory": "Updated memory",
"workflow.toolDisplayName.updateLoadRule": "Updated load rule",
"workflow.toolDisplayName.updatePlan": "Updated plan",
"workflow.toolDisplayName.updateTodos": "Updated todos",
"workflow.toolDisplayName.upsertDocumentByFilename": "Updated a document",
"workflow.toolDisplayName.writeLocalFile": "Wrote a file",
"workflow.working": "Working...",
"you": "You",
"zenMode": "Zen Mode"
}
+74
View File
@@ -465,6 +465,80 @@
"viewMode.fullWidth": "全宽显示",
"viewMode.normal": "普通",
"viewMode.wideScreen": "宽屏",
"workflow.failedSuffix": "(失败)",
"workflow.thoughtForDuration": "思考了 {{duration}}",
"workflow.toolDisplayName.activateDevice": "激活了设备",
"workflow.toolDisplayName.activateSkill": "启用了技能",
"workflow.toolDisplayName.activateTools": "启用了工具",
"workflow.toolDisplayName.addActivityMemory": "保存了记忆",
"workflow.toolDisplayName.addContextMemory": "保存了记忆",
"workflow.toolDisplayName.addExperienceMemory": "保存了记忆",
"workflow.toolDisplayName.addIdentityMemory": "保存了记忆",
"workflow.toolDisplayName.addPreferenceMemory": "保存了记忆",
"workflow.toolDisplayName.calculate": "完成了计算",
"workflow.toolDisplayName.callAgent": "调用了助理",
"workflow.toolDisplayName.clearTodos": "清空了待办",
"workflow.toolDisplayName.copyDocument": "复制了文档",
"workflow.toolDisplayName.crawlMultiPages": "抓取了多个页面",
"workflow.toolDisplayName.crawlSinglePage": "抓取了页面",
"workflow.toolDisplayName.createAgent": "创建了助理",
"workflow.toolDisplayName.createDocument": "创建了文档",
"workflow.toolDisplayName.createPlan": "创建了计划",
"workflow.toolDisplayName.createTodos": "创建了待办",
"workflow.toolDisplayName.deleteAgent": "删除了助理",
"workflow.toolDisplayName.deleteDocument": "删除了文档",
"workflow.toolDisplayName.editDocument": "编辑了文档",
"workflow.toolDisplayName.editLocalFile": "编辑了文件",
"workflow.toolDisplayName.editTitle": "编辑了标题",
"workflow.toolDisplayName.evaluate": "求值了表达式",
"workflow.toolDisplayName.execScript": "执行了脚本",
"workflow.toolDisplayName.execTask": "执行了任务",
"workflow.toolDisplayName.execTasks": "执行了任务",
"workflow.toolDisplayName.execute": "执行了计算",
"workflow.toolDisplayName.executeCode": "执行了代码",
"workflow.toolDisplayName.finishOnboarding": "完成了引导",
"workflow.toolDisplayName.getCommandOutput": "读取了命令输出",
"workflow.toolDisplayName.getDocument": "读取了文档",
"workflow.toolDisplayName.getOnboardingState": "检查了引导状态",
"workflow.toolDisplayName.getPageContent": "读取了文稿内容",
"workflow.toolDisplayName.getTopicContext": "读取了话题上下文",
"workflow.toolDisplayName.globLocalFiles": "搜索了文件",
"workflow.toolDisplayName.grepContent": "搜索了内容",
"workflow.toolDisplayName.importFromMarket": "从市场导入了内容",
"workflow.toolDisplayName.importSkill": "导入了技能",
"workflow.toolDisplayName.initPage": "初始化了文稿",
"workflow.toolDisplayName.killCommand": "停止了命令",
"workflow.toolDisplayName.listDocuments": "列出了文档",
"workflow.toolDisplayName.listLocalFiles": "列出了文件",
"workflow.toolDisplayName.listOnlineDevices": "列出了在线设备",
"workflow.toolDisplayName.modifyNodes": "修改了文稿",
"workflow.toolDisplayName.moveLocalFiles": "移动了文件",
"workflow.toolDisplayName.readDocument": "读取了文档",
"workflow.toolDisplayName.readDocumentByFilename": "读取了文档",
"workflow.toolDisplayName.readKnowledge": "读取了知识",
"workflow.toolDisplayName.readLocalFile": "读取了文件",
"workflow.toolDisplayName.removeDocument": "移除了文档",
"workflow.toolDisplayName.removeIdentityMemory": "移除了记忆",
"workflow.toolDisplayName.renameDocument": "重命名了文档",
"workflow.toolDisplayName.renameLocalFile": "重命名了文件",
"workflow.toolDisplayName.replaceText": "替换了文本",
"workflow.toolDisplayName.runCommand": "运行了命令",
"workflow.toolDisplayName.search": "搜索了网络",
"workflow.toolDisplayName.searchAgent": "搜索了助理",
"workflow.toolDisplayName.searchKnowledgeBase": "搜索了知识库",
"workflow.toolDisplayName.searchLocalFiles": "搜索了文件",
"workflow.toolDisplayName.searchSkill": "搜索了技能",
"workflow.toolDisplayName.searchUserMemory": "搜索了记忆",
"workflow.toolDisplayName.solve": "求解了方程",
"workflow.toolDisplayName.updateAgent": "更新了助理",
"workflow.toolDisplayName.updateDocument": "更新了文档",
"workflow.toolDisplayName.updateIdentityMemory": "更新了记忆",
"workflow.toolDisplayName.updateLoadRule": "更新了加载规则",
"workflow.toolDisplayName.updatePlan": "更新了计划",
"workflow.toolDisplayName.updateTodos": "更新了待办",
"workflow.toolDisplayName.upsertDocumentByFilename": "更新了文档",
"workflow.toolDisplayName.writeLocalFile": "写入了文件",
"workflow.working": "处理中...",
"you": "你",
"zenMode": "专注模式"
}
@@ -4,6 +4,7 @@ import { cssVar } from 'antd-style';
import { Check, X } from 'lucide-react';
import { AnimatePresence, m as motion } from 'motion/react';
import { type Key, memo, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import NeuralNetworkLoading from '@/components/NeuralNetworkLoading';
import { useAutoScroll } from '@/hooks/useAutoScroll';
@@ -23,7 +24,7 @@ import {
import {
areWorkflowToolsComplete,
formatReasoningDuration,
getWorkflowStreamingHeadlineParts,
getWorkflowStreamingHeadlineState,
getWorkflowSummaryText,
hasToolError,
shapeProseForWorkflowHeadline,
@@ -42,7 +43,7 @@ const collectTools = (blocks: AssistantContentBlock[]): ChatToolPayloadWithResul
return blocks.flatMap((b) => b.tools ?? []);
};
const useDebouncedHeadline = (raw: string, allComplete: boolean) => {
const useDebouncedHeadline = (raw: string, allComplete: boolean, immediate = false) => {
const [out, setOut] = useState(raw);
const prevCompleteRef = useRef(allComplete);
@@ -51,6 +52,10 @@ const useDebouncedHeadline = (raw: string, allComplete: boolean) => {
prevCompleteRef.current = allComplete;
const streaming = !allComplete;
if (immediate) {
setOut(raw);
return;
}
if (!streaming) {
setOut(raw);
return;
@@ -61,7 +66,7 @@ const useDebouncedHeadline = (raw: string, allComplete: boolean) => {
}
const id = window.setTimeout(() => setOut(raw), WORKFLOW_HEADLINE_DEBOUNCE_MS);
return () => window.clearTimeout(id);
}, [allComplete, raw]);
}, [allComplete, immediate, raw]);
return !allComplete ? out : raw;
};
@@ -94,6 +99,7 @@ const useCommittedProseHeadline = (proseSource: string, streaming: boolean) => {
const WorkflowCollapse = memo<WorkflowCollapseProps>(
({ assistantMessageId, blocks, disableEditing, workflowChromeComplete = false }) => {
const { t } = useTranslation('chat');
const allTools = useMemo(() => collectTools(blocks), [blocks]);
const toolsPhaseComplete = areWorkflowToolsComplete(allTools);
const isGenerating = useConversationStore(
@@ -131,19 +137,36 @@ const WorkflowCollapse = memo<WorkflowCollapseProps>(
const streaming = !allComplete;
const isExpanded = expanded;
const { explicitStep, fallbackTool, proseSource } = useMemo(
() => getWorkflowStreamingHeadlineParts(blocks, allTools),
[blocks, allTools],
const headlineState = useMemo(() => getWorkflowStreamingHeadlineState(blocks), [blocks]);
const committedProse = useCommittedProseHeadline(
headlineState.kind === 'prose' ? headlineState.proseSource : '',
streaming,
);
const committedProse = useCommittedProseHeadline(proseSource, streaming);
const showExpandedWorkingLabel = streaming && isExpanded;
const workingLabel = t('workflow.working', { defaultValue: 'Working...' });
const streamingHeadlineRaw = useMemo(() => {
if (explicitStep) return explicitStep;
if (committedProse) return committedProse;
if (fallbackTool) return fallbackTool;
return '';
}, [committedProse, explicitStep, fallbackTool]);
const streamingHeadline = useDebouncedHeadline(streamingHeadlineRaw, allComplete);
if (showExpandedWorkingLabel) return workingLabel;
switch (headlineState.kind) {
case 'thinking': {
return headlineState.reasoningTitle;
}
case 'tool': {
return headlineState.explicitStep || headlineState.fallbackTool;
}
case 'prose': {
return committedProse;
}
default: {
return '';
}
}
}, [committedProse, headlineState, showExpandedWorkingLabel, workingLabel]);
const streamingHeadline = useDebouncedHeadline(
streamingHeadlineRaw,
allComplete,
showExpandedWorkingLabel,
);
const [workingElapsedSeconds, setWorkingElapsedSeconds] = useState(0);
@@ -210,7 +233,7 @@ const WorkflowCollapse = memo<WorkflowCollapseProps>(
gap={6}
style={{ minHeight: WORKFLOW_STREAMING_TITLE_MIN_HEIGHT_PX, minWidth: 0 }}
>
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden' }}>
<div style={{ minWidth: 0, overflow: 'hidden' }}>
<AnimatePresence initial={false} mode="wait">
<motion.div
animate={{ opacity: 1, y: 0 }}
@@ -232,7 +255,7 @@ const WorkflowCollapse = memo<WorkflowCollapseProps>(
whiteSpace: 'nowrap',
}}
>
{streamingHeadline || 'Working...'}
{streamingHeadline || workingLabel}
</span>
</motion.div>
</AnimatePresence>
@@ -246,8 +269,13 @@ const WorkflowCollapse = memo<WorkflowCollapseProps>(
) : (
<Flexbox horizontal align="center" gap={6} style={{ minWidth: 0, overflow: 'hidden' }}>
<Text
style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
type="secondary"
style={{
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{summaryText}
</Text>
@@ -264,7 +292,6 @@ const WorkflowCollapse = memo<WorkflowCollapseProps>(
return (
<Accordion
expandedKeys={isExpanded ? ['workflow'] : []}
indicatorPlacement="end"
variant="borderless"
onExpandedChange={handleExpandedChange}
>
@@ -102,106 +102,106 @@ export const DURATION_SECONDS_PER_MINUTE = 60;
/** Duration inputs are in milliseconds; convert to whole seconds for display. */
export const TIME_MS_PER_SECOND = 1000;
// ─── apiName → past-tense human-readable label (workflow summary & headlines) ─
// ─── apiName → i18n key for human-readable label (workflow summary & headlines) ─
/** Past-tense labels for built-in / known tool api names. Unknown api names use title-cased fallback. */
/** Translation keys for built-in / known tool api names. Unknown api names use title-cased fallback. */
export const TOOL_API_DISPLAY_NAMES: Record<string, string> = {
// Web browsing
crawlMultiPages: 'Crawled pages',
crawlSinglePage: 'Crawled a page',
search: 'Searched the web',
crawlMultiPages: 'workflow.toolDisplayName.crawlMultiPages',
crawlSinglePage: 'workflow.toolDisplayName.crawlSinglePage',
search: 'workflow.toolDisplayName.search',
// Knowledge base
readKnowledge: 'Read knowledge',
searchKnowledgeBase: 'Searched knowledge base',
readKnowledge: 'workflow.toolDisplayName.readKnowledge',
searchKnowledgeBase: 'workflow.toolDisplayName.searchKnowledgeBase',
// Notebook
createDocument: 'Created a document',
deleteDocument: 'Deleted a document',
getDocument: 'Read a document',
updateDocument: 'Updated a document',
createDocument: 'workflow.toolDisplayName.createDocument',
deleteDocument: 'workflow.toolDisplayName.deleteDocument',
getDocument: 'workflow.toolDisplayName.getDocument',
updateDocument: 'workflow.toolDisplayName.updateDocument',
// Agent documents
copyDocument: 'Copied a document',
editDocument: 'Edited a document',
listDocuments: 'Listed documents',
readDocument: 'Read a document',
readDocumentByFilename: 'Read a document',
removeDocument: 'Removed a document',
renameDocument: 'Renamed a document',
upsertDocumentByFilename: 'Updated a document',
updateLoadRule: 'Updated load rule',
copyDocument: 'workflow.toolDisplayName.copyDocument',
editDocument: 'workflow.toolDisplayName.editDocument',
listDocuments: 'workflow.toolDisplayName.listDocuments',
readDocument: 'workflow.toolDisplayName.readDocument',
readDocumentByFilename: 'workflow.toolDisplayName.readDocumentByFilename',
removeDocument: 'workflow.toolDisplayName.removeDocument',
renameDocument: 'workflow.toolDisplayName.renameDocument',
upsertDocumentByFilename: 'workflow.toolDisplayName.upsertDocumentByFilename',
updateLoadRule: 'workflow.toolDisplayName.updateLoadRule',
// Calculator
calculate: 'Calculated',
evaluate: 'Evaluated expression',
solve: 'Solved equation',
execute: 'Executed calculation',
calculate: 'workflow.toolDisplayName.calculate',
evaluate: 'workflow.toolDisplayName.evaluate',
solve: 'workflow.toolDisplayName.solve',
execute: 'workflow.toolDisplayName.execute',
// Local system
editLocalFile: 'Edited a file',
globLocalFiles: 'Searched files',
grepContent: 'Searched content',
killCommand: 'Stopped a command',
listLocalFiles: 'Listed files',
moveLocalFiles: 'Moved files',
readLocalFile: 'Read a file',
renameLocalFile: 'Renamed a file',
runCommand: 'Ran a command',
searchLocalFiles: 'Searched files',
writeLocalFile: 'Wrote a file',
getCommandOutput: 'Read command output',
editLocalFile: 'workflow.toolDisplayName.editLocalFile',
globLocalFiles: 'workflow.toolDisplayName.globLocalFiles',
grepContent: 'workflow.toolDisplayName.grepContent',
killCommand: 'workflow.toolDisplayName.killCommand',
listLocalFiles: 'workflow.toolDisplayName.listLocalFiles',
moveLocalFiles: 'workflow.toolDisplayName.moveLocalFiles',
readLocalFile: 'workflow.toolDisplayName.readLocalFile',
renameLocalFile: 'workflow.toolDisplayName.renameLocalFile',
runCommand: 'workflow.toolDisplayName.runCommand',
searchLocalFiles: 'workflow.toolDisplayName.searchLocalFiles',
writeLocalFile: 'workflow.toolDisplayName.writeLocalFile',
getCommandOutput: 'workflow.toolDisplayName.getCommandOutput',
// Cloud sandbox
executeCode: 'Executed code',
executeCode: 'workflow.toolDisplayName.executeCode',
// GTD
createPlan: 'Created a plan',
createTodos: 'Created todos',
updatePlan: 'Updated plan',
updateTodos: 'Updated todos',
clearTodos: 'Cleared todos',
execTask: 'Executed a task',
execTasks: 'Executed tasks',
createPlan: 'workflow.toolDisplayName.createPlan',
createTodos: 'workflow.toolDisplayName.createTodos',
updatePlan: 'workflow.toolDisplayName.updatePlan',
updateTodos: 'workflow.toolDisplayName.updateTodos',
clearTodos: 'workflow.toolDisplayName.clearTodos',
execTask: 'workflow.toolDisplayName.execTask',
execTasks: 'workflow.toolDisplayName.execTasks',
// Memory
addActivityMemory: 'Saved memory',
addContextMemory: 'Saved memory',
addExperienceMemory: 'Saved memory',
addIdentityMemory: 'Saved memory',
addPreferenceMemory: 'Saved memory',
removeIdentityMemory: 'Removed memory',
searchUserMemory: 'Searched memory',
updateIdentityMemory: 'Updated memory',
addActivityMemory: 'workflow.toolDisplayName.addActivityMemory',
addContextMemory: 'workflow.toolDisplayName.addContextMemory',
addExperienceMemory: 'workflow.toolDisplayName.addExperienceMemory',
addIdentityMemory: 'workflow.toolDisplayName.addIdentityMemory',
addPreferenceMemory: 'workflow.toolDisplayName.addPreferenceMemory',
removeIdentityMemory: 'workflow.toolDisplayName.removeIdentityMemory',
searchUserMemory: 'workflow.toolDisplayName.searchUserMemory',
updateIdentityMemory: 'workflow.toolDisplayName.updateIdentityMemory',
// Agent management
callAgent: 'Called an agent',
createAgent: 'Created an agent',
deleteAgent: 'Deleted an agent',
searchAgent: 'Searched agents',
updateAgent: 'Updated an agent',
callAgent: 'workflow.toolDisplayName.callAgent',
createAgent: 'workflow.toolDisplayName.createAgent',
deleteAgent: 'workflow.toolDisplayName.deleteAgent',
searchAgent: 'workflow.toolDisplayName.searchAgent',
updateAgent: 'workflow.toolDisplayName.updateAgent',
// Page agent
editTitle: 'Edited title',
getPageContent: 'Read page content',
initPage: 'Initialized page',
modifyNodes: 'Modified page',
replaceText: 'Replaced text',
editTitle: 'workflow.toolDisplayName.editTitle',
getPageContent: 'workflow.toolDisplayName.getPageContent',
initPage: 'workflow.toolDisplayName.initPage',
modifyNodes: 'workflow.toolDisplayName.modifyNodes',
replaceText: 'workflow.toolDisplayName.replaceText',
// Skills
activateSkill: 'Activated a skill',
activateTools: 'Activated tools',
execScript: 'Executed a script',
activateSkill: 'workflow.toolDisplayName.activateSkill',
activateTools: 'workflow.toolDisplayName.activateTools',
execScript: 'workflow.toolDisplayName.execScript',
// Skill store
importFromMarket: 'Imported from market',
importSkill: 'Imported a skill',
searchSkill: 'Searched skills',
importFromMarket: 'workflow.toolDisplayName.importFromMarket',
importSkill: 'workflow.toolDisplayName.importSkill',
searchSkill: 'workflow.toolDisplayName.searchSkill',
// Misc
finishOnboarding: 'Finished onboarding',
getOnboardingState: 'Checked onboarding state',
getTopicContext: 'Read topic context',
listOnlineDevices: 'Listed devices',
activateDevice: 'Activated device',
finishOnboarding: 'workflow.toolDisplayName.finishOnboarding',
getOnboardingState: 'workflow.toolDisplayName.getOnboardingState',
getTopicContext: 'workflow.toolDisplayName.getTopicContext',
listOnlineDevices: 'workflow.toolDisplayName.listOnlineDevices',
activateDevice: 'workflow.toolDisplayName.activateDevice',
};
@@ -5,6 +5,7 @@ import { type AssistantContentBlock } from '@/types/index';
import { POST_TOOL_FINAL_ANSWER_SCORE_THRESHOLD } from './constants';
import {
getPostToolAnswerSplitIndex,
getWorkflowStreamingHeadlineState,
scorePostToolBlockAsFinalAnswer,
shapeProseForWorkflowHeadline,
} from './toolDisplayNames';
@@ -54,3 +55,122 @@ describe('post-tool final answer split', () => {
expect(getPostToolAnswerSplitIndex(blocks, 0, true, true)).toBeNull();
});
});
describe('reasoning headline extraction', () => {
it('uses the last markdown heading for a trailing thinking-only block', () => {
const state = getWorkflowStreamingHeadlineState([
blk({
id: '0',
content: '',
reasoning: {
content:
'# Initial framing\n\nSome details.\n\n## Search release notes\n\nMore details.\n\n### Finalize patch plan',
} as any,
}),
]);
expect(state).toEqual({
kind: 'thinking',
reasoningTitle: 'Finalize patch plan',
});
});
it('prefers tool state when the trailing block has tools', () => {
const state = getWorkflowStreamingHeadlineState([
blk({
id: '0',
reasoning: {
content: '### Search release notes',
} as any,
}),
blk({
id: '1',
tools: [
{
apiName: 'search',
arguments: '{"query":"Node.js 24"}',
result: {
state: { workflowHeadline: { stepMessage: 'Searching release notes' } },
},
} as any,
],
}),
]);
expect(state).toEqual({
explicitStep: 'Searched the web: Searching release notes',
fallbackTool: 'Searched the web: Node.js 24',
kind: 'tool',
});
});
it('uses prose state when the trailing block is prose', () => {
const state = getWorkflowStreamingHeadlineState([
blk({
id: '0',
tools: [{ apiName: 'search', id: 't1' } as any],
}),
blk({
id: '1',
content: 'Now I will compare the release notes and summarize the migration changes.',
reasoning: {
content: '### Planning',
} as any,
}),
]);
expect(state).toEqual({
kind: 'prose',
proseSource: 'Now I will compare the release notes and summarize the migration changes.',
});
});
it('falls back to the previous usable block when trailing thinking has no heading', () => {
const state = getWorkflowStreamingHeadlineState([
blk({
id: '0',
tools: [
{
apiName: 'search',
arguments: '{"query":"Node.js 24"}',
result: {
state: { workflowHeadline: { stepMessage: 'Searching release notes' } },
},
} as any,
],
}),
blk({
id: '1',
reasoning: {
content: 'Thinking through the comparison strategy without a markdown heading.',
} as any,
}),
]);
expect(state).toEqual({
explicitStep: 'Searched the web: Searching release notes',
fallbackTool: 'Searched the web: Node.js 24',
kind: 'tool',
});
});
it('falls back to the previous usable block when trailing prose is too short', () => {
const state = getWorkflowStreamingHeadlineState([
blk({
id: '0',
reasoning: {
content: '### Search release notes',
} as any,
}),
blk({
id: '1',
content: 'ok',
}),
]);
expect(state).toEqual({
kind: 'thinking',
reasoningTitle: 'Search release notes',
});
});
});
@@ -1,4 +1,5 @@
import { type ChatToolPayloadWithResult } from '@lobechat/types';
import { t } from 'i18next';
import { LOADING_FLAT } from '@/const/message';
import { type AssistantContentBlock } from '@/types/index';
@@ -95,7 +96,11 @@ const toTitleCase = (apiName: string): string => {
};
export const getToolDisplayName = (apiName: string): string => {
return TOOL_API_DISPLAY_NAMES[apiName] || toTitleCase(apiName);
const defaultValue = toTitleCase(apiName);
const key = TOOL_API_DISPLAY_NAMES[apiName];
if (!key) return defaultValue;
return t(key, { defaultValue, ns: 'chat' });
};
export const getToolSummaryText = (tools: ChatToolPayloadWithResult[]): string => {
@@ -235,6 +240,27 @@ const stripLightMarkdownForHeadline = (md: string): string => {
return s;
};
const extractMarkdownHeadingTitle = (md: string): string => {
const withoutCode = md.replaceAll(/```[\s\S]*?```/g, ' ');
const lines = withoutCode.split('\n');
let lastTitle = '';
for (const line of lines) {
const match = line.match(
new RegExp(`^\\s{0,3}#{1,${WORKFLOW_MARKDOWN_HEADING_MAX_LEVEL}}\\s+(.+?)\\s*$`),
);
if (!match) continue;
const raw = match[1]?.replace(/\s+#+\s*$/, '') ?? '';
const title = stripLightMarkdownForHeadline(raw).replaceAll(/\s+/g, ' ').trim();
if (!title) continue;
lastTitle = truncateDisplayAtWord(title, WORKFLOW_PROSE_HEADLINE_MAX_CHARS);
}
return lastTitle;
};
/**
* Deterministic one-line snippet from streamed assistant prose (A path).
* Prefers a full sentence when punctuation exists; otherwise trims to max width.
@@ -256,34 +282,75 @@ export const shapeProseForWorkflowHeadline = (source: string): string => {
return truncateDisplayAtWord(s, WORKFLOW_PROSE_HEADLINE_MAX_CHARS);
};
/** Raw assistant `content` from the latest block that qualifies (scan from end). */
export const extractLatestProseHeadlineSource = (blocks: AssistantContentBlock[]): string => {
for (let i = blocks.length - 1; i >= 0; i--) {
const c = blocks[i]?.content?.trim() ?? '';
if (!c || c === LOADING_FLAT) continue;
if (c.length < WORKFLOW_PROSE_SOURCE_MIN_CHARS) continue;
return c;
}
return '';
const getBlockContent = (block: AssistantContentBlock): string => {
const content = block.content?.trim() ?? '';
if (!content || content === LOADING_FLAT) return '';
return content;
};
export interface WorkflowStreamingHeadlineParts {
explicitStep: string;
fallbackTool: string;
proseSource: string;
}
const getBlockReasoningContent = (block: AssistantContentBlock): string => {
const reasoning = block.reasoning?.content?.trim() ?? '';
if (!reasoning || reasoning === LOADING_FLAT) return '';
return reasoning;
};
/** Split B / raw A source / C for streaming headline composition (A commits in UI with idle/sentence rules). */
export const getWorkflowStreamingHeadlineParts = (
const isThinkingOnlyBlock = (block: AssistantContentBlock): boolean => {
if (block.tools?.length) return false;
if ((block.imageList?.length ?? 0) > 0) return false;
return !!getBlockReasoningContent(block) && !getBlockContent(block) && !block.error;
};
export type WorkflowStreamingHeadlineState =
| { kind: 'idle' }
| { kind: 'prose'; proseSource: string }
| { kind: 'thinking'; reasoningTitle: string }
| { explicitStep: string; fallbackTool: string; kind: 'tool' };
const getHeadlineStateFromBlock = (
block: AssistantContentBlock,
): WorkflowStreamingHeadlineState | null => {
if (block.tools?.length) {
const lastTool = block.tools.at(-1);
const explicitStep = lastTool ? getExplicitStepHeadlineLine(lastTool) : '';
const fallbackTool = lastTool ? getToolFallbackHeadlineLine(lastTool) : '';
if (!explicitStep && !fallbackTool) return null;
return {
explicitStep,
fallbackTool,
kind: 'tool',
};
}
if (isThinkingOnlyBlock(block)) {
const reasoningTitle = extractMarkdownHeadingTitle(getBlockReasoningContent(block));
if (!reasoningTitle) return null;
return {
kind: 'thinking',
reasoningTitle,
};
}
const proseSource = getBlockContent(block);
if (proseSource.length < WORKFLOW_PROSE_SOURCE_MIN_CHARS) return null;
return { kind: 'prose', proseSource };
};
/** Walk backward and return the first block that can produce a meaningful headline state. */
export const getWorkflowStreamingHeadlineState = (
blocks: AssistantContentBlock[],
tools: ChatToolPayloadWithResult[],
): WorkflowStreamingHeadlineParts => {
const last = tools.at(-1);
return {
explicitStep: last ? getExplicitStepHeadlineLine(last) : '',
fallbackTool: last ? getToolFallbackHeadlineLine(last) : '',
proseSource: extractLatestProseHeadlineSource(blocks),
};
): WorkflowStreamingHeadlineState => {
for (let i = blocks.length - 1; i >= 0; i--) {
const block = blocks[i];
if (!block) continue;
const state = getHeadlineStateFromBlock(block);
if (state) return state;
}
return { kind: 'idle' };
};
export const formatReasoningDuration = (ms: number): string => {
@@ -309,7 +376,8 @@ export const getWorkflowSummaryText = (blocks: AssistantContentBlock[]): string
for (const [apiName, { count, errorCount }] of groups) {
let part = getToolDisplayName(apiName);
if (count > 1) part += ` (${count})`;
if (errorCount > 0) part += ' (failed)';
if (errorCount > 0)
part += ` ${t('workflow.failedSuffix', { defaultValue: '(failed)', ns: 'chat' })}`;
toolParts.push(part);
}
@@ -317,7 +385,11 @@ export const getWorkflowSummaryText = (blocks: AssistantContentBlock[]): string
const totalReasoningMs = blocks.reduce((sum, b) => sum + (b.reasoning?.duration ?? 0), 0);
if (totalReasoningMs > 0) {
result += ` · Thought for ${formatReasoningDuration(totalReasoningMs)}`;
result += ` · ${t('workflow.thoughtForDuration', {
defaultValue: 'Thought for {{duration}}',
duration: formatReasoningDuration(totalReasoningMs),
ns: 'chat',
})}`;
}
return result;
+74
View File
@@ -507,6 +507,80 @@ export default {
'viewMode.fullWidth': 'Full Width',
'viewMode.normal': 'Standard',
'viewMode.wideScreen': 'Widescreen',
'workflow.failedSuffix': '(failed)',
'workflow.thoughtForDuration': 'Thought for {{duration}}',
'workflow.toolDisplayName.activateDevice': 'Activated device',
'workflow.toolDisplayName.activateSkill': 'Activated a skill',
'workflow.toolDisplayName.activateTools': 'Activated tools',
'workflow.toolDisplayName.addActivityMemory': 'Saved memory',
'workflow.toolDisplayName.addContextMemory': 'Saved memory',
'workflow.toolDisplayName.addExperienceMemory': 'Saved memory',
'workflow.toolDisplayName.addIdentityMemory': 'Saved memory',
'workflow.toolDisplayName.addPreferenceMemory': 'Saved memory',
'workflow.toolDisplayName.calculate': 'Calculated',
'workflow.toolDisplayName.callAgent': 'Called an agent',
'workflow.toolDisplayName.clearTodos': 'Cleared todos',
'workflow.toolDisplayName.copyDocument': 'Copied a document',
'workflow.toolDisplayName.crawlMultiPages': 'Crawled pages',
'workflow.toolDisplayName.crawlSinglePage': 'Crawled a page',
'workflow.toolDisplayName.createAgent': 'Created an agent',
'workflow.toolDisplayName.createDocument': 'Created a document',
'workflow.toolDisplayName.createPlan': 'Created a plan',
'workflow.toolDisplayName.createTodos': 'Created todos',
'workflow.toolDisplayName.deleteAgent': 'Deleted an agent',
'workflow.toolDisplayName.deleteDocument': 'Deleted a document',
'workflow.toolDisplayName.editDocument': 'Edited a document',
'workflow.toolDisplayName.editLocalFile': 'Edited a file',
'workflow.toolDisplayName.editTitle': 'Edited title',
'workflow.toolDisplayName.evaluate': 'Evaluated expression',
'workflow.toolDisplayName.execScript': 'Executed a script',
'workflow.toolDisplayName.execTask': 'Executed a task',
'workflow.toolDisplayName.execTasks': 'Executed tasks',
'workflow.toolDisplayName.execute': 'Executed calculation',
'workflow.toolDisplayName.executeCode': 'Executed code',
'workflow.toolDisplayName.finishOnboarding': 'Finished onboarding',
'workflow.toolDisplayName.getCommandOutput': 'Read command output',
'workflow.toolDisplayName.getDocument': 'Read a document',
'workflow.toolDisplayName.getOnboardingState': 'Checked onboarding state',
'workflow.toolDisplayName.getPageContent': 'Read Page content',
'workflow.toolDisplayName.getTopicContext': 'Read topic context',
'workflow.toolDisplayName.globLocalFiles': 'Searched files',
'workflow.toolDisplayName.grepContent': 'Searched content',
'workflow.toolDisplayName.importFromMarket': 'Imported from market',
'workflow.toolDisplayName.importSkill': 'Imported a skill',
'workflow.toolDisplayName.initPage': 'Initialized Page',
'workflow.toolDisplayName.killCommand': 'Stopped a command',
'workflow.toolDisplayName.listDocuments': 'Listed documents',
'workflow.toolDisplayName.listLocalFiles': 'Listed files',
'workflow.toolDisplayName.listOnlineDevices': 'Listed devices',
'workflow.toolDisplayName.modifyNodes': 'Modified Page',
'workflow.toolDisplayName.moveLocalFiles': 'Moved files',
'workflow.toolDisplayName.readDocument': 'Read a document',
'workflow.toolDisplayName.readDocumentByFilename': 'Read a document',
'workflow.toolDisplayName.readKnowledge': 'Read knowledge',
'workflow.toolDisplayName.readLocalFile': 'Read a file',
'workflow.toolDisplayName.removeDocument': 'Removed a document',
'workflow.toolDisplayName.removeIdentityMemory': 'Removed memory',
'workflow.toolDisplayName.renameDocument': 'Renamed a document',
'workflow.toolDisplayName.renameLocalFile': 'Renamed a file',
'workflow.toolDisplayName.replaceText': 'Replaced text',
'workflow.toolDisplayName.runCommand': 'Ran a command',
'workflow.toolDisplayName.search': 'Searched the web',
'workflow.toolDisplayName.searchAgent': 'Searched agents',
'workflow.toolDisplayName.searchKnowledgeBase': 'Searched knowledge base',
'workflow.toolDisplayName.searchLocalFiles': 'Searched files',
'workflow.toolDisplayName.searchSkill': 'Searched skills',
'workflow.toolDisplayName.searchUserMemory': 'Searched memory',
'workflow.toolDisplayName.solve': 'Solved equation',
'workflow.toolDisplayName.updateAgent': 'Updated an agent',
'workflow.toolDisplayName.updateDocument': 'Updated a document',
'workflow.toolDisplayName.updateIdentityMemory': 'Updated memory',
'workflow.toolDisplayName.updateLoadRule': 'Updated load rule',
'workflow.toolDisplayName.updatePlan': 'Updated plan',
'workflow.toolDisplayName.updateTodos': 'Updated todos',
'workflow.toolDisplayName.upsertDocumentByFilename': 'Updated a document',
'workflow.toolDisplayName.writeLocalFile': 'Wrote a file',
'workflow.working': 'Working...',
'you': 'You',
'zenMode': 'Zen Mode',
};