From 88ab0ae1723d08f13472effcf900434ef52e980d Mon Sep 17 00:00:00 2001 From: digua Date: Wed, 27 May 2026 21:45:56 +0800 Subject: [PATCH] fix(ai): correct apiKeySet detection and prevent stale key reuse - Fix apiKeySet reporting false positives for local no-key configs by resolving the actual key via resolveApiKey and excluding placeholders - Prevent editing a config from reusing an incompatible API key when provider or connection mode has changed - Pass summary strategy to single-session generation for consistency Co-authored-by: Cursor --- .../src/http/routes/ai-config-display.test.ts | 52 +++++++++++++++++++ apps/cli/src/http/routes/ai-config-display.ts | 21 ++++++++ apps/cli/src/http/routes/ai.ts | 7 ++- .../src/services/summary-service.ts | 5 +- .../common/ChatRecord/BatchSummaryModal.vue | 6 +-- .../common/ChatRecord/SessionTimeline.vue | 6 ++- .../ChatRecord/summaryGeneration.test.ts | 23 ++++++++ .../common/ChatRecord/summaryGeneration.ts | 18 +++++++ .../common/Settings/AI/apiKeyReuse.test.ts | 47 +++++++++++++++++ .../common/Settings/AI/apiKeyReuse.ts | 33 ++++++++++++ .../common/Settings/AI/useAIConfigForm.ts | 32 +++++++++--- 11 files changed, 232 insertions(+), 18 deletions(-) create mode 100644 apps/cli/src/http/routes/ai-config-display.test.ts create mode 100644 apps/cli/src/http/routes/ai-config-display.ts create mode 100644 src/components/common/ChatRecord/summaryGeneration.test.ts create mode 100644 src/components/common/ChatRecord/summaryGeneration.ts create mode 100644 src/components/common/Settings/AI/apiKeyReuse.test.ts create mode 100644 src/components/common/Settings/AI/apiKeyReuse.ts diff --git a/apps/cli/src/http/routes/ai-config-display.test.ts b/apps/cli/src/http/routes/ai-config-display.test.ts new file mode 100644 index 00000000..ae8affd9 --- /dev/null +++ b/apps/cli/src/http/routes/ai-config-display.test.ts @@ -0,0 +1,52 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { toLlmConfigDisplay } from './ai-config-display' + +describe('toLlmConfigDisplay', () => { + it('marks apiKeySet when provider fallback auth resolves a key', () => { + const result = toLlmConfigDisplay( + { + id: 'cfg-1', + name: 'OpenAI compatible', + provider: 'openai-compatible', + apiKey: '', + }, + (provider, authProfile) => (provider === 'openai-compatible' && !authProfile ? 'sk-provider-fallback' : '') + ) + + assert.equal(result.apiKey, '') + assert.equal(result.apiKeySet, true) + }) + + it('does not mark the local no-key placeholder as a real API key', () => { + const result = toLlmConfigDisplay( + { + id: 'cfg-2', + name: 'LAN Ollama', + provider: 'openai-compatible', + apiKey: '', + baseUrl: 'http://127.0.0.1:11434', + authProfile: 'lan-ollama', + }, + () => 'sk-no-key-required' + ) + + assert.equal(result.apiKey, '') + assert.equal(result.apiKeySet, false) + }) + + it('does not mark legacy plaintext apiKey as reusable', () => { + const result = toLlmConfigDisplay( + { + id: 'cfg-3', + name: 'Legacy OpenAI', + provider: 'openai', + apiKey: 'sk-legacy-plaintext', + }, + () => '' + ) + + assert.equal(result.apiKey, '') + assert.equal(result.apiKeySet, false) + }) +}) diff --git a/apps/cli/src/http/routes/ai-config-display.ts b/apps/cli/src/http/routes/ai-config-display.ts new file mode 100644 index 00000000..2399b63c --- /dev/null +++ b/apps/cli/src/http/routes/ai-config-display.ts @@ -0,0 +1,21 @@ +type LlmConfigRecord = Record + +type ResolveApiKey = (provider: string, authProfile?: string) => string | undefined +const NO_KEY_PLACEHOLDER = 'sk-no-key-required' + +function hasRealApiKey(apiKey: string): boolean { + return !!apiKey && apiKey !== NO_KEY_PLACEHOLDER +} + +export function toLlmConfigDisplay(config: LlmConfigRecord, resolveApiKey: ResolveApiKey): LlmConfigRecord { + const { apiKey: _rawApiKey, ...rest } = config + const provider = typeof config.provider === 'string' ? config.provider : '' + const authProfile = typeof config.authProfile === 'string' ? config.authProfile : undefined + const resolvedApiKey = provider ? resolveApiKey(provider, authProfile) : '' + + return { + ...rest, + apiKey: '', + apiKeySet: hasRealApiKey(resolvedApiKey || ''), + } +} diff --git a/apps/cli/src/http/routes/ai.ts b/apps/cli/src/http/routes/ai.ts index 11bbff83..15706f26 100644 --- a/apps/cli/src/http/routes/ai.ts +++ b/apps/cli/src/http/routes/ai.ts @@ -46,6 +46,8 @@ import { deleteCustomModel, } from '../../ai/custom-store' import { validateApiKey, fetchRemoteModels } from '../../ai/remote-api' +import { resolveApiKey } from '@openchatlab/config' +import { toLlmConfigDisplay } from './ai-config-display' function getAiDir(dbManager: DatabaseManager): string { const pathProvider = (dbManager as any)['pathProvider'] @@ -177,10 +179,7 @@ export function registerAiRoutes( try { const data = JSON.parse(fs.readFileSync(configPath, 'utf-8')) - const configs = (data.configs || []).map((c: Record) => { - const { apiKey: _k, ...rest } = c - return { ...rest, apiKey: '', apiKeySet: !!c.authProfile } - }) + const configs = (data.configs || []).map((c: Record) => toLlmConfigDisplay(c, resolveApiKey)) return { configs, defaultAssistant: data.defaultAssistant ?? null, diff --git a/packages/node-runtime/src/services/summary-service.ts b/packages/node-runtime/src/services/summary-service.ts index bb10f7d0..50dcb893 100644 --- a/packages/node-runtime/src/services/summary-service.ts +++ b/packages/node-runtime/src/services/summary-service.ts @@ -16,6 +16,7 @@ import { generateSessionSummary, checkSessionsCanGenerateSummary, type SummaryDeps, + type SummaryOptions, completeSimple, type PiTextContent, } from '../ai' @@ -67,7 +68,7 @@ export async function generateSummary( sessionId: string, chatSessionId: number, serviceDeps: SummaryServiceDeps, - options?: { locale?: string; forceRegenerate?: boolean } + options?: SummaryOptions ) { const llmConfig = serviceDeps.getLlmConfig() if (!llmConfig) { @@ -83,7 +84,7 @@ export async function generateAllSummaries( adapter: SessionRuntimeAdapter, sessionId: string, serviceDeps: SummaryServiceDeps, - options?: { locale?: string; forceRegenerate?: boolean } + options?: SummaryOptions ) { const llmConfig = serviceDeps.getLlmConfig() if (!llmConfig) { diff --git a/src/components/common/ChatRecord/BatchSummaryModal.vue b/src/components/common/ChatRecord/BatchSummaryModal.vue index c5785e0b..4bad7f4b 100644 --- a/src/components/common/ChatRecord/BatchSummaryModal.vue +++ b/src/components/common/ChatRecord/BatchSummaryModal.vue @@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n' import { useDebounceFn } from '@vueuse/core' import { useSessionIndexService } from '@/services' import { getSummaryStrategy } from '@/composables/useUiConfig' +import { stopBatchSummaryGeneration } from './summaryGeneration' const props = defineProps<{ open: boolean @@ -330,10 +331,9 @@ async function startGenerate() { } } -// 停止生成:立即更新 UI 状态,后台请求自然结束不影响 +// 停止生成:保持生成锁定,等待当前循环进入 finally 后统一释放。 function stopGenerate() { - shouldStop.value = true - isGenerating.value = false + stopBatchSummaryGeneration(shouldStop) } // 关闭弹窗 diff --git a/src/components/common/ChatRecord/SessionTimeline.vue b/src/components/common/ChatRecord/SessionTimeline.vue index b2a4a7fe..de8c5a1f 100644 --- a/src/components/common/ChatRecord/SessionTimeline.vue +++ b/src/components/common/ChatRecord/SessionTimeline.vue @@ -8,7 +8,9 @@ import { useI18n } from 'vue-i18n' import { useToast } from '@/composables/useToast' import { useVirtualizer } from '@tanstack/vue-virtual' import { useSessionIndexService, type ChatSessionItem } from '@/services' +import { getSummaryStrategy } from '@/composables/useUiConfig' import BatchSummaryModal from './BatchSummaryModal.vue' +import { buildGenerateSummaryArgs } from './summaryGeneration' // 扁平化列表项类型 type FlatListItem = @@ -229,7 +231,9 @@ async function generateSummary(session: ChatSessionItem, event: Event) { try { console.log('[SessionTimeline] 调用 IPC...') - const result = await useSessionIndexService().generateSummary(props.sessionId, session.id, locale.value) + const result = await useSessionIndexService().generateSummary( + ...buildGenerateSummaryArgs(props.sessionId, session.id, locale.value, getSummaryStrategy()) + ) console.log('[SessionTimeline] IPC 返回:', result) if (result.success && result.summary) { diff --git a/src/components/common/ChatRecord/summaryGeneration.test.ts b/src/components/common/ChatRecord/summaryGeneration.test.ts new file mode 100644 index 00000000..1502f4c2 --- /dev/null +++ b/src/components/common/ChatRecord/summaryGeneration.test.ts @@ -0,0 +1,23 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { buildGenerateSummaryArgs, stopBatchSummaryGeneration } from './summaryGeneration' + +describe('stopBatchSummaryGeneration', () => { + it('keeps generation locked while the active loop is still exiting', () => { + const isGenerating = { value: true } + const shouldStop = { value: false } + + stopBatchSummaryGeneration(shouldStop) + + assert.equal(shouldStop.value, true) + assert.equal(isGenerating.value, true) + }) +}) + +describe('buildGenerateSummaryArgs', () => { + it('passes summary strategy to single-session generation', () => { + const args = buildGenerateSummaryArgs('db-1', 12, 'zh-CN', 'brief') + + assert.deepEqual(args, ['db-1', 12, 'zh-CN', false, 'brief']) + }) +}) diff --git a/src/components/common/ChatRecord/summaryGeneration.ts b/src/components/common/ChatRecord/summaryGeneration.ts new file mode 100644 index 00000000..9b119a14 --- /dev/null +++ b/src/components/common/ChatRecord/summaryGeneration.ts @@ -0,0 +1,18 @@ +import type { SummaryStrategy } from '@/composables/useUiConfig' + +export interface MutableBooleanRef { + value: boolean +} + +export function stopBatchSummaryGeneration(shouldStop: MutableBooleanRef): void { + shouldStop.value = true +} + +export function buildGenerateSummaryArgs( + dbSessionId: string, + chatSessionId: number, + locale: string, + strategy: SummaryStrategy +): [string, number, string, false, SummaryStrategy] { + return [dbSessionId, chatSessionId, locale, false, strategy] +} diff --git a/src/components/common/Settings/AI/apiKeyReuse.test.ts b/src/components/common/Settings/AI/apiKeyReuse.test.ts new file mode 100644 index 00000000..3fe80d2a --- /dev/null +++ b/src/components/common/Settings/AI/apiKeyReuse.test.ts @@ -0,0 +1,47 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { canReuseExistingApiKey } from './apiKeyReuse' + +describe('canReuseExistingApiKey', () => { + it('requires a new key after changing provider', () => { + const result = canReuseExistingApiKey({ + mode: 'edit', + existingApiKeySet: true, + hasNewApiKey: false, + originalProvider: 'openai', + currentProvider: 'anthropic', + originalConnectionMode: 'preset', + currentConnectionMode: 'preset', + }) + + assert.equal(result, false) + }) + + it('allows reusing an existing key when provider and connection mode are unchanged', () => { + const result = canReuseExistingApiKey({ + mode: 'edit', + existingApiKeySet: true, + hasNewApiKey: false, + originalProvider: 'openai', + currentProvider: 'openai', + originalConnectionMode: 'preset', + currentConnectionMode: 'preset', + }) + + assert.equal(result, true) + }) + + it('requires a new key after changing connection mode', () => { + const result = canReuseExistingApiKey({ + mode: 'edit', + existingApiKeySet: true, + hasNewApiKey: false, + originalProvider: 'openai-compatible', + currentProvider: 'openai', + originalConnectionMode: 'openai-compat', + currentConnectionMode: 'preset', + }) + + assert.equal(result, false) + }) +}) diff --git a/src/components/common/Settings/AI/apiKeyReuse.ts b/src/components/common/Settings/AI/apiKeyReuse.ts new file mode 100644 index 00000000..bb09b996 --- /dev/null +++ b/src/components/common/Settings/AI/apiKeyReuse.ts @@ -0,0 +1,33 @@ +export type ConnectionMode = 'preset' | 'local' | 'openai-compat' + +export interface ApiKeyReuseInput { + mode: 'add' | 'edit' + existingApiKeySet?: boolean + hasNewApiKey: boolean + originalProvider?: string + currentProvider: string + originalConnectionMode?: ConnectionMode + currentConnectionMode: ConnectionMode +} + +export function getConnectionModeForConfig(config: { + provider: string + apiKeySet: boolean + baseUrl?: string +}): ConnectionMode { + const isCompat = config.provider === 'openai-compatible' || config.provider.startsWith('custom:') + if (!isCompat) return 'preset' + + const looksLocal = !config.apiKeySet || (config.baseUrl?.includes('localhost') ?? false) + return looksLocal ? 'local' : 'openai-compat' +} + +export function canReuseExistingApiKey(input: ApiKeyReuseInput): boolean { + return ( + input.mode === 'edit' && + !!input.existingApiKeySet && + !input.hasNewApiKey && + input.originalProvider === input.currentProvider && + input.originalConnectionMode === input.currentConnectionMode + ) +} diff --git a/src/components/common/Settings/AI/useAIConfigForm.ts b/src/components/common/Settings/AI/useAIConfigForm.ts index 2b3b3137..d01e3c70 100644 --- a/src/components/common/Settings/AI/useAIConfigForm.ts +++ b/src/components/common/Settings/AI/useAIConfigForm.ts @@ -2,6 +2,7 @@ import { ref, computed, watch, type Ref } from 'vue' import { useI18n } from 'vue-i18n' import { useSettingsStore } from '@/stores/settings' import { useLLMStore } from '@/stores/llm' +import { canReuseExistingApiKey, getConnectionModeForConfig, type ConnectionMode } from './apiKeyReuse' // ==================== 类型 ==================== @@ -37,8 +38,6 @@ export interface Provider { models: Array<{ id: string; name: string; description?: string }> } -export type ConnectionMode = 'preset' | 'local' | 'openai-compat' - // ==================== Composable ==================== const CHINA_MARKET_PROVIDERS = ['doubao', 'siliconflow'] @@ -165,7 +164,17 @@ export function useAIConfigForm(props: { const canSave = computed(() => { const { provider, apiKey, baseUrl, model } = formData.value const isEdit = props.mode.value === 'edit' - const existingKeySet = isEdit && props.config.value?.apiKeySet + const existingKeySet = + isEdit && + canReuseExistingApiKey({ + mode: props.mode.value, + existingApiKeySet: props.config.value?.apiKeySet, + hasNewApiKey: !!apiKey.trim(), + originalProvider: props.config.value?.provider, + currentProvider: provider, + originalConnectionMode: props.config.value ? getConnectionModeForConfig(props.config.value) : undefined, + currentConnectionMode: connectionMode.value, + }) if (isLocalMode.value) { return baseUrl.trim() && model.trim() @@ -263,8 +272,7 @@ export function useAIConfigForm(props: { const hasModelInCatalog = !!(config.model && llmStore.getModelById(config.provider, config.model)) if (isCompat) { - const looksLocal = !config.apiKeySet || (config.baseUrl?.includes('localhost') ?? false) - connectionMode.value = looksLocal ? 'local' : 'openai-compat' + connectionMode.value = getConnectionModeForConfig(config) if (config.customModels && config.customModels.length > 0) { compatModels.value = [...config.customModels] } else { @@ -665,8 +673,8 @@ export function useAIConfigForm(props: { customModels: persistCustomModels, } - if (formData.value.apiKey.trim()) { - updates.apiKey = formData.value.apiKey.trim() + if (formData.value.apiKey.trim() || isLocalMode.value) { + updates.apiKey = finalApiKey } const result = await window.llmApi.updateConfig(props.config.value!.id, updates) @@ -693,7 +701,15 @@ export function useAIConfigForm(props: { } const hasNewApiKey = !!formData.value.apiKey.trim() - const isEditWithExistingKey = props.mode.value === 'edit' && props.config.value?.apiKeySet && !hasNewApiKey + const isEditWithExistingKey = canReuseExistingApiKey({ + mode: props.mode.value, + existingApiKeySet: props.config.value?.apiKeySet, + hasNewApiKey, + originalProvider: props.config.value?.provider, + currentProvider: formData.value.provider, + originalConnectionMode: props.config.value ? getConnectionModeForConfig(props.config.value) : undefined, + currentConnectionMode: connectionMode.value, + }) if (isEditWithExistingKey) { return doSave() }