feat(server): add static file hosting to chatlab serve

Phase 3A.3: Enable chatlab serve to host the Web SPA frontend
alongside the HTTP API, creating a complete local web experience.

- Add --web [dir] option to chatlab serve (defaults to dist-web/)
- Use @fastify/static for efficient static file serving
- SPA history fallback via setNotFoundHandler -> index.html
- Skip web hosting gracefully if dist-web/ doesn't exist
- Add webRoot option to HttpServerOptions / startHttpServer
This commit is contained in:
digua committed 2026-05-13 19:57:25 +08:00
1 parent 17855858d7
commit b1fbeb6f1d
4 files changed
+132 -5

No files matched your search

+1
View File
@@ -14,6 +14,7 @@
"rebuild-sqlite": "npm rebuild better-sqlite3"
},
"dependencies": {
"@fastify/static": "^9.1.3",
"@modelcontextprotocol/sdk": "^1.12.1",
"@openchatlab/config": "workspace:*",
"@openchatlab/core": "workspace:*",
+19 -5
View File
@@ -21,10 +21,7 @@ import {
const program = new Command()
program
.name('chatlab')
.description('ChatLab - 聊天记录分析工具')
.version('0.0.1')
program.name('chatlab').description('ChatLab - 聊天记录分析工具').version('0.0.1')
// chatlab sessions - 列出所有会话
program
@@ -129,7 +126,9 @@ program
if (options.format === 'json') {
console.log(JSON.stringify(result, null, 2))
} else {
console.log(`搜索 "${keyword}" - 共 ${result.total} 条结果${result.hasMore ? '(显示前 ' + limit + ' 条)' : ''}:\n`)
console.log(
`搜索 "${keyword}" - 共 ${result.total} 条结果${result.hasMore ? '(显示前 ' + limit + ' 条)' : ''}:\n`
)
for (const msg of result.messages) {
const time = new Date(msg.timestamp * 1000).toLocaleString()
console.log(` [${time}] ${msg.senderName}: ${msg.content}`)
@@ -275,20 +274,35 @@ program
.option('--port <port>', '服务端口', '3210')
.option('--host <host>', '监听地址', '127.0.0.1')
.option('--token <token>', '自定义 Bearer Token(不指定则从配置文件读取或自动生成)')
.option('--web [dir]', '托管 Web 前端静态资源(默认查找 dist-web/)')
.action(async (options) => {
const { startHttpServer } = await import('./http')
const port = parseInt(options.port, 10)
let webRoot: string | undefined
if (options.web !== undefined) {
const webDir = typeof options.web === 'string' ? options.web : path.resolve(__dirname, '../../dist-web')
if (fs.existsSync(webDir)) {
webRoot = path.resolve(webDir)
} else {
console.warn(`警告: Web 目录不存在: ${webDir},将仅启动 API 服务`)
}
}
try {
const info = await startHttpServer({
port,
host: options.host,
token: options.token || undefined,
webRoot,
})
console.log(`\nChatLab HTTP API 已启动`)
console.log(` 地址: http://${info.host}:${info.port}`)
console.log(` Token: ${info.token}`)
if (webRoot) {
console.log(` Web UI: http://${info.host}:${info.port}/`)
}
console.log(`\n示例:`)
console.log(` curl -H "Authorization: Bearer ${info.token}" http://${info.host}:${info.port}/api/v1/status`)
console.log(`\n按 Ctrl+C 停止服务\n`)
+16
View File
@@ -25,6 +25,8 @@ export interface HttpServerOptions {
port?: number
host?: string
token?: string
/** dist-web/ 目录路径,启用后托管 Web SPA 静态资源 */
webRoot?: string
}
function resolveNativeBinding(): string | undefined {
@@ -88,6 +90,20 @@ export async function startHttpServer(options?: HttpServerOptions): Promise<{
registerSessionRoutes(server, dbManager)
registerWebRoutes(server, dbManager)
// 托管 Web SPA 静态资源
if (options?.webRoot && fs.existsSync(options.webRoot)) {
const fastifyStatic = await import('@fastify/static')
await server.register(fastifyStatic.default, {
root: options.webRoot,
prefix: '/',
wildcard: false,
})
// SPA fallback: 所有非 API/非静态文件路由返回 index.html
server.setNotFoundHandler(async (_request, reply) => {
return reply.sendFile('index.html')
})
}
await server.listen({ port, host })
return { port, host, token }
+96
View File
@@ -161,6 +161,9 @@ importers:
packages/server:
dependencies:
'@fastify/static':
specifier: ^9.1.3
version: 9.1.3
'@modelcontextprotocol/sdk':
specifier: ^1.12.1
version: 1.29.0(zod@3.25.76)
@@ -879,6 +882,9 @@ packages:
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@fastify/accept-negotiator@2.0.1':
resolution: {integrity: sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==}
'@fastify/ajv-compiler@4.0.5':
resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==}
@@ -897,6 +903,12 @@ packages:
'@fastify/proxy-addr@5.1.0':
resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==}
'@fastify/send@4.1.0':
resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==}
'@fastify/static@9.1.3':
resolution: {integrity: sha512-aXrYtsiryLhRxRNaxNqsn7FUISeb7rB9q4eHUPIot5aeQBLNahnz1m6thzm7JWC1poSGXS9XrX8DvuMivp2hkQ==}
'@floating-ui/core@1.7.4':
resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==}
@@ -1004,6 +1016,10 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@lukeed/ms@2.0.2':
resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
engines: {node: '>=8'}
'@malept/cross-spawn-promise@2.0.0':
resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==}
engines: {node: '>= 12.13.0'}
@@ -2368,6 +2384,10 @@ packages:
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
balanced-match@4.0.4:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
@@ -2415,6 +2435,10 @@ packages:
brace-expansion@2.0.2:
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
brace-expansion@5.0.6:
resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==}
engines: {node: 18 || 20 || >=22}
braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
@@ -3127,6 +3151,9 @@ packages:
resolution: {integrity: sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA==}
hasBin: true
fastify-plugin@5.1.0:
resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==}
fastify@5.8.4:
resolution: {integrity: sha512-sa42J1xylbBAYUWALSBoyXKPDUvM3OoNOibIefA+Oha57FryXKKCZarA1iDntOCWp3O35voZLuDg2mdODXtPzQ==}
@@ -3341,6 +3368,10 @@ packages:
resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==}
hasBin: true
glob@13.0.6:
resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==}
engines: {node: 18 || 20 || >=22}
glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
deprecated: Glob versions prior to v9 are no longer supported
@@ -3964,6 +3995,11 @@ packages:
engines: {node: '>=4.0.0'}
hasBin: true
mime@3.0.0:
resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==}
engines: {node: '>=10.0.0'}
hasBin: true
mimic-fn@2.1.0:
resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
engines: {node: '>=6'}
@@ -3984,6 +4020,10 @@ packages:
resolution: {integrity: sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==}
engines: {node: 20 || >=22}
minimatch@10.2.5:
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
engines: {node: 18 || 20 || >=22}
minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
@@ -4026,6 +4066,10 @@ packages:
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
engines: {node: '>=16 || 14 >=14.17'}
minipass@7.1.3:
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
engines: {node: '>=16 || 14 >=14.17'}
minizlib@3.1.0:
resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==}
engines: {node: '>= 18'}
@@ -4280,6 +4324,10 @@ packages:
resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
engines: {node: '>=16 || 14 >=14.18'}
path-scurry@2.0.2:
resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==}
engines: {node: 18 || 20 || >=22}
path-to-regexp@8.4.2:
resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==}
@@ -6357,6 +6405,8 @@ snapshots:
'@eslint/core': 0.17.0
levn: 0.4.1
'@fastify/accept-negotiator@2.0.1': {}
'@fastify/ajv-compiler@4.0.5':
dependencies:
ajv: 8.18.0
@@ -6380,6 +6430,23 @@ snapshots:
'@fastify/forwarded': 3.0.1
ipaddr.js: 2.3.0
'@fastify/send@4.1.0':
dependencies:
'@lukeed/ms': 2.0.2
escape-html: 1.0.3
fast-decode-uri-component: 1.0.1
http-errors: 2.0.1
mime: 3.0.0
'@fastify/static@9.1.3':
dependencies:
'@fastify/accept-negotiator': 2.0.1
'@fastify/send': 4.1.0
content-disposition: 1.1.0
fastify-plugin: 5.1.0
fastq: 1.20.1
glob: 13.0.6
'@floating-ui/core@1.7.4':
dependencies:
'@floating-ui/utils': 0.2.10
@@ -6503,6 +6570,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@lukeed/ms@2.0.2': {}
'@malept/cross-spawn-promise@2.0.0':
dependencies:
cross-spawn: 7.0.6
@@ -8206,6 +8275,8 @@ snapshots:
balanced-match@1.0.2: {}
balanced-match@4.0.4: {}
base64-js@1.5.1: {}
baseline-browser-mapping@2.9.19: {}
@@ -8261,6 +8332,10 @@ snapshots:
dependencies:
balanced-match: 1.0.2
brace-expansion@5.0.6:
dependencies:
balanced-match: 4.0.4
braces@3.0.3:
dependencies:
fill-range: 7.1.1
@@ -9117,6 +9192,8 @@ snapshots:
dependencies:
strnum: 2.1.2
fastify-plugin@5.1.0: {}
fastify@5.8.4:
dependencies:
'@fastify/ajv-compiler': 4.0.5
@@ -9416,6 +9493,12 @@ snapshots:
package-json-from-dist: 1.0.1
path-scurry: 1.11.1
glob@13.0.6:
dependencies:
minimatch: 10.2.5
minipass: 7.1.3
path-scurry: 2.0.2
glob@7.2.3:
dependencies:
fs.realpath: 1.0.0
@@ -9984,6 +10067,8 @@ snapshots:
mime@2.6.0: {}
mime@3.0.0: {}
mimic-fn@2.1.0: {}
mimic-fn@4.0.0: {}
@@ -9996,6 +10081,10 @@ snapshots:
dependencies:
'@isaacs/brace-expansion': 5.0.1
minimatch@10.2.5:
dependencies:
brace-expansion: 5.0.6
minimatch@3.1.2:
dependencies:
brace-expansion: 1.1.12
@@ -10040,6 +10129,8 @@ snapshots:
minipass@7.1.2: {}
minipass@7.1.3: {}
minizlib@3.1.0:
dependencies:
minipass: 7.1.2
@@ -10286,6 +10377,11 @@ snapshots:
lru-cache: 10.4.3
minipass: 7.1.2
path-scurry@2.0.2:
dependencies:
lru-cache: 11.2.5
minipass: 7.1.3
path-to-regexp@8.4.2: {}
pathe@1.1.2: {}