Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
10 changes: 7 additions & 3 deletions public/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 にフォールバックします。"
}
10 changes: 7 additions & 3 deletions public/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -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。"
}
16 changes: 16 additions & 0 deletions src/app/providers/useSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -55,6 +57,7 @@ export const SettingsProvider: React.FC<{ children: React.ReactNode }> = ({ chil
const [openAiApiKey, setOpenAiApiKeyState] = useState<string>('');
const [openAiModel, setOpenAiModelState] = useState<string>('');
const [openAiBaseUrl, setOpenAiBaseUrlState] = useState<string>('');
const [openAiApiMode, setOpenAiApiModeState] = useState<'auto' | 'chat_completions' | 'responses'>('auto');
const [language, setLanguageState] = useState<Language>(Language.EN);
// Render immediately; theme flash is mitigated by early index.html script

Expand Down Expand Up @@ -89,6 +92,7 @@ export const SettingsProvider: React.FC<{ children: React.ReactNode }> = ({ chil
const savedOpenAiApiKey = await getSetting<string>('openAiApiKey') || '';
const savedOpenAiModel = await getSetting<string>('openAiModel') || 'gpt-4o'; // Default OpenAI model
const savedOpenAiBaseUrl = await getSetting<string>('openAiBaseUrl') || 'https://api.openai.com/v1';
const savedOpenAiApiMode = await getSetting<'auto' | 'chat_completions' | 'responses'>('openAiApiMode') || 'auto';
const savedLanguage = await getSetting<Language>('language') || Language.EN;


Expand All @@ -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');
Expand Down Expand Up @@ -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);
Expand All @@ -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);
};

Expand All @@ -282,6 +295,8 @@ export const SettingsProvider: React.FC<{ children: React.ReactNode }> = ({ chil
setOpenAiModel,
openAiBaseUrl,
setOpenAiBaseUrl,
openAiApiMode,
setOpenAiApiMode,
language,
setLanguage,
resetSettings
Expand All @@ -296,6 +311,7 @@ export const SettingsProvider: React.FC<{ children: React.ReactNode }> = ({ chil
openAiApiKey,
openAiModel,
openAiBaseUrl,
openAiApiMode,
language
]);

Expand Down
6 changes: 4 additions & 2 deletions src/features/chat/model/useChatStream.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { parseStreamedText } from '@shared/lib/textHelpers';
export const useChatStream = () => {
const [isThinking, setIsThinking] = useState(false);
const [streamedAiMessage, setStreamedAiMessage] = useState<Message | null>(null);
const { effectiveSystemPrompt, selectedApiProvider, geminiApiKey, geminiModel, openAiApiKey, openAiModel, openAiBaseUrl } = useSettings();
const { effectiveSystemPrompt, selectedApiProvider, geminiApiKey, geminiModel, openAiApiKey, openAiModel, openAiBaseUrl, openAiApiMode } = useSettings();
const controllerRef = useRef<StreamController | null>(null);
if (!controllerRef.current) controllerRef.current = new StreamController();

Expand Down Expand Up @@ -48,6 +48,7 @@ export const useChatStream = () => {
openAiApiKey,
openAiModel,
openAiBaseUrl,
openAiApiMode,
},
(chunk) => {
streamedText += chunk;
Expand Down Expand Up @@ -115,7 +116,8 @@ export const useChatStream = () => {
geminiModel,
openAiApiKey,
openAiModel,
openAiBaseUrl
openAiBaseUrl,
openAiApiMode
]);

return {
Expand Down
17 changes: 17 additions & 0 deletions src/pages/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ const SettingsPage: React.FC = () => {
setOpenAiModel,
openAiBaseUrl,
setOpenAiBaseUrl,
openAiApiMode,
setOpenAiApiMode,
language,
setLanguage,
resetSettings
Expand Down Expand Up @@ -259,6 +261,21 @@ const SettingsPage: React.FC = () => {
/>
<p className="text-xs text-gray-500 mt-1">{t('settings.api_base_url_info')}</p>
</div>
<div>
<label htmlFor="openai-api-mode" className="block text-sm font-medium mb-2">{t('settings.api_protocol_mode')}</label>
<Selector
label={t('settings.api_protocol_mode')}
icon="sync_alt"
options={[
{ value: 'auto', label: t('settings.api_protocol_mode_auto') },
{ value: 'responses', label: t('settings.api_protocol_mode_responses') },
{ value: 'chat_completions', label: t('settings.api_protocol_mode_chat') }
]}
selectedValue={openAiApiMode}
onSelect={(mode) => setOpenAiApiMode(mode as 'auto' | 'chat_completions' | 'responses')}
/>
<p className="text-xs text-gray-500 mt-1">{t('settings.api_protocol_mode_info')}</p>
</div>
<div>
<div className="flex justify-between items-center mb-2">
<label htmlFor="openai-model" className="block text-sm font-medium">{t('settings.api_model_name')}</label>
Expand Down
3 changes: 3 additions & 0 deletions src/shared/services/apiService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export interface StreamChatConfig {
openAiApiKey: string;
openAiModel: string;
openAiBaseUrl: string;
openAiApiMode: 'auto' | 'chat_completions' | 'responses';
}

export const streamChatResponse = async (
Expand All @@ -35,6 +36,7 @@ export const streamChatResponse = async (
openAiApiKey,
openAiModel,
openAiBaseUrl,
openAiApiMode,
} = config;

let fullText = '';
Expand Down Expand Up @@ -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(
Expand Down
112 changes: 99 additions & 13 deletions src/shared/services/providers/OpenAIChatService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<OpenAIServiceConfig> {
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<OpenAIMessage[]> {
const openAIMessages: OpenAIMessage[] = [];
if (systemInstruction) {
Expand Down Expand Up @@ -86,7 +141,9 @@ export class OpenAIChatService implements IChatService<OpenAIServiceConfig> {
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';

Expand All @@ -95,12 +152,45 @@ export class OpenAIChatService implements IChatService<OpenAIServiceConfig> {
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;
Comment on lines +181 to +184
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Parse Responses text from output instead of output_text

In apiMode: 'auto' (the new default), a successful /responses call returns early after reading only data?.output_text. The REST response body typically carries assistant text under output[].content[] (e.g. type: "output_text"), so output_text can be absent and this path emits no chunk, yielding an empty assistant reply while skipping fallback because the status is 200. This makes normal OpenAI responses appear blank for users on the new mode.

Useful? React with 👍 / 👎.

}
} 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')) {
Expand Down Expand Up @@ -138,7 +228,7 @@ export class OpenAIChatService implements IChatService<OpenAIServiceConfig> {
}
}
} 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);
}
Expand All @@ -153,7 +243,8 @@ export class OpenAIChatService implements IChatService<OpenAIServiceConfig> {
signal?: AbortSignal,
onToolCall?: (toolCall: ToolCall) => void
): Promise<void> {
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;
Expand All @@ -169,12 +260,7 @@ export class OpenAIChatService implements IChatService<OpenAIServiceConfig> {
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,
});

Expand Down Expand Up @@ -209,14 +295,14 @@ export class OpenAIChatService implements IChatService<OpenAIServiceConfig> {
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,
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep normalized tool calls in assistant history message

The new compatibility path detects tool calls from message.content via normalizeToolCalls(message), but the assistant turn pushed into messages still stores only message.tool_calls. When a provider returns tool calls only in content, the code executes tools and then sends role: "tool" messages without a matching assistant tool_calls record in history, which commonly breaks the next chat-completions request validation and stops the tool loop.

Useful? React with 👍 / 👎.

messages.push(assistantMessage);

const toolCalls = message.tool_calls ?? [];
if (toolCalls.length === 0) {
break;
}
Expand Down
2 changes: 1 addition & 1 deletion src/shared/services/streamController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export class StreamController {
openAiApiKey: options.openAiApiKey,
openAiModel: options.openAiModel,
openAiBaseUrl: options.openAiBaseUrl,
openAiApiMode: options.openAiApiMode,
};

return await streamChatResponse(
Expand All @@ -42,4 +43,3 @@ export class StreamController {
);
}
}

1 change: 1 addition & 0 deletions src/shared/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,4 +95,5 @@ export interface StreamOptions {
openAiApiKey: string;
openAiModel: string;
openAiBaseUrl: string;
openAiApiMode: 'auto' | 'chat_completions' | 'responses';
}