🐛 fix: fix add message and improve local system tool (#11815)

* fix add message

* fix grep content issue

* fix command tool

* improve loading
This commit is contained in:
Arvin Xu
2026-01-25 22:54:38 +08:00
committed by GitHub
parent 1276a87b0f
commit 3b41009a68
11 changed files with 346 additions and 149 deletions

View File

@@ -548,7 +548,13 @@ export default class LocalFileCtr extends ControllerModule {
filesToSearch = [searchPath];
} else {
// Use glob pattern if provided, otherwise search all files
const globPattern = params.glob || '**/*';
// If glob doesn't contain directory separator and doesn't start with **,
// auto-prefix with **/ to make it recursive
let globPattern = params.glob || '**/*';
if (params.glob && !params.glob.includes('/') && !params.glob.startsWith('**')) {
globPattern = `**/${params.glob}`;
}
filesToSearch = await fg(globPattern, {
absolute: true,
cwd: searchPath,

View File

@@ -15,6 +15,19 @@ import { ControllerModule, IpcMethod } from './index';
const logger = createLogger('controllers:ShellCommandCtr');
// Maximum output length to prevent context explosion
const MAX_OUTPUT_LENGTH = 10_000;
/**
* Truncate string to max length with ellipsis indicator
*/
const truncateOutput = (str: string, maxLength: number = MAX_OUTPUT_LENGTH): string => {
if (str.length <= maxLength) return str;
return (
str.slice(0, maxLength) + '\n... [truncated, ' + (str.length - maxLength) + ' more characters]'
);
};
interface ShellProcess {
lastReadStderr: number;
lastReadStdout: number;
@@ -104,8 +117,8 @@ export default class ShellCommandCtr extends ControllerModule {
childProcess.kill();
resolve({
error: `Command timed out after ${effectiveTimeout}ms`,
stderr,
stdout,
stderr: truncateOutput(stderr),
stdout: truncateOutput(stdout),
success: false,
});
}, effectiveTimeout);
@@ -125,9 +138,9 @@ export default class ShellCommandCtr extends ControllerModule {
logger.info(`${logPrefix} Command completed`, { code, success });
resolve({
exit_code: code || 0,
output: stdout + stderr,
stderr,
stdout,
output: truncateOutput(stdout + stderr),
stderr: truncateOutput(stderr),
stdout: truncateOutput(stdout),
success,
});
}
@@ -138,8 +151,8 @@ export default class ShellCommandCtr extends ControllerModule {
logger.error(`${logPrefix} Command failed:`, error);
resolve({
error: error.message,
stderr,
stdout,
stderr: truncateOutput(stderr),
stdout: truncateOutput(stdout),
success: false,
});
});
@@ -205,10 +218,10 @@ export default class ShellCommandCtr extends ControllerModule {
});
return {
output,
output: truncateOutput(output),
running,
stderr: newStderr,
stdout: newStdout,
stderr: truncateOutput(newStderr),
stdout: truncateOutput(newStdout),
success: true,
};
}

View File

@@ -552,4 +552,265 @@ describe('LocalFileCtr', () => {
expect(result.diffText).toContain('+modified line 2');
});
});
describe('handleGrepContent', () => {
it('should search content in a single file', async () => {
vi.mocked(mockFsPromises.stat).mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
} as any);
vi.mocked(mockFsPromises.readFile).mockResolvedValue('Hello world\nTest line\nAnother test');
const result = await localFileCtr.handleGrepContent({
'pattern': 'test',
'path': '/test/file.txt',
'-i': true,
});
expect(result.success).toBe(true);
expect(result.matches).toContain('/test/file.txt');
expect(result.total_matches).toBe(1);
});
it('should search content in directory with default glob pattern', async () => {
vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => {
if (filePath === '/test') {
return { isFile: () => false, isDirectory: () => true } as any;
}
return { isFile: () => true, isDirectory: () => false } as any;
});
vi.mocked(mockFg).mockResolvedValue(['/test/file1.txt', '/test/file2.txt']);
vi.mocked(mockFsPromises.readFile).mockImplementation(async (filePath) => {
if (filePath === '/test/file1.txt') return 'Hello world';
if (filePath === '/test/file2.txt') return 'Test content';
return '';
});
const result = await localFileCtr.handleGrepContent({
pattern: 'Hello',
path: '/test',
});
expect(result.success).toBe(true);
expect(result.matches).toContain('/test/file1.txt');
expect(result.total_matches).toBe(1);
expect(mockFg).toHaveBeenCalledWith('**/*', expect.objectContaining({ cwd: '/test' }));
});
it('should auto-prefix glob pattern with **/ for non-recursive patterns', async () => {
vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => {
if (filePath === '/test') {
return { isFile: () => false, isDirectory: () => true } as any;
}
return { isFile: () => true, isDirectory: () => false } as any;
});
vi.mocked(mockFg).mockResolvedValue(['/test/src/file1.ts', '/test/lib/file2.tsx']);
vi.mocked(mockFsPromises.readFile).mockResolvedValue('const test = "hello";');
const result = await localFileCtr.handleGrepContent({
pattern: 'test',
path: '/test',
glob: '*.{ts,tsx}',
});
expect(result.success).toBe(true);
// Should auto-prefix *.{ts,tsx} with **/ to make it recursive
expect(mockFg).toHaveBeenCalledWith(
'**/*.{ts,tsx}',
expect.objectContaining({ cwd: '/test' }),
);
});
it('should not modify glob pattern that already contains path separator', async () => {
vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => {
if (filePath === '/test') {
return { isFile: () => false, isDirectory: () => true } as any;
}
return { isFile: () => true, isDirectory: () => false } as any;
});
vi.mocked(mockFg).mockResolvedValue(['/test/src/file1.ts']);
vi.mocked(mockFsPromises.readFile).mockResolvedValue('const test = "hello";');
const result = await localFileCtr.handleGrepContent({
pattern: 'test',
path: '/test',
glob: 'src/*.ts',
});
expect(result.success).toBe(true);
// Should not modify glob pattern that already contains /
expect(mockFg).toHaveBeenCalledWith('src/*.ts', expect.objectContaining({ cwd: '/test' }));
});
it('should not modify glob pattern that starts with **', async () => {
vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => {
if (filePath === '/test') {
return { isFile: () => false, isDirectory: () => true } as any;
}
return { isFile: () => true, isDirectory: () => false } as any;
});
vi.mocked(mockFg).mockResolvedValue(['/test/src/file1.ts']);
vi.mocked(mockFsPromises.readFile).mockResolvedValue('const test = "hello";');
const result = await localFileCtr.handleGrepContent({
pattern: 'test',
path: '/test',
glob: '**/components/*.tsx',
});
expect(result.success).toBe(true);
// Should not modify glob pattern that already starts with **
expect(mockFg).toHaveBeenCalledWith(
'**/components/*.tsx',
expect.objectContaining({ cwd: '/test' }),
);
});
it('should filter by type when provided', async () => {
vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => {
if (filePath === '/test') {
return { isFile: () => false, isDirectory: () => true } as any;
}
return { isFile: () => true, isDirectory: () => false } as any;
});
// fast-glob returns all files, then type filter is applied
vi.mocked(mockFg).mockResolvedValue(['/test/file1.ts', '/test/file2.js', '/test/file3.ts']);
vi.mocked(mockFsPromises.readFile).mockResolvedValue('unique_pattern');
const result = await localFileCtr.handleGrepContent({
pattern: 'unique_pattern',
path: '/test',
type: 'ts',
});
expect(result.success).toBe(true);
// Type filter should exclude .js files from being searched
// Only .ts files should be in the results
expect(result.matches).not.toContain('/test/file2.js');
// At least one .ts file should match
expect(result.matches.length).toBeGreaterThan(0);
expect(result.matches.every((m) => m.endsWith('.ts'))).toBe(true);
});
it('should return content mode with line numbers', async () => {
vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => {
if (filePath === '/test') {
return { isFile: () => false, isDirectory: () => true } as any;
}
return { isFile: () => true, isDirectory: () => false } as any;
});
vi.mocked(mockFg).mockResolvedValue(['/test/file.txt']);
vi.mocked(mockFsPromises.readFile).mockResolvedValue('line 1\ntest line\nline 3');
const result = await localFileCtr.handleGrepContent({
'pattern': 'test',
'path': '/test',
'output_mode': 'content',
'-n': true,
});
expect(result.success).toBe(true);
expect(result.matches.some((m) => m.includes('2:'))).toBe(true);
});
it('should return count mode', async () => {
vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => {
if (filePath === '/test') {
return { isFile: () => false, isDirectory: () => true } as any;
}
return { isFile: () => true, isDirectory: () => false } as any;
});
vi.mocked(mockFg).mockResolvedValue(['/test/file.txt']);
vi.mocked(mockFsPromises.readFile).mockResolvedValue('test one\ntest two\ntest three');
const result = await localFileCtr.handleGrepContent({
pattern: 'test',
path: '/test',
output_mode: 'count',
});
expect(result.success).toBe(true);
expect(result.matches).toContain('/test/file.txt:3');
expect(result.total_matches).toBe(3);
});
it('should respect head_limit', async () => {
vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => {
if (filePath === '/test') {
return { isFile: () => false, isDirectory: () => true } as any;
}
return { isFile: () => true, isDirectory: () => false } as any;
});
vi.mocked(mockFg).mockResolvedValue([
'/test/file1.txt',
'/test/file2.txt',
'/test/file3.txt',
'/test/file4.txt',
'/test/file5.txt',
]);
vi.mocked(mockFsPromises.readFile).mockResolvedValue('test content');
const result = await localFileCtr.handleGrepContent({
pattern: 'test',
path: '/test',
head_limit: 2,
});
expect(result.success).toBe(true);
expect(result.matches.length).toBe(2);
});
it('should handle case insensitive search', async () => {
vi.mocked(mockFsPromises.stat).mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
} as any);
vi.mocked(mockFsPromises.readFile).mockResolvedValue('Hello World\nHELLO world\nhello WORLD');
const result = await localFileCtr.handleGrepContent({
'pattern': 'hello',
'path': '/test/file.txt',
'-i': true,
});
expect(result.success).toBe(true);
expect(result.matches).toContain('/test/file.txt');
});
it('should handle grep error gracefully', async () => {
vi.mocked(mockFsPromises.stat).mockRejectedValue(new Error('Path not found'));
const result = await localFileCtr.handleGrepContent({
pattern: 'test',
path: '/nonexistent',
});
expect(result.success).toBe(false);
expect(result.matches).toEqual([]);
expect(result.total_matches).toBe(0);
});
it('should skip unreadable files gracefully', async () => {
vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => {
if (filePath === '/test') {
return { isFile: () => false, isDirectory: () => true } as any;
}
return { isFile: () => true, isDirectory: () => false } as any;
});
vi.mocked(mockFg).mockResolvedValue(['/test/file1.txt', '/test/file2.txt']);
vi.mocked(mockFsPromises.readFile).mockImplementation(async (filePath) => {
if (filePath === '/test/file1.txt') throw new Error('Permission denied');
return 'test content';
});
const result = await localFileCtr.handleGrepContent({
pattern: 'test',
path: '/test',
});
expect(result.success).toBe(true);
// Should still find match in file2.txt despite file1.txt error
expect(result.matches).toContain('/test/file2.txt');
});
});
});

