Sfoglia il codice sorgente

chore(server): 提交本地 LLM/schema/config 改动

涉及文件 (非本次会话改动):
- server/prisma/schema.prisma           (+1)
- server/src/app.ts                     (+2)
- server/src/config/index.ts            (+6/-?)
- server/src/modules/book-generator/book-generator.store.ts (+3)
- server/src/services/llm/index.ts      (+70/-30)
- server/src/services/llm/response-cleaner.ts (+28)

Co-Authored-By: Claude <noreply@anthropic.com>
MyFramework User 1 mese fa
parent
commit
ac2cdb7f90

+ 1 - 0
server/prisma/schema.prisma

@@ -193,6 +193,7 @@ model BookChapter {
   generatedAt    DateTime?
   audioUrl       String?        @db.Text
   audioDuration  Int            @default(0)
+  audioSource    String?        @db.VarChar(20) // 'full' | 'on_demand' | null — 区分完整异步生成 vs 按需同步合成
   videoUrl       String?        @db.Text
   videoDuration  Int?
   isPublic       Boolean        @default(false)

+ 2 - 0
server/src/app.ts

@@ -52,6 +52,7 @@ import aiGenerateRoutes from './modules/book-generator/ai-generate-controller';
 import albumRoutes from './modules/book-generator/album-controller';
 import albumManagementRoutes from './modules/book-generator/album-management.controller';
 import bookGeneratorRoutes from './modules/book-generator/book-generator.controller';
+import chapterReadRoutes from './modules/book-generator/chapter-read.controller';
 import playlistRoutes from './modules/player/playlist.controller';
 import draftsRoutes from './modules/drafts/drafts.controller';
 import videoGeneratorRoutes from './modules/video-generator/video-generator.controller';
@@ -154,6 +155,7 @@ router.use('/api/book-generator/langgraph', langGraphRoutes.routes());
 router.use('/api/book-generator', aiGenerateRoutes.routes());
 router.use('/api/book-generator', albumRoutes.routes());
 router.use('/api/book-generator', bookGeneratorRoutes.routes());
+router.use('/api/book-generator', chapterReadRoutes.routes());
 router.use('/api/book-generator/album', albumManagementRoutes.routes());
 router.use('/api/playlists', playlistRoutes.routes());
 router.use('/api/drafts', draftsRoutes.routes());

+ 4 - 2
server/src/config/index.ts

@@ -43,10 +43,12 @@ function getModelsByType(type: string) {
 }
 
 // 检查模型是否可切换(根据错误类型判断是否需要切换)
+// 兼容两种调用方式:传入 error 对象 或 error.message 字符串
 function shouldSwitchModel(error: any): boolean {
   if (!error) return false;
-  const message = (error?.message || error?.error?.message || '').toLowerCase();
-  const status = error?.status || error?.response?.status || 0;
+  const isString = typeof error === 'string';
+  const message = (isString ? error : (error?.message || error?.error?.message || '')).toLowerCase();
+  const status = isString ? 0 : (error?.status || error?.response?.status || 0);
 
   // 不可切换的错误:认证/权限/参数问题,换供应商也没用
   const nonSwitchablePatterns = [

+ 3 - 0
server/src/modules/book-generator/book-generator.store.ts

@@ -1745,6 +1745,9 @@ ${writingStyle ? `写作风格:${writingStyle}` : ''}
       await this.updateChapterById(chapterId, {
         content,
         wordCount,
+        // 成功后清理历史的失败错误(429/quota 等)
+        // 之前的问题:429 失败后切换供应商成功了,但 contentError 一直残留
+        contentError: null,
       });
 
       // 推进到 content_completed(如已越过则跳过,不抛异常)

+ 53 - 17
server/src/services/llm/index.ts

@@ -227,7 +227,7 @@ async function invokeWithRetry<T>(
     return await withAiLog(fn, { callType, provider, model: modelId });
   } catch (error: any) {
     // 不可切换的错误,直接抛出
-    if (!config.models.shouldSwitchModel(error?.message || '')) {
+    if (!config.models.shouldSwitchModel(error)) {
       throw error;
     }
 
@@ -310,7 +310,7 @@ export async function callLLMWithMessages(
     return responseContent;
   } catch (error: any) {
     // 不可切换的错误,直接抛出
-    if (!config.models.shouldSwitchModel(error?.message || '')) {
+    if (!config.models.shouldSwitchModel(error)) {
       throw error;
     }
 
@@ -464,7 +464,7 @@ export async function* callLLMStream(
       yield content;
     }
   } catch (error: any) {
-    if (!config.models.shouldSwitchModel(error?.message || '')) {
+    if (!config.models.shouldSwitchModel(error)) {
       throw error;
     }
     // 流式无法完美重试(已部分yield),直接切换供应商
@@ -504,12 +504,16 @@ function trySwitchModel(currentModelId: string, error: any): string | null {
   const errorMessage = error?.message || '';
   const registry = getLlmRegistry();
 
-  // 1. 额度耗尽检测:标记该供应商并切换(4小时自动恢复)
+  // 1. 额度耗尽检测:标记该供应商并立即切换(4小时自动恢复)
   if (EXHAUSTED_PATTERNS.some(p => errorMessage.toLowerCase().includes(p))) {
     const currentNode = findProviderNodeForModel(currentModelId);
     if (currentNode) {
+      const exhaustedName = currentNode.provider.name;
       // 4小时 TTL 自动恢复
-      registry.markExhausted(currentNode.provider.name, errorMessage, 4 * 60 * 60 * 1000);
+      registry.markExhausted(exhaustedName, errorMessage, 4 * 60 * 60 * 1000);
+      console.log(`[LLM] 供应商 ${currentNode.provider.displayName} 额度耗尽,立即切换`);
+      // 传入被耗尽的供应商名,确保从它之后开始查找
+      return switchToNextVendorModel(currentModelId, exhaustedName);
     }
   }
 
@@ -530,7 +534,7 @@ function trySwitchModel(currentModelId: string, error: any): string | null {
   }
 
   // 4. 可恢复错误,调用通用判断(限流/余额/服务不可用等)
-  if (config.models.shouldSwitchModel(errorMessage)) {
+  if (config.models.shouldSwitchModel(error)) {
     return switchToNextVendorModel(currentModelId);
   }
 
@@ -542,9 +546,8 @@ function trySwitchModel(currentModelId: string, error: any): string | null {
  * 优先查找同名/别名模型,保持输出一致性
  * 找不到同名模型时回退到该供应商的第一个文本模型
  */
-function switchToNextVendorModel(currentModelId: string): string | null {
+function switchToNextVendorModel(currentModelId: string, skipProviderName?: string): string | null {
   const registry = getLlmRegistry();
-  const currentNode = findProviderNodeForModel(currentModelId);
   const available = registry.listAvailable();
 
   if (available.length === 0) {
@@ -552,20 +555,49 @@ function switchToNextVendorModel(currentModelId: string): string | null {
     return null;
   }
 
-  // 从当前供应商的下一个开始找
-  const startIndex = currentNode
-    ? (available.findIndex(n => n.provider.name === currentNode.provider.name) + 1) % available.length
-    : 0;
+  // 切换供应商时必须清除模型缓存,否则 getLLM 可能返回旧供应商的客户端
+  // (缓存 key 只有 modelId,不区分供应商,切换后同名模型会命中旧缓存)
+  const invalidateAndReturn = (nextId: string): string => {
+    modelCache.delete(nextId);
+    modelCache.delete(currentModelId); // 旧 modelId 也需要清,防止后续调用命中原供应商缓存
+    return nextId;
+  };
+
+  // 确定起始查找位置
+  // 如果有 skipProviderName(被耗尽的供应商),从它后面开始找
+  // 否则从当前 modelId 所在供应商后面开始找
+  let startIndex = 0;
+  if (skipProviderName) {
+    // 被耗尽的供应商已不在 available 中,需要在全部已启用供应商中定位它
+    const allEnabled = registry.listEnabled();
+    const exhaustedPos = allEnabled.findIndex(n => n.provider.name === skipProviderName);
+    if (exhaustedPos >= 0) {
+      // 从耗尽供应商的下一个开始,找到第一个仍在 available 中的
+      for (let offset = 1; offset <= allEnabled.length; offset++) {
+        const checkName = allEnabled[(exhaustedPos + offset) % allEnabled.length].provider.name;
+        const availIdx = available.findIndex(n => n.provider.name === checkName);
+        if (availIdx >= 0) {
+          startIndex = availIdx;
+          break;
+        }
+      }
+    }
+  } else {
+    // 无供应商被耗尽:从当前模型所在供应商之后开始
+    const currentNode = findProviderNodeForModel(currentModelId);
+    if (currentNode) {
+      startIndex = (available.findIndex(n => n.provider.name === currentNode.provider.name) + 1) % available.length;
+    }
+  }
 
   for (let i = 0; i < available.length; i++) {
     const idx = (startIndex + i) % available.length;
     const node = available[idx];
-    if (node === currentNode) continue;
 
     // 1. 精确匹配:下一个供应商是否也提供同名模型
     if (node.provider.hasModel(currentModelId)) {
       console.log(`[LLM] 同模型切换到供应商 ${node.provider.displayName},模型 ${currentModelId}`);
-      return currentModelId;
+      return invalidateAndReturn(currentModelId);
     }
 
     // 2. 别名匹配:下一个供应商中是否有 canonicalModel 指向当前模型的
@@ -573,15 +605,19 @@ function switchToNextVendorModel(currentModelId: string): string | null {
       const cfg = node.provider.getModelConfig(modelId);
       if (cfg?.canonicalModel === currentModelId) {
         console.log(`[LLM] 别名模型切换到供应商 ${node.provider.displayName},模型 ${modelId}`);
-        return modelId;
+        return invalidateAndReturn(modelId);
       }
     }
+  }
 
-    // 3. 无同名/别名模型,使用该供应商的第一个文本模型
+  // 第一轮没找到同名/别名模型,第二轮 fallback:用第一个可用供应商的首个模型
+  for (let i = 0; i < available.length; i++) {
+    const idx = (startIndex + i) % available.length;
+    const node = available[idx];
     if (node.provider.textModels.length > 0) {
       const nextId = node.provider.textModels[0];
       console.log(`[LLM] 切换到供应商 ${node.provider.displayName},模型 ${nextId}`);
-      return nextId;
+      return invalidateAndReturn(nextId);
     }
   }
 

+ 17 - 11
server/src/services/llm/response-cleaner.ts

@@ -40,13 +40,14 @@ export function cleanLlmResponse(raw: string): string {
     cleaned = cleaned.replace(pattern, '');
   }
 
-  // 2. 移除未闭合思考标签(从开标签到文本末尾)
+  // 2. 移除未闭合思考标签(从开标签到文本末尾,排除自闭合 <think/>)
+  // 用非自闭合标签的判定:开标签以 > 结尾但没有 / 在 > 前
   const unclosedPatterns = [
-    /<think\b[^>]*>[\s\S]*$/gi,
-    /<thinking\b[^>]*>[\s\S]*$/gi,
-    /<thought\b[^>]*>[\s\S]*$/gi,
-    /<reflection\b[^>]*>[\s\S]*$/gi,
-    /<reasoning\b[^>]*>[\s\S]*$/gi,
+    /<think\b[^>\/]*>[\s\S]*$/gi,
+    /<thinking\b[^>\/]*>[\s\S]*$/gi,
+    /<thought\b[^>\/]*>[\s\S]*$/gi,
+    /<reflection\b[^>\/]*>[\s\S]*$/gi,
+    /<reasoning\b[^>\/]*>[\s\S]*$/gi,
   ];
 
   for (const pattern of unclosedPatterns) {
@@ -54,8 +55,8 @@ export function cleanLlmResponse(raw: string): string {
   }
 
   // 3. 移除自闭合标签
-  cleaned = cleaned.replace(/<think\s*\/>/gi, '');
-  cleaned = cleaned.replace(/<thinking\s*\/>/gi, '');
+  cleaned = cleaned.replace(/<think\s*?\/>/gi, '');
+  cleaned = cleaned.replace(/<thinking\s*?\/>/gi, '');
 
   // 4. 移除孤立的闭标签
   cleaned = cleaned.replace(/<\/think\s*>/gi, '');
@@ -85,6 +86,8 @@ export function cleanLlmShortText(
     maxEnglishWords?: number; // 英文最大词数(默认 20)
   } = {}
 ): string {
+  if (!raw || typeof raw !== 'string') return '';
+
   const { maxLength = 50, maxChineseChars = 20, maxEnglishWords = 20 } = options;
 
   let text = cleanLlmResponse(raw);
@@ -92,9 +95,10 @@ export function cleanLlmShortText(
   // 去除 Markdown 格式
   text = text
     .replace(/^#+\s*/gm, '')                        // 标题标记
-    .replace(/\*{1,3}([^*]+)\*{1,3}/g, '$1')        // 加粗/斜体
-    .replace(/`{1,3}[^`]+`{1,3}/g, '')              // 代码块
-    .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')        // 链接
+    .replace(/\*\*([^*]+)\*\*/g, '$1')              // 加粗 **xxx**
+    .replace(/\*([^*]+)\*/g, '$1')                  // 斜体 *xxx*
+    .replace(/`{1,3}([^`]+)`{1,3}/g, '$1')          // 行内代码 `xxx` → 保留内容
+    .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')        // 链接 [text](url) → text
     .replace(/["""《》''「」『』]/g, '')               // 各种引号
     .replace(/[\n\r]+/g, ' ')                        // 换行转空格
     .replace(/^书名[::]\s*/i, '')                    // 去掉可能的"书名:"前缀
@@ -137,7 +141,9 @@ export function cleanLlmShortText(
  * 在 cleanLlmResponse 基础上尝试提取 JSON 对象
  */
 export function extractJsonFromResponse<T = any>(raw: string): T | null {
+  if (!raw || typeof raw !== 'string') return null;
   const cleaned = cleanLlmResponse(raw);
+  if (!cleaned) return null;
 
   // 尝试直接解析
   try {