diff --git a/src/renderer/src/components/workbench/composer/AttachmentFileIcon.tsx b/src/renderer/src/components/workbench/composer/AttachmentFileIcon.tsx index a99f973b..f4f4dbd7 100644 --- a/src/renderer/src/components/workbench/composer/AttachmentFileIcon.tsx +++ b/src/renderer/src/components/workbench/composer/AttachmentFileIcon.tsx @@ -14,10 +14,15 @@ const getFileExtension = (fileName: string): string => { .pop() if (!lastSegment) return '' const dotIndex = lastSegment.lastIndexOf('.') - return dotIndex > -1 ? lastSegment.slice(dotIndex + 1).trim().toLowerCase() : '' + return dotIndex > -1 + ? lastSegment + .slice(dotIndex + 1) + .trim() + .toLowerCase() + : '' } -export const createAttachmentFileReference = (fileName: string): FileReference => ({ +const createAttachmentFileReference = (fileName: string): FileReference => ({ kind: 'file', workspaceId: '', nodeId: `attachment:${fileName}`, diff --git a/src/renderer/src/components/workbench/utils.ts b/src/renderer/src/components/workbench/utils.ts index 5d7d65af..b6abfea3 100644 --- a/src/renderer/src/components/workbench/utils.ts +++ b/src/renderer/src/components/workbench/utils.ts @@ -14,6 +14,12 @@ import { getRuntimeSkillDisplayName, type RuntimeToolName } from '../../../../shared/runtimeCapabilities' +import { + getAssistantMixedStatus as getMixedStatus, + getAssistantToolIconKind as getToolIconKind, + getAssistantToolSummaryText as getToolSummaryText, + getAssistantToolUiKind as getToolUiKind +} from '../../../../shared/assistantExecutionSummary' import { WORKBENCH_PREFERENCE_AI_PREFERENCE_STORAGE_KEY, WORKBENCH_PREFERENCE_MODE_STORAGE_KEY, @@ -196,85 +202,11 @@ const getSkillDisplayName = (skillId?: RuntimeSkillId): string => { return skillId ? getRuntimeSkillDisplayName(skillId) || '新能力' : '新能力' } -const TOOL_UI_KINDS = { - terminal_run: 'terminal', - terminal_read: 'terminal', - terminal_stop: 'terminal', - text_file_read: 'file-read', - text_file_edit: 'file-edit', - kb_search: 'kb', - kb_list_documents: 'kb', - docx_read_outline: 'docx-read', - docx_search: 'docx-read', - docx_read_blocks: 'docx-read', - docx_edit: 'docx-edit', - pdf_inspect: 'pdf-read', - pdf_search: 'pdf-read', - pdf_read_pages: 'pdf-read', - pdf_read_visuals: 'pdf-read', - ppt_read_outline: 'ppt-read', - ppt_read_slides: 'ppt-read', - ppt_search: 'ppt-search', - ppt_read_visuals: 'ppt-read', - ppt_edit: 'ppt-edit', - excel_read_outline: 'excel-read', - excel_search: 'excel-read', - excel_read_cells: 'excel-read', - excel_edit: 'excel-edit', - web_search: 'web-search', - web_crawl: 'web-crawl', - browser_use: 'browser', - wait_time: 'wait', - wait_for_user_takeover: 'user-takeover', - image_analyze: 'image-recognition', - image_generate: 'image-generation' -} as const satisfies Record - -const getToolUiKind = (toolName?: AssistantToolName): AssistantProcessSkillKey => - toolName && toolName !== 'unnamed' ? TOOL_UI_KINDS[toolName] : 'generic' - const getDisplaySkillKey = (display: AssistantProcessDisplay): AssistantProcessSkillKey => { if (display.kind === 'tool') return getToolUiKind(normalizeToolName(display.toolName)) return 'generic' } -const getToolIconKind = (skillKey: AssistantProcessSkillKey): AssistantProcessIconKind => { - if (skillKey === 'docx-edit') return 'edit' - if (skillKey === 'docx-read' || skillKey === 'pdf-read' || skillKey === 'ppt-read') { - return 'document' - } - if (skillKey === 'file-read') return 'document' - if (skillKey === 'file-edit') return 'edit' - if (skillKey === 'ppt-search') return 'search' - if (skillKey === 'ppt-edit') return 'edit' - if (skillKey === 'excel-read') return 'document' - if (skillKey === 'excel-edit') return 'edit' - if (skillKey === 'kb') return 'knowledge' - if (skillKey === 'web-search') return 'search' - if (skillKey === 'web-crawl') return 'web' - if (skillKey === 'browser') return 'browser' - if (skillKey === 'image-recognition' || skillKey === 'image-generation') return 'image' - if (skillKey === 'wait') return 'wait' - if (skillKey === 'user-takeover') return 'browser' - if (skillKey === 'terminal') return 'terminal' - return 'default' -} - -const getMixedStatus = ( - statuses: AssistantProcessStatus[], - fallback: AssistantProcessStatus = 'completed' -): AssistantProcessStatus => { - if (statuses.length === 0) return fallback - if (statuses.some((status) => status === 'running')) return 'running' - const hasCompleted = statuses.some((status) => status === 'completed') - const hasFailed = statuses.some((status) => status === 'failed') - const hasPartialFailed = statuses.some((status) => status === 'partial_failed') - if (hasPartialFailed) return 'partial_failed' - if (hasCompleted && hasFailed) return 'partial_failed' - if (hasFailed) return 'failed' - return 'completed' -} - const getDisplayIconKind = (display: AssistantProcessDisplay): AssistantProcessIconKind => { if (display.kind === 'thinking') return 'default' if (display.kind === 'learning') return 'learning' @@ -284,148 +216,6 @@ const getDisplayIconKind = (display: AssistantProcessDisplay): AssistantProcessI return getToolIconKind(getDisplaySkillKey(display)) } -const getToolSummaryText = ( - skillKey: AssistantProcessSkillKey, - status: AssistantProcessStatus, - count = 1 -): AssistantRichTextSegment[] => { - const normalizedCount = String(Math.max(1, count)) - if (skillKey === 'docx-read') { - if (status === 'running') return sentence(text(`正在读取 ${normalizedCount} 个 docx 文件`)) - if (status === 'completed') return sentence(text(`已读取 ${normalizedCount} 个 docx 文件`)) - if (status === 'partial_failed') { - return sentence(text(`部分读取 ${normalizedCount} 个 docx 文件失败`)) - } - return sentence(text(`读取 ${normalizedCount} 个 docx 文件失败`)) - } - if (skillKey === 'docx-edit') { - if (status === 'running') return sentence(text(`正在编辑 ${normalizedCount} 个 docx 文件`)) - if (status === 'completed') return sentence(text(`已编辑 ${normalizedCount} 个 docx 文件`)) - if (status === 'partial_failed') { - return sentence(text(`部分编辑 ${normalizedCount} 个 docx 文件失败`)) - } - return sentence(text(`编辑 ${normalizedCount} 个 docx 文件失败`)) - } - if (skillKey === 'file-read') { - if (status === 'running') return sentence(text(`正在读取 ${normalizedCount} 个文件`)) - if (status === 'completed') return sentence(text(`已读取 ${normalizedCount} 个文件`)) - if (status === 'partial_failed') { - return sentence(text(`部分读取 ${normalizedCount} 个文件失败`)) - } - return sentence(text(`读取 ${normalizedCount} 个文件失败`)) - } - if (skillKey === 'file-edit') { - if (status === 'running') return sentence(text(`正在编辑 ${normalizedCount} 个文件`)) - if (status === 'completed') return sentence(text(`已编辑 ${normalizedCount} 个文件`)) - if (status === 'partial_failed') { - return sentence(text(`部分编辑 ${normalizedCount} 个文件失败`)) - } - return sentence(text(`编辑 ${normalizedCount} 个文件失败`)) - } - if (skillKey === 'pdf-read') { - if (status === 'running') return sentence(text(`正在读取 ${normalizedCount} 个 PDF 文件`)) - if (status === 'completed') return sentence(text(`已读取 ${normalizedCount} 个 PDF 文件`)) - if (status === 'partial_failed') - return sentence(text(`部分读取 ${normalizedCount} 个 PDF 文件失败`)) - return sentence(text(`读取 ${normalizedCount} 个 PDF 文件失败`)) - } - if (skillKey === 'ppt-read') { - if (status === 'running') return sentence(text(`正在读取 ${normalizedCount} 个 PPT 文件`)) - if (status === 'completed') return sentence(text(`已读取 ${normalizedCount} 个 PPT 文件`)) - if (status === 'partial_failed') - return sentence(text(`部分读取 ${normalizedCount} 个 PPT 文件失败`)) - return sentence(text(`读取 ${normalizedCount} 个 PPT 文件失败`)) - } - if (skillKey === 'ppt-search') { - if (status === 'running') return sentence(text('正在搜索 PPT')) - if (status === 'completed') return sentence(text('已搜索 PPT')) - if (status === 'partial_failed') return sentence(text('部分 PPT 搜索失败')) - return sentence(text('搜索 PPT 失败')) - } - if (skillKey === 'ppt-edit') { - if (status === 'running') return sentence(text(`正在编辑 ${normalizedCount} 个 PPT 文件`)) - if (status === 'completed') return sentence(text(`已编辑 ${normalizedCount} 个 PPT 文件`)) - if (status === 'partial_failed') { - return sentence(text(`部分编辑 ${normalizedCount} 个 PPT 文件失败`)) - } - return sentence(text(`编辑 ${normalizedCount} 个 PPT 文件失败`)) - } - if (skillKey === 'excel-read') { - if (status === 'running') return sentence(text(`正在读取 ${normalizedCount} 个 Excel 文件`)) - if (status === 'completed') return sentence(text(`已读取 ${normalizedCount} 个 Excel 文件`)) - if (status === 'partial_failed') { - return sentence(text(`部分读取 ${normalizedCount} 个 Excel 文件失败`)) - } - return sentence(text(`读取 ${normalizedCount} 个 Excel 文件失败`)) - } - if (skillKey === 'excel-edit') { - if (status === 'running') return sentence(text(`正在编辑 ${normalizedCount} 个 Excel 文件`)) - if (status === 'completed') return sentence(text(`已编辑 ${normalizedCount} 个 Excel 文件`)) - if (status === 'partial_failed') { - return sentence(text(`部分编辑 ${normalizedCount} 个 Excel 文件失败`)) - } - return sentence(text(`编辑 ${normalizedCount} 个 Excel 文件失败`)) - } - if (skillKey === 'kb') { - if (status === 'running') return sentence(text('正在检索知识库')) - if (status === 'completed') return sentence(text('已检索知识库')) - if (status === 'partial_failed') return sentence(text('部分知识库检索失败')) - return sentence(text('检索知识库失败')) - } - if (skillKey === 'terminal') { - if (status === 'running') return sentence(text('正在执行终端指令')) - if (status === 'completed') return sentence(text('已执行终端指令')) - if (status === 'partial_failed') return sentence(text('部分终端指令执行失败')) - return sentence(text('执行终端指令失败')) - } - if (skillKey === 'web-search') { - if (status === 'running') return sentence(text('正在搜索网页')) - if (status === 'completed') return sentence(text('已搜索网页')) - if (status === 'partial_failed') return sentence(text('部分网页搜索失败')) - return sentence(text('搜索网页失败')) - } - if (skillKey === 'web-crawl') { - if (status === 'running') return sentence(text('正在读取网页')) - if (status === 'completed') return sentence(text('已读取网页')) - if (status === 'partial_failed') return sentence(text('部分网页读取失败')) - return sentence(text('读取网页失败')) - } - if (skillKey === 'browser') { - if (status === 'running') return sentence(text('正在操作浏览器')) - if (status === 'completed') return sentence(text(`已操作浏览器 ${normalizedCount} 次`)) - if (status === 'partial_failed') return sentence(text('部分浏览器操作失败')) - return sentence(text('浏览器操作失败')) - } - if (skillKey === 'image-recognition') { - if (status === 'running') return sentence(text('正在识别图片')) - if (status === 'completed') return sentence(text('已识别图片')) - if (status === 'partial_failed') return sentence(text('部分图片识别失败')) - return sentence(text('图片识别失败')) - } - if (skillKey === 'image-generation') { - if (status === 'running') return sentence(text('正在生成图片')) - if (status === 'completed') return sentence(text('已生成图片')) - if (status === 'partial_failed') return sentence(text('部分图片生成失败')) - return sentence(text('图片生成失败')) - } - if (skillKey === 'wait') { - if (status === 'running') return sentence(text('正在等待')) - if (status === 'completed') return sentence(text('已等待')) - if (status === 'partial_failed') return sentence(text('部分等待失败')) - return sentence(text('等待失败')) - } - if (skillKey === 'user-takeover') { - if (status === 'running') return sentence(text('正在等待用户接管浏览器')) - if (status === 'completed') return sentence(text('用户已完成浏览器操作')) - if (status === 'partial_failed') return sentence(text('等待用户接管浏览器时部分失败')) - return sentence(text('等待用户接管浏览器失败')) - } - if (status === 'running') return sentence(text('正在处理任务')) - if (status === 'completed') return sentence(text('已处理任务')) - if (status === 'partial_failed') return sentence(text('部分任务处理失败')) - return sentence(text('处理任务失败')) -} - type ToolItemRenderer = (input?: IpcJsonObject) => AssistantRichTextSegment[] const TOOL_ITEM_RENDERERS = { @@ -545,143 +335,11 @@ const getDisplayItemText = ( return getDisplayGroupText(display, status) } -type ExecutionActionSummary = { - kind: AssistantProcessSkillKey - fileLabels: Set - count: number - statuses: AssistantProcessStatus[] - waitLabels: Set -} - -const getExecutionStatus = (groups: AssistantProcessGroup[]): AssistantProcessStatus => { - return getMixedStatus(groups.map((group) => group.status)) -} - -const getSummaryActionLabel = (kind: AssistantProcessSkillKey): string => { - if ( - kind === 'docx-read' || - kind === 'file-read' || - kind === 'pdf-read' || - kind === 'ppt-read' || - kind === 'excel-read' || - kind === 'web-crawl' - ) { - return '读取' - } - if ( - kind === 'docx-edit' || - kind === 'file-edit' || - kind === 'ppt-edit' || - kind === 'excel-edit' - ) { - return '编辑' - } - if (kind === 'ppt-search' || kind === 'web-search') return '搜索' - if (kind === 'kb') return '检索' - if (kind === 'terminal') return '终端' - if (kind === 'browser' || kind === 'user-takeover') return '浏览器' - if (kind === 'image-recognition' || kind === 'image-generation') return '图片' - if (kind === 'wait') return '等待' - return '处理' -} - -const getSummaryActionLabelText = (summaries: readonly ExecutionActionSummary[]): string => { - const labels = Array.from( - new Set(summaries.map((summary) => getSummaryActionLabel(summary.kind))) - ) - if (labels.length === 0) return '' - return `${labels.slice(0, 3).join('、')}${summaries.length > 3 ? '等' : ''}` -} - -export const buildAssistantExecutionSummaryText = ( - groups: AssistantProcessGroup[] -): AssistantRichTextSegment[] => { - const summaries = new Map() - for (const group of groups) { - const entries = - group.items.length > 0 - ? group.items.map((item) => ({ - toolName: item.toolName, - targetLabel: item.targetLabel, - input: item.input - })) - : [{ toolName: undefined, targetLabel: undefined, input: undefined }] - for (const entry of entries) { - const kind = entry.toolName ? getToolUiKind(entry.toolName) : group.skillKey - const current = - summaries.get(kind) || - ({ - kind, - fileLabels: new Set(), - count: 0, - statuses: [], - waitLabels: new Set() - } satisfies ExecutionActionSummary) - current.count += 1 - current.statuses.push(group.status) - if ( - kind === 'docx-read' || - kind === 'docx-edit' || - kind === 'ppt-read' || - kind === 'ppt-edit' || - kind === 'excel-read' || - kind === 'excel-edit' - ) { - current.fileLabels.add(entry.targetLabel || group.id) - } - if (kind === 'wait') current.waitLabels.add(readWaitDurationLabel(entry.input)) - summaries.set(kind, current) - } - } - - const status = getExecutionStatus(groups) - const ordered = [...summaries.values()] - if (ordered.length === 0) return sentence(text('正在处理任务')) - - const totalCount = ordered.reduce((sum, summary) => sum + summary.count, 0) - if (ordered.length > 3) { - const actionText = getSummaryActionLabelText(ordered) - const suffix = actionText ? `:${actionText}` : '' - if (status === 'running') return sentence(text(`正在执行 ${totalCount} 项操作${suffix}`)) - if (status === 'completed') return sentence(text(`已完成 ${totalCount} 项操作${suffix}`)) - if (status === 'partial_failed') - return sentence(text(`${totalCount} 项操作中有部分失败${suffix}`)) - return sentence(text(`${totalCount} 项操作执行失败${suffix}`)) - } - - return ordered.flatMap((summary, index) => { - const count = summary.fileLabels.size || summary.count || 1 - const summaryStatus = getMixedStatus(summary.statuses, status) - const prefix = - index === 0 - ? '' - : summaryStatus === 'failed' || summaryStatus === 'partial_failed' - ? '但' - : '并' - if (summary.kind === 'wait' && summary.waitLabels.size === 1) { - const waitLabel = [...summary.waitLabels][0] - if (summaryStatus === 'running') return sentence(text(prefix), text(`正在等待 ${waitLabel}`)) - if (summaryStatus === 'completed') return sentence(text(prefix), text(`等待 ${waitLabel}`)) - } - return sentence(text(prefix), ...getToolSummaryText(summary.kind, summaryStatus, count)) - }) -} - -export const resolveAssistantExecutionIcon = ( - groups: AssistantProcessGroup[] -): AssistantProcessIconKind => { - const kinds = new Set( - groups.flatMap((group) => - group.items.length > 0 - ? group.items.map((item) => (item.toolName ? getToolUiKind(item.toolName) : group.skillKey)) - : [group.skillKey] - ) - ) - if (kinds.size !== 1) return 'workflow' - return getToolIconKind([...kinds][0] || 'generic') -} - -export const resolveAssistantExecutionStatus = getExecutionStatus +export { + buildAssistantExecutionSummaryText, + resolveAssistantExecutionIcon, + resolveAssistantExecutionStatus +} from '../../../../shared/assistantExecutionSummary' const createProcessGroup = (input: { id: string diff --git a/src/shared/assistantExecutionSummary.ts b/src/shared/assistantExecutionSummary.ts new file mode 100644 index 00000000..c43deedc --- /dev/null +++ b/src/shared/assistantExecutionSummary.ts @@ -0,0 +1,465 @@ +import type { IpcJsonObject, IpcJsonValue } from './types' +import type { RuntimeToolName } from './runtimeCapabilities' + +export type AssistantExecutionStatus = 'running' | 'completed' | 'failed' | 'partial_failed' + +export type AssistantExecutionIconKind = + | 'search' + | 'browser' + | 'wait' + | 'web' + | 'image' + | 'knowledge' + | 'workflow' + | 'terminal' + | 'document' + | 'edit' + | 'retry' + | 'learning' + | 'compaction' + | 'finalizing' + | 'default' + +export type AssistantExecutionSkillKey = + | 'docx-read' + | 'docx-edit' + | 'file-read' + | 'file-edit' + | 'pdf-read' + | 'ppt-read' + | 'ppt-search' + | 'ppt-edit' + | 'excel-read' + | 'excel-edit' + | 'kb' + | 'terminal' + | 'web-search' + | 'web-crawl' + | 'browser' + | 'image-recognition' + | 'image-generation' + | 'wait' + | 'user-takeover' + | 'generic' + +export type AssistantExecutionTextSegment = { + text: string + emphasis?: boolean + iconKind?: AssistantExecutionIconKind +} + +export type AssistantExecutionGroup = { + id: string + skillKey: AssistantExecutionSkillKey + status: AssistantExecutionStatus + items: readonly { + toolName?: RuntimeToolName | 'unnamed' + targetLabel?: string + input?: IpcJsonObject + }[] +} + +const text = (value: string): AssistantExecutionTextSegment => ({ text: value }) +const sentence = (...segments: AssistantExecutionTextSegment[]): AssistantExecutionTextSegment[] => + segments + +const TOOL_UI_KINDS = { + terminal_run: 'terminal', + terminal_read: 'terminal', + terminal_stop: 'terminal', + text_file_read: 'file-read', + text_file_edit: 'file-edit', + kb_search: 'kb', + kb_list_documents: 'kb', + docx_read_outline: 'docx-read', + docx_search: 'docx-read', + docx_read_blocks: 'docx-read', + docx_edit: 'docx-edit', + pdf_inspect: 'pdf-read', + pdf_search: 'pdf-read', + pdf_read_pages: 'pdf-read', + pdf_read_visuals: 'pdf-read', + ppt_read_outline: 'ppt-read', + ppt_read_slides: 'ppt-read', + ppt_search: 'ppt-search', + ppt_read_visuals: 'ppt-read', + ppt_edit: 'ppt-edit', + excel_read_outline: 'excel-read', + excel_search: 'excel-read', + excel_read_cells: 'excel-read', + excel_edit: 'excel-edit', + web_search: 'web-search', + web_crawl: 'web-crawl', + browser_use: 'browser', + wait_time: 'wait', + wait_for_user_takeover: 'user-takeover', + image_analyze: 'image-recognition', + image_generate: 'image-generation' +} as const satisfies Record + +export const getAssistantToolUiKind = ( + toolName?: RuntimeToolName | 'unnamed' +): AssistantExecutionSkillKey => + toolName && toolName !== 'unnamed' ? TOOL_UI_KINDS[toolName] : 'generic' + +export const getAssistantToolIconKind = ( + skillKey: AssistantExecutionSkillKey +): AssistantExecutionIconKind => { + if (skillKey === 'docx-edit' || skillKey === 'file-edit') return 'edit' + if ( + skillKey === 'docx-read' || + skillKey === 'file-read' || + skillKey === 'pdf-read' || + skillKey === 'ppt-read' || + skillKey === 'excel-read' + ) { + return 'document' + } + if (skillKey === 'ppt-search' || skillKey === 'web-search') return 'search' + if (skillKey === 'ppt-edit' || skillKey === 'excel-edit') return 'edit' + if (skillKey === 'kb') return 'knowledge' + if (skillKey === 'web-crawl') return 'web' + if (skillKey === 'browser' || skillKey === 'user-takeover') return 'browser' + if (skillKey === 'image-recognition' || skillKey === 'image-generation') return 'image' + if (skillKey === 'wait') return 'wait' + if (skillKey === 'terminal') return 'terminal' + return 'default' +} + +export const getAssistantMixedStatus = ( + statuses: readonly AssistantExecutionStatus[], + fallback: AssistantExecutionStatus = 'completed' +): AssistantExecutionStatus => { + if (statuses.length === 0) return fallback + if (statuses.some((status) => status === 'running')) return 'running' + const hasCompleted = statuses.some((status) => status === 'completed') + const hasFailed = statuses.some((status) => status === 'failed') + const hasPartialFailed = statuses.some((status) => status === 'partial_failed') + if (hasPartialFailed || (hasCompleted && hasFailed)) return 'partial_failed' + if (hasFailed) return 'failed' + return 'completed' +} + +type StatusText = Record + +const statusText = ( + status: AssistantExecutionStatus, + values: StatusText +): AssistantExecutionTextSegment[] => sentence(text(values[status])) + +export const getAssistantToolSummaryText = ( + skillKey: AssistantExecutionSkillKey, + status: AssistantExecutionStatus, + count = 1 +): AssistantExecutionTextSegment[] => { + const normalizedCount = String(Math.max(1, count)) + if (skillKey === 'docx-read') { + return statusText(status, { + running: `正在读取 ${normalizedCount} 个 docx 文件`, + completed: `已读取 ${normalizedCount} 个 docx 文件`, + partial_failed: `部分读取 ${normalizedCount} 个 docx 文件失败`, + failed: `读取 ${normalizedCount} 个 docx 文件失败` + }) + } + if (skillKey === 'docx-edit') { + return statusText(status, { + running: `正在编辑 ${normalizedCount} 个 docx 文件`, + completed: `已编辑 ${normalizedCount} 个 docx 文件`, + partial_failed: `部分编辑 ${normalizedCount} 个 docx 文件失败`, + failed: `编辑 ${normalizedCount} 个 docx 文件失败` + }) + } + if (skillKey === 'file-read') { + return statusText(status, { + running: `正在读取 ${normalizedCount} 个文件`, + completed: `已读取 ${normalizedCount} 个文件`, + partial_failed: `部分读取 ${normalizedCount} 个文件失败`, + failed: `读取 ${normalizedCount} 个文件失败` + }) + } + if (skillKey === 'file-edit') { + return statusText(status, { + running: `正在编辑 ${normalizedCount} 个文件`, + completed: `已编辑 ${normalizedCount} 个文件`, + partial_failed: `部分编辑 ${normalizedCount} 个文件失败`, + failed: `编辑 ${normalizedCount} 个文件失败` + }) + } + if (skillKey === 'pdf-read') { + return statusText(status, { + running: `正在读取 ${normalizedCount} 个 PDF 文件`, + completed: `已读取 ${normalizedCount} 个 PDF 文件`, + partial_failed: `部分读取 ${normalizedCount} 个 PDF 文件失败`, + failed: `读取 ${normalizedCount} 个 PDF 文件失败` + }) + } + if (skillKey === 'ppt-read') { + return statusText(status, { + running: `正在读取 ${normalizedCount} 个 PPT 文件`, + completed: `已读取 ${normalizedCount} 个 PPT 文件`, + partial_failed: `部分读取 ${normalizedCount} 个 PPT 文件失败`, + failed: `读取 ${normalizedCount} 个 PPT 文件失败` + }) + } + if (skillKey === 'ppt-search') { + return statusText(status, { + running: '正在搜索 PPT', + completed: '已搜索 PPT', + partial_failed: '部分 PPT 搜索失败', + failed: '搜索 PPT 失败' + }) + } + if (skillKey === 'ppt-edit') { + return statusText(status, { + running: `正在编辑 ${normalizedCount} 个 PPT 文件`, + completed: `已编辑 ${normalizedCount} 个 PPT 文件`, + partial_failed: `部分编辑 ${normalizedCount} 个 PPT 文件失败`, + failed: `编辑 ${normalizedCount} 个 PPT 文件失败` + }) + } + if (skillKey === 'excel-read') { + return statusText(status, { + running: `正在读取 ${normalizedCount} 个 Excel 文件`, + completed: `已读取 ${normalizedCount} 个 Excel 文件`, + partial_failed: `部分读取 ${normalizedCount} 个 Excel 文件失败`, + failed: `读取 ${normalizedCount} 个 Excel 文件失败` + }) + } + if (skillKey === 'excel-edit') { + return statusText(status, { + running: `正在编辑 ${normalizedCount} 个 Excel 文件`, + completed: `已编辑 ${normalizedCount} 个 Excel 文件`, + partial_failed: `部分编辑 ${normalizedCount} 个 Excel 文件失败`, + failed: `编辑 ${normalizedCount} 个 Excel 文件失败` + }) + } + if (skillKey === 'kb') { + return statusText(status, { + running: '正在检索知识库', + completed: '已检索知识库', + partial_failed: '部分知识库检索失败', + failed: '检索知识库失败' + }) + } + if (skillKey === 'terminal') { + return statusText(status, { + running: '正在执行终端指令', + completed: '已执行终端指令', + partial_failed: '部分终端指令执行失败', + failed: '执行终端指令失败' + }) + } + if (skillKey === 'web-search') { + return statusText(status, { + running: '正在搜索网页', + completed: '已搜索网页', + partial_failed: '部分网页搜索失败', + failed: '搜索网页失败' + }) + } + if (skillKey === 'web-crawl') { + return statusText(status, { + running: '正在读取网页', + completed: '已读取网页', + partial_failed: '部分网页读取失败', + failed: '读取网页失败' + }) + } + if (skillKey === 'browser') { + return statusText(status, { + running: '正在操作浏览器', + completed: `已操作浏览器 ${normalizedCount} 次`, + partial_failed: '部分浏览器操作失败', + failed: '浏览器操作失败' + }) + } + if (skillKey === 'image-recognition') { + return statusText(status, { + running: '正在识别图片', + completed: '已识别图片', + partial_failed: '部分图片识别失败', + failed: '图片识别失败' + }) + } + if (skillKey === 'image-generation') { + return statusText(status, { + running: '正在生成图片', + completed: '已生成图片', + partial_failed: '部分图片生成失败', + failed: '图片生成失败' + }) + } + if (skillKey === 'wait') { + return statusText(status, { + running: '正在等待', + completed: '已等待', + partial_failed: '部分等待失败', + failed: '等待失败' + }) + } + if (skillKey === 'user-takeover') { + return statusText(status, { + running: '正在等待用户接管浏览器', + completed: '用户已完成浏览器操作', + partial_failed: '等待用户接管浏览器时部分失败', + failed: '等待用户接管浏览器失败' + }) + } + return statusText(status, { + running: '正在处理任务', + completed: '已处理任务', + partial_failed: '部分任务处理失败', + failed: '处理任务失败' + }) +} + +const readWaitDurationLabel = (input?: IpcJsonObject): string => { + if (!input) return '一段时间' + const seconds = (input as Record).seconds + if (typeof seconds === 'number' && Number.isFinite(seconds)) return `${String(seconds)} 秒` + if (typeof seconds === 'string' && seconds.trim()) return `${seconds.trim()} 秒` + return '一段时间' +} + +type ExecutionActionSummary = { + kind: AssistantExecutionSkillKey + fileLabels: Set + count: number + statuses: AssistantExecutionStatus[] + waitLabels: Set +} + +const getExecutionStatus = (groups: readonly AssistantExecutionGroup[]): AssistantExecutionStatus => + getAssistantMixedStatus(groups.map((group) => group.status)) + +const getSummaryActionLabel = (kind: AssistantExecutionSkillKey): string => { + if ( + kind === 'docx-read' || + kind === 'file-read' || + kind === 'pdf-read' || + kind === 'ppt-read' || + kind === 'excel-read' || + kind === 'web-crawl' + ) { + return '读取' + } + if ( + kind === 'docx-edit' || + kind === 'file-edit' || + kind === 'ppt-edit' || + kind === 'excel-edit' + ) { + return '编辑' + } + if (kind === 'ppt-search' || kind === 'web-search') return '搜索' + if (kind === 'kb') return '检索' + if (kind === 'terminal') return '终端' + if (kind === 'browser' || kind === 'user-takeover') return '浏览器' + if (kind === 'image-recognition' || kind === 'image-generation') return '图片' + if (kind === 'wait') return '等待' + return '处理' +} + +const getSummaryActionLabelText = (summaries: readonly ExecutionActionSummary[]): string => { + const labels = Array.from( + new Set(summaries.map((summary) => getSummaryActionLabel(summary.kind))) + ) + if (labels.length === 0) return '' + return `${labels.slice(0, 3).join('、')}${summaries.length > 3 ? '等' : ''}` +} + +export const buildAssistantExecutionSummaryText = ( + groups: readonly AssistantExecutionGroup[] +): AssistantExecutionTextSegment[] => { + const summaries = new Map() + for (const group of groups) { + const entries = + group.items.length > 0 + ? group.items.map((item) => ({ + toolName: item.toolName, + targetLabel: item.targetLabel, + input: item.input + })) + : [{ toolName: undefined, targetLabel: undefined, input: undefined }] + for (const entry of entries) { + const kind = entry.toolName ? getAssistantToolUiKind(entry.toolName) : group.skillKey + const current = + summaries.get(kind) || + ({ + kind, + fileLabels: new Set(), + count: 0, + statuses: [], + waitLabels: new Set() + } satisfies ExecutionActionSummary) + current.count += 1 + current.statuses.push(group.status) + if ( + kind === 'docx-read' || + kind === 'docx-edit' || + kind === 'ppt-read' || + kind === 'ppt-edit' || + kind === 'excel-read' || + kind === 'excel-edit' + ) { + current.fileLabels.add(entry.targetLabel || group.id) + } + if (kind === 'wait') current.waitLabels.add(readWaitDurationLabel(entry.input)) + summaries.set(kind, current) + } + } + + const status = getExecutionStatus(groups) + const ordered = [...summaries.values()] + if (ordered.length === 0) return sentence(text('正在处理任务')) + + const totalCount = ordered.reduce((sum, summary) => sum + summary.count, 0) + if (ordered.length > 3) { + const actionText = getSummaryActionLabelText(ordered) + const suffix = actionText ? `:${actionText}` : '' + if (status === 'running') return sentence(text(`正在执行 ${totalCount} 项操作${suffix}`)) + if (status === 'completed') return sentence(text(`已完成 ${totalCount} 项操作${suffix}`)) + if (status === 'partial_failed') { + return sentence(text(`${totalCount} 项操作中有部分失败${suffix}`)) + } + return sentence(text(`${totalCount} 项操作执行失败${suffix}`)) + } + + return ordered.flatMap((summary, index) => { + const count = summary.fileLabels.size || summary.count || 1 + const summaryStatus = getAssistantMixedStatus(summary.statuses, status) + const prefix = + index === 0 + ? '' + : summaryStatus === 'failed' || summaryStatus === 'partial_failed' + ? '但' + : '并' + if (summary.kind === 'wait' && summary.waitLabels.size === 1) { + const waitLabel = [...summary.waitLabels][0] + if (summaryStatus === 'running') return sentence(text(prefix), text(`正在等待 ${waitLabel}`)) + if (summaryStatus === 'completed') return sentence(text(prefix), text(`等待 ${waitLabel}`)) + } + return sentence( + text(prefix), + ...getAssistantToolSummaryText(summary.kind, summaryStatus, count) + ) + }) +} + +export const resolveAssistantExecutionIcon = ( + groups: readonly AssistantExecutionGroup[] +): AssistantExecutionIconKind => { + const kinds = new Set( + groups.flatMap((group) => + group.items.length > 0 + ? group.items.map((item) => + item.toolName ? getAssistantToolUiKind(item.toolName) : group.skillKey + ) + : [group.skillKey] + ) + ) + if (kinds.size !== 1) return 'workflow' + return getAssistantToolIconKind([...kinds][0] || 'generic') +} + +export const resolveAssistantExecutionStatus = getExecutionStatus diff --git a/tests/case/unit/workbench/assistant-process-icons.test.ts b/tests/case/unit/workbench/assistant-process-icons.test.ts index d60a8f14..a62c4528 100644 --- a/tests/case/unit/workbench/assistant-process-icons.test.ts +++ b/tests/case/unit/workbench/assistant-process-icons.test.ts @@ -2,47 +2,30 @@ import { describe, expect, it } from 'vitest' import { buildAssistantExecutionSummaryText, - resolveAssistantExecutionIcon -} from '../../../../src/renderer/src/components/workbench/utils' -import type { AssistantProcessGroup } from '../../../../src/renderer/src/components/workbench/types' + resolveAssistantExecutionIcon, + type AssistantExecutionGroup +} from '../../../../src/shared/assistantExecutionSummary' const toPlainText = (segments: ReturnType): string => segments.map((segment) => segment.text).join('') -const createToolGroup = (toolName: AssistantProcessGroup['items'][number]['toolName'], index = 1) => - [ - { - id: `group-${toolName}`, - kind: 'skill', - skillKey: 'generic', - iconKind: 'default', - status: 'completed', - runningText: [], - completedText: [], - failedText: [], - partialFailedText: [], - createdAt: 1, - sourceEventType: 'assistant_process_group_completed', - items: [ - { - id: `item-${toolName}-${String(index)}`, - status: 'completed', - runningText: [], - completedText: [], - failedText: [], - partialFailedText: [], - createdAt: index, - sourceEventType: 'assistant_process_item_completed', - toolName, - targetLabel: `file-${String(index)}` - } - ] - } - ] satisfies AssistantProcessGroup[] +type ToolName = NonNullable -const createToolGroups = ( - toolNames: AssistantProcessGroup['items'][number]['toolName'][] -): AssistantProcessGroup[] => +const createToolGroup = (toolName: ToolName, index = 1): AssistantExecutionGroup[] => [ + { + id: `group-${toolName}`, + skillKey: 'generic', + status: 'completed', + items: [ + { + toolName, + targetLabel: `file-${String(index)}` + } + ] + } +] + +const createToolGroups = (toolNames: ToolName[]): AssistantExecutionGroup[] => toolNames.flatMap((toolName, index) => createToolGroup(toolName, index + 1)) describe('workbench assistant process icons', () => {