Skip to content
Merged
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
21 changes: 15 additions & 6 deletions components/SettingsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ import {
} from "lucide-react";
import { cn } from "@/lib/utils";

const PROVIDER_DEFAULTS: Record<AIProviderType, { label: string; badge: string; baseUrl: string; keyPlaceholder: string }> = {
'openai': { label: 'OpenAI 兼容 (Chat)', badge: 'GPT', baseUrl: 'https://api.openai.com/v1', keyPlaceholder: 'sk-...' },
'openai-responses': { label: 'OpenAI Responses API', badge: 'RSP', baseUrl: 'https://api.openai.com/v1', keyPlaceholder: 'sk-...' },
'gemini': { label: 'Gemini API', badge: 'GEM', baseUrl: 'https://generativelanguage.googleapis.com', keyPlaceholder: 'AIza...' },
'anthropic': { label: 'Anthropic Messages API', badge: 'CLD', baseUrl: 'https://api.anthropic.com', keyPlaceholder: 'sk-ant-...' },
};

interface SettingsModalProps {
isOpen: boolean;
onClose: () => void;
Expand Down Expand Up @@ -485,7 +492,7 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose, s
};

const handleProviderTypeChange = (newType: AIProviderType) => {
const baseUrl = newType === 'gemini' ? 'https://generativelanguage.googleapis.com' : 'https://api.openai.com/v1';
const baseUrl = PROVIDER_DEFAULTS[newType].baseUrl;
setEditForm(prev => ({ ...prev, type: newType, baseUrl }));
};

