From 4694d837292c135bae7ad3737f05bb96b3cbc9f3 Mon Sep 17 00:00:00 2001 From: digua Date: Thu, 25 Jun 2026 18:54:06 +0800 Subject: [PATCH] fix(electron): align Desktop API port with CLI, auto-fallback on conflict, update docs - Change default port from 5200 to 3110 in sync config-manager, Vue store, and settings UI to match CLI DEFAULT_API_PORT - Desktop API server now tries up to 10 consecutive ports when preferred port is occupied (e.g. CLI already running); reports actual bound port - Scope Desktop import route lock to session (Set) matching the push-importer fix - Docs: remove unimplemented JSONL/X-Dry-Run claims, fix port references, replace platform-specific examples with generic ones Co-Authored-By: Claude Sonnet 4.6 --- apps/desktop/main/api/index.ts | 58 +++++++++++++------ apps/desktop/main/api/routes/import.ts | 18 +++--- docs/cn/standard/chatlab-import.md | 24 ++------ docs/en/standard/chatlab-import.md | 20 +------ packages/sync/src/config-manager.ts | 2 +- .../common/Settings/ApiSettingsTab.vue | 2 +- src/stores/apiServer.ts | 2 +- 7 files changed, 60 insertions(+), 66 deletions(-) diff --git a/apps/desktop/main/api/index.ts b/apps/desktop/main/api/index.ts index 1abc105e..219b9581 100644 --- a/apps/desktop/main/api/index.ts +++ b/apps/desktop/main/api/index.ts @@ -16,6 +16,7 @@ import type { ConfigManager, ApiServerConfig } from '@openchatlab/sync' let server: FastifyInstance | null = null let startedAt: number | null = null let lastError: string | null = null +let runningPort: number | null = null let _configManager: ConfigManager | null = null /** Must be called before start/autoStart/setEnabled/setPort */ @@ -38,12 +39,14 @@ export interface ApiServerStatus { export function getStatus(): ApiServerStatus { return { running: server !== null && startedAt !== null, - port: server !== null && startedAt !== null ? cm().load().port : null, + port: server !== null && startedAt !== null ? runningPort : null, startedAt, error: lastError, } } +const PORT_FALLBACK_LIMIT = 10 + export async function start(): Promise { if (server) { apiLogger.info('Server already running') @@ -54,28 +57,44 @@ export async function start(): Promise { cm().ensureToken(config) lastError = null - try { - server = createServer() - registerSystemRoutes(server) - registerSessionRoutes(server) - registerImportRoutes(server) + const preferredPort = config.port + let lastErr: any - await server.listen({ port: config.port, host: '127.0.0.1' }) - startedAt = Math.floor(Date.now() / 1000) - apiLogger.info(`Server started on http://127.0.0.1:${config.port}`) - } catch (err: any) { - server = null - startedAt = null + for (let offset = 0; offset < PORT_FALLBACK_LIMIT; offset++) { + const port = preferredPort + offset + try { + server = createServer() + registerSystemRoutes(server) + registerSessionRoutes(server) + registerImportRoutes(server) - if (err.code === 'EADDRINUSE') { - lastError = `PORT_IN_USE:${config.port}` - apiLogger.warn(`Port ${config.port} is already in use`) - } else { - lastError = err.message || 'Unknown error' - apiLogger.error('Failed to start', err) + await server.listen({ port, host: '127.0.0.1' }) + startedAt = Math.floor(Date.now() / 1000) + runningPort = port + if (offset > 0) { + apiLogger.info(`Port ${preferredPort} in use, bound to http://127.0.0.1:${port} instead`) + } else { + apiLogger.info(`Server started on http://127.0.0.1:${port}`) + } + return + } catch (err: any) { + server = null + lastErr = err + if (err.code !== 'EADDRINUSE') break + apiLogger.warn(`Port ${port} is already in use, trying ${port + 1}…`) } - throw err } + + startedAt = null + runningPort = null + if (lastErr?.code === 'EADDRINUSE') { + lastError = `PORT_IN_USE:${preferredPort}` + apiLogger.warn(`No available port found starting from ${preferredPort}`) + } else { + lastError = lastErr?.message || 'Unknown error' + apiLogger.error('Failed to start', lastErr) + } + throw lastErr } export async function stop(): Promise { @@ -88,6 +107,7 @@ export async function stop(): Promise { } finally { server = null startedAt = null + runningPort = null lastError = null apiLogger.info('Server stopped') } diff --git a/apps/desktop/main/api/routes/import.ts b/apps/desktop/main/api/routes/import.ts index aea7a788..51817b98 100644 --- a/apps/desktop/main/api/routes/import.ts +++ b/apps/desktop/main/api/routes/import.ts @@ -34,7 +34,8 @@ import { } from '../errors' import { apiLogger } from '../logger' -let isImporting = false +// Per-session lock: different sessionIds can import in parallel. +const isImporting = new Set() // ==================== Idempotency cache ==================== @@ -110,7 +111,7 @@ function idempotencyFail(key: string | undefined): void { } export function getImportingStatus(): boolean { - return isImporting + return isImporting.size > 0 } /** @@ -152,7 +153,7 @@ async function writeTempFile( * v3 统一导入处理:自动判断新建或增量 */ async function handleUnifiedImport(request: FastifyRequest, reply: FastifyReply, sessionId: string): Promise { - if (isImporting) { + if (isImporting.has(sessionId)) { const err = importInProgress() reply.code(err.statusCode).send(errorResponse(err)) return @@ -173,7 +174,7 @@ async function handleUnifiedImport(request: FastifyRequest, reply: FastifyReply, const cacheKey = idempotencyKey ? `${idempotencyKey}:${sessionId}:${isDryRun}` : undefined - isImporting = true + isImporting.add(sessionId) let tempFile = '' try { @@ -332,7 +333,7 @@ async function handleUnifiedImport(request: FastifyRequest, reply: FastifyReply, const err = importFailed(error.message || 'Import process error') reply.code(err.statusCode).send(errorResponse(err)) } finally { - isImporting = false + isImporting.delete(sessionId) if (tempFile) { cleanupTempFile(tempFile) } @@ -343,7 +344,8 @@ async function handleUnifiedImport(request: FastifyRequest, reply: FastifyReply, * Legacy import handler (backward compatibility) */ async function handleLegacyImport(request: FastifyRequest, reply: FastifyReply, sessionId?: string): Promise { - if (isImporting) { + const lockKey = sessionId ?? '__legacy__' + if (isImporting.has(lockKey)) { const err = importInProgress() reply.code(err.statusCode).send(errorResponse(err)) return @@ -359,7 +361,7 @@ async function handleLegacyImport(request: FastifyRequest, reply: FastifyReply, return } - isImporting = true + isImporting.add(lockKey) let tempFile = '' try { @@ -425,7 +427,7 @@ async function handleLegacyImport(request: FastifyRequest, reply: FastifyReply, const err = importFailed(error.message || 'Import process error') reply.code(err.statusCode).send(errorResponse(err)) } finally { - isImporting = false + isImporting.delete(lockKey) if (tempFile) { cleanupTempFile(tempFile) } diff --git a/docs/cn/standard/chatlab-import.md b/docs/cn/standard/chatlab-import.md index f58b68e9..a168435f 100644 --- a/docs/cn/standard/chatlab-import.md +++ b/docs/cn/standard/chatlab-import.md @@ -49,8 +49,7 @@ Token 在 ChatLab 设置页面生成,格式为 `clb_` + 64 字符 hex。 ### Content-Type ``` -application/json # 标准 JSON body(≤50MB) -application/x-ndjson # JSONL 流式(无大小限制) +application/json # 标准 JSON body(≤50MB) ``` --- @@ -96,9 +95,8 @@ application/x-ndjson # JSONL 流式(无大小限制) | Header | 必填 | 说明 | | --- | --- | --- | | `Authorization` | 是 | `Bearer ` | -| `Content-Type` | 是 | `application/json` 或 `application/x-ndjson` | +| `Content-Type` | 是 | `application/json` | | `Idempotency-Key` | 建议 | 当前批次的唯一标识,用于重试安全。建议格式:`{sessionId}-{batchIndex}-{windowStart}` | -| `X-Dry-Run` | 否 | 设为 `true` 时仅分析不写入,返回预估结果 | ### 快速测试 @@ -180,17 +178,6 @@ curl http://127.0.0.1:3110/api/v1/imports/group_abc123 \ 回填历史数据时建议传 `"metaUpdateMode": "none"` 防止旧群名覆盖当前值。 ::: -### 请求 Body(JSONL 模式) - -每行一个 JSON 对象,通过 `_type` 字段区分类型。行顺序:`header` → `member`(零或多行) → `message`(一或多行)。 - -```jsonl -{"_type":"header","chatlab":{"version":"0.0.2","exportedAt":1711468800,"generator":"YourSystem/1.0"},"meta":{"name":"产品讨论群","platform":"telegram","type":"group","groupId":"112233445566"},"options":{"metaUpdateMode":"patch","memberUpdateMode":"upsert"}} -{"_type":"member","platformId":"user_a","accountName":"张三","groupNickname":"产品"} -{"_type":"message","platformMessageId":"msg_1001","sender":"user_a","accountName":"张三","timestamp":1711468800,"type":0,"content":"Hello"} -{"_type":"message","platformMessageId":"msg_1002","sender":"user_b","accountName":"李四","timestamp":1711468860,"type":0,"content":"Hi"} -``` - ### 各块携带规则 | 块 | 会话首次创建 | 后续增量导入 | 语义 | @@ -357,9 +344,8 @@ curl http://127.0.0.1:3110/api/v1/imports/group_abc123 \ | 约束 | 值 | 说明 | | ------------------ | ------------ | ------------------------------- | -| JSON body 大小上限 | 50MB | 超过返回 `BODY_TOO_LARGE` (413) | -| JSONL body 大小 | 无限制 | 通过 stream 写入临时文件后处理 | -| 建议每批消息数 | 5000 条 | 兼顾性能和内存占用 | +| JSON body 大小上限 | 50MB | 超过返回 `BODY_TOO_LARGE` (413) | +| 建议每批消息数 | 5000 条 | 兼顾性能和内存占用 | ### 分批原则 @@ -385,7 +371,7 @@ ChatLab 不为调用方维护游标。推荐结构: ### 并发约束 -当前版本:**同一时刻仅允许一个导入任务**。并发请求会收到 `IMPORT_IN_PROGRESS` (409) 错误。 +当前版本:**同一 session 同一时刻仅允许一个导入任务**。对同一 sessionId 的并发请求会收到 `IMPORT_IN_PROGRESS` (409) 错误;不同 session 的并发导入互不影响。 --- diff --git a/docs/en/standard/chatlab-import.md b/docs/en/standard/chatlab-import.md index 4bed5171..2d14973a 100644 --- a/docs/en/standard/chatlab-import.md +++ b/docs/en/standard/chatlab-import.md @@ -49,8 +49,7 @@ Tokens are generated in ChatLab settings and are formatted as `clb_` + 64 hex ch ### Content-Type ``` -application/json # Standard JSON body (≤50MB) -application/x-ndjson # JSONL streaming (no size limit) +application/json # Standard JSON body (≤50MB) ``` --- @@ -96,9 +95,8 @@ For query endpoint details, see [ChatLab API](./chatlab-api.md). | Header | Required | Description | | --- | --- | --- | | `Authorization` | Yes | `Bearer ` | -| `Content-Type` | Yes | `application/json` or `application/x-ndjson` | +| `Content-Type` | Yes | `application/json` | | `Idempotency-Key` | Recommended | Unique identifier for the current batch, for safe retries. Suggested format: `{sessionId}-{batchIndex}-{windowStart}` | -| `X-Dry-Run` | No | Set to `true` to analyze without writing; returns estimated results | ### Quick Test @@ -180,17 +178,6 @@ A successful response returns `"success": true` with write statistics. Repeating When backfilling historical data, pass `"metaUpdateMode": "none"` to prevent old group names from overwriting the current value. ::: -### Request Body (JSONL) - -One JSON object per line, distinguished by the `_type` field. Line order: `header` → `member` (zero or more) → `message` (one or more). - -```jsonl -{"_type":"header","chatlab":{"version":"0.0.2","exportedAt":1711468800,"generator":"YourSystem/1.0"},"meta":{"name":"Product Discussion","platform":"whatsapp","type":"group","groupId":"112233445566"},"options":{"metaUpdateMode":"patch","memberUpdateMode":"upsert"}} -{"_type":"member","platformId":"user_a","accountName":"Alice","groupNickname":"Product"} -{"_type":"message","platformMessageId":"msg_1001","sender":"user_a","accountName":"Alice","timestamp":1711468800,"type":0,"content":"Hello"} -{"_type":"message","platformMessageId":"msg_1002","sender":"user_b","accountName":"Bob","timestamp":1711468860,"type":0,"content":"Hi"} -``` - ### Block Requirements | Block | First Import | Incremental Import | Notes | @@ -358,7 +345,6 @@ Deduplication is scoped to a single session and does not cross sessions. | Constraint | Value | Notes | | --- | --- | --- | | JSON body size limit | 50MB | Exceeding returns `BODY_TOO_LARGE` (413) | -| JSONL body size | Unlimited | Streamed to a temp file before processing | | Recommended batch size | 5,000 messages | Balances performance and memory | ### Batching Rules @@ -385,7 +371,7 @@ After each successful batch, update the cursor with `session.lastTimestamp` from ### Concurrency -Current version: **only one import task is allowed at a time per session**. Concurrent requests return `IMPORT_IN_PROGRESS` (409). +Current version: **only one import task is allowed at a time per session**. Concurrent requests to the same sessionId return `IMPORT_IN_PROGRESS` (409); imports to different sessions run independently. --- diff --git a/packages/sync/src/config-manager.ts b/packages/sync/src/config-manager.ts index c5f70bcc..64f42ddb 100644 --- a/packages/sync/src/config-manager.ts +++ b/packages/sync/src/config-manager.ts @@ -15,7 +15,7 @@ const CONFIG_FILE = 'api-server.json' const DEFAULT_CONFIG: ApiServerConfig = { enabled: false, - port: 5200, + port: 3110, token: '', createdAt: 0, } diff --git a/src/components/common/Settings/ApiSettingsTab.vue b/src/components/common/Settings/ApiSettingsTab.vue index 5cf279d7..46809b45 100644 --- a/src/components/common/Settings/ApiSettingsTab.vue +++ b/src/components/common/Settings/ApiSettingsTab.vue @@ -48,7 +48,7 @@ const subTabs = computed(() => [ const tokenVisible = ref(false) const editingPort = ref(false) -const portInput = ref(5200) +const portInput = ref(3110) const copied = ref(false) const showAddModal = ref(false) diff --git a/src/stores/apiServer.ts b/src/stores/apiServer.ts index 9d2f9604..84949973 100644 --- a/src/stores/apiServer.ts +++ b/src/stores/apiServer.ts @@ -250,7 +250,7 @@ export const useApiServerStore = defineStore('apiServer', () => { const config = ref({ enabled: false, - port: 5200, + port: 3110, token: '', createdAt: 0, })