From 67d35e9c519a4e09a5215344b94f9a1ce9de9965 Mon Sep 17 00:00:00 2001 From: movclantian Date: Sun, 11 Jan 2026 14:36:06 +0800 Subject: [PATCH] feat: update LLM error handling with retry logic and enhance model validation messages --- package-lock.json | 30 +++++- server/src/services/llm.ts | 168 +++++++++++++++++++++++++++--- src/components/SessionSidebar.tsx | 43 ++++---- src/components/SettingsDialog.tsx | 2 +- src/i18n/locales/en.json | 11 +- src/i18n/locales/zh.json | 11 +- src/pages/Index.tsx | 6 +- 7 files changed, 227 insertions(+), 44 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4cdba78..04065ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nano-banana-slides-prompter", - "version": "1.0.5", + "version": "1.0.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nano-banana-slides-prompter", - "version": "1.0.5", + "version": "1.0.6", "license": "GPL-3.0-or-later", "dependencies": { "@hookform/resolvers": "^5.2.2", @@ -126,6 +126,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -3612,6 +3613,7 @@ "integrity": "sha512-NNu0sjyNxpoiW3YuVFfNz7mxSQ+S4X2G28uqg2s+CzoqoQjLPsWSbsFFyztIAqt2vb8kfEAsJNepMGPTxFDx3Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3622,6 +3624,7 @@ "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3632,6 +3635,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -3687,6 +3691,7 @@ "integrity": "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/types": "8.52.0", @@ -3921,6 +3926,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4134,6 +4140,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4685,7 +4692,8 @@ "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/embla-carousel-react": { "version": "8.6.0", @@ -4830,6 +4838,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5353,6 +5362,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.4" }, @@ -6416,6 +6426,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -6585,6 +6596,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -6615,6 +6627,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -6627,6 +6640,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.70.0.tgz", "integrity": "sha512-COOMajS4FI3Wuwrs3GPpi/Jeef/5W1DRR84Yl5/ShlT3dKVFUfoGiEZ/QE6Uw8P4T2/CLJdcTVYKvWBMQTEpvw==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -6677,6 +6691,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -6867,7 +6882,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -7270,6 +7286,7 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "license": "MIT", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -7379,6 +7396,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -7452,6 +7470,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7631,6 +7650,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -7724,6 +7744,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -7974,6 +7995,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/server/src/services/llm.ts b/server/src/services/llm.ts index c02eb81..6d81e83 100644 --- a/server/src/services/llm.ts +++ b/server/src/services/llm.ts @@ -1,8 +1,141 @@ -import OpenAI from 'openai'; +import OpenAI, { APIError, RateLimitError, APIConnectionError, AuthenticationError } from 'openai'; import type { ChatCompletionContentPart } from 'openai/resources/chat/completions'; interface LLMConfig { apiKey: string; baseURL: string; model: string; } +// 重试配置 +const RETRY_CONFIG = { + maxRetries: 3, + initialDelayMs: 1000, + maxDelayMs: 30000, +}; + +// 自定义错误类型,用于前端识别 +export class LLMError extends Error { + constructor( + message: string, + public readonly code: string, + public readonly statusCode?: number, + public readonly retryable: boolean = false + ) { + super(message); + this.name = 'LLMError'; + } +} + +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +function handleAPIError(error: unknown): never { + if (error instanceof RateLimitError) { + throw new LLMError( + 'API rate limit exceeded. Please wait a moment and try again.', + 'RATE_LIMIT', + 429, + true + ); + } + + if (error instanceof AuthenticationError) { + throw new LLMError( + 'Invalid API key. Please check your API key configuration.', + 'AUTH_ERROR', + 401, + false + ); + } + + if (error instanceof APIConnectionError) { + throw new LLMError( + 'Failed to connect to API server. Please check your network or API base URL.', + 'CONNECTION_ERROR', + undefined, + true + ); + } + + if (error instanceof APIError) { + const statusCode = error.status; + if (statusCode === 429) { + throw new LLMError( + 'API rate limit exceeded. Please wait a moment and try again.', + 'RATE_LIMIT', + 429, + true + ); + } + if (statusCode === 401 || statusCode === 403) { + throw new LLMError( + 'Authentication failed. Please check your API key.', + 'AUTH_ERROR', + statusCode, + false + ); + } + if (statusCode === 400) { + throw new LLMError( + 'Invalid request. Please check your input parameters.', + 'BAD_REQUEST', + 400, + false + ); + } + if (statusCode && statusCode >= 500) { + throw new LLMError( + 'API server error. Please try again later.', + 'SERVER_ERROR', + statusCode, + true + ); + } + throw new LLMError( + error.message || 'An unexpected API error occurred.', + 'API_ERROR', + statusCode, + false + ); + } + + if (error instanceof Error) { + throw new LLMError(error.message, 'UNKNOWN_ERROR', undefined, false); + } + + throw new LLMError('An unexpected error occurred.', 'UNKNOWN_ERROR', undefined, false); +} + +async function withRetry( + fn: () => Promise, + maxRetries = RETRY_CONFIG.maxRetries +): Promise { + let lastError: Error = new Error('Unknown error'); + let delay = RETRY_CONFIG.initialDelayMs; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await fn(); + } catch (error) { + lastError = error as Error; + + // 检查是否是可重试的错误 + const isRetryable = + error instanceof RateLimitError || + error instanceof APIConnectionError || + (error instanceof APIError && error.status && error.status >= 500) || + (error instanceof LLMError && error.retryable); + + if (isRetryable && attempt < maxRetries) { + console.log(`Retry attempt ${attempt + 1}/${maxRetries} after ${delay}ms...`); + await sleep(delay); + delay = Math.min(delay * 2, RETRY_CONFIG.maxDelayMs); + continue; + } + + handleAPIError(error); + } + } + + handleAPIError(lastError); +} + function normalizeBaseURL(url: string): string { const parsed = new URL(url.trim()); if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') throw new Error('Base URL must use http or https'); @@ -30,22 +163,33 @@ const buildUserContent = (userPrompt: string, pdfDataUrl?: string): MessageConte export async function generateWithLLM(systemPrompt: string, userPrompt: string, pdfDataUrl?: string, config?: Partial): Promise { const { client, model } = createClient(config); - const response = await client.chat.completions.create({ - model, - messages: [{ role: 'system', content: systemPrompt }, { role: 'user', content: buildUserContent(userPrompt, pdfDataUrl) }], + + return withRetry(async () => { + const response = await client.chat.completions.create({ + model, + messages: [{ role: 'system', content: systemPrompt }, { role: 'user', content: buildUserContent(userPrompt, pdfDataUrl) }], + }); + return response.choices[0]?.message?.content || ''; }); - return response.choices[0]?.message?.content || ''; } export async function* generateWithLLMStream(systemPrompt: string, userPrompt: string, pdfDataUrl?: string, config?: Partial): AsyncGenerator { const { client, model } = createClient(config); - const stream = await client.chat.completions.create({ - model, - messages: [{ role: 'system', content: systemPrompt }, { role: 'user', content: buildUserContent(userPrompt, pdfDataUrl) }], - stream: true, + + const stream = await withRetry(async () => { + return client.chat.completions.create({ + model, + messages: [{ role: 'system', content: systemPrompt }, { role: 'user', content: buildUserContent(userPrompt, pdfDataUrl) }], + stream: true, + }); }); - for await (const chunk of stream) { - const content = chunk.choices[0]?.delta?.content; - if (content) yield content; + + try { + for await (const chunk of stream) { + const content = chunk.choices[0]?.delta?.content; + if (content) yield content; + } + } catch (error) { + handleAPIError(error); } } diff --git a/src/components/SessionSidebar.tsx b/src/components/SessionSidebar.tsx index 3e25fa3..77d6943 100644 --- a/src/components/SessionSidebar.tsx +++ b/src/components/SessionSidebar.tsx @@ -95,33 +95,17 @@ export function SessionSidebar({ isOpen }: SessionSidebarProps) { )} > -
+
{editingId === session.id ? ( -
+
setEditValue(e.target.value)} onKeyDown={handleKeyDown} onClick={(e) => e.stopPropagation()} - className="h-7 text-sm py-0 px-1 flex-1 min-w-0 max-w-full" + className="h-7 text-sm py-0 px-2 w-full ml-1" autoFocus /> - -
) : (
@@ -136,7 +120,26 @@ export function SessionSidebar({ isOpen }: SessionSidebarProps) {
)}
- {editingId !== session.id && ( + {editingId === session.id ? ( +
+ + +
+ ) : (
-
+
5 @@ -275,7 +275,7 @@ export default function Index() {
-