mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
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<string>) 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
4e2130b2d7
commit
4694d83729
7 files changed
+60
-66
No files matched your search
@@ -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<void> {
|
||||
if (server) {
|
||||
apiLogger.info('Server already running')
|
||||
@@ -54,28 +57,44 @@ export async function start(): Promise<void> {
|
||||
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<void> {
|
||||
@@ -88,6 +107,7 @@ export async function stop(): Promise<void> {
|
||||
} finally {
|
||||
server = null
|
||||
startedAt = null
|
||||
runningPort = null
|
||||
lastError = null
|
||||
apiLogger.info('Server stopped')
|
||||
}
|
||||
|
||||
@@ -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<string>()
|
||||
|
||||
// ==================== 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<void> {
|
||||
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<void> {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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 <token>` |
|
||||
| `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 的并发导入互不影响。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 <token>` |
|
||||
| `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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ const CONFIG_FILE = 'api-server.json'
|
||||
|
||||
const DEFAULT_CONFIG: ApiServerConfig = {
|
||||
enabled: false,
|
||||
port: 5200,
|
||||
port: 3110,
|
||||
token: '',
|
||||
createdAt: 0,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -250,7 +250,7 @@ export const useApiServerStore = defineStore('apiServer', () => {
|
||||
|
||||
const config = ref<ApiServerConfig>({
|
||||
enabled: false,
|
||||
port: 5200,
|
||||
port: 3110,
|
||||
token: '',
|
||||
createdAt: 0,
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user