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
+5
View File
@@ -45,6 +45,11 @@ packages/desktop/src-tauri/Cargo.lock
*.temp
.cache/
# Generated files
packages/core/src/tools/descriptions.generated.ts
packages/server/src/embedded-assets.generated.ts
packages/server/src/embedded-wasm.generated.ts
# AI Open reference code
ai-open/
+3 -1
View File
@@ -41,7 +41,9 @@
"website:build": "pnpm --filter @ai-assistant/website build",
"website:dev": "pnpm --filter @ai-assistant/website dev",
"website:preview": "pnpm --filter @ai-assistant/website preview"
"website:preview": "pnpm --filter @ai-assistant/website preview",
"build:standalone": "bun run scripts/build-standalone.ts"
},
"keywords": [
"ai",
+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,
+59 -13
View File
@@ -1,6 +1,6 @@
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);
@@ -20,10 +20,22 @@ export interface ParseResult {
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({
// 动态导入 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 语言
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 Language.load(bashWasmPath);
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,
+32 -2
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 是其静态方法
// 检查是否有嵌入的 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;
}
+2
View File
@@ -13,6 +13,8 @@
},
"scripts": {
"build": "tsc",
"build:bundle": "bun build src/bin/server.ts --target bun --outfile dist/server.bundle.js",
"build:standalone": "bun build src/bin/server.ts --compile --outfile dist/ai-server",
"dev": "tsc --watch",
"start": "bun run src/bin/server.ts",
"start:dev": "bun --watch run src/bin/server.ts",
+17 -9
View File
@@ -14,12 +14,13 @@
import { app, websocket, startServer } from '../index.js';
// 解析命令行参数
function parseArgs(): { port: number; host: string; auth?: boolean; token?: string } {
function parseArgs(): { port: number; host: string; auth?: boolean; token?: string; staticDir?: string } {
const args = process.argv.slice(2);
let port = 3000;
let host = '127.0.0.1';
let auth: boolean | undefined;
let token: string | undefined;
let staticDir: string | undefined;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--port' || args[i] === '-p') {
@@ -35,16 +36,20 @@ function parseArgs(): { port: number; host: string; auth?: boolean; token?: stri
} else if (args[i] === '--token' || args[i] === '-t') {
token = args[i + 1];
i++;
} else if (args[i] === '--static' || args[i] === '-s') {
staticDir = args[i + 1];
i++;
} else if (args[i] === '--help' || args[i] === '-h') {
console.log(`
AI Assistant Server
Usage:
bun run server.ts [options]
ai-server [options]
Options:
-p, --port <port> Port to listen on (default: 3000)
-H, --host <host> Host to bind to (default: 127.0.0.1)
-s, --static <dir> Serve static files from directory (Web UI)
--auth Enable authentication
--no-auth Disable authentication
-t, --token <token> Set authentication token
@@ -52,27 +57,30 @@ Options:
Examples:
# Local development (no auth)
bun run server.ts
ai-server
# Serve with Web UI
ai-server --static ./web-dist
# Remote server with auth
bun run server.ts --host 0.0.0.0 --auth
ai-server --host 0.0.0.0 --auth
# Custom token
bun run server.ts --host 0.0.0.0 --token mysecrettoken
# Full production setup
ai-server --host 0.0.0.0 --static ./web-dist --token mysecrettoken
`);
process.exit(0);
}
}
return { port, host, auth, token };
return { port, host, auth, token, staticDir };
}
// 主函数
async function main() {
const { port, host, auth, token } = parseArgs();
const { port, host, auth, token, staticDir } = parseArgs();
// 初始化并打印启动信息
await startServer({ port, host, auth, token });
await startServer({ port, host, auth, token, staticDir });
// 启动 Bun 服务器
const server = Bun.serve({
+320
View File
@@ -0,0 +1,320 @@
#!/usr/bin/env bun
/**
* AI Assistant Standalone Server
*
* 包含嵌入式 Web UI 的独立服务器
* 由 scripts/build-standalone.ts 构建
*/
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
import { createBunWebSocket } from 'hono/bun';
import {
sessionsRouter,
toolsRouter,
configRouter,
filesRouter,
commandsRouter,
mcpRouter,
hooksRouter,
agentsRouter,
checkpointsRouter,
providersRouter,
servicesRouter,
contextRouter,
lspRouter,
systemCommandsRouter,
statsRouter,
} from '../routes/index.js';
import {
handleWebSocket,
handleWebSocketMessage,
handleWebSocketClose,
getConnectionStats,
} from '../ws.js';
import { handleSSE, getSSEStats } from '../sse.js';
import { getSessionManager } from '../session/manager.js';
import { initCore, isCoreAvailable } from '../agent/index.js';
import {
authMiddleware,
initAuth,
getAuthConfig,
generateToken,
addToken,
} from '../auth/index.js';
// 嵌入的 Web 资源(构建时生成)
import { EMBEDDED_ASSETS, decodeAsset } from '../embedded-assets.generated.js';
// 嵌入的 WASM 文件(构建时生成)
import { EMBEDDED_WASM } from '../embedded-wasm.generated.js';
// 创建 Hono 应用
const app = new Hono();
// WebSocket 升级
const { upgradeWebSocket, websocket } = createBunWebSocket();
// 中间件
app.use('*', logger());
app.use(
'*',
cors({
origin: '*',
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization'],
})
);
// 认证中间件
app.use('*', authMiddleware);
// 健康检查
app.get('/health', (c) => {
const sessionManager = getSessionManager();
const wsStats = getConnectionStats();
const sseStats = getSSEStats();
const authConfig = getAuthConfig();
return c.json({
status: 'ok',
timestamp: new Date().toISOString(),
agent: {
coreAvailable: isCoreAvailable(),
},
auth: {
enabled: authConfig.enabled,
tokenCount: authConfig.tokens.length,
},
stats: {
sessions: sessionManager.count(),
websocket: wsStats,
sse: sseStats,
},
});
});
// API 路由
const api = new Hono();
api.route('/sessions', sessionsRouter);
api.route('/tools', toolsRouter);
api.route('/config', configRouter);
api.route('/files', filesRouter);
api.route('/commands', commandsRouter);
api.route('/mcp', mcpRouter);
api.route('/hooks', hooksRouter);
api.route('/agents', agentsRouter);
api.route('/checkpoints', checkpointsRouter);
api.route('/providers', providersRouter);
api.route('/services', servicesRouter);
api.route('/lsp', lspRouter);
api.route('/system-commands', systemCommandsRouter);
api.route('/stats', statsRouter);
api.route('/', contextRouter);
// SSE 事件流
api.get('/sessions/:id/events', handleSSE);
// WebSocket 端点
api.get(
'/ws/:sessionId',
upgradeWebSocket((c) => {
const sessionId = c.req.param('sessionId');
return {
onOpen(_event, ws) {
handleWebSocket(ws, sessionId);
},
onMessage(event, ws) {
handleWebSocketMessage(ws, sessionId, event.data);
},
onClose(_event, ws) {
handleWebSocketClose(ws, sessionId);
},
onError(event, ws) {
console.error('[WS] Error:', event);
handleWebSocketClose(ws, sessionId);
},
};
})
);
app.route('/api', api);
// 嵌入式 Web UI 静态文件服务
app.get('*', (c) => {
let reqPath = c.req.path;
// 尝试查找精确匹配
let asset = EMBEDDED_ASSETS.get(reqPath);
// 如果没找到且不是文件路径,尝试 index.html (SPA 路由)
if (!asset && !reqPath.includes('.')) {
asset = EMBEDDED_ASSETS.get('/index.html');
}
if (asset) {
const body = decodeAsset(asset);
return new Response(body, {
headers: {
'Content-Type': asset.mimeType,
'Cache-Control': reqPath.includes('/assets/') ? 'public, max-age=31536000' : 'no-cache',
},
});
}
return c.json({ success: false, error: 'Not found' }, 404);
});
// 解析命令行参数
function parseArgs(): { port: number; host: string; auth?: boolean; token?: string } {
const args = process.argv.slice(2);
let port = 3000;
let host = '127.0.0.1';
let auth: boolean | undefined;
let token: string | undefined;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--port' || args[i] === '-p') {
port = parseInt(args[i + 1], 10) || 3000;
i++;
} else if (args[i] === '--host' || args[i] === '-H') {
host = args[i + 1] || '127.0.0.1';
i++;
} else if (args[i] === '--auth') {
auth = true;
} else if (args[i] === '--no-auth') {
auth = false;
} else if (args[i] === '--token' || args[i] === '-t') {
token = args[i + 1];
i++;
} else if (args[i] === '--help' || args[i] === '-h') {
console.log(`
AI Assistant (Standalone)
A terminal-based AI coding assistant with embedded Web UI.
Usage:
ai-assistant [options]
Options:
-p, --port <port> Port to listen on (default: 3000)
-H, --host <host> Host to bind to (default: 127.0.0.1)
--auth Enable authentication
--no-auth Disable authentication
-t, --token <token> Set authentication token
-h, --help Show this help message
Examples:
# Local development
ai-assistant
# Listen on all interfaces with auth
ai-assistant --host 0.0.0.0 --auth
# Custom port and token
ai-assistant --port 8080 --token mysecrettoken
After starting, open http://localhost:<port> in your browser.
`);
process.exit(0);
}
}
return { port, host, auth, token };
}
// 主函数
async function main() {
const { port, host, auth, token } = parseArgs();
// 初始化嵌入的 WASM(在 Core 初始化之前)
if (EMBEDDED_WASM.size > 0) {
try {
// 动态导入 core 模块来设置 WASM
const core = await import('@ai-assistant/core');
if (typeof core.setEmbeddedWasm === 'function') {
core.setEmbeddedWasm(EMBEDDED_WASM);
console.log(`[Server] Embedded WASM initialized (${EMBEDDED_WASM.size} files)`);
}
} catch (error) {
console.warn('[Server] Failed to initialize embedded WASM:', error);
}
}
// 初始化 SessionManager
const sessionManager = getSessionManager();
await sessionManager.init();
// 初始化 Core
const coreLoaded = await initCore();
if (coreLoaded) {
console.log('[Server] Core module initialized');
} else {
console.warn('[Server] Core module not available, running in limited mode');
}
// 初始化认证
const isRemote = host !== '127.0.0.1' && host !== 'localhost';
const authEnabled = auth !== undefined ? auth : isRemote;
initAuth({
enabled: authEnabled,
tokens: [],
skipPaths: ['/health', '/api/health'],
});
if (authEnabled) {
const serverToken = token || generateToken();
addToken(serverToken);
console.log(`[Auth] Authentication enabled`);
console.log(`[Auth] Token: ${serverToken}`);
}
const coreStatus = isCoreAvailable() ? '✅ Core loaded' : '⚠️ Core not available';
const authConfig = getAuthConfig();
const authStatus = authConfig.enabled ? '🔐 Enabled' : '🔓 Disabled';
console.log(`
╔════════════════════════════════════════════╗
║ AI Assistant (Standalone) ║
╠════════════════════════════════════════════╣
║ Web UI: http://${host}:${port}
║ REST API: http://${host}:${port}/api
║ WebSocket: ws://${host}:${port}/api/ws/:sessionId
║ Health: http://${host}:${port}/health
║ Agent: ${coreStatus}
║ Auth: ${authStatus}
║ Web UI: 📦 Embedded
╚════════════════════════════════════════════╝
`);
// 启动服务器
const server = Bun.serve({
port,
hostname: host,
fetch: app.fetch,
websocket,
});
console.log(`Server running at http://${host}:${port}`);
console.log(`Open http://${host === '0.0.0.0' ? 'localhost' : host}:${port} in your browser\n`);
// 优雅关闭
process.on('SIGINT', () => {
console.log('\nShutting down server...');
server.stop();
process.exit(0);
});
process.on('SIGTERM', () => {
console.log('\nShutting down server...');
server.stop();
process.exit(0);
});
}
main().catch((error) => {
console.error('Failed to start server:', error);
process.exit(1);
});
+50 -1
View File
@@ -7,6 +7,7 @@
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
import { serveStatic } from 'hono/bun';
import { createBunWebSocket } from 'hono/bun';
import { sessionsRouter, toolsRouter, configRouter, filesRouter, commandsRouter, mcpRouter, hooksRouter, agentsRouter, checkpointsRouter, providersRouter, servicesRouter, contextRouter, lspRouter, systemCommandsRouter, statsRouter } from './routes/index.js';
@@ -158,6 +159,8 @@ export interface ServerOptions {
auth?: boolean;
/** 预设的 token */
token?: string;
/** 静态文件目录 (托管 Web UI) */
staticDir?: string;
}
/**
@@ -210,18 +213,63 @@ export async function initServer(options: ServerOptions = {}): Promise<void> {
}
}
/**
* 配置静态文件托管
*/
export function configureStaticFiles(staticDir: string): void {
// 静态文件中间件
app.use(
'/*',
serveStatic({
root: staticDir,
rewriteRequestPath: (path) => {
// API 和健康检查路径不走静态文件
if (path.startsWith('/api') || path === '/health') {
return path;
}
return path;
},
})
);
// SPA 路由回退 - 所有非 API/静态文件请求返回 index.html
app.get('*', async (c) => {
const path = c.req.path;
// 跳过 API 和健康检查
if (path.startsWith('/api') || path === '/health') {
return c.notFound();
}
// 返回 index.html
const indexPath = `${staticDir}/index.html`;
const file = Bun.file(indexPath);
if (await file.exists()) {
return new Response(file, {
headers: { 'Content-Type': 'text/html' },
});
}
return c.notFound();
});
}
/**
* 启动服务器 (Bun 环境)
*/
export async function startServer(options: ServerOptions = {}): Promise<void> {
const { port = 3000, host = '127.0.0.1' } = options;
const { port = 3000, host = '127.0.0.1', staticDir } = options;
// 初始化
await initServer(options);
// 配置静态文件托管
if (staticDir) {
configureStaticFiles(staticDir);
}
const coreStatus = isCoreAvailable() ? '✅ Core loaded' : '⚠️ Core not available';
const authConfig = getAuthConfig();
const authStatus = authConfig.enabled ? '🔐 Enabled' : '🔓 Disabled';
const staticStatus = staticDir ? `📁 ${staticDir}` : '❌ Disabled';
console.log(`
╔════════════════════════════════════════════╗
@@ -233,6 +281,7 @@ export async function startServer(options: ServerOptions = {}): Promise<void> {
║ Health: http://${host}:${port}/health
║ Agent: ${coreStatus}
║ Auth: ${authStatus}
║ Static: ${staticStatus}
╚════════════════════════════════════════════╝
`);
+285
View File
@@ -0,0 +1,285 @@
#!/usr/bin/env bun
/**
* Build Standalone Server with Embedded Web UI and WASM
*
* 构建包含 Web UI 和 WASM 文件的独立服务器可执行文件
*
* Usage:
* bun run scripts/build-standalone.ts
* bun run scripts/build-standalone.ts --output ./dist/ai-assistant
*/
import * as fs from 'fs';
import * as path from 'path';
import { $ } from 'bun';
const ROOT_DIR = path.join(import.meta.dir, '..');
const WEB_DIR = path.join(ROOT_DIR, 'packages/web');
const SERVER_DIR = path.join(ROOT_DIR, 'packages/server');
const NODE_MODULES = path.join(ROOT_DIR, 'node_modules');
const CORE_DIR = path.join(ROOT_DIR, 'packages/core');
// 解析命令行参数
function parseArgs(): { output: string } {
const args = process.argv.slice(2);
let output = path.join(ROOT_DIR, 'dist/ai-assistant');
for (let i = 0; i < args.length; i++) {
if (args[i] === '--output' || args[i] === '-o') {
output = args[i + 1];
i++;
} else if (args[i] === '--help' || args[i] === '-h') {
console.log(`
Build Standalone Server with Embedded Web UI
Usage:
bun run scripts/build-standalone.ts [options]
Options:
-o, --output <path> Output executable path (default: dist/ai-assistant)
-h, --help Show this help message
`);
process.exit(0);
}
}
return { output };
}
async function main() {
const { output } = parseArgs();
const outputDir = path.dirname(output);
console.log('🚀 Building standalone AI Assistant...\n');
// 1. 构建 Core
console.log('📦 Building @ai-assistant/core...');
await $`pnpm --filter @ai-assistant/core build`.quiet();
console.log(' ✅ Core built\n');
// 2. 构建 Web
console.log('📦 Building @ai-assistant/web...');
await $`pnpm --filter @ai-assistant/web build`.quiet();
console.log(' ✅ Web built\n');
// 3. 生成嵌入式 Web 资源
console.log('📝 Embedding Web UI assets...');
const webDistDir = path.join(WEB_DIR, 'dist');
const embeddedAssetsFile = path.join(SERVER_DIR, 'src/embedded-assets.generated.ts');
await generateEmbeddedAssets(webDistDir, embeddedAssetsFile);
console.log(' ✅ Assets embedded\n');
// 3.5. 生成嵌入式 WASM 资源
console.log('📝 Embedding WASM files...');
const embeddedWasmFile = path.join(SERVER_DIR, 'src/embedded-wasm.generated.ts');
await generateEmbeddedWasm(embeddedWasmFile);
console.log(' ✅ WASM embedded\n');
// 4. 构建 Server(使用嵌入式入口)
console.log('📦 Building standalone server...');
// 确保输出目录存在
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
await $`bun build ${SERVER_DIR}/src/bin/standalone.ts --compile --outfile ${output}`.quiet();
console.log(' ✅ Server built\n');
// 5. 清理生成的文件
if (fs.existsSync(embeddedAssetsFile)) {
fs.unlinkSync(embeddedAssetsFile);
}
if (fs.existsSync(embeddedWasmFile)) {
fs.unlinkSync(embeddedWasmFile);
}
// 6. 显示结果
const stats = fs.statSync(output);
const sizeMB = (stats.size / 1024 / 1024).toFixed(1);
console.log('═══════════════════════════════════════════');
console.log('✅ Build complete!');
console.log('═══════════════════════════════════════════');
console.log(` Output: ${output}`);
console.log(` Size: ${sizeMB} MB`);
console.log('');
console.log('Usage:');
console.log(` ${output} --help`);
console.log(` ${output} --port 3000`);
console.log('═══════════════════════════════════════════');
}
/**
* 将 Web dist 目录的所有文件转换为嵌入式 TypeScript 模块
*/
async function generateEmbeddedAssets(webDistDir: string, outputFile: string): Promise<void> {
const assets: Map<string, { content: string; mimeType: string }> = new Map();
// 递归收集所有文件
function collectFiles(dir: string, basePath: string = '') {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
const relativePath = basePath ? `${basePath}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
collectFiles(fullPath, relativePath);
} else {
const content = fs.readFileSync(fullPath);
const base64 = content.toString('base64');
const mimeType = getMimeType(entry.name);
assets.set('/' + relativePath, { content: base64, mimeType });
}
}
}
collectFiles(webDistDir);
// 生成 TypeScript 代码
const lines: string[] = [
'// Auto-generated by scripts/build-standalone.ts',
'// Do not edit manually',
'',
'export interface EmbeddedAsset {',
' content: string; // base64 encoded',
' mimeType: string;',
'}',
'',
'export const EMBEDDED_ASSETS: Map<string, EmbeddedAsset> = new Map([',
];
for (const [path, asset] of assets) {
// 转义特殊字符
const escapedContent = asset.content;
lines.push(` ['${path}', { content: '${escapedContent}', mimeType: '${asset.mimeType}' }],`);
}
lines.push(']);');
lines.push('');
lines.push('export function getAsset(path: string): EmbeddedAsset | undefined {');
lines.push(' return EMBEDDED_ASSETS.get(path);');
lines.push('}');
lines.push('');
lines.push('export function decodeAsset(asset: EmbeddedAsset): Uint8Array {');
lines.push(' const binaryString = atob(asset.content);');
lines.push(' const bytes = new Uint8Array(binaryString.length);');
lines.push(' for (let i = 0; i < binaryString.length; i++) {');
lines.push(' bytes[i] = binaryString.charCodeAt(i);');
lines.push(' }');
lines.push(' return bytes;');
lines.push('}');
lines.push('');
fs.writeFileSync(outputFile, lines.join('\n'), 'utf-8');
console.log(` Generated ${assets.size} embedded assets`);
}
/**
* 根据文件扩展名获取 MIME 类型
*/
function getMimeType(filename: string): string {
const ext = path.extname(filename).toLowerCase();
const mimeTypes: Record<string, string> = {
'.html': 'text/html',
'.css': 'text/css',
'.js': 'application/javascript',
'.json': 'application/json',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.eot': 'application/vnd.ms-fontobject',
'.webmanifest': 'application/manifest+json',
'.txt': 'text/plain',
'.xml': 'application/xml',
};
return mimeTypes[ext] || 'application/octet-stream';
}
/**
* 生成嵌入式 WASM 模块
* 包含 tree-sitter.wasm 和可用的语言 WASM 文件
*/
async function generateEmbeddedWasm(outputFile: string): Promise<void> {
const wasmFiles: Map<string, string> = new Map();
// 查找 web-tree-sitter 的 tree-sitter.wasm
const treeSitterWasmPaths = [
path.join(NODE_MODULES, '.pnpm/web-tree-sitter@0.25.10/node_modules/web-tree-sitter/tree-sitter.wasm'),
path.join(NODE_MODULES, 'web-tree-sitter/tree-sitter.wasm'),
];
for (const wasmPath of treeSitterWasmPaths) {
if (fs.existsSync(wasmPath)) {
const content = fs.readFileSync(wasmPath);
wasmFiles.set('tree-sitter.wasm', content.toString('base64'));
console.log(` Found tree-sitter.wasm (${(content.length / 1024).toFixed(1)} KB)`);
break;
}
}
// 查找语言特定的 WASM 文件
const languageWasmPatterns = [
// pnpm 格式
{ dir: path.join(NODE_MODULES, '.pnpm'), pattern: /tree-sitter-(\w+)@.*\/tree-sitter-\1\.wasm$/ },
];
// 扫描 node_modules/.pnpm 查找语言 WASM
const pnpmDir = path.join(NODE_MODULES, '.pnpm');
if (fs.existsSync(pnpmDir)) {
const entries = fs.readdirSync(pnpmDir);
for (const entry of entries) {
if (entry.startsWith('tree-sitter-') && !entry.startsWith('tree-sitter-wasms')) {
// 提取语言名称
const match = entry.match(/^tree-sitter-(\w+)@/);
if (match) {
const lang = match[1];
const wasmPath = path.join(pnpmDir, entry, 'node_modules', `tree-sitter-${lang}`, `tree-sitter-${lang}.wasm`);
if (fs.existsSync(wasmPath)) {
const content = fs.readFileSync(wasmPath);
wasmFiles.set(`tree-sitter-${lang}.wasm`, content.toString('base64'));
console.log(` Found tree-sitter-${lang}.wasm (${(content.length / 1024).toFixed(1)} KB)`);
}
}
}
}
}
if (wasmFiles.size === 0) {
console.warn(' ⚠️ No WASM files found');
}
// 生成 TypeScript 代码
const lines: string[] = [
'// Auto-generated by scripts/build-standalone.ts',
'// Do not edit manually',
'',
'/**',
' * Embedded WASM files for tree-sitter',
' * Keys: wasm filename, Values: base64 encoded content',
' */',
'export const EMBEDDED_WASM: Map<string, string> = new Map([',
];
for (const [name, base64] of wasmFiles) {
lines.push(` ['${name}', '${base64}'],`);
}
lines.push(']);');
lines.push('');
fs.writeFileSync(outputFile, lines.join('\n'), 'utf-8');
console.log(` Generated ${wasmFiles.size} embedded WASM files`);
}
main().catch((error) => {
console.error('❌ Build failed:', error);
process.exit(1);
});