feat: refactor OpenAI authentication to use provider credential store

- Removed OpenAI OAuth handlers and replaced them with provider credential handlers.
- Introduced ProviderCredentialStore to manage API keys and OAuth credentials.
- Migrated legacy OAuth credentials to the new provider credential structure.
- Updated IPC communication to reflect changes in credential management.
- Adjusted frontend components to utilize new credential API methods.
- Added tests for provider credential store functionality and migration logic.
This commit is contained in:
adnaan
2026-08-28 17:55:14 +08:00
parent c31efdb179
commit 1f81240245
19 changed files with 414 additions and 187 deletions
+2 -4
View File
@@ -21,7 +21,7 @@ import { registerDebugHandlers } from './debug' // 调试
import { registerHealthCheckHandlers } from './healthCheck' // 健康检查
import { registerRemoteShellHandlers } from './remoteShell' // 远程 Shell / SFTP
import { registerSkillsHandlers } from './skills' // Skills
import { registerOpenAIAuthHandlers } from './openaiAuth' // OpenAI OAuth
import { registerProviderCredentialHandlers } from './providerCredentials'
import { registerSessionStorageHandlers } from './sessionStorage'
import { registerFormatterHandlers } from './formatter'
import { registerSystemPrivilegeHandlers } from './systemPrivilege'
@@ -44,7 +44,6 @@ export interface IPCContext {
createWindow: (isEmpty?: boolean) => BrowserWindow
/** 根据 key 路由到正确的 store */
resolveStore: (key: string) => Store
credentialsStore: Store
preferencesStore: Store
workspaceMetaStore: Store
bootstrapStore: Store
@@ -133,8 +132,7 @@ export function registerAllHandlers(context: IPCContext) {
// Skills
registerSkillsHandlers()
// OpenAI OAuth
registerOpenAIAuthHandlers()
registerProviderCredentialHandlers()
registerSessionStorageHandlers({
getWindowWorkspace: context.getWindowWorkspace,
@@ -1,17 +1,11 @@
import { safeIpcHandle } from './safeHandle'
import { ProviderCredentialStore } from '../services/credentials/ProviderCredentialStore'
import { OpenAIAuthService } from '../services/openai/OpenAIAuthService'
import { OpenAIUsageStore } from '../services/openai/OpenAIUsageStore'
import { logger } from '@shared/utils/Logger'
const CHATGPT_RESPONSES_URL = 'https://chatgpt.com/backend-api/codex/responses'
/**
* Refresh the usage snapshot by issuing a deliberately invalid request.
*
* The ChatGPT backend has no quota endpoint, but it attaches `x-codex-*` usage
* headers to *every* response including 4xx ones. Sending an empty input is
* rejected before any tokens are billed, which makes it a cheap probe.
*/
async function refreshUsage(): Promise<boolean> {
const token = await OpenAIAuthService.getValidToken()
if (!token) return false
@@ -34,7 +28,7 @@ async function refreshUsage(): Promise<boolean> {
})
return OpenAIUsageStore.captureFromHeaders(response.headers)
} catch (error) {
logger.ipc.warn('[OpenAIAuth] Usage refresh failed', {
logger.ipc.warn('[Credentials] OAuth usage refresh failed', {
error: error instanceof Error ? error.message : String(error),
})
return false
@@ -43,33 +37,31 @@ async function refreshUsage(): Promise<boolean> {
}
}
export function registerOpenAIAuthHandlers(): void {
safeIpcHandle('openai:auth:login', async () => {
export function registerProviderCredentialHandlers(): void {
safeIpcHandle('credentials:api-keys:get', async () => ProviderCredentialStore.getApiKeys())
safeIpcHandle('credentials:api-keys:replace', async (_event, apiKeys: Record<string, string>) => {
ProviderCredentialStore.replaceApiKeys(apiKeys)
return true
})
safeIpcHandle('credentials:oauth:login', async () => {
const tokens = await OpenAIAuthService.login()
return { success: true, accountID: tokens.accountID }
})
safeIpcHandle('openai:auth:logout', async () => {
safeIpcHandle('credentials:oauth:logout', async () => {
await OpenAIAuthService.logout()
OpenAIUsageStore.clear()
return { success: true }
})
safeIpcHandle('openai:auth:usage', async (_event, options?: { refresh?: boolean }) => {
// Serve the cached snapshot unless the caller explicitly wants a probe;
// every real request keeps it current for free.
if (options?.refresh || !OpenAIUsageStore.get()) {
await refreshUsage()
}
safeIpcHandle('credentials:oauth:usage', async (_event, options?: { refresh?: boolean }) => {
if (options?.refresh || !OpenAIUsageStore.get()) await refreshUsage()
return { usage: OpenAIUsageStore.get() }
})
safeIpcHandle('openai:auth:status', async () => {
return OpenAIAuthService.getStatus()
})
safeIpcHandle('openai:auth:token', async () => {
const token = await OpenAIAuthService.getValidToken()
return { token }
})
safeIpcHandle('credentials:oauth:status', async () => OpenAIAuthService.getStatus())
safeIpcHandle('credentials:oauth:token', async () => ({
token: await OpenAIAuthService.getValidToken(),
}))
}
+2 -1
View File
@@ -24,6 +24,7 @@ import { registerWebviewGuards } from './security/webviewGuard'
import { cleanupFileWatcher } from './security/fileWatcher'
import { collectLaunchFiles, flushLaunchFilesToWindow, queueLaunchFiles } from './services/fileAssociation'
import { createScopedStore, getBootstrapStore, getUserConfigDir } from './services/configPath'
import { ProviderCredentialStore } from './services/credentials/ProviderCredentialStore'
import { createFileLogWriter } from './services/fileLogWriter'
import {
shutdownWindowController,
@@ -88,6 +89,7 @@ function resolveStore(_key: string): Store<Record<string, unknown>> {
async function initStores() {
bootstrapStore = getBootstrapStore()
configStore = createScopedStore('config', bootstrapStore)
await ProviderCredentialStore.initialize(configStore)
}
// ==========================================
@@ -739,7 +741,6 @@ async function initializeModules(firstWin: BrowserWindow) {
getMainWindow,
createWindow,
resolveStore,
credentialsStore: configStore,
preferencesStore: configStore,
workspaceMetaStore: configStore,
bootstrapStore,
+18 -12
View File
@@ -519,11 +519,14 @@ export interface ElectronAPI {
}>
mcpRefreshCapabilities: (serverId: string) => Promise<{ success: boolean; error?: string }>
// OpenAI OAuth
openaiAuthLogin: () => Promise<{ success: boolean; accountID?: string; error?: string }>
openaiAuthLogout: () => Promise<{ success: boolean; error?: string }>
openaiAuthStatus: () => Promise<{ loggedIn: boolean; accountID?: string }>
openaiAuthToken: () => Promise<{ token: string | null }>
// Provider credentials
credentialsGetApiKeys: () => Promise<Record<string, string>>
credentialsReplaceApiKeys: (apiKeys: Record<string, string>) => Promise<boolean>
credentialsOAuthLogin: () => Promise<{ success: boolean; accountID?: string; error?: string }>
credentialsOAuthLogout: () => Promise<{ success: boolean; error?: string }>
credentialsOAuthStatus: () => Promise<{ loggedIn: boolean; accountID?: string }>
credentialsOAuthToken: () => Promise<{ token: string | null }>
credentialsOAuthUsage: (options?: { refresh?: boolean }) => Promise<{ usage: unknown }>
mcpGetConfigPaths: () => Promise<{ success: boolean; paths?: { user: string; workspace: string[] }; error?: string }>
mcpReloadConfig: () => Promise<{ success: boolean; error?: string }>
mcpDiscoverExternalConfigs: () => Promise<{ success: boolean; configs?: any[]; error?: string }>
@@ -901,13 +904,16 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke('mcp:getPrompt', request),
mcpRefreshCapabilities: (serverId: string) => ipcRenderer.invoke('mcp:refreshCapabilities', serverId),
// OpenAI OAuth
openaiAuthLogin: () => ipcRenderer.invoke('openai:auth:login'),
openaiAuthLogout: () => ipcRenderer.invoke('openai:auth:logout'),
openaiAuthStatus: () => ipcRenderer.invoke('openai:auth:status'),
openaiAuthToken: () => ipcRenderer.invoke('openai:auth:token'),
openaiAuthUsage: (options?: { refresh?: boolean }) =>
ipcRenderer.invoke('openai:auth:usage', options),
// Provider credentials
credentialsGetApiKeys: () => ipcRenderer.invoke('credentials:api-keys:get'),
credentialsReplaceApiKeys: (apiKeys: Record<string, string>) =>
ipcRenderer.invoke('credentials:api-keys:replace', apiKeys),
credentialsOAuthLogin: () => ipcRenderer.invoke('credentials:oauth:login'),
credentialsOAuthLogout: () => ipcRenderer.invoke('credentials:oauth:logout'),
credentialsOAuthStatus: () => ipcRenderer.invoke('credentials:oauth:status'),
credentialsOAuthToken: () => ipcRenderer.invoke('credentials:oauth:token'),
credentialsOAuthUsage: (options?: { refresh?: boolean }) =>
ipcRenderer.invoke('credentials:oauth:usage', options),
mcpGetConfigPaths: () => ipcRenderer.invoke('mcp:getConfigPaths'),
mcpReloadConfig: () => ipcRenderer.invoke('mcp:reloadConfig'),
mcpDiscoverExternalConfigs: () => ipcRenderer.invoke('mcp:discoverExternalConfigs'),
@@ -0,0 +1,145 @@
import * as fs from 'fs'
import * as path from 'path'
import { app } from 'electron'
import type Store from 'electron-store'
import { logger } from '@shared/utils/Logger'
export interface OAuthCredential {
accessToken: string
refreshToken: string
expiresAt: number
accountID?: string
email?: string
planType?: string
}
type ProviderCredential =
| { type: 'api-key'; apiKey: string }
| ({ type: 'oauth' } & OAuthCredential)
type ProviderCredentials = Record<string, ProviderCredential>
const STORE_KEY = 'providerCredentials'
const OPENAI_OAUTH_PROVIDER = 'openai-oauth'
let credentialStore: Store<Record<string, unknown>> | null = null
let credentials: ProviderCredentials = {}
function requireStore(): Store<Record<string, unknown>> {
if (!credentialStore) throw new Error('Provider credential store is not initialized')
return credentialStore
}
function persist(): void {
requireStore().set(STORE_KEY, credentials)
}
function isOAuthCredential(value: unknown): value is ProviderCredential & { type: 'oauth' } {
if (!value || typeof value !== 'object') return false
const record = value as Record<string, unknown>
return record.type === 'oauth'
&& typeof record.accessToken === 'string'
&& typeof record.refreshToken === 'string'
&& typeof record.expiresAt === 'number'
}
function isApiKeyCredential(value: unknown): value is ProviderCredential & { type: 'api-key' } {
if (!value || typeof value !== 'object') return false
const record = value as Record<string, unknown>
return record.type === 'api-key' && typeof record.apiKey === 'string' && record.apiKey.length > 0
}
function readStoredCredentials(store: Store<Record<string, unknown>>): ProviderCredentials {
const stored = store.get(STORE_KEY)
if (!stored || typeof stored !== 'object' || Array.isArray(stored)) return {}
return Object.fromEntries(
Object.entries(stored).filter(([, credential]) =>
isApiKeyCredential(credential) || isOAuthCredential(credential)
)
) as ProviderCredentials
}
async function migrateLegacyCredentials(store: Store<Record<string, unknown>>): Promise<void> {
let changed = false
const appSettings = store.get('app-settings') as Record<string, unknown> | undefined
const providerConfigs = appSettings?.providerConfigs as Record<string, Record<string, unknown>> | undefined
if (providerConfigs) {
const nextProviderConfigs: Record<string, Record<string, unknown>> = {}
for (const [providerId, config] of Object.entries(providerConfigs)) {
const { apiKey, ...rest } = config
nextProviderConfigs[providerId] = rest
if (typeof apiKey === 'string' && apiKey && !credentials[providerId]) {
credentials[providerId] = { type: 'api-key', apiKey }
changed = true
}
}
if (Object.values(providerConfigs).some(config => typeof config.apiKey === 'string')) {
store.set('app-settings', { ...appSettings, providerConfigs: nextProviderConfigs })
}
}
const legacyOAuthPath = path.join(app.getPath('userData'), 'openai-auth.json')
try {
const parsed = JSON.parse(await fs.promises.readFile(legacyOAuthPath, 'utf8'))
const legacyCredential = { ...parsed, type: 'oauth' }
if (!credentials[OPENAI_OAUTH_PROVIDER] && isOAuthCredential(legacyCredential)) {
credentials[OPENAI_OAUTH_PROVIDER] = legacyCredential
changed = true
}
await fs.promises.unlink(legacyOAuthPath)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
logger.store.warn('[Credentials] Legacy OAuth migration failed:', error)
}
}
if (changed) persist()
}
export const ProviderCredentialStore = {
async initialize(store: Store<Record<string, unknown>>): Promise<void> {
credentialStore = store
credentials = readStoredCredentials(store)
await migrateLegacyCredentials(store)
},
getApiKeys(): Record<string, string> {
const result: Record<string, string> = {}
for (const [providerId, credential] of Object.entries(credentials)) {
if (credential.type === 'api-key') result[providerId] = credential.apiKey
}
return result
},
replaceApiKeys(apiKeys: Record<string, string>): void {
const next = Object.fromEntries(
Object.entries(credentials).filter(([, credential]) => credential.type !== 'api-key')
) as ProviderCredentials
for (const [providerId, apiKey] of Object.entries(apiKeys)) {
if (apiKey) next[providerId] = { type: 'api-key', apiKey }
}
credentials = next
persist()
},
getOAuth(providerId: string): OAuthCredential | null {
const credential = credentials[providerId]
if (!credential || credential.type !== 'oauth') return null
const { type: _type, ...oauth } = credential
return { ...oauth }
},
setOAuth(providerId: string, credential: OAuthCredential): void {
credentials = { ...credentials, [providerId]: { type: 'oauth', ...credential } }
persist()
},
clear(providerId: string): void {
if (!credentials[providerId]) return
const { [providerId]: _removed, ...rest } = credentials
credentials = rest
persist()
},
}
+13 -10
View File
@@ -1,6 +1,9 @@
import { createServer } from 'node:http'
import { shell } from 'electron'
import { OpenAIAuthStore, type OpenAITokens } from './OpenAIAuthStore'
import {
ProviderCredentialStore,
type OAuthCredential,
} from '../credentials/ProviderCredentialStore'
import { logger } from '@shared/utils/Logger'
const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'
@@ -129,7 +132,7 @@ async function refreshTokens(refreshToken: string): Promise<TokenResponse> {
return (await res.json()) as TokenResponse
}
function tokensFromResponse(res: TokenResponse): OpenAITokens {
function tokensFromResponse(res: TokenResponse): OAuthCredential {
const info = extractAccountInfo(res)
return {
accessToken: res.access_token,
@@ -146,7 +149,7 @@ export const OpenAIAuthService = {
* Start the browser-based PKCE OAuth flow.
* Opens the user's browser, waits for the callback, exchanges the code, and persists tokens.
*/
async login(): Promise<OpenAITokens> {
async login(): Promise<OAuthCredential> {
const pkce = await generatePKCE()
const state = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url')
@@ -230,25 +233,25 @@ export const OpenAIAuthService = {
const tokenRes = await exchangeCode(code, pkce)
const tokens = tokensFromResponse(tokenRes)
await OpenAIAuthStore.set(tokens)
ProviderCredentialStore.setOAuth('openai-oauth', tokens)
logger.security.info('[OpenAIAuth] Login successful', { accountID: tokens.accountID })
return tokens
},
async logout(): Promise<void> {
await OpenAIAuthStore.clear()
ProviderCredentialStore.clear('openai-oauth')
},
async getValidToken(): Promise<string | null> {
const tokens = await OpenAIAuthStore.get()
const tokens = ProviderCredentialStore.getOAuth('openai-oauth')
if (!tokens) return null
if (await OpenAIAuthStore.isExpired()) {
if (tokens.expiresAt < Date.now() + 60_000) {
try {
const refreshed = await refreshTokens(tokens.refreshToken)
const next = tokensFromResponse(refreshed)
// Refresh responses may omit id_token — keep the profile claims we already have.
await OpenAIAuthStore.set({
ProviderCredentialStore.setOAuth('openai-oauth', {
...next,
accountID: next.accountID ?? tokens.accountID,
email: next.email ?? tokens.email,
@@ -257,7 +260,7 @@ export const OpenAIAuthService = {
return next.accessToken
} catch (err) {
logger.security.warn('[OpenAIAuth] Token refresh failed, clearing tokens', err)
await OpenAIAuthStore.clear()
ProviderCredentialStore.clear('openai-oauth')
return null
}
}
@@ -272,7 +275,7 @@ export const OpenAIAuthService = {
planType?: string
expiresAt?: number
}> {
const tokens = await OpenAIAuthStore.get()
const tokens = ProviderCredentialStore.getOAuth('openai-oauth')
if (!tokens) return { loggedIn: false }
// Re-derive from the access token so sessions stored by an earlier version
// (which missed the namespaced claims) report their plan without re-login.
@@ -1,67 +0,0 @@
import * as fs from 'fs'
import * as path from 'path'
import { app } from 'electron'
import { logger } from '@shared/utils/Logger'
export interface OpenAITokens {
accessToken: string
refreshToken: string
expiresAt: number
accountID?: string
email?: string
/** ChatGPT subscription tier, e.g. `plus` / `pro` / `team`. */
planType?: string
}
const getFilePath = () => path.join(app.getPath('userData'), 'openai-auth.json')
let writeLock: Promise<void> = Promise.resolve()
function withLock<T>(fn: () => Promise<T>): Promise<T> {
const prev = writeLock
let resolve!: () => void
writeLock = new Promise(r => { resolve = r })
return prev.then(fn).finally(() => resolve())
}
async function atomicWrite(filepath: string, data: string): Promise<void> {
const tmp = `${filepath}.${process.pid}.tmp`
await fs.promises.writeFile(tmp, data, { mode: 0o600 })
await fs.promises.rename(tmp, filepath)
}
async function read(): Promise<OpenAITokens | null> {
try {
const content = await fs.promises.readFile(getFilePath(), 'utf-8')
return JSON.parse(content)
} catch {
return null
}
}
export const OpenAIAuthStore = {
get: read,
set: (tokens: OpenAITokens) =>
withLock(async () => {
try {
await atomicWrite(getFilePath(), JSON.stringify(tokens, null, 2))
} catch (err) {
logger.store.error('[OpenAIAuthStore] Failed to save:', err)
}
}),
clear: () =>
withLock(async () => {
try {
await fs.promises.unlink(getFilePath())
} catch {
// already gone
}
}),
isExpired: async (): Promise<boolean> => {
const tokens = await read()
if (!tokens) return true
return tokens.expiresAt < Date.now() + 60_000
},
}
+18 -21
View File
@@ -83,6 +83,7 @@ export class AgentClass {
threadId?: string
requestId?: string
planTaskId?: string
contextItems?: import('../types').ContextItem[]
/** 该次执行是否为子代理(隐藏线程)。会剔除 task/ask_user 等工具。 */
isSubAgent?: boolean
}
@@ -98,33 +99,23 @@ export class AgentClass {
throw new Error(`Thread ${threadId} is already running`)
}
// 验证凭证:OAuth provider 没有 API Key,其 access token 由主进程
// resolveAuthForConfig() 解析,这里只校验是否已登录。
if (getBuiltinProvider(config.provider)?.auth.type === 'oauth') {
const status = await window.electronAPI?.openaiAuthStatus?.().catch(() => null)
if (!status?.loggedIn) {
this.showError(translateAgentText('oauthSignInWarning'))
throw new Error('Not signed in to ChatGPT')
}
} else if (!config.apiKey) {
this.showError(translateAgentText('apiKeyWarning'))
throw new Error('Missing API key')
}
const abortController = new AbortController()
const requestId = executionOptions?.requestId || crypto.randomUUID()
const contextItems = threadId
const contextItems = executionOptions?.contextItems ?? (threadId
? (store.threads[threadId]?.contextItems || [])
: (store.getCurrentThread()?.contextItems || [])
: (store.getCurrentThread()?.contextItems || []))
let persistSuspended = false
let taskRegistered = false
let assistantId = ''
try {
suspendAgentStorageWrites()
persistSuspended = true
// 1. 【性能关键】批量初始化消息环境(合并用户消息、助手气泡、上下文清理)
const { assistantId, threadId: preparedThreadId } = store.prepareExecution(userMessage, contextItems, executionOptions?.threadId)
const prepared = store.prepareExecution(userMessage, contextItems, executionOptions?.threadId)
assistantId = prepared.assistantId
const preparedThreadId = prepared.threadId
threadId = preparedThreadId
if (!threadId) {
@@ -161,6 +152,12 @@ export class AgentClass {
// 【核心优化】立即让出主线程,确保用户消息和助手气泡瞬间在 UI 渲染
await new Promise(resolve => setTimeout(resolve, 0))
// Local validation happens after the optimistic commit. OAuth resolution
// stays centralized in the main-process credential service.
if (getBuiltinProvider(config.provider)?.auth.type !== 'oauth' && !config.apiKey) {
throw new Error(translateAgentText('apiKeyWarning'))
}
// 3. 提取提到的 Skills
const mentionedSkills = contextItems
.filter(item => item.type === 'Skill')
@@ -235,7 +232,7 @@ export class AgentClass {
// 统一错误处理
const appError = AppError.fromError(error)
logger.agent.error('[Agent] Error:', appError.toJSON())
this.showError(formatErrorMessage(appError))
this.showError(formatErrorMessage(appError), assistantId, threadId || undefined)
throw error
} finally {
if (persistSuspended) {
@@ -491,15 +488,15 @@ export class AgentClass {
/**
* 显示错误消息给用户
*/
private showError(message: string): void {
private showError(message: string, assistantId?: string, threadId?: string): void {
const store = useAgentStore.getState()
const id = store.addAssistantMessage()
const id = assistantId || store.addAssistantMessage('', threadId)
store.addSystemAlertPart(id, {
alertType: 'error',
title: translateAgentText('error'),
message,
})
store.finalizeAssistant(id)
}, threadId)
store.finalizeAssistant(id, threadId)
}
/**
@@ -1518,11 +1518,11 @@ export const createMessageSlice: StateCreator<
},
// 添加上下文项
addContextItem: (item) => {
let threadId = get().currentThreadId
addContextItem: (item, targetThreadId) => {
let threadId = targetThreadId || get().currentThreadId
if (!threadId || !get().threads[threadId]) {
threadId = get().createThread()
threadId = get().createThread({ activate: !targetThreadId })
}
if (!threadId) return
@@ -1557,8 +1557,8 @@ export const createMessageSlice: StateCreator<
},
// 移除上下文项
removeContextItem: (index) => {
const threadId = get().currentThreadId
removeContextItem: (index, targetThreadId) => {
const threadId = targetThreadId || get().currentThreadId
if (!threadId) return
set(state => {
@@ -1578,8 +1578,8 @@ export const createMessageSlice: StateCreator<
},
// 清空上下文项
clearContextItems: () => {
const threadId = get().currentThreadId
clearContextItems: (targetThreadId) => {
const threadId = targetThreadId || get().currentThreadId
if (!threadId) return
set(state => {
+8 -13
View File
@@ -931,14 +931,6 @@ export default function ChatPanel() {
}
if (targetThreadId) {
const currentContextItems = useAgentStore.getState().threads[targetThreadId]?.contextItems || []
const hasDifferentContext =
currentContextItems.length !== contextItemsForSend.length ||
currentContextItems.some((item, index) => item !== contextItemsForSend[index])
if (hasDifferentContext) {
useAgentStore.getState().clearContextItems(targetThreadId)
contextItemsForSend.forEach((item) => useAgentStore.getState().addContextItem(item, targetThreadId))
}
if (explicitServer.lastActiveServer) {
useAgentStore.getState().setLastActiveServer(explicitServer.lastActiveServer, targetThreadId)
}
@@ -957,10 +949,13 @@ export default function ChatPanel() {
return
}
// 发送消息后主动滚到底部,确保用户消息和即将出现的 AI 回复可见
// 不依赖 followOutput 的时序,因为发送瞬间 isStreaming 还是 false
scrollToBottom('smooth')
await sendMessage(userMessage, { mode: effectiveMode, threadId: targetThreadId })
const sendPromise = sendMessage(userMessage, {
mode: effectiveMode,
threadId: targetThreadId,
contextItems: contextItemsForSend,
})
requestAnimationFrame(() => scrollToBottom('auto'))
await sendPromise
}, [input, images, isStreaming, sendMessage, contextFilePath, selectedCode, workspacePath, setChatMode, scrollToBottom, visibleContextItems, chatMode, toast, language, currentThreadId, createThread])
// 编辑消息
@@ -1057,7 +1052,7 @@ export default function ChatPanel() {
const [oauthSignedIn, setOauthSignedIn] = useState(false)
useEffect(() => {
window.electronAPI?.openaiAuthStatus?.()
window.electronAPI?.credentialsOAuthStatus?.()
.then(s => setOauthSignedIn(s?.loggedIn ?? false))
.catch(() => setOauthSignedIn(false))
}, [])
@@ -33,7 +33,7 @@ export default function ModelSelector({ className = '', alignLeft = false }: Mod
// OAuth providers have no API key — availability depends on sign-in state.
const [oauthSignedIn, setOauthSignedIn] = useState(false)
useEffect(() => {
window.electronAPI.openaiAuthStatus()
window.electronAPI.credentialsOAuthStatus()
.then(s => setOauthSignedIn(s.loggedIn))
.catch(() => setOauthSignedIn(false))
}, [isOpen])
@@ -212,7 +212,7 @@ function reconcileCustomHeaderDrafts(
}
type ChatGPTUsage = Awaited<
ReturnType<typeof window.electronAPI.openaiAuthUsage>
ReturnType<typeof window.electronAPI.credentialsOAuthUsage>
>['usage']
/** Human-readable label for a rolling quota window, e.g. 43800 min -> "30 天". */
@@ -370,14 +370,14 @@ const OAuthSignInPanel = memo(function OAuthSignInPanel({
} | null>(null)
const [busy, setBusy] = useState(false)
const [usage, setUsage] = useState<
Awaited<ReturnType<typeof window.electronAPI.openaiAuthUsage>>['usage']
Awaited<ReturnType<typeof window.electronAPI.credentialsOAuthUsage>>['usage']
>(null)
const [usageBusy, setUsageBusy] = useState(false)
const loadUsage = useCallback(async (refreshFromServer = false) => {
setUsageBusy(true)
try {
const result = await window.electronAPI.openaiAuthUsage({ refresh: refreshFromServer })
const result = await window.electronAPI.credentialsOAuthUsage({ refresh: refreshFromServer })
setUsage(result?.usage ?? null)
} catch {
setUsage(null)
@@ -388,7 +388,7 @@ const OAuthSignInPanel = memo(function OAuthSignInPanel({
const refresh = useCallback(async () => {
try {
const next = await window.electronAPI.openaiAuthStatus()
const next = await window.electronAPI.credentialsOAuthStatus()
setStatus(next)
if (next?.loggedIn) void loadUsage()
else setUsage(null)
@@ -404,7 +404,7 @@ const OAuthSignInPanel = memo(function OAuthSignInPanel({
const handleLogin = async () => {
setBusy(true)
try {
const result = await window.electronAPI.openaiAuthLogin()
const result = await window.electronAPI.credentialsOAuthLogin()
if (result.success) {
toast.success(language === 'zh' ? 'ChatGPT 登录成功' : 'Signed in to ChatGPT')
await refresh()
@@ -421,7 +421,7 @@ const OAuthSignInPanel = memo(function OAuthSignInPanel({
const handleLogout = async () => {
setBusy(true)
try {
await window.electronAPI.openaiAuthLogout()
await window.electronAPI.credentialsOAuthLogout()
toast.success(language === 'zh' ? '已退出登录' : 'Signed out')
await refresh()
} finally {
@@ -1029,7 +1029,7 @@ export function ProviderSettings({
const [oauthSignedIn, setOauthSignedIn] = useState(false)
const refreshOAuthStatus = useCallback(async () => {
try {
const status = await window.electronAPI.openaiAuthStatus()
const status = await window.electronAPI.credentialsOAuthStatus()
setOauthSignedIn(status.loggedIn)
} catch {
setOauthSignedIn(false)
+6 -2
View File
@@ -94,7 +94,11 @@ export function useAgentCommands() {
planPhase,
}
const sendMessage = useCallback(async (content: MessageContent, options?: { mode?: WorkMode, threadId?: string }) => {
const sendMessage = useCallback(async (content: MessageContent, options?: {
mode?: WorkMode
threadId?: string
contextItems?: import('@/renderer/agent/types').ContextItem[]
}) => {
const {
llmConfig: config,
workspacePath: currentWorkspacePath,
@@ -124,7 +128,7 @@ export function useAgentCommands() {
promptTemplateId: currentPromptTemplateId,
planPhase: targetMode === 'plan' ? currentPlanPhase : undefined,
},
{ threadId: options?.threadId }
{ threadId: options?.threadId, contextItems: options?.contextItems }
)
}, [])
+5 -1
View File
@@ -52,7 +52,11 @@ export function useMessageQueueConsumer() {
setMode(next.chatMode)
// 发送消息
try {
await sendMessage(next.content, { mode: next.chatMode, threadId: next.targetThreadId })
await sendMessage(next.content, {
mode: next.chatMode,
threadId: next.targetThreadId,
contextItems: next.contextItems,
})
} catch {
// 发送失败不阻塞,错误已由 Agent 内部处理
}
+17 -10
View File
@@ -8,8 +8,9 @@
*
* Architecture:
* - `llmConfig` persistence only stores active model selection + generation behavior
* - provider/network fields (apiKey/baseUrl/timeout/headers/protocol) live in `providerConfigs`
* - runtime `LLMConfig` is reconstructed from persisted llmConfig + providerConfigs + defaults
* - provider/network fields live in `providerConfigs`
* - credentials are persisted by the main-process provider credential store
* - runtime `LLMConfig` is reconstructed from persisted settings + credentials + defaults
*/
import { api } from '@/renderer/services/electronAPI'
@@ -89,8 +90,6 @@ function cleanProviderConfig(
const resolvedProtocol = config.protocol ?? builtinDef?.protocol
const defaultOpenAIProfile = getDefaultOpenAICompatibilityProfile(providerId, resolvedProtocol)
if (config.apiKey) cleaned.apiKey = config.apiKey
if (config.baseUrl && config.baseUrl !== builtinDef?.baseUrl) {
cleaned.baseUrl = config.baseUrl
}
@@ -128,13 +127,12 @@ function cleanProviderConfig(
function mergeProviderConfigs(
saved: Record<string, ProviderConfig> | undefined,
apiKeys: Record<string, string> = {},
): Record<string, ProviderModelConfig> {
const defaults = SETTINGS.providerConfigs.default
if (!saved) return { ...defaults }
const merged: Record<string, ProviderModelConfig> = { ...defaults }
for (const [id, config] of Object.entries(saved)) {
for (const [id, config] of Object.entries(saved || {})) {
if (isBuiltinProvider(id)) {
const resolved = { ...defaults[id], ...config }
merged[id] = {
@@ -158,6 +156,10 @@ function mergeProviderConfigs(
}
}
for (const [id, apiKey] of Object.entries(apiKeys)) {
merged[id] = { ...(merged[id] || {}), apiKey }
}
return merged
}
@@ -191,17 +193,18 @@ class SettingsService {
await this.migrateLegacyCacheOnce()
try {
const [appSettings, editorConfig, securitySettings] = await Promise.all([
const [appSettings, editorConfig, securitySettings, apiKeys] = await Promise.all([
api.settings.get(STORAGE_KEYS.APP),
api.settings.get(STORAGE_KEYS.EDITOR),
api.settings.get(STORAGE_KEYS.SECURITY),
window.electronAPI.credentialsGetApiKeys(),
])
const merged = this.merge({
...(appSettings as object || {}),
editorConfig,
securitySettings,
})
}, apiKeys)
this.cache = merged
return merged
@@ -266,8 +269,10 @@ class SettingsService {
async save(settings: SettingsState): Promise<void> {
try {
const cleanedProviderConfigs: Record<string, ProviderConfig> = {}
const apiKeys: Record<string, string> = {}
for (const [id, config] of Object.entries(settings.providerConfigs)) {
if (config.apiKey) apiKeys[id] = config.apiKey
const cleaned = cleanProviderConfig(id, config, id === settings.llmConfig.provider)
if (cleaned) cleanedProviderConfigs[id] = cleaned as ProviderConfig
}
@@ -277,6 +282,7 @@ class SettingsService {
this.cache = settings
await Promise.all([
window.electronAPI.credentialsReplaceApiKeys(apiKeys),
api.settings.set(STORAGE_KEYS.APP, appSettings),
api.settings.set(STORAGE_KEYS.EDITOR, settings.editorConfig),
api.settings.set(STORAGE_KEYS.SECURITY, settings.securitySettings),
@@ -314,10 +320,11 @@ class SettingsService {
}
}
private merge(saved: Record<string, unknown>): SettingsState {
private merge(saved: Record<string, unknown>, apiKeys: Record<string, string> = {}): SettingsState {
const defaults = getAllDefaults()
const providerConfigs = mergeProviderConfigs(
saved.providerConfigs as Record<string, ProviderConfig> | undefined,
apiKeys,
)
const llmConfig = resolveRuntimeLLMConfig(
saved.llmConfig as Partial<PersistedLLMConfig> | undefined,
+8 -6
View File
@@ -695,12 +695,14 @@ export interface ElectronAPI {
mcpGetPrompt: (request: McpPromptGetRequest) => Promise<McpPromptGetResult>
mcpRefreshCapabilities: (serverId: string) => Promise<{ success: boolean; error?: string }>
// OpenAI OAuth
openaiAuthLogin: () => Promise<{ success: boolean; accountID?: string; error?: string }>
openaiAuthLogout: () => Promise<{ success: boolean; error?: string }>
openaiAuthStatus: () => Promise<{ loggedIn: boolean; accountID?: string; email?: string; planType?: string; expiresAt?: number }>
openaiAuthToken: () => Promise<{ token: string | null }>
openaiAuthUsage: (options?: { refresh?: boolean }) => Promise<{
// Provider credentials
credentialsGetApiKeys: () => Promise<Record<string, string>>
credentialsReplaceApiKeys: (apiKeys: Record<string, string>) => Promise<boolean>
credentialsOAuthLogin: () => Promise<{ success: boolean; accountID?: string; error?: string }>
credentialsOAuthLogout: () => Promise<{ success: boolean; error?: string }>
credentialsOAuthStatus: () => Promise<{ loggedIn: boolean; accountID?: string; email?: string; planType?: string; expiresAt?: number }>
credentialsOAuthToken: () => Promise<{ token: string | null }>
credentialsOAuthUsage: (options?: { refresh?: boolean }) => Promise<{
usage: {
planType?: string
activeLimit?: string
@@ -0,0 +1,45 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@services/agentSessionRepository', () => ({
agentSessionRepository: {
deleteThread: vi.fn(() => Promise.resolve()),
stageSnapshot: vi.fn(),
flush: vi.fn(() => Promise.resolve()),
clear: vi.fn(() => Promise.resolve()),
},
}))
import { Agent } from '@renderer/agent/core/Agent'
import { useAgentStore } from '@renderer/agent/store/AgentStore'
describe('Agent optimistic send', () => {
beforeEach(() => {
useAgentStore.setState({
threads: {},
currentThreadId: null,
threadMessageVersions: {},
branches: {},
activeBranchId: {},
})
})
it('commits the user message before asynchronous validation finishes', async () => {
const promise = Agent.send(
'visible immediately',
{ provider: 'openai', model: 'gpt-5', apiKey: '' } as never,
null,
'agent',
)
const thread = useAgentStore.getState().getCurrentThread()
expect(thread?.messages.map(message => message.role)).toEqual(['user', 'assistant'])
expect(thread?.messages[0].role === 'user' ? thread.messages[0].content : undefined)
.toBe('visible immediately')
await expect(promise).rejects.toThrow()
const assistant = useAgentStore.getState().getCurrentThread()?.messages[1]
expect(assistant?.role).toBe('assistant')
expect(assistant && 'isStreaming' in assistant ? assistant.isStreaming : true).toBe(false)
})
})
@@ -118,4 +118,17 @@ describe('stored message history cap', () => {
// 2 turns => 4 messages, far below the cap.
expect(useAgentStore.getState().threads[threadId].messages).toHaveLength(4)
})
it('updates context on the explicitly targeted thread', () => {
const store = useAgentStore.getState()
const activeId = store.createThread({ activate: true, mode: 'agent', origin: 'user' })
const targetId = store.createThread({ activate: false, mode: 'plan', origin: 'user' })
store.addContextItem({ type: 'Web' }, targetId)
expect(useAgentStore.getState().threads[activeId].contextItems).toEqual([])
expect(useAgentStore.getState().threads[targetId].contextItems).toEqual([{ type: 'Web' }])
store.clearContextItems(targetId)
expect(useAgentStore.getState().threads[targetId].contextItems).toEqual([])
})
})
@@ -0,0 +1,82 @@
import * as fs from 'fs'
import * as os from 'os'
import * as path from 'path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const electronState = vi.hoisted(() => ({ userDataPath: '' }))
vi.mock('electron', () => ({
app: { getPath: () => electronState.userDataPath },
}))
import { ProviderCredentialStore } from '@main/services/credentials/ProviderCredentialStore'
class MemoryStore {
constructor(private readonly values: Record<string, unknown> = {}) {}
get(key: string): unknown { return this.values[key] }
set(key: string, value: unknown): void { this.values[key] = value }
}
describe('ProviderCredentialStore', () => {
let tempDir: string
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'adnify-credentials-'))
electronState.userDataPath = tempDir
})
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true })
})
it('migrates API keys and OAuth into one provider credential map and removes old storage', async () => {
const store = new MemoryStore({
'app-settings': {
providerConfigs: {
openai: { apiKey: 'sk-test', model: 'gpt-5' },
},
},
})
const oauth = {
accessToken: 'access',
refreshToken: 'refresh',
expiresAt: Date.now() + 60_000,
accountID: 'account',
}
const legacyPath = path.join(tempDir, 'openai-auth.json')
fs.writeFileSync(legacyPath, JSON.stringify(oauth))
await ProviderCredentialStore.initialize(store as never)
expect(ProviderCredentialStore.getApiKeys()).toEqual({ openai: 'sk-test' })
expect(ProviderCredentialStore.getOAuth('openai-oauth')).toEqual(oauth)
expect(fs.existsSync(legacyPath)).toBe(false)
expect(store.get('app-settings')).toEqual({
providerConfigs: { openai: { model: 'gpt-5' } },
})
})
it('replaces API keys without touching OAuth credentials', async () => {
const store = new MemoryStore({
providerCredentials: {
anthropic: { type: 'api-key', apiKey: 'old' },
'openai-oauth': {
type: 'oauth',
accessToken: 'access',
refreshToken: 'refresh',
expiresAt: 123,
},
},
})
await ProviderCredentialStore.initialize(store as never)
ProviderCredentialStore.replaceApiKeys({ openai: 'new' })
expect(ProviderCredentialStore.getApiKeys()).toEqual({ openai: 'new' })
expect(ProviderCredentialStore.getOAuth('openai-oauth')).toEqual({
accessToken: 'access',
refreshToken: 'refresh',
expiresAt: 123,
})
})
})