mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
refactor(ui): simplify model button and optimize session index loading
- StatusBar model button: show model name instead of provider, click opens settings directly (scrolls to default model section), remove dropdown - SessionIndexSection: replace N serial pluginQuery calls with single batch API (GET /_web/sessions/index-stats), add IntersectionObserver lazy loading - Full-stack implementation: CLI route, Electron IPC/Worker, frontend adapters Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
783d61f742
commit
9bc0c4279d
15 files changed
+140
-109
No files matched your search
@@ -2,6 +2,10 @@ import type { FastifyInstance } from 'fastify'
|
||||
import { sessionIndexService, type SessionRuntimeAdapter } from '@openchatlab/node-runtime'
|
||||
|
||||
export function registerSessionIndexRoutes(server: FastifyInstance, adapter: SessionRuntimeAdapter): void {
|
||||
server.get('/_web/sessions/index-stats', async () => {
|
||||
return sessionIndexService.getAllIndexStats(adapter)
|
||||
})
|
||||
|
||||
server.post<{
|
||||
Params: { id: string }
|
||||
Body: { gapThreshold?: number }
|
||||
|
||||
@@ -836,6 +836,15 @@ export function registerChatHandlers(ctx: IpcContext): void {
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('session:getAllIndexStats', async () => {
|
||||
try {
|
||||
return await worker.getAllIndexStats()
|
||||
} catch (error) {
|
||||
console.error('Failed to get all index stats:', error)
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 清空会话索引
|
||||
*/
|
||||
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
clearSessions,
|
||||
hasSessionIndex,
|
||||
getSessionStats,
|
||||
getAllIndexStats,
|
||||
updateSessionGapThreshold,
|
||||
getSessions,
|
||||
getSessionsByTimeRange,
|
||||
@@ -228,6 +229,7 @@ const syncHandlers: Record<string, (payload: any) => any> = {
|
||||
clearSessions: (p) => clearSessions(p.sessionId),
|
||||
hasSessionIndex: (p) => hasSessionIndex(p.sessionId),
|
||||
getSessionStats: (p) => getSessionStats(p.sessionId),
|
||||
getAllIndexStats: (p) => getAllIndexStats(p.sessionIds),
|
||||
updateSessionGapThreshold: (p) => updateSessionGapThreshold(p.sessionId, p.gapThreshold),
|
||||
getSessions: (p) => getSessions(p.sessionId),
|
||||
getSessionsByTimeRange: (p) => getSessionsByTimeRange(p.sessionId, p.startTs, p.endTs),
|
||||
|
||||
@@ -27,6 +27,7 @@ export {
|
||||
clearSessions,
|
||||
hasSessionIndex,
|
||||
getSessionStats,
|
||||
getAllIndexStats,
|
||||
updateSessionGapThreshold,
|
||||
getSessions,
|
||||
getSessionsByTimeRange,
|
||||
|
||||
@@ -82,6 +82,19 @@ export function getSessionStats(sessionId: string): SessionIndexStats {
|
||||
})
|
||||
}
|
||||
|
||||
export interface SessionIndexStatusItem {
|
||||
sessionId: string
|
||||
hasIndex: boolean
|
||||
sessionCount: number
|
||||
}
|
||||
|
||||
export function getAllIndexStats(sessionIds: string[]): SessionIndexStatusItem[] {
|
||||
return sessionIds.map((sessionId) => {
|
||||
const stats = getSessionStats(sessionId)
|
||||
return { sessionId, hasIndex: stats.hasIndex, sessionCount: stats.sessionCount }
|
||||
})
|
||||
}
|
||||
|
||||
export function updateSessionGapThreshold(sessionId: string, gapThreshold: number | null): void {
|
||||
withWritableAdapter(sessionId, (adapter) => coreUpdateSessionGapThreshold(adapter, gapThreshold))
|
||||
}
|
||||
|
||||
@@ -746,6 +746,14 @@ export async function getSessionStats(sessionId: string): Promise<SessionStats>
|
||||
return sendToWorker('getSessionStats', { sessionId })
|
||||
}
|
||||
|
||||
export async function getAllIndexStats(): Promise<
|
||||
Array<{ sessionId: string; hasIndex: boolean; sessionCount: number }>
|
||||
> {
|
||||
const sessions = await getAllSessions()
|
||||
const sessionIds = sessions.map((s: any) => s.id)
|
||||
return sendToWorker('getAllIndexStats', { sessionIds })
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新单个聊天的会话切分阈值
|
||||
*/
|
||||
|
||||
@@ -256,6 +256,13 @@ export const sessionApi = {
|
||||
return ipcRenderer.invoke('session:getStats', sessionId)
|
||||
},
|
||||
|
||||
/**
|
||||
* 批量获取所有会话的索引状态
|
||||
*/
|
||||
getAllIndexStats: (): Promise<Array<{ sessionId: string; hasIndex: boolean; sessionCount: number }>> => {
|
||||
return ipcRenderer.invoke('session:getAllIndexStats')
|
||||
},
|
||||
|
||||
/**
|
||||
* 清空会话索引
|
||||
*/
|
||||
|
||||
Vendored
+1
@@ -1128,6 +1128,7 @@ interface SessionApi {
|
||||
generateIncremental: (sessionId: string, gapThreshold?: number) => Promise<number>
|
||||
hasIndex: (sessionId: string) => Promise<boolean>
|
||||
getStats: (sessionId: string) => Promise<SessionStats>
|
||||
getAllIndexStats: () => Promise<Array<{ sessionId: string; hasIndex: boolean; sessionCount: number }>>
|
||||
clear: (sessionId: string) => Promise<boolean>
|
||||
updateGapThreshold: (sessionId: string, gapThreshold: number | null) => Promise<boolean>
|
||||
getSessions: (sessionId: string) => Promise<ChatSessionItem[]>
|
||||
|
||||
@@ -31,7 +31,9 @@ export {
|
||||
getFtsStatus,
|
||||
searchFts,
|
||||
rebuildFts,
|
||||
getAllIndexStats,
|
||||
} from './session-index-service'
|
||||
export type { SessionIndexStatusItem } from './session-index-service'
|
||||
|
||||
// Summary service
|
||||
export { generateSummary, generateAllSummaries } from './summary-service'
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
generateSessionIndex as coreGenerateSessionIndex,
|
||||
generateIncrementalSessionIndex as coreGenerateIncrementalSessionIndex,
|
||||
clearSessionIndex as coreClearSessionIndex,
|
||||
getSessionIndexStats,
|
||||
} from '@openchatlab/core'
|
||||
import { hasFtsTable, searchByFts, rebuildFtsIndex } from '../fts'
|
||||
import type { SessionRuntimeAdapter } from './adapters'
|
||||
@@ -51,3 +52,22 @@ export function rebuildFts(adapter: SessionRuntimeAdapter, sessionId: string) {
|
||||
const db = adapter.ensureWritable(sessionId)
|
||||
return rebuildFtsIndex(db)
|
||||
}
|
||||
|
||||
export interface SessionIndexStatusItem {
|
||||
sessionId: string
|
||||
hasIndex: boolean
|
||||
sessionCount: number
|
||||
}
|
||||
|
||||
export function getAllIndexStats(adapter: SessionRuntimeAdapter): SessionIndexStatusItem[] {
|
||||
const ids = adapter.listSessionIds()
|
||||
return ids.map((sessionId) => {
|
||||
try {
|
||||
const db = adapter.ensureReadonly(sessionId)
|
||||
const stats = getSessionIndexStats(db)
|
||||
return { sessionId, hasIndex: stats.hasIndex, sessionCount: stats.sessionCount }
|
||||
} catch {
|
||||
return { sessionId, hasIndex: false, sessionCount: 0 }
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -27,10 +27,8 @@ const props = defineProps<{
|
||||
const promptStore = usePromptStore()
|
||||
const llmStore = useLLMStore()
|
||||
const { aiGlobalSettings } = storeToRefs(promptStore)
|
||||
const { configs, defaultAssistantConfig, isLoading: isLoadingLLM } = storeToRefs(llmStore)
|
||||
const { defaultAssistantConfig, isLoading: isLoadingLLM } = storeToRefs(llmStore)
|
||||
|
||||
// 下拉菜单状态
|
||||
const isModelPopoverOpen = ref(false)
|
||||
const isOpeningLog = ref(false)
|
||||
|
||||
const agentPhaseText = computed(() => {
|
||||
@@ -121,20 +119,8 @@ function openChatSettings() {
|
||||
layoutStore.openSettings('ai', 'chat')
|
||||
}
|
||||
|
||||
// 切换 AI 模型配置
|
||||
async function switchModelConfig(configId: string) {
|
||||
const config = llmStore.configs.find((c) => c.id === configId)
|
||||
const success = await llmStore.setDefaultAssistantModel(configId, config?.model || '')
|
||||
if (success) {
|
||||
isModelPopoverOpen.value = false
|
||||
} else {
|
||||
toast.fail(t('ai.chat.statusBar.model.switchFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
function openModelSettings() {
|
||||
isModelPopoverOpen.value = false
|
||||
layoutStore.openSettings('ai', 'model')
|
||||
layoutStore.openSettings('ai', 'defaultModel')
|
||||
}
|
||||
|
||||
// 导出当前对话
|
||||
@@ -258,71 +244,21 @@ const thinkingLevelLabel = computed(() => {
|
||||
<div class="relative z-20 flex items-center justify-between">
|
||||
<!-- 左侧:模型切换器 -->
|
||||
<div class="flex items-center gap-1">
|
||||
<UPopover v-model:open="isModelPopoverOpen" :ui="{ content: 'z-[80] p-0' }">
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-300"
|
||||
:disabled="isLoadingLLM"
|
||||
>
|
||||
<UIcon name="i-heroicons-cpu-chip" class="h-3.5 w-3.5" />
|
||||
<span class="max-w-[120px] truncate">
|
||||
{{ defaultAssistantConfig?.name || t('ai.chat.statusBar.model.notConfigured') }}
|
||||
</span>
|
||||
<UIcon name="i-heroicons-chevron-down" class="h-3 w-3" />
|
||||
</button>
|
||||
<template #content>
|
||||
<div class="w-48 py-1">
|
||||
<div class="px-3 py-1.5 text-xs font-medium text-gray-400 dark:text-gray-500">
|
||||
{{ t('ai.chat.statusBar.model.title') }}
|
||||
</div>
|
||||
|
||||
<!-- 配置列表 -->
|
||||
<template v-if="configs.length > 0">
|
||||
<button
|
||||
v-for="config in configs"
|
||||
:key="config.id"
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
:class="[
|
||||
config.id === defaultAssistantConfig?.id
|
||||
? 'text-pink-600 dark:text-pink-400'
|
||||
: 'text-gray-700 dark:text-gray-300',
|
||||
]"
|
||||
@click="switchModelConfig(config.id)"
|
||||
>
|
||||
<UIcon
|
||||
:name="
|
||||
config.id === defaultAssistantConfig?.id ? 'i-heroicons-check-circle-solid' : 'i-heroicons-cpu-chip'
|
||||
"
|
||||
class="h-4 w-4 shrink-0"
|
||||
:class="[config.id === defaultAssistantConfig?.id ? 'text-pink-500' : 'text-gray-400']"
|
||||
/>
|
||||
<div class="flex flex-col truncate">
|
||||
<span class="truncate">{{ config.name }}</span>
|
||||
<span v-if="config.model" class="truncate text-[10px] text-gray-400 dark:text-gray-500">
|
||||
{{ llmStore.getModelById(config.provider, config.model)?.name || config.model }}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else class="px-3 py-2 text-sm text-gray-400 dark:text-gray-500">
|
||||
{{ t('ai.chat.statusBar.model.empty') }}
|
||||
</div>
|
||||
|
||||
<!-- 分隔线 -->
|
||||
<div class="my-1 border-t border-gray-200 dark:border-gray-700" />
|
||||
|
||||
<!-- 管理配置按钮 -->
|
||||
<button
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-300"
|
||||
@click="openModelSettings"
|
||||
>
|
||||
<UIcon name="i-heroicons-cog-6-tooth" class="h-4 w-4 shrink-0" />
|
||||
<span>{{ t('ai.chat.statusBar.model.manage') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</UPopover>
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-300"
|
||||
:disabled="isLoadingLLM"
|
||||
@click="openModelSettings"
|
||||
>
|
||||
<UIcon name="i-heroicons-cpu-chip" class="h-3.5 w-3.5" />
|
||||
<span class="max-w-[160px] truncate">
|
||||
{{
|
||||
defaultAssistantConfig?.model
|
||||
? llmStore.getModelById(defaultAssistantConfig.provider, defaultAssistantConfig.model)?.name ||
|
||||
defaultAssistantConfig.model
|
||||
: t('ai.chat.statusBar.model.notConfigured')
|
||||
}}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- 思考强度选择器(仅对 reasoning 模型显示) -->
|
||||
<UPopover v-if="showThinkingSelector" v-model:open="isThinkingPopoverOpen" :ui="{ content: 'z-[80] p-0' }">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* 会话索引管理区块
|
||||
* 配置会话索引阈值和批量生成功能
|
||||
*/
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import UITabs from '@/components/UI/Tabs.vue'
|
||||
import { useDataService, useSessionIndexService } from '@/services'
|
||||
@@ -72,35 +72,25 @@ function onSummaryStrategyChange(val: string | number) {
|
||||
patchUiConfig({ summary_strategy: val as SummaryStrategy })
|
||||
}
|
||||
|
||||
// 加载所有会话的索引状态
|
||||
// 加载所有会话的索引状态(单次批量请求)
|
||||
async function loadSessionIndexStatus() {
|
||||
isLoadingSessionStatus.value = true
|
||||
try {
|
||||
// 获取所有会话
|
||||
const sessions = await useDataService().getSessions()
|
||||
const [sessions, indexStats] = await Promise.all([
|
||||
useDataService().getSessions(),
|
||||
useSessionIndexService().getAllIndexStats(),
|
||||
])
|
||||
|
||||
// 获取每个会话的索引状态
|
||||
const statusList: SessionIndexStatus[] = []
|
||||
for (const session of sessions) {
|
||||
try {
|
||||
const stats = await useSessionIndexService().getStats(session.id)
|
||||
statusList.push({
|
||||
id: session.id,
|
||||
name: session.name,
|
||||
hasIndex: stats.hasIndex,
|
||||
sessionCount: stats.sessionCount,
|
||||
})
|
||||
} catch {
|
||||
statusList.push({
|
||||
id: session.id,
|
||||
name: session.name,
|
||||
hasIndex: false,
|
||||
sessionCount: 0,
|
||||
})
|
||||
const statsMap = new Map(indexStats.map((s) => [s.sessionId, s]))
|
||||
allSessionsStatus.value = sessions.map((session) => {
|
||||
const stats = statsMap.get(session.id)
|
||||
return {
|
||||
id: session.id,
|
||||
name: session.name,
|
||||
hasIndex: stats?.hasIndex ?? false,
|
||||
sessionCount: stats?.sessionCount ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
allSessionsStatus.value = statusList
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('加载会话索引状态失败:', error)
|
||||
} finally {
|
||||
@@ -189,16 +179,35 @@ async function batchRegenerateAll() {
|
||||
isBatchGenerating.value = false
|
||||
}
|
||||
|
||||
// 组件挂载时加载数据
|
||||
// 使用 IntersectionObserver 实现懒加载:仅当组件进入视口时才加载会话状态
|
||||
const sectionRef = ref<HTMLElement | null>(null)
|
||||
let observer: IntersectionObserver | null = null
|
||||
let hasLoadedStatus = false
|
||||
|
||||
onMounted(() => {
|
||||
loadSessionThreshold()
|
||||
summaryStrategy.value = getSummaryStrategy()
|
||||
loadSessionIndexStatus()
|
||||
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting && !hasLoadedStatus) {
|
||||
hasLoadedStatus = true
|
||||
loadSessionIndexStatus()
|
||||
observer?.disconnect()
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
)
|
||||
if (sectionRef.value) observer.observe(sectionRef.value)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
observer?.disconnect()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div ref="sectionRef" class="space-y-6">
|
||||
<!-- 会话索引配置 -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import type {
|
||||
SessionIndexAdapter,
|
||||
SessionStats,
|
||||
SessionIndexStatusItem,
|
||||
ChatSessionItem,
|
||||
SummaryResult,
|
||||
BatchSummaryResult,
|
||||
@@ -28,6 +29,10 @@ export class ElectronSessionIndexAdapter implements SessionIndexAdapter {
|
||||
return window.sessionApi.getStats(sessionId)
|
||||
}
|
||||
|
||||
async getAllIndexStats(): Promise<SessionIndexStatusItem[]> {
|
||||
return window.sessionApi.getAllIndexStats()
|
||||
}
|
||||
|
||||
clear(sessionId: string): Promise<boolean> {
|
||||
return window.sessionApi.clear(sessionId)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import type {
|
||||
SessionIndexAdapter,
|
||||
SessionStats,
|
||||
SessionIndexStatusItem,
|
||||
ChatSessionItem,
|
||||
SummaryResult,
|
||||
BatchSummaryResult,
|
||||
@@ -63,6 +64,12 @@ export class FetchSessionIndexAdapter implements SessionIndexAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
async getAllIndexStats(): Promise<SessionIndexStatusItem[]> {
|
||||
const resp = await fetch('/_web/sessions/index-stats')
|
||||
if (!resp.ok) return []
|
||||
return (await resp.json()) as SessionIndexStatusItem[]
|
||||
}
|
||||
|
||||
async clear(sessionId: string): Promise<boolean> {
|
||||
await fetch(`/_web/sessions/${sessionId}/clear-index`, { method: 'POST' })
|
||||
return true
|
||||
|
||||
@@ -37,11 +37,18 @@ export interface CanGenerateInfo {
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export interface SessionIndexStatusItem {
|
||||
sessionId: string
|
||||
hasIndex: boolean
|
||||
sessionCount: number
|
||||
}
|
||||
|
||||
export interface SessionIndexAdapter {
|
||||
generate(sessionId: string, gapThreshold?: number): Promise<number>
|
||||
generateIncremental(sessionId: string, gapThreshold?: number): Promise<number>
|
||||
hasIndex(sessionId: string): Promise<boolean>
|
||||
getStats(sessionId: string): Promise<SessionStats>
|
||||
getAllIndexStats(): Promise<SessionIndexStatusItem[]>
|
||||
clear(sessionId: string): Promise<boolean>
|
||||
updateGapThreshold(sessionId: string, gapThreshold: number | null): Promise<boolean>
|
||||
getSessions(sessionId: string): Promise<ChatSessionItem[]>
|
||||
|
||||
Reference in new issue
Block a user