From a8f0d5cb883786da56d92418341008df82a8c877 Mon Sep 17 00:00:00 2001 From: digua Date: Sun, 31 May 2026 20:17:07 +0800 Subject: [PATCH] refactor: extract shared HTTP routes into @openchatlab/http-routes package Migrate 30+ endpoints from apps/cli/src/http/ to packages/http-routes/ with a platform-agnostic HttpRouteContext DI interface, enabling future reuse by Electron Internal Server. Batch 1: auth, errors, context, sessions, members, preferences Batch 2: analytics, sql, session-index, export, nlp Batch 3: REST system, REST sessions Includes 7 smoke tests via Fastify inject covering /api/v1/* and /_web/* route groups. Co-authored-by: Cursor --- apps/cli/package.json | 1 + apps/cli/src/http/auth.test.ts | 2 +- apps/cli/src/http/index.ts | 13 +-- apps/cli/src/http/routes/web/index.ts | 22 ++-- apps/cli/src/http/routes/web/sessions.test.ts | 17 ++- apps/cli/src/http/server.ts | 3 +- apps/cli/tsconfig.json | 3 +- packages/http-routes/package.json | 16 +++ .../http => packages/http-routes/src}/auth.ts | 9 +- packages/http-routes/src/context.ts | 23 ++++ .../http-routes/src}/errors.ts | 2 +- packages/http-routes/src/helpers.ts | 15 +++ packages/http-routes/src/index.ts | 29 +++++ packages/http-routes/src/register.ts | 35 ++++++ .../http-routes/src}/routes/sessions.ts | 37 +++--- .../src/routes/shared-routes.test.ts | 110 ++++++++++++++++++ .../http-routes/src}/routes/system.ts | 9 +- .../http-routes/src}/routes/web/analytics.ts | 18 +-- .../http-routes/src}/routes/web/export.ts | 7 +- .../http-routes/src}/routes/web/members.ts | 7 +- .../http-routes/src/routes/web}/nlp.ts | 42 ++----- .../src/routes/web}/preferences.ts | 14 +-- .../src}/routes/web/session-index.ts | 7 +- .../http-routes/src}/routes/web/sessions.ts | 7 +- .../http-routes/src}/routes/web/sql.ts | 6 +- pnpm-lock.yaml | 21 ++++ 26 files changed, 347 insertions(+), 128 deletions(-) create mode 100644 packages/http-routes/package.json rename {apps/cli/src/http => packages/http-routes/src}/auth.ts (87%) create mode 100644 packages/http-routes/src/context.ts rename {apps/cli/src/http => packages/http-routes/src}/errors.ts (96%) create mode 100644 packages/http-routes/src/helpers.ts create mode 100644 packages/http-routes/src/index.ts create mode 100644 packages/http-routes/src/register.ts rename {apps/cli/src/http => packages/http-routes/src}/routes/sessions.ts (83%) create mode 100644 packages/http-routes/src/routes/shared-routes.test.ts rename {apps/cli/src/http => packages/http-routes/src}/routes/system.ts (89%) rename {apps/cli/src/http => packages/http-routes/src}/routes/web/analytics.ts (91%) rename {apps/cli/src/http => packages/http-routes/src}/routes/web/export.ts (82%) rename {apps/cli/src/http => packages/http-routes/src}/routes/web/members.ts (89%) rename {apps/cli/src/http/routes => packages/http-routes/src/routes/web}/nlp.ts (51%) rename {apps/cli/src/http/routes => packages/http-routes/src/routes/web}/preferences.ts (83%) rename {apps/cli/src/http => packages/http-routes/src}/routes/web/session-index.ts (92%) rename {apps/cli/src/http => packages/http-routes/src}/routes/web/sessions.ts (85%) rename {apps/cli/src/http => packages/http-routes/src}/routes/web/sql.ts (89%) diff --git a/apps/cli/package.json b/apps/cli/package.json index 435bc3e0..21ada094 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -66,6 +66,7 @@ "devDependencies": { "@openchatlab/config": "workspace:*", "@openchatlab/core": "workspace:*", + "@openchatlab/http-routes": "workspace:*", "@openchatlab/node-runtime": "workspace:*", "@openchatlab/parser": "workspace:*", "@openchatlab/shared-types": "workspace:*", diff --git a/apps/cli/src/http/auth.test.ts b/apps/cli/src/http/auth.test.ts index 766c7250..981a5cc8 100644 --- a/apps/cli/src/http/auth.test.ts +++ b/apps/cli/src/http/auth.test.ts @@ -1,6 +1,6 @@ import { describe, it, beforeEach } from 'node:test' import assert from 'node:assert/strict' -import { setAuthToken, setRequireAuth, authHook } from './auth' +import { setAuthToken, setRequireAuth, authHook } from '@openchatlab/http-routes' function fakeRequest(url: string, authorization?: string) { return { url, headers: { authorization } } as never diff --git a/apps/cli/src/http/index.ts b/apps/cli/src/http/index.ts index 56eb066b..809b8485 100644 --- a/apps/cli/src/http/index.ts +++ b/apps/cli/src/http/index.ts @@ -18,13 +18,9 @@ import { verifyCliDataPath, } from '@openchatlab/node-runtime' import { createServer } from './server' -import { setAuthToken, setRequireAuth } from './auth' -import { registerSystemRoutes } from './routes/system' -import { registerSessionRoutes } from './routes/sessions' +import { setAuthToken, setRequireAuth } from '@openchatlab/http-routes' import { registerWebRoutes } from './routes/web' -import { registerNlpRoutes } from './routes/nlp' import { registerAiRoutes } from './routes/ai' -import { registerPreferencesRoutes } from './routes/preferences' import { registerProxyRoutes } from './routes/proxy' import { initServerAiLogger, closeServerAiLogger } from '../ai/logger' import { initSync, cleanupSync } from '../sync' @@ -133,12 +129,8 @@ export async function startHttpServer(options?: HttpServerOptions): Promise<{ limits: { fileSize: 1024 * 1024 * 1024 }, // 1GB }) - registerSystemRoutes(server, dbManager) - registerSessionRoutes(server, dbManager) - registerNlpRoutes(server, dbManager) registerAiRoutes(server, dbManager, convManager) registerWebRoutes(server, dbManager, { pathProvider, nativeBinding }) - registerPreferencesRoutes(server, pathProvider) initSync(server, dbManager, pathProvider, { port, host, token }) @@ -189,8 +181,5 @@ export async function stopHttpServer(): Promise { } export { createServer } from './server' -export { registerSystemRoutes } from './routes/system' -export { registerSessionRoutes } from './routes/sessions' export { registerWebRoutes } from './routes/web' -export { registerNlpRoutes } from './routes/nlp' export { registerAiRoutes } from './routes/ai' diff --git a/apps/cli/src/http/routes/web/index.ts b/apps/cli/src/http/routes/web/index.ts index eb9df35e..d5578767 100644 --- a/apps/cli/src/http/routes/web/index.ts +++ b/apps/cli/src/http/routes/web/index.ts @@ -23,16 +23,11 @@ import type { FastifyInstance } from 'fastify' import type { PathProvider } from '@openchatlab/core' import type { DatabaseManager } from '@openchatlab/node-runtime' import { createDatabaseManagerAdapter } from '@openchatlab/node-runtime' +import { registerSharedRoutes } from '@openchatlab/http-routes' import { MergeSessionCache } from '../../../merger/merge-cache' -import { registerSessionRoutes } from './sessions' -import { registerMemberRoutes } from './members' -import { registerAnalyticsRoutes } from './analytics' -import { registerSqlRoutes } from './sql' -import { registerSessionIndexRoutes } from './session-index' import { registerSummaryRoutes } from './summaries' import { registerImportRoutes } from './import' import { registerMergeRoutes } from './merge' -import { registerExportRoutes } from './export' import { registerCacheRoutes } from './cache' import { getVersion } from '../../../version' @@ -82,17 +77,20 @@ export function registerWebRoutes( } const resolvedPathProvider = options?.pathProvider ?? fallbackPathProvider - registerSessionRoutes(server, adapter) - registerMemberRoutes(server, adapter) - registerAnalyticsRoutes(server, dbManager, adapter) - registerSqlRoutes(server, adapter) - registerSessionIndexRoutes(server, adapter) + registerSharedRoutes(server, { + dbManager, + sessionAdapter: adapter, + pathProvider: resolvedPathProvider, + getVersion, + nativeBinding: options?.nativeBinding, + }) + + // CLI-specific routes not yet migrated to @openchatlab/http-routes registerSummaryRoutes(server, dbManager, adapter) registerImportRoutes(server, dbManager) if (mergeCache) { registerMergeRoutes(server, dbManager, mergeCache) } - registerExportRoutes(server, adapter) registerCacheRoutes(server, resolvedPathProvider) server.get('/_web/system/check-update', async () => { diff --git a/apps/cli/src/http/routes/web/sessions.test.ts b/apps/cli/src/http/routes/web/sessions.test.ts index e0e16c23..c262cc4a 100644 --- a/apps/cli/src/http/routes/web/sessions.test.ts +++ b/apps/cli/src/http/routes/web/sessions.test.ts @@ -2,7 +2,8 @@ import { describe, it } from 'node:test' import assert from 'node:assert/strict' import Fastify from 'fastify' import type { SessionRuntimeAdapter } from '@openchatlab/node-runtime' -import { registerSessionRoutes } from './sessions' +import type { HttpRouteContext } from '@openchatlab/http-routes' +import { registerSessionRoutes } from '@openchatlab/http-routes' function createMissingSessionAdapter(): SessionRuntimeAdapter { return { @@ -21,10 +22,20 @@ function createMissingSessionAdapter(): SessionRuntimeAdapter { } } -describe('CLI Web session routes', () => { +function createMockContext(adapter: SessionRuntimeAdapter): HttpRouteContext { + return { + dbManager: {} as any, + sessionAdapter: adapter, + pathProvider: {} as any, + getVersion: () => '0.0.0-test', + } +} + +describe('shared session routes', () => { it('returns 404 when requesting a missing session by id', async () => { const app = Fastify() - registerSessionRoutes(app, createMissingSessionAdapter()) + const ctx = createMockContext(createMissingSessionAdapter()) + registerSessionRoutes(app, ctx) const response = await app.inject({ method: 'GET', url: '/_web/sessions/missing' }) await app.close() diff --git a/apps/cli/src/http/server.ts b/apps/cli/src/http/server.ts index 702ead50..ea8fdc8b 100644 --- a/apps/cli/src/http/server.ts +++ b/apps/cli/src/http/server.ts @@ -5,8 +5,7 @@ */ import Fastify, { type FastifyInstance, type FastifyError } from 'fastify' -import { authHook } from './auth' -import { ApiError, ApiErrorCode, errorResponse, serverError } from './errors' +import { authHook, ApiError, ApiErrorCode, errorResponse, serverError } from '@openchatlab/http-routes' const JSON_BODY_LIMIT = 50 * 1024 * 1024 // 50MB diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 1a34f473..15682e5b 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -13,6 +13,7 @@ "../../packages/parser/**/*", "../../packages/tools/**/*", "../../packages/sync/**/*", - "../../packages/mcp-server/**/*" + "../../packages/mcp-server/**/*", + "../../packages/http-routes/**/*" ] } diff --git a/packages/http-routes/package.json b/packages/http-routes/package.json new file mode 100644 index 00000000..f47df809 --- /dev/null +++ b/packages/http-routes/package.json @@ -0,0 +1,16 @@ +{ + "name": "@openchatlab/http-routes", + "version": "0.0.0", + "private": true, + "description": "ChatLab shared HTTP API routes — consumed by CLI Server and Electron Internal Server", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "dependencies": { + "@openchatlab/config": "workspace:*", + "@openchatlab/core": "workspace:*", + "@openchatlab/node-runtime": "workspace:*", + "@openchatlab/shared-types": "workspace:*", + "fastify": "^5.8.4" + } +} diff --git a/apps/cli/src/http/auth.ts b/packages/http-routes/src/auth.ts similarity index 87% rename from apps/cli/src/http/auth.ts rename to packages/http-routes/src/auth.ts index c7f24386..4dfb95db 100644 --- a/apps/cli/src/http/auth.ts +++ b/packages/http-routes/src/auth.ts @@ -1,7 +1,9 @@ /** * ChatLab HTTP API — Bearer Token authentication hook * - * 从 electron/main/api/auth.ts 迁移,使用 @openchatlab/config 读取 token。 + * Shared auth middleware for CLI Server and Electron Internal Server. + * URL classification: /api/* always requires token, /_web/* conditionally, + * static files and SPA fallback are public. */ import type { FastifyRequest, FastifyReply } from 'fastify' @@ -11,9 +13,6 @@ import { unauthorized, errorResponse } from './errors' let cachedToken: string | null = null let requireAuthEnabled = false -/** - * 设置 auth hook 使用的 token(由 server 启动时注入) - */ export function setAuthToken(token: string): void { cachedToken = token } @@ -40,12 +39,10 @@ export async function authHook(request: FastifyRequest, reply: FastifyReply): Pr const url = request.url - // /api/* always requires Bearer token if (url.startsWith('/api/')) { return requireBearerToken(request, reply) } - // /_web/* requires Bearer token when requireAuthEnabled if (url.startsWith('/_web/')) { if (requireAuthEnabled) return requireBearerToken(request, reply) return diff --git a/packages/http-routes/src/context.ts b/packages/http-routes/src/context.ts new file mode 100644 index 00000000..39feeb70 --- /dev/null +++ b/packages/http-routes/src/context.ts @@ -0,0 +1,23 @@ +/** + * HttpRouteContext — shared dependency injection interface for route handlers. + * + * CLI Server and Electron Internal Server each construct their own context + * and pass it to registerSharedRoutes(). Route handlers only depend on this + * interface, never on CLI or Electron specific modules. + */ + +import type { PathProvider } from '@openchatlab/core' +import type { DatabaseManager, SessionRuntimeAdapter, PreferencesManager } from '@openchatlab/node-runtime' + +export interface HttpRouteContext { + dbManager: DatabaseManager + sessionAdapter: SessionRuntimeAdapter + pathProvider: PathProvider + + getVersion: () => string + + /** Reserved for future migrated routes (import/merge) that need the native binding path */ + nativeBinding?: string + + preferencesManager?: PreferencesManager +} diff --git a/apps/cli/src/http/errors.ts b/packages/http-routes/src/errors.ts similarity index 96% rename from apps/cli/src/http/errors.ts rename to packages/http-routes/src/errors.ts index 23e9dbb4..387b7a65 100644 --- a/apps/cli/src/http/errors.ts +++ b/packages/http-routes/src/errors.ts @@ -1,7 +1,7 @@ /** * ChatLab HTTP API — Error codes and factory functions * - * 从 electron/main/api/errors.ts 迁移,完全平台无关。 + * Platform-agnostic error handling shared by CLI Server and Electron Internal Server. */ export enum ApiErrorCode { diff --git a/packages/http-routes/src/helpers.ts b/packages/http-routes/src/helpers.ts new file mode 100644 index 00000000..44c2ffd8 --- /dev/null +++ b/packages/http-routes/src/helpers.ts @@ -0,0 +1,15 @@ +/** + * Shared helpers for route modules. + */ + +import type { TimeFilter } from '@openchatlab/shared-types' + +export function parseTimeFilter(query: Record): TimeFilter | undefined { + const { startTs, endTs, memberId } = query + if (!startTs && !endTs && !memberId) return undefined + const filter: TimeFilter = {} + if (startTs) filter.startTs = parseInt(startTs, 10) + if (endTs) filter.endTs = parseInt(endTs, 10) + if (memberId) filter.memberId = parseInt(memberId, 10) + return filter +} diff --git a/packages/http-routes/src/index.ts b/packages/http-routes/src/index.ts new file mode 100644 index 00000000..1f91a60d --- /dev/null +++ b/packages/http-routes/src/index.ts @@ -0,0 +1,29 @@ +export type { HttpRouteContext } from './context' +export { registerSharedRoutes } from './register' +export { setAuthToken, setRequireAuth, authHook } from './auth' +export { + ApiError, + ApiErrorCode, + unauthorized, + sessionNotFound, + invalidPayload, + sqlReadonlyViolation, + sqlExecutionError, + exportTooLarge, + serverError, + successResponse, + errorResponse, +} from './errors' +export { parseTimeFilter } from './helpers' + +// Individual route registration for granular testing or selective registration +export { registerSystemRoutes } from './routes/system' +export { registerRestSessionRoutes } from './routes/sessions' +export { registerSessionRoutes } from './routes/web/sessions' +export { registerMemberRoutes } from './routes/web/members' +export { registerPreferencesRoutes } from './routes/web/preferences' +export { registerAnalyticsRoutes } from './routes/web/analytics' +export { registerSqlRoutes } from './routes/web/sql' +export { registerSessionIndexRoutes } from './routes/web/session-index' +export { registerExportRoutes } from './routes/web/export' +export { registerNlpRoutes } from './routes/web/nlp' diff --git a/packages/http-routes/src/register.ts b/packages/http-routes/src/register.ts new file mode 100644 index 00000000..a17d9749 --- /dev/null +++ b/packages/http-routes/src/register.ts @@ -0,0 +1,35 @@ +/** + * Aggregate route registration — one call to register all shared routes. + * + * CLI Server and Electron Internal Server call this instead of + * importing individual route modules. + */ + +import type { FastifyInstance } from 'fastify' +import type { HttpRouteContext } from './context' +import { registerSystemRoutes } from './routes/system' +import { registerRestSessionRoutes } from './routes/sessions' +import { registerSessionRoutes } from './routes/web/sessions' +import { registerMemberRoutes } from './routes/web/members' +import { registerPreferencesRoutes } from './routes/web/preferences' +import { registerAnalyticsRoutes } from './routes/web/analytics' +import { registerSqlRoutes } from './routes/web/sql' +import { registerSessionIndexRoutes } from './routes/web/session-index' +import { registerExportRoutes } from './routes/web/export' +import { registerNlpRoutes } from './routes/web/nlp' + +export function registerSharedRoutes(server: FastifyInstance, ctx: HttpRouteContext): void { + // REST API (/api/v1/*) + registerSystemRoutes(server, ctx) + registerRestSessionRoutes(server, ctx) + + // Web UI API (/_web/*) + registerSessionRoutes(server, ctx) + registerMemberRoutes(server, ctx) + registerPreferencesRoutes(server, ctx) + registerAnalyticsRoutes(server, ctx) + registerSqlRoutes(server, ctx) + registerSessionIndexRoutes(server, ctx) + registerExportRoutes(server, ctx) + registerNlpRoutes(server, ctx) +} diff --git a/apps/cli/src/http/routes/sessions.ts b/packages/http-routes/src/routes/sessions.ts similarity index 83% rename from apps/cli/src/http/routes/sessions.ts rename to packages/http-routes/src/routes/sessions.ts index fa7e84d7..4b4b060c 100644 --- a/apps/cli/src/http/routes/sessions.ts +++ b/packages/http-routes/src/routes/sessions.ts @@ -1,12 +1,12 @@ /** - * ChatLab HTTP API — Session routes + * ChatLab HTTP API — REST Session routes (/api/v1/sessions/*) * - * 从 electron/main/api/routes/sessions.ts 迁移。 - * 使用 DatabaseManager + @openchatlab/core 替代 workerManager。 + * Public REST API for external tools, scripts, and integrations. + * Uses DatabaseManager + @openchatlab/core for data access. */ import type { FastifyInstance } from 'fastify' -import type { DatabaseManager } from '@openchatlab/node-runtime' +import type { HttpRouteContext } from '../context' import { getSessionInfo, getSessionMeta, @@ -22,19 +22,18 @@ import { successResponse, errorResponse, sessionNotFound, exportTooLarge, sqlExe const EXPORT_MESSAGE_LIMIT = 100_000 -function ensureDb(dbManager: DatabaseManager, sessionId: string) { - const db = dbManager.open(sessionId) +function ensureDb(ctx: HttpRouteContext, sessionId: string) { + const db = ctx.dbManager.open(sessionId) if (!db) throw sessionNotFound(sessionId) return db } -export function registerSessionRoutes(server: FastifyInstance, dbManager: DatabaseManager): void { - // GET /api/v1/sessions — List all sessions +export function registerRestSessionRoutes(server: FastifyInstance, ctx: HttpRouteContext): void { server.get('/api/v1/sessions', async () => { - const sessionIds = dbManager.listSessionIds() + const sessionIds = ctx.dbManager.listSessionIds() const sessions = sessionIds .map((id) => { - const db = dbManager.open(id) + const db = ctx.dbManager.open(id) if (!db) return null const info = getSessionInfo(db) if (!info) return null @@ -54,9 +53,8 @@ export function registerSessionRoutes(server: FastifyInstance, dbManager: Databa return successResponse(sessions) }) - // GET /api/v1/sessions/:id — Single session detail server.get<{ Params: { id: string } }>('/api/v1/sessions/:id', async (request) => { - const db = ensureDb(dbManager, request.params.id) + const db = ensureDb(ctx, request.params.id) const info = getSessionInfo(db) if (!info) throw sessionNotFound(request.params.id) return successResponse({ @@ -72,7 +70,6 @@ export function registerSessionRoutes(server: FastifyInstance, dbManager: Databa }) }) - // GET /api/v1/sessions/:id/messages — Query messages (paginated) server.get<{ Params: { id: string } Querystring: { @@ -85,7 +82,7 @@ export function registerSessionRoutes(server: FastifyInstance, dbManager: Databa } }>('/api/v1/sessions/:id/messages', async (request) => { const { id } = request.params - const db = ensureDb(dbManager, id) + const db = ensureDb(ctx, id) const page = Math.max(1, parseInt(request.query.page || '1', 10) || 1) const limit = Math.min(1000, Math.max(1, parseInt(request.query.limit || '100', 10) || 100)) @@ -111,17 +108,15 @@ export function registerSessionRoutes(server: FastifyInstance, dbManager: Databa }) }) - // GET /api/v1/sessions/:id/members — Member list server.get<{ Params: { id: string } }>('/api/v1/sessions/:id/members', async (request) => { - const db = ensureDb(dbManager, request.params.id) + const db = ensureDb(ctx, request.params.id) const members = getMembers(db) return successResponse(members) }) - // GET /api/v1/sessions/:id/stats/overview — Overview statistics server.get<{ Params: { id: string } }>('/api/v1/sessions/:id/stats/overview', async (request) => { const { id } = request.params - const db = ensureDb(dbManager, id) + const db = ensureDb(ctx, id) const overview = getSessionOverview(db) const memberActivity = getMemberActivity(db) @@ -151,10 +146,9 @@ export function registerSessionRoutes(server: FastifyInstance, dbManager: Databa }) }) - // POST /api/v1/sessions/:id/sql — Execute SQL (read-only) server.post<{ Params: { id: string }; Body: { sql: string } }>('/api/v1/sessions/:id/sql', async (request, reply) => { const { id } = request.params - const db = ensureDb(dbManager, id) + const db = ensureDb(ctx, id) const { sql } = request.body || {} if (!sql || typeof sql !== 'string') { @@ -177,10 +171,9 @@ export function registerSessionRoutes(server: FastifyInstance, dbManager: Databa } }) - // GET /api/v1/sessions/:id/export — Export ChatLab Format JSON server.get<{ Params: { id: string } }>('/api/v1/sessions/:id/export', async (request, reply) => { const { id } = request.params - const db = ensureDb(dbManager, id) + const db = ensureDb(ctx, id) const meta = getSessionMeta(db) if (!meta) throw sessionNotFound(id) const overview = getSessionOverview(db) diff --git a/packages/http-routes/src/routes/shared-routes.test.ts b/packages/http-routes/src/routes/shared-routes.test.ts new file mode 100644 index 00000000..7bec5774 --- /dev/null +++ b/packages/http-routes/src/routes/shared-routes.test.ts @@ -0,0 +1,110 @@ +/** + * Smoke tests for registerSharedRoutes — verifies all route groups + * are registered and respond correctly with mock context. + * + * Uses a single Fastify instance to avoid repeated NLP dict init overhead. + */ + +import { describe, it, before, after } from 'node:test' +import assert from 'node:assert/strict' +import Fastify, { type FastifyInstance } from 'fastify' +import type { PathProvider } from '@openchatlab/core' +import type { DatabaseManager, SessionRuntimeAdapter } from '@openchatlab/node-runtime' +import type { HttpRouteContext } from '../context' +import { registerSharedRoutes } from '../register' + +function createTestContext(): HttpRouteContext { + const pathProvider: PathProvider = { + getSystemDir: () => '/tmp/chatlab-test', + getUserDataDir: () => '/tmp/chatlab-test/data', + getDatabaseDir: () => '/tmp/chatlab-test/databases', + getAiDataDir: () => '/tmp/chatlab-test/ai', + getSettingsDir: () => '/tmp/chatlab-test/settings', + getCacheDir: () => '/tmp/chatlab-test/cache', + getTempDir: () => '/tmp/chatlab-test/temp', + getLogsDir: () => '/tmp/chatlab-test/logs', + getDownloadsDir: () => '/tmp/chatlab-test/downloads', + } + + const dbManager = { + listSessionIds: () => [], + open: () => null, + openWritable: () => null, + close: () => {}, + closeAll: () => {}, + getDbPath: (id: string) => `/tmp/${id}.db`, + } as unknown as DatabaseManager + + const sessionAdapter: SessionRuntimeAdapter = { + listSessionIds: () => [], + openReadonly: () => null, + openWritable: () => null, + closeSession: () => {}, + getDbPath: (id: string) => `/tmp/${id}.db`, + deleteSessionFile: () => false, + ensureReadonly: () => { + throw Object.assign(new Error('Session not found'), { statusCode: 404 }) + }, + ensureWritable: () => { + throw Object.assign(new Error('Session not found'), { statusCode: 404 }) + }, + } + + return { dbManager, sessionAdapter, pathProvider, getVersion: () => '0.0.0-test' } +} + +describe('registerSharedRoutes smoke tests', () => { + let app: FastifyInstance + + before(async () => { + app = Fastify() + registerSharedRoutes(app, createTestContext()) + await app.ready() + }) + + after(async () => { + await app.close() + }) + + it('GET /api/v1/status returns 200 with version', async () => { + const resp = await app.inject({ method: 'GET', url: '/api/v1/status' }) + assert.equal(resp.statusCode, 200) + const body = resp.json() + assert.equal(body.data.version, '0.0.0-test') + assert.equal(body.data.name, 'ChatLab API') + }) + + it('GET /api/v1/schema returns schema definition', async () => { + const resp = await app.inject({ method: 'GET', url: '/api/v1/schema' }) + assert.equal(resp.statusCode, 200) + const body = resp.json() + assert.equal(body.data.format, 'ChatLab Format') + }) + + it('GET /api/v1/sessions returns empty list', async () => { + const resp = await app.inject({ method: 'GET', url: '/api/v1/sessions' }) + assert.equal(resp.statusCode, 200) + assert.deepEqual(resp.json().data, []) + }) + + it('GET /_web/sessions returns empty list', async () => { + const resp = await app.inject({ method: 'GET', url: '/_web/sessions' }) + assert.equal(resp.statusCode, 200) + assert.ok(Array.isArray(resp.json())) + }) + + it('GET /_web/sessions/:id returns 404 for missing session', async () => { + const resp = await app.inject({ method: 'GET', url: '/_web/sessions/nonexistent' }) + assert.equal(resp.statusCode, 404) + }) + + it('GET /_web/nlp/pos-tags returns 200', async () => { + const resp = await app.inject({ method: 'GET', url: '/_web/nlp/pos-tags' }) + assert.equal(resp.statusCode, 200) + }) + + it('GET /_web/preferences returns 200 or 500', async () => { + const resp = await app.inject({ method: 'GET', url: '/_web/preferences' }) + assert.ok([200, 500].includes(resp.statusCode), `Expected 200 or 500, got ${resp.statusCode}`) + }) +}) diff --git a/apps/cli/src/http/routes/system.ts b/packages/http-routes/src/routes/system.ts similarity index 89% rename from apps/cli/src/http/routes/system.ts rename to packages/http-routes/src/routes/system.ts index 56305853..6a17b016 100644 --- a/apps/cli/src/http/routes/system.ts +++ b/packages/http-routes/src/routes/system.ts @@ -6,22 +6,21 @@ */ import type { FastifyInstance } from 'fastify' -import type { DatabaseManager } from '@openchatlab/node-runtime' +import type { HttpRouteContext } from '../context' import { successResponse } from '../errors' -import { getVersion } from '../../version' -export function registerSystemRoutes(server: FastifyInstance, dbManager: DatabaseManager): void { +export function registerSystemRoutes(server: FastifyInstance, ctx: HttpRouteContext): void { server.get('/api/v1/status', async () => { let sessionCount = 0 try { - sessionCount = dbManager.listSessionIds().length + sessionCount = ctx.dbManager.listSessionIds().length } catch { // ignore } return successResponse({ name: 'ChatLab API', - version: getVersion(), + version: ctx.getVersion(), uptime: Math.floor(process.uptime()), sessionCount, }) diff --git a/apps/cli/src/http/routes/web/analytics.ts b/packages/http-routes/src/routes/web/analytics.ts similarity index 91% rename from apps/cli/src/http/routes/web/analytics.ts rename to packages/http-routes/src/routes/web/analytics.ts index d1abac3b..e837e85f 100644 --- a/apps/cli/src/http/routes/web/analytics.ts +++ b/packages/http-routes/src/routes/web/analytics.ts @@ -1,6 +1,6 @@ import type { FastifyInstance } from 'fastify' -import type { DatabaseManager } from '@openchatlab/node-runtime' -import { createJiebaNlpProvider, type SessionRuntimeAdapter } from '@openchatlab/node-runtime' +import type { HttpRouteContext } from '../../context' +import { createJiebaNlpProvider } from '@openchatlab/node-runtime' import { getTimeRange, getAvailableYears, @@ -20,16 +20,12 @@ import { getClusterGraph, getLanguagePreferenceAnalysis, } from '@openchatlab/core' -import { parseTimeFilter } from './helpers' +import { parseTimeFilter } from '../../helpers' type FilteredQuery = { startTs?: string; endTs?: string; memberId?: string } -export function registerAnalyticsRoutes( - server: FastifyInstance, - _dbManager: DatabaseManager, - adapter: SessionRuntimeAdapter -): void { - // ==================== 时间范围 ==================== +export function registerAnalyticsRoutes(server: FastifyInstance, ctx: HttpRouteContext): void { + const { sessionAdapter: adapter } = ctx server.get<{ Params: { id: string } }>('/_web/sessions/:id/years', async (request) => { const db = adapter.ensureReadonly(request.params.id) @@ -41,8 +37,6 @@ export function registerAnalyticsRoutes( return getTimeRange(db) }) - // ==================== 统计分析 ==================== - server.get<{ Params: { id: string }; Querystring: FilteredQuery }>( '/_web/sessions/:id/stats/member-activity', async (request) => { @@ -83,8 +77,6 @@ export function registerAnalyticsRoutes( } ) - // ==================== 高级分析 ==================== - server.get<{ Params: { id: string }; Querystring: FilteredQuery }>( '/_web/sessions/:id/analytics/relationship', async (request) => { diff --git a/apps/cli/src/http/routes/web/export.ts b/packages/http-routes/src/routes/web/export.ts similarity index 82% rename from apps/cli/src/http/routes/web/export.ts rename to packages/http-routes/src/routes/web/export.ts index a90e95e0..7937c31f 100644 --- a/apps/cli/src/http/routes/web/export.ts +++ b/packages/http-routes/src/routes/web/export.ts @@ -1,7 +1,10 @@ import type { FastifyInstance } from 'fastify' -import { exportService, type SessionRuntimeAdapter } from '@openchatlab/node-runtime' +import type { HttpRouteContext } from '../../context' +import { exportService } from '@openchatlab/node-runtime' + +export function registerExportRoutes(server: FastifyInstance, ctx: HttpRouteContext): void { + const { sessionAdapter: adapter } = ctx -export function registerExportRoutes(server: FastifyInstance, adapter: SessionRuntimeAdapter): void { server.post<{ Params: { id: string } Body: { diff --git a/apps/cli/src/http/routes/web/members.ts b/packages/http-routes/src/routes/web/members.ts similarity index 89% rename from apps/cli/src/http/routes/web/members.ts rename to packages/http-routes/src/routes/web/members.ts index df5989ec..1e06554d 100644 --- a/apps/cli/src/http/routes/web/members.ts +++ b/packages/http-routes/src/routes/web/members.ts @@ -1,7 +1,10 @@ import type { FastifyInstance } from 'fastify' -import { memberService, type SessionRuntimeAdapter } from '@openchatlab/node-runtime' +import type { HttpRouteContext } from '../../context' +import { memberService } from '@openchatlab/node-runtime' + +export function registerMemberRoutes(server: FastifyInstance, ctx: HttpRouteContext): void { + const { sessionAdapter: adapter } = ctx -export function registerMemberRoutes(server: FastifyInstance, adapter: SessionRuntimeAdapter): void { server.get<{ Params: { id: string } }>('/_web/sessions/:id/members', async (request) => { return memberService.getMembers(adapter, request.params.id) }) diff --git a/apps/cli/src/http/routes/nlp.ts b/packages/http-routes/src/routes/web/nlp.ts similarity index 51% rename from apps/cli/src/http/routes/nlp.ts rename to packages/http-routes/src/routes/web/nlp.ts index 7cdb7f93..77a3fdb9 100644 --- a/apps/cli/src/http/routes/nlp.ts +++ b/packages/http-routes/src/routes/web/nlp.ts @@ -1,16 +1,13 @@ /** * NLP Web API — /_web/nlp/ routes * - * 提供词频统计、词性标签、词库管理等 NLP 功能的 HTTP 接口, - * 供 CLI Web 前端使用(对齐 Electron preload 的 window.nlpApi)。 - * - * 业务逻辑全部来自 @openchatlab/core(类型/数据)和 - * @openchatlab/node-runtime(分词引擎/词频计算/词库管理)。 + * Word frequency, POS tags, dictionary management. + * Business logic from @openchatlab/core and @openchatlab/node-runtime. */ import * as path from 'path' import type { FastifyInstance } from 'fastify' -import type { DatabaseManager } from '@openchatlab/node-runtime' +import type { HttpRouteContext } from '../../context' import type { WordFrequencyParams, SupportedLocale } from '@openchatlab/core' import { POS_TAG_DEFINITIONS } from '@openchatlab/core' import { @@ -24,49 +21,37 @@ import { ensureDefaultDict, } from '@openchatlab/node-runtime' -function ensureDb(dbManager: DatabaseManager, sessionId: string) { - const db = dbManager.open(sessionId) - if (!db) { - throw Object.assign(new Error(`Session not found: ${sessionId}`), { statusCode: 404 }) - } - return db -} - -export function registerNlpRoutes(server: FastifyInstance, dbManager: DatabaseManager): void { - const pathProvider = (dbManager as any)['pathProvider'] - if (pathProvider) { - const nlpDir = path.join(pathProvider.getSystemDir(), 'nlp') - initNlpDir(nlpDir) - ensureDefaultDict(nlpDir).catch((err) => console.warn('[NLP] Auto-download zh-CN dict failed:', err)) - } +export function registerNlpRoutes(server: FastifyInstance, ctx: HttpRouteContext): void { + const nlpDir = path.join(ctx.pathProvider.getSystemDir(), 'nlp') + initNlpDir(nlpDir) + ensureDefaultDict(nlpDir).catch((err) => console.warn('[NLP] Auto-download zh-CN dict failed:', err)) server.get('/_web/nlp/pos-tags', async () => { return POS_TAG_DEFINITIONS }) server.get('/_web/nlp/dicts', async () => { - const nlpDir = resolveNlpDir(dbManager) return getDictList(nlpDir) }) server.get<{ Params: { id: string } }>('/_web/nlp/dicts/:id/status', async (request) => { - const nlpDir = resolveNlpDir(dbManager) return isDictDownloaded(nlpDir, request.params.id) }) server.post<{ Params: { id: string } }>('/_web/nlp/dicts/:id/download', async (request) => { - const nlpDir = resolveNlpDir(dbManager) return downloadDict(nlpDir, request.params.id) }) server.delete<{ Params: { id: string } }>('/_web/nlp/dicts/:id', async (request) => { - const nlpDir = resolveNlpDir(dbManager) return deleteDict(nlpDir, request.params.id) }) server.post<{ Body: WordFrequencyParams }>('/_web/nlp/word-frequency', async (request) => { const params = request.body - const db = ensureDb(dbManager, params.sessionId) + const db = ctx.dbManager.open(params.sessionId) + if (!db) { + throw Object.assign(new Error(`Session not found: ${params.sessionId}`), { statusCode: 404 }) + } return computeWordFrequency(db, params) }) @@ -78,8 +63,3 @@ export function registerNlpRoutes(server: FastifyInstance, dbManager: DatabaseMa } ) } - -function resolveNlpDir(dbManager: DatabaseManager): string { - const pathProvider = (dbManager as any)['pathProvider'] - return path.join(pathProvider.getSystemDir(), 'nlp') -} diff --git a/apps/cli/src/http/routes/preferences.ts b/packages/http-routes/src/routes/web/preferences.ts similarity index 83% rename from apps/cli/src/http/routes/preferences.ts rename to packages/http-routes/src/routes/web/preferences.ts index 19e982d5..f1dc4a95 100644 --- a/apps/cli/src/http/routes/preferences.ts +++ b/packages/http-routes/src/routes/web/preferences.ts @@ -5,23 +5,19 @@ */ import type { FastifyInstance } from 'fastify' +import type { HttpRouteContext } from '../../context' import { PreferencesManager, type Preferences } from '@openchatlab/node-runtime' import { loadConfig, writeConfigField, type UiConfig } from '@openchatlab/config' -import type { PathProvider } from '@openchatlab/core' -let prefManager: PreferencesManager | null = null - -export function registerPreferencesRoutes(server: FastifyInstance, pathProvider: PathProvider): void { - if (!prefManager) { - prefManager = new PreferencesManager(pathProvider.getSystemDir()) - } +export function registerPreferencesRoutes(server: FastifyInstance, ctx: HttpRouteContext): void { + const prefManager = ctx.preferencesManager ?? new PreferencesManager(ctx.pathProvider.getSystemDir()) server.get('/_web/preferences', async () => { - return prefManager!.load() + return prefManager.load() }) server.patch<{ Body: Partial }>('/_web/preferences', async (request) => { - return prefManager!.save(request.body) + return prefManager.save(request.body) }) server.get('/_web/preferences/ui-config', async () => { diff --git a/apps/cli/src/http/routes/web/session-index.ts b/packages/http-routes/src/routes/web/session-index.ts similarity index 92% rename from apps/cli/src/http/routes/web/session-index.ts rename to packages/http-routes/src/routes/web/session-index.ts index df8902de..baed849d 100644 --- a/apps/cli/src/http/routes/web/session-index.ts +++ b/packages/http-routes/src/routes/web/session-index.ts @@ -1,7 +1,10 @@ import type { FastifyInstance } from 'fastify' -import { sessionIndexService, type SessionRuntimeAdapter } from '@openchatlab/node-runtime' +import type { HttpRouteContext } from '../../context' +import { sessionIndexService } from '@openchatlab/node-runtime' + +export function registerSessionIndexRoutes(server: FastifyInstance, ctx: HttpRouteContext): void { + const { sessionAdapter: adapter } = ctx -export function registerSessionIndexRoutes(server: FastifyInstance, adapter: SessionRuntimeAdapter): void { server.get('/_web/sessions/index-stats', async () => { return sessionIndexService.getAllIndexStats(adapter) }) diff --git a/apps/cli/src/http/routes/web/sessions.ts b/packages/http-routes/src/routes/web/sessions.ts similarity index 85% rename from apps/cli/src/http/routes/web/sessions.ts rename to packages/http-routes/src/routes/web/sessions.ts index 65d324ce..2ddc7e81 100644 --- a/apps/cli/src/http/routes/web/sessions.ts +++ b/packages/http-routes/src/routes/web/sessions.ts @@ -1,7 +1,10 @@ import type { FastifyInstance } from 'fastify' -import { sessionService, type SessionRuntimeAdapter } from '@openchatlab/node-runtime' +import type { HttpRouteContext } from '../../context' +import { sessionService } from '@openchatlab/node-runtime' + +export function registerSessionRoutes(server: FastifyInstance, ctx: HttpRouteContext): void { + const { sessionAdapter: adapter } = ctx -export function registerSessionRoutes(server: FastifyInstance, adapter: SessionRuntimeAdapter): void { server.get('/_web/sessions', async () => { return sessionService.listAnalysisSessions(adapter) }) diff --git a/apps/cli/src/http/routes/web/sql.ts b/packages/http-routes/src/routes/web/sql.ts similarity index 89% rename from apps/cli/src/http/routes/web/sql.ts rename to packages/http-routes/src/routes/web/sql.ts index 117d3202..679cf5a7 100644 --- a/apps/cli/src/http/routes/web/sql.ts +++ b/packages/http-routes/src/routes/web/sql.ts @@ -1,8 +1,10 @@ import type { FastifyInstance } from 'fastify' -import type { SessionRuntimeAdapter } from '@openchatlab/node-runtime' +import type { HttpRouteContext } from '../../context' import { executeSql, getSchemaDetailed } from '@openchatlab/core' -export function registerSqlRoutes(server: FastifyInstance, adapter: SessionRuntimeAdapter): void { +export function registerSqlRoutes(server: FastifyInstance, ctx: HttpRouteContext): void { + const { sessionAdapter: adapter } = ctx + server.post<{ Params: { id: string }; Body: { sql: string } }>('/_web/sessions/:id/sql', async (request, reply) => { const db = adapter.ensureReadonly(request.params.id) const { sql } = request.body || {} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1c677b57..ad70aba6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -140,6 +140,9 @@ importers: '@openchatlab/core': specifier: workspace:* version: link:../../packages/core + '@openchatlab/http-routes': + specifier: workspace:* + version: link:../../packages/http-routes '@openchatlab/node-runtime': specifier: workspace:* version: link:../../packages/node-runtime @@ -231,6 +234,24 @@ importers: packages/core: {} + packages/http-routes: + dependencies: + '@openchatlab/config': + specifier: workspace:* + version: link:../config + '@openchatlab/core': + specifier: workspace:* + version: link:../core + '@openchatlab/node-runtime': + specifier: workspace:* + version: link:../node-runtime + '@openchatlab/shared-types': + specifier: workspace:* + version: link:../shared-types + fastify: + specifier: ^5.8.4 + version: 5.8.4 + packages/mcp-server: dependencies: '@modelcontextprotocol/sdk':