diff --git a/docs/cn/chatlab-api.md b/docs/cn/chatlab-api.md new file mode 100644 index 0000000..b1ce8c7 --- /dev/null +++ b/docs/cn/chatlab-api.md @@ -0,0 +1,499 @@ +# ChatLab API 文档 + +ChatLab 提供本地 RESTful API 服务,允许外部工具、脚本和 MCP 等通过 HTTP 接口查询聊天记录、执行 SQL 查询、导入聊天数据。 + +## 快速开始 + +### 1. 启用服务 + +打开 ChatLab → 设置 → ChatLab API → 开启服务。 + +启用后会自动生成 API Token,默认监听端口 `5200`。 + +### 2. 验证服务状态 + +```bash +curl http://127.0.0.1:5200/api/v1/status \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +响应示例: + +```json +{ + "success": true, + "data": { + "name": "ChatLab API", + "version": "1.0.0", + "uptime": 3600, + "sessionCount": 5 + }, + "meta": { + "timestamp": 1711468800, + "version": "0.0.2" + } +} +``` + +## 基本信息 + +| 项目 | 说明 | +| -------- | ------------------------- | +| 基础 URL | `http://127.0.0.1:5200` | +| API 前缀 | `/api/v1` | +| 认证方式 | Bearer Token | +| 数据格式 | JSON | +| 绑定地址 | `127.0.0.1`(仅本机访问) | + +### 认证 + +所有请求必须携带 `Authorization` 请求头: + +``` +Authorization: Bearer clb_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +Token 可在 设置 → ChatLab API 页面查看和重新生成。 + +### 统一响应格式 + +**成功响应:** + +```json +{ + "success": true, + "data": { ... }, + "meta": { + "timestamp": 1711468800, + "version": "0.0.2" + } +} +``` + +**错误响应:** + +```json +{ + "success": false, + "error": { + "code": "SESSION_NOT_FOUND", + "message": "Session not found: abc123" + } +} +``` + +--- + +## 端点列表 + +### 系统 + +| 方法 | 路径 | 说明 | +| ---- | ---------------- | -------------------------- | +| GET | `/api/v1/status` | 服务状态 | +| GET | `/api/v1/schema` | ChatLab Format JSON Schema | + +### 数据查询(导出) + +| 方法 | 路径 | 说明 | +| ---- | ------------------------------------- | ------------------------ | +| GET | `/api/v1/sessions` | 获取所有会话列表 | +| GET | `/api/v1/sessions/:id` | 获取单个会话详情 | +| GET | `/api/v1/sessions/:id/messages` | 查询消息(分页) | +| GET | `/api/v1/sessions/:id/members` | 获取成员列表 | +| GET | `/api/v1/sessions/:id/stats/overview` | 获取概览统计 | +| POST | `/api/v1/sessions/:id/sql` | 执行自定义 SQL(只读) | +| GET | `/api/v1/sessions/:id/export` | 导出 ChatLab Format JSON | + +### 数据导入 + +| 方法 | 路径 | 说明 | +| ---- | ----------------------------- | ------------------------ | +| POST | `/api/v1/import` | 导入聊天记录(新建会话) | +| POST | `/api/v1/sessions/:id/import` | 增量导入到指定会话 | + +--- + +## 端点详细说明 + +### GET /api/v1/status + +获取 API 服务的运行状态。 + +**响应:** + +| 字段 | 类型 | 说明 | +| -------------- | ------ | ------------------------- | +| `name` | string | 服务名称(`ChatLab API`) | +| `version` | string | ChatLab 应用版本 | +| `uptime` | number | 服务运行时间(秒) | +| `sessionCount` | number | 当前会话总数 | + +--- + +### GET /api/v1/schema + +获取 ChatLab Format 的 JSON Schema 定义,便于构建符合规范的导入数据。 + +--- + +### GET /api/v1/sessions + +获取所有已导入的会话列表。 + +**响应示例:** + +```json +{ + "success": true, + "data": [ + { + "id": "session_abc123", + "name": "技术交流群", + "platform": "qq", + "type": "group", + "messageCount": 58000, + "memberCount": 120 + } + ] +} +``` + +--- + +### GET /api/v1/sessions/:id + +获取单个会话的详细信息。 + +**路径参数:** + +| 参数 | 类型 | 说明 | +| ---- | ------ | ------- | +| `id` | string | 会话 ID | + +--- + +### GET /api/v1/sessions/:id/messages + +分页查询指定会话的消息列表,支持多种过滤条件。 + +**查询参数:** + +| 参数 | 类型 | 默认值 | 说明 | +| ----------- | ------ | ------ | ------------------------ | +| `page` | number | 1 | 页码 | +| `limit` | number | 100 | 每页条数(最大 1000) | +| `startTime` | number | - | 起始时间戳(秒级 Unix) | +| `endTime` | number | - | 结束时间戳(秒级 Unix) | +| `keyword` | string | - | 关键词搜索 | +| `senderId` | string | - | 按发送者 platformId 筛选 | +| `type` | number | - | 按消息类型筛选 | + +**请求示例:** + +```bash +curl "http://127.0.0.1:5200/api/v1/sessions/abc123/messages?page=1&limit=50&keyword=你好" \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +**响应:** + +```json +{ + "success": true, + "data": { + "messages": [ + { + "senderPlatformId": "123456", + "senderName": "张三", + "timestamp": 1703001600, + "type": 0, + "content": "你好!" + } + ], + "total": 1500, + "page": 1, + "limit": 50, + "totalPages": 30 + } +} +``` + +--- + +### GET /api/v1/sessions/:id/members + +获取指定会话的所有成员列表。 + +--- + +### GET /api/v1/sessions/:id/stats/overview + +获取指定会话的概览统计信息。 + +**响应:** + +```json +{ + "success": true, + "data": { + "messageCount": 58000, + "memberCount": 120, + "timeRange": { + "start": 1609459200, + "end": 1703001600 + }, + "messageTypeDistribution": { + "0": 45000, + "1": 8000, + "5": 3000, + "80": 2000 + }, + "topMembers": [ + { + "platformId": "123456", + "name": "张三", + "messageCount": 5800, + "percentage": 10.0 + } + ] + } +} +``` + +| 字段 | 说明 | +| ------------------------- | -------------------------------------------------------------------------------- | +| `messageCount` | 总消息数 | +| `memberCount` | 成员数 | +| `timeRange` | 最早/最新消息时间戳(秒级 Unix) | +| `messageTypeDistribution` | 各消息类型的数量(key 为 [消息类型](./chatlab-format.md#消息类型对照表) 枚举值) | +| `topMembers` | 前 10 活跃成员(按消息数降序) | + +--- + +### POST /api/v1/sessions/:id/sql + +对指定会话的数据库执行只读 SQL 查询。仅允许 `SELECT` 语句。 + +**请求体:** + +```json +{ + "sql": "SELECT sender, COUNT(*) as count FROM messages GROUP BY sender ORDER BY count DESC LIMIT 10" +} +``` + +**响应:** + +```json +{ + "success": true, + "data": { + "columns": ["sender", "count"], + "rows": [ + ["123456", 5800], + ["789012", 3200] + ] + } +} +``` + +> 关于数据库表结构,请参考 ChatLab 内部文档或使用 `SELECT * FROM sqlite_master WHERE type='table'` 查询。 + +--- + +### GET /api/v1/sessions/:id/export + +导出完整会话数据,格式为 [ChatLab Format](./chatlab-format.md) JSON。 + +**限制:** 最多导出 **10 万条** 消息。如果会话消息数超过此限制,返回 `400 EXPORT_TOO_LARGE` 错误。超大会话建议使用 `/messages` 分页 API 逐页获取。 + +**响应:** + +```json +{ + "success": true, + "data": { + "chatlab": { + "version": "0.0.2", + "exportedAt": 1711468800, + "generator": "ChatLab API" + }, + "meta": { + "name": "技术交流群", + "platform": "qq", + "type": "group" + }, + "members": [...], + "messages": [...] + } +} +``` + +--- + +### POST /api/v1/import + +将聊天记录导入 ChatLab,**创建新会话**。 + +#### 支持的 Content-Type + +| Content-Type | 格式 | 适用场景 | Body 限制 | +| ---------------------- | ------------------- | ------------------------------ | ---------- | +| `application/json` | ChatLab Format JSON | 中小数据(快速测试、脚本集成) | **50MB** | +| `application/x-ndjson` | ChatLab JSONL 格式 | 大规模数据(生产级集成) | **无限制** | + +#### JSON 模式示例 + +```bash +curl -X POST http://127.0.0.1:5200/api/v1/import \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "chatlab": { + "version": "0.0.2", + "exportedAt": 1711468800 + }, + "meta": { + "name": "导入测试", + "platform": "qq", + "type": "group" + }, + "members": [ + { "platformId": "123", "accountName": "测试用户" } + ], + "messages": [ + { + "sender": "123", + "accountName": "测试用户", + "timestamp": 1711468800, + "type": 0, + "content": "Hello World" + } + ] + }' +``` + +#### JSONL 模式示例 + +```bash +cat data.jsonl | curl -X POST http://127.0.0.1:5200/api/v1/import \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: application/x-ndjson" \ + --data-binary @- +``` + +**响应:** + +```json +{ + "success": true, + "data": { + "mode": "new", + "sessionId": "session_xyz789" + } +} +``` + +> 关于 ChatLab Format 的详细规范,请参考 [ChatLab 标准化格式规范](./chatlab-format.md)。 + +--- + +### POST /api/v1/sessions/:id/import + +将聊天记录**增量导入**到已存在的会话。支持去重,相同消息不会重复插入。 + +**去重规则:** + +消息唯一键为 `timestamp + senderPlatformId + contentLength`。如果一条消息的时间戳、发送者和内容长度与已有消息完全相同,则视为重复并跳过。 + +**路径参数:** + +| 参数 | 类型 | 说明 | +| ---- | ------ | ----------- | +| `id` | string | 目标会话 ID | + +Content-Type 和请求体格式与 `POST /api/v1/import` 相同。 + +**响应:** + +```json +{ + "success": true, + "data": { + "mode": "incremental", + "sessionId": "session_abc123", + "newMessageCount": 150 + } +} +``` + +--- + +## 并发与限制 + +| 限制项 | 值 | 说明 | +| ---------------- | ------- | ------------------------------- | +| JSON 请求体大小 | 50MB | `application/json` 模式 | +| JSONL 请求体大小 | 无限制 | `application/x-ndjson` 流式模式 | +| 导出消息上限 | 10 万条 | `/export` 端点 | +| 分页最大每页 | 1000 条 | `/messages` 端点 | +| 导入并发 | 1 | 同一时刻仅允许一个导入操作 | + +--- + +## 错误码 + +| 错误码 | HTTP 状态码 | 说明 | +| ------------------------ | ----------- | ------------------------------- | +| `UNAUTHORIZED` | 401 | Token 无效或缺失 | +| `SESSION_NOT_FOUND` | 404 | 会话不存在 | +| `INVALID_FORMAT` | 400 | 请求体不符合 ChatLab Format | +| `SQL_READONLY_VIOLATION` | 400 | SQL 不是 SELECT 语句 | +| `SQL_EXECUTION_ERROR` | 400 | SQL 执行出错 | +| `EXPORT_TOO_LARGE` | 400 | 消息数超过导出上限(10 万条) | +| `BODY_TOO_LARGE` | 413 | 请求体超过 50MB(仅 JSON 模式) | +| `IMPORT_IN_PROGRESS` | 409 | 有其他导入正在进行 | +| `IMPORT_FAILED` | 500 | 导入失败 | +| `SERVER_ERROR` | 500 | 服务内部错误 | + +--- + +## 安全说明 + +- **仅本机访问**:API 绑定 `127.0.0.1`,不对外暴露 +- **Token 认证**:所有端点需携带有效 Bearer Token +- **SQL 只读限制**:`/sql` 端点仅允许 `SELECT` 查询 +- **默认关闭**:API 服务需手动开启 + +--- + +## 使用场景 + +### 1. MCP 集成 + +将 ChatLab API 接入 Claude Desktop 等 AI 工具,实现 AI 对聊天记录的直接查询和分析。 + +### 2. 自动化脚本 + +编写脚本定期从其他平台导出聊天记录,转换为 ChatLab Format 后通过 Push API 自动导入。 + +### 3. 数据分析 + +通过 SQL 端点执行自定义查询,配合 Python/R 等工具进行高级数据分析。 + +### 4. 数据备份 + +通过 `/export` 端点定期导出重要会话数据作为 JSON 备份。 + +### 5. 定时拉取 + +在设置页配置外部数据源 URL,ChatLab 会按设定间隔自动拉取并导入新数据。 + +--- + +## 版本信息 + +| 版本 | 说明 | +| ---- | ------------------------------------------------------------------------------ | +| v1 | 初始版本,支持会话查询、消息搜索、SQL、导出、导入(JSON + JSONL)、Pull 调度器 | diff --git a/docs/en/chatlab-api.md b/docs/en/chatlab-api.md new file mode 100644 index 0000000..a61916c --- /dev/null +++ b/docs/en/chatlab-api.md @@ -0,0 +1,499 @@ +# ChatLab API Documentation + +ChatLab provides a local RESTful API service that allows external tools, scripts, and MCP to query chat records, execute SQL queries, and import chat data via HTTP. + +## Quick Start + +### 1. Enable the Service + +Open ChatLab → Settings → ChatLab API → Enable Service. + +Once enabled, an API Token is automatically generated. The default port is `5200`. + +### 2. Verify Service Status + +```bash +curl http://127.0.0.1:5200/api/v1/status \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +Response example: + +```json +{ + "success": true, + "data": { + "name": "ChatLab API", + "version": "1.0.0", + "uptime": 3600, + "sessionCount": 5 + }, + "meta": { + "timestamp": 1711468800, + "version": "0.0.2" + } +} +``` + +## General Information + +| Item | Description | +|------|-------------| +| Base URL | `http://127.0.0.1:5200` | +| API Prefix | `/api/v1` | +| Authentication | Bearer Token | +| Data Format | JSON | +| Bind Address | `127.0.0.1` (localhost only) | + +### Authentication + +All requests must include the `Authorization` header: + +``` +Authorization: Bearer clb_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +The Token can be viewed and regenerated in Settings → ChatLab API. + +### Unified Response Format + +**Success response:** + +```json +{ + "success": true, + "data": { ... }, + "meta": { + "timestamp": 1711468800, + "version": "0.0.2" + } +} +``` + +**Error response:** + +```json +{ + "success": false, + "error": { + "code": "SESSION_NOT_FOUND", + "message": "Session not found: abc123" + } +} +``` + +--- + +## Endpoint List + +### System + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/v1/status` | Service status | +| GET | `/api/v1/schema` | ChatLab Format JSON Schema | + +### Data Query (Export) + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/v1/sessions` | List all sessions | +| GET | `/api/v1/sessions/:id` | Get single session details | +| GET | `/api/v1/sessions/:id/messages` | Query messages (paginated) | +| GET | `/api/v1/sessions/:id/members` | Get member list | +| GET | `/api/v1/sessions/:id/stats/overview` | Get overview statistics | +| POST | `/api/v1/sessions/:id/sql` | Execute custom SQL (read-only) | +| GET | `/api/v1/sessions/:id/export` | Export ChatLab Format JSON | + +### Data Import + +| Method | Path | Description | +|--------|------|-------------| +| POST | `/api/v1/import` | Import chat records (new session) | +| POST | `/api/v1/sessions/:id/import` | Incremental import to existing session | + +--- + +## Endpoint Details + +### GET /api/v1/status + +Get the running status of the API service. + +**Response:** + +| Field | Type | Description | +|-------|------|-------------| +| `name` | string | Service name (`ChatLab API`) | +| `version` | string | ChatLab application version | +| `uptime` | number | Service uptime in seconds | +| `sessionCount` | number | Total number of sessions | + +--- + +### GET /api/v1/schema + +Get the JSON Schema definition for ChatLab Format, useful for building compliant import data. + +--- + +### GET /api/v1/sessions + +Get all imported sessions. + +**Response example:** + +```json +{ + "success": true, + "data": [ + { + "id": "session_abc123", + "name": "Tech Discussion Group", + "platform": "wechat", + "type": "group", + "messageCount": 58000, + "memberCount": 120 + } + ] +} +``` + +--- + +### GET /api/v1/sessions/:id + +Get detailed information for a single session. + +**Path parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `id` | string | Session ID | + +--- + +### GET /api/v1/sessions/:id/messages + +Query messages from a specific session with pagination and filtering support. + +**Query parameters:** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `page` | number | 1 | Page number | +| `limit` | number | 100 | Items per page (max 1000) | +| `startTime` | number | - | Start timestamp (Unix seconds) | +| `endTime` | number | - | End timestamp (Unix seconds) | +| `keyword` | string | - | Keyword search | +| `senderId` | string | - | Filter by sender's platformId | +| `type` | number | - | Filter by message type | + +**Request example:** + +```bash +curl "http://127.0.0.1:5200/api/v1/sessions/abc123/messages?page=1&limit=50&keyword=hello" \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +**Response:** + +```json +{ + "success": true, + "data": { + "messages": [ + { + "senderPlatformId": "123456", + "senderName": "John", + "timestamp": 1703001600, + "type": 0, + "content": "Hello!" + } + ], + "total": 1500, + "page": 1, + "limit": 50, + "totalPages": 30 + } +} +``` + +--- + +### GET /api/v1/sessions/:id/members + +Get all members of a specific session. + +--- + +### GET /api/v1/sessions/:id/stats/overview + +Get overview statistics for a specific session. + +**Response:** + +```json +{ + "success": true, + "data": { + "messageCount": 58000, + "memberCount": 120, + "timeRange": { + "start": 1609459200, + "end": 1703001600 + }, + "messageTypeDistribution": { + "0": 45000, + "1": 8000, + "5": 3000, + "80": 2000 + }, + "topMembers": [ + { + "platformId": "123456", + "name": "John", + "messageCount": 5800, + "percentage": 10.0 + } + ] + } +} +``` + +| Field | Description | +|-------|-------------| +| `messageCount` | Total message count | +| `memberCount` | Total member count | +| `timeRange` | Earliest/latest message timestamps (Unix seconds) | +| `messageTypeDistribution` | Count per message type (key is [message type](./chatlab-format.md#message-type-reference) enum value) | +| `topMembers` | Top 10 active members (sorted by message count descending) | + +--- + +### POST /api/v1/sessions/:id/sql + +Execute a read-only SQL query against a specific session's database. Only `SELECT` statements are allowed. + +**Request body:** + +```json +{ + "sql": "SELECT sender, COUNT(*) as count FROM messages GROUP BY sender ORDER BY count DESC LIMIT 10" +} +``` + +**Response:** + +```json +{ + "success": true, + "data": { + "columns": ["sender", "count"], + "rows": [ + ["123456", 5800], + ["789012", 3200] + ] + } +} +``` + +> For database schema details, refer to ChatLab internal documentation or use `SELECT * FROM sqlite_master WHERE type='table'` to inspect the tables. + +--- + +### GET /api/v1/sessions/:id/export + +Export complete session data in [ChatLab Format](./chatlab-format.md) JSON. + +**Limit:** Maximum **100,000 messages** per export. If the session exceeds this limit, a `400 EXPORT_TOO_LARGE` error is returned. For larger sessions, use the paginated `/messages` API. + +**Response:** + +```json +{ + "success": true, + "data": { + "chatlab": { + "version": "0.0.2", + "exportedAt": 1711468800, + "generator": "ChatLab API" + }, + "meta": { + "name": "Tech Discussion Group", + "platform": "wechat", + "type": "group" + }, + "members": [...], + "messages": [...] + } +} +``` + +--- + +### POST /api/v1/import + +Import chat records into ChatLab, **creating a new session**. + +#### Supported Content-Types + +| Content-Type | Format | Use Case | Body Limit | +|------|------|------|------| +| `application/json` | ChatLab Format JSON | Small to medium data (quick testing, script integration) | **50MB** | +| `application/x-ndjson` | ChatLab JSONL format | Large-scale data (production integration) | **Unlimited** | + +#### JSON Mode Example + +```bash +curl -X POST http://127.0.0.1:5200/api/v1/import \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "chatlab": { + "version": "0.0.2", + "exportedAt": 1711468800 + }, + "meta": { + "name": "Import Test", + "platform": "qq", + "type": "group" + }, + "members": [ + { "platformId": "123", "accountName": "Test User" } + ], + "messages": [ + { + "sender": "123", + "accountName": "Test User", + "timestamp": 1711468800, + "type": 0, + "content": "Hello World" + } + ] + }' +``` + +#### JSONL Mode Example + +```bash +cat data.jsonl | curl -X POST http://127.0.0.1:5200/api/v1/import \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: application/x-ndjson" \ + --data-binary @- +``` + +**Response:** + +```json +{ + "success": true, + "data": { + "mode": "new", + "sessionId": "session_xyz789" + } +} +``` + +> For the full ChatLab Format specification, see [ChatLab Format Specification](./chatlab-format.md). + +--- + +### POST /api/v1/sessions/:id/import + +**Incrementally import** chat records into an existing session. Duplicate messages are automatically skipped. + +**Deduplication rules:** + +The unique key for each message is `timestamp + senderPlatformId + contentLength`. If a message's timestamp, sender, and content length match an existing message exactly, it is considered a duplicate and skipped. + +**Path parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `id` | string | Target session ID | + +Content-Type and request body format are the same as `POST /api/v1/import`. + +**Response:** + +```json +{ + "success": true, + "data": { + "mode": "incremental", + "sessionId": "session_abc123", + "newMessageCount": 150 + } +} +``` + +--- + +## Concurrency & Limits + +| Limit | Value | Description | +|-------|-------|-------------| +| JSON body size | 50MB | `application/json` mode | +| JSONL body size | Unlimited | `application/x-ndjson` streaming mode | +| Export message limit | 100,000 | `/export` endpoint | +| Max page size | 1,000 | `/messages` endpoint | +| Import concurrency | 1 | Only one import operation allowed at a time | + +--- + +## Error Codes + +| Error Code | HTTP Status | Description | +|------------|-------------|-------------| +| `UNAUTHORIZED` | 401 | Invalid or missing token | +| `SESSION_NOT_FOUND` | 404 | Session not found | +| `INVALID_FORMAT` | 400 | Request body does not conform to ChatLab Format | +| `SQL_READONLY_VIOLATION` | 400 | SQL is not a SELECT statement | +| `SQL_EXECUTION_ERROR` | 400 | SQL execution error | +| `EXPORT_TOO_LARGE` | 400 | Message count exceeds export limit (100K) | +| `BODY_TOO_LARGE` | 413 | Request body exceeds 50MB (JSON mode only) | +| `IMPORT_IN_PROGRESS` | 409 | Another import is already in progress | +| `IMPORT_FAILED` | 500 | Import failed | +| `SERVER_ERROR` | 500 | Internal server error | + +--- + +## Security + +- **Localhost only**: API binds to `127.0.0.1`, not exposed to the network +- **Token authentication**: All endpoints require a valid Bearer Token +- **Read-only SQL**: `/sql` endpoint only allows `SELECT` queries +- **Disabled by default**: API service must be manually enabled + +--- + +## Use Cases + +### 1. MCP Integration + +Connect the ChatLab API to AI tools like Claude Desktop, enabling AI to directly query and analyze chat records. + +### 2. Automation Scripts + +Write scripts to periodically export chat records from other platforms, convert to ChatLab Format, and automatically import via the Push API. + +### 3. Data Analysis + +Use the SQL endpoint to run custom queries, combined with Python/R for advanced data analysis. + +### 4. Data Backup + +Periodically export important session data via the `/export` endpoint as JSON backups. + +### 5. Scheduled Pulling + +Configure external data source URLs in the Settings page. ChatLab will automatically fetch and import new data at the configured interval. + +--- + +## Version History + +| Version | Description | +|---------|-------------| +| v1 | Initial release — session query, message search, SQL, export, import (JSON + JSONL), Pull scheduler | diff --git a/electron/main/api/auth.ts b/electron/main/api/auth.ts index ad4b5df..7b1a1f6 100644 --- a/electron/main/api/auth.ts +++ b/electron/main/api/auth.ts @@ -1,5 +1,5 @@ /** - * ChatLab API Bearer Token 认证中间件 + * ChatLab API — Bearer Token authentication hook */ import type { FastifyRequest, FastifyReply } from 'fastify' diff --git a/electron/main/api/config.ts b/electron/main/api/config.ts index c8e51c2..c8a0dc8 100644 --- a/electron/main/api/config.ts +++ b/electron/main/api/config.ts @@ -1,6 +1,6 @@ /** - * ChatLab API 配置管理 - * 持久化存储在 userData/settings/api-server.json + * ChatLab API — Configuration management + * Persisted to userData/settings/api-server.json */ import * as fs from 'fs' @@ -63,7 +63,7 @@ export function updateConfig(partial: Partial): ApiServerConfig } /** - * 确保 Token 存在(首次启用时自动生成) + * Ensure token exists (auto-generated on first enable) */ export function ensureToken(config: ApiServerConfig): ApiServerConfig { if (!config.token) { diff --git a/electron/main/api/dataSource.ts b/electron/main/api/dataSource.ts new file mode 100644 index 0000000..3c41079 --- /dev/null +++ b/electron/main/api/dataSource.ts @@ -0,0 +1,103 @@ +/** + * ChatLab API — Data source configuration management + * Persisted to userData/settings/data-sources.json + */ + +import * as fs from 'fs' +import * as path from 'path' +import * as crypto from 'crypto' +import { getSettingsDir, ensureDir } from '../paths' + +const CONFIG_FILE = 'data-sources.json' + +export interface DataSource { + id: string + name: string + url: string + /** Remote API Bearer Token (optional) */ + token: string + /** Pull interval in minutes */ + intervalMinutes: number + /** Whether scheduled pulling is enabled */ + enabled: boolean + /** Target session ID (empty string means create new each time) */ + targetSessionId: string + /** Last pull timestamp (seconds) */ + lastPullAt: number + /** Last pull status */ + lastStatus: 'idle' | 'success' | 'error' + /** Last error message */ + lastError: string + /** Number of new messages from last pull */ + lastNewMessages: number + /** Created timestamp (seconds) */ + createdAt: number +} + +function getConfigPath(): string { + return path.join(getSettingsDir(), CONFIG_FILE) +} + +export function loadDataSources(): DataSource[] { + try { + const filePath = getConfigPath() + if (fs.existsSync(filePath)) { + const raw = fs.readFileSync(filePath, 'utf-8') + return JSON.parse(raw) as DataSource[] + } + } catch (err) { + console.error('[DataSource] Failed to load config:', err) + } + return [] +} + +export function saveDataSources(sources: DataSource[]): void { + try { + ensureDir(getSettingsDir()) + fs.writeFileSync(getConfigPath(), JSON.stringify(sources, null, 2), 'utf-8') + } catch (err) { + console.error('[DataSource] Failed to save config:', err) + } +} + +export function generateId(): string { + return `ds_${crypto.randomBytes(6).toString('hex')}` +} + +export function addDataSource(partial: Omit): DataSource { + const sources = loadDataSources() + const ds: DataSource = { + ...partial, + id: generateId(), + lastPullAt: 0, + lastStatus: 'idle', + lastError: '', + lastNewMessages: 0, + createdAt: Math.floor(Date.now() / 1000), + } + sources.push(ds) + saveDataSources(sources) + return ds +} + +export function updateDataSource(id: string, updates: Partial): DataSource | null { + const sources = loadDataSources() + const idx = sources.findIndex((s) => s.id === id) + if (idx === -1) return null + sources[idx] = { ...sources[idx], ...updates, id } + saveDataSources(sources) + return sources[idx] +} + +export function deleteDataSource(id: string): boolean { + const sources = loadDataSources() + const filtered = sources.filter((s) => s.id !== id) + if (filtered.length === sources.length) return false + saveDataSources(filtered) + return true +} + +export function getDataSource(id: string): DataSource | null { + const sources = loadDataSources() + return sources.find((s) => s.id === id) || null +} diff --git a/electron/main/api/errors.ts b/electron/main/api/errors.ts index 4aad246..27e848f 100644 --- a/electron/main/api/errors.ts +++ b/electron/main/api/errors.ts @@ -1,5 +1,5 @@ /** - * ChatLab API 错误码与错误工厂 + * ChatLab API — Error codes and factory functions */ export enum ApiErrorCode { @@ -40,12 +40,12 @@ export class ApiError extends Error { } } -export function unauthorized(message = 'Token 无效或缺失'): ApiError { +export function unauthorized(message = 'Invalid or missing token'): ApiError { return new ApiError(ApiErrorCode.UNAUTHORIZED, message) } export function sessionNotFound(id: string): ApiError { - return new ApiError(ApiErrorCode.SESSION_NOT_FOUND, `会话不存在: ${id}`) + return new ApiError(ApiErrorCode.SESSION_NOT_FOUND, `Session not found: ${id}`) } export function invalidFormat(message: string): ApiError { @@ -53,7 +53,7 @@ export function invalidFormat(message: string): ApiError { } export function sqlReadonlyViolation(): ApiError { - return new ApiError(ApiErrorCode.SQL_READONLY_VIOLATION, '仅允许 SELECT 查询') + return new ApiError(ApiErrorCode.SQL_READONLY_VIOLATION, 'Only SELECT queries are allowed') } export function sqlExecutionError(message: string): ApiError { @@ -63,23 +63,22 @@ export function sqlExecutionError(message: string): ApiError { export function exportTooLarge(count: number, limit: number): ApiError { return new ApiError( ApiErrorCode.EXPORT_TOO_LARGE, - `消息数 ${count} 超过导出上限 ${limit},请使用分页 /messages API` + `Message count ${count} exceeds export limit ${limit}. Use paginated /messages API instead.` ) } export function importInProgress(): ApiError { - return new ApiError(ApiErrorCode.IMPORT_IN_PROGRESS, '当前有导入任务正在执行') + return new ApiError(ApiErrorCode.IMPORT_IN_PROGRESS, 'An import operation is already in progress') } export function importFailed(message: string): ApiError { return new ApiError(ApiErrorCode.IMPORT_FAILED, message) } -export function serverError(message = '服务内部错误'): ApiError { +export function serverError(message = 'Internal server error'): ApiError { return new ApiError(ApiErrorCode.SERVER_ERROR, message) } -/** 构建统一的成功响应 */ export function successResponse(data: T, meta?: Record) { return { success: true as const, @@ -92,7 +91,6 @@ export function successResponse(data: T, meta?: Record) { } } -/** 构建统一的错误响应 */ export function errorResponse(error: ApiError) { return { success: false as const, diff --git a/electron/main/api/index.ts b/electron/main/api/index.ts index 98c166b..ca6dd0a 100644 --- a/electron/main/api/index.ts +++ b/electron/main/api/index.ts @@ -1,6 +1,6 @@ /** - * ChatLab API — 服务管理器 - * 负责 fastify 服务生命周期管理 + * ChatLab API — Server manager + * Manages fastify server lifecycle */ import type { FastifyInstance } from 'fastify' @@ -8,6 +8,7 @@ import { createServer } from './server' import { loadConfig, saveConfig, ensureToken, type ApiServerConfig } from './config' import { registerSystemRoutes } from './routes/system' import { registerSessionRoutes } from './routes/sessions' +import { registerImportRoutes } from './routes/import' let server: FastifyInstance | null = null let startedAt: number | null = null @@ -43,6 +44,7 @@ export async function start(): Promise { server = createServer() registerSystemRoutes(server) registerSessionRoutes(server) + registerImportRoutes(server) await server.listen({ port: config.port, host: '127.0.0.1' }) startedAt = Math.floor(Date.now() / 1000) @@ -83,8 +85,8 @@ export async function restart(): Promise { } /** - * 应用启动时自动恢复:若配置为 enabled 则尝试启动 - * 失败则静默记录(不影响应用正常使用) + * Auto-restore on app startup: attempt to start if config.enabled is true. + * Failures are silently recorded (does not affect normal app usage). */ export async function autoStart(): Promise { const config = loadConfig() @@ -93,12 +95,12 @@ export async function autoStart(): Promise { try { await start() } catch { - // 静默失败,lastError 已记录 + // silent failure, lastError already recorded } } /** - * 设置启用状态(持久化) + * Set enabled state (persisted) */ export async function setEnabled(enabled: boolean): Promise { const config = loadConfig() @@ -110,7 +112,7 @@ export async function setEnabled(enabled: boolean): Promise { try { await start() } catch { - // lastError 已记录 + // lastError already recorded } } else { await stop() @@ -120,7 +122,7 @@ export async function setEnabled(enabled: boolean): Promise { } /** - * 设置端口(持久化,需要重启服务) + * Set port (persisted, requires server restart) */ export async function setPort(port: number): Promise { const config = loadConfig() @@ -134,7 +136,7 @@ export async function setPort(port: number): Promise { try { await start() } catch { - // lastError 已记录 + // lastError already recorded } } diff --git a/electron/main/api/pullScheduler.ts b/electron/main/api/pullScheduler.ts new file mode 100644 index 0000000..99d5922 --- /dev/null +++ b/electron/main/api/pullScheduler.ts @@ -0,0 +1,270 @@ +/** + * ChatLab API — Pull scheduler + * Periodically fetches ChatLab Format data from external sources and imports it + */ + +import * as fs from 'fs' +import * as path from 'path' +import * as crypto from 'crypto' +import { net } from 'electron' +import { getTempDir } from '../paths' +import * as worker from '../worker/workerManager' +import { loadDataSources, saveDataSources, type DataSource } from './dataSource' +import { getImportingStatus } from './routes/import' + +const timers = new Map>() +let initialized = false + +function getTempFilePath(ext: string): string { + const id = crypto.randomBytes(8).toString('hex') + return path.join(getTempDir(), `pull-import-${id}${ext}`) +} + +function cleanupTempFile(filePath: string): void { + try { + if (fs.existsSync(filePath)) fs.unlinkSync(filePath) + } catch { /* ignore */ } +} + +function notifySessionListChanged(): void { + try { + const { BrowserWindow } = require('electron') + const wins = BrowserWindow.getAllWindows() + for (const win of wins) { + win.webContents.send('api:importCompleted') + } + } catch { /* ignore */ } +} + +function notifyPullResult(dsId: string, status: 'success' | 'error', detail: string): void { + try { + const { BrowserWindow } = require('electron') + const wins = BrowserWindow.getAllWindows() + for (const win of wins) { + win.webContents.send('api:pullResult', { dsId, status, detail }) + } + } catch { /* ignore */ } +} + +/** + * Fetch data from remote URL to a temporary file + */ +async function fetchToTempFile(ds: DataSource): Promise { + return new Promise((resolve, reject) => { + const url = ds.url.includes('?') + ? `${ds.url}&since=${ds.lastPullAt}` + : `${ds.url}?since=${ds.lastPullAt}` + + const request = net.request(url) + + if (ds.token) { + request.setHeader('Authorization', `Bearer ${ds.token}`) + } + request.setHeader('Accept', 'application/json, application/x-ndjson') + + const contentType = { value: '' } + let tempFile = '' + let writeStream: fs.WriteStream | null = null + + request.on('response', (response) => { + if (response.statusCode !== 200) { + reject(new Error(`HTTP ${response.statusCode}`)) + return + } + + contentType.value = (response.headers['content-type'] as string) || 'application/json' + const isJsonl = contentType.value.includes('ndjson') || contentType.value.includes('jsonl') + tempFile = getTempFilePath(isJsonl ? '.jsonl' : '.json') + writeStream = fs.createWriteStream(tempFile) + + response.on('data', (chunk: Buffer) => { + writeStream!.write(chunk) + }) + + response.on('end', () => { + writeStream!.end(() => { + resolve(tempFile) + }) + }) + + response.on('error', (err: Error) => { + writeStream?.end() + cleanupTempFile(tempFile) + reject(err) + }) + }) + + request.on('error', (err: Error) => { + if (writeStream) writeStream.end() + if (tempFile) cleanupTempFile(tempFile) + reject(err) + }) + + request.end() + }) +} + +/** + * Execute a single pull operation + */ +async function executePull(ds: DataSource): Promise { + if (getImportingStatus()) { + console.log(`[PullScheduler] Skipping pull for "${ds.name}": import in progress`) + return + } + + console.log(`[PullScheduler] Pulling from "${ds.name}" (${ds.url})`) + + const sources = loadDataSources() + const idx = sources.findIndex((s) => s.id === ds.id) + if (idx === -1) return + + let tempFile = '' + try { + tempFile = await fetchToTempFile(ds) + + // Skip empty responses + const stat = fs.statSync(tempFile) + if (stat.size === 0) { + console.log(`[PullScheduler] Empty response from "${ds.name}", skipping`) + sources[idx].lastPullAt = Math.floor(Date.now() / 1000) + sources[idx].lastStatus = 'success' + sources[idx].lastNewMessages = 0 + saveDataSources(sources) + return + } + + let result: any + if (ds.targetSessionId) { + result = await worker.incrementalImport(ds.targetSessionId, tempFile) + if (result.success) { + try { + await worker.generateIncrementalSessions(ds.targetSessionId) + } catch { /* ignore */ } + } + } else { + result = await worker.streamImport(tempFile) + } + + sources[idx].lastPullAt = Math.floor(Date.now() / 1000) + + if (result.success) { + sources[idx].lastStatus = 'success' + sources[idx].lastNewMessages = result.newMessageCount ?? 0 + sources[idx].lastError = '' + notifySessionListChanged() + notifyPullResult(ds.id, 'success', `Added ${sources[idx].lastNewMessages} new messages`) + } else { + sources[idx].lastStatus = 'error' + sources[idx].lastError = result.error || 'Import failed' + notifyPullResult(ds.id, 'error', sources[idx].lastError) + } + + saveDataSources(sources) + } catch (error: any) { + console.error(`[PullScheduler] Pull failed for "${ds.name}":`, error) + sources[idx].lastPullAt = Math.floor(Date.now() / 1000) + sources[idx].lastStatus = 'error' + sources[idx].lastError = error.message || 'Pull failed' + saveDataSources(sources) + notifyPullResult(ds.id, 'error', sources[idx].lastError) + } finally { + if (tempFile) cleanupTempFile(tempFile) + } +} + +/** + * Start timer for a data source + */ +function startTimer(ds: DataSource): void { + stopTimer(ds.id) + + if (!ds.enabled || ds.intervalMinutes < 1) return + + const intervalMs = ds.intervalMinutes * 60 * 1000 + + // Execute immediately on start + executePull(ds).catch((err) => { + console.error(`[PullScheduler] Initial pull failed:`, err) + }) + + const timer = setInterval(() => { + const current = loadDataSources().find((s) => s.id === ds.id) + if (!current || !current.enabled) { + stopTimer(ds.id) + return + } + executePull(current).catch((err) => { + console.error(`[PullScheduler] Scheduled pull failed:`, err) + }) + }, intervalMs) + + timers.set(ds.id, timer) + console.log(`[PullScheduler] Timer started for "${ds.name}" (every ${ds.intervalMinutes}min)`) +} + +function stopTimer(id: string): void { + const timer = timers.get(id) + if (timer) { + clearInterval(timer) + timers.delete(id) + } +} + +/** + * Initialize scheduler: start timers for all enabled data sources + */ +export function initScheduler(): void { + if (initialized) return + initialized = true + + const sources = loadDataSources() + for (const ds of sources) { + if (ds.enabled) { + startTimer(ds) + } + } + + console.log(`[PullScheduler] Initialized with ${sources.filter((s) => s.enabled).length} active sources`) +} + +/** + * Stop all active timers + */ +export function stopAllTimers(): void { + for (const [id] of timers) { + stopTimer(id) + } + initialized = false + console.log('[PullScheduler] All timers stopped') +} + +/** + * Reload timer for a single data source (called after config changes) + */ +export function reloadTimer(dsId: string): void { + stopTimer(dsId) + const ds = loadDataSources().find((s) => s.id === dsId) + if (ds && ds.enabled) { + startTimer(ds) + } +} + +/** + * Manually trigger a single pull + */ +export async function triggerPull(dsId: string): Promise<{ success: boolean; error?: string }> { + const ds = loadDataSources().find((s) => s.id === dsId) + if (!ds) return { success: false, error: 'Data source not found' } + + try { + await executePull(ds) + const updated = loadDataSources().find((s) => s.id === dsId) + if (updated?.lastStatus === 'error') { + return { success: false, error: updated.lastError } + } + return { success: true } + } catch (error: any) { + return { success: false, error: error.message } + } +} diff --git a/electron/main/api/routes/import.ts b/electron/main/api/routes/import.ts new file mode 100644 index 0000000..a99bbb4 --- /dev/null +++ b/electron/main/api/routes/import.ts @@ -0,0 +1,196 @@ +/** + * ChatLab API — Import routes (Push mode) + * + * POST /api/v1/import Import to new session + * POST /api/v1/sessions/:id/import Incremental import to existing session + * + * Content-Type dispatch: + * application/json → parse body → temp .json → chatlab parser + * application/x-ndjson → pipe raw stream → temp .jsonl → chatlab-jsonl parser + */ + +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify' +import * as fs from 'fs' +import * as path from 'path' +import * as crypto from 'crypto' +import { pipeline } from 'stream/promises' +import { getTempDir } from '../../paths' +import * as worker from '../../worker/workerManager' +import { + successResponse, + sessionNotFound, + importInProgress, + importFailed, + invalidFormat, + errorResponse, +} from '../errors' + +let isImporting = false + +function getTempFilePath(ext: string): string { + const id = crypto.randomBytes(8).toString('hex') + return path.join(getTempDir(), `api-import-${id}${ext}`) +} + +function cleanupTempFile(filePath: string): void { + try { + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath) + } + } catch (err) { + console.error('[ChatLab API] Failed to cleanup temp file:', err) + } +} + +/** + * Notify renderer process to refresh session list. + * Lazy-requires electron to avoid circular dependency. + */ +function notifySessionListChanged(): void { + try { + const { BrowserWindow } = require('electron') + const wins = BrowserWindow.getAllWindows() + for (const win of wins) { + win.webContents.send('api:importCompleted') + } + } catch { + // ignore + } +} + +export function getImportingStatus(): boolean { + return isImporting +} + +async function handleImport( + request: FastifyRequest, + reply: FastifyReply, + sessionId?: string +): Promise { + if (isImporting) { + const err = importInProgress() + reply.code(err.statusCode).send(errorResponse(err)) + return + } + + const contentType = (request.headers['content-type'] || '').toLowerCase() + const isJsonl = contentType.includes('application/x-ndjson') + const isJson = contentType.includes('application/json') + + if (!isJsonl && !isJson) { + const err = invalidFormat('Content-Type must be application/json or application/x-ndjson') + reply.code(err.statusCode).send(errorResponse(err)) + return + } + + isImporting = true + let tempFile = '' + + try { + if (isJson) { + // JSON mode: fastify already parsed body, write to temp file + const body = request.body + if (!body || typeof body !== 'object') { + const err = invalidFormat('Request body is not valid JSON') + reply.code(err.statusCode).send(errorResponse(err)) + return + } + + tempFile = getTempFilePath('.json') + fs.writeFileSync(tempFile, JSON.stringify(body), 'utf-8') + } else { + // JSONL mode: pipe raw stream to temp file + tempFile = getTempFilePath('.jsonl') + const writeStream = fs.createWriteStream(tempFile) + await pipeline(request.raw, writeStream) + } + + let result: any + + if (sessionId) { + // Incremental import to specified session + const session = await worker.getSession(sessionId) + if (!session) { + const err = sessionNotFound(sessionId) + reply.code(err.statusCode).send(errorResponse(err)) + return + } + + result = await worker.incrementalImport(sessionId, tempFile) + + if (result.success) { + try { + await worker.generateIncrementalSessions(sessionId) + } catch { + // non-blocking + } + + notifySessionListChanged() + + reply.send( + successResponse({ + mode: 'incremental', + sessionId, + newMessageCount: result.newMessageCount, + }) + ) + return + } else { + const err = importFailed(result.error || 'Incremental import failed') + reply.code(err.statusCode).send(errorResponse(err)) + return + } + } else { + // New session import + result = await worker.streamImport(tempFile) + + if (result.success) { + notifySessionListChanged() + + reply.send( + successResponse({ + mode: 'new', + sessionId: result.sessionId, + }) + ) + return + } else { + const err = importFailed(result.error || 'Import failed') + reply.code(err.statusCode).send(errorResponse(err)) + return + } + } + } catch (error: any) { + console.error('[ChatLab API] Import error:', error) + const err = importFailed(error.message || 'Import process error') + reply.code(err.statusCode).send(errorResponse(err)) + } finally { + isImporting = false + if (tempFile) { + cleanupTempFile(tempFile) + } + } +} + +export function registerImportRoutes(server: FastifyInstance): void { + // JSONL mode: skip fastify's default body parsing, use request.raw stream directly + server.addContentTypeParser( + 'application/x-ndjson', + (_request, _payload, done) => { + done(null, undefined) + } + ) + + // POST /api/v1/import — Import to new session + server.post('/api/v1/import', async (request, reply) => { + await handleImport(request, reply) + }) + + // POST /api/v1/sessions/:id/import — Incremental import to existing session + server.post<{ Params: { id: string } }>( + '/api/v1/sessions/:id/import', + async (request, reply) => { + await handleImport(request, reply, request.params.id) + } + ) +} diff --git a/electron/main/api/routes/sessions.ts b/electron/main/api/routes/sessions.ts index 75512ef..6b418b1 100644 --- a/electron/main/api/routes/sessions.ts +++ b/electron/main/api/routes/sessions.ts @@ -1,5 +1,5 @@ /** - * ChatLab API — 会话与导出路由 + * ChatLab API — Session and export routes */ import type { FastifyInstance } from 'fastify' @@ -15,19 +15,19 @@ async function ensureSession(sessionId: string) { } export function registerSessionRoutes(server: FastifyInstance): void { - // GET /api/v1/sessions — 会话列表 + // GET /api/v1/sessions — List all sessions server.get('/api/v1/sessions', async () => { const sessions = await worker.getAllSessions() return successResponse(sessions) }) - // GET /api/v1/sessions/:id — 单个会话详情 + // GET /api/v1/sessions/:id — Single session detail server.get<{ Params: { id: string } }>('/api/v1/sessions/:id', async (request) => { const session = await ensureSession(request.params.id) return successResponse(session) }) - // GET /api/v1/sessions/:id/messages — 查询消息(分页) + // GET /api/v1/sessions/:id/messages — Query messages (paginated) server.get<{ Params: { id: string } Querystring: { @@ -77,14 +77,14 @@ export function registerSessionRoutes(server: FastifyInstance): void { ) }) - // GET /api/v1/sessions/:id/members — 成员列表 + // GET /api/v1/sessions/:id/members — Member list server.get<{ Params: { id: string } }>('/api/v1/sessions/:id/members', async (request) => { await ensureSession(request.params.id) const members = await worker.getMembers(request.params.id) return successResponse(members) }) - // GET /api/v1/sessions/:id/stats/overview — 概览统计 + // 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 session = await ensureSession(id) @@ -116,7 +116,7 @@ export function registerSessionRoutes(server: FastifyInstance): void { }) }) - // POST /api/v1/sessions/:id/sql — 执行 SQL(只读) + // 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) => { @@ -125,7 +125,7 @@ export function registerSessionRoutes(server: FastifyInstance): void { const { sql } = request.body || {} if (!sql || typeof sql !== 'string') { - const err = sqlExecutionError('缺少 sql 参数') + const err = sqlExecutionError('Missing sql parameter') return reply.code(err.statusCode).send(errorResponse(err)) } @@ -133,7 +133,7 @@ export function registerSessionRoutes(server: FastifyInstance): void { const result = await worker.executeRawSQL(id, sql) return successResponse(result) } catch (err: any) { - const message = err.message || 'SQL 执行错误' + const message = err.message || 'SQL execution error' if (message.includes('SELECT') || message.includes('只读') || message.includes('readonly')) { const apiErr = new ApiError('SQL_READONLY_VIOLATION' as any, message) apiErr.statusCode = 400 @@ -145,7 +145,7 @@ export function registerSessionRoutes(server: FastifyInstance): void { } ) - // GET /api/v1/sessions/:id/export — 导出 ChatLab Format JSON + // 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 session = await ensureSession(id) diff --git a/electron/main/api/routes/system.ts b/electron/main/api/routes/system.ts index a387254..6c8e945 100644 --- a/electron/main/api/routes/system.ts +++ b/electron/main/api/routes/system.ts @@ -1,6 +1,6 @@ /** - * ChatLab API — 系统路由 - * GET /api/v1/status 服务状态 + * ChatLab API — System routes + * GET /api/v1/status Service status * GET /api/v1/schema ChatLab Format JSON Schema */ @@ -16,7 +16,7 @@ export function registerSystemRoutes(server: FastifyInstance): void { const sessions = await worker.getAllSessions() sessionCount = sessions.length } catch { - // Worker 未就绪时忽略 + // ignore when worker not ready } return successResponse({ diff --git a/electron/main/api/server.ts b/electron/main/api/server.ts index 6306f77..7b85db5 100644 --- a/electron/main/api/server.ts +++ b/electron/main/api/server.ts @@ -1,5 +1,5 @@ /** - * ChatLab API — fastify 服务器实例 + * ChatLab API — Fastify server instance */ import Fastify, { type FastifyInstance, type FastifyError } from 'fastify' @@ -23,7 +23,7 @@ export function createServer(): FastifyInstance { } if (error.statusCode === 413) { - const bodyErr = new ApiError(ApiErrorCode.BODY_TOO_LARGE, '请求体超过 50MB 上限') + const bodyErr = new ApiError(ApiErrorCode.BODY_TOO_LARGE, 'Request body exceeds 50MB limit') reply.code(413).send(errorResponse(bodyErr)) return } diff --git a/electron/main/ipc/api.ts b/electron/main/ipc/api.ts index 286f430..b54f543 100644 --- a/electron/main/ipc/api.ts +++ b/electron/main/ipc/api.ts @@ -1,13 +1,28 @@ /** - * ChatLab API 服务 IPC 处理器 + * ChatLab API — IPC handlers for renderer process */ import { ipcMain } from 'electron' import type { IpcContext } from './types' import * as apiServer from '../api' import { loadConfig, regenerateToken, updateConfig } from '../api/config' +import { + loadDataSources, + addDataSource, + updateDataSource, + deleteDataSource, + type DataSource, +} from '../api/dataSource' +import { + initScheduler, + stopAllTimers, + reloadTimer, + triggerPull, +} from '../api/pullScheduler' export function registerApiHandlers(_ctx: IpcContext): void { + // ==================== API Server Management ==================== + ipcMain.handle('api:getConfig', () => { const config = loadConfig() return { @@ -37,10 +52,50 @@ export function registerApiHandlers(_ctx: IpcContext): void { ipcMain.handle('api:updateConfig', (_event, partial: Record) => { return updateConfig(partial as any) }) + + // ==================== Data Source Management ==================== + + ipcMain.handle('api:getDataSources', () => { + return loadDataSources() + }) + + ipcMain.handle( + 'api:addDataSource', + ( + _event, + partial: Omit< + DataSource, + 'id' | 'createdAt' | 'lastPullAt' | 'lastStatus' | 'lastError' | 'lastNewMessages' + > + ) => { + const ds = addDataSource(partial) + if (ds.enabled) { + reloadTimer(ds.id) + } + return ds + } + ) + + ipcMain.handle('api:updateDataSource', (_event, id: string, updates: Partial) => { + const ds = updateDataSource(id, updates) + if (ds) { + reloadTimer(ds.id) + } + return ds + }) + + ipcMain.handle('api:deleteDataSource', (_event, id: string) => { + reloadTimer(id) // stops timer + return deleteDataSource(id) + }) + + ipcMain.handle('api:triggerPull', async (_event, id: string) => { + return triggerPull(id) + }) } /** - * 应用启动后尝试自动启动 API 服务,如果失败则通知主窗口 + * Auto-start API server and Pull scheduler after app launch */ export async function initApiServer(ctx: IpcContext): Promise { await apiServer.autoStart() @@ -53,8 +108,12 @@ export async function initApiServer(ctx: IpcContext): Promise { }) }) } + + // Initialize Pull scheduler (independent of API server, pulls even if API is not running) + initScheduler() } export async function cleanupApiServer(): Promise { + stopAllTimers() await apiServer.stop() } diff --git a/electron/preload/apis/api-server.ts b/electron/preload/apis/api-server.ts index 0d8de82..8520af6 100644 --- a/electron/preload/apis/api-server.ts +++ b/electron/preload/apis/api-server.ts @@ -18,7 +18,24 @@ export interface ApiServerStatus { error: string | null } +export interface DataSource { + id: string + name: string + url: string + token: string + intervalMinutes: number + enabled: boolean + targetSessionId: string + lastPullAt: number + lastStatus: 'idle' | 'success' | 'error' + lastError: string + lastNewMessages: number + createdAt: number +} + export const apiServerApi = { + // ==================== API 服务管理 ==================== + getConfig: (): Promise => { return ipcRenderer.invoke('api:getConfig') }, @@ -44,4 +61,38 @@ export const apiServerApi = { ipcRenderer.on('api:startupError', handler) return () => ipcRenderer.removeListener('api:startupError', handler) }, + + // ==================== 数据源管理 ==================== + + getDataSources: (): Promise => { + return ipcRenderer.invoke('api:getDataSources') + }, + + addDataSource: (partial: Omit): Promise => { + return ipcRenderer.invoke('api:addDataSource', partial) + }, + + updateDataSource: (id: string, updates: Partial): Promise => { + return ipcRenderer.invoke('api:updateDataSource', id, updates) + }, + + deleteDataSource: (id: string): Promise => { + return ipcRenderer.invoke('api:deleteDataSource', id) + }, + + triggerPull: (id: string): Promise<{ success: boolean; error?: string }> => { + return ipcRenderer.invoke('api:triggerPull', id) + }, + + onPullResult: (callback: (data: { dsId: string; status: string; detail: string }) => void): (() => void) => { + const handler = (_event: any, data: { dsId: string; status: string; detail: string }) => callback(data) + ipcRenderer.on('api:pullResult', handler) + return () => ipcRenderer.removeListener('api:pullResult', handler) + }, + + onImportCompleted: (callback: () => void): (() => void) => { + const handler = () => callback() + ipcRenderer.on('api:importCompleted', handler) + return () => ipcRenderer.removeListener('api:importCompleted', handler) + }, } diff --git a/electron/preload/index.d.ts b/electron/preload/index.d.ts index 06c3284..ab71bff 100644 --- a/electron/preload/index.d.ts +++ b/electron/preload/index.d.ts @@ -893,6 +893,21 @@ interface ApiServerStatus { error: string | null } +interface DataSource { + id: string + name: string + url: string + token: string + intervalMinutes: number + enabled: boolean + targetSessionId: string + lastPullAt: number + lastStatus: 'idle' | 'success' | 'error' + lastError: string + lastNewMessages: number + createdAt: number +} + interface ApiServerApi { getConfig: () => Promise getStatus: () => Promise @@ -900,6 +915,13 @@ interface ApiServerApi { setPort: (port: number) => Promise regenerateToken: () => Promise onStartupError: (callback: (data: { error: string }) => void) => () => void + getDataSources: () => Promise + addDataSource: (partial: Omit) => Promise + updateDataSource: (id: string, updates: Partial) => Promise + deleteDataSource: (id: string) => Promise + triggerPull: (id: string) => Promise<{ success: boolean; error?: string }> + onPullResult: (callback: (data: { dsId: string; status: string; detail: string }) => void) => () => void + onImportCompleted: (callback: () => void) => () => void } // Session Index API 类型 - 会话索引功能 @@ -1048,4 +1070,5 @@ export { ApiServerApi, ApiServerConfig, ApiServerStatus, + DataSource, } diff --git a/src/i18n/locales/en-US/settings.json b/src/i18n/locales/en-US/settings.json index 111c5df..a8607db 100644 --- a/src/i18n/locales/en-US/settings.json +++ b/src/i18n/locales/en-US/settings.json @@ -433,6 +433,27 @@ "noToken": "No token generated yet. Please enable the service first", "regenerate": "Regenerate" }, + "dataSources": { + "title": "Data Sources", + "desc": "Configure external data source URLs. ChatLab will automatically pull and import chat data at the specified interval.", + "empty": "No data sources", + "disabled": "Paused", + "every": "Every", + "minutes": "min", + "lastSync": "Last sync", + "addBtn": "Add Data Source", + "form": { + "name": "Name", + "namePlaceholder": "e.g., My Chat Server", + "url": "Data Source URL", + "token": "Access Token (optional)", + "tokenPlaceholder": "Bearer Token for the remote API", + "interval": "Pull Interval (minutes)", + "targetSession": "Target Session ID (optional)", + "targetSessionPlaceholder": "Leave empty to create new session each time", + "add": "Add" + } + }, "usage": { "title": "Usage Guide", "desc": "After enabling the service, use the following endpoints to query data. All requests require Bearer Token authentication.", diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index d2e3069..9f7e2d4 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -433,6 +433,27 @@ "noToken": "トークンが未生成です。まずサービスを有効にしてください", "regenerate": "再生成" }, + "dataSources": { + "title": "データソース", + "desc": "外部データソースの URL を設定すると、ChatLab が指定間隔で自動的にチャットデータを取得・インポートします。", + "empty": "データソースなし", + "disabled": "一時停止中", + "every": "", + "minutes": "分ごと", + "lastSync": "最終同期", + "addBtn": "データソースを追加", + "form": { + "name": "名前", + "namePlaceholder": "例:マイチャットサーバー", + "url": "データソース URL", + "token": "アクセストークン(任意)", + "tokenPlaceholder": "リモート API の Bearer Token", + "interval": "取得間隔(分)", + "targetSession": "ターゲットセッション ID(任意)", + "targetSessionPlaceholder": "空欄の場合、毎回新規セッションを作成", + "add": "追加" + } + }, "usage": { "title": "使用ガイド", "desc": "サービスを有効にした後、以下のエンドポイントでデータを照会できます。すべてのリクエストに Bearer Token 認証が必要です。", diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 0aaa9e0..4a7b99f 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -433,6 +433,27 @@ "noToken": "尚未生成 Token,请先启用服务", "regenerate": "重新生成" }, + "dataSources": { + "title": "数据源", + "desc": "配置外部数据源 URL,ChatLab 将按设定间隔自动拉取并导入聊天数据。", + "empty": "暂无数据源", + "disabled": "已暂停", + "every": "每", + "minutes": "分钟", + "lastSync": "上次同步", + "addBtn": "添加数据源", + "form": { + "name": "名称", + "namePlaceholder": "例如:我的聊天服务器", + "url": "数据源 URL", + "token": "访问令牌(可选)", + "tokenPlaceholder": "远程 API 的 Bearer Token", + "interval": "拉取间隔(分钟)", + "targetSession": "目标会话 ID(可选)", + "targetSessionPlaceholder": "留空则每次新建会话", + "add": "添加" + } + }, "usage": { "title": "使用说明", "desc": "启用服务后,可使用以下端点查询数据。所有请求需携带 Bearer Token 认证。", diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index f1f7cbe..3474026 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -433,6 +433,27 @@ "noToken": "尚未產生 Token,請先啟用服務", "regenerate": "重新產生" }, + "dataSources": { + "title": "資料來源", + "desc": "設定外部資料來源 URL,ChatLab 將按設定間隔自動拉取並匯入聊天資料。", + "empty": "暫無資料來源", + "disabled": "已暫停", + "every": "每", + "minutes": "分鐘", + "lastSync": "上次同步", + "addBtn": "新增資料來源", + "form": { + "name": "名稱", + "namePlaceholder": "例如:我的聊天伺服器", + "url": "資料來源 URL", + "token": "存取權杖(可選)", + "tokenPlaceholder": "遠端 API 的 Bearer Token", + "interval": "拉取間隔(分鐘)", + "targetSession": "目標會話 ID(可選)", + "targetSessionPlaceholder": "留空則每次新建會話", + "add": "新增" + } + }, "usage": { "title": "使用說明", "desc": "啟用服務後,可使用以下端點查詢數據。所有請求需攜帶 Bearer Token 認證。", diff --git a/src/pages/settings/components/ApiSettingsTab.vue b/src/pages/settings/components/ApiSettingsTab.vue index 0a81a4c..9e58ba7 100644 --- a/src/pages/settings/components/ApiSettingsTab.vue +++ b/src/pages/settings/components/ApiSettingsTab.vue @@ -1,28 +1,41 @@