mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-05-14 10:29:15 +08:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9eb20da030 | |||
| e2c20e2e3c | |||
| ed6a979aa5 | |||
| 541708b953 | |||
| 30d61fdf2e | |||
| a80730eb4c | |||
| b28b763249 | |||
| 6e10dd194b | |||
| c97e2e7098 | |||
| 352a071a21 | |||
| ef2334b758 |
@@ -13,6 +13,9 @@ on:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
|
||||
@@ -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 中展示该列表,用户选择需要导入的对话。
|
||||
|
||||
---
|
||||
|
||||
@@ -1,4 +1,34 @@
|
||||
[
|
||||
{
|
||||
"version": "0.18.3",
|
||||
"date": "2026-04-28",
|
||||
"summary": "支持配置快捷工具入口位置,优化默认时间筛选,并修复弹窗层级与数据目录安全提示。",
|
||||
"changes": [
|
||||
{
|
||||
"type": "feat",
|
||||
"items": ["支持配置快捷工具入口位置", "优化默认时间筛选体验", "禁止将数据目录设置在应用安装目录下"]
|
||||
},
|
||||
{
|
||||
"type": "fix",
|
||||
"items": ["修复弹窗样式覆盖问题", "修复设置页内弹窗被遮挡的层级问题"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.18.2",
|
||||
"date": "2026-04-26",
|
||||
"summary": "新增订阅类型选择、远程会话分页发现与每次拉取条数配置。",
|
||||
"changes": [
|
||||
{
|
||||
"type": "feat",
|
||||
"items": [
|
||||
"订阅会话时支持按类型筛选与选择",
|
||||
"支持远程会话分页发现与按需加载更多",
|
||||
"支持为数据源配置每次拉取的消息条数"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.18.1",
|
||||
"date": "2026-04-24",
|
||||
|
||||
@@ -1,4 +1,38 @@
|
||||
[
|
||||
{
|
||||
"version": "0.18.3",
|
||||
"date": "2026-04-28",
|
||||
"summary": "This release adds configurable quick tool entry placement, improves the default time filter, and fixes modal layering and data directory safety messaging.",
|
||||
"changes": [
|
||||
{
|
||||
"type": "feat",
|
||||
"items": [
|
||||
"Add a setting for the placement of the quick tool entry.",
|
||||
"Improve the default time filter experience.",
|
||||
"Prevent data directories from being set inside the app installation directory."
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "fix",
|
||||
"items": ["Fix modal style override issues.", "Fix modal layering issues in the settings page."]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.18.2",
|
||||
"date": "2026-04-26",
|
||||
"summary": "This release adds session type filters for subscriptions, paginated remote session discovery, and configurable per-request message limits.",
|
||||
"changes": [
|
||||
{
|
||||
"type": "feat",
|
||||
"items": [
|
||||
"Add session type filtering and selection when subscribing to sessions.",
|
||||
"Add paginated remote session discovery with on-demand loading.",
|
||||
"Allow each data source to configure how many messages are fetched per request."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.18.1",
|
||||
"date": "2026-04-24",
|
||||
|
||||
@@ -1,4 +1,41 @@
|
||||
[
|
||||
{
|
||||
"version": "0.18.3",
|
||||
"date": "2026-04-28",
|
||||
"summary": "クイックツール入口の配置設定に対応し、デフォルトの時間フィルターを改善しました。あわせて、モーダルの重なり順とデータ保存先の安全案内も修正しています。",
|
||||
"changes": [
|
||||
{
|
||||
"type": "feat",
|
||||
"items": [
|
||||
"クイックツール入口の配置を設定できるようにしました。",
|
||||
"デフォルトの時間フィルターの使い勝手を改善しました。",
|
||||
"データ保存先をアプリのインストールディレクトリ内に設定できないようにしました。"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "fix",
|
||||
"items": [
|
||||
"モーダルのスタイルが上書きされる問題を修正しました。",
|
||||
"設定ページ内でモーダルが背面に隠れる重なり順の問題を修正しました。"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.18.2",
|
||||
"date": "2026-04-26",
|
||||
"summary": "購読時の種類選択、リモート会話のページ分割取得、1 回あたりの取得件数設定に対応しました。",
|
||||
"changes": [
|
||||
{
|
||||
"type": "feat",
|
||||
"items": [
|
||||
"会話を購読する際に、種類で絞り込んで選択できるようにしました。",
|
||||
"リモート会話のページ分割取得と、必要に応じた追加読み込みに対応しました。",
|
||||
"データソースごとに、1 回の取得で読み込むメッセージ件数を設定できるようにしました。"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.18.1",
|
||||
"date": "2026-04-24",
|
||||
|
||||
@@ -1,4 +1,34 @@
|
||||
[
|
||||
{
|
||||
"version": "0.18.3",
|
||||
"date": "2026-04-28",
|
||||
"summary": "本次更新支援設定快捷工具入口位置,優化預設時間篩選,並修正彈窗層級與資料目錄安全提示。",
|
||||
"changes": [
|
||||
{
|
||||
"type": "feat",
|
||||
"items": ["支援設定快捷工具入口位置", "優化預設時間篩選體驗", "禁止將資料目錄設在應用程式安裝目錄內"]
|
||||
},
|
||||
{
|
||||
"type": "fix",
|
||||
"items": ["修正彈窗樣式被覆蓋的問題", "修正設定頁內彈窗被遮擋的層級問題"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.18.2",
|
||||
"date": "2026-04-26",
|
||||
"summary": "本次更新新增訂閱類型選擇、遠端會話分頁發現,以及每次拉取訊息數量的設定。",
|
||||
"changes": [
|
||||
{
|
||||
"type": "feat",
|
||||
"items": [
|
||||
"訂閱會話時支援依類型篩選與選取",
|
||||
"支援遠端會話分頁發現與按需載入更多",
|
||||
"支援為資料來源設定每次拉取的訊息數量"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.18.1",
|
||||
"date": "2026-04-24",
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface DataSource {
|
||||
baseUrl: string
|
||||
token: string
|
||||
intervalMinutes: number
|
||||
pullLimit: number
|
||||
enabled: boolean
|
||||
createdAt: number
|
||||
sessions: ImportSession[]
|
||||
@@ -62,6 +63,9 @@ export function loadDataSources(): DataSource[] {
|
||||
return []
|
||||
}
|
||||
|
||||
for (const ds of parsed) {
|
||||
if (!ds.pullLimit) ds.pullLimit = 1000
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -101,6 +105,7 @@ export function addDataSource(partial: {
|
||||
baseUrl: string
|
||||
token: string
|
||||
intervalMinutes: number
|
||||
pullLimit?: number
|
||||
}): DataSource {
|
||||
const sources = loadDataSources()
|
||||
const ds: DataSource = {
|
||||
@@ -109,6 +114,7 @@ export function addDataSource(partial: {
|
||||
baseUrl: normalizeBaseUrl(partial.baseUrl),
|
||||
token: partial.token,
|
||||
intervalMinutes: partial.intervalMinutes,
|
||||
pullLimit: partial.pullLimit || 1000,
|
||||
enabled: true,
|
||||
createdAt: Math.floor(Date.now() / 1000),
|
||||
sessions: [],
|
||||
@@ -120,7 +126,7 @@ export function addDataSource(partial: {
|
||||
|
||||
export function updateDataSource(
|
||||
id: string,
|
||||
updates: Partial<Pick<DataSource, 'name' | 'baseUrl' | 'token' | 'intervalMinutes' | 'enabled'>>
|
||||
updates: Partial<Pick<DataSource, 'name' | 'baseUrl' | 'token' | 'intervalMinutes' | 'pullLimit' | 'enabled'>>
|
||||
): DataSource | null {
|
||||
const sources = loadDataSources()
|
||||
const idx = sources.findIndex((s) => s.id === id)
|
||||
@@ -130,6 +136,7 @@ export function updateDataSource(
|
||||
if (updates.baseUrl !== undefined) ds.baseUrl = normalizeBaseUrl(updates.baseUrl)
|
||||
if (updates.token !== undefined) ds.token = updates.token
|
||||
if (updates.intervalMinutes !== undefined) ds.intervalMinutes = updates.intervalMinutes
|
||||
if (updates.pullLimit !== undefined) ds.pullLimit = updates.pullLimit
|
||||
if (updates.enabled !== undefined) ds.enabled = updates.enabled
|
||||
saveDataSources(sources)
|
||||
return ds
|
||||
|
||||
@@ -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<string, unknown> | 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,
|
||||
}
|
||||
}
|
||||
@@ -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<RemoteSession[]> {
|
||||
return new Promise<RemoteSession[]>((resolve, reject) => {
|
||||
const url = normalizeBaseUrl(baseUrl) + '/sessions?format=chatlab'
|
||||
export function fetchRemoteSessions(
|
||||
baseUrl: string,
|
||||
token?: string,
|
||||
query: RemoteSessionDiscoveryQuery = {}
|
||||
): Promise<RemoteSessionDiscoveryResult> {
|
||||
return new Promise<RemoteSessionDiscoveryResult>((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<Re
|
||||
|
||||
response.on('end', () => {
|
||||
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'))
|
||||
}
|
||||
|
||||
@@ -233,7 +233,6 @@ async function importTempFile(baseUrl: string, sess: ImportSession, tempFile: st
|
||||
// ==================== Core pull loop (per ImportSession) ====================
|
||||
|
||||
const MAX_PAGES_PER_PULL = 50
|
||||
const DEFAULT_PULL_LIMIT = 1000
|
||||
const PULL_OVERLAP_SECONDS = 60
|
||||
|
||||
interface PullSessionResult {
|
||||
@@ -264,7 +263,7 @@ async function executePullSession(sourceId: string, ds: DataSource, sess: Import
|
||||
since,
|
||||
offset,
|
||||
end,
|
||||
limit: DEFAULT_PULL_LIMIT,
|
||||
limit: ds.pullLimit,
|
||||
})
|
||||
|
||||
try {
|
||||
|
||||
@@ -59,7 +59,10 @@ export function registerApiHandlers(_ctx: IpcContext): void {
|
||||
|
||||
ipcMain.handle(
|
||||
'api:addDataSource',
|
||||
(_event, partial: { name?: string; baseUrl: string; token: string; intervalMinutes: number }) => {
|
||||
(
|
||||
_event,
|
||||
partial: { name?: string; baseUrl: string; token: string; intervalMinutes: number; pullLimit?: number }
|
||||
) => {
|
||||
const ds = addDataSource(partial)
|
||||
return ds
|
||||
}
|
||||
@@ -70,7 +73,7 @@ export function registerApiHandlers(_ctx: IpcContext): void {
|
||||
(
|
||||
_event,
|
||||
id: string,
|
||||
updates: Partial<Pick<DataSource, 'name' | 'baseUrl' | 'token' | 'intervalMinutes' | 'enabled'>>
|
||||
updates: Partial<Pick<DataSource, 'name' | 'baseUrl' | 'token' | 'intervalMinutes' | 'pullLimit' | 'enabled'>>
|
||||
) => {
|
||||
const ds = updateDataSource(id, updates)
|
||||
if (ds) {
|
||||
@@ -114,13 +117,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')
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// electron/main/ipc/cache.ts
|
||||
import { ipcMain, shell, dialog } from 'electron'
|
||||
import { ipcMain, shell, dialog, app } from 'electron'
|
||||
import * as fs from 'fs/promises'
|
||||
import * as fsSync from 'fs'
|
||||
import * as path from 'path'
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
setCustomDataDir,
|
||||
ensureAppDirs,
|
||||
} from '../paths'
|
||||
import { isInsideAppInstallDir } from '../utils/pathUtils'
|
||||
|
||||
/**
|
||||
* 递归计算目录大小
|
||||
@@ -161,7 +162,22 @@ export function registerCacheHandlers(_context: IpcContext): void {
|
||||
return { success: false }
|
||||
}
|
||||
|
||||
return { success: true, path: result.filePaths[0] }
|
||||
const selectedPath = result.filePaths[0]
|
||||
|
||||
// 安全检查:禁止选择应用安装目录(更新时会被清空)
|
||||
try {
|
||||
const exePath = app.getPath('exe')
|
||||
if (isInsideAppInstallDir(selectedPath, exePath)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'INSTALL_DIR_FORBIDDEN',
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 获取 exe 路径失败时跳过此检查
|
||||
}
|
||||
|
||||
return { success: true, path: selectedPath }
|
||||
} catch (error) {
|
||||
console.error('[Cache] Error selecting data dir:', error)
|
||||
return { success: false, error: String(error) }
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
ensureMarkerFile,
|
||||
isDirectorySafeToUse,
|
||||
isExistingChatLabDir,
|
||||
isInsideAppInstallDir,
|
||||
isPathSafe,
|
||||
isSubPath,
|
||||
writeMigrationLog,
|
||||
@@ -209,6 +210,16 @@ export function setCustomDataDir(
|
||||
return { success: false, error: '不能使用系统关键目录作为数据目录' }
|
||||
}
|
||||
|
||||
// 安全检查:不能放在应用安装目录下(更新时会被清空)
|
||||
try {
|
||||
const exePath = app.getPath('exe')
|
||||
if (isInsideAppInstallDir(normalized, exePath)) {
|
||||
return { success: false, error: '不能将数据目录放在应用安装目录下,应用更新时该目录会被清空' }
|
||||
}
|
||||
} catch {
|
||||
// 获取 exe 路径失败时跳过此检查
|
||||
}
|
||||
|
||||
// 安全检查:目标目录应为空或已有 ChatLab 数据
|
||||
if (!isDirectorySafeToUse(normalized, CHATLAB_MARKER_FILE, CHATLAB_REQUIRED_DIRS)) {
|
||||
return { success: false, error: '目标目录不为空且不包含 ChatLab 数据,请选择空目录或已有数据目录' }
|
||||
|
||||
@@ -199,3 +199,29 @@ function hasChatLabStructure(entries: string[], markerFile: string, requiredDirs
|
||||
const hasRequiredDirs = requiredDirs.every((dir) => entries.includes(dir))
|
||||
return hasMarker && hasRequiredDirs
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取应用安装根目录
|
||||
* macOS: .app 包路径(如 /Applications/ChatLab.app)
|
||||
* Windows/Linux: 可执行文件所在目录
|
||||
*/
|
||||
export function getAppInstallDir(exePath: string): string {
|
||||
if (process.platform === 'darwin') {
|
||||
const appBundleMatch = exePath.match(/^(.+?\.app)(\/|$)/)
|
||||
if (appBundleMatch) {
|
||||
return appBundleMatch[1]
|
||||
}
|
||||
}
|
||||
return path.dirname(exePath)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查目标路径是否位于应用安装目录内(或等于安装目录)
|
||||
*/
|
||||
export function isInsideAppInstallDir(targetPath: string, exePath: string): boolean {
|
||||
const installDir = getAppInstallDir(exePath)
|
||||
const normalizedTarget = normalizePathForCompare(targetPath)
|
||||
const normalizedInstall = normalizePathForCompare(installDir)
|
||||
|
||||
return normalizedTarget === normalizedInstall || normalizedTarget.startsWith(`${normalizedInstall}${path.sep}`)
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface DataSource {
|
||||
baseUrl: string
|
||||
token: string
|
||||
intervalMinutes: number
|
||||
pullLimit: number
|
||||
enabled: boolean
|
||||
createdAt: number
|
||||
sessions: ImportSession[]
|
||||
@@ -50,6 +51,16 @@ export interface RemoteSession {
|
||||
lastMessageAt?: number
|
||||
}
|
||||
|
||||
export interface RemoteSessionDiscoveryPage {
|
||||
hasMore: boolean
|
||||
nextCursor?: string
|
||||
}
|
||||
|
||||
export interface RemoteSessionDiscoveryResult {
|
||||
sessions: RemoteSession[]
|
||||
page?: RemoteSessionDiscoveryPage
|
||||
}
|
||||
|
||||
export const apiServerApi = {
|
||||
// ==================== API 服务管理 ====================
|
||||
|
||||
@@ -90,13 +101,14 @@ export const apiServerApi = {
|
||||
baseUrl: string
|
||||
token: string
|
||||
intervalMinutes: number
|
||||
pullLimit?: number
|
||||
}): Promise<DataSource> => {
|
||||
return ipcRenderer.invoke('api:addDataSource', partial)
|
||||
},
|
||||
|
||||
updateDataSource: (
|
||||
id: string,
|
||||
updates: Partial<Pick<DataSource, 'name' | 'baseUrl' | 'token' | 'intervalMinutes' | 'enabled'>>
|
||||
updates: Partial<Pick<DataSource, 'name' | 'baseUrl' | 'token' | 'intervalMinutes' | 'pullLimit' | 'enabled'>>
|
||||
): Promise<DataSource | null> => {
|
||||
return ipcRenderer.invoke('api:updateDataSource', id, updates)
|
||||
},
|
||||
@@ -128,8 +140,12 @@ export const apiServerApi = {
|
||||
return ipcRenderer.invoke('api:triggerPullAll', sourceId)
|
||||
},
|
||||
|
||||
fetchRemoteSessions: (baseUrl: string, token?: string): Promise<RemoteSession[]> => {
|
||||
return ipcRenderer.invoke('api:fetchRemoteSessions', baseUrl, token || '')
|
||||
fetchRemoteSessions: (
|
||||
baseUrl: string,
|
||||
token?: string,
|
||||
query?: { keyword?: string; limit?: number; cursor?: string }
|
||||
): Promise<RemoteSessionDiscoveryResult> => {
|
||||
return ipcRenderer.invoke('api:fetchRemoteSessions', baseUrl, token || '', query)
|
||||
},
|
||||
|
||||
onPullResult: (
|
||||
|
||||
Vendored
+18
-2
@@ -1019,6 +1019,7 @@ interface DataSource {
|
||||
baseUrl: string
|
||||
token: string
|
||||
intervalMinutes: number
|
||||
pullLimit: number
|
||||
enabled: boolean
|
||||
createdAt: number
|
||||
sessions: ImportSession[]
|
||||
@@ -1034,6 +1035,16 @@ interface RemoteSession {
|
||||
lastMessageAt?: number
|
||||
}
|
||||
|
||||
interface RemoteSessionDiscoveryPage {
|
||||
hasMore: boolean
|
||||
nextCursor?: string
|
||||
}
|
||||
|
||||
interface RemoteSessionDiscoveryResult {
|
||||
sessions: RemoteSession[]
|
||||
page?: RemoteSessionDiscoveryPage
|
||||
}
|
||||
|
||||
interface ApiServerApi {
|
||||
getConfig: () => Promise<ApiServerConfig>
|
||||
getStatus: () => Promise<ApiServerStatus>
|
||||
@@ -1047,10 +1058,11 @@ interface ApiServerApi {
|
||||
baseUrl: string
|
||||
token: string
|
||||
intervalMinutes: number
|
||||
pullLimit?: number
|
||||
}) => Promise<DataSource>
|
||||
updateDataSource: (
|
||||
id: string,
|
||||
updates: Partial<Pick<DataSource, 'name' | 'baseUrl' | 'token' | 'intervalMinutes' | 'enabled'>>
|
||||
updates: Partial<Pick<DataSource, 'name' | 'baseUrl' | 'token' | 'intervalMinutes' | 'pullLimit' | 'enabled'>>
|
||||
) => Promise<DataSource | null>
|
||||
deleteDataSource: (id: string) => Promise<boolean>
|
||||
addImportSessions: (
|
||||
@@ -1060,7 +1072,11 @@ interface ApiServerApi {
|
||||
removeImportSession: (sourceId: string, sessionId: string) => Promise<boolean>
|
||||
triggerPull: (sourceId: string, sessionId?: string) => Promise<{ success: boolean; error?: string }>
|
||||
triggerPullAll: (sourceId: string) => Promise<{ success: boolean; error?: string }>
|
||||
fetchRemoteSessions: (baseUrl: string, token?: string) => Promise<RemoteSession[]>
|
||||
fetchRemoteSessions: (
|
||||
baseUrl: string,
|
||||
token?: string,
|
||||
query?: { keyword?: string; limit?: number; cursor?: string }
|
||||
) => Promise<RemoteSessionDiscoveryResult>
|
||||
onPullResult: (
|
||||
callback: (data: { sourceId: string; sessionId?: string; status: string; detail: string }) => void
|
||||
) => () => void
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ChatLab",
|
||||
"version": "0.18.1",
|
||||
"version": "0.18.3",
|
||||
"description": "本地化的聊天记录分析工具,通过 SQL 和 AI Agent 回顾你的社交记忆",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -310,22 +310,9 @@ watch(
|
||||
class="relative min-h-0 flex-1 overflow-x-hidden overflow-y-auto p-4"
|
||||
:class="{ 'p-0!': messages.length === 0 && !isAIThinking }"
|
||||
>
|
||||
<!-- 空状态全局背景光 -->
|
||||
<div
|
||||
v-if="messages.length === 0 && !isAIThinking"
|
||||
class="pointer-events-none absolute left-1/2 top-0 -z-10 h-full w-full max-w-[800px] -translate-x-1/2 overflow-hidden opacity-50"
|
||||
>
|
||||
<div
|
||||
class="absolute -top-10 left-1/4 h-80 w-80 rounded-full bg-blue-400/20 blur-[80px] dark:bg-blue-500/20"
|
||||
></div>
|
||||
<div
|
||||
class="absolute -top-10 right-1/4 h-80 w-80 rounded-full bg-pink-400/20 blur-[80px] dark:bg-pink-500/20"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref="conversationContentRef"
|
||||
class="relative z-10 mx-auto max-w-3xl space-y-6"
|
||||
class="mx-auto max-w-3xl space-y-6"
|
||||
:class="{
|
||||
'flex min-h-full flex-col justify-center px-4 pb-32 pt-4 space-y-0!':
|
||||
messages.length === 0 && !isAIThinking,
|
||||
|
||||
@@ -168,7 +168,7 @@ async function saveConfig() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UModal v-model:open="isOpen">
|
||||
<UModal v-model:open="isOpen" :ui="{ content: 'z-[101]', overlay: 'z-[100]' }">
|
||||
<template #content>
|
||||
<UCard>
|
||||
<template #header>
|
||||
|
||||
@@ -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
|
||||
@@ -25,12 +26,23 @@ const formData = ref({
|
||||
baseUrl: '',
|
||||
token: '',
|
||||
intervalMinutes: 60,
|
||||
pullLimit: 1000,
|
||||
})
|
||||
|
||||
const remoteSessions = ref<RemoteSession[]>([])
|
||||
const selectedSessionIds = ref<Set<string>>(new Set())
|
||||
const discovering = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
const discoveryError = ref('')
|
||||
const discoveryKeyword = ref('')
|
||||
const discoveryNextCursor = ref<string | undefined>()
|
||||
const discoveryHasMore = ref(false)
|
||||
const hasDiscoveryRun = ref(false)
|
||||
|
||||
type SessionTypeFilter = 'all' | SessionTypeSelection
|
||||
|
||||
const activeSessionTypeFilter = ref<SessionTypeFilter>('all')
|
||||
const discoveryPageSize = 100
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
@@ -42,13 +54,19 @@ watch(
|
||||
baseUrl: props.manageSource.baseUrl,
|
||||
token: props.manageSource.token,
|
||||
intervalMinutes: props.manageSource.intervalMinutes,
|
||||
pullLimit: props.manageSource.pullLimit,
|
||||
}
|
||||
} else {
|
||||
formData.value = { name: '', baseUrl: '', token: '', intervalMinutes: 60 }
|
||||
formData.value = { name: '', baseUrl: '', token: '', intervalMinutes: 60, pullLimit: 1000 }
|
||||
}
|
||||
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()
|
||||
}
|
||||
@@ -57,17 +75,50 @@ watch(
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const availableSessions = computed(() => remoteSessions.value.filter((s) => !props.subscribedRemoteIds?.has(s.id)))
|
||||
const visibleRemoteSessions = computed(() => {
|
||||
if (activeSessionTypeFilter.value === 'all') return remoteSessions.value
|
||||
return remoteSessions.value.filter((session) => getSessionTypeSelection(session) === activeSessionTypeFilter.value)
|
||||
})
|
||||
|
||||
const allSelected = computed(
|
||||
() => availableSessions.value.length > 0 && selectedSessionIds.value.size === availableSessions.value.length
|
||||
const visibleAvailableSessions = computed(() =>
|
||||
visibleRemoteSessions.value.filter((s) => !props.subscribedRemoteIds?.has(s.id))
|
||||
)
|
||||
|
||||
const allSelected = computed(
|
||||
() =>
|
||||
visibleAvailableSessions.value.length > 0 &&
|
||||
visibleAvailableSessions.value.every((session) => selectedSessionIds.value.has(session.id))
|
||||
)
|
||||
|
||||
const sessionTypeSelectionOptions = computed(() =>
|
||||
(
|
||||
[
|
||||
{ value: 'all', label: t('settings.api.dataSources.discovery.typeAll') },
|
||||
{ value: 'private', label: t('settings.api.dataSources.discovery.typePrivate') },
|
||||
{ value: 'group', label: t('settings.api.dataSources.discovery.typeGroup') },
|
||||
{ value: 'other', label: t('settings.api.dataSources.discovery.typeOther') },
|
||||
] as Array<{ value: SessionTypeFilter; label: string }>
|
||||
).map((option) => ({
|
||||
...option,
|
||||
count:
|
||||
option.value === 'all'
|
||||
? remoteSessions.value.length
|
||||
: remoteSessions.value.filter((session) => getSessionTypeSelection(session) === option.value).length,
|
||||
}))
|
||||
)
|
||||
|
||||
function setSessionTypeFilter(type: SessionTypeFilter) {
|
||||
activeSessionTypeFilter.value = type
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
const visibleIds = visibleAvailableSessions.value.map((s) => s.id)
|
||||
if (allSelected.value) {
|
||||
selectedSessionIds.value = new Set()
|
||||
const next = new Set(selectedSessionIds.value)
|
||||
for (const id of visibleIds) next.delete(id)
|
||||
selectedSessionIds.value = next
|
||||
} else {
|
||||
selectedSessionIds.value = new Set(availableSessions.value.map((s) => s.id))
|
||||
selectedSessionIds.value = new Set([...selectedSessionIds.value, ...visibleIds])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,17 +135,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()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +207,7 @@ async function handleSubmit() {
|
||||
baseUrl: formData.value.baseUrl,
|
||||
token: formData.value.token,
|
||||
intervalMinutes: formData.value.intervalMinutes,
|
||||
pullLimit: formData.value.pullLimit || undefined,
|
||||
})
|
||||
if (ds && selectedSessionIds.value.size > 0) {
|
||||
const sessions = remoteSessions.value
|
||||
@@ -180,11 +276,26 @@ function formatMessageCount(count?: number): string {
|
||||
/>
|
||||
</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="formData.intervalMinutes" type="number" :min="1" class="w-full" />
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<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="formData.intervalMinutes" type="number" :min="1" class="w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('settings.api.dataSources.form.pullLimit') }}
|
||||
</label>
|
||||
<UInput
|
||||
v-model.number="formData.pullLimit"
|
||||
type="number"
|
||||
:min="100"
|
||||
:max="10000"
|
||||
class="w-full"
|
||||
:placeholder="t('settings.api.dataSources.form.pullLimitPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -210,7 +321,7 @@ function formatMessageCount(count?: number): string {
|
||||
</div>
|
||||
|
||||
<!-- Session list -->
|
||||
<div v-if="remoteSessions.length > 0">
|
||||
<div v-if="hasDiscoveryRun">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('settings.api.dataSources.discovery.found', { count: remoteSessions.length }) }}
|
||||
@@ -226,9 +337,45 @@ function formatMessageCount(count?: number): string {
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
<div class="max-h-64 overflow-y-auto rounded-lg border border-gray-200 dark:border-gray-600">
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<UInput
|
||||
v-model="discoveryKeyword"
|
||||
class="flex-1"
|
||||
:placeholder="t('settings.api.dataSources.discovery.searchPlaceholder')"
|
||||
@keydown.enter.prevent="searchSessions"
|
||||
/>
|
||||
<UButton color="primary" variant="soft" :loading="discovering" @click="searchSessions">
|
||||
{{ t('settings.api.dataSources.discovery.search') }}
|
||||
</UButton>
|
||||
</div>
|
||||
<div class="mb-2 flex flex-wrap items-center gap-2">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('settings.api.dataSources.discovery.selectByType') }}
|
||||
</span>
|
||||
<div class="inline-flex rounded-lg bg-gray-100 p-0.5 dark:bg-gray-800">
|
||||
<button
|
||||
v-for="option in sessionTypeSelectionOptions"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
class="rounded-md px-2.5 py-1 text-xs transition-colors"
|
||||
:class="
|
||||
activeSessionTypeFilter === option.value
|
||||
? 'bg-white text-blue-600 shadow-sm dark:bg-gray-700 dark:text-blue-300'
|
||||
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200'
|
||||
"
|
||||
:disabled="option.count === 0"
|
||||
@click="setSessionTypeFilter(option.value)"
|
||||
>
|
||||
{{ option.label }} ({{ option.count }})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="remoteSessions.length > 0"
|
||||
class="max-h-64 overflow-y-auto rounded-lg border border-gray-200 dark:border-gray-600"
|
||||
>
|
||||
<div
|
||||
v-for="session in remoteSessions"
|
||||
v-for="session in visibleRemoteSessions"
|
||||
:key="session.id"
|
||||
class="flex items-center gap-3 border-b border-gray-100 px-3 py-2 last:border-0"
|
||||
:class="
|
||||
@@ -265,6 +412,17 @@ function formatMessageCount(count?: number): string {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="rounded-lg border border-dashed border-gray-200 px-3 py-6 text-center text-xs text-gray-500 dark:border-gray-700 dark:text-gray-400"
|
||||
>
|
||||
{{ t('settings.api.dataSources.discovery.found', { count: 0 }) }}
|
||||
</div>
|
||||
<div v-if="discoveryHasMore" class="mt-3 flex justify-center">
|
||||
<UButton color="neutral" variant="soft" :loading="loadingMore" @click="loadMoreSessions">
|
||||
{{ t('settings.api.dataSources.discovery.loadMore') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
|
||||
@@ -10,7 +10,7 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
saved: [updates: { name: string; baseUrl: string; token: string; intervalMinutes: number }]
|
||||
saved: [updates: { name: string; baseUrl: string; token: string; intervalMinutes: number; pullLimit: number }]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -20,6 +20,7 @@ const form = ref({
|
||||
baseUrl: '',
|
||||
token: '',
|
||||
intervalMinutes: 60,
|
||||
pullLimit: 1000,
|
||||
})
|
||||
|
||||
watch(
|
||||
@@ -31,6 +32,7 @@ watch(
|
||||
baseUrl: props.dataSource.baseUrl,
|
||||
token: props.dataSource.token,
|
||||
intervalMinutes: props.dataSource.intervalMinutes,
|
||||
pullLimit: props.dataSource.pullLimit,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,6 +44,7 @@ function save() {
|
||||
baseUrl: form.value.baseUrl,
|
||||
token: form.value.token,
|
||||
intervalMinutes: form.value.intervalMinutes,
|
||||
pullLimit: form.value.pullLimit,
|
||||
})
|
||||
emit('update:open', false)
|
||||
}
|
||||
@@ -85,11 +88,26 @@ function save() {
|
||||
/>
|
||||
</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="form.intervalMinutes" type="number" :min="1" class="w-full" />
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<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="form.intervalMinutes" type="number" :min="1" class="w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('settings.api.dataSources.form.pullLimit') }}
|
||||
</label>
|
||||
<UInput
|
||||
v-model.number="form.pullLimit"
|
||||
type="number"
|
||||
:min="100"
|
||||
:max="10000"
|
||||
class="w-full"
|
||||
:placeholder="t('settings.api.dataSources.form.pullLimitPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -128,7 +128,13 @@ function openEditSource(ds: DataSource) {
|
||||
showEditModal.value = true
|
||||
}
|
||||
|
||||
async function handleEditSaved(updates: { name: string; baseUrl: string; token: string; intervalMinutes: number }) {
|
||||
async function handleEditSaved(updates: {
|
||||
name: string
|
||||
baseUrl: string
|
||||
token: string
|
||||
intervalMinutes: number
|
||||
pullLimit: number
|
||||
}) {
|
||||
if (!editingDataSource.value) return
|
||||
await store.updateDataSource(editingDataSource.value.id, updates)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ const { t } = useI18n()
|
||||
// Store
|
||||
const layoutStore = useLayoutStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const { screenshotMobileAdapt } = storeToRefs(layoutStore)
|
||||
const { screenshotMobileAdapt, toolsPanelPosition } = storeToRefs(layoutStore)
|
||||
const { locale, defaultSessionTab } = storeToRefs(settingsStore)
|
||||
|
||||
// Auto Launch
|
||||
@@ -71,6 +71,12 @@ const defaultTabOptions = computed(() => [
|
||||
{ label: t('settings.basic.defaultTab.aiChat'), value: 'ai-chat' },
|
||||
])
|
||||
|
||||
// Tools panel position options
|
||||
const toolsPanelPositionOptions = computed(() => [
|
||||
{ label: t('settings.basic.toolsPanel.positionHeader'), value: 'header' },
|
||||
{ label: t('settings.basic.toolsPanel.positionSide'), value: 'side' },
|
||||
])
|
||||
|
||||
// Sync theme with main process
|
||||
import { watch } from 'vue'
|
||||
watch(
|
||||
@@ -157,6 +163,20 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 dark:border-gray-700"></div>
|
||||
<div class="flex items-center justify-between p-4">
|
||||
<div class="flex-1 pr-4">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ t('settings.basic.toolsPanel.positionLabel') }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('settings.basic.toolsPanel.positionDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-64">
|
||||
<UTabs v-model="toolsPanelPosition" size="sm" class="gap-0" :items="toolsPanelPositionOptions"></UTabs>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 dark:border-gray-700"></div>
|
||||
<div class="flex items-center justify-between p-4">
|
||||
<div class="flex-1 pr-4">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
|
||||
@@ -119,7 +119,12 @@ async function selectDataDir() {
|
||||
dataDirError.value = null
|
||||
try {
|
||||
const result = await window.cacheApi.selectDataDir()
|
||||
if (!result.success || !result.path) return
|
||||
if (!result.success || !result.path) {
|
||||
if (result.error === 'INSTALL_DIR_FORBIDDEN') {
|
||||
dataDirError.value = t('settings.storage.dataLocation.installDirForbidden')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 显示确认弹窗
|
||||
pendingNewDir.value = result.path
|
||||
@@ -346,7 +351,7 @@ defineExpose({
|
||||
</div>
|
||||
|
||||
<!-- 切换数据目录确认弹窗 -->
|
||||
<UModal v-model:open="showConfirmModal">
|
||||
<UModal v-model:open="showConfirmModal" :ui="{ content: 'z-[101]', overlay: 'z-[100]' }">
|
||||
<template #content>
|
||||
<div class="p-5">
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
@@ -390,7 +395,7 @@ defineExpose({
|
||||
</UModal>
|
||||
|
||||
<!-- 迁移成功后强制重启弹窗 -->
|
||||
<UModal v-model:open="showRelaunchModal" :dismissible="false">
|
||||
<UModal v-model:open="showRelaunchModal" :dismissible="false" :ui="{ content: 'z-[101]', overlay: 'z-[100]' }">
|
||||
<template #content>
|
||||
<div class="p-5">
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
|
||||
@@ -1,33 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useLayoutStore } from '@/stores/layout'
|
||||
|
||||
const { t } = useI18n()
|
||||
const layoutStore = useLayoutStore()
|
||||
const { isToolsPanelLocked, isToolsPanelMini } = storeToRefs(layoutStore)
|
||||
const { isToolsPanelLocked, isToolsPanelMini, toolsPanelPosition, isToolsPanelOpen } = storeToRefs(layoutStore)
|
||||
|
||||
const isHeaderMode = computed(() => toolsPanelPosition.value === 'header')
|
||||
|
||||
const isHovered = ref(false)
|
||||
let hideTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const isVisible = computed(() => isToolsPanelLocked.value || isHovered.value)
|
||||
const isVisible = computed(() => {
|
||||
if (isHeaderMode.value) return isToolsPanelOpen.value
|
||||
return isToolsPanelLocked.value || isHovered.value
|
||||
})
|
||||
|
||||
function onMouseEnter() {
|
||||
if (hideTimer) {
|
||||
clearTimeout(hideTimer)
|
||||
hideTimer = null
|
||||
}
|
||||
isHovered.value = true
|
||||
if (!isHeaderMode.value) {
|
||||
isHovered.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function onMouseLeave() {
|
||||
if (isHeaderMode.value) {
|
||||
hideTimer = setTimeout(() => {
|
||||
isToolsPanelOpen.value = false
|
||||
}, 200)
|
||||
return
|
||||
}
|
||||
if (isToolsPanelLocked.value) return
|
||||
hideTimer = setTimeout(() => {
|
||||
isHovered.value = false
|
||||
}, 200)
|
||||
}
|
||||
|
||||
watch(toolsPanelPosition, () => {
|
||||
isToolsPanelOpen.value = false
|
||||
isHovered.value = false
|
||||
})
|
||||
|
||||
type ToolEvent =
|
||||
| 'openIncrementalImport'
|
||||
| 'openSessionIndex'
|
||||
@@ -41,7 +59,9 @@ const emit = defineEmits<{
|
||||
|
||||
function handleToolClick(event: ToolEvent) {
|
||||
emit(event)
|
||||
if (!isToolsPanelLocked.value) {
|
||||
if (isHeaderMode.value) {
|
||||
isToolsPanelOpen.value = false
|
||||
} else if (!isToolsPanelLocked.value) {
|
||||
isHovered.value = false
|
||||
}
|
||||
}
|
||||
@@ -86,8 +106,8 @@ const tools = [
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Mini 模式:常驻竖条,只有图标 -->
|
||||
<div v-if="isToolsPanelMini" class="fixed right-0 top-1/3">
|
||||
<!-- Mini 模式:常驻竖条,只有图标(仅 side 模式) -->
|
||||
<div v-if="!isHeaderMode && isToolsPanelMini" class="fixed right-0 top-1/3">
|
||||
<div
|
||||
class="no-capture flex flex-col items-center gap-0.5 rounded-l-lg border border-r-0 border-gray-200/60 bg-white py-1.5 shadow-sm dark:border-white/5 dark:bg-gray-900"
|
||||
>
|
||||
@@ -116,17 +136,24 @@ const tools = [
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 普通模式:hover 展开面板 -->
|
||||
<div v-else class="fixed right-0 top-1/3 z-40" @mouseenter="onMouseEnter" @mouseleave="onMouseLeave">
|
||||
<!-- Trigger 标签(面板隐藏时可见,面板展开时淡出) -->
|
||||
<!-- 普通/header 模式:面板从右侧滑入 -->
|
||||
<div
|
||||
v-else
|
||||
class="fixed right-0 z-40"
|
||||
:class="isHeaderMode ? 'top-14' : 'top-1/3'"
|
||||
@mouseenter="onMouseEnter"
|
||||
@mouseleave="onMouseLeave"
|
||||
>
|
||||
<!-- Trigger 标签(仅 side 模式) -->
|
||||
<div
|
||||
class="h-10 w-6 cursor-pointer items-center justify-center rounded-l-lg border border-r-0 border-gray-200 bg-white text-gray-400 shadow-sm transition-opacity duration-200 hover:bg-gray-50 hover:text-gray-600 dark:border-white/10 dark:bg-gray-800 dark:text-gray-500 dark:hover:bg-gray-700 dark:hover:text-gray-300"
|
||||
v-if="!isHeaderMode"
|
||||
class="h-10 w-6 cursor-pointer items-center justify-center rounded-l-lg border border-r-0 border-primary-200 bg-primary-50 text-primary-500 shadow-sm transition-opacity duration-200 hover:bg-primary-100 hover:text-primary-600 dark:border-primary-800 dark:bg-primary-950 dark:text-primary-400 dark:hover:bg-primary-900 dark:hover:text-primary-300"
|
||||
:class="isVisible ? 'pointer-events-none flex opacity-0' : 'flex opacity-100'"
|
||||
>
|
||||
<UIcon name="i-heroicons-wrench-screwdriver" class="h-3.5 w-3.5" />
|
||||
</div>
|
||||
|
||||
<!-- 面板(覆盖在 trigger 上方,从右侧滑入) -->
|
||||
<!-- 面板(从右侧滑入) -->
|
||||
<div
|
||||
class="absolute right-0 top-0 transition-all duration-250 ease-in-out"
|
||||
:class="isVisible ? 'translate-x-0 opacity-100' : 'pointer-events-none translate-x-full opacity-0'"
|
||||
@@ -138,7 +165,7 @@ const tools = [
|
||||
<span class="px-0.5 text-[9px] font-bold uppercase tracking-widest text-gray-400 dark:text-gray-500">
|
||||
{{ t('analysis.overview.tools') }}
|
||||
</span>
|
||||
<div class="flex items-center gap-0.5">
|
||||
<div v-if="!isHeaderMode" class="flex items-center gap-0.5">
|
||||
<!-- Mini 模式按钮 -->
|
||||
<UTooltip :text="t('analysis.toolsPanel.miniMode')" :popper="{ placement: 'left' }">
|
||||
<button
|
||||
|
||||
@@ -65,7 +65,7 @@ export function useTimeSelect(route: RouteLocationNormalizedLoaded, router: Rout
|
||||
|
||||
/**
|
||||
* 从 URL query 构建 TimeSelect 初始状态。
|
||||
* 优先级:URL 参数 > 缓存(上次用户设置)> 默认值(总览 Tab「全部」,其余「最近一年」)
|
||||
* 优先级:URL 参数 > 缓存(上次用户设置)> 默认值(最近一年)
|
||||
*/
|
||||
const initialTimeState = computed<Partial<TimeSelectState>>(() => {
|
||||
const q = route.query
|
||||
@@ -86,7 +86,7 @@ export function useTimeSelect(route: RouteLocationNormalizedLoaded, router: Rout
|
||||
}
|
||||
return {
|
||||
mode: 'recent',
|
||||
recentDays: activeTab.value === 'overview' ? 0 : 365,
|
||||
recentDays: 365,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -48,6 +48,12 @@
|
||||
"openAtLoginDesc": "Automatically start ChatLab when the system boots",
|
||||
"devModeHint": "Not available in dev mode"
|
||||
},
|
||||
"toolsPanel": {
|
||||
"positionLabel": "Tools Panel Position",
|
||||
"positionDesc": "Choose where the quick tools panel appears",
|
||||
"positionSide": "Side",
|
||||
"positionHeader": "Header"
|
||||
},
|
||||
"screenshot": {
|
||||
"title": "Screenshot",
|
||||
"mobileAdapt": "Mobile Adaptation",
|
||||
@@ -178,6 +184,7 @@
|
||||
"newPath": "New directory path",
|
||||
"defaultPath": "Default location",
|
||||
"confirmWarning": "After restarting, the old data directory will be automatically deleted. Please ensure the new path is correct.",
|
||||
"installDirForbidden": "Data directory cannot be placed inside the app installation directory, as it will be cleared during updates",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"migrationSuccessTitle": "Migration Complete",
|
||||
@@ -498,6 +505,8 @@
|
||||
"token": "Access Token (optional)",
|
||||
"tokenPlaceholder": "Bearer Token for the remote API",
|
||||
"interval": "Pull Interval (minutes)",
|
||||
"pullLimit": "Messages per Pull",
|
||||
"pullLimitPlaceholder": "Default 1000",
|
||||
"targetSession": "Target Session ID (optional)",
|
||||
"targetSessionPlaceholder": "Leave empty to create new session each time",
|
||||
"add": "Add"
|
||||
@@ -509,8 +518,16 @@
|
||||
"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",
|
||||
"typeAll": "All",
|
||||
"typePrivate": "Private",
|
||||
"typeGroup": "Group",
|
||||
"typeOther": "Other",
|
||||
"loadMore": "Load More",
|
||||
"subscribe": "Subscribe {count} sessions",
|
||||
"messages": "messages",
|
||||
"error": "Failed to connect to remote server"
|
||||
|
||||
@@ -48,6 +48,12 @@
|
||||
"openAtLoginDesc": "システム起動時に ChatLab を自動的に実行します",
|
||||
"devModeHint": "開発モードでは利用できません"
|
||||
},
|
||||
"toolsPanel": {
|
||||
"positionLabel": "ツールパネルの位置",
|
||||
"positionDesc": "クイックツールパネルの表示位置を選択します",
|
||||
"positionSide": "サイド",
|
||||
"positionHeader": "ヘッダー"
|
||||
},
|
||||
"screenshot": {
|
||||
"title": "スクリーンショット設定",
|
||||
"mobileAdapt": "モバイル対応",
|
||||
@@ -178,6 +184,7 @@
|
||||
"newPath": "新しいディレクトリパス",
|
||||
"defaultPath": "デフォルトの場所",
|
||||
"confirmWarning": "アプリ再起動後、旧データディレクトリは自動的に削除されます。新しいディレクトリパスが正しいことを確認してください。",
|
||||
"installDirForbidden": "データディレクトリをアプリのインストールディレクトリ内に配置することはできません。更新時にディレクトリが消去されます",
|
||||
"cancel": "キャンセル",
|
||||
"confirm": "切り替えを確認",
|
||||
"migrationSuccessTitle": "データ移行が完了しました",
|
||||
@@ -497,6 +504,8 @@
|
||||
"token": "アクセストークン(任意)",
|
||||
"tokenPlaceholder": "リモート API の Bearer Token",
|
||||
"interval": "取得間隔(分)",
|
||||
"pullLimit": "1回の取得メッセージ数",
|
||||
"pullLimitPlaceholder": "デフォルト 1000",
|
||||
"targetSession": "ターゲットセッション ID(任意)",
|
||||
"targetSessionPlaceholder": "空欄の場合、毎回新規セッションを作成",
|
||||
"add": "追加"
|
||||
@@ -508,8 +517,16 @@
|
||||
"baseUrlPlaceholder": "例:http://127.0.0.1:2333/api/v1",
|
||||
"browse": "セッションを参照",
|
||||
"found": "{count} 件のセッションが見つかりました",
|
||||
"search": "検索",
|
||||
"searchPlaceholder": "セッション名で検索",
|
||||
"selectAll": "すべて選択",
|
||||
"deselectAll": "選択解除",
|
||||
"selectByType": "セッションタイプ",
|
||||
"typeAll": "すべて",
|
||||
"typePrivate": "個人チャット",
|
||||
"typeGroup": "グループチャット",
|
||||
"typeOther": "その他",
|
||||
"loadMore": "さらに読み込む",
|
||||
"subscribe": "{count} 件を購読",
|
||||
"messages": "件のメッセージ",
|
||||
"error": "リモートサーバーへの接続に失敗しました"
|
||||
|
||||
@@ -48,6 +48,12 @@
|
||||
"openAtLoginDesc": "系统启动时自动运行 ChatLab",
|
||||
"devModeHint": "开发模式下不可用"
|
||||
},
|
||||
"toolsPanel": {
|
||||
"positionLabel": "快捷工具入口位置",
|
||||
"positionDesc": "选择快捷工具面板的显示位置",
|
||||
"positionSide": "右侧",
|
||||
"positionHeader": "顶部"
|
||||
},
|
||||
"screenshot": {
|
||||
"title": "截图设置",
|
||||
"mobileAdapt": "移动端适配",
|
||||
@@ -178,6 +184,7 @@
|
||||
"newPath": "新目录路径",
|
||||
"defaultPath": "默认位置",
|
||||
"confirmWarning": "重启应用后,旧数据目录将被自动删除。请确保新目录路径正确。",
|
||||
"installDirForbidden": "不能将数据目录放在应用安装目录下,应用更新时该目录会被清空",
|
||||
"cancel": "取消",
|
||||
"confirm": "确认切换",
|
||||
"migrationSuccessTitle": "数据迁移完成",
|
||||
@@ -498,6 +505,8 @@
|
||||
"token": "访问令牌(可选)",
|
||||
"tokenPlaceholder": "远程 API 的 Bearer Token",
|
||||
"interval": "拉取间隔(分钟)",
|
||||
"pullLimit": "每次拉取消息数",
|
||||
"pullLimitPlaceholder": "默认 1000",
|
||||
"targetSession": "目标会话 ID(可选)",
|
||||
"targetSessionPlaceholder": "留空则每次新建会话",
|
||||
"add": "添加"
|
||||
@@ -509,8 +518,16 @@
|
||||
"baseUrlPlaceholder": "示例:http://127.0.0.1:2333/api/v1",
|
||||
"browse": "浏览远程会话",
|
||||
"found": "发现 {count} 个会话",
|
||||
"search": "搜索",
|
||||
"searchPlaceholder": "按会话名称搜索",
|
||||
"selectAll": "全选",
|
||||
"deselectAll": "取消全选",
|
||||
"selectByType": "会话类型",
|
||||
"typeAll": "全部",
|
||||
"typePrivate": "私聊",
|
||||
"typeGroup": "群聊",
|
||||
"typeOther": "其他",
|
||||
"loadMore": "加载更多",
|
||||
"subscribe": "订阅 {count} 个会话",
|
||||
"messages": "条消息",
|
||||
"error": "连接远程服务器失败"
|
||||
|
||||
@@ -48,6 +48,12 @@
|
||||
"openAtLoginDesc": "系統啟動時自動執行 ChatLab",
|
||||
"devModeHint": "開發模式下不可用"
|
||||
},
|
||||
"toolsPanel": {
|
||||
"positionLabel": "快捷工具入口位置",
|
||||
"positionDesc": "選擇快捷工具面板的顯示位置",
|
||||
"positionSide": "右側",
|
||||
"positionHeader": "頂部"
|
||||
},
|
||||
"screenshot": {
|
||||
"title": "截圖設定",
|
||||
"mobileAdapt": "行動畫面適配",
|
||||
@@ -178,6 +184,7 @@
|
||||
"newPath": "新目錄路徑",
|
||||
"defaultPath": "預設位置",
|
||||
"confirmWarning": "重新啟動應用程式後,舊資料目錄將被自動刪除。請確保新目錄路徑正確。",
|
||||
"installDirForbidden": "不能將資料目錄放在應用程式安裝目錄下,應用程式更新時該目錄會被清空",
|
||||
"cancel": "取消",
|
||||
"confirm": "確認切換",
|
||||
"migrationSuccessTitle": "資料遷移完成",
|
||||
@@ -497,6 +504,8 @@
|
||||
"token": "存取權杖(可選)",
|
||||
"tokenPlaceholder": "遠端 API 的 Bearer Token",
|
||||
"interval": "拉取間隔(分鐘)",
|
||||
"pullLimit": "每次拉取訊息數",
|
||||
"pullLimitPlaceholder": "預設 1000",
|
||||
"targetSession": "目標會話 ID(可選)",
|
||||
"targetSessionPlaceholder": "留空則每次新建會話",
|
||||
"add": "新增"
|
||||
@@ -508,8 +517,16 @@
|
||||
"baseUrlPlaceholder": "示例:http://127.0.0.1:2333/api/v1",
|
||||
"browse": "瀏覽遠端會話",
|
||||
"found": "發現 {count} 個會話",
|
||||
"search": "搜尋",
|
||||
"searchPlaceholder": "依會話名稱搜尋",
|
||||
"selectAll": "全選",
|
||||
"deselectAll": "取消全選",
|
||||
"selectByType": "會話類型",
|
||||
"typeAll": "全部",
|
||||
"typePrivate": "私訊",
|
||||
"typeGroup": "群組",
|
||||
"typeOther": "其他",
|
||||
"loadMore": "載入更多",
|
||||
"subscribe": "訂閱 {count} 個會話",
|
||||
"messages": "則訊息",
|
||||
"error": "連線遠端伺服器失敗"
|
||||
|
||||
@@ -128,6 +128,15 @@ const { headerDescription } = useSessionHeaderDescription({
|
||||
icon-class="bg-primary-600 text-white dark:bg-primary-500 dark:text-white"
|
||||
>
|
||||
<template #actions>
|
||||
<UTooltip v-if="layoutStore.toolsPanelPosition === 'header'" :text="t('analysis.overview.tools')">
|
||||
<UButton
|
||||
icon="i-heroicons-wrench-screwdriver"
|
||||
variant="ghost"
|
||||
color="primary"
|
||||
size="sm"
|
||||
@click="layoutStore.toggleToolsPanelOpen()"
|
||||
/>
|
||||
</UTooltip>
|
||||
<CaptureButton />
|
||||
</template>
|
||||
<!-- Tabs -->
|
||||
|
||||
@@ -141,6 +141,15 @@ const otherMemberAvatar = computed(() => {
|
||||
icon-class="bg-pink-600 text-white dark:bg-pink-500 dark:text-white"
|
||||
>
|
||||
<template #actions>
|
||||
<UTooltip v-if="layoutStore.toolsPanelPosition === 'header'" :text="t('analysis.overview.tools')">
|
||||
<UButton
|
||||
icon="i-heroicons-wrench-screwdriver"
|
||||
variant="ghost"
|
||||
color="primary"
|
||||
size="sm"
|
||||
@click="layoutStore.toggleToolsPanelOpen()"
|
||||
/>
|
||||
</UTooltip>
|
||||
<CaptureButton />
|
||||
</template>
|
||||
<!-- Tabs -->
|
||||
|
||||
+25
-4
@@ -36,6 +36,7 @@ export interface DataSource {
|
||||
baseUrl: string
|
||||
token: string
|
||||
intervalMinutes: number
|
||||
pullLimit: number
|
||||
enabled: boolean
|
||||
createdAt: number
|
||||
sessions: ImportSession[]
|
||||
@@ -51,6 +52,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<ApiServerConfig>({
|
||||
enabled: false,
|
||||
@@ -143,7 +154,13 @@ export const useApiServerStore = defineStore('apiServer', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function addDataSource(partial: { name?: string; baseUrl: string; token: string; intervalMinutes: number }) {
|
||||
async function addDataSource(partial: {
|
||||
name?: string
|
||||
baseUrl: string
|
||||
token: string
|
||||
intervalMinutes: number
|
||||
pullLimit?: number
|
||||
}) {
|
||||
try {
|
||||
const ds = await window.apiServerApi.addDataSource(partial)
|
||||
dataSources.value.push(ds)
|
||||
@@ -156,7 +173,7 @@ export const useApiServerStore = defineStore('apiServer', () => {
|
||||
|
||||
async function updateDataSource(
|
||||
id: string,
|
||||
updates: Partial<Pick<DataSource, 'name' | 'baseUrl' | 'token' | 'intervalMinutes' | 'enabled'>>
|
||||
updates: Partial<Pick<DataSource, 'name' | 'baseUrl' | 'token' | 'intervalMinutes' | 'pullLimit' | 'enabled'>>
|
||||
) {
|
||||
try {
|
||||
const ds = await window.apiServerApi.updateDataSource(id, updates)
|
||||
@@ -244,8 +261,12 @@ export const useApiServerStore = defineStore('apiServer', () => {
|
||||
})
|
||||
}
|
||||
|
||||
async function fetchRemoteSessions(baseUrl: string, token?: string): Promise<RemoteSession[]> {
|
||||
return window.apiServerApi.fetchRemoteSessions(baseUrl, token)
|
||||
async function fetchRemoteSessions(
|
||||
baseUrl: string,
|
||||
token?: string,
|
||||
query?: { keyword?: string; limit?: number; cursor?: string }
|
||||
): Promise<RemoteSessionDiscoveryResult> {
|
||||
return window.apiServerApi.fetchRemoteSessions(baseUrl, token, query)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
+10
-1
@@ -16,6 +16,8 @@ export const useLayoutStore = defineStore(
|
||||
|
||||
const isToolsPanelLocked = ref(false)
|
||||
const isToolsPanelMini = ref(false)
|
||||
const toolsPanelPosition = ref<'side' | 'header'>('header')
|
||||
const isToolsPanelOpen = ref(false)
|
||||
|
||||
// 截图设置
|
||||
const screenshotMobileAdapt = ref(false) // 截图时开启移动端适配,默认关闭
|
||||
@@ -72,6 +74,10 @@ export const useLayoutStore = defineStore(
|
||||
isToolsPanelLocked.value = !isToolsPanelLocked.value
|
||||
}
|
||||
|
||||
function toggleToolsPanelOpen() {
|
||||
isToolsPanelOpen.value = !isToolsPanelOpen.value
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开设置弹窗,可选指定 Tab 和 SubTab
|
||||
*/
|
||||
@@ -96,6 +102,8 @@ export const useLayoutStore = defineStore(
|
||||
isSidebarCollapsed,
|
||||
isToolsPanelLocked,
|
||||
isToolsPanelMini,
|
||||
toolsPanelPosition,
|
||||
isToolsPanelOpen,
|
||||
showScreenCaptureModal,
|
||||
screenCaptureImage,
|
||||
showChatRecordDrawer,
|
||||
@@ -106,6 +114,7 @@ export const useLayoutStore = defineStore(
|
||||
settingsSubTab,
|
||||
toggleSidebar,
|
||||
toggleToolsPanelLock,
|
||||
toggleToolsPanelOpen,
|
||||
toggleToolsPanelMini,
|
||||
openScreenCaptureModal,
|
||||
closeScreenCaptureModal,
|
||||
@@ -122,7 +131,7 @@ export const useLayoutStore = defineStore(
|
||||
storage: sessionStorage,
|
||||
},
|
||||
{
|
||||
pick: ['screenshotMobileAdapt', 'isToolsPanelLocked', 'isToolsPanelMini'],
|
||||
pick: ['screenshotMobileAdapt', 'isToolsPanelLocked', 'isToolsPanelMini', 'toolsPanelPosition'],
|
||||
storage: localStorage,
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user