♻️ refactor: unify tool content formatting with ComputerRuntime and shared UI (#13470)

* ♻️ refactor: unify tool content formatting with ComputerRuntime and shared UI components

Introduce `@lobechat/tool-runtime` with `ComputerRuntime` abstract class to ensure consistent
content formatting (via `formatCommandResult`, `formatFileContent`, etc.) across local-system,
cloud-sandbox, and skills packages. Create `@lobechat/shared-tool-ui` to share Render and
Inspector components, eliminating duplicated UI code across tool packages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* 🐛 fix: address review issues — state mapping for renders and IPC param denormalization

- Add legacy state field mappings in local-system executor (listResults, fileContent,
  searchResults) for backward compatibility with existing render components
- Add denormalizeParams in LocalSystemExecutionRuntime to map ComputerRuntime params
  back to IPC-expected field names (file_path, items, shell_id, etc.)
- Fix i18n type casting for dynamic translation keys in shared-tool-ui inspectors

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* ♻️ refactor: inject render capabilities via context, unify state shape for cross-package render reuse

- Add ToolRenderContext with injectable capabilities (openFile, openFolder,
  isLoading, displayRelativePath) to shared-tool-ui
- Update local-system render components (ReadLocalFile, ListFiles, SearchFiles,
  MoveLocalFiles, FileItem) to use context instead of direct Electron imports
- Enrich ReadFileState with render-compatible fields (filename, fileType,
  charCount, loc, totalCharCount)
- Cloud-sandbox now fully reuses local-system renders — renders degrade
  gracefully when capabilities are not provided (no open file buttons in sandbox)
- Remove executor-level state mapping hacks

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* 🐛 fix: fix sandbox render bugs — SearchFiles, GrepContent, MoveFiles, GlobFiles

- SearchFiles: ensure results is always an array (not object passthrough)
- GrepContent: update formatGrepResults to support object matches
  `{path, content, lineNumber}` alongside string matches
- MoveFiles: render now handles both IPC format (items/oldPath/newPath) and
  ComputerRuntime format (operations/source/destination)
- GlobFiles: fallback totalCount to files.length when API returns 0

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* 🐛 fix: unify SearchLocalFiles inspector with shared factory

SearchLocalFiles inspector now supports all keyword field variants
(keyword, keywords, query) and reads from unified state (results/totalCount).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* 🐛 fix: handle missing path in grep matches to avoid undefined display

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* 🐛 fix: improve render field compatibility for sandbox

- EditLocalFile render: support both file_path (IPC) and path (sandbox) args
- SearchFiles render: support keyword/keywords/query arg variants
- FileItem: derive name from path when not provided

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* 🐛 fix: add missing cloud-sandbox i18n key for noResults

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arvin Xu 2026-04-02 19:42:45 +08:00 committed by GitHub
parent f96edd56fb
commit be99aaebd0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
69 changed files with 2305 additions and 2523 deletions

View file

@ -256,6 +256,7 @@
"@lobechat/openapi": "workspace:*",
"@lobechat/prompts": "workspace:*",
"@lobechat/python-interpreter": "workspace:*",
"@lobechat/shared-tool-ui": "workspace:*",
"@lobechat/ssrf-safe-fetch": "workspace:*",
"@lobechat/utils": "workspace:*",
"@lobechat/web-crawler": "workspace:*",

View file

@ -9,6 +9,11 @@
"./executionRuntime": "./src/ExecutionRuntime/index.ts"
},
"main": "./src/index.ts",
"dependencies": {
"@lobechat/builtin-tool-local-system": "workspace:*",
"@lobechat/shared-tool-ui": "workspace:*",
"@lobechat/tool-runtime": "workspace:*"
},
"devDependencies": {
"@lobechat/types": "workspace:*"
},

View file

