## 方案二:基于 OPFS 的 bash 模拟层(纯前端)
### 核心理念
将 `bash` 工具重构为一个**命令解释器**,利用浏览器 **Origin Private File System (OPFS)** 和 **File System Access API** 模拟常见的文件/系统操作命令。无需服务器权限,所有操作都在浏览器沙箱内完成。
---
### 1. 支持的命令范围
只实现最常用的文件操作和简单系统查询,其余命令返回清晰的错误提示,引导 LLM 使用 `read`/`write`/`edit` 工具。
| 命令 | 模拟方式 | 说明 |
|------|----------|------|
| `ls [path]` | OPFS 目录遍历 | 列出文件和目录 |
| `cat <file>` | OPFS 文件读取 | 等同于 `read` 工具 |
| `echo "text" > file` | OPFS 写入(覆盖) | 等同于 `write` 工具 |
| `echo "text" >> file` | OPFS 追加写入 | |
| `mkdir <dir>` | OPFS 创建目录 | |
| `rm <file>` | OPFS 删除文件 | |
| `rmdir <dir>` | OPFS 删除目录(需为空) | |
| `pwd` | 返回虚拟工作目录 | 固定返回 `/sandbox` |
| `cd <dir>` | 更新虚拟工作目录 | 仅影响后续命令解析 |
| `touch <file>` | 创建空文件 | |
| `cp <src> <dst>` | OPFS 复制 | |
| `mv <src> <dst>` | OPFS 移动/重命名 | |
| `node <script>` | **无法支持** | 返回提示 |
| `npm/npx` | **无法支持** | 返回提示 |
---
### 2. 实现步骤
#### 2.1 创建文件系统封装层
```javascript
// opfsHelper.js
class VirtualFS {
constructor() {
this.root = null;
this.cwd = '/sandbox';
this.ready = this.init();
}
async init() {
// 获取 OPFS 根目录
this.root = await navigator.storage.getDirectory();
// 确保基础目录存在
await this.ensureDir('/sandbox');
}
// 解析相对路径为绝对路径
resolvePath(path) {
if (path.startsWith('/')) return path;
return `${this.cwd}/${path}`.replace(/\/+/g, '/').replace(/\/$/, '') || '/';
}
// 获取目录句柄
async getDirHandle(path, create = false) {
const parts = path.split('/').filter(Boolean);
let current = this.root;
for (const part of parts) {
if (create) {
current = await current.getDirectoryHandle(part, { create: true });
} else {
try {
current = await current.getDirectoryHandle(part);
} catch {
throw new Error(`目录不存在: ${path}`);
}
}
}
return current;
}
// 获取文件句柄
async getFileHandle(path, create = false) {
const dirPath = path.substring(0, path.lastIndexOf('/')) || '/';
const fileName = path.substring(path.lastIndexOf('/') + 1);
const dirHandle = await this.getDirHandle(dirPath, create);
if (create) {
return await dirHandle.getFileHandle(fileName, { create: true });
} else {
try {
return await dirHandle.getFileHandle(fileName);
} catch {
throw new Error(`文件不存在: ${path}`);
}
}
}
async ensureDir(path) {
const parts = path.split('/').filter(Boolean);
let current = this.root;
for (const part of parts) {
current = await current.getDirectoryHandle(part, { create: true });
}
}
}
```
#### 2.2 命令解释器
```javascript
// bashExecutor.js
class BashExecutor {
constructor(vfs) {
this.vfs = vfs;
}
async execute(command) {
const args = this.parseCommand(command);
if (!args.length) return '';
const cmd = args[0].toLowerCase();
const params = args.slice(1);
try {
switch (cmd) {
case 'ls': return await this.ls(params);
case 'cat': return await this.cat(params);
case 'echo': return await this.echo(params, command);
case 'mkdir': return await this.mkdir(params);
case 'rm': return await this.rm(params);
case 'rmdir': return await this.rmdir(params);
case 'pwd': return this.vfs.cwd;
case 'cd': return await this.cd(params);
case 'touch': return await this.touch(params);
case 'cp': return await this.cp(params);
case 'mv': return await this.mv(params);
case 'node':
case 'npm':
case 'npx':
return `[提示] 当前环境不支持 ${cmd} 命令。如需运行 JavaScript,请复制代码到本地 Node.js 环境执行。`;
default:
return `[提示] 不支持的 bash 命令: ${cmd}。支持的命令:ls, cat, echo, mkdir, rm, rmdir, pwd, cd, touch, cp, mv。对于文件读写,建议优先使用 read/write/edit 工具。`;
}
} catch (error) {
return `命令执行错误: ${error.message}`;
}
}
parseCommand(cmdStr) {
// 简单解析,支持引号
const regex = /"([^"]*)"|'([^']*)'|(\S+)/g;
const args = [];
let match;
while ((match = regex.exec(cmdStr)) !== null) {
args.push(match[1] || match[2] || match[3]);
}
return args;
}
// ---- 各命令实现 ----
async ls(params) {
const path = params[0] || '.';
const absPath = this.vfs.resolvePath(path);
const dirHandle = await this.vfs.getDirHandle(absPath);
const entries = [];
for await (const [name, handle] of dirHandle.entries()) {
entries.push(handle.kind === 'directory' ? `${name}/` : name);
}
return entries.sort().join('\n') || '(空目录)';
}
async cat(params) {
if (!params.length) throw new Error('cat: 缺少文件参数');
const absPath = this.vfs.resolvePath(params[0]);
const fileHandle = await this.vfs.getFileHandle(absPath);
const file = await fileHandle.getFile();
return await file.text();
}
async echo(params, rawCmd) {
// 解析重定向
const match = rawCmd.match(/echo\s+(.*?)\s*(>>?)\s*(.+)/);
if (match) {
const text = match[1].replace(/^["']|["']$/g, '');
const redirectType = match[2]; // > or >>
const filePath = this.vfs.resolvePath(match[3].trim());
const fileHandle = await this.vfs.getFileHandle(filePath, true);
const writable = await fileHandle.createWritable({ keepExistingData: redirectType === '>>' });
if (redirectType === '>>') {
const current = await (await fileHandle.getFile()).text();
await writable.write(current + text + '\n');
} else {
await writable.write(text + '\n');
}
await writable.close();
return '';
}
return params.join(' ');
}
async mkdir(params) {
if (!params.length) throw new Error('mkdir: 缺少目录名');
const absPath = this.vfs.resolvePath(params[0]);
await this.vfs.ensureDir(absPath);
return '';
}
async rm(params) {
if (!params.length) throw new Error('rm: 缺少文件名');
const absPath = this.vfs.resolvePath(params[0]);
const parentDir = await this.vfs.getDirHandle(absPath.substring(0, absPath.lastIndexOf('/')) || '/');
const fileName = absPath.split('/').pop();
await parentDir.removeEntry(fileName);
return '';
}
async rmdir(params) {
if (!params.length) throw new Error('rmdir: 缺少目录名');
const absPath = this.vfs.resolvePath(params[0]);
const parentDir = await this.vfs.getDirHandle(absPath.substring(0, absPath.lastIndexOf('/')) || '/');
const dirName = absPath.split('/').pop();
await parentDir.removeEntry(dirName, { recursive: false });
return '';
}
async cd(params) {
if (!params.length) return this.vfs.cwd;
const newPath = params[0];
if (newPath === '..') {
this.vfs.cwd = this.vfs.cwd.substring(0, this.vfs.cwd.lastIndexOf('/')) || '/';
} else {
this.vfs.cwd = this.vfs.resolvePath(newPath);
}
return '';
}
async touch(params) {
if (!params.length) throw new Error('touch: 缺少文件名');
const absPath = this.vfs.resolvePath(params[0]);
const fileHandle = await this.vfs.getFileHandle(absPath, true);
const writable = await fileHandle.createWritable({ keepExistingData: true });
await writable.write('');
await writable.close();
return '';
}
async cp(params) {
if (params.length < 2) throw new Error('cp: 缺少源或目标');
const src = this.vfs.resolvePath(params[0]);
const dst = this.vfs.resolvePath(params[1]);
const srcFile = await this.vfs.getFileHandle(src);
const content = await (await srcFile.getFile()).text();
const dstFile = await this.vfs.getFileHandle(dst, true);
const writable = await dstFile.createWritable();
await writable.write(content);
await writable.close();
return '';
}
async mv(params) {
await this.cp(params);
await this.rm([params[0]]);
return '';
}
}
```
#### 2.3 集成到 bash 工具 Handler
在原 `bash` 工具的执行函数中,初始化 VFS(单例),然后调用解释器。
```javascript
// 在 executor 模块顶部
let bashExecutor = null;
const vfsInstance = new VirtualFS();
// bash handler 实现
async function bashHandler(params) {
const command = params.command?.trim();
if (!command) return '请提供命令';
// 初始化 VFS(只执行一次)
if (!bashExecutor) {
await vfsInstance.ready;
bashExecutor = new BashExecutor(vfsInstance);
}
try {
const result = await bashExecutor.execute(command);
return result || '(命令执行成功,无输出)';
} catch (error) {
return `bash 执行失败: ${error.message}`;
}
}
```
---
### 3. 与现有工具的协作
- **read / write / edit** 使用已有的文件操作实现(可能基于 OPFS 或内存),**bash 模拟层共享同一套 OPFS 文件系统**,因此两者操作的是同一批文件。
- 如果现有工具还未切换到 OPFS,需要统一文件存储层。最简单的方法是让 `read`/`write`/`edit` 也改为调用 `VirtualFS` 的方法,或者让它们直接读写 `localStorage`(但 OPFS 更强大)。
- 建议将 `VirtualFS` 作为全局单例,所有文件工具(bash / read / write / edit)都通过它操作,保证一致性。
---
### 4. 能力提升与限制
**你现在可以**:
- 在浏览器内创建、修改、删除文件,目录管理
- 让 LLM 使用熟悉的 Linux 命令完成文件操作
- 脚本内容通过 `cat > script.sh` 写入,但不能运行
**你依然不能**:
- 运行二进制程序、`node`、`python` 等解释器
- 安装系统依赖
- 直接操作真实文件系统(除非用户通过 `<input type="file">` 主动导入)
---
### 5. 可选的增强
- **`pipe` 支持**:如 `ls | grep *.txt`,可在解释器内模拟简单的管道链
- **`grep`/`wc`/`head`/`tail`** 等文本处理命令,用 JS 实现不难
- **颜色输出剥离**:LLM 返回结果时自动去除 ANSI 转义码
---
完成上述修改后,`bash` 工具即可在纯前端环境中“复活”,无需任何服务器权限。需要我提供与现有 `read`/`write` 工具统一的 OPFS 改造方案吗?