mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-13 19:20:04 +00:00
✨ feat(codex): refine Codex tool renders (#15651)
* 💄 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
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "复制了文档",
|
||||
|
||||
@@ -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": "获取可用模型",
|
||||
|
||||
@@ -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<BuiltinRenderProps<GlobArgs>>(({ 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<BuiltinRenderProps<GlobArgs>>(({ content }) => {
|
||||
if (!content) return null;
|
||||
|
||||
return (
|
||||
<ToolResultCard
|
||||
wrapHeader
|
||||
icon={FolderSearch}
|
||||
header={
|
||||
<>
|
||||
{pattern && (
|
||||
<Text strong className={styles.pattern}>
|
||||
{pattern}
|
||||
</Text>
|
||||
)}
|
||||
{scope && (
|
||||
<Text ellipsis className={styles.scope}>
|
||||
{scope}
|
||||
</Text>
|
||||
)}
|
||||
{matchCount > 0 && <Text className={styles.count}>{`${matchCount} matches`}</Text>}
|
||||
</>
|
||||
}
|
||||
<Highlighter
|
||||
wrap
|
||||
language={'text'}
|
||||
showLanguage={false}
|
||||
style={{ maxHeight: 240, overflow: 'auto' }}
|
||||
variant={'borderless'}
|
||||
>
|
||||
{content && (
|
||||
<Highlighter
|
||||
wrap
|
||||
language={'text'}
|
||||
showLanguage={false}
|
||||
style={{ maxHeight: 240, overflow: 'auto' }}
|
||||
variant={'borderless'}
|
||||
>
|
||||
{content}
|
||||
</Highlighter>
|
||||
)}
|
||||
</ToolResultCard>
|
||||
{content}
|
||||
</Highlighter>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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<BuiltinRenderProps<GrepArgs>>(({ args, content }) => {
|
||||
const pattern = args?.pattern || '';
|
||||
const scope = args?.path || '';
|
||||
const glob = args?.glob || args?.type;
|
||||
const Grep = memo<BuiltinRenderProps<GrepArgs>>(({ content }) => {
|
||||
if (!content) return null;
|
||||
|
||||
return (
|
||||
<ToolResultCard
|
||||
wrapHeader
|
||||
icon={Search}
|
||||
header={
|
||||
<>
|
||||
{pattern && (
|
||||
<Text strong className={styles.pattern}>
|
||||
{pattern}
|
||||
</Text>
|
||||
)}
|
||||
{glob && <Tag>{glob}</Tag>}
|
||||
{scope && (
|
||||
<Text ellipsis className={styles.scope}>
|
||||
{scope}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
<Highlighter
|
||||
wrap
|
||||
language={'text'}
|
||||
showLanguage={false}
|
||||
style={{ maxHeight: 240, overflow: 'auto' }}
|
||||
variant={'borderless'}
|
||||
>
|
||||
{content && (
|
||||
<Highlighter
|
||||
wrap
|
||||
language={'text'}
|
||||
showLanguage={false}
|
||||
style={{ maxHeight: 240, overflow: 'auto' }}
|
||||
variant={'borderless'}
|
||||
>
|
||||
{content}
|
||||
</Highlighter>
|
||||
)}
|
||||
</ToolResultCard>
|
||||
{content}
|
||||
</Highlighter>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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<BuiltinRenderProps<ReadArgs>>(({ 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 (
|
||||
<ToolResultCard
|
||||
icon={FileText}
|
||||
header={
|
||||
<>
|
||||
<Text strong>{fileName || 'Read'}</Text>
|
||||
{dir && dir !== '.' && (
|
||||
<Text ellipsis className={styles.path}>
|
||||
{dir}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
<Highlighter
|
||||
wrap
|
||||
language={ext || 'text'}
|
||||
showLanguage={false}
|
||||
style={{ maxHeight: 240, overflow: 'auto' }}
|
||||
variant={'borderless'}
|
||||
>
|
||||
{source && (
|
||||
<Highlighter
|
||||
wrap
|
||||
language={ext || 'text'}
|
||||
showLanguage={false}
|
||||
style={{ maxHeight: 240, overflow: 'auto' }}
|
||||
variant={'borderless'}
|
||||
>
|
||||
{source}
|
||||
</Highlighter>
|
||||
)}
|
||||
</ToolResultCard>
|
||||
{source}
|
||||
</Highlighter>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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<BuiltinRenderProps<WebFetchArgs>>(({ args, content }) => {
|
||||
const url = args?.url || '';
|
||||
const prompt = args?.prompt || '';
|
||||
const WebFetch = memo<BuiltinRenderProps<WebFetchArgs>>(({ content }) => {
|
||||
if (!content) return null;
|
||||
|
||||
return (
|
||||
<ToolResultCard
|
||||
wrapHeader
|
||||
icon={Link}
|
||||
header={
|
||||
<>
|
||||
{url && (
|
||||
<Text ellipsis strong className={styles.url}>
|
||||
{url}
|
||||
</Text>
|
||||
)}
|
||||
{prompt && (
|
||||
<Text ellipsis className={styles.prompt}>
|
||||
{prompt}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{content && (
|
||||
<Markdown style={{ maxHeight: 240, overflow: 'auto' }} variant={'chat'}>
|
||||
{content}
|
||||
</Markdown>
|
||||
)}
|
||||
</ToolResultCard>
|
||||
<Markdown style={{ maxHeight: 240, overflow: 'auto' }} variant={'chat'}>
|
||||
{content}
|
||||
</Markdown>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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<BuiltinRenderProps<WebSearchArgs>>(({ 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<BuiltinRenderProps<WebSearchArgs>>(({ content }) => {
|
||||
if (!content) return null;
|
||||
|
||||
return (
|
||||
<ToolResultCard
|
||||
wrapHeader
|
||||
icon={Globe}
|
||||
header={
|
||||
<>
|
||||
{query && (
|
||||
<Text strong className={styles.query}>
|
||||
{query}
|
||||
</Text>
|
||||
)}
|
||||
{scope && (
|
||||
<Text ellipsis className={styles.domains}>
|
||||
{scope}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
<Highlighter
|
||||
wrap
|
||||
language={'text'}
|
||||
showLanguage={false}
|
||||
style={{ maxHeight: 240, overflow: 'auto' }}
|
||||
variant={'borderless'}
|
||||
>
|
||||
{content && (
|
||||
<Highlighter
|
||||
wrap
|
||||
language={'text'}
|
||||
showLanguage={false}
|
||||
style={{ maxHeight: 240, overflow: 'auto' }}
|
||||
variant={'borderless'}
|
||||
>
|
||||
{content}
|
||||
</Highlighter>
|
||||
)}
|
||||
</ToolResultCard>
|
||||
{content}
|
||||
</Highlighter>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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<BuiltinRenderProps<WriteArgs>>(({ args }) => {
|
||||
if (!args) return <Skeleton active />;
|
||||
|
||||
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<BuiltinRenderProps<WriteArgs>>(({ args }) => {
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ToolResultCard
|
||||
icon={FilePlus2}
|
||||
header={
|
||||
<>
|
||||
<Text strong>{fileName || 'Write'}</Text>
|
||||
{filePath && filePath !== fileName && (
|
||||
<Text ellipsis className={styles.path}>
|
||||
{filePath}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{renderContent()}
|
||||
</ToolResultCard>
|
||||
);
|
||||
return renderContent();
|
||||
});
|
||||
|
||||
Write.displayName = 'ClaudeCodeWrite';
|
||||
|
||||
@@ -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<CommandExecutionArgs, RunCommandState>
|
||||
>((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 (
|
||||
<SharedReadInspector
|
||||
apiName={apiName}
|
||||
args={readArgs || partialReadArgs || {}}
|
||||
identifier={identifier}
|
||||
isArgumentsStreaming={isArgumentsStreaming}
|
||||
isLoading={isLoading}
|
||||
partialArgs={partialReadArgs}
|
||||
result={result}
|
||||
toolCallId={toolCallId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const grepArgs = mapCommandToGrepArgs(args?.command);
|
||||
const partialGrepArgs = mapCommandToGrepArgs(partialArgs?.command);
|
||||
if (grepArgs || partialGrepArgs) {
|
||||
return (
|
||||
<SharedGrepInspector
|
||||
apiName={apiName}
|
||||
args={grepArgs || partialGrepArgs || {}}
|
||||
identifier={identifier}
|
||||
isArgumentsStreaming={isArgumentsStreaming}
|
||||
isLoading={isLoading}
|
||||
partialArgs={partialGrepArgs}
|
||||
result={result}
|
||||
toolCallId={toolCallId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <RunCommandInspector {...props} translationKey={COMMAND_EXECUTION_KEY} />;
|
||||
});
|
||||
|
||||
CommandExecutionInspector.displayName = 'CodexCommandExecutionInspector';
|
||||
|
||||
export default CommandExecutionInspector;
|
||||
@@ -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<BuiltinInspectorProps<CodexFileChangeArgs, CodexFileChangeState>>(
|
||||
({ 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 (
|
||||
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>File changes</div>
|
||||
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>{summary}</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -60,11 +58,13 @@ const FileChangeInspector = memo<BuiltinInspectorProps<CodexFileChangeArgs, Code
|
||||
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
|
||||
)}
|
||||
>
|
||||
<span>File changes:</span>
|
||||
{stats.firstPath && (
|
||||
<span className={styles.chip}>
|
||||
{stats.firstPath ? (
|
||||
<>
|
||||
<span className={styles.summary}>{summary}:</span>
|
||||
<FilePathDisplay filePath={stats.firstPath} />
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{summary}</span>
|
||||
)}
|
||||
{stats.total > 1 && <span className={styles.count}>+{stats.total - 1}</span>}
|
||||
{hasLineStats && (
|
||||
|
||||
@@ -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<BuiltinRenderProps<CodexFileChangeArgs, CodexFileChangeState>>(
|
||||
({ 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 (
|
||||
<Text className={styles.emptyState}>
|
||||
{t('builtins.codex.fileChange.noChanges', { defaultValue: 'No file changes' })}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ToolResultCard
|
||||
wrapHeader
|
||||
icon={FileText}
|
||||
header={
|
||||
<Flexbox horizontal align={'center'} className={styles.header} wrap={'wrap'}>
|
||||
<Text className={styles.headerLabel}>File changes:</Text>
|
||||
{stats.firstPath && (
|
||||
<span className={styles.headerChip}>
|
||||
<FilePathDisplay filePath={stats.firstPath} />
|
||||
</span>
|
||||
)}
|
||||
{stats.total > 1 && <Text className={styles.headerCount}>+{stats.total - 1}</Text>}
|
||||
<LineStats linesAdded={stats.linesAdded} linesDeleted={stats.linesDeleted} />
|
||||
</Flexbox>
|
||||
}
|
||||
>
|
||||
{stats.total > 0 ? (
|
||||
<div className={styles.panel}>
|
||||
<Flexbox horizontal align={'center'} className={styles.panelHeader} wrap={'wrap'}>
|
||||
<div className={styles.panelTitle}>
|
||||
<Icon icon={Files} size={16} />
|
||||
<Text strong>{summary}</Text>
|
||||
</div>
|
||||
{detail && <Text className={styles.panelMeta}>{detail}</Text>}
|
||||
<LineStats linesAdded={stats.linesAdded} linesDeleted={stats.linesDeleted} />
|
||||
</Flexbox>
|
||||
<Flexbox className={styles.list}>
|
||||
{data.changes.map((change, index) => {
|
||||
const kind = getFileChangeKind(change.kind);
|
||||
const path = change.path || '';
|
||||
<Flexbox className={styles.list}>
|
||||
{data.changes.map((change, index) => {
|
||||
const kind = getFileChangeKind(change.kind);
|
||||
const path = change.path || '';
|
||||
|
||||
return (
|
||||
<Flexbox horizontal className={styles.row} key={`${path}-${index}`}>
|
||||
<span className={cx(styles.kindDot, getKindClassName(kind))} />
|
||||
<div className={styles.rowMain}>
|
||||
<div className={styles.path}>
|
||||
{path ? (
|
||||
<FilePathDisplay filePath={path} />
|
||||
) : (
|
||||
<Text className={styles.unknownPath}>Unknown file</Text>
|
||||
)}
|
||||
</div>
|
||||
<LineStats
|
||||
linesAdded={change.linesAdded}
|
||||
linesDeleted={change.linesDeleted}
|
||||
/>
|
||||
</div>
|
||||
</Flexbox>
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<Flexbox horizontal className={styles.row} key={`${path}-${index}`}>
|
||||
<span className={cx(styles.kindDot, getKindClassName(kind))} />
|
||||
<div className={styles.rowMain}>
|
||||
<div className={styles.path}>
|
||||
{path ? (
|
||||
<FilePathDisplay filePath={path} />
|
||||
) : (
|
||||
<Text className={styles.unknownPath}>
|
||||
{t('builtins.codex.fileChange.unknownFile', {
|
||||
defaultValue: 'Unknown file',
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<LineStats linesAdded={change.linesAdded} linesDeleted={change.linesDeleted} />
|
||||
</div>
|
||||
</Flexbox>
|
||||
</div>
|
||||
) : (
|
||||
<Text className={styles.emptyState}>No file changes</Text>
|
||||
)}
|
||||
</ToolResultCard>
|
||||
);
|
||||
})}
|
||||
</Flexbox>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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<string>(LINEAR_TOOL_NAMES);
|
||||
const SharedLinearInspector = LinearInspector as ComponentType<
|
||||
BuiltinInspectorProps<Record<string, unknown>>
|
||||
>;
|
||||
|
||||
const McpToolInspector = memo<BuiltinInspectorProps<CodexMcpToolArgs, CodexMcpToolState>>(
|
||||
({ 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 (
|
||||
<SharedLinearInspector
|
||||
apiName={linearApiName}
|
||||
args={getMcpInputRecord(args, pluginState) || {}}
|
||||
identifier={'codex'}
|
||||
isArgumentsStreaming={isArgumentsStreaming}
|
||||
isLoading={isLoading}
|
||||
partialArgs={getMcpInputRecord(partialArgs) || {}}
|
||||
pluginState={pluginState}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const target = [server, tool].filter(Boolean).join(' > ');
|
||||
|
||||
if (isArgumentsStreaming && !target) {
|
||||
return <div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>{label}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
inspectorTextStyles.root,
|
||||
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
|
||||
)}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{target && (
|
||||
<>
|
||||
<span>: </span>
|
||||
<span className={highlightTextStyles.primary}>{target}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
McpToolInspector.displayName = 'CodexMcpToolInspector';
|
||||
|
||||
export default McpToolInspector;
|
||||
@@ -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<BuiltinRenderProps<CodexMcpToolArgs, CodexMcpToolState, string>>(
|
||||
({ 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 (
|
||||
<Flexbox gap={12}>
|
||||
{input && (
|
||||
<div>
|
||||
<Text className={styles.sectionLabel}>
|
||||
{t('builtins.codex.mcpTool.input', { defaultValue: 'Input' })}
|
||||
</Text>
|
||||
<Highlighter
|
||||
wrap
|
||||
language={input.language}
|
||||
showLanguage={input.language !== 'text'}
|
||||
style={{ maxHeight: 220, overflow: 'auto', paddingInline: 8 }}
|
||||
variant={'outlined'}
|
||||
>
|
||||
{input.text}
|
||||
</Highlighter>
|
||||
</div>
|
||||
)}
|
||||
{output && (
|
||||
<div>
|
||||
<Text className={styles.sectionLabel}>
|
||||
{t('builtins.codex.mcpTool.result', { defaultValue: 'Result' })}
|
||||
</Text>
|
||||
<Highlighter
|
||||
wrap
|
||||
language={output.language}
|
||||
showLanguage={output.language !== 'text'}
|
||||
style={{ maxHeight: 360, overflow: 'auto', paddingInline: 8 }}
|
||||
variant={'filled'}
|
||||
>
|
||||
{output.text}
|
||||
</Highlighter>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div>
|
||||
<Text className={styles.sectionLabel} style={{ color: cssVar.colorError }}>
|
||||
{t('builtins.codex.mcpTool.error', { defaultValue: 'Error' })}
|
||||
</Text>
|
||||
<Highlighter
|
||||
wrap
|
||||
language={'text'}
|
||||
showLanguage={false}
|
||||
style={{ maxHeight: 220, overflow: 'auto', paddingInline: 8 }}
|
||||
variant={'filled'}
|
||||
>
|
||||
{error}
|
||||
</Highlighter>
|
||||
</div>
|
||||
)}
|
||||
</Flexbox>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
McpToolRender.displayName = 'CodexMcpToolRender';
|
||||
|
||||
export default McpToolRender;
|
||||
@@ -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<BuiltinInspectorProps<CodexWebSearchArgs>>(
|
||||
({ 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 <div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>{label}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
inspectorTextStyles.root,
|
||||
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
|
||||
)}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{query && (
|
||||
<>
|
||||
<span>: </span>
|
||||
<span className={highlightTextStyles.primary}>{query}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
WebSearchInspector.displayName = 'CodexWebSearchInspector';
|
||||
|
||||
export default WebSearchInspector;
|
||||
@@ -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<BuiltinRenderProps<CodexWebSearchArgs>>(({ 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 (
|
||||
<Flexbox className={styles.root}>
|
||||
{query && (
|
||||
<Flexbox horizontal className={styles.queryRow}>
|
||||
<span className={styles.queryLabel}>
|
||||
{t('builtins.codex.webSearch.query', { defaultValue: 'Query' })}
|
||||
</span>
|
||||
<span className={styles.query}>{query}</span>
|
||||
</Flexbox>
|
||||
)}
|
||||
{results.length > 0 && (
|
||||
<Flexbox className={styles.resultList}>
|
||||
{results.map((result, index) => {
|
||||
const key = result.url || `${result.title}-${index}`;
|
||||
const title = <span className={styles.title}>{result.title}</span>;
|
||||
|
||||
return (
|
||||
<Flexbox className={styles.resultItem} gap={3} key={key}>
|
||||
{result.url ? (
|
||||
<a href={result.url} rel={'noreferrer'} target={'_blank'}>
|
||||
{title}
|
||||
</a>
|
||||
) : (
|
||||
title
|
||||
)}
|
||||
{result.url && <Text className={styles.url}>{result.url}</Text>}
|
||||
{result.snippet && <Text className={styles.snippet}>{result.snippet}</Text>}
|
||||
</Flexbox>
|
||||
);
|
||||
})}
|
||||
</Flexbox>
|
||||
)}
|
||||
{output && <pre className={styles.output}>{output}</pre>}
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
WebSearchRender.displayName = 'CodexWebSearchRender';
|
||||
|
||||
export default WebSearchRender;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<number[]>((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 };
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { RenderDisplayControl } from '@lobechat/types';
|
||||
|
||||
export const CodexRenderDisplayControls: Record<string, RenderDisplayControl> = {
|
||||
command_execution: 'collapsed',
|
||||
file_change: 'expand',
|
||||
mcp_tool_call: 'expand',
|
||||
todo_list: 'expand',
|
||||
web_search: 'expand',
|
||||
};
|
||||
@@ -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<string, BuiltinInspector> = {
|
||||
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<string, BuiltinRender> = {
|
||||
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<string, RenderDisplayControl> = {
|
||||
file_change: 'expand',
|
||||
todo_list: 'expand',
|
||||
};
|
||||
export { CodexRenderDisplayControls };
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
'use client';
|
||||
|
||||
export interface CodexMcpToolArgs extends Record<string, unknown> {
|
||||
arguments?: unknown;
|
||||
error?: unknown;
|
||||
result?: unknown;
|
||||
server?: unknown;
|
||||
tool?: unknown;
|
||||
}
|
||||
|
||||
export interface CodexMcpToolState extends Record<string, unknown> {
|
||||
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<string, unknown> =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
|
||||
const normalizeString = (value: unknown) => (typeof value === 'string' ? value.trim() : '');
|
||||
|
||||
const getStringByKeys = (record: Record<string, unknown> | 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<string, unknown> | 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,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,198 @@
|
||||
'use client';
|
||||
|
||||
export interface CodexWebSearchArgs extends Record<string, unknown> {
|
||||
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<string, unknown> =>
|
||||
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<string, unknown>, 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<string, unknown>[] = [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<string, unknown>): 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;
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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<GithubRunCommandArgs, GithubRunCommandState>
|
||||
>(({ 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 (
|
||||
<ToolResultCard
|
||||
wrapHeader
|
||||
icon={GitBranch}
|
||||
header={
|
||||
<Flexbox horizontal align={'center'} className={styles.headerRow} wrap={'wrap'}>
|
||||
<span className={styles.ghPrefix}>gh</span>
|
||||
<span className={styles.headerCommand}>{headerLabel}</span>
|
||||
{success !== undefined && (
|
||||
<span
|
||||
className={`${styles.exitCode} ${
|
||||
success ? styles.exitCodeSuccess : styles.exitCodeError
|
||||
}`}
|
||||
>
|
||||
exit {exitCode ?? (success ? 0 : 1)}
|
||||
</span>
|
||||
)}
|
||||
</Flexbox>
|
||||
}
|
||||
>
|
||||
<Flexbox gap={12}>
|
||||
{normalized && (
|
||||
<div>
|
||||
<Text className={styles.sectionLabel}>Command</Text>
|
||||
<Highlighter
|
||||
wrap
|
||||
language={'sh'}
|
||||
showLanguage={false}
|
||||
style={{ maxHeight: 160, overflow: 'auto', paddingInline: 8 }}
|
||||
variant={'outlined'}
|
||||
>
|
||||
{`gh ${normalized}`}
|
||||
</Highlighter>
|
||||
</div>
|
||||
)}
|
||||
{outputBody && (
|
||||
<div>
|
||||
<Text className={styles.sectionLabel}>Output</Text>
|
||||
<Highlighter
|
||||
wrap
|
||||
language={outputLanguage}
|
||||
showLanguage={outputLanguage === 'json'}
|
||||
style={{ maxHeight: 360, overflow: 'auto', paddingInline: 8 }}
|
||||
variant={'filled'}
|
||||
>
|
||||
{outputBody}
|
||||
</Highlighter>
|
||||
</div>
|
||||
)}
|
||||
{stderr && (
|
||||
<div>
|
||||
<Text className={styles.sectionLabel} style={{ color: cssVar.colorError }}>
|
||||
Stderr
|
||||
</Text>
|
||||
<Highlighter
|
||||
wrap
|
||||
language={'text'}
|
||||
showLanguage={false}
|
||||
style={{ maxHeight: 200, overflow: 'auto', paddingInline: 8 }}
|
||||
variant={'filled'}
|
||||
>
|
||||
{stderr}
|
||||
</Highlighter>
|
||||
</div>
|
||||
)}
|
||||
</Flexbox>
|
||||
</ToolResultCard>
|
||||
<Flexbox gap={12}>
|
||||
{normalized && (
|
||||
<div>
|
||||
<Text className={styles.sectionLabel}>
|
||||
Command
|
||||
{success !== undefined && (
|
||||
<span
|
||||
className={`${styles.exitCode} ${
|
||||
success ? styles.exitCodeSuccess : styles.exitCodeError
|
||||
}`}
|
||||
>
|
||||
exit {exitCode ?? (success ? 0 : 1)}
|
||||
</span>
|
||||
)}
|
||||
</Text>
|
||||
<Highlighter
|
||||
wrap
|
||||
language={'sh'}
|
||||
showLanguage={false}
|
||||
style={{ maxHeight: 160, overflow: 'auto', paddingInline: 8 }}
|
||||
variant={'outlined'}
|
||||
>
|
||||
{`gh ${normalized}`}
|
||||
</Highlighter>
|
||||
</div>
|
||||
)}
|
||||
{outputBody && (
|
||||
<div>
|
||||
<Text className={styles.sectionLabel}>Output</Text>
|
||||
<Highlighter
|
||||
wrap
|
||||
language={outputLanguage}
|
||||
showLanguage={outputLanguage === 'json'}
|
||||
style={{ maxHeight: 360, overflow: 'auto', paddingInline: 8 }}
|
||||
variant={'filled'}
|
||||
>
|
||||
{outputBody}
|
||||
</Highlighter>
|
||||
</div>
|
||||
)}
|
||||
{stderr && (
|
||||
<div>
|
||||
<Text className={styles.sectionLabel} style={{ color: cssVar.colorError }}>
|
||||
Stderr
|
||||
</Text>
|
||||
<Highlighter
|
||||
wrap
|
||||
language={'text'}
|
||||
showLanguage={false}
|
||||
style={{ maxHeight: 200, overflow: 'auto', paddingInline: 8 }}
|
||||
variant={'filled'}
|
||||
>
|
||||
{stderr}
|
||||
</Highlighter>
|
||||
</div>
|
||||
)}
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string, Record<string, BuiltinInspector>> =
|
||||
[TaskManifest.identifier]: TaskInspectors as Record<string, BuiltinInspector>,
|
||||
[WebBrowsingManifest.identifier]: WebBrowsingInspectors as Record<string, BuiltinInspector>,
|
||||
[WebOnboardingManifest.identifier]: WebOnboardingInspectors as Record<string, BuiltinInspector>,
|
||||
codex: {
|
||||
...CodexInspectors,
|
||||
command_execution: createRunCommandInspector('Run') as BuiltinInspector,
|
||||
},
|
||||
codex: CodexInspectors,
|
||||
[GithubIdentifier]: GithubInspectors,
|
||||
[LinearIdentifier]: LinearInspectors,
|
||||
[TwitterIdentifier]: TwitterInspectors,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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<string, unknown> =>
|
||||
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<string, unknown>, 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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<ToolResultCardProps>(
|
||||
({ icon, header, children, wrapHeader }) => (
|
||||
<Flexbox className={styles.container} gap={8}>
|
||||
<Flexbox
|
||||
horizontal
|
||||
align={'center'}
|
||||
className={styles.header}
|
||||
gap={8}
|
||||
wrap={wrapHeader ? 'wrap' : undefined}
|
||||
>
|
||||
<Icon icon={icon} size={'small'} />
|
||||
{header}
|
||||
</Flexbox>
|
||||
{children && <Flexbox className={styles.previewBox}>{children}</Flexbox>}
|
||||
</Flexbox>
|
||||
),
|
||||
);
|
||||
|
||||
ToolResultCard.displayName = 'ToolResultCard';
|
||||
@@ -1,3 +1,2 @@
|
||||
export { AnimatedNumber } from './AnimatedNumber';
|
||||
export { FilePathDisplay } from './FilePathDisplay';
|
||||
export { ToolResultCard } from './ToolResultCard';
|
||||
|
||||
@@ -13,7 +13,15 @@ interface FileListProps {
|
||||
|
||||
const FileIcon = memo<FileListProps>(({ fileName, size, variant = 'raw', isDirectory }) => {
|
||||
if (isDirectory)
|
||||
return <FileTypeIcon color={'gold'} size={size} type={'folder'} variant={'color'} />;
|
||||
return (
|
||||
<MaterialFileTypeIcon
|
||||
fallbackUnknownType={false}
|
||||
filename={fileName}
|
||||
size={size}
|
||||
type={'folder'}
|
||||
variant={variant}
|
||||
/>
|
||||
);
|
||||
|
||||
if (Object.keys(mimeTypeMap).some((key) => fileName?.toLowerCase().endsWith(`.${key}`))) {
|
||||
const ext = fileName.split('.').pop()?.toLowerCase() as string;
|
||||
|
||||
@@ -132,6 +132,7 @@ vi.mock('@/features/ExplorerTree', () => {
|
||||
return {
|
||||
ExplorerTree,
|
||||
FOLDER_ICON_CSS: '',
|
||||
HIDE_POINTER_FOCUS_RING_CSS: '',
|
||||
getExplorerTreeStyleVars: () => ({}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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<Props>(({ agentId, data, mutate, style }) => {
|
||||
nodes={nodes}
|
||||
ref={treeRef}
|
||||
style={{ height: '100%' }}
|
||||
unsafeCSS={FOLDER_ICON_CSS}
|
||||
unsafeCSS={DOCUMENT_TREE_UNSAFE_CSS}
|
||||
header={
|
||||
<DocumentExplorerToolbar
|
||||
onCreateDocument={() => handleCreateDocument(null)}
|
||||
|
||||
@@ -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<LocalFileLinkProperties> => ({
|
||||
children: null,
|
||||
id: 'local-file-link',
|
||||
node: {
|
||||
properties,
|
||||
},
|
||||
tagName: 'lobeLocalFileLink',
|
||||
type: 'element',
|
||||
});
|
||||
|
||||
vi.mock('@lobechat/const', async (importOriginal) => ({
|
||||
...((await importOriginal()) as Record<string, unknown>),
|
||||
isDesktop: true,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/FileIcon', () => ({
|
||||
default: ({ fileName, size }: { fileName: string; size?: number }) => (
|
||||
<span data-file-name={fileName} data-size={size} data-testid="file-icon" />
|
||||
),
|
||||
}));
|
||||
|
||||
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(
|
||||
<Render
|
||||
{...createRenderProps({
|
||||
linkHref: '/Users/me/project/src/Group.tsx:265',
|
||||
linkLabel: 'Group.tsx',
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<MarkdownElementProps<LocalFileLinkProperties>>(({ 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<HTMLAnchorElement>) => {
|
||||
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 (
|
||||
<A className={styles.link} href={linkHref} onClick={handleClick}>
|
||||
<span aria-hidden className={styles.icon}>
|
||||
<FileIcon fileName={iconFileName} size={16} variant={'raw'} />
|
||||
</span>
|
||||
<span>{label}</span>
|
||||
</A>
|
||||
);
|
||||
});
|
||||
|
||||
Render.displayName = 'LocalFileLinkRender';
|
||||
|
||||
export default Render;
|
||||
@@ -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<MarkdownElementProps>,
|
||||
rehypePlugin: rehypeLocalFileLink,
|
||||
scope: 'all',
|
||||
tag: LOBE_LOCAL_FILE_LINK_TAG,
|
||||
};
|
||||
|
||||
export default LocalFileLinkElement;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
@@ -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<string, unknown>),
|
||||
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' });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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(
|
||||
<Group
|
||||
id="assistant-1"
|
||||
messageIndex={0}
|
||||
blocks={[
|
||||
blk({
|
||||
content: finalSummary,
|
||||
id: 'block-1',
|
||||
tools: [
|
||||
{ apiName: 'command_execution', id: 'tool-1' } as any,
|
||||
{ apiName: 'command_execution', id: 'tool-2' } as any,
|
||||
{ apiName: 'command_execution', id: 'tool-3' } as any,
|
||||
],
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<Group
|
||||
|
||||
@@ -265,6 +265,13 @@ const appendWorkflowRangeBlock = (
|
||||
return;
|
||||
}
|
||||
|
||||
appendWorkflowBlock(
|
||||
segments,
|
||||
createWorkflowRenderBlock(block, {
|
||||
content: '',
|
||||
imageList: undefined,
|
||||
}),
|
||||
);
|
||||
appendAnswerBlock(
|
||||
segments,
|
||||
createAnswerRenderBlock(block, {
|
||||
@@ -273,13 +280,6 @@ const appendWorkflowRangeBlock = (
|
||||
tools: undefined,
|
||||
}),
|
||||
);
|
||||
appendWorkflowBlock(
|
||||
segments,
|
||||
createWorkflowRenderBlock(block, {
|
||||
content: '',
|
||||
imageList: undefined,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const appendPostToolBlocks = (
|
||||
|
||||
@@ -163,6 +163,13 @@ export const TOOL_API_DISPLAY_NAMES: Record<string, string> = {
|
||||
// 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',
|
||||
|
||||
@@ -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<AssistantContentBlock> & { 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 =
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -1,21 +1,209 @@
|
||||
import { genCdnUrl } from '@lobehub/ui';
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
const folderClosedSvg = `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z'/></svg>`;
|
||||
const folderOpenSvg = `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='m6 14 1.45-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.55 6a2 2 0 0 1-1.94 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.69.9H18a2 2 0 0 1 2 2v2'/></svg>`;
|
||||
|
||||
// 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;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(() => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon icon={skillName ? SkillsIcon : FileIcon} size={12} />
|
||||
{skillName ? (
|
||||
<Icon className={styles.tabIcon} icon={SkillsIcon} size={12} />
|
||||
) : (
|
||||
<span className={styles.tabIcon}>
|
||||
<FileIcon fileName={filename} size={14} variant={'raw'} />
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.tabLabel}>{label}</span>
|
||||
<button
|
||||
aria-label={`Close ${filename}`}
|
||||
|
||||
+3
-1
@@ -46,7 +46,8 @@ vi.mock('@/features/ExplorerTree', () => {
|
||||
MockExplorerTree.displayName = 'MockExplorerTree';
|
||||
return {
|
||||
ExplorerTree: MockExplorerTree,
|
||||
FOLDER_ICON_CSS: '',
|
||||
FOLDER_ICON_CSS: 'folder-css',
|
||||
HIDE_POINTER_FOCUS_RING_CSS: 'hide-pointer-focus-ring-css',
|
||||
getExplorerTreeStyleVars: () => ({}),
|
||||
};
|
||||
});
|
||||
@@ -165,6 +166,7 @@ describe('Files — reveal request integration', () => {
|
||||
{ path: 'src/foo/bar.ts', status: 'modified' },
|
||||
{ path: 'deleted.ts', status: 'deleted' },
|
||||
]);
|
||||
expect(explorerTreeProps.current?.unsafeCSS).toBe('folder-css\nhide-pointer-focus-ring-css');
|
||||
|
||||
const nodes = explorerTreeProps.current?.nodes as { id: string }[];
|
||||
const dirtyNode = nodes.find((node) => node.id === 'src/foo/bar.ts');
|
||||
|
||||
@@ -11,7 +11,12 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import NeuralNetworkLoading from '@/components/NeuralNetworkLoading';
|
||||
import type { 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 type { ExplorerTreeHandle } from '@/features/ExplorerTree/types';
|
||||
import { localFileService } from '@/services/electron/localFileService';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
@@ -36,19 +41,15 @@ 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;
|
||||
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
`,
|
||||
@@ -72,6 +73,8 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
|
||||
const stripTrailingSlash = (value: string) => (value.endsWith('/') ? value.slice(0, -1) : value);
|
||||
|
||||
const FILE_TREE_UNSAFE_CSS = `${FOLDER_ICON_CSS}\n${HIDE_POINTER_FOCUS_RING_CSS}`;
|
||||
|
||||
const getParentRelativePath = (relativePath: string): string | null => {
|
||||
const cleaned = stripTrailingSlash(relativePath);
|
||||
const idx = cleaned.lastIndexOf('/');
|
||||
@@ -289,7 +292,7 @@ const Files = memo<FilesProps>(({ deviceId, workingDirectory }) => {
|
||||
nodes={nodes}
|
||||
ref={treeRef}
|
||||
style={{ height: '100%' }}
|
||||
unsafeCSS={FOLDER_ICON_CSS}
|
||||
unsafeCSS={FILE_TREE_UNSAFE_CSS}
|
||||
onExpandedChange={setExpandedIds}
|
||||
onNodeClick={handleNodeClick}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user