mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 18:34:51 +08:00
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 <cursoragent@cursor.com>
This commit is contained in:
1 parent
a9b8599417
commit
88ab0ae172
11 files changed
+232
-18
No files matched your search
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
type LlmConfigRecord = Record<string, unknown>
|
||||
|
||||
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 || ''),
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>) => {
|
||||
const { apiKey: _k, ...rest } = c
|
||||
return { ...rest, apiKey: '', apiKeySet: !!c.authProfile }
|
||||
})
|
||||
const configs = (data.configs || []).map((c: Record<string, unknown>) => toLlmConfigDisplay(c, resolveApiKey))
|
||||
return {
|
||||
configs,
|
||||
defaultAssistant: data.defaultAssistant ?? null,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// 关闭弹窗
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user