mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
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:
1 parent
b1427e2422
commit
09d4b1ddac
4 files changed
+86
-3
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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 冲突。
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user