diff --git a/public/locales/en.json b/public/locales/en.json index 263786b..6b8c87d 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -115,8 +115,12 @@ "about.app_description": "An AI-powered learning assistant designed to make learning intuitive and engaging.", "about.developed_by": "Developed by CJackHwang", "about.github_repo": "GitHub Repository", - "about.license": "License (AGPL-3.0)" - , + "about.license": "License (AGPL-3.0)", "toast.input_required": "Please enter content", - "toast.translation_fallback": "Could not load translations for {0}. Falling back to English." + "toast.translation_fallback": "Could not load translations for {0}. Falling back to English.", + "settings.api_protocol_mode": "Protocol mode", + "settings.api_protocol_mode_auto": "Auto (prefer Responses)", + "settings.api_protocol_mode_responses": "Responses API", + "settings.api_protocol_mode_chat": "Chat Completions API", + "settings.api_protocol_mode_info": "Auto mode will try /v1/responses first, then fallback to /v1/chat/completions when unsupported." } diff --git a/public/locales/ja.json b/public/locales/ja.json index 447b5d2..0960e2b 100644 --- a/public/locales/ja.json +++ b/public/locales/ja.json @@ -112,8 +112,12 @@ "about.app_description": "学習を直感的で魅力的にするために設計されたAI学習アシスタント。", "about.developed_by": "開発者 CJackHwang", "about.github_repo": "GitHubリポジトリ", - "about.license": "ライセンス (AGPL-3.0)" - , + "about.license": "ライセンス (AGPL-3.0)", "toast.input_required": "内容を入力してください", - "toast.translation_fallback": "{0} の翻訳を読み込めませんでした。英語にフォールバックします。" + "toast.translation_fallback": "{0} の翻訳を読み込めませんでした。英語にフォールバックします。", + "settings.api_protocol_mode": "プロトコルモード", + "settings.api_protocol_mode_auto": "自動(Responses優先)", + "settings.api_protocol_mode_responses": "Responses API", + "settings.api_protocol_mode_chat": "Chat Completions API", + "settings.api_protocol_mode_info": "自動モードではまず /v1/responses を試し、未対応なら /v1/chat/completions にフォールバックします。" } diff --git a/public/locales/zh-CN.json b/public/locales/zh-CN.json index 1b355e2..e1e0248 100644 --- a/public/locales/zh-CN.json +++ b/public/locales/zh-CN.json @@ -112,8 +112,12 @@ "about.app_description": "一个旨在使学习变得直观和有趣的AI学习助手。", "about.developed_by": "由 CJackHwang 开发", "about.github_repo": "GitHub 仓库", - "about.license": "许可证 (AGPL-3.0)" - , + "about.license": "许可证 (AGPL-3.0)", "toast.input_required": "请输入内容", - "toast.translation_fallback": "无法加载 {0} 翻译,已回退为英语。" + "toast.translation_fallback": "无法加载 {0} 翻译,已回退为英语。", + "settings.api_protocol_mode": "协议模式", + "settings.api_protocol_mode_auto": "自动(优先 Responses)", + "settings.api_protocol_mode_responses": "Responses API", + "settings.api_protocol_mode_chat": "Chat Completions API", + "settings.api_protocol_mode_info": "自动模式会先尝试 /v1/responses,不支持时回退到 /v1/chat/completions。" } diff --git a/src/app/providers/useSettings.tsx b/src/app/providers/useSettings.tsx index b5c60e8..a0985e6 100644 --- a/src/app/providers/useSettings.tsx +++ b/src/app/providers/useSettings.tsx @@ -24,6 +24,8 @@ interface SettingsContextType { setOpenAiModel: (model: string) => void; openAiBaseUrl: string; setOpenAiBaseUrl: (url: string) => void; + openAiApiMode: 'auto' | 'chat_completions' | 'responses'; + setOpenAiApiMode: (mode: 'auto' | 'chat_completions' | 'responses') => void; language: Language; setLanguage: (language: Language) => void; resetSettings: () => void; @@ -55,6 +57,7 @@ export const SettingsProvider: React.FC<{ children: React.ReactNode }> = ({ chil const [openAiApiKey, setOpenAiApiKeyState] = useState(''); const [openAiModel, setOpenAiModelState] = useState(''); const [openAiBaseUrl, setOpenAiBaseUrlState] = useState(''); + const [openAiApiMode, setOpenAiApiModeState] = useState<'auto' | 'chat_completions' | 'responses'>('auto'); const [language, setLanguageState] = useState(Language.EN); // Render immediately; theme flash is mitigated by early index.html script @@ -89,6 +92,7 @@ export const SettingsProvider: React.FC<{ children: React.ReactNode }> = ({ chil const savedOpenAiApiKey = await getSetting('openAiApiKey') || ''; const savedOpenAiModel = await getSetting('openAiModel') || 'gpt-4o'; // Default OpenAI model const savedOpenAiBaseUrl = await getSetting('openAiBaseUrl') || 'https://api.openai.com/v1'; + const savedOpenAiApiMode = await getSetting<'auto' | 'chat_completions' | 'responses'>('openAiApiMode') || 'auto'; const savedLanguage = await getSetting('language') || Language.EN; @@ -100,6 +104,7 @@ export const SettingsProvider: React.FC<{ children: React.ReactNode }> = ({ chil setOpenAiApiKeyState(savedOpenAiApiKey); setOpenAiModelState(savedOpenAiModel); setOpenAiBaseUrlState(savedOpenAiBaseUrl); + setOpenAiApiModeState(savedOpenAiApiMode); setLanguageState(savedLanguage); } catch (error) { const appError = handleError(error, 'db'); @@ -239,6 +244,13 @@ export const SettingsProvider: React.FC<{ children: React.ReactNode }> = ({ chil showToast(appError.userMessage, 'error'); }); }; + const setOpenAiApiMode = (mode: 'auto' | 'chat_completions' | 'responses') => { + setOpenAiApiModeState(mode); + setSetting('openAiApiMode', mode).catch(error => { + const appError = handleError(error, 'settings'); + showToast(appError.userMessage, 'error'); + }); + }; const setLanguage = (lang: Language) => { setLanguageState(lang); @@ -258,6 +270,7 @@ export const SettingsProvider: React.FC<{ children: React.ReactNode }> = ({ chil setOpenAiApiKey(''); setOpenAiModel('gpt-4o'); setOpenAiBaseUrl('https://api.openai.com/v1'); + setOpenAiApiMode('auto'); setLanguage(Language.EN); }; @@ -282,6 +295,8 @@ export const SettingsProvider: React.FC<{ children: React.ReactNode }> = ({ chil setOpenAiModel, openAiBaseUrl, setOpenAiBaseUrl, + openAiApiMode, + setOpenAiApiMode, language, setLanguage, resetSettings @@ -296,6 +311,7 @@ export const SettingsProvider: React.FC<{ children: React.ReactNode }> = ({ chil openAiApiKey, openAiModel, openAiBaseUrl, + openAiApiMode, language ]); diff --git a/src/features/chat/model/useChatStream.tsx b/src/features/chat/model/useChatStream.tsx index 56bb0ae..33a7500 100644 --- a/src/features/chat/model/useChatStream.tsx +++ b/src/features/chat/model/useChatStream.tsx @@ -8,7 +8,7 @@ import { parseStreamedText } from '@shared/lib/textHelpers'; export const useChatStream = () => { const [isThinking, setIsThinking] = useState(false); const [streamedAiMessage, setStreamedAiMessage] = useState(null); - const { effectiveSystemPrompt, selectedApiProvider, geminiApiKey, geminiModel, openAiApiKey, openAiModel, openAiBaseUrl } = useSettings(); + const { effectiveSystemPrompt, selectedApiProvider, geminiApiKey, geminiModel, openAiApiKey, openAiModel, openAiBaseUrl, openAiApiMode } = useSettings(); const controllerRef = useRef(null); if (!controllerRef.current) controllerRef.current = new StreamController(); @@ -48,6 +48,7 @@ export const useChatStream = () => { openAiApiKey, openAiModel, openAiBaseUrl, + openAiApiMode, }, (chunk) => { streamedText += chunk; @@ -115,7 +116,8 @@ export const useChatStream = () => { geminiModel, openAiApiKey, openAiModel, - openAiBaseUrl + openAiBaseUrl, + openAiApiMode ]); return { diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 1e0499e..761611f 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -31,6 +31,8 @@ const SettingsPage: React.FC = () => { setOpenAiModel, openAiBaseUrl, setOpenAiBaseUrl, + openAiApiMode, + setOpenAiApiMode, language, setLanguage, resetSettings @@ -259,6 +261,21 @@ const SettingsPage: React.FC = () => { />

{t('settings.api_base_url_info')}

+
+ + setOpenAiApiMode(mode as 'auto' | 'chat_completions' | 'responses')} + /> +

{t('settings.api_protocol_mode_info')}

+
diff --git a/src/shared/services/apiService.ts b/src/shared/services/apiService.ts index 9a2cad9..61231df 100644 --- a/src/shared/services/apiService.ts +++ b/src/shared/services/apiService.ts @@ -16,6 +16,7 @@ export interface StreamChatConfig { openAiApiKey: string; openAiModel: string; openAiBaseUrl: string; + openAiApiMode: 'auto' | 'chat_completions' | 'responses'; } export const streamChatResponse = async ( @@ -35,6 +36,7 @@ export const streamChatResponse = async ( openAiApiKey, openAiModel, openAiBaseUrl, + openAiApiMode, } = config; let fullText = ''; @@ -76,6 +78,7 @@ export const streamChatResponse = async ( apiKey: openAiApiKey, model: openAiModel, baseUrl: openAiBaseUrl, + apiMode: openAiApiMode, tools: hasTools ? (toolDefinitions as OpenAIFunctionDefinition[]) : undefined, }; await service.stream( diff --git a/src/shared/services/providers/OpenAIChatService.ts b/src/shared/services/providers/OpenAIChatService.ts index fe0a7d2..d9b22c0 100644 --- a/src/shared/services/providers/OpenAIChatService.ts +++ b/src/shared/services/providers/OpenAIChatService.ts @@ -29,10 +29,65 @@ export interface OpenAIServiceConfig { apiKey: string; model: string; baseUrl: string; + apiMode?: 'auto' | 'chat_completions' | 'responses'; tools?: OpenAIFunctionDefinition[]; } export class OpenAIChatService implements IChatService { + private normalizeBaseUrl(baseUrl?: string): string { + return (baseUrl || 'https://api.openai.com/v1').replace(/\/+$/, ''); + } + + private buildChatCompletionsPayload(model: string, messages: OpenAIMessage[], tools?: OpenAIFunctionDefinition[], stream: boolean = true) { + return { + model, + messages, + stream, + ...(tools && tools.length > 0 ? { tools, tool_choice: 'auto' as const } : {}), + }; + } + + private buildResponsesPayload(model: string, messages: OpenAIMessage[], tools?: OpenAIFunctionDefinition[]) { + return { + model, + input: messages, + ...(tools && tools.length > 0 ? { tools, tool_choice: 'auto' as const } : {}), + }; + } + + private shouldFallbackToChatCompletions(status: number, errorBody: unknown): boolean { + if (status === 404 || status === 501) return true; + const raw = JSON.stringify(errorBody || {}).toLowerCase(); + return raw.includes('not support') || raw.includes('unsupported') || raw.includes('unknown endpoint') || raw.includes('responses'); + } + + private extractResponsesOutputText(data: any): string { + if (typeof data?.output_text === 'string' && data.output_text.length > 0) { + return data.output_text; + } + + if (!Array.isArray(data?.output)) return ''; + + return data.output + .flatMap((item: any) => (Array.isArray(item?.content) ? item.content : [])) + .filter((part: any) => part?.type === 'output_text' && typeof part?.text === 'string') + .map((part: any) => part.text) + .join(''); + } + + private normalizeToolCalls(message: any): OpenAIToolCall[] { + const directCalls = Array.isArray(message?.tool_calls) ? message.tool_calls : []; + if (directCalls.length > 0) return directCalls; + const contentCalls = Array.isArray(message?.content) + ? message.content.filter((part: any) => part?.type === 'tool_call' && part?.name) + : []; + return contentCalls.map((part: any, index: number) => ({ + id: part.id ?? `tool-${index}-${Date.now()}`, + type: 'function' as const, + function: { name: part.name, arguments: typeof part.arguments === 'string' ? part.arguments : JSON.stringify(part.arguments ?? {}) }, + })); + } + private async messagesToOpenAIChatFormat(messages: Message[], systemInstruction: string): Promise { const openAIMessages: OpenAIMessage[] = []; if (systemInstruction) { @@ -86,7 +141,9 @@ export class OpenAIChatService implements IChatService { return; } - const openaiEndpoint = `${config.baseUrl || 'https://api.openai.com/v1'}/chat/completions`; + const baseUrl = this.normalizeBaseUrl(config.baseUrl); + const chatEndpoint = `${baseUrl}/chat/completions`; + const responsesEndpoint = `${baseUrl}/responses`; const messages = await this.messagesToOpenAIChatFormat([...chatHistory, newMessage], systemInstruction); const model = config.model || 'gpt-4o'; @@ -95,12 +152,45 @@ export class OpenAIChatService implements IChatService { throw new AppError("CONFIG_ERROR", "Configuration Error: Video generation models are not supported in chat. Please select a text-based model in settings."); } - const response = await fetch(openaiEndpoint, { + let endpoint = chatEndpoint; + let response: Response; + const shouldTryResponses = (config.apiMode ?? 'auto') !== 'chat_completions'; + if (shouldTryResponses) { + endpoint = responsesEndpoint; + response = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${config.apiKey}` }, + body: JSON.stringify(this.buildResponsesPayload(model, messages)), + signal, + }); + if (!response.ok) { + let errorData: any = {}; + try { errorData = await response.json(); } catch {} + if (!((config.apiMode ?? 'auto') === 'auto' && this.shouldFallbackToChatCompletions(response.status, errorData))) { + const message = errorData?.error?.message || JSON.stringify(errorData); + throw new Error(`API error (${response.status}): ${message}`); + } + endpoint = chatEndpoint; + response = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${config.apiKey}` }, + body: JSON.stringify(this.buildChatCompletionsPayload(model, messages, undefined, true)), + signal, + }); + } else { + const data = await response.json(); + const outputText = this.extractResponsesOutputText(data); + if (outputText) onChunk(outputText); + return; + } + } else { + response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${config.apiKey}` }, - body: JSON.stringify({ model, messages, stream: true }), + body: JSON.stringify(this.buildChatCompletionsPayload(model, messages, undefined, true)), signal, }); + } if (!response.ok) { if (response.status === 404 && config.baseUrl.includes('googleapis.com')) { @@ -138,7 +228,7 @@ export class OpenAIChatService implements IChatService { } } } catch (error) { - const appError = handleError(error, 'api', { provider: 'openai', model, endpoint: openaiEndpoint }); + const appError = handleError(error, 'api', { provider: 'openai', model, endpoint: baseUrl }); if (appError.code === 'CANCELLED') return; onChunk(appError.userMessage); } @@ -153,7 +243,8 @@ export class OpenAIChatService implements IChatService { signal?: AbortSignal, onToolCall?: (toolCall: ToolCall) => void ): Promise { - const openaiEndpoint = `${config.baseUrl || 'https://api.openai.com/v1'}/chat/completions`; + const baseUrl = this.normalizeBaseUrl(config.baseUrl); + const openaiEndpoint = `${baseUrl}/chat/completions`; const model = config.model || 'gpt-4o'; const tools = config.tools ?? []; const MAX_TOOL_ITERATIONS = 10; @@ -169,12 +260,7 @@ export class OpenAIChatService implements IChatService { const response = await fetch(openaiEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${config.apiKey}` }, - body: JSON.stringify({ - model, - messages, - tools, - tool_choice: 'auto', - }), + body: JSON.stringify(this.buildChatCompletionsPayload(model, messages, tools, false)), signal, }); @@ -209,14 +295,14 @@ export class OpenAIChatService implements IChatService { onChunk(textChunk); } + const toolCalls = this.normalizeToolCalls(message); const assistantMessage = { role: 'assistant' as const, content: typeof message.content === 'string' ? message.content : '', - tool_calls: message.tool_calls, + tool_calls: toolCalls.length > 0 ? toolCalls : undefined, }; messages.push(assistantMessage); - const toolCalls = message.tool_calls ?? []; if (toolCalls.length === 0) { break; } diff --git a/src/shared/services/streamController.ts b/src/shared/services/streamController.ts index 99c2e19..9d97253 100644 --- a/src/shared/services/streamController.ts +++ b/src/shared/services/streamController.ts @@ -30,6 +30,7 @@ export class StreamController { openAiApiKey: options.openAiApiKey, openAiModel: options.openAiModel, openAiBaseUrl: options.openAiBaseUrl, + openAiApiMode: options.openAiApiMode, }; return await streamChatResponse( @@ -42,4 +43,3 @@ export class StreamController { ); } } - diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index d27fa9a..488ee8c 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -95,4 +95,5 @@ export interface StreamOptions { openAiApiKey: string; openAiModel: string; openAiBaseUrl: string; + openAiApiMode: 'auto' | 'chat_completions' | 'responses'; }