feat(cli): add chatlab web command for one-click Web UI launch

- New `chatlab web` subcommand: starts HTTP API + serves Web SPA + auto-opens browser
- Options: --port, --host, --no-open
- Cross-platform `openBrowser()` using open/start/xdg-open
- Add `setWebMode()` in auth: static resources bypass token auth in web mode
- Add `build:full` and `prepare:web` scripts for dist-web preparation
This commit is contained in:
digua committed 2026-05-19 21:53:00 +08:00
1 parent b1427e2422
commit 09d4b1ddac
4 files changed
+86 -3

No files matched your search

+2
View File
@@ -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",
+68
View File
@@ -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 <port>', '服务端口', '3110')
.option('--host <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 冲突。
+13 -1
View File
@@ -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<void> {
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()
+3 -2
View File
@@ -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<void> {
dbManager = null
}
closeServerAiLogger()
setWebMode(false)
server = null
}
}