From a2ea314cd85334e5799f04790d731dc91cdd4e78 Mon Sep 17 00:00:00 2001 From: Arvin Xu Date: Wed, 10 Jun 2026 22:13:56 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(codex):=20refine=20Codex=20too?= =?UTF-8?q?l=20renders=20(#15651)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 💄 style(codex): refine file change tool render * ✨ feat(codex): add web search tool render * ✨ feat(codex): add mcp tool render * ✨ feat(codex): improve tool command display * 💄 style(files): refine explorer tree icons * ✅ test: fix local file link render props --- locales/en-US/chat.json | 1 + locales/en-US/plugin.json | 18 ++ locales/zh-CN/chat.json | 1 + locales/zh-CN/plugin.json | 18 ++ .../src/client/Render/Glob/index.tsx | 70 +---- .../src/client/Render/Grep/index.tsx | 60 +--- .../src/client/Render/Read/index.tsx | 48 +-- .../src/client/Render/WebFetch/index.tsx | 49 +-- .../src/client/Render/WebSearch/index.tsx | 60 +--- .../src/client/Render/Write/index.tsx | 32 +- .../src/codex/CommandExecutionInspector.tsx | 115 +++++++ .../src/codex/FileChangeInspector.tsx | 42 +-- .../src/codex/FileChangeRender.tsx | 170 +++-------- .../src/codex/McpToolInspector.tsx | 79 +++++ .../builtin-tools/src/codex/McpToolRender.tsx | 94 ++++++ .../src/codex/WebSearchInspector.tsx | 46 +++ .../src/codex/WebSearchRender.tsx | 148 +++++++++ .../src/codex/commandExecutionUtils.test.ts | 100 +++++++ .../src/codex/commandExecutionUtils.ts | 281 ++++++++++++++++++ .../src/codex/displayControls.ts | 9 + packages/builtin-tools/src/codex/index.ts | 22 +- .../builtin-tools/src/codex/mcpToolUtils.ts | 217 ++++++++++++++ .../builtin-tools/src/codex/webSearchUtils.ts | 198 ++++++++++++ .../builtin-tools/src/displayControls.test.ts | 16 + packages/builtin-tools/src/displayControls.ts | 2 +- .../src/github/RunCommandRender.tsx | 154 ++++------ packages/builtin-tools/src/inspectors.ts | 6 +- packages/builtin-tools/vitest.config.mts | 7 + .../src/adapters/codex.test.ts | 74 +++++ .../src/adapters/codex.ts | 129 +++++++- packages/locales/src/default/chat.ts | 1 + packages/locales/src/default/plugin.ts | 18 ++ .../src/components/ToolResultCard.tsx | 49 --- .../shared-tool-ui/src/components/index.ts | 1 - src/components/FileIcon/index.tsx | 10 +- .../DocumentExplorerTree.test.tsx | 1 + .../DocumentExplorerTree.tsx | 18 +- .../plugins/LocalFileLink/Render.test.tsx | 83 ++++++ .../Markdown/plugins/LocalFileLink/Render.tsx | 72 +++++ .../Markdown/plugins/LocalFileLink/index.ts | 15 + .../plugins/LocalFileLink/parse.test.ts | 39 +++ .../Markdown/plugins/LocalFileLink/parse.ts | 153 ++++++++++ .../LocalFileLink/rehypePlugin.test.ts | 45 +++ .../plugins/LocalFileLink/rehypePlugin.ts | 35 +++ .../Conversation/Markdown/plugins/index.ts | 2 + .../AssistantGroup/components/Group.test.tsx | 75 ++++- .../AssistantGroup/components/Group.tsx | 14 +- .../Messages/AssistantGroup/constants.ts | 7 + .../AssistantGroup/toolDisplayNames.test.ts | 36 +++ .../DevPanel/RenderGallery/fixtures/codex.ts | 49 +++ .../ExplorerTree/folderIconStyle.test.ts | 15 + src/features/ExplorerTree/folderIconStyle.ts | 240 ++++++++++++++- src/features/ExplorerTree/index.tsx | 6 +- src/features/Portal/LocalFile/TabStrip.tsx | 20 +- .../Files/__tests__/index.test.tsx | 4 +- .../WorkingSidebar/Files/index.tsx | 19 +- 56 files changed, 2661 insertions(+), 632 deletions(-) create mode 100644 packages/builtin-tools/src/codex/CommandExecutionInspector.tsx create mode 100644 packages/builtin-tools/src/codex/McpToolInspector.tsx create mode 100644 packages/builtin-tools/src/codex/McpToolRender.tsx create mode 100644 packages/builtin-tools/src/codex/WebSearchInspector.tsx create mode 100644 packages/builtin-tools/src/codex/WebSearchRender.tsx create mode 100644 packages/builtin-tools/src/codex/commandExecutionUtils.test.ts create mode 100644 packages/builtin-tools/src/codex/commandExecutionUtils.ts create mode 100644 packages/builtin-tools/src/codex/displayControls.ts create mode 100644 packages/builtin-tools/src/codex/mcpToolUtils.ts create mode 100644 packages/builtin-tools/src/codex/webSearchUtils.ts create mode 100644 packages/builtin-tools/src/displayControls.test.ts create mode 100644 packages/builtin-tools/vitest.config.mts delete mode 100644 packages/shared-tool-ui/src/components/ToolResultCard.tsx create mode 100644 src/features/Conversation/Markdown/plugins/LocalFileLink/Render.test.tsx create mode 100644 src/features/Conversation/Markdown/plugins/LocalFileLink/Render.tsx create mode 100644 src/features/Conversation/Markdown/plugins/LocalFileLink/index.ts create mode 100644 src/features/Conversation/Markdown/plugins/LocalFileLink/parse.test.ts create mode 100644 src/features/Conversation/Markdown/plugins/LocalFileLink/parse.ts create mode 100644 src/features/Conversation/Markdown/plugins/LocalFileLink/rehypePlugin.test.ts create mode 100644 src/features/Conversation/Markdown/plugins/LocalFileLink/rehypePlugin.ts create mode 100644 src/features/ExplorerTree/folderIconStyle.test.ts diff --git a/locales/en-US/chat.json b/locales/en-US/chat.json index 21dbae6b54..f93f595c18 100644 --- a/locales/en-US/chat.json +++ b/locales/en-US/chat.json @@ -915,6 +915,7 @@ "workflow.toolDisplayName.addPreferenceMemory": "Saved memory", "workflow.toolDisplayName.calculate": "Calculated", "workflow.toolDisplayName.callAgent": "Called an agent", + "workflow.toolDisplayName.callMcpTool": "Called MCP tool", "workflow.toolDisplayName.callSubAgent": "Call SubAgent", "workflow.toolDisplayName.clearTodos": "Cleared todos", "workflow.toolDisplayName.copyDocument": "Copied a document", diff --git a/locales/en-US/plugin.json b/locales/en-US/plugin.json index 9e96f02b39..6bb223beb8 100644 --- a/locales/en-US/plugin.json +++ b/locales/en-US/plugin.json @@ -1,6 +1,24 @@ { "arguments.moreParams": "{{count}} params in total", "arguments.title": "Arguments", + "builtins.codex.apiName.command_execution": "Run command", + "builtins.codex.apiName.file_change": "Edit files", + "builtins.codex.apiName.mcp_tool_call": "Call MCP tool", + "builtins.codex.apiName.todo_list": "Update tasks", + "builtins.codex.apiName.web_search": "Search the web", + "builtins.codex.commandExecution.grep": "Search", + "builtins.codex.commandExecution.noResults": "No results", + "builtins.codex.commandExecution.readFile": "Read file", + "builtins.codex.fileChange.editedFiles_one": "Edited {{count}} file", + "builtins.codex.fileChange.editedFiles_other": "Edited {{count}} files", + "builtins.codex.fileChange.editing": "Editing files", + "builtins.codex.fileChange.noChanges": "No file changes", + "builtins.codex.fileChange.unknownFile": "Unknown file", + "builtins.codex.mcpTool.error": "Error", + "builtins.codex.mcpTool.input": "Input", + "builtins.codex.mcpTool.result": "Result", + "builtins.codex.mcpTool.unknownTool": "MCP tool", + "builtins.codex.webSearch.query": "Query", "builtins.lobe-activator.apiName.activateTools": "Activate Tools", "builtins.lobe-activator.inspector.activateTools.notFoundCount": "{{count}} not found", "builtins.lobe-agent-builder.apiName.getAvailableModels": "Get available models", diff --git a/locales/zh-CN/chat.json b/locales/zh-CN/chat.json index 0e70b452b4..7f26dffbab 100644 --- a/locales/zh-CN/chat.json +++ b/locales/zh-CN/chat.json @@ -915,6 +915,7 @@ "workflow.toolDisplayName.addPreferenceMemory": "保存了记忆", "workflow.toolDisplayName.calculate": "完成了计算", "workflow.toolDisplayName.callAgent": "调用了助理", + "workflow.toolDisplayName.callMcpTool": "调用了 MCP 工具", "workflow.toolDisplayName.callSubAgent": "Call SubAgent", "workflow.toolDisplayName.clearTodos": "清空了待办", "workflow.toolDisplayName.copyDocument": "复制了文档", diff --git a/locales/zh-CN/plugin.json b/locales/zh-CN/plugin.json index afb5a8a1eb..d0c08c7e5c 100644 --- a/locales/zh-CN/plugin.json +++ b/locales/zh-CN/plugin.json @@ -1,6 +1,24 @@ { "arguments.moreParams": "等 {{count}} 个参数", "arguments.title": "参数列表", + "builtins.codex.apiName.command_execution": "运行命令", + "builtins.codex.apiName.file_change": "编辑文件", + "builtins.codex.apiName.mcp_tool_call": "调用 MCP 工具", + "builtins.codex.apiName.todo_list": "更新任务", + "builtins.codex.apiName.web_search": "搜索网页", + "builtins.codex.commandExecution.grep": "搜索", + "builtins.codex.commandExecution.noResults": "没有结果", + "builtins.codex.commandExecution.readFile": "读取文件", + "builtins.codex.fileChange.editedFiles_one": "已编辑 {{count}} 个文件", + "builtins.codex.fileChange.editedFiles_other": "已编辑 {{count}} 个文件", + "builtins.codex.fileChange.editing": "正在编辑文件", + "builtins.codex.fileChange.noChanges": "没有文件改动", + "builtins.codex.fileChange.unknownFile": "未知文件", + "builtins.codex.mcpTool.error": "错误", + "builtins.codex.mcpTool.input": "输入", + "builtins.codex.mcpTool.result": "结果", + "builtins.codex.mcpTool.unknownTool": "MCP 工具", + "builtins.codex.webSearch.query": "搜索词", "builtins.lobe-activator.apiName.activateTools": "激活工具", "builtins.lobe-activator.inspector.activateTools.notFoundCount": "{{count}} 个未找到", "builtins.lobe-agent-builder.apiName.getAvailableModels": "获取可用模型", diff --git a/packages/builtin-tool-claude-code/src/client/Render/Glob/index.tsx b/packages/builtin-tool-claude-code/src/client/Render/Glob/index.tsx index dbe2a03e99..534f3cb534 100644 --- a/packages/builtin-tool-claude-code/src/client/Render/Glob/index.tsx +++ b/packages/builtin-tool-claude-code/src/client/Render/Glob/index.tsx @@ -1,73 +1,27 @@ 'use client'; -import { ToolResultCard } from '@lobechat/shared-tool-ui/components'; import type { BuiltinRenderProps } from '@lobechat/types'; -import { Highlighter, Text } from '@lobehub/ui'; -import { createStaticStyles } from 'antd-style'; -import { FolderSearch } from 'lucide-react'; -import { memo, useMemo } from 'react'; - -const styles = createStaticStyles(({ css, cssVar }) => ({ - count: css` - font-size: 12px; - color: ${cssVar.colorTextTertiary}; - `, - pattern: css` - font-family: ${cssVar.fontFamilyCode}; - `, - scope: css` - font-size: 12px; - color: ${cssVar.colorTextTertiary}; - word-break: break-all; - `, -})); +import { Highlighter } from '@lobehub/ui'; +import { memo } from 'react'; interface GlobArgs { path?: string; pattern?: string; } -const Glob = memo>(({ args, content }) => { - const pattern = args?.pattern || ''; - const scope = args?.path || ''; - - const matchCount = useMemo(() => { - if (!content) return 0; - return content.split('\n').filter((line: string) => line.trim().length > 0).length; - }, [content]); +const Glob = memo>(({ content }) => { + if (!content) return null; return ( - - {pattern && ( - - {pattern} - - )} - {scope && ( - - {scope} - - )} - {matchCount > 0 && {`${matchCount} matches`}} - - } + - {content && ( - - {content} - - )} - + {content} + ); }); diff --git a/packages/builtin-tool-claude-code/src/client/Render/Grep/index.tsx b/packages/builtin-tool-claude-code/src/client/Render/Grep/index.tsx index 35b2fe28bf..f7cec717a5 100644 --- a/packages/builtin-tool-claude-code/src/client/Render/Grep/index.tsx +++ b/packages/builtin-tool-claude-code/src/client/Render/Grep/index.tsx @@ -1,23 +1,9 @@ 'use client'; -import { ToolResultCard } from '@lobechat/shared-tool-ui/components'; import type { BuiltinRenderProps } from '@lobechat/types'; -import { Highlighter, Tag, Text } from '@lobehub/ui'; -import { createStaticStyles } from 'antd-style'; -import { Search } from 'lucide-react'; +import { Highlighter } from '@lobehub/ui'; import { memo } from 'react'; -const styles = createStaticStyles(({ css, cssVar }) => ({ - pattern: css` - font-family: ${cssVar.fontFamilyCode}; - `, - scope: css` - font-size: 12px; - color: ${cssVar.colorTextTertiary}; - word-break: break-all; - `, -})); - interface GrepArgs { glob?: string; output_mode?: 'files_with_matches' | 'content' | 'count'; @@ -26,43 +12,19 @@ interface GrepArgs { type?: string; } -const Grep = memo>(({ args, content }) => { - const pattern = args?.pattern || ''; - const scope = args?.path || ''; - const glob = args?.glob || args?.type; +const Grep = memo>(({ content }) => { + if (!content) return null; return ( - - {pattern && ( - - {pattern} - - )} - {glob && {glob}} - {scope && ( - - {scope} - - )} - - } + - {content && ( - - {content} - - )} - + {content} + ); }); diff --git a/packages/builtin-tool-claude-code/src/client/Render/Read/index.tsx b/packages/builtin-tool-claude-code/src/client/Render/Read/index.tsx index 9d0515eab8..cd6e66d557 100644 --- a/packages/builtin-tool-claude-code/src/client/Render/Read/index.tsx +++ b/packages/builtin-tool-claude-code/src/client/Render/Read/index.tsx @@ -1,21 +1,10 @@ 'use client'; -import { ToolResultCard } from '@lobechat/shared-tool-ui/components'; import type { BuiltinRenderProps } from '@lobechat/types'; -import { Highlighter, Text } from '@lobehub/ui'; -import { createStaticStyles } from 'antd-style'; -import { FileText } from 'lucide-react'; +import { Highlighter } from '@lobehub/ui'; import path from 'path-browserify-esm'; import { memo, useMemo } from 'react'; -const styles = createStaticStyles(({ css, cssVar }) => ({ - path: css` - min-width: 0; - font-size: 12px; - color: ${cssVar.colorTextTertiary}; - `, -})); - interface ReadArgs { file_path?: string; limit?: number; @@ -38,38 +27,21 @@ const stripLineNumbers = (text: string): string => { const Read = memo>(({ args, content }) => { const filePath = args?.file_path || ''; - const fileName = filePath ? path.basename(filePath) : ''; - const dir = filePath ? path.dirname(filePath) : ''; const ext = filePath ? path.extname(filePath).slice(1).toLowerCase() : ''; const source = useMemo(() => stripLineNumbers(content || ''), [content]); + if (!source) return null; return ( - - {fileName || 'Read'} - {dir && dir !== '.' && ( - - {dir} - - )} - - } + - {source && ( - - {source} - - )} - + {source} + ); }); diff --git a/packages/builtin-tool-claude-code/src/client/Render/WebFetch/index.tsx b/packages/builtin-tool-claude-code/src/client/Render/WebFetch/index.tsx index 2d5aa06e57..3c6c96a85a 100644 --- a/packages/builtin-tool-claude-code/src/client/Render/WebFetch/index.tsx +++ b/packages/builtin-tool-claude-code/src/client/Render/WebFetch/index.tsx @@ -1,55 +1,18 @@ 'use client'; -import { ToolResultCard } from '@lobechat/shared-tool-ui/components'; import type { BuiltinRenderProps } from '@lobechat/types'; -import { Markdown, Text } from '@lobehub/ui'; -import { createStaticStyles } from 'antd-style'; -import { Link } from 'lucide-react'; +import { Markdown } from '@lobehub/ui'; import { memo } from 'react'; import type { WebFetchArgs } from '../../../types'; -const styles = createStaticStyles(({ css, cssVar }) => ({ - prompt: css` - font-size: 12px; - color: ${cssVar.colorTextTertiary}; - word-break: break-all; - `, - url: css` - font-family: ${cssVar.fontFamilyCode}; - word-break: break-all; - `, -})); - -const WebFetch = memo>(({ args, content }) => { - const url = args?.url || ''; - const prompt = args?.prompt || ''; +const WebFetch = memo>(({ content }) => { + if (!content) return null; return ( - - {url && ( - - {url} - - )} - {prompt && ( - - {prompt} - - )} - - } - > - {content && ( - - {content} - - )} - + + {content} + ); }); diff --git a/packages/builtin-tool-claude-code/src/client/Render/WebSearch/index.tsx b/packages/builtin-tool-claude-code/src/client/Render/WebSearch/index.tsx index 2d45548f42..67012cefa8 100644 --- a/packages/builtin-tool-claude-code/src/client/Render/WebSearch/index.tsx +++ b/packages/builtin-tool-claude-code/src/client/Render/WebSearch/index.tsx @@ -1,62 +1,24 @@ 'use client'; -import { ToolResultCard } from '@lobechat/shared-tool-ui/components'; import type { BuiltinRenderProps } from '@lobechat/types'; -import { Highlighter, Text } from '@lobehub/ui'; -import { createStaticStyles } from 'antd-style'; -import { Globe } from 'lucide-react'; +import { Highlighter } from '@lobehub/ui'; import { memo } from 'react'; import type { WebSearchArgs } from '../../../types'; -const styles = createStaticStyles(({ css, cssVar }) => ({ - domains: css` - font-size: 12px; - color: ${cssVar.colorTextTertiary}; - word-break: break-all; - `, - query: css` - font-family: ${cssVar.fontFamilyCode}; - `, -})); - -const WebSearch = memo>(({ args, content }) => { - const query = args?.query || ''; - const allowed = args?.allowed_domains?.join(', '); - const blocked = args?.blocked_domains?.map((d) => `-${d}`).join(', '); - const scope = [allowed, blocked].filter(Boolean).join(' · '); +const WebSearch = memo>(({ content }) => { + if (!content) return null; return ( - - {query && ( - - {query} - - )} - {scope && ( - - {scope} - - )} - - } + - {content && ( - - {content} - - )} - + {content} + ); }); diff --git a/packages/builtin-tool-claude-code/src/client/Render/Write/index.tsx b/packages/builtin-tool-claude-code/src/client/Render/Write/index.tsx index 37c31e7ad7..5d44edb39b 100644 --- a/packages/builtin-tool-claude-code/src/client/Render/Write/index.tsx +++ b/packages/builtin-tool-claude-code/src/client/Render/Write/index.tsx @@ -1,21 +1,10 @@ 'use client'; -import { ToolResultCard } from '@lobechat/shared-tool-ui/components'; import type { BuiltinRenderProps } from '@lobechat/types'; -import { Highlighter, Markdown, Skeleton, Text } from '@lobehub/ui'; -import { createStaticStyles } from 'antd-style'; -import { FilePlus2 } from 'lucide-react'; +import { Highlighter, Markdown, Skeleton } from '@lobehub/ui'; import path from 'path-browserify-esm'; import { memo } from 'react'; -const styles = createStaticStyles(({ css, cssVar }) => ({ - path: css` - font-size: 12px; - color: ${cssVar.colorTextTertiary}; - word-break: break-all; - `, -})); - interface WriteArgs { content?: string; file_path?: string; @@ -25,7 +14,6 @@ const Write = memo>(({ args }) => { if (!args) return ; const filePath = args.file_path || ''; - const fileName = filePath ? path.basename(filePath) : ''; const ext = filePath ? path.extname(filePath).slice(1).toLowerCase() : ''; const renderContent = () => { @@ -52,23 +40,7 @@ const Write = memo>(({ args }) => { ); }; - return ( - - {fileName || 'Write'} - {filePath && filePath !== fileName && ( - - {filePath} - - )} - - } - > - {renderContent()} - - ); + return renderContent(); }); Write.displayName = 'ClaudeCodeWrite'; diff --git a/packages/builtin-tools/src/codex/CommandExecutionInspector.tsx b/packages/builtin-tools/src/codex/CommandExecutionInspector.tsx new file mode 100644 index 0000000000..af3e3639cc --- /dev/null +++ b/packages/builtin-tools/src/codex/CommandExecutionInspector.tsx @@ -0,0 +1,115 @@ +'use client'; + +import { + createGrepContentInspector, + createReadLocalFileInspector, + RunCommandInspector, +} from '@lobechat/shared-tool-ui/inspectors'; +import type { RunCommandState } from '@lobechat/tool-runtime'; +import type { BuiltinInspectorProps } from '@lobechat/types'; +import { memo } from 'react'; + +import { + getCodexGrepCommandDisplay, + getCodexReadFileCommandDisplay, +} from './commandExecutionUtils'; + +const COMMAND_EXECUTION_KEY = 'builtins.codex.apiName.command_execution'; +const GREP_KEY = 'builtins.codex.commandExecution.grep'; +const GREP_NO_RESULTS_KEY = 'builtins.codex.commandExecution.noResults'; +const READ_FILE_KEY = 'builtins.codex.commandExecution.readFile'; +const SharedGrepInspector = createGrepContentInspector({ + noResultsKey: GREP_NO_RESULTS_KEY, + translationKey: GREP_KEY, +}); +const SharedReadInspector = createReadLocalFileInspector(READ_FILE_KEY); + +interface CommandExecutionArgs { + background?: boolean; + command: string; + description?: string; + timeout?: number; +} + +interface ReadFileArgs { + endLine?: number; + path?: string; + startLine?: number; +} + +interface GrepArgs { + pattern?: string; +} + +const mapCommandToReadArgs = (command?: string): ReadFileArgs | undefined => { + const display = getCodexReadFileCommandDisplay(command); + if (!display) return; + + return { + endLine: display.endLine, + path: display.filePath, + startLine: display.startLine, + }; +}; + +const mapCommandToGrepArgs = (command?: string): GrepArgs | undefined => { + const display = getCodexGrepCommandDisplay(command); + if (!display) return; + + return { pattern: display.pattern }; +}; + +const CommandExecutionInspector = memo< + BuiltinInspectorProps +>((props) => { + const { + apiName, + args, + identifier, + isArgumentsStreaming, + isLoading, + partialArgs, + result, + toolCallId, + } = props; + + const readArgs = mapCommandToReadArgs(args?.command); + const partialReadArgs = mapCommandToReadArgs(partialArgs?.command); + if (readArgs || partialReadArgs) { + return ( + + ); + } + + const grepArgs = mapCommandToGrepArgs(args?.command); + const partialGrepArgs = mapCommandToGrepArgs(partialArgs?.command); + if (grepArgs || partialGrepArgs) { + return ( + + ); + } + + return ; +}); + +CommandExecutionInspector.displayName = 'CodexCommandExecutionInspector'; + +export default CommandExecutionInspector; diff --git a/packages/builtin-tools/src/codex/FileChangeInspector.tsx b/packages/builtin-tools/src/codex/FileChangeInspector.tsx index 61b593bc5f..cb757597da 100644 --- a/packages/builtin-tools/src/codex/FileChangeInspector.tsx +++ b/packages/builtin-tools/src/codex/FileChangeInspector.tsx @@ -5,24 +5,11 @@ import { inspectorTextStyles, shinyTextStyles } from '@lobechat/shared-tool-ui/s import type { BuiltinInspectorProps } from '@lobechat/types'; import { createStaticStyles, cx } from 'antd-style'; import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; import { type CodexFileChangeArgs, type CodexFileChangeState, getFileChangeStats } from './utils'; const styles = createStaticStyles(({ css, cssVar }) => ({ - chip: css` - overflow: hidden; - display: inline-flex; - flex-shrink: 1; - align-items: center; - - min-width: 0; - margin-inline-start: 6px; - padding-block: 2px; - padding-inline: 10px; - border-radius: 999px; - - background: ${cssVar.colorFillTertiary}; - `, count: css` margin-inline-start: 4px; font-size: 12px; @@ -31,25 +18,36 @@ const styles = createStaticStyles(({ css, cssVar }) => ({ lineAdded: css` margin-inline-start: 6px; font-size: 12px; - font-weight: 600; color: ${cssVar.colorSuccess}; `, lineDeleted: css` margin-inline-start: 4px; font-size: 12px; - font-weight: 600; color: ${cssVar.colorError}; `, + summary: css` + margin-inline-end: 6px; + `, })); const FileChangeInspector = memo>( ({ args, partialArgs, isArgumentsStreaming, isLoading, pluginState }) => { + const { t } = useTranslation('plugin'); const stats = getFileChangeStats(args || partialArgs, pluginState); const hasLineStats = stats.linesAdded > 0 || stats.linesDeleted > 0; + const summary = + stats.total > 0 + ? t('builtins.codex.fileChange.editedFiles', { + count: stats.total, + defaultValue: stats.total === 1 ? 'Edited {{count}} file' : 'Edited {{count}} files', + }) + : isArgumentsStreaming || isLoading + ? t('builtins.codex.fileChange.editing', { defaultValue: 'Editing files' }) + : t('builtins.codex.fileChange.noChanges', { defaultValue: 'No file changes' }); if (isArgumentsStreaming && !stats.firstPath) { return ( -
File changes
+
{summary}
); } @@ -60,11 +58,13 @@ const FileChangeInspector = memo - File changes: - {stats.firstPath && ( - + {stats.firstPath ? ( + <> + {summary}: - + + ) : ( + {summary} )} {stats.total > 1 && +{stats.total - 1}} {hasLineStats && ( diff --git a/packages/builtin-tools/src/codex/FileChangeRender.tsx b/packages/builtin-tools/src/codex/FileChangeRender.tsx index 1c1040bcc6..c286b4cd1a 100644 --- a/packages/builtin-tools/src/codex/FileChangeRender.tsx +++ b/packages/builtin-tools/src/codex/FileChangeRender.tsx @@ -1,11 +1,11 @@ 'use client'; -import { FilePathDisplay, ToolResultCard } from '@lobechat/shared-tool-ui/components'; +import { FilePathDisplay } from '@lobechat/shared-tool-ui/components'; import type { BuiltinRenderProps } from '@lobechat/types'; -import { Flexbox, Icon, Text } from '@lobehub/ui'; +import { Flexbox, Text } from '@lobehub/ui'; import { createStaticStyles, cx } from 'antd-style'; -import { Files, FileText } from 'lucide-react'; import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; import { type CodexFileChangeArgs, @@ -22,34 +22,6 @@ const styles = createStaticStyles(({ css, cssVar }) => ({ font-size: 13px; color: ${cssVar.colorTextTertiary}; `, - header: css` - flex-wrap: wrap; - gap: 6px; - align-items: center; - min-width: 0; - `, - headerChip: css` - overflow: hidden; - display: inline-flex; - flex: 0 1 auto; - align-items: center; - - min-width: 0; - max-width: 100%; - padding-block: 4px; - padding-inline: 10px; - border-radius: 999px; - - background: ${cssVar.colorFillTertiary}; - `, - headerCount: css` - font-size: 12px; - color: ${cssVar.colorTextTertiary}; - `, - headerLabel: css` - font-size: 12px; - color: ${cssVar.colorTextSecondary}; - `, kindAdded: css` background: ${cssVar.colorSuccess}; `, @@ -69,11 +41,9 @@ const styles = createStaticStyles(({ css, cssVar }) => ({ background: ${cssVar.colorWarning}; `, lineAdded: css` - font-weight: 600; color: ${cssVar.colorSuccess}; `, lineDeleted: css` - font-weight: 600; color: ${cssVar.colorError}; `, lineStats: css` @@ -85,33 +55,10 @@ const styles = createStaticStyles(({ css, cssVar }) => ({ font-size: 12px; `, list: css` - gap: 0; - `, - panel: css` - overflow: hidden; - border: 1px solid ${cssVar.colorBorderSecondary}; - border-radius: 12px; - background: ${cssVar.colorFillQuaternary}; - `, - panelHeader: css` - flex-wrap: wrap; - gap: 8px; - align-items: center; - - padding: 12px; - border-block-end: 1px solid ${cssVar.colorBorderSecondary}; - - background: ${cssVar.colorBgContainer}; - `, - panelMeta: css` - font-size: 12px; - color: ${cssVar.colorTextTertiary}; - `, - panelTitle: css` - display: flex; - gap: 8px; - align-items: center; + gap: 2px; min-width: 0; + padding-block: 2px; + padding-inline: 4px; `, rowMain: css` display: flex; @@ -128,13 +75,12 @@ const styles = createStaticStyles(({ css, cssVar }) => ({ min-width: 0; `, row: css` - gap: 10px; + gap: 8px; align-items: center; - padding: 12px; - & + & { - border-block-start: 1px solid ${cssVar.colorBorderSecondary}; - } + min-height: 26px; + padding-block: 3px; + padding-inline: 0; `, unknownPath: css` font-size: 13px; @@ -175,75 +121,45 @@ LineStats.displayName = 'CodexFileChangeLineStats'; const FileChangeRender = memo>( ({ args, pluginState }) => { + const { t } = useTranslation('plugin'); const stats = getFileChangeStats(args, pluginState); const data = getFileChangeData(args, pluginState); - const summary = stats.total === 1 ? '1 file change' : `${stats.total} file changes`; - const detail = [ - stats.byKind.added > 0 ? `${stats.byKind.added} added` : null, - stats.byKind.modified > 0 ? `${stats.byKind.modified} modified` : null, - stats.byKind.deleted > 0 ? `${stats.byKind.deleted} deleted` : null, - stats.byKind.renamed > 0 ? `${stats.byKind.renamed} renamed` : null, - ] - .filter(Boolean) - .join(', '); + + if (stats.total === 0) { + return ( + + {t('builtins.codex.fileChange.noChanges', { defaultValue: 'No file changes' })} + + ); + } return ( - - File changes: - {stats.firstPath && ( - - - - )} - {stats.total > 1 && +{stats.total - 1}} - - - } - > - {stats.total > 0 ? ( -
- -
- - {summary} -
- {detail && {detail}} - -
- - {data.changes.map((change, index) => { - const kind = getFileChangeKind(change.kind); - const path = change.path || ''; + + {data.changes.map((change, index) => { + const kind = getFileChangeKind(change.kind); + const path = change.path || ''; - return ( - - -
-
- {path ? ( - - ) : ( - Unknown file - )} -
- -
-
- ); - })} + return ( + + +
+
+ {path ? ( + + ) : ( + + {t('builtins.codex.fileChange.unknownFile', { + defaultValue: 'Unknown file', + })} + + )} +
+ +
-
- ) : ( - No file changes - )} -
+ ); + })} + ); }, ); diff --git a/packages/builtin-tools/src/codex/McpToolInspector.tsx b/packages/builtin-tools/src/codex/McpToolInspector.tsx new file mode 100644 index 0000000000..8e233d378a --- /dev/null +++ b/packages/builtin-tools/src/codex/McpToolInspector.tsx @@ -0,0 +1,79 @@ +'use client'; + +import { LINEAR_TOOL_NAMES, LinearInspector } from '@lobechat/shared-tool-ui/inspectors'; +import { + highlightTextStyles, + inspectorTextStyles, + shinyTextStyles, +} from '@lobechat/shared-tool-ui/styles'; +import type { BuiltinInspectorProps } from '@lobechat/types'; +import { cx } from 'antd-style'; +import type { ComponentType } from 'react'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { CodexMcpToolArgs, CodexMcpToolState } from './mcpToolUtils'; +import { + getCodexLinearMcpApiName, + getMcpInputRecord, + getMcpServer, + getMcpToolName, +} from './mcpToolUtils'; + +const LINEAR_TOOL_NAME_SET = new Set(LINEAR_TOOL_NAMES); +const SharedLinearInspector = LinearInspector as ComponentType< + BuiltinInspectorProps> +>; + +const McpToolInspector = memo>( + ({ args, partialArgs, isArgumentsStreaming, isLoading, pluginState }) => { + const { t } = useTranslation('plugin'); + const label = t('builtins.codex.apiName.mcp_tool_call', { + defaultValue: 'Call MCP tool', + }); + const server = getMcpServer(args, pluginState) || getMcpServer(partialArgs); + const tool = getMcpToolName(args, pluginState) || getMcpToolName(partialArgs); + const linearApiName = getCodexLinearMcpApiName(tool); + + if (LINEAR_TOOL_NAME_SET.has(linearApiName)) { + return ( + + ); + } + + const target = [server, tool].filter(Boolean).join(' > '); + + if (isArgumentsStreaming && !target) { + return
{label}
; + } + + return ( +
+ {label} + {target && ( + <> + : + {target} + + )} +
+ ); + }, +); + +McpToolInspector.displayName = 'CodexMcpToolInspector'; + +export default McpToolInspector; diff --git a/packages/builtin-tools/src/codex/McpToolRender.tsx b/packages/builtin-tools/src/codex/McpToolRender.tsx new file mode 100644 index 0000000000..2a001cbd8d --- /dev/null +++ b/packages/builtin-tools/src/codex/McpToolRender.tsx @@ -0,0 +1,94 @@ +'use client'; + +import type { BuiltinRenderProps } from '@lobechat/types'; +import { Flexbox, Highlighter, Text } from '@lobehub/ui'; +import { createStaticStyles, cssVar } from 'antd-style'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { CodexMcpToolArgs, CodexMcpToolState } from './mcpToolUtils'; +import { + formatMcpInput, + formatMcpOutput, + getMcpErrorText, + getMcpInput, + getMcpResultText, + getMcpToolName, +} from './mcpToolUtils'; + +const styles = createStaticStyles(({ css, cssVar }) => ({ + sectionLabel: css` + margin-block-end: 4px; + font-size: 12px; + color: ${cssVar.colorTextSecondary}; + `, +})); + +const McpToolRender = memo>( + ({ args, content, pluginState }) => { + const { t } = useTranslation('plugin'); + const toolName = getMcpToolName(args, pluginState); + const input = formatMcpInput(getMcpInput(args, pluginState), toolName); + const output = formatMcpOutput(getMcpResultText(content, pluginState, args), toolName); + const error = getMcpErrorText(pluginState, args); + + if (!input && !output && !error) return null; + + return ( + + {input && ( +
+ + {t('builtins.codex.mcpTool.input', { defaultValue: 'Input' })} + + + {input.text} + +
+ )} + {output && ( +
+ + {t('builtins.codex.mcpTool.result', { defaultValue: 'Result' })} + + + {output.text} + +
+ )} + {error && ( +
+ + {t('builtins.codex.mcpTool.error', { defaultValue: 'Error' })} + + + {error} + +
+ )} +
+ ); + }, +); + +McpToolRender.displayName = 'CodexMcpToolRender'; + +export default McpToolRender; diff --git a/packages/builtin-tools/src/codex/WebSearchInspector.tsx b/packages/builtin-tools/src/codex/WebSearchInspector.tsx new file mode 100644 index 0000000000..d3a00b6ee8 --- /dev/null +++ b/packages/builtin-tools/src/codex/WebSearchInspector.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { + highlightTextStyles, + inspectorTextStyles, + shinyTextStyles, +} from '@lobechat/shared-tool-ui/styles'; +import type { BuiltinInspectorProps } from '@lobechat/types'; +import { cx } from 'antd-style'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { type CodexWebSearchArgs, getWebSearchQuery } from './webSearchUtils'; + +const WebSearchInspector = memo>( + ({ args, partialArgs, isArgumentsStreaming, isLoading }) => { + const { t } = useTranslation('plugin'); + const label = t('builtins.codex.apiName.web_search', { defaultValue: 'Search the web' }); + const query = getWebSearchQuery(args) || getWebSearchQuery(partialArgs); + + if (isArgumentsStreaming && !query) { + return
{label}
; + } + + return ( +
+ {label} + {query && ( + <> + : + {query} + + )} +
+ ); + }, +); + +WebSearchInspector.displayName = 'CodexWebSearchInspector'; + +export default WebSearchInspector; diff --git a/packages/builtin-tools/src/codex/WebSearchRender.tsx b/packages/builtin-tools/src/codex/WebSearchRender.tsx new file mode 100644 index 0000000000..7d0bcbbdec --- /dev/null +++ b/packages/builtin-tools/src/codex/WebSearchRender.tsx @@ -0,0 +1,148 @@ +'use client'; + +import type { BuiltinRenderProps } from '@lobechat/types'; +import { Flexbox, Text } from '@lobehub/ui'; +import { createStaticStyles } from 'antd-style'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { + type CodexWebSearchArgs, + getWebSearchOutput, + getWebSearchQuery, + getWebSearchResults, +} from './webSearchUtils'; + +const styles = createStaticStyles(({ css, cssVar }) => ({ + output: css` + overflow: auto; + + max-height: 280px; + margin: 0; + padding-block: 2px; + padding-inline: 4px; + + font-family: ${cssVar.fontFamily}; + font-size: 13px; + line-height: 1.55; + color: ${cssVar.colorTextSecondary}; + white-space: pre-wrap; + `, + query: css` + overflow: hidden; + + min-width: 0; + + color: ${cssVar.colorText}; + text-overflow: ellipsis; + white-space: nowrap; + `, + queryLabel: css` + flex-shrink: 0; + color: ${cssVar.colorTextTertiary}; + `, + queryRow: css` + gap: 6px; + align-items: center; + + padding-block: 2px; + padding-inline: 4px; + + font-size: 12px; + `, + resultItem: css` + min-width: 0; + padding-block: 5px; + padding-inline: 4px; + border-block-end: 1px solid ${cssVar.colorSplit}; + + &:last-child { + border-block-end: 0; + } + `, + resultList: css` + gap: 0; + min-width: 0; + `, + root: css` + gap: 4px; + min-width: 0; + padding-block: 2px; + `, + snippet: css` + font-size: 12px; + line-height: 1.45; + color: ${cssVar.colorTextSecondary}; + `, + title: css` + overflow: hidden; + + font-size: 13px; + font-weight: 500; + line-height: 1.45; + color: ${cssVar.colorText}; + text-overflow: ellipsis; + white-space: nowrap; + + &:hover { + color: ${cssVar.colorLink}; + } + `, + url: css` + overflow: hidden; + + font-size: 12px; + color: ${cssVar.colorTextTertiary}; + text-overflow: ellipsis; + white-space: nowrap; + `, +})); + +const WebSearchRender = memo>(({ args, content }) => { + const { t } = useTranslation('plugin'); + const query = getWebSearchQuery(args); + const results = getWebSearchResults(args, content); + const output = results.length > 0 ? '' : getWebSearchOutput(content); + + if (!query && results.length === 0 && !output) return null; + + return ( + + {query && ( + + + {t('builtins.codex.webSearch.query', { defaultValue: 'Query' })} + + {query} + + )} + {results.length > 0 && ( + + {results.map((result, index) => { + const key = result.url || `${result.title}-${index}`; + const title = {result.title}; + + return ( + + {result.url ? ( + + {title} + + ) : ( + title + )} + {result.url && {result.url}} + {result.snippet && {result.snippet}} + + ); + })} + + )} + {output &&
{output}
} +
+ ); +}); + +WebSearchRender.displayName = 'CodexWebSearchRender'; + +export default WebSearchRender; diff --git a/packages/builtin-tools/src/codex/commandExecutionUtils.test.ts b/packages/builtin-tools/src/codex/commandExecutionUtils.test.ts new file mode 100644 index 0000000000..e270422369 --- /dev/null +++ b/packages/builtin-tools/src/codex/commandExecutionUtils.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest'; + +import { + getCodexGrepCommandDisplay, + getCodexReadFileCommandDisplay, +} from './commandExecutionUtils'; + +describe('getCodexReadFileCommandDisplay', () => { + it('parses sed range reads', () => { + expect( + getCodexReadFileCommandDisplay( + "sed -n '1,260p' src/features/Conversation/Messages/AssistantGroup/Tool/index.tsx", + ), + ).toEqual({ + endLine: 260, + filePath: 'src/features/Conversation/Messages/AssistantGroup/Tool/index.tsx', + startLine: 1, + }); + }); + + it('parses double quoted and bare ranges', () => { + expect(getCodexReadFileCommandDisplay('sed -n "260,620p" packages/foo.ts')).toEqual({ + endLine: 620, + filePath: 'packages/foo.ts', + startLine: 260, + }); + expect(getCodexReadFileCommandDisplay('sed -n 42p packages/foo.ts')).toEqual({ + filePath: 'packages/foo.ts', + startLine: 42, + }); + }); + + it('unwraps simple shell wrappers', () => { + expect( + getCodexReadFileCommandDisplay("/bin/zsh -lc 'sed -n '\\''1,260p'\\'' packages/foo.ts'"), + ).toEqual({ + endLine: 260, + filePath: 'packages/foo.ts', + startLine: 1, + }); + }); + + it('supports a quoted file path token', () => { + expect(getCodexReadFileCommandDisplay("sed -n '1,3p' 'src/foo bar.ts'")).toEqual({ + endLine: 3, + filePath: 'src/foo bar.ts', + startLine: 1, + }); + }); + + it('parses cat single file reads', () => { + expect(getCodexReadFileCommandDisplay('cat .agents/skills/react/SKILL.md')).toEqual({ + filePath: '.agents/skills/react/SKILL.md', + }); + expect(getCodexReadFileCommandDisplay("cat -- 'src/foo bar.ts'")).toEqual({ + filePath: 'src/foo bar.ts', + }); + }); + + it('ignores commands with extra shell behavior', () => { + expect(getCodexReadFileCommandDisplay("sed -n '1,260p' src/foo.ts | head")).toBeUndefined(); + expect(getCodexReadFileCommandDisplay("sed -n '1,260p' src/foo.ts > /tmp/out")).toBeUndefined(); + expect(getCodexReadFileCommandDisplay("sed -n '1,260p;2,3p' src/foo.ts")).toBeUndefined(); + expect(getCodexReadFileCommandDisplay('cat src/foo.ts src/bar.ts')).toBeUndefined(); + expect(getCodexReadFileCommandDisplay('cat src/foo.ts | head')).toBeUndefined(); + }); +}); + +describe('getCodexGrepCommandDisplay', () => { + it('parses simple rg content searches', () => { + expect(getCodexGrepCommandDisplay('rg "createReadLocalFileInspector" packages src')).toEqual({ + pattern: 'createReadLocalFileInspector', + }); + }); + + it('parses rg --files pipelines', () => { + expect( + getCodexGrepCommandDisplay( + 'rg --files packages src | rg "codex|AssistantGroup/Tool|toolDisplayNames"', + ), + ).toEqual({ + pattern: 'codex|AssistantGroup/Tool|toolDisplayNames', + }); + }); + + it('parses rg regexp options', () => { + expect(getCodexGrepCommandDisplay('rg -n -e "foo|bar" src')).toEqual({ + pattern: 'foo|bar', + }); + expect(getCodexGrepCommandDisplay('rg --regexp=foo src')).toEqual({ + pattern: 'foo', + }); + }); + + it('ignores unsafe or unsupported rg commands', () => { + expect(getCodexGrepCommandDisplay('rg "foo" src > /tmp/out')).toBeUndefined(); + expect(getCodexGrepCommandDisplay('rg --files src | sort | rg "foo"')).toBeUndefined(); + expect(getCodexGrepCommandDisplay('rg --files src')).toBeUndefined(); + }); +}); diff --git a/packages/builtin-tools/src/codex/commandExecutionUtils.ts b/packages/builtin-tools/src/codex/commandExecutionUtils.ts new file mode 100644 index 0000000000..d3432c959d --- /dev/null +++ b/packages/builtin-tools/src/codex/commandExecutionUtils.ts @@ -0,0 +1,281 @@ +const SHELL_WRAPPER_PATTERN = + /^(?:\/usr\/bin\/env\s+)?(?:\/\S+\/)?(?:bash|sh|zsh)\s+(?:-lc|-c|-l\s+-c)\s+(\S[\s\S]*)$/; + +const CAT_OPTION_PATTERN = /^-[a-z]+$/i; +const SED_RANGE_PATTERN = /^(\d+)(?:,(\d+))?p$/; + +const hasShellControlOperator = (value: string) => + /\|\||&&|[|;<>`]/.test(value) || value.includes('$('); + +const hasUnsafeShellControlOperator = (value: string) => + /\|\||&&|[;<>`]/.test(value) || value.includes('$('); + +const stripOuterShellQuotes = (value: string) => { + const trimmed = value.trim(); + if (trimmed.length < 2) return trimmed; + + const quote = trimmed[0]; + if ((quote !== '"' && quote !== "'") || trimmed.at(-1) !== quote) return trimmed; + + const body = trimmed.slice(1, -1); + if (quote === "'") return body.replaceAll("'\\''", "'"); + + return body + .replaceAll('\\"', '"') + .replaceAll('\\`', '`') + .replaceAll('\\$', '$') + .replaceAll('\\\\', '\\'); +}; + +const stripShellWrapper = (command?: string) => { + const trimmed = command?.trim() || ''; + if (!trimmed) return ''; + + const match = trimmed.match(SHELL_WRAPPER_PATTERN); + if (!match) return trimmed; + + return stripOuterShellQuotes(match[1]) || trimmed; +}; + +const pushToken = (tokens: string[], token: string) => { + if (token) tokens.push(token); +}; + +const tokenizeShellLike = (command: string): string[] | undefined => { + const tokens: string[] = []; + let current = ''; + let quote: '"' | "'" | undefined; + let escaping = false; + + for (const char of command) { + if (escaping) { + current += char; + escaping = false; + continue; + } + + if (char === '\\' && quote !== "'") { + escaping = true; + continue; + } + + if (quote) { + if (char === quote) { + quote = undefined; + } else { + current += char; + } + continue; + } + + if (char === '"' || char === "'") { + quote = char; + continue; + } + + if (char === '|') { + pushToken(tokens, current); + current = ''; + tokens.push('|'); + continue; + } + + if (/\s/.test(char)) { + pushToken(tokens, current); + current = ''; + continue; + } + + current += char; + } + + if (quote || escaping) return; + pushToken(tokens, current); + + return tokens; +}; + +const parseLineRange = (range: string) => { + const match = range.match(SED_RANGE_PATTERN); + if (!match) return; + + return { + endLine: match[2] ? Number(match[2]) : undefined, + startLine: Number(match[1]), + }; +}; + +const getSingleFileToken = (token?: string) => { + if (!token || token === '-' || hasShellControlOperator(token)) return; + return token; +}; + +export interface CodexReadFileCommandDisplay { + endLine?: number; + filePath: string; + startLine?: number; +} + +const parseSedReadCommand = (tokens: string[]): CodexReadFileCommandDisplay | undefined => { + if (tokens[0] !== 'sed') return; + + const printOption = tokens[1]; + if (printOption !== '-n' && printOption !== '--quiet' && printOption !== '--silent') return; + + const range = parseLineRange(tokens[2] || ''); + if (!range) return; + + const targetIndex = tokens[3] === '--' ? 4 : 3; + if (tokens.length !== targetIndex + 1) return; + + const filePath = getSingleFileToken(tokens[targetIndex]); + if (!filePath) return; + + return { ...range, filePath }; +}; + +const parseCatReadCommand = (tokens: string[]): CodexReadFileCommandDisplay | undefined => { + if (tokens[0] !== 'cat') return; + + let targetIndex = 1; + while (CAT_OPTION_PATTERN.test(tokens[targetIndex] || '')) targetIndex += 1; + if (tokens[targetIndex] === '--') targetIndex += 1; + if (tokens.length !== targetIndex + 1) return; + + const filePath = getSingleFileToken(tokens[targetIndex]); + if (!filePath) return; + + return { filePath }; +}; + +export const getCodexReadFileCommandDisplay = ( + command?: string, +): CodexReadFileCommandDisplay | undefined => { + const displayCommand = stripShellWrapper(command); + if (!displayCommand || hasShellControlOperator(displayCommand)) return; + + const tokens = tokenizeShellLike(displayCommand); + if (!tokens) return; + + return parseSedReadCommand(tokens) || parseCatReadCommand(tokens); +}; + +const RG_OPTIONS_WITH_VALUE = new Set([ + '-A', + '-B', + '-C', + '-g', + '-m', + '-t', + '-T', + '--after-context', + '--before-context', + '--colors', + '--context', + '--context-separator', + '--engine', + '--field-context-separator', + '--field-match-separator', + '--glob', + '--iglob', + '--json-seq', + '--max-columns', + '--max-count', + '--max-depth', + '--max-filesize', + '--mmap', + '--path-separator', + '--pre', + '--replace', + '--sort', + '--sortr', + '--type', + '--type-add', + '--type-clear', + '--type-not', +]); + +const RG_PATTERN_OPTIONS = new Set(['-e', '--regexp']); + +const splitRgPipeline = (tokens: string[]) => { + const pipeIndexes = tokens.reduce((indexes, token, index) => { + if (token === '|') indexes.push(index); + return indexes; + }, []); + + if (pipeIndexes.length === 0) return [tokens]; + if (pipeIndexes.length > 1) return; + + const pipeIndex = pipeIndexes[0]; + const first = tokens.slice(0, pipeIndex); + const second = tokens.slice(pipeIndex + 1); + + if (first[0] !== 'rg' || second[0] !== 'rg' || !first.includes('--files')) return; + + return [second]; +}; + +const getAttachedRgPattern = (token: string) => { + if (token.startsWith('--regexp=')) return token.slice('--regexp='.length); + if (token.startsWith('-e') && token.length > 2) return token.slice(2); +}; + +const isRgOptionWithAttachedValue = (token: string) => + ['-A', '-B', '-C', '-g', '-m', '-t', '-T'].some( + (option) => token.startsWith(option) && token.length > option.length, + ) || /^--[^=]+=/.test(token); + +const getRgPatternFromTokens = (tokens: string[]) => { + if (tokens[0] !== 'rg') return; + + for (let index = 1; index < tokens.length; index++) { + const token = tokens[index]; + if (!token) continue; + + const attachedPattern = getAttachedRgPattern(token); + if (attachedPattern) return attachedPattern; + + if (RG_PATTERN_OPTIONS.has(token)) { + return tokens[index + 1]; + } + + if (token === '--') { + return tokens[index + 1]; + } + + if (RG_OPTIONS_WITH_VALUE.has(token)) { + index += 1; + continue; + } + + if (token.startsWith('-')) { + if (isRgOptionWithAttachedValue(token)) continue; + continue; + } + + return token; + } +}; + +export interface CodexGrepCommandDisplay { + pattern: string; +} + +export const getCodexGrepCommandDisplay = ( + command?: string, +): CodexGrepCommandDisplay | undefined => { + const displayCommand = stripShellWrapper(command); + if (!displayCommand || hasUnsafeShellControlOperator(displayCommand)) return; + + const tokens = tokenizeShellLike(displayCommand); + if (!tokens) return; + if (!tokens.includes('|') && tokens.includes('--files')) return; + + const rgCommands = splitRgPipeline(tokens); + if (!rgCommands) return; + + const pattern = getRgPatternFromTokens(rgCommands.at(-1) || []); + if (!pattern) return; + + return { pattern }; +}; diff --git a/packages/builtin-tools/src/codex/displayControls.ts b/packages/builtin-tools/src/codex/displayControls.ts new file mode 100644 index 0000000000..8cda8996ed --- /dev/null +++ b/packages/builtin-tools/src/codex/displayControls.ts @@ -0,0 +1,9 @@ +import type { RenderDisplayControl } from '@lobechat/types'; + +export const CodexRenderDisplayControls: Record = { + command_execution: 'collapsed', + file_change: 'expand', + mcp_tool_call: 'expand', + todo_list: 'expand', + web_search: 'expand', +}; diff --git a/packages/builtin-tools/src/codex/index.ts b/packages/builtin-tools/src/codex/index.ts index 32cd0355b9..371187493b 100644 --- a/packages/builtin-tools/src/codex/index.ts +++ b/packages/builtin-tools/src/codex/index.ts @@ -1,25 +1,29 @@ -import { - type BuiltinInspector, - type BuiltinRender, - type RenderDisplayControl, -} from '@lobechat/types'; +import { type BuiltinInspector, type BuiltinRender } from '@lobechat/types'; +import CommandExecutionInspector from './CommandExecutionInspector'; +import { CodexRenderDisplayControls } from './displayControls'; import FileChangeInspector from './FileChangeInspector'; import FileChangeRender from './FileChangeRender'; +import McpToolInspector from './McpToolInspector'; +import McpToolRender from './McpToolRender'; import TodoListInspector from './TodoListInspector'; import TodoListRender from './TodoListRender'; +import WebSearchInspector from './WebSearchInspector'; +import WebSearchRender from './WebSearchRender'; export const CodexInspectors: Record = { + command_execution: CommandExecutionInspector as BuiltinInspector, file_change: FileChangeInspector as BuiltinInspector, + mcp_tool_call: McpToolInspector as BuiltinInspector, todo_list: TodoListInspector as BuiltinInspector, + web_search: WebSearchInspector as BuiltinInspector, }; export const CodexRenders: Record = { file_change: FileChangeRender as BuiltinRender, + mcp_tool_call: McpToolRender as BuiltinRender, todo_list: TodoListRender as BuiltinRender, + web_search: WebSearchRender as BuiltinRender, }; -export const CodexRenderDisplayControls: Record = { - file_change: 'expand', - todo_list: 'expand', -}; +export { CodexRenderDisplayControls }; diff --git a/packages/builtin-tools/src/codex/mcpToolUtils.ts b/packages/builtin-tools/src/codex/mcpToolUtils.ts new file mode 100644 index 0000000000..fae811e61f --- /dev/null +++ b/packages/builtin-tools/src/codex/mcpToolUtils.ts @@ -0,0 +1,217 @@ +'use client'; + +export interface CodexMcpToolArgs extends Record { + arguments?: unknown; + error?: unknown; + result?: unknown; + server?: unknown; + tool?: unknown; +} + +export interface CodexMcpToolState extends Record { + arguments?: unknown; + error?: unknown; + result?: unknown; + server?: unknown; + status?: unknown; + tool?: unknown; +} + +export interface FormattedMcpValue { + language: string; + text: string; +} + +const LINEAR_CODEX_PREFIX = 'linear_'; +const LINEAR_CODEX_SERVER_PREFIX = 'server_'; +const SERVER_KEYS = ['server', 'serverName', 'server_name', 'connector', 'connector_id']; +const TOOL_KEYS = ['tool', 'toolName', 'tool_name', 'name']; +const INPUT_KEYS = ['arguments', 'args', 'input', 'params', 'parameters']; + +const COMPLETED_MCP_TOOL_CALL_PATTERN = /^Completed mcp_tool_call\.?$/iu; + +export const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const normalizeString = (value: unknown) => (typeof value === 'string' ? value.trim() : ''); + +const getStringByKeys = (record: Record | undefined, keys: string[]) => { + if (!record) return ''; + + for (const key of keys) { + const value = normalizeString(record[key]); + if (value) return value; + } + + return ''; +}; + +export const getMcpServer = (args?: CodexMcpToolArgs, state?: CodexMcpToolState) => + getStringByKeys(args, SERVER_KEYS) || getStringByKeys(state, SERVER_KEYS); + +export const getMcpToolName = (args?: CodexMcpToolArgs, state?: CodexMcpToolState) => + getStringByKeys(args, TOOL_KEYS) || getStringByKeys(state, TOOL_KEYS); + +export const getMcpInput = (args?: CodexMcpToolArgs, state?: CodexMcpToolState) => { + const records = [args, state].filter(isRecord); + + for (const record of records) { + for (const key of INPUT_KEYS) { + if (record[key] !== undefined && record[key] !== null) return record[key]; + } + } +}; + +const tryParseJson = (value: string): unknown => { + try { + return JSON.parse(value); + } catch { + return undefined; + } +}; + +export const getMcpInputRecord = ( + args?: CodexMcpToolArgs, + state?: CodexMcpToolState, +): Record | undefined => { + const input = getMcpInput(args, state); + if (isRecord(input)) return input; + + if (typeof input === 'string') { + const parsed = tryParseJson(input); + if (isRecord(parsed)) return parsed; + } +}; + +export const getCodexLinearMcpApiName = (toolName: string) => { + if (!toolName) return ''; + + let apiName = toolName.trim(); + if (apiName.startsWith(LINEAR_CODEX_PREFIX)) { + apiName = apiName.slice(LINEAR_CODEX_PREFIX.length); + } + if (apiName.startsWith(LINEAR_CODEX_SERVER_PREFIX)) { + apiName = apiName.slice(LINEAR_CODEX_SERVER_PREFIX.length); + } + + return apiName; +}; + +const stringifyValue = (value: unknown): string => { + if (typeof value === 'string') return value.trim(); + if (value === undefined || value === null) return ''; + + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +}; + +const getTextFromContentItem = (item: unknown): string => { + if (typeof item === 'string') return item; + if (!isRecord(item)) return stringifyValue(item); + + const text = normalizeString(item.text) || normalizeString(item.content); + if (text) return text; + + return stringifyValue(item); +}; + +const unwrapMcpResultEnvelope = (value: unknown): unknown => { + if (!isRecord(value)) return value; + + if ('Ok' in value) return value.Ok; + if ('Err' in value) return value.Err; + if ('ok' in value) return value.ok; + if ('error' in value && Object.keys(value).length === 1) return value.error; + + return value; +}; + +export const getMcpResultText = ( + content?: string, + state?: CodexMcpToolState, + args?: CodexMcpToolArgs, +) => { + const result = unwrapMcpResultEnvelope(state?.result ?? args?.result); + + if (Array.isArray(result)) { + return result.map(getTextFromContentItem).filter(Boolean).join('\n\n'); + } + + if (isRecord(result)) { + if (Array.isArray(result.content)) { + return result.content.map(getTextFromContentItem).filter(Boolean).join('\n\n'); + } + + const text = normalizeString(result.text) || normalizeString(result.output); + if (text) return text; + } + + const resultText = stringifyValue(result); + if (resultText) return resultText; + + const output = content?.trim() || ''; + if (COMPLETED_MCP_TOOL_CALL_PATTERN.test(output)) return ''; + + return output; +}; + +export const getMcpErrorText = (state?: CodexMcpToolState, args?: CodexMcpToolArgs) => { + const error = state?.error ?? args?.error; + if (!error) return ''; + + if (isRecord(error)) { + const message = normalizeString(error.message) || normalizeString(error.error); + if (message) return message; + } + + return stringifyValue(error); +}; + +export const formatMcpInput = ( + input: unknown, + toolName?: string, +): FormattedMcpValue | undefined => { + if (input === undefined || input === null) return; + + const parsed = typeof input === 'string' ? tryParseJson(input) : undefined; + const value = parsed ?? input; + + if (isRecord(value)) { + const code = normalizeString(value.code); + if (code && Object.keys(value).length === 1) { + return { + language: toolName === 'js' || toolName === 'javascript' ? 'javascript' : 'text', + text: code, + }; + } + } + + return { + language: typeof value === 'string' ? 'text' : 'json', + text: stringifyValue(value), + }; +}; + +export const formatMcpOutput = ( + value: string, + toolName?: string, +): FormattedMcpValue | undefined => { + const text = value.trim(); + if (!text) return; + + const parsed = tryParseJson(text); + if (parsed !== undefined) { + return { + language: 'json', + text: stringifyValue(parsed), + }; + } + + return { + language: toolName === 'js' || toolName === 'javascript' ? 'javascript' : 'text', + text, + }; +}; diff --git a/packages/builtin-tools/src/codex/webSearchUtils.ts b/packages/builtin-tools/src/codex/webSearchUtils.ts new file mode 100644 index 0000000000..6d4ceefd8c --- /dev/null +++ b/packages/builtin-tools/src/codex/webSearchUtils.ts @@ -0,0 +1,198 @@ +'use client'; + +export interface CodexWebSearchArgs extends Record { + query?: unknown; + results?: unknown; + search_query?: unknown; + searchQuery?: unknown; +} + +export interface CodexWebSearchResult { + snippet?: string; + title: string; + url?: string; +} + +const QUERY_KEYS = ['query', 'search_query', 'searchQuery', 'q', 'keyword', 'keywords', 'term']; +const NESTED_ARG_KEYS = ['args', 'arguments', 'input', 'params', 'request', 'payload', 'data']; +const RESULT_KEYS = ['results', 'search_results', 'searchResults', 'items', 'sources', 'citations']; +const TITLE_KEYS = ['title', 'name', 'pageTitle', 'source']; +const URL_KEYS = ['url', 'link', 'href', 'sourceUrl']; +const SNIPPET_KEYS = ['snippet', 'summary', 'description', 'text', 'content']; + +const MAX_RESULTS = 8; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const normalizeString = (value: unknown) => (typeof value === 'string' ? value.trim() : ''); + +const stripTrailingUrlPunctuation = (value: string) => value.replace(/[),.;\]]+$/u, ''); + +const stripNumberPrefix = (value: string) => { + const trimmed = value.trim(); + const marker = trimmed.match(/^\d+[).]\s+/u); + + return marker ? trimmed.slice(marker[0].length).trim() : trimmed; +}; + +const stripLeadingSeparator = (value: string) => value.replace(/^[-–—:]\s+/u, '').trim(); + +const stripTrailingSeparator = (value: string) => value.replace(/\s+[-–—:]$/u, '').trim(); + +const getStringFromRecord = (record: Record, keys: string[]) => { + for (const key of keys) { + const value = normalizeString(record[key]); + if (value) return value; + } + + return ''; +}; + +const getQueryFromValue = (value: unknown): string => { + const direct = normalizeString(value); + if (direct) return direct; + + if (Array.isArray(value)) { + for (const item of value) { + const query = getQueryFromValue(item); + if (query) return query; + } + } + + if (isRecord(value)) { + return getStringFromRecord(value, QUERY_KEYS); + } + + return ''; +}; + +const getNestedRecords = (args?: unknown) => { + if (!isRecord(args)) return []; + + const records: Record[] = [args]; + for (const key of NESTED_ARG_KEYS) { + const nested = args[key]; + if (isRecord(nested)) records.push(nested); + } + + return records; +}; + +export const getWebSearchQuery = (args?: unknown) => { + for (const record of getNestedRecords(args)) { + for (const key of QUERY_KEYS) { + const query = getQueryFromValue(record[key]); + if (query) return query; + } + } + + return ''; +}; + +const getResultFromRecord = (record: Record): CodexWebSearchResult | undefined => { + const title = getStringFromRecord(record, TITLE_KEYS); + const url = getStringFromRecord(record, URL_KEYS); + const snippet = getStringFromRecord(record, SNIPPET_KEYS); + + if (!title && !url && !snippet) return; + + return { + snippet: snippet || undefined, + title: title || url || snippet, + url: url || undefined, + }; +}; + +const getResultsFromValue = (value: unknown): CodexWebSearchResult[] => { + if (Array.isArray(value)) { + return value + .map((item) => (isRecord(item) ? getResultFromRecord(item) : undefined)) + .filter((item): item is CodexWebSearchResult => !!item) + .slice(0, MAX_RESULTS); + } + + if (isRecord(value)) { + for (const key of RESULT_KEYS) { + const results = getResultsFromValue(value[key]); + if (results.length > 0) return results; + } + + const result = getResultFromRecord(value); + return result ? [result] : []; + } + + return []; +}; + +const parseMarkdownResultLine = (line: string): CodexWebSearchResult | undefined => { + const normalized = stripNumberPrefix(line); + if (!normalized.startsWith('[')) return; + + const titleEnd = normalized.indexOf(']('); + if (titleEnd <= 1) return; + + const urlStart = titleEnd + 2; + const urlEnd = normalized.indexOf(')', urlStart); + if (urlEnd <= urlStart) return; + + const title = normalized.slice(1, titleEnd).trim(); + const url = stripTrailingUrlPunctuation(normalized.slice(urlStart, urlEnd).trim()); + if (!title || !url) return; + + const snippet = stripLeadingSeparator(normalized.slice(urlEnd + 1)); + + return { + snippet: snippet || undefined, + title, + url, + }; +}; + +const parseTextResultLine = (line: string): CodexWebSearchResult | undefined => { + const normalized = stripNumberPrefix(line); + const urlMatch = normalized.match(/https?:\/\/\S+/u); + if (!urlMatch?.[0]) return; + + const urlStart = urlMatch.index ?? 0; + const urlEnd = urlStart + urlMatch[0].length; + const url = stripTrailingUrlPunctuation(urlMatch[0]); + const beforeUrl = stripTrailingSeparator(normalized.slice(0, urlStart)); + const afterUrl = stripLeadingSeparator(normalized.slice(urlEnd)); + const title = beforeUrl || afterUrl; + + if (!title || !url) return; + + return { + title, + url, + }; +}; + +const getResultsFromContent = (content?: string): CodexWebSearchResult[] => { + if (!content) return []; + + return content + .split('\n') + .map((line) => parseMarkdownResultLine(line) || parseTextResultLine(line)) + .filter((item): item is CodexWebSearchResult => !!item) + .slice(0, MAX_RESULTS); +}; + +export const getWebSearchResults = (args?: unknown, content?: string) => { + for (const record of getNestedRecords(args)) { + for (const key of RESULT_KEYS) { + const results = getResultsFromValue(record[key]); + if (results.length > 0) return results; + } + } + + return getResultsFromContent(content); +}; + +export const getWebSearchOutput = (content?: string) => { + const output = content?.trim() || ''; + if (/^Completed web_search\.?$/iu.test(output)) return ''; + + return output; +}; diff --git a/packages/builtin-tools/src/displayControls.test.ts b/packages/builtin-tools/src/displayControls.test.ts new file mode 100644 index 0000000000..b428b2e258 --- /dev/null +++ b/packages/builtin-tools/src/displayControls.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { CodexRenderDisplayControls } from './codex/displayControls'; +import { getBuiltinRenderDisplayControl } from './displayControls'; + +vi.mock('@lobechat/builtin-tool-claude-code/client', () => ({ + ClaudeCodeIdentifier: 'claude-code', + ClaudeCodeRenderDisplayControls: {}, +})); + +describe('CodexRenderDisplayControls', () => { + it('collapses Codex command output by default', () => { + expect(CodexRenderDisplayControls.command_execution).toBe('collapsed'); + expect(getBuiltinRenderDisplayControl('codex', 'command_execution')).toBe('collapsed'); + }); +}); diff --git a/packages/builtin-tools/src/displayControls.ts b/packages/builtin-tools/src/displayControls.ts index 1c7330de60..78ff93eab0 100644 --- a/packages/builtin-tools/src/displayControls.ts +++ b/packages/builtin-tools/src/displayControls.ts @@ -4,7 +4,7 @@ import { } from '@lobechat/builtin-tool-claude-code/client'; import { type RenderDisplayControl } from '@lobechat/types'; -import { CodexRenderDisplayControls } from './codex'; +import { CodexRenderDisplayControls } from './codex/displayControls'; // Kept separate from `./renders` so consumers that only need display-control // fallbacks (e.g. the tool store selector) don't pull in every builtin tool's diff --git a/packages/builtin-tools/src/github/RunCommandRender.tsx b/packages/builtin-tools/src/github/RunCommandRender.tsx index 0424c65f49..2270944542 100644 --- a/packages/builtin-tools/src/github/RunCommandRender.tsx +++ b/packages/builtin-tools/src/github/RunCommandRender.tsx @@ -1,14 +1,11 @@ 'use client'; -import { ToolResultCard } from '@lobechat/shared-tool-ui/components'; import type { BuiltinRenderProps } from '@lobechat/types'; import { Flexbox, Highlighter, Text } from '@lobehub/ui'; import { createStaticStyles, cssVar } from 'antd-style'; -import { GitBranch } from 'lucide-react'; import { memo, useMemo } from 'react'; import { - getGhSubcommand, getGithubOutput, type GithubRunCommandArgs, type GithubRunCommandState, @@ -18,6 +15,7 @@ import { const styles = createStaticStyles(({ css, cssVar }) => ({ exitCode: css` + margin-inline-start: 8px; font-family: ${cssVar.fontFamilyCode}; font-size: 12px; `, @@ -27,30 +25,6 @@ const styles = createStaticStyles(({ css, cssVar }) => ({ exitCodeSuccess: css` color: ${cssVar.colorSuccess}; `, - ghPrefix: css` - flex-shrink: 0; - font-family: ${cssVar.fontFamilyCode}; - font-size: 12px; - color: ${cssVar.colorTextDescription}; - `, - headerCommand: css` - overflow: hidden; - flex: 1 1 auto; - - min-width: 0; - - font-family: ${cssVar.fontFamilyCode}; - font-size: 13px; - color: ${cssVar.colorText}; - text-overflow: ellipsis; - white-space: nowrap; - `, - headerRow: css` - flex-wrap: wrap; - gap: 6px; - align-items: center; - min-width: 0; - `, sectionLabel: css` margin-block-end: 4px; font-size: 12px; @@ -62,10 +36,7 @@ const GithubRunCommandRender = memo< BuiltinRenderProps >(({ args, content, pluginState }) => { const rawCommand = args?.command || ''; - const description = args?.description || ''; const normalized = normalizeGhCommand(rawCommand); - const subcommand = getGhSubcommand(rawCommand); - const headerLabel = description || subcommand || normalized || 'command'; const output = getGithubOutput(pluginState, content); const stderr = pluginState?.stderr || ''; @@ -83,72 +54,63 @@ const GithubRunCommandRender = memo< if (!normalized && !output && !stderr) return null; return ( - - gh - {headerLabel} - {success !== undefined && ( - - exit {exitCode ?? (success ? 0 : 1)} - - )} - - } - > - - {normalized && ( -
- Command - - {`gh ${normalized}`} - -
- )} - {outputBody && ( -
- Output - - {outputBody} - -
- )} - {stderr && ( -
- - Stderr - - - {stderr} - -
- )} -
-
+ + {normalized && ( +
+ + Command + {success !== undefined && ( + + exit {exitCode ?? (success ? 0 : 1)} + + )} + + + {`gh ${normalized}`} + +
+ )} + {outputBody && ( +
+ Output + + {outputBody} + +
+ )} + {stderr && ( +
+ + Stderr + + + {stderr} + +
+ )} +
); }); diff --git a/packages/builtin-tools/src/inspectors.ts b/packages/builtin-tools/src/inspectors.ts index 34e5a2f3d5..ab2a8fb54d 100644 --- a/packages/builtin-tools/src/inspectors.ts +++ b/packages/builtin-tools/src/inspectors.ts @@ -64,7 +64,6 @@ import { WebOnboardingInspectors, WebOnboardingManifest, } from '@lobechat/builtin-tool-web-onboarding/client'; -import { createRunCommandInspector } from '@lobechat/shared-tool-ui/inspectors'; import type { BuiltinInspector } from '@lobechat/types'; import { CodexInspectors } from './codex'; @@ -116,10 +115,7 @@ const BuiltinToolInspectors: Record> = [TaskManifest.identifier]: TaskInspectors as Record, [WebBrowsingManifest.identifier]: WebBrowsingInspectors as Record, [WebOnboardingManifest.identifier]: WebOnboardingInspectors as Record, - codex: { - ...CodexInspectors, - command_execution: createRunCommandInspector('Run') as BuiltinInspector, - }, + codex: CodexInspectors, [GithubIdentifier]: GithubInspectors, [LinearIdentifier]: LinearInspectors, [TwitterIdentifier]: TwitterInspectors, diff --git a/packages/builtin-tools/vitest.config.mts b/packages/builtin-tools/vitest.config.mts new file mode 100644 index 0000000000..4ac6027d57 --- /dev/null +++ b/packages/builtin-tools/vitest.config.mts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + }, +}); diff --git a/packages/heterogeneous-agents/src/adapters/codex.test.ts b/packages/heterogeneous-agents/src/adapters/codex.test.ts index 2676ea5b67..2f1ad2194b 100644 --- a/packages/heterogeneous-agents/src/adapters/codex.test.ts +++ b/packages/heterogeneous-agents/src/adapters/codex.test.ts @@ -455,6 +455,80 @@ describe('CodexAdapter', () => { }); }); + it('maps mcp_tool_call items into compact args and MCP result content', () => { + const adapter = new CodexAdapter(); + + const started = adapter.adapt({ + item: { + arguments: { code: '1 + 1' }, + id: 'item_5', + server: 'node_repl', + status: 'in_progress', + tool: 'js', + type: 'mcp_tool_call', + }, + type: 'item.started', + }); + const completed = adapter.adapt({ + item: { + arguments: { code: '1 + 1' }, + error: null, + id: 'item_5', + result: { + content: [{ text: '2', type: 'text' }], + isError: false, + }, + server: 'node_repl', + status: 'completed', + tool: 'js', + type: 'mcp_tool_call', + }, + type: 'item.completed', + }); + + expect(started[0]).toMatchObject({ + data: { + chunkType: 'tools_calling', + toolsCalling: [ + { + apiName: 'mcp_tool_call', + arguments: JSON.stringify({ + arguments: { code: '1 + 1' }, + server: 'node_repl', + tool: 'js', + }), + id: 'item_5', + identifier: 'codex', + }, + ], + }, + type: 'stream_chunk', + }); + expect(completed[0]).toMatchObject({ + data: { + content: '2', + isError: false, + pluginState: { + arguments: { code: '1 + 1' }, + error: null, + result: { + content: [{ text: '2', type: 'text' }], + isError: false, + }, + server: 'node_repl', + status: 'completed', + tool: 'js', + }, + toolCallId: 'item_5', + }, + type: 'tool_result', + }); + expect(completed[1]).toMatchObject({ + data: { isSuccess: true, toolCallId: 'item_5' }, + type: 'tool_end', + }); + }); + it('uses failure copy for unsuccessful non-command tool completions', () => { const adapter = new CodexAdapter(); diff --git a/packages/heterogeneous-agents/src/adapters/codex.ts b/packages/heterogeneous-agents/src/adapters/codex.ts index 329d9ba431..a88e6bde05 100644 --- a/packages/heterogeneous-agents/src/adapters/codex.ts +++ b/packages/heterogeneous-agents/src/adapters/codex.ts @@ -12,6 +12,7 @@ const CODEX_IDENTIFIER = 'codex'; const CODEX_COLLAB_TOOL_CALL_API = 'collab_tool_call'; const CODEX_COMMAND_API = 'command_execution'; const CODEX_FILE_CHANGE_API = 'file_change'; +const CODEX_MCP_TOOL_CALL_API = 'mcp_tool_call'; const CODEX_TODO_LIST_API = 'todo_list'; interface CodexBaseItem { @@ -48,6 +49,14 @@ interface CodexFileChangeItem extends CodexBaseItem { linesDeleted?: number; } +interface CodexMcpToolCallItem extends CodexBaseItem { + arguments?: unknown; + error?: unknown; + result?: unknown; + server?: string; + tool?: string; +} + interface CodexCollabAgentState { message?: string | null; status?: string; @@ -66,6 +75,7 @@ type CodexToolItem = | CodexCollabToolCallItem | CodexCommandExecutionItem | CodexFileChangeItem + | CodexMcpToolCallItem | CodexTodoListItem; const isCommandExecutionItem = (item: CodexToolItem): item is CodexCommandExecutionItem => @@ -77,6 +87,9 @@ const isCollabToolCallItem = (item: CodexToolItem): item is CodexCollabToolCallI const isFileChangeItem = (item: CodexToolItem): item is CodexFileChangeItem => item.type === CODEX_FILE_CHANGE_API; +const isMcpToolCallItem = (item: CodexToolItem): item is CodexMcpToolCallItem => + item.type === CODEX_MCP_TOOL_CALL_API; + const isTodoListItem = (item: CodexToolItem): item is CodexTodoListItem => item.type === CODEX_TODO_LIST_API; @@ -162,12 +175,116 @@ const synthesizeFileChangePluginState = (item: CodexFileChangeItem) => { }; }; +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const stringifyUnknown = (value: unknown): string => { + if (typeof value === 'string') return value; + if (value === undefined || value === null) return ''; + + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +}; + +const getRecordString = (record: Record, key: string): string | undefined => { + const value = record[key]; + return typeof value === 'string' && value.trim() ? value : undefined; +}; + +const unwrapMcpResultEnvelope = (value: unknown): unknown => { + if (!isRecord(value)) return value; + + if ('Ok' in value) return value.Ok; + if ('Err' in value) return value.Err; + if ('ok' in value) return value.ok; + + return value; +}; + +const getMcpContentItemText = (item: unknown): string => { + if (typeof item === 'string') return item; + if (!isRecord(item)) return stringifyUnknown(item); + + const text = getRecordString(item, 'text') || getRecordString(item, 'content'); + if (text) return text; + + return stringifyUnknown(item); +}; + +const getMcpResultContent = (item: CodexMcpToolCallItem): string => { + const result = unwrapMcpResultEnvelope(item.result); + + if (Array.isArray(result)) { + return result.map(getMcpContentItemText).filter(Boolean).join('\n\n'); + } + + if (isRecord(result)) { + if (Array.isArray(result.content)) { + return result.content.map(getMcpContentItemText).filter(Boolean).join('\n\n'); + } + + const text = getRecordString(result, 'text') || getRecordString(result, 'output'); + if (text) return text; + } + + return stringifyUnknown(result); +}; + +const getMcpErrorContent = (item: CodexMcpToolCallItem): string => { + const error = item.error || unwrapMcpResultEnvelope(item.result); + + if (isRecord(error)) { + return ( + getRecordString(error, 'message') || + getRecordString(error, 'error') || + stringifyUnknown(error) + ); + } + + return stringifyUnknown(error); +}; + +const hasMcpResultError = (item: CodexMcpToolCallItem): boolean => { + if (item.error) return true; + + const result = item.result; + if (!isRecord(result)) return false; + if ('Err' in result) return true; + + const ok = unwrapMcpResultEnvelope(result); + return isRecord(ok) && ok.isError === true; +}; + +const synthesizeMcpToolPluginState = (item: CodexMcpToolCallItem) => ({ + arguments: item.arguments, + error: item.error, + result: item.result, + server: item.server, + status: item.status, + tool: item.tool, +}); + const pluralize = (count: number, singular: string, plural = `${singular}s`) => count === 1 ? singular : plural; +const toMcpToolPayloadArguments = (item: CodexMcpToolCallItem) => ({ + arguments: item.arguments, + server: item.server, + tool: item.tool, +}); + const toToolPayload = (item: CodexToolItem): ToolCallPayload => ({ apiName: item.type || CODEX_COMMAND_API, - arguments: JSON.stringify(isCommandExecutionItem(item) ? { command: item.command || '' } : item), + arguments: JSON.stringify( + isCommandExecutionItem(item) + ? { command: item.command || '' } + : isMcpToolCallItem(item) + ? toMcpToolPayloadArguments(item) + : item, + ), id: item.id, identifier: CODEX_IDENTIFIER, type: 'default', @@ -253,6 +370,9 @@ const getFailureVerb = (item: CodexToolItem): 'cancelled' | 'failed' => const getToolFailureContent = (item: CodexToolItem): string => { if (isTodoListItem(item)) return `Todo list update ${getFailureVerb(item)}.`; if (isFileChangeItem(item)) return `File changes ${getFailureVerb(item)}.`; + if (isMcpToolCallItem(item)) { + return getMcpErrorContent(item) || `MCP tool ${getFailureVerb(item)}.`; + } if (isCollabToolCallItem(item)) return `${item.tool || 'Collaboration'} ${getFailureVerb(item)}.`; return `${item.type} ${getFailureVerb(item)}.`; @@ -267,6 +387,7 @@ const getToolContent = (item: CodexToolItem, isSuccess: boolean): string => { if (isTodoListItem(item)) return summarizeTodoList(item); if (isFileChangeItem(item)) return summarizeFileChange(item); + if (isMcpToolCallItem(item)) return getMcpResultContent(item); if (isCollabToolCallItem(item)) return summarizeCollabToolCall(item); return summarizeFallbackTool(item); @@ -278,6 +399,8 @@ const isSuccessfulToolCompletion = (item: CodexToolItem): boolean => { return item.status === 'completed' && (exitCode === undefined || exitCode === 0); } + if (isMcpToolCallItem(item) && hasMcpResultError(item)) return false; + return item.status !== 'cancelled' && item.status !== 'error' && item.status !== 'failed'; }; @@ -308,7 +431,9 @@ const getToolResultData = (item: CodexToolItem): ToolResultData => { ? synthesizeTodoListPluginState(item) : isSuccess && isFileChangeItem(item) ? synthesizeFileChangePluginState(item) - : undefined; + : isMcpToolCallItem(item) + ? synthesizeMcpToolPluginState(item) + : undefined; return { content: output, diff --git a/packages/locales/src/default/chat.ts b/packages/locales/src/default/chat.ts index 3681e3054a..5470cf8dda 100644 --- a/packages/locales/src/default/chat.ts +++ b/packages/locales/src/default/chat.ts @@ -1003,6 +1003,7 @@ export default { 'workflow.toolDisplayName.addPreferenceMemory': 'Saved memory', 'workflow.toolDisplayName.calculate': 'Calculated', 'workflow.toolDisplayName.callAgent': 'Called an agent', + 'workflow.toolDisplayName.callMcpTool': 'Called MCP tool', 'workflow.toolDisplayName.callSubAgent': 'Call SubAgent', 'workflow.toolDisplayName.clearTodos': 'Cleared todos', 'workflow.toolDisplayName.copyDocument': 'Copied a document', diff --git a/packages/locales/src/default/plugin.ts b/packages/locales/src/default/plugin.ts index ea0da0be1e..84b5beac00 100644 --- a/packages/locales/src/default/plugin.ts +++ b/packages/locales/src/default/plugin.ts @@ -82,6 +82,24 @@ export default { 'builtins.lobe-claude-code.todoWrite.allDone': 'All tasks completed', 'builtins.lobe-claude-code.todoWrite.currentStep': 'Current step', 'builtins.lobe-claude-code.todoWrite.todos': 'Todos', + 'builtins.codex.apiName.command_execution': 'Run command', + 'builtins.codex.apiName.file_change': 'Edit files', + 'builtins.codex.apiName.mcp_tool_call': 'Call MCP tool', + 'builtins.codex.apiName.todo_list': 'Update tasks', + 'builtins.codex.apiName.web_search': 'Search the web', + 'builtins.codex.commandExecution.grep': 'Search', + 'builtins.codex.commandExecution.noResults': 'No results', + 'builtins.codex.commandExecution.readFile': 'Read file', + 'builtins.codex.fileChange.editedFiles_one': 'Edited {{count}} file', + 'builtins.codex.fileChange.editedFiles_other': 'Edited {{count}} files', + 'builtins.codex.fileChange.editing': 'Editing files', + 'builtins.codex.fileChange.noChanges': 'No file changes', + 'builtins.codex.fileChange.unknownFile': 'Unknown file', + 'builtins.codex.mcpTool.error': 'Error', + 'builtins.codex.mcpTool.input': 'Input', + 'builtins.codex.mcpTool.result': 'Result', + 'builtins.codex.mcpTool.unknownTool': 'MCP tool', + 'builtins.codex.webSearch.query': 'Query', 'builtins.lobe-cloud-sandbox.apiName.editFile': 'Edit file', 'builtins.lobe-cloud-sandbox.apiName.executeCode': 'Execute code', 'builtins.lobe-cloud-sandbox.apiName.exportFile': 'Export file', diff --git a/packages/shared-tool-ui/src/components/ToolResultCard.tsx b/packages/shared-tool-ui/src/components/ToolResultCard.tsx deleted file mode 100644 index 37ba94918a..0000000000 --- a/packages/shared-tool-ui/src/components/ToolResultCard.tsx +++ /dev/null @@ -1,49 +0,0 @@ -'use client'; - -import { Flexbox, Icon } from '@lobehub/ui'; -import { createStaticStyles } from 'antd-style'; -import { type LucideIcon } from 'lucide-react'; -import { memo, type ReactNode } from 'react'; - -const styles = createStaticStyles(({ css, cssVar }) => ({ - container: css` - padding-block: 4px; - `, - header: css` - padding-inline: 4px; - color: ${cssVar.colorTextSecondary}; - `, - previewBox: css` - overflow: hidden; - padding: 8px; - border-radius: 8px; - background: ${cssVar.colorFillTertiary}; - `, -})); - -interface ToolResultCardProps { - children?: ReactNode; - header: ReactNode; - icon: LucideIcon; - wrapHeader?: boolean; -} - -export const ToolResultCard = memo( - ({ icon, header, children, wrapHeader }) => ( - - - - {header} - - {children && {children}} - - ), -); - -ToolResultCard.displayName = 'ToolResultCard'; diff --git a/packages/shared-tool-ui/src/components/index.ts b/packages/shared-tool-ui/src/components/index.ts index 3b62ad75a9..5e66897c53 100644 --- a/packages/shared-tool-ui/src/components/index.ts +++ b/packages/shared-tool-ui/src/components/index.ts @@ -1,3 +1,2 @@ export { AnimatedNumber } from './AnimatedNumber'; export { FilePathDisplay } from './FilePathDisplay'; -export { ToolResultCard } from './ToolResultCard'; diff --git a/src/components/FileIcon/index.tsx b/src/components/FileIcon/index.tsx index d987243fe8..dcf53b8658 100644 --- a/src/components/FileIcon/index.tsx +++ b/src/components/FileIcon/index.tsx @@ -13,7 +13,15 @@ interface FileListProps { const FileIcon = memo(({ fileName, size, variant = 'raw', isDirectory }) => { if (isDirectory) - return ; + return ( + + ); if (Object.keys(mimeTypeMap).some((key) => fileName?.toLowerCase().endsWith(`.${key}`))) { const ext = fileName.split('.').pop()?.toLowerCase() as string; diff --git a/src/features/AgentDocumentsExplorer/DocumentExplorerTree.test.tsx b/src/features/AgentDocumentsExplorer/DocumentExplorerTree.test.tsx index 580ce84157..e94ed107d4 100644 --- a/src/features/AgentDocumentsExplorer/DocumentExplorerTree.test.tsx +++ b/src/features/AgentDocumentsExplorer/DocumentExplorerTree.test.tsx @@ -132,6 +132,7 @@ vi.mock('@/features/ExplorerTree', () => { return { ExplorerTree, FOLDER_ICON_CSS: '', + HIDE_POINTER_FOCUS_RING_CSS: '', getExplorerTreeStyleVars: () => ({}), }; }); diff --git a/src/features/AgentDocumentsExplorer/DocumentExplorerTree.tsx b/src/features/AgentDocumentsExplorer/DocumentExplorerTree.tsx index ffe5073a24..34ac51c874 100644 --- a/src/features/AgentDocumentsExplorer/DocumentExplorerTree.tsx +++ b/src/features/AgentDocumentsExplorer/DocumentExplorerTree.tsx @@ -12,7 +12,12 @@ import type { ExplorerTreeHandle, ExplorerTreeNode, } from '@/features/ExplorerTree'; -import { ExplorerTree, FOLDER_ICON_CSS, getExplorerTreeStyleVars } from '@/features/ExplorerTree'; +import { + ExplorerTree, + FOLDER_ICON_CSS, + getExplorerTreeStyleVars, + HIDE_POINTER_FOCUS_RING_CSS, +} from '@/features/ExplorerTree'; import { useChatStore } from '@/store/chat'; import DocumentExplorerToolbar from './DocumentExplorerToolbar'; @@ -24,6 +29,7 @@ import { canDropDocument } from './utils/canDrop'; const SKILL_INDEX_FILENAME = 'SKILL.md'; const FILE_TREE_HOST_TAG = 'file-tree-container'; const RENAME_INPUT_SELECTOR = 'input[data-item-rename-input]'; +const DOCUMENT_TREE_UNSAFE_CSS = `${FOLDER_ICON_CSS}\n${HIDE_POINTER_FOCUS_RING_CSS}`; // pierre/trees auto-selects the full value when the rename input mounts. For // files with extensions (e.g. `Untitled document.md`), narrow the selection to @@ -45,18 +51,14 @@ const styles = createStaticStyles(({ css, cssVar }) => ({ --trees-bg-override: transparent; --trees-border-color-override: transparent; --trees-selected-bg-override: ${cssVar.colorFillSecondary}; + --trees-selected-fg-override: ${cssVar.colorText}; --trees-bg-muted-override: ${cssVar.colorFillTertiary}; - --trees-fg-override: ${cssVar.colorText}; + --trees-fg-override: ${cssVar.colorTextSecondary}; --trees-fg-muted-override: ${cssVar.colorTextSecondary}; --trees-accent-override: ${cssVar.colorPrimary}; --trees-padding-inline-override: 0px; --trees-font-size-override: 12px; --trees-border-radius-override: 6px; - - /* Drop the doubled outline pierre/trees draws via ::before on a - * focused+selected row — the filled background from - * --trees-selected-bg-override is already a clear selection signal. */ - --trees-selected-focused-border-color-override: transparent; `, })); @@ -274,7 +276,7 @@ const DocumentExplorerTree = memo(({ agentId, data, mutate, style }) => { nodes={nodes} ref={treeRef} style={{ height: '100%' }} - unsafeCSS={FOLDER_ICON_CSS} + unsafeCSS={DOCUMENT_TREE_UNSAFE_CSS} header={ handleCreateDocument(null)} diff --git a/src/features/Conversation/Markdown/plugins/LocalFileLink/Render.test.tsx b/src/features/Conversation/Markdown/plugins/LocalFileLink/Render.test.tsx new file mode 100644 index 0000000000..f5f2186780 --- /dev/null +++ b/src/features/Conversation/Markdown/plugins/LocalFileLink/Render.test.tsx @@ -0,0 +1,83 @@ +/** + * @vitest-environment happy-dom + */ +import { fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { useChatStore } from '@/store/chat'; + +import type { MarkdownElementProps } from '../type'; +import Render from './Render'; + +interface LocalFileLinkProperties { + linkHref?: string; + linkLabel?: string; +} + +const createRenderProps = ( + properties: LocalFileLinkProperties, +): MarkdownElementProps => ({ + children: null, + id: 'local-file-link', + node: { + properties, + }, + tagName: 'lobeLocalFileLink', + type: 'element', +}); + +vi.mock('@lobechat/const', async (importOriginal) => ({ + ...((await importOriginal()) as Record), + isDesktop: true, +})); + +vi.mock('@/components/FileIcon', () => ({ + default: ({ fileName, size }: { fileName: string; size?: number }) => ( + + ), +})); + +describe('LocalFileLink Render', () => { + afterEach(() => { + useChatStore.setState(useChatStore.getInitialState()); + }); + + it('opens local file links in the right-side local file portal', () => { + useChatStore.setState({ + activeAgentId: 'agent-1', + activeTopicId: 'topic-1', + topicDataMap: { + 'agent_agent-1': { + items: [ + { + id: 'topic-1', + metadata: { workingDirectory: '/Users/me/project' }, + }, + ], + total: 1, + }, + } as any, + }); + + render( + , + ); + + fireEvent.click(screen.getByRole('link', { name: 'Group.tsx' })); + + expect(screen.getByTestId('file-icon')).toHaveAttribute('data-file-name', 'Group.tsx'); + expect(screen.getByTestId('file-icon')).toHaveAttribute('data-size', '16'); + expect(useChatStore.getState().openLocalFiles).toEqual([ + { + filePath: '/Users/me/project/src/Group.tsx', + workingDirectory: '/Users/me/project', + }, + ]); + expect(useChatStore.getState().showPortal).toBe(true); + }); +}); diff --git a/src/features/Conversation/Markdown/plugins/LocalFileLink/Render.tsx b/src/features/Conversation/Markdown/plugins/LocalFileLink/Render.tsx new file mode 100644 index 0000000000..21218af0c3 --- /dev/null +++ b/src/features/Conversation/Markdown/plugins/LocalFileLink/Render.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { isDesktop } from '@lobechat/const'; +import { A } from '@lobehub/ui'; +import { createStaticStyles } from 'antd-style'; +import { memo, type MouseEvent, useCallback } from 'react'; + +import FileIcon from '@/components/FileIcon'; +import { useChatStore } from '@/store/chat'; +import { topicSelectors } from '@/store/chat/selectors'; + +import { type MarkdownElementProps } from '../type'; +import { parseLocalFileHref } from './parse'; + +interface LocalFileLinkProperties { + linkHref?: string; + linkLabel?: string; +} + +const styles = createStaticStyles(({ css }) => ({ + icon: css` + display: inline-flex; + flex-shrink: 0; + align-items: center; + `, + link: css` + display: inline-flex; + gap: 4px; + align-items: center; + vertical-align: -0.16em; + `, +})); + +const getFileName = (filePath: string) => filePath.split(/[\\/]/).at(-1) || filePath; + +const Render = memo>(({ node }) => { + const { linkHref, linkLabel } = node?.properties || {}; + const openLocalFile = useChatStore((s) => s.openLocalFile); + const workingDirectory = useChatStore(topicSelectors.currentTopicWorkingDirectory); + const parsed = isDesktop ? parseLocalFileHref(linkHref, { workingDirectory }) : null; + const label = linkLabel || parsed?.filePath || linkHref || ''; + const iconFileName = parsed ? getFileName(parsed.filePath) : label; + + const handleClick = useCallback( + (event: MouseEvent) => { + if (!parsed) return; + if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { + return; + } + + event.preventDefault(); + openLocalFile({ + filePath: parsed.filePath, + workingDirectory: parsed.workingDirectory, + }); + }, + [openLocalFile, parsed], + ); + + return ( + + + + + {label} + + ); +}); + +Render.displayName = 'LocalFileLinkRender'; + +export default Render; diff --git a/src/features/Conversation/Markdown/plugins/LocalFileLink/index.ts b/src/features/Conversation/Markdown/plugins/LocalFileLink/index.ts new file mode 100644 index 0000000000..6bd1d33bf2 --- /dev/null +++ b/src/features/Conversation/Markdown/plugins/LocalFileLink/index.ts @@ -0,0 +1,15 @@ +import { type FC } from 'react'; + +import { type MarkdownElement, type MarkdownElementProps } from '../type'; +import { LOBE_LOCAL_FILE_LINK_TAG } from './parse'; +import { rehypeLocalFileLink } from './rehypePlugin'; +import Render from './Render'; + +const LocalFileLinkElement: MarkdownElement = { + Component: Render as FC, + rehypePlugin: rehypeLocalFileLink, + scope: 'all', + tag: LOBE_LOCAL_FILE_LINK_TAG, +}; + +export default LocalFileLinkElement; diff --git a/src/features/Conversation/Markdown/plugins/LocalFileLink/parse.test.ts b/src/features/Conversation/Markdown/plugins/LocalFileLink/parse.test.ts new file mode 100644 index 0000000000..4f62dd7683 --- /dev/null +++ b/src/features/Conversation/Markdown/plugins/LocalFileLink/parse.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; + +import { parseLocalFileHref } from './parse'; + +describe('parseLocalFileHref', () => { + it('parses a macOS absolute path with a line suffix', () => { + expect( + parseLocalFileHref('/Users/arvinxx/project/src/Group.tsx:265', { + workingDirectory: '/Users/arvinxx/project', + }), + ).toEqual({ + filePath: '/Users/arvinxx/project/src/Group.tsx', + line: 265, + workingDirectory: '/Users/arvinxx/project', + }); + }); + + it('parses file URLs and strips line and column suffixes', () => { + expect(parseLocalFileHref('file:///Users/me/My%20File.tsx:10:2')).toEqual({ + column: 2, + filePath: '/Users/me/My File.tsx', + line: 10, + workingDirectory: '/Users/me', + }); + }); + + it('uses the file directory as working directory when no active cwd matches', () => { + expect(parseLocalFileHref('/tmp/report.md')).toEqual({ + filePath: '/tmp/report.md', + workingDirectory: '/tmp', + }); + }); + + it('ignores normal app routes and external URLs', () => { + expect(parseLocalFileHref('/settings/profile')).toBeNull(); + expect(parseLocalFileHref('https://example.com/file.ts')).toBeNull(); + expect(parseLocalFileHref('#user-content-fn-1')).toBeNull(); + }); +}); diff --git a/src/features/Conversation/Markdown/plugins/LocalFileLink/parse.ts b/src/features/Conversation/Markdown/plugins/LocalFileLink/parse.ts new file mode 100644 index 0000000000..ac3e2a6211 --- /dev/null +++ b/src/features/Conversation/Markdown/plugins/LocalFileLink/parse.ts @@ -0,0 +1,153 @@ +export const LOBE_LOCAL_FILE_LINK_TAG = 'lobeLocalFileLink'; + +export interface ParsedLocalFileHref { + column?: number; + filePath: string; + line?: number; + workingDirectory: string; +} + +interface ParseLocalFileHrefOptions { + workingDirectory?: string; +} + +const WINDOWS_ABSOLUTE_PATH_REGEX = /^(?:[a-z]:[\\/]|\\\\)/i; +const URL_PROTOCOL_REGEX = /^[a-z][a-z\d+.-]*:/i; +const LINE_SUFFIX_REGEX = /:(\d+)(?::(\d+))?$/; + +const KNOWN_LOCAL_PATH_PREFIXES = [ + '/Applications/', + '/Users/', + '/Volumes/', + '/home/', + '/mnt/', + '/opt/', + '/private/', + '/tmp/', + '/var/', + '/workspace/', +] as const; + +const safeDecodeURIComponent = (value: string) => { + try { + return decodeURIComponent(value); + } catch { + return value; + } +}; + +const normalizeForCompare = (filePath: string) => { + const normalized = filePath.replaceAll('\\', '/'); + if (normalized === '/') return normalized; + return normalized.replace(/\/+$/, ''); +}; + +const isWindowsAbsolutePath = (filePath: string) => WINDOWS_ABSOLUTE_PATH_REGEX.test(filePath); + +const isPosixAbsolutePath = (filePath: string) => filePath.startsWith('/'); + +const isAbsoluteLocalPath = (filePath: string) => + isPosixAbsolutePath(filePath) || isWindowsAbsolutePath(filePath); + +const isWithinDirectory = (filePath: string, directory: string) => { + const normalizedFilePath = normalizeForCompare(filePath); + const normalizedDirectory = normalizeForCompare(directory); + + return ( + normalizedFilePath === normalizedDirectory || + normalizedFilePath.startsWith(`${normalizedDirectory}/`) + ); +}; + +const hasKnownLocalRoot = (filePath: string) => { + if (isWindowsAbsolutePath(filePath)) return true; + + const normalized = normalizeForCompare(filePath); + return KNOWN_LOCAL_PATH_PREFIXES.some((prefix) => { + const root = prefix.slice(0, -1); + return normalized === root || normalized.startsWith(prefix); + }); +}; + +const dirname = (filePath: string) => { + const normalized = filePath.replace(/[\\/]+$/, ''); + const slashIndex = Math.max(normalized.lastIndexOf('/'), normalized.lastIndexOf('\\')); + + if (slashIndex <= 0) { + if (normalized.startsWith('/')) return '/'; + return normalized; + } + + return normalized.slice(0, slashIndex); +}; + +const extractLineSuffix = (candidate: string) => { + const match = candidate.match(LINE_SUFFIX_REGEX); + if (!match) return { filePath: candidate }; + + const filePath = candidate.slice(0, -match[0].length); + if (!filePath) return { filePath: candidate }; + + const line = Number.parseInt(match[1]!, 10); + const column = match[2] ? Number.parseInt(match[2], 10) : undefined; + + return { + column: column && column > 0 ? column : undefined, + filePath, + line: line > 0 ? line : undefined, + }; +}; + +const hrefToPathCandidate = (href: string) => { + const isFileUrl = href.toLowerCase().startsWith('file:'); + + if (isFileUrl) { + try { + const url = new URL(href); + if (url.protocol !== 'file:') return null; + + const pathname = safeDecodeURIComponent(url.pathname); + return /^\/[a-z]:/i.test(pathname) ? pathname.slice(1) : pathname; + } catch { + return null; + } + } + + if (URL_PROTOCOL_REGEX.test(href) && !isWindowsAbsolutePath(href)) return null; + + return safeDecodeURIComponent(href); +}; + +export const parseLocalFileHref = ( + href?: string, + { workingDirectory }: ParseLocalFileHrefOptions = {}, +): ParsedLocalFileHref | null => { + const rawHref = href?.trim(); + if (!rawHref) return null; + + const candidate = hrefToPathCandidate(rawHref); + if (!candidate) return null; + + const { filePath, line, column } = extractLineSuffix(candidate); + if (!isAbsoluteLocalPath(filePath)) return null; + + const matchedWorkingDirectory = + workingDirectory && isWithinDirectory(filePath, workingDirectory) + ? workingDirectory + : undefined; + + if ( + !matchedWorkingDirectory && + !rawHref.toLowerCase().startsWith('file:') && + !hasKnownLocalRoot(filePath) + ) { + return null; + } + + return { + column, + filePath, + line, + workingDirectory: matchedWorkingDirectory || dirname(filePath), + }; +}; diff --git a/src/features/Conversation/Markdown/plugins/LocalFileLink/rehypePlugin.test.ts b/src/features/Conversation/Markdown/plugins/LocalFileLink/rehypePlugin.test.ts new file mode 100644 index 0000000000..9b0a3f8f0e --- /dev/null +++ b/src/features/Conversation/Markdown/plugins/LocalFileLink/rehypePlugin.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { LOBE_LOCAL_FILE_LINK_TAG } from './parse'; +import { rehypeLocalFileLink } from './rehypePlugin'; + +vi.mock('@lobechat/const', async (importOriginal) => ({ + ...((await importOriginal()) as Record), + isDesktop: true, +})); + +const createAnchor = (href: string, text: string) => ({ + children: [{ type: 'text', value: text }], + properties: { href }, + tagName: 'a', + type: 'element', +}); + +describe('rehypeLocalFileLink', () => { + it('rewrites absolute local file links into local file link nodes', () => { + const anchor = createAnchor('/Users/me/project/src/Group.tsx:265', 'Group.tsx'); + const tree = { children: [anchor], type: 'root' }; + + rehypeLocalFileLink()(tree); + + expect(anchor).toEqual({ + children: [], + properties: { + linkHref: '/Users/me/project/src/Group.tsx:265', + linkLabel: 'Group.tsx', + }, + tagName: LOBE_LOCAL_FILE_LINK_TAG, + type: 'element', + }); + }); + + it('keeps regular app routes untouched', () => { + const anchor = createAnchor('/settings/profile', 'settings'); + const tree = { children: [anchor], type: 'root' }; + + rehypeLocalFileLink()(tree); + + expect(anchor.tagName).toBe('a'); + expect(anchor.properties).toEqual({ href: '/settings/profile' }); + }); +}); diff --git a/src/features/Conversation/Markdown/plugins/LocalFileLink/rehypePlugin.ts b/src/features/Conversation/Markdown/plugins/LocalFileLink/rehypePlugin.ts new file mode 100644 index 0000000000..d45b5b9773 --- /dev/null +++ b/src/features/Conversation/Markdown/plugins/LocalFileLink/rehypePlugin.ts @@ -0,0 +1,35 @@ +import { isDesktop } from '@lobechat/const'; +import { SKIP, visit } from 'unist-util-visit'; + +import { LOBE_LOCAL_FILE_LINK_TAG, parseLocalFileHref } from './parse'; + +const getNodeText = (node: any): string => { + if (!node) return ''; + if (node.type === 'text') return String(node.value ?? ''); + if (Array.isArray(node.children)) return node.children.map(getNodeText).join(''); + return ''; +}; + +export const rehypeLocalFileLink = () => (tree: any) => { + if (!isDesktop) return; + + visit(tree, 'element', (node: any) => { + if (node.tagName !== 'a') return; + + const href = node.properties?.href as string | undefined; + const parsed = parseLocalFileHref(href); + if (!parsed) return; + + const text = getNodeText(node).trim(); + const label = text || parsed.filePath.split(/[\\/]/).at(-1) || parsed.filePath; + + node.tagName = LOBE_LOCAL_FILE_LINK_TAG; + node.children = []; + node.properties = { + linkHref: href, + linkLabel: label, + }; + + return SKIP; + }); +}; diff --git a/src/features/Conversation/Markdown/plugins/index.ts b/src/features/Conversation/Markdown/plugins/index.ts index 5b121c9de6..4adb577ee8 100644 --- a/src/features/Conversation/Markdown/plugins/index.ts +++ b/src/features/Conversation/Markdown/plugins/index.ts @@ -4,6 +4,7 @@ import LobeAgents from './LobeAgents'; import LobeArtifact from './LobeArtifact'; import LobeThinking from './LobeThinking'; import LocalFile from './LocalFile'; +import LocalFileLink from './LocalFileLink'; import Mention from './Mention'; import Skill from './Skill'; import Task from './Task'; @@ -26,5 +27,6 @@ export const markdownElements: MarkdownElement[] = [ UserFeedback, ImageSearchRef, LobeAgents, + LocalFileLink, Link, ]; diff --git a/src/features/Conversation/Messages/AssistantGroup/components/Group.test.tsx b/src/features/Conversation/Messages/AssistantGroup/components/Group.test.tsx index 69a1cb4e09..2442685a66 100644 --- a/src/features/Conversation/Messages/AssistantGroup/components/Group.test.tsx +++ b/src/features/Conversation/Messages/AssistantGroup/components/Group.test.tsx @@ -140,7 +140,7 @@ describe('Group', () => { mockIsGenerating = false; }); - it('keeps long structured mixed content visible and renders the single tool inline', () => { + it('keeps long structured mixed content visible after the single inline tool', () => { const longContent = '后宫番 + 实际项目中的状态管理问题,这个组合挺有意思的!\n\n对于实际项目中的状态管理,你目前遇到的具体问题是什么?比如:\n- 不知道什么时候该用 useState,什么时候该用 Context\n- 组件间状态传递变得混乱\n- 性能问题(不必要的重渲染)'; @@ -164,17 +164,6 @@ describe('Group', () => { expect(sequence).toEqual(['answer-segment', 'answer-segment']); expect(parseAnswerSegments()).toEqual([ - { - content: longContent, - contentOverride: longContent, - disableMarkdownStreaming: false, - domId: 'block-1__answer', - hasError: false, - hasToolsOverride: false, - id: 'block-1', - isFirstBlock: false, - toolCount: 0, - }, { content: '', contentOverride: '', @@ -186,9 +175,71 @@ describe('Group', () => { isFirstBlock: false, toolCount: 1, }, + { + content: longContent, + contentOverride: longContent, + disableMarkdownStreaming: false, + domId: 'block-1__answer', + hasError: false, + hasToolsOverride: false, + id: 'block-1', + isFirstBlock: false, + toolCount: 0, + }, ]); }); + it('keeps a final-looking mixed block after a folded multi-tool workflow', () => { + const finalSummary = + '我已经完成改动和验证,准备汇总。\n\n- targeted ESLint 通过\n- targeted Prettier check 通过\n- git diff --check 通过'; + + const { container } = render( + , + ); + + const sequence = Array.from(container.querySelectorAll('[data-testid]')).map((node) => + node.getAttribute('data-testid'), + ); + + expect(sequence).toEqual(['workflow-segment', 'answer-segment']); + expect(parseWorkflowSegment()).toEqual([ + { + content: '', + contentOverride: '', + disableMarkdownStreaming: false, + domId: 'block-1__workflow', + hasError: false, + hasToolsOverride: true, + toolCount: 3, + }, + ]); + expect(parseAnswerSegment()).toEqual({ + content: finalSummary, + contentOverride: finalSummary, + disableMarkdownStreaming: false, + domId: 'block-1__answer', + hasError: false, + hasToolsOverride: false, + id: 'block-1', + isFirstBlock: false, + toolCount: 0, + }); + }); + it('keeps a short mixed status block inline when there is only one tool call', () => { render( = { // Cloud sandbox executeCode: 'workflow.toolDisplayName.executeCode', + // Codex + command_execution: 'workflow.toolDisplayName.runCommand', + file_change: 'workflow.toolDisplayName.editLocalFile', + mcp_tool_call: 'workflow.toolDisplayName.callMcpTool', + todo_list: 'workflow.toolDisplayName.updateTodos', + web_search: 'workflow.toolDisplayName.search', + // Lobe Agent — Plan & Todos createPlan: 'workflow.toolDisplayName.createPlan', createTodos: 'workflow.toolDisplayName.createTodos', diff --git a/src/features/Conversation/Messages/AssistantGroup/toolDisplayNames.test.ts b/src/features/Conversation/Messages/AssistantGroup/toolDisplayNames.test.ts index 590c774016..fdffc4ef99 100644 --- a/src/features/Conversation/Messages/AssistantGroup/toolDisplayNames.test.ts +++ b/src/features/Conversation/Messages/AssistantGroup/toolDisplayNames.test.ts @@ -5,7 +5,9 @@ import { type AssistantContentBlock } from '@/types/index'; import { POST_TOOL_FINAL_ANSWER_SCORE_THRESHOLD } from './constants'; import { getPostToolAnswerSplitIndex, + getToolDisplayName, getWorkflowStreamingHeadlineState, + getWorkflowSummaryText, scoreBlockContentAsAnswerLike, scorePostToolBlockAsFinalAnswer, shapeProseForWorkflowHeadline, @@ -14,6 +16,40 @@ import { const blk = (p: Partial & { id: string }): AssistantContentBlock => ({ content: '', ...p }) as AssistantContentBlock; +describe('tool display names', () => { + it('uses friendly labels for Codex tool api names', () => { + expect(getToolDisplayName('command_execution')).toBe('Ran a command'); + expect(getToolDisplayName('file_change')).toBe('Edited a file'); + expect(getToolDisplayName('mcp_tool_call')).toBe('Called MCP tool'); + expect(getToolDisplayName('todo_list')).toBe('Updated todos'); + expect(getToolDisplayName('web_search')).toBe('Searched the web'); + }); + + it('uses friendly Codex labels in workflow summaries', () => { + const summary = getWorkflowSummaryText([ + blk({ + id: '0', + tools: [ + { apiName: 'command_execution', id: 'tool-1', result: { content: 'ok' } } as any, + { apiName: 'command_execution', id: 'tool-2', result: { content: 'ok' } } as any, + { apiName: 'file_change', id: 'tool-3', result: { content: 'ok' } } as any, + { apiName: 'mcp_tool_call', id: 'tool-4', result: { content: 'ok' } } as any, + { apiName: 'web_search', id: 'tool-5', result: { content: 'ok' } } as any, + ], + }), + ]); + + expect(summary).toContain('Ran a command (2)'); + expect(summary).toContain('Edited a file'); + expect(summary).toContain('Called MCP tool'); + expect(summary).toContain('Searched the web'); + expect(summary).not.toContain('Command_execution'); + expect(summary).not.toContain('File_change'); + expect(summary).not.toContain('Mcp_tool_call'); + expect(summary).not.toContain('Web_search'); + }); +}); + describe('shapeProseForWorkflowHeadline', () => { it('does not split on dot inside Node.js in CJK prose', () => { const s = diff --git a/src/features/DevPanel/RenderGallery/fixtures/codex.ts b/src/features/DevPanel/RenderGallery/fixtures/codex.ts index 335dd256a3..e276e765cc 100644 --- a/src/features/DevPanel/RenderGallery/fixtures/codex.ts +++ b/src/features/DevPanel/RenderGallery/fixtures/codex.ts @@ -17,10 +17,18 @@ export default defineFixtures({ description: 'Preview Codex file change summaries.', name: 'file_change', }, + { + description: 'Preview Codex MCP tool rendering.', + name: 'mcp_tool_call', + }, { description: 'Preview Codex todo list rendering.', name: 'todo_list', }, + { + description: 'Preview Codex web search rendering.', + name: 'web_search', + }, ], fixtures: { command_execution: single({ @@ -83,6 +91,28 @@ export default defineFixtures({ linesDeleted: 0, }, }), + mcp_tool_call: single({ + args: { + arguments: { + code: "const result = await import('./package.json', { with: { type: 'json' } });\nresult.default.name;", + }, + server: 'node_repl', + tool: 'js', + }, + content: '@lobehub/desktop', + pluginState: { + arguments: { + code: "const result = await import('./package.json', { with: { type: 'json' } });\nresult.default.name;", + }, + result: { + content: [{ text: '@lobehub/desktop', type: 'text' }], + isError: false, + }, + server: 'node_repl', + status: 'completed', + tool: 'js', + }, + }), todo_list: single({ args: { items: [ @@ -93,5 +123,24 @@ export default defineFixtures({ }, content: 'Todo list updated (1/3 completed).', }), + web_search: single({ + args: { + query: 'Codex tool render examples', + results: [ + { + snippet: 'A compact preview of Codex builtin tool output in the chat timeline.', + title: 'Codex tool render examples', + url: 'https://example.com/codex-render', + }, + { + snippet: 'How LobeHub maps builtin tool inspectors, renders, and display controls.', + title: 'LobeHub builtin tool render registry', + url: 'https://example.com/lobehub-tools', + }, + ], + }, + content: + 'Search results\n\n1. Codex tool render examples - https://example.com/codex-render\n2. LobeHub builtin tool render registry - https://example.com/lobehub-tools', + }), }, }); diff --git a/src/features/ExplorerTree/folderIconStyle.test.ts b/src/features/ExplorerTree/folderIconStyle.test.ts new file mode 100644 index 0000000000..b443bb3843 --- /dev/null +++ b/src/features/ExplorerTree/folderIconStyle.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; + +import { getExplorerTreeIconCSS } from './folderIconStyle'; + +describe('getExplorerTreeIconCSS', () => { + it('maps ico files to the image icon', () => { + const css = getExplorerTreeIconCSS('https://example.com/icons'); + const icoImageRule = new RegExp( + String.raw`\[data-item-type="file"\]\[data-item-path\$="\.ico" i\]` + + String.raw`[\S\s]*?background-image: url\("https:\/\/example\.com\/icons\/image\.svg"\)`, + ); + + expect(css).toMatch(icoImageRule); + }); +}); diff --git a/src/features/ExplorerTree/folderIconStyle.ts b/src/features/ExplorerTree/folderIconStyle.ts index 83cd7be981..e6b80229f2 100644 --- a/src/features/ExplorerTree/folderIconStyle.ts +++ b/src/features/ExplorerTree/folderIconStyle.ts @@ -1,21 +1,209 @@ +import { genCdnUrl } from '@lobehub/ui'; import type { CSSProperties } from 'react'; -const folderClosedSvg = ``; -const folderOpenSvg = ``; - // Pierre's unsafeCSS is captured at FileTree construction with no public // setter, so we can't rebuild this string in response to tree changes. Drive // the file-icon offset through a CSS custom property the wrapper sets — custom // properties cascade through shadow DOM, so toggling it on the host reflows // the offset live (see `getExplorerTreeStyleVars`). const FILE_ICON_OFFSET_VAR = '--explorer-file-icon-offset'; +const FOLDER_ICON_SIZE = '18px'; +const FILE_ICON_SIZE = '16px'; // Chevron column width + row gap at default density (16 + 6). We standardised // consumers on default density, so this matches `--trees-icon-width` + // `--trees-item-row-gap` exactly. const RESERVED_FILE_ICON_OFFSET = '22px'; -export const FOLDER_ICON_CSS = ` +const MATERIAL_FILE_ICON_ASSETS_URL = genCdnUrl({ + path: 'assets', + pkg: '@lobehub/assets-fileicon', + version: '1.0.0', +}); + +const MATERIAL_FOLDER_ICON_RULES = [ + { iconName: 'folder-github', names: ['.github', 'github', '_github', '__github__'] }, + { + iconName: 'folder-vscode', + names: ['.vscode', 'vscode', '.vscode-test', 'vscode-test'], + }, + { iconName: 'folder-docs', names: ['docs', 'doc', 'documents', 'documentation'] }, + { iconName: 'folder-src', names: ['src', 'source', 'sources', 'code'] }, + { iconName: 'folder-node', names: ['node_modules'] }, + { iconName: 'folder-app', names: ['app', 'apps'] }, + { iconName: 'folder-packages', names: ['package', 'packages', 'pkg', 'pkgs'] }, + { iconName: 'folder-public', names: ['public', 'web'] }, + { iconName: 'folder-scripts', names: ['script', 'scripts'] }, + { iconName: 'folder-i18n', names: ['i18n', 'locale', 'locales'] }, + { iconName: 'folder-temp', names: ['tmp', 'temp'] }, + { iconName: 'folder-next', names: ['.next', 'next'] }, + { iconName: 'folder-husky', names: ['.husky', 'husky'] }, + { iconName: 'folder-git', names: ['.git', 'git', '.githooks', 'githooks'] }, + { iconName: 'folder-container', names: ['.devcontainer', 'devcontainer', 'container'] }, + { iconName: 'folder-command', names: ['command', 'commands', 'cmd', 'cli'] }, + { iconName: 'folder-test', names: ['test', 'tests', '__test__', '__tests__'] }, + { iconName: 'folder-mock', names: ['mock', 'mocks', '__mock__', '__mocks__'] }, + { iconName: 'folder-resource', names: ['asset', 'assets', 'resource', 'resources'] }, + { iconName: 'folder-components', names: ['component', 'components'] }, + { iconName: 'folder-routes', names: ['route', 'routes'] }, + { iconName: 'folder-server', names: ['server', 'backend'] }, + { iconName: 'folder-client', names: ['client', 'frontend'] }, + { iconName: 'folder-desktop', names: ['desktop'] }, + { iconName: 'folder-mobile', names: ['mobile'] }, + { iconName: 'folder-views', names: ['page', 'pages', 'view', 'views'] }, + { iconName: 'folder-config', names: ['.config', 'config', 'configs'] }, + { iconName: 'folder-environment', names: ['.env', '.envs', 'env', 'envs'] }, + { iconName: 'folder-database', names: ['db', 'database'] }, + { iconName: 'folder-class', names: ['model', 'models'] }, + { iconName: 'folder-controller', names: ['service', 'services'] }, + { iconName: 'folder-hook', names: ['hook', 'hooks'] }, + { iconName: 'folder-typescript', names: ['type', 'types', 'typings'] }, + { iconName: 'folder-utils', names: ['util', 'utils', 'utilities'] }, + { iconName: 'folder-plugin', names: ['plugin', 'plugins'] }, + { iconName: 'folder-shared', names: ['common', 'shared'] }, + { iconName: 'folder-css', names: ['style', 'styles'] }, + { iconName: 'folder-images', names: ['image', 'images', 'icon', 'icons'] }, + { iconName: 'folder-markdown', names: ['md', 'markdown'] }, + { iconName: 'folder-json', names: ['json'] }, + { iconName: 'folder-javascript', names: ['js', 'javascript'] }, + { iconName: 'folder-console', names: ['console', 'shell'] }, +] satisfies { iconName: string; names: string[] }[]; + +const MATERIAL_FILE_EXTENSION_RULES = [ + { extensions: ['tsx'], iconName: 'react_ts' }, + { extensions: ['jsx'], iconName: 'react' }, + { extensions: ['ts', 'mts', 'cts'], iconName: 'typescript' }, + { extensions: ['js', 'mjs', 'cjs'], iconName: 'javascript' }, + { extensions: ['json', 'jsonc', 'json5', 'jsonl'], iconName: 'json' }, + { extensions: ['md'], iconName: 'markdown' }, + { extensions: ['mdx'], iconName: 'mdx' }, + { extensions: ['yml', 'yaml'], iconName: 'yaml' }, + { extensions: ['sh', 'bash', 'zsh', 'fish'], iconName: 'console' }, + { extensions: ['env'], iconName: 'tune' }, + { extensions: ['svg'], iconName: 'svg' }, + { extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'ico'], iconName: 'image' }, + { extensions: ['css'], iconName: 'css' }, + { extensions: ['scss', 'sass'], iconName: 'sass' }, + { extensions: ['less'], iconName: 'less' }, + { extensions: ['lock'], iconName: 'lock' }, + { extensions: ['log'], iconName: 'log' }, + { extensions: ['txt'], iconName: 'document' }, +] satisfies { extensions: string[]; iconName: string }[]; + +const MATERIAL_FILE_NAME_RULES = [ + { iconName: 'codeowners', names: ['CODEOWNERS', 'OWNERS'] }, + { iconName: 'git', names: ['.gitignore', '.gitmodules', '.gitattributes', '.gitkeep'] }, + { iconName: 'npm', names: ['.npmrc', '.npmignore'] }, + { iconName: 'nodejs', names: ['.nvmrc', '.node-version', 'package.json', 'package-lock.json'] }, + { iconName: 'docker', names: ['.dockerignore', 'Dockerfile'] }, + { iconName: 'bun', names: ['bun.lockb', 'bunfig.toml'] }, + { iconName: 'markdown', names: ['AGENTS.md', 'CLAUDE.md', 'PULL_REQUEST_TEMPLATE.md'] }, +] satisfies { iconName: string; names: string[] }[]; + +const MATERIAL_FILE_PREFIX_RULES = [ + { iconName: 'tune', prefixes: ['.env.'] }, + { iconName: 'eslint', prefixes: ['.eslintrc', 'eslint.config.'] }, + { iconName: 'stylelint', prefixes: ['.stylelintrc', 'stylelint.config.'] }, + { iconName: 'prettier', prefixes: ['.prettierrc', 'prettier.config.'] }, + { iconName: 'commitlint', prefixes: ['.commitlintrc', 'commitlint.config.'] }, + { iconName: 'next', prefixes: ['next.config.'] }, + { iconName: 'vite', prefixes: ['vite.config.'] }, +] satisfies { iconName: string; prefixes: string[] }[]; + +const cssString = (value: string) => value.replaceAll('\\', '\\\\').replaceAll('"', '\\"'); + +const cssUrl = (url: string) => `url("${cssString(url)}")`; + +const iconUrl = (iconsUrl: string, iconName: string, open = false) => + `${iconsUrl}/${iconName}${open ? '-open' : ''}.svg`; + +const iconBackground = (iconsUrl: string, iconName: string, open = false) => + cssUrl(iconUrl(iconsUrl, iconName, open)); + +const getFolderRowSelectors = (names: string[]) => + names + .flatMap((name) => { + const escaped = cssString(name); + return [ + `[data-item-type="folder"][data-item-path="${escaped}/" i]`, + `[data-item-type="folder"][data-item-path$="/${escaped}/" i]`, + `[data-item-type="folder"][data-item-path="${escaped}" i]`, + `[data-item-type="folder"][data-item-path$="/${escaped}" i]`, + ]; + }) + .join(',\n'); + +const getFileNameSelectors = (names: string[]) => + names + .flatMap((name) => { + const escaped = cssString(name); + return [ + `[data-item-type="file"][data-item-path="${escaped}" i]`, + `[data-item-type="file"][data-item-path$="/${escaped}" i]`, + ]; + }) + .join(',\n'); + +const getFileExtensionSelectors = (extensions: string[]) => + extensions + .map((extension) => { + const escaped = cssString(extension); + return `[data-item-type="file"][data-item-path$=".${escaped}" i]`; + }) + .join(',\n'); + +const getFilePrefixSelectors = (prefixes: string[]) => + prefixes + .flatMap((prefix) => { + const escaped = cssString(prefix); + return [ + `[data-item-type="file"][data-item-path^="${escaped}" i]`, + `[data-item-type="file"][data-item-path*="/${escaped}" i]`, + ]; + }) + .join(',\n'); + +const getFileIconSectionRules = (selectors: string, iconsUrl: string, iconName: string) => ` + ${selectors + .split(',\n') + .map((selector) => `${selector} > [data-item-section="icon"]`) + .join(',\n')} { + background-image: ${iconBackground(iconsUrl, iconName)}; + } +`; + +const getFolderIconRules = (iconsUrl: string) => + MATERIAL_FOLDER_ICON_RULES.map(({ iconName, names }) => { + const selectors = getFolderRowSelectors(names); + return ` + ${selectors + .split(',\n') + .map((selector) => `${selector} [data-item-section="content"]::before`) + .join(',\n')} { + background-image: ${iconBackground(iconsUrl, iconName)}; + } + ${selectors + .split(',\n') + .map((selector) => `${selector}[aria-expanded="true"] [data-item-section="content"]::before`) + .join(',\n')} { + background-image: ${iconBackground(iconsUrl, iconName, true)}; + } +`; + }).join('\n'); + +const getFileIconRules = (iconsUrl: string) => ` +${MATERIAL_FILE_EXTENSION_RULES.map(({ extensions, iconName }) => + getFileIconSectionRules(getFileExtensionSelectors(extensions), iconsUrl, iconName), +).join('\n')} +${MATERIAL_FILE_NAME_RULES.map(({ iconName, names }) => + getFileIconSectionRules(getFileNameSelectors(names), iconsUrl, iconName), +).join('\n')} +${MATERIAL_FILE_PREFIX_RULES.map(({ iconName, prefixes }) => + getFileIconSectionRules(getFilePrefixSelectors(prefixes), iconsUrl, iconName), +).join('\n')} +`; + +export const getExplorerTreeIconCSS = (iconsUrl = MATERIAL_FILE_ICON_ASSETS_URL) => ` [data-item-type="folder"] [data-item-section="content"] { display: flex; align-items: center; @@ -23,20 +211,44 @@ export const FOLDER_ICON_CSS = ` [data-item-type="folder"] [data-item-section="content"]::before { content: ''; flex: 0 0 auto; - width: 14px; - height: 14px; - margin-inline-end: 6px; - background-color: currentColor; - -webkit-mask: url("data:image/svg+xml;utf8,${folderClosedSvg}") no-repeat center / contain; - mask: url("data:image/svg+xml;utf8,${folderClosedSvg}") no-repeat center / contain; - opacity: 0.85; + width: ${FOLDER_ICON_SIZE}; + height: ${FOLDER_ICON_SIZE}; + margin-inline-end: 4px; + background-image: ${iconBackground(iconsUrl, 'folder')}; + background-position: center; + background-repeat: no-repeat; + background-size: ${FOLDER_ICON_SIZE} ${FOLDER_ICON_SIZE}; } [data-item-type="folder"][aria-expanded="true"] [data-item-section="content"]::before { - -webkit-mask-image: url("data:image/svg+xml;utf8,${folderOpenSvg}"); - mask-image: url("data:image/svg+xml;utf8,${folderOpenSvg}"); + background-image: ${iconBackground(iconsUrl, 'folder', true)}; } - [data-item-type="file"] [data-item-section="icon"] { + [data-item-type="file"] > [data-item-section="icon"] { margin-inline-start: var(${FILE_ICON_OFFSET_VAR}, 0px); + background-image: ${iconBackground(iconsUrl, 'file')}; + background-position: center; + background-repeat: no-repeat; + background-size: ${FILE_ICON_SIZE} ${FILE_ICON_SIZE}; + } + [data-item-type="file"] > [data-item-section="icon"] > svg { + visibility: hidden; + } +${getFolderIconRules(iconsUrl)} +${getFileIconRules(iconsUrl)} +`; + +export const FOLDER_ICON_CSS = getExplorerTreeIconCSS(); + +// pierre/trees marks the clicked row as model-focused, which otherwise paints +// a pointer-only ring. +// Keep the native :focus-visible ring for keyboard navigation. +export const HIDE_POINTER_FOCUS_RING_CSS = ` + [data-type='item'][data-item-focused='true']:not(:focus-visible)::before { + outline: none; + } + + [data-type='item'][data-item-focused='true']:not(:focus-visible) + [data-item-flattened-subitems] { + --truncate-marker-block-inset: 0px; } `; diff --git a/src/features/ExplorerTree/index.tsx b/src/features/ExplorerTree/index.tsx index f1fe8721c9..5e5ce7ef2d 100644 --- a/src/features/ExplorerTree/index.tsx +++ b/src/features/ExplorerTree/index.tsx @@ -1,4 +1,8 @@ -export { FOLDER_ICON_CSS, getExplorerTreeStyleVars } from './folderIconStyle'; +export { + FOLDER_ICON_CSS, + getExplorerTreeStyleVars, + HIDE_POINTER_FOCUS_RING_CSS, +} from './folderIconStyle'; export type { ExplorerTreeCanDropCtx, ExplorerTreeHandle, diff --git a/src/features/Portal/LocalFile/TabStrip.tsx b/src/features/Portal/LocalFile/TabStrip.tsx index a9a1557d91..a49fe04afc 100644 --- a/src/features/Portal/LocalFile/TabStrip.tsx +++ b/src/features/Portal/LocalFile/TabStrip.tsx @@ -4,10 +4,11 @@ import { ContextMenuTrigger, type GenericItemType, Icon } from '@lobehub/ui'; import { confirmModal, ScrollArea } from '@lobehub/ui/base-ui'; import { SkillsIcon } from '@lobehub/ui/icons'; import { createStaticStyles } from 'antd-style'; -import { FileIcon, XIcon } from 'lucide-react'; +import { XIcon } from 'lucide-react'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; +import FileIcon from '@/components/FileIcon'; import { useChatStore } from '@/store/chat'; import { chatPortalSelectors } from '@/store/chat/selectors'; @@ -19,6 +20,15 @@ const resolveSkillName = (filePath: string): string | null => { }; const styles = createStaticStyles(({ css, cssVar }) => ({ + tabIcon: css` + display: inline-flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + + width: 14px; + height: 14px; + `, tabClose: css` cursor: pointer; @@ -242,7 +252,13 @@ const TabStrip = memo(() => { } }} > - + {skillName ? ( + + ) : ( + + + + )} {label}