Forráskód Böngészése

fix: CosyVoice SSE 增加详细诊断日志,定位未收到音频数据错误

- 移除静默 catch{},改为记录原始数据和解析失败原因
- 错误信息增加诊断上下文:eventCount/audioCount/lastError/最近5个事件
- 非音频SSE事件也记录便于调试
- HTTP非200响应读取body内容放入错误信息
- 增加请求/响应信息日志(endpoint/model/voice/textLen/status/contentType)
MyFramework User 3 hónapja
szülő
commit
6429b1cef2
100 módosított fájl, 4322 hozzáadás és 261 törlés
  1. 4 0
      deploy-package/server/app.js
  2. 6 8
      deploy-package/server/modules/book-generator/langgraph/book-langgraph.js
  3. 4 5
      deploy-package/server/modules/book-generator/langgraph/controller.js
  4. 81 0
      deploy-package/server/modules/queue/queue.controller.js
  5. 326 0
      deploy-package/server/modules/queue/queue.service.js
  6. 337 0
      deploy-package/server/public/admin/queue.html
  7. 1 1
      my-uniapp-vue3/src/manifest.json
  8. 3 3
      my-uniapp-vue3/src/pages/book-generator/create.vue
  9. 1 1
      my-uniapp-vue3/src/pages/login/index.vue
  10. 2 2
      my-uniapp-vue3/src/pages/mine/index.vue
  11. 6 0
      my-uniapp-vue3/src/utils/request.ts
  12. 0 0
      server/audio-book-server@1.0.0
  13. 2 2
      server/check-book-fields.js
  14. 1 1
      server/create-mini-book.js
  15. 1 1
      server/create-test-book.js
  16. 33 0
      server/debug-control.js
  17. 34 0
      server/debug-jsonrepair.js
  18. 51 0
      server/debug-parse.js
  19. 112 0
      server/debug-parse10.js
  20. 75 0
      server/debug-parse11.js
  21. 94 0
      server/debug-parse12.js
  22. 71 0
      server/debug-parse13.js
  23. 76 0
      server/debug-parse14.js
  24. 127 0
      server/debug-parse15.js
  25. 89 0
      server/debug-parse16.js
  26. 132 0
      server/debug-parse17.js
  27. 75 0
      server/debug-parse18.js
  28. 95 0
      server/debug-parse19.js
  29. 45 0
      server/debug-parse2.js
  30. 97 0
      server/debug-parse20.js
  31. 112 0
      server/debug-parse21.js
  32. 119 0
      server/debug-parse22.js
  33. 76 0
      server/debug-parse23.js
  34. 109 0
      server/debug-parse24.js
  35. 37 0
      server/debug-parse25.js
  36. 113 0
      server/debug-parse26.js
  37. 123 0
      server/debug-parse27.js
  38. 103 0
      server/debug-parse28.js
  39. 74 0
      server/debug-parse3.js
  40. 31 0
      server/debug-parse4.js
  41. 72 0
      server/debug-parse5.js
  42. 41 0
      server/debug-parse6.js
  43. 53 0
      server/debug-parse7.js
  44. 82 0
      server/debug-parse8.js
  45. 58 0
      server/debug-parse9.js
  46. 64 0
      server/debug-truncate.js
  47. 2 2
      server/fix-controller-queue.js
  48. 1 1
      server/fix-remaining-queues.js
  49. 2 1
      server/prisma/schema.prisma
  50. 1 1
      server/regenerate-book.js
  51. 50 151
      server/src/modules/book-generator/book-generator.store.ts
  52. 1 1
      server/src/modules/book-generator/book-generator.types.ts
  53. 22 1
      server/src/modules/book-generator/book-type-config.ts
  54. 1 1
      server/src/modules/book-generator/graph.ts
  55. 1 1
      server/src/modules/book-generator/index.ts
  56. 92 56
      server/src/modules/book-generator/langgraph-controller.ts
  57. 20 1
      server/src/modules/book-generator/nodes/deep-plan.node.ts
  58. 21 1
      server/src/modules/book-generator/nodes/plan.node.ts
  59. 1 1
      server/src/modules/book-generator/nodes/quality-check.node.ts
  60. 22 1
      server/src/modules/book-generator/prompts/builder.ts
  61. 0 9
      server/src/modules/book-generator/tts-queue.ts
  62. 1 1
      server/src/modules/subscription/subscription.controller.ts
  63. 50 5
      server/src/modules/tts/aliyun.provider.ts
  64. 1 1
      server/sync-generate.js
  65. 1 1
      server/test-content-gen.js
  66. 46 0
      server/test-detect.js
  67. 48 0
      server/test-quick.js
  68. 0 0
      server/tsx
  69. 3 0
      test-results/sse_test.mp3
  70. 86 0
      test/book_audio_test.py
  71. 23 0
      test/debug_endpoint.py
  72. 36 0
      test/dl_voices.py
  73. 104 0
      test/download_all_voices.py
  74. 94 0
      test/download_voices.py
  75. 19 0
      test/gen_index.py
  76. 112 0
      test/gen_voice_samples.py
  77. 19 0
      test/quick_test.py
  78. 15 0
      test/quick_test2.py
  79. 13 0
      test/quick_test3.py
  80. 25 0
      test/test_sse.py
  81. 14 0
      test/test_v35.py
  82. 73 0
      voices/INDEX.md
  83. 3 0
      voices/longanhuan.mp3
  84. 3 0
      voices/longanlang_v3.mp3
  85. 3 0
      voices/longanli_v3.mp3
  86. 3 0
      voices/longanling_v3.mp3
  87. 3 0
      voices/longanqin_v3.mp3
  88. 3 0
      voices/longanran_v3.mp3
  89. 3 0
      voices/longanrou_v3.mp3
  90. 3 0
      voices/longantai_v3.mp3
  91. 3 0
      voices/longanwen_v3.mp3
  92. 3 0
      voices/longanxuan_v3.mp3
  93. 3 0
      voices/longanya_v3.mp3
  94. 3 0
      voices/longanyang.mp3
  95. 3 0
      voices/longanyue_v3.mp3
  96. 3 0
      voices/longanyun_v3.mp3
  97. 3 0
      voices/longanzhi_v3.mp3
  98. 3 0
      voices/longcheng_v3.mp3
  99. 3 0
      voices/longdaiyu_v3.mp3
  100. 3 0
      voices/longfei_v3.mp3

+ 4 - 0
deploy-package/server/app.js

