mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-04-29 16:22:43 +08:00
style: 代码格式优化
This commit is contained in:
@@ -37,13 +37,13 @@ Response example:
|
||||
|
||||
## 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) |
|
||||
| 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
|
||||
|
||||
@@ -88,29 +88,29 @@ The Token can be viewed and regenerated in Settings → ChatLab API.
|
||||
|
||||
### System
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/v1/status` | Service status |
|
||||
| GET | `/api/v1/schema` | ChatLab Format JSON Schema |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| Method | Path | Description |
|
||||
| ------ | ----------------------------- | -------------------------------------- |
|
||||
| POST | `/api/v1/import` | Import chat records (new session) |
|
||||
| POST | `/api/v1/sessions/:id/import` | Incremental import to existing session |
|
||||
|
||||
---
|
||||
|
||||
@@ -122,12 +122,12 @@ 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 |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -167,9 +167,9 @@ Get detailed information for a single session.
|
||||
|
||||
**Path parameters:**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `id` | string | Session ID |
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ------ | ----------- |
|
||||
| `id` | string | Session ID |
|
||||
|
||||
---
|
||||
|
||||
@@ -179,15 +179,15 @@ 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 |
|
||||
| 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:**
|
||||
|
||||
@@ -262,7 +262,7 @@ Get overview statistics for a specific session.
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| --- | --- |
|
||||
| `messageCount` | Total message count |
|
||||
| `memberCount` | Total member count |
|
||||
| `timeRange` | Earliest/latest message timestamps (Unix seconds) |
|
||||
@@ -339,7 +339,7 @@ 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** |
|
||||
|
||||
@@ -409,9 +409,9 @@ The unique key for each message is `timestamp + senderPlatformId + contentLength
|
||||
|
||||
**Path parameters:**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `id` | string | Target session ID |
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ------ | ----------------- |
|
||||
| `id` | string | Target session ID |
|
||||
|
||||
Content-Type and request body format are the same as `POST /api/v1/import`.
|
||||
|
||||
@@ -432,30 +432,30 @@ Content-Type and request body format are the same as `POST /api/v1/import`.
|
||||
|
||||
## 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -494,6 +494,6 @@ Configure external data source URLs in the Settings page. ChatLab will automatic
|
||||
|
||||
## Version History
|
||||
|
||||
| Version | Description |
|
||||
|---------|-------------|
|
||||
| v1 | Initial release — session query, message search, SQL, export, import (JSON + JSONL), Pull scheduler |
|
||||
| Version | Description |
|
||||
| ------- | --------------------------------------------------------------------------------------------------- |
|
||||
| v1 | Initial release — session query, message search, SQL, export, import (JSON + JSONL), Pull scheduler |
|
||||
|
||||
@@ -10,10 +10,10 @@ presetQuestions:
|
||||
- Analyze the active hours of the chat
|
||||
---
|
||||
|
||||
You are a professional yet friendly chat record analysis assistant.
|
||||
Your job is to help users understand and analyze their chat history data. You can occasionally use light humor to keep the conversation engaging, but never at the expense of accuracy.
|
||||
You are a professional yet friendly chat record analysis assistant. Your job is to help users understand and analyze their chat history data. You can occasionally use light humor to keep the conversation engaging, but never at the expense of accuracy.
|
||||
|
||||
## Response Guidelines
|
||||
|
||||
1. Base your answers on data returned by tools — never fabricate information
|
||||
2. If the data is insufficient to answer a question, say so clearly
|
||||
3. Keep responses concise and use Markdown formatting
|
||||
|
||||
@@ -10,10 +10,10 @@ presetQuestions:
|
||||
- チャットの活発な時間帯を分析して
|
||||
---
|
||||
|
||||
あなたはプロフェッショナルでありながら親しみやすいチャット記録分析アシスタントです。
|
||||
ユーザーのチャット履歴データを理解し分析する手助けをすることが主な役割です。適度にユーモアを交えても構いませんが、分析の正確性を最優先にしてください。
|
||||
あなたはプロフェッショナルでありながら親しみやすいチャット記録分析アシスタントです。ユーザーのチャット履歴データを理解し分析する手助けをすることが主な役割です。適度にユーモアを交えても構いませんが、分析の正確性を最優先にしてください。
|
||||
|
||||
## 回答ガイドライン
|
||||
|
||||
1. ツールから返されたデータに基づいて回答し、情報を捏造しないこと
|
||||
2. データが不十分な場合はその旨を明確に伝えること
|
||||
3. 簡潔で分かりやすい回答を心がけ、Markdown形式を使用すること
|
||||
|
||||
@@ -189,4 +189,3 @@ export interface AssistantSaveResult {
|
||||
success: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -2,13 +2,7 @@
|
||||
* 技能系统模块入口
|
||||
*/
|
||||
|
||||
export type {
|
||||
SkillDef,
|
||||
SkillSummary,
|
||||
BuiltinSkillInfo,
|
||||
SkillInitResult,
|
||||
SkillSaveResult,
|
||||
} from './types'
|
||||
export type { SkillDef, SkillSummary, BuiltinSkillInfo, SkillInitResult, SkillSaveResult } from './types'
|
||||
|
||||
export {
|
||||
initSkillManager,
|
||||
|
||||
@@ -16,13 +16,7 @@ import { createHash } from 'crypto'
|
||||
import { getAiDataDir, ensureDir } from '../../paths'
|
||||
import { aiLogger } from '../logger'
|
||||
import { parseSkillFile } from './parser'
|
||||
import type {
|
||||
SkillDef,
|
||||
SkillSummary,
|
||||
SkillInitResult,
|
||||
SkillSaveResult,
|
||||
BuiltinSkillInfo,
|
||||
} from './types'
|
||||
import type { SkillDef, SkillSummary, SkillInitResult, SkillSaveResult, BuiltinSkillInfo } from './types'
|
||||
|
||||
// ==================== 内置技能模板 ====================
|
||||
// 云端市场上线后,本地不再内置技能模板,全部从云端获取
|
||||
@@ -271,10 +265,7 @@ const MAX_SKILL_MENU_ITEMS = 15
|
||||
* 构建 AI 自选技能菜单文本
|
||||
* 只包含与当前 chatType + 助手工具权限兼容的技能
|
||||
*/
|
||||
export function getSkillMenu(
|
||||
chatType: 'group' | 'private',
|
||||
allowedTools?: string[]
|
||||
): string | null {
|
||||
export function getSkillMenu(chatType: 'group' | 'private', allowedTools?: string[]): string | null {
|
||||
ensureInitialized()
|
||||
|
||||
const compatible = Array.from(cachedSkills.values()).filter((skill) => {
|
||||
|
||||
@@ -40,7 +40,11 @@ export function createTool(context: ToolContext): AgentTool<typeof schema> {
|
||||
}
|
||||
|
||||
const texts = rows.map((r) => r.content)
|
||||
const segLocale: SupportedLocale = locale?.startsWith('ja') ? 'ja-JP' : locale?.startsWith('zh') ? 'zh-CN' : 'en-US'
|
||||
const segLocale: SupportedLocale = locale?.startsWith('ja')
|
||||
? 'ja-JP'
|
||||
: locale?.startsWith('zh')
|
||||
? 'zh-CN'
|
||||
: 'en-US'
|
||||
const segOptions: BatchSegmentOptions = {
|
||||
minCount: 2,
|
||||
topN,
|
||||
|
||||
@@ -37,7 +37,9 @@ export function createTool(context: ToolContext): AgentTool<typeof schema> {
|
||||
`
|
||||
const rows = await workerManager.pluginQuery<MsgRow>(sessionId, sql, { days })
|
||||
if (!rows || rows.length < 2) {
|
||||
const text = isZh ? '该时间范围内消息不足,无法分析响应时间' : 'Not enough messages in this time range to analyze response time'
|
||||
const text = isZh
|
||||
? '该时间范围内消息不足,无法分析响应时间'
|
||||
: 'Not enough messages in this time range to analyze response time'
|
||||
return { content: [{ type: 'text', text }], details: null }
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,7 @@ const SQL_TOOL_DEFS: CustomSqlToolDef[] = [
|
||||
// ==================== 通用分析 ====================
|
||||
{
|
||||
name: 'message_type_breakdown',
|
||||
description:
|
||||
'按消息类型统计近 N 天的消息分布(文本、图片、语音、表情等各有多少条)。适用于了解沟通方式偏好。',
|
||||
description: '按消息类型统计近 N 天的消息分布(文本、图片、语音、表情等各有多少条)。适用于了解沟通方式偏好。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -183,8 +182,7 @@ const SQL_TOOL_DEFS: CustomSqlToolDef[] = [
|
||||
},
|
||||
{
|
||||
name: 'conversation_initiator_stats',
|
||||
description:
|
||||
'统计每个成员发起会话(作为会话首条消息的发送者)的次数,找出谁最常开启话题。需要已生成会话索引。',
|
||||
description: '统计每个成员发起会话(作为会话首条消息的发送者)的次数,找出谁最常开启话题。需要已生成会话索引。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -204,8 +202,7 @@ const SQL_TOOL_DEFS: CustomSqlToolDef[] = [
|
||||
},
|
||||
{
|
||||
name: 'activity_heatmap',
|
||||
description:
|
||||
'返回 星期×小时 的消息数矩阵,适合生成活跃度热力图。weekday: 0=周日, 1=周一, ..., 6=周六。',
|
||||
description: '返回 星期×小时 的消息数矩阵,适合生成活跃度热力图。weekday: 0=周日, 1=周一, ..., 6=周六。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
||||
@@ -64,7 +64,9 @@ export function generateId(): string {
|
||||
return `ds_${crypto.randomBytes(6).toString('hex')}`
|
||||
}
|
||||
|
||||
export function addDataSource(partial: Omit<DataSource, 'id' | 'createdAt' | 'lastPullAt' | 'lastStatus' | 'lastError' | 'lastNewMessages'>): DataSource {
|
||||
export function addDataSource(
|
||||
partial: Omit<DataSource, 'id' | 'createdAt' | 'lastPullAt' | 'lastStatus' | 'lastError' | 'lastNewMessages'>
|
||||
): DataSource {
|
||||
const sources = loadDataSources()
|
||||
const ds: DataSource = {
|
||||
...partial,
|
||||
|
||||
@@ -23,7 +23,9 @@ function getTempFilePath(ext: string): string {
|
||||
function cleanupTempFile(filePath: string): void {
|
||||
try {
|
||||
if (fs.existsSync(filePath)) fs.unlinkSync(filePath)
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function notifySessionListChanged(): void {
|
||||
@@ -33,7 +35,9 @@ function notifySessionListChanged(): void {
|
||||
for (const win of wins) {
|
||||
win.webContents.send('api:importCompleted')
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function notifyPullResult(dsId: string, status: 'success' | 'error', detail: string): void {
|
||||
@@ -43,7 +47,9 @@ function notifyPullResult(dsId: string, status: 'success' | 'error', detail: str
|
||||
for (const win of wins) {
|
||||
win.webContents.send('api:pullResult', { dsId, status, detail })
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,9 +57,7 @@ function notifyPullResult(dsId: string, status: 'success' | 'error', detail: str
|
||||
*/
|
||||
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 url = ds.url.includes('?') ? `${ds.url}&since=${ds.lastPullAt}` : `${ds.url}?since=${ds.lastPullAt}`
|
||||
|
||||
const request = net.request(url)
|
||||
|
||||
@@ -140,7 +144,9 @@ async function executePull(ds: DataSource): Promise<void> {
|
||||
if (result.success) {
|
||||
try {
|
||||
await worker.generateIncrementalSessions(ds.targetSessionId)
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result = await worker.streamImport(tempFile)
|
||||
|
||||
@@ -62,11 +62,7 @@ export function getImportingStatus(): boolean {
|
||||
return isImporting
|
||||
}
|
||||
|
||||
async function handleImport(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
sessionId?: string
|
||||
): Promise<void> {
|
||||
async function handleImport(request: FastifyRequest, reply: FastifyReply, sessionId?: string): Promise<void> {
|
||||
if (isImporting) {
|
||||
const err = importInProgress()
|
||||
reply.code(err.statusCode).send(errorResponse(err))
|
||||
@@ -174,12 +170,9 @@ async function handleImport(
|
||||
|
||||
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)
|
||||
}
|
||||
)
|
||||
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) => {
|
||||
@@ -187,10 +180,7 @@ export function registerImportRoutes(server: FastifyInstance): void {
|
||||
})
|
||||
|
||||
// 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)
|
||||
}
|
||||
)
|
||||
server.post<{ Params: { id: string } }>('/api/v1/sessions/:id/import', async (request, reply) => {
|
||||
await handleImport(request, reply, request.params.id)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -57,24 +57,15 @@ export function registerSessionRoutes(server: FastifyInstance): void {
|
||||
const keywords = keyword ? [keyword] : []
|
||||
const senderIdNum = senderId ? parseInt(senderId, 10) : undefined
|
||||
|
||||
const result = await worker.searchMessages(
|
||||
id,
|
||||
keywords,
|
||||
hasFilter ? filter : undefined,
|
||||
limit,
|
||||
offset,
|
||||
senderIdNum
|
||||
)
|
||||
const result = await worker.searchMessages(id, keywords, hasFilter ? filter : undefined, limit, offset, senderIdNum)
|
||||
|
||||
return successResponse(
|
||||
{
|
||||
messages: result.messages,
|
||||
total: result.total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(result.total / limit),
|
||||
}
|
||||
)
|
||||
return successResponse({
|
||||
messages: result.messages,
|
||||
total: result.total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(result.total / limit),
|
||||
})
|
||||
})
|
||||
|
||||
// GET /api/v1/sessions/:id/members — Member list
|
||||
@@ -117,33 +108,30 @@ export function registerSessionRoutes(server: FastifyInstance): void {
|
||||
})
|
||||
|
||||
// POST /api/v1/sessions/:id/sql — Execute SQL (read-only)
|
||||
server.post<{ Params: { id: string }; Body: { sql: string } }>(
|
||||
'/api/v1/sessions/:id/sql',
|
||||
async (request, reply) => {
|
||||
const { id } = request.params
|
||||
await ensureSession(id)
|
||||
server.post<{ Params: { id: string }; Body: { sql: string } }>('/api/v1/sessions/:id/sql', async (request, reply) => {
|
||||
const { id } = request.params
|
||||
await ensureSession(id)
|
||||
|
||||
const { sql } = request.body || {}
|
||||
if (!sql || typeof sql !== 'string') {
|
||||
const err = sqlExecutionError('Missing sql parameter')
|
||||
return reply.code(err.statusCode).send(errorResponse(err))
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await worker.executeRawSQL(id, sql)
|
||||
return successResponse(result)
|
||||
} catch (err: any) {
|
||||
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
|
||||
return reply.code(400).send(errorResponse(apiErr))
|
||||
}
|
||||
const apiErr = sqlExecutionError(message)
|
||||
return reply.code(apiErr.statusCode).send(errorResponse(apiErr))
|
||||
}
|
||||
const { sql } = request.body || {}
|
||||
if (!sql || typeof sql !== 'string') {
|
||||
const err = sqlExecutionError('Missing sql parameter')
|
||||
return reply.code(err.statusCode).send(errorResponse(err))
|
||||
}
|
||||
)
|
||||
|
||||
try {
|
||||
const result = await worker.executeRawSQL(id, sql)
|
||||
return successResponse(result)
|
||||
} catch (err: any) {
|
||||
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
|
||||
return reply.code(400).send(errorResponse(apiErr))
|
||||
}
|
||||
const apiErr = sqlExecutionError(message)
|
||||
return reply.code(apiErr.statusCode).send(errorResponse(apiErr))
|
||||
}
|
||||
})
|
||||
|
||||
// GET /api/v1/sessions/:id/export — Export ChatLab Format JSON
|
||||
server.get<{ Params: { id: string } }>('/api/v1/sessions/:id/export', async (request, reply) => {
|
||||
|
||||
@@ -46,7 +46,10 @@ export function registerSystemRoutes(server: FastifyInstance): void {
|
||||
required: ['name', 'platform', 'type'],
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
platform: { type: 'string', enum: ['qq', 'wechat', 'telegram', 'discord', 'line', 'whatsapp', 'instagram', 'unknown'] },
|
||||
platform: {
|
||||
type: 'string',
|
||||
enum: ['qq', 'wechat', 'telegram', 'discord', 'line', 'whatsapp', 'instagram', 'unknown'],
|
||||
},
|
||||
type: { type: 'string', enum: ['group', 'private'] },
|
||||
groupId: { type: 'string' },
|
||||
},
|
||||
|
||||
@@ -144,9 +144,7 @@ const migrations: Migration[] = [
|
||||
descriptionKey: 'database.migrationV4Desc',
|
||||
userMessageKey: 'database.migrationV4Message',
|
||||
up: (db) => {
|
||||
const hasTable = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='message_fts'")
|
||||
.get()
|
||||
const hasTable = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='message_fts'").get()
|
||||
if (hasTable) return
|
||||
|
||||
db.exec(`
|
||||
@@ -161,9 +159,7 @@ const migrations: Migration[] = [
|
||||
const insertFts = db.prepare('INSERT INTO message_fts(rowid, content) VALUES (?, ?)')
|
||||
|
||||
const countRow = db
|
||||
.prepare(
|
||||
"SELECT COUNT(*) as total FROM message WHERE type = 0 AND content IS NOT NULL AND content != ''"
|
||||
)
|
||||
.prepare("SELECT COUNT(*) as total FROM message WHERE type = 0 AND content IS NOT NULL AND content != ''")
|
||||
.get() as { total: number }
|
||||
|
||||
let offset = 0
|
||||
|
||||
@@ -111,11 +111,7 @@ export interface OverviewCache {
|
||||
/**
|
||||
* 从数据库计算概览统计并写入缓存
|
||||
*/
|
||||
export function computeAndSetOverviewCache(
|
||||
db: Database.Database,
|
||||
sessionId: string,
|
||||
cacheDir: string
|
||||
): OverviewCache {
|
||||
export function computeAndSetOverviewCache(db: Database.Database, sessionId: string, cacheDir: string): OverviewCache {
|
||||
const msgStats = db.prepare('SELECT MIN(ts) as first_ts, MAX(ts) as last_ts FROM message').get() as {
|
||||
first_ts: number | null
|
||||
last_ts: number | null
|
||||
@@ -169,11 +165,7 @@ export interface MembersCache {
|
||||
/**
|
||||
* 从数据库计算成员统计并写入缓存
|
||||
*/
|
||||
export function computeAndSetMembersCache(
|
||||
db: Database.Database,
|
||||
sessionId: string,
|
||||
cacheDir: string
|
||||
): MembersCache {
|
||||
export function computeAndSetMembersCache(db: Database.Database, sessionId: string, cacheDir: string): MembersCache {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT msg.sender_id, COUNT(*) as count,
|
||||
|
||||
@@ -6,19 +6,8 @@ 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'
|
||||
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 ====================
|
||||
@@ -63,10 +52,7 @@ export function registerApiHandlers(_ctx: IpcContext): void {
|
||||
'api:addDataSource',
|
||||
(
|
||||
_event,
|
||||
partial: Omit<
|
||||
DataSource,
|
||||
'id' | 'createdAt' | 'lastPullAt' | 'lastStatus' | 'lastError' | 'lastNewMessages'
|
||||
>
|
||||
partial: Omit<DataSource, 'id' | 'createdAt' | 'lastPullAt' | 'lastStatus' | 'lastError' | 'lastNewMessages'>
|
||||
) => {
|
||||
const ds = addDataSource(partial)
|
||||
if (ds.enabled) {
|
||||
|
||||
@@ -394,9 +394,10 @@ export function getTimeRange(sessionId: string): { start: number; end: number }
|
||||
const db = openDatabase(sessionId)
|
||||
if (!db) return null
|
||||
|
||||
const row = db
|
||||
.prepare('SELECT MIN(ts) as start, MAX(ts) as end FROM message')
|
||||
.get() as { start: number | null; end: number | null }
|
||||
const row = db.prepare('SELECT MIN(ts) as start, MAX(ts) as end FROM message').get() as {
|
||||
start: number | null
|
||||
end: number | null
|
||||
}
|
||||
|
||||
if (row.start === null || row.end === null) return null
|
||||
|
||||
|
||||
@@ -43,9 +43,9 @@ export function hasFtsIndex(sessionId: string): boolean {
|
||||
const db = openDatabase(sessionId)
|
||||
if (!db) return false
|
||||
try {
|
||||
const row = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='message_fts'")
|
||||
.get() as { name: string } | undefined
|
||||
const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='message_fts'").get() as
|
||||
| { name: string }
|
||||
| undefined
|
||||
return !!row
|
||||
} catch {
|
||||
return false
|
||||
@@ -72,9 +72,9 @@ export function buildFtsIndex(sessionId: string): { indexed: number } {
|
||||
|
||||
const insertFts = db.prepare('INSERT INTO message_fts(rowid, content) VALUES (?, ?)')
|
||||
|
||||
const countRow = db.prepare(
|
||||
"SELECT COUNT(*) as total FROM message WHERE type = 0 AND content IS NOT NULL AND content != ''"
|
||||
).get() as { total: number }
|
||||
const countRow = db
|
||||
.prepare("SELECT COUNT(*) as total FROM message WHERE type = 0 AND content IS NOT NULL AND content != ''")
|
||||
.get() as { total: number }
|
||||
const total = countRow.total
|
||||
|
||||
let indexed = 0
|
||||
@@ -121,9 +121,7 @@ export function rebuildFtsIndex(sessionId: string): { indexed: number } {
|
||||
if (!db) return { indexed: 0 }
|
||||
|
||||
try {
|
||||
const hasTable = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='message_fts'")
|
||||
.get()
|
||||
const hasTable = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='message_fts'").get()
|
||||
|
||||
if (hasTable) {
|
||||
db.exec('DROP TABLE message_fts')
|
||||
@@ -142,17 +140,12 @@ export function rebuildFtsIndex(sessionId: string): { indexed: number } {
|
||||
* 批量写入 FTS 条目
|
||||
* 用于增量导入时同步写入
|
||||
*/
|
||||
export function insertFtsEntries(
|
||||
sessionId: string,
|
||||
entries: Array<{ id: number; content: string | null }>
|
||||
): void {
|
||||
export function insertFtsEntries(sessionId: string, entries: Array<{ id: number; content: string | null }>): void {
|
||||
const db = openWritableDb(sessionId)
|
||||
if (!db) return
|
||||
|
||||
try {
|
||||
const hasTable = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='message_fts'")
|
||||
.get()
|
||||
const hasTable = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='message_fts'").get()
|
||||
if (!hasTable) {
|
||||
db.close()
|
||||
return
|
||||
@@ -194,14 +187,12 @@ export function searchByFts(
|
||||
if (!matchQuery) return { rowids: [], total: 0 }
|
||||
|
||||
try {
|
||||
const countRow = db
|
||||
.prepare('SELECT COUNT(*) as total FROM message_fts WHERE content MATCH ?')
|
||||
.get(matchQuery) as { total: number }
|
||||
const countRow = db.prepare('SELECT COUNT(*) as total FROM message_fts WHERE content MATCH ?').get(matchQuery) as {
|
||||
total: number
|
||||
}
|
||||
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT rowid FROM message_fts WHERE content MATCH ? ORDER BY rank LIMIT ? OFFSET ?`
|
||||
)
|
||||
.prepare(`SELECT rowid FROM message_fts WHERE content MATCH ? ORDER BY rank LIMIT ? OFFSET ?`)
|
||||
.all(matchQuery, limit, offset) as Array<{ rowid: number }>
|
||||
|
||||
return {
|
||||
|
||||
@@ -345,9 +345,7 @@ function searchMessagesWithFts(
|
||||
LIMIT ? OFFSET ?
|
||||
`
|
||||
|
||||
const rows = db
|
||||
.prepare(sql)
|
||||
.all(matchQuery, ...timeParams, ...senderParams, limit, offset) as DbMessageRow[]
|
||||
const rows = db.prepare(sql).all(matchQuery, ...timeParams, ...senderParams, limit, offset) as DbMessageRow[]
|
||||
|
||||
return {
|
||||
messages: rows.map(sanitizeMessageRow),
|
||||
|
||||
@@ -68,7 +68,9 @@ export const apiServerApi = {
|
||||
return ipcRenderer.invoke('api:getDataSources')
|
||||
},
|
||||
|
||||
addDataSource: (partial: Omit<DataSource, 'id' | 'createdAt' | 'lastPullAt' | 'lastStatus' | 'lastError' | 'lastNewMessages'>): Promise<DataSource> => {
|
||||
addDataSource: (
|
||||
partial: Omit<DataSource, 'id' | 'createdAt' | 'lastPullAt' | 'lastStatus' | 'lastError' | 'lastNewMessages'>
|
||||
): Promise<DataSource> => {
|
||||
return ipcRenderer.invoke('api:addDataSource', partial)
|
||||
},
|
||||
|
||||
|
||||
4
electron/preload/index.d.ts
vendored
4
electron/preload/index.d.ts
vendored
@@ -916,7 +916,9 @@ interface ApiServerApi {
|
||||
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>
|
||||
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 }>
|
||||
|
||||
@@ -121,10 +121,12 @@ description: Use when 用户希望根据一句自然语言需求创建新的 Cha
|
||||
## 工具选择规则
|
||||
|
||||
工具分为两类:
|
||||
|
||||
- **核心工具(core)**:始终启用,无需在 `allowedBuiltinTools` 中列出。包括:get_chat_overview, search_messages, get_recent_messages, get_message_context, search_sessions, get_session_messages, get_members
|
||||
- **分析工具(analysis)**:需在 `allowedBuiltinTools` 中显式列出才会启用
|
||||
|
||||
`allowedBuiltinTools` 仅用于控制分析工具,核心工具始终可用:
|
||||
|
||||
- 若角色不需要分析工具,可省略 `allowedBuiltinTools`(默认仅核心工具可用)
|
||||
- 若角色需要特定分析能力,列出所需的分析工具名称
|
||||
- 仅在角色明显聚焦时选择对应的分析工具,例如:
|
||||
|
||||
@@ -467,17 +467,11 @@ function closeModal() {
|
||||
{{ t('ai.assistant.config.analysisTools') }}
|
||||
</h3>
|
||||
<div v-if="!readonly" class="flex gap-2">
|
||||
<button
|
||||
class="text-[10px] text-primary-500 hover:text-primary-600"
|
||||
@click="selectAllAnalysisTools"
|
||||
>
|
||||
<button class="text-[10px] text-primary-500 hover:text-primary-600" @click="selectAllAnalysisTools">
|
||||
{{ t('ai.assistant.config.selectAll') }}
|
||||
</button>
|
||||
<span class="text-[10px] text-gray-300 dark:text-gray-600">|</span>
|
||||
<button
|
||||
class="text-[10px] text-primary-500 hover:text-primary-600"
|
||||
@click="clearAllAnalysisTools"
|
||||
>
|
||||
<button class="text-[10px] text-primary-500 hover:text-primary-600" @click="clearAllAnalysisTools">
|
||||
{{ t('ai.assistant.config.deselectAll') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -394,7 +394,9 @@ function fillInput(content: string) {
|
||||
})
|
||||
}
|
||||
|
||||
function handleSelectMention(member: Pick<MentionCandidate, 'memberId' | 'platformId' | 'displayName' | 'insertName' | 'aliases'>) {
|
||||
function handleSelectMention(
|
||||
member: Pick<MentionCandidate, 'memberId' | 'platformId' | 'displayName' | 'insertName' | 'aliases'>
|
||||
) {
|
||||
if (props.disabled || !mentionRange.value) return
|
||||
|
||||
const prefix = inputValue.value.slice(0, mentionRange.value.start)
|
||||
|
||||
@@ -285,9 +285,7 @@ function handleRetry() {
|
||||
</button>
|
||||
|
||||
<template v-else>
|
||||
<span
|
||||
class="px-3 py-1.5 text-xs font-medium text-gray-400 dark:text-gray-500"
|
||||
>
|
||||
<span class="px-3 py-1.5 text-xs font-medium text-gray-400 dark:text-gray-500">
|
||||
{{ t('ai.skill.market.imported') }}
|
||||
</span>
|
||||
<button
|
||||
@@ -302,7 +300,10 @@ function handleRetry() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!cloudLoading && !cloudError && sortedCatalog.length === 0" class="py-12 text-center text-sm text-gray-400">
|
||||
<div
|
||||
v-if="!cloudLoading && !cloudError && sortedCatalog.length === 0"
|
||||
class="py-12 text-center text-sm text-gray-400"
|
||||
>
|
||||
{{ t('ai.skill.market.noCatalog') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -76,7 +76,7 @@ function initChart() {
|
||||
chartInstance.setOption({ backgroundColor: 'transparent', ...props.option })
|
||||
}
|
||||
|
||||
// 更新图表
|
||||
// 更新图表
|
||||
function updateChart() {
|
||||
if (!chartInstance) {
|
||||
initChart()
|
||||
|
||||
@@ -194,7 +194,10 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="main-content" :class="props.showHeader ? 'max-w-5xl p-6' : 'flex h-full max-w-none flex-col overflow-hidden p-4'">
|
||||
<div
|
||||
class="main-content"
|
||||
:class="props.showHeader ? 'max-w-5xl p-6' : 'flex h-full max-w-none flex-col overflow-hidden p-4'"
|
||||
>
|
||||
<!-- 页面标题 -->
|
||||
<div v-if="props.showHeader" class="mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
|
||||
@@ -72,7 +72,11 @@ const viewTimeFilter = computed(() => ({
|
||||
:session-id="props.sessionId"
|
||||
:time-filter="viewTimeFilter"
|
||||
/>
|
||||
<ClusterView v-else-if="activeSubTab === 'cluster'" :session-id="props.sessionId" :time-filter="viewTimeFilter" />
|
||||
<ClusterView
|
||||
v-else-if="activeSubTab === 'cluster'"
|
||||
:session-id="props.sessionId"
|
||||
:time-filter="viewTimeFilter"
|
||||
/>
|
||||
<RankingView
|
||||
v-else-if="activeSubTab === 'ranking'"
|
||||
:session-id="props.sessionId"
|
||||
|
||||
@@ -20,7 +20,9 @@ const isOpen = ref(false)
|
||||
<UModal v-if="props.sessionId" v-model:open="isOpen" :ui="{ content: 'max-w-6xl h-[85vh]' }">
|
||||
<template #content>
|
||||
<div class="flex h-full flex-col overflow-hidden bg-white dark:bg-gray-900">
|
||||
<div class="flex flex-none items-center justify-between border-b border-gray-200 px-5 py-3 dark:border-gray-700">
|
||||
<div
|
||||
class="flex flex-none items-center justify-between border-b border-gray-200 px-5 py-3 dark:border-gray-700"
|
||||
>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('analysis.subTabs.member.nicknameHistory') }}
|
||||
</h2>
|
||||
|
||||
@@ -120,7 +120,6 @@ const otherMemberAvatar = computed(() => {
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -260,19 +259,18 @@ const otherMemberAvatar = computed(() => {
|
||||
<UModal v-if="currentSessionId" v-model:open="showMemberManagementModal" :ui="{ content: 'max-w-6xl h-[85vh]' }">
|
||||
<template #content>
|
||||
<div class="flex h-full flex-col overflow-hidden bg-white dark:bg-gray-900">
|
||||
<div class="flex flex-none items-center justify-between border-b border-gray-200 px-5 py-3 dark:border-gray-700">
|
||||
<div
|
||||
class="flex flex-none items-center justify-between border-b border-gray-200 px-5 py-3 dark:border-gray-700"
|
||||
>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('analysis.tooltip.memberManagement') }}</h2>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('analysis.tooltip.memberManagement') }}
|
||||
</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('members.private.description', { count: session?.memberCount ?? 0 }) }}
|
||||
</p>
|
||||
</div>
|
||||
<UButton
|
||||
variant="ghost"
|
||||
icon="i-heroicons-x-mark"
|
||||
size="sm"
|
||||
@click="showMemberManagementModal = false"
|
||||
/>
|
||||
<UButton variant="ghost" icon="i-heroicons-x-mark" size="sm" @click="showMemberManagementModal = false" />
|
||||
</div>
|
||||
<div class="flex-1 overflow-auto">
|
||||
<MemberManagementPanel :session-id="currentSessionId" :show-header="false" />
|
||||
|
||||
@@ -126,9 +126,14 @@ function formatTime(ts: number): string {
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Beta 提示 -->
|
||||
<div class="rounded-lg border border-amber-200/60 bg-amber-50 px-4 py-3 dark:border-amber-500/20 dark:bg-amber-500/10">
|
||||
<div
|
||||
class="rounded-lg border border-amber-200/60 bg-amber-50 px-4 py-3 dark:border-amber-500/20 dark:bg-amber-500/10"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<UIcon name="i-heroicons-exclamation-triangle" class="mt-0.5 h-5 w-5 shrink-0 text-amber-500 dark:text-amber-400" />
|
||||
<UIcon
|
||||
name="i-heroicons-exclamation-triangle"
|
||||
class="mt-0.5 h-5 w-5 shrink-0 text-amber-500 dark:text-amber-400"
|
||||
/>
|
||||
<p class="text-sm font-medium text-amber-800 dark:text-amber-300">
|
||||
{{ t('settings.api.betaWarning') }}
|
||||
</p>
|
||||
|
||||
@@ -126,7 +126,9 @@ export const useApiServerStore = defineStore('apiServer', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function addDataSource(partial: Omit<DataSource, 'id' | 'createdAt' | 'lastPullAt' | 'lastStatus' | 'lastError' | 'lastNewMessages'>) {
|
||||
async function addDataSource(
|
||||
partial: Omit<DataSource, 'id' | 'createdAt' | 'lastPullAt' | 'lastStatus' | 'lastError' | 'lastNewMessages'>
|
||||
) {
|
||||
try {
|
||||
const ds = await window.apiServerApi.addDataSource(partial)
|
||||
dataSources.value.push(ds)
|
||||
|
||||
Reference in New Issue
Block a user