feat: 支持 Bun standalone 单文件打包

- core: 工具描述从文件系统加载改为编译时内联生成
- core: 添加 WASM 加载器支持嵌入式 WASM 数据
- core: bash-parser 使用动态导入 web-tree-sitter
- server: 添加静态文件托管支持 (--static 参数)
- server: 新增 standalone 入口点 (嵌入 Web UI + WASM)
- scripts: 添加 build-standalone.ts 构建脚本
- 更新 .gitignore 忽略生成文件
This commit is contained in:
2025-12-30 13:57:29 +08:00
parent 5f38753f6d
commit c3db79c00d
15 changed files with 949 additions and 102 deletions
+2 -1
View File
@@ -40,7 +40,8 @@
}
},
"scripts": {
"build": "tsc && cp -r src/tools/descriptions dist/tools/",
"prebuild": "bun run scripts/generate-descriptions.ts",
"build": "tsc",
"dev": "tsc --watch",
"test": "vitest run",
"test:watch": "vitest",
@@ -0,0 +1,89 @@
#!/usr/bin/env bun
/**
* Generate Tool Descriptions
*
* 将 descriptions 目录下的 .txt 文件转换为 TypeScript 模块
* 用于 Bun 打包时内联工具描述
*/
import * as fs from 'fs';
import * as path from 'path';
const DESCRIPTIONS_DIR = path.join(__dirname, '../src/tools/descriptions');
const OUTPUT_FILE = path.join(__dirname, '../src/tools/descriptions.generated.ts');
interface DescriptionEntry {
category: string;
name: string;
content: string;
}
function collectDescriptions(): DescriptionEntry[] {
const entries: DescriptionEntry[] = [];
const categories = fs.readdirSync(DESCRIPTIONS_DIR);
for (const category of categories) {
const categoryPath = path.join(DESCRIPTIONS_DIR, category);
const stat = fs.statSync(categoryPath);
if (stat.isDirectory()) {
const files = fs.readdirSync(categoryPath);
for (const file of files) {
if (file.endsWith('.txt')) {
const name = file.replace('.txt', '');
const content = fs.readFileSync(path.join(categoryPath, file), 'utf-8').trim();
entries.push({ category, name, content });
}
}
} else if (category.endsWith('.txt')) {
const name = category.replace('.txt', '');
const content = fs.readFileSync(categoryPath, 'utf-8').trim();
entries.push({ category: '', name, content });
}
}
return entries;
}
function escapeString(str: string): string {
return str
.replace(/\\/g, '\\\\')
.replace(/`/g, '\\`')
.replace(/\$/g, '\\$');
}
function generateCode(entries: DescriptionEntry[]): string {
const lines: string[] = [
'// Auto-generated by scripts/generate-descriptions.ts',
'// Do not edit manually',
'',
'export const TOOL_DESCRIPTIONS: Record<string, string> = {',
];
for (const entry of entries) {
const escapedContent = escapeString(entry.content);
lines.push(` '${entry.name}': \`${escapedContent}\`,`);
lines.push('');
}
lines.push('};');
lines.push('');
return lines.join('\n');
}
function main() {
console.log('Collecting tool descriptions...');
const entries = collectDescriptions();
console.log(`Found ${entries.length} descriptions`);
console.log('Generating TypeScript code...');
const code = generateCode(entries);
console.log(`Writing to ${OUTPUT_FILE}...`);
fs.writeFileSync(OUTPUT_FILE, code, 'utf-8');
console.log('Done!');
}
main();
+3
View File
@@ -354,6 +354,9 @@ export type {
FileSearchOptions,
} from './file-index/index.js';
// RepoMap WASM 加载器(用于 standalone 构建)
export { setEmbeddedWasm, hasEmbeddedWasm } from './repomap/index.js';
// Constants - 统一存储路径
export {
APP_DIR_NAME,
+68 -22
View File
@@ -1,29 +1,41 @@
import { Parser, Language, type Node } from 'web-tree-sitter';
import * as path from 'path';
import { fileURLToPath } from 'url';
import { hasEmbeddedWasm, getEmbeddedWasm } from '../repomap/tags/wasm-loader.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// 解析后的命令结构
export interface ParsedCommand {
name: string; // 命令名,如 "git"
name: string; // 命令名,如 "git"
subcommand?: string; // 子命令,如 "push"
args: string[]; // 参数列表
text: string; // 原始命令文本
args: string[]; // 参数列表
text: string; // 原始命令文本
}
// 解析结果
export interface ParseResult {
commands: ParsedCommand[]; // 所有解析出的命令
commands: ParsedCommand[]; // 所有解析出的命令
success: boolean;
error?: string;
}
// 单例解析器
let parserInstance: Parser | null = null;
let bashLanguage: Language | null = null;
// Tree-sitter 类型
interface TreeSitterNode {
type: string;
text: string;
childCount: number;
child(index: number): TreeSitterNode | null;
}
// 单例解析器 (使用 any 类型避免与 web-tree-sitter 的类型冲突)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let parserInstance: any = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let bashLanguage: any = null;
let initPromise: Promise<void> | null = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let TreeSitter: any = null;
/**
* 获取 wasm 文件路径
@@ -55,19 +67,51 @@ async function initParser(): Promise<void> {
initPromise = (async () => {
try {
// 初始化 tree-sitter
await Parser.init({
locateFile: (scriptName: string) => {
return getWasmPath(scriptName);
},
});
// 动态导入 web-tree-sitter
TreeSitter = await import('web-tree-sitter');
// 检查是否有嵌入的 WASM
if (hasEmbeddedWasm()) {
const wasmData = getEmbeddedWasm('tree-sitter.wasm');
if (wasmData) {
// 使用嵌入的 WASM 数据初始化
await TreeSitter.Parser.init({
wasmBinary: wasmData,
});
} else {
// 回退到文件系统
await TreeSitter.Parser.init({
locateFile: (scriptName: string) => {
return getWasmPath(scriptName);
},
});
}
} else {
// 使用文件系统加载
await TreeSitter.Parser.init({
locateFile: (scriptName: string) => {
return getWasmPath(scriptName);
},
});
}
// 创建解析器实例
parserInstance = new Parser();
parserInstance = new TreeSitter.Parser();
// 加载 bash 语言
const bashWasmPath = getWasmPath('tree-sitter-bash.wasm');
bashLanguage = await Language.load(bashWasmPath);
if (hasEmbeddedWasm()) {
const bashWasmData = getEmbeddedWasm('tree-sitter-bash.wasm');
if (bashWasmData) {
bashLanguage = await TreeSitter.Parser.Language.load(bashWasmData);
} else {
const bashWasmPath = getWasmPath('tree-sitter-bash.wasm');
bashLanguage = await TreeSitter.Parser.Language.load(bashWasmPath);
}
} else {
const bashWasmPath = getWasmPath('tree-sitter-bash.wasm');
bashLanguage = await TreeSitter.Parser.Language.load(bashWasmPath);
}
parserInstance.setLanguage(bashLanguage);
} catch (error) {
initPromise = null;
@@ -81,7 +125,7 @@ async function initParser(): Promise<void> {
/**
* 从语法树节点中提取命令信息
*/
function extractCommandFromNode(node: Node): ParsedCommand {
function extractCommandFromNode(node: TreeSitterNode): ParsedCommand {
const parts: string[] = [];
for (let i = 0; i < node.childCount; i++) {
@@ -101,8 +145,10 @@ function extractCommandFromNode(node: Node): ParsedCommand {
// 对于字符串类型,提取内部文本(去掉引号)
if (child.type === 'string' || child.type === 'raw_string') {
const text = child.text;
if ((text.startsWith('"') && text.endsWith('"')) ||
(text.startsWith("'") && text.endsWith("'"))) {
if (
(text.startsWith('"') && text.endsWith('"')) ||
(text.startsWith("'") && text.endsWith("'"))
) {
parts.push(text.slice(1, -1));
} else {
parts.push(text);
@@ -138,8 +184,8 @@ function extractCommandFromNode(node: Node): ParsedCommand {
/**
* 递归查找所有命令节点
*/
function findCommandNodes(node: Node): Node[] {
const commands: Node[] = [];
function findCommandNodes(node: TreeSitterNode): TreeSitterNode[] {
const commands: TreeSitterNode[] = [];
if (node.type === 'command') {
commands.push(node);
+3
View File
@@ -11,6 +11,9 @@ export { RepoMap, createRepoMap } from './repomap.js';
// Tag 提取
export { TagExtractor } from './tags/index.js';
// WASM 加载器(用于 standalone 构建)
export { setEmbeddedWasm, hasEmbeddedWasm } from './tags/wasm-loader.js';
// PageRank 排序
export {
Graph,
+33 -3
View File
@@ -8,6 +8,7 @@ import * as path from 'path';
import { fileURLToPath } from 'url';
import type { Tag } from '../types.js';
import { getLanguageFromFilename } from '../types.js';
import { hasEmbeddedWasm, getEmbeddedWasm } from './wasm-loader.js';
// Tree-sitter 类型(web-tree-sitter
interface TreeSitterParser {
@@ -56,8 +57,25 @@ async function initTreeSitter(): Promise<void> {
try {
const TreeSitter = await import('web-tree-sitter');
// Parser 是命名导出的类,init 是其静态方法
await TreeSitter.Parser.init();
// 检查是否有嵌入的 WASM
if (hasEmbeddedWasm()) {
const wasmData = getEmbeddedWasm('tree-sitter.wasm');
if (wasmData) {
// 使用嵌入的 WASM 数据直接初始化
// web-tree-sitter 支持传入 WebAssembly.Module 或 ArrayBuffer
await TreeSitter.Parser.init({
// 传入 WASM 二进制数据
wasmBinary: wasmData,
});
} else {
await TreeSitter.Parser.init();
}
} else {
// 使用默认行为(从 node_modules 加载)
await TreeSitter.Parser.init();
}
ParserClass = TreeSitter.Parser;
treeSitterInitialized = true;
} catch (error) {
@@ -215,7 +233,19 @@ export class TagExtractor {
}
try {
// 尝试加载 WASM 语言文件
// 首先检查是否有嵌入的语言 WASM
if (hasEmbeddedWasm()) {
const wasmName = `tree-sitter-${lang}.wasm`;
const wasmData = getEmbeddedWasm(wasmName);
if (wasmData) {
// 使用嵌入的 WASM 数据加载语言
const language = await ParserClass.Language.load(wasmData);
this.languages.set(lang, language);
return language;
}
}
// 回退到从文件系统加载
const wasmPath = this.getWasmPath(lang);
const language = await ParserClass.Language.load(wasmPath);
this.languages.set(lang, language);
@@ -0,0 +1,52 @@
/**
* WASM Loader for Tree-sitter
*
* 支持从文件系统或嵌入的 base64 数据加载 WASM
*/
// 嵌入的 WASM 数据(构建时生成)
let embeddedWasm: Map<string, string> | null = null;
/**
* 设置嵌入的 WASM 数据
*/
export function setEmbeddedWasm(wasm: Map<string, string>): void {
embeddedWasm = wasm;
}
/**
* 检查是否有嵌入的 WASM
*/
export function hasEmbeddedWasm(): boolean {
return embeddedWasm !== null && embeddedWasm.size > 0;
}
/**
* 获取嵌入的 WASM 数据
*/
export function getEmbeddedWasm(name: string): Uint8Array | null {
if (!embeddedWasm) return null;
const base64 = embeddedWasm.get(name);
if (!base64) return null;
// 解码 base64
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
/**
* 获取 tree-sitter.wasm 的路径或数据
*/
export function getTreeSitterWasmPath(): string | undefined {
// 如果没有嵌入数据,返回 undefined 让默认行为生效
if (!hasEmbeddedWasm()) {
return undefined;
}
// 返回一个特殊标记,在 init 时会被拦截
return '__embedded__';
}
+11 -59
View File
@@ -1,64 +1,16 @@
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
/**
* Tool Description Loader
*
* 从内联的 descriptions.generated.ts 加载工具描述
* 支持 Bun 单文件打包
*/
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// 工具名到子目录的映射
const TOOL_CATEGORY_MAP: Record<string, string> = {
// shell
bash: 'shell',
kill_shell: 'shell',
// filesystem
read_file: 'filesystem',
write_file: 'filesystem',
edit_file: 'filesystem',
multi_edit: 'filesystem',
glob: 'filesystem',
grep: 'filesystem',
// web
web_search: 'web',
web_extract: 'web',
// git
git_status: 'git',
git_diff: 'git',
git_log: 'git',
git_branch: 'git',
git_add: 'git',
git_commit: 'git',
git_push: 'git',
git_pull: 'git',
git_checkout: 'git',
git_stash: 'git',
// todo
todo_write: 'todo',
// plan
ask_user_question: 'plan',
enter_plan_mode: 'plan',
exit_plan_mode: 'plan',
// task
task: 'task',
task_output: 'task',
// checkpoint
checkpoint_create: 'checkpoint',
checkpoint_list: 'checkpoint',
checkpoint_diff: 'checkpoint',
checkpoint_restore: 'checkpoint',
undo: 'checkpoint',
// repomap
repo_map: 'repomap',
};
import { TOOL_DESCRIPTIONS } from './descriptions.generated.js';
export function loadDescription(toolName: string): string {
const category = TOOL_CATEGORY_MAP[toolName];
const filePath = category
? path.join(__dirname, 'descriptions', category, `${toolName}.txt`)
: path.join(__dirname, 'descriptions', `${toolName}.txt`);
try {
return fs.readFileSync(filePath, 'utf-8').trim();
} catch {
throw new Error(`无法加载工具描述文件: ${filePath}`);
const description = TOOL_DESCRIPTIONS[toolName];
if (!description) {
throw new Error(`工具描述未找到: ${toolName}`);
}
return description;
}