Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 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
19 changes: 18 additions & 1 deletion server/src/routes/configs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,6 @@ router.put('/api/configs/ai/bulk', (req, res) => {
}

const bulkSync = db.transaction(() => {
// Read existing keys BEFORE delete
const existingKeys = new Map<string, string>();
const existingRows = db.prepare('SELECT id, api_key_encrypted FROM ai_configs').all() as Array<{ id: string; api_key_encrypted: string }>;
for (const row of existingRows) {
Expand All @@ -139,19 +138,37 @@ router.put('/api/configs/ai/bulk', (req, res) => {
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);

const skippedConfigs: Array<{ id: string; name: string; reason: string }> = [];

for (const c of configs) {
let encryptedKey = '';
if (c.apiKey && !c.apiKey.startsWith('***')) {
encryptedKey = encrypt(c.apiKey, config.encryptionKey);
} else {
encryptedKey = existingKeys.get(String(c.id)) ?? '';
}

if (!encryptedKey) {
skippedConfigs.push({
id: c.id,
name: c.name ?? '',
reason: c.apiKey?.startsWith('***')
? 'API key is masked and no existing key found'
: 'API key is empty',
});
continue;
}

stmt.run(
c.id, c.name ?? '', c.apiType ?? 'openai', c.baseUrl ?? '',
encryptedKey, c.model ?? '', c.isActive ? 1 : 0,
c.customPrompt ?? null, c.useCustomPrompt ? 1 : 0, c.concurrency ?? 1, c.reasoningEffort ?? null
);
}

if (skippedConfigs.length > 0) {
console.warn('[configs] Skipped AI configs with missing keys:', skippedConfigs);
}
});

bulkSync();
Expand Down
4 changes: 2 additions & 2 deletions src/components/DiscoveryView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -664,8 +664,8 @@ export const DiscoveryView: React.FC = React.memo(() => {
return;
}

if (activeConfig.apiKeyStatus === 'decrypt_failed') {
alert(t('AI服务的API密钥无法解密,请在设置中重新输入并保存该配置。', 'The AI service API key could not be decrypted. Please re-enter and save the configuration in settings.'));
if (activeConfig.apiKeyStatus === 'decrypt_failed' || activeConfig.apiKeyStatus === 'empty') {
alert(t('AI服务的API密钥无法解密或为空,请在设置中重新输入并保存该配置。', 'The AI service API key could not be decrypted or is empty. Please re-enter and save the configuration in settings.'));
return;
}

Expand Down
22 changes: 10 additions & 12 deletions src/components/MarkdownRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -493,19 +493,17 @@ const MarkdownImage: React.FC<{ src?: string; alt?: string; baseUrl?: string }>

if (hasError) {
return (
<div className="my-4 p-4 bg-gray-100 dark:bg-white/[0.04] rounded-lg border border-black/[0.06] dark:border-white/[0.04] flex items-center gap-3">
<svg className="w-5 h-5 text-gray-700 dark:text-text-secondary flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div className="my-2 px-3 py-2 bg-gray-100 dark:bg-white/[0.04] rounded border border-black/[0.06] dark:border-white/[0.04] flex items-center gap-2 text-xs">
<svg className="w-4 h-4 text-gray-500 dark:text-text-tertiary flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<div className="flex-1 min-w-0">
<p className="text-sm text-gray-700 dark:text-text-secondary font-medium">
{language === 'zh' ? '图片加载失败' : 'Image failed to load'}
</p>
{alt && <p className="text-xs text-gray-700 dark:text-text-secondary truncate">{alt}</p>}
</div>
<span className="text-gray-500 dark:text-text-tertiary">
{language === 'zh' ? '图片加载失败' : 'Image failed'}
</span>
{alt && <span className="text-gray-400 dark:text-text-quaternary truncate max-w-[120px]">{alt}</span>}
<button
onClick={handleRetry}
className="px-2 py-1 text-xs bg-gray-100 dark:bg-white/[0.04] text-gray-700 dark:text-text-secondary rounded hover:bg-gray-200 dark:hover:bg-white/[0.08] transition-colors flex-shrink-0"
className="ml-auto px-2 py-0.5 text-xs text-brand-violet hover:text-brand-violet/80 transition-colors flex-shrink-0"
>
{language === 'zh' ? '重试' : 'Retry'}
</button>
Expand Down Expand Up @@ -548,11 +546,11 @@ const MarkdownImage: React.FC<{ src?: string; alt?: string; baseUrl?: string }>
) : (
<div className="my-4 flex flex-col items-center group/img">
{isLoading && (
<div className="w-full max-w-md h-48 bg-light-surface dark:bg-panel-dark rounded-xl flex flex-col items-center justify-center animate-pulse gap-2">
<svg className="w-8 h-8 text-gray-300 dark:text-text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div className="w-full max-w-md h-16 bg-light-surface dark:bg-panel-dark rounded-lg flex items-center justify-center animate-pulse gap-2">
<svg className="w-5 h-5 text-gray-300 dark:text-text-tertiary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span className="text-xs text-gray-400 dark:text-text-tertiary">{language === 'zh' ? '加载中...' : 'Loading...'}</span>
<span className="text-xs text-gray-400 dark:text-text-quaternary">{language === 'zh' ? '加载中...' : 'Loading...'}</span>
</div>
)}

Expand Down
2 changes: 1 addition & 1 deletion src/components/ReadmeModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,7 @@ export const ReadmeModal: React.FC<ReadmeModalProps> = ({
href={repository.html_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center space-x-1 px-3 py-2 text-sm text-gray-700 dark:text-text-secondary hover:text-gray-900 dark:hover:text-gray-900 hover:bg-light-surface dark:hover:bg-white/10 rounded-lg transition-colors"
className="flex items-center space-x-1 px-3 py-2 text-sm text-gray-700 dark:text-text-primary hover:text-gray-900 dark:hover:text-white hover:bg-light-surface dark:hover:bg-white/10 rounded-lg transition-colors"
title={t('在 GitHub 上查看', 'View on GitHub')}
>
<ExternalLink className="w-4 h-4" />
Expand Down
4 changes: 2 additions & 2 deletions src/components/RepositoryCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -279,8 +279,8 @@ const RepositoryCardComponent: React.FC<RepositoryCardProps> = ({
return;
}

if (activeConfig.apiKeyStatus === 'decrypt_failed') {
alert(language === 'zh' ? 'AI服务的API密钥无法解密,请在设置中重新输入并保存该配置。' : 'The AI service API key could not be decrypted. Please re-enter and save the configuration in settings.');
if (activeConfig.apiKeyStatus === 'decrypt_failed' || activeConfig.apiKeyStatus === 'empty') {
alert(language === 'zh' ? 'AI服务的API密钥无法解密或为空,请在设置中重新输入并保存该配置。' : 'The AI service API key could not be decrypted or is empty. Please re-enter and save the configuration in settings.');
return;
}

Expand Down
4 changes: 2 additions & 2 deletions src/components/RepositoryList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -277,8 +277,8 @@ export const RepositoryList: React.FC<RepositoryListProps> = ({
return;
}

if (activeConfig.apiKeyStatus === 'decrypt_failed') {
alert(language === 'zh' ? 'AI服务的API密钥无法解密,请在设置中重新输入并保存该配置。' : 'The AI service API key could not be decrypted. Please re-enter and save the configuration in settings.');
if (activeConfig.apiKeyStatus === 'decrypt_failed' || activeConfig.apiKeyStatus === 'empty') {
alert(language === 'zh' ? 'AI服务的API密钥无法解密或为空,请在设置中重新输入并保存该配置。' : 'The AI service API key could not be decrypted or is empty. Please re-enter and save the configuration in settings.');
return;
}

Expand Down
4 changes: 2 additions & 2 deletions src/components/SubscriptionRepoCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -209,8 +209,8 @@ export const SubscriptionRepoCard: React.FC<SubscriptionRepoCardProps> = ({ repo
return;
}

if (activeConfig.apiKeyStatus === 'decrypt_failed') {
alert(t('AI服务的API密钥无法解密,请在设置中重新输入并保存该配置。', 'The AI service API key could not be decrypted. Please re-enter and save the configuration in settings.'));
if (activeConfig.apiKeyStatus === 'decrypt_failed' || activeConfig.apiKeyStatus === 'empty') {
alert(t('AI服务的API密钥无法解密或为空,请在设置中重新输入并保存该配置。', 'The AI service API key could not be decrypted or is empty. Please re-enter and save the configuration in settings.'));
return;
}

Expand Down
26 changes: 13 additions & 13 deletions src/components/settings/AIConfigPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react'
import { Bot, Plus, Edit3, Trash2, Save, X, TestTube, RefreshCw, MessageSquare, Eye, EyeOff, AlertCircle } from 'lucide-react';
import { AIConfig, AIApiType, AIReasoningEffort } from '../../types';
import { useAppStore } from '../../store/useAppStore';
import { AIService } from '../../services/aiService';
import { AIService, ConnectionTestResult } from '../../services/aiService';
Comment thread
SummerRay160 marked this conversation as resolved.
Outdated
import { buildFinalApiUrl } from '../../utils/apiUrlBuilder';
import { SliderInput } from '../ui/SliderInput';

Expand Down Expand Up @@ -152,12 +152,12 @@ export const AIConfigPanel: React.FC<AIConfigPanelProps> = ({ t }) => {
setTestingId(config.id);
try {
const aiService = new AIService(config, language);
const isConnected = await aiService.testConnection();
if (isConnected) {
const result = await aiService.testConnection();

if (result.success) {
alert(t('AI服务连接成功!', 'AI service connection successful!'));
} else {
alert(t('AI服务连接失败,请检查配置。', 'AI service connection failed. Please check configuration.'));
alert(result.message);
}
} catch (error) {
console.error('AI test failed:', error);
Expand Down Expand Up @@ -190,12 +190,12 @@ export const AIConfigPanel: React.FC<AIConfigPanelProps> = ({ t }) => {
};

const aiService = new AIService(tempConfig, language);
const isConnected = await aiService.testConnection();
if (isConnected) {
alert(t('AI服务连接成功!', 'AI service connection successful!'));
const result = await aiService.testConnection();

if (result.success) {
alert(t('AI服务连接成功!', 'AI service connection successful!'));
} else {
alert(t('AI服务连接失败,请检查配置。', 'AI service connection failed. Please check configuration.'));
alert(result.message);
}
} catch (error) {
console.error('AI test failed:', error);
Expand Down Expand Up @@ -630,11 +630,11 @@ Focus on practicality and accurate categorization to help users quickly understa
{(config.apiType || 'openai').toUpperCase()} • {config.baseUrl} • {config.model} • {t('并发数', 'Concurrency')}: {config.concurrency || 1}
{config.reasoningEffort ? ` • reasoning: ${config.reasoningEffort}` : ''}
</p>
{config.apiKeyStatus === 'decrypt_failed' && (
{(config.apiKeyStatus === 'decrypt_failed' || config.apiKeyStatus === 'empty') && (
<p className="mt-1 text-sm text-gray-700 dark:text-text-secondary ">
{t(
'存储的 API Key 无法解密,请重新输入并保存该配置。',
'The stored API key could not be decrypted. Please re-enter and save this configuration.'
'存储的 API Key 无法解密或为空,请重新输入并保存该配置。',
'The stored API key could not be decrypted or is empty. Please re-enter and save this configuration.'
)}
</p>
)}
Expand Down
135 changes: 128 additions & 7 deletions src/services/aiService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,38 @@ interface OpenAIResponse {
choices?: OpenAIResponseChoice[];
}

export interface ConnectionTestResult {
success: boolean;
statusCode?: number;
statusText?: string;
errorType?: 'network' | 'auth' | 'timeout' | 'server' | 'unknown';
message: string;
}

function getStatusCodeMeaning(statusCode: number, language: string): string {
const meanings: Record<number, { zh: string; en: string }> = {
400: { zh: '请求参数错误', en: 'Bad Request' },
401: { zh: 'API密钥无效或已过期', en: 'Invalid or expired API key' },
403: { zh: '没有权限访问该资源', en: 'Forbidden - no permission' },
404: { zh: 'API端点或模型不存在', en: 'API endpoint or model not found' },
408: { zh: '请求超时', en: 'Request timeout' },
429: { zh: '请求过于频繁,已达到速率限制', en: 'Rate limit exceeded' },
500: { zh: '服务器内部错误', en: 'Internal server error' },
502: { zh: '网关错误,服务器暂时不可用', en: 'Bad Gateway' },
503: { zh: '服务暂时不可用,请稍后重试', en: 'Service unavailable' },
504: { zh: '网关超时', en: 'Gateway timeout' },
};
return meanings[statusCode]?.[language as 'zh' | 'en'] || (language === 'zh' ? '未知错误' : 'Unknown error');
}

function getErrorTypeFromStatus(statusCode: number): ConnectionTestResult['errorType'] {
if (statusCode === 401 || statusCode === 403) return 'auth';
if (statusCode === 408 || statusCode === 504) return 'timeout';
if (statusCode >= 500) return 'server';
if (statusCode >= 400) return 'unknown';
return 'unknown';
}

export class AIService {
private config: AIConfig;
private language: string;
Expand Down Expand Up @@ -442,14 +474,23 @@ Focus on practicality and accurate categorization to help users quickly understa
}
}

async testConnection(): Promise<boolean> {
async testConnection(): Promise<ConnectionTestResult> {
const apiType = this.getApiType();
const timeoutMs = apiType === 'openai-responses' || this.config.reasoningEffort ? 30000 : 10000;

try {
const base = new URL(this.config.baseUrl);
if (base.protocol !== 'http:' && base.protocol !== 'https:') return false;
if (base.protocol !== 'http:' && base.protocol !== 'https:') {
return {
success: false,
errorType: 'unknown',
message: this.language === 'zh'
? '无效的协议,请使用 http:// 或 https://'
: 'Invalid protocol, please use http:// or https://',
};
}

const controller = new AbortController();
const apiType = this.getApiType();
const timeoutMs = apiType === 'openai-responses' || this.config.reasoningEffort ? 30000 : 10000;
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const content = await this.requestText({
Expand All @@ -459,12 +500,92 @@ Focus on practicality and accurate categorization to help users quickly understa
maxTokens: 50,
signal: controller.signal,
});
return !!content;
if (content) {
return {
success: true,
message: this.language === 'zh' ? '连接成功' : 'Connection successful',
};
}
return {
success: false,
errorType: 'unknown',
message: this.language === 'zh' ? '未收到响应内容' : 'No content received',
};
} finally {
clearTimeout(timeoutId);
}
} catch {
return false;
} catch (error) {
const err = error as Error;
const errorMessage = err.message || '';

// 解析状态码
const statusMatch = errorMessage.match(/(\d{3})/);
const statusCode = statusMatch ? parseInt(statusMatch[1], 10) : undefined;

// 处理超时错误
if (errorMessage.includes('timeout') || errorMessage.includes('abort') || err.name === 'AbortError') {
return {
success: false,
errorType: 'timeout',
message: this.language === 'zh'
? `连接超时(${timeoutMs / 1000}秒)。请检查:1. 网络连接是否正常 2. API端点是否正确 3. 服务器是否响应缓慢`
: `Connection timeout (${timeoutMs / 1000}s). Please check: 1. Network connection 2. API endpoint 3. Server response time`,
};
}

// 处理网络错误
if (errorMessage.includes('fetch') || errorMessage.includes('network') || errorMessage.includes('Failed to fetch')) {
return {
success: false,
errorType: 'network',
message: this.language === 'zh'
? '网络连接失败。请检查:1. 网络连接是否正常 2. API端点地址是否正确 3. 防火墙或代理设置'
: 'Network connection failed. Please check: 1. Network connection 2. API endpoint 3. Firewall or proxy settings',
};
}

// 如果有状态码,提供详细的错误信息
if (statusCode) {
const meaning = getStatusCodeMeaning(statusCode, this.language);
const errorType = getErrorTypeFromStatus(statusCode) ?? 'unknown';
const suggestions: Record<string, { zh: string; en: string }> = {
auth: {
zh: '请检查 API 密钥是否正确,或密钥是否已过期',
en: 'Please check if the API key is correct or expired',
},
timeout: {
zh: '请求超时,请稍后重试或检查网络连接',
en: 'Request timeout, please retry later or check network',
},
server: {
zh: '服务器端错误,请稍后重试或联系服务提供商',
en: 'Server error, please retry later or contact provider',
},
unknown: {
zh: '请检查 API 端点、模型名称和请求参数是否正确',
en: 'Please check API endpoint, model name and request parameters',
},
};

return {
success: false,
statusCode,
statusText: meaning,
errorType,
message: this.language === 'zh'
? `HTTP ${statusCode} - ${meaning}\n建议:${suggestions[errorType].zh}`
: `HTTP ${statusCode} - ${meaning}\nSuggestion: ${suggestions[errorType].en}`,
};
}

// 默认错误
return {
success: false,
errorType: 'unknown',
message: this.language === 'zh'
? `连接失败:${errorMessage || '未知错误'}\n请检查 API 端点、API 密钥和模型名称是否正确`
: `Connection failed: ${errorMessage || 'Unknown error'}\nPlease check API endpoint, API key and model name`,
};
}
}

Expand Down
Loading