agens-studio 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,567 @@
1
+ import { config } from '../../config.js';
2
+ import { getRelevantSamples, getGlobalPrefs, getCorrections } from './promptMemory.js';
3
+
4
+ /**
5
+ * mimo 大模型客户端
6
+ * ------------------------------------------------------------------
7
+ * 用于提示词优化。OpenAI 兼容协议,鉴权 header 为 api-key。
8
+ *
9
+ * 核心方法:optimizePrompt
10
+ * - 把用户简短的中文描述扩写成高质量中文提示词
11
+ * - 从记忆库取相关高分样本 + 全局偏好,作为 few-shot 示例
12
+ * - 让模型输出纯中文文本,不解释
13
+ * ------------------------------------------------------------------
14
+ */
15
+
16
+ const MODE_LABELS = {
17
+ image: '文生图',
18
+ img2img: '图生图',
19
+ text2video: '文生视频',
20
+ image2video: '图生视频',
21
+ multi2video: '多图生视频',
22
+ keyframe: '关键帧动画',
23
+ };
24
+
25
+ /**
26
+ * 调用 mimo chat completions
27
+ * @param {string} system
28
+ * @param {string} user
29
+ * @returns {Promise<string>} 模型回复文本
30
+ */
31
+ async function chat(system, user) {
32
+ const cfg = config.mimo;
33
+ const res = await fetch(`${cfg.baseUrl}${cfg.endpoint}`, {
34
+ method: 'POST',
35
+ headers: {
36
+ 'Content-Type': 'application/json',
37
+ // 注意:mimo 用 api-key header,不是 Authorization
38
+ 'api-key': cfg.apiKey,
39
+ },
40
+ body: JSON.stringify({
41
+ model: cfg.model,
42
+ messages: [
43
+ { role: 'system', content: system },
44
+ { role: 'user', content: user },
45
+ ],
46
+ max_completion_tokens: cfg.maxTokens,
47
+ temperature: cfg.temperature,
48
+ top_p: cfg.topP,
49
+ stream: false,
50
+ stop: null,
51
+ frequency_penalty: 0,
52
+ presence_penalty: 0,
53
+ }),
54
+ });
55
+ const text = await res.text();
56
+ if (!res.ok) {
57
+ throw new Error(`mimo 调用失败 (${res.status}): ${text.slice(0, 300)}`);
58
+ }
59
+ let data;
60
+ try { data = JSON.parse(text); } catch { throw new Error('mimo 响应不是有效 JSON'); }
61
+ const content = data.choices?.[0]?.message?.content;
62
+ if (!content) throw new Error('mimo 响应未包含内容');
63
+ return content.trim();
64
+ }
65
+
66
+ /**
67
+ * 优化正向提示词(中文 → 中文)
68
+ * 文生图/图生图/视频通用入口;图生图时传 imageDescription(识图结果)
69
+ */
70
+ export async function optimizePrompt({ text, mode, extraHints, imageDescription }) {
71
+ return optimize({ text, mode, extraHints, kind: 'positive', imageDescription });
72
+ }
73
+
74
+ /**
75
+ * 优化反向提示词(描述「不想要的内容」,中文 → 中文)
76
+ */
77
+ export async function optimizeNegativePrompt({ text, mode, positive }) {
78
+ return optimize({ text, mode, kind: 'negative', positive });
79
+ }
80
+
81
+ /**
82
+ * 重写提示词(层级2:用户带着具体修改指令重新优化)
83
+ * @param {object} input
84
+ * @param {string} input.text 用户原始输入
85
+ * @param {string} input.mode 生成模式
86
+ * @param {string} input.lastResult 上一次的优化结果
87
+ * @param {string} input.instruction 用户的修改指令(如「更简洁」「不要镜头运动」)
88
+ * @param {boolean} [input.isNegative] 是否反向提示词的重写
89
+ * @param {string} [input.positive] 反向重写时的正向参考
90
+ * @param {string} [input.imageDescription] 图生图重写时的识图结果(保持 [保留] 有依据)
91
+ */
92
+ export async function rewritePrompt({ text, mode, lastResult, instruction, isNegative, positive, imageDescription }) {
93
+ return optimize({
94
+ text,
95
+ mode,
96
+ kind: isNegative ? 'negative' : 'positive',
97
+ positive,
98
+ rewriteInstruction: instruction,
99
+ lastResult,
100
+ imageDescription,
101
+ });
102
+ }
103
+
104
+ /**
105
+ * 内部通用优化函数(正向/反向共用调用逻辑,差异在 system prompt)
106
+ * @param {string} [rewriteInstruction] 重写指令(层级2:用户的具体修改要求)
107
+ * @param {string} [lastResult] 上一次的优化结果(重写时参考)
108
+ * @param {string} [imageDescription] 图生图时的识图结果(让模型知道原图内容)
109
+ */
110
+ async function optimize({ text, mode, extraHints, kind, positive, rewriteInstruction, lastResult, imageDescription }) {
111
+ const isNegative = kind === 'negative';
112
+ const modeLabel = MODE_LABELS[mode] || '通用';
113
+ const prefs = await getGlobalPrefs(kind);
114
+ const samples = await getRelevantSamples(text, mode, config.promptMemory.feedSamples, kind);
115
+
116
+ // ---------- 构造 system prompt ----------
117
+ const systemParts = isNegative ? buildNegativeSystem() : buildPositiveSystem(mode);
118
+
119
+ // 全局偏好
120
+ if (prefs.favoriteStyle || (prefs.commonKeywords && prefs.commonKeywords.length)) {
121
+ systemParts.push('', isNegative
122
+ ? '【该用户常需排除的元素】(请参考用户历史,补充类似负面要素)'
123
+ : '【该用户的历史偏好】(请在优化时自然融入,不要生硬堆砌)');
124
+ if (prefs.favoriteStyle) systemParts.push(`- ${isNegative ? '常排除' : '偏好风格'}:${prefs.favoriteStyle}`);
125
+ if (prefs.commonKeywords.length) systemParts.push(`- ${isNegative ? '常见负面' : '常用'}元素:${prefs.commonKeywords.join('、')}`);
126
+ }
127
+
128
+ // ---------- 构造 user prompt(含 few-shot 示例)----------
129
+ const userParts = [];
130
+
131
+ if (samples.length) {
132
+ userParts.push(isNegative
133
+ ? '【历史优秀反向样本】(用户认可的反向写法,请参考其覆盖的负面维度)'
134
+ : '【历史优秀样本】(用户认可的写法,请参考其详细程度和风格)');
135
+ samples.forEach((s, i) => {
136
+ userParts.push(`示例${i + 1}(${MODE_LABELS[s.mode] || ''}):`);
137
+ userParts.push(`原:${s.original}`);
138
+ userParts.push(`优:${s.optimized}`);
139
+ userParts.push('');
140
+ });
141
+ }
142
+
143
+ // 修正要求(点踩原因 + 重写指令)—— 告诉模型这类主题要避免什么
144
+ const corrections = await getCorrections(text, mode, 3, kind);
145
+ if (corrections.length) {
146
+ userParts.push('【用户曾反馈的修正要求】(请务必避免同类问题)');
147
+ corrections.forEach((c) => {
148
+ if (c.correction) {
149
+ userParts.push(`- 优化「${c.original}」时用户不满意:${c.correction}`);
150
+ }
151
+ c.rewrites.forEach((rw) => {
152
+ if (rw) userParts.push(`- 用户曾要求改写「${c.original}」:${rw}`);
153
+ });
154
+ });
155
+ userParts.push('');
156
+ }
157
+
158
+ // 重写指令(本次如果是重写,带上具体要求)
159
+ if (rewriteInstruction) {
160
+ userParts.push('【本次是重写】用户对上一次结果不满意,明确提出以下要求,请严格遵循:');
161
+ userParts.push(rewriteInstruction);
162
+ if (lastResult) {
163
+ userParts.push(`(上一次的结果是:${lastResult},请在此基础上修改)`);
164
+ }
165
+ userParts.push('');
166
+ }
167
+
168
+ // 图生图识图结果(让模型知道原图内容,用于"保留"结构)
169
+ if (!isNegative && imageDescription) {
170
+ userParts.push('【用户上传的原图识别结果】(图生图必须基于此,[保留]标签要引用这里的元素)');
171
+ userParts.push(imageDescription);
172
+ userParts.push('');
173
+ }
174
+
175
+ userParts.push(`【当前任务】`);
176
+ userParts.push(`生成模式:${modeLabel}`);
177
+ if (isNegative && positive) {
178
+ userParts.push(`正向提示词(参考主题,反向要与之相关但不重复):${positive}`);
179
+ }
180
+ if (!isNegative && extraHints && extraHints.length) {
181
+ userParts.push(`用户额外要求:${extraHints.join(';')}`);
182
+ }
183
+ userParts.push('');
184
+ userParts.push(isNegative
185
+ ? `请把下面这条反向描述优化成完整的反向提示词:`
186
+ : `请优化下面这条提示词:`);
187
+ userParts.push(text);
188
+
189
+ const system = systemParts.join('\n');
190
+ const user = userParts.join('\n');
191
+
192
+ let optimized = await chat(system, user);
193
+ optimized = cleanOutput(optimized);
194
+
195
+ // 带维度标签的模式(生图 + 视频 4 种):解析标签分维度展示
196
+ // 反向提示词:纯文本输出(cleanOutput 已处理)
197
+ const useTags = !isNegative && [
198
+ 'image', 'img2img', 'text2video', 'image2video', 'multi2video', 'keyframe',
199
+ ].includes(mode);
200
+ if (useTags) {
201
+ // 尝试解析成维度对象;若模型没按标签输出,降级为整段文本
202
+ const dims = parseDimensionTags(optimized, mode);
203
+ return { optimized, dimensions: dims };
204
+ }
205
+ return { optimized };
206
+ }
207
+
208
+ /**
209
+ * 解析带维度标签的输出(通用:支持任意标签集)
210
+ * 文生图:[主体][场景][风格][光照][镜头][构图][细节密度][质量]
211
+ * 图生图:[修改][新风格][增删][保留]
212
+ * 文生视频:[主体][动作][场景][镜头][光照][风格]
213
+ * 图生视频:[运动][稳定]
214
+ * 多图生视频:[关系][过渡][风格]
215
+ * 关键帧动画:[过渡][保持][运动]
216
+ * 没匹配到标签则返回 null(前端按纯文本展示)
217
+ *
218
+ * 返回的 key 是中文标签原文,前端 DIM_LABELS 做展示映射。
219
+ */
220
+ function parseDimensionTags(text, mode) {
221
+ const result = {};
222
+ // 用正则按 [标签] 切分
223
+ const re = /\[([^\]]+)\]/g;
224
+ const splits = [];
225
+ let m;
226
+ while ((m = re.exec(text)) !== null) {
227
+ splits.push({ tag: m[1].trim(), start: m.index, end: re.lastIndex });
228
+ }
229
+ if (splits.length === 0) return null;
230
+ for (let i = 0; i < splits.length; i++) {
231
+ const s = splits[i];
232
+ const contentEnd = i + 1 < splits.length ? splits[i + 1].start : text.length;
233
+ const content = text.slice(s.end, contentEnd).trim();
234
+ if (content) result[s.tag] = content; // key 用中文标签原文
235
+ }
236
+ // 至少匹配到 2 个维度才算成功,否则降级
237
+ if (Object.keys(result).length < 2) return null;
238
+ return result;
239
+ }
240
+
241
+ /**
242
+ * 把维度对象拼成纯文本(去掉标签),传给 agens 用
243
+ */
244
+ export function dimensionsToText(dims) {
245
+ if (!dims) return '';
246
+ return Object.values(dims).filter(Boolean).join(',');
247
+ }
248
+
249
+ /**
250
+ * 生成多个候选提示词(不同风格方向)
251
+ * 一次让 mimo 输出 3 个候选,每个用 ===候选N:方向名=== 分隔
252
+ * @returns {Promise<{candidates: [{label, optimized, dimensions}]}>}
253
+ */
254
+ export async function generateCandidates({ text, mode, imageDescription }) {
255
+ // 复用 optimize 的 system prompt,但要求输出 3 个不同方向
256
+ const baseSystem = buildPositiveSystem(mode);
257
+ const system = [
258
+ ...baseSystem,
259
+ '',
260
+ '【本次特殊要求 - 生成 3 个候选】',
261
+ '请生成 3 个风格方向不同的候选提示词,用以下格式严格分隔:',
262
+ '===候选1:方向名===',
263
+ '(该方向的维度标签提示词)',
264
+ '===候选2:方向名===',
265
+ '(该方向的维度标签提示词)',
266
+ '===候选3:方向名===',
267
+ '(该方向的维度标签提示词)',
268
+ '',
269
+ '3 个候选必须在视觉风格/氛围/构图上有明显差异(如写实电影感 / 梦幻艺术感 / 极简氛围)。',
270
+ '每个候选都要完整覆盖该模式的所有维度标签。',
271
+ ].join('\n');
272
+
273
+ // 构造 user prompt
274
+ const userParts = [];
275
+ if (imageDescription) {
276
+ userParts.push('【用户上传的原图识别结果】');
277
+ userParts.push(imageDescription);
278
+ userParts.push('');
279
+ }
280
+ userParts.push('请基于以下输入生成 3 个候选提示词:');
281
+ userParts.push(text);
282
+
283
+ const raw = await chat(system, userParts.join('\n'));
284
+
285
+ // 解析 3 个候选
286
+ const candidates = parseCandidates(raw, mode);
287
+ // 兜底:如果没解析出候选,至少保证有内容
288
+ if (candidates.length === 0) {
289
+ const dims = parseDimensionTags(raw, mode);
290
+ candidates.push({ label: '默认', optimized: cleanOutput(raw), dimensions: dims });
291
+ }
292
+ return { candidates };
293
+ }
294
+
295
+ /** 解析多候选输出 */
296
+ function parseCandidates(raw, mode) {
297
+ const blocks = raw.split(/===候选\d+[::]/);
298
+ const labels = raw.match(/===候选\d+[::][^=\n]+/g) || [];
299
+ const result = [];
300
+ for (let i = 1; i < blocks.length; i++) { // i=1 跳过 split 第一个空块
301
+ const content = blocks[i].trim();
302
+ if (!content || content.length < 10) continue;
303
+ // 提取方向名
304
+ let label = `候选${result.length + 1}`;
305
+ if (labels[result.length]) {
306
+ const m = labels[result.length].match(/[::]([^=\n]+)/);
307
+ if (m) label = m[1].replace(/===.*/, '').trim();
308
+ }
309
+ const dims = parseDimensionTags(content, mode);
310
+ const plainText = cleanOutput(content);
311
+ result.push({ label, optimized: plainText, dimensions: dims });
312
+ }
313
+ return result.slice(0, 3);
314
+ }
315
+
316
+ /** 正向 system prompt 构造(按模式拆分:文生图/图生图/视频) */
317
+ function buildPositiveSystem(mode) {
318
+ if (mode === 'image') return buildText2ImageSystem();
319
+ if (mode === 'img2img') return buildImage2ImageSystem();
320
+ if (mode === 'text2video') return buildText2VideoSystem();
321
+ if (mode === 'image2video') return buildImage2VideoSystem();
322
+ if (mode === 'multi2video') return buildMulti2VideoSystem();
323
+ if (mode === 'keyframe') return buildKeyframeSystem();
324
+ return buildText2VideoSystem(); // 兜底
325
+ }
326
+
327
+ /**
328
+ * 文生图 system prompt:严格覆盖官方推荐的 8 维度,带维度标签输出
329
+ * Agnes Image 2.1 Flash 对高信息密度画面有优化,默认强调。
330
+ */
331
+ function buildText2ImageSystem() {
332
+ return [
333
+ '你是专业的 AI 绘画提示词工程师,擅长把简短的中文想法扩写成高质量、细节丰富的中文提示词,用于 Agnes Image 生图模型。',
334
+ '',
335
+ '【必须覆盖以下 8 个维度,缺一不可】',
336
+ '1. 主体:画面核心对象(是谁/是什么,什么状态/动作)',
337
+ '2. 场景/环境:发生在哪里,背景氛围',
338
+ '3. 视觉风格:写实/动漫/3D/艺术/电影感等',
339
+ '4. 光照:光线类型、方向、色温(如柔和金光、戏剧性侧光)',
340
+ '5. 镜头角度:俯视/平视/仰视/特写',
341
+ '6. 构图:广角/居中/三分法/对称',
342
+ '7. 细节密度:丰富细节、高信息密度(生图模型对此优化过,务必体现)',
343
+ '8. 质量要求:超清、8K、高细节',
344
+ '',
345
+ '【输出格式 - 极其重要】',
346
+ '每个维度用「[维度名]内容」的标签格式输出,全部写在一行或换行均可,例如:',
347
+ '[主体]一座发光的悬浮城市[场景]悬于雾气弥漫的峡谷之上,日出时分[风格]电影级写实[光照]柔和金色晨光[镜头]高空俯瞰广角[构图]对称构图,主体居中[细节密度]丰富的建筑细节,高信息密度[质量]8K超清,高细节',
348
+ '',
349
+ '【硬性要求】',
350
+ '1. 必须输出全部 8 个标签,顺序不限但缺一不可',
351
+ '2. 必须中文,描述具体有画面感,禁止空泛词(如「很美」)',
352
+ '3. 禁止任何前言、后记、解释、markdown 格式',
353
+ '4. 第一项就必须是 [主体] 标签',
354
+ ];
355
+ }
356
+
357
+ /**
358
+ * 图生图 system prompt:采用「修改要求 + 新风格 + 增删元素 + 保留元素」结构
359
+ * 配合识图结果,明确告诉模型改什么、保什么。
360
+ */
361
+ function buildImage2ImageSystem() {
362
+ return [
363
+ '你是专业的 AI 图生图提示词工程师,负责把用户的修改意图转化为高质量中文提示词,用于基于原图的图像编辑。',
364
+ '',
365
+ '【图生图的核心原则】',
366
+ '图生图必须同时说清「要改什么」和「要保留什么」。如果只说改不说留,模型会丢失原图结构。',
367
+ '',
368
+ '【必须采用以下 4 个标签结构输出】',
369
+ '[修改] 要改变什么(如转成赛博朋克夜景)',
370
+ '[新风格] 目标视觉风格(如赛博朋克电影感)',
371
+ '[增删] 需要添加或移除的元素(如添加霓虹灯、移除行人)',
372
+ '[保留] 必须保持不变的元素(如人物姿势、构图、相机角度)',
373
+ '',
374
+ '输出示例:',
375
+ '[修改]将白天街景转为赛博朋克夜景,加霓虹灯和湿润路面反光[新风格]赛博朋克电影感,冷色调[增删]添加霓虹招牌、全息广告;移除阳光直射[保留]保持原街道布局、建筑形状、人物位置和相机角度',
376
+ '',
377
+ '【硬性要求】',
378
+ '1. 必须输出全部 4 个标签',
379
+ '2. [保留] 标签极其重要,必须基于识图结果明确写出要保留的元素',
380
+ '3. 必须中文,禁止前言、后记、解释、markdown',
381
+ '4. 第一项必须是 [修改] 标签',
382
+ ];
383
+ }
384
+
385
+ /**
386
+ * 视频正向 system prompt(文生视频/图生视频等,保持原有逻辑)
387
+ */
388
+ /** 文生视频 system prompt:6 维度(主体+动作+场景+镜头+光照+风格) */
389
+ function buildText2VideoSystem() {
390
+ return [
391
+ '你是专业的 AI 视频提示词工程师,擅长把简短的中文想法扩写成高质量中文提示词,用于 Agnes Video 文生视频。',
392
+ '',
393
+ '【必须覆盖以下 6 个维度,缺一不可】',
394
+ '1. 主体:画面核心对象(是谁/是什么,什么状态)',
395
+ '2. 动作:主体的动作/运动(视频必须有动态)',
396
+ '3. 场景:环境背景、氛围',
397
+ '4. 镜头:镜头运动方式(跟踪/平移/推进/固定/环绕)',
398
+ '5. 光照:光线类型、方向、色温',
399
+ '6. 风格:视觉风格(写实/电影感/动漫等)',
400
+ '',
401
+ '【输出格式 - 极其重要】',
402
+ '每个维度用「[维度名]内容」的标签格式输出,例如:',
403
+ '[主体]一位年轻宇航员在红色沙漠星球上行走[动作]缓缓前行,风吹起尘埃飘动[场景]广袤红色沙漠,远处有岩石地貌[镜头]缓慢的电影级跟踪镜头[光照]戏剧性夕阳侧光[风格]写实科幻风格',
404
+ '',
405
+ '【硬性要求】',
406
+ '1. 必须输出全部 6 个标签',
407
+ '2. 必须中文,[动作]和[镜头]是视频核心,务必具体',
408
+ '3. 禁止前言、后记、解释、markdown',
409
+ '4. 第一项必须是 [主体] 标签',
410
+ ];
411
+ }
412
+
413
+ /** 图生视频 system prompt:动+稳(哪些动、哪些保持稳定) */
414
+ function buildImage2VideoSystem() {
415
+ return [
416
+ '你是专业的 AI 图生视频提示词工程师,负责把用户的动画意图转化为高质量中文提示词,用于基于原图生成动态视频。',
417
+ '',
418
+ '【图生视频核心原则】',
419
+ '图生视频必须同时说清「哪些内容需要运动」和「哪些主体元素需要保持稳定」。只说动不说稳,主体会变形。',
420
+ '',
421
+ '【必须采用以下 2 个标签结构输出】',
422
+ '[运动] 哪些内容需要动(如:头发轻摆、呼吸起伏、背景灯光闪烁、水流)',
423
+ '[稳定] 哪些主体元素必须保持稳定(基于识图:人物面部、服装、姿势、构图)',
424
+ '',
425
+ '输出示例:',
426
+ '[运动]人物轻微呼吸起伏,头发随风轻摆,背景灯光柔和闪烁,裙摆微微飘动[稳定]保持人物面部表情、服装样式、站立姿势和构图位置不变,主体身份一致',
427
+ '',
428
+ '【硬性要求】',
429
+ '1. 必须输出 [运动] 和 [稳定] 两个标签',
430
+ '2. [稳定] 极其重要,必须基于识图结果明确写出要稳定的元素',
431
+ '3. 必须中文,禁止前言、后记、解释、markdown',
432
+ '4. 第一项必须是 [运动] 标签',
433
+ ];
434
+ }
435
+
436
+ /** 多图生视频 system prompt:关系+过渡+风格 */
437
+ function buildMulti2VideoSystem() {
438
+ return [
439
+ '你是专业的 AI 多图生视频提示词工程师,负责基于多张输入图片生成过渡视频的提示词。',
440
+ '',
441
+ '【多图生视频核心原则】',
442
+ '必须描述输入图片之间的关系,以及画面如何过渡演变。',
443
+ '',
444
+ '【必须采用以下 3 个标签结构输出】',
445
+ '[关系] 输入图片之间的关系(如:第一张为起点场景,第二张为目标场景)',
446
+ '[过渡] 画面如何演变(平滑变换、渐变、蒙太奇、时间推移)',
447
+ '[风格] 统一风格保持一致(光照、色调、视觉风格)',
448
+ '',
449
+ '输出示例:',
450
+ '[关系]第一张图为起始的白天街景,第二张图为目标场景的夜晚霓虹街景[过渡]从白天平滑过渡到夜晚,光影逐渐变化,行人车流自然流动,镜头缓慢推进[风格]电影级写实,保持整体色调一致,自然运动节奏',
451
+ '',
452
+ '【硬性要求】',
453
+ '1. 必须输出 [关系]、[过渡]、[风格] 三个标签',
454
+ '2. [过渡] 是核心,务必描述具体的演变过程',
455
+ '3. 必须中文,禁止前言、后记、解释、markdown',
456
+ '4. 第一项必须是 [关系] 标签',
457
+ ];
458
+ }
459
+
460
+ /** 关键帧动画 system prompt:过渡+保持+运动 */
461
+ function buildKeyframeSystem() {
462
+ return [
463
+ '你是专业的 AI 关键帧动画提示词工程师,负责在多个关键帧之间生成平滑过渡的提示词。',
464
+ '',
465
+ '【关键帧动画核心原则】',
466
+ '必须清晰描述关键帧之间的过渡关系,明确保持一致的元素和过渡中的运动。',
467
+ '',
468
+ '【必须采用以下 3 个标签结构输出】',
469
+ '[过渡] 从首帧到尾帧的过渡过程描述(如何演变)',
470
+ '[保持] 需要保持一致的元素(角色身份、相机角度、风格)',
471
+ '[运动] 过渡中的自然运动(如人物动作、镜头移动)',
472
+ '',
473
+ '输出示例:',
474
+ '[过渡]从首帧的清晨阳光场景平滑过渡到尾帧的黄昏暮色场景,光影随时间自然变化[保持]保持角色身份一致、相机角度固定、整体视觉风格统一[运动]人物缓缓转头,镜头轻微推进,云层缓慢飘动',
475
+ '',
476
+ '【硬性要求】',
477
+ '1. 必须输出 [过渡]、[保持]、[运动] 三个标签',
478
+ '2. [保持] 必须基于识图结果,明确写出首尾帧的共同元素',
479
+ '3. 必须中文,禁止前言、后记、解释、markdown',
480
+ '4. 第一项必须是 [过渡] 标签',
481
+ ];
482
+ }
483
+
484
+ /** 反向 system prompt 构造 */
485
+ function buildNegativeSystem() {
486
+ return [
487
+ '你是专业的 AI 创作反向提示词工程师,专门为 AI 绘画和视频生成服务,描述「画面中不希望出现的元素和缺陷」。',
488
+ '',
489
+ '【反向提示词的作用】',
490
+ '反向提示词用于告诉模型「不要生成什么」,用来规避画质问题、不符合主题的元素、版权风险等。',
491
+ '',
492
+ '【覆盖维度 - 尽量全面】',
493
+ '1. 画质缺陷:模糊、低分辨率、噪点、压缩痕迹、像素化',
494
+ '2. 结构错误:人体结构畸形、多余的手指/肢体、面部扭曲、不对称、透视错误',
495
+ '3. 文字水印:水印、logo、签名、文字、字幕、边框',
496
+ '4. 风格偏差:卡通化(当需要写实)、低质量渲染、塑料感、过度修饰',
497
+ '5. 主题相关:根据用户主题排除不合适的元素',
498
+ '6. 构图问题:构图失衡、主体偏移、裁切不当、空白过多',
499
+ '',
500
+ '【输出要求 - 极其重要】',
501
+ '1. 必须输出中文,用逗号分隔的短语形式(不是完整句子)',
502
+ '2. 每个负面元素 2-6 个字,简洁明确',
503
+ '3. 覆盖 10-20 个负面要素',
504
+ '4. 禁止任何 markdown 格式、前言、解释',
505
+ '5. 第一项就必须是负面要素',
506
+ '',
507
+ '正确示例(直接这样输出,不要任何额外内容):',
508
+ '模糊, 低分辨率, 噪点, 压缩痕迹, 像素化, 水印, logo, 文字, 字幕, 边框, 人体畸形, 多余手指, 面部扭曲, 透视错误, 塑料感, 过度修饰, 构图失衡, 主体偏移, 杂乱背景, 低质量',
509
+ ];
510
+ }
511
+
512
+ /**
513
+ * 清理模型输出,确保是纯净的提示词文本
514
+ * 处理:markdown 格式、多版本分节、前缀后记、引号包裹、代码块
515
+ */
516
+ function cleanOutput(s) {
517
+ if (!s) return '';
518
+ let t = s.trim();
519
+
520
+ // 1. 去掉 markdown 代码块包裹
521
+ t = t.replace(/```[a-z]*\n?/gi, '').replace(/```/g, '');
522
+
523
+ // 2. 如果有 markdown 标题/分节(# 开头、--- 分隔),说明输出多个版本,
524
+ // 优先取「进阶版/详细版/优化版」段落,否则取第一个实质内容段
525
+ const lines = t.split(/\n/).map((l) => l.trim());
526
+ // 找带「>」引用的段落(通常是真正的提示词)
527
+ const quoteBlocks = [];
528
+ let cur = [];
529
+ for (const l of lines) {
530
+ if (/^>+/.test(l)) {
531
+ cur.push(l.replace(/^>+\s*/, ''));
532
+ } else if (cur.length) {
533
+ quoteBlocks.push(cur.join(''));
534
+ cur = [];
535
+ }
536
+ }
537
+ if (cur.length) quoteBlocks.push(cur.join(''));
538
+
539
+ if (quoteBlocks.length) {
540
+ // 优先取较长的(通常是进阶版),过滤掉太短的标题性引用
541
+ const candidates = quoteBlocks.filter((b) => b.length >= 30);
542
+ if (candidates.length) {
543
+ t = candidates.sort((a, b) => b.length - a.length)[0];
544
+ }
545
+ }
546
+
547
+ // 3. 去掉 markdown 加粗/标题符号
548
+ t = t.replace(/^#+\s*/gm, '').replace(/\*\*/g, '').replace(/^---+$/gm, '');
549
+
550
+ // 4. 去掉常见前缀(优化后:/以下是…/好的,/基础版 等)
551
+ t = t.replace(
552
+ /^(优化后|优化结果|结果|提示词|以下是优化后的提示词?|好的[,,]?|基础版|进阶版|详细版|优化版|画面描述)[::]*\s*/i,
553
+ ''
554
+ );
555
+
556
+ // 5. 去掉首尾引号
557
+ t = t.replace(/^["""「『《]+/, '').replace(/[""」』》]+$/, '');
558
+
559
+ // 6. 截到第一个空行之前(避免取到后面的解释)
560
+ const firstPara = t.split(/\n\s*\n/)[0];
561
+ if (firstPara && firstPara.length >= 20) t = firstPara;
562
+
563
+ // 7. 合并多余空白
564
+ t = t.replace(/\n+/g, ' ').replace(/\s{2,}/g, ' ').trim();
565
+
566
+ return t;
567
+ }