diff --git a/desktop/src/api/hahaOpenAIOAuth.ts b/desktop/src/api/hahaOpenAIOAuth.ts new file mode 100644 index 00000000..2aefbea6 --- /dev/null +++ b/desktop/src/api/hahaOpenAIOAuth.ts @@ -0,0 +1,38 @@ +// desktop/src/api/hahaOpenAIOAuth.ts + +import { api, getBaseUrl } from './client' + +export type HahaOpenAIOAuthStatus = + | { loggedIn: false } + | { + loggedIn: true + expiresAt: number | null + email: string | null + accountId: string | null + } + +function currentServerPort(): number { + const port = new URL(getBaseUrl()).port + const parsed = Number.parseInt(port, 10) + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`Cannot determine server port from baseUrl: ${getBaseUrl()}`) + } + return parsed +} + +export const hahaOpenAIOAuthApi = { + start() { + return api.post<{ authorizeUrl: string; state: string }>( + '/api/haha-openai-oauth/start', + { serverPort: currentServerPort() }, + ) + }, + + status() { + return api.get('/api/haha-openai-oauth') + }, + + logout() { + return api.delete<{ ok: true }>('/api/haha-openai-oauth') + }, +} diff --git a/desktop/src/components/settings/ChatGPTOfficialLogin.tsx b/desktop/src/components/settings/ChatGPTOfficialLogin.tsx new file mode 100644 index 00000000..4bbe0082 --- /dev/null +++ b/desktop/src/components/settings/ChatGPTOfficialLogin.tsx @@ -0,0 +1,104 @@ +// desktop/src/components/settings/ChatGPTOfficialLogin.tsx + +import { useEffect } from 'react' +import { open as shellOpen } from '@tauri-apps/plugin-shell' +import { LogIn, LogOut } from 'lucide-react' +import { useHahaOpenAIOAuthStore } from '../../stores/hahaOpenAIOAuthStore' +import { useTranslation } from '../../i18n' + +export function ChatGPTOfficialLogin() { + const t = useTranslation() + const { + status, + isLoading, + error, + fetchStatus, + login, + logout, + startPolling, + stopPolling, + } = useHahaOpenAIOAuthStore() + + useEffect(() => { + void fetchStatus() + return () => stopPolling() + }, [fetchStatus, stopPolling]) + + const handleLogin = async () => { + try { + const { authorizeUrl } = await login() + try { + await shellOpen(authorizeUrl) + startPolling() + } catch (err) { + console.error('[ChatGPTOfficialLogin] shellOpen failed:', err) + useHahaOpenAIOAuthStore.setState({ + error: t('settings.chatgptOfficialLogin.openBrowserFailed'), + }) + } + } catch { + // store.login() errors are already captured into store.error + } + } + + if (status === null) { + if (error) { + return ( +
+ {t('settings.chatgptOfficialLogin.errorPrefix')}{error} +
+ ) + } + return ( +
+ {t('common.loading')} +
+ ) + } + + if (status.loggedIn) { + const accountLabel = status.email || status.accountId || t('settings.chatgptOfficialLogin.accountUnknown') + return ( +
+ + {t('settings.chatgptOfficialLogin.loggedInPrefix')} {accountLabel} + + +
+ ) + } + + return ( +
+
+ {t('settings.chatgptOfficialLogin.intro')} +
+ + {error && ( +
+ {t('settings.chatgptOfficialLogin.errorPrefix')}{error} +
+ )} +
+ ) +} diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index ad1f0d1f..0d57aa4a 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -271,6 +271,17 @@ export const en = { 'settings.claudeOfficialLogin.errorPrefix': 'Error: ', 'settings.claudeOfficialLogin.openBrowserFailed': 'Failed to open browser; please visit the authorization URL manually.', + // Settings > ChatGPT Official Login + 'settings.chatgptOfficialLogin.intro': 'Sign in with ChatGPT to use GPT models from desktop sessions.', + 'settings.chatgptOfficialLogin.loginButton': 'Sign in with ChatGPT', + 'settings.chatgptOfficialLogin.loginStarting': 'Starting sign-in...', + 'settings.chatgptOfficialLogin.logoutButton': 'Sign out', + 'settings.chatgptOfficialLogin.logoutProcessing': 'Signing out...', + 'settings.chatgptOfficialLogin.loggedInPrefix': 'Signed in as', + 'settings.chatgptOfficialLogin.accountUnknown': 'ChatGPT account', + 'settings.chatgptOfficialLogin.openBrowserFailed': 'Unable to open browser. Copy the authorization URL from server logs and open it manually.', + 'settings.chatgptOfficialLogin.errorPrefix': 'ChatGPT OAuth error: ', + // Settings > Providers 'settings.providers.title': 'Providers', 'settings.providers.description': 'Manage API providers for model access.', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 3bed1c29..392d090b 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -273,6 +273,17 @@ export const zh: Record = { 'settings.claudeOfficialLogin.errorPrefix': '错误:', 'settings.claudeOfficialLogin.openBrowserFailed': '无法打开浏览器,请手动访问授权链接。', + // Settings > ChatGPT Official Login + 'settings.chatgptOfficialLogin.intro': '登录 ChatGPT 后,就可以在桌面端会话里使用 GPT 模型。', + 'settings.chatgptOfficialLogin.loginButton': '登录 ChatGPT', + 'settings.chatgptOfficialLogin.loginStarting': '正在启动登录...', + 'settings.chatgptOfficialLogin.logoutButton': '退出登录', + 'settings.chatgptOfficialLogin.logoutProcessing': '正在退出...', + 'settings.chatgptOfficialLogin.loggedInPrefix': '已登录', + 'settings.chatgptOfficialLogin.accountUnknown': 'ChatGPT 账号', + 'settings.chatgptOfficialLogin.openBrowserFailed': '无法打开浏览器。请从服务端日志复制授权链接并手动打开。', + 'settings.chatgptOfficialLogin.errorPrefix': 'ChatGPT OAuth 错误:', + // Settings > Providers 'settings.providers.title': '服务商', 'settings.providers.description': '管理 API 服务商以访问模型。', diff --git a/desktop/src/stores/hahaOpenAIOAuthStore.test.ts b/desktop/src/stores/hahaOpenAIOAuthStore.test.ts new file mode 100644 index 00000000..5b6475dd --- /dev/null +++ b/desktop/src/stores/hahaOpenAIOAuthStore.test.ts @@ -0,0 +1,97 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { startMock, statusMock, logoutMock } = vi.hoisted(() => ({ + startMock: vi.fn(), + statusMock: vi.fn(), + logoutMock: vi.fn(), +})) + +vi.mock('../api/hahaOpenAIOAuth', () => ({ + hahaOpenAIOAuthApi: { + start: startMock, + status: statusMock, + logout: logoutMock, + }, +})) + +import { useHahaOpenAIOAuthStore } from './hahaOpenAIOAuthStore' + +const initialState = useHahaOpenAIOAuthStore.getState() + +describe('hahaOpenAIOAuthStore', () => { + beforeEach(() => { + vi.useFakeTimers() + startMock.mockReset() + statusMock.mockReset() + logoutMock.mockReset() + useHahaOpenAIOAuthStore.setState({ + ...initialState, + status: null, + isPolling: false, + isLoading: false, + error: null, + }) + }) + + afterEach(() => { + useHahaOpenAIOAuthStore.getState().stopPolling() + useHahaOpenAIOAuthStore.setState(initialState) + vi.useRealTimers() + }) + + it('login returns authorizeUrl without starting polling', async () => { + startMock.mockResolvedValue({ + authorizeUrl: 'http://localhost:3456/callback/openai?state=openai-state', + state: 'openai-state', + }) + + const result = await useHahaOpenAIOAuthStore.getState().login() + + expect(result.authorizeUrl).toContain('/callback/openai') + expect(useHahaOpenAIOAuthStore.getState().isPolling).toBe(false) + }) + + it('startPolling stops after OpenAI OAuth status becomes logged in', async () => { + statusMock + .mockResolvedValueOnce({ loggedIn: false }) + .mockResolvedValueOnce({ + loggedIn: true, + expiresAt: Date.now() + 60_000, + email: 'user@example.com', + accountId: 'acct_123', + }) + + useHahaOpenAIOAuthStore.getState().startPolling() + expect(useHahaOpenAIOAuthStore.getState().isPolling).toBe(true) + + await vi.advanceTimersByTimeAsync(2_000) + expect(useHahaOpenAIOAuthStore.getState().isPolling).toBe(true) + + await vi.advanceTimersByTimeAsync(2_000) + expect(useHahaOpenAIOAuthStore.getState().status).toMatchObject({ + loggedIn: true, + email: 'user@example.com', + accountId: 'acct_123', + }) + expect(useHahaOpenAIOAuthStore.getState().isPolling).toBe(false) + }) + + it('logout clears status and stops polling', async () => { + logoutMock.mockResolvedValue({ ok: true }) + useHahaOpenAIOAuthStore.setState({ + status: { + loggedIn: true, + expiresAt: Date.now() + 60_000, + email: 'user@example.com', + accountId: 'acct_123', + }, + }) + useHahaOpenAIOAuthStore.getState().startPolling() + + await useHahaOpenAIOAuthStore.getState().logout() + + expect(logoutMock).toHaveBeenCalledTimes(1) + expect(useHahaOpenAIOAuthStore.getState().status).toEqual({ loggedIn: false }) + expect(useHahaOpenAIOAuthStore.getState().isPolling).toBe(false) + }) +}) diff --git a/desktop/src/stores/hahaOpenAIOAuthStore.ts b/desktop/src/stores/hahaOpenAIOAuthStore.ts new file mode 100644 index 00000000..044ff33d --- /dev/null +++ b/desktop/src/stores/hahaOpenAIOAuthStore.ts @@ -0,0 +1,101 @@ +// desktop/src/stores/hahaOpenAIOAuthStore.ts + +import { create } from 'zustand' +import { + hahaOpenAIOAuthApi, + type HahaOpenAIOAuthStatus, +} from '../api/hahaOpenAIOAuth' + +const POLL_INTERVAL_MS = 2_000 + +type HahaOpenAIOAuthState = { + status: HahaOpenAIOAuthStatus | null + isPolling: boolean + isLoading: boolean + error: string | null + + fetchStatus: () => Promise + login: () => Promise<{ authorizeUrl: string }> + logout: () => Promise + startPolling: () => void + stopPolling: () => void +} + +export const useHahaOpenAIOAuthStore = create((set, get) => { + let pollTimer: ReturnType | null = null + + return { + status: null, + isPolling: false, + isLoading: false, + error: null, + + fetchStatus: async () => { + try { + const status = await hahaOpenAIOAuthApi.status() + set({ status, error: null }) + } catch (err) { + set({ error: err instanceof Error ? err.message : String(err) }) + } + }, + + login: async () => { + set({ isLoading: true, error: null }) + try { + const res = await hahaOpenAIOAuthApi.start() + set({ isLoading: false }) + return { authorizeUrl: res.authorizeUrl } + } catch (err) { + set({ + isLoading: false, + error: err instanceof Error ? err.message : String(err), + }) + throw err + } + }, + + logout: async () => { + get().stopPolling() + set({ isLoading: true, error: null }) + try { + await hahaOpenAIOAuthApi.logout() + set({ status: { loggedIn: false }, isLoading: false }) + } catch (err) { + set({ + isLoading: false, + error: err instanceof Error ? err.message : String(err), + }) + throw err + } + }, + + startPolling: () => { + if (pollTimer) return + set({ isPolling: true }) + + const scheduleNext = () => { + pollTimer = setTimeout(async () => { + pollTimer = null + await get().fetchStatus() + const cur = get().status + if (cur && cur.loggedIn) { + get().stopPolling() + return + } + if (get().isPolling) { + scheduleNext() + } + }, POLL_INTERVAL_MS) + } + scheduleNext() + }, + + stopPolling: () => { + if (pollTimer) { + clearTimeout(pollTimer) + pollTimer = null + } + set({ isPolling: false }) + }, + } +})