소스 검색

feat(ai-content): 优化内容生成体验

- 添加流式生成API (streamGenerateChunk method)
- 添加SSE端点 /chunk/stream 支持实时流式输出
- 前端显示生成进度和已生成字数
- 后端支持流式调用DashScope LLM API

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
MyFramework User 5 달 전
부모
커밋
d62ebaed56

+ 21 - 4
my-uniapp-vue3/src/pages/ai-content/index.vue

@@ -187,8 +187,8 @@
         <!-- 生成进度 -->
         <view v-if="generating" class="progress-section">
           <view class="progress-header">
-            <text>正在生成第 {{ currentChapter + 1 }} 章...</text>
-            <text>{{ Math.round((currentChapter + 1) / outline.length * 100) }}%</text>
+            <text>{{ generatingProgress }}</text>
+            <text v-if="currentCharCount > 0">{{ currentCharCount }} 字</text>
           </view>
           <progress
             :percent="Math.round((currentChapter + 1) / outline.length * 100)"
@@ -335,6 +335,8 @@ const characters = ref<any[]>([]);
 const generating = ref(false);
 const currentChapter = ref(0);
 const generatedChapters = ref<string[]>([]);
+const generatingProgress = ref(''); // 当前进度
+const currentCharCount = ref(0); // 当前已生成字数
 
 // 质量检测
 const sensitiveResult = ref<{ isClean: boolean; foundWords: string[] }>({ isClean: true, foundWords: [] });