@ -1,338 +1,46 @@
import {
formatEditResult,
formatFileContent,
formatFileList,
formatFileSearchResults,
formatGlobResults,
formatMoveResults,
formatRenameResult,
formatWriteResult,
} from '@lobechat/prompts';
import { ComputerRuntime } from '@lobechat/tool-runtime';
import type { BuiltinServerRuntimeOutput } from '@lobechat/types';
import type {
EditLocalFileParams,
EditLocalFileState,
ExecuteCodeParams,
ExecuteCodeState,
ExportFileParams,
ExportFileState,
GetCommandOutputParams,
GetCommandOutputState,
GlobFilesState,
GlobLocalFilesParams,
GrepContentParams,
GrepContentState,
ISandboxService,
KillCommandParams,
KillCommandState,
ListLocalFilesParams,
ListLocalFilesState,
MoveLocalFilesParams,
MoveLocalFilesState,
ReadLocalFileParams,
ReadLocalFileState,
RenameLocalFileParams,
RenameLocalFileState,
RunCommandParams,
RunCommandState,
SearchLocalFilesParams,
SearchLocalFilesState,
WriteLocalFileParams,
WriteLocalFileState,
SandboxCallToolResult,
} from '../types';
/**
* Cloud Sandbox Execution Runtime
*
* This runtime executes tools via the injected ISandboxService.
* The service handles context (topicId, userId) internally - Runtime doesn't need to know about it.
* Extends ComputerRuntime for standard computer operations (files, shell, search).
* Adds cloud-specific capabilities: code execution and file export.
*
* Dependency Injection:
* - Client: Inject codeInterpreterService (uses tRPC client)
* - Server: Inject ServerSandboxService (uses MarketSDK directly)
*/
export class CloudSandboxExecutionRuntime {
export class CloudSandboxExecutionRuntime extends ComputerRuntime {
private sandboxService: ISandboxService;
constructor(sandboxService: ISandboxService) {
super();
this.sandboxService = sandboxService;
}
// ==================== File Operations ====================
async listLocalFiles(args: ListLocalFilesParams): Promise<BuiltinServerRuntimeOutput> {
try {
const result = await this.callTool('listLocalFiles', args);
if (!result.success) {
return {
content: result.error?.message || JSON.stringify(result.error),
state: { files: [] },
success: true,
};
}
const files = result.result?.files || [];
const state: ListLocalFilesState = { files };
const content = formatFileList({
directory: args.directoryPath,
files: files.map((f: { isDirectory: boolean; name: string }) => ({
isDirectory: f.isDirectory,
name: f.name,
})),
});
return {
content,
state,
success: true,
};
} catch (error) {
return this.handleError(error);
}
protected async callService(
toolName: string,
params: Record<string, any>,
): Promise<SandboxCallToolResult> {
return this.sandboxService.callTool(toolName, params);
}
async readLocalFile(args: ReadLocalFileParams): Promise<BuiltinServerRuntimeOutput> {
try {
const result = await this.callTool('readLocalFile', args);
if (!result.success) {
return {
content: result.error?.message || JSON.stringify(result.error),
state: {
content: '',
endLine: args.endLine,
path: args.path,
startLine: args.startLine,
},
success: true,
};
}
const state: ReadLocalFileState = {
content: result.result?.content || '',
endLine: args.endLine,
path: args.path,
startLine: args.startLine,
totalLines: result.result?.totalLines,
};
const lineRange: [number, number] | undefined =
args.startLine !== undefined && args.endLine !== undefined
? [args.startLine, args.endLine]
: undefined;
const content = formatFileContent({
content: result.result?.content || '',
lineRange,
path: args.path,
});
return {
content,
state,
success: true,
};
} catch (error) {
return this.handleError(error);
}
}
async writeLocalFile(args: WriteLocalFileParams): Promise<BuiltinServerRuntimeOutput> {
try {
const result = await this.callTool('writeLocalFile', args);
if (!result.success) {
return {
content: result.error?.message || JSON.stringify(result.error),
state: {
path: args.path,
success: false,
},
success: true,
};
}
const state: WriteLocalFileState = {
bytesWritten: result.result?.bytesWritten,
path: args.path,
success: result.success,
};
const content = formatWriteResult({
path: args.path,
success: true,
});
return {
content,
state,
success: true,
};
} catch (error) {
return this.handleError(error);
}
}
async editLocalFile(args: EditLocalFileParams): Promise<BuiltinServerRuntimeOutput> {
try {
const result = await this.callTool('editLocalFile', args);
if (!result.success) {
return {
content: result.error?.message || JSON.stringify(result.error),
state: {
path: args.path,
replacements: 0,
},
success: true,
};
}
const state: EditLocalFileState = {
diffText: result.result?.diffText,
linesAdded: result.result?.linesAdded,
linesDeleted: result.result?.linesDeleted,
path: args.path,
replacements: result.result?.replacements || 0,
};
const content = formatEditResult({
filePath: args.path,
linesAdded: state.linesAdded,
linesDeleted: state.linesDeleted,
replacements: state.replacements,
});
return {
content,
state,
success: true,
};
} catch (error) {
return this.handleError(error);
}
}
async searchLocalFiles(args: SearchLocalFilesParams): Promise<BuiltinServerRuntimeOutput> {
try {
const result = await this.callTool('searchLocalFiles', args);
if (!result.success) {
return {
content: result.error?.message || JSON.stringify(result.error),
state: {
results: [],
totalCount: 0,
},
success: true,
};
}
const results = result.result?.results || [];
const state: SearchLocalFilesState = {
results,
totalCount: result.result?.totalCount || 0,
};
const content = formatFileSearchResults(
results.map((r: { path: string }) => ({ path: r.path })),
);
return {
content,
state,
success: true,
};
} catch (error) {
return this.handleError(error);
}
}
async moveLocalFiles(args: MoveLocalFilesParams): Promise<BuiltinServerRuntimeOutput> {
try {
const result = await this.callTool('moveLocalFiles', args);
if (!result.success) {
return {
content: result.error?.message || JSON.stringify(result.error),
state: {
results: [],
successCount: 0,
totalCount: args.operations.length,
},
success: true,
};
}
const results = result.result?.results || [];
const state: MoveLocalFilesState = {
results,
successCount: result.result?.successCount || 0,
totalCount: args.operations.length,
};
const content = formatMoveResults(results);
return {
content,
state,
success: true,
};
} catch (error) {
return this.handleError(error);
}
}
async renameLocalFile(args: RenameLocalFileParams): Promise<BuiltinServerRuntimeOutput> {
try {
const result = await this.callTool('renameLocalFile', args);
if (!result.success) {
return {
content: result.error?.message || JSON.stringify(result.error),
state: {
error: result.error?.message,
newPath: '',
oldPath: args.oldPath,
success: false,
},
success: true,
};
}
const state: RenameLocalFileState = {
error: result.result?.error,
newPath: result.result?.newPath || '',
oldPath: args.oldPath,
success: result.success,
};
const content = formatRenameResult({
error: result.result?.error,
newName: args.newName,
oldPath: args.oldPath,
success: result.success,
});
return {
content,
state,
success: true,
};
} catch (error) {
return this.handleError(error);
}
}
// ==================== Code Execution ====================
// ==================== Cloud-Specific: Code Execution ====================
async executeCode(args: ExecuteCodeParams): Promise<BuiltinServerRuntimeOutput> {
try {
const language = args.language || 'python';
const result = await this.callTool('executeCode', {
const result = await this.callService('executeCode', {
code: args.code,
language,
});
@ -360,207 +68,20 @@ export class CloudSandboxExecutionRuntime {
success: true,
};
} catch (error) {
console.log('executeCode error', error);
console.error('executeCode error', error);
return this.handleError(error);
}
}
// ==================== Shell Commands ====================
async runCommand(args: RunCommandParams): Promise<BuiltinServerRuntimeOutput> {
try {
const result = await this.callTool('runCommand', args);
if (!result.success) {
return {
content: result.error?.message || JSON.stringify(result.error),
state: {
error: result.error?.message,
isBackground: args.background || false,
success: false,
},
success: true,
};
}
const state: RunCommandState = {
commandId: result.result?.commandId,
error: result.result?.error,
exitCode: result.result?.exitCode,
isBackground: args.background || false,
output: result.result?.output,
stderr: result.result?.stderr,
success: result.success,
};
return {
content: JSON.stringify(result.result),
state,
success: true,
};
} catch (error) {
return this.handleError(error);
}
}
async getCommandOutput(args: GetCommandOutputParams): Promise<BuiltinServerRuntimeOutput> {
try {
const result = await this.callTool('getCommandOutput', args);
if (!result.success) {
return {
content: result.error?.message || JSON.stringify(result.error),
state: {
error: result.error?.message,
running: false,
success: false,
},
success: true,
};
}
const state: GetCommandOutputState = {
error: result.result?.error,
newOutput: result.result?.newOutput,
running: result.result?.running ?? false,
success: result.success,
};
return {
content: JSON.stringify(result.result),
state,
success: true,
};
} catch (error) {
return this.handleError(error);
}
}
async killCommand(args: KillCommandParams): Promise<BuiltinServerRuntimeOutput> {
try {
const result = await this.callTool('killCommand', args);
if (!result.success) {
return {
content: result.error?.message || JSON.stringify(result.error),
state: {
commandId: args.commandId,
error: result.error?.message,
success: false,
},
success: true,
};
}
const state: KillCommandState = {
commandId: args.commandId,
error: result.result?.error,
success: result.success,
};
return {
content: JSON.stringify({
message: `Successfully killed command: ${args.commandId}`,
success: true,
}),
state,
success: true,
};
} catch (error) {
return this.handleError(error);
}
}
// ==================== Search & Find ====================
async grepContent(args: GrepContentParams): Promise<BuiltinServerRuntimeOutput> {
try {
const result = await this.callTool('grepContent', args);
if (!result.success) {
return {
content: result.error?.message || JSON.stringify(result.error),
state: {
matches: [],
pattern: args.pattern,
totalMatches: 0,
},
success: true,
};
}
const state: GrepContentState = {
matches: result.result?.matches || [],
pattern: args.pattern,
totalMatches: result.result?.totalMatches || 0,
};
return {
content: JSON.stringify(result.result),
state,
success: true,
};
} catch (error) {
return this.handleError(error);
}
}
async globLocalFiles(args: GlobLocalFilesParams): Promise<BuiltinServerRuntimeOutput> {
try {
const result = await this.callTool('globLocalFiles', args);
if (!result.success) {
return {
content: result.error?.message || JSON.stringify(result.error),
state: {
files: [],
pattern: args.pattern,
totalCount: 0,
},
success: true,
};
}
const files = result.result?.files || [];
const totalCount = result.result?.totalCount || 0;
const state: GlobFilesState = {
files,
pattern: args.pattern,
totalCount,
};
const content = formatGlobResults({
files,
totalFiles: totalCount,
});
return {
content,
state,
success: true,
};
} catch (error) {
return this.handleError(error);
}
}
// ==================== Export Operations ====================
// ==================== Cloud-Specific: File Export ====================
/**
* Export a file from the sandbox to cloud storage
* Uses a single call that handles:
* 1. Generate pre-signed upload URL
* 2. Call sandbox to upload file
* 3. Create persistent file record
* 4. Return permanent /f/:id URL
*/
async exportFile(args: ExportFileParams): Promise<BuiltinServerRuntimeOutput> {
try {
// Extract filename from path
const filename = args.path.split('/').pop() || 'exported_file';
// Single call that handles everything: upload URL generation, sandbox upload, and file record creation
const result = await this.sandboxService.exportAndUploadFile(args.path, filename);
const state: ExportFileState = {
@ -594,32 +115,4 @@ export class CloudSandboxExecutionRuntime {
return this.handleError(error);
}
}
// ==================== Helper Methods ====================
/**
* Call a tool via the injected sandbox service
*/
private async callTool(
toolName: string,
params: Record<string, any>,
): Promise<{
error?: { message: string; name?: string };
result: any;
sessionExpiredAndRecreated?: boolean;
success: boolean;
}> {
const result = await this.sandboxService.callTool(toolName, params);
return result;
}
private handleError(error: unknown): BuiltinServerRuntimeOutput {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
content: errorMessage,
error,
success: false,
};
}
}

View file

@ -1,94 +1,7 @@
'use client';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { Icon, Text } from '@lobehub/ui';
import { createStaticStyles, cssVar, cx } from 'antd-style';
import { Minus, Plus } from 'lucide-react';
import type { ReactNode } from 'react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { createEditLocalFileInspector } from '@lobechat/shared-tool-ui/inspectors';
import { inspectorTextStyles, shinyTextStyles } from '@/styles';
import type { EditLocalFileState } from '../../../types';
import { FilePathDisplay } from '../../components/FilePathDisplay';
const styles = createStaticStyles(({ css, cssVar }) => ({
separator: css`
margin-inline: 2px;
color: ${cssVar.colorTextQuaternary};
`,
}));
interface EditLocalFileParams {
file_path: string;
new_string: string;
old_string: string;
}
export const EditLocalFileInspector = memo<
BuiltinInspectorProps<EditLocalFileParams, EditLocalFileState>
>(({ args, partialArgs, isArgumentsStreaming, pluginState, isLoading }) => {
const { t } = useTranslation('plugin');
const filePath = args?.file_path || partialArgs?.file_path || '';
// During argument streaming
if (isArgumentsStreaming) {
if (!filePath)
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-cloud-sandbox.apiName.editLocalFile')}</span>
</div>
);
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-cloud-sandbox.apiName.editLocalFile')}: </span>
<FilePathDisplay filePath={filePath} />
</div>
);
}
// Build stats parts with colors and icons
const linesAdded = pluginState?.linesAdded ?? 0;
const linesDeleted = pluginState?.linesDeleted ?? 0;
const statsParts: ReactNode[] = [];
if (linesAdded > 0) {
statsParts.push(
<Text code as={'span'} color={cssVar.colorSuccess} fontSize={12} key="added">
<Icon icon={Plus} size={12} />
{linesAdded}
</Text>,
);
}
if (linesDeleted > 0) {
statsParts.push(
<Text code as={'span'} color={cssVar.colorError} fontSize={12} key="deleted">
<Icon icon={Minus} size={12} />
{linesDeleted}
</Text>,
);
}
return (
<div className={cx(inspectorTextStyles.root, isLoading && shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-cloud-sandbox.apiName.editLocalFile')}: </span>
<FilePathDisplay filePath={filePath} />
{!isLoading && statsParts.length > 0 && (
<>
{' '}
{statsParts.map((part, index) => (
<span key={index}>
{index > 0 && <span className={styles.separator}> / </span>}
{part}
</span>
))}
</>
)}
</div>
);
});
EditLocalFileInspector.displayName = 'EditLocalFileInspector';
export const EditLocalFileInspector = createEditLocalFileInspector(
'builtins.lobe-cloud-sandbox.apiName.editLocalFile',
);

View file

@ -1,73 +1,7 @@
'use client';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { createStaticStyles, cssVar, cx } from 'antd-style';
import { Check, X } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { createGlobLocalFilesInspector } from '@lobechat/shared-tool-ui/inspectors';
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
import type { GlobFilesState } from '../../../types';
const styles = createStaticStyles(({ css }) => ({
statusIcon: css`
margin-block-end: -2px;
margin-inline-start: 4px;
`,
}));
interface GlobFilesParams {
path?: string;
pattern: string;
}
export const GlobLocalFilesInspector = memo<BuiltinInspectorProps<GlobFilesParams, GlobFilesState>>(
({ args, partialArgs, isArgumentsStreaming, pluginState, isLoading }) => {
const { t } = useTranslation('plugin');
const pattern = args?.pattern || partialArgs?.pattern || '';
// During argument streaming
if (isArgumentsStreaming) {
if (!pattern)
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-cloud-sandbox.apiName.globLocalFiles')}</span>
</div>
);
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-cloud-sandbox.apiName.globLocalFiles')}: </span>
<span className={highlightTextStyles.primary}>{pattern}</span>
</div>
);
}
// Check if glob was successful
const totalCount = pluginState?.totalCount ?? 0;
const hasResults = totalCount > 0;
return (
<div className={cx(inspectorTextStyles.root, isLoading && shinyTextStyles.shinyText)}>
<span style={{ marginInlineStart: 2 }}>
<span>{t('builtins.lobe-cloud-sandbox.apiName.globLocalFiles')}: </span>
{pattern && <span className={highlightTextStyles.primary}>{pattern}</span>}
{isLoading ? null : pluginState ? (
hasResults ? (
<>
<span style={{ marginInlineStart: 4 }}>({totalCount})</span>
<Check className={styles.statusIcon} color={cssVar.colorSuccess} size={14} />
</>
) : (
<X className={styles.statusIcon} color={cssVar.colorError} size={14} />
)
) : null}
</span>
</div>
);
},
export const GlobLocalFilesInspector = createGlobLocalFilesInspector(
'builtins.lobe-cloud-sandbox.apiName.globLocalFiles',
);
GlobLocalFilesInspector.displayName = 'GlobLocalFilesInspector';

View file

@ -1,69 +1,8 @@
'use client';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { Text } from '@lobehub/ui';
import { cssVar, cx } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { createGrepContentInspector } from '@lobechat/shared-tool-ui/inspectors';
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
import type { GrepContentState } from '../../../types';
interface GrepContentParams {
include?: string;
path?: string;
pattern: string;
}
export const GrepContentInspector = memo<
BuiltinInspectorProps<GrepContentParams, GrepContentState>
>(({ args, partialArgs, isArgumentsStreaming, pluginState, isLoading }) => {
const { t } = useTranslation('plugin');
const pattern = args?.pattern || partialArgs?.pattern || '';
// During argument streaming
if (isArgumentsStreaming) {
if (!pattern)
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-cloud-sandbox.apiName.grepContent')}</span>
</div>
);
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-cloud-sandbox.apiName.grepContent')}: </span>
<span className={highlightTextStyles.primary}>{pattern}</span>
</div>
);
}
// Check result count
const resultCount = pluginState?.totalMatches ?? 0;
const hasResults = resultCount > 0;
return (
<div className={cx(inspectorTextStyles.root, isLoading && shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-cloud-sandbox.apiName.grepContent')}: </span>
{pattern && <span className={highlightTextStyles.primary}>{pattern}</span>}
{!isLoading &&
pluginState &&
(hasResults ? (
<span style={{ marginInlineStart: 4 }}>({resultCount})</span>
) : (
<Text
as={'span'}
color={cssVar.colorTextDescription}
fontSize={12}
style={{ marginInlineStart: 4 }}
>
({t('builtins.lobe-local-system.inspector.noResults')})
</Text>
))}
</div>
);
export const GrepContentInspector = createGrepContentInspector({
noResultsKey: 'builtins.lobe-cloud-sandbox.inspector.noResults',
translationKey: 'builtins.lobe-cloud-sandbox.apiName.grepContent',
});
GrepContentInspector.displayName = 'GrepContentInspector';

View file

@ -1,68 +1,7 @@
'use client';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { Text } from '@lobehub/ui';
import { cssVar, cx } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { createListLocalFilesInspector } from '@lobechat/shared-tool-ui/inspectors';
import { inspectorTextStyles, shinyTextStyles } from '@/styles';
import type { ListLocalFilesState } from '../../../types';
import { FilePathDisplay } from '../../components/FilePathDisplay';
interface ListLocalFilesParams {
path: string;
}
export const ListLocalFilesInspector = memo<
BuiltinInspectorProps<ListLocalFilesParams, ListLocalFilesState>
>(({ args, partialArgs, isArgumentsStreaming, pluginState, isLoading }) => {
const { t } = useTranslation('plugin');
const path = args?.path || partialArgs?.path || '';
// During argument streaming
if (isArgumentsStreaming) {
if (!path)
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-cloud-sandbox.apiName.listLocalFiles')}</span>
</div>
);
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-cloud-sandbox.apiName.listLocalFiles')}: </span>
<FilePathDisplay isDirectory filePath={path} />
</div>
);
}
// Show result count if available
const resultCount = pluginState?.files?.length ?? 0;
const hasResults = resultCount > 0;
return (
<div className={cx(inspectorTextStyles.root, isLoading && shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-cloud-sandbox.apiName.listLocalFiles')}: </span>
<FilePathDisplay isDirectory filePath={path} />
{!isLoading &&
pluginState?.files &&
(hasResults ? (
<span style={{ marginInlineStart: 4 }}>({resultCount})</span>
) : (
<Text
as={'span'}
color={cssVar.colorTextDescription}
fontSize={12}
style={{ marginInlineStart: 4 }}
>
({t('builtins.lobe-local-system.inspector.noResults')})
</Text>
))}
</div>
);
});
ListLocalFilesInspector.displayName = 'ListLocalFilesInspector';
export const ListLocalFilesInspector = createListLocalFilesInspector(
'builtins.lobe-cloud-sandbox.apiName.listLocalFiles',
);

View file

@ -1,74 +1,7 @@
'use client';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { createStaticStyles, cx } from 'antd-style';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { createReadLocalFileInspector } from '@lobechat/shared-tool-ui/inspectors';
import { inspectorTextStyles, shinyTextStyles } from '@/styles';
import type { ReadLocalFileState } from '../../../types';
import { FilePathDisplay } from '../../components/FilePathDisplay';
const styles = createStaticStyles(({ css }) => ({
lineRange: css`
flex-shrink: 0;
margin-inline-start: 4px;
opacity: 0.7;
`,
}));
interface ReadLocalFileParams {
end_line?: number;
path: string;
start_line?: number;
}
export const ReadLocalFileInspector = memo<
BuiltinInspectorProps<ReadLocalFileParams, ReadLocalFileState>
>(({ args, partialArgs, isArgumentsStreaming, isLoading }) => {
const { t } = useTranslation('plugin');
const filePath = args?.path || partialArgs?.path || '';
const startLine = args?.start_line || partialArgs?.start_line;
const endLine = args?.end_line || partialArgs?.end_line;
// Format line range display, e.g., "L1-L200"
const lineRangeText = useMemo(() => {
if (startLine === undefined && endLine === undefined) return null;
const start = startLine ?? 1;
const end = endLine;
if (end !== undefined) {
return `L${start}-L${end}`;
}
return `L${start}`;
}, [startLine, endLine]);
// During argument streaming
if (isArgumentsStreaming) {
if (!filePath)
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-cloud-sandbox.apiName.readLocalFile')}</span>
</div>
);
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-cloud-sandbox.apiName.readLocalFile')}: </span>
<FilePathDisplay filePath={filePath} />
{lineRangeText && <span className={styles.lineRange}>{lineRangeText}</span>}
</div>
);
}
return (
<div className={cx(inspectorTextStyles.root, isLoading && shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-cloud-sandbox.apiName.readLocalFile')}: </span>
<FilePathDisplay filePath={filePath} />
{lineRangeText && <span className={styles.lineRange}>{lineRangeText}</span>}
</div>
);
});
ReadLocalFileInspector.displayName = 'ReadLocalFileInspector';
export const ReadLocalFileInspector = createReadLocalFileInspector(
'builtins.lobe-cloud-sandbox.apiName.readLocalFile',
);

View file

@ -1,65 +1,7 @@
'use client';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { createStaticStyles, cssVar, cx } from 'antd-style';
import { Check, X } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { createRunCommandInspector } from '@lobechat/shared-tool-ui/inspectors';
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
import type { RunCommandState } from '../../../types';
const styles = createStaticStyles(({ css }) => ({
statusIcon: css`
margin-block-end: -2px;
margin-inline-start: 4px;
`,
}));
interface RunCommandParams {
background?: boolean;
command: string;
description: string;
timeout?: number;
}
export const RunCommandInspector = memo<BuiltinInspectorProps<RunCommandParams, RunCommandState>>(
({ args, partialArgs, isArgumentsStreaming, pluginState, isLoading }) => {
const { t } = useTranslation('plugin');
const description = args?.description || partialArgs?.description;
if (isArgumentsStreaming) {
if (!description)
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-cloud-sandbox.apiName.runCommand')}</span>
</div>
);
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-cloud-sandbox.apiName.runCommand')}: </span>
<span className={highlightTextStyles.primary}>{description}</span>
</div>
);
}
return (
<div className={cx(inspectorTextStyles.root, isLoading && shinyTextStyles.shinyText)}>
<span style={{ marginInlineStart: 2 }}>
<span>{t('builtins.lobe-cloud-sandbox.apiName.runCommand')}: </span>
{description && <span className={highlightTextStyles.primary}>{description}</span>}
{isLoading ? null : pluginState?.success && pluginState?.exitCode === 0 ? (
<Check className={styles.statusIcon} color={cssVar.colorSuccess} size={14} />
) : (
<X className={styles.statusIcon} color={cssVar.colorError} size={14} />
)}
</span>
</div>
);
},
export const RunCommandInspector = createRunCommandInspector(
'builtins.lobe-cloud-sandbox.apiName.runCommand',
);
RunCommandInspector.displayName = 'RunCommandInspector';

View file

@ -1,70 +1,8 @@
'use client';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { Text } from '@lobehub/ui';
import { cssVar, cx } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { createSearchLocalFilesInspector } from '@lobechat/shared-tool-ui/inspectors';
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
import type { SearchLocalFilesState } from '../../../types';
interface SearchLocalFilesParams {
path?: string;
query: string;
}
export const SearchLocalFilesInspector = memo<
BuiltinInspectorProps<SearchLocalFilesParams, SearchLocalFilesState>
>(({ args, partialArgs, isArgumentsStreaming, pluginState, isLoading }) => {
const { t } = useTranslation('plugin');
const query = args?.query || partialArgs?.query || '';
// During argument streaming
if (isArgumentsStreaming) {
if (!query)
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-cloud-sandbox.apiName.searchLocalFiles')}</span>
</div>
);
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-cloud-sandbox.apiName.searchLocalFiles')}: </span>
<span className={highlightTextStyles.primary}>{query}</span>
</div>
);
}
// Check if search returned results
const resultCount = pluginState?.results?.length ?? 0;
const hasResults = resultCount > 0;
return (
<div className={cx(inspectorTextStyles.root, isLoading && shinyTextStyles.shinyText)}>
<span style={{ marginInlineStart: 2 }}>
<span>{t('builtins.lobe-cloud-sandbox.apiName.searchLocalFiles')}: </span>
{query && <span className={highlightTextStyles.primary}>{query}</span>}
{!isLoading &&
pluginState?.results &&
(hasResults ? (
<span style={{ marginInlineStart: 4 }}>({resultCount})</span>
) : (
<Text
as={'span'}
color={cssVar.colorTextDescription}
fontSize={12}
style={{ marginInlineStart: 4 }}
>
({t('builtins.lobe-local-system.inspector.noResults')})
</Text>
))}
</span>
</div>
);
export const SearchLocalFilesInspector = createSearchLocalFilesInspector({
noResultsKey: 'builtins.lobe-cloud-sandbox.inspector.noResults',
translationKey: 'builtins.lobe-cloud-sandbox.apiName.searchLocalFiles',
});
SearchLocalFilesInspector.displayName = 'SearchLocalFilesInspector';

View file

@ -1,57 +1,7 @@
'use client';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { Icon, Text } from '@lobehub/ui';
import { cssVar, cx } from 'antd-style';
import { Plus } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { createWriteLocalFileInspector } from '@lobechat/shared-tool-ui/inspectors';
import { inspectorTextStyles, shinyTextStyles } from '@/styles';
import type { WriteLocalFileState } from '../../../types';
import { FilePathDisplay } from '../../components/FilePathDisplay';
interface WriteLocalFileParams {
content: string;
path: string;
}
export const WriteLocalFileInspector = memo<
BuiltinInspectorProps<WriteLocalFileParams, WriteLocalFileState>
>(({ args, partialArgs, isArgumentsStreaming }) => {
const { t } = useTranslation('plugin');
const filePath = args?.path || partialArgs?.path || '';
const content = args?.content || partialArgs?.content || '';
// Calculate lines from content
const lines = content ? content.split('\n').length : 0;
// During argument streaming without path
if (isArgumentsStreaming && !filePath) {
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-cloud-sandbox.apiName.writeLocalFile')}</span>
</div>
);
}
return (
<div
className={cx(inspectorTextStyles.root, isArgumentsStreaming && shinyTextStyles.shinyText)}
>
<span>{t('builtins.lobe-cloud-sandbox.apiName.writeLocalFile')}: </span>
<FilePathDisplay filePath={filePath} />
{lines > 0 && (
<Text code as={'span'} color={cssVar.colorSuccess} fontSize={12}>
{' '}
<Icon icon={Plus} size={12} />
{lines}
</Text>
)}
</div>
);
});
WriteLocalFileInspector.displayName = 'WriteLocalFileInspector';
export const WriteLocalFileInspector = createWriteLocalFileInspector(
'builtins.lobe-cloud-sandbox.apiName.writeLocalFile',
);

View file

@ -1,27 +1,26 @@
import { LocalSystemRenders } from '@lobechat/builtin-tool-local-system/client';
import { RunCommandRender } from '@lobechat/shared-tool-ui/renders';
import { CloudSandboxApiName } from '../../types';
import EditLocalFile from './EditLocalFile';
import ExecuteCode from './ExecuteCode';
import ExportFile from './ExportFile';
import ListFiles from './ListFiles';
import MoveLocalFiles from './MoveLocalFiles';
import ReadLocalFile from './ReadLocalFile';
import RunCommand from './RunCommand';
import SearchFiles from './SearchFiles';
import WriteFile from './WriteFile';
/**
* Cloud Sandbox Render Components Registry
*
* Reuses local-system renders for shared file/shell operations.
* Only cloud-specific tools (executeCode, exportFile) have their own renders.
*/
export const CloudSandboxRenders = {
[CloudSandboxApiName.editLocalFile]: EditLocalFile,
[CloudSandboxApiName.editLocalFile]: LocalSystemRenders.editLocalFile,
[CloudSandboxApiName.executeCode]: ExecuteCode,
[CloudSandboxApiName.exportFile]: ExportFile,
[CloudSandboxApiName.listLocalFiles]: ListFiles,
[CloudSandboxApiName.moveLocalFiles]: MoveLocalFiles,
[CloudSandboxApiName.readLocalFile]: ReadLocalFile,
[CloudSandboxApiName.runCommand]: RunCommand,
[CloudSandboxApiName.searchLocalFiles]: SearchFiles,
[CloudSandboxApiName.writeLocalFile]: WriteFile,
[CloudSandboxApiName.listLocalFiles]: LocalSystemRenders.listLocalFiles,
[CloudSandboxApiName.moveLocalFiles]: LocalSystemRenders.moveLocalFiles,
[CloudSandboxApiName.readLocalFile]: LocalSystemRenders.readLocalFile,
[CloudSandboxApiName.runCommand]: RunCommandRender,
[CloudSandboxApiName.searchLocalFiles]: LocalSystemRenders.searchLocalFiles,
[CloudSandboxApiName.writeLocalFile]: LocalSystemRenders.writeLocalFile,
};
// Export API names for use in other modules

View file

@ -90,7 +90,7 @@ class CloudSandboxExecutor extends BaseExecutor<typeof CloudSandboxApiName> {
ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
const runtime = this.getRuntime(ctx);
const result = await runtime.listLocalFiles(params);
const result = await runtime.listFiles(params);
return this.toBuiltinResult(result);
};
@ -99,7 +99,7 @@ class CloudSandboxExecutor extends BaseExecutor<typeof CloudSandboxApiName> {
ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
const runtime = this.getRuntime(ctx);
const result = await runtime.readLocalFile(params);
const result = await runtime.readFile(params);
return this.toBuiltinResult(result);
};
@ -108,7 +108,7 @@ class CloudSandboxExecutor extends BaseExecutor<typeof CloudSandboxApiName> {
ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
const runtime = this.getRuntime(ctx);
const result = await runtime.writeLocalFile(params);
const result = await runtime.writeFile(params);
return this.toBuiltinResult(result);
};
@ -117,7 +117,7 @@ class CloudSandboxExecutor extends BaseExecutor<typeof CloudSandboxApiName> {
ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
const runtime = this.getRuntime(ctx);
const result = await runtime.editLocalFile(params);
const result = await runtime.editFile(params);
return this.toBuiltinResult(result);
};
@ -126,7 +126,7 @@ class CloudSandboxExecutor extends BaseExecutor<typeof CloudSandboxApiName> {
ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
const runtime = this.getRuntime(ctx);
const result = await runtime.searchLocalFiles(params);
const result = await runtime.searchFiles(params);
return this.toBuiltinResult(result);
};
@ -135,7 +135,7 @@ class CloudSandboxExecutor extends BaseExecutor<typeof CloudSandboxApiName> {
ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
const runtime = this.getRuntime(ctx);
const result = await runtime.moveLocalFiles(params);
const result = await runtime.moveFiles(params);
return this.toBuiltinResult(result);
};
@ -144,7 +144,7 @@ class CloudSandboxExecutor extends BaseExecutor<typeof CloudSandboxApiName> {
ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
const runtime = this.getRuntime(ctx);
const result = await runtime.renameLocalFile(params);
const result = await runtime.renameFile(params);
return this.toBuiltinResult(result);
};
@ -204,7 +204,7 @@ class CloudSandboxExecutor extends BaseExecutor<typeof CloudSandboxApiName> {
ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
const runtime = this.getRuntime(ctx);
const result = await runtime.globLocalFiles(params);
const result = await runtime.globFiles(params);
return this.toBuiltinResult(result);
};

View file

@ -1,70 +1,20 @@
// ==================== File Operations ====================
// Re-export shared state types from @lobechat/tool-runtime
export type {
EditFileState as EditLocalFileState,
GetCommandOutputState,
GlobFilesState,
GrepContentState,
KillCommandState,
ListFilesState as ListLocalFilesState,
MoveFilesState as MoveLocalFilesState,
ReadFileState as ReadLocalFileState,
RenameFileState as RenameLocalFileState,
RunCommandState,
SearchFilesState as SearchLocalFilesState,
WriteFileState as WriteLocalFileState,
} from '@lobechat/tool-runtime';
export interface ListLocalFilesState {
files: Array<{
isDirectory: boolean;
name: string;
path: string;
size?: number;
}>;
}
export interface ReadLocalFileState {
content: string;
endLine?: number;
path: string;
startLine?: number;
totalLines?: number;
}
export interface WriteLocalFileState {
bytesWritten?: number;
path: string;
success: boolean;
}
export interface EditLocalFileState {
diffText?: string;
linesAdded?: number;
linesDeleted?: number;
path: string;
replacements: number;
}
export interface SearchLocalFilesState {
results: Array<{
isDirectory: boolean;
modifiedAt?: string;
name: string;
path: string;
size?: number;
}>;
totalCount: number;
}
export interface MoveLocalFilesState {
results: Array<{
destination: string;
error?: string;
source: string;
success: boolean;
}>;
successCount: number;
totalCount: number;
}
export interface RenameLocalFileState {
error?: string;
newPath: string;
oldPath: string;
success: boolean;
}
export interface GlobFilesState {
files: string[];
pattern: string;
totalCount: number;
}
// ==================== Cloud-Specific State ====================
export interface ExportFileState {
/** The download URL for the exported file (permanent /f/:id URL) */
@ -83,18 +33,6 @@ export interface ExportFileState {
success: boolean;
}
export interface GrepContentState {
matches: Array<{
content?: string;
lineNumber?: number;
path: string;
}>;
pattern: string;
totalMatches: number;
}
// ==================== Code Execution ====================
export interface ExecuteCodeState {
/** Error message if execution failed */
error?: string;
@ -110,31 +48,6 @@ export interface ExecuteCodeState {
success: boolean;
}
// ==================== Shell Commands ====================
export interface RunCommandState {
commandId?: string;
error?: string;
exitCode?: number;
isBackground: boolean;
output?: string;
stderr?: string;
success: boolean;
}
export interface GetCommandOutputState {
error?: string;
newOutput?: string;
running: boolean;
success: boolean;
}
export interface KillCommandState {
commandId: string;
error?: string;
success: boolean;
}
// ==================== Session Info ====================
export interface SessionInfo {

View file

@ -5,11 +5,14 @@
"exports": {
".": "./src/index.ts",
"./client": "./src/client/index.ts",
"./executor": "./src/executor/index.ts"
"./executor": "./src/executor/index.ts",
"./executionRuntime": "./src/ExecutionRuntime/index.ts"
},
"main": "./src/index.ts",
"dependencies": {
"@lobechat/electron-client-ipc": "workspace:*"
"@lobechat/electron-client-ipc": "workspace:*",
"@lobechat/shared-tool-ui": "workspace:*",
"@lobechat/tool-runtime": "workspace:*"
},
"devDependencies": {
"@lobechat/types": "workspace:*"

View file

@ -0,0 +1,285 @@
import type { ServiceResult } from '@lobechat/tool-runtime';
import { ComputerRuntime } from '@lobechat/tool-runtime';
import type { BuiltinServerRuntimeOutput } from '@lobechat/types';
/**
* Service interface for local system operations.
* Abstracts the Electron IPC layer so the runtime is testable and decoupled.
*/
export interface ILocalSystemService {
editLocalFile: (params: any) => Promise<any>;
getCommandOutput: (params: any) => Promise<any>;
globFiles: (params: any) => Promise<any>;
grepContent: (params: any) => Promise<any>;
killCommand: (params: any) => Promise<any>;
listLocalFiles: (params: any) => Promise<any>;
moveLocalFiles: (params: any) => Promise<any>;
readLocalFile: (params: any) => Promise<any>;
readLocalFiles: (params: any) => Promise<any>;
renameLocalFile: (params: any) => Promise<any>;
runCommand: (params: any) => Promise<any>;
searchLocalFiles: (params: any) => Promise<any>;
writeFile: (params: any) => Promise<any>;
}
/**
* Maps IPC tool names to localFileService method names.
* IPC service uses different method names than the standard tool names.
*/
const SERVICE_METHOD_MAP: Record<string, keyof ILocalSystemService> = {
editLocalFile: 'editLocalFile',
getCommandOutput: 'getCommandOutput',
globLocalFiles: 'globFiles',
grepContent: 'grepContent',
killCommand: 'killCommand',
listLocalFiles: 'listLocalFiles',
moveLocalFiles: 'moveLocalFiles',
readLocalFile: 'readLocalFile',
renameLocalFile: 'renameLocalFile',
runCommand: 'runCommand',
searchLocalFiles: 'searchLocalFiles',
writeLocalFile: 'writeFile',
};
/**
* Local System Execution Runtime
*
* Extends ComputerRuntime for standard computer operations via Electron IPC.
* Normalizes snake_case IPC results (exit_code, shell_id, total_matches)
* into the camelCase format expected by ComputerRuntime.
*/
export class LocalSystemExecutionRuntime extends ComputerRuntime {
private service: ILocalSystemService;
constructor(service: ILocalSystemService) {
super();
this.service = service;
}
protected async callService(
toolName: string,
params: Record<string, any>,
): Promise<ServiceResult> {
const methodName = SERVICE_METHOD_MAP[toolName];
if (!methodName) {
return { error: { message: `Unknown tool: ${toolName}` }, result: null, success: false };
}
// Map ComputerRuntime params back to IPC-expected shapes
const ipcParams = this.denormalizeParams(toolName, params);
const method = this.service[methodName] as (params: any) => Promise<any>;
const result = await method(ipcParams);
return this.normalizeResult(toolName, result);
}
/**
* Map ComputerRuntime normalized params back to IPC field names.
*/
private denormalizeParams(toolName: string, params: Record<string, any>): any {
switch (toolName) {
case 'editLocalFile': {
return {
file_path: params.path,
new_string: params.replace,
old_string: params.search,
replace_all: params.all,
};
}
case 'listLocalFiles': {
return {
path: params.directoryPath,
sortBy: params.sortBy,
sortOrder: params.sortOrder,
};
}
case 'moveLocalFiles': {
return {
items: params.operations?.map((op: any) => ({
newPath: op.destination,
oldPath: op.source,
})),
};
}
case 'renameLocalFile': {
return {
newName: params.newName,
path: params.oldPath,
};
}
case 'getCommandOutput': {
return { shell_id: params.commandId };
}
case 'killCommand': {
return { shell_id: params.commandId };
}
default: {
return params;
}
}
}
/**
* Batch read multiple files unique to local system.
*/
async readFiles(params: any): Promise<BuiltinServerRuntimeOutput> {
try {
const { formatMultipleFiles } = await import('@lobechat/prompts');
const results = await this.service.readLocalFiles(params);
return {
content: formatMultipleFiles(results),
state: { filesContent: results },
success: true,
};
} catch (error) {
return this.handleError(error);
}
}
/**
* Normalize raw IPC results into the ServiceResult format.
* IPC methods return domain objects directly; we wrap them appropriately.
*/
private normalizeResult(toolName: string, raw: any): ServiceResult {
switch (toolName) {
case 'runCommand': {
// RunCommandResult has snake_case fields from local-file-shell
return {
result: {
error: raw.error,
exitCode: raw.exit_code,
output: raw.output,
commandId: raw.shell_id,
stderr: raw.stderr,
stdout: raw.stdout,
success: raw.success,
},
success: raw.success,
};
}
case 'getCommandOutput': {
return {
result: {
error: raw.error,
newOutput: raw.output,
running: raw.running,
success: raw.success,
},
success: raw.success,
};
}
case 'killCommand': {
return {
result: { error: raw.error, success: raw.success },
success: raw.success,
};
}
case 'grepContent': {
return {
result: {
matches: raw.matches,
totalMatches: raw.total_matches,
},
success: raw.success,
};
}
case 'globLocalFiles': {
return {
result: {
files: raw.files,
totalCount: raw.total_files,
},
success: raw.success,
};
}
case 'listLocalFiles': {
return {
result: { files: raw.files, totalCount: raw.totalCount },
success: true,
};
}
case 'readLocalFile': {
// Pass through all IPC fields for render compatibility
return {
result: {
charCount: raw.charCount,
content: raw.content,
fileType: raw.fileType,
filename: raw.filename,
loc: raw.loc,
totalCharCount: raw.totalCharCount,
totalLineCount: raw.totalLineCount,
},
success: true,
};
}
case 'writeLocalFile': {
return {
result: { bytesWritten: raw.bytesWritten, success: raw.success },
success: raw.success ?? true,
};
}
case 'editLocalFile': {
return {
result: {
diffText: raw.diffText,
error: raw.error,
linesAdded: raw.linesAdded,
linesDeleted: raw.linesDeleted,
replacements: raw.replacements,
},
success: raw.success,
};
}
case 'searchLocalFiles': {
// Returns LocalFileItem[] directly
const results = Array.isArray(raw) ? raw : [];
return {
result: { results, totalCount: results.length },
success: true,
};
}
case 'moveLocalFiles': {
// Returns LocalMoveFilesResultItem[] directly
const results = Array.isArray(raw) ? raw : [];
return {
result: {
results,
successCount: results.filter((r: any) => r.success).length,
},
success: true,
};
}
case 'renameLocalFile': {
return {
result: { error: raw.error, newPath: raw.newPath, success: raw.success },
success: raw.success,
};
}
default: {
// Generic passthrough
return { result: raw, success: true };
}
}
}
}

View file

@ -1,89 +1,7 @@
'use client';
import type { EditLocalFileParams } from '@lobechat/electron-client-ipc';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { Icon, Text } from '@lobehub/ui';
import { createStaticStyles, cssVar, cx } from 'antd-style';
import { Minus, Plus } from 'lucide-react';
import type { ReactNode } from 'react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { createEditLocalFileInspector } from '@lobechat/shared-tool-ui/inspectors';
import { inspectorTextStyles, shinyTextStyles } from '@/styles';
import type { EditLocalFileState } from '../../../types';
import { FilePathDisplay } from '../../components/FilePathDisplay';
const styles = createStaticStyles(({ css, cssVar }) => ({
separator: css`
margin-inline: 2px;
color: ${cssVar.colorTextQuaternary};
`,
}));
export const EditLocalFileInspector = memo<
BuiltinInspectorProps<EditLocalFileParams, EditLocalFileState>
>(({ args, partialArgs, isArgumentsStreaming, pluginState, isLoading }) => {
const { t } = useTranslation('plugin');
const filePath = args?.file_path || partialArgs?.file_path || '';
// During argument streaming
if (isArgumentsStreaming) {
if (!filePath)
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-local-system.apiName.editLocalFile')}</span>
</div>
);
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-local-system.apiName.editLocalFile')}: </span>
<FilePathDisplay filePath={filePath} />
</div>
);
}
// Build stats parts with colors and icons
const linesAdded = pluginState?.linesAdded ?? 0;
const linesDeleted = pluginState?.linesDeleted ?? 0;
const statsParts: ReactNode[] = [];
if (linesAdded > 0) {
statsParts.push(
<Text code as={'span'} color={cssVar.colorSuccess} fontSize={12} key="added">
<Icon icon={Plus} size={12} />
{linesAdded}
</Text>,
);
}
if (linesDeleted > 0) {
statsParts.push(
<Text code as={'span'} color={cssVar.colorError} fontSize={12} key="deleted">
<Icon icon={Minus} size={12} />
{linesDeleted}
</Text>,
);
}
return (
<div className={cx(inspectorTextStyles.root, isLoading && shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-local-system.apiName.editLocalFile')}: </span>
<FilePathDisplay filePath={filePath} />
{!isLoading && statsParts.length > 0 && (
<>
{' '}
{statsParts.map((part, index) => (
<span key={index}>
{index > 0 && <span className={styles.separator}> / </span>}
{part}
</span>
))}
</>
)}
</div>
);
});
EditLocalFileInspector.displayName = 'EditLocalFileInspector';
export const EditLocalFileInspector = createEditLocalFileInspector(
'builtins.lobe-local-system.apiName.editLocalFile',
);

View file

@ -1,77 +1,7 @@
'use client';
import type { GlobFilesParams } from '@lobechat/electron-client-ipc';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { Text } from '@lobehub/ui';
import { createStaticStyles, cssVar, cx } from 'antd-style';
import { Check, X } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { createGlobLocalFilesInspector } from '@lobechat/shared-tool-ui/inspectors';
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
import type { GlobFilesState } from '../../..';
const styles = createStaticStyles(({ css }) => ({
statusIcon: css`
margin-block-end: -2px;
margin-inline-start: 4px;
`,
}));
export const GlobLocalFilesInspector = memo<BuiltinInspectorProps<GlobFilesParams, GlobFilesState>>(
({ args, partialArgs, isArgumentsStreaming, pluginState, isLoading }) => {
const { t } = useTranslation('plugin');
const pattern = args?.pattern || partialArgs?.pattern || '';
// During argument streaming
if (isArgumentsStreaming) {
if (!pattern)
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-local-system.apiName.globLocalFiles')}</span>
</div>
);
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-local-system.apiName.globLocalFiles')}: </span>
<span className={highlightTextStyles.primary}>{pattern}</span>
</div>
);
}
// Check if glob was successful
const isSuccess = pluginState?.result?.success;
const engine = pluginState?.result?.engine;
return (
<div className={cx(inspectorTextStyles.root, isLoading && shinyTextStyles.shinyText)}>
<span style={{ marginInlineStart: 2 }}>
<span>{t('builtins.lobe-local-system.apiName.globLocalFiles')}: </span>
{pattern && <span className={highlightTextStyles.primary}>{pattern}</span>}
{isLoading ? null : pluginState?.result ? (
isSuccess ? (
<Check className={styles.statusIcon} color={cssVar.colorSuccess} size={14} />
) : (
<X className={styles.statusIcon} color={cssVar.colorError} size={14} />
)
) : null}
{!isLoading && engine && (
<Text
as={'span'}
color={cssVar.colorTextDescription}
fontSize={12}
style={{ marginInlineStart: 4 }}
>
[{engine}]
</Text>
)}
</span>
</div>
);
},
export const GlobLocalFilesInspector = createGlobLocalFilesInspector(
'builtins.lobe-local-system.apiName.globLocalFiles',
);
GlobLocalFilesInspector.displayName = 'GlobLocalFilesInspector';

View file

@ -1,75 +1,8 @@
'use client';
import type { GrepContentParams } from '@lobechat/electron-client-ipc';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { Text } from '@lobehub/ui';
import { cssVar, cx } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { createGrepContentInspector } from '@lobechat/shared-tool-ui/inspectors';
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
import type { GrepContentState } from '../../..';
export const GrepContentInspector = memo<
BuiltinInspectorProps<GrepContentParams, GrepContentState>
>(({ args, partialArgs, isArgumentsStreaming, pluginState, isLoading }) => {
const { t } = useTranslation('plugin');
const pattern = args?.pattern || partialArgs?.pattern || '';
// During argument streaming
if (isArgumentsStreaming) {
if (!pattern)
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-local-system.apiName.grepContent')}</span>
</div>
);
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-local-system.apiName.grepContent')}: </span>
<span className={highlightTextStyles.primary}>{pattern}</span>
</div>
);
}
// Check result count
const resultCount = pluginState?.result?.total_matches ?? 0;
const hasResults = resultCount > 0;
const engine = pluginState?.result?.engine;
return (
<div className={cx(inspectorTextStyles.root, isLoading && shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-local-system.apiName.grepContent')}: </span>
{pattern && <span className={highlightTextStyles.primary}>{pattern}</span>}
{!isLoading &&
pluginState?.result &&
(hasResults ? (
<span style={{ marginInlineStart: 4 }}>({resultCount})</span>
) : (
<Text
as={'span'}
color={cssVar.colorTextDescription}
fontSize={12}
style={{ marginInlineStart: 4 }}
>
({t('builtins.lobe-local-system.inspector.noResults')})
</Text>
))}
{!isLoading && engine && (
<Text
as={'span'}
color={cssVar.colorTextDescription}
fontSize={12}
style={{ marginInlineStart: 4 }}
>
[{engine}]
</Text>
)}
</div>
);
export const GrepContentInspector = createGrepContentInspector({
noResultsKey: 'builtins.lobe-local-system.inspector.noResults',
translationKey: 'builtins.lobe-local-system.apiName.grepContent',
});
GrepContentInspector.displayName = 'GrepContentInspector';

View file

@ -1,67 +1,7 @@
'use client';
import type { ListLocalFileParams } from '@lobechat/electron-client-ipc';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { Flexbox, Text } from '@lobehub/ui';
import { cssVar, cx } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { createListLocalFilesInspector } from '@lobechat/shared-tool-ui/inspectors';
import { inspectorTextStyles, shinyTextStyles } from '@/styles';
import type { LocalFileListState } from '../../..';
import { FilePathDisplay } from '../../components/FilePathDisplay';
export const ListLocalFilesInspector = memo<
BuiltinInspectorProps<ListLocalFileParams, LocalFileListState>
>(({ args, partialArgs, isArgumentsStreaming, pluginState, isLoading }) => {
const { t } = useTranslation('plugin');
const path = args?.path || partialArgs?.path || '';
// During argument streaming
if (isArgumentsStreaming) {
if (!path)
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-local-system.apiName.listLocalFiles')}</span>
</div>
);
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-local-system.apiName.listLocalFiles')}: </span>
<FilePathDisplay isDirectory filePath={path} />
</div>
);
}
// Show result count if available
const resultCount = pluginState?.listResults?.length ?? 0;
const hasResults = resultCount > 0;
return (
<div className={cx(inspectorTextStyles.root, isLoading && shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-local-system.apiName.listLocalFiles')}: </span>
<Flexbox allowShrink horizontal align={'center'} justify={'center'}>
<FilePathDisplay isDirectory filePath={path} />
</Flexbox>
{!isLoading &&
pluginState?.listResults &&
(hasResults ? (
<span style={{ marginInlineStart: 4 }}>({resultCount})</span>
) : (
<Text
as={'span'}
color={cssVar.colorTextDescription}
fontSize={12}
style={{ marginInlineStart: 4 }}
>
({t('builtins.lobe-local-system.inspector.noResults')})
</Text>
))}
</div>
);
});
ListLocalFilesInspector.displayName = 'ListLocalFilesInspector';
export const ListLocalFilesInspector = createListLocalFilesInspector(
'builtins.lobe-local-system.apiName.listLocalFiles',
);

View file

@ -1,65 +1,7 @@
'use client';
import type { LocalReadFileParams } from '@lobechat/electron-client-ipc';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { createStaticStyles, cx } from 'antd-style';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { createReadLocalFileInspector } from '@lobechat/shared-tool-ui/inspectors';
import { inspectorTextStyles, shinyTextStyles } from '@/styles';
import type { LocalReadFileState } from '../../..';
import { FilePathDisplay } from '../../components/FilePathDisplay';
const styles = createStaticStyles(({ css }) => ({
lineRange: css`
flex-shrink: 0;
margin-inline-start: 4px;
font-size: 12px;
opacity: 0.7;
`,
}));
export const ReadLocalFileInspector = memo<
BuiltinInspectorProps<LocalReadFileParams, LocalReadFileState>
>(({ args, partialArgs, isArgumentsStreaming, isLoading }) => {
const { t } = useTranslation('plugin');
const filePath = args?.path || partialArgs?.path || '';
const loc = args?.loc || partialArgs?.loc;
// Format line range display, e.g., "L1-L200"
const lineRangeText = useMemo(() => {
if (!loc || loc.length !== 2) return null;
const [start, end] = loc;
return `L${start + 1}-L${end}`;
}, [loc]);
// During argument streaming
if (isArgumentsStreaming) {
if (!filePath)
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-local-system.apiName.readLocalFile')}</span>
</div>
);
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-local-system.apiName.readLocalFile')}: </span>
<FilePathDisplay filePath={filePath} />
{lineRangeText && <span className={styles.lineRange}>{lineRangeText}</span>}
</div>
);
}
return (
<div className={cx(inspectorTextStyles.root, isLoading && shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-local-system.apiName.readLocalFile')}: </span>
<FilePathDisplay filePath={filePath} />
{lineRangeText && <span className={styles.lineRange}>{lineRangeText}</span>}
</div>
);
});
ReadLocalFileInspector.displayName = 'ReadLocalFileInspector';
export const ReadLocalFileInspector = createReadLocalFileInspector(
'builtins.lobe-local-system.apiName.readLocalFile',
);

View file

@ -1,72 +1,7 @@
'use client';
import type { RunCommandParams, RunCommandResult } from '@lobechat/electron-client-ipc';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { createStaticStyles, cssVar, cx } from 'antd-style';
import { Check, X } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { createRunCommandInspector } from '@lobechat/shared-tool-ui/inspectors';
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
const styles = createStaticStyles(({ css }) => ({
statusIcon: css`
margin-block-end: -2px;
margin-inline-start: 4px;
`,
}));
interface RunCommandState {
message: string;
result: RunCommandResult;
}
export const RunCommandInspector = memo<BuiltinInspectorProps<RunCommandParams, RunCommandState>>(
({ args, partialArgs, isArgumentsStreaming, pluginState, isLoading }) => {
const { t } = useTranslation('plugin');
// Show description if available, otherwise show command
const description = args?.description || partialArgs?.description || args?.command || '';
// During argument streaming
if (isArgumentsStreaming) {
if (!description)
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-local-system.apiName.runCommand')}</span>
</div>
);
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-local-system.apiName.runCommand')}: </span>
<span className={highlightTextStyles.primary}>{description}</span>
</div>
);
}
// Get execution result from pluginState
const result = pluginState?.result;
const isSuccess = result?.success || result?.exit_code === 0;
return (
<div className={cx(inspectorTextStyles.root, isLoading && shinyTextStyles.shinyText)}>
<span style={{ marginInlineStart: 2 }}>
<span>{t('builtins.lobe-local-system.apiName.runCommand')}: </span>
{description && <span className={highlightTextStyles.primary}>{description}</span>}
{isLoading ? null : result?.success !== undefined ? (
isSuccess ? (
<Check className={styles.statusIcon} color={cssVar.colorSuccess} size={14} />
) : (
<X className={styles.statusIcon} color={cssVar.colorError} size={14} />
)
) : null}
</span>
</div>
);
},
export const RunCommandInspector = createRunCommandInspector(
'builtins.lobe-local-system.apiName.runCommand',
);
RunCommandInspector.displayName = 'RunCommandInspector';
export default RunCommandInspector;

View file

@ -1,77 +1,8 @@
'use client';
import type { LocalSearchFilesParams } from '@lobechat/electron-client-ipc';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { Text } from '@lobehub/ui';
import { cssVar, cx } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { createSearchLocalFilesInspector } from '@lobechat/shared-tool-ui/inspectors';
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
import type { LocalFileSearchState } from '../../..';
export const SearchLocalFilesInspector = memo<
BuiltinInspectorProps<LocalSearchFilesParams, LocalFileSearchState>
>(({ args, partialArgs, isArgumentsStreaming, pluginState, isLoading }) => {
const { t } = useTranslation('plugin');
const keywords = args?.keywords || partialArgs?.keywords || '';
// During argument streaming
if (isArgumentsStreaming) {
if (!keywords)
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-local-system.apiName.searchLocalFiles')}</span>
</div>
);
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-local-system.apiName.searchLocalFiles')}: </span>
<span className={highlightTextStyles.primary}>{keywords}</span>
</div>
);
}
// Check if search returned results
const resultCount = pluginState?.searchResults?.length ?? 0;
const hasResults = resultCount > 0;
const engine = pluginState?.engine;
return (
<div className={cx(inspectorTextStyles.root, isLoading && shinyTextStyles.shinyText)}>
<span style={{ marginInlineStart: 2 }}>
<span>{t('builtins.lobe-local-system.apiName.searchLocalFiles')}: </span>
{keywords && <span className={highlightTextStyles.primary}>{keywords}</span>}
{!isLoading &&
pluginState?.searchResults &&
(hasResults ? (
<span style={{ marginInlineStart: 4 }}>({resultCount})</span>
) : (
<Text
as={'span'}
color={cssVar.colorTextDescription}
fontSize={12}
style={{ marginInlineStart: 4 }}
>
({t('builtins.lobe-local-system.inspector.noResults')})
</Text>
))}
{!isLoading && engine && (
<Text
as={'span'}
color={cssVar.colorTextDescription}
fontSize={12}
style={{ marginInlineStart: 4 }}
>
[{engine}]
</Text>
)}
</span>
</div>
);
export const SearchLocalFilesInspector = createSearchLocalFilesInspector({
noResultsKey: 'builtins.lobe-local-system.inspector.noResults',
translationKey: 'builtins.lobe-local-system.apiName.searchLocalFiles',
});
SearchLocalFilesInspector.displayName = 'SearchLocalFilesInspector';

View file

@ -1,52 +1,7 @@
'use client';
import type { WriteLocalFileParams } from '@lobechat/electron-client-ipc';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { Icon, Text } from '@lobehub/ui';
import { cssVar, cx } from 'antd-style';
import { Plus } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { createWriteLocalFileInspector } from '@lobechat/shared-tool-ui/inspectors';
import { inspectorTextStyles, shinyTextStyles } from '@/styles';
import { FilePathDisplay } from '../../components/FilePathDisplay';
export const WriteLocalFileInspector = memo<BuiltinInspectorProps<WriteLocalFileParams>>(
({ args, partialArgs, isArgumentsStreaming }) => {
const { t } = useTranslation('plugin');
const filePath = args?.path || partialArgs?.path || '';
const content = args?.content || partialArgs?.content || '';
// Calculate lines from content
const lines = content ? content.split('\n').length : 0;
// During argument streaming without path
if (isArgumentsStreaming && !filePath) {
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-local-system.apiName.writeLocalFile')}</span>
</div>
);
}
return (
<div
className={cx(inspectorTextStyles.root, isArgumentsStreaming && shinyTextStyles.shinyText)}
>
<span>{t('builtins.lobe-local-system.apiName.writeLocalFile')}: </span>
<FilePathDisplay filePath={filePath} />
{lines > 0 && (
<Text code as={'span'} color={cssVar.colorSuccess} fontSize={12}>
{' '}
<Icon icon={Plus} size={12} />
{lines}
</Text>
)}
</div>
);
},
export const WriteLocalFileInspector = createWriteLocalFileInspector(
'builtins.lobe-local-system.apiName.writeLocalFile',
);
WriteLocalFileInspector.displayName = 'WriteLocalFileInspector';

View file

@ -1,13 +1,15 @@
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, PatchDiff, Skeleton } from '@lobehub/ui';
import React, { memo } from 'react';
const EditLocalFile = memo<BuiltinRenderProps<EditLocalFileParams, EditLocalFileState>>(
const EditLocalFile = memo<BuiltinRenderProps<any, EditLocalFileState>>(
({ args, pluginState, pluginError }) => {
if (!args) return <Skeleton active />;
// Support both IPC format (file_path) and ComputerRuntime format (path)
const filePath = args.file_path || args.path || '';
return (
<Flexbox gap={12}>
{pluginError ? (
@ -19,7 +21,7 @@ const EditLocalFile = memo<BuiltinRenderProps<EditLocalFileParams, EditLocalFile
/>
) : pluginState?.diffText ? (
<PatchDiff
fileName={args.file_path}
fileName={filePath}
patch={pluginState.diffText}
showHeader={false}
variant="borderless"

View file

@ -1,21 +1,19 @@
import type { LocalFileItem } from '@lobechat/electron-client-ipc';
import { useToolRenderCapabilities } from '@lobechat/shared-tool-ui';
import type { ChatMessagePluginError } from '@lobechat/types';
import { Flexbox, Skeleton } from '@lobehub/ui';
import { memo } from 'react';
import { useChatStore } from '@/store/chat';
import { chatToolSelectors } from '@/store/chat/selectors';
import FileItem from '../../components/FileItem';
interface SearchFilesProps {
listResults?: LocalFileItem[];
listResults?: Array<{ isDirectory: boolean; name: string; path?: string; size?: number }>;
messageId: string;
pluginError: ChatMessagePluginError;
}
const SearchFiles = memo<SearchFilesProps>(({ listResults = [], messageId }) => {
const loading = useChatStore(chatToolSelectors.isSearchingLocalFiles(messageId));
const { isLoading } = useToolRenderCapabilities();
const loading = isLoading?.(messageId);
if (loading) {
return (
@ -31,7 +29,7 @@ const SearchFiles = memo<SearchFilesProps>(({ listResults = [], messageId }) =>
return (
<Flexbox gap={2} style={{ maxHeight: 140, overflow: 'scroll' }}>
{listResults.map((item) => (
<FileItem key={item.path} {...item} showTime />
<FileItem key={item.path || item.name} {...item} showTime />
))}
</Flexbox>
);

View file

@ -1,15 +1,14 @@
import type { LocalFileListState } from '@lobechat/builtin-tool-local-system';
import type { ListLocalFileParams } from '@lobechat/electron-client-ipc';
import type { ListFilesState } from '@lobechat/tool-runtime';
import type { BuiltinRenderProps } from '@lobechat/types';
import React, { memo } from 'react';
import { memo } from 'react';
import SearchResult from './Result';
const ListFiles = memo<BuiltinRenderProps<ListLocalFileParams, LocalFileListState>>(
const ListFiles = memo<BuiltinRenderProps<any, ListFilesState>>(
({ messageId, pluginError, pluginState }) => {
return (
<SearchResult
listResults={pluginState?.listResults}
listResults={pluginState?.files}
messageId={messageId}
pluginError={pluginError}
/>

View file

@ -1,11 +1,9 @@
import { useToolRenderCapabilities } from '@lobechat/shared-tool-ui';
import { Flexbox, Icon, Text } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { ArrowRight } from 'lucide-react';
import { memo } from 'react';
import { useElectronStore } from '@/store/electron';
import { desktopStateSelectors } from '@/store/electron/selectors';
const styles = createStaticStyles(({ css, cssVar }) => ({
icon: css`
color: ${cssVar.colorTextQuaternary};
@ -33,8 +31,9 @@ interface MoveFileItemProps {
}
const MoveFileItem = memo<MoveFileItemProps>(({ oldPath, newPath }) => {
const displayOldPath = useElectronStore(desktopStateSelectors.displayRelativePath(oldPath));
const displayNewPath = useElectronStore(desktopStateSelectors.displayRelativePath(newPath));
const { displayRelativePath } = useToolRenderCapabilities();
const displayOldPath = displayRelativePath ? displayRelativePath(oldPath) : oldPath;
const displayNewPath = displayRelativePath ? displayRelativePath(newPath) : newPath;
return (
<Flexbox horizontal align="center" className={styles.item} gap={8} width="100%">

View file

@ -1,4 +1,3 @@
import type { MoveLocalFilesParams } from '@lobechat/electron-client-ipc';
import type { BuiltinRenderProps } from '@lobechat/types';
import { Flexbox, Text } from '@lobehub/ui';
import { memo } from 'react';
@ -6,15 +5,27 @@ import { useTranslation } from 'react-i18next';
import MoveFileItem from './MoveFileItem';
const MoveLocalFiles = memo<BuiltinRenderProps<MoveLocalFilesParams>>(({ args }) => {
const { items } = args;
interface MoveFilesArgs {
items?: Array<{ newPath: string; oldPath: string }>;
operations?: Array<{ destination: string; source: string }>;
}
const MoveLocalFiles = memo<BuiltinRenderProps<MoveFilesArgs>>(({ args }) => {
const { t } = useTranslation('tool');
// Support both IPC format (items) and ComputerRuntime format (operations)
const moveItems = (args.items || args.operations || []).map((item: any) => ({
newPath: item.newPath || item.destination || '',
oldPath: item.oldPath || item.source || '',
}));
return (
<Flexbox gap={8}>
<Text type="secondary">{t('localFiles.moveFiles.itemsMoved', { count: items.length })}</Text>
<Text type="secondary">
{t('localFiles.moveFiles.itemsMoved', { count: moveItems.length })}
</Text>
<Flexbox gap={6}>
{items.map((item, index) => (
{moveItems.map((item, index) => (
<MoveFileItem key={index} newPath={item.newPath} oldPath={item.oldPath} />
))}
</Flexbox>

View file

@ -1,4 +1,5 @@
import type { LocalReadFileResult } from '@lobechat/electron-client-ipc';
import { useToolRenderCapabilities } from '@lobechat/shared-tool-ui';
import type { ReadFileState } from '@lobechat/tool-runtime';
import { ActionIcon, Flexbox, Icon, Markdown, Text } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { AlignLeft, Asterisk, ExternalLink, FolderOpen } from 'lucide-react';
@ -6,9 +7,6 @@ import React, { memo } from 'react';
import { useTranslation } from 'react-i18next';
import FileIcon from '@/components/FileIcon';
import { localFileService } from '@/services/electron/localFileService';
import { useElectronStore } from '@/store/electron';
import { desktopStateSelectors } from '@/store/electron/selectors';
const styles = createStaticStyles(({ css, cssVar }) => ({
actions: css`
@ -88,26 +86,36 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
`,
}));
// Assuming the result object might include the original path and an optional warning
interface ReadFileViewProps extends LocalReadFileResult {
path: string; // The full path requested
}
const ReadFileView = memo<ReadFileViewProps>(
({ filename, path, fileType, charCount, content, totalLineCount, totalCharCount, loc }) => {
const ReadFileView = memo<ReadFileState>(
({
filename: filenameProp,
path,
fileType,
charCount,
content,
totalLines,
totalCharCount,
loc,
}) => {
const { t } = useTranslation('tool');
const { openFile, openFolder, displayRelativePath } = useToolRenderCapabilities();
const filename = filenameProp || path.split('/').pop() || path;
const handleOpenFile = (e: React.MouseEvent) => {
e.stopPropagation();
localFileService.openLocalFile({ path });
};
const handleOpenFile = openFile
? (e: React.MouseEvent) => {
e.stopPropagation();
openFile(path);
}
: undefined;
const handleOpenFolder = (e: React.MouseEvent) => {
e.stopPropagation();
localFileService.openLocalFolder({ isDirectory: false, path });
};
const handleOpenFolder = openFolder
? (e: React.MouseEvent) => {
e.stopPropagation();
openFolder(path);
}
: undefined;
const displayPath = useElectronStore(desktopStateSelectors.displayRelativePath(path));
const displayPath = displayRelativePath ? displayRelativePath(path) : path;
return (
<Flexbox className={styles.container} gap={12}>
@ -125,41 +133,60 @@ const ReadFileView = memo<ReadFileViewProps>(
<Text ellipsis className={styles.fileName}>
{filename}
</Text>
{/* Actions on Hover */}
<Flexbox horizontal className={styles.actions} gap={2} style={{ marginLeft: 8 }}>
<ActionIcon
icon={ExternalLink}
size="small"
title={t('localFiles.openFile')}
onClick={handleOpenFile}
/>
<ActionIcon
icon={FolderOpen}
size="small"
title={t('localFiles.openFolder')}
onClick={handleOpenFolder}
/>
</Flexbox>
{(handleOpenFile || handleOpenFolder) && (
<Flexbox horizontal className={styles.actions} gap={2} style={{ marginLeft: 8 }}>
{handleOpenFile && (
<ActionIcon
icon={ExternalLink}
size="small"
title={t('localFiles.openFile')}
onClick={handleOpenFile}
/>
)}
{handleOpenFolder && (
<ActionIcon
icon={FolderOpen}
size="small"
title={t('localFiles.openFolder')}
onClick={handleOpenFolder}
/>
)}
</Flexbox>
)}
</Flexbox>
</Flexbox>
<Flexbox horizontal align={'center'} className={styles.meta} gap={16}>
<Flexbox horizontal align={'center'} gap={4}>
<Icon icon={Asterisk} size={'small'} />
<span>
{charCount} / <span className={styles.lineCount}>{totalCharCount}</span>
</span>
</Flexbox>
<Flexbox horizontal align={'center'} gap={4}>
<Icon icon={AlignLeft} size={'small'} />
<span>
L{loc?.[0]}-{loc?.[1]} /{' '}
<span className={styles.lineCount}>{totalLineCount}</span>
</span>
</Flexbox>
{charCount !== undefined && (
<Flexbox horizontal align={'center'} gap={4}>
<Icon icon={Asterisk} size={'small'} />
<span>
{charCount}
{totalCharCount !== undefined && (
<>
{' '}
/ <span className={styles.lineCount}>{totalCharCount}</span>
</>
)}
</span>
</Flexbox>
)}
{loc && (
<Flexbox horizontal align={'center'} gap={4}>
<Icon icon={AlignLeft} size={'small'} />
<span>
L{loc[0]}-{loc[1]}
{totalLines !== undefined && (
<>
{' '}
/ <span className={styles.lineCount}>{totalLines}</span>
</>
)}
</span>
</Flexbox>
)}
</Flexbox>
</Flexbox>
{/* Path */}
<Text ellipsis className={styles.path} type={'secondary'}>
{displayPath}
</Text>

View file

@ -1,31 +1,24 @@
import type { LocalReadFileState } from '@lobechat/builtin-tool-local-system';
import type { LocalReadFileParams } from '@lobechat/electron-client-ipc';
import type { ChatMessagePluginError } from '@lobechat/types';
import { useToolRenderCapabilities } from '@lobechat/shared-tool-ui';
import type { ReadFileState } from '@lobechat/tool-runtime';
import type { BuiltinRenderProps } from '@lobechat/types';
import { memo } from 'react';
import { useChatStore } from '@/store/chat';
import { chatToolSelectors } from '@/store/chat/slices/builtinTool/selectors';
import ReadFileSkeleton from './ReadFileSkeleton';
import ReadFileView from './ReadFileView';
interface ReadFileQueryProps {
args: LocalReadFileParams;
messageId: string;
pluginError: ChatMessagePluginError;
pluginState: LocalReadFileState;
}
const ReadFileQuery = memo<BuiltinRenderProps<{ path: string }, ReadFileState>>(
({ args, pluginState, messageId }) => {
const { isLoading } = useToolRenderCapabilities();
const loading = isLoading?.(messageId);
const ReadFileQuery = memo<ReadFileQueryProps>(({ args, pluginState, messageId }) => {
const loading = useChatStore(chatToolSelectors.isSearchingLocalFiles(messageId));
if (loading) {
return <ReadFileSkeleton />;
}
if (loading) {
return <ReadFileSkeleton />;
}
if (!args?.path || !pluginState) return null;
if (!args?.path || !pluginState) return null;
return <ReadFileView {...pluginState.fileContent} path={args.path} />;
});
return <ReadFileView {...pluginState} path={args.path} />;
},
);
export default ReadFileQuery;

View file

@ -1,70 +0,0 @@
import { type RunCommandParams, type RunCommandResult } from '@lobechat/electron-client-ipc';
import { type BuiltinRenderProps } from '@lobechat/types';
import { Block, Flexbox, Highlighter } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { memo } from 'react';
const styles = createStaticStyles(({ css, cssVar }) => ({
container: css`
overflow: hidden;
padding-inline: 8px;
& .ant-highlighter-highlighter-hover-actions {
inset-block-start: 4px;
inset-inline-end: 4px;
}
`,
head: css`
font-family: ${cssVar.fontFamilyCode};
font-size: 12px;
`,
header: css`
.action-icon {
opacity: 0;
transition: opacity 0.2s ease;
}
&:hover {
.action-icon {
opacity: 1;
}
}
`,
statusIcon: css`
font-size: 12px;
`,
}));
interface RunCommandState {
message: string;
result: RunCommandResult;
}
const RunCommand = memo<BuiltinRenderProps<RunCommandParams, RunCommandState>>(
({ args, pluginState }) => {
const { result } = pluginState || {};
return (
<Flexbox className={styles.container} gap={8}>
<Block gap={8} padding={'0 8px 0'} variant={'outlined'}>
<Highlighter
wrap
language={'sh'}
showLanguage={false}
style={{ padding: 8 }}
variant={'borderless'}
>
{args.command}
</Highlighter>
{result?.output && (
<Highlighter wrap language={'text'} showLanguage={false} variant={'filled'}>
{result.output}
</Highlighter>
)}
</Block>
</Flexbox>
);
},
);
export default RunCommand;

View file

@ -1,21 +1,19 @@
import type { FileResult } from '@lobechat/builtin-tool-local-system';
import { useToolRenderCapabilities } from '@lobechat/shared-tool-ui';
import type { ChatMessagePluginError } from '@lobechat/types';
import { Flexbox, Skeleton } from '@lobehub/ui';
import { memo } from 'react';
import { useChatStore } from '@/store/chat';
import { chatToolSelectors } from '@/store/chat/selectors';
import FileItem from '../../components/FileItem';
interface SearchFilesProps {
messageId: string;
pluginError: ChatMessagePluginError;
searchResults?: FileResult[];
searchResults?: Array<{ isDirectory?: boolean; name?: string; path: string; size?: number }>;
}
const SearchFiles = memo<SearchFilesProps>(({ searchResults = [], messageId }) => {
const loading = useChatStore(chatToolSelectors.isSearchingLocalFiles(messageId));
const { isLoading } = useToolRenderCapabilities();
const loading = isLoading?.(messageId);
if (loading) {
return (

View file

@ -1,25 +1,23 @@
import type { LocalFileSearchState } from '@lobechat/builtin-tool-local-system';
import type { LocalSearchFilesParams } from '@lobechat/electron-client-ipc';
import { useToolRenderCapabilities } from '@lobechat/shared-tool-ui';
import type { SearchFilesState } from '@lobechat/tool-runtime';
import { memo } from 'react';
import { useChatStore } from '@/store/chat';
import { chatToolSelectors } from '@/store/chat/selectors';
import SearchView from './SearchView';
interface SearchQueryViewProps {
args: LocalSearchFilesParams;
args: any;
messageId: string;
pluginState?: LocalFileSearchState;
pluginState?: SearchFilesState;
}
const SearchQueryView = memo<SearchQueryViewProps>(({ messageId, args, pluginState }) => {
const loading = useChatStore(chatToolSelectors.isSearchingLocalFiles(messageId));
const searchResults = pluginState?.searchResults || [];
const { isLoading } = useToolRenderCapabilities();
const loading = isLoading?.(messageId);
const searchResults = pluginState?.results || [];
return (
<SearchView
defaultQuery={args?.keywords}
defaultQuery={args?.keywords || args?.keyword || args?.query || ''}
resultsNumber={searchResults.length}
searching={loading || !pluginState}
/>

View file

@ -1,5 +1,4 @@
import type { LocalFileSearchState } from '@lobechat/builtin-tool-local-system';
import type { LocalSearchFilesParams } from '@lobechat/electron-client-ipc';
import type { SearchFilesState } from '@lobechat/tool-runtime';
import type { BuiltinRenderProps } from '@lobechat/types';
import { Flexbox } from '@lobehub/ui';
import { memo } from 'react';
@ -7,7 +6,7 @@ import { memo } from 'react';
import SearchResult from './Result';
import SearchQuery from './SearchQuery';
const SearchFiles = memo<BuiltinRenderProps<LocalSearchFilesParams, LocalFileSearchState>>(
const SearchFiles = memo<BuiltinRenderProps<any, SearchFilesState>>(
({ messageId, pluginError, args, pluginState }) => {
return (
<Flexbox gap={4}>
@ -15,7 +14,7 @@ const SearchFiles = memo<BuiltinRenderProps<LocalSearchFilesParams, LocalFileSea
<SearchResult
messageId={messageId}
pluginError={pluginError}
searchResults={pluginState?.searchResults}
searchResults={pluginState?.results}
/>
</Flexbox>
);

View file

@ -1,9 +1,10 @@
import { RunCommandRender } from '@lobechat/shared-tool-ui/renders';
import { LocalSystemApiName } from '../..';
import EditLocalFile from './EditLocalFile';
import ListFiles from './ListFiles';
import MoveLocalFiles from './MoveLocalFiles';
import ReadLocalFile from './ReadLocalFile';
import RunCommand from './RunCommand';
import SearchFiles from './SearchFiles';
import WriteFile from './WriteFile';
@ -15,7 +16,7 @@ export const LocalSystemRenders = {
[LocalSystemApiName.listLocalFiles]: ListFiles,
[LocalSystemApiName.moveLocalFiles]: MoveLocalFiles,
[LocalSystemApiName.readLocalFile]: ReadLocalFile,
[LocalSystemApiName.runCommand]: RunCommand,
[LocalSystemApiName.runCommand]: RunCommandRender,
[LocalSystemApiName.searchLocalFiles]: SearchFiles,
[LocalSystemApiName.writeLocalFile]: WriteFile,
};

View file

@ -1,4 +1,4 @@
import type { LocalFileItem } from '@lobechat/electron-client-ipc';
import { useToolRenderCapabilities } from '@lobechat/shared-tool-ui';
import { ActionIcon, Flexbox } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import dayjs from 'dayjs';
@ -7,7 +7,6 @@ import React, { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import FileIcon from '@/components/FileIcon';
import { localFileService } from '@/services/electron/localFileService';
import { formatSize } from '@/utils/format';
const styles = createStaticStyles(({ css, cssVar }) => ({
@ -47,13 +46,31 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
`,
}));
interface FileItemProps extends LocalFileItem {
interface FileItemProps {
createdTime?: Date | string;
isDirectory?: boolean;
name?: string;
path?: string;
showTime?: boolean;
size?: number;
type?: string;
}
const FileItem = memo<FileItemProps>(
({ isDirectory, name, path, size, type, showTime = false, createdTime }) => {
({ isDirectory, name: nameProp, path, size, type, showTime = false, createdTime }) => {
const { t } = useTranslation('tool');
const [isHovering, setIsHovering] = useState(false);
const { openFile, openFolder } = useToolRenderCapabilities();
const name = nameProp || path?.split('/').pop() || '';
const handleClick = () => {
if (!path) return;
if (isDirectory) {
openFolder?.(path);
} else {
openFile?.(path);
}
};
return (
<Flexbox
@ -62,15 +79,17 @@ const FileItem = memo<FileItemProps>(
className={styles.container}
gap={12}
padding={'2px 8px'}
style={{ cursor: 'pointer', fontSize: 12, width: '100%' }}
style={{
cursor: openFile || openFolder ? 'pointer' : 'default',
fontSize: 12,
width: '100%',
}}
onClick={handleClick}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
onClick={() => {
localFileService.openLocalFileOrFolder(path, isDirectory);
}}
>
<FileIcon
fileName={name}
fileName={name || ''}
fileType={type}
isDirectory={isDirectory}
size={16}
@ -83,13 +102,13 @@ const FileItem = memo<FileItemProps>(
style={{ overflow: 'hidden', width: '100%' }}
>
<div className={styles.title}>{name}</div>
{showTime ? (
{showTime && createdTime ? (
<div className={styles.path}>{dayjs(createdTime).format('MMM DD hh:mm')}</div>
) : (
<div className={styles.path}>{path}</div>
)}
</Flexbox>
{isHovering ? (
{isHovering && openFolder ? (
<Flexbox direction={'horizontal-reverse'} gap={8} style={{ minWidth: 50 }}>
<ActionIcon
icon={FolderOpen}
@ -98,12 +117,12 @@ const FileItem = memo<FileItemProps>(
title={t('localFiles.openFolder')}
onClick={(e) => {
e.stopPropagation();
localFileService.openLocalFolder({ isDirectory, path });
if (path) openFolder(path);
}}
/>
</Flexbox>
) : (
<span className={styles.size}>{formatSize(size)}</span>
<span className={styles.size}>{size !== undefined ? formatSize(size) : ''}</span>
)}
</Flexbox>
);

View file

@ -1,63 +1,24 @@
/* eslint-disable import-x/consistent-type-specifier-style */
import type {
EditLocalFileParams,
EditLocalFileResult,
GetCommandOutputParams,
GetCommandOutputResult,
GlobFilesParams,
GlobFilesResult,
GrepContentParams,
GrepContentResult,
KillCommandParams,
KillCommandResult,
ListLocalFileParams,
LocalFileItem,
LocalMoveFilesResultItem,
LocalReadFileParams,
LocalReadFileResult,
LocalReadFilesParams,
LocalSearchFilesParams,
MoveLocalFilesParams,
RenameLocalFileParams,
RenameLocalFileResult,
RunCommandParams,
RunCommandResult,
WriteLocalFileParams,
} from '@lobechat/electron-client-ipc';
import {
formatCommandOutput,
formatCommandResult,
formatEditResult,
formatFileContent,
formatFileList,
formatFileSearchResults,
formatGlobResults,
formatGrepResults,
formatKillResult,
formatMoveResults,
formatMultipleFiles,
formatRenameResult,
formatWriteResult,
} from '@lobechat/prompts';
import { type BuiltinToolResult } from '@lobechat/types';
import type { BuiltinToolResult } from '@lobechat/types';
import { BaseExecutor } from '@lobechat/types';
import { localFileService } from '@/services/electron/localFileService';
import type {
EditLocalFileState,
GetCommandOutputState,
GlobFilesState,
GrepContentState,
KillCommandState,
LocalFileListState,
LocalFileSearchState,
LocalMoveFilesState,
LocalReadFilesState,
LocalReadFileState,
LocalRenameFileState,
RunCommandState,
} from '../types';
import { LocalSystemExecutionRuntime } from '../ExecutionRuntime';
import { LocalSystemIdentifier } from '../types';
import { resolveArgsWithScope } from '../utils/path';
@ -80,267 +41,131 @@ const LocalSystemApiEnum = {
/**
* Local System Tool Executor
*
* Handles all local file system operations including file CRUD, shell commands, and search.
* Delegates standard computer operations to LocalSystemExecutionRuntime (extends ComputerRuntime).
* Handles scope resolution for paths before delegating.
*/
class LocalSystemExecutor extends BaseExecutor<typeof LocalSystemApiEnum> {
readonly identifier = LocalSystemIdentifier;
protected readonly apiEnum = LocalSystemApiEnum;
private runtime = new LocalSystemExecutionRuntime(localFileService);
/**
* Convert BuiltinServerRuntimeOutput to BuiltinToolResult
*/
private toResult(output: {
content: string;
error?: any;
state?: any;
success: boolean;
}): BuiltinToolResult {
if (!output.success) {
return {
content: output.content,
error: output.error
? { body: output.error, message: output.content, type: 'PluginServerError' }
: undefined,
success: false,
};
}
return { content: output.content, state: output.state, success: true };
}
// ==================== File Operations ====================
listLocalFiles = async (params: ListLocalFileParams): Promise<BuiltinToolResult> => {
try {
const result = await localFileService.listLocalFiles(params);
const state: LocalFileListState = {
listResults: result.files,
totalCount: result.totalCount,
};
const content = formatFileList({
directory: params.path,
files: result.files,
const result = await this.runtime.listFiles({
directoryPath: params.path,
sortBy: params.sortBy,
sortOrder: params.sortOrder,
totalCount: result.totalCount,
});
return {
content,
state,
success: true,
};
return this.toResult(result);
} catch (error) {
return {
content: (error as Error).message,
error: { body: error, message: (error as Error).message, type: 'PluginServerError' },
success: false,
};
return this.errorResult(error);
}
};
readLocalFile = async (params: LocalReadFileParams): Promise<BuiltinToolResult> => {
try {
const result: LocalReadFileResult = await localFileService.readLocalFile(params);
const state: LocalReadFileState = { fileContent: result };
const content = formatFileContent({
content: result.content,
lineRange: params.loc,
const result = await this.runtime.readFile({
endLine: params.loc?.[1],
path: params.path,
startLine: params.loc?.[0],
});
return {
content,
state,
success: true,
};
return this.toResult(result);
} catch (error) {
return {
content: (error as Error).message,
error: { body: error, message: (error as Error).message, type: 'PluginServerError' },
success: false,
};
return this.errorResult(error);
}
};
readLocalFiles = async (params: LocalReadFilesParams): Promise<BuiltinToolResult> => {
try {
const results: LocalReadFileResult[] = await localFileService.readLocalFiles(params);
const state: LocalReadFilesState = { filesContent: results };
const content = formatMultipleFiles(results);
return {
content,
state,
success: true,
};
const result = await this.runtime.readFiles(params);
return this.toResult(result);
} catch (error) {
return {
content: (error as Error).message,
error: { body: error, message: (error as Error).message, type: 'PluginServerError' },
success: false,
};
return this.errorResult(error);
}
};
searchLocalFiles = async (params: LocalSearchFilesParams): Promise<BuiltinToolResult> => {
try {
const resolvedParams = resolveArgsWithScope(params, 'directory');
const result: LocalFileItem[] = await localFileService.searchLocalFiles(resolvedParams);
// Extract engine from first result (all results use same engine)
const engine = result[0]?.engine;
const state: LocalFileSearchState = {
engine,
resolvedPath: resolvedParams.directory,
searchResults: result,
};
const content = formatFileSearchResults(result);
return {
content,
state,
success: true,
};
const result = await this.runtime.searchFiles({
directory: resolvedParams.directory || '',
});
return this.toResult(result);
} catch (error) {
return {
content: (error as Error).message,
error: { body: error, message: (error as Error).message, type: 'PluginServerError' },
success: false,
};
return this.errorResult(error);
}
};
moveLocalFiles = async (params: MoveLocalFilesParams): Promise<BuiltinToolResult> => {
try {
const results: LocalMoveFilesResultItem[] = await localFileService.moveLocalFiles(params);
const successCount = results.filter((r) => r.success).length;
const content = formatMoveResults(results);
const state: LocalMoveFilesState = {
results,
successCount,
totalCount: results.length,
};
return {
content,
state,
success: true,
};
const result = await this.runtime.moveFiles({
operations: params.items.map((item) => ({
destination: item.newPath,
source: item.oldPath,
})),
});
return this.toResult(result);
} catch (error) {
return {
content: (error as Error).message,
error: { body: error, message: (error as Error).message, type: 'PluginServerError' },
success: false,
};
return this.errorResult(error);
}
};
renameLocalFile = async (params: RenameLocalFileParams): Promise<BuiltinToolResult> => {
try {
const result: RenameLocalFileResult = await localFileService.renameLocalFile(params);
if (!result.success) {
const state: LocalRenameFileState = {
error: result.error,
newPath: '',
oldPath: params.path,
success: false,
};
return {
content: formatRenameResult({
error: result.error,
newName: params.newName,
oldPath: params.path,
success: false,
}),
state,
success: false,
};
}
const state: LocalRenameFileState = {
newPath: result.newPath!,
const result = await this.runtime.renameFile({
newName: params.newName,
oldPath: params.path,
success: true,
};
return {
content: formatRenameResult({
newName: params.newName,
oldPath: params.path,
success: true,
}),
state,
success: true,
};
});
return this.toResult(result);
} catch (error) {
return {
content: (error as Error).message,
error: { body: error, message: (error as Error).message, type: 'PluginServerError' },
success: false,
};
return this.errorResult(error);
}
};
writeLocalFile = async (params: WriteLocalFileParams): Promise<BuiltinToolResult> => {
try {
const result = await localFileService.writeFile(params);
if (!result.success) {
return {
content: formatWriteResult({
error: result.error,
path: params.path,
success: false,
}),
error: { message: result.error || 'Failed to write file', type: 'PluginServerError' },
success: false,
};
}
return {
content: formatWriteResult({
path: params.path,
success: true,
}),
success: true,
};
const result = await this.runtime.writeFile(params);
return this.toResult(result);
} catch (error) {
return {
content: (error as Error).message,
error: { body: error, message: (error as Error).message, type: 'PluginServerError' },
success: false,
};
return this.errorResult(error);
}
};
editLocalFile = async (params: EditLocalFileParams): Promise<BuiltinToolResult> => {
try {
const result: EditLocalFileResult = await localFileService.editLocalFile(params);
if (!result.success) {
return {
content: `Edit failed: ${result.error}`,
success: false,
};
}
const content = formatEditResult({
filePath: params.file_path,
linesAdded: result.linesAdded,
linesDeleted: result.linesDeleted,
replacements: result.replacements,
const result = await this.runtime.editFile({
all: params.replace_all,
path: params.file_path,
replace: params.new_string,
search: params.old_string,
});
const state: EditLocalFileState = {
diffText: result.diffText,
linesAdded: result.linesAdded,
linesDeleted: result.linesDeleted,
replacements: result.replacements,
};
return {
content,
state,
success: true,
};
return this.toResult(result);
} catch (error) {
return {
content: (error as Error).message,
error: { body: error, message: (error as Error).message, type: 'PluginServerError' },
success: false,
};
return this.errorResult(error);
}
};
@ -348,83 +173,32 @@ class LocalSystemExecutor extends BaseExecutor<typeof LocalSystemApiEnum> {
runCommand = async (params: RunCommandParams): Promise<BuiltinToolResult> => {
try {
const result: RunCommandResult = await localFileService.runCommand(params);
const content = formatCommandResult({
error: result.error,
exitCode: result.exit_code,
shellId: result.shell_id,
stderr: result.stderr,
stdout: result.stdout,
success: result.success,
});
const state: RunCommandState = { message: content.split('\n\n')[0], result };
return {
content,
state,
success: result.success,
};
const result = await this.runtime.runCommand(params);
return this.toResult(result);
} catch (error) {
return {
content: (error as Error).message,
error: { body: error, message: (error as Error).message, type: 'PluginServerError' },
success: false,
};
return this.errorResult(error);
}
};
getCommandOutput = async (params: GetCommandOutputParams): Promise<BuiltinToolResult> => {
try {
const result: GetCommandOutputResult = await localFileService.getCommandOutput(params);
const content = formatCommandOutput({
error: result.error,
output: result.output,
running: result.running,
success: result.success,
const result = await this.runtime.getCommandOutput({
commandId: params.shell_id,
});
const state: GetCommandOutputState = { message: content.split('\n\n')[0], result };
return {
content,
state,
success: result.success,
};
return this.toResult(result);
} catch (error) {
return {
content: (error as Error).message,
error: { body: error, message: (error as Error).message, type: 'PluginServerError' },
success: false,
};
return this.errorResult(error);
}
};
killCommand = async (params: KillCommandParams): Promise<BuiltinToolResult> => {
try {
const result: KillCommandResult = await localFileService.killCommand(params);
const content = formatKillResult({
error: result.error,
shellId: params.shell_id,
success: result.success,
const result = await this.runtime.killCommand({
commandId: params.shell_id,
});
const state: KillCommandState = { message: content, result };
return {
content,
state,
success: result.success,
};
return this.toResult(result);
} catch (error) {
return {
content: (error as Error).message,
error: { body: error, message: (error as Error).message, type: 'PluginServerError' },
success: false,
};
return this.errorResult(error);
}
};
@ -433,68 +207,37 @@ class LocalSystemExecutor extends BaseExecutor<typeof LocalSystemApiEnum> {
grepContent = async (params: GrepContentParams): Promise<BuiltinToolResult> => {
try {
const resolvedParams = resolveArgsWithScope(params, 'path');
const result: GrepContentResult = await localFileService.grepContent(resolvedParams);
const content = result.success
? formatGrepResults({
matches: result.matches,
totalMatches: result.total_matches,
})
: `Search failed: ${result.error || 'Unknown error'}`;
const state: GrepContentState = {
message: content.split('\n')[0],
resolvedPath: resolvedParams.path,
result,
};
return {
content,
state,
success: result.success,
};
const result = await this.runtime.grepContent({
directory: resolvedParams.path || '',
pattern: resolvedParams.pattern,
});
return this.toResult(result);
} catch (error) {
return {
content: (error as Error).message,
error: { body: error, message: (error as Error).message, type: 'PluginServerError' },
success: false,
};
return this.errorResult(error);
}
};
globLocalFiles = async (params: GlobFilesParams): Promise<BuiltinToolResult> => {
try {
const resolvedParams = resolveArgsWithScope(params, 'pattern');
const result: GlobFilesResult = await localFileService.globFiles(resolvedParams);
const content = result.success
? formatGlobResults({
files: result.files,
totalFiles: result.total_files,
})
: `Glob search failed: ${result.error || 'Unknown error'}`;
const state: GlobFilesState = {
message: content.split('\n')[0],
resolvedPath: resolvedParams.pattern,
result,
};
return {
content,
state,
success: result.success,
};
const result = await this.runtime.globFiles({
pattern: resolvedParams.pattern,
});
return this.toResult(result);
} catch (error) {
return {
content: (error as Error).message,
error: { body: error, message: (error as Error).message, type: 'PluginServerError' },
success: false,
};
return this.errorResult(error);
}
};
// ==================== Helpers ====================
private errorResult(error: unknown): BuiltinToolResult {
return {
content: (error as Error).message,
error: { body: error, message: (error as Error).message, type: 'PluginServerError' },
success: false,
};
}
}
// Export the executor instance for registration

View file

@ -4,10 +4,8 @@ export { systemPrompt } from './systemRole';
export {
type EditLocalFileState,
type FileResult,
type GetCommandOutputState,
type GlobFilesState,
type GrepContentState,
type KillCommandState,
type LocalFileListState,
type LocalFileSearchState,
type LocalMoveFilesState,

View file

@ -1,14 +1,17 @@
import {
type GetCommandOutputResult,
type GlobFilesResult,
type GrepContentResult,
type KillCommandResult,
type LocalFileItem,
type LocalMoveFilesResultItem,
type LocalReadFileResult,
type RunCommandResult,
import type {
LocalFileItem,
LocalMoveFilesResultItem,
LocalReadFileResult,
} from '@lobechat/electron-client-ipc';
// Re-export shared state types from @lobechat/tool-runtime
export type {
EditFileState as EditLocalFileState,
GlobFilesState,
GrepContentState,
RunCommandState,
} from '@lobechat/tool-runtime';
export const LocalSystemIdentifier = 'lobe-local-system';
export const LocalSystemApiName = {
@ -41,6 +44,8 @@ export interface FileResult {
type: string;
}
// ==================== Local-System-Specific State Types ====================
export interface LocalFileSearchState {
/** Search engine used (e.g., 'mdfind', 'fd', 'find', 'fast-glob') */
engine?: string;
@ -75,39 +80,3 @@ export interface LocalRenameFileState {
oldPath: string;
success: boolean;
}
export interface RunCommandState {
message: string;
result: RunCommandResult;
}
export interface GetCommandOutputState {
message: string;
result: GetCommandOutputResult;
}
export interface KillCommandState {
message: string;
result: KillCommandResult;
}
export interface GrepContentState {
message: string;
/** Resolved search path after scope resolution */
resolvedPath?: string;
result: GrepContentResult;
}
export interface GlobFilesState {
message: string;
/** Resolved full glob (path + pattern) after scope resolution. May contain glob metacharacters like `*` or `**`. */
resolvedPath?: string;
result: GlobFilesResult;
}
export interface EditLocalFileState {
diffText?: string;
linesAdded?: number;
linesDeleted?: number;
replacements: number;
}

View file

@ -10,10 +10,11 @@
},
"main": "./src/index.ts",
"dependencies": {
"@lobechat/const": "workspace:*"
"@lobechat/const": "workspace:*",
"@lobechat/prompts": "workspace:*",
"@lobechat/shared-tool-ui": "workspace:*"
},
"devDependencies": {
"@lobechat/prompts": "workspace:*",
"@lobechat/types": "workspace:*"
},
"peerDependencies": {

View file

@ -1,20 +1,20 @@
import { resourcesTreePrompt } from '@lobechat/prompts';
import {
type BuiltinServerRuntimeOutput,
type BuiltinSkill,
type SkillItem,
type SkillListItem,
type SkillResourceContent,
import { formatCommandResult, resourcesTreePrompt } from '@lobechat/prompts';
import type {
BuiltinServerRuntimeOutput,
BuiltinSkill,
SkillItem,
SkillListItem,
SkillResourceContent,
} from '@lobechat/types';
import {
type ActivateSkillParams,
type CommandResult,
type ExecScriptParams,
type ExportFileParams,
type ReadReferenceParams,
type RunCommandOptions,
type RunCommandParams,
import type {
ActivateSkillParams,
CommandResult,
ExecScriptParams,
ExportFileParams,
ReadReferenceParams,
RunCommandOptions,
RunCommandParams,
} from '../types';
/**
@ -77,17 +77,7 @@ export class SkillsExecutionRuntime {
description,
});
const output = [result.output, result.stderr].filter(Boolean).join('\n');
return {
content: output || '(no output)',
state: {
command,
exitCode: result.exitCode,
success: result.success,
},
success: result.success,
};
return this.formatCommandOutput(command, result);
} catch (e) {
return {
content: `Failed to execute command: ${(e as Error).message}`,
@ -106,18 +96,7 @@ export class SkillsExecutionRuntime {
try {
const result = await this.service.runCommand({ command });
const output = [result.output, result.stderr].filter(Boolean).join('\n');
return {
content: output || '(no output)',
state: {
command,
exitCode: result.exitCode,
success: result.success,
},
success: result.success,
};
return this.formatCommandOutput(command, result);
} catch (e) {
return {
content: `Failed to execute command: ${(e as Error).message}`,
@ -138,17 +117,7 @@ export class SkillsExecutionRuntime {
try {
const result = await this.service.runCommand({ command });
const output = [result.output, result.stderr].filter(Boolean).join('\n');
return {
content: output || '(no output)',
state: {
command,
exitCode: result.exitCode,
success: result.success,
},
success: result.success,
};
return this.formatCommandOutput(command, result);
} catch (e) {
return {
content: `Failed to execute command: ${(e as Error).message}`,
@ -320,4 +289,27 @@ export class SkillsExecutionRuntime {
success: true,
};
}
/**
* Format command result using the shared formatCommandResult from @lobechat/prompts.
* This ensures consistent content format across all runtimes.
*/
private formatCommandOutput(command: string, result: CommandResult): BuiltinServerRuntimeOutput {
const content = formatCommandResult({
stderr: result.stderr,
stdout: result.output,
success: result.success,
exitCode: result.exitCode,
});
return {
content,
state: {
command,
exitCode: result.exitCode,
success: result.success,
},
success: result.success,
};
}
}

View file

@ -1,60 +1,7 @@
'use client';
import { type BuiltinInspectorProps } from '@lobechat/types';
import { createStaticStyles, cssVar, cx } from 'antd-style';
import { Check, X } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { createRunCommandInspector } from '@lobechat/shared-tool-ui/inspectors';
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
import type { CommandResult, RunCommandParams } from '../../../types';
const styles = createStaticStyles(({ css }) => ({
statusIcon: css`
margin-block-end: -2px;
margin-inline-start: 4px;
`,
}));
export const RunCommandInspector = memo<BuiltinInspectorProps<RunCommandParams, CommandResult>>(
({ args, partialArgs, isArgumentsStreaming, isLoading, pluginState }) => {
const { t } = useTranslation('plugin');
const description = args?.description || partialArgs?.description;
if (isArgumentsStreaming) {
if (!description)
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-skills.apiName.runCommand')}</span>
</div>
);
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-skills.apiName.runCommand')}: </span>
<span className={highlightTextStyles.primary}>{description}</span>
</div>
);
}
return (
<div className={cx(inspectorTextStyles.root, isLoading && shinyTextStyles.shinyText)}>
<span style={{ marginInlineStart: 2 }}>
<span>{t('builtins.lobe-skills.apiName.runCommand')}: </span>
{description && <span className={highlightTextStyles.primary}>{description}</span>}
{isLoading ? null : pluginState?.success !== undefined ? (
pluginState.success ? (
<Check className={styles.statusIcon} color={cssVar.colorSuccess} size={14} />
) : (
<X className={styles.statusIcon} color={cssVar.colorError} size={14} />
)
) : null}
</span>
</div>
);
},
export const RunCommandInspector = createRunCommandInspector(
'builtins.lobe-skills.apiName.runCommand',
);
RunCommandInspector.displayName = 'RunCommandInspector';

View file

@ -1,55 +0,0 @@
'use client';
import type { BuiltinRenderProps } from '@lobechat/types';
import { Block, Flexbox, Highlighter } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { memo } from 'react';
import type { CommandResult, RunCommandParams } from '../../../types';
const styles = createStaticStyles(({ css }) => ({
container: css`
overflow: hidden;
padding-inline: 8px 0;
`,
}));
const RunCommand = memo<BuiltinRenderProps<RunCommandParams, CommandResult>>(
({ args, content, pluginState }) => {
return (
<Flexbox className={styles.container} gap={8}>
<Block gap={8} padding={8} variant={'outlined'}>
<Highlighter
wrap
language={'sh'}
showLanguage={false}
style={{ maxHeight: 200, overflow: 'auto', paddingInline: 8 }}
variant={'borderless'}
>
{args?.command || ''}
</Highlighter>
{(pluginState?.output || content) && (
<Highlighter
wrap
language={'text'}
showLanguage={false}
style={{ maxHeight: 200, overflow: 'auto', paddingInline: 8 }}
variant={'filled'}
>
{pluginState?.output || content}
</Highlighter>
)}
{pluginState?.stderr && (
<Highlighter wrap language={'text'} showLanguage={false} variant={'filled'}>
{pluginState.stderr}
</Highlighter>
)}
</Block>
</Flexbox>
);
},
);
RunCommand.displayName = 'RunCommand';
export default RunCommand;

View file

@ -1,12 +1,13 @@
import { RunCommandRender } from '@lobechat/shared-tool-ui/renders';
import { SkillsApiName } from '../../types';
import ExecScript from './ExecScript';
import ReadReference from './ReadReference';
import RunCommand from './RunCommand';
import RunSkill from './RunSkill';
export const SkillsRenders = {
[SkillsApiName.execScript]: ExecScript,
[SkillsApiName.readReference]: ReadReference,
[SkillsApiName.runCommand]: RunCommand,
[SkillsApiName.runCommand]: RunCommandRender,
[SkillsApiName.activateSkill]: RunSkill,
};

View file

@ -1,5 +1,5 @@
export interface FormatGrepResultsParams {
matches: string[];
matches: Array<string | { content?: string; lineNumber?: number; path: string }>;
maxDisplay?: number;
totalMatches: number;
}
@ -16,7 +16,19 @@ export const formatGrepResults = ({
}
const displayMatches = matches.slice(0, maxDisplay);
const matchList = displayMatches.map((m) => ` ${m}`).join('\n');
const matchList = displayMatches
.map((m) => {
if (typeof m === 'string') return ` ${m}`;
const parts: string[] = [];
if (m.path) parts.push(m.path);
if (m.lineNumber !== undefined) parts.push(`:${m.lineNumber}`);
if (m.content) {
if (parts.length > 0) parts.push(`: ${m.content}`);
else parts.push(m.content);
}
return ` ${parts.join('')}`;
})
.join('\n');
const moreInfo =
matches.length > maxDisplay ? `\n ... and ${matches.length - maxDisplay} more` : '';

View file

@ -0,0 +1,24 @@
{
"name": "@lobechat/shared-tool-ui",
"version": "1.0.0",
"private": true,
"exports": {
".": "./src/index.ts",
"./renders": "./src/Render/index.ts",
"./inspectors": "./src/Inspector/index.ts"
},
"dependencies": {
"@lobechat/tool-runtime": "workspace:*"
},
"devDependencies": {
"@lobechat/types": "workspace:*"
},
"peerDependencies": {
"@lobehub/ui": "^5",
"antd-style": "*",
"lucide-react": "*",
"path-browserify-esm": "*",
"react": "*",
"react-i18next": "*"
}
}

View file

@ -0,0 +1,109 @@
'use client';
import type { EditFileState } from '@lobechat/tool-runtime';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { Icon, Text } from '@lobehub/ui';
import { createStaticStyles, cssVar, cx } from 'antd-style';
import { Minus, Plus } from 'lucide-react';
import type { ReactNode } from 'react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { FilePathDisplay } from '../../components/FilePathDisplay';
import { inspectorTextStyles, shinyTextStyles } from '../../styles';
const styles = createStaticStyles(({ css, cssVar }) => ({
separator: css`
margin-inline: 2px;
color: ${cssVar.colorTextQuaternary};
`,
}));
interface EditFileArgs {
all?: boolean;
file_path?: string;
path?: string;
replace?: string;
search?: string;
}
export interface EditLocalFileInspectorProps extends BuiltinInspectorProps<
EditFileArgs,
EditFileState
> {
translationKey: string;
}
export const EditLocalFileInspector = memo<EditLocalFileInspectorProps>(
({ args, partialArgs, isArgumentsStreaming, pluginState, isLoading, translationKey }) => {
const { t } = useTranslation('plugin');
const filePath =
args?.file_path || args?.path || partialArgs?.file_path || partialArgs?.path || '';
if (isArgumentsStreaming) {
if (!filePath)
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t(translationKey as any)}</span>
</div>
);
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t(translationKey as any)}: </span>
<FilePathDisplay filePath={filePath} />
</div>
);
}
const linesAdded = pluginState?.linesAdded ?? 0;
const linesDeleted = pluginState?.linesDeleted ?? 0;
const statsParts: ReactNode[] = [];
if (linesAdded > 0) {
statsParts.push(
<Text code as={'span'} color={cssVar.colorSuccess} fontSize={12} key="added">
<Icon icon={Plus} size={12} />
{linesAdded}
</Text>,
);
}
if (linesDeleted > 0) {
statsParts.push(
<Text code as={'span'} color={cssVar.colorError} fontSize={12} key="deleted">
<Icon icon={Minus} size={12} />
{linesDeleted}
</Text>,
);
}
return (
<div className={cx(inspectorTextStyles.root, isLoading && shinyTextStyles.shinyText)}>
<span>{t(translationKey as any)}: </span>
<FilePathDisplay filePath={filePath} />
{!isLoading && statsParts.length > 0 && (
<>
{' '}
{statsParts.map((part, index) => (
<span key={index}>
{index > 0 && <span className={styles.separator}> / </span>}
{part}
</span>
))}
</>
)}
</div>
);
},
);
EditLocalFileInspector.displayName = 'EditLocalFileInspector';
export const createEditLocalFileInspector = (translationKey: string) => {
const Inspector = memo<BuiltinInspectorProps<any, any>>((props) => (
<EditLocalFileInspector {...props} translationKey={translationKey} />
));
Inspector.displayName = 'EditLocalFileInspector';
return Inspector;
};

View file

@ -0,0 +1,68 @@
'use client';
import type { GlobFilesState } from '@lobechat/tool-runtime';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { createStaticStyles, cssVar, cx } from 'antd-style';
import { Check, X } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '../../styles';
const styles = createStaticStyles(({ css }) => ({
statusIcon: css`
margin-block-end: -2px;
margin-inline-start: 4px;
`,
}));
interface GlobFilesArgs {
directory?: string;
pattern?: string;
}
export const createGlobLocalFilesInspector = (translationKey: string) => {
const Inspector = memo<BuiltinInspectorProps<GlobFilesArgs, GlobFilesState>>(
({ args, partialArgs, isArgumentsStreaming, pluginState, isLoading }) => {
const { t } = useTranslation('plugin');
const pattern = args?.pattern || partialArgs?.pattern || '';
if (isArgumentsStreaming) {
if (!pattern)
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t(translationKey as any)}</span>
</div>
);
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t(translationKey as any)}: </span>
<span className={highlightTextStyles.primary}>{pattern}</span>
</div>
);
}
const hasFiles = (pluginState?.totalCount ?? 0) > 0;
return (
<div className={cx(inspectorTextStyles.root, isLoading && shinyTextStyles.shinyText)}>
<span style={{ marginInlineStart: 2 }}>
<span>{t(translationKey as any)}: </span>
{pattern && <span className={highlightTextStyles.primary}>{pattern}</span>}
{isLoading ? null : pluginState ? (
hasFiles ? (
<Check className={styles.statusIcon} color={cssVar.colorSuccess} size={14} />
) : (
<X className={styles.statusIcon} color={cssVar.colorError} size={14} />
)
) : null}
</span>
</div>
);
},
);
Inspector.displayName = 'GlobLocalFilesInspector';
return Inspector;
};

View file

@ -0,0 +1,76 @@
'use client';
import type { GrepContentState } from '@lobechat/tool-runtime';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { Text } from '@lobehub/ui';
import { cssVar, cx } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '../../styles';
interface GrepContentArgs {
directory?: string;
path?: string;
pattern?: string;
}
interface CreateGrepContentInspectorOptions {
noResultsKey: string;
translationKey: string;
}
export const createGrepContentInspector = ({
translationKey,
noResultsKey,
}: CreateGrepContentInspectorOptions) => {
const Inspector = memo<BuiltinInspectorProps<GrepContentArgs, GrepContentState>>(
({ args, partialArgs, isArgumentsStreaming, pluginState, isLoading }) => {
const { t } = useTranslation('plugin');
const pattern = args?.pattern || partialArgs?.pattern || '';
if (isArgumentsStreaming) {
if (!pattern)
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t(translationKey as any)}</span>
</div>
);
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t(translationKey as any)}: </span>
<span className={highlightTextStyles.primary}>{pattern}</span>
</div>
);
}
const resultCount = pluginState?.totalMatches ?? 0;
const hasResults = resultCount > 0;
return (
<div className={cx(inspectorTextStyles.root, isLoading && shinyTextStyles.shinyText)}>
<span>{t(translationKey as any)}: </span>
{pattern && <span className={highlightTextStyles.primary}>{pattern}</span>}
{!isLoading &&
pluginState &&
(hasResults ? (
<span style={{ marginInlineStart: 4 }}>({resultCount})</span>
) : (
<Text
as={'span'}
color={cssVar.colorTextDescription}
fontSize={12}
style={{ marginInlineStart: 4 }}
>
({t(noResultsKey as any)})
</Text>
))}
</div>
);
},
);
Inspector.displayName = 'GrepContentInspector';
return Inspector;
};

View file

@ -0,0 +1,53 @@
'use client';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { cx } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { FilePathDisplay } from '../../components/FilePathDisplay';
import { inspectorTextStyles, shinyTextStyles } from '../../styles';
interface ListFilesArgs {
directoryPath?: string;
path?: string;
}
export const createListLocalFilesInspector = (translationKey: string) => {
const Inspector = memo<BuiltinInspectorProps<ListFilesArgs, any>>(
({ args, partialArgs, isArgumentsStreaming, pluginState, isLoading }) => {
const { t } = useTranslation('plugin');
const dirPath = args?.path || args?.directoryPath || partialArgs?.path || '';
const resultCount = pluginState?.totalCount ?? pluginState?.files?.length ?? 0;
if (isArgumentsStreaming) {
if (!dirPath)
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t(translationKey as any)}</span>
</div>
);
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t(translationKey as any)}: </span>
<FilePathDisplay isDirectory filePath={dirPath} />
</div>
);
}
return (
<div className={cx(inspectorTextStyles.root, isLoading && shinyTextStyles.shinyText)}>
<span>{t(translationKey as any)}: </span>
<FilePathDisplay isDirectory filePath={dirPath} />
{!isLoading && resultCount > 0 && (
<span style={{ marginInlineStart: 4 }}>({resultCount})</span>
)}
</div>
);
},
);
Inspector.displayName = 'ListLocalFilesInspector';
return Inspector;
};

View file

@ -0,0 +1,61 @@
'use client';
import type { ReadFileState } from '@lobechat/tool-runtime';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { cx } from 'antd-style';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { FilePathDisplay } from '../../components/FilePathDisplay';
import { inspectorTextStyles, shinyTextStyles } from '../../styles';
interface ReadFileArgs {
endLine?: number;
loc?: [number, number];
path?: string;
startLine?: number;
}
export const createReadLocalFileInspector = (translationKey: string) => {
const Inspector = memo<BuiltinInspectorProps<ReadFileArgs, ReadFileState>>(
({ args, partialArgs, isArgumentsStreaming, isLoading }) => {
const { t } = useTranslation('plugin');
const filePath = args?.path || partialArgs?.path || '';
const lineRange = useMemo(() => {
const start = args?.startLine ?? args?.loc?.[0];
const end = args?.endLine ?? args?.loc?.[1];
if (start !== undefined && end !== undefined) return `L${start}-L${end}`;
if (start !== undefined) return `L${start}`;
return undefined;
}, [args]);
if (isArgumentsStreaming) {
if (!filePath)
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t(translationKey as any)}</span>
</div>
);
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t(translationKey as any)}: </span>
<FilePathDisplay filePath={filePath} />
</div>
);
}
return (
<div className={cx(inspectorTextStyles.root, isLoading && shinyTextStyles.shinyText)}>
<span>{t(translationKey as any)}: </span>
<FilePathDisplay filePath={filePath} />
{lineRange && <span style={{ marginInlineStart: 4 }}>({lineRange})</span>}
</div>
);
},
);
Inspector.displayName = 'ReadLocalFileInspector';
return Inspector;
};

View file

@ -0,0 +1,88 @@
'use client';
import type { RunCommandState } from '@lobechat/tool-runtime';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { createStaticStyles, cssVar, cx } from 'antd-style';
import { Check, X } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '../../styles';
const styles = createStaticStyles(({ css }) => ({
statusIcon: css`
margin-block-end: -2px;
margin-inline-start: 4px;
`,
}));
interface RunCommandArgs {
background?: boolean;
command: string;
description?: string;
timeout?: number;
}
export interface RunCommandInspectorProps extends BuiltinInspectorProps<
RunCommandArgs,
RunCommandState
> {
/** i18n key for the API name label, e.g. 'builtins.lobe-local-system.apiName.runCommand' */
translationKey: string;
}
export const RunCommandInspector = memo<RunCommandInspectorProps>(
({ args, partialArgs, isArgumentsStreaming, pluginState, isLoading, translationKey }) => {
const { t } = useTranslation('plugin');
const description = args?.description || partialArgs?.description || args?.command || '';
if (isArgumentsStreaming) {
if (!description)
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t(translationKey as any)}</span>
</div>
);
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t(translationKey as any)}: </span>
<span className={highlightTextStyles.primary}>{description}</span>
</div>
);
}
const isSuccess = pluginState?.success || pluginState?.exitCode === 0;
return (
<div className={cx(inspectorTextStyles.root, isLoading && shinyTextStyles.shinyText)}>
<span style={{ marginInlineStart: 2 }}>
<span>{t(translationKey as any)}: </span>
{description && <span className={highlightTextStyles.primary}>{description}</span>}
{isLoading ? null : pluginState?.success !== undefined ? (
isSuccess ? (
<Check className={styles.statusIcon} color={cssVar.colorSuccess} size={14} />
) : (
<X className={styles.statusIcon} color={cssVar.colorError} size={14} />
)
) : null}
</span>
</div>
);
},
);
RunCommandInspector.displayName = 'RunCommandInspector';
/**
* Factory to create a RunCommandInspector with a bound translation key.
* Use this in each package's inspector registry to avoid wrapper components.
*/
export const createRunCommandInspector = (translationKey: string) => {
const Inspector = memo<BuiltinInspectorProps<RunCommandArgs, RunCommandState>>((props) => (
<RunCommandInspector {...props} translationKey={translationKey} />
));
Inspector.displayName = 'RunCommandInspector';
return Inspector;
};

View file

@ -0,0 +1,86 @@
'use client';
import type { SearchFilesState } from '@lobechat/tool-runtime';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { Text } from '@lobehub/ui';
import { cssVar, cx } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '../../styles';
interface SearchFilesArgs {
keyword?: string;
keywords?: string;
query?: string;
}
interface CreateSearchLocalFilesInspectorOptions {
noResultsKey: string;
translationKey: string;
}
export const createSearchLocalFilesInspector = ({
translationKey,
noResultsKey,
}: CreateSearchLocalFilesInspectorOptions) => {
const Inspector = memo<BuiltinInspectorProps<SearchFilesArgs, SearchFilesState>>(
({ args, partialArgs, isArgumentsStreaming, pluginState, isLoading }) => {
const { t } = useTranslation('plugin');
// Support all keyword field variants
const query =
args?.keyword ||
args?.keywords ||
args?.query ||
partialArgs?.keyword ||
partialArgs?.keywords ||
partialArgs?.query ||
'';
if (isArgumentsStreaming) {
if (!query)
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t(translationKey as any)}</span>
</div>
);
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t(translationKey as any)}: </span>
<span className={highlightTextStyles.primary}>{query}</span>
</div>
);
}
const resultCount = pluginState?.results?.length ?? pluginState?.totalCount ?? 0;
const hasResults = resultCount > 0;
return (
<div className={cx(inspectorTextStyles.root, isLoading && shinyTextStyles.shinyText)}>
<span style={{ marginInlineStart: 2 }}>
<span>{t(translationKey as any)}: </span>
{query && <span className={highlightTextStyles.primary}>{query}</span>}
{!isLoading &&
pluginState &&
(hasResults ? (
<span style={{ marginInlineStart: 4 }}>({resultCount})</span>
) : (
<Text
as={'span'}
color={cssVar.colorTextDescription}
fontSize={12}
style={{ marginInlineStart: 4 }}
>
({t(noResultsKey as any)})
</Text>
))}
</span>
</div>
);
},
);
Inspector.displayName = 'SearchLocalFilesInspector';
return Inspector;
};

View file

@ -0,0 +1,61 @@
'use client';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { Icon, Text } from '@lobehub/ui';
import { cssVar, cx } from 'antd-style';
import { Plus } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { FilePathDisplay } from '../../components/FilePathDisplay';
import { inspectorTextStyles, shinyTextStyles } from '../../styles';
interface WriteFileArgs {
content?: string;
path?: string;
}
export const createWriteLocalFileInspector = (translationKey: string) => {
const Inspector = memo<BuiltinInspectorProps<WriteFileArgs, any>>(
({ args, partialArgs, isArgumentsStreaming, isLoading }) => {
const { t } = useTranslation('plugin');
const filePath = args?.path || partialArgs?.path || '';
const lineCount = args?.content?.split('\n').length;
if (isArgumentsStreaming) {
if (!filePath)
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t(translationKey as any)}</span>
</div>
);
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t(translationKey as any)}: </span>
<FilePathDisplay filePath={filePath} />
</div>
);
}
return (
<div className={cx(inspectorTextStyles.root, isLoading && shinyTextStyles.shinyText)}>
<span>{t(translationKey as any)}: </span>
<FilePathDisplay filePath={filePath} />
{!isLoading && lineCount && (
<>
{' '}
<Text code as={'span'} color={cssVar.colorSuccess} fontSize={12}>
<Icon icon={Plus} size={12} />
{lineCount}
</Text>
</>
)}
</div>
);
},
);
Inspector.displayName = 'WriteLocalFileInspector';
return Inspector;
};

View file

@ -0,0 +1,8 @@
export { createEditLocalFileInspector } from './EditLocalFile';
export { createGlobLocalFilesInspector } from './GlobLocalFiles';
export { createGrepContentInspector } from './GrepContent';
export { createListLocalFilesInspector } from './ListLocalFiles';
export { createReadLocalFileInspector } from './ReadLocalFile';
export { createRunCommandInspector, RunCommandInspector } from './RunCommand';
export { createSearchLocalFilesInspector } from './SearchLocalFiles';
export { createWriteLocalFileInspector } from './WriteLocalFile';

View file

@ -1,12 +1,11 @@
'use client';
import type { RunCommandState } from '@lobechat/tool-runtime';
import type { BuiltinRenderProps } from '@lobechat/types';
import { Block, Flexbox, Highlighter } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { memo } from 'react';
import type { RunCommandState } from '../../../types';
const styles = createStaticStyles(({ css }) => ({
container: css`
overflow: hidden;
@ -14,15 +13,17 @@ const styles = createStaticStyles(({ css }) => ({
`,
}));
interface RunCommandParams {
interface RunCommandArgs {
background?: boolean;
command: string;
description?: string;
timeout?: number;
}
const RunCommand = memo<BuiltinRenderProps<RunCommandParams, RunCommandState>>(
({ args, pluginState }) => {
const RunCommand = memo<BuiltinRenderProps<RunCommandArgs, RunCommandState>>(
({ args, content, pluginState }) => {
const output = pluginState?.output || pluginState?.stdout || content;
return (
<Flexbox className={styles.container} gap={8}>
<Block gap={8} padding={8} variant={'outlined'}>
@ -33,9 +34,9 @@ const RunCommand = memo<BuiltinRenderProps<RunCommandParams, RunCommandState>>(
style={{ maxHeight: 200, overflow: 'auto', paddingInline: 8 }}
variant={'borderless'}
>
{args.command}
{args?.command || ''}
</Highlighter>
{pluginState?.output && (
{output && (
<Highlighter
wrap
language={'text'}
@ -43,7 +44,7 @@ const RunCommand = memo<BuiltinRenderProps<RunCommandParams, RunCommandState>>(
style={{ maxHeight: 200, overflow: 'auto', paddingInline: 8 }}
variant={'filled'}
>
{pluginState.output}
{output}
</Highlighter>
)}
{pluginState?.stderr && (

View file

@ -0,0 +1 @@
export { default as RunCommandRender } from './RunCommand';

View file

@ -0,0 +1,65 @@
'use client';
import { MaterialFileTypeIcon, Text } from '@lobehub/ui';
import { createStaticStyles, cssVar } from 'antd-style';
import path from 'path-browserify-esm';
import { memo, useMemo } from 'react';
const styles = createStaticStyles(({ css }) => ({
icon: css`
flex-shrink: 0;
margin-inline-end: 4px;
`,
text: css`
overflow: hidden;
color: ${cssVar.colorText};
text-overflow: ellipsis;
white-space: nowrap;
`,
}));
interface FilePathDisplayProps {
filePath: string;
isDirectory?: boolean;
}
export const FilePathDisplay = memo<FilePathDisplayProps>(({ filePath, isDirectory }) => {
const { displayPath, name } = useMemo(() => {
if (!filePath) return { displayPath: '', name: '' };
const { base, dir } = path.parse(filePath);
const parentDir = path.basename(dir);
return {
displayPath: parentDir ? `${parentDir}/${base}` : base,
name: base,
};
}, [filePath]);
if (!filePath) return null;
return (
<>
{name && (
<MaterialFileTypeIcon
className={styles.icon}
fallbackUnknownType={false}
filename={name}
size={16}
type={isDirectory ? 'folder' : 'file'}
variant={'raw'}
/>
)}
{displayPath && (
<Text
className={styles.text}
ellipsis={{
tooltipWhenOverflow: true,
}}
>
{displayPath}
</Text>
)}
</>
);
});
FilePathDisplay.displayName = 'FilePathDisplay';

View file

@ -0,0 +1,26 @@
'use client';
import { createContext, use } from 'react';
/**
* Capabilities that can be injected into shared tool render components.
*
* - local-system: provides all (Electron IPC + store)
* - cloud-sandbox: provides none (renders without loading state, open file actions)
*/
export interface ToolRenderCapabilities {
/** Display a path relative to working directory. Returns the path as-is if not provided. */
displayRelativePath?: (path: string) => string;
/** Whether a tool call is currently loading for a given messageId */
isLoading?: (messageId: string) => boolean;
/** Open a file in the OS file manager */
openFile?: (path: string) => void;
/** Open the containing folder of a file in the OS file manager */
openFolder?: (path: string) => void;
}
const ToolRenderContext = createContext<ToolRenderCapabilities>({});
export const ToolRenderProvider = ToolRenderContext.Provider;
export const useToolRenderCapabilities = () => use(ToolRenderContext);

View file

@ -0,0 +1,2 @@
export type { ToolRenderCapabilities } from './context';
export { ToolRenderProvider, useToolRenderCapabilities } from './context';

View file

@ -0,0 +1,73 @@
import { createStaticStyles, keyframes } from 'antd-style';
/**
* Inspector text style ellipsis + secondary color + flex align
*/
export const inspectorTextStyles = createStaticStyles(({ css, cssVar }) => ({
root: css`
overflow: hidden;
display: flex;
align-items: center;
min-width: 0;
color: ${cssVar.colorTextSecondary};
text-overflow: ellipsis;
white-space: nowrap;
`,
}));
/**
* Highlight underline effect using gradient background
*/
export const highlightTextStyles = createStaticStyles(({ css, cssVar }) => {
const highlightBase = (highlightColor: string) => css`
overflow: hidden;
min-width: 0;
margin-inline-start: 4px;
padding-block-end: 1px;
color: ${cssVar.colorText};
text-overflow: ellipsis;
background: linear-gradient(to top, ${highlightColor} 40%, transparent 40%);
`;
return {
gold: highlightBase(cssVar.gold4),
info: highlightBase(cssVar.colorInfoBg),
primary: highlightBase(cssVar.colorPrimaryBgHover),
warning: highlightBase(cssVar.colorWarningBg),
};
});
const shine = keyframes`
0% {
background-position: 100%;
}
100% {
background-position: -100%;
}
`;
/**
* Shiny loading text animation
*/
export const shinyTextStyles = createStaticStyles(({ css, cssVar }) => ({
shinyText: css`
color: color-mix(in srgb, ${cssVar.colorText} 45%, transparent);
background: linear-gradient(
120deg,
color-mix(in srgb, ${cssVar.colorTextBase} 0%, transparent) 40%,
${cssVar.colorTextSecondary} 50%,
color-mix(in srgb, ${cssVar.colorTextBase} 0%, transparent) 60%
);
background-clip: text;
background-size: 200% 100%;
animation: ${shine} 1.5s linear infinite;
`,
}));

View file

@ -0,0 +1,15 @@
{
"name": "@lobechat/tool-runtime",
"version": "1.0.0",
"private": true,
"exports": {
".": "./src/index.ts"
},
"main": "./src/index.ts",
"dependencies": {
"@lobechat/prompts": "workspace:*"
},
"devDependencies": {
"@lobechat/types": "workspace:*"
}
}

View file

@ -0,0 +1,473 @@
import {
formatCommandOutput,
formatCommandResult,
formatEditResult,
formatFileContent,
formatFileList,
formatFileSearchResults,
formatGlobResults,
formatGrepResults,
formatKillResult,
formatMoveResults,
formatRenameResult,
formatWriteResult,
} from '@lobechat/prompts';
import type { BuiltinServerRuntimeOutput } from '@lobechat/types';
import type {
EditFileParams,
EditFileState,
GetCommandOutputParams,
GetCommandOutputState,
GlobFilesParams,
GlobFilesState,
GrepContentParams,
GrepContentState,
KillCommandParams,
KillCommandState,
ListFilesParams,
ListFilesState,
MoveFilesParams,
MoveFilesState,
ReadFileParams,
ReadFileState,
RenameFileParams,
RenameFileState,
RunCommandParams,
RunCommandState,
SearchFilesParams,
SearchFilesState,
ServiceResult,
WriteFileParams,
WriteFileState,
} from './types';
/**
* ComputerRuntime abstract base for computer operations (file system, shell, search).
*
* Subclasses implement `callService` to delegate to their specific backend
* (Electron IPC, cloud sandbox API, etc.). The base class handles:
* - Normalizing raw results into formatted content via `@lobechat/prompts`
* - Building consistent state objects for UI rendering
*/
export abstract class ComputerRuntime {
/**
* Call the underlying service to execute a tool.
* Each subclass maps this to its own transport (IPC, HTTP, tRPC, etc.).
*/
protected abstract callService(
toolName: string,
params: Record<string, any>,
): Promise<ServiceResult>;
// ==================== File Operations ====================
async listFiles(args: ListFilesParams): Promise<BuiltinServerRuntimeOutput> {
try {
const result = await this.callService('listLocalFiles', args);
if (!result.success) {
return this.errorOutput(result, { files: [], totalCount: 0 });
}
const files = result.result?.files || [];
const totalCount = result.result?.totalCount;
const state: ListFilesState = { files, totalCount };
const content = formatFileList({
directory: args.directoryPath,
files: files.map((f: { isDirectory: boolean; name: string }) => ({
isDirectory: f.isDirectory,
name: f.name,
})),
sortBy: args.sortBy,
sortOrder: args.sortOrder,
totalCount,
});
return { content, state, success: true };
} catch (error) {
return this.handleError(error);
}
}
async readFile(args: ReadFileParams): Promise<BuiltinServerRuntimeOutput> {
try {
const result = await this.callService('readLocalFile', args);
if (!result.success) {
return this.errorOutput(result, {
content: '',
endLine: args.endLine,
path: args.path,
startLine: args.startLine,
});
}
const r = result.result || {};
const fileContent = r.content || '';
const state: ReadFileState = {
charCount: r.charCount ?? fileContent.length,
content: fileContent,
endLine: args.endLine,
fileType: r.fileType,
filename: r.filename,
loc: r.loc,
path: args.path,
startLine: args.startLine,
totalCharCount: r.totalCharCount,
totalLines: r.totalLineCount ?? r.totalLines,
};
const lineRange: [number, number] | undefined =
args.startLine !== undefined && args.endLine !== undefined
? [args.startLine, args.endLine]
: undefined;
const content = formatFileContent({
content: fileContent,
lineRange,
path: args.path,
});
return { content, state, success: true };
} catch (error) {
return this.handleError(error);
}
}
async writeFile(args: WriteFileParams): Promise<BuiltinServerRuntimeOutput> {
try {
const result = await this.callService('writeLocalFile', args);
if (!result.success) {
return this.errorOutput(result, { path: args.path, success: false });
}
const state: WriteFileState = {
bytesWritten: result.result?.bytesWritten,
path: args.path,
success: true,
};
const content = formatWriteResult({ path: args.path, success: true });
return { content, state, success: true };
} catch (error) {
return this.handleError(error);
}
}
async editFile(args: EditFileParams): Promise<BuiltinServerRuntimeOutput> {
try {
const result = await this.callService('editLocalFile', args);
if (!result.success) {
return this.errorOutput(result, { path: args.path, replacements: 0 });
}
const state: EditFileState = {
diffText: result.result?.diffText,
linesAdded: result.result?.linesAdded,
linesDeleted: result.result?.linesDeleted,
path: args.path,
replacements: result.result?.replacements || 0,
};
const content = formatEditResult({
filePath: args.path,
linesAdded: state.linesAdded,
linesDeleted: state.linesDeleted,
replacements: state.replacements,
});
return { content, state, success: true };
} catch (error) {
return this.handleError(error);
}
}
async searchFiles(args: SearchFilesParams): Promise<BuiltinServerRuntimeOutput> {
try {
const result = await this.callService('searchLocalFiles', args);
if (!result.success) {
return this.errorOutput(result, { results: [], totalCount: 0 });
}
const rawResults = result.result?.results || result.result;
const results = Array.isArray(rawResults) ? rawResults : [];
const state: SearchFilesState = {
results,
totalCount: result.result?.totalCount || results.length,
};
const content = formatFileSearchResults(
results.map((r: { path: string }) => ({ path: r.path })),
);
return { content, state, success: true };
} catch (error) {
return this.handleError(error);
}
}
async moveFiles(args: MoveFilesParams): Promise<BuiltinServerRuntimeOutput> {
try {
const result = await this.callService('moveLocalFiles', args);
if (!result.success) {
return this.errorOutput(result, {
results: [],
successCount: 0,
totalCount: args.operations.length,
});
}
const rawResults = result.result?.results || result.result;
const results = Array.isArray(rawResults) ? rawResults : [];
const successCount =
result.result?.successCount ??
results.filter((r: { success: boolean }) => r.success).length;
const state: MoveFilesState = {
results,
successCount,
totalCount: args.operations.length,
};
const content = formatMoveResults(results);
return { content, state, success: true };
} catch (error) {
return this.handleError(error);
}
}
async renameFile(args: RenameFileParams): Promise<BuiltinServerRuntimeOutput> {
try {
const result = await this.callService('renameLocalFile', args);
if (!result.success) {
const errorMsg = result.error?.message || result.result?.error;
return {
content: formatRenameResult({
error: errorMsg,
newName: args.newName,
oldPath: args.oldPath,
success: false,
}),
state: {
error: errorMsg,
newPath: '',
oldPath: args.oldPath,
success: false,
} satisfies RenameFileState,
success: true,
};
}
const state: RenameFileState = {
error: result.result?.error,
newPath: result.result?.newPath || '',
oldPath: args.oldPath,
success: true,
};
const content = formatRenameResult({
error: result.result?.error,
newName: args.newName,
oldPath: args.oldPath,
success: true,
});
return { content, state, success: true };
} catch (error) {
return this.handleError(error);
}
}
// ==================== Shell Commands ====================
async runCommand(args: RunCommandParams): Promise<BuiltinServerRuntimeOutput> {
try {
const result = await this.callService('runCommand', args);
if (!result.success) {
return this.errorOutput(result, {
error: result.error?.message,
isBackground: args.background || false,
success: false,
});
}
const r = result.result || {};
const state: RunCommandState = {
commandId: r.commandId || r.shell_id,
error: r.error,
exitCode: r.exitCode ?? r.exit_code,
isBackground: args.background || false,
output: r.output,
stderr: r.stderr,
stdout: r.stdout,
success: result.success,
};
const content = formatCommandResult({
error: r.error,
exitCode: r.exitCode ?? r.exit_code,
shellId: r.commandId || r.shell_id,
stderr: r.stderr,
stdout: r.stdout || r.output,
success: result.success,
});
return { content, state, success: true };
} catch (error) {
return this.handleError(error);
}
}
async getCommandOutput(args: GetCommandOutputParams): Promise<BuiltinServerRuntimeOutput> {
try {
const result = await this.callService('getCommandOutput', args);
if (!result.success) {
return this.errorOutput(result, {
error: result.error?.message,
running: false,
success: false,
});
}
const r = result.result || {};
const state: GetCommandOutputState = {
error: r.error,
newOutput: r.newOutput || r.output,
running: r.running ?? false,
success: result.success,
};
const content = formatCommandOutput({
error: r.error,
output: r.newOutput || r.output,
running: r.running ?? false,
success: result.success,
});
return { content, state, success: true };
} catch (error) {
return this.handleError(error);
}
}
async killCommand(args: KillCommandParams): Promise<BuiltinServerRuntimeOutput> {
try {
const result = await this.callService('killCommand', args);
if (!result.success) {
return this.errorOutput(result, {
commandId: args.commandId,
error: result.error?.message,
success: false,
});
}
const state: KillCommandState = {
commandId: args.commandId,
error: result.result?.error,
success: result.success,
};
const content = formatKillResult({
error: result.result?.error,
shellId: args.commandId,
success: result.success,
});
return { content, state, success: true };
} catch (error) {
return this.handleError(error);
}
}
// ==================== Search & Find ====================
async grepContent(args: GrepContentParams): Promise<BuiltinServerRuntimeOutput> {
try {
const result = await this.callService('grepContent', args);
if (!result.success) {
return this.errorOutput(result, {
matches: [],
pattern: args.pattern,
totalMatches: 0,
});
}
const r = result.result || {};
const matches = r.matches || [];
const totalMatches = r.totalMatches ?? r.total_matches ?? 0;
const state: GrepContentState = {
matches,
pattern: args.pattern,
totalMatches,
};
const content = formatGrepResults({ matches, totalMatches });
return { content, state, success: true };
} catch (error) {
return this.handleError(error);
}
}
async globFiles(args: GlobFilesParams): Promise<BuiltinServerRuntimeOutput> {
try {
const result = await this.callService('globLocalFiles', args);
if (!result.success) {
return this.errorOutput(result, {
files: [],
pattern: args.pattern,
totalCount: 0,
});
}
const files = result.result?.files || [];
const totalCount = result.result?.totalCount ?? result.result?.total_files ?? files.length;
const state: GlobFilesState = {
files,
pattern: args.pattern,
totalCount,
};
const content = formatGlobResults({ files, totalFiles: totalCount });
return { content, state, success: true };
} catch (error) {
return this.handleError(error);
}
}
// ==================== Helpers ====================
protected handleError(error: unknown): BuiltinServerRuntimeOutput {
const errorMessage = error instanceof Error ? error.message : String(error);
return { content: errorMessage, error, success: false };
}
private errorOutput(result: ServiceResult, state: any): BuiltinServerRuntimeOutput {
return {
content: result.error?.message || JSON.stringify(result.error),
state,
success: true,
};
}
}

View file

@ -0,0 +1,2 @@
export { ComputerRuntime } from './ComputerRuntime';
export type * from './types';

View file

@ -0,0 +1,192 @@
/**
* Normalized result returned by the service layer.
* Each ComputerRuntime subclass maps its raw service response into this shape.
*/
export interface ServiceResult {
error?: { message: string; name?: string };
result: any;
success: boolean;
}
// ==================== Params ====================
export interface ListFilesParams {
directoryPath: string;
sortBy?: string;
sortOrder?: string;
}
export interface ReadFileParams {
endLine?: number;
path: string;
startLine?: number;
}
export interface WriteFileParams {
content: string;
createDirectories?: boolean;
path: string;
}
export interface EditFileParams {
all?: boolean;
path: string;
replace: string;
search: string;
}
export interface SearchFilesParams {
directory: string;
fileType?: string;
keyword?: string;
modifiedAfter?: string;
modifiedBefore?: string;
}
export interface MoveFilesParams {
operations: Array<{
destination: string;
source: string;
}>;
}
export interface RenameFileParams {
newName: string;
oldPath: string;
}
export interface GlobFilesParams {
directory?: string;
pattern: string;
}
export interface RunCommandParams {
background?: boolean;
command: string;
timeout?: number;
}
export interface GetCommandOutputParams {
commandId: string;
}
export interface KillCommandParams {
commandId: string;
}
export interface GrepContentParams {
directory: string;
filePattern?: string;
pattern: string;
recursive?: boolean;
}
// ==================== State ====================
export interface ListFilesState {
files: Array<{
isDirectory: boolean;
name: string;
path?: string;
size?: number;
}>;
totalCount?: number;
}
export interface ReadFileState {
/** Character count of the returned content */
charCount?: number;
content: string;
endLine?: number;
/** Base filename extracted from path */
filename?: string;
/** Detected file type (e.g., 'ts', 'md', 'json') */
fileType?: string;
/** Line range as tuple [start, end] */
loc?: [number, number];
path: string;
startLine?: number;
/** Total character count of the entire file */
totalCharCount?: number;
/** Total line count of the entire file */
totalLines?: number;
}
export interface WriteFileState {
bytesWritten?: number;
path: string;
success: boolean;
}
export interface EditFileState {
diffText?: string;
linesAdded?: number;
linesDeleted?: number;
path: string;
replacements: number;
}
export interface SearchFilesState {
results: Array<{
isDirectory?: boolean;
modifiedAt?: string;
name?: string;
path: string;
size?: number;
}>;
totalCount: number;
}
export interface MoveFilesState {
results: Array<{
destination?: string;
error?: string;
source?: string;
success: boolean;
}>;
successCount: number;
totalCount: number;
}
export interface RenameFileState {
error?: string;
newPath: string;
oldPath: string;
success: boolean;
}
export interface GlobFilesState {
files: string[];
pattern: string;
totalCount: number;
}
export interface RunCommandState {
commandId?: string;
error?: string;
exitCode?: number;
isBackground: boolean;
output?: string;
stderr?: string;
stdout?: string;
success: boolean;
}
export interface GetCommandOutputState {
error?: string;
newOutput?: string;
running: boolean;
success: boolean;
}
export interface KillCommandState {
commandId: string;
error?: string;
success: boolean;
}
export interface GrepContentState {
matches: Array<string | { content?: string; lineNumber?: number; path: string }>;
pattern: string;
totalMatches: number;
}

View file

@ -1,8 +1,10 @@
import { getBuiltinRender } from '@lobechat/builtin-tools/renders';
import { ToolRenderProvider } from '@lobechat/shared-tool-ui';
import { safeParseJSON } from '@lobechat/utils';
import { memo } from 'react';
import { useParseContent } from '../useParseContent';
import { useToolRenderCaps } from './useToolRenderCaps';
export interface BuiltinTypeProps {
apiName?: string;
@ -34,6 +36,7 @@ const BuiltinType = memo<BuiltinTypeProps>(
apiName,
}) => {
const { data } = useParseContent(content);
const caps = useToolRenderCaps();
const Render = getBuiltinRender(identifier, apiName);
@ -42,16 +45,18 @@ const BuiltinType = memo<BuiltinTypeProps>(
const args = safeParseJSON(argumentsStr);
return (
<Render
apiName={apiName}
args={args || {}}
content={data}
identifier={identifier}
messageId={messageId || toolCallId || ''}
pluginError={pluginError}
pluginState={pluginState}
toolCallId={toolCallId}
/>
<ToolRenderProvider value={caps}>
<Render
apiName={apiName}
args={args || {}}
content={data}
identifier={identifier}
messageId={messageId || toolCallId || ''}
pluginError={pluginError}
pluginState={pluginState}
toolCallId={toolCallId}
/>
</ToolRenderProvider>
);
},
);

View file

@ -0,0 +1,39 @@
import type { ToolRenderCapabilities } from '@lobechat/shared-tool-ui';
import { useMemo } from 'react';
import { localFileService } from '@/services/electron/localFileService';
import { useChatStore } from '@/store/chat';
import { chatToolSelectors } from '@/store/chat/slices/builtinTool/selectors';
import { useElectronStore } from '@/store/electron';
import { desktopStateSelectors } from '@/store/electron/selectors';
/**
* Provides platform-aware capabilities for tool render components.
* In Electron: provides file operations, loading state, relative paths.
* In browser: provides only loading state (no file operations).
*/
export const useToolRenderCaps = (): ToolRenderCapabilities => {
const isElectron = typeof window !== 'undefined' && !!(window as any).__ELECTRON__;
return useMemo<ToolRenderCapabilities>(() => {
const caps: ToolRenderCapabilities = {
isLoading: (messageId: string) => {
return chatToolSelectors.isSearchingLocalFiles(messageId)(useChatStore.getState());
},
};
if (isElectron) {
caps.openFile = (path: string) => {
localFileService.openLocalFile({ path });
};
caps.openFolder = (path: string) => {
localFileService.openLocalFolder({ isDirectory: false, path });
};
caps.displayRelativePath = (path: string) => {
return desktopStateSelectors.displayRelativePath(path)(useElectronStore.getState());
};
}
return caps;
}, [isElectron]);
};

View file

@ -52,6 +52,7 @@ export default {
'builtins.lobe-cloud-sandbox.apiName.runCommand': 'Run command',
'builtins.lobe-cloud-sandbox.apiName.searchLocalFiles': 'Search files',
'builtins.lobe-cloud-sandbox.apiName.writeLocalFile': 'Write file',
'builtins.lobe-cloud-sandbox.inspector.noResults': 'No results',
'builtins.lobe-cloud-sandbox.title': 'Cloud Sandbox',
'builtins.lobe-group-agent-builder.apiName.batchCreateAgents': 'Batch create agents',
'builtins.lobe-group-agent-builder.apiName.createAgent': 'Create agent',