Expand Down Expand Up @@ -735,18 +742,20 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose, s
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="openai">OpenAI 兼容</SelectItem>
<SelectItem value="openai">OpenAI 兼容 (Chat)</SelectItem>
<SelectItem value="openai-responses">OpenAI Responses API</SelectItem>
<SelectItem value="gemini">Gemini API</SelectItem>
<SelectItem value="anthropic">Anthropic Messages API</SelectItem>
</SelectContent>
</Select>
</div>
<div className="md:col-span-2 space-y-2">
<Label>接入点地址</Label>
<Input className="font-mono" placeholder={editForm.type === 'openai' ? 'https://api.openai.com/v1' : 'https://generativelanguage.googleapis.com'} value={editForm.baseUrl} onChange={e => setEditForm({ ...editForm, baseUrl: e.target.value })} />
<Input className="font-mono" placeholder={PROVIDER_DEFAULTS[editForm.type].baseUrl} value={editForm.baseUrl} onChange={e => setEditForm({ ...editForm, baseUrl: e.target.value })} />
</div>
<div className="md:col-span-2 space-y-2">
<Label>API Key</Label>
<Input type="password" className="font-mono" placeholder="sk-..." value={editForm.apiKey} onChange={e => setEditForm({ ...editForm, apiKey: e.target.value })} />
<Input type="password" className="font-mono" placeholder={PROVIDER_DEFAULTS[editForm.type].keyPlaceholder} value={editForm.apiKey} onChange={e => setEditForm({ ...editForm, apiKey: e.target.value })} />
</div>
</div>
</CardContent>
Expand All @@ -771,7 +780,7 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose, s
"w-10 h-10 rounded-lg flex items-center justify-center text-white font-black text-[10px]",
"bg-primary"
)}>
{provider.type === 'gemini' ? 'GEM' : 'GPT'}
{PROVIDER_DEFAULTS[provider.type]?.badge || provider.type.toUpperCase().slice(0, 3)}
</div>
<div className="min-w-0">
<h4 className="font-bold text-sm truncate">{provider.name}</h4>
Expand Down Expand Up @@ -824,7 +833,7 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose, s
<div className="p-6 space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<Badge variant="outline" className="w-fit">
{localSettings.providers.find(p => p.id === activeProviderForModels)?.type === 'gemini' ? 'Gemini API' : 'OpenAI'}
{PROVIDER_DEFAULTS[localSettings.providers.find(p => p.id === activeProviderForModels)?.type || 'openai']?.label || 'Unknown'}
</Badge>
<Button size="sm" onClick={handleFetchModels} disabled={isFetchingModels} className="gap-2">
<RefreshCw className={cn("w-3.5 h-3.5", isFetchingModels && "animate-spin")} />
Expand Down
217 changes: 142 additions & 75 deletions services/geminiService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ const parseApiError = async (response: Response, providerName: string): Promise<
// Gemini often uses error.message, OpenAI uses error.message or just string
const errObj = json.error;
if (typeof errObj === 'string') details = errObj;
else if (errObj.message) details = errObj.message;
else if (errObj.message) details = errObj.type ? `[${errObj.type}] ${errObj.message}` : errObj.message;
else details = JSON.stringify(errObj);
} else {
details = errorBody.substring(0, 300);
Expand All @@ -60,43 +60,58 @@ const parseApiError = async (response: Response, providerName: string): Promise<

// --- Helper: Fetch Models List ---
export const fetchProviderModels = async (provider: AIProvider): Promise<string[]> => {
const isGemini = provider.type === 'gemini';
const baseUrl = provider.baseUrl.replace(/\/+$/, '');

try {
if (isGemini) {
// Gemini: GET /v1beta/models
const url = `${baseUrl}/v1beta/models?key=${provider.apiKey}`;
const response = await fetch(url);

if (!response.ok) {
throw new Error(await parseApiError(response, 'Gemini API'));
}

const data = await response.json();
// Gemini returns names like "models/gemini-pro". We usually just want the ID part.
if (data.models && Array.isArray(data.models)) {
return data.models.map((m: any) => m.name.replace(/^models\//, ''));
switch (provider.type) {
case 'gemini': {
const url = `${baseUrl}/v1beta/models?key=${provider.apiKey}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(await parseApiError(response, 'Gemini API'));
}
const data = await response.json();
if (data.models && Array.isArray(data.models)) {
return data.models.map((m: any) => m.name.replace(/^models\//, ''));
}
return [];
}
return [];
} else {
// OpenAI: GET /models
const url = `${baseUrl}/models`;
const response = await fetch(url, {
headers: {
'Authorization': `Bearer ${provider.apiKey}`
case 'anthropic': {
const url = `${baseUrl}/v1/models`;
const response = await fetch(url, {
headers: {
'x-api-key': provider.apiKey,
'anthropic-version': '2023-06-01'
}
});
if (!response.ok) {
throw new Error(await parseApiError(response, 'Anthropic API'));
}
});

if (!response.ok) {
throw new Error(await parseApiError(response, 'OpenAI API'));
const data = await response.json();
if (data.data && Array.isArray(data.data)) {
return data.data.map((m: any) => m.id);
}
return [];
}

const data = await response.json();
if (data.data && Array.isArray(data.data)) {
return data.data.map((m: any) => m.id);
case 'openai':
case 'openai-responses': {
const url = `${baseUrl}/models`;
const response = await fetch(url, {
headers: {
'Authorization': `Bearer ${provider.apiKey}`
}
});
if (!response.ok) {
throw new Error(await parseApiError(response, 'OpenAI API'));
}
const data = await response.json();
if (data.data && Array.isArray(data.data)) {
return data.data.map((m: any) => m.id);
}
return [];
}
return [];
default:
throw new Error(`不支持的 API 格式: ${provider.type}`);
}
} catch (error: any) {
console.error("Fetch Models Error:", error);
Expand All @@ -111,62 +126,114 @@ const callLLM = async (
prompt: string,
jsonMode: boolean = false
): Promise<string> => {
const isGemini = provider.type === 'gemini';

// Clean URL: Remove trailing slash
const baseUrl = provider.baseUrl.replace(/\/+$/, '');

const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 60000); // 60s Timeout

try {
if (isGemini) {
// GEMINI REST API
const url = `${baseUrl}/v1beta/models/${modelId}:generateContent?key=${provider.apiKey}`;
const body = {
contents: [{ parts: [{ text: prompt }] }],
generationConfig: jsonMode ? { responseMimeType: "application/json" } : undefined
};

const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: controller.signal
});

if (!response.ok) {
throw new Error(await parseApiError(response, 'Gemini REST API'));
let response: Response;
let providerLabel: string;

switch (provider.type) {
case 'gemini': {
providerLabel = 'Gemini REST API';
const url = `${baseUrl}/v1beta/models/${modelId}:generateContent?key=${provider.apiKey}`;
const body = {
contents: [{ parts: [{ text: prompt }] }],
generationConfig: jsonMode ? { responseMimeType: "application/json" } : undefined
};
response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: controller.signal
});
if (!response.ok) {
throw new Error(await parseApiError(response, providerLabel));
}
const geminiData = await response.json();
return geminiData.candidates?.[0]?.content?.parts?.[0]?.text || '';
}

const data = await response.json();
return data.candidates?.[0]?.content?.parts?.[0]?.text || '';
case 'openai': {
providerLabel = 'OpenAI API';
const url = `${baseUrl}/chat/completions`;
const body = {
model: modelId,
messages: [{ role: 'user', content: prompt }],
response_format: jsonMode ? { type: "json_object" } : undefined
};
response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${provider.apiKey}`
},
body: JSON.stringify(body),
signal: controller.signal
});
if (!response.ok) {
throw new Error(await parseApiError(response, providerLabel));
}
const openaiData = await response.json();
return openaiData.choices?.[0]?.message?.content || '';
}

} else {
// OPENAI COMPATIBLE API
const url = `${baseUrl}/chat/completions`;
const body = {
model: modelId,
messages: [{ role: 'user', content: prompt }],
response_format: jsonMode ? { type: "json_object" } : undefined
};

const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${provider.apiKey}`
},
body: JSON.stringify(body),
signal: controller.signal
});

if (!response.ok) {
throw new Error(await parseApiError(response, 'OpenAI API'));
case 'openai-responses': {
providerLabel = 'OpenAI Responses API';
const url = `${baseUrl}/responses`;
const body: Record<string, any> = {
model: modelId,
input: prompt,
};
if (jsonMode) {
body.text = { format: { type: "json_object" } };
}
response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${provider.apiKey}`
},
body: JSON.stringify(body),
signal: controller.signal
});
if (!response.ok) {
throw new Error(await parseApiError(response, providerLabel));
}
const respData = await response.json();
return respData.output_text || respData.output?.[0]?.content?.[0]?.text || '';
}

case 'anthropic': {
providerLabel = 'Anthropic Messages API';
const url = `${baseUrl}/v1/messages`;
const body = {
model: modelId,
max_tokens: 64000,
messages: [{ role: 'user', content: prompt }]
};
response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': provider.apiKey,
'anthropic-version': '2023-06-01'
},
body: JSON.stringify(body),
signal: controller.signal
});
if (!response.ok) {
throw new Error(await parseApiError(response, providerLabel));
}
const anthropicData = await response.json();
return anthropicData.content?.[0]?.text || '';
}

const data = await response.json();
return data.choices?.[0]?.message?.content || '';
default:
throw new Error(`不支持的 API 格式: ${provider.type}`);
}
} catch (e: any) {
if (e.name === 'AbortError') {
Expand Down
2 changes: 1 addition & 1 deletion types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export interface FeedSummary {

// --- AI Settings Types ---

export type AIProviderType = 'openai' | 'gemini';
export type AIProviderType = 'openai' | 'openai-responses' | 'gemini' | 'anthropic';

export interface AIProvider {
id: string;
Expand Down