View File

@@ -193,6 +193,42 @@ describe('ShellCommandCtr', () => {
expect(result.stderr).toBe('error message\n');
});
it('should truncate long output to prevent context explosion', async () => {
let exitCallback: (code: number) => void;
let stdoutCallback: (data: Buffer) => void;
mockChildProcess.on.mockImplementation((event: string, callback: any) => {
if (event === 'exit') {
exitCallback = callback;
setTimeout(() => exitCallback(0), 10);
}
return mockChildProcess;
});
mockChildProcess.stdout.on.mockImplementation((event: string, callback: any) => {
if (event === 'data') {
stdoutCallback = callback;
// Simulate very long output (15k characters)
const longOutput = 'x'.repeat(15_000);
setTimeout(() => stdoutCallback(Buffer.from(longOutput)), 5);
}
return mockChildProcess.stdout;
});
mockChildProcess.stderr.on.mockImplementation(() => mockChildProcess.stderr);
const result = await shellCommandCtr.handleRunCommand({
command: 'command-with-long-output',
description: 'long output command',
});
expect(result.success).toBe(true);
// Output should be truncated to ~10k + truncation message
expect(result.stdout!.length).toBeLessThan(15_000);
expect(result.stdout).toContain('truncated');
expect(result.stdout).toContain('more characters');
});
it('should enforce timeout limits', async () => {
mockChildProcess.on.mockImplementation(() => mockChildProcess);
mockChildProcess.stdout.on.mockImplementation(() => mockChildProcess.stdout);

View File

@@ -1,21 +1,16 @@
import { type EditLocalFileState } from '@lobechat/builtin-tool-local-system';
import { type EditLocalFileParams } from '@lobechat/electron-client-ipc';
import { type BuiltinRenderProps } from '@lobechat/types';
import { Alert, Flexbox, Icon, Skeleton } from '@lobehub/ui';
import { ChevronRight } from 'lucide-react';
import { Alert, Flexbox, Skeleton } from '@lobehub/ui';
import { useTheme } from 'next-themes';
import path from 'path-browserify-esm';
import React, { memo, useMemo } from 'react';
import { Diff, Hunk, parseDiff } from 'react-diff-view';
import 'react-diff-view/style/index.css';
import { LocalFile, LocalFolder } from '@/features/LocalFile';
import '@/styles/react-diff-view.dark.css';
const EditLocalFile = memo<BuiltinRenderProps<EditLocalFileParams, EditLocalFileState>>(
({ args, pluginState, pluginError }) => {
const { base, dir } = path.parse(args.file_path);
// Parse diff for react-diff-view
const files = useMemo(() => {
const diffText = pluginState?.diffText;
@@ -35,11 +30,6 @@ const EditLocalFile = memo<BuiltinRenderProps<EditLocalFileParams, EditLocalFile
return (
<Flexbox gap={12}>
<Flexbox horizontal>
<LocalFolder path={dir} />
<Icon icon={ChevronRight} />
<LocalFile name={base} path={args.file_path} />
</Flexbox>
{pluginError ? (
<Alert
description={pluginError.message || 'Unknown error occurred'}

View File

@@ -3,21 +3,16 @@ import { type ListLocalFileParams } from '@lobechat/electron-client-ipc';
import { type BuiltinRenderProps } from '@lobechat/types';
import React, { memo } from 'react';
import { LocalFolder } from '@/features/LocalFile';
import SearchResult from './Result';
const ListFiles = memo<BuiltinRenderProps<ListLocalFileParams, LocalFileListState>>(
({ messageId, pluginError, args, pluginState }) => {
({ messageId, pluginError, pluginState }) => {
return (
<>
<LocalFolder path={args.path} />
<SearchResult
listResults={pluginState?.listResults}
messageId={messageId}
pluginError={pluginError}
/>
</>
<SearchResult
listResults={pluginState?.listResults}
messageId={messageId}
pluginError={pluginError}
/>
);
},
);

View File

@@ -1,37 +0,0 @@
import { Button, Icon } from '@lobehub/ui';
import { Plus } from 'lucide-react';
import { memo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useActionSWR } from '@/libs/swr';
import { useAgentStore } from '@/store/agent';
const AddButton = memo(() => {
const navigate = useNavigate();
const createAgent = useAgentStore((s) => s.createAgent);
// Use a unique SWR key to avoid conflicts with useCreateMenuItems which uses 'agent.createAgent'
const { mutate, isValidating } = useActionSWR('agent.createAgentFromWelcome', async () => {
const result = await createAgent({});
navigate(`/agent/${result.agentId}/profile`);
return result;
});
return (
<Button
icon={<Icon icon={Plus} size={'small'} />}
loading={isValidating}
onClick={() => mutate()}
style={{
alignItems: 'center',
borderRadius: 4,
height: '20px',
justifyContent: 'center',
padding: '0 1px',
width: '20px',
}}
variant={'filled'}
/>
);
});
export default AddButton;

View File

@@ -3,7 +3,7 @@
import { Avatar, Flexbox, Markdown, Text } from '@lobehub/ui';
import isEqual from 'fast-deep-equal';
import React, { memo, useMemo } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { useTranslation } from 'react-i18next';
import { DEFAULT_AVATAR, DEFAULT_INBOX_AVATAR } from '@/const/meta';
import { useIsMobile } from '@/hooks/useIsMobile';
@@ -12,7 +12,6 @@ import { agentSelectors, builtinAgentSelectors } from '@/store/agent/selectors';
import { useUserStore } from '@/store/user';
import { userGeneralSettingsSelectors } from '@/store/user/selectors';
import AddButton from './AddButton';
import OpeningQuestions from './OpeningQuestions';
import ToolAuthAlert from './ToolAuthAlert';
@@ -57,27 +56,8 @@ const InboxWelcome = memo(() => {
{displayTitle}
</Text>
<Flexbox width={'min(100%, 640px)'}>
<Markdown
customRender={(dom, context) => {
if (context.text.includes('<plus />')) {
return (
<Trans
components={{
br: <br />,
plus: <AddButton />,
}}
i18nKey="guide.defaultMessage"
ns="welcome"
values={{ appName: 'Lobe AI' }}
/>
);
}
return dom;
}}
fontSize={fontSize}
variant={'chat'}
>
{isInbox ? t('guide.defaultMessage', { appName: 'Lobe AI' }) : message}
<Markdown fontSize={fontSize} variant={'chat'}>
{isInbox ? t('guide.defaultMessageWithoutCreate', { appName: 'Lobe AI' }) : message}
</Markdown>
</Flexbox>
{openingQuestions.length > 0 && (

View File

@@ -1,32 +0,0 @@
import { Button } from '@lobehub/ui';
import { memo } from 'react';
import { useActionSWR } from '@/libs/swr';
import { useSessionStore } from '@/store/session';
const AddButton = memo(() => {
const createSession = useSessionStore((s) => s.createSession);
const { mutate, isValidating } = useActionSWR(['session.createSession', undefined], () => {
return createSession({ group: undefined });
});
return (
<Button
loading={isValidating}
onClick={() => mutate()}
style={{
alignItems: 'center',
borderRadius: 4,
height: '20px',
justifyContent: 'center',
padding: '0 1px',
width: '20px',
}}
variant={'filled'}
>
+
</Button>
);
});
export default AddButton;

View File

@@ -3,7 +3,7 @@
import { Flexbox, Markdown, Text } from '@lobehub/ui';
import isEqual from 'fast-deep-equal';
import React, { memo, useMemo } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { useTranslation } from 'react-i18next';
import SupervisorAvatar from '@/app/[variants]/(main)/group/features/GroupAvatar';
import { useIsMobile } from '@/hooks/useIsMobile';
@@ -13,7 +13,6 @@ import { agentGroupSelectors, useAgentGroupStore } from '@/store/agentGroup';
import { useUserStore } from '@/store/user';
import { userGeneralSettingsSelectors } from '@/store/user/selectors';
import AddButton from './AddButton';
import OpeningQuestions from './OpeningQuestions';
import ToolAuthAlert from './ToolAuthAlert';
@@ -68,27 +67,8 @@ const InboxWelcome = memo(() => {
{displayTitle}
</Text>
<Flexbox width={'min(100%, 640px)'}>
<Markdown
customRender={(dom, context) => {
if (context.text.includes('<plus />')) {
return (
<Trans
components={{
br: <br />,
plus: <AddButton />,
}}
i18nKey="guide.defaultMessage"
ns="welcome"
values={{ appName: 'Lobe AI' }}
/>
);
}
return dom;
}}
fontSize={fontSize}
variant={'chat'}
>
{isInbox ? t('guide.defaultMessage', { appName: 'Lobe AI' }) : message}
<Markdown fontSize={fontSize} variant={'chat'}>
{isInbox ? t('guide.defaultMessageWithoutCreate', { appName: 'Lobe AI' }) : message}
</Markdown>
</Flexbox>
{openingQuestions.length > 0 && (

View File

@@ -68,7 +68,12 @@ const GroupMessage = memo<GroupMessageProps>(({ id, index, disableEditing, isLat
// Get editing state from ConversationStore
const editing = useConversationStore(messageStateSelectors.isMessageEditing(contentId || ''));
const creating = useConversationStore(messageStateSelectors.isMessageCreating(id));
const { minHeight } = useNewScreen({ creating, isLatestItem, messageId: id });
const generating = useConversationStore(messageStateSelectors.isMessageGenerating(id));
const { minHeight } = useNewScreen({
creating: creating || generating,
isLatestItem,
messageId: id,
});
const setMessageItemActionElementPortialContext = useSetMessageItemActionElementPortialContext();
const setMessageItemActionTypeContext = useSetMessageItemActionTypeContext();