From 352a071a2151e12af175e9f9e0ee1b43949dfce0 Mon Sep 17 00:00:00 2001 From: digua Date: Sun, 26 Apr 2026 22:25:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E8=BF=9C=E7=A8=8B?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E5=88=86=E9=A1=B5=E5=8F=91=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/cn/standard/chatlab-pull.md | 35 +++++- electron/main/api/pullDiscovery.shared.ts | 68 +++++++++++ electron/main/api/pullDiscovery.ts | 32 +++-- electron/main/ipc/api.ts | 15 ++- electron/preload/apis/api-server.ts | 18 ++- electron/preload/index.d.ts | 16 ++- .../Settings/API/DataSourceAddModal.vue | 115 +++++++++++++----- .../common/Settings/API/sessionDiscovery.ts | 28 +++++ src/i18n/locales/en-US/settings.json | 3 + src/i18n/locales/ja-JP/settings.json | 3 + src/i18n/locales/zh-CN/settings.json | 3 + src/i18n/locales/zh-TW/settings.json | 3 + src/stores/apiServer.ts | 18 ++- 13 files changed, 295 insertions(+), 62 deletions(-) create mode 100644 electron/main/api/pullDiscovery.shared.ts create mode 100644 src/components/common/Settings/API/sessionDiscovery.ts diff --git a/docs/cn/standard/chatlab-pull.md b/docs/cn/standard/chatlab-pull.md index fceaacaa..b50d91da 100644 --- a/docs/cn/standard/chatlab-pull.md +++ b/docs/cn/standard/chatlab-pull.md @@ -58,10 +58,11 @@ Accept: application/json **可选参数:** -| 参数 | 类型 | 说明 | -| --------- | ------ | ------------------------ | -| `keyword` | string | 按对话名称模糊搜索 | -| `limit` | number | 返回条数限制(默认全部) | +| 参数 | 类型 | 说明 | +| --------- | ------ | -------------------------------------------------------------------------- | +| `keyword` | string | 按对话名称模糊搜索。搜索语义由服务端定义,推荐按 `name` 模糊匹配,可选扩展到 `id` | +| `limit` | number | 返回条数限制。未传时默认返回全部;若服务端实现分页,建议设置合理上限 | +| `cursor` | string | 分页游标。仅在服务端支持分页发现时使用;`keyword` 变化后必须重新从第一页开始 | **响应:** @@ -86,7 +87,11 @@ Accept: application/json "memberCount": 2, "lastMessageAt": 1711465200 } - ] + ], + "page": { + "hasMore": true, + "nextCursor": "eyJsYXN0TWVzc2FnZUF0IjoxNzExNDY1MjAwLCJpZCI6Ind4aWRfZnJpZW5kX2EifQ==" + } } ``` @@ -100,6 +105,26 @@ Accept: application/json | `memberCount` | number | 否 | 成员数 | | `lastMessageAt` | number | 否 | 最新消息时间戳 | +`page` 为**可选增强字段**: + +| 字段 | 类型 | 必填 | 说明 | +| ------------ | ------- | ---- | ------------------------------------------------------------------ | +| `hasMore` | boolean | 否 | 是否还有下一页。仅在服务端支持分页发现时返回 | +| `nextCursor` | string | 否 | 下一页游标。`hasMore=true` 时应返回;客户端原样透传给下次请求 | + +**兼容规则:** + +- 旧版服务端可以继续只返回 `{ "sessions": [...] }`,不带 `page` +- ChatLab 客户端在响应中**未发现** `page` 字段时,应按“单次全量结果”处理 +- 若响应中包含 `page`,客户端可根据产品交互选择手动“加载更多”或自动续拉 +- ChatLab 当前推荐在 UI 中使用手动“加载更多”,按 `hasMore / nextCursor` 拉取后续页面 + +**分页一致性建议:** + +- 服务端应保证分页顺序稳定,推荐使用固定排序(例如 `lastMessageAt desc, id asc`) +- `cursor` 必须与当前查询条件绑定;只要 `keyword` 变化,旧 `cursor` 就应视为失效 +- 不建议在 `/sessions` 发现接口中使用 `offset` 分页,避免在列表变化时出现重复或漏项 + ChatLab 在 UI 中展示该列表,用户选择需要导入的对话。 --- diff --git a/electron/main/api/pullDiscovery.shared.ts b/electron/main/api/pullDiscovery.shared.ts new file mode 100644 index 00000000..7c117b08 --- /dev/null +++ b/electron/main/api/pullDiscovery.shared.ts @@ -0,0 +1,68 @@ +export interface RemoteSession { + id: string + name: string + platform: string + type: string + messageCount?: number + memberCount?: number + lastMessageAt?: number +} + +export interface RemoteSessionDiscoveryPage { + hasMore: boolean + nextCursor?: string +} + +export interface RemoteSessionDiscoveryResult { + sessions: RemoteSession[] + page?: RemoteSessionDiscoveryPage +} + +export interface RemoteSessionDiscoveryQuery { + keyword?: string + limit?: number + cursor?: string +} + +export function buildRemoteSessionsUrl(baseUrl: string, query: RemoteSessionDiscoveryQuery = {}): string { + const searchParams = new URLSearchParams() + searchParams.set('format', 'chatlab') + + if (query.keyword?.trim()) searchParams.set('keyword', query.keyword.trim()) + if (query.limit && query.limit > 0) searchParams.set('limit', String(query.limit)) + if (query.cursor) searchParams.set('cursor', query.cursor) + + return `${baseUrl}/sessions?${searchParams.toString()}` +} + +/** + * Parse remote sessions response with backward compatibility. + * Supports: Pull protocol `{ sessions, page? }`, ChatLab API `{ success, data }`, and plain array. + */ +export function parseRemoteSessionsResponse(body: string): RemoteSessionDiscoveryResult { + const parsed = JSON.parse(body) + + let sessions: RemoteSession[] + let pageSource: Record | undefined + + if (Array.isArray(parsed)) { + sessions = parsed + } else if (parsed && typeof parsed === 'object') { + sessions = parsed.sessions ?? parsed.data?.sessions ?? parsed.data ?? [] + if (!Array.isArray(sessions)) sessions = [] + pageSource = parsed.page ?? parsed.data?.page + } else { + sessions = [] + } + + return { + sessions, + page: + pageSource && typeof pageSource === 'object' + ? { + hasMore: Boolean(pageSource.hasMore), + nextCursor: typeof pageSource.nextCursor === 'string' ? pageSource.nextCursor : undefined, + } + : undefined, + } +} diff --git a/electron/main/api/pullDiscovery.ts b/electron/main/api/pullDiscovery.ts index 5f676d95..2d6ca917 100644 --- a/electron/main/api/pullDiscovery.ts +++ b/electron/main/api/pullDiscovery.ts @@ -5,24 +5,26 @@ import { net } from 'electron' import { normalizeBaseUrl } from './dataSource' +import { + buildRemoteSessionsUrl, + parseRemoteSessionsResponse, + type RemoteSessionDiscoveryQuery, + type RemoteSessionDiscoveryResult, +} from './pullDiscovery.shared' -export interface RemoteSession { - id: string - name: string - platform: string - type: string - messageCount?: number - memberCount?: number - lastMessageAt?: number -} +export type { RemoteSession, RemoteSessionDiscoveryQuery, RemoteSessionDiscoveryResult } from './pullDiscovery.shared' /** * Fetch available sessions from a remote data source. * Calls GET {baseUrl}/sessions according to the Pull protocol. */ -export function fetchRemoteSessions(baseUrl: string, token?: string): Promise { - return new Promise((resolve, reject) => { - const url = normalizeBaseUrl(baseUrl) + '/sessions?format=chatlab&limit=10000' +export function fetchRemoteSessions( + baseUrl: string, + token?: string, + query: RemoteSessionDiscoveryQuery = {} +): Promise { + return new Promise((resolve, reject) => { + const url = buildRemoteSessionsUrl(normalizeBaseUrl(baseUrl), query) const request = net.request(url) if (token) { @@ -44,11 +46,7 @@ export function fetchRemoteSessions(baseUrl: string, token?: string): Promise { try { - const parsed = JSON.parse(body) - const sessions: RemoteSession[] = Array.isArray(parsed) - ? parsed - : (parsed.data?.sessions ?? parsed.sessions ?? []) - resolve(sessions) + resolve(parseRemoteSessionsResponse(body)) } catch (err) { reject(new Error('Failed to parse remote sessions response')) } diff --git a/electron/main/ipc/api.ts b/electron/main/ipc/api.ts index bc301069..e8647d35 100644 --- a/electron/main/ipc/api.ts +++ b/electron/main/ipc/api.ts @@ -114,13 +114,16 @@ export function registerApiHandlers(_ctx: IpcContext): void { // ==================== Remote Discovery ==================== - ipcMain.handle('api:fetchRemoteSessions', async (_event, baseUrl: string, token: string) => { - try { - return await fetchRemoteSessions(baseUrl, token || undefined) - } catch (err: any) { - throw new Error(err.message || 'Failed to fetch remote sessions') + ipcMain.handle( + 'api:fetchRemoteSessions', + async (_event, baseUrl: string, token: string, query?: { keyword?: string; limit?: number; cursor?: string }) => { + try { + return await fetchRemoteSessions(baseUrl, token || undefined, query) + } catch (err: any) { + throw new Error(err.message || 'Failed to fetch remote sessions') + } } - }) + ) } /** diff --git a/electron/preload/apis/api-server.ts b/electron/preload/apis/api-server.ts index a7b303df..4f06a54d 100644 --- a/electron/preload/apis/api-server.ts +++ b/electron/preload/apis/api-server.ts @@ -50,6 +50,16 @@ export interface RemoteSession { lastMessageAt?: number } +export interface RemoteSessionDiscoveryPage { + hasMore: boolean + nextCursor?: string +} + +export interface RemoteSessionDiscoveryResult { + sessions: RemoteSession[] + page?: RemoteSessionDiscoveryPage +} + export const apiServerApi = { // ==================== API 服务管理 ==================== @@ -128,8 +138,12 @@ export const apiServerApi = { return ipcRenderer.invoke('api:triggerPullAll', sourceId) }, - fetchRemoteSessions: (baseUrl: string, token?: string): Promise => { - return ipcRenderer.invoke('api:fetchRemoteSessions', baseUrl, token || '') + fetchRemoteSessions: ( + baseUrl: string, + token?: string, + query?: { keyword?: string; limit?: number; cursor?: string } + ): Promise => { + return ipcRenderer.invoke('api:fetchRemoteSessions', baseUrl, token || '', query) }, onPullResult: ( diff --git a/electron/preload/index.d.ts b/electron/preload/index.d.ts index 668c617e..50526713 100644 --- a/electron/preload/index.d.ts +++ b/electron/preload/index.d.ts @@ -1034,6 +1034,16 @@ interface RemoteSession { lastMessageAt?: number } +interface RemoteSessionDiscoveryPage { + hasMore: boolean + nextCursor?: string +} + +interface RemoteSessionDiscoveryResult { + sessions: RemoteSession[] + page?: RemoteSessionDiscoveryPage +} + interface ApiServerApi { getConfig: () => Promise getStatus: () => Promise @@ -1060,7 +1070,11 @@ interface ApiServerApi { removeImportSession: (sourceId: string, sessionId: string) => Promise triggerPull: (sourceId: string, sessionId?: string) => Promise<{ success: boolean; error?: string }> triggerPullAll: (sourceId: string) => Promise<{ success: boolean; error?: string }> - fetchRemoteSessions: (baseUrl: string, token?: string) => Promise + fetchRemoteSessions: ( + baseUrl: string, + token?: string, + query?: { keyword?: string; limit?: number; cursor?: string } + ) => Promise onPullResult: ( callback: (data: { sourceId: string; sessionId?: string; status: string; detail: string }) => void ) => () => void diff --git a/src/components/common/Settings/API/DataSourceAddModal.vue b/src/components/common/Settings/API/DataSourceAddModal.vue index 22ae7ff3..ce08f94b 100644 --- a/src/components/common/Settings/API/DataSourceAddModal.vue +++ b/src/components/common/Settings/API/DataSourceAddModal.vue @@ -2,6 +2,7 @@ import { ref, watch, computed } from 'vue' import { useI18n } from 'vue-i18n' import { useApiServerStore, type DataSource, type RemoteSession } from '@/stores/apiServer' +import { getSessionTypeSelection, type SessionTypeSelection } from './sessionDiscovery' const props = defineProps<{ open: boolean @@ -30,12 +31,17 @@ const formData = ref({ const remoteSessions = ref([]) const selectedSessionIds = ref>(new Set()) const discovering = ref(false) +const loadingMore = ref(false) const discoveryError = ref('') +const discoveryKeyword = ref('') +const discoveryNextCursor = ref() +const discoveryHasMore = ref(false) +const hasDiscoveryRun = ref(false) -type SessionTypeSelection = 'private' | 'group' | 'other' type SessionTypeFilter = 'all' | SessionTypeSelection const activeSessionTypeFilter = ref('all') +const discoveryPageSize = 100 watch( () => props.open, @@ -54,6 +60,10 @@ watch( remoteSessions.value = [] selectedSessionIds.value = new Set() discoveryError.value = '' + discoveryKeyword.value = '' + discoveryNextCursor.value = undefined + discoveryHasMore.value = false + hasDiscoveryRun.value = false activeSessionTypeFilter.value = 'all' if (isManageMode.value) { await discoverSessions() @@ -95,27 +105,6 @@ const sessionTypeSelectionOptions = computed(() => })) ) -function getSessionTypeSelection(session: RemoteSession): SessionTypeSelection { - const rawType = String(session.type || '') - .trim() - .toLowerCase() - const id = String(session.id || '') - .trim() - .toLowerCase() - if (rawType === 'group' || id.endsWith('@chatroom')) return 'group' - if ( - rawType === 'channel' || - rawType === 'official' || - rawType === 'other' || - id.startsWith('gh_') || - id.includes('@openim') || - (id.startsWith('weixin') && id !== 'weixin') - ) { - return 'other' - } - return 'private' -} - function setSessionTypeFilter(type: SessionTypeFilter) { activeSessionTypeFilter.value = type } @@ -144,18 +133,61 @@ function closeModal() { } async function discoverSessions() { + await fetchDiscoveryPage({ append: false, resetSelection: true }) +} + +async function searchSessions() { + await fetchDiscoveryPage({ append: false, resetSelection: true }) +} + +async function loadMoreSessions() { + if (!discoveryHasMore.value || !discoveryNextCursor.value) return + await fetchDiscoveryPage({ append: true, resetSelection: false }) +} + +function mergeRemoteSessions(current: RemoteSession[], next: RemoteSession[]): RemoteSession[] { + const merged = new Map(current.map((session) => [session.id, session])) + for (const session of next) { + merged.set(session.id, session) + } + return [...merged.values()] +} + +// 发现请求支持分页;首次查询重置列表,后续由“加载更多”追加。 +async function fetchDiscoveryPage(options: { append: boolean; resetSelection: boolean }) { if (!formData.value.baseUrl) return - discovering.value = true + + if (options.append) loadingMore.value = true + else discovering.value = true + + hasDiscoveryRun.value = true discoveryError.value = '' - remoteSessions.value = [] - selectedSessionIds.value = new Set() - activeSessionTypeFilter.value = 'all' + + if (!options.append) { + remoteSessions.value = [] + discoveryNextCursor.value = undefined + discoveryHasMore.value = false + activeSessionTypeFilter.value = 'all' + if (options.resetSelection) { + selectedSessionIds.value = new Set() + } + } + try { - remoteSessions.value = await store.fetchRemoteSessions(formData.value.baseUrl, formData.value.token) + const result = await store.fetchRemoteSessions(formData.value.baseUrl, formData.value.token, { + keyword: discoveryKeyword.value.trim() || undefined, + limit: discoveryPageSize, + cursor: options.append ? discoveryNextCursor.value : undefined, + }) + + remoteSessions.value = options.append ? mergeRemoteSessions(remoteSessions.value, result.sessions) : result.sessions + discoveryHasMore.value = Boolean(result.page?.hasMore) + discoveryNextCursor.value = result.page?.nextCursor } catch (err: any) { discoveryError.value = err.message || t('settings.api.dataSources.discovery.error') } finally { discovering.value = false + loadingMore.value = false } } @@ -271,7 +303,7 @@ function formatMessageCount(count?: number): string { -
+
{{ t('settings.api.dataSources.discovery.found', { count: remoteSessions.length }) }} @@ -287,6 +319,17 @@ function formatMessageCount(count?: number): string { }}
+
+ + + {{ t('settings.api.dataSources.discovery.search') }} + +
{{ t('settings.api.dataSources.discovery.selectByType') }} @@ -309,7 +352,10 @@ function formatMessageCount(count?: number): string {
-
+
+
+ {{ t('settings.api.dataSources.discovery.found', { count: 0 }) }} +
+
+ + {{ t('settings.api.dataSources.discovery.loadMore') }} + +
diff --git a/src/components/common/Settings/API/sessionDiscovery.ts b/src/components/common/Settings/API/sessionDiscovery.ts new file mode 100644 index 00000000..bc8b674e --- /dev/null +++ b/src/components/common/Settings/API/sessionDiscovery.ts @@ -0,0 +1,28 @@ +import type { RemoteSession } from '@/stores/apiServer' + +export type SessionTypeSelection = 'private' | 'group' | 'other' + +export function getSessionTypeSelection(session: RemoteSession): SessionTypeSelection { + const rawType = String(session.type || '') + .trim() + .toLowerCase() + const id = String(session.id || '') + .trim() + .toLowerCase() + + if (rawType === 'group' || id.endsWith('@chatroom')) return 'group' + if (rawType === 'private') return 'private' + + if ( + rawType === 'channel' || + rawType === 'official' || + rawType === 'other' || + id.startsWith('gh_') || + id.includes('@openim') || + (id.startsWith('weixin') && id !== 'weixin') + ) { + return 'other' + } + + return 'other' +} diff --git a/src/i18n/locales/en-US/settings.json b/src/i18n/locales/en-US/settings.json index 59543cc4..85c1fd38 100644 --- a/src/i18n/locales/en-US/settings.json +++ b/src/i18n/locales/en-US/settings.json @@ -509,6 +509,8 @@ "baseUrlPlaceholder": "e.g. http://127.0.0.1:2333/api/v1", "browse": "Browse Sessions", "found": "Found {count} sessions", + "search": "Search", + "searchPlaceholder": "Search by session name", "selectAll": "Select All", "deselectAll": "Deselect All", "selectByType": "Session type", @@ -516,6 +518,7 @@ "typePrivate": "Private", "typeGroup": "Group", "typeOther": "Other", + "loadMore": "Load More", "subscribe": "Subscribe {count} sessions", "messages": "messages", "error": "Failed to connect to remote server" diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index f3620761..e6e8075c 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -508,6 +508,8 @@ "baseUrlPlaceholder": "例:http://127.0.0.1:2333/api/v1", "browse": "セッションを参照", "found": "{count} 件のセッションが見つかりました", + "search": "検索", + "searchPlaceholder": "セッション名で検索", "selectAll": "すべて選択", "deselectAll": "選択解除", "selectByType": "セッションタイプ", @@ -515,6 +517,7 @@ "typePrivate": "個人チャット", "typeGroup": "グループチャット", "typeOther": "その他", + "loadMore": "さらに読み込む", "subscribe": "{count} 件を購読", "messages": "件のメッセージ", "error": "リモートサーバーへの接続に失敗しました" diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 1fb9573e..1ba3f4a1 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -509,6 +509,8 @@ "baseUrlPlaceholder": "示例:http://127.0.0.1:2333/api/v1", "browse": "浏览远程会话", "found": "发现 {count} 个会话", + "search": "搜索", + "searchPlaceholder": "按会话名称搜索", "selectAll": "全选", "deselectAll": "取消全选", "selectByType": "会话类型", @@ -516,6 +518,7 @@ "typePrivate": "私聊", "typeGroup": "群聊", "typeOther": "其他", + "loadMore": "加载更多", "subscribe": "订阅 {count} 个会话", "messages": "条消息", "error": "连接远程服务器失败" diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index 06053fa2..f8dd57e3 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -508,6 +508,8 @@ "baseUrlPlaceholder": "示例:http://127.0.0.1:2333/api/v1", "browse": "瀏覽遠端會話", "found": "發現 {count} 個會話", + "search": "搜尋", + "searchPlaceholder": "依會話名稱搜尋", "selectAll": "全選", "deselectAll": "取消全選", "selectByType": "會話類型", @@ -515,6 +517,7 @@ "typePrivate": "私訊", "typeGroup": "群組", "typeOther": "其他", + "loadMore": "載入更多", "subscribe": "訂閱 {count} 個會話", "messages": "則訊息", "error": "連線遠端伺服器失敗" diff --git a/src/stores/apiServer.ts b/src/stores/apiServer.ts index 95226f0d..a739dc60 100644 --- a/src/stores/apiServer.ts +++ b/src/stores/apiServer.ts @@ -51,6 +51,16 @@ export interface RemoteSession { lastMessageAt?: number } +export interface RemoteSessionDiscoveryPage { + hasMore: boolean + nextCursor?: string +} + +export interface RemoteSessionDiscoveryResult { + sessions: RemoteSession[] + page?: RemoteSessionDiscoveryPage +} + export const useApiServerStore = defineStore('apiServer', () => { const config = ref({ enabled: false, @@ -244,8 +254,12 @@ export const useApiServerStore = defineStore('apiServer', () => { }) } - async function fetchRemoteSessions(baseUrl: string, token?: string): Promise { - return window.apiServerApi.fetchRemoteSessions(baseUrl, token) + async function fetchRemoteSessions( + baseUrl: string, + token?: string, + query?: { keyword?: string; limit?: number; cursor?: string } + ): Promise { + return window.apiServerApi.fetchRemoteSessions(baseUrl, token, query) } return {