@@ -32,6 +32,7 @@ const audioedit_controller_1 = __importDefault(require("./modules/audioedit/audi
 const book_generator_controller_1 = __importDefault(require("./modules/book-generator/book-generator.controller"));
 const controller_1 = __importDefault(require("./modules/book-generator/langgraph/controller"));
 const video_generator_controller_1 = __importDefault(require("./modules/video-generator/video-generator.controller"));
+const queue_controller_1 = __importDefault(require("./modules/queue/queue.controller"));
 const app = new koa_1.default();
 const router = new router_1.default();
 // 中间件
@@ -53,6 +54,8 @@ app.use((0, koa_body_1.default)({
 app.use((0, koa_mount_1.default)('/uploads', (0, koa_static_1.default)(config_1.config.upload.dir)));
 // 静态文件服务 - 视频文件
 app.use((0, koa_mount_1.default)('/videos', (0, koa_static_1.default)(path_1.default.join(process.cwd(), 'public', 'videos'))));
+// 静态文件服务 - 管理后台页面
+app.use((0, koa_mount_1.default)('/admin', (0, koa_static_1.default)(path_1.default.join(process.cwd(), 'public', 'admin'))));
 // 健康检查
 router.get('/health', (ctx) => {
     ctx.body = { code: 0, message: 'ok', data: { status: 'healthy' } };
@@ -77,6 +80,7 @@ router.use('/api/audio', audioedit_controller_1.default.routes());
 router.use('/api/book-generator', book_generator_controller_1.default.routes());
 router.use('/api/book-generator/langgraph', controller_1.default.routes());
 router.use('/api/video', video_generator_controller_1.default.routes());
+router.use('/api/queue', queue_controller_1.default.routes());
 app.use(router.routes()).use(router.allowedMethods());
 // 启动服务
 async function start() {

+ 6 - 8
deploy-package/server/modules/book-generator/langgraph/book-langgraph.js

@@ -17,7 +17,7 @@ const langgraph_1 = require("@langchain/langgraph");
 const GraphState = langgraph_1.Annotation.Root({
     bookId: (langgraph_1.Annotation),
     topic: (langgraph_1.Annotation),
-    bookScale: (langgraph_1.Annotation), // '小册子' | '标准教程' | '系统教材' | '专业厚本' | '大部头'
+    bookScale: (langgraph_1.Annotation), // 纯数字字符串,如 '1000', '130000', '340000'
     currentChapter: (langgraph_1.Annotation),
     finished: (langgraph_1.Annotation),
     error: (langgraph_1.Annotation),
@@ -86,9 +86,9 @@ const SCALE_CHAPTER_RANGE = {
     '800': { min: 1, max: 1, wordsPerChapter: 800 },
     '2000': { min: 1, max: 1, wordsPerChapter: 2000 },
     '5000': { min: 1, max: 1, wordsPerChapter: 5000 },
-    小册子: { min: 3, max: 8, wordsPerChapter: 3000 },
-    标准教程: { min: 5, max: 15, wordsPerChapter: 2500 },
-    系统教材: { min: 10, max: 20, wordsPerChapter: 2500 },
+    '2000': { min: 2, max: 8, wordsPerChapter: 3000 },
+    '130000': { min: 5, max: 15, wordsPerChapter: 2500 },
+    '340000': { min: 10, max: 20, wordsPerChapter: 2500 },
 };
 async function generateOutlineNode(state) {
     console.log('[LangGraph] 生成大纲, bookId:', state.bookId, 'scale:', state.bookScale);
@@ -97,10 +97,8 @@ async function generateOutlineNode(state) {
         '800': '短文,约800字',
         '2000': '短文,约2000字',
         '5000': '短文,约5000字',
-        小册子: '小册子,1-5万字,快速上手/技巧合集,3-8章',
-        标准教程: '标准教程,5-15万字,大众技能/职场工具,5-15章',
-        系统教材: '系统教材,15-30万字,完整课程/职业培训,10-20章',
     };
+    const scaleLabel = scaleDesc[state.bookScale] || `书籍,约${state.bookScale}字`;
     const prompt = `请为书籍《${state.topic}》设计大纲。
 
 书籍规模:${scaleDesc[state.bookScale] || '中篇'}
@@ -245,7 +243,7 @@ function createGraph() {
 // ============ 主类 ============
 class LangGraphBookGenerator {
     graph = createGraph();
-    async generate(bookId, topic, bookScale = 'medium') {
+    async generate(bookId, topic, bookScale = '130000') {
         const initialState = {
             bookId,
             topic,

+ 4 - 5
deploy-package/server/modules/book-generator/langgraph/controller.js

@@ -22,15 +22,14 @@ router.post('/books', async (ctx) => {
             ctx.body = { code: 1, message: '书名和描述不能为空' };
             return;
         }
-        const bookScale = body.bookScale || '标准教程';
+        const bookScale = body.bookScale || '130000';
         // 创建书籍(先设置一个预估章节数,实际数量由AI分析后确定)
         const scaleToChapters = {
             '800': 1,
             '2000': 1,
             '5000': 1,
-            小册子: 5,
-            标准教程: 10,
-            系统教材: 15,
+            '130000': 10,
+            '340000': 15,
         };
         const estimatedChapters = scaleToChapters[bookScale] || 10;
         const book = await book_generator_store_1.bookStore.create({
@@ -75,7 +74,7 @@ router.post('/books/:id/generate', async (ctx) => {
             return;
         }
         // 启动 LangGraph 生成
-        book_langgraph_1.langGraphGenerator.generate(bookId, book.description, '标准教程' // 固定为标准教程规模
+        book_langgraph_1.langGraphGenerator.generate(bookId, book.description, '130000'
         ).catch(err => {
             console.error('[LangGraph] 生成失败:', err);
         });

+ 81 - 0
deploy-package/server/modules/queue/queue.controller.js

@@ -0,0 +1,81 @@
+"use strict";
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+const router_1 = __importDefault(require("@koa/router"));
+const queue_service_1 = require("./queue.service");
+const errorHandler_1 = require("../../middleware/errorHandler");
+const auth_1 = require("../../middleware/auth");
+
+const router = new router_1.default();
+
+// 获取队列状态
+router.get('/status', async (ctx) => {
+    const status = await queue_service_1.queueService.getStatus();
+    ctx.body = { code: 0, message: 'success', data: status };
+});
+
+// 获取任务列表
+router.get('/tasks', async (ctx) => {
+    const { status, page = '1', pageSize = '20' } = ctx.query;
+    const result = await queue_service_1.queueService.getTasks(
+        status || null,
+        parseInt(page, 10),
+        parseInt(pageSize, 10)
+    );
+    ctx.body = { code: 0, message: 'success', data: result };
+});
+
+// 添加任务
+router.post('/task', auth_1.optionalAuth, async (ctx) => {
+    const userId = ctx.state.user?.userId;
+    const { text, voiceId, voiceParams } = ctx.request.body;
+
+    try {
+        const result = await queue_service_1.queueService.addTask(userId, text, voiceId, voiceParams);
+        ctx.body = { code: 0, message: '任务已加入队列', data: result };
+    } catch (err) {
+        if (err.message.includes('请输入') || err.message.includes('请选择') || err.message.includes('已有')) {
+            throw new errorHandler_1.BadRequestError(err.message);
+        }
+        throw err;
+    }
+});
+
+// 删除任务
+router.delete('/task/:audioId', async (ctx) => {
+    const { audioId } = ctx.params;
+    try {
+        const result = await queue_service_1.queueService.deleteTask(audioId);
+        ctx.body = { code: 0, message: '任务已删除', data: result };
+    } catch (err) {
+        if (err.message === '任务不存在') {
+            throw new errorHandler_1.NotFoundError(err.message);
+        }
+        if (err.message === '任务正在处理中,无法删除') {
+            throw new errorHandler_1.BadRequestError(err.message);
+        }
+        throw err;
+    }
+});
+
+// 启动队列
+router.post('/start', async (ctx) => {
+    const result = queue_service_1.queueService.start();
+    ctx.body = { code: 0, message: result.message, data: result };
+});
+
+// 停止队列
+router.post('/stop', async (ctx) => {
+    const result = queue_service_1.queueService.stop();
+    ctx.body = { code: 0, message: result.message, data: result };
+});
+
+// 清空待处理任务
+router.delete('/tasks', async (ctx) => {
+    const result = await queue_service_1.queueService.clearPendingTasks();
+    ctx.body = { code: 0, message: '已清空待处理任务', data: result };
+});
+
+exports.default = router;

+ 326 - 0
deploy-package/server/modules/queue/queue.service.js

@@ -0,0 +1,326 @@
+"use strict";
+var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
+    if (k2 === undefined) k2 = k;
+    var desc = Object.getOwnPropertyDescriptor(m, k);
+    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
+      desc = { enumerable: true, get: function() { return m[k]; } };
+    }
+    Object.defineProperty(o, k2, desc);
+}) : (function(o, m, k, k2) {
+    if (k2 === undefined) k2 = k;
+    o[k2] = m[k];
+}));
+var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
+    Object.defineProperty(o, "default", { enumerable: true, value: v });
+}) : function(o, v) {
+    o["default"] = v;
+});
+var __importStar = (this && this.__importStar) || (function () {
+    var ownKeys = function(o) {
+        ownKeys = Object.getOwnPropertyNames || function (o) {
+            var ar = [];
+            for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
+            return ar;
+        };
+        return ownKeys(o);
+    };
+    return function (mod) {
+        if (mod && mod.__esModule) return mod;
+        var result = {};
+        if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
+        __setModuleDefault(result, mod);
+        return result;
+    };
+})();
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.queueService = void 0;
+const models_1 = require("../../models");
+const TtsService = __importStar(require("../tts/tts.service"));
+
+const MAX_CONCURRENCY = 2;
+const POLL_INTERVAL_MS = 1000;
+
+class QueueService {
+    constructor() {
+        this.isRunning = false;
+        this.activeCount = 0;
+        this.timer = null;
+    }
+
+    isRunning = false;
+    activeCount = 0;
+    timer = null;
+
+    /**
+     * 添加任务到队列
+     */
+    async addTask(userId, text, voiceId, voiceParams) {
+        if (!text || !text.trim()) {
+            throw new Error('请输入要转换的文本');
+        }
+        if (!voiceId) {
+            throw new Error('请选择音色');
+        }
+
+        const existing = await models_1.prisma.audioRecord.findFirst({
+            where: {
+                userId: userId || null,
+                status: 'pending',
+            },
+        });
+        if (existing) {
+            throw new Error('已有待处理任务,请等待完成后再添加');
+        }
+
+        const params = {
+            speed: voiceParams?.speed || 1.0,
+            pitch: voiceParams?.pitch || 0,
+            volume: voiceParams?.volume || 50,
+        };
+
+        const record = await models_1.prisma.audioRecord.create({
+            data: {
+                userId: userId || null,
+                audioId: `queue_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
+                title: text.slice(0, 50),
+                text,
+                wordCount: text.length,
+                voiceId,
+                voiceParams: JSON.stringify(params),
+                status: 'pending',
+            },
+        });
+
+        if (this.isRunning) {
+            this._poll();
+        }
+
+        return { id: record.id, audioId: record.audioId, status: record.status };
+    }
+
+    /**
+     * 启动队列
+     */
+    start() {
+        if (this.isRunning) {
+            return { running: true, message: '队列已在运行中' };
+        }
+        this.isRunning = true;
+        console.log('[QueueService] 队列已启动 (并发=' + MAX_CONCURRENCY + ')');
+        this._poll();
+        return { running: true, message: '队列已启动' };
+    }
+
+    /**
+     * 停止队列
+     */
+    stop() {
+        if (!this.isRunning) {
+            return { running: false, message: '队列已停止' };
+        }
+        this.isRunning = false;
+        if (this.timer) {
+            clearTimeout(this.timer);
+            this.timer = null;
+        }
+        console.log('[QueueService] 队列已停止 (活跃任务=' + this.activeCount + ')');
+        return { running: false, message: '队列已停止,正在处理的任务会继续完成' };
+    }
+
+    /**
+     * 获取队列状态
+     */
+    async getStatus() {
+        const [pending, processing, completed, failed] = await Promise.all([
+            models_1.prisma.audioRecord.count({ where: { status: 'pending' } }),
+            models_1.prisma.audioRecord.count({ where: { status: 'processing' } }),
+            models_1.prisma.audioRecord.count({ where: { status: 'completed' } }),
+            models_1.prisma.audioRecord.count({ where: { status: 'failed' } }),
+        ]);
+
+        return {
+            running: this.isRunning,
+            activeCount: this.activeCount,
+            maxConcurrency: MAX_CONCURRENCY,
+            pending,
+            processing,
+            completed,
+            failed,
+        };
+    }
+
+    /**
+     * 获取任务列表
+     */
+    async getTasks(status, page = 1, pageSize = 20) {
+        const where = {};
+        if (status) {
+            where.status = status;
+        }
+        const [tasks, total] = await Promise.all([
+            models_1.prisma.audioRecord.findMany({
+                where,
+                orderBy: { createdAt: 'desc' },
+                skip: (page - 1) * pageSize,
+                take: pageSize,
+                select: {
+                    id: true,
+                    audioId: true,
+                    title: true,
+                    wordCount: true,
+                    voiceId: true,
+                    voiceParams: true,
+                    audioUrl: true,
+                    audioDuration: true,
+                    audioSize: true,
+                    status: true,
+                    errorMsg: true,
+                    createdAt: true,
+                    updatedAt: true,
+                },
+            }),
+            models_1.prisma.audioRecord.count({ where }),
+        ]);
+
+        return { list: tasks, total, page, pageSize };
+    }
+
+    /**
+     * 删除任务
+     */
+    async deleteTask(audioId) {
+        const record = await models_1.prisma.audioRecord.findUnique({
+            where: { audioId },
+        });
+
+        if (!record) {
+            throw new Error('任务不存在');
+        }
+
+        if (record.status === 'processing') {
+            throw new Error('任务正在处理中,无法删除');
+        }
+
+        await models_1.prisma.audioRecord.delete({
+            where: { audioId },
+        });
+
+        return { deleted: true, audioId };
+    }
+
+    /**
+     * 清空所有待处理任务
+     */
+    async clearPendingTasks() {
+        const result = await models_1.prisma.audioRecord.deleteMany({
+            where: { status: 'pending' },
+        });
+
+        return { deleted: result.count };
+    }
+
+    /**
+     * 内部:轮询处理队列
+     */
+    _poll() {
+        if (this.timer) {
+            clearTimeout(this.timer);
+        }
+        this.timer = setTimeout(() => this._pollTick(), POLL_INTERVAL_MS);
+    }
+
+    async _pollTick() {
+        if (!this.isRunning) return;
+
+        try {
+            while (this.isRunning && this.activeCount < MAX_CONCURRENCY) {
+                const task = await this._claimNextTask();
+                if (!task) break;
+
+                this.activeCount++;
+                this._processTask(task).finally(() => {
+                    this.activeCount--;
+                });
+            }
+        } catch (err) {
+            console.error('[QueueService] 轮询异常:', err.message);
+        }
+
+        if (this.isRunning) {
+            this._poll();
+        }
+    }
+
+    async _claimNextTask() {
+        return models_1.prisma.$transaction(async (tx) => {
+            const task = await tx.audioRecord.findFirst({
+                where: { status: 'pending' },
+                orderBy: { createdAt: 'asc' },
+            });
+            if (!task) return null;
+
+            await tx.audioRecord.update({
+                where: { id: task.id },
+                data: { status: 'processing' },
+            });
+
+            return task;
+        });
+    }
+
+    async _processTask(task) {
+        const logPrefix = '[QueueService] 任务 #' + task.id;
+        console.log(logPrefix + ' 开始处理: ' + task.audioId + ' 文本长度=' + task.wordCount);
+
+        try {
+            const voiceParams = task.voiceParams ? JSON.parse(task.voiceParams) : {};
+
+            await TtsService.generateAudio(
+                task.userId,
+                task.text,
+                task.voiceId,
+                voiceParams,
+                async (audioUrl, duration) => {
+                    try {
+                        await models_1.prisma.audioRecord.update({
+                            where: { id: task.id },
+                            data: {
+                                status: 'completed',
+                                audioUrl,
+                                audioDuration: duration || 0,
+                            },
+                        });
+                        console.log(logPrefix + ' 完成 → ' + audioUrl);
+                    } catch (err) {
+                        console.error(logPrefix + ' 更新完成状态失败:', err.message);
+                    }
+                }
+            );
+
+            await new Promise((resolve) => {
+                const checkComplete = async () => {
+                    const record = await models_1.prisma.audioRecord.findUnique({
+                        where: { id: task.id },
+                    });
+                    if (record && (record.status === 'completed' || record.status === 'failed')) {
+                        resolve();
+                        return;
+                    }
+                    setTimeout(checkComplete, 2000);
+                };
+                setTimeout(checkComplete, 5000);
+            });
+        } catch (err) {
+            console.error(logPrefix + ' 处理异常:', err.message);
+            await models_1.prisma.audioRecord.update({
+                where: { id: task.id },
+                data: {
+                    status: 'failed',
+                    errorMsg: err.message || '未知错误',
+                },
+            }).catch(() => {});
+        }
+    }
+}
+
+exports.queueService = new QueueService();

+ 337 - 0
deploy-package/server/public/admin/queue.html

@@ -0,0 +1,337 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>TTS 队列管理后台</title>
+<style>
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f0f2f5; color: #333; min-height: 100vh; }
+.container { max-width: 1200px; margin: 0 auto; padding: 24px; }
+header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
+header h1 { font-size: 22px; color: #1a1a1a; }
+.header-actions { display: flex; gap: 10px; }
+button { cursor: pointer; border: none; border-radius: 6px; padding: 8px 18px; font-size: 14px; font-weight: 500; transition: all .2s; display: inline-flex; align-items: center; gap: 6px; }
+button:active { transform: scale(.97); }
+.btn-primary { background: #1677ff; color: #fff; }
+.btn-primary:hover { background: #4096ff; }
+.btn-danger { background: #ff4d4f; color: #fff; }
+.btn-danger:hover { background: #ff7875; }
+.btn-default { background: #fff; color: #333; border: 1px solid #d9d9d9; }
+.btn-default:hover { border-color: #1677ff; color: #1677ff; }
+.btn-sm { padding: 4px 10px; font-size: 12px; }
+.btn-success { background: #52c41a; color: #fff; }
+.btn-success:hover { background: #73d13d; }
+.btn-warning { background: #faad14; color: #fff; }
+.btn-warning:hover { background: #ffc53d; }
+.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin-bottom: 24px; }
+.stat-card { background: #fff; border-radius: 10px; padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,.08); text-align: center; }
+.stat-card .count { font-size: 32px; font-weight: 700; }
+.stat-card .label { font-size: 13px; color: #888; margin-top: 4px; }
+.stat-card.pending .count { color: #faad14; }
+.stat-card.processing .count { color: #1677ff; }
+.stat-card.completed .count { color: #52c41a; }
+.stat-card.failed .count { color: #ff4d4f; }
+.stat-card.running { border: 2px solid #52c41a; }
+.stat-card.stopped { border: 2px solid #ff4d4f; }
+.card { background: #fff; border-radius: 10px; padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,.08); margin-bottom: 20px; }
+.card-title { font-size: 16px; font-weight: 600; margin-bottom: 16px; color: #1a1a1a; }
+.form-row { display: flex; gap: 12px; flex-wrap: wrap; align-items: flex-end; }
+.form-group { display: flex; flex-direction: column; gap: 4px; }
+.form-group label { font-size: 13px; color: #666; font-weight: 500; }
+.form-group input, .form-group textarea, .form-group select { padding: 8px 12px; border: 1px solid #d9d9d9; border-radius: 6px; font-size: 14px; outline: none; transition: border-color .2s; }
+.form-group input:focus, .form-group textarea:focus, .form-group select:focus { border-color: #1677ff; }
+.form-group textarea { resize: vertical; min-height: 60px; }
+table { width: 100%; border-collapse: collapse; }
+th, td { padding: 10px 14px; text-align: left; border-bottom: 1px solid #f0f0f0; font-size: 13px; }
+th { background: #fafafa; font-weight: 600; color: #666; white-space: nowrap; }
+tr:hover td { background: #fafafa; }
+.badge { display: inline-block; padding: 2px 10px; border-radius: 10px; font-size: 12px; font-weight: 500; }
+.badge-pending { background: #fff7e6; color: #d48806; }
+.badge-processing { background: #e6f4ff; color: #0958d9; }
+.badge-completed { background: #f6ffed; color: #389e0d; }
+.badge-failed { background: #fff1f0; color: #cf1322; }
+.status-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
+.status-dot.running { background: #52c41a; animation: pulse 1.5s infinite; }
+.status-dot.stopped { background: #d9d9d9; }
+@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .4; } }
+.empty { text-align: center; padding: 40px; color: #999; font-size: 14px; }
+.toast { position: fixed; top: 20px; right: 20px; padding: 12px 20px; border-radius: 8px; color: #fff; font-size: 14px; z-index: 1000; animation: slideIn .3s ease; box-shadow: 0 4px 12px rgba(0,0,0,.15); }
+.toast-success { background: #52c41a; }
+.toast-error { background: #ff4d4f; }
+.toast-info { background: #1677ff; }
+@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
+.tabs { display: flex; gap: 0; margin-bottom: 16px; border-bottom: 2px solid #f0f0f0; }
+.tab { padding: 8px 16px; cursor: pointer; font-size: 14px; color: #666; border-bottom: 2px solid transparent; margin-bottom: -2px; transition: all .2s; }
+.tab:hover { color: #1677ff; }
+.tab.active { color: #1677ff; border-bottom-color: #1677ff; font-weight: 500; }
+.pagination { display: flex; justify-content: center; align-items: center; gap: 8px; margin-top: 16px; }
+.voice-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 8px; }
+.voice-option { padding: 8px 12px; border: 1px solid #d9d9d9; border-radius: 6px; cursor: pointer; text-align: center; font-size: 13px; transition: all .2s; }
+.voice-option:hover { border-color: #1677ff; }
+.voice-option.selected { border-color: #1677ff; background: #e6f4ff; color: #1677ff; font-weight: 500; }
+.text-truncate { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+</style>
+</head>
+<body>
+<div class="container">
+  <header>
+    <h1>🎙️ TTS 队列管理后台</h1>
+    <div class="header-actions">
+      <button class="btn-success" id="btnStart" onclick="startQueue()">▶ 启动队列</button>
+      <button class="btn-warning" id="btnStop" onclick="stopQueue()">⏸ 停止队列</button>
+      <button class="btn-danger btn-sm" onclick="clearPending()">🗑 清空待处理</button>
+    </div>
+  </header>
+
+  <!-- 状态卡片 -->
+  <div class="stats-grid" id="statsGrid">
+    <div class="stat-card" id="statusCard">
+      <div class="count" id="runningStatus">⏳</div>
+      <div class="label">队列状态</div>
+    </div>
+    <div class="stat-card pending"><div class="count" id="countPending">0</div><div class="label">待处理</div></div>
+    <div class="stat-card processing"><div class="count" id="countProcessing">0</div><div class="label">处理中</div></div>
+    <div class="stat-card completed"><div class="count" id="countCompleted">0</div><div class="label">已完成</div></div>
+    <div class="stat-card failed"><div class="count" id="countFailed">0</div><div class="label">失败</div></div>
+  </div>
+
+  <!-- 添加任务 -->
+  <div class="card">
+    <div class="card-title">📝 添加 TTS 任务</div>
+    <div class="form-row">
+      <div class="form-group" style="flex:2; min-width:250px;">
+        <label>文本内容</label>
+        <textarea id="taskText" placeholder="输入要转换的文字..."></textarea>
+      </div>
+      <div class="form-group" style="flex:1; min-width:120px;">
+        <label>语速</label>
+        <input type="number" id="taskSpeed" value="1.0" step="0.1" min="0.5" max="2.0">
+      </div>
+      <div class="form-group" style="flex:1; min-width:120px;">
+        <label>音量</label>
+        <input type="number" id="taskVolume" value="50" min="0" max="100">
+      </div>
+      <div class="form-group" style="align-self:flex-end;">
+        <button class="btn-primary" onclick="addTask()">➕ 加入队列</button>
+      </div>
+    </div>
+    <div style="margin-top:12px;">
+      <label style="font-size:13px;color:#666;font-weight:500;display:block;margin-bottom:6px;">选择音色</label>
+      <div class="voice-grid" id="voiceGrid"></div>
+      <input type="hidden" id="taskVoiceId" value="cherry">
+    </div>
+  </div>
+
+  <!-- 任务列表 -->
+  <div class="card">
+    <div class="card-title">📋 任务列表</div>
+    <div class="tabs">
+      <div class="tab active" data-status="" onclick="switchTab('')">全部</div>
+      <div class="tab" data-status="pending" onclick="switchTab('pending')">待处理</div>
+      <div class="tab" data-status="processing" onclick="switchTab('processing')">处理中</div>
+      <div class="tab" data-status="completed" onclick="switchTab('completed')">已完成</div>
+      <div class="tab" data-status="failed" onclick="switchTab('failed')">失败</div>
+    </div>
+    <div id="taskTable" style="overflow-x:auto;">
+      <table><thead><tr><th>ID</th><th>标题</th><th>字数</th><th>音色</th><th>状态</th><th>时长</th><th>创建时间</th><th>操作</th></tr></thead>
+      <tbody id="taskTbody"><tr><td colspan="8" class="empty">加载中...</td></tr></tbody></table>
+    </div>
+    <div class="pagination" id="pagination"></div>
+  </div>
+</div>
+
+<script>
+const API = '/api/queue';
+const VOICES = [
+  { id: 'cherry', name: '芊悦', gender: 'female' },
+  { id: 'serena', name: '苏瑶', gender: 'female' },
+  { id: 'ethan', name: '晨煦', gender: 'male' },
+  { id: 'chelsie', name: '千雪', gender: 'female' },
+  { id: 'momo', name: '茉兔', gender: 'female' },
+  { id: 'vivian', name: '十三', gender: 'female' },
+  { id: 'moon', name: '月白', gender: 'male' },
+  { id: 'maia', name: '四月', gender: 'female' },
+  { id: 'kai', name: '凯', gender: 'male' },
+  { id: 'nofish', name: '不吃鱼', gender: 'male' },
+];
+
+let currentTab = '';
+let currentPage = 1;
+
+function initVoices() {
+  const grid = document.getElementById('voiceGrid');
+  grid.innerHTML = VOICES.map(v =>
+    `<div class="voice-option ${v.id === 'cherry' ? 'selected' : ''}" data-voice="${v.id}" onclick="selectVoice('${v.id}', this)">
+      ${v.name} <span style="font-size:11px;color:#999;">${v.gender === 'female' ? '♀' : '♂'}</span>
+    </div>`
+  ).join('');
+}
+
+function selectVoice(voiceId, el) {
+  document.getElementById('taskVoiceId').value = voiceId;
+  document.querySelectorAll('.voice-option').forEach(o => o.classList.remove('selected'));
+  el.classList.add('selected');
+}
+
+function toast(msg, type = 'info') {
+  const t = document.createElement('div');
+  t.className = 'toast toast-' + type;
+  t.textContent = msg;
+  document.body.appendChild(t);
+  setTimeout(() => { t.remove(); }, 2500);
+}
+
+async function api(url, opts = {}) {
+  const res = await fetch(url, { headers: { 'Content-Type': 'application/json' }, ...opts });
+  const data = await res.json();
+  if (data.code !== 0) throw new Error(data.message || '请求失败');
+  return data.data;
+}
+
+async function refreshStatus() {
+  try {
+    const data = await api(API + '/status');
+    const running = data.running;
+    document.getElementById('runningStatus').innerHTML = running
+      ? '<span class="status-dot running"></span>运行中'
+      : '<span class="status-dot stopped"></span>已停止';
+    const statusCard = document.getElementById('statusCard');
+    statusCard.className = 'stat-card ' + (running ? 'running' : 'stopped');
+    document.getElementById('btnStart').style.display = running ? 'none' : '';
+    document.getElementById('btnStop').style.display = running ? '' : 'none';
+    document.getElementById('countPending').textContent = data.pending;
+    document.getElementById('countProcessing').textContent = data.processing;
+    document.getElementById('countCompleted').textContent = data.completed;
+    document.getElementById('countFailed').textContent = data.failed;
+  } catch (e) { console.error(e); }
+}
+
+async function refreshTasks() {
+  try {
+    const params = new URLSearchParams({ page: currentPage, pageSize: 15 });
+    if (currentTab) params.set('status', currentTab);
+    const data = await api(API + '/tasks?' + params);
+    const tbody = document.getElementById('taskTbody');
+    if (data.list.length === 0) {
+      tbody.innerHTML = '<tr><td colspan="8" class="empty">暂无任务</td></tr>';
+    } else {
+      tbody.innerHTML = data.list.map(t => {
+        const title = t.title || '(无标题)';
+        const createdAt = new Date(t.createdAt).toLocaleString('zh-CN');
+        const duration = t.audioDuration ? Math.floor(t.audioDuration / 60) + '分' + (t.audioDuration % 60) + '秒' : '-';
+        let actions = '';
+        if (t.status === 'pending') {
+          actions = `<button class="btn-danger btn-sm" onclick="deleteTask('${t.audioId}')">删除</button>`;
+        } else if (t.status !== 'processing') {
+          actions = `<button class="btn-danger btn-sm" onclick="deleteTask('${t.audioId}')">删除</button>`;
+        }
+        return `<tr>
+          <td>${t.id}</td>
+          <td class="text-truncate" title="${escapeHtml(title)}">${escapeHtml(title)}</td>
+          <td>${t.wordCount || 0}</td>
+          <td>${t.voiceId || '-'}</td>
+          <td><span class="badge badge-${t.status}">${statusLabel(t.status)}</span></td>
+          <td>${duration}</td>
+          <td>${createdAt}</td>
+          <td>${actions}</td>
+        </tr>`;
+      }).join('');
+    }
+    renderPagination(data.total, data.pageSize);
+  } catch (e) { console.error(e); }
+}
+
+function statusLabel(s) {
+  const map = { pending: '待处理', processing: '处理中', completed: '已完成', failed: '失败' };
+  return map[s] || s;
+}
+
+function escapeHtml(s) { return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
+
+function renderPagination(total, pageSize) {
+  const totalPages = Math.ceil(total / pageSize);
+  const pag = document.getElementById('pagination');
+  if (totalPages <= 1) { pag.innerHTML = ''; return; }
+  let html = '';
+  html += `<button class="btn-default btn-sm" ${currentPage <= 1 ? 'disabled' : ''} onclick="goPage(${currentPage - 1})">上一页</button>`;
+  for (let i = 1; i <= totalPages; i++) {
+    html += `<button class="btn-default btn-sm" style="${i === currentPage ? 'background:#1677ff;color:#fff;' : ''}" onclick="goPage(${i})">${i}</button>`;
+  }
+  html += `<button class="btn-default btn-sm" ${currentPage >= totalPages ? 'disabled' : ''} onclick="goPage(${currentPage + 1})">下一页</button>`;
+  pag.innerHTML = html;
+}
+
+function goPage(p) { currentPage = p; refreshTasks(); }
+
+function switchTab(status) {
+  currentTab = status;
+  currentPage = 1;
+  document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.status === status));
+  refreshTasks();
+}
+
+async function addTask() {
+  const text = document.getElementById('taskText').value.trim();
+  const voiceId = document.getElementById('taskVoiceId').value;
+  const speed = parseFloat(document.getElementById('taskSpeed').value);
+  const volume = parseInt(document.getElementById('taskVolume').value);
+  if (!text) return toast('请输入文本内容', 'error');
+  try {
+    await api(API + '/task', {
+      method: 'POST',
+      body: JSON.stringify({ text, voiceId, voiceParams: { speed, volume } }),
+    });
+    toast('任务已加入队列', 'success');
+    document.getElementById('taskText').value = '';
+    refreshStatus();
+    refreshTasks();
+  } catch (e) { toast(e.message, 'error'); }
+}
+
+async function deleteTask(audioId) {
+  if (!confirm('确定删除此任务?')) return;
+  try {
+    await api(API + '/task/' + audioId, { method: 'DELETE' });
+    toast('任务已删除', 'success');
+    refreshStatus();
+    refreshTasks();
+  } catch (e) { toast(e.message, 'error'); }
+}
+
+async function startQueue() {
+  try {
+    const data = await api(API + '/start', { method: 'POST' });
+    toast(data.message, 'success');
+    refreshStatus();
+    refreshTasks();
+  } catch (e) { toast(e.message, 'error'); }
+}
+
+async function stopQueue() {
+  try {
+    const data = await api(API + '/stop', { method: 'POST' });
+    toast(data.message, 'success');
+    refreshStatus();
+    refreshTasks();
+  } catch (e) { toast(e.message, 'error'); }
+}
+
+async function clearPending() {
+  if (!confirm('确定清空所有待处理任务?')) return;
+  try {
+    const data = await api(API + '/tasks', { method: 'DELETE' });
+    toast('已清空 ' + data.deleted + ' 个待处理任务', 'success');
+    refreshStatus();
+    refreshTasks();
+  } catch (e) { toast(e.message, 'error'); }
+}
+
+initVoices();
+refreshStatus();
+refreshTasks();
+setInterval(refreshStatus, 5000);
+setInterval(refreshTasks, 8000);
+</script>
+</body>
+</html>

+ 1 - 1
my-uniapp-vue3/src/manifest.json

@@ -1,5 +1,5 @@
 {
-    "name" : "AI有声书",
+    "name" : "声工坊",
     "appid" : "__UNI__AUDIOBOOK",
     "description" : "AI有声书生成应用",
     "versionName" : "1.0.0",

+ 3 - 3
my-uniapp-vue3/src/pages/book-generator/create.vue

@@ -341,7 +341,7 @@ const newBook = ref({
   industry: '',
   specialFeatures: [] as string[],
   style: '',
-  bookScale: 'medium',
+  bookScale: '',
   bookType: 'auto',
   outlineLevel: 'auto',
 });
@@ -715,7 +715,7 @@ async function createNewBook() {
       title: newBook.value.title,
       subtitle: newBook.value.subtitle,
       description: mergedDesc,
-      bookScale: newBook.value.bookScale,
+      bookScale: newBook.value.bookScale || undefined,
       bookType,
       genLevel,
     });
@@ -736,7 +736,7 @@ async function createNewBook() {
 }
 
 onMounted(() => {
-  selectBookScale('medium');
+  // 不再默认选中规模,让 AI 根据描述自动判断
 });
 </script>
 

+ 1 - 1
my-uniapp-vue3/src/pages/login/index.vue

@@ -2,7 +2,7 @@
   <view class="page">
     <!-- 顶部背景 -->
     <view class="header">
-      <text class="title">AI有声书</text>
+      <text class="title">声工坊</text>
       <text class="subtitle">让文字变成声音</text>
     </view>
 

+ 2 - 2
my-uniapp-vue3/src/pages/mine/index.vue

@@ -288,8 +288,8 @@ function goBookGenerator() {
 // 关于我们
 function showAbout() {
   uni.showModal({
-    title: '关于AI有声书',
-    content: 'AI有声书是一款智能文本转语音工具,帮助您轻松制作高质量音频内容。\n\n版本: 1.0.0',
+    title: '关于声工坊',
+    content: '声工坊是一款智能文本转语音工具,帮助您轻松制作高质量音频内容。\n\n版本: 1.0.0',
     showCancel: false,
   });
 }

+ 6 - 0
my-uniapp-vue3/src/utils/request.ts

@@ -28,6 +28,12 @@ export function getFullUrl(path: string): string {
   }
   // 使用 config 中的 getServerBaseUrl 获取后端地址
   const backendUrl = getServerBaseUrl();
+  // 确保服务器地址是完整的 https URL,避免浏览器认为是相对路径
+  if (!backendUrl.startsWith('http')) {
+    // 如果 backendUrl 是相对路径(如 '/' 或 ''),使用生产环境域名
+    console.warn('[Request] backendUrl 是相对路径,使用生产环境域名:', backendUrl);
+    return 'https://book.rrbrr.com' + path;
+  }
   return backendUrl + path;
 }
 

+ 0 - 0
server/audio-book-server@1.0.0


+ 2 - 2
server/check-book-fields.js

@@ -21,10 +21,10 @@ async function checkBookFields() {
       await prisma.book.update({
         where: { id: 1 },
         data: {
-          bookScale: '标准教程'
+          bookScale: '130000'
         }
       });
-      console.log('✅ 已设置 bookScale = 标准教程');
+      console.log('✅ 已设置 bookScale = 130000');
     }
 
   } catch (error) {

+ 1 - 1
server/create-mini-book.js

@@ -8,7 +8,7 @@ async function createMiniBook() {
   const bookData = {
     title: 'Python入门教程',
     description: '面向初学者的Python编程基础教程',
-    bookScale: '小册子', // 最小规模:2-5万字,2-5章
+    bookScale: '2000', // 最小规模:约2000字
     targetAudience: '编程初学者',
     knowledgeDepth: '入门',
     industry: '教育',

+ 1 - 1
server/create-test-book.js

@@ -7,7 +7,7 @@ const data = JSON.stringify({
   description: '系统讲解操作系统的核心原理与设计思想,涵盖进程管理、内存管理、文件系统、设备管理、安全保护等关键模块,帮助学生建立完整的操作系统知识体系。',
   targetAudience: '大学生',
   style: '专业严谨',
-  bookScale: '标准教程',
+  bookScale: '130000',
   knowledgeLevel: '进阶',
   industry: 'IT/计算机'
 });

+ 33 - 0
server/debug-control.js

@@ -0,0 +1,33 @@
+const fs = require('fs');
+
+const filePath = 'temp/rich-outline-fail_57_1779023037497.txt';
+const content = fs.readFileSync(filePath, 'utf-8');
+const marker = '========== AI 原始响应 ';
+const markerIdx = content.indexOf(marker);
+const headerEndIdx = content.indexOf(' ==========', markerIdx);
+const aiStart = content.indexOf('\n', headerEndIdx) + 1;
+const aiEnd = content.indexOf('\n\n', aiStart);
+const aiResponse = content.substring(aiStart, aiEnd);
+
+console.log('AI response length:', aiResponse.length);
+
+// Find all problematic characters (control chars except newlines/tabs/cr)
+for (let i = 0; i < aiResponse.length; i++) {
+  const code = aiResponse.charCodeAt(i);
+  if (code < 32 && code !== 10 && code !== 13 && code !== 9) {
+    console.log('Control char at', i, ': code', code, 'hex', code.toString(16));
+  }
+}
+
+// Check characters around 4070-4080
+console.log('\nChars around 4070-4090:');
+for (let i = 4070; i < 4090; i++) {
+  const code = aiResponse.charCodeAt(i);
+  const ch = aiResponse[i];
+  console.log('pos', i, ': code=', code, 'char=', JSON.stringify(ch));
+}
+
+// Check if there's an issue with the content structure
+// Position 4074 is },{
+const ctx = aiResponse.substring(4040, 4120);
+console.log('\nContext 4040-4120:', JSON.stringify(ctx));

+ 34 - 0
server/debug-jsonrepair.js

@@ -0,0 +1,34 @@
+const fs = require('fs');
+const { repairJson } = require('jsonrepair');
+
+const content = fs.readFileSync('temp/rich-outline-fail_57_1779023037497.txt', 'utf-8');
+const marker = '========== AI 原始响应 ';
+const markerIdx = content.indexOf(marker);
+const headerEndIdx = content.indexOf(' ==========', markerIdx);
+const aiStart = content.indexOf('\n', headerEndIdx) + 1;
+const aiEnd = content.indexOf('\n\n', aiStart);
+const aiResponse = content.substring(aiStart, aiEnd);
+
+console.log('AI response length:', aiResponse.length);
+console.log('Response end:', JSON.stringify(aiResponse.slice(-100)));
+
+// Test strategy 1: jsonrepair directly
+try {
+  const repaired = repairJson(aiResponse);
+  console.log('\nStrategy 1: repairJson length:', repaired.length);
+  const data = JSON.parse(repaired);
+  if (data.chapters && Array.isArray(data.chapters)) {
+    console.log('Strategy 1 SUCCESS: chapters count =', data.chapters.length);
+    console.log('First chapter:', JSON.stringify(data.chapters[0]).substring(0, 100));
+  } else {
+    console.log('Strategy 1: no chapters in parsed data');
+  }
+} catch(e) {
+  console.log('\nStrategy 1 FAILED:', e.message.substring(0, 100));
+}
+
+// Check if response ends with complete structure
+console.log('\nLast 200 chars:', JSON.stringify(aiResponse.slice(-200)));
+console.log('Does it end with ]} ?', aiResponse.endsWith(']}'));
+console.log('Does it end with ]}] ?', aiResponse.endsWith(']}]'));
+console.log('Does it end with ]})] ?', aiResponse.endsWith(']})]'));

+ 51 - 0
server/debug-parse.js

@@ -0,0 +1,51 @@
+const fs = require('fs');
+const content = fs.readFileSync('temp/rich-outline-fail_57_1779014018760.txt', 'utf-8');
+const marker = '========== AI 原始响应 ';
+const idx = content.indexOf(marker);
+const start = content.indexOf('\n', idx) + 1;
+const end = content.indexOf('\n\n========== 响应结尾');
+const aiResponse = content.substring(start, end).trim();
+
+console.log('Length:', aiResponse.length);
+console.log('First 100:', JSON.stringify(aiResponse.substring(0, 100)));
+console.log('Last 100:', JSON.stringify(aiResponse.substring(aiResponse.length - 100)));
+
+const pos = 798;
+console.log('\n--- Error at position 798 ---');
+console.log('Char at 798:', JSON.stringify(aiResponse[pos]));
+console.log('Char code at 798:', aiResponse.charCodeAt(798));
+
+// Check if this is inside a string value (unescaped newline inside quotes)
+let inString = false;
+let escapeNext = false;
+let i = 0;
+const str = aiResponse;
+while (i < pos) {
+  const ch = str[i];
+  if (escapeNext) {
+    escapeNext = false;
+  } else if (ch === '"') {
+    inString = !inString;
+  } else if (ch === '\\' && inString) {
+    escapeNext = true;
+  }
+  i++;
+}
+console.log('Is inside string at pos 798:', inString);
+
+// Check if it's truncation
+console.log('\n--- Truncation check ---');
+console.log('Ends with }:', aiResponse.trim().endsWith('}'));
+console.log('Ends with ,:', aiResponse.trim().endsWith(','));
+
+// Find the actual last complete property
+const lastComma = aiResponse.lastIndexOf(',');
+const lastBrace = aiResponse.lastIndexOf('}');
+const lastBracket = aiResponse.lastIndexOf(']');
+console.log('Last comma pos:', lastComma, 'char:', JSON.stringify(aiResponse[lastComma]));
+console.log('Last brace pos:', lastBrace);
+console.log('Last bracket pos:', lastBracket);
+
+// Check the trailing content
+console.log('\n--- Trailing content ---');
+console.log(JSON.stringify(aiResponse.substring(aiResponse.length - 200)));

+ 112 - 0
server/debug-parse10.js

@@ -0,0 +1,112 @@
+const fs = require('fs');
+const content = fs.readFileSync('temp/rich-outline-fail_57_1779015509994.txt', 'utf-8');
+const marker = '========== AI 原始响应 ';
+const markerIdx = content.indexOf(marker);
+const headerEndIdx = content.indexOf(' ==========', markerIdx);
+const aiStart = content.indexOf('\n', headerEndIdx) + 1;
+const aiEnd = content.indexOf('\n\n', aiStart);
+const aiResponse = content.substring(aiStart, aiEnd);
+
+console.log('AI response length:', aiResponse.length);
+
+// The error is at position 1624 which is the "{" in "{\"number\":3"
+// JSON says: Expected double-quoted property name at position 1624
+// This means when we hit this "{", we should be seeing a property name, but we're not
+// This suggests we might be INSIDE a string when we encounter this "{"
+
+// Let's trace back from position 1624 to find where the string state changes
+let inString = false;
+let escapeNext = false;
+let lastNonSpaceBefore = -1;
+
+for (let i = 0; i < 1624; i++) {
+  const ch = aiResponse[i];
+  if (escapeNext) {
+    escapeNext = false;
+    lastNonSpaceBefore = i;
+    continue;
+  }
+  if (ch === '\\') {
+    escapeNext = true;
+    lastNonSpaceBefore = i;
+    continue;
+  }
+  if (ch === '"') {
+    inString = !inString;
+    lastNonSpaceBefore = i;
+    continue;
+  }
+  if (!inString && ch !== ' ' && ch !== '\n' && ch !== '\r' && ch !== '\t') {
+    lastNonSpaceBefore = i;
+  }
+}
+
+console.log('At position 1624: inString =', inString);
+console.log('Last non-space char before 1624: pos', lastNonSpaceBefore, '=', JSON.stringify(aiResponse[lastNonSpaceBefore]));
+
+// Find the nearest opening quote before position 1624 that might be the start of the problematic string
+// Look at the last 200 chars before position 1624
+console.log('\nLast 200 chars before position 1624:');
+console.log(JSON.stringify(aiResponse.substring(1424, 1624)));
+
+// Check for unescaped newlines or special chars in strings
+let hasProblem = false;
+for (let i = 0; i < 1624; i++) {
+  const code = aiResponse.charCodeAt(i);
+  if (code < 32 && code !== 9 && code !== 10 && code !== 13) {
+    console.log('Problematic char at', i, ': code', code, 'char', JSON.stringify(aiResponse[i]));
+    hasProblem = true;
+  }
+}
+if (!hasProblem) {
+  console.log('No control characters found before position 1624');
+}
+
+// Maybe it's the "numbered" property names issue again but at a nested level
+// Let's check all occurrences of problematic patterns
+const problemPatterns = [
+  [',{number":', 'comma opening brace number colon quote'],
+  [',{title":', 'comma opening brace title colon quote'],
+  [',{summary":', 'comma opening brace summary colon quote'],
+  [',{"number":', 'comma opening brace QUOTE number colon quote (correct)'],
+];
+
+for (const [pattern, desc] of problemPatterns) {
+  let count = 0;
+  let idx = aiResponse.indexOf(pattern);
+  while (idx !== -1 && idx < 1624) { count++; idx = aiResponse.indexOf(pattern, idx + 1); }
+  if (count > 0) console.log(pattern, '->', count, 'times (before pos 1624)');
+}
+
+// Let's try a different approach - find all "},{" occurrences and see if any are malformed
+const badCommaBrace = /},\{"[^"]+":/g;
+let badMatches = [];
+let m;
+while ((m = badCommaBrace.exec(aiResponse)) !== null) {
+  if (m.index < 1700) {
+    badMatches.push({pos: m.index, match: m[0]});
+  }
+}
+console.log('\nAll },{" patterns near the error:');
+badMatches.forEach(b => console.log('  pos', b.pos, ':', JSON.stringify(b.match.substring(0, 50))));
+
+// Check the actual structure by counting nesting levels
+let braceLevel = 0;
+let bracketLevel = 0;
+inString = false;
+escapeNext = false;
+for (let i = 0; i < aiResponse.length; i++) {
+  const ch = aiResponse[i];
+  if (escapeNext) { escapeNext = false; continue; }
+  if (ch === '\\') { escapeNext = true; continue; }
+  if (ch === '"') { inString = !inString; continue; }
+  if (!inString) {
+    if (ch === '{') braceLevel++;
+    else if (ch === '}') braceLevel--;
+    else if (ch === '[') bracketLevel++;
+    else if (ch === ']') bracketLevel--;
+  }
+  if (i === 1624) {
+    console.log('\nAt error position 1624: braceLevel =', braceLevel, 'bracketLevel =', bracketLevel, 'inString =', inString);
+  }
+}

+ 75 - 0
server/debug-parse11.js

@@ -0,0 +1,75 @@
+const fs = require('fs');
+const content = fs.readFileSync('temp/rich-outline-fail_57_1779015509994.txt', 'utf-8');
+const marker = '========== AI 原始响应 ';
+const markerIdx = content.indexOf(marker);
+const headerEndIdx = content.indexOf(' ==========', markerIdx);
+const aiStart = content.indexOf('\n', headerEndIdx) + 1;
+const aiEnd = content.indexOf('\n\n', aiStart);
+const aiResponse = content.substring(aiStart, aiEnd);
+
+console.log('Length:', aiResponse.length);
+
+// Test the raw response without any fixes
+console.log('\n=== TEST 1: Raw response ===');
+try {
+  const data = JSON.parse(aiResponse);
+  console.log('SUCCESS, chapters:', data.chapters?.length);
+} catch(e) {
+  console.log('FAILED:', e.message.substring(0, 80));
+}
+
+// Test with the regex match
+const match = aiResponse.match(/\{[\s\S]*\}/);
+console.log('\n=== TEST 2: Regex match ===');
+if (match) {
+  console.log('Match length:', match[0].length);
+  console.log('Match starts with:', JSON.stringify(match[0].substring(0, 50)));
+  console.log('Match ends with:', JSON.stringify(match[0].substring(match[0].length - 50)));
+  try {
+    const data = JSON.parse(match[0]);
+    console.log('SUCCESS, chapters:', data.chapters?.length);
+  } catch(e) {
+    console.log('FAILED:', e.message.substring(0, 80));
+  }
+} else {
+  console.log('No match found');
+}
+
+// Let's test the actual structure around position 1624
+console.log('\n=== TEST 3: Structural analysis ===');
+const pos = 1624;
+console.log('At pos', pos, ':');
+console.log('  Char:', JSON.stringify(aiResponse[pos]), 'code:', aiResponse.charCodeAt(pos));
+
+// Find the last complete property before pos 1624
+// Look for patterns like "propertyName":value or "propertyName":[" or "propertyName":{
+const beforeError = aiResponse.substring(0, pos);
+const lastPropMatch = beforeError.match(/"([^"]+)":\s*[^\[{,"]+$/);
+console.log('Last property value before error:', lastPropMatch ? lastPropMatch[0] : 'none');
+
+// Let's also check if there's a 0x00 embedded null byte that didn't show up in our check
+console.log('\n=== TEST 4: Byte-level analysis ===');
+const bytes = Buffer.from(aiResponse, 'utf8');
+console.log('Buffer length:', bytes.length);
+for (let i = 1620; i < 1630; i++) {
+  console.log('  byte', i, ':', bytes[i], '(char:', String.fromCharCode(bytes[i]), ')');
+}
+
+// Check the original file for any 0x00 bytes that might be stripped
+console.log('\n=== TEST 5: Original file check ===');
+const fileBytes = fs.readFileSync('temp/rich-outline-fail_57_1779015509994.txt');
+console.log('File size:', fileBytes.length);
+const aiStartMarker = '========== AI 原始响应 ';
+const aiStartIdx = fileBytes.indexOf(aiStartMarker);
+const headerEndIdxBytes = fileBytes.indexOf(Buffer.from(' =========='), aiStartIdx);
+const aiContentStart = fileBytes.indexOf(Buffer.from('\n'), headerEndIdxBytes) + 1;
+const aiContentEnd = fileBytes.indexOf(Buffer.from('\n\n========== 响应结尾'), aiContentStart);
+console.log('AI content start offset:', aiContentStart, 'end offset:', aiContentEnd);
+console.log('AI content length from file:', aiContentEnd - aiContentStart);
+
+// Check for null bytes in the file
+let nullCount = 0;
+for (let i = aiContentStart; i < aiContentEnd; i++) {
+  if (fileBytes[i] === 0) nullCount++;
+}
+console.log('Null bytes in AI content:', nullCount);

+ 94 - 0
server/debug-parse12.js

@@ -0,0 +1,94 @@
+const fs = require('fs');
+const content = fs.readFileSync('temp/rich-outline-fail_57_1779015509994.txt', 'utf-8');
+const marker = '========== AI 原始响应 ';
+const markerIdx = content.indexOf(marker);
+const headerEndIdx = content.indexOf(' ==========', markerIdx);
+const aiStart = content.indexOf('\n', headerEndIdx) + 1;
+const aiEnd = content.indexOf('\n\n', aiStart);
+const aiResponse = content.substring(aiStart, aiEnd);
+
+console.log('String length (UTF-16 code units):', aiResponse.length);
+
+// The issue: JSON.parse uses UTF-16 code unit positions, but the JSON is actually UTF-8 encoded
+// when transmitted over the network. The LLM returns UTF-8 bytes, but in Node.js when we get
+// the response as a string, it's decoded to UTF-16.
+
+// Wait - actually JSON.parse should work correctly with UTF-16 strings in JavaScript.
+// Let me check if there's actually a malformed character somewhere.
+
+// Try using a simple character-by-character JSON parser approach to find the issue
+const pos = 1624;
+console.log('\nAnalyzing around position', pos);
+
+// Get the actual bytes via TextEncoder
+const encoder = new TextEncoder();
+const decoder = new TextDecoder('utf-8');
+
+// Let's check the raw bytes of the string
+const uint8Array = encoder.encode(aiResponse);
+console.log('Encoded bytes length:', uint8Array.length);
+
+// Check bytes around position 1624 (in UTF-16 string)
+console.log('\nBytes around UTF-16 position', pos, ':');
+for (let i = Math.max(0, pos - 5); i < Math.min(uint8Array.length, pos + 10); i++) {
+  console.log('  byte', i, ':', uint8Array[i], '(char:', String.fromCharCode(uint8Array[i]), ')');
+}
+
+// Now let's try to find where the actual byte-level position differs from UTF-16 position
+// by counting UTF-8 multi-byte characters before position 1624
+
+let utf8Offset = 0;
+let utf16Offset = 0;
+let lastAsciiBefore1624 = -1;
+let lastAsciiUtf8BytePos = -1;
+
+for (let i = 0; i < aiResponse.length && utf16Offset < 1624; i++) {
+  const code = aiResponse.charCodeAt(i);
+  if (code <= 0x7F) {
+    // ASCII - 1 byte in UTF-8, 1 code unit in UTF-16
+    if (utf16Offset === 1623) {
+      lastAsciiBefore1624 = code;
+      lastAsciiUtf8BytePos = utf8Offset;
+    }
+    utf8Offset += 1;
+    utf16Offset += 1;
+  } else if (code <= 0x7FF) {
+    // 2-byte UTF-8
+    utf8Offset += 2;
+    utf16Offset += 1;
+  } else if (code <= 0xFFFF) {
+    // 3-byte UTF-8 (most CJK characters)
+    utf8Offset += 3;
+    utf16Offset += 1;
+  } else {
+    // 4-byte UTF-8 (emoji, etc)
+    utf8Offset += 4;
+    utf16Offset += 2;
+  }
+}
+
+console.log('\nAt UTF-16 position 1624:');
+console.log('  Corresponding UTF-8 byte position would be around:', utf8Offset);
+console.log('  Last ASCII char (at UTF-16 1623):', lastAsciiBefore1624, '(code:', lastAsciiBefore1624, ')');
+
+// Now let's check what JSON.parse says the error position actually means
+// In JSON parse errors, "position" is the character offset in the string
+// Let's see if position 1624 in the string is actually where the issue is
+
+console.log('\n=== Direct parse attempt ===');
+try {
+  JSON.parse(aiResponse);
+} catch(e) {
+  console.log('Error:', e.message);
+  const match = e.message.match(/position (\d+)/);
+  if (match) {
+    const errorPos = parseInt(match[1]);
+    console.log('Error position:', errorPos);
+    console.log('Character at error position:', JSON.stringify(aiResponse[errorPos]));
+    console.log('Context:', JSON.stringify(aiResponse.substring(Math.max(0, errorPos-50), errorPos+50)));
+
+    // Check if char at errorPos is the start of a multi-byte sequence
+    const code = aiResponse.charCodeAt(errorPos);
+    console.log('Code point at errorPos:', code, 'hex:', code.toString(16));
+  }
+}

+ 71 - 0
server/debug-parse13.js

@@ -0,0 +1,71 @@
+const fs = require('fs');
+const content = fs.readFileSync('temp/rich-outline-fail_57_1779015509994.txt', 'utf-8');
+const marker = '========== AI 原始响应 ';
+const markerIdx = content.indexOf(marker);
+const headerEndIdx = content.indexOf(' ==========', markerIdx);
+const aiStart = content.indexOf('\n', headerEndIdx) + 1;
+const aiEnd = content.indexOf('\n\n', aiStart);
+const aiResponse = content.substring(aiStart, aiEnd);
+
+console.log('AI response length:', aiResponse.length);
+
+// My fix function
+function fixMissingQuotes(jsonStr) {
+  return jsonStr
+    .replace(/\{number":/g, '{"number":')
+    .replace(/\{title":/g, '{"title":')
+    .replace(/\{summary":/g, '{"summary":')
+    .replace(/\{opening":/g, '{"opening":')
+    .replace(/\{structure":/g, '{"structure":')
+    .replace(/\{mustCover":/g, '{"mustCover":')
+    .replace(/\{mustNotRepeat":/g, '{"mustNotRepeat":')
+    .replace(/\{toneAdjustment":/g, '{"toneAdjustment":')
+    .replace(/\{keyTakeaway":/g, '{"keyTakeaway":')
+    .replace(/\{keyPoints":/g, '{"keyPoints":')
+    .replace(/\{estimatedWords":/g, '{"estimatedWords":')
+    .replace(/\{writingInstructions":/g, '{"writingInstructions":');
+}
+
+// Direct replace test
+const directFixed = fixMissingQuotes(aiResponse);
+console.log('\nDirect fix - chars changed:', aiResponse.length - directFixed.split('').filter((c,i) => c !== directFixed[i]).length);
+
+// Test direct fix
+try {
+  const data = JSON.parse(directFixed);
+  console.log('Direct fix: SUCCESS, chapters:', data.chapters?.length);
+} catch(e) {
+  console.log('Direct fix: FAILED -', e.message.substring(0, 80));
+
+  // Check if there's actually a replacement happening
+  const badPattern = /\{number":/g;
+  const count = (aiResponse.match(badPattern) || []).length;
+  console.log('Bad pattern "{number\\": found:', count, 'times in original');
+
+  // Try a broader fix approach - replace ,{word": with ,{"word":
+  const broaderFixed = aiResponse.replace(/,(\{[^"]+":)/g, function(m, p1) {
+    return ',{"' + p1.slice(1);
+  });
+  console.log('Broader fix - chars changed:', aiResponse.length - broaderFixed.split('').filter((c,i) => c !== broaderFixed[i]).length);
+
+  try {
+    const data = JSON.parse(broaderFixed);
+    console.log('Broader fix: SUCCESS, chapters:', data.chapters?.length);
+  } catch(e2) {
+    console.log('Broader fix: FAILED -', e2.message.substring(0, 80));
+
+    // Check the exact position
+    const pos = parseInt(e2.message.match(/position (\d+)/)?.[1] || '0');
+    console.log('Error at position:', pos);
+    console.log('Context:', JSON.stringify(aiResponse.substring(Math.max(0,pos-50), pos+50)));
+  }
+}
+
+// Also test: what if the problem is not missing quotes but something else?
+// Let's check if there are any single quotes being used instead of double quotes
+const singleQuoteProps = /'[^']*':/g;
+const singleQuoteMatches = aiResponse.match(singleQuoteProps);
+console.log('\nSingle-quoted properties found:', singleQuoteMatches ? singleQuoteMatches.length : 0);
+if (singleQuoteMatches && singleQuoteMatches.length < 10) {
+  singleQuoteMatches.forEach(m => console.log('  ', m));
+}

+ 76 - 0
server/debug-parse14.js

@@ -0,0 +1,76 @@
+const fs = require('fs');
+const content = fs.readFileSync('temp/rich-outline-fail_57_1779015509994.txt', 'utf-8');
+const marker = '========== AI 原始响应 ';
+const markerIdx = content.indexOf(marker);
+const headerEndIdx = content.indexOf(' ==========', markerIdx);
+const aiStart = content.indexOf('\n', headerEndIdx) + 1;
+const aiEnd = content.indexOf('\n\n', aiStart);
+const aiResponse = content.substring(aiStart, aiEnd);
+
+// Verify the issue with a minimal fix function
+// The issue with my previous fix was: using a function in replace causes issues
+// Let's do simple string replacement
+
+const input = 'Hello {world": test';
+const output = input.replace("{world":/, '{"world":');
+console.log('Simple replacement test:');
+console.log('Input:', JSON.stringify(input));
+console.log('Output:', JSON.stringify(output));
+
+// Now let's just check if the JSON has any obvious issues by
+// checking if ALL property names are properly quoted
+
+let inString = false;
+let escapeNext = false;
+let braceLevel = 0;
+let bracketLevel = 0;
+let issues = [];
+
+for (let i = 0; i < aiResponse.length; i++) {
+  const ch = aiResponse[i];
+  if (escapeNext) { escapeNext = false; continue; }
+  if (ch === '\\') { escapeNext = true; continue; }
+  if (ch === '"') { inString = !inString; continue; }
+  if (!inString) {
+    if (ch === '{') {
+      braceLevel++;
+      // Check the next non-space character
+      let j = i + 1;
+      while (j < aiResponse.length && aiResponse[j] === ' ') j++;
+      if (j < aiResponse.length && aiResponse[j] !== '"' && aiResponse[j] !== '}') {
+        issues.push({pos: i, char: ch, nextChar: aiResponse[j], context: aiResponse.substring(Math.max(0,i-20), i+30)});
+      }
+    } else if (ch === '}') {
+      braceLevel--;
+    } else if (ch === '[') {
+      bracketLevel++;
+    } else if (ch === ']') {
+      bracketLevel--;
+    }
+  }
+}
+
+console.log('\nIssues found:');
+issues.slice(0, 10).forEach(issue => {
+  console.log('  pos', issue.pos, ': char', JSON.stringify(issue.char), 'next', JSON.stringify(issue.nextChar));
+  console.log('    context:', JSON.stringify(issue.context));
+});
+
+// Try a completely different approach - use a streaming JSON parser to find the error
+console.log('\n=== Trying to find exact error location ===');
+// Let's manually find the last COMPLETE property before position 1624
+const before1624 = aiResponse.substring(0, 1624);
+console.log('Last 200 chars before position 1624:');
+console.log(JSON.stringify(before1624.substring(before1624.length - 200)));
+
+// Let's check if maybe the issue is a TRUNCATION - the JSON ends in the middle of a string
+console.log('\n=== Checking for truncation ===');
+console.log('Ends with }:', aiResponse.trim().endsWith('}'));
+console.log('Ends with ,:', aiResponse.trim().endsWith(','));
+
+const lastComma = before1624.lastIndexOf(',');
+const lastBrace = before1624.lastIndexOf('}');
+const lastBracket = before1624.lastIndexOf(']');
+console.log('Last comma before 1624:', lastComma, '=', JSON.stringify(before1624[lastComma]));
+console.log('Last brace before 1624:', lastBrace);
+console.log('Last bracket before 1624:', lastBracket);

+ 127 - 0
server/debug-parse15.js

@@ -0,0 +1,127 @@
+const fs = require('fs');
+const content = fs.readFileSync('temp/rich-outline-fail_57_1779015509994.txt', 'utf-8');
+const marker = '========== AI 原始响应 ';
+const markerIdx = content.indexOf(marker);
+const headerEndIdx = content.indexOf(' ==========', markerIdx);
+const aiStart = content.indexOf('\n', headerEndIdx) + 1;
+const aiEnd = content.indexOf('\n\n', aiStart);
+const aiResponse = content.substring(aiStart, aiEnd);
+
+console.log('AI response length:', aiResponse.length);
+
+// Check for issues with opening braces
+let inString = false;
+let escapeNext = false;
+let braceLevel = 0;
+let issues = [];
+
+for (let i = 0; i < aiResponse.length; i++) {
+  const ch = aiResponse[i];
+  if (escapeNext) { escapeNext = false; continue; }
+  if (ch === '\\') { escapeNext = true; continue; }
+  if (ch === '"') { inString = !inString; continue; }
+  if (!inString) {
+    if (ch === '{') {
+      braceLevel++;
+      // Check the next non-space character
+      let j = i + 1;
+      while (j < aiResponse.length && aiResponse[j] === ' ') j++;
+      if (j < aiResponse.length && aiResponse[j] !== '"' && aiResponse[j] !== '}') {
+        issues.push({pos: i, char: ch, nextChar: aiResponse[j], context: aiResponse.substring(Math.max(0,i-20), i+30)});
+      }
+    } else if (ch === '}') {
+      braceLevel--;
+    }
+  }
+}
+
+console.log('\nOpening brace issues found:', issues.length);
+if (issues.length > 0) {
+  issues.slice(0, 5).forEach(issue => {
+    console.log('  pos', issue.pos, ': char', JSON.stringify(issue.char), 'next', JSON.stringify(issue.nextChar));
+    console.log('    context:', JSON.stringify(issue.context));
+  });
+}
+
+// Check last 200 chars of position 1624
+const before1624 = aiResponse.substring(0, 1624);
+console.log('\nLast 200 chars before position 1624:');
+console.log(JSON.stringify(before1624.substring(before1624.length - 200)));
+
+// Check if the problem is an UNESCAPED comma or something within a string value
+// that ends up looking like a property delimiter
+console.log('\n=== Checking for string value issues ===');
+// Find all unescaped [ or { that appear inside string values
+inString = false;
+escapeNext = false;
+let stringIssues = [];
+let lastStringStart = -1;
+let stringContent = '';
+
+for (let i = 0; i < aiResponse.length; i++) {
+  const ch = aiResponse[i];
+  if (escapeNext) {
+    escapeNext = false;
+    stringContent += ch;
+    continue;
+  }
+  if (ch === '\\') {
+    escapeNext = true;
+    stringContent += ch;
+    continue;
+  }
+  if (ch === '"') {
+    if (inString) {
+      // End of string
+      inString = false;
+    } else {
+      // Start of string
+      inString = true;
+      lastStringStart = i;
+      stringContent = '';
+    }
+    continue;
+  }
+  if (inString) {
+    stringContent += ch;
+    // Check if there's a ,{ pattern inside the string that might cause issues
+    if (stringContent.endsWith(',{') || stringContent.endsWith(',{')) {
+      stringIssues.push({pos: i, content: stringContent.slice(-20)});
+    }
+  }
+}
+
+console.log('Potential string value issues:', stringIssues.length);
+if (stringIssues.length > 0) {
+  stringIssues.slice(0, 3).forEach(si => {
+    console.log('  pos', si.pos, 'content ending:', JSON.stringify(si.content));
+  });
+}
+
+// The error at position 1624 is a "{" when inString should be false
+// Let me check if maybe there's an issue with how I'm reading the file
+// Let's just try to print the actual bytes at position 1624 in the original file
+
+console.log('\n=== Re-reading file for byte-level check ===');
+const fileContent = fs.readFileSync('temp/rich-outline-fail_57_1779015509994.txt');
+const fileContentStr = fileContent.toString('utf-8');
+const aiStartIdx = fileContentStr.indexOf(marker);
+const headerEndIdxStr = fileContentStr.indexOf(' ==========', aiStartIdx);
+const aiStartOffset = fileContentStr.indexOf('\n', headerEndIdxStr) + 1;
+const aiEndOffset = fileContentStr.indexOf('\n\n', aiStartOffset);
+const aiFromFile = fileContentStr.substring(aiStartOffset, aiEndOffset);
+
+console.log('aiFromFile length:', aiFromFile.length);
+console.log('Position 1624 from file:', JSON.stringify(aiFromFile[1624]), 'code:', aiFromFile.charCodeAt(1624));
+
+// Try parsing aiFromFile
+try {
+  JSON.parse(aiFromFile);
+  console.log('aiFromFile: SUCCESS');
+} catch(e) {
+  console.log('aiFromFile: FAILED -', e.message.substring(0, 80));
+  const pos = parseInt(e.message.match(/position (\d+)/)?.[1] || '0');
+  if (pos === 1624) {
+    console.log('Same error at position 1624 - issue is in the data itself');
+  }
+}

+ 89 - 0
server/debug-parse16.js

@@ -0,0 +1,89 @@
+const fs = require('fs');
+const content = fs.readFileSync('temp/rich-outline-fail_57_1779015509994.txt', 'utf-8');
+const marker = '========== AI 原始响应 ';
+const markerIdx = content.indexOf(marker);
+const headerEndIdx = content.indexOf(' ==========', markerIdx);
+const aiStart = content.indexOf('\n', headerEndIdx) + 1;
+const aiEnd = content.indexOf('\n\n', aiStart);
+const aiResponse = content.substring(aiStart, aiEnd);
+
+console.log('AI response length:', aiResponse.length);
+
+// Extract the substring around the error and try to parse it
+const pos = 1624;
+const start = Math.max(0, pos - 500);
+const end = Math.min(aiResponse.length, pos + 200);
+const snippet = aiResponse.substring(start, end);
+
+console.log('\n=== Snippet around error (500 before, 200 after pos 1624) ===');
+console.log('Start offset:', start, 'End offset:', end);
+console.log('JSON snippet:', JSON.stringify('...' + snippet + '...'));
+
+// Now let's try to create a mini JSON with just the part around the error
+// and see if we can identify the issue
+const beforeSnippet = aiResponse.substring(0, pos);
+const afterSnippet = aiResponse.substring(pos);
+
+// Try parsing a truncated version that ends at the error position
+console.log('\n=== Try parsing up to error position ===');
+try {
+  JSON.parse(beforeSnippet);
+  console.log('Parsed OK (unexpected)');
+} catch(e) {
+  console.log('Parse error at:', e.message);
+}
+
+// Try parsing from error position onwards
+console.log('\n=== Try parsing from error position ===');
+try {
+  const data = JSON.parse('{' + afterSnippet);
+  console.log('Parsed OK, chapters:', data.chapters?.length);
+} catch(e) {
+  console.log('Parse error at:', e.message.substring(0, 80));
+}
+
+// Let me check if maybe there's a weird character right before position 1624
+// that I'm missing
+console.log('\n=== Detailed byte analysis ===');
+for (let i = 1618; i < 1635; i++) {
+  const code = aiResponse.charCodeAt(i);
+  console.log('  pos', i, ':', 'U+' + code.toString(16).toUpperCase().padStart(4, '0'), JSON.stringify(aiResponse[i]));
+}
+
+// Let's also check if maybe there's a BOM or something
+console.log('\nFirst char:', aiResponse.charCodeAt(0), JSON.stringify(aiResponse[0]));
+console.log('First 3 chars:', aiResponse.substring(0, 3).split('').map(c => c.charCodeAt(0)));
+
+// Try using TextEncoder to get actual bytes
+const encoder = new TextEncoder();
+const bytes = encoder.encode(aiResponse);
+console.log('\n=== UTF-8 bytes around position 1624 (converted to code units) ===');
+// Since the string is 18719 chars but UTF-8 bytes is 38333,
+// that means most chars are 3-byte Chinese chars
+// Let's find the byte offset for UTF-16 position 1624
+let utf8Offset = 0;
+for (let utf16Idx = 0; utf16Idx < 1624; utf16Idx++) {
+  const code = aiResponse.charCodeAt(utf16Idx);
+  if (code <= 0x7F) utf8Offset += 1;
+  else if (code <= 0x7FF) utf8Offset += 2;
+  else if (code <= 0xFFFF) utf8Offset += 3;
+  else utf8Offset += 4;
+}
+console.log('UTF-8 offset for UTF-16 pos 1624:', utf8Offset);
+console.log('UTF-8 bytes around that position:');
+for (let i = utf8Offset - 5; i < utf8Offset + 10; i++) {
+  if (i >= 0 && i < bytes.length) {
+    console.log('  byte', i, ':', bytes[i], String.fromCharCode(bytes[i]));
+  }
+}
+
+// Now check if those UTF-8 bytes form valid UTF-8 characters
+console.log('\n=== Checking UTF-8 validity around position ===');
+// 3-byte UTF-8 for CJK: 1110xxxx 10xxxxxx 10xxxxxx
+// Let's see what the bytes around utf8Offset actually represent
+for (let i = utf8Offset - 3; i < utf8Offset + 6; i++) {
+  if (i >= 0 && i < bytes.length) {
+    const b = bytes[i];
+    console.log('  byte', i, ':', b, 'binary:', b.toString(2).padStart(8, '0'));
+  }
+}

+ 132 - 0
server/debug-parse17.js

@@ -0,0 +1,132 @@
+const fs = require('fs');
+const content = fs.readFileSync('temp/rich-outline-fail_57_1779015509994.txt', 'utf-8');
+const marker = '========== AI 原始响应 ';
+const markerIdx = content.indexOf(marker);
+const headerEndIdx = content.indexOf(' ==========', markerIdx);
+const aiStart = content.indexOf('\n', headerEndIdx) + 1;
+const aiEnd = content.indexOf('\n\n', aiStart);
+const aiResponse = content.substring(aiStart, aiEnd);
+
+console.log('AI response length:', aiResponse.length);
+
+// The error is at position 1624, char "{" code 123
+// Error: Expected double-quoted property name
+// This means when the parser hits {, it expected "propertyName": first
+// This can happen if:
+// 1. We're at the start of a new object but the previous object wasn't properly closed
+// 2. There's a structural error that makes the parser think we're in the wrong place
+
+// Let me trace the brace/bracket depth and check where it goes negative
+let inString = false;
+let escapeNext = false;
+let braceLevel = 0;  // { }
+let bracketLevel = 0; // [ ]
+let pos = 0;
+let errorPos = 1624;
+
+for (let i = 0; i <= errorPos; i++) {
+  const ch = aiResponse[i];
+  if (escapeNext) { escapeNext = false; continue; }
+  if (ch === '\\') { escapeNext = true; continue; }
+  if (ch === '"') { inString = !inString; continue; }
+  if (inString) continue;
+
+  if (ch === '{') {
+    braceLevel++;
+    if (i === errorPos) {
+      console.log('At error pos, opening brace, levels: brace=', braceLevel, 'bracket=', bracketLevel);
+    }
+  } else if (ch === '}') {
+    braceLevel--;
+    if (i === errorPos) {
+      console.log('At error pos, closing brace, levels: brace=', braceLevel, 'bracket=', bracketLevel);
+    }
+  } else if (ch === '[') {
+    bracketLevel++;
+    if (i === errorPos) {
+      console.log('At error pos, opening bracket, levels: brace=', braceLevel, 'bracket=', bracketLevel);
+    }
+  } else if (ch === ']') {
+    bracketLevel--;
+    if (i === errorPos) {
+      console.log('At error pos, closing bracket, levels: brace=', braceLevel, 'bracket=', bracketLevel);
+    }
+  }
+
+  // Check what levels are at errorPos-1 (the char before the error)
+  if (i === errorPos - 1) {
+    console.log('Before error pos (at ' + i + '):', JSON.stringify(ch), 'levels: brace=', braceLevel, 'bracket=', bracketLevel);
+  }
+}
+
+console.log('\nAt error position 1624 itself, before processing it:');
+// Calculate levels BEFORE processing char at 1624
+let braceBefore = 0, bracketBefore = 0;
+inString = false;
+escapeNext = false;
+for (let i = 0; i < 1624; i++) {
+  const ch = aiResponse[i];
+  if (escapeNext) { escapeNext = false; continue; }
+  if (ch === '\\') { escapeNext = true; continue; }
+  if (ch === '"') { inString = !inString; continue; }
+  if (inString) continue;
+  if (ch === '{') braceBefore++;
+  else if (ch === '}') braceBefore--;
+  else if (ch === '[') bracketBefore++;
+  else if (ch === ']') bracketBefore--;
+}
+console.log('Before processing char at 1624: braceLevel=' + braceBefore + ' bracketLevel=' + bracketBefore);
+console.log('Char at 1624 is:', JSON.stringify(aiResponse[1624]));
+
+// The char at 1624 is "{" - so after processing it, braceLevel would be braceBefore+1
+console.log('If we process {, braceLevel would become:', braceBefore + 1);
+
+// But the ERROR says at position 1624 we saw { and expected a property name
+// This means braceLevel must have been 0 BEFORE processing the {, meaning
+// we're trying to open a new object at the top level, which is fine IF we
+// just had a comma
+
+// Wait - let me check what's BEFORE the { at 1624
+console.log('\n=== Context before the { at 1624 ===');
+console.log(JSON.stringify(aiResponse.substring(1580, 1640)));
+
+// The substring shows: ...],\"mustNotRepeat\":[...],{\"number\":3,...
+// So it's: ]},{"number":3
+// That ] closes an array, } closes an object, , separates, { starts new object
+// This is valid if we're adding to a chapters array!
+
+// Let me try a different approach - parse the JSON and see where it fails
+// by trying to parse chunks
+console.log('\n=== Trying incremental parse ===');
+let lastGoodPos = 0;
+inString = false;
+escapeNext = false;
+braceLevel = 0;
+bracketLevel = 0;
+
+for (let i = 0; i < aiResponse.length; i++) {
+  const ch = aiResponse[i];
+  if (escapeNext) { escapeNext = false; continue; }
+  if (ch === '\\') { escapeNext = true; continue; }
+  if (ch === '"') { inString = !inString; continue; }
+  if (inString) continue;
+
+  if (ch === '{') braceLevel++;
+  else if (ch === '}') braceLevel--;
+  else if (ch === '[') bracketLevel++;
+  else if (ch === ']') bracketLevel--;
+
+  // Try parsing up to this point
+  if ((ch === '}' || ch === ',') && braceLevel === 0 && bracketLevel === 0) {
+    try {
+      JSON.parse(aiResponse.substring(0, i + 1));
+      lastGoodPos = i + 1;
+    } catch(e) {
+      if (i > 1600 && i < 1700) {
+        console.log('Parse failed at', i, 'char', JSON.stringify(ch), 'error:', e.message.substring(0, 60));
+      }
+    }
+  }
+}
+console.log('Last good parse position:', lastGoodPos);
+console.log('Context around last good:', JSON.stringify(aiResponse.substring(lastGoodPos - 20, lastGoodPos + 40)));

+ 75 - 0
server/debug-parse18.js

@@ -0,0 +1,75 @@
+const fs = require('fs');
+const content = fs.readFileSync('temp/rich-outline-fail_57_1779015509994.txt', 'utf-8');
+const marker = '========== AI 原始响应 ';
+const markerIdx = content.indexOf(marker);
+const headerEndIdx = content.indexOf(' ==========', markerIdx);
+const aiStart = content.indexOf('\n', headerEndIdx) + 1;
+const aiEnd = content.indexOf('\n\n', aiStart);
+const aiResponse = content.substring(aiStart, aiEnd);
+
+console.log('AI response length:', aiResponse.length);
+
+// Let me try a completely different approach: check if there's a smarter fix
+// I'll look for ALL occurrences of },{ followed by an unquoted property name
+
+// First, let's find what property names are used after },{
+const allCases = [];
+const regex = /\},\{([^"]+):/g;
+let match;
+while ((match = regex.exec(aiResponse)) !== null) {
+  allCases.push({pos: match.index, prop: match[1]});
+}
+console.log('Found', allCases.length, 'cases of },{propName:');
+if (allCases.length < 20) {
+  allCases.forEach(c => console.log('  pos', c.pos, ':', c.prop));
+}
+
+// Check if maybe there are nested issues
+// Let's try parsing from the beginning but stop at position 1624
+const truncatedAtError = aiResponse.substring(0, 1624) + '"}]}]}'; // Close all open structures
+try {
+  JSON.parse(truncatedAtError);
+  console.log('Truncated parse: SUCCESS');
+} catch(e) {
+  console.log('Truncated parse: FAILED -', e.message.substring(0, 80));
+}
+
+// Try a different fix: replace ALL },{ with },{"
+const fixedV2 = aiResponse.replace(/\},\{([a-zA-Z_])/g, '},{"$1');
+try {
+  const data = JSON.parse(fixedV2);
+  console.log('\nFix v2: SUCCESS, chapters:', data.chapters?.length);
+} catch(e) {
+  console.log('\nFix v2: FAILED -', e.message.substring(0, 80));
+
+  // Find which occurrence is problematic
+  const fixedPos = parseInt(e.message.match(/position (\d+)/)?.[1] || '0');
+  console.log('Error at position', fixedPos);
+  console.log('Context:', JSON.stringify(fixedV2.substring(Math.max(0, fixedPos-50), fixedPos+50)));
+}
+
+// Let's also check for a different issue - what if there's a missing comma between array elements?
+// Check for patterns like "keyPoints":["foo","bar"]}{"next"
+const badTransition = /\]\}\{/g;
+const badTransitions = aiResponse.match(badTransition);
+console.log('\nFound', badTransitions ? badTransitions.length : 0, 'instances of ]}{ (missing comma)');
+
+// Let's check if the entire JSON is actually truncated - look at the very end
+console.log('\n=== End of JSON ===');
+console.log('Last 200 chars:', JSON.stringify(aiResponse.substring(aiResponse.length - 200)));
+
+// Try parsing from start, but let's see if we can find any workaround
+// What if we just try to extract the chapters array without the outer structure?
+const chaptersMatch = aiResponse.match(/"chapters"\s*:\s*\[([\s\S]*)\]\s*\}$/);
+if (chaptersMatch) {
+  console.log('\nFound chapters array match, length:', chaptersMatch[1].length);
+  try {
+    const chaptersStr = '[{"number":1' + chaptersMatch[1];
+    const data = JSON.parse(chaptersStr);
+    console.log('Chapters extraction: SUCCESS, count:', data.length);
+  } catch(e) {
+    console.log('Chapters extraction: FAILED -', e.message.substring(0, 80));
+  }
+} else {
+  console.log('\nNo chapters array found at expected location');
+}

+ 95 - 0
server/debug-parse19.js

@@ -0,0 +1,95 @@
+const fs = require('fs');
+const content = fs.readFileSync('temp/rich-outline-fail_57_1779015509994.txt', 'utf-8');
+const marker = '========== AI 原始响应 ';
+const markerIdx = content.indexOf(marker);
+const headerEndIdx = content.indexOf(' ==========', markerIdx);
+const aiStart = content.indexOf('\n', headerEndIdx) + 1;
+const aiEnd = content.indexOf('\n\n', aiStart);
+const aiResponse = content.substring(aiStart, aiEnd);
+
+console.log('AI response length:', aiResponse.length);
+
+// Let's try isolating the problematic section
+// Around position 1624 we have: ...]},{"number":3,"title":"干枯的笔",...
+
+// Try to create a minimal JSON with just the section around the error
+const before = '{"chapters":[{"writingInstructions":{"mustNotRepeat":["a","b"]},';
+const after = '}]}';
+const middle = aiResponse.substring(1610, 1700);
+const testJson = before + middle + after;
+
+console.log('\n=== Test: surround error area with valid JSON ===');
+console.log('Test JSON length:', testJson.length);
+try {
+  const data = JSON.parse(testJson);
+  console.log('SUCCESS');
+} catch(e) {
+  console.log('FAILED:', e.message.substring(0, 100));
+}
+
+// Now let's check: maybe the issue is with the FIRST character after the error position
+// Let me see what happens if we parse from 1620 to 1650
+console.log('\n=== Parse tiny snippet ===');
+const snippet = aiResponse.substring(1600, 1650);
+console.log('Snippet:', JSON.stringify(snippet));
+try {
+  JSON.parse(snippet);
+  console.log('SUCCESS');
+} catch(e) {
+  console.log('FAILED:', e.message);
+}
+
+// Try parsing the JSON up to position 1620
+console.log('\n=== Parse up to position 1620 ===');
+const prefix = aiResponse.substring(0, 1620) + '"]}]}]}';
+try {
+  JSON.parse(prefix);
+  console.log('SUCCESS');
+} catch(e) {
+  console.log('FAILED:', e.message.substring(0, 100));
+}
+
+// Most importantly - let's check if the ACTUAL issue is that the AI returned a malformed
+// JSON that's missing some key structure. Let me check the mainTheme and chapters structure
+console.log('\n=== Check main structure ===');
+const mainThemeMatch = aiResponse.match(/"mainTheme"\s*:\s*"([^"]+)"/);
+console.log('mainTheme found:', mainThemeMatch ? 'yes, length ' + mainThemeMatch[1].length : 'no');
+
+const chaptersArrayMatch = aiResponse.match(/"chapters"\s*:\s*\[/);
+console.log('chapters array found:', chaptersArrayMatch ? 'yes' : 'no');
+
+const mainThemeValue = mainThemeMatch ? mainThemeMatch[1] : '';
+console.log('\nmainTheme value first 100 chars:', JSON.stringify(mainThemeValue.substring(0, 100)));
+
+// Let me try to count braces and brackets in the entire string to find imbalance
+console.log('\n=== Brace/Bracket counting ===');
+let braceLevel = 0;
+let bracketLevel = 0;
+let inString = false;
+let escapeNext = false;
+let minBraceLevel = 0;
+let minBracketLevel = 0;
+
+for (let i = 0; i < aiResponse.length; i++) {
+  const ch = aiResponse[i];
+  if (escapeNext) { escapeNext = false; continue; }
+  if (ch === '\\') { escapeNext = true; continue; }
+  if (ch === '"') { inString = !inString; continue; }
+  if (inString) continue;
+
+  if (ch === '{') { braceLevel++; }
+  else if (ch === '}') { braceLevel--; if (braceLevel < minBraceLevel) minBraceLevel = braceLevel; }
+  else if (ch === '[') { bracketLevel++; }
+  else if (ch === ']') { bracketLevel--; if (bracketLevel < minBracketLevel) minBracketLevel = bracketLevel; }
+}
+
+console.log('Final brace level:', braceLevel, '(should be 0)');
+console.log('Final bracket level:', bracketLevel, '(should be 0)');
+console.log('Minimum brace level reached:', minBraceLevel);
+console.log('Minimum bracket level reached:', minBracketLevel);
+
+if (braceLevel !== 0 || bracketLevel !== 0) {
+  console.log('JSON is UNBALANCED - this is the root cause!');
+} else {
+  console.log('JSON is balanced - the issue must be somewhere specific');
+}

+ 45 - 0
server/debug-parse2.js

@@ -0,0 +1,45 @@
+// Debug: check where truncation happens - in the response or in saveFailedResponse
+
+const fs = require('fs');
+
+// Check the saved file - does it match what we expect?
+const content = fs.readFileSync('temp/rich-outline-fail_57_1779014018760.txt', 'utf-8');
+const marker = '========== AI 原始响应 ';
+const idx = content.indexOf(marker);
+
+// File says "AI 原始响应 (9867 chars)"
+// Let's check what 9867 chars of actual response would be
+const start = content.indexOf('\n', idx) + 1;
+const end = content.indexOf('\n\n========== 响应结尾');
+const savedResponse = content.substring(start, end).trim();
+
+console.log('Saved response length:', savedResponse.length);
+console.log('File header says: 9867 chars');
+console.log('Match:', savedResponse.length === 9867);
+
+console.log('\n--- Saved response end ---');
+console.log(JSON.stringify(savedResponse.substring(savedResponse.length - 100)));
+
+// The file also has a tail
+const tailMarker = '========== 响应结尾500字符 ';
+const tailIdx = content.indexOf(tailMarker);
+const tailStart = content.indexOf('\n', tailIdx) + 1;
+const tail = content.substring(tailStart).trim();
+console.log('\n--- Tail (last 500 chars of original) ---');
+console.log(JSON.stringify(tail.substring(0, 500)));
+
+// Check if saved response ends mid-property
+const lastPropMatch = savedResponse.match(/"estimatedWords":\s*(\d+)/);
+console.log('\n--- Last complete property "estimatedWords" ---');
+const allEstWords = savedResponse.match(/"estimatedWords":\s*\d+/g);
+if (allEstWords) {
+  console.log('All estimatedWords found:', allEstWords.length);
+  console.log('Last one:', allEstWords[allEstWords.length - 1]);
+} else {
+  console.log('No complete estimatedWords properties');
+}
+
+// Is there a partial "estimatedWords" at the end?
+const last500 = savedResponse.slice(-200);
+console.log('\n--- Last 200 chars ---');
+console.log(JSON.stringify(last500));

+ 97 - 0
server/debug-parse20.js

@@ -0,0 +1,97 @@
+const fs = require('fs');
+const content = fs.readFileSync('temp/rich-outline-fail_57_1779015509994.txt', 'utf-8');
+const marker = '========== AI 原始响应 ';
+const markerIdx = content.indexOf(marker);
+const headerEndIdx = content.indexOf(' ==========', markerIdx);
+const aiStart = content.indexOf('\n', headerEndIdx) + 1;
+const aiEnd = content.indexOf('\n\n', aiStart);
+const aiResponse = content.substring(aiStart, aiEnd);
+
+let openBraces = 0, closeBraces = 0, openBrackets = 0, closeBrackets = 0;
+let inString = false, escapeNext = false;
+
+for (let i = 0; i < aiResponse.length; i++) {
+  const ch = aiResponse[i];
+  if (escapeNext) { escapeNext = false; continue; }
+  if (ch === '\\') { escapeNext = true; continue; }
+  if (ch === '"') { inString = !inString; continue; }
+  if (inString) continue;
+  if (ch === '{') openBraces++;
+  else if (ch === '}') closeBraces++;
+  else if (ch === '[') openBrackets++;
+  else if (ch === ']') closeBrackets++;
+}
+
+console.log('Opening braces:', openBraces, 'Closing braces:', closeBraces, 'Diff:', openBraces - closeBraces);
+console.log('Opening brackets:', openBrackets, 'Closing brackets:', closeBrackets, 'Diff:', openBrackets - closeBrackets);
+console.log('Response ends with:', JSON.stringify(aiResponse.substring(aiResponse.length - 20)));
+
+// If braces are unbalanced, let's try to find where it becomes complete
+// by scanning through and finding where brace/bracket levels return to 0
+let braceLevel = 0, bracketLevel = 0;
+let lastCompletePos = 0;
+inString = false;
+escapeNext = false;
+
+for (let i = 0; i < aiResponse.length; i++) {
+  const ch = aiResponse[i];
+  if (escapeNext) { escapeNext = false; continue; }
+  if (ch === '\\') { escapeNext = true; continue; }
+  if (ch === '"') { inString = !inString; continue; }
+  if (inString) continue;
+  if (ch === '{') braceLevel++;
+  else if (ch === '}') braceLevel--;
+  else if (ch === '[') bracketLevel++;
+  else if (ch === ']') bracketLevel--;
+
+  if (braceLevel === 0 && bracketLevel === 0 && (ch === '}' || ch === ']')) {
+    // Try parsing up to here
+    const sub = aiResponse.substring(0, i + 1);
+    try {
+      const data = JSON.parse(sub);
+      if (data.chapters && data.chapters.length > 0) {
+        lastCompletePos = i + 1;
+        console.log('Found complete valid JSON at position', i + 1, 'with', data.chapters.length, 'chapters');
+      }
+    } catch(e) {}
+  }
+}
+
+console.log('Last complete valid JSON position:', lastCompletePos);
+if (lastCompletePos > 0) {
+  console.log('Partial JSON length:', lastCompletePos, 'of', aiResponse.length, '(' + Math.round(lastCompletePos/aiResponse.length*100) + '%)');
+}
+
+// Try to find if we can truncate and still get valid partial JSON
+console.log('\n=== Trying truncation approach ===');
+// The JSON is truncated (unbalanced). Let me try to find the last complete chapter
+const chaptersData = aiResponse.match(/"chapters"\s*:\s*\[([\s\S]*)/);
+if (chaptersData) {
+  console.log('Chapters array starts at position:', aiResponse.indexOf('"chapters"'));
+  // Try to find where the chapters array closes
+  let bracketCount = 0;
+  let chaptersEnd = -1;
+  inString = false;
+  escapeNext = false;
+  const chaptersStartIdx = aiResponse.indexOf('"chapters"');
+  for (let i = chaptersStartIdx; i < aiResponse.length; i++) {
+    const ch = aiResponse[i];
+    if (escapeNext) { escapeNext = false; continue; }
+    if (ch === '\\') { escapeNext = true; continue; }
+    if (ch === '"') { inString = !inString; continue; }
+    if (inString) continue;
+    if (ch === '[') bracketCount++;
+    else if (ch === ']') { bracketCount--; if (bracketCount === 0) { chaptersEnd = i; break; } }
+  }
+  console.log('Chapters array ends at position:', chaptersEnd);
+  if (chaptersEnd > 0) {
+    const chaptersJson = '{"chapters":' + aiResponse.substring(chaptersStartIdx + 13, chaptersEnd + 1);
+    console.log('Trying to parse chapters alone...');
+    try {
+      const data = JSON.parse(chaptersJson);
+      console.log('SUCCESS! Chapters count:', data.chapters.length);
+    } catch(e) {
+      console.log('FAILED:', e.message.substring(0, 100));
+    }
+  }
+}

+ 112 - 0
server/debug-parse21.js

@@ -0,0 +1,112 @@
+const fs = require('fs');
+const content = fs.readFileSync('temp/rich-outline-fail_57_1779015509994.txt', 'utf-8');
+const marker = '========== AI 原始响应 ';
+const markerIdx = content.indexOf(marker);
+const headerEndIdx = content.indexOf(' ==========', markerIdx);
+const aiStart = content.indexOf('\n', headerEndIdx) + 1;
+const aiEnd = content.indexOf('\n\n', aiStart);
+const aiResponse = content.substring(aiStart, aiEnd);
+
+console.log('Length:', aiResponse.length);
+
+// Goal: Find the chapters that are COMPLETE (both opening and closing braces)
+// by finding the last position where we have a valid chapter object
+
+// Strategy: Find all "chapters": [ patterns, then find where each chapter ends
+// A chapter ends when we see "}," or "]," patterns that close the chapter object
+
+// Actually, let's try a different approach: find the last complete "number":X in a chapters array
+// and work backwards to find the full chapter
+
+// First, find where chapters array starts
+const chaptersStart = aiResponse.indexOf('"chapters":[');
+console.log('Chapters array starts at:', chaptersStart);
+
+if (chaptersStart === -1) {
+  console.log('No chapters array found');
+  process.exit(1);
+}
+
+// Try to find where the JSON becomes invalid - that's our truncation point
+// Then extract all chapters before that
+let lastGoodPos = chaptersStart;
+let braceLevel = 0;
+let inString = false;
+let escapeNext = false;
+
+for (let i = chaptersStart; i < aiResponse.length; i++) {
+  const ch = aiResponse[i];
+  if (escapeNext) { escapeNext = false; continue; }
+  if (ch === '\\') { escapeNext = true; continue; }
+  if (ch === '"') { inString = !inString; continue; }
+  if (inString) continue;
+  if (ch === '{') braceLevel++;
+  else if (ch === '}') braceLevel--;
+  else if (ch === '[') braceLevel++;
+  else if (ch === ']') braceLevel--;
+
+  if (braceLevel < 0) {
+    console.log('JSON goes negative at position', i, '- char', JSON.stringify(ch));
+    break;
+  }
+
+  // Try to parse up to this point
+  const sub = '{"outline":' + aiResponse.substring(chaptersStart, i + 1) + '}';
+  try {
+    JSON.parse(sub);
+    lastGoodPos = i + 1;
+  } catch(e) {
+    // If it fails because braces are unbalanced, that's ok - we might be mid-chapter
+    if (!e.message.includes('Unexpected end')) {
+      // Some other error
+    }
+  }
+}
+
+console.log('Last position where prefix might be valid:', lastGoodPos);
+console.log('Prefix length:', lastGoodPos - chaptersStart);
+
+// Try to extract the complete chapters from the prefix
+const prefix = '{"outline":{"chapters":' + aiResponse.substring(chaptersStart + 13, lastGoodPos) + '}}';
+console.log('\nTrying to parse prefix as complete outline...');
+try {
+  const data = JSON.parse(prefix);
+  console.log('SUCCESS! Chapters:', data.outline?.chapters?.length);
+  if (data.outline?.chapters?.length > 0) {
+    console.log('First chapter:', data.outline.chapters[0].title);
+    console.log('Last chapter:', data.outline.chapters[data.outline.chapters.length - 1].title);
+  }
+} catch(e) {
+  console.log('FAILED:', e.message.substring(0, 100));
+
+  // Try a more aggressive approach - just extract complete chapter objects
+  console.log('\n=== Trying chapter-by-chapter extraction ===');
+  // Find all occurrences of "number":N where N is a number
+  const chapterNumRegex = /"number":(\d+)/g;
+  let match;
+  let chapters = [];
+  let lastEnd = chaptersStart + 13; // After "chapters":[
+
+  while ((match = chapterNumRegex.exec(aiResponse)) !== null) {
+    const num = parseInt(match[1]);
+    const startPos = match.index;
+
+    // Find the end of this chapter - look for }], or }},{ patterns
+    let endSearchStart = startPos + match[0].length;
+    // Find the next "number": pattern or end of chapters array
+    const nextNumMatch = chapterNumRegex.exec(aiResponse.substring(endSearchStart));
+    const endPos = nextNumMatch ? endSearchStart + nextNumMatch.index : aiResponse.lastIndexOf(']}', aiResponse.length);
+
+    const chapterJson = '{"number":' + num + aiResponse.substring(startPos + 9, endPos);
+    try {
+      const chapter = JSON.parse(chapterJson);
+      if (chapter.title && chapter.summary) {
+        chapters.push(chapter);
+        console.log('Found chapter', num + ':', chapter.title, 'length:', chapterJson.length);
+      }
+    } catch(e) {
+      console.log('Chapter', num, 'parse failed:', e.message.substring(0, 50));
+    }
+  }
+  console.log('\nTotal complete chapters found:', chapters.length);
+}

+ 119 - 0
server/debug-parse22.js

@@ -0,0 +1,119 @@
+const fs = require('fs');
+const content = fs.readFileSync('temp/rich-outline-fail_57_1779015509994.txt', 'utf-8');
+const marker = '========== AI 原始响应 ';
+const markerIdx = content.indexOf(marker);
+const headerEndIdx = content.indexOf(' ==========', markerIdx);
+const aiStart = content.indexOf('\n', headerEndIdx) + 1;
+const aiEnd = content.indexOf('\n\n', aiStart);
+const aiResponse = content.substring(aiStart, aiEnd);
+
+console.log('AI response length:', aiResponse.length);
+
+// Check for the EXACT patterns my fix targets
+const pattern1 = '{number":';
+const pattern2 = '{"number":';
+console.log('Pattern "{number\\":":', aiResponse.includes('{number":'));
+console.log('Pattern "{\\"number\\":":', aiResponse.includes('{"number":'));
+
+// Let's see what character sequence is at position 1624
+console.log('\nExact chars at position 1624:');
+for (let i = 1624; i < 1635; i++) {
+  console.log('  pos', i, ': U+' + aiResponse.charCodeAt(i).toString(16).padStart(4, '0'), JSON.stringify(aiResponse[i]));
+}
+
+// Check if position 1624 actually starts with { followed by "
+console.log('\nAt 1624, char is "{":', aiResponse[1624] === '{');
+console.log('At 1625, char is "\"":', aiResponse[1625] === '"');
+console.log('At 1626, char is "n":', aiResponse[1626] === 'n');
+
+// So it's },{\"number\":3 at positions 1620-1634
+// 1620: " (closing quote of mustNotRepeat array)
+// 1621: ] (closing bracket of mustNotRepeat array)
+// 1622: } (closing brace of writingInstructions)
+// 1623: , (comma separator)
+// 1624: { (OPENING NEW OBJECT)
+// 1625: " (opening quote of property name)
+// 1626: n, 1627: u, 1628: m, 1629: b, 1630: e, 1631: r (number)
+// 1632: " (closing quote)
+// 1633: : (colon)
+// 1634: 3 (value)
+
+// So the JSON is CORRECT! The issue must be something else.
+// The error message says "Expected double-quoted property name" at position 1624
+// But at position 1624, we have "{" which is NOT a property name!
+
+// Wait - maybe the issue is that my fix function is NOT being applied to the right string!
+// Let me check - the function fixMissingQuotes takes a JSON string and applies replacements
+// But the issue is the fix does .replace(/\{number":/g, '{"number":')
+// This replaces "{number": with "{\"number\":"
+// Let me verify this replacement works on the actual string
+
+const test1 = '{\"number":3}';
+const fixed1 = test1.replace(/\{number":/g, '{"number":');
+console.log('\nFix test:');
+console.log('Input:', JSON.stringify(test1));
+console.log('Output:', JSON.stringify(fixed1));
+
+// The actual string at 1624 is NOT {number": but just {"
+// Let me trace what my fix would do
+// My fix: .replace(/\{number":/g, '{"number":')
+// This replaces the LITERAL string "{number":" with '{"number":'
+
+// But in the actual JSON, the sequence is:
+// ...]},{"number":3,"title":"
+//          ^-- at 1620
+// So at 1624 we have { followed by "number":
+// The string literal in my regex is: "{number":
+// Let's verify this is present in the JSON
+const idx = aiResponse.indexOf('{number":');
+console.log('\nIndex of "{number\\":":', idx);
+
+// Also check what's actually before position 1624
+console.log('\nChar before position 1624 (at 1623):', JSON.stringify(aiResponse[1623]), 'code:', aiResponse.charCodeAt(1623));
+
+// I think the issue might be that I'm looking at the WRONG error
+// Let me re-examine - the JSON has 39 more opening braces than closing braces
+// This means the JSON is TRUNCATED - some objects were never closed
+// And my fix function is working correctly, but the JSON is still truncated
+
+// Let me check: can I find where the JSON SHOULD end, and try to fix it there?
+console.log('\n=== Checking for truncation point ===');
+console.log('Expected closing braces:', 101 - 39); // 62 should be the target
+console.log('Actual closing braces:', 62);
+
+// Find where the JSON stops making sense
+let inString = false, escapeNext = false;
+let braceLevel = 0;
+let lastValidPos = 0;
+
+for (let i = 0; i < aiResponse.length; i++) {
+  const ch = aiResponse[i];
+  if (escapeNext) { escapeNext = false; continue; }
+  if (ch === '\\') { escapeNext = true; continue; }
+  if (ch === '"') { inString = !inString; continue; }
+  if (inString) continue;
+  if (ch === '{') braceLevel++;
+  else if (ch === '}') braceLevel--;
+
+  // When braceLevel goes below 0, that's where we have extra closing
+  if (braceLevel < 0) {
+    console.log('Brace level goes negative at position', i, '- char:', JSON.stringify(ch));
+    break;
+  }
+
+  // Track last position where we had balanced braces at top level
+  if (braceLevel === 0 && ch === '}') {
+    // Try parsing
+    try {
+      const sub = aiResponse.substring(0, i + 1);
+      JSON.parse(sub);
+      lastValidPos = i + 1;
+    } catch(e) {}
+  }
+}
+
+console.log('Last valid parse position:', lastValidPos);
+if (lastValidPos > 0) {
+  console.log('JSON is truncated at position', lastValidPos, 'of', aiResponse.length);
+  console.log('We have', lastValidPos, 'valid chars but need', aiResponse.length);
+}

+ 76 - 0
server/debug-parse23.js

@@ -0,0 +1,76 @@
+const fs = require('fs');
+const content = fs.readFileSync('temp/rich-outline-fail_57_1779015509994.txt', 'utf-8');
+const marker = '========== AI 原始响应 ';
+const markerIdx = content.indexOf(marker);
+const headerEndIdx = content.indexOf(' ==========', markerIdx);
+const aiStart = content.indexOf('\n', headerEndIdx) + 1;
+const aiEnd = content.indexOf('\n\n', aiStart);
+const aiResponse = content.substring(aiStart, aiEnd);
+
+// The issue: we have more opens than closes, meaning JSON is truncated
+// But the actual character at position 1624 looks correct: {"number":3
+
+// Let me check if maybe there's a truncation recovery strategy that could work
+// We need to find where the valid JSON ends and try to make sense of it
+
+// Let me check if the response actually contains a valid prefix we can use
+// by scanning for valid property structures
+
+// First, let's see if there's any position where we have a COMPLETELY valid prefix
+let validUpTo = 0;
+for (let i = 1; i <= aiResponse.length; i++) {
+  try {
+    const sub = aiResponse.substring(0, i);
+    JSON.parse(sub);
+    validUpTo = i;
+  } catch(e) {
+    // continue
+  }
+}
+console.log('Longest valid prefix length:', validUpTo);
+console.log('Valid prefix ends with:', JSON.stringify(aiResponse.substring(validUpTo - 30, validUpTo)));
+console.log('Valid prefix length vs total:', validUpTo, '/', aiResponse.length, '=', Math.round(validUpTo/aiResponse.length*100) + '%');
+
+// If we have a long enough valid prefix, we might be able to extract chapters from it
+if (validUpTo > 1000) {
+  console.log('\n=== Valid prefix analysis ===');
+  const prefix = aiResponse.substring(0, validUpTo);
+  try {
+    const data = JSON.parse(prefix);
+    if (data.mainTheme) console.log('mainTheme found:', data.mainTheme.substring(0, 50));
+    if (data.chapters) console.log('chapters count in prefix:', data.chapters.length);
+  } catch(e) {
+    console.log('Parse failed:', e.message);
+  }
+}
+
+// Now let me try a different approach: count closing braces needed to close the JSON
+// and try appending them to see if we can make it parse
+console.log('\n=== Trying to complete the truncated JSON ===');
+// We have 39 more opens than closes, so we need 39 closing braces
+// Let's try to complete the JSON by finding where each unclosed brace should close
+
+// But actually, maybe the better approach is to find where chapters end
+// and just try to extract what we can
+
+// Let me try yet another approach: find all complete "chapters": [...] patterns
+const chaptersRegex = /"chapters"\s*:\s*\[([\s\S]*?)\]\s*\}/g;
+let match;
+let foundChapters = [];
+while ((match = chaptersRegex.exec(aiResponse)) !== null) {
+  const inner = match[1];
+  console.log('Found chapters array at position', match.index, 'length', inner.length);
+  // Try to parse the chapters array
+  try {
+    const arr = JSON.parse('[' + inner + ']');
+    if (Array.isArray(arr) && arr.length > 0) {
+      console.log('  Parsed successfully!', arr.length, 'chapters');
+      foundChapters.push(...arr);
+    }
+  } catch(e) {
+    // Try to extract chapter numbers
+    const numMatches = inner.match(/"number":(\d+)/g);
+    console.log('  Chapter numbers found:', numMatches ? numMatches.length : 0);
+  }
+}
+console.log('\nTotal chapters found in arrays:', foundChapters.length);

+ 109 - 0
server/debug-parse24.js

@@ -0,0 +1,109 @@
+const fs = require('fs');
+const content = fs.readFileSync('temp/rich-outline-fail_57_1779017412608.txt', 'utf-8');
+const marker = '========== AI 原始响应 ';
+const markerIdx = content.indexOf(marker);
+const headerEndIdx = content.indexOf(' ==========', markerIdx);
+const aiStart = content.indexOf('\n', headerEndIdx) + 1;
+const aiEnd = content.indexOf('\n\n', aiStart);
+const aiResponse = content.substring(aiStart, aiEnd);
+
+console.log('AI response length:', aiResponse.length);
+
+// Check if the new fail file has a different error
+const pos = parseInt(content.match(/position (\d+)/)?.[1] || '0');
+console.log('Error position in file:', pos);
+
+// Check for truncation
+let openBraces = 0, closeBraces = 0;
+let inString = false, escapeNext = false;
+for (let i = 0; i < aiResponse.length; i++) {
+  const ch = aiResponse[i];
+  if (escapeNext) { escapeNext = false; continue; }
+  if (ch === '\\') { escapeNext = true; continue; }
+  if (ch === '"') { inString = !inString; continue; }
+  if (inString) continue;
+  if (ch === '{') openBraces++;
+  else if (ch === '}') closeBraces++;
+}
+console.log('Open braces:', openBraces, 'Close:', closeBraces, 'Diff:', openBraces - closeBraces);
+console.log('Ends with }:', aiResponse.trim().endsWith('}'));
+
+try {
+  JSON.parse(aiResponse);
+  console.log('Raw parse: SUCCESS');
+} catch(e) {
+  console.log('Raw parse FAILED:', e.message.substring(0, 100));
+  // Check position
+  const errorPos = parseInt(e.message.match(/position (\d+)/)?.[1] || '0');
+  if (errorPos > 0 && errorPos < aiResponse.length) {
+    console.log('Context around error:');
+    console.log(JSON.stringify(aiResponse.substring(Math.max(0, errorPos-50), errorPos+50)));
+  }
+}
+
+// Try with fixMissingQuotes
+function fixMissingQuotes(jsonStr) {
+  return jsonStr
+    .replace(/\{number":/g, '{"number":')
+    .replace(/\{title":/g, '{"title":')
+    .replace(/\{summary":/g, '{"summary":')
+    .replace(/\{opening":/g, '{"opening":')
+    .replace(/\{structure":/g, '{"structure":')
+    .replace(/\{mustCover":/g, '{"mustCover":')
+    .replace(/\{mustNotRepeat":/g, '{"mustNotRepeat":')
+    .replace(/\{toneAdjustment":/g, '{"toneAdjustment":')
+    .replace(/\{keyTakeaway":/g, '{"keyTakeaway":')
+    .replace(/\{keyPoints":/g, '{"keyPoints":')
+    .replace(/\{estimatedWords":/g, '{"estimatedWords":')
+    .replace(/\{writingInstructions":/g, '{"writingInstructions":');
+}
+
+const fixed = fixMissingQuotes(aiResponse);
+try {
+  JSON.parse(fixed);
+  console.log('Fixed parse: SUCCESS');
+} catch(e) {
+  console.log('Fixed parse FAILED:', e.message.substring(0, 100));
+}
+
+// Check what strategy 9 would extract
+console.log('\n=== Testing strategy 9 extraction ===');
+const chaptersArrayStart = aiResponse.indexOf('"chapters":[');
+if (chaptersArrayStart !== -1) {
+  let bracketDepth = 0;
+  let inString = false;
+  let escapeNext = false;
+  let arrayEnd = -1;
+
+  for (let i = chaptersArrayStart + 12; i < aiResponse.length; i++) {
+    const ch = aiResponse[i];
+    if (escapeNext) { escapeNext = false; continue; }
+    if (ch === '\\') { escapeNext = true; continue; }
+    if (ch === '"') { inString = !inString; continue; }
+    if (inString) continue;
+    if (ch === '[') bracketDepth++;
+    else if (ch === ']') { bracketDepth--; if (bracketDepth === 0) { arrayEnd = i; break; } }
+  }
+
+  console.log('Chapters array ends at:', arrayEnd);
+  const arrayContent = aiResponse.substring(chaptersArrayStart + 12, arrayEnd);
+  const fixedContent = fixMissingQuotes(arrayContent);
+
+  try {
+    const chapters = JSON.parse('[' + fixedContent + ']');
+    console.log('Parsed chapters array: SUCCESS, count:', chapters.length);
+  } catch(e) {
+    console.log('Parsed chapters array: FAILED -', e.message.substring(0, 80));
+    // Try one by one
+    const chapterMatches = fixedContent.match(/\{[^}]+\}/g) || [];
+    console.log('Found', chapterMatches.length, 'chapter objects by regex');
+    let validChapters = 0;
+    for (const chStr of chapterMatches) {
+      try {
+        JSON.parse(chStr);
+        validChapters++;
+      } catch(e) {}
+    }
+    console.log('Valid chapter objects:', validChapters);
+  }
+}

+ 37 - 0
server/debug-parse25.js

@@ -0,0 +1,37 @@
+const fs = require('fs');
+const content = fs.readFileSync('temp/rich-outline-fail_57_1779022210168.txt', 'utf-8');
+const marker = '========== AI 原始响应 ';
+const markerIdx = content.indexOf(marker);
+const headerEndIdx = content.indexOf(' ==========', markerIdx);
+const aiStart = content.indexOf('\n', headerEndIdx) + 1;
+const aiEnd = content.indexOf('\n\n', aiStart);
+const aiResponse = content.substring(aiStart, aiEnd);
+
+console.log('AI response length:', aiResponse.length);
+
+// Count braces
+let openBraces = 0, closeBraces = 0;
+let inString = false, escapeNext = false;
+for (let i = 0; i < aiResponse.length; i++) {
+  const ch = aiResponse[i];
+  if (escapeNext) { escapeNext = false; continue; }
+  if (ch === '\\') { escapeNext = true; continue; }
+  if (ch === '"') { inString = !inString; continue; }
+  if (inString) continue;
+  if (ch === '{') openBraces++;
+  else if (ch === '}') closeBraces++;
+}
+console.log('Open braces:', openBraces, 'Close:', closeBraces, 'Diff:', openBraces - closeBraces);
+console.log('Ends with }:', aiResponse.trim().endsWith('}'));
+
+try {
+  JSON.parse(aiResponse);
+  console.log('Raw parse: SUCCESS');
+} catch(e) {
+  console.log('Raw parse FAILED:', e.message.substring(0, 100));
+  const pos = parseInt(e.message.match(/position (\d+)/)?.[1] || '0');
+  console.log('Error at position:', pos);
+  if (pos > 0 && pos < aiResponse.length) {
+    console.log('Context:', JSON.stringify(aiResponse.substring(Math.max(0,pos-50), pos+50)));
+  }
+}

+ 113 - 0
server/debug-parse26.js

@@ -0,0 +1,113 @@
+const fs = require('fs');
+const content = fs.readFileSync('temp/rich-outline-fail_57_1779022210168.txt', 'utf-8');
+const marker = '========== AI 原始响应 ';
+const markerIdx = content.indexOf(marker);
+const headerEndIdx = content.indexOf(' ==========', markerIdx);
+const aiStart = content.indexOf('\n', headerEndIdx) + 1;
+const aiEnd = content.indexOf('\n\n', aiStart);
+const aiResponse = content.substring(aiStart, aiEnd);
+
+console.log('AI response length:', aiResponse.length);
+
+// Extract mainTheme
+const mainThemeMatch = aiResponse.match(/"mainTheme"\s*:\s*"([^"]+)"/);
+console.log('mainTheme:', mainThemeMatch ? mainThemeMatch[1].substring(0, 50) : 'none');
+
+// Extract structureLogic
+const structureLogicMatch = aiResponse.match(/"structureLogic"\s*:\s*"([^"]+)"/);
+console.log('structureLogic:', structureLogicMatch ? structureLogicMatch[1].substring(0, 50) : 'none');
+
+// Find chapters array
+const chaptersArrayStart = aiResponse.indexOf('"chapters":[');
+console.log('\nChapters array starts at:', chaptersArrayStart);
+
+if (chaptersArrayStart === -1) {
+  console.log('No chapters array found!');
+  process.exit(1);
+}
+
+// Find array end by counting brackets
+let bracketDepth = 0;
+let inString = false;
+let escapeNext = false;
+let arrayEnd = -1;
+
+for (let i = chaptersArrayStart + 12; i < aiResponse.length; i++) {
+  const ch = aiResponse[i];
+  if (escapeNext) { escapeNext = false; continue; }
+  if (ch === '\\') { escapeNext = true; continue; }
+  if (ch === '"') { inString = !inString; continue; }
+  if (inString) continue;
+  if (ch === '[') bracketDepth++;
+  else if (ch === ']') { bracketDepth--; if (bracketDepth === 0) { arrayEnd = i; break; } }
+}
+
+console.log('Chapters array ends at:', arrayEnd);
+
+if (arrayEnd === -1) {
+  console.log('Could not find array end!');
+  process.exit(1);
+}
+
+const arrayContent = aiResponse.substring(chaptersArrayStart + 12, arrayEnd);
+console.log('Array content length:', arrayContent.length);
+console.log('Array content start:', JSON.stringify(arrayContent.substring(0, 100)));
+console.log('Array content end:', JSON.stringify(arrayContent.substring(arrayContent.length - 100)));
+
+// Fix missing quotes
+function fixMissingQuotes(jsonStr) {
+  return jsonStr
+    .replace(/\{number":/g, '{"number":')
+    .replace(/\{title":/g, '{"title":')
+    .replace(/\{summary":/g, '{"summary":')
+    .replace(/\{opening":/g, '{"opening":')
+    .replace(/\{structure":/g, '{"structure":')
+    .replace(/\{mustCover":/g, '{"mustCover":')
+    .replace(/\{mustNotRepeat":/g, '{"mustNotRepeat":')
+    .replace(/\{toneAdjustment":/g, '{"toneAdjustment":')
+    .replace(/\{keyTakeaway":/g, '{"keyTakeaway":')
+    .replace(/\{keyPoints":/g, '{"keyPoints":')
+    .replace(/\{estimatedWords":/g, '{"estimatedWords":')
+    .replace(/\{writingInstructions":/g, '{"writingInstructions":')
+    .replace(/\{number:/g, '{"number":')
+    .replace(/\{title:/g, '{"title":')
+    .replace(/\{summary:/g, '{"summary":')
+    .replace(/\{opening:/g, '{"opening":')
+    .replace(/\{structure:/g, '{"structure":')
+    .replace(/\{mustCover:/g, '{"mustCover":')
+    .replace(/\{mustNotRepeat:/g, '{"mustNotRepeat":')
+    .replace(/\{toneAdjustment:/g, '{"toneAdjustment":')
+    .replace(/\{keyTakeaway:/g, '{"keyTakeaway":')
+    .replace(/\{keyPoints:/g, '{"keyPoints":')
+    .replace(/\{estimatedWords:/g, '{"estimatedWords":')
+    .replace(/\{writingInstructions:/g, '{"writingInstructions":');
+}
+
+const fixedContent = fixMissingQuotes(arrayContent);
+console.log('\nFixed content start:', JSON.stringify(fixedContent.substring(0, 100)));
+
+// Try to parse
+try {
+  const chapters = JSON.parse('[' + fixedContent + ']');
+  console.log('Parsed SUCCESS, chapters:', chapters.length);
+} catch(e) {
+  console.log('Parse FAILED:', e.message.substring(0, 80));
+
+  // Try regex extraction
+  const chapterMatches = fixedContent.match(/\{[^}]+\}/g) || [];
+  console.log('Found', chapterMatches.length, 'chapter objects by regex');
+
+  let validChapters = 0;
+  for (const chStr of chapterMatches) {
+    try {
+      const ch = JSON.parse(chStr);
+      if (ch && ch.title && ch.number) {
+        validChapters++;
+        console.log('  Valid chapter:', ch.number, '-', ch.title.substring(0, 40));
+      }
+    } catch(e) {
+      // Skip
+    }
+  }
+  console.log('Total valid chapters:', validChapters);
+}

+ 123 - 0
server/debug-parse27.js

@@ -0,0 +1,123 @@
+const fs = require('fs');
+const content = fs.readFileSync('temp/rich-outline-fail_57_1779022210168.txt', 'utf-8');
+const marker = '========== AI 原始响应 ';
+const markerIdx = content.indexOf(marker);
+const headerEndIdx = content.indexOf(' ==========', markerIdx);
+const aiStart = content.indexOf('\n', headerEndIdx) + 1;
+const aiEnd = content.indexOf('\n\n', aiStart);
+const aiResponse = content.substring(aiStart, aiEnd);
+
+console.log('AI response length:', aiResponse.length);
+
+// Find chapters array
+const chaptersArrayStart = aiResponse.indexOf('"chapters":[');
+console.log('Chapters array starts at:', chaptersArrayStart);
+
+if (chaptersArrayStart === -1) {
+  console.log('No chapters array found!');
+  process.exit(1);
+}
+
+// Proper bracket counting that respects strings
+function findArrayEnd(jsonStr, startIdx) {
+  let bracketDepth = 0;
+  let inString = false;
+  let escapeNext = false;
+
+  for (let i = startIdx; i < jsonStr.length; i++) {
+    const ch = jsonStr[i];
+    if (escapeNext) { escapeNext = false; continue; }
+    if (ch === '\\') { escapeNext = true; continue; }
+    if (ch === '"') { inString = !inString; continue; }
+    if (inString) continue;
+
+    if (ch === '[') { bracketDepth++; }
+    else if (ch === ']') {
+      bracketDepth--;
+      if (bracketDepth === 0) return i;
+    }
+  }
+  return -1;
+}
+
+const arrayEnd = findArrayEnd(aiResponse, chaptersArrayStart + 12);
+console.log('Chapters array ends at:', arrayEnd);
+
+if (arrayEnd === -1) {
+  console.log('Could not find array end with proper string handling!');
+  process.exit(1);
+}
+
+const arrayContent = aiResponse.substring(chaptersArrayStart + 12, arrayEnd);
+console.log('Array content length:', arrayContent.length);
+console.log('Array content start:', JSON.stringify(arrayContent.substring(0, 150)));
+console.log('Array content end:', JSON.stringify(arrayContent.substring(arrayContent.length - 100)));
+
+// Fix missing quotes
+function fixMissingQuotes(jsonStr) {
+  return jsonStr
+    .replace(/\{number":/g, '{"number":')
+    .replace(/\{title":/g, '{"title":')
+    .replace(/\{summary":/g, '{"summary":')
+    .replace(/\{opening":/g, '{"opening":')
+    .replace(/\{structure":/g, '{"structure":')
+    .replace(/\{mustCover":/g, '{"mustCover":')
+    .replace(/\{mustNotRepeat":/g, '{"mustNotRepeat":')
+    .replace(/\{toneAdjustment":/g, '{"toneAdjustment":')
+    .replace(/\{keyTakeaway":/g, '{"keyTakeaway":')
+    .replace(/\{keyPoints":/g, '{"keyPoints":')
+    .replace(/\{estimatedWords":/g, '{"estimatedWords":')
+    .replace(/\{writingInstructions":/g, '{"writingInstructions":')
+    .replace(/\{number:/g, '{"number":')
+    .replace(/\{title:/g, '{"title":')
+    .replace(/\{summary:/g, '{"summary":')
+    .replace(/\{opening:/g, '{"opening":')
+    .replace(/\{structure:/g, '{"structure":')
+    .replace(/\{mustCover:/g, '{"mustCover":')
+    .replace(/\{mustNotRepeat:/g, '{"mustNotRepeat":')
+    .replace(/\{toneAdjustment:/g, '{"toneAdjustment":')
+    .replace(/\{keyTakeaway:/g, '{"keyTakeaway":')
+    .replace(/\{keyPoints:/g, '{"keyPoints":')
+    .replace(/\{estimatedWords:/g, '{"estimatedWords":')
+    .replace(/\{writingInstructions:/g, '{"writingInstructions":');
+}
+
+const fixedContent = fixMissingQuotes(arrayContent);
+
+// Try to parse the whole array
+try {
+  const chapters = JSON.parse('[' + fixedContent + ']');
+  console.log('Parsed SUCCESS, chapters:', chapters.length);
+  chapters.slice(0, 5).forEach((ch, i) => {
+    console.log('  Chapter', i+1, ':', ch.title ? ch.title.substring(0, 40) : 'NO TITLE', 'number:', ch.number);
+  });
+} catch(e) {
+  console.log('Array parse FAILED:', e.message.substring(0, 100));
+
+  // Try extracting chapter by chapter
+  console.log('\n=== Trying chapter-by-chapter extraction ===');
+
+  // Use a regex to find all chapter objects
+  const chapterRegex = /\{"number":\s*(\d+)[^}]*\}/g;
+  let match;
+  let validChapters = [];
+
+  while ((match = chapterRegex.exec(fixedContent)) !== null) {
+    try {
+      const chapter = JSON.parse(match[0]);
+      if (chapter && chapter.title) {
+        validChapters.push(chapter);
+        console.log('  Found chapter', chapter.number, ':', chapter.title.substring(0, 40));
+      }
+    } catch(e) {
+      // Try to extract just the number and title
+      const numMatch = match[0].match(/"number":\s*(\d+)/);
+      const titleMatch = match[0].match(/"title":\s*"([^"]+)"/);
+      if (numMatch && titleMatch) {
+        validChapters.push({ number: parseInt(numMatch[1]), title: titleMatch[1] });
+        console.log('  Found partial chapter', numMatch[1], ':', titleMatch[1].substring(0, 40));
+      }
+    }
+  }
+  console.log('\nTotal valid chapters found:', validChapters.length);
+}

+ 103 - 0
server/debug-parse28.js

@@ -0,0 +1,103 @@
+const fs = require('fs');
+const content = fs.readFileSync('temp/rich-outline-fail_57_1779022210168.txt', 'utf-8');
+const marker = '========== AI 原始响应 ';
+const markerIdx = content.indexOf(marker);
+const headerEndIdx = content.indexOf(' ==========', markerIdx);
+const aiStart = content.indexOf('\n', headerEndIdx) + 1;
+const aiEnd = content.indexOf('\n\n', aiStart);
+const aiResponse = content.substring(aiStart, aiEnd);
+
+// Find chapters array - need to find the correct start
+const chaptersArrayStart = aiResponse.indexOf('"chapters":[');
+console.log('Chapters array starts at:', chaptersArrayStart);
+
+// Check the raw characters around position 376
+console.log('\nChars around position 376:');
+for (let i = 370; i < 390; i++) {
+  console.log('  pos', i, ':', JSON.stringify(aiResponse[i]), 'code:', aiResponse.charCodeAt(i));
+}
+
+// Check chars around position 5000
+console.log('\nChars around position 5000:');
+for (let i = 4995; i < 5010; i++) {
+  console.log('  pos', i, ':', JSON.stringify(aiResponse[i]), 'code:', aiResponse.charCodeAt(i));
+}
+
+// The issue is the findArrayEnd function must be finding the WRONG closing bracket
+// Let's trace through it manually
+console.log('\n=== Manual trace of bracket counting ===');
+let bracketDepth = 0;
+let inString = false;
+let escapeNext = false;
+let lastCloseBracket = -1;
+let lastCloseBracketContext = '';
+
+for (let i = chaptersArrayStart + 12; i < chaptersArrayStart + 500; i++) {
+  const ch = aiResponse[i];
+  if (escapeNext) { escapeNext = false; continue; }
+  if (ch === '\\') { escapeNext = true; continue; }
+  if (ch === '"') { inString = !inString; continue; }
+  if (inString) continue;
+
+  if (ch === '[') { bracketDepth++; console.log('  +bracket at', i, 'now depth', bracketDepth); }
+  else if (ch === ']') {
+    bracketDepth--;
+    lastCloseBracket = i;
+    lastCloseBracketContext = aiResponse.substring(Math.max(0, i-30), i+30);
+    console.log('  -bracket at', i, 'now depth', bracketDepth, 'context:', JSON.stringify(lastCloseBracketContext));
+    if (bracketDepth === 0) { console.log('  Found array end at', i); break; }
+  }
+}
+
+// So the first ] when depth becomes 0 is at position 376 - but that can't be right for 65 chapters
+// The issue is we're starting to count from AFTER "chapters":[
+// We should start counting from the [ itself
+
+console.log('\n=== Re-trace starting from the opening [ ===');
+bracketDepth = 0;
+inString = false;
+escapeNext = false;
+
+for (let i = chaptersArrayStart + 11; i < chaptersArrayStart + 500; i++) {  // Start at 11 to include the [
+  const ch = aiResponse[i];
+  if (escapeNext) { escapeNext = false; continue; }
+  if (ch === '\\') { escapeNext = true; continue; }
+  if (ch === '"') { inString = !inString; continue; }
+  if (inString) continue;
+
+  if (ch === '[') { bracketDepth++; }
+  else if (ch === ']') {
+    bracketDepth--;
+    if (bracketDepth === 0) {
+      console.log('Array ends at position', i);
+      console.log('Context:', JSON.stringify(aiResponse.substring(Math.max(0, i-50), i+50)));
+      break;
+    }
+  }
+}
+
+// The correct approach: count from the [ at "chapters":[
+console.log('\n=== Correct trace (starting from opening [) ===');
+bracketDepth = 0;
+inString = false;
+escapeNext = false;
+const chaptersOpenBracket = chaptersArrayStart + 11; // Position of [ after "chapters":
+
+for (let i = chaptersOpenBracket; i < aiResponse.length; i++) {
+  const ch = aiResponse[i];
+  if (escapeNext) { escapeNext = false; continue; }
+  if (ch === '\\') { escapeNext = true; continue; }
+  if (ch === '"') { inString = !inString; continue; }
+  if (inString) continue;
+
+  if (ch === '[') bracketDepth++;
+  else if (ch === ']') {
+    bracketDepth--;
+    if (bracketDepth === 0) {
+      console.log('Array actually ends at position', i);
+      console.log('This is', i - chaptersOpenBracket, 'characters from the opening [');
+      console.log('Context:', JSON.stringify(aiResponse.substring(i-100, i+50)));
+      break;
+    }
+  }
+}

+ 74 - 0
server/debug-parse3.js

@@ -0,0 +1,74 @@
+const fs = require('fs');
+const content = fs.readFileSync('temp/rich-outline-fail_57_1779014018760.txt', 'utf-8');
+
+// Check the ORIGINAL tail (last 500 chars as stored in file)
+const tailMarker = '========== 响应结尾500字符 ';
+const tailIdx = content.indexOf(tailMarker);
+const tailStart = content.indexOf('\n', tailIdx) + 1;
+const tail = content.substring(tailStart).trim();
+
+console.log('=== File tail (last 500 chars of original response) ===');
+console.log(JSON.stringify(tail.substring(0, 500)));
+
+// Now let's try to see if we can manually fix the JSON by:
+// 1. Finding the incomplete last object and completing it
+const aiResponse = content.split('========== AI 原始响应 ')[1].split('==========')[0].trim();
+
+console.log('\n=== Checking if truncation is the issue ===');
+console.log('Saved length:', aiResponse.length, 'File says: 9867');
+
+const lastCompleteProp = '"estimatedWords": 800';
+const lastCompleteIdx = aiResponse.lastIndexOf(lastCompleteProp);
+console.log('Last complete estimatedWords at:', lastCompleteIdx);
+
+// The response seems to be truncated mid-property
+// Let's count levels to see where we are
+let braceLevel = 0;
+let bracketLevel = 0;
+let inString = false;
+let escapeNext = false;
+let lastCompletePos = lastCompleteIdx + lastCompleteProp.length;
+
+for (let i = 0; i < lastCompleteIdx + lastCompleteProp.length; i++) {
+  const ch = aiResponse[i];
+  if (escapeNext) { escapeNext = false; continue; }
+  if (ch === '\\') { escapeNext = true; continue; }
+  if (ch === '"') { inString = !inString; continue; }
+  if (!inString) {
+    if (ch === '{') braceLevel++;
+    else if (ch === '}') braceLevel--;
+    else if (ch === '[') bracketLevel++;
+    else if (ch === ']') bracketLevel--;
+  }
+}
+console.log('At end of last complete prop - Brace level:', braceLevel, 'Bracket level:', bracketLevel);
+
+// Now check what the full structure looks like
+// If braceLevel = 0, we need to close several }
+console.log('\n=== Simulating truncated JSON completion ===');
+
+// The incomplete part ends with:
+// "estimated" (incomplete "estimatedWords": <number>)
+const incomplete = ', "estimated"; // truncated here'
+
+// Try to count what we'd need to close
+console.log('Missing closing brackets/braces to make valid JSON:');
+
+// Count open structures at truncation point
+inString = false;
+escapeNext = false;
+braceLevel = 0;
+bracketLevel = 0;
+for (let i = 0; i < aiResponse.length; i++) {
+  const ch = aiResponse[i];
+  if (escapeNext) { escapeNext = false; continue; }
+  if (ch === '\\') { escapeNext = true; continue; }
+  if (ch === '"') { inString = !inString; continue; }
+  if (!inString) {
+    if (ch === '{') braceLevel++;
+    else if (ch === '}') braceLevel--;
+    else if (ch === '[') bracketLevel++;
+    else if (ch === ']') bracketLevel--;
+  }
+}
+console.log('At truncation - Open braces:', braceLevel, 'Open brackets:', bracketLevel);

+ 31 - 0
server/debug-parse4.js

@@ -0,0 +1,31 @@
+// Verify: the truncation is due to max_tokens limit
+// Check roughly how many tokens 9867 Chinese chars would be
+const content = `每一个200字,都是对一个日常思维盲区的温柔颠覆——让你在下一个转角遇见不同的自己`;
+// Rough estimate: Chinese chars ~= tokens when using BPE-like tokenizers
+// For MiniMax M2.7 with 128k context, 9867 chars would be roughly 5000+ tokens
+
+// Check: does the response have roughly the right size for 4096 tokens?
+const fs = require('fs');
+const fileContent = fs.readFileSync('temp/rich-outline-fail_57_1779014018760.txt', 'utf-8');
+const marker = '========== AI 原始响应 ';
+const markerIdx = fileContent.indexOf(marker);
+const headerEndIdx = fileContent.indexOf(' ==========', markerIdx);
+const aiStart = fileContent.indexOf('\n', headerEndIdx) + 1;
+const aiEnd = fileContent.indexOf('\n\n', aiStart);
+const aiResponse = fileContent.substring(aiStart, aiEnd);
+
+console.log('AI response length (chars):', aiResponse.length);
+
+// Chinese chars ratio
+const chineseChars = (aiResponse.match(/[一-鿿]/g) || []).length;
+console.log('Chinese chars:', chineseChars);
+console.log('Non-Chinese chars:', aiResponse.length - chineseChars);
+
+// Rough token estimate: Chinese chars ~= 1.5-2 tokens each for MiniMax
+// Plus English/punctuation ~= 0.5 tokens each
+const estimatedTokens = chineseChars * 1.5 + (aiResponse.length - chineseChars) * 0.5;
+console.log('Estimated tokens (rough):', Math.round(estimatedTokens));
+
+// The model has maxTokens=4096, so if estimatedTokens > 4096, truncation happened
+console.log('\nmaxTokens setting: 4096');
+console.log('Would be truncated:', estimatedTokens > 4096 ? 'YES' : 'NO');

+ 72 - 0
server/debug-parse5.js

@@ -0,0 +1,72 @@
+const fs = require('fs');
+const content = fs.readFileSync('temp/rich-outline-fail_57_1779014691106.txt', 'utf-8');
+const marker = '========== AI 原始响应 ';
+const markerIdx = content.indexOf(marker);
+const headerEndIdx = content.indexOf(' ==========', markerIdx);
+const aiStart = content.indexOf('\n', headerEndIdx) + 1;
+const aiEnd = content.indexOf('\n\n', aiStart);
+const aiResponse = content.substring(aiStart, aiEnd);
+
+console.log('AI response length:', aiResponse.length);
+
+const pos = 523;
+console.log('\n=== Position', pos, 'context ===');
+console.log(JSON.stringify(aiResponse.substring(Math.max(0,pos-80), pos+80)));
+
+// Check what char is at position 523
+console.log('\nChar at 523:', JSON.stringify(aiResponse[523]));
+console.log('Char code at 523:', aiResponse.charCodeAt(523));
+
+// Check if position 523 is inside a string
+let inString = false;
+let escapeNext = false;
+let lastQuotePos = -1;
+for (let i = 0; i < pos; i++) {
+  const ch = aiResponse[i];
+  if (escapeNext) { escapeNext = false; continue; }
+  if (ch === '\\') { escapeNext = true; continue; }
+  if (ch === '"') { inString = !inString; lastQuotePos = i; }
+}
+console.log('At pos 523, inString:', inString, 'lastQuotePos:', lastQuotePos);
+
+// First 200 chars
+console.log('\n=== First 200 chars ===');
+console.log(JSON.stringify(aiResponse.substring(0, 200)));
+
+// Check for <think>
+console.log('\nContains <think>:', aiResponse.includes('<think>'));
+console.log('Starts with {:', aiResponse.trim().startsWith('{'));
+
+// Try the fixed think tag regex
+const thinkTagMatch = aiResponse.match(/^<think>[\s\S]*?<\/think>\s*/i);
+console.log('thinkTagMatch found:', !!thinkTagMatch);
+
+// Try strategy 1: direct parse
+try {
+  const data = JSON.parse(aiResponse);
+  console.log('Direct parse SUCCESS, chapters:', data.chapters?.length);
+} catch(e) {
+  console.log('Direct parse failed:', e.message.substring(0, 80));
+}
+
+// Try strategy 2: remove code blocks
+let cleaned = aiResponse.replace(/```json\s*/g, '').replace(/```\s*/g, '');
+try {
+  const data = JSON.parse(cleaned);
+  console.log('After codeblock removal SUCCESS, chapters:', data.chapters?.length);
+} catch(e) {
+  console.log('Strategy 2 failed:', e.message.substring(0, 80));
+}
+
+// Strategy 3: regex extract
+const match = aiResponse.match(/\{[\s\S]*\}/);
+if (match) {
+  try {
+    const data = JSON.parse(match[0]);
+    console.log('Strategy 3 SUCCESS, chapters:', data.chapters?.length);
+  } catch(e) {
+    console.log('Strategy 3 failed:', e.message.substring(0, 80));
+  }
+} else {
+  console.log('Strategy 3: no JSON object found');
+}

+ 41 - 0
server/debug-parse6.js

@@ -0,0 +1,41 @@
+const fs = require('fs');
+const content = fs.readFileSync('temp/rich-outline-fail_57_1779014691106.txt', 'utf-8');
+const marker = '========== AI 原始响应 ';
+const markerIdx = content.indexOf(marker);
+const headerEndIdx = content.indexOf(' ==========', markerIdx);
+const aiStart = content.indexOf('\n', headerEndIdx) + 1;
+const aiEnd = content.indexOf('\n\n', aiStart);
+const aiResponse = content.substring(aiStart, aiEnd);
+
+const pos = 523;
+// Check around position 500-550
+console.log('500-550 chars:');
+console.log(JSON.stringify(aiResponse.substring(500, 550)));
+console.log('\nActual bytes:');
+for (let i = 500; i < 550; i++) {
+  const code = aiResponse.charCodeAt(i);
+  process.stdout.write(i + ':' + code + '[' + aiResponse[i] + '] ');
+}
+console.log();
+
+// The context shows: ...呼吸"},{number":2,"title"
+// So at position 523 we have {number":2
+// This means "number" property name is missing opening quote!
+// Let's verify
+const snippet = aiResponse.substring(pos - 5, pos + 30);
+console.log('\nSnippet around error:', JSON.stringify(snippet));
+
+// The exact characters at positions 523-530
+console.log('\nChars 523-532:');
+for (let i = 523; i < 532; i++) {
+  const code = aiResponse.charCodeAt(i);
+  console.log('  pos', i, '=', code, JSON.stringify(aiResponse[i]));
+}
+
+// Check if maybe there's a stray quote or something
+// Look at what's between last " and the { at pos 523
+let lastFewChars = '';
+for (let i = 518; i < 523; i++) {
+  lastFewChars += JSON.stringify(aiResponse[i]) + ' ';
+}
+console.log('\n518-522 chars:', lastFewChars);

+ 53 - 0
server/debug-parse7.js

@@ -0,0 +1,53 @@
+const fs = require('fs');
+const content = fs.readFileSync('temp/rich-outline-fail_57_1779014691106.txt', 'utf-8');
+const marker = '========== AI 原始响应 ';
+const markerIdx = content.indexOf(marker);
+const headerEndIdx = content.indexOf(' ==========', markerIdx);
+const aiStart = content.indexOf('\n', headerEndIdx) + 1;
+const aiEnd = content.indexOf('\n\n', aiStart);
+const aiResponse = content.substring(aiStart, aiEnd);
+
+console.log('Response length:', aiResponse.length);
+
+// The error is at position 523, char at 523 is "{"
+// The snippet shows: ..."呼吸"},{number":2,"title":
+// Wait - let me look more carefully at the actual bytes
+// At 520 we have " (quote, closing keyTakeaway string)
+// At 521 we have } (closing the parent object)
+// At 522 we have , (comma)
+// At 523 we have { (opening a new object)
+// At 524-529 we have number (but it should be "number")
+// At 530 we have "
+// At 531 we have :
+// At 532 we have 2
+
+// So the AI returned: ...呼吸"},{number":2,...
+
+// But properly formatted JSON would be: ...呼吸"},{"number":2,...
+
+// The issue is there's a missing quote BEFORE "number"
+
+// Let's check if this is a truncation or an AI generation error
+// by looking at what's around pos 523 more carefully
+
+console.log('\nContext around pos 523 (200 chars before and after):');
+console.log(JSON.stringify(aiResponse.substring(323, 623)));
+
+// Check the last 200 chars
+console.log('\nLast 200 chars:');
+console.log(JSON.stringify(aiResponse.substring(aiResponse.length - 200)));
+
+// Count all occurrences of ",{" to see if there's a pattern
+let count = 0;
+let idx = aiResponse.indexOf(',{');
+while (idx !== -1) { count++; idx = aiResponse.indexOf(',{', idx + 1); }
+console.log('\nOccurrences of ",{":', count);
+
+// Count all occurrences of number": without opening quote
+const badPattern = /,\{number":/g;
+const matches = aiResponse.match(badPattern);
+console.log('Bad pattern ",{number":":', matches ? matches.length : 0);
+
+// The issue: there's NO space after the comma, it's ","{number":2"
+// That's a JSON error - missing quote before "number"
+// This is likely an AI generation error, not truncation

+ 82 - 0
server/debug-parse8.js

@@ -0,0 +1,82 @@
+const fs = require('fs');
+const content = fs.readFileSync('temp/rich-outline-fail_57_1779014691106.txt', 'utf-8');
+const marker = '========== AI 原始响应 ';
+const markerIdx = content.indexOf(marker);
+const headerEndIdx = content.indexOf(' ==========', markerIdx);
+const aiStart = content.indexOf('\n', headerEndIdx) + 1;
+const aiEnd = content.indexOf('\n\n', aiStart);
+const aiResponse = content.substring(aiStart, aiEnd);
+
+// Check for all missing opening quotes on property names
+// Pattern: ,{word": should be ,{"word":
+// Common property names that might be affected
+
+const badPatterns = [
+  /,\{number":/g,
+  /,\{title":/g,
+  /,\{summary":/g,
+  /,\{opening":/g,
+  /,\{structure":/g,
+  /,\{mustCover":/g,
+  /,\{mustNotRepeat":/g,
+  /,\{toneAdjustment":/g,
+  /,\{keyTakeaway":/g,
+  /,\{keyPoints":/g,
+  /,\{estimatedWords":/g,
+  /,\{writingInstructions":/g,
+];
+
+for (const pattern of badPatterns) {
+  const matches = aiResponse.match(pattern);
+  if (matches) {
+    console.log(pattern.toString(), '-> found', matches.length, 'times');
+  }
+}
+
+// Can we fix by adding the missing quote?
+// The fix would be: ,{word": -> ,{"word":
+let fixed = aiResponse;
+
+// Apply fixes for common patterns
+fixed = fixed.replace(/,(\{[^"]+":)/g, ',{"'.slice(1) + '$1'.slice(1));
+// Wait that's confusing, let me do it simpler
+fixed = fixed.replace(/\{number":/g, '{"number":');
+fixed = fixed.replace(/\{title":/g, '{"title":');
+fixed = fixed.replace(/\{summary":/g, '{"summary":');
+fixed = fixed.replace(/\{opening":/g, '{"opening":');
+fixed = fixed.replace(/\{structure":/g, '{"structure":');
+fixed = fixed.replace(/\{mustCover":/g, '{"mustCover":');
+fixed = fixed.replace(/\{mustNotRepeat":/g, '{"mustNotRepeat":');
+fixed = fixed.replace(/\{toneAdjustment":/g, '{"toneAdjustment":');
+fixed = fixed.replace(/\{keyTakeaway":/g, '{"keyTakeaway":');
+fixed = fixed.replace(/\{keyPoints":/g, '{"keyPoints":');
+fixed = fixed.replace(/\{estimatedWords":/g, '{"estimatedWords":');
+fixed = fixed.replace(/\{writingInstructions":/g, '{"writingInstructions":');
+
+try {
+  const data = JSON.parse(fixed);
+  console.log('\nAfter fix: Valid JSON! chapters:', data.chapters?.length);
+} catch(e) {
+  console.log('\nAfter fix: Still invalid:', e.message.substring(0, 100));
+  // Check remaining issues
+  let inString = false;
+  let escapeNext = false;
+  let i = 0;
+  const str = fixed;
+  while (i < str.length) {
+    const ch = str[i];
+    if (escapeNext) { escapeNext = false; i++; continue; }
+    if (ch === '\\') { escapeNext = true; i++; continue; }
+    if (ch === '"') { inString = !inString; i++; continue; }
+    if (!inString && ch === '{') {
+      // Check if next non-space char is a quote
+      let j = i + 1;
+      while (j < str.length && str[j] === ' ') j++;
+      if (str[j] && str[j] !== '"') {
+        console.log('Missing quote before property at position', i, '->', JSON.stringify(str.substring(i, i+20)));
+        break;
+      }
+    }
+    i++;
+  }
+}

+ 58 - 0
server/debug-parse9.js

@@ -0,0 +1,58 @@
+const fs = require('fs');
+const content = fs.readFileSync('temp/rich-outline-fail_57_1779015509994.txt', 'utf-8');
+const marker = '========== AI 原始响应 ';
+const markerIdx = content.indexOf(marker);
+const headerEndIdx = content.indexOf(' ==========', markerIdx);
+const aiStart = content.indexOf('\n', headerEndIdx) + 1;
+const aiEnd = content.indexOf('\n\n', aiStart);
+const aiResponse = content.substring(aiStart, aiEnd);
+
+console.log('AI response length:', aiResponse.length);
+
+// Check position 1624
+const pos = 1624;
+console.log('\n=== Context around pos', pos, '===');
+console.log(JSON.stringify(aiResponse.substring(pos-80, pos+80)));
+
+// Count bad patterns
+const badPattern = /,{number":/g;
+const matches = aiResponse.match(badPattern);
+console.log('\nBad pattern count:', matches ? matches.length : 0);
+
+// What is at position 1624?
+console.log('\nChars at', pos, 'to', pos+10, ':');
+for (let i = pos; i < pos+10; i++) {
+  console.log('  pos', i, ':', JSON.stringify(aiResponse[i]), 'code:', aiResponse.charCodeAt(i));
+}
+
+// Try with different fix patterns
+function fixMissingQuotes(jsonStr) {
+  return jsonStr
+    .replace(/,(\{[^"]+":)/g, function(m) { return ',{"' + m.slice(2); })
+    .replace(/\{number":/g, '{"number":')
+    .replace(/\{title":/g, '{"title":')
+    .replace(/\{summary":/g, '{"summary":')
+    .replace(/\{opening":/g, '{"opening":')
+    .replace(/\{structure":/g, '{"structure":')
+    .replace(/\{mustCover":/g, '{"mustCover":')
+    .replace(/\{mustNotRepeat":/g, '{"mustNotRepeat":')
+    .replace(/\{toneAdjustment":/g, '{"toneAdjustment":')
+    .replace(/\{keyTakeaway":/g, '{"keyTakeaway":')
+    .replace(/\{keyPoints":/g, '{"keyPoints":')
+    .replace(/\{estimatedWords":/g, '{"estimatedWords":')
+    .replace(/\{writingInstructions":/g, '{"writingInstructions":');
+}
+
+const match = aiResponse.match(/\{[\s\S]*\}/);
+if (match) {
+  const fixed = fixMissingQuotes(match[0]);
+  try {
+    const data = JSON.parse(fixed);
+    console.log('\nFixed+parsed SUCCESS, chapters:', data.chapters?.length);
+  } catch(e) {
+    console.log('\nFixed+parsed failed:', e.message.substring(0, 100));
+    const p = parseInt(e.message.match(/position (\d+)/)?.[1] || '0');
+    console.log('Error at position', p);
+    console.log('Context:', JSON.stringify(fixed.substring(Math.max(0,p-50), p+50)));
+  }
+}

+ 64 - 0
server/debug-truncate.js

@@ -0,0 +1,64 @@
+const fs = require('fs');
+const content = fs.readFileSync('temp/rich-outline-fail_57_1779023037497.txt', 'utf-8');
+const marker = '========== AI 原始响应 ';
+const markerIdx = content.indexOf(marker);
+const headerEndIdx = content.indexOf(' ==========', markerIdx);
+const aiStart = content.indexOf('\n', headerEndIdx) + 1;
+const aiEnd = content.indexOf('\n\n', aiStart);
+const aiResponse = content.substring(aiStart, aiEnd);
+
+const chaptersArrayStart = aiResponse.indexOf('"chapters":[');
+const arrayOpenPos = chaptersArrayStart + 11;
+
+console.log('Starting from arrayOpenPos:', arrayOpenPos);
+console.log('First 30 chars from arrayOpenPos:', JSON.stringify(aiResponse.substring(arrayOpenPos, arrayOpenPos + 30)));
+
+let bracketDepth = 0;
+let inString = false;
+let escapeNext = false;
+let arrayEnd = -1;
+
+for (let i = arrayOpenPos; i < aiResponse.length; i++) {
+  const ch = aiResponse[i];
+  if (escapeNext) { escapeNext = false; continue; }
+  if (ch === '\\') { escapeNext = true; continue; }
+  if (ch === '"') { inString = !inString; continue; }
+  if (inString) continue;
+
+  if (ch === '[') { bracketDepth++; }
+  else if (ch === ']') {
+    bracketDepth--;
+    if (bracketDepth === 0) { arrayEnd = i; console.log('Array end found at', i); break; }
+  }
+}
+
+if (arrayEnd === -1) {
+  console.log('Array end NOT FOUND');
+  console.log('bracketDepth after full scan:', bracketDepth);
+  console.log('Last 100 chars:', JSON.stringify(aiResponse.slice(-100)));
+
+  // Scan from arrayOpenPos and find ALL ] positions with depth info
+  let depth = 0;
+  inString = false;
+  escapeNext = false;
+  const closeBrackets = [];
+  for (let i = arrayOpenPos; i < aiResponse.length; i++) {
+    const ch = aiResponse[i];
+    if (escapeNext) { escapeNext = false; continue; }
+    if (ch === '\\') { escapeNext = true; continue; }
+    if (ch === '"') { inString = !inString; continue; }
+    if (inString) continue;
+
+    if (ch === '[') depth++;
+    else if (ch === ']') {
+      depth--;
+      closeBrackets.push({ pos: i, depth });
+    }
+  }
+  console.log('Close brackets found:', closeBrackets.slice(-10));
+} else {
+  console.log('arrayEnd:', arrayEnd);
+  const arrayContent = aiResponse.substring(arrayOpenPos + 1, arrayEnd);
+  console.log('arrayContent length:', arrayContent.length);
+  console.log('arrayContent end:', JSON.stringify(aiResponse.substring(arrayEnd - 50, arrayEnd + 10)));
+}

+ 2 - 2
server/fix-controller-queue.js

@@ -49,7 +49,7 @@ content = content.replace(
     langGraphGenerator.generate(
       bookId,
       book.description || book.title,
-      book.bookScale || '标准教程'
+      book.bookScale || '130000'
     ).catch(err => {
       console.error('[LangGraph] 续生成失败:', err);
     });`,
@@ -57,7 +57,7 @@ content = content.replace(
     const jobId = await queueService.addBookGenerationTask({
       bookId,
       topic: book.description || book.title,
-      bookScale: book.bookScale || '标准教程',
+      bookScale: book.bookScale || '130000',
     });
     
     console.log(\`[LangGraph] 续生成任务已加入队列: bookId=\${bookId}, jobId=\${jobId}\`);`

+ 1 - 1
server/fix-remaining-queues.js

@@ -23,7 +23,7 @@ content = content.replace(
     const jobId = await queueService.addBookGenerationTask({
       bookId,
       topic: book.description || book.title,
-      bookScale: book.bookScale || '标准教程',
+      bookScale: book.bookScale || '130000',
     });
     
     console.log(\`[LangGraph] 续生成任务已加入队列: bookId=\${bookId}, jobId=\${jobId}\`);`

+ 2 - 1
server/prisma/schema.prisma

@@ -140,7 +140,7 @@ model Book {
   coverUrl       String?
   targetAudience String         @default("通用")
   style          String         @default("专业严谨")
-  bookScale      String         @default("标准教程")
+  bookScale      String         @default("130000")
   totalChapters  Int            @default(10)
   estimatedWords Int            @default(0)
   progress       Int            @default(0)
@@ -409,6 +409,7 @@ model AiCallLog {
   provider  String   @db.VarChar(30)   // minimax | bailian | volcengine
   model     String   @db.VarChar(50)   // speech-2.8-hd | qwen3-tts-instruct-flash | MiniMax-M2.7
   textLen   Int      @default(0)       // 输入文本长度
+  prompt    String?  @db.LongText       // 提示词内容
   duration  Int      @default(0)       // 耗时(ms)
   success   Boolean  @default(true)
   errorMsg  String?  @db.VarChar(200)

+ 1 - 1
server/regenerate-book.js

@@ -5,7 +5,7 @@ const bookId = 1;
 console.log(`开始重新生成书籍 #${bookId} 的大纲...`);
 
 const data = JSON.stringify({
-  bookScale: '标准教程'
+  bookScale: '130000'
 });
 
 const options = {

+ 50 - 151
server/src/modules/book-generator/book-generator.store.ts

@@ -402,7 +402,7 @@ export class BookStore {
         description: data.description,
         targetAudience: data.targetAudience || '通用',
         style: data.style || '专业严谨',
-        bookScale: data.bookScale || '标准教程',
+        bookScale: data.bookScale || '130000',
         totalChapters: data.totalChapters ?? 10,
         estimatedWords: data.estimatedWords ?? 0,
         genStage: 'draft',
@@ -936,7 +936,7 @@ export class BookStore {
     const result = await generateAudio(
       userId ? String(userId) : String(chapter.book?.userId || '0'),
       chapter.content,
-      'default',
+      'longyingling_v3',
       { speed: 1.0, pitch: 0, volume: 50 },
       async (audioUrl: string, duration: number) => {
         // 音频生成完成后更新章节
@@ -1075,7 +1075,7 @@ export class BookStore {
         userId: userId || chapterBefore.book?.userId || null,
         contentHash,
         content: chapterBefore.content, // 保存提交时的内容副本
-        voiceId: 'default',
+        voiceId: 'longyingling_v3',
         status: 'pending',
       },
     });
@@ -1299,8 +1299,8 @@ export class BookStore {
   /**
    * 处理 TTS 队列任务(由 tts-queue.ts 调用)
    *
-   * 这是实际的音频生成逻辑,使用平铺循环重试(最多 3 次),
-   * 不再有递归调用
+   * 只尝试 1 次。Provider 层会自动遍历所有可用 Provider。
+   * 失败后标记 failed,由用户手动重新生成
    */
   async processTtsTask(taskId: number): Promise<void> {
     const task = await prisma.ttsTask.findUnique({ where: { id: taskId } });
@@ -1318,161 +1318,65 @@ export class BookStore {
 
     console.log(`[TtsTask] 开始处理任务#${taskId}, chapterId=${chapterId}, 内容长度=${content.length}`);
 
-    // ===== 平铺循环重试(最多 3 次,无递归) =====
-    const MAX_RETRIES = 3;
-    let lastError: Error | null = null;
-
-    for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
-      try {
-        console.log(`[TtsTask] #${taskId} 第${attempt + 1}次尝试生成音频...`);
-
-        const chapter = await prisma.bookChapter.findUnique({
-          where: { id: chapterId },
-          include: { book: true },
-        });
-        if (!chapter) {
-          await this._failTask(taskId, `章节${chapterId}不存在`);
-          return;
-        }
-
-        const result = await generateAudio(
-          String(task.userId || chapter.book?.userId || '0'),
-          content,
-          'default',
-          { speed: 1.0, pitch: 0, volume: 50 },
-          async (audioUrl: string, duration: number) => {
-            // 音频文件已生成,processAudioGeneration 已写入 audioUrl/audioDuration/lrcLyrics
-            // 回调只负责推进 genStage → audio_completed(不再写 audioUrl,避免双写冲突)
-            console.log(`[TtsTask] #${taskId} 音频文件就绪: ${audioUrl?.substring(0, 60)}...`);
-            try {
-              await advanceChapter(chapterId, 'audio_completed');
-              // TTS 完成后消耗配额(按实际音频时长换算,而非文本字数)
-              try {
-                const userId = chapter.book?.userId || task.userId || 1;
-                // 按音频时长计费:每60秒 = 1分钟配额
-                const audioMinutes = Math.ceil(duration / 60);
-                await consumeAudioMinutes(userId, audioMinutes, `书籍「${chapter.book?.title || '未知'}」- ${chapter.title} (${duration}s)`);
-              } catch (quotaErr: any) {
-                console.warn(`[Quota] TTS完成后消耗音频分钟配额失败:`, quotaErr.message);
-              }
-              tryAutoMerge(chapterId, chapter.bookId, chapter.level, chapter.parentId);
-            } catch (advanceErr: any) {
-              // advanceChapter 失败通常是因为乐观锁冲突
-              // 此时音频文件已存好(audioUrl已写入),但 genStage 未推进
-              // 音频扫描器会在 30s 内检测到 audio_generating+有audioUrl 的卡死状态并自动修复
-              console.warn(
-                `[TtsTask] #${taskId} genStage推进失败(chapterId=${chapterId}): ${advanceErr.message},` +
-                `音频文件已就绪,将由扫描器兜底修复`
-              );
-            }
-          },
-          {
-            bookId: chapter.bookId ? String(chapter.bookId) : undefined,
-            chapterId: chapter.id,
-            chapterTitle: chapter.title,
-          }
-        );
-
-        // 成功:更新任务状态
-        await prisma.ttsTask.update({
-          where: { id: taskId },
-          data: {
-            status: 'completed',
-            audioUrl: result.audioUrl,
-            completedAt: new Date(),
-            retryCount: attempt,
-          },
-        });
-
-        console.log(`[TtsTask] ✅ 任务#${taskId} 完成`);
-
-        // 记录成功日志
-        logAiCall({
-          callType: 'tts_task_completed',
-          provider: 'tts',
-          model: 'tts-task',
-          textLen: content?.length || 0,
-          success: true,
-          chapterId,
-          bookId: task.bookId ?? undefined,
-        });
-
-        await this.tryCleanupBookTasks(chapterId);
+    try {
+      const chapter = await prisma.bookChapter.findUnique({
+        where: { id: chapterId },
+        include: { book: true },
+      });
+      if (!chapter) {
+        await this._failTask(taskId, `章节${chapterId}不存在`);
         return;
+      }
 
-      } catch (err: any) {
-        lastError = err;
-        const errorMsg = err?.message || String(err);
-        console.error(`[TtsTask] ❌ 任务#${taskId} 第${attempt + 1}次失败:`, errorMsg);
-
-        // 记录 TTS 失败日志到数据库(每次重试都要记)
-        logAiCall({
-          callType: `tts_task_retry${attempt > 0 ? `_${attempt}` : ''}`,
-          provider: 'tts',
-          model: 'tts-task',
-          textLen: content?.length || 0,
-          success: false,
-          errorMsg,
-          chapterId,
-          bookId: task.bookId ?? undefined,
-        });
-
-        if (attempt < MAX_RETRIES) {
-          // 指数退避 + 随机抖动,避免雪崩
-          const delay = Math.min(2000 * Math.pow(2, attempt) + Math.random() * 1000, 30000);
-          console.log(`[TtsTask] #${taskId} ${(delay / 1000).toFixed(1)}s 后重试...`);
-
-          // 原子化状态重置:使用 safeTransitionChapter 直接从当前状态转到 audio_generating
-          // 避免两步操作(regenerateChapter + advanceChapter)之间的竞态窗口
-          // AudioScanner 在 content_completed 状态时可能创建重复任务
+      const result = await generateAudio(
+        String(task.userId || chapter.book?.userId || '0'),
+        content,
+        'longyingling_v3',
+        { speed: 1.0, pitch: 0, volume: 50 },
+        async (audioUrl: string, duration: number) => {
+          console.log(`[TtsTask] #${taskId} 音频就绪: ${audioUrl?.substring(0, 60)}...`);
           try {
-            const currentChapter = await prisma.bookChapter.findUnique({
-              where: { id: chapterId },
-              select: { genStage: true },
-            });
-            if (currentChapter) {
-              const curStage = currentChapter.genStage as any;
-              // 如果仍在 audio_generating,无需操作
-              // 如果回退到了 content_completed(由外层 regenerateChapter 触发),则直接前进
-              if (curStage === 'content_completed') {
-                await advanceChapter(chapterId, 'audio_generating');
-              } else if (curStage !== 'audio_generating') {
-                // 其他异常状态,尝试安全转移
-                await safeTransitionChapter(chapterId, curStage, 'audio_generating').catch(() => {});
-              }
+            await advanceChapter(chapterId, 'audio_completed');
+            try {
+              const userId = chapter.book?.userId || task.userId || 1;
+              const audioMinutes = Math.ceil(duration / 60);
+              await consumeAudioMinutes(userId, audioMinutes, `书籍「${chapter.book?.title || '未知'}」- ${chapter.title} (${duration}s)`);
+            } catch (quotaErr: any) {
+              console.warn(`[Quota] 消耗音频配额失败:`, quotaErr.message);
             }
-          } catch (resetErr: any) {
-            console.warn(`[TtsTask] #${taskId} 状态重置失败:`, resetErr.message);
-            // 兜底:尝试回退再前进(旧逻辑)
-            await regenerateChapter(chapterId, 'content_completed').catch(() => {});
-            await advanceChapter(chapterId, 'audio_generating').catch(() => {});
+            tryAutoMerge(chapterId, chapter.bookId, chapter.level, chapter.parentId);
+          } catch (advanceErr: any) {
+            console.warn(`[TtsTask] #${taskId} genStage推进失败: ${advanceErr.message},音频已就绪`);
           }
+        },
+        {
+          bookId: chapter.bookId ? String(chapter.bookId) : undefined,
+          chapterId: chapter.id,
+          chapterTitle: chapter.title,
+        }
+      );
 
-          // 更新重试计数
-          await prisma.ttsTask.update({
-            where: { id: taskId },
-            data: { retryCount: attempt + 1, errorMsg: errorMsg.substring(0, 500) },
-          });
+      await prisma.ttsTask.update({
+        where: { id: taskId },
+        data: { status: 'completed', audioUrl: result.audioUrl, completedAt: new Date() },
+      });
 
-          await new Promise(resolve => setTimeout(resolve, delay));
-          continue;
-        }
+      console.log(`[TtsTask] ✅ 任务#${taskId} 完成`);
+      logAiCall({ callType: 'tts_task_completed', provider: 'tts', model: 'tts-task', textLen: content?.length || 0, success: true, chapterId, bookId: task.bookId ?? undefined });
+      await this.tryCleanupBookTasks(chapterId);
 
-        // 所有重试失败
-        await this._failTask(taskId, errorMsg);
-        return;
-      }
+    } catch (err: any) {
+      const errorMsg = err?.message || String(err);
+      console.error(`[TtsTask] ❌ 任务#${taskId} 失败:`, errorMsg);
+      logAiCall({ callType: 'tts_task_failed', provider: 'tts', model: 'tts-task', textLen: content?.length || 0, success: false, errorMsg, chapterId, bookId: task.bookId ?? undefined });
+      await this._failTask(taskId, errorMsg);
     }
-
-    // 理论上不会到这里,但作为兜底
-    await this._failTask(taskId, lastError?.message || '未知错误');
   }
 
   /**
-   * 标记任务失败并回退章节状态
+   * 标记任务失败(不回退章节状态,由用户手动重新生成)
    */
   private async _failTask(taskId: number, errorMsg: string): Promise<void> {
-    const task = await prisma.ttsTask.findUnique({ where: { id: taskId } });
     console.error(`[TtsTask] ❌ 任务#${taskId} 最终失败:`, errorMsg);
 
     await prisma.ttsTask.update({
@@ -1483,11 +1387,6 @@ export class BookStore {
         completedAt: new Date(),
       },
     });
-
-    // 回退章节状态
-    if (task?.chapterId) {
-      await regenerateChapter(task.chapterId, 'content_completed').catch(() => {});
-    }
   }
 
   /**

+ 1 - 1
server/src/modules/book-generator/book-generator.types.ts

@@ -38,7 +38,7 @@ export interface BookBase {
   description: string;              // 书籍描述/用户输入
   targetAudience: string;            // 目标受众
   style: string;                    // 写作风格
-  bookScale: string;                // 书籍规模:800, 2000, 5000, 小册子, 标准教程, 系统教材
+  bookScale: string;                // 书籍规模:纯数字字符串,如 1000, 2000, 130000, 340000 等
   totalChapters: number;            // 总章节数
   estimatedWords: number;           // 预估总字数
   genStage?: BookGenStage;           // 线性阶段状态

+ 22 - 1
server/src/modules/book-generator/book-type-config.ts

@@ -79,9 +79,30 @@ export const DETECTABLE_TYPES = ['340000', '210000', '130000', '80000', '50000',
 
 // 获取单个类型配置
 export function getBookTypeConfig(key: string): BookTypeConfig {
+  // 如果 key 是数字字符串(如 '200', '3000'),直接计算
+  if (/^\d+$/.test(key)) {
+    const wordCount = parseInt(key);
+    return {
+      key: key,
+      label: '自定义',
+      description: `约${wordCount}字`,
+      chapters: calcChapters(wordCount),
+      totalWords: wordCount,
+      chapterWords: calcChapterWords(wordCount),
+      audioMinutes: calcAudioMinutes(wordCount),
+      structureFormat: '单章完整',
+      readingDifficulty: '中',
+      isShortArticle: wordCount <= 10000,
+    };
+  }
   return BOOK_TYPE_CONFIG[key] || BOOK_TYPE_CONFIG['130000'];
 }
 
+// 根据字数计算每章字数
+const calcChapterWords = (totalWords: number): number => {
+  return Math.round(totalWords / calcChapters(totalWords));
+};
+
 // 获取所有类型配置(供前端显示)
 export function getAllBookTypes(): BookTypeConfig[] {
   return Object.values(BOOK_TYPE_CONFIG);
@@ -118,7 +139,7 @@ export function estimateBookWords(key: string): { min: number; max: number; avg:
 // ============================================
 
 /** 全局绝对上限:50 万字
- * 前端最大可选为「系统教材」30 万字,×1.5 安全系数 = 45 万,取整 50 万
+ * 前端最大可选为「上2」34 万字,×1.5 安全系数 = 51 万,取整 50 万
  * 任何用户/任何类型均不可突破 */
 export const GLOBAL_ABSOLUTE_WORD_LIMIT = 500_000;
 

+ 1 - 1
server/src/modules/book-generator/graph.ts

@@ -35,7 +35,7 @@ export const GraphState = Annotation.Root({
   }),
   bookScale: Annotation<string>({
     reducer: (_prev, update) => update ?? _prev,
-    default: () => '标准教程' as string,
+    default: () => '130000' as string,
   }),
   /**
    * 大纲层级:1=仅章,2=章→节,3=章→节→小节。

+ 1 - 1
server/src/modules/book-generator/index.ts

@@ -104,7 +104,7 @@ import { parseOutline } from './parsers/outline.parser';
  * 独立的大纲生成函数(供 API 预览调用,不依赖策略系统)
  * 用于让用户在正式开始生成前预览大纲结构
  */
-export async function generateOutline(bookId: string, topic: string, bookScale: string = '标准教程'): Promise<any> {
+export async function generateOutline(bookId: string, topic: string, bookScale: string = '130000'): Promise<any> {
   console.log('[generateOutline] 预览大纲, bookId:', bookId, 'scale:', bookScale);
 
   const messages = buildOutlineMessages(topic, bookScale);

+ 92 - 56
server/src/modules/book-generator/langgraph-controller.ts

@@ -236,29 +236,9 @@ function parseDetectResult(text: string): { detectedType: string; confidence: nu
   }
 }
 
-function keywordFallback(title: string, description: string): { detectedType: string } {
-  const text = `${title} ${description}`.toLowerCase();
-
-  if (text.includes('小说') || text.includes('故事') || text.includes('fiction')) {
-    if (text.includes('网络') || text.includes('连载') || text.includes('修仙') || text.includes('穿越')) {
-      return { detectedType: '网络小说' };
-    }
-    return { detectedType: '现代出版长篇小说' };
-  }
-  if (text.includes('科普') || text.includes('经管') || text.includes('畅销') || text.includes('通俗')) {
-    return { detectedType: '科普经管畅销书' };
-  }
-  if (text.includes('专业') || text.includes('大学') || text.includes('研究生') || text.includes('算法') || text.includes('操作系统') || text.includes('数据库')) {
-    return { detectedType: '大学专业教材' };
-  }
-  if (text.includes('中小学') || text.includes('初中') || text.includes('高中') || text.includes('青少年') || text.includes('儿童')) {
-    return { detectedType: '中小学课本' };
-  }
-  if (text.includes('古典') || text.includes('章回') || text.includes('名著') || text.includes('红楼') || text.includes('西游') || text.includes('三国') || text.includes('水浒')) {
-    return { detectedType: '古典名著' };
-  }
-
-  return { detectedType: '中小学课本' }; // 默认
+function keywordFallback(_title: string, _description: string): { detectedType: string } {
+  // 完全由 AI 判断,fallback 只在 LLM 完全失败时作为最后保险
+  return { detectedType: '1000' }; // 最小规模
 }
 
 /**
@@ -288,28 +268,28 @@ async function autoGenerateTitle(description: string): Promise<string> {
 
 /**
  * AI 自动检测书籍规模(字数等级),后端静默调用
+ * AI 分析描述中的字数要求,以 JSON 格式返回,供程序提取
  */
 async function autoDetectBookType(title: string, description: string): Promise<string> {
-  const detectableTypes = DETECTABLE_TYPES;
-  const typeDescriptions = detectableTypes.map(key => {
-    const t = BOOK_TYPE_CONFIG[key];
-    return `- ${t.key}: ${t.description},${t.chapters}章,总字数约${t.totalWords}字`;
-  }).join('\n');
-
+  console.log(`[autoDetectBookType] 被调用: title="${title}", desc前50="${description?.substring(0, 50)}"`);
   const messages: ChatMessage[] = [
     {
       role: 'system',
-      content: `你是一位专业的图书分类编辑。根据标题和描述,判断书籍属于以下哪种类型:
+      content: `你是一位专业的图书分类编辑。根据标题和描述,判断书籍适合的字数规模。
 
-可选类型:
-${typeDescriptions}
+## 判断规则
 
-分类规则:
-1. 分析标题中的关键词(如"科普""青少年""专业""小说"等)
-2. 分析描述中的目标读者、写作风格、内容深度
-3. 匹配最符合的类型
+**规则1 - 字数直接返回**:如果描述里有"X字"(如"200字""500字""几千字"),直接返回X作为bookScale值,不要映射到预定义规模。
+  - 描述含"200字"→返回200
+  - 描述含"500字"→返回500
+  - 描述含"几千字"→估计约3000,返回3000
+  - 描述含"上万字"→估计约10000,返回10000
 
-输出格式:只返回类型 key,不要其他内容。`,
+**规则2 - 无法判断**:描述中没有字数信息 → 返回1000
+
+## 输出格式(必须严格JSON)
+{"bookScale": 数字}
+只返回JSON,不要任何其他文字。`,
     },
     {
       role: 'user',
@@ -320,12 +300,30 @@ ${typeDescriptions}
   try {
     const response = await callLLMWithMessages(messages);
     const trimmed = response.trim();
-    if (detectableTypes.includes(trimmed)) return trimmed;
-    const found = detectableTypes.find(t => trimmed.includes(t) || t.includes(trimmed));
-    if (found) return found;
-    return keywordFallback(title, description).detectedType;
-  } catch {
-    return keywordFallback(title, description).detectedType;
+    console.log(`[autoDetectBookType] AI原始响应: ${trimmed.substring(0, 200)}`);
+
+    // 尝试解析 JSON,提取 bookScale(实际字数)
+    const jsonMatch = trimmed.match(/\{[\s\S]*\}/);
+    if (jsonMatch) {
+      try {
+        const data = JSON.parse(jsonMatch[0]);
+        if (typeof data.bookScale === 'number') {
+          const wordCount = data.bookScale;
+          console.log(`[autoDetectBookType] AI返回: bookScale=${wordCount}`);
+          // 直接返回实际字数,getBookTypeConfig 支持任意数字 key
+          return String(wordCount);
+        }
+      } catch {
+        // JSON解析失败,忽略
+      }
+    }
+
+    // JSON解析失败 → 默认返回最小规模
+    console.log(`[autoDetectBookType] AI响应无法解析,使用默认值1000`);
+    return '1000';
+  } catch (error: any) {
+    console.error(`[autoDetectBookType] AI调用异常:`, error?.message || error);
+    return '1000';
   }
 }
 
@@ -424,7 +422,8 @@ router.post('/books', optionalAuth, async (ctx: Context) => {
     const userId = ctx.state.user?.userId ? parseInt(ctx.state.user.userId as string) : parseInt(TEST_USER_ID);
 
     const providedTitle = body.title?.trim();
-    const providedBookScale = body.bookScale;
+    // 只接受数字格式的 bookScale(如 '200', '1000', '130000'),非数字当作未提供
+    const providedBookScale = body.bookScale && /^\d+$/.test(body.bookScale) ? body.bookScale : undefined;
 
     // ========== 快速路径:用户已提供 title 和 bookScale,无需 AI ==========
     if (providedTitle && providedBookScale) {
@@ -472,7 +471,8 @@ router.post('/books', optionalAuth, async (ctx: Context) => {
 
     // ========== 需要 AI 辅助的路径:先创建书籍,再异步回填 ==========
     // 使用临时标题/默认规模先创建书籍,立即返回给前端
-    const fallbackScale = providedBookScale || 'medium';
+    // 规模会由 AI 异步回填
+    const fallbackScale = providedBookScale || '1000';
     const fallbackTitle = providedTitle || '正在生成标题...';
     const fallbackConfig = getScaleConfig(fallbackScale);
     const fallbackWords = fallbackConfig.totalWords;
@@ -593,6 +593,7 @@ async function fillAiResultsAsync(
   providedTitle?: string,
   providedBookScale?: string,
 ): Promise<void> {
+  console.log(`[fillAiResultsAsync] 开始: bookId=${bookId}, providedTitle="${providedTitle}", providedBookScale="${providedBookScale}", desc前50字="${description?.substring(0, 50)}"`);
   try {
     let title = providedTitle;
     let bookScale = providedBookScale;
@@ -840,8 +841,43 @@ router.post('/books/:id/generate', optionalAuth, async (ctx: Context) => {
       return;
     }
 
-    // 从已有书籍或请求获取 scale 和 genLevel(传入描述做类型检测)
-    const bookScale = body.bookScale || book.bookScale || '130000';
+    // ========== 确定 bookScale ==========
+    // 优先级:
+    //   1) 请求显式指定(body.bookScale)→ 用户选的,直接用
+    //   2) AI 根据描述自动检测 → LLM 判断
+    //   3) 书籍已有规模 → 沿用
+    //   4) 兜底 → 1000(最小规模)
+    let bookScale: string | undefined;
+
+    if (body.bookScale && BOOK_TYPE_CONFIG[body.bookScale]) {
+      // 用户在当前请求中显式选了规模
+      bookScale = body.bookScale;
+      console.log(`[LangGraph Generate] 使用请求指定的 bookScale=${bookScale}`);
+    } else {
+      // 未显式选择 → 让 AI 根据描述判断
+      const desc = book.description || '';
+      try {
+        console.log(`[LangGraph Generate] AI 自动检测规模...`);
+        bookScale = await autoDetectBookType(book.title || '', desc);
+        console.log(`[LangGraph Generate] AI 检测结果 → bookScale=${bookScale}`);
+      } catch (e) {
+        console.warn(`[LangGraph Generate] AI 检测失败,回退:`, e);
+      }
+
+      // AI 失败或返回无效值 → 用书籍已保存的
+      if (!bookScale || !BOOK_TYPE_CONFIG[bookScale]) {
+        bookScale = book.bookScale && BOOK_TYPE_CONFIG[book.bookScale] ? book.bookScale : undefined;
+      }
+    }
+
+    // 最终兜底:用最小规模(1000字),避免误判生成大量无用内容
+    bookScale = bookScale && BOOK_TYPE_CONFIG[bookScale] ? bookScale : '1000';
+
+    // 持久化检测结果
+    if (bookScale !== book.bookScale) {
+      await bookStore.update(bookId, { bookScale });
+    }
+    
     const genLevel = resolveGenLevel(bookScale, body.genLevel, book.description);
 
     // ========== 生成前配额检查 ==========
@@ -917,7 +953,7 @@ router.post('/books/:id/generate', optionalAuth, async (ctx: Context) => {
 router.post('/books/:id/audio', async (ctx: Context) => {
   try {
     const bookId = ctx.params.id as string;
-    const { voiceId = 'cherry' } = ctx.request.body as { voiceId?: string };
+    const { voiceId = 'longyingling_v3' } = ctx.request.body as { voiceId?: string };
 
     const book = await bookStore.getById(bookId);
     if (!book) {
@@ -1167,7 +1203,7 @@ router.post('/books/:id/resume', async (ctx: Context) => {
     langGraphGenerator.generate(
       bookId,
       book.description || book.title,
-      book.bookScale || '130000'
+      book.bookScale || '1000'
     ).then(() => {
       console.log(`[LangGraph] 续生成完成: bookId=${bookId}`);
     }).catch(err => {
@@ -1318,11 +1354,11 @@ router.post('/books/:id/outline', async (ctx: Context) => {
     }
 
     // 获取书籍类型配置
-    const scaleConfig = getScaleConfig(book.bookScale || '标准教程');
+    const scaleConfig = getScaleConfig(book.bookScale || '130000');
 
     // 调用AI生成大纲
     const { generateOutline } = await import('./index.js');
-    const outline = await generateOutline(bookId, book.description, book.bookScale || '标准教程');
+    const outline = await generateOutline(bookId, book.description, book.bookScale || '130000');
 
     // 更新书籍的大纲
     await bookStore.update(bookId, { outline });
@@ -2526,7 +2562,7 @@ router.post('/books/:id/interactive/plan', optionalAuth, async (ctx: Context) =>
     const initState: any = {
       bookId,
       topic: book.description,
-      bookScale: book.bookScale || '130000',
+      bookScale: book.bookScale || '1000',
       genLevel: (book as any).genLevel || 2,
       description: book.description,
       title: book.title,
@@ -2672,7 +2708,7 @@ router.post('/books/:id/interactive/outline', optionalAuth, async (ctx: Context)
     const initState: any = {
       bookId,
       topic: book.description,
-      bookScale: book.bookScale || '130000',
+      bookScale: book.bookScale || '1000',
       genLevel: (book as any).genLevel || 2,
       description: book.description,
       title: book.title,
@@ -2856,7 +2892,7 @@ router.post('/books/:id/interactive/generate', optionalAuth, async (ctx: Context
     const userId = book.userId || parseInt(TEST_USER_ID);
 
     // 配额检查
-    const scaleConfig = getScaleConfig(book.bookScale || '130000');
+    const scaleConfig = getScaleConfig(book.bookScale || '1000');
     const totalWords = scaleConfig.totalWords;
     const audioQuota = await checkAudioQuota(userId, totalWords);
     if (!audioQuota.allowed) {
@@ -2869,7 +2905,7 @@ router.post('/books/:id/interactive/generate', optionalAuth, async (ctx: Context
     const initState: any = {
       bookId,
       topic: book.description,
-      bookScale: book.bookScale || '130000',
+      bookScale: book.bookScale || '1000',
       genLevel: (book as any).genLevel || 2,
       description: book.description,
       title: book.title,
@@ -2887,7 +2923,7 @@ router.post('/books/:id/interactive/generate', optionalAuth, async (ctx: Context
     langGraphGenerator.generate(
       bookId,
       book.description,
-      book.bookScale || '130000',
+      book.bookScale || '1000',
       (book as any).genLevel || 2
     ).then(() => {
       console.log(`[Interactive] 生成完成: bookId=${bookId}`);

+ 20 - 1
server/src/modules/book-generator/nodes/deep-plan.node.ts

@@ -43,6 +43,20 @@ export interface DeepBookPlan {
   };
 }
 
+/**
+ * 从描述中提取明确的字数要求
+ * 如 "生成200字文本" → 200
+ */
+function extractWordCountFromDescription(description: string): number | null {
+  const text = description.toLowerCase();
+  const match = text.match(/(\d+)\s*字/i);
+  if (match) {
+    const n = parseInt(match[1]);
+    return isNaN(n) ? null : n;
+  }
+  return null;
+}
+
 /**
  * 构建深度规划提示词消息
  */
@@ -53,9 +67,14 @@ function buildDeepPlanMessages(
   userFeedback?: string,
 ): ChatMessage[] {
   const config = getScaleConfig(bookScale);
-  const totalWords = config?.totalWords || 130000;
   const chapters = config?.chapters || 17;
 
+  // 如果 description 里有明确字数要求,优先用它
+  const descWordCount = extractWordCountFromDescription(description);
+  const totalWords = descWordCount !== null
+    ? descWordCount
+    : (config?.totalWords || 130000);
+
   let userPrompt = `## 书籍信息
 - 书名:${title}
 - 字数规模:约${totalWords}字,约${chapters}章

+ 21 - 1
server/src/modules/book-generator/nodes/plan.node.ts

@@ -35,6 +35,21 @@ export interface BookPlan {
   reasoning: string;
 }
 
+/**
+ * 从描述中提取明确的字数要求
+ * 如 "生成200字文本" → 200
+ */
+function extractWordCountFromDescription(description: string): number | null {
+  const text = description.toLowerCase();
+  // 匹配 "200字"、"500字" 等
+  const match = text.match(/(\d+)\s*字/i);
+  if (match) {
+    const n = parseInt(match[1]);
+    return isNaN(n) ? null : n;
+  }
+  return null;
+}
+
 /**
  * 构建规划提示词
  */
@@ -44,9 +59,14 @@ function buildPlanPrompt(
   bookScale: string
 ): string {
   const config = getScaleConfig(bookScale);
-  const totalWords = config?.totalWords || 130000;
   const chapters = config?.chapters || 17;
 
+  // 如果 description 里有明确字数要求,优先用它
+  const descWordCount = extractWordCountFromDescription(description);
+  const totalWords = descWordCount !== null
+    ? descWordCount
+    : (config?.totalWords || 130000);
+
   return `你是一位经验丰富的图书策划编辑。请对以下书籍做全面规划。
 
 ## 书籍信息

+ 1 - 1
server/src/modules/book-generator/nodes/quality-check.node.ts

@@ -310,7 +310,7 @@ export async function qualityCheckNode(state: typeof GraphState.State): Promise<
 
     // 持久化质量报告到数据库
     await bookStore.update(state.bookId, {
-      errorMsg: `[质量校验] ${result.overallAssessment} | 不合格: ${result.failedChapters.length}章`,
+      errorMsg: `[质量校验] ${result.overallAssessment}`,
     } as any);
 
     const qualityResultJson = JSON.stringify(result);

+ 22 - 1
server/src/modules/book-generator/prompts/builder.ts

@@ -213,6 +213,21 @@ export function buildDynamicPrompt(topic: string, bookScale: string, description
 
 // ============ 消息构建函数 ============
 
+/**
+ * 从描述中提取明确的字数要求
+ * 如 "生成200字文本" → 200
+ */
+function extractWordCountFromDescription(description: string | undefined): number | null {
+  if (!description) return null;
+  const text = description.toLowerCase();
+  const match = text.match(/(\d+)\s*字/i);
+  if (match) {
+    const n = parseInt(match[1]);
+    return isNaN(n) ? null : n;
+  }
+  return null;
+}
+
 /**
  * 构建大纲生成消息(动态提示词)
  * @param bookPlan 前序规划节点的分析结果(可选),包含写作风格、结构逻辑、内容深度等
@@ -238,11 +253,17 @@ export function buildOutlineMessages(topic: string, bookScale: string, descripti
     }
   }
 
+  // 如果 description 里有明确字数要求,优先用它作为规模
+  const descWordCount = extractWordCountFromDescription(description);
+  const scaleDesc = descWordCount !== null
+    ? `约${descWordCount}字(用户明确要求)`
+    : (SCALE_DESC[bookScale] || '中篇');
+
   return [
     { role: 'system', content: systemPrompt },
     {
       role: 'user',
-      content: `书名:《${topic}》\n规模:${SCALE_DESC[bookScale] || '中篇'}\n章节数:约${chapters}章\n${description ? `需求描述:${description}\n` : ''}\n请生成书籍大纲。`,
+      content: `书名:《${topic}》\n规模:${scaleDesc}\n章节数:约${chapters}章\n${description ? `需求描述:${description}\n` : ''}\n请生成书籍大纲。`,
     },
   ];
 }

+ 0 - 9
server/src/modules/book-generator/tts-queue.ts

@@ -140,15 +140,6 @@ function createGenQueue(config: QueueConfig): QueueInstance {
       } catch (updateErr) {
         console.error(`[${name}] 更新失败状态异常:`, updateErr);
       }
-      // 兜底回退:确保章节 genStage 不会卡在 audio_generating
-      if (task.chapterId) {
-        try {
-          await regenerateChapter(task.chapterId, 'content_completed');
-          console.log(`[${name}] 🔄 兜底回退章节#${task.chapterId} → content_completed`);
-        } catch (rollbackErr: any) {
-          console.error(`[${name}] 兜底回退章节#${task.chapterId} 失败:`, rollbackErr.message);
-        }
-      }
     }
   }
 

+ 1 - 1
server/src/modules/subscription/subscription.controller.ts

@@ -109,7 +109,7 @@ router.post('/check-quota', authMiddleware, async (ctx: Context) => {
 router.get('/book-scale-estimate', async (ctx: Context) => {
   const { scale } = ctx.query as { scale?: string };
   
-  const result = SubscriptionService.estimateBookWords(scale || '标准教程');
+  const result = SubscriptionService.estimateBookWords(scale || '130000');
   
   ctx.body = {
     code: 0,

+ 50 - 5
server/src/modules/tts/aliyun.provider.ts

@@ -159,6 +159,7 @@ export class AliyunTtsProvider implements ITtsProvider {
    * CosyVoice SSE 流式合成:边生成边接收,无超时限制
    */
   private async synthesizeStream(requestBody: any, outputPath: string): Promise<string> {
+    console.log(`🔊 [Aliyun SSE] 请求: ${this.ttsApiPath}, model=${this.modelId}, voice=${requestBody.input?.voice || 'default'}, textLen=${requestBody.input?.text?.length || 0}`);
     const response = await axios.post(this.ttsApiPath, requestBody, {
       headers: {
         'Authorization': `Bearer ${this.apiKey}`,
@@ -168,14 +169,25 @@ export class AliyunTtsProvider implements ITtsProvider {
       responseType: 'stream',
       timeout: 0,
     });
+    console.log(`📡 [Aliyun SSE] 响应: HTTP ${response.status}, contentType=${response.headers['content-type']}`);
 
     if (response.status !== 200) {
-      throw new Error(`Aliyun SSE HTTP ${response.status}`);
+      const body = await new Promise<string>((r) => {
+        let d = '';
+        response.data.on('data', (c: Buffer) => d += c.toString());
+        response.data.on('end', () => r(d));
+      });
+      console.error(`❌ [Aliyun SSE] HTTP ${response.status}: ${body.substring(0, 500)}`);
+      throw new Error(`Aliyun SSE HTTP ${response.status}: ${body.substring(0, 200)}`);
     }
 
     return new Promise((resolve, reject) => {
       const chunks: Buffer[] = [];
       let buffer = '';
+      let sseEventCount = 0;
+      let audioEventCount = 0;
+      let lastErrorMsg = '';
+      const lastRawEvents: string[] = []; // 保留最后5个事件用于调试
 
       response.data.on('data', (chunk: Buffer) => {
         buffer += chunk.toString();
@@ -184,20 +196,50 @@ export class AliyunTtsProvider implements ITtsProvider {
 
         for (const line of lines) {
           if (line.startsWith('data:')) {
+            sseEventCount++;
+            const rawData = line.substring(5).trim();
+
+            // 调试:记录最后5个事件
+            lastRawEvents.push(rawData.substring(0, 300));
+            if (lastRawEvents.length > 5) lastRawEvents.shift();
+
             try {
-              const json = JSON.parse(line.substring(5).trim());
+              const json = JSON.parse(rawData);
+
+              // 检查是否为错误响应
+              if (json.code || json.message) {
+                lastErrorMsg = `[${json.code || 'unknown'}] ${json.message || ''}`;
+                console.warn(`⚠️ [Aliyun SSE] 错误事件: ${lastErrorMsg}, raw=${rawData.substring(0, 200)}`);
+                continue;
+              }
+
+              // 提取音频数据
               const audioData = json.output?.audio?.data;
               if (audioData) {
+                audioEventCount++;
                 chunks.push(Buffer.from(audioData, 'base64'));
+              } else {
+                // 非音频事件(如 task_id、运行状态等),记录便于调试
+                const snippet = JSON.stringify(json).substring(0, 150);
+                console.log(`📡 [Aliyun SSE] 非音频事件 #${sseEventCount}: ${snippet}`);
               }
-            } catch {}
+            } catch {
+              console.warn(`⚠️ [Aliyun SSE] JSON解析失败: raw=${rawData.substring(0, 200)}`);
+            }
           }
         }
       });
 
       response.data.on('end', () => {
+        console.log(`[Aliyun SSE] 流结束: totalEvents=${sseEventCount}, audioEvents=${audioEventCount}, chunks=${chunks.length}`);
         if (chunks.length === 0) {
-          reject(new Error('CosyVoice SSE 未收到音频数据'));
+          const diag = [
+            `eventCount=${sseEventCount}`,
+            `audioCount=${audioEventCount}`,
+            `lastError=${lastErrorMsg || '无错误事件'}`,
+            `lastEvents=${JSON.stringify(lastRawEvents)}`,
+          ].join(', ');
+          reject(new Error(`CosyVoice SSE 未收到音频数据(诊断: ${diag})`));
           return;
         }
         const audioBuffer = Buffer.concat(chunks);
@@ -208,7 +250,10 @@ export class AliyunTtsProvider implements ITtsProvider {
         resolve(outputPath);
       });
 
-      response.data.on('error', reject);
+      response.data.on('error', (err: any) => {
+        console.error(`❌ [Aliyun SSE] Stream error: ${err.message}, chunks=${chunks.length}, events=${sseEventCount}`);
+        reject(err);
+      });
     });
   }
 

+ 1 - 1
server/sync-generate.js

@@ -34,7 +34,7 @@ async function resetAndSyncGenerate() {
     await langGraphGenerator.generate(
       bookId.toString(),
       book.description,
-      book.bookScale || '标准教程'
+      book.bookScale || '130000'
     );
     
     console.log('\n✅ 生成完成!');

+ 1 - 1
server/test-content-gen.js

@@ -11,7 +11,7 @@ async function testContentGeneration() {
     data: {
       title: 'Git入门',
       description: 'Git版本控制基础教程',
-      bookScale: '小册子',
+      bookScale: '2000',
       targetAudience: '开发者',
       style: '实用教程',
       totalChapters: 2,

+ 46 - 0
server/test-detect.js

@@ -0,0 +1,46 @@
+/**
+ * 测试 autoDetectBookType 在各种描述下的实际返回
+ */
+const http = require('http');
+
+function post(path, data) {
+  return new Promise((resolve, reject) => {
+    const body = JSON.stringify(data);
+    const req = http.request({
+      hostname: 'localhost',
+      port: 3000,
+      path,
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }
+    }, res => {
+      let d = '';
+      res.on('data', c => d += c);
+      res.on('end', () => resolve(JSON.parse(d)));
+    });
+    req.on('error', reject);
+    req.write(body);
+    req.end();
+  });
+}
+
+async function test() {
+  const cases = [
+    { title: '测试', description: '生成200字文本' },
+    { title: '测试', description: '请写一段500字的内容' },
+    { title: '测试', description: '写一个几千字的短文' },
+    { title: '修仙小说', description: '写一个修仙故事' },
+    { title: 'Python入门', description: '编程教程' },
+    { title: '测试', description: '关于机器学习的内容' },
+  ];
+
+  for (const c of cases) {
+    const result = await post('/api/book-generator/langgraph/books', {
+      title: c.title,
+      description: c.description
+    });
+    console.log(`描述: "${c.description}"`);
+    console.log(`  -> bookScale=${result.data.book.bookScale}, totalChapters=${result.data.book.totalChapters}, aiPreparing=${result.data.aiPreparing}`);
+  }
+}
+
+test().catch(console.error);

+ 48 - 0
server/test-quick.js

@@ -0,0 +1,48 @@
+// 测试:创建书籍时自动检测规模(200字描述)
+// 直接观察最终传入prompt的字数
+
+const http = require('http');
+
+function request(options, postData) {
+  return new Promise((resolve, reject) => {
+    const req = http.request(options, (res) => {
+      let data = '';
+      res.on('data', chunk => data += chunk);
+      res.on('end', () => resolve(data));
+    });
+    req.on('error', reject);
+    if (postData) req.write(postData);
+    req.end();
+  });
+}
+
+async function main() {
+  // 创建书籍:描述=生成200字文本,没选规模
+  const body = JSON.stringify({
+    title: '测试',
+    description: '生成200字文本'
+  });
+
+  const result = await request({
+    hostname: 'localhost',
+    port: 3000,
+    path: '/api/book-generator/langgraph/books',
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json',
+      'Content-Length': Buffer.byteLength(body)
+    }
+  }, body);
+
+  const data = JSON.parse(result);
+  console.log('\n========== 创建书籍结果 ==========');
+  console.log(`bookId: ${data.data.book.id}`);
+  console.log(`bookScale: ${data.data.book.bookScale}`);
+  console.log(`totalChapters: ${data.data.book.totalChapters}`);
+  console.log(`estimatedWords: ${data.data.book.estimatedWords}`);
+  console.log(`aiPreparing: ${data.data.aiPreparing}`);
+  console.log('\n说明: aiPreparing=true 表示AI正在异步检测规模');
+  console.log('如果bookScale=1000且描述有"200字",说明自动检测已生效');
+}
+
+main().catch(console.error);

+ 0 - 0
server/tsx


+ 3 - 0
test-results/sse_test.mp3

@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e07d58076deb3de66d1aeb96252ea76e9a81003aab6b6a23cae2394ee7270a20
+size 75451

+ 86 - 0
test/book_audio_test.py

@@ -0,0 +1,86 @@
+"""测试:创建书籍 → 生成内容 → 生成音频"""
+import requests, json, time
+
+BASE = 'http://localhost:3000/api/book-generator/langgraph'
+
+# 1. 创建书籍
+print('1. 创建书籍...')
+r = requests.post(f'{BASE}/books', json={
+    'title': '测试3000字音频', 'description': 'CosyVoice 书籍音频测试',
+    'bookScale': '2000', 'totalChapters': 1
+})
+b = r.json()['data']['book']
+bookId = b['id']
+print(f'   bookId={bookId}')
+
+# 2. 等大纲阶段完成
+print('2. 等待大纲生成...')
+for _ in range(30):
+    time.sleep(3)
+    r = requests.get(f'{BASE}/books/{bookId}')
+    stage = r.json()['data']['book']['genStage']
+    if stage not in ('draft', 'outlining'):
+        print(f'   当前阶段: {stage}')
+        break
+
+# 3. 用 debug 直接注入内容生成音频
+print('3. 用debug注入内容并生成音频...')
+r = requests.post(f'{BASE}/debug/trigger-full', json={
+    'bookId': bookId,
+    'voiceId': 'longyingling_v3',
+    'sampleText': (
+        '第一章 春之序曲\n\n'
+        '春天来了,万物复苏。轻柔的东风拂过大地,带走了冬日的严寒。'
+        '山坡上,嫩绿的小草破土而出,像是给大地铺上了一层柔软的地毯。'
+        '远处的杏花开了,粉白的颜色在阳光下显得格外温柔。'
+        '小溪解冻了,清澈的溪水哗啦啦地流淌,唱着欢快的歌。'
+        '鸟儿们从南方飞回来了,在枝头跳跃鸣叫,仿佛在诉说旅途的见闻。'
+        '农民伯伯扛着锄头下地了,翻新的泥土散发着特有的清香。'
+        '孩子们在田野里奔跑,放风筝,欢笑声回荡在蓝天白云之间。'
+        '一年之计在于春,这个时候播下的种子,到了秋天就会收获满满的果实。'
+        '春天是希望的季节,是一切美好的开始。'
+        '\n第二章 夏之热烈\n\n'
+        '夏天来了,太阳像个大火球挂在天上,热辣辣地烤着大地。'
+        '知了在树上不停地叫着,仿佛在说太热了太热了。'
+        '池塘里的荷花开了,粉嫩的花瓣在碧绿的荷叶映衬下格外好看。'
+        '蜻蜓立在荷花尖上,透明的翅膀在阳光下闪着光。'
+        '傍晚时分,人们纷纷走出家门,在树荫下乘凉聊天。'
+        '老人们摇着蒲扇,讲着古老的故事,孩子们围坐一旁听得入神。'
+        '夜空中繁星点点,偶尔有流星划过,带走了人们心中最美好的愿望。'
+        '夏天的雨说来就来,豆大的雨点噼里啪啦打在地上,'
+        '转眼间又雨过天晴,天空中出现一道美丽的彩虹。'
+        '夏天是热烈的,是奔放的,是生命最旺盛的季节。'
+        '\n第三章 秋之收获\n\n'
+        '秋天来了,天高云淡,金风送爽。田野里一片金黄,'
+        '沉甸甸的稻穗低垂着头,等待着收割的季节。'
+        '果园里硕果累累,红彤彤的苹果挂满枝头,'
+        '黄澄澄的梨子压弯了枝条,到处洋溢着丰收的喜悦。'
+        '树叶开始变黄,一片片飘落下来,像是金色的蝴蝶在空中起舞。'
+        '南飞的大雁排成人字形,向温暖的南方飞去。'
+        '丰收的田野里,机器的轰鸣声和农民们爽朗的笑声交织在一起。'
+        '家家户户的院子里晒满了金灿灿的玉米和红艳艳的辣椒,'
+        '那是用汗水换来的最美图画。'
+        '桂花开了,满城飘香,那是秋天独有的味道。'
+        '秋天是成熟的季节,是收获的季节,是感恩的季节。'
+    )
+})
+print(f'   trigger result: {r.json().get("message")}')
+
+# 4. 等待音频生成
+print('4. 等待音频生成...')
+for _ in range(60):
+    time.sleep(5)
+    r = requests.get(f'{BASE}/books/{bookId}')
+    chapters = r.json()['data']['book']['chapters']
+    if chapters:
+        for c in chapters:
+            stage = c.get('genStage', '')
+            audio_url = c.get('audioUrl', '')
+            if audio_url or stage == 'audio_completed':
+                print(f'   章节ID={c["id"]} stage={stage} audioUrl={audio_url[:60] if audio_url else "无"}')
+        # 检查是否全部完成
+        all_done = all(c.get('genStage') in ('audio_completed', 'video_completed') for c in chapters if c.get('genStage'))
+        if all_done:
+            print('   ✅ 全部章节音频生成完成!')
+            break
+    print(f'   .', end='', flush=True)

+ 23 - 0
test/debug_endpoint.py

@@ -0,0 +1,23 @@
+import requests
+
+# 1. CosyVoice 端点
+r = requests.post(
+    'https://dashscope.aliyuncs.com/api/v1/services/audio/tts/SpeechSynthesizer',
+    headers={'Authorization': 'Bearer sk-c25679401ba24c749f53be86b0c9a7a6', 'Content-Type': 'application/json'},
+    json={'model': 'cosyvoice-v3-flash', 'input': {'text': '春天来了', 'voice': 'longanyang'}}
+)
+print(f'SpeechSynthesizer: HTTP {r.status_code}')
+d = r.json()
+if r.status_code == 200:
+    print(f'  ✅ audioUrl={d["output"]["audio"]["url"][:60]}...')
+else:
+    print(f'  ❌ {d.get("message","")}')
+
+# 2. Qwen-TTS 端点(旧)
+r2 = requests.post(
+    'https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation',
+    headers={'Authorization': 'Bearer sk-c25679401ba24c749f53be86b0c9a7a6', 'Content-Type': 'application/json'},
+    json={'model': 'cosyvoice-v3-flash', 'input': {'text': '春天来了', 'voice': 'longanyang'}}
+)
+print(f'multimodal: HTTP {r2.status_code}')
+print(f'  {r2.json().get("message","")}')

+ 36 - 0
test/dl_voices.py

@@ -0,0 +1,36 @@
+import json, os, re, requests, time
+
+with open('test/all_voices.json', 'r', encoding='utf-8') as f:
+    raw = f.read()
+m = re.search(r'"(\[.*\])"', raw, re.DOTALL)
+data = json.loads(m.group(1).replace(r'\"', '"').replace(r'\\\\', '\\'))
+
+print(f'共 {len(data)} 个音色')
+os.makedirs('voices', exist_ok=True)
+
+dl = skip = fail = 0
+for i, v in enumerate(data):
+    url, voice = v['url'], v['voice']
+    if not url or not voice:
+        skip += 1; continue
+    fpath = f'voices/{voice}.mp3'
+    if os.path.exists(fpath):
+        skip += 1; continue
+    try:
+        r = requests.get(url, timeout=30)
+        if r.status_code == 200 and len(r.content) > 100:
+            open(fpath, 'wb').write(r.content); dl += 1
+        else: fail += 1
+    except: fail += 1
+    if (i+1) % 30 == 0:
+        print(f'  进度: {i+1}/{len(data)} (下载{dl} 跳过{skip} 失败{fail})')
+    time.sleep(0.15)
+
+print(f'\n完成! 下载{dl} 跳过{skip} 失败{fail}')
+# 列表
+files = sorted(os.listdir('voices'))
+print(f'voices/ 共 {len(files)} 个文件:')
+for f in files[:20]:
+    print(f'  {f}')
+if len(files) > 20:
+    print(f'  ... 等 {len(files)} 个')

+ 104 - 0
test/download_all_voices.py

@@ -0,0 +1,104 @@
+#!/usr/bin/env python3
+"""下载所有阿里云音色样本到 project/voices/ 目录"""
+import json, os, re, requests, time, sys
+
+OUTPUT_DIR = "voices"
+os.makedirs(OUTPUT_DIR, exist_ok=True)
+
+# 先获取全部音色URL(不只是前80个)
+print("正在通过浏览器提取全部音色...")
+import subprocess
+result = subprocess.run([
+    "npx", "playwright-cli", "run-code",
+    r"""async (page) => {
+  const data = await page.evaluate(() => {
+    const result = [];
+    document.querySelectorAll('audio').forEach(audio => {
+      const src = audio.querySelector('source')?.src || audio.src;
+      if (src) {
+        const tr = audio.closest('tr');
+        const text = tr?.textContent || '';
+        const vm = text.match(/voice\s*参数\s*[::]\s*([a-z]\w*)/i);
+        const nm = text.match(/名称\s*[::]\s*(\S+)/);
+        result.push({url: src, voice: vm?vm[1]:'', name: nm?nm[1]:''});
+      }
+    });
+    return result;
+  });
+  return JSON.stringify(data);
+}"""], capture_output=True, text=True, timeout=60,
+    cwd=r"c:/Users/caoyg/ai/audio-tts/audio_codebuddy")
+
+output = result.stdout
+# 提取真正的 JSON
+json_match = re.search(r'"(\[.*\])"', output, re.DOTALL)
+if not json_match:
+    print("无法解析输出,前500字符:", output[:500])
+    sys.exit(1)
+
+json_str = json_match.group(1)
+# 反转义
+json_str = json_str.replace('\\"', '"').replace('\\\\', '\\')
+voices = json.loads(json_str)
+print(f"找到 {len(voices)} 个音色样本\n")
+
+# 按模型分类计数器
+stats = {}
+downloaded = 0
+failed = 0
+skipped = 0
+
+for i, v in enumerate(voices):
+    url = v['url']
+    voice = v['voice']
+    name = v.get('name', '')
+    
+    if not url:
+        skipped += 1
+        continue
+    
+    # 清理文件名
+    safe_name = f"{name}_{voice}" if name else voice
+    safe_name = re.sub(r'[\\/:*?"<>|]', '_', safe_name)
+    safe_name = voice  # 简单用voice参数名
+    fname = f"{voice}.mp3"
+    fpath = os.path.join(OUTPUT_DIR, fname)
+    
+    if os.path.exists(fpath):
+        stats[voice] = stats.get(voice, 0) + 1
+        skipped += 1
+        continue
+    
+    # 进度
+    pct = (i+1) * 100 // len(voices)
+    bar = "█" * (pct // 5) + "░" * (20 - pct // 5)
+    print(f"\r[{bar}] {pct}%  {i+1}/{len(voices)}  {voice:25s}", end='', flush=True)
+    
+    try:
+        resp = requests.get(url, timeout=60, headers={
+            'Referer': 'https://help.aliyun.com/',
+            'User-Agent': 'Mozilla/5.0'
+        })
+        if resp.status_code == 200 and len(resp.content) > 100:
+            with open(fpath, 'wb') as f:
+                f.write(resp.content)
+            downloaded += 1
+        else:
+            failed += 1
+    except Exception as e:
+        failed += 1
+        pass
+    
+    time.sleep(0.2)
+
+print(f"\n\n完成!下载 {downloaded} 个, 跳过 {skipped} 个, 失败 {failed} 个")
+print(f"文件保存在: {OUTPUT_DIR}/")
+
+# 生成索引
+with open(os.path.join(OUTPUT_DIR, "INDEX.md"), "w", encoding="utf-8") as f:
+    f.write("# 阿里云百炼 官方音色样本\n\n")
+    f.write(f"共 {len(voices)} 个音色,来源:help.aliyun.com 官方帮助中心\n\n")
+    f.write("| # | 音色名 | voice参数 | 文件名 |\n")
+    f.write("|---|--------|----------|--------|\n")
+    for i, v in enumerate(voices):
+        f.write(f"| {i+1} | {v['name']} | `{v['voice']}` | [{v['voice']}.mp3]({v['voice']}.mp3) |\n")

+ 94 - 0
test/download_voices.py

@@ -0,0 +1,94 @@
+#!/usr/bin/env python3
+"""从阿里云帮助页面提取全部音色并下载音频样本"""
+import json, os, re, requests, subprocess, time
+
+OUTPUT_DIR = "docs/voices/audio"
+os.makedirs(OUTPUT_DIR, exist_ok=True)
+
+# 用 playwright 提取音色数据
+print("正在提取音色列表...")
+result = subprocess.run([
+    "npx", "playwright-cli", "run-code",
+    r"""async (page) => {
+  const data = [];
+  // 遍历所有包含音频的表格行
+  const rows = document.querySelectorAll('tr');
+  rows.forEach(tr => {
+    const audioEl = tr.querySelector('audio');
+    if (!audioEl) return;
+    const src = audioEl.querySelector('source')?.src || audioEl.src;
+    if (!src) return;
+    
+    // 提取voice参数
+    const text = tr.textContent || '';
+    const voiceMatch = text.match(/voice参数[::]\s*(\S+)/);
+    const nameMatch = voiceMatch ? text.match(/名称[::]\s*(\S+)/) : null;
+    const sceneMatch = text.match(/(?:^|\n)([^\n]{3,20}?)\s*名称[::]/);
+    
+    data.push({
+      url: src,
+      voice: voiceMatch ? voiceMatch[1] : '',
+      name: nameMatch ? nameMatch[1] : '',
+      scene: sceneMatch ? sceneMatch[1].trim() : ''
+    });
+  });
+  return JSON.stringify(data);
+}"""], capture_output=True, text=True, cwd="c:/Users/caoyg/ai/audio-tts/audio_codebuddy")
+
+# 解析输出
+output = result.stdout
+# 提取 JSON
+json_start = output.find('[')
+json_end = output.rfind(']') + 1
+if json_start < 0 or json_end <= json_start:
+    print("未找到音色数据")
+    print(output[:500])
+    exit(1)
+
+voices = json.loads(output[json_start:json_end])
+print(f"找到 {len(voices)} 个音色")
+
+# 写音色索引
+index_lines = ["# 官方音色样本索引\n", f"\n共 {len(voices)} 个音色,来源:阿里云百炼帮助中心\n\n"]
+index_lines.append("| 音色名称 | voice参数 | 适用场景 | 文件名 |\n")
+index_lines.append("|---------|----------|---------|--------|\n")
+
+downloaded = 0
+for i, v in enumerate(voices):
+    url = v['url']
+    voice_param = v['voice']
+    name = v['name']
+    scene = v['scene']
+    
+    # 文件名:场景_音色名_voice参数.mp3
+    safe_name = f"{scene}_{name}_{voice_param}" if scene else f"{name}_{voice_param}"
+    safe_name = re.sub(r'[\\/:*?"<>|]', '_', safe_name)
+    fname = f"{safe_name}.mp3"
+    fpath = os.path.join(OUTPUT_DIR, fname)
+    
+    index_lines.append(f"| {name} | `{voice_param}` | {scene} | [{fname}](audio/{fname}) |\n")
+    
+    if os.path.exists(fpath):
+        print(f"  [{i+1}/{len(voices)}] 跳过(已存在): {fname}")
+        downloaded += 1
+        continue
+    
+    try:
+        print(f"  [{i+1}/{len(voices)}] 下载: {voice_param} -> {fname}")
+        resp = requests.get(url, timeout=30)
+        if resp.status_code == 200:
+            with open(fpath, 'wb') as f:
+                f.write(resp.content)
+            downloaded += 1
+        else:
+            print(f"    HTTP {resp.status_code}")
+    except Exception as e:
+        print(f"    错误: {e}")
+    time.sleep(0.3)
+
+# 写索引文件
+index_path = os.path.join(OUTPUT_DIR, "INDEX.md")
+with open(index_path, 'w', encoding='utf-8') as f:
+    f.writelines(index_lines)
+
+print(f"\n完成!下载 {downloaded}/{len(voices)} 个音色到 {OUTPUT_DIR}/")

+ 19 - 0
test/gen_index.py

@@ -0,0 +1,19 @@
+import os
+
+vd = 'voices'
+files = sorted([f for f in os.listdir(vd) if f.endswith('.mp3')])
+nm = {f.replace('.mp3',''): f.replace('.mp3','') for f in files}  # 简洁: voice名=文件名
+
+total_size = sum(os.path.getsize(os.path.join(vd, f)) for f in files)
+
+lines = ['# CosyVoice v3-flash 音色样本\n']
+lines.append(f'\n共 **{len(files)}** 个音色 | 总大小 **{total_size/1024/1024:.1f} MB** | 阿里云百炼 API\n\n')
+lines.append('| # | voice参数 | 文件 |\n')
+lines.append('|---|----------|------|\n')
+for i, f in enumerate(files):
+    voice = f.replace('.mp3', '')
+    lines.append(f'| {i+1} | `{voice}` | [{f}]({f}) |\n')
+
+with open(os.path.join(vd, 'INDEX.md'), 'w', encoding='utf-8') as fp:
+    fp.writelines(lines)
+print(f'✅ {vd}/INDEX.md 已生成, {len(files)} 个音色')

+ 112 - 0
test/gen_voice_samples.py

@@ -0,0 +1,112 @@
+#!/usr/bin/env python3
+"""用 CosyVoice API 为每个音色生成试听样本"""
+import requests, json, os, time
+
+API_KEY = "sk-c25679401ba24c749f53be86b0c9a7a6"
+API_URL = "https://dashscope.aliyuncs.com/api/v1/services/audio/tts/SpeechSynthesizer"
+MODEL = "cosyvoice-v3-flash"
+OUTPUT_DIR = "voices"
+
+# 从文档整理的完整音色列表
+VOICES = [
+    # 标杆音色
+    ("longanyang", "龙安洋-阳光大男孩"), ("longanhuan", "龙安欢-欢脱元气女"),
+    ("longhuhu_v3", "龙呼呼-天真烂漫女童"),
+    # 有声书
+    ("longmiao_v3", "龙妙-抑扬顿挫女"), ("longsanshu_v3", "龙三叔-沉稳质感男"),
+    ("longyuan_v3", "龙媛-温暖治愈女"), ("longyue_v3", "龙悦-温暖磁性女"),
+    ("longxiu_v3", "龙修-博才说书男"), ("longnan_v3", "龙楠-睿智青年男"),
+    ("longwanjun_v3", "龙婉君-细腻柔声女"), ("longyichen_v3", "龙逸尘-洒脱活力男"),
+    ("longlaobo_v3", "龙老伯-沧桑岁月爷"), ("longlaoyi_v3", "龙老姨-烟火从容阿姨"),
+    # 客服
+    ("longyingxun_v3", "龙应询-年轻青涩男"), ("longyingjing_v3", "龙应静-低调冷静女"),
+    ("longyingling_v3", "龙应聆-温和共情女"), ("longyingtao_v3", "龙应桃-温柔淡定女"),
+    ("longyingmu_v3", "龙应沐-优雅知性女"), ("longyingxiao_v3", "龙应笑-清甜推销女"),
+    # 语音助手
+    ("longxiaochun_v3", "龙小淳-知性积极女"), ("longxiaoxia_v3", "龙小夏-沉稳权威女"),
+    ("longanyun_v3", "龙安昀-居家暖男"), ("longanwen_v3", "龙安温-优雅知性女"),
+    ("longanli_v3", "龙安莉-利落从容女"), ("longanlang_v3", "龙安朗-清爽利落男"),
+    ("longyumi_v3", "YUMI-正经青年女"),
+    # 社交
+    ("longanqin_v3", "龙安亲-亲和活泼女"), ("longanya_v3", "龙安雅-高雅气质女"),
+    ("longanzhi_v3", "龙安智-睿智轻熟男"), ("longanling_v3", "龙安灵-思维灵动女"),
+    ("longantai_v3", "龙安台-嗲甜台湾女"), ("longhua_v3", "龙华-元气甜美女"),
+    ("longcheng_v3", "龙橙-智慧青年男"), ("longze_v3", "龙泽-温暖元气男"),
+    ("longzhe_v3", "龙哲-呆板大暖男"), ("longyan_v3", "龙颜-温暖春风女"),
+    ("longxing_v3", "龙星-温婉邻家女"), ("longtian_v3", "龙天-磁性理智男"),
+    ("longwan_v3", "龙婉-细腻柔声女"), ("longqiang_v3", "龙嫱-浪漫风情女"),
+    ("longfeifei_v3", "龙菲菲-甜美娇气女"), ("longhao_v3", "龙浩-多情忧郁男"),
+    ("longanrou_v3", "龙安柔-温柔闺蜜女"), ("longhan_v3", "龙寒-温暖痴情男"),
+    # 童声
+    ("longpaopao_v3", "龙泡泡-飞天泡泡音"), ("longjielidou_v3", "龙杰力豆-阳光顽皮男"),
+    ("longxian_v3", "龙仙-豪放可爱女"), ("longling_v3", "龙铃-稚气呆板女"),
+    ("longshanshan_v3", "龙闪闪-戏剧化童声"), ("longniuniu_v3", "龙牛牛-阳光男童声"),
+    # 方言
+    ("longjiaxin_v3", "龙嘉欣-优雅粤语女"), ("longjiayi_v3", "龙嘉怡-知性粤语女"),
+    ("longanyue_v3", "龙安粤-欢脱粤语男"), ("longlaotie_v3", "龙老铁-东北直率男"),
+    ("longshange_v3", "龙陕哥-陕北男"),
+    # 新闻/直播
+    ("longshuo_v3", "龙硕-博才干练男"), ("longshu_v3", "龙书-沉稳青年男"),
+    ("loongbella_v3", "Bella3.0-精准干练女"), ("longanran_v3", "龙安燃-活泼质感女"),
+    ("longanxuan_v3", "龙安宣-经典直播女"),
+    # 特色
+    ("longjiqi_v3", "龙机器-呆萌机器人"), ("longhouge_v3", "龙猴哥-经典猴哥"),
+    ("longdaiyu_v3", "龙黛玉-娇率才女音"), ("longfei_v3", "龙飞-热血磁性男"),
+    # 外语
+    ("loongkyong_v3", "loongkyong-韩语女"), ("loongriko_v3", "Riko-霓虹女"),
+    ("loongabby_v3", "美式英文女"), ("loongandy_v3", "美式英文男"),
+]
+
+os.makedirs(OUTPUT_DIR, exist_ok=True)
+
+# 测试短句
+TEST_TEXT = "你好,欢迎使用语音合成服务,我是你的智能语音助手。"
+headers = {"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}
+
+done = skip = fail = 0
+total = len(VOICES)
+print(f"模型: {MODEL}, 待生成: {total} 个音色\n")
+
+for i, (voice, name) in enumerate(VOICES):
+    fpath = os.path.join(OUTPUT_DIR, f"{voice}.mp3")
+    if os.path.exists(fpath):
+        skip += 1
+        if skip % 10 == 0:
+            print(f"  [{i+1}/{total}] 跳过已存在 ({skip}个)")
+        continue
+
+    payload = {
+        "model": MODEL,
+        "input": {"text": TEST_TEXT, "voice": voice, "format": "mp3", "sample_rate": 24000}
+    }
+    try:
+        resp = requests.post(API_URL, headers=headers, json=payload, timeout=60)
+        if resp.status_code == 200:
+            data = resp.json()
+            audio_url = data["output"]["audio"]["url"]
+            ar = requests.get(audio_url, timeout=30)
+            if ar.status_code == 200:
+                with open(fpath, 'wb') as f:
+                    f.write(ar.content)
+                done += 1
+                print(f"  ✅ [{i+1}/{total}] {name} ({voice}) - {len(ar.content)} bytes")
+            else:
+                fail += 1
+                print(f"  ❌ [{i+1}/{total}] {voice} 下载失败")
+        else:
+            err = resp.json().get('message', resp.text)[:60]
+            fail += 1
+            print(f"  ⚠️  [{i+1}/{total}] {voice} 不支持: {err}")
+    except Exception as e:
+        fail += 1
+        print(f"  ❌ [{i+1}/{total}] {voice} 异常: {e}")
+    time.sleep(0.5)
+
+print(f"\n========== 完成 ==========")
+print(f"生成: {done}  跳过: {skip}  失败: {fail}")
+print(f"保存目录: {OUTPUT_DIR}/")
+
+# 统计
+files = [f for f in os.listdir(OUTPUT_DIR) if f.endswith('.mp3')]
+total_size = sum(os.path.getsize(os.path.join(OUTPUT_DIR, f)) for f in files)
+print(f"共 {len(files)} 个 mp3, 总大小 {total_size/1024/1024:.1f} MB")

+ 19 - 0
test/quick_test.py

@@ -0,0 +1,19 @@
+import requests, json
+
+# 1. 音色列表
+r = requests.get('http://localhost:3000/api/tts/voices')
+voices = r.json()['data']['voices']
+print(f'1. 音色数: {len(voices)},前3: {[v["id"] for v in voices[:3]]}')
+
+# 2. 生成音频
+r = requests.post('http://localhost:3000/api/tts/generate', json={
+    'text': '春天来了万物复苏阳光温暖地洒在大地上花儿竞相开放小鸟在枝头歌唱',
+    'voiceId': 'longanyang'
+})
+d = r.json()
+print(f'2. 生成: code={d.get("code")}, msg={d.get("message")}')
+if d.get('data'):
+    print(f'   audioId={d["data"].get("audioId")}')
+    print(f'   audioUrl={d["data"].get("audioUrl","")[:80]}')
+else:
+    print(f'   err={json.dumps(d, ensure_ascii=False)[:200]}')

+ 15 - 0
test/quick_test2.py

@@ -0,0 +1,15 @@
+import requests, json
+
+# 用统一的 voice_01 测试
+r = requests.post('http://localhost:3000/api/tts/generate', json={
+    'text': '春天来了万物复苏阳光温暖地洒在大地上',
+    'voiceId': 'voice_01',
+    'ttsProvider': 'bailian'
+})
+d = r.json()
+print(f'code={d.get("code")} msg={d.get("message")}')
+if d.get('data'):
+    print(f'audioId={d["data"].get("audioId")}')
+    print(f'audioUrl={d["data"].get("audioUrl","")[:80]}')
+else:
+    print(f'err={json.dumps(d, ensure_ascii=False)[:300]}')

+ 13 - 0
test/quick_test3.py

@@ -0,0 +1,13 @@
+import requests
+
+# 直接通过服务端调用
+r = requests.post('http://localhost:3000/api/tts/generate', json={
+    'text': '春天来了万物复苏',
+    'voiceId': 'voice_01',
+    'ttsProvider': 'bailian'
+})
+d = r.json()
+print(f'generate: code={d.get("code")} msg={d.get("message")}')
+if d.get('data'):
+    print(f'  audioId={d["data"].get("audioId")}')
+    print(f'  audioUrl={d["data"].get("audioUrl","")[:80]}')

+ 25 - 0
test/test_sse.py

@@ -0,0 +1,25 @@
+import requests, json
+
+r = requests.post(
+    'https://dashscope.aliyuncs.com/api/v1/services/audio/tts/SpeechSynthesizer',
+    headers={'Authorization': 'Bearer sk-c25679401ba24c749f53be86b0c9a7a6', 'Content-Type': 'application/json', 'X-DashScope-SSE': 'enable'},
+    json={'model': 'cosyvoice-v3-flash', 'input': {'text': '春天来了万物复苏阳光温暖', 'voice': 'longanyang', 'format': 'mp3', 'sample_rate': 24000}},
+    stream=True
+)
+
+chunks = []
+for line in r.iter_lines(decode_unicode=True):
+    if line and line.startswith('data:'):
+        try:
+            d = json.loads(line[5:].strip())
+            data = d.get('output',{}).get('audio',{}).get('data','')
+            if data:
+                chunks.append(data)
+        except: pass
+
+import base64
+audio = b''.join(base64.b64decode(c) for c in chunks)
+print(f'HTTP {r.status_code}, chunks: {len(chunks)}, total: {len(audio)} bytes')
+with open('test-results/sse_test.mp3', 'wb') as f:
+    f.write(audio)
+print('saved to test-results/sse_test.mp3')

+ 14 - 0
test/test_v35.py

@@ -0,0 +1,14 @@
+import requests
+
+headers = {'Authorization': 'Bearer sk-c25679401ba24c749f53be86b0c9a7a6', 'Content-Type': 'application/json'}
+url = 'https://dashscope.aliyuncs.com/api/v1/services/audio/tts/SpeechSynthesizer'
+
+# 测试 v3.5-flash 用系统音色
+for model in ['cosyvoice-v3.5-flash', 'cosyvoice-v3.5-plus']:
+    r = requests.post(url, headers=headers, json={
+        'model': model,
+        'input': {'text': '测试', 'voice': 'longanyang', 'format': 'mp3', 'sample_rate': 24000}
+    })
+    d = r.json()
+    ok = '✅' if r.status_code == 200 else '❌'
+    print(f'{ok} {model} + longanyang: HTTP {r.status_code} - {d.get("message","")}')

+ 73 - 0
voices/INDEX.md

@@ -0,0 +1,73 @@
+# CosyVoice v3-flash 音色样本
+
+共 **67** 个音色 | 总大小 **7.5 MB** | 阿里云百炼 API
+
+| # | voice参数 | 文件 |
+|---|----------|------|
+| 1 | `longanhuan` | [longanhuan.mp3](longanhuan.mp3) |
+| 2 | `longanlang_v3` | [longanlang_v3.mp3](longanlang_v3.mp3) |
+| 3 | `longanli_v3` | [longanli_v3.mp3](longanli_v3.mp3) |
+| 4 | `longanling_v3` | [longanling_v3.mp3](longanling_v3.mp3) |
+| 5 | `longanqin_v3` | [longanqin_v3.mp3](longanqin_v3.mp3) |
+| 6 | `longanran_v3` | [longanran_v3.mp3](longanran_v3.mp3) |
+| 7 | `longanrou_v3` | [longanrou_v3.mp3](longanrou_v3.mp3) |
+| 8 | `longantai_v3` | [longantai_v3.mp3](longantai_v3.mp3) |
+| 9 | `longanwen_v3` | [longanwen_v3.mp3](longanwen_v3.mp3) |
+| 10 | `longanxuan_v3` | [longanxuan_v3.mp3](longanxuan_v3.mp3) |
+| 11 | `longanya_v3` | [longanya_v3.mp3](longanya_v3.mp3) |
+| 12 | `longanyang` | [longanyang.mp3](longanyang.mp3) |
+| 13 | `longanyue_v3` | [longanyue_v3.mp3](longanyue_v3.mp3) |
+| 14 | `longanyun_v3` | [longanyun_v3.mp3](longanyun_v3.mp3) |
+| 15 | `longanzhi_v3` | [longanzhi_v3.mp3](longanzhi_v3.mp3) |
+| 16 | `longcheng_v3` | [longcheng_v3.mp3](longcheng_v3.mp3) |
+| 17 | `longdaiyu_v3` | [longdaiyu_v3.mp3](longdaiyu_v3.mp3) |
+| 18 | `longfei_v3` | [longfei_v3.mp3](longfei_v3.mp3) |
+| 19 | `longfeifei_v3` | [longfeifei_v3.mp3](longfeifei_v3.mp3) |
+| 20 | `longhan_v3` | [longhan_v3.mp3](longhan_v3.mp3) |
+| 21 | `longhao_v3` | [longhao_v3.mp3](longhao_v3.mp3) |
+| 22 | `longhouge_v3` | [longhouge_v3.mp3](longhouge_v3.mp3) |
+| 23 | `longhua_v3` | [longhua_v3.mp3](longhua_v3.mp3) |
+| 24 | `longhuhu_v3` | [longhuhu_v3.mp3](longhuhu_v3.mp3) |
+| 25 | `longjiaxin_v3` | [longjiaxin_v3.mp3](longjiaxin_v3.mp3) |
+| 26 | `longjiayi_v3` | [longjiayi_v3.mp3](longjiayi_v3.mp3) |
+| 27 | `longjielidou_v3` | [longjielidou_v3.mp3](longjielidou_v3.mp3) |
+| 28 | `longjiqi_v3` | [longjiqi_v3.mp3](longjiqi_v3.mp3) |
+| 29 | `longlaobo_v3` | [longlaobo_v3.mp3](longlaobo_v3.mp3) |
+| 30 | `longlaotie_v3` | [longlaotie_v3.mp3](longlaotie_v3.mp3) |
+| 31 | `longlaoyi_v3` | [longlaoyi_v3.mp3](longlaoyi_v3.mp3) |
+| 32 | `longling_v3` | [longling_v3.mp3](longling_v3.mp3) |
+| 33 | `longmiao_v3` | [longmiao_v3.mp3](longmiao_v3.mp3) |
+| 34 | `longnan_v3` | [longnan_v3.mp3](longnan_v3.mp3) |
+| 35 | `longniuniu_v3` | [longniuniu_v3.mp3](longniuniu_v3.mp3) |
+| 36 | `longpaopao_v3` | [longpaopao_v3.mp3](longpaopao_v3.mp3) |
+| 37 | `longqiang_v3` | [longqiang_v3.mp3](longqiang_v3.mp3) |
+| 38 | `longsanshu_v3` | [longsanshu_v3.mp3](longsanshu_v3.mp3) |
+| 39 | `longshange_v3` | [longshange_v3.mp3](longshange_v3.mp3) |
+| 40 | `longshanshan_v3` | [longshanshan_v3.mp3](longshanshan_v3.mp3) |
+| 41 | `longshu_v3` | [longshu_v3.mp3](longshu_v3.mp3) |
+| 42 | `longshuo_v3` | [longshuo_v3.mp3](longshuo_v3.mp3) |
+| 43 | `longtian_v3` | [longtian_v3.mp3](longtian_v3.mp3) |
+| 44 | `longwan_v3` | [longwan_v3.mp3](longwan_v3.mp3) |
+| 45 | `longwanjun_v3` | [longwanjun_v3.mp3](longwanjun_v3.mp3) |
+| 46 | `longxian_v3` | [longxian_v3.mp3](longxian_v3.mp3) |
+| 47 | `longxiaochun_v3` | [longxiaochun_v3.mp3](longxiaochun_v3.mp3) |
+| 48 | `longxiaoxia_v3` | [longxiaoxia_v3.mp3](longxiaoxia_v3.mp3) |
+| 49 | `longxing_v3` | [longxing_v3.mp3](longxing_v3.mp3) |
+| 50 | `longyan_v3` | [longyan_v3.mp3](longyan_v3.mp3) |
+| 51 | `longyichen_v3` | [longyichen_v3.mp3](longyichen_v3.mp3) |
+| 52 | `longyingjing_v3` | [longyingjing_v3.mp3](longyingjing_v3.mp3) |
+| 53 | `longyingling_v3` | [longyingling_v3.mp3](longyingling_v3.mp3) |
+| 54 | `longyingmu_v3` | [longyingmu_v3.mp3](longyingmu_v3.mp3) |
+| 55 | `longyingtao_v3` | [longyingtao_v3.mp3](longyingtao_v3.mp3) |
+| 56 | `longyingxiao_v3` | [longyingxiao_v3.mp3](longyingxiao_v3.mp3) |
+| 57 | `longyingxun_v3` | [longyingxun_v3.mp3](longyingxun_v3.mp3) |
+| 58 | `longyuan_v3` | [longyuan_v3.mp3](longyuan_v3.mp3) |
+| 59 | `longyue_v3` | [longyue_v3.mp3](longyue_v3.mp3) |
+| 60 | `longyumi_v3` | [longyumi_v3.mp3](longyumi_v3.mp3) |
+| 61 | `longze_v3` | [longze_v3.mp3](longze_v3.mp3) |
+| 62 | `longzhe_v3` | [longzhe_v3.mp3](longzhe_v3.mp3) |
+| 63 | `loongabby_v3` | [loongabby_v3.mp3](loongabby_v3.mp3) |
+| 64 | `loongandy_v3` | [loongandy_v3.mp3](loongandy_v3.mp3) |
+| 65 | `loongbella_v3` | [loongbella_v3.mp3](loongbella_v3.mp3) |
+| 66 | `loongkyong_v3` | [loongkyong_v3.mp3](loongkyong_v3.mp3) |
+| 67 | `loongriko_v3` | [loongriko_v3.mp3](loongriko_v3.mp3) |

+ 3 - 0
voices/longanhuan.mp3

@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:1dcb3fcbd075f847a7f1162e796d5945be18595dafcfaeeb7b998e696445239d
+size 220008

+ 3 - 0
voices/longanlang_v3.mp3

@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b49eb474f9f8518dfff5a8ac233dfb6bcf2d89eb69ead5602296d6b2b64b03f0
+size 91291

+ 3 - 0
voices/longanli_v3.mp3

@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:73c85ba784cec4d6f67ea5ea4e2b3bfa2643614924192878d5c6171ecf10d717
+size 123931

+ 3 - 0
voices/longanling_v3.mp3

@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ddcdb4d57fcd7224a78cc16fd0570a51d7fff75b02297df2567ea854ed9ba46e
+size 98491

+ 3 - 0
voices/longanqin_v3.mp3

@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:bdfbe61155f3bca3d5368716b8ddcb55c20ab84578a26ff2058a6d6c2cc56dbe
+size 97531

+ 3 - 0
voices/longanran_v3.mp3

@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:900159f96eb04ad93c6623dba049c8f2f29e2283f677e2e4a5e08c07f1b5e5c2
+size 82171

+ 3 - 0
voices/longanrou_v3.mp3

@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:bb7ac906c9cdd652a8a416ed5f7530188504bccb7c216ec92b15ae3ebcc4073c
+size 91771

+ 3 - 0
voices/longantai_v3.mp3

@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:bf3d68cc82434e72663b8de45d3c1f25bfa7d6aca77467cd17176e8f30c517bc
+size 95131

+ 3 - 0
voices/longanwen_v3.mp3

@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ca9eb0d6a199eda4f45662b775b5d43f7f8e32520e5b224ff05f76f9dcae4537
+size 117691

+ 3 - 0
voices/longanxuan_v3.mp3

@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:31b9a2c9924dac7f9537e3537d3fceb1587a6275a0ce6f0ccfe19e2102bac9e9
+size 86491

+ 3 - 0
voices/longanya_v3.mp3

@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b728ba0483fa870b1c2510c5d9938448101829cda36ccedc50124fd56be1d1db
+size 120091

+ 3 - 0
voices/longanyang.mp3

@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d5f922074ef0101daa4d3b32b0ff5bece9dd88415eda6f9c8d05287aadc332c8
+size 286464

+ 3 - 0
voices/longanyue_v3.mp3

@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:10fa931ecbf6964cb57325c9d8f6e9871c01f0bba24038734255e5ac7c6130bd
+size 109531

+ 3 - 0
voices/longanyun_v3.mp3

@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6852485d26bc05d130dfaa34ddce4b5ae178046c272377f3e820f4ed64c2cbfb
+size 103291

+ 3 - 0
voices/longanzhi_v3.mp3

@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:0992c98ec4a5393370eb88efc3a43c437c8845a0d2db94145f9526bdc11ee7ed
+size 107131

+ 3 - 0
voices/longcheng_v3.mp3

@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:31c41940479f8c507c9b2901ee4bac4d8d8c655c13dfdfb2da44d3026fae451c
+size 88891

+ 3 - 0
voices/longdaiyu_v3.mp3

@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:4b3bdacfbdb217f8acc202408824234033364df92f44973e36d81c059ab43981
+size 161371

+ 3 - 0
voices/longfei_v3.mp3

@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:bd3a9f9980c3c39553362887e4547c15e8ac8e09774c0e6a52bc6599d1a8bbfc
+size 86971

Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott