mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
feat: 支持API导入
This commit is contained in:
1 parent
6d5e6f6e7a
commit
7ce50eab7c
21 files changed
+2100
-97
No files matched your search
@@ -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 调度器 |
|
||||
@@ -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 |
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* ChatLab API Bearer Token 认证中间件
|
||||
* ChatLab API — Bearer Token authentication hook
|
||||
*/
|
||||
|
||||
import type { FastifyRequest, FastifyReply } from 'fastify'
|
||||
|
||||
@@ -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>): ApiServerConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保 Token 存在(首次启用时自动生成)
|
||||
* Ensure token exists (auto-generated on first enable)
|
||||
*/
|
||||
export function ensureToken(config: ApiServerConfig): ApiServerConfig {
|
||||
if (!config.token) {
|
||||
|
||||
@@ -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, 'id' | 'createdAt' | 'lastPullAt' | 'lastStatus' | 'lastError' | 'lastNewMessages'>): 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>): 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
|
||||
}
|
||||
@@ -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<T>(data: T, meta?: Record<string, unknown>) {
|
||||
return {
|
||||
success: true as const,
|
||||
@@ -92,7 +91,6 @@ export function successResponse<T>(data: T, meta?: Record<string, unknown>) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 构建统一的错误响应 */
|
||||
export function errorResponse(error: ApiError) {
|
||||
return {
|
||||
success: false as const,
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用启动时自动恢复:若配置为 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<void> {
|
||||
const config = loadConfig()
|
||||
@@ -93,12 +95,12 @@ export async function autoStart(): Promise<void> {
|
||||
try {
|
||||
await start()
|
||||
} catch {
|
||||
// 静默失败,lastError 已记录
|
||||
// silent failure, lastError already recorded
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置启用状态(持久化)
|
||||
* Set enabled state (persisted)
|
||||
*/
|
||||
export async function setEnabled(enabled: boolean): Promise<ApiServerStatus> {
|
||||
const config = loadConfig()
|
||||
@@ -110,7 +112,7 @@ export async function setEnabled(enabled: boolean): Promise<ApiServerStatus> {
|
||||
try {
|
||||
await start()
|
||||
} catch {
|
||||
// lastError 已记录
|
||||
// lastError already recorded
|
||||
}
|
||||
} else {
|
||||
await stop()
|
||||
@@ -120,7 +122,7 @@ export async function setEnabled(enabled: boolean): Promise<ApiServerStatus> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置端口(持久化,需要重启服务)
|
||||
* Set port (persisted, requires server restart)
|
||||
*/
|
||||
export async function setPort(port: number): Promise<ApiServerStatus> {
|
||||
const config = loadConfig()
|
||||
@@ -134,7 +136,7 @@ export async function setPort(port: number): Promise<ApiServerStatus> {
|
||||
try {
|
||||
await start()
|
||||
} catch {
|
||||
// lastError 已记录
|
||||
// lastError already recorded
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, ReturnType<typeof setInterval>>()
|
||||
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<string> {
|
||||
return new Promise<string>((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<void> {
|
||||
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 }
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>) => {
|
||||
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<DataSource>) => {
|
||||
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<void> {
|
||||
await apiServer.autoStart()
|
||||
@@ -53,8 +108,12 @@ export async function initApiServer(ctx: IpcContext): Promise<void> {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Initialize Pull scheduler (independent of API server, pulls even if API is not running)
|
||||
initScheduler()
|
||||
}
|
||||
|
||||
export async function cleanupApiServer(): Promise<void> {
|
||||
stopAllTimers()
|
||||
await apiServer.stop()
|
||||
}
|
||||
@@ -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<ApiServerConfig> => {
|
||||
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<DataSource[]> => {
|
||||
return ipcRenderer.invoke('api:getDataSources')
|
||||
},
|
||||
|
||||
addDataSource: (partial: Omit<DataSource, 'id' | 'createdAt' | 'lastPullAt' | 'lastStatus' | 'lastError' | 'lastNewMessages'>): Promise<DataSource> => {
|
||||
return ipcRenderer.invoke('api:addDataSource', partial)
|
||||
},
|
||||
|
||||
updateDataSource: (id: string, updates: Partial<DataSource>): Promise<DataSource | null> => {
|
||||
return ipcRenderer.invoke('api:updateDataSource', id, updates)
|
||||
},
|
||||
|
||||
deleteDataSource: (id: string): Promise<boolean> => {
|
||||
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)
|
||||
},
|
||||
}
|
||||
Vendored
+23
@@ -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<ApiServerConfig>
|
||||
getStatus: () => Promise<ApiServerStatus>
|
||||
@@ -900,6 +915,13 @@ interface ApiServerApi {
|
||||
setPort: (port: number) => Promise<ApiServerStatus>
|
||||
regenerateToken: () => Promise<ApiServerConfig>
|
||||
onStartupError: (callback: (data: { error: string }) => void) => () => void
|
||||
getDataSources: () => Promise<DataSource[]>
|
||||
addDataSource: (partial: Omit<DataSource, 'id' | 'createdAt' | 'lastPullAt' | 'lastStatus' | 'lastError' | 'lastNewMessages'>) => Promise<DataSource>
|
||||
updateDataSource: (id: string, updates: Partial<DataSource>) => Promise<DataSource | null>
|
||||
deleteDataSource: (id: string) => Promise<boolean>
|
||||
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,
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 認証が必要です。",
|
||||
|
||||
@@ -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 认证。",
|
||||
|
||||
@@ -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 認證。",
|
||||
|
||||
@@ -1,28 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { ref, reactive, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useApiServerStore } from '@/stores/apiServer'
|
||||
import { useApiServerStore, type DataSource } from '@/stores/apiServer'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
const { t } = useI18n()
|
||||
const store = useApiServerStore()
|
||||
const { config, status, loading, isRunning, hasError, isPortInUse } = storeToRefs(store)
|
||||
const { config, status, loading, isRunning, hasError, isPortInUse, dataSources, pullingId } = storeToRefs(store)
|
||||
|
||||
const tokenVisible = ref(false)
|
||||
const editingPort = ref(false)
|
||||
const portInput = ref(5200)
|
||||
const copied = ref(false)
|
||||
const showAddForm = ref(false)
|
||||
|
||||
const newSource = reactive({
|
||||
name: '',
|
||||
url: '',
|
||||
token: '',
|
||||
intervalMinutes: 60,
|
||||
enabled: true,
|
||||
targetSessionId: '',
|
||||
})
|
||||
|
||||
let unlistenStartupError: (() => void) | null = null
|
||||
let unlistenPullResult: (() => void) | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
await store.refresh()
|
||||
portInput.value = config.value.port
|
||||
unlistenStartupError = store.listenStartupError()
|
||||
unlistenPullResult = store.listenPullResult()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
unlistenStartupError?.()
|
||||
unlistenPullResult?.()
|
||||
})
|
||||
|
||||
const maskedToken = computed(() => {
|
||||
@@ -71,14 +84,47 @@ async function copyToken() {
|
||||
await navigator.clipboard.writeText(config.value.token)
|
||||
copied.value = true
|
||||
setTimeout(() => { copied.value = false }, 2000)
|
||||
} catch {
|
||||
// fallback
|
||||
}
|
||||
} catch { /* fallback */ }
|
||||
}
|
||||
|
||||
async function handleRegenerateToken() {
|
||||
await store.regenerateToken()
|
||||
}
|
||||
|
||||
// ==================== 数据源管理 ====================
|
||||
|
||||
function resetNewSource() {
|
||||
newSource.name = ''
|
||||
newSource.url = ''
|
||||
newSource.token = ''
|
||||
newSource.intervalMinutes = 60
|
||||
newSource.enabled = true
|
||||
newSource.targetSessionId = ''
|
||||
showAddForm.value = false
|
||||
}
|
||||
|
||||
async function addSource() {
|
||||
if (!newSource.name || !newSource.url) return
|
||||
await store.addDataSource({ ...newSource })
|
||||
resetNewSource()
|
||||
}
|
||||
|
||||
async function toggleSourceEnabled(ds: DataSource) {
|
||||
await store.updateDataSource(ds.id, { enabled: !ds.enabled })
|
||||
}
|
||||
|
||||
async function removeSource(ds: DataSource) {
|
||||
await store.deleteDataSource(ds.id)
|
||||
}
|
||||
|
||||
async function pullNow(ds: DataSource) {
|
||||
await store.triggerPull(ds.id)
|
||||
}
|
||||
|
||||
function formatTime(ts: number): string {
|
||||
if (!ts) return '-'
|
||||
return new Date(ts * 1000).toLocaleString()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -101,8 +147,6 @@ async function handleRegenerateToken() {
|
||||
</div>
|
||||
<USwitch :model-value="config.enabled" :loading="loading" @update:model-value="toggleEnabled" />
|
||||
</div>
|
||||
|
||||
<!-- 运行状态 -->
|
||||
<div v-if="config.enabled" class="mt-3 flex items-center gap-2 border-t border-gray-200 pt-3 dark:border-gray-700">
|
||||
<span class="inline-block h-2 w-2 rounded-full" :class="isRunning ? 'bg-green-500' : hasError ? 'bg-red-500' : 'bg-gray-400'"></span>
|
||||
<span class="text-xs" :class="statusColor">{{ statusText }}</span>
|
||||
@@ -110,8 +154,6 @@ async function handleRegenerateToken() {
|
||||
{{ apiBaseUrl }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 端口占用提示 -->
|
||||
<div v-if="isPortInUse" class="mt-2 rounded-md bg-red-50 p-2 text-xs text-red-600 dark:bg-red-900/20 dark:text-red-400">
|
||||
{{ t('settings.api.service.portInUseHint') }}
|
||||
</div>
|
||||
@@ -127,27 +169,17 @@ async function handleRegenerateToken() {
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/50">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1 pr-4">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ t('settings.api.port.label') }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('settings.api.port.desc') }}
|
||||
</p>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ t('settings.api.port.label') }}</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('settings.api.port.desc') }}</p>
|
||||
</div>
|
||||
<div v-if="editingPort" class="flex items-center gap-2">
|
||||
<UInput v-model.number="portInput" type="number" :min="1024" :max="65535" size="sm" class="w-24" />
|
||||
<UButton size="xs" color="primary" :loading="loading" @click="savePort">
|
||||
{{ t('settings.api.port.save') }}
|
||||
</UButton>
|
||||
<UButton size="xs" variant="ghost" @click="cancelPortEdit">
|
||||
{{ t('settings.api.port.cancel') }}
|
||||
</UButton>
|
||||
<UButton size="xs" color="primary" :loading="loading" @click="savePort">{{ t('settings.api.port.save') }}</UButton>
|
||||
<UButton size="xs" variant="ghost" @click="cancelPortEdit">{{ t('settings.api.port.cancel') }}</UButton>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-2">
|
||||
<span class="text-sm font-mono text-gray-700 dark:text-gray-300">{{ config.port }}</span>
|
||||
<UButton size="xs" variant="ghost" @click="editingPort = true; portInput = config.port">
|
||||
{{ t('settings.api.port.edit') }}
|
||||
</UButton>
|
||||
<UButton size="xs" variant="ghost" @click="editingPort = true; portInput = config.port">{{ t('settings.api.port.edit') }}</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -160,34 +192,132 @@ async function handleRegenerateToken() {
|
||||
{{ t('settings.api.token.title') }}
|
||||
</h3>
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/50">
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ t('settings.api.token.label') }}
|
||||
</p>
|
||||
<p class="mb-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('settings.api.token.desc') }}
|
||||
</p>
|
||||
<div v-if="config.token" class="flex items-center gap-2">
|
||||
<code class="flex-1 rounded bg-gray-100 px-3 py-2 font-mono text-xs text-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
||||
{{ tokenVisible ? config.token : maskedToken }}
|
||||
</code>
|
||||
<UButton size="xs" variant="ghost" @click="tokenVisible = !tokenVisible">
|
||||
<UIcon :name="tokenVisible ? 'i-heroicons-eye-slash' : 'i-heroicons-eye'" class="h-4 w-4" />
|
||||
</UButton>
|
||||
<UButton size="xs" variant="ghost" @click="copyToken">
|
||||
<UIcon :name="copied ? 'i-heroicons-check' : 'i-heroicons-clipboard'" class="h-4 w-4" />
|
||||
</UButton>
|
||||
</div>
|
||||
<div v-else class="text-xs text-gray-400">
|
||||
{{ t('settings.api.token.noToken') }}
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<UButton size="xs" variant="soft" color="warning" @click="handleRegenerateToken">
|
||||
<UIcon name="i-heroicons-arrow-path" class="mr-1 h-3 w-3" />
|
||||
{{ t('settings.api.token.regenerate') }}
|
||||
</UButton>
|
||||
<p class="mb-2 text-sm font-medium text-gray-900 dark:text-white">{{ t('settings.api.token.label') }}</p>
|
||||
<p class="mb-3 text-xs text-gray-500 dark:text-gray-400">{{ t('settings.api.token.desc') }}</p>
|
||||
<div v-if="config.token" class="flex items-center gap-2">
|
||||
<code class="flex-1 rounded bg-gray-100 px-3 py-2 font-mono text-xs text-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
||||
{{ tokenVisible ? config.token : maskedToken }}
|
||||
</code>
|
||||
<UButton size="xs" variant="ghost" @click="tokenVisible = !tokenVisible">
|
||||
<UIcon :name="tokenVisible ? 'i-heroicons-eye-slash' : 'i-heroicons-eye'" class="h-4 w-4" />
|
||||
</UButton>
|
||||
<UButton size="xs" variant="ghost" @click="copyToken">
|
||||
<UIcon :name="copied ? 'i-heroicons-check' : 'i-heroicons-clipboard'" class="h-4 w-4" />
|
||||
</UButton>
|
||||
</div>
|
||||
<div v-else class="text-xs text-gray-400">{{ t('settings.api.token.noToken') }}</div>
|
||||
<div class="mt-3">
|
||||
<UButton size="xs" variant="soft" color="warning" @click="handleRegenerateToken">
|
||||
<UIcon name="i-heroicons-arrow-path" class="mr-1 h-3 w-3" />
|
||||
{{ t('settings.api.token.regenerate') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 数据源管理 -->
|
||||
<div>
|
||||
<h3 class="mb-3 flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
<UIcon name="i-heroicons-cloud-arrow-down" class="h-4 w-4 text-indigo-500" />
|
||||
{{ t('settings.api.dataSources.title') }}
|
||||
</h3>
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/50">
|
||||
<p class="mb-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('settings.api.dataSources.desc') }}
|
||||
</p>
|
||||
|
||||
<!-- 数据源列表 -->
|
||||
<div v-if="dataSources.length > 0" class="mb-4 space-y-3">
|
||||
<div
|
||||
v-for="ds in dataSources"
|
||||
:key="ds.id"
|
||||
class="rounded-lg border border-gray-200 bg-white p-3 dark:border-gray-600 dark:bg-gray-800"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="inline-block h-2 w-2 rounded-full"
|
||||
:class="ds.lastStatus === 'success' ? 'bg-green-500' : ds.lastStatus === 'error' ? 'bg-red-500' : 'bg-gray-400'"
|
||||
></span>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">{{ ds.name }}</span>
|
||||
<span v-if="!ds.enabled" class="text-xs text-gray-400">({{ t('settings.api.dataSources.disabled') }})</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<UButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
:loading="pullingId === ds.id"
|
||||
@click="pullNow(ds)"
|
||||
>
|
||||
<UIcon name="i-heroicons-arrow-path" class="h-3.5 w-3.5" />
|
||||
</UButton>
|
||||
<UButton size="xs" variant="ghost" @click="toggleSourceEnabled(ds)">
|
||||
<UIcon :name="ds.enabled ? 'i-heroicons-pause' : 'i-heroicons-play'" class="h-3.5 w-3.5" />
|
||||
</UButton>
|
||||
<UButton size="xs" variant="ghost" color="error" @click="removeSource(ds)">
|
||||
<UIcon name="i-heroicons-trash" class="h-3.5 w-3.5" />
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span class="font-mono">{{ ds.url }}</span>
|
||||
<span class="mx-2">·</span>
|
||||
<span>{{ t('settings.api.dataSources.every') }} {{ ds.intervalMinutes }} {{ t('settings.api.dataSources.minutes') }}</span>
|
||||
</div>
|
||||
<div v-if="ds.lastPullAt" class="mt-1 text-xs text-gray-400">
|
||||
{{ t('settings.api.dataSources.lastSync') }}: {{ formatTime(ds.lastPullAt) }}
|
||||
<span v-if="ds.lastStatus === 'success'" class="text-green-500">
|
||||
(+{{ ds.lastNewMessages }})
|
||||
</span>
|
||||
<span v-if="ds.lastStatus === 'error'" class="text-red-500">
|
||||
{{ ds.lastError }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="mb-4 text-center text-xs text-gray-400 py-4">
|
||||
{{ t('settings.api.dataSources.empty') }}
|
||||
</div>
|
||||
|
||||
<!-- 添加表单 -->
|
||||
<div v-if="showAddForm" class="rounded-lg border border-blue-200 bg-blue-50/50 p-3 dark:border-blue-800 dark:bg-blue-900/20">
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">{{ t('settings.api.dataSources.form.name') }}</label>
|
||||
<UInput v-model="newSource.name" size="sm" :placeholder="t('settings.api.dataSources.form.namePlaceholder')" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">{{ t('settings.api.dataSources.form.url') }}</label>
|
||||
<UInput v-model="newSource.url" size="sm" placeholder="https://example.com/api/export" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">{{ t('settings.api.dataSources.form.token') }}</label>
|
||||
<UInput v-model="newSource.token" size="sm" :placeholder="t('settings.api.dataSources.form.tokenPlaceholder')" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">{{ t('settings.api.dataSources.form.interval') }}</label>
|
||||
<UInput v-model.number="newSource.intervalMinutes" type="number" :min="1" size="sm" class="w-32" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">{{ t('settings.api.dataSources.form.targetSession') }}</label>
|
||||
<UInput v-model="newSource.targetSessionId" size="sm" :placeholder="t('settings.api.dataSources.form.targetSessionPlaceholder')" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2 pt-1">
|
||||
<UButton size="xs" color="primary" :disabled="!newSource.name || !newSource.url" @click="addSource">
|
||||
{{ t('settings.api.dataSources.form.add') }}
|
||||
</UButton>
|
||||
<UButton size="xs" variant="ghost" @click="resetNewSource">
|
||||
{{ t('settings.api.port.cancel') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UButton v-if="!showAddForm" size="xs" variant="soft" @click="showAddForm = true">
|
||||
<UIcon name="i-heroicons-plus" class="mr-1 h-3 w-3" />
|
||||
{{ t('settings.api.dataSources.addBtn') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -198,9 +328,7 @@ async function handleRegenerateToken() {
|
||||
{{ t('settings.api.usage.title') }}
|
||||
</h3>
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/50">
|
||||
<p class="mb-3 text-xs text-gray-600 dark:text-gray-400">
|
||||
{{ t('settings.api.usage.desc') }}
|
||||
</p>
|
||||
<p class="mb-3 text-xs text-gray-600 dark:text-gray-400">{{ t('settings.api.usage.desc') }}</p>
|
||||
<div class="space-y-2">
|
||||
<div class="rounded bg-gray-100 p-2 font-mono text-xs text-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
||||
<span class="text-green-600 dark:text-green-400">GET</span> {{ apiBaseUrl }}/status
|
||||
@@ -215,9 +343,7 @@ async function handleRegenerateToken() {
|
||||
<span class="text-blue-600 dark:text-blue-400">POST</span> {{ apiBaseUrl }}/sessions/:id/sql
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('settings.api.usage.authHint') }}
|
||||
</p>
|
||||
<p class="mt-3 text-xs text-gray-500 dark:text-gray-400">{{ t('settings.api.usage.authHint') }}</p>
|
||||
<div class="mt-1 rounded bg-gray-100 p-2 font-mono text-xs text-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
||||
Authorization: Bearer {{ config.token ? maskedToken : 'clb_...' }}
|
||||
</div>
|
||||
|
||||
+94
-1
@@ -19,6 +19,21 @@ 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 useApiServerStore = defineStore('apiServer', () => {
|
||||
const config = ref<ApiServerConfig>({
|
||||
enabled: false,
|
||||
@@ -35,6 +50,8 @@ export const useApiServerStore = defineStore('apiServer', () => {
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const dataSources = ref<DataSource[]>([])
|
||||
const pullingId = ref<string | null>(null)
|
||||
|
||||
const isRunning = computed(() => status.value.running)
|
||||
const hasError = computed(() => !!status.value.error)
|
||||
@@ -57,7 +74,7 @@ export const useApiServerStore = defineStore('apiServer', () => {
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
await Promise.all([fetchConfig(), fetchStatus()])
|
||||
await Promise.all([fetchConfig(), fetchStatus(), fetchDataSources()])
|
||||
}
|
||||
|
||||
async function setEnabled(enabled: boolean) {
|
||||
@@ -99,10 +116,80 @@ export const useApiServerStore = defineStore('apiServer', () => {
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 数据源管理 ====================
|
||||
|
||||
async function fetchDataSources() {
|
||||
try {
|
||||
dataSources.value = await window.apiServerApi.getDataSources()
|
||||
} catch (err) {
|
||||
console.error('[ApiServerStore] Failed to fetch data sources:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function addDataSource(partial: Omit<DataSource, 'id' | 'createdAt' | 'lastPullAt' | 'lastStatus' | 'lastError' | 'lastNewMessages'>) {
|
||||
try {
|
||||
const ds = await window.apiServerApi.addDataSource(partial)
|
||||
dataSources.value.push(ds)
|
||||
return ds
|
||||
} catch (err) {
|
||||
console.error('[ApiServerStore] Failed to add data source:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function updateDataSource(id: string, updates: Partial<DataSource>) {
|
||||
try {
|
||||
const ds = await window.apiServerApi.updateDataSource(id, updates)
|
||||
if (ds) {
|
||||
const idx = dataSources.value.findIndex((s) => s.id === id)
|
||||
if (idx !== -1) dataSources.value[idx] = ds
|
||||
}
|
||||
return ds
|
||||
} catch (err) {
|
||||
console.error('[ApiServerStore] Failed to update data source:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteDataSource(id: string) {
|
||||
try {
|
||||
const ok = await window.apiServerApi.deleteDataSource(id)
|
||||
if (ok) {
|
||||
dataSources.value = dataSources.value.filter((s) => s.id !== id)
|
||||
}
|
||||
return ok
|
||||
} catch (err) {
|
||||
console.error('[ApiServerStore] Failed to delete data source:', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerPull(id: string) {
|
||||
pullingId.value = id
|
||||
try {
|
||||
const result = await window.apiServerApi.triggerPull(id)
|
||||
await fetchDataSources()
|
||||
return result
|
||||
} catch (err) {
|
||||
console.error('[ApiServerStore] Failed to trigger pull:', err)
|
||||
return { success: false, error: String(err) }
|
||||
} finally {
|
||||
pullingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function listenPullResult() {
|
||||
return window.apiServerApi.onPullResult(() => {
|
||||
fetchDataSources()
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
config,
|
||||
status,
|
||||
loading,
|
||||
dataSources,
|
||||
pullingId,
|
||||
isRunning,
|
||||
hasError,
|
||||
isPortInUse,
|
||||
@@ -113,5 +200,11 @@ export const useApiServerStore = defineStore('apiServer', () => {
|
||||
setPort,
|
||||
regenerateToken,
|
||||
listenStartupError,
|
||||
fetchDataSources,
|
||||
addDataSource,
|
||||
updateDataSource,
|
||||
deleteDataSource,
|
||||
triggerPull,
|
||||
listenPullResult,
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user