diff --git a/.gitignore b/.gitignore index e3fb01e..53ac07d 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/package.json b/package.json index 1059215..5873a4d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/core/package.json b/packages/core/package.json index 4f5fdda..c40153b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", diff --git a/packages/core/scripts/generate-descriptions.ts b/packages/core/scripts/generate-descriptions.ts new file mode 100644 index 0000000..15b667b --- /dev/null +++ b/packages/core/scripts/generate-descriptions.ts @@ -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 = {', + ]; + + 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(); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e645638..7464b52 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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, diff --git a/packages/core/src/permission/bash-parser.ts b/packages/core/src/permission/bash-parser.ts index b2cc1cf..9e6c42a 100644 --- a/packages/core/src/permission/bash-parser.ts +++ b/packages/core/src/permission/bash-parser.ts @@ -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 | null = null; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let TreeSitter: any = null; /** * 获取 wasm 文件路径 @@ -55,19 +67,51 @@ async function initParser(): Promise { 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 { /** * 从语法树节点中提取命令信息 */ -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); diff --git a/packages/core/src/repomap/index.ts b/packages/core/src/repomap/index.ts index 92ac939..1ef0bde 100644 --- a/packages/core/src/repomap/index.ts +++ b/packages/core/src/repomap/index.ts @@ -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, diff --git a/packages/core/src/repomap/tags/extractor.ts b/packages/core/src/repomap/tags/extractor.ts index c4c83b0..70e6bad 100644 --- a/packages/core/src/repomap/tags/extractor.ts +++ b/packages/core/src/repomap/tags/extractor.ts @@ -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 { 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); diff --git a/packages/core/src/repomap/tags/wasm-loader.ts b/packages/core/src/repomap/tags/wasm-loader.ts new file mode 100644 index 0000000..7c2f11e --- /dev/null +++ b/packages/core/src/repomap/tags/wasm-loader.ts @@ -0,0 +1,52 @@ +/** + * WASM Loader for Tree-sitter + * + * 支持从文件系统或嵌入的 base64 数据加载 WASM + */ + +// 嵌入的 WASM 数据(构建时生成) +let embeddedWasm: Map | null = null; + +/** + * 设置嵌入的 WASM 数据 + */ +export function setEmbeddedWasm(wasm: Map): 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__'; +} diff --git a/packages/core/src/tools/load_description.ts b/packages/core/src/tools/load_description.ts index dab8978..8fb5a59 100644 --- a/packages/core/src/tools/load_description.ts +++ b/packages/core/src/tools/load_description.ts @@ -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 = { - // 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; } diff --git a/packages/server/package.json b/packages/server/package.json index 3c1506b..48816d3 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -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", diff --git a/packages/server/src/bin/server.ts b/packages/server/src/bin/server.ts index adb5251..39116ec 100644 --- a/packages/server/src/bin/server.ts +++ b/packages/server/src/bin/server.ts @@ -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,44 +36,51 @@ 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 to listen on (default: 3000) - -H, --host Host to bind to (default: 127.0.0.1) - --auth Enable authentication - --no-auth Disable authentication - -t, --token Set authentication token - -h, --help Show this help message + -p, --port Port to listen on (default: 3000) + -H, --host Host to bind to (default: 127.0.0.1) + -s, --static Serve static files from directory (Web UI) + --auth Enable authentication + --no-auth Disable authentication + -t, --token Set authentication token + -h, --help Show this help message 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({ diff --git a/packages/server/src/bin/standalone.ts b/packages/server/src/bin/standalone.ts new file mode 100644 index 0000000..bbf9de0 --- /dev/null +++ b/packages/server/src/bin/standalone.ts @@ -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 to listen on (default: 3000) + -H, --host Host to bind to (default: 127.0.0.1) + --auth Enable authentication + --no-auth Disable authentication + -t, --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: 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); +}); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index f3899d7..d9bddba 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -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 { } } +/** + * 配置静态文件托管 + */ +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 { - 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 { ║ Health: http://${host}:${port}/health ║ Agent: ${coreStatus} ║ Auth: ${authStatus} +║ Static: ${staticStatus} ╚════════════════════════════════════════════╝ `); diff --git a/scripts/build-standalone.ts b/scripts/build-standalone.ts new file mode 100644 index 0000000..330e59b --- /dev/null +++ b/scripts/build-standalone.ts @@ -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 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 { + const assets: Map = 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 = 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 = { + '.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 { + const wasmFiles: Map = 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 = 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); +});