@@ -415,22 +417,37 @@ async function startGeneration() {
   generating.value = true;
   currentChapter.value = 0;
   generatedChapters.value = [];
+  generatingProgress.value = '准备开始...';
+  currentCharCount.value = 0;
 
   try {
     for (let i = 0; i < outline.value.length; i++) {
       currentChapter.value = i;
+      generatingProgress.value = `正在生成第 ${i + 1}/${outline.value.length} 章...`;
+      currentCharCount.value = 0;
+
+      const chapterTitle = outline.value[i]?.title || `第${i + 1}章`;
+      const previousContent = i > 0 ? generatedChapters.value[i - 1] : '';
+
       const result = await post<any>('/ai-content/chunk/generate', {
         outlineId: `outline-${Date.now()}`,
         chapterIndex: i,
+        chapterTitle,
+        previousContent,
       });
-      generatedChapters.value.push(result.content || `这是第${i + 1}章的内容...\n\n[模拟生成的内容]`);
+
+      generatedChapters.value.push(result.content || `这是第${i + 1}章的内容...`);
+      currentCharCount.value = result.wordCount || result.content?.length || 0;
+      generatingProgress.value = `第 ${i + 1}/${outline.value.length} 章完成 (${currentCharCount.value}字)`;
     }
 
     // 自动进行质量检测
+    generatingProgress.value = '进行质量检测...';
     await checkQuality();
     currentStep.value = 4;
+    generatingProgress.value = '';
   } catch (e: any) {
-    uni.showToast({ title: '生成失败', icon: 'none' });
+    uni.showToast({ title: '生成失败: ' + (e.message || '未知错误'), icon: 'none' });
   } finally {
     generating.value = false;
   }

+ 24 - 0
server/src/modules/ai-content/ai-content.controller.ts

@@ -72,6 +72,30 @@ router.post('/chunk/generate', async (ctx) => {
   ctx.body = { code: 0, message: 'success', data: result };
 });
 
+// 流式生成章节内容(SSE)
+router.post('/chunk/stream', async (ctx) => {
+  const { outlineId, chapterIndex, chapterTitle, previousContent } = ctx.request.body as {
+    outlineId: string;
+    chapterIndex: number;
+    chapterTitle?: string;
+    previousContent?: string;
+  };
+
+  ctx.set('Content-Type', 'text/event-stream');
+  ctx.set('Cache-Control', 'no-cache');
+  ctx.set('Connection', 'keep-alive');
+  ctx.set('X-Accel-Buffering', 'no');
+
+  try {
+    for await (const chunk of aiContentService.streamGenerateChunk(outlineId, chapterIndex, chapterTitle, previousContent)) {
+      ctx.write(`data: ${JSON.stringify(chunk)}\n\n`);
+    }
+  } catch (error: any) {
+    ctx.write(`data: ${JSON.stringify({ type: 'error', message: error.message })}\n\n`);
+  }
+  ctx.end();
+});
+
 // 流式生成(模拟SSE)
 router.get('/stream/generate', async (ctx) => {
   const { outlineId } = ctx.query;

+ 145 - 0
server/src/modules/ai-content/ai-content.service.ts

@@ -44,6 +44,74 @@ export class AIContentService {
     this.model = DEFAULT_MODEL;
   }
 
+  /**
+   * 调用 DashScope API (流式)
+   */
+  private async *streamLLM(prompt: string, systemPrompt?: string): AsyncGenerator<string> {
+    if (!this.apiKey) {
+      throw new Error('未配置 AI API Key');
+    }
+
+    const messages: any[] = [];
+    if (systemPrompt) {
+      messages.push({ role: 'system', content: systemPrompt });
+    }
+    messages.push({ role: 'user', content: prompt });
+
+    try {
+      const response = await axios.post(
+        'https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation',
+        {
+          model: this.model,
+          input: {
+            messages,
+          },
+          parameters: {
+            result_format: 'message',
+            stream: true,
+          },
+        },
+        {
+          headers: {
+            'Authorization': `Bearer ${this.apiKey}`,
+            'Content-Type': 'application/json',
+          },
+          timeout: 180000,
+          responseType: 'stream',
+        }
+      );
+
+      let buffer = '';
+      for await (const chunk of response.data) {
+        buffer += chunk.toString();
+
+        // 解析SSE格式的数据
+        const lines = buffer.split('\n');
+        buffer = lines.pop() || '';
+
+        for (const line of lines) {
+          if (line.startsWith('data:')) {
+            const data = line.slice(5).trim();
+            if (data && data !== '[DONE]') {
+              try {
+                const parsed = JSON.parse(data);
+                const content = parsed.output?.choices?.[0]?.message?.content;
+                if (content) {
+                  yield content;
+                }
+              } catch (e) {
+                // 忽略解析错误
+              }
+            }
+          }
+        }
+      }
+    } catch (error: any) {
+      console.error('❌ LLM 流式调用失败:', error.response?.data || error.message);
+      throw new Error(error.message || 'AI 生成失败');
+    }
+  }
+
   /**
    * 调用 DashScope API
    */
@@ -344,6 +412,83 @@ ${chapterTitle ? `章节标题:${chapterTitle}` : ''}
     }
   }
 
+  /**
+   * 流式生成章节内容 - 使用 LLM SSE
+   */
+  async *streamGenerateChunk(outlineId: string, chapterIndex: number, chapterTitle?: string, previousContent?: string) {
+    const prompt = `请续写以下小说内容:
+
+${previousContent ? `前文内容:\n${previousContent}\n\n` : ''}
+请续写第${chapterIndex + 1}章内容。
+
+${chapterTitle ? `章节标题:${chapterTitle}` : ''}
+
+要求:
+1. 内容要丰富、生动,不少于2000字
+2. 包含人物对话、心理描写、场景描写
+3. 情节要紧凑,有吸引力
+4. 直接返回正文内容,不需要额外说明`;
+
+    const chunkId = `chunk-${Date.now()}-${chapterIndex}`;
+    let fullContent = '';
+    let charCount = 0;
+
+    // 先发送开始信号
+    yield {
+      type: 'start',
+      chunkId,
+      chapterIndex,
+      message: '开始生成...',
+    };
+
+    try {
+      for await (const chunk of this.streamLLM(prompt)) {
+        fullContent += chunk;
+        charCount += chunk.length;
+
+        // 实时发送内容片段
+        yield {
+          type: 'content',
+          chunkId,
+          chapterIndex,
+          content: chunk,
+          charCount,
+          message: `已生成 ${charCount} 字...`,
+        };
+      }
+
+      // 发送完成信号
+      yield {
+        type: 'done',
+        chunkId,
+        chapterIndex,
+        content: fullContent,
+        charCount: fullContent.length,
+        wordCount: this.estimateWordCount(fullContent),
+        status: 'completed',
+        message: '生成完成!',
+      };
+    } catch (error: any) {
+      console.error('流式生成失败:', error);
+      yield {
+        type: 'error',
+        chunkId,
+        chapterIndex,
+        message: error.message || '生成失败',
+        status: 'error',
+      };
+    }
+  }
+
+  /**
+   * 估算字数(中文按字符,英文按单词)
+   */
+  private estimateWordCount(text: string): number {
+    const chineseChars = (text.match(/[\u4e00-\u9fa5]/g) || []).length;
+    const englishWords = (text.match(/[a-zA-Z]+/g) || []).length;
+    return chineseChars + Math.floor(englishWords * 0.5);
+  }
+
   /**
    * 流式生成(模拟SSE)
    */