diff --git a/apps/cli/package.json b/apps/cli/package.json index 8658d1d8..59e488b7 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -41,6 +41,8 @@ "scripts": { "cli": "node scripts/ensure-native.mjs && tsx src/cli.ts", "build": "tsup", + "build:full": "pnpm -w run build:web && pnpm run build && pnpm run prepare:web", + "prepare:web": "rm -rf dist-web && cp -r ../../dist-web dist-web", "ensure-native": "node scripts/ensure-native.mjs", "rebuild-native": "bash scripts/rebuild-native.sh", "rebuild-sqlite": "npm rebuild better-sqlite3", diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts index c290657b..b8dc0e45 100644 --- a/apps/cli/src/cli.ts +++ b/apps/cli/src/cli.ts @@ -7,6 +7,7 @@ import * as fs from 'fs' import * as path from 'path' +import { execSync } from 'child_process' import { Command } from 'commander' import { loadConfig, getConfigPath } from '@openchatlab/config' import { NodePathProvider, DatabaseManager } from '@openchatlab/node-runtime' @@ -342,6 +343,59 @@ program } }) +// chatlab web - 一键启动 Web UI +program + .command('web') + .description('一键启动 Web UI(含 HTTP API 后端 + 自动打开浏览器)') + .option('--port ', '服务端口', '3110') + .option('--host ', '监听地址', '127.0.0.1') + .option('--no-open', '不自动打开浏览器') + .action(async (options) => { + const { startHttpServer } = await import('./http') + const port = parseInt(options.port, 10) + const webDir = path.resolve(__dirname, '../dist-web') + let webRoot: string | undefined + + if (fs.existsSync(webDir)) { + webRoot = webDir + } else { + console.warn('Warning: dist-web/ not found, Web UI will not be available (API-only mode)') + } + + try { + const info = await startHttpServer({ port, host: options.host, webRoot }) + const url = `http://${info.host === '0.0.0.0' ? '127.0.0.1' : info.host}:${info.port}` + + console.log(`\nChatLab v${getVersion()}`) + console.log(` Web UI: ${url}`) + console.log(` Token: ${info.token}`) + + if (options.open && webRoot) { + openBrowser(url) + console.log(`\nBrowser opened. Press Ctrl+C to stop.`) + } else { + console.log(`\nPress Ctrl+C to stop.`) + } + + const shutdown = async () => { + console.log('\nShutting down...') + const { stopHttpServer } = await import('./http') + await stopHttpServer() + process.exit(0) + } + process.on('SIGINT', shutdown) + process.on('SIGTERM', shutdown) + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err) + if (message.includes('EADDRINUSE')) { + console.error(`Error: port ${port} is already in use`) + } else { + console.error(`Startup failed: ${message}`) + } + process.exit(1) + } + }) + // chatlab config - 配置管理 const configCmd = program.command('config').description('配置管理') @@ -362,6 +416,20 @@ configCmd // --- 工具函数 --- +function openBrowser(url: string): void { + try { + const cmd = + process.platform === 'darwin' + ? `open "${url}"` + : process.platform === 'win32' + ? `start "" "${url}"` + : `xdg-open "${url}"` + execSync(cmd, { stdio: 'ignore' }) + } catch { + console.log(` Open manually: ${url}`) + } +} + /** * 查找独立编译的 better-sqlite3 原生模块路径。 * 在非 Electron 环境下使用,避免与 electron-rebuild 冲突。 diff --git a/apps/cli/src/http/auth.ts b/apps/cli/src/http/auth.ts index 603a0fc7..5ae63289 100644 --- a/apps/cli/src/http/auth.ts +++ b/apps/cli/src/http/auth.ts @@ -9,6 +9,7 @@ import { timingSafeEqual } from 'crypto' import { unauthorized, errorResponse } from './errors' let cachedToken: string | null = null +let webModeEnabled = false /** * 设置 auth hook 使用的 token(由 server 启动时注入) @@ -17,6 +18,14 @@ export function setAuthToken(token: string): void { cachedToken = token } +/** + * Enable web mode: static resources and SPA paths bypass auth. + * Only `/api/` routes remain protected. + */ +export function setWebMode(enabled: boolean): void { + webModeEnabled = enabled +} + function safeTokenCompare(a: string, b: string): boolean { const bufA = Buffer.from(a) const bufB = Buffer.from(b) @@ -27,9 +36,12 @@ function safeTokenCompare(a: string, b: string): boolean { export async function authHook(request: FastifyRequest, reply: FastifyReply): Promise { if (!cachedToken) return - // /_web/ 内部 API 不需要认证(仅限同源 Web UI 使用) + // /_web/ internal API: no auth required (same-origin Web UI only) if (request.url.startsWith('/_web/')) return + // Web mode: only /api/ routes require auth; static files and SPA are public + if (webModeEnabled && !request.url.startsWith('/api/')) return + const authHeader = request.headers.authorization if (!authHeader || !authHeader.startsWith('Bearer ')) { const err = unauthorized() diff --git a/apps/cli/src/http/index.ts b/apps/cli/src/http/index.ts index b9565bfa..0e9e927a 100644 --- a/apps/cli/src/http/index.ts +++ b/apps/cli/src/http/index.ts @@ -13,7 +13,7 @@ import { loadConfig, writeConfigField, MigrationRunner, ALL_MIGRATIONS } from '@ import type { ChatLabConfig } from '@openchatlab/config' import { NodePathProvider, DatabaseManager, AIConversationManager } from '@openchatlab/node-runtime' import { createServer } from './server' -import { setAuthToken } from './auth' +import { setAuthToken, setWebMode } from './auth' import { registerSystemRoutes } from './routes/system' import { registerSessionRoutes } from './routes/sessions' import { registerWebRoutes } from './routes/web' @@ -107,8 +107,8 @@ export async function startHttpServer(options?: HttpServerOptions): Promise<{ initSync(server, dbManager, pathProvider, { port, host, token }) - // 托管 Web SPA 静态资源 if (options?.webRoot && fs.existsSync(options.webRoot)) { + setWebMode(true) const fastifyStatic = await import('@fastify/static') await server.register(fastifyStatic.default, { root: options.webRoot, @@ -145,6 +145,7 @@ export async function stopHttpServer(): Promise { dbManager = null } closeServerAiLogger() + setWebMode(false) server = null } }