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:
diguaandCursor committed 2026-05-31 10:28:29 +08:00
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 }
+9
View File
@@ -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 []
}
})
/**
* 清空会话索引
*/
+2
View File
@@ -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 })
}
/**
* 更新单个聊天的会话切分阈值
*/
+7
View File
@@ -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')
},
/**
* 清空会话索引
*/
+1
View File
@@ -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 }
}
})
}
+17 -81
View File
@@ -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
View File
@@ -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)
}
+7
View File
@@ -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
+7
View File
@@ -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[]>