1
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
pub(crate) mod big_fish;
|
||||
pub(crate) mod character_animation;
|
||||
pub(crate) mod character_visual;
|
||||
pub(crate) mod puzzle_image;
|
||||
pub(crate) mod puzzle;
|
||||
pub(crate) mod rpg;
|
||||
pub(crate) mod scene_background;
|
||||
|
||||
|
||||
212
server-rs/crates/api-server/src/prompt/puzzle/agent_chat.rs
Normal file
212
server-rs/crates/api-server/src/prompt/puzzle/agent_chat.rs
Normal file
@@ -0,0 +1,212 @@
|
||||
use module_puzzle::{PuzzleAnchorPack, PuzzleAnchorStatus, empty_anchor_pack};
|
||||
use serde_json::{Value as JsonValue, json};
|
||||
use spacetime_client::{
|
||||
PuzzleAgentMessageRecord, PuzzleAgentSessionRecord, PuzzleAnchorPackRecord,
|
||||
};
|
||||
|
||||
use crate::creation_agent_anchor_templates::{
|
||||
get_creation_agent_anchor_template, render_anchor_question_block,
|
||||
};
|
||||
use crate::creation_agent_chat::render_quick_fill_extra_rules;
|
||||
|
||||
/// 拼图共创 Agent 的系统提示词。
|
||||
///
|
||||
/// 这里作为拼图聊天提示词主源,业务文件只负责调用 LLM、解析结果和写回状态。
|
||||
pub(crate) const PUZZLE_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责和陶泥主共创拼图画面的中文创意策划。
|
||||
|
||||
你要帮助用户把一句灵感逐步收束成可以发布成拼图关卡的视觉方案。
|
||||
|
||||
你必须同时输出:
|
||||
1. 一段直接发给用户的中文回复 replyText
|
||||
2. 当前进度 progressPercent
|
||||
3. 下一轮完整可用的 nextAnchorPack
|
||||
|
||||
硬约束:
|
||||
1. 只能输出 JSON,不能输出代码块或解释
|
||||
2. nextAnchorPack 必须是完整对象,不能只输出 patch
|
||||
3. replyText 必须是自然中文,不能提“字段”“锚点”“结构”“JSON”等内部词
|
||||
4. replyText 一次最多推进一个最关键问题
|
||||
5. 如果用户已经给出明确方向,就优先吸收和收束,不要机械反问
|
||||
6. progressPercent 范围只能是 0 到 100
|
||||
7. status 只能使用 missing / inferred / confirmed / locked
|
||||
"#;
|
||||
|
||||
/// 拼图共创 Agent 单轮 JSON 输出契约。
|
||||
const PUZZLE_AGENT_OUTPUT_CONTRACT: &str = r#"请严格按以下 JSON 输出,不要输出其他文字:
|
||||
{
|
||||
"replyText": "",
|
||||
"progressPercent": 0,
|
||||
"nextAnchorPack": {
|
||||
"themePromise": {
|
||||
"key": "themePromise",
|
||||
"label": "题材承诺",
|
||||
"value": "",
|
||||
"status": "missing"
|
||||
},
|
||||
"visualSubject": {
|
||||
"key": "visualSubject",
|
||||
"label": "画面主体",
|
||||
"value": "",
|
||||
"status": "missing"
|
||||
},
|
||||
"visualMood": {
|
||||
"key": "visualMood",
|
||||
"label": "视觉气质",
|
||||
"value": "",
|
||||
"status": "missing"
|
||||
},
|
||||
"compositionHooks": {
|
||||
"key": "compositionHooks",
|
||||
"label": "拼图记忆点",
|
||||
"value": "",
|
||||
"status": "missing"
|
||||
},
|
||||
"tagsAndForbidden": {
|
||||
"key": "tagsAndForbidden",
|
||||
"label": "标签与禁忌",
|
||||
"value": "",
|
||||
"status": "missing"
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
/// 拼图共创 Agent 的用户提示词,用于触发模型按系统约定返回单轮 JSON。
|
||||
pub(crate) const PUZZLE_AGENT_JSON_TURN_USER_PROMPT: &str = "请按约定输出这一轮的 JSON。";
|
||||
|
||||
/// 拼图草稿生成对话提示词脚本。
|
||||
pub(crate) fn build_puzzle_agent_prompt(
|
||||
session: &PuzzleAgentSessionRecord,
|
||||
quick_fill_requested: bool,
|
||||
) -> String {
|
||||
let anchor_question_block = get_creation_agent_anchor_template("puzzle")
|
||||
.map(render_anchor_question_block)
|
||||
.unwrap_or_else(|| "模板目标:收束成可以发布为拼图关卡的视觉方案。".to_string());
|
||||
let quick_fill_rules = if quick_fill_requested {
|
||||
format!(
|
||||
"\n\n{}",
|
||||
render_quick_fill_extra_rules(
|
||||
"当前题材方向里的拼图关键词",
|
||||
"不要要求用户再提供素材、风格或禁忌",
|
||||
"输出完整 nextAnchorPack,直接补齐 value 为空或 status 为 missing 的项",
|
||||
"生成结果页",
|
||||
)
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
format!(
|
||||
"{anchor_question_block}{quick_fill_rules}\n\n当前是第 {turn} 轮,当前进度 {progress}% 。\n\n是否要求自动补充剩余关键字:{quick_fill_requested_text}\n\n当前 anchor pack:\n{anchor_pack}\n\n最近聊天记录:\n{chat_history}\n\n{contract}",
|
||||
anchor_question_block = anchor_question_block,
|
||||
quick_fill_rules = quick_fill_rules,
|
||||
turn = session.current_turn.saturating_add(1),
|
||||
progress = session.progress_percent,
|
||||
quick_fill_requested_text = if quick_fill_requested { "是" } else { "否" },
|
||||
anchor_pack = serialize_puzzle_record_anchor_pack(&session.anchor_pack),
|
||||
chat_history =
|
||||
serde_json::to_string_pretty(&build_chat_history(session.messages.as_slice()))
|
||||
.unwrap_or_else(|_| "[]".to_string()),
|
||||
contract = PUZZLE_AGENT_OUTPUT_CONTRACT,
|
||||
)
|
||||
}
|
||||
|
||||
/// 将 SpacetimeDB 记录态锚点序列化成提示词可读 JSON。
|
||||
pub(crate) fn serialize_puzzle_record_anchor_pack(record: &PuzzleAnchorPackRecord) -> String {
|
||||
serde_json::to_string_pretty(&map_puzzle_record_anchor_pack(record)).unwrap_or_else(|_| {
|
||||
serde_json::to_string_pretty(&empty_anchor_pack()).unwrap_or_else(|_| "{}".to_string())
|
||||
})
|
||||
}
|
||||
|
||||
fn build_chat_history(messages: &[PuzzleAgentMessageRecord]) -> Vec<JsonValue> {
|
||||
messages
|
||||
.iter()
|
||||
.map(|message| {
|
||||
json!({
|
||||
"role": message.role,
|
||||
"kind": message.kind,
|
||||
"content": message.text,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn map_puzzle_record_anchor_pack(record: &PuzzleAnchorPackRecord) -> PuzzleAnchorPack {
|
||||
PuzzleAnchorPack {
|
||||
theme_promise: map_puzzle_record_anchor_item(&record.theme_promise),
|
||||
visual_subject: map_puzzle_record_anchor_item(&record.visual_subject),
|
||||
visual_mood: map_puzzle_record_anchor_item(&record.visual_mood),
|
||||
composition_hooks: map_puzzle_record_anchor_item(&record.composition_hooks),
|
||||
tags_and_forbidden: map_puzzle_record_anchor_item(&record.tags_and_forbidden),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_puzzle_record_anchor_item(
|
||||
record: &spacetime_client::PuzzleAnchorItemRecord,
|
||||
) -> module_puzzle::PuzzleAnchorItem {
|
||||
module_puzzle::PuzzleAnchorItem {
|
||||
key: record.key.clone(),
|
||||
label: record.label.clone(),
|
||||
value: record.value.clone(),
|
||||
status: parse_puzzle_anchor_status(record.status.as_str()),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_puzzle_anchor_status(value: &str) -> PuzzleAnchorStatus {
|
||||
match value {
|
||||
"confirmed" => PuzzleAnchorStatus::Confirmed,
|
||||
"locked" => PuzzleAnchorStatus::Locked,
|
||||
"inferred" => PuzzleAnchorStatus::Inferred,
|
||||
_ => PuzzleAnchorStatus::Missing,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::build_puzzle_agent_prompt;
|
||||
|
||||
fn anchor_item(
|
||||
key: &str,
|
||||
label: &str,
|
||||
value: &str,
|
||||
status: &str,
|
||||
) -> spacetime_client::PuzzleAnchorItemRecord {
|
||||
spacetime_client::PuzzleAnchorItemRecord {
|
||||
key: key.to_string(),
|
||||
label: label.to_string(),
|
||||
value: value.to_string(),
|
||||
status: status.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_session_record() -> spacetime_client::PuzzleAgentSessionRecord {
|
||||
spacetime_client::PuzzleAgentSessionRecord {
|
||||
session_id: "puzzle-session-test".to_string(),
|
||||
seed_text: "雨夜猫咪遗迹".to_string(),
|
||||
current_turn: 2,
|
||||
progress_percent: 60,
|
||||
stage: "collecting_anchors".to_string(),
|
||||
anchor_pack: spacetime_client::PuzzleAnchorPackRecord {
|
||||
theme_promise: anchor_item("themePromise", "题材承诺", "雨夜猫咪遗迹", "confirmed"),
|
||||
visual_subject: anchor_item("visualSubject", "画面主体", "", "missing"),
|
||||
visual_mood: anchor_item("visualMood", "视觉气质", "", "missing"),
|
||||
composition_hooks: anchor_item("compositionHooks", "拼图记忆点", "", "missing"),
|
||||
tags_and_forbidden: anchor_item("tagsAndForbidden", "标签与禁忌", "", "missing"),
|
||||
},
|
||||
draft: None,
|
||||
messages: Vec::new(),
|
||||
last_assistant_reply: None,
|
||||
published_profile_id: None,
|
||||
suggested_actions: Vec::new(),
|
||||
result_preview: None,
|
||||
updated_at: "2026-04-24T10:00:00.000Z".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_fill_prompt_forbids_follow_up_questions() {
|
||||
let prompt = build_puzzle_agent_prompt(&empty_session_record(), true);
|
||||
|
||||
assert!(prompt.contains("用户刚刚主动要求你自动补充剩余关键字"));
|
||||
assert!(prompt.contains("不要再继续提问"));
|
||||
assert!(prompt.contains("progressPercent 直接输出为 100"));
|
||||
}
|
||||
}
|
||||
106
server-rs/crates/api-server/src/prompt/puzzle/image.rs
Normal file
106
server-rs/crates/api-server/src/prompt/puzzle/image.rs
Normal file
@@ -0,0 +1,106 @@
|
||||
/// 拼图图片生成的默认反向提示词。
|
||||
///
|
||||
/// 这里单独收口拼图图片提示词,避免图片生成链路、候选图持久化和 DashScope 请求编排
|
||||
/// 混在同一个脚本里,后续调画风或资产约束时只需要改这一处。
|
||||
pub(crate) const PUZZLE_DEFAULT_NEGATIVE_PROMPT: &str =
|
||||
"低清晰度,低质量,文字水印,畸形构图,过度模糊,重复肢体,画面脏污";
|
||||
|
||||
/// wan2.2 / wan2.1 文生图旧协议的正向 prompt 上限。
|
||||
///
|
||||
/// 中文注释:DashScope 旧 text2image 接口会把超长 prompt 判成请求参数不合法,
|
||||
/// 所以这里先在拼图提示词模块内压缩,保证固定玩法约束不会被用户长描述挤掉。
|
||||
pub(crate) const PUZZLE_TEXT_TO_IMAGE_PROMPT_MAX_CHARS: usize = 500;
|
||||
|
||||
const PUZZLE_IMAGE_LEVEL_NAME_MAX_CHARS: usize = 40;
|
||||
const PUZZLE_IMAGE_PROMPT_FALLBACK: &str = "清晰、有辨识度的拼图画面";
|
||||
|
||||
/// 根据拼图关卡名和陶泥主输入构造最终发给图片模型的提示词。
|
||||
pub(crate) fn build_puzzle_image_prompt(level_name: &str, prompt: &str) -> String {
|
||||
let level_name =
|
||||
truncate_puzzle_prompt_segment(level_name.trim(), PUZZLE_IMAGE_LEVEL_NAME_MAX_CHARS);
|
||||
let prompt = prompt.trim();
|
||||
let prompt = if prompt.is_empty() {
|
||||
PUZZLE_IMAGE_PROMPT_FALLBACK
|
||||
} else {
|
||||
prompt
|
||||
};
|
||||
let template_chars = build_puzzle_image_prompt_text(level_name.as_str(), "")
|
||||
.chars()
|
||||
.count();
|
||||
let prompt_max_chars = PUZZLE_TEXT_TO_IMAGE_PROMPT_MAX_CHARS.saturating_sub(template_chars);
|
||||
let prompt = truncate_puzzle_prompt_segment(prompt, prompt_max_chars);
|
||||
let image_prompt = build_puzzle_image_prompt_text(level_name.as_str(), prompt.as_str());
|
||||
|
||||
debug_assert!(
|
||||
image_prompt.chars().count() <= PUZZLE_TEXT_TO_IMAGE_PROMPT_MAX_CHARS,
|
||||
"puzzle image prompt should fit DashScope wan2.2 limit"
|
||||
);
|
||||
image_prompt
|
||||
}
|
||||
|
||||
fn build_puzzle_image_prompt_text(level_name: &str, prompt: &str) -> String {
|
||||
format!(
|
||||
concat!(
|
||||
"请生成一张高清插画。",
|
||||
"关卡名:{level_name}。",
|
||||
"画面主体:{prompt}。",
|
||||
"画面要求:1:1 正方形拼图关卡,适配 3x3 或 4x4 拼图切块,",
|
||||
"主体要清晰集中,前中后景层次明确,局部细节丰富但不要杂乱,",
|
||||
"避免文字、水印、边框和 UI 元素。"
|
||||
),
|
||||
level_name = level_name,
|
||||
prompt = prompt,
|
||||
)
|
||||
}
|
||||
|
||||
fn truncate_puzzle_prompt_segment(value: &str, max_chars: usize) -> String {
|
||||
if value.chars().count() <= max_chars {
|
||||
return value.to_string();
|
||||
}
|
||||
|
||||
const MARKER: &str = "...";
|
||||
if max_chars <= MARKER.chars().count() {
|
||||
return value.chars().take(max_chars).collect();
|
||||
}
|
||||
|
||||
let keep_chars = max_chars - MARKER.chars().count();
|
||||
format!(
|
||||
"{}{MARKER}",
|
||||
value.chars().take(keep_chars).collect::<String>()
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn build_puzzle_image_prompt_keeps_puzzle_asset_constraints() {
|
||||
let prompt = build_puzzle_image_prompt("雨夜神庙", "猫咪在发光遗迹前寻找线索");
|
||||
|
||||
assert!(prompt.contains("雨夜神庙"));
|
||||
assert!(prompt.contains("猫咪在发光遗迹前寻找线索"));
|
||||
assert!(prompt.contains("1:1 正方形拼图关卡"));
|
||||
assert!(prompt.contains("3x3 或 4x4"));
|
||||
assert!(prompt.contains("避免文字、水印、边框和 UI 元素"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_puzzle_image_prompt_trims_long_user_description_for_wan22() {
|
||||
let long_level_name = "雨夜神庙".repeat(20);
|
||||
let long_description =
|
||||
"发光遗迹、猫咪、漂浮碎片、雨水反光、远处灯塔、适合拼图切块。".repeat(50);
|
||||
let prompt = build_puzzle_image_prompt(long_level_name.as_str(), long_description.as_str());
|
||||
|
||||
assert!(prompt.chars().count() <= PUZZLE_TEXT_TO_IMAGE_PROMPT_MAX_CHARS);
|
||||
assert!(prompt.contains("1:1 正方形拼图关卡"));
|
||||
assert!(prompt.contains("3x3 或 4x4"));
|
||||
assert!(prompt.contains("避免文字、水印、边框和 UI 元素"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_negative_prompt_blocks_text_and_low_quality_assets() {
|
||||
assert!(PUZZLE_DEFAULT_NEGATIVE_PROMPT.contains("低清晰度"));
|
||||
assert!(PUZZLE_DEFAULT_NEGATIVE_PROMPT.contains("文字水印"));
|
||||
}
|
||||
}
|
||||
2
server-rs/crates/api-server/src/prompt/puzzle/mod.rs
Normal file
2
server-rs/crates/api-server/src/prompt/puzzle/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub(crate) mod agent_chat;
|
||||
pub(crate) mod image;
|
||||
@@ -1,44 +0,0 @@
|
||||
/// 拼图图片生成的默认反向提示词。
|
||||
///
|
||||
/// 这里单独收口拼图图片提示词,避免图片生成链路、候选图持久化和 DashScope 请求编排
|
||||
/// 混在同一个脚本里,后续调画风或资产约束时只需要改这一处。
|
||||
pub(crate) const PUZZLE_DEFAULT_NEGATIVE_PROMPT: &str =
|
||||
"低清晰度,低质量,文字水印,畸形构图,过度模糊,重复肢体,画面脏污";
|
||||
|
||||
/// 根据拼图关卡名和陶泥主输入构造最终发给图片模型的提示词。
|
||||
pub(crate) fn build_puzzle_image_prompt(level_name: &str, prompt: &str) -> String {
|
||||
format!(
|
||||
concat!(
|
||||
"请生成一张适合 1:1 正方形拼图关卡的高清插画。",
|
||||
"关卡名:{level_name}。",
|
||||
"画面主体:{prompt}。",
|
||||
"画面要求:1:1 正方形画布,适配 3x3 或 4x4 拼图切块,",
|
||||
"主体要清晰集中,前中后景层次明确,局部细节丰富但不要杂乱,",
|
||||
"避免文字、水印、边框和 UI 元素。"
|
||||
),
|
||||
level_name = level_name,
|
||||
prompt = prompt,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn build_puzzle_image_prompt_keeps_puzzle_asset_constraints() {
|
||||
let prompt = build_puzzle_image_prompt("雨夜神庙", "猫咪在发光遗迹前寻找线索");
|
||||
|
||||
assert!(prompt.contains("雨夜神庙"));
|
||||
assert!(prompt.contains("猫咪在发光遗迹前寻找线索"));
|
||||
assert!(prompt.contains("1:1 正方形拼图关卡"));
|
||||
assert!(prompt.contains("3x3 或 4x4"));
|
||||
assert!(prompt.contains("避免文字、水印、边框和 UI 元素"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_negative_prompt_blocks_text_and_low_quality_assets() {
|
||||
assert!(PUZZLE_DEFAULT_NEGATIVE_PROMPT.contains("低清晰度"));
|
||||
assert!(PUZZLE_DEFAULT_NEGATIVE_PROMPT.contains("文字水印"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user