Compare commits

...

11 Commits

Author SHA1 Message Date
digua 9eb20da030 release: v0.18.3 2026-04-28 00:15:33 +08:00
digua e2c20e2e3c feat: 默认时间筛选优化 2026-04-28 00:06:08 +08:00
digua ed6a979aa5 feat: 支持快捷工具入口位置配置 2026-04-28 00:06:08 +08:00
digua 541708b953 fix: 修复弹窗样式覆盖问题 2026-04-28 00:06:08 +08:00
digua 30d61fdf2e feat: 禁止将数据目录设置在应用安装目录下 2026-04-28 00:06:08 +08:00
digua a80730eb4c fix: 修复设置页内弹窗被遮挡的 z-index 问题 2026-04-28 00:06:08 +08:00
digua b28b763249 release: v0.18.2 2026-04-27 00:00:05 +08:00
digua 6e10dd194b chore: Github Actions 权限变更 2026-04-26 23:59:52 +08:00
digua c97e2e7098 feat: 支持配置每次拉取消息数 2026-04-26 23:38:29 +08:00
digua 352a071a21 feat: 支持远程会话分页发现 2026-04-26 23:38:29 +08:00
xuncha ef2334b758 feat: 订阅新增类型选择 2026-04-26 23:38:29 +08:00
35 changed files with 792 additions and 105 deletions
+3
View File
@@ -13,6 +13,9 @@ on:
tags:
- 'v*'
permissions:
contents: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
+30 -5
View File
@@ -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 中展示该列表,用户选择需要导入的对话。
---
+30
View File
@@ -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",
+34
View File
@@ -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",
+37
View File
@@ -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",
+30
View File
@@ -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",
+8 -1
View File
@@ -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
+68
View File
@@ -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,
}
}
+15 -17
View File
@@ -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'))
}
+1 -2
View File
@@ -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 {
+14 -8
View File
@@ -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')
}
}
})
)
}
/**
+18 -2
View File
@@ -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) }
+11
View File
@@ -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 数据,请选择空目录或已有数据目录' }
+26
View File
@@ -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}`)
}
+19 -3
View File
@@ -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: (
+18 -2
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{
"name": "ChatLab",
"version": "0.18.1",
"version": "0.18.3",
"description": "本地化的聊天记录分析工具,通过 SQL 和 AI Agent 回顾你的社交记忆",
"repository": {
"type": "git",
+1 -14
View File
@@ -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">
+40 -13
View File
@@ -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
+2 -2
View File
@@ -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,
}
})
+17
View File
@@ -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"
+17
View File
@@ -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": "リモートサーバーへの接続に失敗しました"
+17
View File
@@ -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": "连接远程服务器失败"
+17
View File
@@ -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": "連線遠端伺服器失敗"
+9
View File
@@ -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 -->
+9
View File
@@ -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
View File
@@ -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
View File
@@ -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,
},
],