6k6g-v3 / src /worker /task-executor.ts
fanyubo
fix: 更新 buildTag 为 v9-anti-slop-v2 + 清理 Dockerfile
edd35e3
Raw
History Blame Contribute Delete
56.3 kB
/**
* 使命必达执行器 - Mission-Critical Task Executor
*
* ★★★ 核心特性 ★★★
* 1. 智能模型切换:超时自动切换到下一个模型,不等待看门狗
* 2. 速度优先:记录快速模型,下次优先使用
* 3. 无限重试:模型池轮询,直到找到响应快的模型
* 4. 使命必达:绝不放弃,直到任务完成
* 5. ★★★ 任务 3: 模型感知适配 - 自动折叠 System Role、裁剪 Context ★★★
*/
import pLimit from 'p-limit';
import Redis from 'ioredis';
import { ModelDispatcher } from '../lib/model-dispatcher';
import { getModelMeta, TaskPhase, MODEL_POOL } from '../lib/model-registry';
// ★★★ 任务 3: 导入适配器 ★★★
import {
adaptPayload,
sniffErrorAndLearn,
getEffectiveModelMeta,
ChatMessage,
} from '../lib/payload-adapter';
import { getDesignPrompt, getStyledPrompt } from '../lib/design-prompt-loader';
import type { StyleProfile } from '../lib/style-profiles';
import { runOpenGamePipeline } from '../lib/opengame-pipeline';
import { sanitizeErrorMessage } from '../lib/error-sanitizer';
console.log('[STARTUP] task-executor loaded, BUILD: 20260523-v9-ANTI-SLOP-V2');
export type DesignTaskType = 'html_prototype' | 'pitch_deck' | 'ui_design_system';
export type OpenGameTaskType = 'opengame_generation';
export interface TaskParams {
prompt: string;
sections?: number;
style?: StyleProfile;
maxTokens?: number;
draftContent?: string;
skipPhase1?: boolean;
tier?: 'free' | 'starter' | 'pro' | 'enterprise';
taskType?: 'full_plan' | 'sales_manual' | 'world_setting' | 'competitor_analysis' | DesignTaskType | OpenGameTaskType;
}
export interface TaskOutline {
title: string;
sections: Array<{
title: string;
points: string[];
}>;
metadata?: Record<string, any>;
}
export interface TaskResult {
content: string;
outline: TaskOutline;
sectionsGenerated: number;
duration: number;
title?: string; // ★ LLM 生成的文档标题(15字以内)
phases: {
planning: { duration: number; model: string; attempts: number };
expansion: { duration: number; model: string; sections: number; attempts: number };
review: { duration: number; model: string; attempts: number };
};
totalAttempts: number; // 总尝试次数
fastModelsUsed: string[]; // 使用的快速模型列表
outputType?: 'docx' | 'zip' | 'pptx';
fileExtension?: string;
contentType?: string;
}
export interface ProgressReport {
taskId: string;
status: 'planning' | 'writing' | 'reviewing' | 'completed' | 'failed';
progress: number;
phase: string;
detail?: string;
updatedAt: number;
modelSwitch?: string; // 模型切换信息
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// ★★★ 日本服务器极速跳板模式 ★★★
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// ★★★ 任务 1: 使用日本跳板替代直连 NVIDIA ★★★
const PROXY_ENDPOINT = process.env.LLM_PROXY_ENDPOINT || 'https://game.5e1.com/api/internal/llm-proxy';
const PROXY_SECRET = process.env.INTERNAL_API_SECRET || '';
const CONCURRENCY_LIMIT = 2;
// ★★★ 设计任务类型检测 ★★★
const DESIGN_TASK_TYPES: DesignTaskType[] = ['html_prototype', 'pitch_deck', 'ui_design_system'];
function isDesignTask(taskType?: string): taskType is DesignTaskType {
return DESIGN_TASK_TYPES.includes(taskType as DesignTaskType);
}
function getDesignTaskOutputMeta(taskType: DesignTaskType): { outputType: 'zip' | 'pptx'; fileExtension: string; contentType: string } {
if (taskType === 'pitch_deck') {
return {
outputType: 'pptx',
fileExtension: 'pptx',
contentType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
};
}
return {
outputType: 'zip',
fileExtension: 'zip',
contentType: 'application/zip',
};
}
// ★★★ 超时策略:给模型充足生成时间 ★★★
const MODEL_TIMEOUT_MS = 45000; // 45秒总超时(含 TTFB + token 生成,mimo-v2.5 planning 需要 20-30s)
const MAX_MODEL_ATTEMPTS = 5;
const FAST_MODEL_THRESHOLD = 5000;
const SLOW_MODEL_BLACKLIST_MS = 120000;
// Redis Key 用于存储快速模型优先级
const FAST_MODEL_KEY = 'slow_task:fast_models';
export class TaskExecutor {
private dispatcher: ModelDispatcher;
private redis: Redis;
private abortFlag: boolean = false;
private progressCallback: ((report: ProgressReport) => void) | null = null;
// 心跳状态
private lastProgress: number = 0;
private lastPhase: string = 'init';
private lastDetail: string = '';
// AbortController
private abortController: AbortController | null = null;
// ★★★ 新增:快速模型缓存 ★★★
private fastModelCache: Map<string, { avgTtfb: number; lastSuccess: number }> = new Map();
private slowModelBlacklist: Map<string, number> = new Map(); // modelId -> blacklistUntil
constructor(dispatcher: ModelDispatcher, redis: Redis) {
this.dispatcher = dispatcher;
this.redis = redis;
// 加载快速模型缓存
this.loadFastModelCache();
}
/**
* 从 Redis 加载快速模型缓存
*/
private async loadFastModelCache(): Promise<void> {
try {
const data = await this.redis.hgetall(FAST_MODEL_KEY);
if (data) {
for (const [modelId, stats] of Object.entries(data)) {
const parsed = typeof stats === 'string' ? JSON.parse(stats) : stats;
this.fastModelCache.set(modelId, {
avgTtfb: parsed.avgTtfb || 5000,
lastSuccess: parsed.lastSuccess || Date.now()
});
}
console.log(`[MissionExecutor] 📊 加载快速模型缓存: ${this.fastModelCache.size} 个模型`);
}
} catch (err) {
console.warn('[MissionExecutor] 加载快速模型缓存失败:', err);
}
}
/**
* 记录快速模型
*/
private async recordFastModel(modelId: string, ttfb: number): Promise<void> {
if (ttfb < FAST_MODEL_THRESHOLD) {
const existing = this.fastModelCache.get(modelId);
const avgTtfb = existing ? (existing.avgTtfb + ttfb) / 2 : ttfb;
this.fastModelCache.set(modelId, { avgTtfb, lastSuccess: Date.now() });
// 写入 Redis 持久化
try {
await this.redis.hset(FAST_MODEL_KEY, {
[modelId]: JSON.stringify({ avgTtfb, lastSuccess: Date.now() })
});
console.log(`[MissionExecutor] ⚡ 记录快速模型: ${modelId} (avg TTFB: ${avgTtfb}ms)`);
} catch (err) {
console.warn('[MissionExecutor] 记录快速模型失败:', err);
}
}
}
/**
* 检查模型是否在黑名单中
*/
private isModelBlacklisted(modelId: string): boolean {
const blacklistUntil = this.slowModelBlacklist.get(modelId);
if (blacklistUntil && Date.now() < blacklistUntil) {
return true;
}
// 黑名单过期,移除
if (blacklistUntil) {
this.slowModelBlacklist.delete(modelId);
}
return false;
}
/**
* 将慢模型加入临时黑名单
*/
private blacklistSlowModel(modelId: string): void {
this.slowModelBlacklist.set(modelId, Date.now() + SLOW_MODEL_BLACKLIST_MS);
console.log(`[MissionExecutor] 🚫 暂时黑名单慢模型: ${modelId} (${SLOW_MODEL_BLACKLIST_MS / 1000}秒)`);
}
/**
* ★★★ 核心方法:使命必达的模型调用 ★★★
* 特性:
* - 单次超时45秒,超时立即切换下一个模型
* - 每个阶段最多尝试5个模型
* - 记录快速模型,下次优先使用
* - 黑名单慢模型2分钟
*/
private async callNIMMissionCritical(
taskId: string,
phase: TaskPhase,
messages: Array<{ role: string; content: string }>,
maxTokens: number,
systemPrompt?: string
): Promise<{ content: string; modelId: string; ttfb: number; attempts: number }> {
const startTime = Date.now();
let attempts = 0;
let lastError: Error | null = null;
this.dispatcher.setPhase(phase);
// ★★★ 最多尝试 MAX_MODEL_ATTEMPTS 个不同模型 ★★★
while (attempts < MAX_MODEL_ATTEMPTS) {
attempts++;
// 获取下一个可用模型(跳过黑名单)
let modelId: string | null = null;
let tryCount = 0;
const maxTry = 10;
while (!modelId && tryCount < maxTry) {
const candidate = this.dispatcher.getNextModel();
if (!candidate) {
console.warn(`[MissionExecutor] 无可用模型,重置健康状态`);
this.dispatcher.resetAllHealth();
continue;
}
// 检查黑名单
if (!this.isModelBlacklisted(candidate)) {
modelId = candidate;
} else {
console.log(`[MissionExecutor] 跳过黑名单模型: ${candidate}`);
}
tryCount++;
}
if (!modelId) {
// 所有模型都在黑名单,清空黑名单强制使用
this.slowModelBlacklist.clear();
modelId = this.dispatcher.getNextModel();
if (!modelId) {
throw new Error('所有模型池耗尽,无法继续');
}
}
const apiKey = this.dispatcher.getApiKey();
const meta = getModelMeta(modelId);
console.log(`[MissionExecutor] 🎯 第 ${attempts} 次尝试: ${modelId} (${phase}阶段)`);
await this.updateProgress(taskId, this.lastProgress, this.lastPhase,
`${phase}阶段尝试 ${attempts}/${MAX_MODEL_ATTEMPTS}: ${modelId}`);
// ★★★ 单次调用超时45秒 ★★★
const callStartTime = Date.now();
let heartbeatInterval: ReturnType<typeof setInterval> | null = null; // ★★★ 定义在外层,便于 catch 访问 ★★★
try {
// ★★★ 修复:创建 AbortController 用于 fetch 和看门狗 ★★★
this.abortController = new AbortController();
this.abortFlag = false;
// ★★★ 关键修复:使用 AbortSignal.timeout 覆盖整个请求周期 ★★★
// AbortSignal.timeout 会在 fetch + response.json() 整个过程中超时
// 这是 Node.js 18+ 特性,比手动 setTimeout 更可靠
const timeoutSignal = AbortSignal.timeout(MODEL_TIMEOUT_MS);
// 心跳定时器(每10秒更新进度)
heartbeatInterval = setInterval(async () => {
if (this.abortFlag) {
if (heartbeatInterval) clearInterval(heartbeatInterval);
return;
}
const elapsed = Math.floor((Date.now() - callStartTime) / 1000);
await this.updateProgress(taskId, this.lastProgress, this.lastPhase,
`${phase}阶段: ${modelId} 响应中 (${elapsed}秒)`);
}, 10000);
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// ★★★ 任务 3: 模型感知适配 ★★★
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 1. 获取有效模型元数据(合并静态 + 学习数据)
const effectiveMeta = await getEffectiveModelMeta(this.redis, modelId);
// 2. 构建原始消息数组
const rawMessages: ChatMessage[] = systemPrompt
? [{ role: 'system', content: systemPrompt }, ...messages.map(m => ({ role: m.role as 'user' | 'assistant', content: m.content }))]
: messages.map(m => ({ role: m.role as 'user' | 'assistant', content: m.content }));
// 3. 使用适配器自动适配
const adapted = adaptPayload(modelId, rawMessages, { maxTokens });
// 4. 如果有适配行为,打印日志
if (adapted.adapted) {
console.log(`[MissionExecutor] 🔧 ${modelId} 适配: ${adapted.adaptations.join('; ')}`);
await this.updateProgress(taskId, this.lastProgress, this.lastPhase,
`${phase}阶段: ${modelId} 已适配 (${adapted.adaptations.length}项)`);
}
const finalMaxTokens = adapted.maxTokens;
const finalMessages = adapted.messages;
// ★★★ 任务 2: 使用跳板模式 ★★★
// 不再直连 NVIDIA,而是通过日本服务器跳板
console.log(`[MissionExecutor] 🚀 使用跳板: ${PROXY_ENDPOINT}`);
// ★★★ 硬超时:Promise.race 覆盖 fetch + response.json() 整个生命周期 ★★★
// AbortSignal.timeout 只控制连接/TTFB,不控制 token 生成速度
// Promise.race 确保总时间不超过 MODEL_TIMEOUT_MS
const fetchAndParse = async () => {
const response = await fetch(PROXY_ENDPOINT, {
method: 'POST',
headers: {
'Authorization': `Bearer ${PROXY_SECRET}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: modelId,
messages: finalMessages,
max_tokens: finalMaxTokens,
stream: false
}),
signal: timeoutSignal
});
if (!response.ok) {
const errText = await response.text();
throw { status: response.status, body: errText, isHttpError: true };
}
return response.json() as Promise<{
choices?: Array<{ message?: { content?: string; reasoning_content?: string } }>;
_proxy_meta?: { ttfb: number };
}>;
};
const hardTimeout = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error('MODEL_HARD_TIMEOUT')), MODEL_TIMEOUT_MS);
});
const data = await Promise.race([fetchAndParse(), hardTimeout]);
// ★★★ 跳板返回错误处理 + 任务 3: 错误嗅探与学习 ★★★
// (HTTP 错误已在 fetchAndParse 内部抛出)
const content = data.choices?.[0]?.message?.content || '';
if (!content) {
const rLen = data.choices?.[0]?.message?.reasoning_content?.length || 0;
console.warn(`[MissionExecutor] ⚠️ ${modelId} content 为空! reasoning_content=${rLen} tokens, 可能 max_tokens 不足或缺少 system prompt`);
}
const ttfb = data._proxy_meta?.ttfb || (Date.now() - callStartTime);
// ★★★ 清除心跳定时器 ★★★
if (heartbeatInterval) clearInterval(heartbeatInterval);
// ★★★ 成功!记录快速模型 ★★★
await this.recordFastModel(modelId, ttfb);
await this.dispatcher.reportSuccess(modelId, ttfb, content.length / 4, Date.now() - callStartTime);
console.log(`[MissionExecutor] ✅ 成功: ${modelId} TTFB=${ttfb}ms 尝试次数=${attempts}`);
return { content, modelId, ttfb, attempts };
} catch (err: any) {
if (heartbeatInterval) clearInterval(heartbeatInterval);
const elapsed = Date.now() - callStartTime;
// ★★★ 区分超时、HTTP 错误和其他错误 ★★★
const isTimeout = err.message === 'MODEL_HARD_TIMEOUT'
|| err.name === 'AbortError'
|| err.name === 'TimeoutError'
|| elapsed >= MODEL_TIMEOUT_MS - 1000;
if (isTimeout) {
console.warn(`[MissionExecutor] ⏱️ ${modelId} 超时 (${elapsed}ms),加入黑名单,切换下一个`);
this.blacklistSlowModel(modelId);
await this.dispatcher.reportFailure(modelId, 504, 'Timeout');
lastError = new Error(`模型 ${modelId} 超时 (${elapsed}ms)`);
} else if (err.isHttpError) {
// ★★★ HTTP 错误:嗅探学习 ★★★
console.error(`[跳板报错] 模型: ${modelId}, 状态码: ${err.status}, 内容: ${err.body?.slice(0, 200)}`);
const sniffResult = await sniffErrorAndLearn(this.redis, modelId, err.body || '');
if (sniffResult.shouldRetry && attempts < MAX_MODEL_ATTEMPTS) {
console.log(`[MissionExecutor] 🔄 ${modelId} 自愈重试 (已学习适配)`);
attempts--;
if (sniffResult.newParams) maxTokens = sniffResult.newParams.maxTokens;
continue;
}
await this.dispatcher.reportFailure(modelId, err.status || 500, err.body?.slice(0, 100) || 'Unknown');
lastError = new Error(`HTTP ${err.status}: ${(err.body || '').slice(0, 200)}`);
} else {
console.warn(`[MissionExecutor] ❌ ${modelId} 错误: ${err.message}`);
await this.dispatcher.reportFailure(modelId, 500, err.message);
lastError = err;
}
// 继续尝试下一个模型
console.log(`[MissionExecutor] 🔄 切换到下一个模型...`);
}
}
// 所有模型都失败
throw new Error(sanitizeErrorMessage(
`使命必达失败: ${phase}阶段尝试了 ${attempts} 个模型均超时/失败。最后错误: ${lastError?.message}`
));
}
/**
* 执行慢任务完整流程
*/
async execute(taskId: string, params: TaskParams): Promise<TaskResult> {
const startTime = Date.now();
const phases = {
planning: { duration: 0, model: '', attempts: 0 },
expansion: { duration: 0, model: '', sections: 0, attempts: 0 },
review: { duration: 0, model: '', attempts: 0 }
};
let totalAttempts = 0;
const fastModelsUsed: string[] = [];
this.abortController = new AbortController();
this.abortFlag = false;
// ★ OPT-02: 兜底 — 当 prompt 为空但 draftContent 存在时,用 draftContent 作为 prompt
if (!params.prompt && params.draftContent) {
params.prompt = params.draftContent;
console.log(`[TaskExecutor] ⚡ Prompt 为空,使用 draftContent 作为 fallback (${params.prompt.length} chars)`);
}
// ★★★ DRY_RUN 急速测试模式 ★★★
if (params.prompt && params.prompt.includes('[DRY_RUN]')) {
console.log('[MissionExecutor] 🚀 DRY_RUN 模式');
await this.updateProgress(taskId, 50, 'writing', 'DRY_RUN 测试...');
await new Promise(resolve => setTimeout(resolve, 3000));
const fakeMarkdown = `# DRY_RUN 测试文档
任务 ID: ${taskId}
测试时间: ${new Date().toISOString()}
使命必达机制验证成功!`;
await this.redis.set(`slow_task:content:${taskId}`, fakeMarkdown, 'EX', 86400 * 7);
await this.redis.hset('slow_task:results', {
[taskId]: JSON.stringify({
taskId, status: 'completed', progress: 100, phase: 'completed',
detail: 'DRY_RUN 完成', updatedAt: Date.now(),
// downloadUrl 由 processor.ts 上传 COS 后设置
qualityGrade: 'S', sectionsGenerated: 1, duration: 3000
})
});
return {
content: fakeMarkdown,
outline: { title: 'DRY_RUN', sections: [{ title: 'Test', points: ['test'] }] },
sectionsGenerated: 1, duration: 3000, phases,
totalAttempts: 1, fastModelsUsed: ['dry_run'],
title: 'DRY_RUN',
};
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// OpenGame 管线任务
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
if (params.taskType === 'opengame_generation') {
return this.executeOpenGame(taskId, params);
}
try {
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// Phase 1: 结构规划
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
await this.updateProgress(taskId, 5, 'planning', '开始规划大纲...');
const planningStart = Date.now();
let outline: TaskOutline;
let llmTitle = ''; // ★ LLM 生成的文档标题
// ★★★ 修复:draftContent 为空时,fallback 到 prompt(含 chatContext 中的 HTML artifact) ★★★
const draftSource = params.draftContent || '';
// 检测是否为草案模式:有 draftContent,或 prompt 包含 HTML/结构化内容
const hasHtmlContent = params.prompt && (/<h[1-6][^>]*>/i.test(params.prompt) || /<artifact[\s>]/i.test(params.prompt) || /<div[\s>]/i.test(params.prompt));
const isDraftMode = (draftSource.length > 0 && params.skipPhase1) || hasHtmlContent;
const effectiveDraft = draftSource.length > 0 ? draftSource : (hasHtmlContent ? params.prompt : '');
if (isDraftMode && effectiveDraft.length > 0) {
// ★★★ 关键:先清洗 HTML,移除 style/script/head 内容 ★★★
const cleanedContent = this.cleanHtmlContent(effectiveDraft);
// 先尝试结构化提取
outline = this.extractOutlineFromDraft(effectiveDraft);
phases.planning.model = 'draft-anchor';
phases.planning.attempts = 1;
// ★ 诊断数据
const draftDebug: Record<string, any> = {
promptLen: params.prompt?.length || 0,
draftSourceLen: draftSource.length,
effectiveDraftLen: effectiveDraft.length,
cleanedContentLen: cleanedContent.length,
cleanedContentPreview: cleanedContent.slice(0, 200),
hasHtmlContent,
isDraftMode,
extractedSections: outline.sections.length,
extractedTitle: outline.title,
firstSectionTitle: outline.sections[0]?.title || '',
buildTag: 'v9-anti-slop-v2',
llmOutlineAttempted: false,
llmOutlineSuccess: false,
llmTitleAttempted: false,
llmTitleSuccess: false,
};
(phases.planning as any).draftDebug = draftDebug;
// ★★★ 如果结构化提取只得到泛化 fallback,用 LLM 从实际内容生成大纲 ★★★
const isGenericOutline = outline.sections.length === 1 && outline.sections[0].title === '主体内容';
if (isGenericOutline) {
console.log('[MissionExecutor] 结构化提取失败,使用 LLM 从实际内容生成大纲...');
draftDebug.llmOutlineAttempted = true;
try {
const isPaid = params.tier && params.tier !== 'free';
const contentForLLM = cleanedContent.slice(0, 4000);
const outlinePrompt = `你是一位文档分析专家。请分析以下内容,提取核心主题并生成文档大纲。
内容:
${contentForLLM}
严格输出纯 JSON,禁止包含其他文字或代码块标记:
{
"title": "文档标题(15字以内,精准有力,必须体现具体内容主题)",
"sections": [
{ "title": "章节标题", "points": ["要点1", "要点2", "要点3"] }
]
}
要求:
- 章节数量:${isPaid ? '5-8' : '4-6'}
- 每章节 3-5 个要点
- 标题必须反映内容的具体主题,禁止使用"主体内容""概述"等泛化标题`;
const outlineResult = await this.callNIMMissionCritical(
taskId, 'planning',
[{ role: 'user', content: outlinePrompt }],
4000,
'你是一位文档分析专家。只输出纯 JSON,不要任何多余文字。'
);
draftDebug.llmOutlineRaw = outlineResult.content.slice(0, 500);
draftDebug.llmOutlineModel = outlineResult.modelId;
// 安全解析 JSON
const jsonStr = outlineResult.content.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
let parsed: any = null;
try { parsed = JSON.parse(jsonStr); } catch {
const m = jsonStr.match(/\{[\s\S]*\}/);
if (m) { try { parsed = JSON.parse(m[0]); } catch {} }
}
if (parsed && parsed.sections && parsed.sections.length > 0) {
outline = {
title: parsed.title || outline.title,
sections: parsed.sections.map((s: any) => ({
title: s.title || '未命名章节',
points: Array.isArray(s.points) ? s.points : ['详细阐述'],
})),
metadata: { source: 'draft-llm' },
};
llmTitle = parsed.title || '';
draftDebug.llmOutlineSuccess = true;
draftDebug.llmSections = outline.sections.length;
draftDebug.llmOutlineTitle = llmTitle;
console.log('[MissionExecutor] LLM 大纲生成成功:', outline.title, '章节:', outline.sections.length);
} else {
draftDebug.llmOutlineParseError = 'JSON parsed but no sections found';
console.warn('[MissionExecutor] LLM 大纲 JSON 解析成功但无 sections:', JSON.stringify(parsed)?.slice(0, 200));
}
} catch (outlineErr: any) {
draftDebug.llmOutlineError = outlineErr.message?.slice(0, 200);
console.warn('[MissionExecutor] LLM 大纲生成失败:', outlineErr.message);
}
}
// ★★★ 标题生成:如果 LLM 大纲没有产出标题,调用专门的标题生成 ★★★
if (!llmTitle) {
draftDebug.llmTitleAttempted = true;
try {
const titleResult = await this.callNIMMissionCritical(
taskId, 'planning',
[{
role: 'user',
content: `请为以下文档内容生成一个精准有力的标题(15字以内,直接输出标题文字,不要引号或任何标记):\n\n${cleanedContent.slice(0, 1000)}`
}],
2000,
'你是文档标题专家。只输出一个标题,15字以内,精准有力,不要任何多余文字。'
);
const rawTitle = titleResult.content.replace(/["'"「」【】\s\n\r]/g, '').slice(0, 15);
if (rawTitle.length > 0) {
llmTitle = rawTitle;
draftDebug.llmTitleSuccess = true;
draftDebug.llmTitleResult = llmTitle;
draftDebug.llmTitleModel = titleResult.modelId;
console.log('[MissionExecutor] LLM 生成标题(草案模式):', llmTitle);
} else {
draftDebug.llmTitleEmpty = true;
console.warn('[MissionExecutor] LLM 标题返回空');
}
} catch (titleErr: any) {
draftDebug.llmTitleError = titleErr.message?.slice(0, 200);
console.warn('[MissionExecutor] 标题生成失败:', titleErr.message);
}
// ★★★ 最终兜底:从内容首行提取标题 ★★★
if (!llmTitle) {
const firstLine = cleanedContent.split(/[。\n]/).find(l => l.trim().length > 3) || '';
llmTitle = firstLine.replace(/[^a-zA-Z0-9一-鿿·\-| ]/g, '').trim().slice(0, 15);
draftDebug.heuristicTitle = llmTitle;
console.log('[MissionExecutor] 启发式标题:', llmTitle);
}
}
} else {
const isPaid = params.tier && params.tier !== 'free';
const planningSystemPrompt = isPaid
? `你是腾讯/网易级资深系统策划,正在为「大师全案」生成文档大纲。
【反 AI 废话协议】
- 禁止学术名词堆砌(沉没成本/损失厌恶等术语全篇最多出现1次)
- 禁止名人名言、煽情愿景
- 章节标题必须具体有力,禁止"概述""简介""背景"等虚词
- 每个要点必须是可执行的具体动作或可量化的数据指标
大纲格式(严格输出纯 JSON):
{
"title": "文档标题(15字以内,体现具体主题,禁止'完整策划案''大师全案'等泛化词)",
"subtitle": "副标题(20字以内,说明核心价值)",
"sections": [
{ "title": "章节标题", "points": ["要点1", "要点2", "要点3", "要点4", "要点5"] }
]
}
结构要求:
- 章节数量:6-8 个,逻辑递进
- 每章节 4-6 个要点,每个要点必须具体到可执行
- 必须包含的模块(按顺序):
1. 现状诊断(含具体数据指标和问题定位)
2. 核心机制设计(含数据结构、接口定义、边界情况)
3. 用户流程与交互(含客户端-服务端通信流程)
4. 数值与经济系统(含数值表、平衡公式、付费转化节点)
5. 风险与异常处理(含 TOP3 风险 + 具体规避方案)
6. 验收标准(含可测量 KPI 和测试用例)
- 要点示例(必须达到此粒度):
✓ "用户数据表含 userId/giftId/status/purchasedAt 五个核心字段"
✓ "支付回调超时 30s 触发定时补偿任务,24h 内自动对账"
✓ "首充转化率目标 42%,基于沉没成本机制行业基准推导"
✗ "分析用户需求"(太抽象)
✗ "制定运营策略"(太泛化)`
: `你是资深系统策划,正在为「灵感草案」生成文档大纲。
【反 AI 废话协议】禁止学术名词堆砌、名人名言、模糊词。章节标题必须具体。
大纲格式(严格输出纯 JSON):
{
"title": "文档标题(10字以内,体现具体主题,禁止'灵感草案''草案'等泛化词)",
"subtitle": "副标题(简短描述核心方向)",
"sections": [
{ "title": "章节标题", "points": ["要点1", "要点2", "要点3"] }
]
}
结构要求:
- 章节数量:4-5 个,抓住核心
- 每章节 3-4 个要点,每个要点具体可执行
- 必须覆盖:核心机制 → 关键数据/数值 → 执行路径 → 风险与注意事项
- 要点必须有具体数字或可执行动作,禁止泛化罗列
- 标题示例:"独立游戏首充系统设计"(好) vs "变现策略分析"(差)`;
const planningResult = await this.callNIMMissionCritical(
taskId, 'planning',
[{ role: 'user', content: params.prompt }],
4000,
planningSystemPrompt
);
// 解析大纲
try {
const jsonMatch = planningResult.content.match(/\{[\s\S]*\}/);
outline = jsonMatch ? JSON.parse(jsonMatch[0]) : {
title: '生成文档', sections: [
{ title: '概述', points: ['背景介绍', '核心概念'] },
{ title: '主体', points: ['详细分析', '关键要素'] },
{ title: '总结', points: ['核心结论', '建议方案'] }
]
};
} catch {
outline = { title: '生成文档', sections: [{ title: '主体', points: ['内容生成'] }] };
}
// ★★★ 从 LLM 输出的 JSON 中提取 title ★★★
llmTitle = outline.title || '';
console.log('[MissionExecutor] LLM 生成标题(规划模式):', llmTitle);
phases.planning.model = planningResult.modelId;
phases.planning.attempts = planningResult.attempts;
totalAttempts += planningResult.attempts;
if (planningResult.ttfb < FAST_MODEL_THRESHOLD) {
fastModelsUsed.push(planningResult.modelId);
}
}
phases.planning.duration = Date.now() - planningStart;
await this.updateProgress(taskId, 20, 'planning', '大纲生成完成');
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// Phase 2: 分章扩写
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const sections = outline.sections || [];
const expansionStart = Date.now();
const limit = pLimit(CONCURRENCY_LIMIT);
const designTask = isDesignTask(params.taskType);
console.log(`[MissionExecutor] Phase 2: 扩写 ${sections.length} 个章节${designTask ? ' (设计任务)' : ''}`);
// ★★★ 设计任务:一次性完整生成(不拆章节) ★★★
let allSectionContent: string[] = [];
if (designTask) {
const designPrompt = params.style
? getStyledPrompt(params.taskType!, params.style)
: getDesignPrompt(params.taskType!);
await this.updateProgress(taskId, 30, 'writing', '设计任务:一次性生成完整内容...');
// ★★★ 在 user message 中强制重复输出格式要求 ★★★
const formatHint = params.taskType === 'pitch_deck'
? `【关键】你必须使用 <slide title="..."> 标签输出,不要使用 <file> 标签。每个 slide 标签包含一个幻灯片。`
: `【关键】你必须使用 <file name="..."> 标签输出。每个 file 标签包含一个文件。`;
const designResult = await this.callNIMMissionCritical(
taskId, 'expansion',
[{ role: 'user', content: [
`## 任务需求`,
``,
params.prompt,
``,
formatHint,
``,
`请严格按照 system prompt 中的输出格式要求,生成完整的内容。`,
`所有内容必须包含在单个输出中,不要分多次输出。`,
].join('\n') }],
16000,
designPrompt
);
phases.expansion.attempts = designResult.attempts;
totalAttempts += designResult.attempts;
if (designResult.ttfb < FAST_MODEL_THRESHOLD) {
fastModelsUsed.push(designResult.modelId);
}
allSectionContent = [designResult.content];
phases.expansion.sections = 1;
await this.updateProgress(taskId, 80, 'writing', '设计内容生成完成');
}
// ★★★ 非设计任务:分章节扩写 ★★★
const sectionTasks = designTask ? [] : sections.map((section, index) =>
limit(async () => {
if (this.abortFlag) return '';
const startProgress = 20 + Math.floor(index / sections.length * 60);
await this.updateProgress(taskId, startProgress, 'writing',
`章节 ${index + 1}/${sections.length}: ${section.title}`);
const isPaid = params.tier && params.tier !== 'free';
const expansionSystemPrompt = isPaid
? `你是腾讯/网易级资深系统策划,正在撰写「大师全案」。请深度扩写以下章节。
═══════════════════════════════════════
【反 AI 虚空废话协议 — 违反即不合格】
═══════════════════════════════════════
1. 禁止在线打草稿:不得出现「错误!应为」「修正:」「更正:」「实际上应该是」等自我纠错痕迹。一次成型,不回头。
2. 禁止学术名词复读:沉没成本/损失厌恶/认知失调/心理锚点等术语,全章最多提及1次(1句解释即止),此后用「该机制」「此设计」指代。
3. 禁止名人名言:不得引用彼得·德鲁克、乔布斯等人名言,不得使用「我们的使命是」「最危险的不是」等煽情句式。
4. 禁止 QA 自问自答:不得使用「你可能会问」「答案是」等伪对话。
5. 禁止模糊词:「很多」「大量」「显著」「一定」「若干」全部替换为具体数字或百分比。
6. 禁止泛化罗列:不得出现「首先...其次...最后...」八股文结构,不得用「概述」「简介」等虚词作章节标题。
═══════════════════════════════════════
【强制输出 — 涉及系统/功能/机制设计时必须包含】
═══════════════════════════════════════
A. 数据结构表 — 每个核心系统必须给出字段定义:
| 字段名 | 类型 | 说明 | 示例值 |
B. 客户端-服务端流程 — 涉及网络请求时必须给出:
请求: POST /api/xxx → 请求体: {} → 响应: {} → 错误码: 列表
C. 边界情况表 — 每个系统必须列出 3-5 个异常场景:
| 场景 | 触发条件 | 处理方式 |
D. 核心循环 — 用箭头链描述主流程:
步骤A → 步骤B → [条件判断] → 步骤C
═══════════════════════════════════════
输出格式标签(按场景选用,禁止使用未定义标签)
═══════════════════════════════════════
1. 高管摘要(开篇必须使用,结论先行):
<exec_summary>2-3句话概括本章最核心的结论或建议</exec_summary>
2. 数据网格(核心数据/财务指标,最多4个):
<metric_grid>指标 → 数值 | 指标 → 数值 | 指标 → 数值</metric_grid>
3. KPI指标横排(每章最多1次):
<kpi_row>指标 → 数值 | 指标 → 数值</kpi_row>
4. 核心结论框(每章最多1次):
<highlight_box title="核心结论">明确判断 + 数据依据</highlight_box>
5. 操作步骤(执行类章节):
<step_box>1. 步骤名 | 具体操作 + 预期结果 + 时间节点</step_box>
6. 阶段进度:
<phase_bar>第一阶段:xxx | 第二阶段:xxx | 第三阶段:xxx</phase_bar>
7. 左右对比:
<comparison_box left_title="方案A" right_title="方案B">左侧内容 | 右侧内容</comparison_box>
8. 话术演示:
<script_box scene="场景">对话脚本</script_box>
9. 提示框:
<tip_box title="执行建议">具体建议 + 时间 + 责任角色</tip_box>
10. 警告框:
<warn_box>风险描述 + 规避方案</warn_box>
11. 检查清单:
<checklist title="交付标准">[x] 已完成 [ ] 待完成</checklist>
12. 大数字突出:
<number_card label="指标名" value="数值" note="说明">
13. 数据表格(Markdown):
| 维度 | 现状 | 目标 | 差距 |
═══════════════════════════════════════
内容质量标准
═══════════════════════════════════════
✓ 每章 800-1500 字,必须包含至少 1 个数据表格 + 1 个流程描述
✓ 所有数字必须有单位和来源,禁止「显著提升」「一定效果」
✓ 竞品案例必须具名(《原神》《王者荣耀》等),不用「某游戏」「某公司」
✓ 执行建议必须有时间节点(「第1周」「上线后3天」)和责任主体
✓ 涉及系统设计时,必须给出数据结构、接口定义、边界情况
✓ 必须使用 3-5 种不同格式标签
风格:${params.style || '专业严谨,数据驱动,直击要害,像腾讯/网易内部策划案'}`
: `你是资深系统策划,正在撰写「灵感草案」。帮用户快速看清核心问题和可行方向。
═══════════════════════════════════════
【反 AI 虚空废话协议 — 违反即不合格】
═══════════════════════════════════════
1. 禁止自我纠错:不得出现「错误」「修正」「更正」等痕迹。一次成型。
2. 禁止学术名词复读:术语全章最多1次,此后用「该机制」指代。
3. 禁止名人名言和煽情句式。
4. 禁止 QA 自问自答。
5. 禁止模糊词:用具体数字替代「很多」「大量」「显著」。
═══════════════════════════════════════
输出格式标签(精简版,按需选用)
═══════════════════════════════════════
1. 高管摘要(开篇使用):<exec_summary>1-2句核心结论</exec_summary>
2. 核心数据:<metric_grid>指标 → 数值 | 指标 → 数值</metric_grid>
3. 核心结论(每章最多1个):<highlight_box title="核心洞察">关键判断</highlight_box>
4. 注意事项:<warn_box>风险 + 规避方案</warn_box>
5. 行动建议:<step_box>1. 步骤 | 说明</step_box>
6. 对比框:<comparison_box left_title="A" right_title="B">左 | 右</comparison_box>
7. 检查清单:<checklist title="行动">[ ] 待完成</checklist>
═══════════════════════════════════════
内容质量要求
═══════════════════════════════════════
✓ 每章 400-800 字,精炼不啰嗦
✓ 每个要点 2-3 句话说清楚
✓ 至少用 2 种格式标签
✓ 涉及系统设计时给出核心数据结构和边界情况
✓ 给方向的同时给出关键数字(转化率、成本、时间估算)
风格:${params.style || '简洁直接,专业务实,像资深同事给的建议'}`;
// ★★★ 构建扩写用户消息:有草案时作为背景参考 ★★★
const draftReference = params.draftContent
? `\n## 背景参考(草案素材)\n\n⚠️ 以下内容仅作为背景参考和结构依据,不得照搬原文。你的任务是在此基础上重新创作一份专业全案,深度、数据、案例均需大幅扩充。\n\n${params.draftContent.slice(0, 500)}\n`
: '';
const expansionResult = await this.callNIMMissionCritical(
taskId, 'expansion',
[{ role: 'user', content: [
`## 章节任务`,
``,
`章节标题:**${section.title}**`,
``,
`核心要点(需要深度覆盖):`,
section.points.map((p, i) => `${i + 1}. ${p}`).join('\n'),
``,
`原始用户需求(保持相关性):`,
params.prompt.slice(0, 300),
draftReference,
`要求:`,
isPaid
? `- 深度扩写,字数 800-1500字\n- 每个要点都要有数据支撑和具体案例\n- 必须使用至少 3 种格式标签\n- 禁止模糊表达,全部量化`
: `- 精简扩写,字数 400-800字\n- 每个要点 2-3 句话,激发思路\n- 使用 2-3 种格式标签\n- 简洁直接`,
].join('\n') }],
isPaid ? 4000 : 2000,
expansionSystemPrompt
);
const endProgress = 20 + Math.floor((index + 1) / sections.length * 60);
await this.updateProgress(taskId, endProgress, 'writing',
`章节 ${index + 1}/${sections.length}: ${section.title} (完成)`);
phases.expansion.attempts += expansionResult.attempts;
totalAttempts += expansionResult.attempts;
if (expansionResult.ttfb < FAST_MODEL_THRESHOLD) {
fastModelsUsed.push(expansionResult.modelId);
}
return expansionResult.content;
})
);
const sectionResults = designTask ? allSectionContent : await Promise.all(sectionTasks);
const validSections = sectionResults.filter(r => r.length > 0);
phases.expansion.duration = Date.now() - expansionStart;
phases.expansion.sections = validSections.length;
phases.expansion.model = this.dispatcher.getNextModel() || 'multi';
if (!designTask) {
await this.updateProgress(taskId, 80, 'writing', `生成 ${validSections.length} 个章节`);
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// Phase 3: 质量自检(可选,设计任务跳过)
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const fullContent = validSections.join('\n\n---\n\n');
const REVIEW_SKIP_THRESHOLD = 10000;
// ★★★ 设计任务跳过 review(XML 标签会被 review 破坏)★★★
if (designTask) {
console.log(`[MissionExecutor] ⚠️ 设计任务,跳过 Review`);
phases.review.model = 'skipped-design-task';
phases.review.attempts = 0;
} else if (fullContent.length > REVIEW_SKIP_THRESHOLD) {
console.log(`[MissionExecutor] ⚠️ 内容过长(${fullContent.length}),跳过 Review`);
phases.review.model = 'skipped';
phases.review.attempts = 0;
} else {
await this.updateProgress(taskId, 85, 'reviewing', '开始质量审查...');
const reviewStart = Date.now();
try {
// ★★★ 修复:裁剪content到3000字符,maxTokens降到512 ★★★
// 模型maxContext=4096,需要给prompt留足够空间
const reviewContent = fullContent.length > 3000
? fullContent.slice(0, 3000) + '\n...[内容已裁剪,仅审查前半部分]'
: fullContent;
const reviewResult = await this.callNIMMissionCritical(
taskId, 'review',
[{ role: 'user', content: reviewContent }],
1000,
`你是腾讯/网易级文档质量审核编辑。执行以下审查:
【反 AI 废话审查 — 发现即删除/修正】
1. 删除所有自我纠错痕迹(「错误!应为」「修正:」「更正:」「实际上应该是」)
2. 删除名人名言和煽情愿景句式(「我们的使命是」「最危险的不是」)
3. 删除 QA 自问自答(「你可能会问」「答案是」)
4. 学术名词(沉没成本/损失厌恶/认知失调等)若出现超过1次,保留首次,删除后续重复
5. 模糊词(「很多」「大量」「显著」「一定」)替换为具体数字或删除
【内容质量审查】
6. 修正逻辑矛盾或前后不一致的表述
7. 补充遗漏的关键数据占位符(格式:[待补充:XXX数据])
8. 优化过于口语化的表达为专业文档风格
【格式保护】
9. 严禁删除或修改 <tag> 标签(exec_summary, metric_grid, kpi_row, highlight_box, info_box, step_box, phase_bar, comparison_box, script_box, warn_box, tip_box, checklist, number_card)
10. 严禁修改数据表格的 | 格式
11. 输出修正后的完整内容,不加任何说明文字`
);
phases.review.model = reviewResult.modelId;
phases.review.attempts = reviewResult.attempts;
phases.review.duration = Date.now() - reviewStart;
totalAttempts += reviewResult.attempts;
// ★★★ Review结果仅替换审查部分 ★★★
const reviewedContent = reviewResult.content.length > reviewContent.length * 0.5
? reviewResult.content + (fullContent.length > 3000 ? '\n\n' + fullContent.slice(3000) : '')
: fullContent;
await this.redis.set(`slow_task:content:${taskId}`, reviewedContent, 'EX', 86400 * 7);
await this.updateProgress(taskId, 95, 'reviewing', '审查完成');
} catch (reviewErr) {
console.warn(`[MissionExecutor] Review 失败,使用原文:`, reviewErr);
phases.review.model = 'fallback';
phases.review.attempts = 0;
await this.redis.set(`slow_task:content:${taskId}`, fullContent, 'EX', 86400 * 7);
}
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 完成!写入最终状态
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const finalContent = await this.redis.get(`slow_task:content:${taskId}`) || fullContent;
await this.redis.hset('slow_task:results', {
[taskId]: JSON.stringify({
taskId, status: 'completed', progress: 100, phase: 'completed',
detail: `任务完成 (尝试 ${totalAttempts} 次,快速模型: ${fastModelsUsed.length})`,
updatedAt: Date.now(),
// downloadUrl 由 processor.ts 上传 COS 后设置(真实 URL)
qualityGrade: totalAttempts <= 2 ? 'S' : totalAttempts <= 4 ? 'A' : 'B',
sectionsGenerated: validSections.length,
duration: Date.now() - startTime,
phases, totalAttempts, fastModelsUsed
})
});
console.log(`[MissionExecutor] ✅ 任务完成: ${taskId}, 总尝试: ${totalAttempts}, 快速模型: ${fastModelsUsed.join(',')}`);
// ★★★ 设计任务附加输出元数据 ★★★
const designMeta = designTask ? getDesignTaskOutputMeta(params.taskType as DesignTaskType) : {};
return {
content: typeof finalContent === 'string' ? finalContent : JSON.stringify(finalContent),
outline, sectionsGenerated: validSections.length,
duration: Date.now() - startTime, phases, totalAttempts, fastModelsUsed,
title: llmTitle || outline.title || '', // ★ 优先使用 LLM 生成的标题
...designMeta,
};
} catch (err: any) {
const safeMessage = sanitizeErrorMessage(err?.message);
console.error(`[MissionExecutor] ❌ 任务失败: ${err.message}`);
await this.updateProgress(taskId, 0, 'failed', safeMessage);
throw err;
}
}
/**
* 更新进度
*/
private async updateProgress(taskId: string, progress: number, phase: string, detail?: string): Promise<void> {
this.lastProgress = progress;
this.lastPhase = phase;
this.lastDetail = detail || '';
const status = progress === 100 ? 'completed' : progress === 0 ? 'failed' : 'writing';
// ★★★ 合并现有状态,避免覆盖 processor.ts 设置的 downloadUrl 等字段 ★★★
try {
const existing = await this.redis.hget('slow_task:results', taskId);
const prevState = existing ? (typeof existing === 'string' ? JSON.parse(existing) : existing) : {};
const report: ProgressReport = {
...prevState,
taskId,
status,
progress, phase, detail: detail || '', updatedAt: Date.now()
};
await this.redis.hset('slow_task:results', { [taskId]: JSON.stringify(report) });
if (this.progressCallback) {
this.progressCallback(report);
}
} catch {
// fallback: 直接写入(不合并)
const report: ProgressReport = {
taskId,
status,
progress, phase, detail: detail || '', updatedAt: Date.now()
};
await this.redis.hset('slow_task:results', { [taskId]: JSON.stringify(report) });
if (this.progressCallback) {
this.progressCallback(report);
}
}
}
/**
* 注册进度回调
*/
onProgress(callback: (report: ProgressReport) => void): void {
this.progressCallback = callback;
}
/**
* 中止任务
*/
abort(): void {
this.abortFlag = true;
if (this.abortController) {
this.abortController.abort();
console.warn('[MissionExecutor] 🛑 AbortController.abort() 已调用');
}
}
isAborted(): boolean {
return this.abortFlag;
}
reset(): void {
this.abortFlag = false;
this.progressCallback = null;
this.slowModelBlacklist.clear();
}
/**
* 执行 OpenGame 管线任务
*/
private async executeOpenGame(taskId: string, params: TaskParams): Promise<TaskResult> {
const startTime = Date.now();
const outputDir = `/tmp/opengame/${taskId}`;
try {
const result = await runOpenGamePipeline({
prompt: params.prompt,
style: typeof params.style === 'string' ? params.style : params.style?.name,
complexity: 'medium',
tier: params.tier as 'free' | 'pro' | 'enterprise' | undefined,
outputDir,
onProgress: (report) => {
this.updateProgress(taskId, report.progress, report.stage, report.detail).catch(() => {});
},
});
const duration = Date.now() - startTime;
const content = result.code.files.map(f => `// === ${f.path} ===\n${f.content}`).join('\n\n');
// 写入 Redis
await this.redis.set(`slow_task:content:${taskId}`, content, 'EX', 86400 * 7);
await this.redis.hset('slow_task:results', {
[taskId]: JSON.stringify({
taskId,
status: 'completed',
progress: 100,
phase: 'completed',
detail: `OpenGame 完成: ${result.filesWritten.length} 个文件, 验证 ${result.verification.passed ? '通过' : '未通过'}`,
updatedAt: Date.now(),
qualityGrade: result.verification.passed ? 'A' : 'B',
}),
});
return {
content,
outline: {
title: result.gdd.metadata.title,
sections: result.gdd.entities.map(e => ({ title: e.name, points: e.behaviors })),
},
sectionsGenerated: result.code.files.length,
duration,
phases: {
planning: { duration: 0, model: 'pipeline', attempts: 1 },
expansion: { duration: 0, model: 'pipeline', sections: result.code.files.length, attempts: 1 },
review: { duration: 0, model: 'pipeline', attempts: result.verification.rounds },
},
totalAttempts: result.verification.rounds,
fastModelsUsed: ['opengame-pipeline'],
outputType: 'zip',
fileExtension: 'zip',
contentType: 'application/zip',
};
} catch (err: any) {
const safeMessage = sanitizeErrorMessage(err?.message);
await this.updateProgress(taskId, 0, 'failed', safeMessage);
throw err;
}
}
/**
* 清洗 HTML 内容:移除 style/script/head 标签及其内容,再剥离剩余标签
*/
private cleanHtmlContent(html: string): string {
return html
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<head[^>]*>[\s\S]*<\/head>/gi, '')
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/<[^>]*>/g, ' ')
.replace(/&[a-z]+;/gi, ' ')
.replace(/\s+/g, ' ')
.trim();
}
/**
* 从草案提取大纲
*/
private extractOutlineFromDraft(draft: string): TaskOutline {
const sections: Array<{ title: string; points: string[] }> = [];
// ★★★ 关键修复:先移除 style/script/head 标签内容,再提取标题 ★★★
const cleanedForStructure = draft
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<head[^>]*>[\s\S]*<\/head>/gi, '')
.replace(/<!--[\s\S]*?-->/g, '');
const mdHeadingRegex = /^##\s+(.+)$/gm;
let mdMatch;
while ((mdMatch = mdHeadingRegex.exec(cleanedForStructure)) !== null) {
sections.push({ title: mdMatch[1].trim(), points: ['延续草案风格'] });
}
if (sections.length === 0) {
const htmlHeadingRegex = /<h2[^>]*>([\s\S]*?)<\/h2>/gi;
let htmlMatch;
while ((htmlMatch = htmlHeadingRegex.exec(cleanedForStructure)) !== null) {
sections.push({ title: htmlMatch[1].replace(/<[^>]*>/g, '').trim(), points: ['延续草案风格'] });
}
}
if (sections.length === 0) {
sections.push({ title: '主体内容', points: ['详细阐述'] });
}
const titleMatch = cleanedForStructure.match(/^#\s+(.+)$/m) || cleanedForStructure.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i);
const docTitle = titleMatch ? titleMatch[1].replace(/<[^>]*>/g, '').trim() : '策划方案';
return { title: docTitle, sections, metadata: { source: 'draft' } };
}
}
export function createTaskExecutor(dispatcher: ModelDispatcher, redis: Redis): TaskExecutor {
return new TaskExecutor(dispatcher, redis);
}