Refactor server-rs runtime and update related docs
This commit is contained in:
@@ -325,7 +325,6 @@
|
||||
例如:
|
||||
|
||||
- `initialAffinity`
|
||||
- `dangerLevel`
|
||||
- 精确数值型 build 倾向
|
||||
- 复杂掉落预算
|
||||
|
||||
|
||||
@@ -596,7 +596,6 @@ chapterXpBudget =
|
||||
1. `SceneChapterBlueprint.acts` 数量
|
||||
2. 当前章节 hostile NPC 数量
|
||||
3. 当前章节任务 step 中战斗目标占比
|
||||
4. `dangerLevel`
|
||||
5. linked thread 是否为主线高压线程
|
||||
|
||||
## 6.4 实际速度评估规则
|
||||
|
||||
@@ -307,7 +307,6 @@
|
||||
|
||||
1. `name`
|
||||
2. `description`
|
||||
3. `dangerLevel`
|
||||
|
||||
## 7.4 第四阶段不允许编辑的内容
|
||||
|
||||
@@ -425,7 +424,6 @@
|
||||
1. `id`
|
||||
2. `name`
|
||||
3. `description` 或 `summary`
|
||||
4. `dangerLevel`
|
||||
5. `purpose`
|
||||
6. `mood`
|
||||
|
||||
|
||||
@@ -505,7 +505,6 @@ draftProfile.landmarks.find(...)
|
||||
kind: 'camp' | 'landmark';
|
||||
name: string;
|
||||
description: string;
|
||||
dangerLevel?: string;
|
||||
imageSrc?: string;
|
||||
};
|
||||
onPublishSuccess: (payload) => void;
|
||||
|
||||
@@ -213,7 +213,6 @@
|
||||
1. 开局场景允许配置的字段必须与普通场景一致,至少包括:
|
||||
- `name`
|
||||
- `description`
|
||||
- `dangerLevel`
|
||||
- `imageSrc`
|
||||
- `sceneNpcIds`
|
||||
- `connections`
|
||||
|
||||
@@ -55,7 +55,7 @@ name, role, publicMask, hiddenHook, relationToPlayer, summary, threadIds
|
||||
新增场景保留旧 Node 的 system prompt 与 user prompt 字段约束:
|
||||
|
||||
```text
|
||||
name, purpose, mood, dangerLevel, secret, summary, threadIds, characterIds
|
||||
name, purpose, mood, secret, summary, threadIds, characterIds
|
||||
```
|
||||
|
||||
Rust 侧只做最小归一化:补 `id`、去除重名、限制数量 `1..=3`,不改写提示词原文语义。
|
||||
|
||||
@@ -60,6 +60,6 @@
|
||||
- 某一幕连续重试仍失败时,只允许在该幕写入 `backgroundGenerationError` 作为诊断字段;只要至少一幕成功,草稿仍应完成并让前端展示成功图片。
|
||||
- 只有全部幕背景均失败时,才把“生成幕背景图失败”作为草稿素材阶段失败原因保存。
|
||||
- Rust 服务实际生图模型读取 `DASHSCOPE_SCENE_IMAGE_MODEL` / `DASHSCOPE_COVER_IMAGE_MODEL` / `DASHSCOPE_REFERENCE_IMAGE_MODEL`;兼容旧 `DASHSCOPE_IMAGE_MODEL`,避免 `.env.example` 中配置了模型但服务端仍使用硬编码模型。
|
||||
- 自动草稿幕背景不能把 `backgroundPromptText` 直接作为最终 `prompt` 传给 DashScope;它必须像草稿页手动生成一样,把幕级描述作为 `userPrompt`,并用同一个地点对象的 `name/description/dangerLevel` 作为场景上下文,再由 `build_custom_world_scene_image_prompt` 统一拼入世界名、世界摘要、风格、玩家目标、场景名、场景描述和负面词。用户不修改默认描述直接点生成时,手动生成与自动草稿生成的正式生图上下文必须一致。
|
||||
- 自动草稿幕背景不能把 `backgroundPromptText` 直接作为最终 `prompt` 传给 DashScope;它必须像草稿页手动生成一样,把幕级描述作为 `userPrompt`,并用同一个地点对象的 `name/description` 作为场景上下文,再由 `build_custom_world_scene_image_prompt` 统一拼入世界名、世界摘要、风格、玩家目标、场景名、场景描述和负面词。用户不修改默认描述直接点生成时,手动生成与自动草稿生成的正式生图上下文必须一致。
|
||||
- 自动草稿幕背景的默认尺寸必须与草稿页手动生成默认尺寸一致,当前统一为 `1280*720`;不能在自动链路中单独改成 `1600*900`,否则同一 prompt 在同一模型下也可能因供应商尺寸支持或耗时不同而表现不一致。
|
||||
- 批量自动生图失败日志必须保留 `AppError.details.message` 中的供应商真实原因,不能只记录 `AppError.message()` 的 HTTP 泛化文案,否则排查时只能看到“上游服务请求失败”,无法确认是尺寸、模型、限流、超时还是内容审核失败。
|
||||
|
||||
@@ -15,7 +15,7 @@ LLM 扩展提示词为了草稿卡片简洁,只要求返回角色的 `publicMa
|
||||
但结果页与运行时 `CustomWorldProfile` 读取的是当前完整字段:
|
||||
|
||||
- NPC:`description / backstory / personality / motivation / relationshipHooks / tags / initialAffinity`
|
||||
- 场景:`description / dangerLevel / sceneNpcIds / connections`
|
||||
- 场景:`description / sceneNpcIds / connections`
|
||||
|
||||
因此新增实体即使后端动作成功,也可能因为字段缺失或关联字段名不一致,在结果页表现为“生成后没有可用内容 / 场景没有 NPC 关联”。
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# 自定义世界场景 dangerLevel 字段移除落地说明(2026-04-25)
|
||||
|
||||
## 背景
|
||||
|
||||
自定义世界场景对象过去在开局归处、地标、草稿实体和生图上下文中携带 `dangerLevel`。该字段会让场景结构额外承载“危险等级”枚举,并在提示词中要求模型生成固定的 `low|medium|high|extreme` 值。当前场景设计更依赖 `description`、`visualDescription`、幕级事件和任务描述表达氛围,不再需要独立危险等级字段。
|
||||
|
||||
## 落地范围
|
||||
|
||||
- 场景数据结构删除 `dangerLevel`,包括开局归处、地标、共享草稿协议与前端表单映射。
|
||||
- 自定义世界生成、修复和补全提示词不再要求模型输出 `dangerLevel`。
|
||||
- 场景生图上下文只使用世界名、世界摘要、整体基调、玩家目标、场景名、场景描述和用户画面描述,不再拼接危险等级氛围。
|
||||
- Rust API 与 SpacetimeDB 模块展示卡片不再从 `dangerLevel` 读取副标题或场景上下文。
|
||||
- 测试夹具移除 `dangerLevel` 输入,避免新测试继续固化该字段。
|
||||
|
||||
## 兼容策略
|
||||
|
||||
- 旧存档或旧 LLM 返回中若仍包含 `dangerLevel`,前端和 Rust 归一化流程不再读取或回写该字段。
|
||||
- 不新增替代字段;需要表达危险、压迫或安全感时,写入 `description`、`visualDescription`、`mood`、`sceneTaskDescription` 或幕级描述。
|
||||
- `server-node` 旧实现不作为兼容目标,本次仅清理当前前端、共享协议与 `server-rs` 链路。
|
||||
|
||||
## 编码落点
|
||||
|
||||
- `src/types/customWorld.ts`:删除场景类型上的 `dangerLevel`。
|
||||
- `src/prompts/customWorldPrompts.ts` 与 `server-rs/crates/api-server/src/prompt/foundation_draft.rs`:删除所有生成/修复 `dangerLevel` 的模板与约束。
|
||||
- `src/services/customWorld.ts`、`src/services/customWorldCamp.ts`、`src/services/customWorldBuilder.ts`:删除归一化和 fallback 写入。
|
||||
- `src/data/customWorldLibrary.ts`、`src/data/customWorldSceneGraph.ts`、`src/data/customWorldVisuals.ts`:删除持久化、场景图谱和视觉上下文映射。
|
||||
- `server-rs/crates/api-server` 与 `server-rs/crates/spacetime-module`:删除 Rust 侧默认 JSON、提示词、实体卡片副标题和场景上下文读取。
|
||||
@@ -0,0 +1,49 @@
|
||||
# NPC 相遇先手发言与 Runtime 聊天路由修复
|
||||
|
||||
## 背景
|
||||
|
||||
进入游戏与 NPC 相遇后,前端会调用 `POST /api/runtime/chat/npc/turn/stream` 生成 NPC 主动开场或聊天回合。Rust API 尚未承接旧 Node 的该路由时,接口返回 404,前端解析为“资源不存在”,导致:
|
||||
|
||||
1. NPC 主动开场失败,对话框中没有先发言。
|
||||
2. 点击聊天选项继续对话时同样报“资源不存在”。
|
||||
3. 场景中和平相遇 NPC 的朝向与站位不稳定,不能稳定表现为与主角对望。
|
||||
|
||||
## 本轮落地规则
|
||||
|
||||
1. 前端只负责表现和触发,不在本地补 LLM 编排。
|
||||
2. Rust API 必须至少提供契约兼容的后端 SSE 路由,避免回退到 server-node。
|
||||
3. 任意好感度下,首次与一个 NPC 相遇都先进入 NPC 主动开场;后续再按敌对/普通分支处理。
|
||||
4. 和平相遇态 NPC 固定使用已解析相遇锚点,与主角形成面对面的右侧对称表现,并强制朝向主角。
|
||||
|
||||
## 代码设计
|
||||
|
||||
### Rust Runtime Chat
|
||||
|
||||
新增 `server-rs/crates/api-server/src/runtime_chat.rs`:
|
||||
|
||||
1. 注册 `POST /api/runtime/chat/npc/turn/stream`。
|
||||
2. 返回前端已支持的 SSE 事件:
|
||||
- `reply_delta`:增量文本。
|
||||
- `complete`:`npcReply / affinityDelta / affinityText / suggestions / pendingQuestOffer / chatDirective`。
|
||||
3. 当前先提供后端确定性兜底回复,保证 Rust API 迁移期间链路可用;后续完整 LLM 编排应继续在 Rust API 内实现,不回接 server-node。
|
||||
|
||||
### 前端交互
|
||||
|
||||
调整 `src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts`:
|
||||
|
||||
1. 首次相遇判断提前到敌对短路之前。
|
||||
2. `firstMeaningfulContactResolved` 为 false 时,无论好感度或敌对状态如何,都调用 `startNpcInitiatedOpening(...)`。
|
||||
|
||||
调整 `src/components/game-canvas/GameCanvasEntityLayer.tsx`:
|
||||
|
||||
1. 和平相遇 NPC 使用 `RESOLVED_ENTITY_X_METERS` 作为稳定右侧锚点。
|
||||
2. 渲染朝向直接使用 `getFacingTowardPlayer(...)` 结果,避免通用 NPC 形象被二次反转。
|
||||
|
||||
## 后续迁移点
|
||||
|
||||
Rust 兜底路由只解决运行时断链。完整体验仍应继续迁移旧 Node 中 NPC 聊天编排能力,包括:
|
||||
|
||||
1. 基于上下文的 LLM 回复。
|
||||
2. 聊天建议生成。
|
||||
3. 待接委托 `pendingQuestOffer` 的服务端判定与生成。
|
||||
4. 限轮与强制退出指令的完整结算。
|
||||
@@ -0,0 +1,32 @@
|
||||
# Rust 生成链路 Prompt 脚本迁移设计(2026-04-25)
|
||||
|
||||
## 1. 目标
|
||||
|
||||
把 `server-rs` 中四条现役生成链路的提示词从业务流程文件中抽离到独立 `prompt` 目录,后续迭代只修改 prompt 脚本,不在路由、任务、资产持久化代码中直接堆提示词。
|
||||
|
||||
## 2. 目录约定
|
||||
|
||||
新增目录:`server-rs/crates/api-server/src/prompt/`
|
||||
|
||||
模块划分:
|
||||
|
||||
1. `scene_background.rs`:场景背景图与幕背景图提示词。
|
||||
2. `character_visual.rs`:角色主形象提示词与负向提示词。
|
||||
3. `character_animation.rs`:角色动作、序列帧、图生视频、动作迁移提示词。
|
||||
4. `foundation_draft.rs`:草稿生成各阶段 JSON 系统提示词、修复提示词、框架/角色/场景/档案分阶段 user prompt。
|
||||
5. `mod.rs`:统一导出子模块。
|
||||
|
||||
## 3. 迁移边界
|
||||
|
||||
1. 只迁移 prompt 构造与 prompt 常量,不迁移 DashScope、OSS、SpacetimeDB、任务状态、并发控制和持久化逻辑。
|
||||
2. `custom_world.rs` 只保留场景幕引用收集、校验和调用生成服务,不再承载幕背景图提示词正文。
|
||||
3. `custom_world_ai.rs` 只保留图片生成、下载、入库、接口 payload 归一化;场景图 prompt builder 迁入 `prompt::scene_background`。
|
||||
4. `custom_world_asset_prompts.rs` 作为兼容转发层保留,避免一次性改动角色资产调用点过大;真实提示词脚本迁入 `prompt::character_visual` 与 `prompt::character_animation`。
|
||||
5. `custom_world_foundation_draft.rs` 只保留分阶段编排、JSON 解析、归一化和写回;所有阶段 prompt builder 迁入 `prompt::foundation_draft`。
|
||||
|
||||
## 4. 验收标准
|
||||
|
||||
1. `cargo fmt -p api-server` 通过。
|
||||
2. `cargo check -p api-server` 通过。
|
||||
3. 四条链路仍能从原调用点拿到相同语义的提示词。
|
||||
4. 文档明确后续 prompt 修改主源在 `src/prompt/`。
|
||||
@@ -83,7 +83,6 @@ export interface RpgAgentFoundationDraftLandmark {
|
||||
mood: string;
|
||||
importance: string;
|
||||
secret?: string;
|
||||
dangerLevel?: string;
|
||||
imageSrc?: string | null;
|
||||
generatedSceneAssetId?: string | null;
|
||||
generatedScenePrompt?: string | null;
|
||||
@@ -121,7 +120,6 @@ export interface RpgAgentFoundationDraftCamp {
|
||||
name: string;
|
||||
description: string;
|
||||
mood: string;
|
||||
dangerLevel?: string;
|
||||
imageSrc?: string | null;
|
||||
generatedSceneAssetId?: string | null;
|
||||
generatedScenePrompt?: string | null;
|
||||
|
||||
@@ -176,7 +176,6 @@ export function createRpgAgentFoundationDraftProfileFixture(): RpgAgentFoundatio
|
||||
mood: '潮湿、压抑、风声不止',
|
||||
importance: '开局核心场景',
|
||||
secret: '高处潮痕说明海面异常抬升过。',
|
||||
dangerLevel: 'high',
|
||||
imageSrc: '/generated-custom-world-scenes/landmark-1/latest-scene.png',
|
||||
generatedSceneAssetId: 'scene-asset-landmark-1',
|
||||
characterIds: ['story-1'],
|
||||
@@ -189,7 +188,6 @@ export function createRpgAgentFoundationDraftProfileFixture(): RpgAgentFoundatio
|
||||
name: '回潮暂栖所',
|
||||
description: '能暂时收拢队伍、整理灯册与潮路线索的落脚点。',
|
||||
mood: '克制、紧绷,但还能暂时收拢局势',
|
||||
dangerLevel: 'low',
|
||||
imageSrc: '/custom/camp/huichao.png',
|
||||
generatedSceneAssetId: 'scene-asset-camp-1',
|
||||
summary: '玩家能在这里整理情报、回看旧灯册和沉船名单。',
|
||||
@@ -437,14 +435,12 @@ export function createRpgCreationPublishedProfileFixture(): CustomWorldProfileRe
|
||||
camp: {
|
||||
name: draft.camp?.name,
|
||||
description: draft.camp?.description,
|
||||
dangerLevel: draft.camp?.dangerLevel,
|
||||
imageSrc: draft.camp?.imageSrc,
|
||||
},
|
||||
landmarks: draft.landmarks.map((landmark) => ({
|
||||
id: landmark.id,
|
||||
name: landmark.name,
|
||||
description: landmark.description,
|
||||
dangerLevel: landmark.dangerLevel,
|
||||
imageSrc: landmark.imageSrc,
|
||||
sceneNpcIds: landmark.characterIds,
|
||||
connections: [
|
||||
|
||||
@@ -89,6 +89,7 @@ use crate::{
|
||||
runtime_browse_history::{
|
||||
delete_runtime_browse_history, get_runtime_browse_history, post_runtime_browse_history,
|
||||
},
|
||||
runtime_chat::stream_runtime_npc_chat_turn,
|
||||
runtime_inventory::get_runtime_inventory_state,
|
||||
runtime_profile::{get_profile_dashboard, get_profile_play_stats, get_profile_wallet_ledger},
|
||||
runtime_save::{
|
||||
@@ -237,6 +238,13 @@ pub fn build_router(state: AppState) -> Router {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/chat/npc/turn/stream",
|
||||
post(stream_runtime_npc_chat_turn).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/auth/logout",
|
||||
post(logout)
|
||||
|
||||
@@ -63,6 +63,9 @@ use crate::{
|
||||
generate_custom_world_foundation_draft,
|
||||
},
|
||||
http_error::AppError,
|
||||
prompt::scene_background::{
|
||||
SceneActBackgroundPromptParams, build_scene_act_background_image_prompt,
|
||||
},
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
};
|
||||
@@ -1598,7 +1601,7 @@ async fn generate_draft_foundation_act_backgrounds(
|
||||
act_ref.scene_id.as_str(),
|
||||
act_ref.scene_name.as_str(),
|
||||
act_ref.scene_description.as_str(),
|
||||
act_ref.prompt.as_str(),
|
||||
act_ref.scene_image_prompt.as_str(),
|
||||
)
|
||||
.await
|
||||
};
|
||||
@@ -1772,9 +1775,13 @@ struct SceneActGenerationRef {
|
||||
scene_name: String,
|
||||
scene_description: String,
|
||||
prompt: String,
|
||||
scene_image_prompt: String,
|
||||
}
|
||||
|
||||
fn collect_scene_act_refs(draft_profile: &Value) -> Vec<SceneActGenerationRef> {
|
||||
let world_name =
|
||||
json_text_from_value(draft_profile, "name").unwrap_or_else(|| "未命名世界".to_string());
|
||||
let world_tone = json_text_from_value(draft_profile, "tone").unwrap_or_default();
|
||||
let scene_context_by_id = collect_scene_context_by_id(draft_profile);
|
||||
draft_profile
|
||||
.get("sceneChapterBlueprints")
|
||||
@@ -1800,9 +1807,10 @@ fn collect_scene_act_refs(draft_profile: &Value) -> Vec<SceneActGenerationRef> {
|
||||
description: json_text_from_value(chapter, "description")
|
||||
.or_else(|| json_text_from_value(chapter, "summary"))
|
||||
.unwrap_or_default(),
|
||||
danger_level: json_text_from_value(chapter, "dangerLevel").unwrap_or_default(),
|
||||
});
|
||||
let scene_contexts = scene_context_by_id.clone();
|
||||
let world_name = world_name.clone();
|
||||
let world_tone = world_tone.clone();
|
||||
chapter
|
||||
.get("acts")
|
||||
.and_then(Value::as_array)
|
||||
@@ -1838,8 +1846,31 @@ fn collect_scene_act_refs(draft_profile: &Value) -> Vec<SceneActGenerationRef> {
|
||||
id: act_scene_id.clone(),
|
||||
name: scene_name,
|
||||
description: chapter_scene_context.description.clone(),
|
||||
danger_level: chapter_scene_context.danger_level.clone(),
|
||||
});
|
||||
let title = json_text_from_value(act, "title")
|
||||
.unwrap_or_else(|| format!("第{}幕", act_index + 1));
|
||||
let summary = json_text_from_value(act, "summary").unwrap_or_default();
|
||||
let act_goal = json_text_from_value(act, "actGoal").unwrap_or_default();
|
||||
let transition_hook =
|
||||
json_text_from_value(act, "transitionHook").unwrap_or_default();
|
||||
let primary_role_name = json_first_text_from_value(
|
||||
act,
|
||||
&["primaryRoleName", "primaryRole", "mainRoleName"],
|
||||
)
|
||||
.unwrap_or_default();
|
||||
let scene_image_prompt =
|
||||
build_scene_act_background_image_prompt(SceneActBackgroundPromptParams {
|
||||
world_name: world_name.as_str(),
|
||||
world_tone: world_tone.as_str(),
|
||||
scene_name: scene_context.name.as_str(),
|
||||
title: title.as_str(),
|
||||
summary: summary.as_str(),
|
||||
act_goal: act_goal.as_str(),
|
||||
transition_hook: transition_hook.as_str(),
|
||||
primary_role_name: primary_role_name.as_str(),
|
||||
support_role_names: collect_scene_act_support_role_names(act),
|
||||
prompt_text: prompt.as_str(),
|
||||
});
|
||||
|
||||
SceneActGenerationRef {
|
||||
chapter_index,
|
||||
@@ -1847,19 +1878,18 @@ fn collect_scene_act_refs(draft_profile: &Value) -> Vec<SceneActGenerationRef> {
|
||||
scene_id: act_scene_id,
|
||||
scene_name: scene_context.name,
|
||||
scene_description: scene_context.description,
|
||||
prompt: prompt.clone(),
|
||||
prompt,
|
||||
scene_image_prompt,
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct SceneImageContext {
|
||||
id: String,
|
||||
name: String,
|
||||
description: String,
|
||||
danger_level: String,
|
||||
}
|
||||
|
||||
fn collect_scene_context_by_id(draft_profile: &Value) -> BTreeMap<String, SceneImageContext> {
|
||||
@@ -1895,10 +1925,26 @@ fn scene_context_from_object(
|
||||
description: read_string_field(object, "description")
|
||||
.or_else(|| read_string_field(object, "visualDescription"))
|
||||
.unwrap_or_default(),
|
||||
danger_level: read_string_field(object, "dangerLevel").unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_scene_act_support_role_names(act: &Value) -> Vec<String> {
|
||||
// 兼容旧 Node 自动资产链路可能写入的 supportRoleNames,也兼容单字段字符串,避免迁移后丢上下文。
|
||||
let mut names = act
|
||||
.get("supportRoleNames")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
names.extend(json_text_from_value(act, "supportRoleName"));
|
||||
names.extend(json_text_from_value(act, "supportRoles"));
|
||||
names
|
||||
}
|
||||
fn validate_scene_act_background_prompts(act_refs: &[SceneActGenerationRef]) -> Result<(), String> {
|
||||
if let Some(act_ref) = act_refs.iter().find(|act_ref| act_ref.prompt.is_empty()) {
|
||||
return Err(format!(
|
||||
@@ -2664,8 +2710,7 @@ mod tests {
|
||||
{
|
||||
"id": "scene-office",
|
||||
"name": "旧港办公室",
|
||||
"description": "旧港边缘的玻璃办公室,窗外能看到潮湿码头。",
|
||||
"dangerLevel": "low"
|
||||
"description": "旧港边缘的玻璃办公室,窗外能看到潮湿码头。"
|
||||
}
|
||||
],
|
||||
"sceneChapterBlueprints": [
|
||||
|
||||
@@ -205,7 +205,7 @@ fn build_custom_world_agent_landmark_expansion_prompt(params: ExpansionPromptPar
|
||||
params.prompt_seed
|
||||
}
|
||||
),
|
||||
"返回 JSON 数组。每个对象字段只允许包含:name, purpose, mood, dangerLevel, secret, summary, threadIds, characterIds。".to_string(),
|
||||
"返回 JSON 数组。每个对象字段只允许包含:name, purpose, mood, secret, summary, threadIds, characterIds。".to_string(),
|
||||
"threadIds / characterIds 必须优先引用现有对象 id。".to_string(),
|
||||
]
|
||||
.join("\n")
|
||||
@@ -341,7 +341,6 @@ fn normalize_generated_landmark_profile_fields(object: &mut JsonMap<String, Json
|
||||
}
|
||||
|
||||
insert_text_if_missing(object, "description", description_parts.join(" ").as_str());
|
||||
insert_text_if_missing(object, "dangerLevel", "中");
|
||||
object
|
||||
.entry("connections".to_string())
|
||||
.or_insert_with(|| JsonValue::Array(Vec::new()));
|
||||
@@ -612,7 +611,6 @@ mod tests {
|
||||
"purpose": "玩家第一次追查沉钟旧案的入口。",
|
||||
"mood": "潮湿、压抑、灯火忽明忽暗。",
|
||||
"secret": "码头木桩下藏着改写航道的符牌。",
|
||||
"dangerLevel": "高",
|
||||
"characterIds": ["character-witness"]
|
||||
})],
|
||||
draft_profile
|
||||
|
||||
@@ -34,6 +34,10 @@ use crate::{
|
||||
build_result_scene_npc_system_prompt, build_result_scene_npc_user_prompt,
|
||||
},
|
||||
http_error::AppError,
|
||||
prompt::scene_background::{
|
||||
DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT, SceneImagePromptLandmark,
|
||||
SceneImagePromptParams, SceneImagePromptProfile, build_custom_world_scene_image_prompt,
|
||||
},
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
};
|
||||
@@ -172,8 +176,6 @@ struct SceneImageLandmarkInput {
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
#[serde(default)]
|
||||
danger_level: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
@@ -340,7 +342,6 @@ struct OptimizedCoverUpload {
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
const DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT: &str = "文字,水印,logo,UI界面,对话框,边框,人物近景特写,多人合照,模糊,低清晰度,畸形建筑,现代车辆,监控摄像头";
|
||||
const COVER_OUTPUT_WIDTH: u32 = 1600;
|
||||
const COVER_OUTPUT_HEIGHT: u32 = 900;
|
||||
const COVER_UPLOAD_MAX_BYTES: usize = 10 * 1024 * 1024;
|
||||
@@ -575,7 +576,6 @@ pub(crate) async fn generate_custom_world_scene_image_for_profile(
|
||||
id: Some(scene_id.to_string()),
|
||||
name: Some(scene_name.to_string()),
|
||||
description: Some(scene_description.to_string()),
|
||||
danger_level: None,
|
||||
}),
|
||||
};
|
||||
let normalized = normalize_scene_image_request(payload)?;
|
||||
@@ -1147,7 +1147,6 @@ fn build_landmark_fallback(world_name: &str) -> Value {
|
||||
"name": "新场景",
|
||||
"description": format!("围绕《{world_name}》当前主线冲突扩展出的关键场景。"),
|
||||
"visualDescription": "低照度、层次复杂、带有明显环境叙事痕迹。",
|
||||
"dangerLevel": "medium",
|
||||
"sceneNpcIds": [],
|
||||
"connections": [],
|
||||
"narrativeResidues": [],
|
||||
@@ -1180,14 +1179,24 @@ fn normalize_scene_image_request(
|
||||
}
|
||||
|
||||
let prompt = trim_to_option(payload.prompt.as_deref()).unwrap_or_else(|| {
|
||||
build_custom_world_scene_image_prompt(
|
||||
&profile,
|
||||
&landmark,
|
||||
payload.user_prompt.as_deref().unwrap_or_default(),
|
||||
reference_image_src.is_some(),
|
||||
landmark_name.as_deref(),
|
||||
world_name.as_str(),
|
||||
)
|
||||
build_custom_world_scene_image_prompt(SceneImagePromptParams {
|
||||
profile: SceneImagePromptProfile {
|
||||
name: profile.name.as_deref().unwrap_or_default(),
|
||||
subtitle: profile.subtitle.as_deref().unwrap_or_default(),
|
||||
tone: profile.tone.as_deref().unwrap_or_default(),
|
||||
player_goal: profile.player_goal.as_deref().unwrap_or_default(),
|
||||
summary: profile.summary.as_deref().unwrap_or_default(),
|
||||
setting_text: profile.setting_text.as_deref().unwrap_or_default(),
|
||||
},
|
||||
landmark: SceneImagePromptLandmark {
|
||||
name: landmark.name.as_deref().unwrap_or_default(),
|
||||
description: landmark.description.as_deref().unwrap_or_default(),
|
||||
},
|
||||
user_prompt: payload.user_prompt.as_deref().unwrap_or_default(),
|
||||
has_reference_image: reference_image_src.is_some(),
|
||||
fallback_landmark_name: landmark_name.as_deref(),
|
||||
fallback_world_name: world_name.as_str(),
|
||||
})
|
||||
});
|
||||
if prompt.is_empty() {
|
||||
return Err(
|
||||
@@ -1212,117 +1221,6 @@ fn normalize_scene_image_request(
|
||||
})
|
||||
}
|
||||
|
||||
fn build_custom_world_scene_image_prompt(
|
||||
profile: &SceneImageProfileInput,
|
||||
landmark: &SceneImageLandmarkInput,
|
||||
user_prompt: &str,
|
||||
has_reference_image: bool,
|
||||
fallback_landmark_name: Option<&str>,
|
||||
fallback_world_name: &str,
|
||||
) -> String {
|
||||
let world_name = clamp_scene_image_text(
|
||||
trim_to_option(profile.name.as_deref())
|
||||
.unwrap_or_else(|| fallback_world_name.to_string())
|
||||
.as_str(),
|
||||
18,
|
||||
);
|
||||
let world_subtitle = clamp_scene_image_text(
|
||||
trim_to_option(profile.subtitle.as_deref())
|
||||
.unwrap_or_default()
|
||||
.as_str(),
|
||||
18,
|
||||
);
|
||||
let world_tone = clamp_scene_image_text(
|
||||
trim_to_option(profile.tone.as_deref())
|
||||
.unwrap_or_default()
|
||||
.as_str(),
|
||||
48,
|
||||
);
|
||||
let world_goal = clamp_scene_image_text(
|
||||
trim_to_option(profile.player_goal.as_deref())
|
||||
.unwrap_or_default()
|
||||
.as_str(),
|
||||
48,
|
||||
);
|
||||
let world_summary = clamp_scene_image_text(
|
||||
trim_to_option(profile.summary.as_deref())
|
||||
.unwrap_or_default()
|
||||
.as_str(),
|
||||
72,
|
||||
);
|
||||
let world_setting = clamp_scene_image_text(
|
||||
trim_to_option(profile.setting_text.as_deref())
|
||||
.unwrap_or_default()
|
||||
.as_str(),
|
||||
72,
|
||||
);
|
||||
let landmark_name = clamp_scene_image_text(
|
||||
trim_to_option(landmark.name.as_deref())
|
||||
.or_else(|| fallback_landmark_name.map(ToOwned::to_owned))
|
||||
.unwrap_or_else(|| "未命名场景".to_string())
|
||||
.as_str(),
|
||||
18,
|
||||
);
|
||||
let landmark_description = clamp_scene_image_text(
|
||||
trim_to_option(landmark.description.as_deref())
|
||||
.unwrap_or_default()
|
||||
.as_str(),
|
||||
96,
|
||||
);
|
||||
let requested_visual = clamp_scene_image_text(user_prompt, 120);
|
||||
let danger_mood = describe_danger_level(
|
||||
trim_to_option(landmark.danger_level.as_deref())
|
||||
.unwrap_or_default()
|
||||
.as_str(),
|
||||
);
|
||||
|
||||
vec![
|
||||
"为横版 16:9 2D RPG 生成高完成度像素风场景背景,适合作为剧情探索与战斗底图。".to_string(),
|
||||
"画面构图必须严格按上下 1:1 分区:上半部分严格控制在整张图的 1/2 高度内,只描绘场景远景与中远景轮廓,不要让背景内容向下侵占超过半屏。".to_string(),
|
||||
"下半部分严格占据整张图的 1/2 高度,用于玩家角色站位与展示,必须是模拟 3D 游戏视角的地面近景,有明确的透视延伸和近大远小关系,不是平铺的 2D 侧视地面。".to_string(),
|
||||
"下半部分的内容必须是明确可站立的地面本体,例如道路、石板、平台、广场、甲板、沙地或草地,要有连续、稳定、可落脚的站位逻辑,不能只是装饰性前景、坑洞、障碍堆、栏杆带或不可通行的景物。".to_string(),
|
||||
"下半部分地面近景要保持相对简洁、低细节、轮廓清楚、便于角色站立,不要堆满道具、植被、碎石、栏杆或复杂装饰。".to_string(),
|
||||
if has_reference_image {
|
||||
"已提供一张自定义参考图,请沿用其构图、镜头或氛围线索,同时继续满足本次场景需求。".to_string()
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
format!(
|
||||
"世界:{}{}。",
|
||||
if world_name.is_empty() {
|
||||
"未命名世界"
|
||||
} else {
|
||||
world_name.as_str()
|
||||
},
|
||||
if world_subtitle.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(",{world_subtitle}")
|
||||
}
|
||||
),
|
||||
conditional_prompt_line("玩家设定", world_setting.as_str()),
|
||||
conditional_prompt_line("世界概述", world_summary.as_str()),
|
||||
conditional_prompt_line("整体基调", world_tone.as_str()),
|
||||
conditional_prompt_line("玩家目标关联", world_goal.as_str()),
|
||||
format!(
|
||||
"场景名称:{}。",
|
||||
if landmark_name.is_empty() {
|
||||
"未命名场景"
|
||||
} else {
|
||||
landmark_name.as_str()
|
||||
}
|
||||
),
|
||||
conditional_prompt_line("场景描述", landmark_description.as_str()),
|
||||
conditional_prompt_line("本次想要生成的画面内容", requested_visual.as_str()),
|
||||
format!("{danger_mood}。"),
|
||||
"不要出现 UI、字幕、文字、水印、logo 或装饰边框,人物仅可作为很小的远景剪影,画面重点放在场景本身,不要遮挡下半部分的角色展示区域。".to_string(),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|line| !line.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
}
|
||||
|
||||
fn require_dashscope_settings(state: &AppState) -> Result<DashScopeSettings, AppError> {
|
||||
// Stage 2 的真实图片生成统一走 DashScope,这里先把配置缺失拦在业务入口前。
|
||||
let base_url = state.config.dashscope_base_url.trim().trim_end_matches('/');
|
||||
@@ -2362,10 +2260,6 @@ fn mime_to_extension(mime_type: &str) -> &str {
|
||||
}
|
||||
}
|
||||
|
||||
fn clamp_scene_image_text(value: &str, max_length: usize) -> String {
|
||||
clamp_text(value, max_length, true)
|
||||
}
|
||||
|
||||
fn conditional_prompt_line(prefix: &str, value: &str) -> String {
|
||||
if value.is_empty() {
|
||||
String::new()
|
||||
@@ -2374,17 +2268,6 @@ fn conditional_prompt_line(prefix: &str, value: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn describe_danger_level(danger_level: &str) -> String {
|
||||
match danger_level.trim().to_ascii_lowercase().as_str() {
|
||||
"low" | "低" => "气氛相对平静,但暗藏细节张力".to_string(),
|
||||
"medium" | "中" => "带有明确的探索压力与潜在威胁".to_string(),
|
||||
"high" | "高" => "危险感强烈,空间中有明显压迫感".to_string(),
|
||||
"extreme" | "极高" => "极端危险,环境本身就像会吞没闯入者".to_string(),
|
||||
_ if !danger_level.trim().is_empty() => format!("危险氛围:{}", danger_level.trim()),
|
||||
_ => "危险气质保持克制但不可忽视".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_storage_segment(value: &str, fallback: &str) -> String {
|
||||
let normalized = value
|
||||
.trim()
|
||||
@@ -2627,7 +2510,6 @@ mod tests {
|
||||
id: Some("reef_temple".to_string()),
|
||||
name: Some("礁石神殿".to_string()),
|
||||
description: Some("古老礁石上的半沉神殿。".to_string()),
|
||||
danger_level: None,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -2665,7 +2547,6 @@ mod tests {
|
||||
id: Some("reef_temple".to_string()),
|
||||
name: Some("礁石神殿".to_string()),
|
||||
description: Some("古老礁石上的半沉神殿。".to_string()),
|
||||
danger_level: Some("high".to_string()),
|
||||
};
|
||||
let manual_prompt = build_custom_world_scene_image_prompt(
|
||||
&profile_input,
|
||||
|
||||
@@ -1,365 +1,6 @@
|
||||
use crate::character_animation_assets::find_motion_template;
|
||||
use shared_contracts::assets::CharacterAnimationStrategy;
|
||||
|
||||
/// 自定义世界角色主图提示词脚本。
|
||||
pub(crate) fn build_character_visual_prompt(
|
||||
prompt_text: &str,
|
||||
character_brief_text: Option<&str>,
|
||||
) -> String {
|
||||
let character_brief = [character_brief_text.unwrap_or_default(), prompt_text]
|
||||
.into_iter()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
build_master_prompt(character_brief.as_str())
|
||||
}
|
||||
|
||||
/// 角色主图统一提示词骨架,迁移自旧共享 qwenSprite 主链。
|
||||
fn build_master_prompt(character_brief: &str) -> String {
|
||||
[
|
||||
"单人,2D 横版游戏角色标准设定图,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。".to_string(),
|
||||
"视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。".to_string(),
|
||||
"主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。".to_string(),
|
||||
"画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。".to_string(),
|
||||
"风格要求:横版像素动作角色体型,头身比优先控制在 1 到 1.5 头身,保留清楚的头、躯干、双臂和双腿轮廓。明确的像素动作角色设定稿气质,整体按像素游戏角色设计方向组织,使用深色清楚轮廓、稳定剪影、有限大色块和硬朗边缘,不要柔和厚涂插画感,发型、服装、配饰优先形成醒目可读的像素级识别点,适合横版动作 sprite 资产。高可读性游戏角色设定图,形体清晰,服装层次明确,便于后续连续动作生成。".to_string(),
|
||||
"请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,只有当文字设定明确要求非人结构时,才改为对应非人身体。".to_string(),
|
||||
"主题词默认只作用在角色自身的服装剪裁、材质、纹样、饰品、发光细节上,不要把主题词自动扩写成背景建筑、自然场景、漂浮装饰或额外环境物件。".to_string(),
|
||||
"视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。".to_string(),
|
||||
character_brief.trim().to_string(),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// 自定义世界角色主图负面提示词脚本。
|
||||
pub(crate) fn build_character_visual_negative_prompt() -> String {
|
||||
[
|
||||
"正面视角",
|
||||
"左朝向",
|
||||
"完全 90 度纯右视图",
|
||||
"镜头透视",
|
||||
"半身像",
|
||||
"脚被裁切",
|
||||
"头顶被裁切",
|
||||
"多角色",
|
||||
"复杂背景",
|
||||
"建筑场景",
|
||||
"漂浮物",
|
||||
"烟雾环境",
|
||||
"武器消失",
|
||||
"武器换手",
|
||||
"额外手臂",
|
||||
"额外腿",
|
||||
"服装变化",
|
||||
"脸部变化",
|
||||
"模糊",
|
||||
"运动模糊",
|
||||
"文字",
|
||||
"水印",
|
||||
"UI 元素",
|
||||
"软萌 Q版大头贴",
|
||||
"儿童绘本风",
|
||||
"厚涂插画感",
|
||||
"低对比柔边",
|
||||
]
|
||||
.join(",")
|
||||
}
|
||||
|
||||
pub(crate) fn build_character_animation_prompt(
|
||||
strategy: &CharacterAnimationStrategy,
|
||||
prompt_text: &str,
|
||||
character_brief_text: Option<&str>,
|
||||
action_template_id: Option<&str>,
|
||||
animation: &str,
|
||||
frame_count: u32,
|
||||
fps: u32,
|
||||
duration_seconds: u32,
|
||||
loop_: bool,
|
||||
use_chroma_key: bool,
|
||||
) -> String {
|
||||
match strategy {
|
||||
CharacterAnimationStrategy::ImageToVideo => build_ark_character_animation_prompt(
|
||||
animation,
|
||||
prompt_text,
|
||||
character_brief_text,
|
||||
action_template_id,
|
||||
loop_,
|
||||
use_chroma_key,
|
||||
),
|
||||
CharacterAnimationStrategy::ImageSequence => {
|
||||
build_image_sequence_prompt(animation, prompt_text, frame_count, use_chroma_key)
|
||||
}
|
||||
CharacterAnimationStrategy::MotionTransfer
|
||||
| CharacterAnimationStrategy::ReferenceToVideo => build_npc_animation_prompt(
|
||||
animation,
|
||||
prompt_text,
|
||||
character_brief_text,
|
||||
action_template_id,
|
||||
loop_,
|
||||
use_chroma_key,
|
||||
fps,
|
||||
duration_seconds,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_image_sequence_prompt(
|
||||
animation: &str,
|
||||
prompt_text: &str,
|
||||
frame_count: u32,
|
||||
use_chroma_key: bool,
|
||||
) -> String {
|
||||
[
|
||||
format!(
|
||||
"同一角色连续 {} 帧动作序列,动作主题是 {}。",
|
||||
frame_count, animation
|
||||
),
|
||||
"固定机位,单人,全身,侧身朝右,保持同一套服装、发型、武器和体型。".to_string(),
|
||||
"帧间动作连续,姿态逐步推进,不要换人,不要跳变,不要多余物体。".to_string(),
|
||||
if use_chroma_key {
|
||||
"纯绿色背景,无地面装饰,方便后期抠像。".to_string()
|
||||
} else {
|
||||
"背景尽量纯净,避免复杂场景。".to_string()
|
||||
},
|
||||
prompt_text.trim().to_string(),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
fn build_npc_animation_prompt(
|
||||
animation: &str,
|
||||
prompt_text: &str,
|
||||
character_brief_text: Option<&str>,
|
||||
action_template_id: Option<&str>,
|
||||
loop_: bool,
|
||||
use_chroma_key: bool,
|
||||
fps: u32,
|
||||
duration_seconds: u32,
|
||||
) -> String {
|
||||
let character_brief = build_compact_animation_character_brief(character_brief_text);
|
||||
let action_detail_text = sanitize_animation_prompt_text(prompt_text, 140);
|
||||
let loop_rule = if loop_ {
|
||||
"这是循环动作,直接进入动作循环中段,不要开场静止站桩,不要把主参考图原样作为第一帧。"
|
||||
.to_string()
|
||||
} else if animation == "die" {
|
||||
"这是死亡终结动作,首帧参考主图角色形象即可,尾帧停在死亡结束姿态,不要回到主图形象。"
|
||||
.to_string()
|
||||
} else {
|
||||
"这是非循环动作,首帧和尾帧都要回到参考主图角色形象,中段完成动作变化。".to_string()
|
||||
};
|
||||
|
||||
if let Some(template) = action_template_id.and_then(|id| find_motion_template(id)) {
|
||||
return [
|
||||
format!(
|
||||
"单人 NPC 全身动作视频,动作主题是 {}。角色固定为同一人,右向斜侧身,镜头稳定,轮廓清晰,武器不可丢失。",
|
||||
template.animation
|
||||
),
|
||||
if use_chroma_key {
|
||||
"背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。".to_string()
|
||||
} else {
|
||||
"背景简洁纯净,无复杂场景。".to_string()
|
||||
},
|
||||
if character_brief.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("角色设定:{}。", character_brief)
|
||||
},
|
||||
format!("动作补充:{}。", template.prompt_suffix),
|
||||
if action_detail_text.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("动作细节:{}。", action_detail_text)
|
||||
},
|
||||
format!("目标帧率 {} fps,时长约 {} 秒。", fps.clamp(1, 60), duration_seconds.clamp(1, 8)),
|
||||
loop_rule,
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
[
|
||||
format!("单人 NPC 全身动作视频,动作主题是 {}。", animation),
|
||||
"角色固定为同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。".to_string(),
|
||||
"动作连贯,避免服装、发型、面部、武器随机漂移。".to_string(),
|
||||
if use_chroma_key {
|
||||
"背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。".to_string()
|
||||
} else {
|
||||
"背景简洁纯净,无复杂场景。".to_string()
|
||||
},
|
||||
if character_brief.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("角色设定:{}。", character_brief)
|
||||
},
|
||||
if action_detail_text.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
action_detail_text
|
||||
},
|
||||
format!(
|
||||
"目标帧率 {} fps,时长约 {} 秒。",
|
||||
fps.clamp(1, 60),
|
||||
duration_seconds.clamp(1, 8)
|
||||
),
|
||||
loop_rule,
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
fn build_ark_character_animation_prompt(
|
||||
animation: &str,
|
||||
prompt_text: &str,
|
||||
character_brief_text: Option<&str>,
|
||||
action_template_id: Option<&str>,
|
||||
loop_: bool,
|
||||
use_chroma_key: bool,
|
||||
) -> String {
|
||||
let normalized_animation_name = animation.trim().replace(char::is_whitespace, "_");
|
||||
let normalized_animation_name = if normalized_animation_name.is_empty() {
|
||||
"idle".to_string()
|
||||
} else {
|
||||
normalized_animation_name
|
||||
};
|
||||
let character_brief = build_compact_animation_character_brief(character_brief_text);
|
||||
let action_detail_text = sanitize_animation_prompt_text(prompt_text, 140);
|
||||
if let Some(template) = action_template_id.and_then(find_motion_template) {
|
||||
return build_video_action_prompt(
|
||||
template.id,
|
||||
template.prompt_suffix,
|
||||
action_detail_text.as_str(),
|
||||
Some(character_brief.as_str()),
|
||||
use_chroma_key,
|
||||
);
|
||||
}
|
||||
|
||||
build_video_action_prompt(
|
||||
normalized_animation_name.as_str(),
|
||||
if loop_ {
|
||||
"循环动作必须自然闭环,不要静止开场。"
|
||||
} else {
|
||||
"中段完成完整动作变化,收束干净。"
|
||||
},
|
||||
action_detail_text.as_str(),
|
||||
Some(character_brief.as_str()),
|
||||
use_chroma_key,
|
||||
)
|
||||
}
|
||||
|
||||
/// 角色动作视频统一提示词骨架,按每个动作模板与补充描述生成。
|
||||
fn build_video_action_prompt(
|
||||
action_id: &str,
|
||||
action_sequence: &str,
|
||||
action_detail_text: &str,
|
||||
character_brief_text: Option<&str>,
|
||||
use_chroma_key: bool,
|
||||
) -> String {
|
||||
[
|
||||
format!("单人全身角色动作视频,动作英文名是 {}。", action_id),
|
||||
"角色固定为图1同一角色,保持右向斜侧身动作视角,镜头稳定,轮廓清晰,不要退化成完全 90 度纯右视图。".to_string(),
|
||||
"视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。".to_string(),
|
||||
"主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。".to_string(),
|
||||
"画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。".to_string(),
|
||||
"风格要求:横版像素动作角色体型,头身比优先控制在 1 到 1.5 头身,保留清楚的头、躯干、双臂和双腿轮廓。明确的像素动作角色设定稿气质,整体按像素游戏角色设计方向组织,使用深色清楚轮廓、稳定剪影、有限大色块和硬朗边缘,不要柔和厚涂插画感,发型、服装、配饰优先形成醒目可读的像素级识别点,适合横版动作 sprite 资产。高可读性游戏角色设定图,形体清晰,服装层次明确,便于后续连续动作生成。".to_string(),
|
||||
format!("动作结构:{}。结尾要求:动作收束清楚,便于后续抽帧。", action_sequence),
|
||||
if use_chroma_key {
|
||||
"背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。".to_string()
|
||||
} else {
|
||||
"背景简洁纯净,无其他人物和复杂场景元素,方便后期抽帧。".to_string()
|
||||
},
|
||||
format!(
|
||||
"动作补充细节:{}",
|
||||
if action_detail_text.trim().is_empty() {
|
||||
"保持动作清晰、节奏明确、适合后续抽帧为 sprite sheet。"
|
||||
} else {
|
||||
action_detail_text.trim()
|
||||
}
|
||||
),
|
||||
character_brief_text
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| format!("角色设定:{}。", value))
|
||||
.unwrap_or_default(),
|
||||
"目标是后续抽帧为横版动作游戏精灵表,因此不要镜头切换,不要景别变化,不要角色漂移。".to_string(),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
pub(crate) fn build_fallback_moderation_safe_animation_prompt(
|
||||
animation: &str,
|
||||
loop_: bool,
|
||||
use_chroma_key: bool,
|
||||
) -> String {
|
||||
[
|
||||
format!("单人全身角色动作视频,动作主题是 {}。", animation),
|
||||
"角色固定为同一人,右向斜侧身,镜头稳定,轮廓清楚。".to_string(),
|
||||
if loop_ {
|
||||
"循环动作直接进入稳定循环,不要静止开场,不要定格首帧。".to_string()
|
||||
} else {
|
||||
"非循环动作首尾回到角色标准站姿,中段完成动作变化。".to_string()
|
||||
},
|
||||
if use_chroma_key {
|
||||
"背景为纯绿色绿幕,无其他人物和场景元素。".to_string()
|
||||
} else {
|
||||
"背景简洁纯净。".to_string()
|
||||
},
|
||||
]
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
fn sanitize_animation_prompt_text(value: &str, max_length: usize) -> String {
|
||||
value
|
||||
.replace(char::is_whitespace, " ")
|
||||
.replace("血浆", "")
|
||||
.replace("喷血", "")
|
||||
.replace("鲜血", "")
|
||||
.replace("断肢", "")
|
||||
.replace("斩首", "")
|
||||
.replace("裸体", "")
|
||||
.replace("裸露", "")
|
||||
.replace("色情", "")
|
||||
.replace("性交", "")
|
||||
.replace("死亡", "倒地结束")
|
||||
.replace("死去", "倒地结束")
|
||||
.replace("击杀", "倒地结束")
|
||||
.replace("受击", "失衡")
|
||||
.replace("受伤", "失衡")
|
||||
.replace("砍杀", "挥击")
|
||||
.replace("斩击", "挥击")
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
.chars()
|
||||
.take(max_length)
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn build_compact_animation_character_brief(value: Option<&str>) -> String {
|
||||
let normalized = sanitize_animation_prompt_text(value.unwrap_or_default(), 160);
|
||||
if normalized.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
normalized
|
||||
.split(['/', '|', '\n', ',', ',', '。', ';', ';'])
|
||||
.map(str::trim)
|
||||
.filter(|item| !item.is_empty())
|
||||
.take(4)
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
}
|
||||
pub(crate) use crate::prompt::character_animation::{
|
||||
build_character_animation_prompt, build_fallback_moderation_safe_animation_prompt,
|
||||
};
|
||||
pub(crate) use crate::prompt::character_visual::{
|
||||
build_character_visual_negative_prompt, build_character_visual_prompt,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
use platform_llm::{LlmClient, LlmMessage, LlmTextRequest};
|
||||
use crate::prompt::foundation_draft::{
|
||||
build_custom_world_framework_json_repair_prompt, build_custom_world_framework_prompt,
|
||||
build_custom_world_landmark_network_batch_json_repair_prompt,
|
||||
build_custom_world_landmark_network_batch_prompt,
|
||||
build_custom_world_landmark_seed_batch_json_repair_prompt,
|
||||
build_custom_world_landmark_seed_batch_prompt,
|
||||
build_custom_world_role_batch_json_repair_prompt, build_custom_world_role_batch_prompt,
|
||||
build_custom_world_role_outline_batch_json_repair_prompt,
|
||||
build_custom_world_role_outline_batch_prompt,
|
||||
};
|
||||
use platform_llm::{LlmClient, LlmMessage, LlmTextRequest};
|
||||
use serde_json::{Map as JsonMap, Value as JsonValue, json};
|
||||
use shared_contracts::runtime::ExecuteCustomWorldAgentActionRequest;
|
||||
use spacetime_client::CustomWorldAgentSessionRecord;
|
||||
@@ -158,7 +168,6 @@ const FOUNDATION_DRAFT_LANDMARK_COUNT: usize = 2;
|
||||
const FOUNDATION_ROLE_OUTLINE_BATCH_SIZE: usize = 2;
|
||||
const FOUNDATION_LANDMARK_BATCH_SIZE: usize = 2;
|
||||
const FOUNDATION_ROLE_DETAIL_BATCH_SIZE: usize = 2;
|
||||
const CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES: [i64; 4] = [15, 30, 60, 90];
|
||||
|
||||
async fn request_foundation_json_stage<F>(
|
||||
llm_client: &LlmClient,
|
||||
@@ -604,360 +613,6 @@ fn build_eight_anchor_foundation_text(anchor_content: &JsonValue) -> String {
|
||||
sections.join("\n")
|
||||
}
|
||||
|
||||
fn build_custom_world_framework_prompt(setting_text: &str) -> String {
|
||||
[
|
||||
"请先根据下面的玩家设定创建一份“世界核心骨架”,后续我会分步骤生成角色名单、场景名单和详细档案。".to_string(),
|
||||
"你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
|
||||
"这一步只保留世界顶层信息与一个开局归处场景,不要输出 playableNpcs、storyNpcs、landmarks,也不要展开人物和地图细节。".to_string(),
|
||||
"玩家设定:".to_string(),
|
||||
setting_text.trim().to_string(),
|
||||
"".to_string(),
|
||||
"输出 JSON 模板:".to_string(),
|
||||
"{".to_string(),
|
||||
" \"name\": \"世界名称\",".to_string(),
|
||||
" \"subtitle\": \"世界副标题\",".to_string(),
|
||||
" \"summary\": \"世界概述\",".to_string(),
|
||||
" \"tone\": \"世界基调\",".to_string(),
|
||||
" \"playerGoal\": \"玩家核心目标\",".to_string(),
|
||||
" \"templateWorldType\": \"WUXIA|XIANXIA\",".to_string(),
|
||||
" \"majorFactions\": [\"势力甲\", \"势力乙\"],".to_string(),
|
||||
" \"coreConflicts\": [\"冲突甲\", \"冲突乙\"],".to_string(),
|
||||
" \"camp\": {".to_string(),
|
||||
" \"name\": \"开局归处名称\",".to_string(),
|
||||
" \"description\": \"这是玩家进入世界后的第一处落脚点描述\",".to_string(),
|
||||
" \"sceneTaskDescription\": \"首次进入该场景时要生成的章节任务核心上下文\",".to_string(),
|
||||
" \"actBackgroundPromptTexts\": [\"开局第一幕背景画面描述\", \"开局第二幕背景画面描述\", \"开局第三幕背景画面描述\"],".to_string(),
|
||||
" \"actEventDescriptions\": [\"开局第一幕事件描述\", \"开局第二幕事件描述\", \"开局第三幕事件描述\"],".to_string(),
|
||||
" \"dangerLevel\": \"low|medium|high|extreme\"".to_string(),
|
||||
" }".to_string(),
|
||||
"}".to_string(),
|
||||
"".to_string(),
|
||||
"要求:".to_string(),
|
||||
"- 所有生成文本都必须使用中文。".to_string(),
|
||||
"- 这一步只输出顶层 9 个字段:name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、camp。".to_string(),
|
||||
"- 这是一个完全独立的自定义世界;不要在任何正文里直接写出“武侠世界”“仙侠世界”等现成世界名。".to_string(),
|
||||
"- templateWorldType 只是系统兼容字段,不代表正文应当引用的世界名称。".to_string(),
|
||||
"- camp 必须表示玩家开局时的落脚处,名字不要直接写成“某某营地”,更接近归舍、住处、栖居、前哨居所这类“家/归处”的概念。".to_string(),
|
||||
"- camp.sceneTaskDescription 必须描述玩家首次进入开局场景时要完成的核心任务,会作为游戏章节任务生成上下文,控制在 24 到 56 个汉字内。".to_string(),
|
||||
"- camp.actBackgroundPromptTexts 必须恰好 3 条,分别对应开局场景第 1/2/3 幕背景图画面内容描述;每条都必须可直接交给生图模型,控制在 40 到 90 个汉字内。".to_string(),
|
||||
"- camp.actEventDescriptions 必须恰好 3 条,分别描述每一幕发生的事件;事件必须和当前幕对面的角色强相关,控制在 24 到 56 个汉字内。".to_string(),
|
||||
"- 不要输出 playableNpcs、storyNpcs、landmarks、items,也不要输出任何角色和地图细节。".to_string(),
|
||||
"- majorFactions 保持 2 到 3 个,coreConflicts 保持 2 到 3 个。".to_string(),
|
||||
"- 世界设定必须直接源自玩家输入,不要脱离主题乱扩写。".to_string(),
|
||||
"- 每个字符串尽量简洁:subtitle 控制在 8 到 18 个汉字内,summary 控制在 16 到 32 个汉字内,tone 控制在 6 到 16 个汉字内,playerGoal 控制在 16 到 32 个汉字内,camp.description 控制在 18 到 40 个汉字内。".to_string(),
|
||||
"- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(),
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
fn build_custom_world_framework_json_repair_prompt(response_text: &str) -> String {
|
||||
[
|
||||
"下面这段文本本应是自定义世界核心骨架的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。",
|
||||
"请只输出修复后的 JSON 对象。",
|
||||
"顶层必须只包含:name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、camp。",
|
||||
"不要输出 playableNpcs、storyNpcs、landmarks、items 或任何其他字段。",
|
||||
"majorFactions 与 coreConflicts 必须是字符串数组。",
|
||||
"camp 必须是对象,且包含:name、description、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions、dangerLevel。",
|
||||
"原始文本:",
|
||||
response_text.trim(),
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
fn build_custom_world_role_outline_batch_prompt(
|
||||
framework: &JsonValue,
|
||||
role_type: &str,
|
||||
batch_count: usize,
|
||||
forbidden_names: &[String],
|
||||
) -> String {
|
||||
let key = role_key(role_type);
|
||||
let label = if role_type == "playable" {
|
||||
"可扮演角色"
|
||||
} else {
|
||||
"场景角色"
|
||||
};
|
||||
[
|
||||
format!("请根据下面的世界核心信息,生成一批{label}框架名单。"),
|
||||
"后续我会继续补全人物档案,所以这一步每个角色只保留身份骨架与资产默认描述字段。".to_string(),
|
||||
"你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
|
||||
"世界核心信息:".to_string(),
|
||||
build_framework_summary_text(framework, 0),
|
||||
if forbidden_names.is_empty() { "".to_string() } else { format!("这些名字已经生成,禁止重复:{}", forbidden_names.join("、")) },
|
||||
"".to_string(),
|
||||
"输出 JSON 模板:".to_string(),
|
||||
"{".to_string(),
|
||||
format!(" \"{key}\": ["),
|
||||
" {".to_string(),
|
||||
" \"name\": \"角色名称\",".to_string(),
|
||||
" \"title\": \"称号\",".to_string(),
|
||||
" \"role\": \"身份\",".to_string(),
|
||||
" \"description\": \"极简定位描述\",".to_string(),
|
||||
" \"visualDescription\": \"默认角色形象描述\",".to_string(),
|
||||
" \"actionDescription\": \"默认角色动作描述\",".to_string(),
|
||||
" \"sceneVisualDescription\": \"默认出现场景描述\",".to_string(),
|
||||
" \"initialAffinity\": 18,".to_string(),
|
||||
" \"relationshipHooks\": [\"一个关系切入口\"],".to_string(),
|
||||
" \"tags\": [\"标签1\", \"标签2\"]".to_string(),
|
||||
" }".to_string(),
|
||||
" ]".to_string(),
|
||||
"}".to_string(),
|
||||
"".to_string(),
|
||||
"要求:".to_string(),
|
||||
format!("- 必须生成恰好 {batch_count} 个{label}。"),
|
||||
"- 这是一个完全独立的自定义世界;不要把角色写成来自“武侠世界”“仙侠世界”等现成世界。".to_string(),
|
||||
"- 名称必须具体且互不重复,不要使用 角色1、NPC1、场景角色1 之类的占位名。".to_string(),
|
||||
"- 只保留:name、title、role、description、visualDescription、actionDescription、sceneVisualDescription、initialAffinity、relationshipHooks、tags。".to_string(),
|
||||
"- visualDescription 是打开角色形象图像生成面板时默认填入的角色形象描述,必须具体到体型、服装、轮廓与识别点,控制在 24 到 60 个汉字内。".to_string(),
|
||||
"- actionDescription 是打开每个角色动作视频生成面板时默认填入的动作描述,必须体现该角色默认动作节奏、武器或施法方式,控制在 18 到 48 个汉字内。".to_string(),
|
||||
"- sceneVisualDescription 是该角色常出现或关联的场景画面描述,会作为场景生图描述框的默认候选,控制在 24 到 60 个汉字内。".to_string(),
|
||||
"- relationshipHooks 最多 1 条;tags 保持 1 到 2 个。".to_string(),
|
||||
"- description 控制在 8 到 18 个汉字内,title 和 role 也尽量短。".to_string(),
|
||||
"- initialAffinity 必须是 -40 到 90 的整数。".to_string(),
|
||||
if role_type == "playable" { "- 可扮演角色的定位必须明显不同,通常使用 18 到 40 的初始好感。".to_string() } else { "- 场景角色要覆盖势力成员、居民、异类或怪物,不要全是同一种身份;敌对或怪物型角色可以使用负好感。".to_string() },
|
||||
"- 所有生成文本都必须使用中文。".to_string(),
|
||||
"- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(),
|
||||
].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
|
||||
fn build_custom_world_role_outline_batch_json_repair_prompt(
|
||||
response_text: &str,
|
||||
role_type: &str,
|
||||
expected_count: usize,
|
||||
forbidden_names: &[String],
|
||||
) -> String {
|
||||
let key = role_key(role_type);
|
||||
[
|
||||
format!("下面这段文本本应是自定义世界{}框架名单批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。", if role_type == "playable" { "可扮演角色" } else { "场景角色" }),
|
||||
"请只输出修复后的 JSON 对象。".to_string(),
|
||||
format!("顶层必须只包含一个 {key} 数组。"),
|
||||
format!("必须保留恰好 {expected_count} 个角色对象。"),
|
||||
if forbidden_names.is_empty() { "".to_string() } else { format!("禁止使用这些重复名:{}。", forbidden_names.join("、")) },
|
||||
"每个角色只包含:name、title、role、description、visualDescription、actionDescription、sceneVisualDescription、initialAffinity、relationshipHooks、tags。".to_string(),
|
||||
"如果缺少字段:字符串补空字符串,relationshipHooks 和 tags 补空数组,initialAffinity 补默认整数。".to_string(),
|
||||
"不要输出 backstory、skills、landmarks 或任何其他字段。".to_string(),
|
||||
"原始文本:".to_string(),
|
||||
response_text.trim().to_string(),
|
||||
].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
fn build_custom_world_landmark_seed_batch_prompt(
|
||||
framework: &JsonValue,
|
||||
batch_count: usize,
|
||||
forbidden_names: &[String],
|
||||
) -> String {
|
||||
[
|
||||
"请根据下面的世界核心信息,生成一批关键场景框架名单。".to_string(),
|
||||
"后续我会继续补全场景网络,所以这一步每个地点只保留场景骨架、地点默认生图描述和逐幕背景描述。".to_string(),
|
||||
"你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
|
||||
"世界核心信息:".to_string(),
|
||||
build_framework_summary_text(framework, 0),
|
||||
if forbidden_names.is_empty() { "".to_string() } else { format!("这些地点已经生成,禁止重复:{}", forbidden_names.join("、")) },
|
||||
"".to_string(),
|
||||
"输出 JSON 模板:".to_string(),
|
||||
"{".to_string(),
|
||||
" \"landmarks\": [".to_string(),
|
||||
" {".to_string(),
|
||||
" \"name\": \"场景名称\",".to_string(),
|
||||
" \"description\": \"场景极简描述\",".to_string(),
|
||||
" \"visualDescription\": \"默认场景生图描述\",".to_string(),
|
||||
" \"sceneTaskDescription\": \"首次进入该场景时要生成的章节任务核心上下文\",".to_string(),
|
||||
" \"actBackgroundPromptTexts\": [\"第一幕背景画面描述\", \"第二幕背景画面描述\", \"第三幕背景画面描述\"],".to_string(),
|
||||
" \"actEventDescriptions\": [\"第一幕事件描述\", \"第二幕事件描述\", \"第三幕事件描述\"],".to_string(),
|
||||
" \"dangerLevel\": \"low|medium|high|extreme\"".to_string(),
|
||||
" }".to_string(),
|
||||
" ]".to_string(),
|
||||
"}".to_string(),
|
||||
"".to_string(),
|
||||
"要求:".to_string(),
|
||||
format!("- 必须生成恰好 {batch_count} 个关键场景。"),
|
||||
"- 这是一个完全独立的自定义世界;地点名称必须直接服务玩家输入主题。".to_string(),
|
||||
"- 名称必须具体且互不重复,不要使用 地点1、场景1 之类的占位名。".to_string(),
|
||||
"- 每个地点只保留:name、description、visualDescription、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions、dangerLevel。".to_string(),
|
||||
"- sceneTaskDescription 必须描述玩家首次进入该场景时要完成的核心任务,会作为游戏章节任务生成上下文,控制在 24 到 56 个汉字内。".to_string(),
|
||||
"- visualDescription 是打开场景背景图像生成面板时默认填入的场景描述,必须具体到画面主体、远近景层次、地面可站立区域和氛围识别点,控制在 32 到 80 个汉字内。".to_string(),
|
||||
"- actBackgroundPromptTexts 必须恰好 3 条,分别对应这个场景章节的第 1/2/3 幕背景图画面内容描述;每条都必须是大模型根据当前地点、主线阶段和可出场角色直接写出的画面描述,控制在 40 到 90 个汉字内。".to_string(),
|
||||
"- actBackgroundPromptTexts 禁止使用“某某第1幕背景;玩家会在……”这类标题、摘要、规则句拼接格式;必须像可直接交给生图模型的自然画面描述。".to_string(),
|
||||
"- actEventDescriptions 必须恰好 3 条,分别描述每一幕发生的事件;事件必须和当前幕对面的角色强相关,控制在 24 到 56 个汉字内。".to_string(),
|
||||
"- description 控制在 12 到 24 个汉字内。".to_string(),
|
||||
"- dangerLevel 只能是 low、medium、high、extreme 之一。".to_string(),
|
||||
"- 所有生成文本都必须使用中文。".to_string(),
|
||||
"- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(),
|
||||
].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
|
||||
fn build_custom_world_landmark_seed_batch_json_repair_prompt(
|
||||
response_text: &str,
|
||||
expected_count: usize,
|
||||
forbidden_names: &[String],
|
||||
) -> String {
|
||||
[
|
||||
"下面这段文本本应是自定义世界关键场景框架名单批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。".to_string(),
|
||||
"请只输出修复后的 JSON 对象。".to_string(),
|
||||
"顶层必须只包含一个 landmarks 数组。".to_string(),
|
||||
format!("必须保留恰好 {expected_count} 个地点对象。"),
|
||||
if forbidden_names.is_empty() { "".to_string() } else { format!("禁止使用这些重复名:{}。", forbidden_names.join("、")) },
|
||||
"每个地点只包含:name、description、visualDescription、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions、dangerLevel。".to_string(),
|
||||
"如果缺少字段:字符串补空字符串,actBackgroundPromptTexts 和 actEventDescriptions 补空数组,dangerLevel 补 medium。".to_string(),
|
||||
"不要输出 sceneNpcNames、connectedLandmarks、items 或任何其他字段。".to_string(),
|
||||
"原始文本:".to_string(),
|
||||
response_text.trim().to_string(),
|
||||
].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
|
||||
fn build_custom_world_landmark_network_batch_prompt(
|
||||
framework: &JsonValue,
|
||||
story_npcs: &[JsonValue],
|
||||
landmark_batch: &[JsonValue],
|
||||
) -> String {
|
||||
[
|
||||
"请补全下面这一批关键场景的探索网络信息。".to_string(),
|
||||
"你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
|
||||
"世界核心信息:".to_string(),
|
||||
build_framework_summary_text(framework, 10),
|
||||
"可用场景角色名单:".to_string(),
|
||||
names_from_entries(story_npcs).join("、"),
|
||||
"本批场景:".to_string(),
|
||||
compact_json_text(&JsonValue::Array(landmark_batch.to_vec())),
|
||||
"".to_string(),
|
||||
"输出 JSON 模板:".to_string(),
|
||||
"{".to_string(),
|
||||
" \"landmarks\": [".to_string(),
|
||||
" {".to_string(),
|
||||
" \"name\": \"场景名称\",".to_string(),
|
||||
" \"description\": \"场景描述\",".to_string(),
|
||||
" \"dangerLevel\": \"low|medium|high|extreme\",".to_string(),
|
||||
" \"sceneNpcNames\": [\"会在这里出现的角色名\"],".to_string(),
|
||||
" \"connectedLandmarkNames\": [\"相邻或可通往的地点名\"],".to_string(),
|
||||
" \"entryHook\": \"玩家进入这里时首先遇到的钩子\"".to_string(),
|
||||
" }".to_string(),
|
||||
" ]".to_string(),
|
||||
"}".to_string(),
|
||||
"".to_string(),
|
||||
"要求:".to_string(),
|
||||
"- 必须只补全本批场景,name 必须与本批场景完全一致,不得增删改名。".to_string(),
|
||||
"- sceneNpcNames 只能引用上方可用场景角色名单中的名字,每个地点 1 到 3 个。".to_string(),
|
||||
"- connectedLandmarkNames 优先引用已知关键场景名称,每个地点 1 到 3 个。".to_string(),
|
||||
"- entryHook 控制在 16 到 36 个汉字内。".to_string(),
|
||||
"- 所有生成文本都必须使用中文。".to_string(),
|
||||
"- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(),
|
||||
].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
|
||||
fn build_custom_world_landmark_network_batch_json_repair_prompt(
|
||||
response_text: &str,
|
||||
expected_names: &[String],
|
||||
) -> String {
|
||||
[
|
||||
"下面这段文本本应是自定义世界关键场景探索网络补全批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。".to_string(),
|
||||
"请只输出修复后的 JSON 对象。".to_string(),
|
||||
"顶层必须只包含一个 landmarks 数组。".to_string(),
|
||||
format!("这个数组里只能保留这些地点名:{}。", expected_names.join("、")),
|
||||
"名称必须与名单完全一致,不得增删改名;如果原文遗漏,可按名单顺序补齐占位对象。".to_string(),
|
||||
"每个地点都必须包含:name、description、dangerLevel、sceneNpcNames、connectedLandmarkNames、entryHook。".to_string(),
|
||||
"如果缺少字段:字符串补空字符串,数组补空数组,dangerLevel 补 medium。".to_string(),
|
||||
"不要新增名单外的地点。".to_string(),
|
||||
"原始文本:".to_string(),
|
||||
response_text.trim().to_string(),
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
fn build_custom_world_role_batch_prompt(
|
||||
framework: &JsonValue,
|
||||
role_type: &str,
|
||||
role_batch: &[JsonValue],
|
||||
stage: &str,
|
||||
) -> String {
|
||||
let key = role_key(role_type);
|
||||
let label = if role_type == "playable" {
|
||||
"可扮演角色"
|
||||
} else {
|
||||
"场景角色"
|
||||
};
|
||||
let stage_label = if stage == "narrative" {
|
||||
"叙事档案"
|
||||
} else {
|
||||
"养成档案"
|
||||
};
|
||||
let required_fields = if stage == "narrative" {
|
||||
"name、backstory、personality、motivation、combatStyle"
|
||||
} else {
|
||||
"name、backstoryReveal、skills、initialItems"
|
||||
};
|
||||
let template_extra = if stage == "narrative" {
|
||||
[
|
||||
" \"backstory\": \"公开背景\",",
|
||||
" \"personality\": \"性格关键词\",",
|
||||
" \"motivation\": \"当前动机\",",
|
||||
" \"combatStyle\": \"行动或战斗风格\"",
|
||||
]
|
||||
.join("\n")
|
||||
} else {
|
||||
[
|
||||
" \"backstoryReveal\": { \"publicSummary\": \"公开摘要\", \"chapters\": [{ \"affinityRequired\": 15, \"title\": \"羁绊章节\", \"summary\": \"章节摘要\" }] },",
|
||||
" \"skills\": [{ \"name\": \"技能名\", \"summary\": \"技能摘要\", \"style\": \"风格\" }],",
|
||||
" \"initialItems\": [{ \"name\": \"物品名\", \"category\": \"道具\", \"quantity\": 1, \"rarity\": \"common\", \"description\": \"描述\", \"tags\": [\"标签\"] }]",
|
||||
].join("\n")
|
||||
};
|
||||
[
|
||||
format!("请为下面这一批{label}补全{stage_label}。"),
|
||||
"你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
|
||||
"世界核心信息:".to_string(),
|
||||
build_framework_summary_text(framework, 10),
|
||||
"本批角色:".to_string(),
|
||||
build_role_outline_prompt_text(role_batch, framework, role_type),
|
||||
"".to_string(),
|
||||
"输出 JSON 模板:".to_string(),
|
||||
"{".to_string(),
|
||||
format!(" \"{key}\": ["),
|
||||
" {".to_string(),
|
||||
" \"name\": \"角色名称\",".to_string(),
|
||||
template_extra,
|
||||
" }".to_string(),
|
||||
" ]".to_string(),
|
||||
"}".to_string(),
|
||||
"".to_string(),
|
||||
"要求:".to_string(),
|
||||
"- 必须只补全本批角色,name 必须与本批角色完全一致,不得增删改名。".to_string(),
|
||||
format!("- 每个角色必须包含:{required_fields}。"),
|
||||
if stage == "narrative" { "- backstory 控制在 32 到 80 个汉字内;personality、motivation、combatStyle 都要短而具体。".to_string() } else { format!("- backstoryReveal 必须包含 publicSummary 和 4 个 chapters,chapters.affinityRequired 固定为 {}。", CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.iter().map(i64::to_string).collect::<Vec<_>>().join("、")) },
|
||||
if stage == "narrative" { "- 不要输出 backstoryReveal、skills、initialItems。".to_string() } else { "- skills 默认 3 个;initialItems 默认 3 个;不要输出 backstory、personality、motivation、combatStyle。".to_string() },
|
||||
"- 所有生成文本都必须使用中文。".to_string(),
|
||||
"- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(),
|
||||
].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
|
||||
fn build_custom_world_role_batch_json_repair_prompt(
|
||||
response_text: &str,
|
||||
role_type: &str,
|
||||
stage: &str,
|
||||
expected_names: &[String],
|
||||
) -> String {
|
||||
let key = role_key(role_type);
|
||||
if stage == "narrative" {
|
||||
return [
|
||||
format!("下面这段文本本应是自定义世界{}叙事档案补全批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。", if role_type == "playable" { "可扮演角色" } else { "场景角色" }),
|
||||
"请只输出修复后的 JSON 对象。".to_string(),
|
||||
format!("顶层必须只包含一个 {key} 数组。"),
|
||||
format!("这个数组里只能保留这些角色名:{}。", expected_names.join("、")),
|
||||
"名称必须与名单完全一致,不得增删改名;如果原文遗漏,可按名单顺序补齐占位对象。".to_string(),
|
||||
"每个角色都必须包含:name、backstory、personality、motivation、combatStyle。".to_string(),
|
||||
"如果缺少字段:字符串补空字符串。".to_string(),
|
||||
"不要输出 backstoryReveal、skills、initialItems,也不要新增名单外的角色。".to_string(),
|
||||
"原始文本:".to_string(),
|
||||
response_text.trim().to_string(),
|
||||
].join("\n");
|
||||
}
|
||||
[
|
||||
format!("下面这段文本本应是自定义世界{}档案补全批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。", if role_type == "playable" { "可扮演角色" } else { "场景角色" }),
|
||||
"请只输出修复后的 JSON 对象。".to_string(),
|
||||
format!("顶层必须只包含一个 {key} 数组。"),
|
||||
format!("这个数组里只能保留这些角色名:{}。", expected_names.join("、")),
|
||||
"名称必须与名单完全一致,不得增删改名;如果原文遗漏,可按名单顺序补齐占位对象。".to_string(),
|
||||
"每个角色都必须包含:name、backstoryReveal、skills、initialItems。".to_string(),
|
||||
format!("backstoryReveal 必须包含 publicSummary 和 4 个 chapters,chapters.affinityRequired 固定为 {}。", CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.iter().map(i64::to_string).collect::<Vec<_>>().join("、")),
|
||||
"skills 默认补成 3 个对象,每个对象包含 name、summary、style;initialItems 默认补成 3 个对象,每个对象包含 name、category、quantity、rarity、description、tags。".to_string(),
|
||||
"不要输出 backstory、personality、motivation、combatStyle、landmarks,也不要新增名单外的角色。".to_string(),
|
||||
"原始文本:".to_string(),
|
||||
response_text.trim().to_string(),
|
||||
].join("\n")
|
||||
}
|
||||
#[cfg(test)]
|
||||
fn build_foundation_draft_user_prompt(session: &CustomWorldAgentSessionRecord) -> String {
|
||||
let anchor_content = to_pretty_json(&session.anchor_content);
|
||||
@@ -1071,14 +726,15 @@ fn build_foundation_draft_profile_from_framework(
|
||||
)])
|
||||
}),
|
||||
);
|
||||
let camp = framework.get("camp").cloned().unwrap_or_else(|| json!({ "name": "开局归处", "description": "玩家进入世界后的第一处落脚点。", "dangerLevel": "low" }));
|
||||
let camp = framework.get("camp").cloned().unwrap_or_else(|| json!({ "name": "开局归处", "description": "玩家进入世界后的第一处落脚点。" }));
|
||||
object.insert("camp".to_string(), camp.clone());
|
||||
object.insert(
|
||||
"playableNpcs".to_string(),
|
||||
JsonValue::Array(playable_detailed),
|
||||
);
|
||||
object.insert("storyNpcs".to_string(), JsonValue::Array(story_detailed));
|
||||
let scene_chapter_blueprints = build_scene_chapter_blueprints_from_camp_and_landmarks(&camp, &landmarks);
|
||||
let scene_chapter_blueprints =
|
||||
build_scene_chapter_blueprints_from_camp_and_landmarks(&camp, &landmarks);
|
||||
object.insert("landmarks".to_string(), JsonValue::Array(landmarks));
|
||||
object.insert("chapters".to_string(), JsonValue::Array(Vec::new()));
|
||||
object.insert(
|
||||
@@ -1127,8 +783,8 @@ fn build_scene_chapter_blueprint_from_scene(
|
||||
) -> JsonValue {
|
||||
let scene_name = json_text(scene, "name")
|
||||
.unwrap_or_else(|| format!("{}{}", fallback_name_prefix, chapter_index + 1));
|
||||
let scene_id = json_text(scene, "id")
|
||||
.unwrap_or_else(|| format!("{}-{}", id_prefix, chapter_index + 1));
|
||||
let scene_id =
|
||||
json_text(scene, "id").unwrap_or_else(|| format!("{}-{}", id_prefix, chapter_index + 1));
|
||||
let summary = json_text(scene, "description").unwrap_or_default();
|
||||
let scene_task_description = json_text(scene, "sceneTaskDescription")
|
||||
.unwrap_or_else(|| build_default_scene_task_description(&scene_name, &summary));
|
||||
@@ -1201,7 +857,9 @@ fn build_scene_act_blueprint_from_landmark(
|
||||
|
||||
fn build_default_scene_task_description(scene_name: &str, scene_summary: &str) -> String {
|
||||
if scene_summary.trim().is_empty() {
|
||||
return format!("首次进入{scene_name}时,确认当前场景的核心异常、关键角色与下一步行动方向。");
|
||||
return format!(
|
||||
"首次进入{scene_name}时,确认当前场景的核心异常、关键角色与下一步行动方向。"
|
||||
);
|
||||
}
|
||||
format!("首次进入{scene_name}时,围绕{scene_summary}确认核心异常、关键角色与下一步行动方向。")
|
||||
}
|
||||
@@ -1269,7 +927,7 @@ fn normalize_framework_shape(framework: &mut JsonValue, setting_text: &str) {
|
||||
object.insert("coreConflicts".to_string(), JsonValue::Array(Vec::new()));
|
||||
}
|
||||
if !object.get("camp").is_some_and(JsonValue::is_object) {
|
||||
object.insert("camp".to_string(), json!({ "name": "开局归处", "description": "玩家进入世界后的第一处落脚点。", "dangerLevel": "low" }));
|
||||
object.insert("camp".to_string(), json!({ "name": "开局归处", "description": "玩家进入世界后的第一处落脚点。" }));
|
||||
}
|
||||
if let Some(camp) = object.get_mut("camp").and_then(JsonValue::as_object_mut) {
|
||||
let camp_name = camp
|
||||
@@ -1350,131 +1008,6 @@ fn normalize_framework_shape(framework: &mut JsonValue, setting_text: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
fn build_framework_summary_text(framework: &JsonValue, max_landmarks: usize) -> String {
|
||||
let landmark_text = array_field(framework, "landmarks")
|
||||
.into_iter()
|
||||
.take(max_landmarks)
|
||||
.map(|landmark| {
|
||||
format!(
|
||||
"{}({},{})",
|
||||
json_text(&landmark, "name").unwrap_or_default(),
|
||||
json_text(&landmark, "dangerLevel").unwrap_or_default(),
|
||||
json_text(&landmark, "description").unwrap_or_default()
|
||||
)
|
||||
})
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("、");
|
||||
[
|
||||
format!("世界:{}", json_text(framework, "name").unwrap_or_default()),
|
||||
format!(
|
||||
"副标题:{}",
|
||||
json_text(framework, "subtitle").unwrap_or_default()
|
||||
),
|
||||
format!(
|
||||
"世界概述:{}",
|
||||
json_text(framework, "summary").unwrap_or_default()
|
||||
),
|
||||
format!(
|
||||
"世界基调:{}",
|
||||
json_text(framework, "tone").unwrap_or_default()
|
||||
),
|
||||
format!(
|
||||
"玩家核心目标:{}",
|
||||
json_text(framework, "playerGoal").unwrap_or_default()
|
||||
),
|
||||
json_string_array(framework, "majorFactions")
|
||||
.map(|items| format!("主要势力:{}", items.join("、")))
|
||||
.unwrap_or_default(),
|
||||
json_string_array(framework, "coreConflicts")
|
||||
.map(|items| format!("核心冲突:{}", items.join("、")))
|
||||
.unwrap_or_default(),
|
||||
format!(
|
||||
"开局归处:{}({})",
|
||||
json_path_text(framework, &["camp", "name"]).unwrap_or_default(),
|
||||
json_path_text(framework, &["camp", "description"]).unwrap_or_default()
|
||||
),
|
||||
if landmark_text.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("关键场景:{landmark_text}")
|
||||
},
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn build_role_outline_prompt_text(
|
||||
role_batch: &[JsonValue],
|
||||
framework: &JsonValue,
|
||||
role_type: &str,
|
||||
) -> String {
|
||||
role_batch
|
||||
.iter()
|
||||
.map(|role| {
|
||||
let appearance_text = if role_type == "story" {
|
||||
landmark_names_for_role(
|
||||
framework,
|
||||
json_text(role, "name").unwrap_or_default().as_str(),
|
||||
)
|
||||
.join("、")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
[
|
||||
format!(
|
||||
"- {} / {}",
|
||||
json_text(role, "name").unwrap_or_default(),
|
||||
json_text(role, "title").unwrap_or_default()
|
||||
),
|
||||
format!("身份:{}", json_text(role, "role").unwrap_or_default()),
|
||||
format!(
|
||||
"框架描述:{}",
|
||||
json_text(role, "description").unwrap_or_default()
|
||||
),
|
||||
format!(
|
||||
"预设好感:{}",
|
||||
role.get("initialAffinity")
|
||||
.and_then(JsonValue::as_i64)
|
||||
.unwrap_or(0)
|
||||
),
|
||||
json_string_array(role, "relationshipHooks")
|
||||
.map(|items| format!("关系切入口:{}", items.join("、")))
|
||||
.unwrap_or_default(),
|
||||
json_string_array(role, "tags")
|
||||
.map(|items| format!("标签:{}", items.join("、")))
|
||||
.unwrap_or_default(),
|
||||
if appearance_text.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("出现场景:{appearance_text}")
|
||||
},
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn landmark_names_for_role(framework: &JsonValue, role_name: &str) -> Vec<String> {
|
||||
array_field(framework, "landmarks")
|
||||
.into_iter()
|
||||
.filter_map(|landmark| {
|
||||
let names = json_string_array(&landmark, "sceneNpcNames").unwrap_or_default();
|
||||
if names.iter().any(|name| name == role_name) {
|
||||
json_text(&landmark, "name")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn role_key(role_type: &str) -> &'static str {
|
||||
if role_type == "playable" {
|
||||
"playableNpcs"
|
||||
@@ -1679,8 +1212,9 @@ fn normalize_scene_chapter_blueprint(chapter: JsonValue) -> JsonValue {
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("第一幕");
|
||||
object.insert("title".to_string(), JsonValue::String(title.to_string()));
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| "第一幕".to_string());
|
||||
object.insert("title".to_string(), JsonValue::String(title.clone()));
|
||||
let summary = object
|
||||
.get("summary")
|
||||
.and_then(JsonValue::as_str)
|
||||
@@ -1695,7 +1229,7 @@ fn normalize_scene_chapter_blueprint(chapter: JsonValue) -> JsonValue {
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| build_default_scene_task_description(title, summary.as_str()));
|
||||
.unwrap_or_else(|| build_default_scene_task_description(title.as_str(), summary.as_str()));
|
||||
object.insert(
|
||||
"sceneTaskDescription".to_string(),
|
||||
JsonValue::String(scene_task_description),
|
||||
@@ -1794,12 +1328,18 @@ fn normalize_scene_act_blueprint(act: JsonValue, index: usize) -> JsonValue {
|
||||
.unwrap_or_else(|| {
|
||||
build_default_act_event_description(summary.as_str(), opposite_npc_id.as_str(), index)
|
||||
});
|
||||
object.insert("encounterNpcIds".to_string(), JsonValue::Array(encounter_npc_ids));
|
||||
object.insert(
|
||||
"encounterNpcIds".to_string(),
|
||||
JsonValue::Array(encounter_npc_ids),
|
||||
);
|
||||
object.insert(
|
||||
"primaryNpcId".to_string(),
|
||||
JsonValue::String(opposite_npc_id.clone()),
|
||||
);
|
||||
object.insert("oppositeNpcId".to_string(), JsonValue::String(opposite_npc_id));
|
||||
object.insert(
|
||||
"oppositeNpcId".to_string(),
|
||||
JsonValue::String(opposite_npc_id),
|
||||
);
|
||||
object.insert(
|
||||
"eventDescription".to_string(),
|
||||
JsonValue::String(event_description),
|
||||
@@ -1979,11 +1519,17 @@ mod tests {
|
||||
let landmarks = vec![json!({
|
||||
"name": "雾港码头",
|
||||
"description": "旧船骨露出黑潮。",
|
||||
"sceneTaskDescription": "首次进入雾港码头时,查明黑潮船骨与灯童丁目击证词的关联。",
|
||||
"actBackgroundPromptTexts": [
|
||||
"潮湿木栈桥在青灰雾里延伸,近处有可站立的破旧甲板,远处旧船骨与灯塔剪影压低天空。",
|
||||
"封锁绳与巡海灯横切码头,中景堆满浸水货箱,远景黑潮拍打沉船残骸。",
|
||||
"退潮后的泥滩露出父亲留下的海图匣,雾中灯火错位闪烁,岸边留出对峙站位。"
|
||||
],
|
||||
"actEventDescriptions": [
|
||||
"灯童丁在潮湿栈桥上拦住玩家,交出与旧船骨有关的第一句证词。",
|
||||
"灯童丁发现巡海灯突然转向,逼玩家判断封锁线真正保护的目标。",
|
||||
"灯童丁指认海图匣位置,玩家必须在退潮前确认父亲留下的暗号。"
|
||||
],
|
||||
"sceneNpcNames": ["灯童丁"]
|
||||
})];
|
||||
|
||||
@@ -2000,6 +1546,20 @@ mod tests {
|
||||
"潮湿木栈桥在青灰雾里延伸,近处有可站立的破旧甲板,远处旧船骨与灯塔剪影压低天空。"
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
blueprints[0].get("sceneTaskDescription"),
|
||||
Some(&json!(
|
||||
"首次进入雾港码头时,查明黑潮船骨与灯童丁目击证词的关联。"
|
||||
))
|
||||
);
|
||||
assert_eq!(acts[0].get("oppositeNpcId"), Some(&json!("灯童丁")));
|
||||
assert_eq!(acts[0].get("primaryNpcId"), Some(&json!("灯童丁")));
|
||||
assert_eq!(
|
||||
acts[0].get("eventDescription"),
|
||||
Some(&json!(
|
||||
"灯童丁在潮湿栈桥上拦住玩家,交出与旧船骨有关的第一句证词。"
|
||||
))
|
||||
);
|
||||
assert!(
|
||||
!acts[0]
|
||||
.get("backgroundPromptText")
|
||||
@@ -2014,8 +1574,7 @@ mod tests {
|
||||
let mut framework = json!({
|
||||
"camp": {
|
||||
"name": "萧家祖宅",
|
||||
"description": "玩家开局并成长的家族祖宅。",
|
||||
"dangerLevel": "low"
|
||||
"description": "玩家开局并成长的家族祖宅。"
|
||||
}
|
||||
});
|
||||
normalize_framework_shape(&mut framework, "废柴少年的逆袭传奇");
|
||||
@@ -2038,10 +1597,22 @@ mod tests {
|
||||
|
||||
assert_eq!(opening_chapter.get("sceneId"), Some(&json!("camp-1")));
|
||||
assert_eq!(opening_acts.len(), 3);
|
||||
assert!(opening_acts.iter().all(|act| act
|
||||
.get("backgroundPromptText")
|
||||
.and_then(JsonValue::as_str)
|
||||
.is_some_and(|value| !value.trim().is_empty())));
|
||||
assert!(opening_acts.iter().all(|act| {
|
||||
act.get("backgroundPromptText")
|
||||
.and_then(JsonValue::as_str)
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
}));
|
||||
assert!(
|
||||
opening_chapter
|
||||
.get("sceneTaskDescription")
|
||||
.and_then(JsonValue::as_str)
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
);
|
||||
assert!(opening_acts.iter().all(|act| {
|
||||
act.get("eventDescription")
|
||||
.and_then(JsonValue::as_str)
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
}));
|
||||
assert_eq!(blueprints.len(), 2);
|
||||
}
|
||||
|
||||
@@ -2056,6 +1627,11 @@ mod tests {
|
||||
);
|
||||
|
||||
assert_eq!(act.get("backgroundPromptText"), Some(&json!("")));
|
||||
assert!(
|
||||
act.get("eventDescription")
|
||||
.and_then(JsonValue::as_str)
|
||||
.is_some_and(|value| value.contains("玩家进入雾港码头"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2127,7 +1703,7 @@ mod tests {
|
||||
request_capture.clone(),
|
||||
vec![
|
||||
llm_response(
|
||||
r#"{"name":"雾港归航","subtitle":"失灯旧案","summary":"守灯人与群岛议会围绕沉船旧案对峙。","tone":"海雾悬疑","playerGoal":"查清父亲沉船真相","templateWorldType":"WUXIA","majorFactions":["群岛议会","灯塔署"],"coreConflicts":["守灯塔的旧档案被人改写。"],"camp":{"name":"旧灯塔归舍","description":"海雾边缘的守灯人旧居。","dangerLevel":"low"}}"#,
|
||||
r#"{"name":"雾港归航","subtitle":"失灯旧案","summary":"守灯人与群岛议会围绕沉船旧案对峙。","tone":"海雾悬疑","playerGoal":"查清父亲沉船真相","templateWorldType":"WUXIA","majorFactions":["群岛议会","灯塔署"],"coreConflicts":["守灯塔的旧档案被人改写。"],"camp":{"name":"旧灯塔归舍","description":"海雾边缘的守灯人旧居。"}}"#,
|
||||
),
|
||||
llm_response(
|
||||
r#"{"playableNpcs":[{"name":"岑灯","title":"返乡守灯人","role":"主角代理","description":"追查旧案的人","initialAffinity":24,"relationshipHooks":["旧案牵连"],"tags":["守灯人"]}]}"#,
|
||||
@@ -2145,10 +1721,10 @@ mod tests {
|
||||
r#"{"storyNpcs":[{"name":"档吏庚","title":"旧档吏","role":"保管者","description":"藏起原始卷宗","initialAffinity":10,"relationshipHooks":["原始卷宗"],"tags":["档案"]},{"name":"潮女辛","title":"听潮女","role":"引路人","description":"听懂海雾低语","initialAffinity":35,"relationshipHooks":["海雾低语"],"tags":["引路"]}]}"#,
|
||||
),
|
||||
llm_response(
|
||||
r#"{"landmarks":[{"name":"旧灯塔","description":"雾中仍亮着错位灯火","dangerLevel":"medium"},{"name":"沉船湾","description":"退潮后露出旧船骨","dangerLevel":"high"}]}"#,
|
||||
r#"{"landmarks":[{"name":"旧灯塔","description":"雾中仍亮着错位灯火"},{"name":"沉船湾","description":"退潮后露出旧船骨"}]}"#,
|
||||
),
|
||||
llm_response(
|
||||
r#"{"landmarks":[{"name":"旧灯塔","description":"雾中仍亮着错位灯火","dangerLevel":"medium","sceneNpcNames":["灯童丁","档吏庚"],"connectedLandmarkNames":["沉船湾"],"entryHook":"灯火按被篡改的航线闪烁。"},{"name":"沉船湾","description":"退潮后露出旧船骨","dangerLevel":"high","sceneNpcNames":["船魂戊","潮医乙"],"connectedLandmarkNames":["旧灯塔"],"entryHook":"旧船骨里传出父亲留下的暗号。"}]}"#,
|
||||
r#"{"landmarks":[{"name":"旧灯塔","description":"雾中仍亮着错位灯火","sceneNpcNames":["灯童丁","档吏庚"],"connectedLandmarkNames":["沉船湾"],"entryHook":"灯火按被篡改的航线闪烁。"},{"name":"沉船湾","description":"退潮后露出旧船骨","sceneNpcNames":["船魂戊","潮医乙"],"connectedLandmarkNames":["旧灯塔"],"entryHook":"旧船骨里传出父亲留下的暗号。"}]}"#,
|
||||
),
|
||||
llm_response(
|
||||
r#"{"playableNpcs":[{"name":"岑灯","backstory":"被停职的守灯人返乡后发现父亲沉船案被改写。","personality":"克制执拗","motivation":"查清父亲沉船真相","combatStyle":"借灯火与海图周旋"}]}"#,
|
||||
|
||||
@@ -36,12 +36,14 @@ mod logout_all;
|
||||
mod password_entry;
|
||||
mod password_management;
|
||||
mod phone_auth;
|
||||
mod prompt;
|
||||
mod puzzle;
|
||||
mod puzzle_agent_turn;
|
||||
mod refresh_session;
|
||||
mod request_context;
|
||||
mod response_headers;
|
||||
mod runtime_browse_history;
|
||||
mod runtime_chat;
|
||||
mod runtime_inventory;
|
||||
mod runtime_profile;
|
||||
mod runtime_save;
|
||||
|
||||
297
server-rs/crates/api-server/src/prompt/character_animation.rs
Normal file
297
server-rs/crates/api-server/src/prompt/character_animation.rs
Normal file
@@ -0,0 +1,297 @@
|
||||
use crate::character_animation_assets::find_motion_template;
|
||||
use shared_contracts::assets::CharacterAnimationStrategy;
|
||||
|
||||
pub(crate) fn build_character_animation_prompt(
|
||||
strategy: &CharacterAnimationStrategy,
|
||||
prompt_text: &str,
|
||||
character_brief_text: Option<&str>,
|
||||
action_template_id: Option<&str>,
|
||||
animation: &str,
|
||||
frame_count: u32,
|
||||
fps: u32,
|
||||
duration_seconds: u32,
|
||||
loop_: bool,
|
||||
use_chroma_key: bool,
|
||||
) -> String {
|
||||
match strategy {
|
||||
CharacterAnimationStrategy::ImageToVideo => build_ark_character_animation_prompt(
|
||||
animation,
|
||||
prompt_text,
|
||||
character_brief_text,
|
||||
action_template_id,
|
||||
loop_,
|
||||
use_chroma_key,
|
||||
),
|
||||
CharacterAnimationStrategy::ImageSequence => {
|
||||
build_image_sequence_prompt(animation, prompt_text, frame_count, use_chroma_key)
|
||||
}
|
||||
CharacterAnimationStrategy::MotionTransfer
|
||||
| CharacterAnimationStrategy::ReferenceToVideo => build_npc_animation_prompt(
|
||||
animation,
|
||||
prompt_text,
|
||||
character_brief_text,
|
||||
action_template_id,
|
||||
loop_,
|
||||
use_chroma_key,
|
||||
fps,
|
||||
duration_seconds,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_image_sequence_prompt(
|
||||
animation: &str,
|
||||
prompt_text: &str,
|
||||
frame_count: u32,
|
||||
use_chroma_key: bool,
|
||||
) -> String {
|
||||
[
|
||||
format!(
|
||||
"同一角色连续 {} 帧动作序列,动作主题是 {}。",
|
||||
frame_count, animation
|
||||
),
|
||||
"固定机位,单人,全身,侧身朝右,保持同一套服装、发型、武器和体型。".to_string(),
|
||||
"帧间动作连续,姿态逐步推进,不要换人,不要跳变,不要多余物体。".to_string(),
|
||||
if use_chroma_key {
|
||||
"纯绿色背景,无地面装饰,方便后期抠像。".to_string()
|
||||
} else {
|
||||
"背景尽量纯净,避免复杂场景。".to_string()
|
||||
},
|
||||
prompt_text.trim().to_string(),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
fn build_npc_animation_prompt(
|
||||
animation: &str,
|
||||
prompt_text: &str,
|
||||
character_brief_text: Option<&str>,
|
||||
action_template_id: Option<&str>,
|
||||
loop_: bool,
|
||||
use_chroma_key: bool,
|
||||
fps: u32,
|
||||
duration_seconds: u32,
|
||||
) -> String {
|
||||
let character_brief = build_compact_animation_character_brief(character_brief_text);
|
||||
let action_detail_text = sanitize_animation_prompt_text(prompt_text, 140);
|
||||
let loop_rule = if loop_ {
|
||||
"这是循环动作,直接进入动作循环中段,不要开场静止站桩,不要把主参考图原样作为第一帧。"
|
||||
.to_string()
|
||||
} else if animation == "die" {
|
||||
"这是死亡终结动作,首帧参考主图角色形象即可,尾帧停在死亡结束姿态,不要回到主图形象。"
|
||||
.to_string()
|
||||
} else {
|
||||
"这是非循环动作,首帧和尾帧都要回到参考主图角色形象,中段完成动作变化。".to_string()
|
||||
};
|
||||
|
||||
if let Some(template) = action_template_id.and_then(|id| find_motion_template(id)) {
|
||||
return [
|
||||
format!(
|
||||
"单人 NPC 全身动作视频,动作主题是 {}。角色固定为同一人,右向斜侧身,镜头稳定,轮廓清晰,武器不可丢失。",
|
||||
template.animation
|
||||
),
|
||||
if use_chroma_key {
|
||||
"背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。".to_string()
|
||||
} else {
|
||||
"背景简洁纯净,无复杂场景。".to_string()
|
||||
},
|
||||
if character_brief.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("角色设定:{}。", character_brief)
|
||||
},
|
||||
format!("动作补充:{}。", template.prompt_suffix),
|
||||
if action_detail_text.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("动作细节:{}。", action_detail_text)
|
||||
},
|
||||
format!("目标帧率 {} fps,时长约 {} 秒。", fps.clamp(1, 60), duration_seconds.clamp(1, 8)),
|
||||
loop_rule,
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
[
|
||||
format!("单人 NPC 全身动作视频,动作主题是 {}。", animation),
|
||||
"角色固定为同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。".to_string(),
|
||||
"动作连贯,避免服装、发型、面部、武器随机漂移。".to_string(),
|
||||
if use_chroma_key {
|
||||
"背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。".to_string()
|
||||
} else {
|
||||
"背景简洁纯净,无复杂场景。".to_string()
|
||||
},
|
||||
if character_brief.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("角色设定:{}。", character_brief)
|
||||
},
|
||||
if action_detail_text.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
action_detail_text
|
||||
},
|
||||
format!(
|
||||
"目标帧率 {} fps,时长约 {} 秒。",
|
||||
fps.clamp(1, 60),
|
||||
duration_seconds.clamp(1, 8)
|
||||
),
|
||||
loop_rule,
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
fn build_ark_character_animation_prompt(
|
||||
animation: &str,
|
||||
prompt_text: &str,
|
||||
character_brief_text: Option<&str>,
|
||||
action_template_id: Option<&str>,
|
||||
loop_: bool,
|
||||
use_chroma_key: bool,
|
||||
) -> String {
|
||||
let normalized_animation_name = animation.trim().replace(char::is_whitespace, "_");
|
||||
let normalized_animation_name = if normalized_animation_name.is_empty() {
|
||||
"idle".to_string()
|
||||
} else {
|
||||
normalized_animation_name
|
||||
};
|
||||
let character_brief = build_compact_animation_character_brief(character_brief_text);
|
||||
let action_detail_text = sanitize_animation_prompt_text(prompt_text, 140);
|
||||
if let Some(template) = action_template_id.and_then(find_motion_template) {
|
||||
return build_video_action_prompt(
|
||||
template.id,
|
||||
template.prompt_suffix,
|
||||
action_detail_text.as_str(),
|
||||
Some(character_brief.as_str()),
|
||||
use_chroma_key,
|
||||
);
|
||||
}
|
||||
|
||||
build_video_action_prompt(
|
||||
normalized_animation_name.as_str(),
|
||||
if loop_ {
|
||||
"循环动作必须自然闭环,不要静止开场。"
|
||||
} else {
|
||||
"中段完成完整动作变化,收束干净。"
|
||||
},
|
||||
action_detail_text.as_str(),
|
||||
Some(character_brief.as_str()),
|
||||
use_chroma_key,
|
||||
)
|
||||
}
|
||||
|
||||
/// 角色动作视频统一提示词骨架,按每个动作模板与补充描述生成。
|
||||
fn build_video_action_prompt(
|
||||
action_id: &str,
|
||||
action_sequence: &str,
|
||||
action_detail_text: &str,
|
||||
character_brief_text: Option<&str>,
|
||||
use_chroma_key: bool,
|
||||
) -> String {
|
||||
[
|
||||
format!("单人全身角色动作视频,动作英文名是 {}。", action_id),
|
||||
"角色固定为图1同一角色,保持右向斜侧身动作视角,镜头稳定,轮廓清晰,不要退化成完全 90 度纯右视图。".to_string(),
|
||||
"视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。".to_string(),
|
||||
"主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。".to_string(),
|
||||
"画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。".to_string(),
|
||||
"风格要求:横版像素动作角色体型,头身比优先控制在 1 到 1.5 头身,保留清楚的头、躯干、双臂和双腿轮廓。明确的像素动作角色设定稿气质,整体按像素游戏角色设计方向组织,使用深色清楚轮廓、稳定剪影、有限大色块和硬朗边缘,不要柔和厚涂插画感,发型、服装、配饰优先形成醒目可读的像素级识别点,适合横版动作 sprite 资产。高可读性游戏角色设定图,形体清晰,服装层次明确,便于后续连续动作生成。".to_string(),
|
||||
format!("动作结构:{}。结尾要求:动作收束清楚,便于后续抽帧。", action_sequence),
|
||||
if use_chroma_key {
|
||||
"背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。".to_string()
|
||||
} else {
|
||||
"背景简洁纯净,无其他人物和复杂场景元素,方便后期抽帧。".to_string()
|
||||
},
|
||||
format!(
|
||||
"动作补充细节:{}",
|
||||
if action_detail_text.trim().is_empty() {
|
||||
"保持动作清晰、节奏明确、适合后续抽帧为 sprite sheet。"
|
||||
} else {
|
||||
action_detail_text.trim()
|
||||
}
|
||||
),
|
||||
character_brief_text
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| format!("角色设定:{}。", value))
|
||||
.unwrap_or_default(),
|
||||
"目标是后续抽帧为横版动作游戏精灵表,因此不要镜头切换,不要景别变化,不要角色漂移。".to_string(),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
pub(crate) fn build_fallback_moderation_safe_animation_prompt(
|
||||
animation: &str,
|
||||
loop_: bool,
|
||||
use_chroma_key: bool,
|
||||
) -> String {
|
||||
[
|
||||
format!("单人全身角色动作视频,动作主题是 {}。", animation),
|
||||
"角色固定为同一人,右向斜侧身,镜头稳定,轮廓清楚。".to_string(),
|
||||
if loop_ {
|
||||
"循环动作直接进入稳定循环,不要静止开场,不要定格首帧。".to_string()
|
||||
} else {
|
||||
"非循环动作首尾回到角色标准站姿,中段完成动作变化。".to_string()
|
||||
},
|
||||
if use_chroma_key {
|
||||
"背景为纯绿色绿幕,无其他人物和场景元素。".to_string()
|
||||
} else {
|
||||
"背景简洁纯净。".to_string()
|
||||
},
|
||||
]
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
fn sanitize_animation_prompt_text(value: &str, max_length: usize) -> String {
|
||||
value
|
||||
.replace(char::is_whitespace, " ")
|
||||
.replace("血浆", "")
|
||||
.replace("喷血", "")
|
||||
.replace("鲜血", "")
|
||||
.replace("断肢", "")
|
||||
.replace("斩首", "")
|
||||
.replace("裸体", "")
|
||||
.replace("裸露", "")
|
||||
.replace("色情", "")
|
||||
.replace("性交", "")
|
||||
.replace("死亡", "倒地结束")
|
||||
.replace("死去", "倒地结束")
|
||||
.replace("击杀", "倒地结束")
|
||||
.replace("受击", "失衡")
|
||||
.replace("受伤", "失衡")
|
||||
.replace("砍杀", "挥击")
|
||||
.replace("斩击", "挥击")
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
.chars()
|
||||
.take(max_length)
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn build_compact_animation_character_brief(value: Option<&str>) -> String {
|
||||
let normalized = sanitize_animation_prompt_text(value.unwrap_or_default(), 160);
|
||||
if normalized.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
normalized
|
||||
.split(['/', '|', '\n', ',', ',', '。', ';', ';'])
|
||||
.map(str::trim)
|
||||
.filter(|item| !item.is_empty())
|
||||
.take(4)
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
}
|
||||
67
server-rs/crates/api-server/src/prompt/character_visual.rs
Normal file
67
server-rs/crates/api-server/src/prompt/character_visual.rs
Normal file
@@ -0,0 +1,67 @@
|
||||
/// 自定义世界角色主图提示词脚本。
|
||||
pub(crate) fn build_character_visual_prompt(
|
||||
prompt_text: &str,
|
||||
character_brief_text: Option<&str>,
|
||||
) -> String {
|
||||
let character_brief = [character_brief_text.unwrap_or_default(), prompt_text]
|
||||
.into_iter()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
build_master_prompt(character_brief.as_str())
|
||||
}
|
||||
|
||||
/// 角色主图统一提示词骨架,迁移自旧共享 qwenSprite 主链。
|
||||
fn build_master_prompt(character_brief: &str) -> String {
|
||||
[
|
||||
"单人,2D 横版游戏角色标准设定图,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。".to_string(),
|
||||
"视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。".to_string(),
|
||||
"主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。".to_string(),
|
||||
"画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。".to_string(),
|
||||
"风格要求:横版像素动作角色体型,头身比优先控制在 1 到 1.5 头身,保留清楚的头、躯干、双臂和双腿轮廓。明确的像素动作角色设定稿气质,整体按像素游戏角色设计方向组织,使用深色清楚轮廓、稳定剪影、有限大色块和硬朗边缘,不要柔和厚涂插画感,发型、服装、配饰优先形成醒目可读的像素级识别点,适合横版动作 sprite 资产。高可读性游戏角色设定图,形体清晰,服装层次明确,便于后续连续动作生成。".to_string(),
|
||||
"请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,只有当文字设定明确要求非人结构时,才改为对应非人身体。".to_string(),
|
||||
"主题词默认只作用在角色自身的服装剪裁、材质、纹样、饰品、发光细节上,不要把主题词自动扩写成背景建筑、自然场景、漂浮装饰或额外环境物件。".to_string(),
|
||||
"视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。".to_string(),
|
||||
character_brief.trim().to_string(),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// 自定义世界角色主图负面提示词脚本。
|
||||
pub(crate) fn build_character_visual_negative_prompt() -> String {
|
||||
[
|
||||
"正面视角",
|
||||
"左朝向",
|
||||
"完全 90 度纯右视图",
|
||||
"镜头透视",
|
||||
"半身像",
|
||||
"脚被裁切",
|
||||
"头顶被裁切",
|
||||
"多角色",
|
||||
"复杂背景",
|
||||
"建筑场景",
|
||||
"漂浮物",
|
||||
"烟雾环境",
|
||||
"武器消失",
|
||||
"武器换手",
|
||||
"额外手臂",
|
||||
"额外腿",
|
||||
"服装变化",
|
||||
"脸部变化",
|
||||
"模糊",
|
||||
"运动模糊",
|
||||
"文字",
|
||||
"水印",
|
||||
"UI 元素",
|
||||
"软萌 Q版大头贴",
|
||||
"儿童绘本风",
|
||||
"厚涂插画感",
|
||||
"低对比柔边",
|
||||
]
|
||||
.join(",")
|
||||
}
|
||||
534
server-rs/crates/api-server/src/prompt/foundation_draft.rs
Normal file
534
server-rs/crates/api-server/src/prompt/foundation_draft.rs
Normal file
@@ -0,0 +1,534 @@
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
const CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES: [i64; 4] = [15, 30, 60, 90];
|
||||
|
||||
pub(crate) fn build_custom_world_framework_prompt(setting_text: &str) -> String {
|
||||
[
|
||||
"请先根据下面的玩家设定创建一份“世界核心骨架”,后续我会分步骤生成角色名单、场景名单和详细档案。".to_string(),
|
||||
"你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
|
||||
"这一步只保留世界顶层信息与一个开局归处场景,不要输出 playableNpcs、storyNpcs、landmarks,也不要展开人物和地图细节。".to_string(),
|
||||
"玩家设定:".to_string(),
|
||||
setting_text.trim().to_string(),
|
||||
"".to_string(),
|
||||
"输出 JSON 模板:".to_string(),
|
||||
"{".to_string(),
|
||||
" \"name\": \"世界名称\",".to_string(),
|
||||
" \"subtitle\": \"世界副标题\",".to_string(),
|
||||
" \"summary\": \"世界概述\",".to_string(),
|
||||
" \"tone\": \"世界基调\",".to_string(),
|
||||
" \"playerGoal\": \"玩家核心目标\",".to_string(),
|
||||
" \"templateWorldType\": \"WUXIA|XIANXIA\",".to_string(),
|
||||
" \"majorFactions\": [\"势力甲\", \"势力乙\"],".to_string(),
|
||||
" \"coreConflicts\": [\"冲突甲\", \"冲突乙\"],".to_string(),
|
||||
" \"camp\": {".to_string(),
|
||||
" \"name\": \"开局归处名称\",".to_string(),
|
||||
" \"description\": \"这是玩家进入世界后的第一处落脚点描述\",".to_string(),
|
||||
" \"sceneTaskDescription\": \"首次进入该场景时要生成的章节任务核心上下文\",".to_string(),
|
||||
" \"actBackgroundPromptTexts\": [\"开局第一幕背景画面描述\", \"开局第二幕背景画面描述\", \"开局第三幕背景画面描述\"],".to_string(),
|
||||
" \"actEventDescriptions\": [\"开局第一幕事件描述\", \"开局第二幕事件描述\", \"开局第三幕事件描述\"],".to_string(),
|
||||
" }".to_string(),
|
||||
"}".to_string(),
|
||||
"".to_string(),
|
||||
"要求:".to_string(),
|
||||
"- 所有生成文本都必须使用中文。".to_string(),
|
||||
"- 这一步只输出顶层 9 个字段:name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、camp。".to_string(),
|
||||
"- 这是一个完全独立的自定义世界;不要在任何正文里直接写出“武侠世界”“仙侠世界”等现成世界名。".to_string(),
|
||||
"- templateWorldType 只是系统兼容字段,不代表正文应当引用的世界名称。".to_string(),
|
||||
"- camp 必须表示玩家开局时的落脚处,名字不要直接写成“某某营地”,更接近归舍、住处、栖居、前哨居所这类“家/归处”的概念。".to_string(),
|
||||
"- camp.sceneTaskDescription 必须描述玩家首次进入开局场景时要完成的核心任务,会作为游戏章节任务生成上下文,控制在 24 到 56 个汉字内。".to_string(),
|
||||
"- camp.actBackgroundPromptTexts 必须恰好 3 条,分别对应开局场景第 1/2/3 幕背景图画面内容描述;每条都必须可直接交给生图模型,控制在 40 到 90 个汉字内。".to_string(),
|
||||
"- camp.actEventDescriptions 必须恰好 3 条,分别描述每一幕发生的事件;事件必须和当前幕对面的角色强相关,控制在 24 到 56 个汉字内。".to_string(),
|
||||
"- 不要输出 playableNpcs、storyNpcs、landmarks、items,也不要输出任何角色和地图细节。".to_string(),
|
||||
"- majorFactions 保持 2 到 3 个,coreConflicts 保持 2 到 3 个。".to_string(),
|
||||
"- 世界设定必须直接源自玩家输入,不要脱离主题乱扩写。".to_string(),
|
||||
"- 每个字符串尽量简洁:subtitle 控制在 8 到 18 个汉字内,summary 控制在 16 到 32 个汉字内,tone 控制在 6 到 16 个汉字内,playerGoal 控制在 16 到 32 个汉字内,camp.description 控制在 18 到 40 个汉字内。".to_string(),
|
||||
"- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(),
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
pub(crate) fn build_custom_world_framework_json_repair_prompt(response_text: &str) -> String {
|
||||
[
|
||||
"下面这段文本本应是自定义世界核心骨架的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。",
|
||||
"请只输出修复后的 JSON 对象。",
|
||||
"顶层必须只包含:name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、camp。",
|
||||
"不要输出 playableNpcs、storyNpcs、landmarks、items 或任何其他字段。",
|
||||
"majorFactions 与 coreConflicts 必须是字符串数组。",
|
||||
"camp 必须是对象,且包含:name、description、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions。",
|
||||
"原始文本:",
|
||||
response_text.trim(),
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
pub(crate) fn build_custom_world_role_outline_batch_prompt(
|
||||
framework: &JsonValue,
|
||||
role_type: &str,
|
||||
batch_count: usize,
|
||||
forbidden_names: &[String],
|
||||
) -> String {
|
||||
let key = role_key(role_type);
|
||||
let label = if role_type == "playable" {
|
||||
"可扮演角色"
|
||||
} else {
|
||||
"场景角色"
|
||||
};
|
||||
[
|
||||
format!("请根据下面的世界核心信息,生成一批{label}框架名单。"),
|
||||
"后续我会继续补全人物档案,所以这一步每个角色只保留身份骨架与资产默认描述字段。".to_string(),
|
||||
"你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
|
||||
"世界核心信息:".to_string(),
|
||||
build_framework_summary_text(framework, 0),
|
||||
if forbidden_names.is_empty() { "".to_string() } else { format!("这些名字已经生成,禁止重复:{}", forbidden_names.join("、")) },
|
||||
"".to_string(),
|
||||
"输出 JSON 模板:".to_string(),
|
||||
"{".to_string(),
|
||||
format!(" \"{key}\": ["),
|
||||
" {".to_string(),
|
||||
" \"name\": \"角色名称\",".to_string(),
|
||||
" \"title\": \"称号\",".to_string(),
|
||||
" \"role\": \"身份\",".to_string(),
|
||||
" \"description\": \"极简定位描述\",".to_string(),
|
||||
" \"visualDescription\": \"默认角色形象描述\",".to_string(),
|
||||
" \"actionDescription\": \"默认角色动作描述\",".to_string(),
|
||||
" \"sceneVisualDescription\": \"默认出现场景描述\",".to_string(),
|
||||
" \"initialAffinity\": 18,".to_string(),
|
||||
" \"relationshipHooks\": [\"一个关系切入口\"],".to_string(),
|
||||
" \"tags\": [\"标签1\", \"标签2\"]".to_string(),
|
||||
" }".to_string(),
|
||||
" ]".to_string(),
|
||||
"}".to_string(),
|
||||
"".to_string(),
|
||||
"要求:".to_string(),
|
||||
format!("- 必须生成恰好 {batch_count} 个{label}。"),
|
||||
"- 这是一个完全独立的自定义世界;不要把角色写成来自“武侠世界”“仙侠世界”等现成世界。".to_string(),
|
||||
"- 名称必须具体且互不重复,不要使用 角色1、NPC1、场景角色1 之类的占位名。".to_string(),
|
||||
"- 只保留:name、title、role、description、visualDescription、actionDescription、sceneVisualDescription、initialAffinity、relationshipHooks、tags。".to_string(),
|
||||
"- visualDescription 是打开角色形象图像生成面板时默认填入的角色形象描述,必须具体到体型、服装、轮廓与识别点,控制在 24 到 60 个汉字内。".to_string(),
|
||||
"- actionDescription 是打开每个角色动作视频生成面板时默认填入的动作描述,必须体现该角色默认动作节奏、武器或施法方式,控制在 18 到 48 个汉字内。".to_string(),
|
||||
"- sceneVisualDescription 是该角色常出现或关联的场景画面描述,会作为场景生图描述框的默认候选,控制在 24 到 60 个汉字内。".to_string(),
|
||||
"- relationshipHooks 最多 1 条;tags 保持 1 到 2 个。".to_string(),
|
||||
"- description 控制在 8 到 18 个汉字内,title 和 role 也尽量短。".to_string(),
|
||||
"- initialAffinity 必须是 -40 到 90 的整数。".to_string(),
|
||||
if role_type == "playable" { "- 可扮演角色的定位必须明显不同,通常使用 18 到 40 的初始好感。".to_string() } else { "- 场景角色要覆盖势力成员、居民、异类或怪物,不要全是同一种身份;敌对或怪物型角色可以使用负好感。".to_string() },
|
||||
"- 所有生成文本都必须使用中文。".to_string(),
|
||||
"- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(),
|
||||
].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
|
||||
pub(crate) fn build_custom_world_role_outline_batch_json_repair_prompt(
|
||||
response_text: &str,
|
||||
role_type: &str,
|
||||
expected_count: usize,
|
||||
forbidden_names: &[String],
|
||||
) -> String {
|
||||
let key = role_key(role_type);
|
||||
[
|
||||
format!("下面这段文本本应是自定义世界{}框架名单批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。", if role_type == "playable" { "可扮演角色" } else { "场景角色" }),
|
||||
"请只输出修复后的 JSON 对象。".to_string(),
|
||||
format!("顶层必须只包含一个 {key} 数组。"),
|
||||
format!("必须保留恰好 {expected_count} 个角色对象。"),
|
||||
if forbidden_names.is_empty() { "".to_string() } else { format!("禁止使用这些重复名:{}。", forbidden_names.join("、")) },
|
||||
"每个角色只包含:name、title、role、description、visualDescription、actionDescription、sceneVisualDescription、initialAffinity、relationshipHooks、tags。".to_string(),
|
||||
"如果缺少字段:字符串补空字符串,relationshipHooks 和 tags 补空数组,initialAffinity 补默认整数。".to_string(),
|
||||
"不要输出 backstory、skills、landmarks 或任何其他字段。".to_string(),
|
||||
"原始文本:".to_string(),
|
||||
response_text.trim().to_string(),
|
||||
].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
pub(crate) fn build_custom_world_landmark_seed_batch_prompt(
|
||||
framework: &JsonValue,
|
||||
batch_count: usize,
|
||||
forbidden_names: &[String],
|
||||
) -> String {
|
||||
[
|
||||
"请根据下面的世界核心信息,生成一批关键场景框架名单。".to_string(),
|
||||
"后续我会继续补全场景网络,所以这一步每个地点只保留场景骨架、地点默认生图描述和逐幕背景描述。".to_string(),
|
||||
"你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
|
||||
"世界核心信息:".to_string(),
|
||||
build_framework_summary_text(framework, 0),
|
||||
if forbidden_names.is_empty() { "".to_string() } else { format!("这些地点已经生成,禁止重复:{}", forbidden_names.join("、")) },
|
||||
"".to_string(),
|
||||
"输出 JSON 模板:".to_string(),
|
||||
"{".to_string(),
|
||||
" \"landmarks\": [".to_string(),
|
||||
" {".to_string(),
|
||||
" \"name\": \"场景名称\",".to_string(),
|
||||
" \"description\": \"场景极简描述\",".to_string(),
|
||||
" \"visualDescription\": \"默认场景生图描述\",".to_string(),
|
||||
" \"sceneTaskDescription\": \"首次进入该场景时要生成的章节任务核心上下文\",".to_string(),
|
||||
" \"actBackgroundPromptTexts\": [\"第一幕背景画面描述\", \"第二幕背景画面描述\", \"第三幕背景画面描述\"],".to_string(),
|
||||
" \"actEventDescriptions\": [\"第一幕事件描述\", \"第二幕事件描述\", \"第三幕事件描述\"],".to_string(),
|
||||
" }".to_string(),
|
||||
" ]".to_string(),
|
||||
"}".to_string(),
|
||||
"".to_string(),
|
||||
"要求:".to_string(),
|
||||
format!("- 必须生成恰好 {batch_count} 个关键场景。"),
|
||||
"- 这是一个完全独立的自定义世界;地点名称必须直接服务玩家输入主题。".to_string(),
|
||||
"- 名称必须具体且互不重复,不要使用 地点1、场景1 之类的占位名。".to_string(),
|
||||
"- 每个地点只保留:name、description、visualDescription、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions。".to_string(),
|
||||
"- sceneTaskDescription 必须描述玩家首次进入该场景时要完成的核心任务,会作为游戏章节任务生成上下文,控制在 24 到 56 个汉字内。".to_string(),
|
||||
"- visualDescription 是打开场景背景图像生成面板时默认填入的场景描述,必须具体到画面主体、远近景层次、地面可站立区域和氛围识别点,控制在 32 到 80 个汉字内。".to_string(),
|
||||
"- actBackgroundPromptTexts 必须恰好 3 条,分别对应这个场景章节的第 1/2/3 幕背景图画面内容描述;每条都必须是大模型根据当前地点、主线阶段和可出场角色直接写出的画面描述,控制在 40 到 90 个汉字内。".to_string(),
|
||||
"- actBackgroundPromptTexts 禁止使用“某某第1幕背景;玩家会在……”这类标题、摘要、规则句拼接格式;必须像可直接交给生图模型的自然画面描述。".to_string(),
|
||||
"- actEventDescriptions 必须恰好 3 条,分别描述每一幕发生的事件;事件必须和当前幕对面的角色强相关,控制在 24 到 56 个汉字内。".to_string(),
|
||||
"- description 控制在 12 到 24 个汉字内。".to_string(),
|
||||
"- 所有生成文本都必须使用中文。".to_string(),
|
||||
"- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(),
|
||||
].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
|
||||
pub(crate) fn build_custom_world_landmark_seed_batch_json_repair_prompt(
|
||||
response_text: &str,
|
||||
expected_count: usize,
|
||||
forbidden_names: &[String],
|
||||
) -> String {
|
||||
[
|
||||
"下面这段文本本应是自定义世界关键场景框架名单批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。".to_string(),
|
||||
"请只输出修复后的 JSON 对象。".to_string(),
|
||||
"顶层必须只包含一个 landmarks 数组。".to_string(),
|
||||
format!("必须保留恰好 {expected_count} 个地点对象。"),
|
||||
if forbidden_names.is_empty() { "".to_string() } else { format!("禁止使用这些重复名:{}。", forbidden_names.join("、")) },
|
||||
"每个地点只包含:name、description、visualDescription、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions。".to_string(),
|
||||
"如果缺少字段:字符串补空字符串,actBackgroundPromptTexts 和 actEventDescriptions 补空数组。".to_string(),
|
||||
"不要输出 sceneNpcNames、connectedLandmarks、items 或任何其他字段。".to_string(),
|
||||
"原始文本:".to_string(),
|
||||
response_text.trim().to_string(),
|
||||
].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
|
||||
pub(crate) fn build_custom_world_landmark_network_batch_prompt(
|
||||
framework: &JsonValue,
|
||||
story_npcs: &[JsonValue],
|
||||
landmark_batch: &[JsonValue],
|
||||
) -> String {
|
||||
[
|
||||
"请补全下面这一批关键场景的探索网络信息。".to_string(),
|
||||
"你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
|
||||
"世界核心信息:".to_string(),
|
||||
build_framework_summary_text(framework, 10),
|
||||
"可用场景角色名单:".to_string(),
|
||||
names_from_entries(story_npcs).join("、"),
|
||||
"本批场景:".to_string(),
|
||||
compact_json_text(&JsonValue::Array(landmark_batch.to_vec())),
|
||||
"".to_string(),
|
||||
"输出 JSON 模板:".to_string(),
|
||||
"{".to_string(),
|
||||
" \"landmarks\": [".to_string(),
|
||||
" {".to_string(),
|
||||
" \"name\": \"场景名称\",".to_string(),
|
||||
" \"description\": \"场景描述\",".to_string(),
|
||||
" \"sceneNpcNames\": [\"会在这里出现的角色名\"],".to_string(),
|
||||
" \"connectedLandmarkNames\": [\"相邻或可通往的地点名\"],".to_string(),
|
||||
" \"entryHook\": \"玩家进入这里时首先遇到的钩子\"".to_string(),
|
||||
" }".to_string(),
|
||||
" ]".to_string(),
|
||||
"}".to_string(),
|
||||
"".to_string(),
|
||||
"要求:".to_string(),
|
||||
"- 必须只补全本批场景,name 必须与本批场景完全一致,不得增删改名。".to_string(),
|
||||
"- sceneNpcNames 只能引用上方可用场景角色名单中的名字,每个地点 1 到 3 个。".to_string(),
|
||||
"- connectedLandmarkNames 优先引用已知关键场景名称,每个地点 1 到 3 个。".to_string(),
|
||||
"- entryHook 控制在 16 到 36 个汉字内。".to_string(),
|
||||
"- 所有生成文本都必须使用中文。".to_string(),
|
||||
"- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(),
|
||||
].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
|
||||
pub(crate) fn build_custom_world_landmark_network_batch_json_repair_prompt(
|
||||
response_text: &str,
|
||||
expected_names: &[String],
|
||||
) -> String {
|
||||
[
|
||||
"下面这段文本本应是自定义世界关键场景探索网络补全批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。".to_string(),
|
||||
"请只输出修复后的 JSON 对象。".to_string(),
|
||||
"顶层必须只包含一个 landmarks 数组。".to_string(),
|
||||
format!("这个数组里只能保留这些地点名:{}。", expected_names.join("、")),
|
||||
"名称必须与名单完全一致,不得增删改名;如果原文遗漏,可按名单顺序补齐占位对象。".to_string(),
|
||||
"每个地点都必须包含:name、description、sceneNpcNames、connectedLandmarkNames、entryHook。".to_string(),
|
||||
"如果缺少字段:字符串补空字符串,数组补空数组。".to_string(),
|
||||
"不要新增名单外的地点。".to_string(),
|
||||
"原始文本:".to_string(),
|
||||
response_text.trim().to_string(),
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
pub(crate) fn build_custom_world_role_batch_prompt(
|
||||
framework: &JsonValue,
|
||||
role_type: &str,
|
||||
role_batch: &[JsonValue],
|
||||
stage: &str,
|
||||
) -> String {
|
||||
let key = role_key(role_type);
|
||||
let label = if role_type == "playable" {
|
||||
"可扮演角色"
|
||||
} else {
|
||||
"场景角色"
|
||||
};
|
||||
let stage_label = if stage == "narrative" {
|
||||
"叙事档案"
|
||||
} else {
|
||||
"养成档案"
|
||||
};
|
||||
let required_fields = if stage == "narrative" {
|
||||
"name、backstory、personality、motivation、combatStyle"
|
||||
} else {
|
||||
"name、backstoryReveal、skills、initialItems"
|
||||
};
|
||||
let template_extra = if stage == "narrative" {
|
||||
[
|
||||
" \"backstory\": \"公开背景\",",
|
||||
" \"personality\": \"性格关键词\",",
|
||||
" \"motivation\": \"当前动机\",",
|
||||
" \"combatStyle\": \"行动或战斗风格\"",
|
||||
]
|
||||
.join("\n")
|
||||
} else {
|
||||
[
|
||||
" \"backstoryReveal\": { \"publicSummary\": \"公开摘要\", \"chapters\": [{ \"affinityRequired\": 15, \"title\": \"羁绊章节\", \"summary\": \"章节摘要\" }] },",
|
||||
" \"skills\": [{ \"name\": \"技能名\", \"summary\": \"技能摘要\", \"style\": \"风格\" }],",
|
||||
" \"initialItems\": [{ \"name\": \"物品名\", \"category\": \"道具\", \"quantity\": 1, \"rarity\": \"common\", \"description\": \"描述\", \"tags\": [\"标签\"] }]",
|
||||
].join("\n")
|
||||
};
|
||||
[
|
||||
format!("请为下面这一批{label}补全{stage_label}。"),
|
||||
"你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
|
||||
"世界核心信息:".to_string(),
|
||||
build_framework_summary_text(framework, 10),
|
||||
"本批角色:".to_string(),
|
||||
build_role_outline_prompt_text(role_batch, framework, role_type),
|
||||
"".to_string(),
|
||||
"输出 JSON 模板:".to_string(),
|
||||
"{".to_string(),
|
||||
format!(" \"{key}\": ["),
|
||||
" {".to_string(),
|
||||
" \"name\": \"角色名称\",".to_string(),
|
||||
template_extra,
|
||||
" }".to_string(),
|
||||
" ]".to_string(),
|
||||
"}".to_string(),
|
||||
"".to_string(),
|
||||
"要求:".to_string(),
|
||||
"- 必须只补全本批角色,name 必须与本批角色完全一致,不得增删改名。".to_string(),
|
||||
format!("- 每个角色必须包含:{required_fields}。"),
|
||||
if stage == "narrative" { "- backstory 控制在 32 到 80 个汉字内;personality、motivation、combatStyle 都要短而具体。".to_string() } else { format!("- backstoryReveal 必须包含 publicSummary 和 4 个 chapters,chapters.affinityRequired 固定为 {}。", CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.iter().map(i64::to_string).collect::<Vec<_>>().join("、")) },
|
||||
if stage == "narrative" { "- 不要输出 backstoryReveal、skills、initialItems。".to_string() } else { "- skills 默认 3 个;initialItems 默认 3 个;不要输出 backstory、personality、motivation、combatStyle。".to_string() },
|
||||
"- 所有生成文本都必须使用中文。".to_string(),
|
||||
"- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(),
|
||||
].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
|
||||
pub(crate) fn build_custom_world_role_batch_json_repair_prompt(
|
||||
response_text: &str,
|
||||
role_type: &str,
|
||||
stage: &str,
|
||||
expected_names: &[String],
|
||||
) -> String {
|
||||
let key = role_key(role_type);
|
||||
if stage == "narrative" {
|
||||
return [
|
||||
format!("下面这段文本本应是自定义世界{}叙事档案补全批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。", if role_type == "playable" { "可扮演角色" } else { "场景角色" }),
|
||||
"请只输出修复后的 JSON 对象。".to_string(),
|
||||
format!("顶层必须只包含一个 {key} 数组。"),
|
||||
format!("这个数组里只能保留这些角色名:{}。", expected_names.join("、")),
|
||||
"名称必须与名单完全一致,不得增删改名;如果原文遗漏,可按名单顺序补齐占位对象。".to_string(),
|
||||
"每个角色都必须包含:name、backstory、personality、motivation、combatStyle。".to_string(),
|
||||
"如果缺少字段:字符串补空字符串。".to_string(),
|
||||
"不要输出 backstoryReveal、skills、initialItems,也不要新增名单外的角色。".to_string(),
|
||||
"原始文本:".to_string(),
|
||||
response_text.trim().to_string(),
|
||||
].join("\n");
|
||||
}
|
||||
[
|
||||
format!("下面这段文本本应是自定义世界{}档案补全批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。", if role_type == "playable" { "可扮演角色" } else { "场景角色" }),
|
||||
"请只输出修复后的 JSON 对象。".to_string(),
|
||||
format!("顶层必须只包含一个 {key} 数组。"),
|
||||
format!("这个数组里只能保留这些角色名:{}。", expected_names.join("、")),
|
||||
"名称必须与名单完全一致,不得增删改名;如果原文遗漏,可按名单顺序补齐占位对象。".to_string(),
|
||||
"每个角色都必须包含:name、backstoryReveal、skills、initialItems。".to_string(),
|
||||
format!("backstoryReveal 必须包含 publicSummary 和 4 个 chapters,chapters.affinityRequired 固定为 {}。", CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.iter().map(i64::to_string).collect::<Vec<_>>().join("、")),
|
||||
"skills 默认补成 3 个对象,每个对象包含 name、summary、style;initialItems 默认补成 3 个对象,每个对象包含 name、category、quantity、rarity、description、tags。".to_string(),
|
||||
"不要输出 backstory、personality、motivation、combatStyle、landmarks,也不要新增名单外的角色。".to_string(),
|
||||
"原始文本:".to_string(),
|
||||
response_text.trim().to_string(),
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
fn build_framework_summary_text(framework: &JsonValue, max_landmarks: usize) -> String {
|
||||
let landmark_text = array_field(framework, "landmarks")
|
||||
.into_iter()
|
||||
.take(max_landmarks)
|
||||
.map(|landmark| {
|
||||
format!(
|
||||
"{}({})",
|
||||
json_text(&landmark, "name").unwrap_or_default(),
|
||||
json_text(&landmark, "description").unwrap_or_default()
|
||||
)
|
||||
})
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("、");
|
||||
[
|
||||
format!("世界:{}", json_text(framework, "name").unwrap_or_default()),
|
||||
format!(
|
||||
"副标题:{}",
|
||||
json_text(framework, "subtitle").unwrap_or_default()
|
||||
),
|
||||
format!(
|
||||
"世界概述:{}",
|
||||
json_text(framework, "summary").unwrap_or_default()
|
||||
),
|
||||
format!(
|
||||
"世界基调:{}",
|
||||
json_text(framework, "tone").unwrap_or_default()
|
||||
),
|
||||
format!(
|
||||
"玩家核心目标:{}",
|
||||
json_text(framework, "playerGoal").unwrap_or_default()
|
||||
),
|
||||
json_string_array(framework, "majorFactions")
|
||||
.map(|items| format!("主要势力:{}", items.join("、")))
|
||||
.unwrap_or_default(),
|
||||
json_string_array(framework, "coreConflicts")
|
||||
.map(|items| format!("核心冲突:{}", items.join("、")))
|
||||
.unwrap_or_default(),
|
||||
format!(
|
||||
"开局归处:{}({})",
|
||||
json_path_text(framework, &["camp", "name"]).unwrap_or_default(),
|
||||
json_path_text(framework, &["camp", "description"]).unwrap_or_default()
|
||||
),
|
||||
if landmark_text.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("关键场景:{landmark_text}")
|
||||
},
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn build_role_outline_prompt_text(
|
||||
role_batch: &[JsonValue],
|
||||
framework: &JsonValue,
|
||||
role_type: &str,
|
||||
) -> String {
|
||||
role_batch
|
||||
.iter()
|
||||
.map(|role| {
|
||||
let appearance_text = if role_type == "story" {
|
||||
landmark_names_for_role(
|
||||
framework,
|
||||
json_text(role, "name").unwrap_or_default().as_str(),
|
||||
)
|
||||
.join("、")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
[
|
||||
format!(
|
||||
"- {} / {}",
|
||||
json_text(role, "name").unwrap_or_default(),
|
||||
json_text(role, "title").unwrap_or_default()
|
||||
),
|
||||
format!("身份:{}", json_text(role, "role").unwrap_or_default()),
|
||||
format!(
|
||||
"框架描述:{}",
|
||||
json_text(role, "description").unwrap_or_default()
|
||||
),
|
||||
format!(
|
||||
"预设好感:{}",
|
||||
role.get("initialAffinity")
|
||||
.and_then(JsonValue::as_i64)
|
||||
.unwrap_or(0)
|
||||
),
|
||||
json_string_array(role, "relationshipHooks")
|
||||
.map(|items| format!("关系切入口:{}", items.join("、")))
|
||||
.unwrap_or_default(),
|
||||
json_string_array(role, "tags")
|
||||
.map(|items| format!("标签:{}", items.join("、")))
|
||||
.unwrap_or_default(),
|
||||
if appearance_text.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("出现场景:{appearance_text}")
|
||||
},
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn landmark_names_for_role(framework: &JsonValue, role_name: &str) -> Vec<String> {
|
||||
array_field(framework, "landmarks")
|
||||
.into_iter()
|
||||
.filter_map(|landmark| {
|
||||
let names = json_string_array(&landmark, "sceneNpcNames").unwrap_or_default();
|
||||
if names.iter().any(|name| name == role_name) {
|
||||
json_text(&landmark, "name")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn role_key(role_type: &str) -> &'static str {
|
||||
if role_type == "playable" {
|
||||
"playableNpcs"
|
||||
} else {
|
||||
"storyNpcs"
|
||||
}
|
||||
}
|
||||
|
||||
fn array_field(value: &JsonValue, key: &str) -> Vec<JsonValue> {
|
||||
value
|
||||
.get(key)
|
||||
.and_then(JsonValue::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn names_from_entries(entries: &[JsonValue]) -> Vec<String> {
|
||||
entries
|
||||
.iter()
|
||||
.filter_map(|entry| json_text(entry, "name"))
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn json_text(value: &JsonValue, key: &str) -> Option<String> {
|
||||
json_path_text(value, &[key])
|
||||
}
|
||||
|
||||
fn json_path_text(value: &JsonValue, path: &[&str]) -> Option<String> {
|
||||
let mut current = value;
|
||||
for segment in path {
|
||||
current = current.get(*segment)?;
|
||||
}
|
||||
current
|
||||
.as_str()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn json_string_array(value: &JsonValue, key: &str) -> Option<Vec<String>> {
|
||||
let items = value
|
||||
.get(key)?
|
||||
.as_array()?
|
||||
.iter()
|
||||
.filter_map(|entry| entry.as_str().map(str::trim))
|
||||
.filter(|entry| !entry.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>();
|
||||
if items.is_empty() { None } else { Some(items) }
|
||||
}
|
||||
|
||||
fn compact_json_text(value: &JsonValue) -> String {
|
||||
serde_json::to_string(value).unwrap_or_else(|_| "null".to_string())
|
||||
}
|
||||
4
server-rs/crates/api-server/src/prompt/mod.rs
Normal file
4
server-rs/crates/api-server/src/prompt/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub(crate) mod character_animation;
|
||||
pub(crate) mod character_visual;
|
||||
pub(crate) mod foundation_draft;
|
||||
pub(crate) mod scene_background;
|
||||
167
server-rs/crates/api-server/src/prompt/scene_background.rs
Normal file
167
server-rs/crates/api-server/src/prompt/scene_background.rs
Normal file
@@ -0,0 +1,167 @@
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct SceneImagePromptProfile<'a> {
|
||||
pub name: &'a str,
|
||||
pub subtitle: &'a str,
|
||||
pub tone: &'a str,
|
||||
pub player_goal: &'a str,
|
||||
pub summary: &'a str,
|
||||
pub setting_text: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct SceneImagePromptLandmark<'a> {
|
||||
pub name: &'a str,
|
||||
pub description: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct SceneImagePromptParams<'a> {
|
||||
pub profile: SceneImagePromptProfile<'a>,
|
||||
pub landmark: SceneImagePromptLandmark<'a>,
|
||||
pub user_prompt: &'a str,
|
||||
pub has_reference_image: bool,
|
||||
pub fallback_landmark_name: Option<&'a str>,
|
||||
pub fallback_world_name: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct SceneActBackgroundPromptParams<'a> {
|
||||
pub world_name: &'a str,
|
||||
pub world_tone: &'a str,
|
||||
pub scene_name: &'a str,
|
||||
pub title: &'a str,
|
||||
pub summary: &'a str,
|
||||
pub act_goal: &'a str,
|
||||
pub transition_hook: &'a str,
|
||||
pub primary_role_name: &'a str,
|
||||
pub support_role_names: Vec<String>,
|
||||
pub prompt_text: &'a str,
|
||||
}
|
||||
|
||||
pub(crate) const DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT: &str = "文字,水印,logo,UI界面,对话框,边框,人物近景特写,多人合照,模糊,低清晰度,畸形建筑,现代车辆,监控摄像头";
|
||||
|
||||
pub(crate) fn build_custom_world_scene_image_prompt(params: SceneImagePromptParams<'_>) -> String {
|
||||
let world_name = clamp_scene_image_text(
|
||||
if params.profile.name.trim().is_empty() {
|
||||
params.fallback_world_name
|
||||
} else {
|
||||
params.profile.name
|
||||
},
|
||||
18,
|
||||
);
|
||||
let world_subtitle = clamp_scene_image_text(params.profile.subtitle, 18);
|
||||
let world_tone = clamp_scene_image_text(params.profile.tone, 48);
|
||||
let world_goal = clamp_scene_image_text(params.profile.player_goal, 48);
|
||||
let world_summary = clamp_scene_image_text(params.profile.summary, 72);
|
||||
let world_setting = clamp_scene_image_text(params.profile.setting_text, 72);
|
||||
let landmark_name = clamp_scene_image_text(
|
||||
if params.landmark.name.trim().is_empty() {
|
||||
params.fallback_landmark_name.unwrap_or("未命名场景")
|
||||
} else {
|
||||
params.landmark.name
|
||||
},
|
||||
18,
|
||||
);
|
||||
let landmark_description = clamp_scene_image_text(params.landmark.description, 96);
|
||||
let requested_visual = clamp_scene_image_text(params.user_prompt, 120);
|
||||
|
||||
vec![
|
||||
"为横版 16:9 2D RPG 生成高完成度像素风场景背景,适合作为剧情探索与战斗底图。".to_string(),
|
||||
"画面构图必须严格按上下 1:1 分区:上半部分严格控制在整张图的 1/2 高度内,只描绘场景远景与中远景轮廓,不要让背景内容向下侵占超过半屏。".to_string(),
|
||||
"下半部分严格占据整张图的 1/2 高度,用于玩家角色站位与展示,必须是模拟 3D 游戏视角的地面近景,有明确的透视延伸和近大远小关系,不是平铺的 2D 侧视地面。".to_string(),
|
||||
"下半部分的内容必须是明确可站立的地面本体,例如道路、石板、平台、广场、甲板、沙地或草地,要有连续、稳定、可落脚的站位逻辑,不能只是装饰性前景、坑洞、障碍堆、栏杆带或不可通行的景物。".to_string(),
|
||||
"下半部分地面近景要保持相对简洁、低细节、轮廓清楚、便于角色站立,不要堆满道具、植被、碎石、栏杆或复杂装饰。".to_string(),
|
||||
if params.has_reference_image {
|
||||
"已提供一张自定义参考图,请沿用其构图、镜头或氛围线索,同时继续满足本次场景需求。".to_string()
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
format!(
|
||||
"世界:{}{}。",
|
||||
if world_name.is_empty() {
|
||||
"未命名世界"
|
||||
} else {
|
||||
world_name.as_str()
|
||||
},
|
||||
if world_subtitle.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(",{world_subtitle}")
|
||||
}
|
||||
),
|
||||
conditional_prompt_line("玩家设定", world_setting.as_str()),
|
||||
conditional_prompt_line("世界概述", world_summary.as_str()),
|
||||
conditional_prompt_line("整体基调", world_tone.as_str()),
|
||||
conditional_prompt_line("玩家目标关联", world_goal.as_str()),
|
||||
format!(
|
||||
"场景名称:{}。",
|
||||
if landmark_name.is_empty() {
|
||||
"未命名场景"
|
||||
} else {
|
||||
landmark_name.as_str()
|
||||
}
|
||||
),
|
||||
conditional_prompt_line("场景描述", landmark_description.as_str()),
|
||||
conditional_prompt_line("本次想要生成的画面内容", requested_visual.as_str()),
|
||||
"不要出现 UI、字幕、文字、水印、logo 或装饰边框,人物仅可作为很小的远景剪影,画面重点放在场景本身,不要遮挡下半部分的角色展示区域。".to_string(),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|line| !line.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
}
|
||||
|
||||
pub(crate) fn build_scene_act_background_image_prompt(
|
||||
params: SceneActBackgroundPromptParams<'_>,
|
||||
) -> String {
|
||||
// 幕背景图不是普通地点图,必须把世界、幕目标、过渡钩子和角色关系一起写入图像提示词,
|
||||
// 同时明确禁止角色立绘和 UI 元素进入背景资产。
|
||||
[
|
||||
format!("这是世界《{}》中的场景幕背景图。", params.world_name),
|
||||
format!("场景:{}", params.scene_name),
|
||||
format!("幕标题:{}", params.title),
|
||||
format!("幕摘要:{}", params.summary),
|
||||
format!("幕目标:{}", params.act_goal),
|
||||
format!("过渡钩子:{}", params.transition_hook),
|
||||
format!(
|
||||
"主角色:{}",
|
||||
if params.primary_role_name.trim().is_empty() {
|
||||
"待补主角色"
|
||||
} else {
|
||||
params.primary_role_name.trim()
|
||||
}
|
||||
),
|
||||
if params.support_role_names.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("辅助角色:{}", params.support_role_names.join("、"))
|
||||
},
|
||||
format!("世界气质:{}", params.world_tone),
|
||||
format!("背景描述:{}", params.prompt_text),
|
||||
"要求:只生成环境背景,不出现角色立绘、站位 UI、对白框、按钮或文字。".to_string(),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn clamp_scene_image_text(value: &str, max_length: usize) -> String {
|
||||
value
|
||||
.trim()
|
||||
.replace(char::is_whitespace, " ")
|
||||
.chars()
|
||||
.take(max_length)
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn conditional_prompt_line(prefix: &str, value: &str) -> String {
|
||||
if value.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("{prefix}:{value}。")
|
||||
}
|
||||
}
|
||||
|
||||
145
server-rs/crates/api-server/src/runtime_chat.rs
Normal file
145
server-rs/crates/api-server/src/runtime_chat.rs
Normal file
@@ -0,0 +1,145 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::Extension,
|
||||
http::{StatusCode, header},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{http_error::AppError, request_context::RequestContext};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NpcChatTurnRequest {
|
||||
encounter: Value,
|
||||
player_message: String,
|
||||
#[serde(default)]
|
||||
npc_initiates_conversation: bool,
|
||||
#[serde(default)]
|
||||
chat_directive: Option<Value>,
|
||||
}
|
||||
|
||||
pub async fn stream_runtime_npc_chat_turn(
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Json(payload): Json<NpcChatTurnRequest>,
|
||||
) -> Result<Response, Response> {
|
||||
let npc_name = read_string_field(&payload.encounter, "npcName")
|
||||
.or_else(|| read_string_field(&payload.encounter, "name"))
|
||||
.unwrap_or_else(|| "对方".to_string());
|
||||
let player_message = payload.player_message.trim();
|
||||
if player_message.is_empty() {
|
||||
return Err(runtime_chat_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "runtime-chat",
|
||||
"message": "playerMessage 不能为空",
|
||||
})),
|
||||
));
|
||||
}
|
||||
|
||||
let npc_reply = build_deterministic_npc_reply(
|
||||
npc_name.as_str(),
|
||||
player_message,
|
||||
payload.npc_initiates_conversation,
|
||||
);
|
||||
let suggestions = build_deterministic_chat_suggestions(npc_name.as_str(), player_message);
|
||||
let complete_payload = json!({
|
||||
"npcReply": npc_reply,
|
||||
"affinityDelta": 0,
|
||||
"affinityText": "关系暂未变化",
|
||||
"suggestions": suggestions,
|
||||
"pendingQuestOffer": null,
|
||||
"chatDirective": build_completion_directive(payload.chat_directive.as_ref()),
|
||||
});
|
||||
|
||||
let mut body = String::new();
|
||||
append_sse_event(&request_context, &mut body, "reply_delta", &json!({ "text": npc_reply }))?;
|
||||
append_sse_event(&request_context, &mut body, "complete", &complete_payload)?;
|
||||
Ok(build_event_stream_response(body))
|
||||
}
|
||||
|
||||
fn build_deterministic_npc_reply(
|
||||
npc_name: &str,
|
||||
player_message: &str,
|
||||
npc_initiates_conversation: bool,
|
||||
) -> String {
|
||||
// Rust API 尚未迁入旧 Node 的完整 LLM NPC 聊天编排前,先由后端提供稳定兜底,保证相遇与选项聊天链路不断。
|
||||
if npc_initiates_conversation {
|
||||
return format!("{npc_name}看向你,先开口说道:“你来了。先别急着走,我正有话想和你说。”");
|
||||
}
|
||||
format!("{npc_name}听完你的话,回应道:“{player_message}。我明白你的意思,我们继续说。”")
|
||||
}
|
||||
|
||||
fn build_deterministic_chat_suggestions(npc_name: &str, player_message: &str) -> Vec<String> {
|
||||
// 建议只承载玩家可点选的行动意图,不在 UI 里额外塞说明文案。
|
||||
vec![
|
||||
format!("继续询问{npc_name}的近况"),
|
||||
"追问这里发生了什么".to_string(),
|
||||
if player_message.contains('帮') || player_message.contains('忙') {
|
||||
"请对方说清需要什么帮助".to_string()
|
||||
} else {
|
||||
"换个轻松的话题".to_string()
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn build_completion_directive(chat_directive: Option<&Value>) -> Value {
|
||||
let Some(directive) = chat_directive else {
|
||||
return Value::Null;
|
||||
};
|
||||
json!({
|
||||
"turnLimit": directive.get("turnLimit").cloned().unwrap_or(Value::Null),
|
||||
"remainingTurns": directive.get("remainingTurns").cloned().unwrap_or(Value::Null),
|
||||
"forceExit": directive.get("forceExitAfterTurn").and_then(Value::as_bool).unwrap_or(false),
|
||||
"closingMode": directive.get("closingMode").cloned().unwrap_or(Value::Null),
|
||||
})
|
||||
}
|
||||
|
||||
fn read_string_field(value: &Value, field: &str) -> Option<String> {
|
||||
value
|
||||
.get(field)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|text| !text.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn append_sse_event(
|
||||
request_context: &RequestContext,
|
||||
body: &mut String,
|
||||
event: &str,
|
||||
payload: &Value,
|
||||
) -> Result<(), Response> {
|
||||
let payload_text = serde_json::to_string(payload).map_err(|error| {
|
||||
runtime_chat_error_response(
|
||||
request_context,
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": "runtime-chat",
|
||||
"message": format!("SSE payload 序列化失败:{error}"),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
body.push_str("event: ");
|
||||
body.push_str(event);
|
||||
body.push('\n');
|
||||
body.push_str("data: ");
|
||||
body.push_str(&payload_text);
|
||||
body.push_str("\n\n");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_event_stream_response(body: String) -> Response {
|
||||
(
|
||||
[
|
||||
(header::CONTENT_TYPE, "text/event-stream; charset=utf-8"),
|
||||
(header::CACHE_CONTROL, "no-cache"),
|
||||
],
|
||||
body,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn runtime_chat_error_response(request_context: &RequestContext, error: AppError) -> Response {
|
||||
error.into_response_with_context(Some(request_context))
|
||||
}
|
||||
@@ -3298,7 +3298,7 @@ fn upsert_generated_entity_card(
|
||||
.unwrap_or_else(|| "新角色".to_string())
|
||||
}
|
||||
RpgAgentDraftCardKind::Landmark => {
|
||||
read_optional_text_field(entity_object, &["purpose", "mood", "dangerLevel"])
|
||||
read_optional_text_field(entity_object, &["purpose", "mood"])
|
||||
.unwrap_or_else(|| "新地点".to_string())
|
||||
}
|
||||
_ => "新增对象".to_string(),
|
||||
@@ -3820,7 +3820,7 @@ fn upsert_asset_role_card(ctx: &ReducerContext, session_id: &str, role_id: &str,
|
||||
fn upsert_asset_scene_card(ctx: &ReducerContext, session_id: &str, scene_id: &str, scene_kind: &str, scene: &JsonMap<String, JsonValue>, updated_at_micros: i64) -> Result<(), String> {
|
||||
let kind = if scene_kind == "camp" { RpgAgentDraftCardKind::Camp } else { RpgAgentDraftCardKind::Landmark };
|
||||
let title = read_optional_text_field(scene, &["name"]).unwrap_or_else(|| if scene_kind == "camp" { "开局营地" } else { "场景" }.to_string());
|
||||
let subtitle = read_optional_text_field(scene, &["purpose", "mood", "dangerLevel"]).unwrap_or_else(|| "场景资产已就绪".to_string());
|
||||
let subtitle = read_optional_text_field(scene, &["purpose", "mood"]).unwrap_or_else(|| "场景资产已就绪".to_string());
|
||||
let summary = read_optional_text_field(scene, &["summary", "description", "publicMask"]).unwrap_or_else(|| "场景图已写回草稿。".to_string());
|
||||
upsert_asset_card(ctx, session_id, scene_id, kind, &title, &subtitle, &summary, None, Some("场景图已就绪"), updated_at_micros)
|
||||
}
|
||||
|
||||
@@ -784,7 +784,6 @@ function buildOpeningSceneSearchText(
|
||||
return [
|
||||
campScene.name,
|
||||
campScene.description,
|
||||
campScene.dangerLevel,
|
||||
profile.playerGoal,
|
||||
profile.summary,
|
||||
'开局场景',
|
||||
@@ -920,7 +919,6 @@ function buildLandmarkSearchText(
|
||||
return [
|
||||
landmark.name,
|
||||
landmark.description,
|
||||
landmark.dangerLevel,
|
||||
...landmark.sceneNpcIds.map((npcId) => storyNpcById.get(npcId)?.name ?? ''),
|
||||
...landmark.connections.flatMap((connection) => [
|
||||
landmarkById.get(connection.targetLandmarkId)?.name ?? '',
|
||||
|
||||
@@ -190,7 +190,6 @@ function createProfile(): CustomWorldProfile {
|
||||
camp: {
|
||||
name: '潮灯居',
|
||||
description: '玩家最初落脚的旧灯塔内院。',
|
||||
dangerLevel: 'medium',
|
||||
},
|
||||
landmarks: [],
|
||||
creatorIntent: null,
|
||||
@@ -215,7 +214,6 @@ function createProfileWithLandmark(): CustomWorldProfile {
|
||||
id: 'landmark-1',
|
||||
name: '沉钟栈桥',
|
||||
description: '旧钟与潮声常年相撞的码头栈桥。',
|
||||
dangerLevel: 'medium',
|
||||
imageSrc: '/generated-custom-world-scenes/original-scene.png',
|
||||
sceneNpcIds: ['story-1', 'story-2', 'story-3'],
|
||||
connections: [],
|
||||
@@ -277,7 +275,6 @@ function CampEditorFlowHarness() {
|
||||
id: 'custom-scene-camp',
|
||||
name: '潮灯居',
|
||||
description: '玩家最初落脚的旧灯塔内院。',
|
||||
dangerLevel: 'medium',
|
||||
imageSrc: '/generated-custom-world-scenes/original-camp.png',
|
||||
sceneNpcIds: ['story-1', 'story-2', 'story-3'],
|
||||
connections: [
|
||||
|
||||
@@ -182,7 +182,6 @@ const baseProfile = {
|
||||
camp: {
|
||||
name: '潮灯居',
|
||||
description: '玩家最初落脚的旧灯塔内院。',
|
||||
dangerLevel: 'medium',
|
||||
},
|
||||
anchorContent: {
|
||||
worldPromise: {
|
||||
@@ -233,7 +232,6 @@ const baseProfile = {
|
||||
id: 'landmark-1',
|
||||
name: '沉钟栈桥',
|
||||
description: '旧钟与潮声常年相撞的码头栈桥。',
|
||||
dangerLevel: 'medium',
|
||||
sceneNpcIds: ['story-1'],
|
||||
connections: [],
|
||||
},
|
||||
|
||||
@@ -352,9 +352,7 @@ export function GameCanvasEntityLayer({
|
||||
const isCampCompanionEncounter =
|
||||
encounter.specialBehavior === 'initial_companion'
|
||||
|| encounter.specialBehavior === 'camp_companion';
|
||||
const peacefulAnchorX = isCampCompanionEncounter
|
||||
? RESOLVED_ENTITY_X_METERS
|
||||
: encounter.xMeters ?? monsterAnchorMeters;
|
||||
const peacefulAnchorX = RESOLVED_ENTITY_X_METERS;
|
||||
const isPeacefulEncounterMoving =
|
||||
(!isCampCompanionEncounter && sceneTransitionPhase !== 'idle')
|
||||
|| Math.abs(peacefulAnchorX - RESOLVED_ENTITY_X_METERS) > 0.01;
|
||||
@@ -373,10 +371,7 @@ export function GameCanvasEntityLayer({
|
||||
const peacefulBottomOffsetPx = peacefulResolvedCharacter
|
||||
? getCharacterBottomOffsetPx(stageLiftPx, peacefulResolvedCharacter)
|
||||
: stageLiftPx + peacefulHostileBottomOffsetPx;
|
||||
const peacefulNpcSpriteFacing =
|
||||
encounter.kind === 'treasure' || peacefulResolvedCharacter
|
||||
? towardPeacefulPlayer
|
||||
: getRenderableNpcFacing(encounter, towardPeacefulPlayer, {medievalVisual: true});
|
||||
const peacefulNpcSpriteFacing = towardPeacefulPlayer;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -241,7 +241,6 @@ export function createLandmarkDraft(
|
||||
),
|
||||
name: `自定义场景${profile.landmarks.length + 1}`,
|
||||
description: '',
|
||||
dangerLevel: '中',
|
||||
imageSrc: undefined,
|
||||
sceneNpcIds: profile.storyNpcs.slice(0, 3).map((npc) => npc.id),
|
||||
connections: previousLandmark
|
||||
|
||||
@@ -434,7 +434,6 @@ const compiledAgentDraftSession: CustomWorldAgentSessionSnapshot = {
|
||||
id: 'landmark-1',
|
||||
name: '回潮旧灯塔',
|
||||
description: '旧灯塔是整片群岛最先看见异动的地方。',
|
||||
dangerLevel: 'high',
|
||||
sceneNpcIds: ['story-1'],
|
||||
connections: [],
|
||||
},
|
||||
|
||||
@@ -185,7 +185,6 @@ describe('characterPresets custom world runtime characters', () => {
|
||||
{
|
||||
name: '夜港旧栈',
|
||||
description: '潮雾和旧木桥把视线切成断续几段。',
|
||||
dangerLevel: 'medium',
|
||||
sceneNpcNames: ['沈雾', '陆沉', '顾潮'],
|
||||
connections: [
|
||||
{
|
||||
@@ -198,7 +197,6 @@ describe('characterPresets custom world runtime characters', () => {
|
||||
{
|
||||
name: '断桥外沿',
|
||||
description: '旧桥断口还挂着潮湿残旗。',
|
||||
dangerLevel: 'high',
|
||||
sceneNpcNames: ['沈雾', '陆沉', '顾潮'],
|
||||
connections: [
|
||||
{
|
||||
|
||||
@@ -858,7 +858,6 @@ function normalizeLandmark(
|
||||
id: toText(value.id, `saved-landmark-${index + 1}`),
|
||||
name,
|
||||
description: toText(value.description),
|
||||
dangerLevel: toText(value.dangerLevel),
|
||||
imageSrc: toText(value.imageSrc) || undefined,
|
||||
narrativeResidues:
|
||||
preserveStructuredRecordArray<SceneNarrativeResidue>(
|
||||
@@ -891,7 +890,6 @@ function normalizeCampScene(
|
||||
name: toText(value.name, fallback.name),
|
||||
description: toText(value.description, fallback.description),
|
||||
visualDescription: toText(value.visualDescription) || undefined,
|
||||
dangerLevel: toText(value.dangerLevel, fallback.dangerLevel),
|
||||
imageSrc: toText(value.imageSrc) || undefined,
|
||||
sceneNpcIds: toStringArray(value.sceneNpcIds),
|
||||
connections: toRecordArray(value.connections)
|
||||
@@ -978,6 +976,8 @@ function normalizeSceneActBlueprint(
|
||||
const advanceRule = toText(value.advanceRule);
|
||||
const title = toText(value.title);
|
||||
const summary = toText(value.summary);
|
||||
const primaryNpcId = toText(value.primaryNpcId, encounterNpcIds[0] ?? '');
|
||||
const oppositeNpcId = toText(value.oppositeNpcId, primaryNpcId);
|
||||
|
||||
if (!title && !summary && encounterNpcIds.length === 0) {
|
||||
return null;
|
||||
@@ -997,7 +997,14 @@ function normalizeSceneActBlueprint(
|
||||
backgroundImageSrc: toText(value.backgroundImageSrc) || undefined,
|
||||
backgroundAssetId: toText(value.backgroundAssetId) || undefined,
|
||||
encounterNpcIds,
|
||||
primaryNpcId: toText(value.primaryNpcId, encounterNpcIds[0] ?? ''),
|
||||
primaryNpcId,
|
||||
oppositeNpcId,
|
||||
eventDescription: toText(
|
||||
value.eventDescription,
|
||||
oppositeNpcId
|
||||
? `第 ${index + 1} 幕中,玩家与${oppositeNpcId}正面接触,推动当前场景事件升级。`
|
||||
: `第 ${index + 1} 幕中,玩家处理当前场景的关键事件。`,
|
||||
),
|
||||
linkedThreadIds: toStringArray(value.linkedThreadIds),
|
||||
advanceRule: SCENE_ACT_ADVANCE_RULES.has(advanceRule as never)
|
||||
? (advanceRule as SceneActBlueprint['advanceRule'])
|
||||
@@ -1033,6 +1040,10 @@ function normalizeSceneChapterBlueprints(value: unknown) {
|
||||
sceneId,
|
||||
title: toText(entry.title, toText(entry.sceneName, sceneId)),
|
||||
summary: toText(entry.summary),
|
||||
sceneTaskDescription: toText(
|
||||
entry.sceneTaskDescription,
|
||||
`首次进入${toText(entry.title, toText(entry.sceneName, sceneId))}时,确认当前场景核心任务与关键角色。`,
|
||||
),
|
||||
linkedThreadIds: toStringArray(entry.linkedThreadIds),
|
||||
linkedLandmarkIds: toStringArray(entry.linkedLandmarkIds),
|
||||
acts,
|
||||
|
||||
@@ -384,7 +384,6 @@ export function normalizeCustomWorldLandmarks(params: {
|
||||
name: landmark.name,
|
||||
description: landmark.description,
|
||||
visualDescription: landmark.visualDescription,
|
||||
dangerLevel: landmark.dangerLevel,
|
||||
imageSrc: landmark.imageSrc,
|
||||
narrativeResidues: landmark.narrativeResidues,
|
||||
sceneNpcIds: resolveSceneNpcIdsForLandmark(
|
||||
|
||||
@@ -204,7 +204,7 @@ type CustomWorldSceneImageMatchOptions = {
|
||||
| 'camp'
|
||||
| 'ownedSettingLayers'
|
||||
> | null;
|
||||
landmark?: Pick<CustomWorldLandmark, 'id' | 'name' | 'description' | 'dangerLevel'> | null;
|
||||
landmark?: Pick<CustomWorldLandmark, 'id' | 'name' | 'description'> | null;
|
||||
usedImageSrcs?: Iterable<string>;
|
||||
};
|
||||
|
||||
@@ -328,7 +328,6 @@ function buildSourceText(
|
||||
themeHints,
|
||||
landmark?.name,
|
||||
landmark?.description,
|
||||
landmark?.dangerLevel,
|
||||
`scene-${index + 1}`,
|
||||
seedKey,
|
||||
]).join(' ');
|
||||
@@ -492,7 +491,7 @@ export function resolveCustomWorldLandmarkImage(
|
||||
| 'compatibilityTemplateWorldType'
|
||||
| 'ownedSettingLayers'
|
||||
>,
|
||||
landmark: Pick<CustomWorldLandmark, 'id' | 'name' | 'description' | 'dangerLevel' | 'imageSrc'>,
|
||||
landmark: Pick<CustomWorldLandmark, 'id' | 'name' | 'description' | 'imageSrc'>,
|
||||
index: number,
|
||||
usedImageSrcs?: Iterable<string>,
|
||||
) {
|
||||
@@ -586,7 +585,6 @@ export function resolveCustomWorldCampSceneImage(
|
||||
id: 'custom-scene-camp',
|
||||
name: campScene.name,
|
||||
description: campScene.description,
|
||||
dangerLevel: campScene.dangerLevel,
|
||||
},
|
||||
usedImageSrcs,
|
||||
},
|
||||
|
||||
@@ -70,7 +70,6 @@ describe('scene background assets', () => {
|
||||
id: 'landmark-1',
|
||||
name: '残城旧营',
|
||||
description: '断墙、军帐与营火灰烬混在一起,像一处被遗弃的边关驻地。',
|
||||
dangerLevel: 'high',
|
||||
imageSrc: generatedImage,
|
||||
sceneNpcIds: [],
|
||||
connections: [],
|
||||
@@ -79,7 +78,6 @@ describe('scene background assets', () => {
|
||||
id: 'landmark-2',
|
||||
name: '雾锁渡桥',
|
||||
description: '古桥横跨冷河,雾气压在水面上,只有残灯还在摇晃。',
|
||||
dangerLevel: 'medium',
|
||||
sceneNpcIds: [],
|
||||
connections: [],
|
||||
},
|
||||
@@ -87,7 +85,6 @@ describe('scene background assets', () => {
|
||||
id: 'landmark-3',
|
||||
name: '地宫裂隙',
|
||||
description: '墓道向下坍塌,石阶与机关残痕一路通往地底深处。',
|
||||
dangerLevel: 'extreme',
|
||||
sceneNpcIds: [],
|
||||
connections: [],
|
||||
},
|
||||
|
||||
@@ -140,7 +140,6 @@ describe('scenePresets custom world npc mapping', () => {
|
||||
{
|
||||
name: '雾潮码头',
|
||||
description: '旧船桩和潮雾把视线切成断续的几段。',
|
||||
dangerLevel: 'high',
|
||||
sceneNpcNames: ['沈雾', '陆沉', '顾潮'],
|
||||
connections: [
|
||||
{
|
||||
@@ -153,7 +152,6 @@ describe('scenePresets custom world npc mapping', () => {
|
||||
{
|
||||
name: '断桥旧道',
|
||||
description: '半塌的桥面上还挂着旧索和残旗。',
|
||||
dangerLevel: 'high',
|
||||
sceneNpcNames: ['沈雾', '陆沉', '顾潮'],
|
||||
connections: [
|
||||
{
|
||||
|
||||
@@ -1047,8 +1047,14 @@ describe('npcEncounterActions', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('opens hostile npc encounters as a declaration dialogue with only escape and fight options', () => {
|
||||
it('lets hostile npc encounters speak first on first contact', async () => {
|
||||
const encounter = createEncounter();
|
||||
streamNpcChatTurnMock.mockResolvedValueOnce({
|
||||
affinityDelta: 0,
|
||||
affinityText: '关系暂未变化',
|
||||
npcReply: '先别急着拔剑,我有话要问你。',
|
||||
suggestions: ['你想问什么'],
|
||||
});
|
||||
const actions = createNpcEncounterActions({
|
||||
gameState: createState({
|
||||
currentEncounter: encounter,
|
||||
@@ -1071,6 +1077,7 @@ describe('npcEncounterActions', () => {
|
||||
});
|
||||
|
||||
expect(actions.enterNpcInteraction(encounter, '与断桥客搭话')).toBe(true);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(actions.setGameState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -1080,18 +1087,26 @@ describe('npcEncounterActions', () => {
|
||||
expect(actions.setCurrentStory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
displayMode: 'dialogue',
|
||||
options: [
|
||||
dialogue: [
|
||||
expect.objectContaining({
|
||||
functionId: 'battle_escape_breakout',
|
||||
actionText: '逃跑',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_fight',
|
||||
actionText: '与他对战',
|
||||
speaker: 'npc',
|
||||
text: '先别急着拔剑,我有话要问你。',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(streamNpcChatTurnMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({id: 'npc-rival'}),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
'【NPC 主动开场】',
|
||||
expect.anything(),
|
||||
expect.objectContaining({npcInitiatesConversation: true}),
|
||||
);
|
||||
});
|
||||
|
||||
it('lets the current act primary npc enter limited chat even with negative affinity', () => {
|
||||
|
||||
@@ -1466,17 +1466,6 @@ export function createStoryNpcEncounterActions({
|
||||
nextTurnCount: 0,
|
||||
});
|
||||
|
||||
if ((npcState.affinity < 0 || encounter.hostile) && !limitedChatDirective) {
|
||||
setCurrentStory(
|
||||
buildHostileNpcStoryMoment(
|
||||
encounter,
|
||||
playerCharacter,
|
||||
npcState.affinity,
|
||||
),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
const npcInteractionOptions =
|
||||
getAvailableOptionsForState(nextState, playerCharacter) ?? [];
|
||||
const chatOptions = npcInteractionOptions.filter((option) =>
|
||||
@@ -1514,6 +1503,17 @@ export function createStoryNpcEncounterActions({
|
||||
return true;
|
||||
}
|
||||
|
||||
if ((npcState.affinity < 0 || encounter.hostile) && !limitedChatDirective) {
|
||||
setCurrentStory(
|
||||
buildHostileNpcStoryMoment(
|
||||
encounter,
|
||||
playerCharacter,
|
||||
npcState.affinity,
|
||||
),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
return enterNpcChat(
|
||||
encounter,
|
||||
seedChatOption,
|
||||
|
||||
@@ -213,14 +213,12 @@ function buildSavedProfile() {
|
||||
camp: {
|
||||
name: '回潮暂栖所',
|
||||
description: '能暂时收拢队伍、整理灯册与潮路线索的落脚点。',
|
||||
dangerLevel: 'low',
|
||||
},
|
||||
landmarks: [
|
||||
{
|
||||
id: 'landmark-1',
|
||||
name: '回潮旧灯塔',
|
||||
description: '被海雾啃旧的石塔仍在夜里维持着微弱灯火。',
|
||||
dangerLevel: 'high',
|
||||
sceneNpcIds: ['story-1'],
|
||||
connections: [
|
||||
{
|
||||
@@ -243,7 +241,6 @@ function buildSavedProfile() {
|
||||
id: 'landmark-2',
|
||||
name: '雾栈尽头',
|
||||
description: '旧栈桥尽头只剩断索、潮板和被抹掉的船名。',
|
||||
dangerLevel: 'high',
|
||||
sceneNpcIds: [],
|
||||
connections: [
|
||||
{
|
||||
|
||||
@@ -16,7 +16,6 @@ const framework = {
|
||||
camp: {
|
||||
name: '旧灯塔营地',
|
||||
description: '潮雾里的临时归处。',
|
||||
dangerLevel: 'medium',
|
||||
},
|
||||
playableNpcs: [],
|
||||
storyNpcs: [],
|
||||
|
||||
@@ -35,7 +35,6 @@ type CustomWorldGenerationLandmarkOutline = {
|
||||
name: string;
|
||||
description: string;
|
||||
visualDescription?: string;
|
||||
dangerLevel: string;
|
||||
sceneNpcNames: string[];
|
||||
connections: Array<{
|
||||
targetLandmarkName: string;
|
||||
@@ -58,7 +57,6 @@ type CustomWorldGenerationFramework = {
|
||||
camp: {
|
||||
name: string;
|
||||
description: string;
|
||||
dangerLevel: string;
|
||||
};
|
||||
playableNpcs: CustomWorldGenerationRoleOutline[];
|
||||
storyNpcs: CustomWorldGenerationRoleOutline[];
|
||||
@@ -92,7 +90,7 @@ function buildFrameworkSummaryText(
|
||||
.slice(0, maxLandmarks)
|
||||
.map(
|
||||
(landmark) =>
|
||||
`${landmark.name}(${landmark.dangerLevel},${landmark.description})`,
|
||||
`${landmark.name}(${landmark.description})`,
|
||||
)
|
||||
.join('、');
|
||||
|
||||
@@ -193,7 +191,6 @@ export function buildCustomWorldFrameworkPrompt(settingText: string) {
|
||||
' "camp": {',
|
||||
' "name": "开局归处名称",',
|
||||
' "description": "这是玩家进入世界后的第一处落脚点描述",',
|
||||
' "dangerLevel": "low|medium|high|extreme"',
|
||||
' }',
|
||||
'}',
|
||||
'',
|
||||
@@ -460,7 +457,7 @@ export function buildCustomWorldFrameworkJsonRepairPrompt(
|
||||
'顶层必须只包含:name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、camp。',
|
||||
'不要输出 playableNpcs、storyNpcs、landmarks、items 或任何其他字段。',
|
||||
'majorFactions 与 coreConflicts 必须是字符串数组。',
|
||||
'camp 必须是对象,且包含:name、description、dangerLevel。',
|
||||
'camp 必须是对象,且包含:name、description。',
|
||||
'原始文本:',
|
||||
responseText.trim(),
|
||||
].join('\n');
|
||||
@@ -576,7 +573,6 @@ export function buildCustomWorldLandmarkSeedBatchPrompt(params: {
|
||||
' {',
|
||||
' "name": "场景名称",',
|
||||
' "description": "极简场景描述",',
|
||||
' "dangerLevel": "low|medium|high|extreme"',
|
||||
' }',
|
||||
' ]',
|
||||
'}',
|
||||
@@ -584,7 +580,7 @@ export function buildCustomWorldLandmarkSeedBatchPrompt(params: {
|
||||
'要求:',
|
||||
`- 必须生成恰好 ${batchCount} 个 landmarks。`,
|
||||
'- 这是一个完全独立的自定义世界;不要在场景描述里直接写“武侠世界”“仙侠世界”等现成世界名。',
|
||||
'- 这一步只保留:name、description、dangerLevel。',
|
||||
'- 这一步只保留:name、description。',
|
||||
'- 不要输出 sceneNpcNames、connections、imageSrc 或其他字段。',
|
||||
'- 名称必须具体且互不重复,不要使用 场景1、地点1 之类的占位名。',
|
||||
'- description 控制在 8 到 18 个汉字内。',
|
||||
@@ -610,7 +606,7 @@ export function buildCustomWorldLandmarkSeedBatchJsonRepairPrompt(params: {
|
||||
forbiddenNames.length > 0
|
||||
? `禁止使用这些重复场景名:${forbiddenNames.join('、')}。`
|
||||
: '',
|
||||
'每个地标只包含:name、description、dangerLevel。',
|
||||
'每个地标只包含:name、description。',
|
||||
'不要输出 sceneNpcNames、connections 或其他字段。',
|
||||
'原始文本:',
|
||||
responseText.trim(),
|
||||
@@ -643,7 +639,7 @@ export function buildCustomWorldLandmarkNetworkBatchPrompt(params: {
|
||||
landmarkBatch
|
||||
.map(
|
||||
(landmark) =>
|
||||
`- ${landmark.name} / 危险度:${landmark.dangerLevel} / 描述:${landmark.description}`,
|
||||
`- ${landmark.name} / 描述:${landmark.description}`,
|
||||
)
|
||||
.join('\n'),
|
||||
'',
|
||||
@@ -672,7 +668,7 @@ export function buildCustomWorldLandmarkNetworkBatchPrompt(params: {
|
||||
`- 每个场景必须提供恰好 2 条 connections;relativePosition 只能使用:${relativePositionValues}。`,
|
||||
'- targetLandmarkName 必须来自全部场景名,且不能连接自己,两个目标场景不能重复。',
|
||||
'- summary 控制在 4 到 10 个汉字内。',
|
||||
'- 不要输出 description、dangerLevel、backstory 或其他字段。',
|
||||
'- 不要输出 description、backstory 或其他字段。',
|
||||
'- 所有生成文本都必须使用中文。',
|
||||
'- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。',
|
||||
].join('\n');
|
||||
@@ -691,7 +687,7 @@ export function buildCustomWorldLandmarkNetworkBatchJsonRepairPrompt(params: {
|
||||
`landmarks 里只能保留这些场景名:${expectedNames.join('、')}。`,
|
||||
'每个场景对象只包含:name、sceneNpcNames、connections。',
|
||||
'connections 里的每个对象必须包含:targetLandmarkName、relativePosition、summary。',
|
||||
'不要输出 description、dangerLevel 或其他字段。',
|
||||
'不要输出 description 或其他字段。',
|
||||
'原始文本:',
|
||||
responseText.trim(),
|
||||
].join('\n');
|
||||
@@ -883,7 +879,6 @@ export function buildCustomWorldGenerationPrompt(settingText: string) {
|
||||
' "camp": {',
|
||||
' "name": "开局归处名称",',
|
||||
' "description": "玩家进入世界后的第一处落脚点描述",',
|
||||
' "dangerLevel": "low|medium|high|extreme"',
|
||||
' },',
|
||||
' "playableNpcs": [',
|
||||
' {',
|
||||
@@ -957,7 +952,6 @@ export function buildCustomWorldGenerationPrompt(settingText: string) {
|
||||
' {',
|
||||
' "name": "场景名称",',
|
||||
' "description": "场景描述",',
|
||||
' "dangerLevel": "low|medium|high|extreme",',
|
||||
' "sceneNpcNames": ["会在这个场景出现的角色1", "会在这个场景出现的角色2", "会在这个场景出现的角色3"],',
|
||||
' "connections": [',
|
||||
' {',
|
||||
@@ -1005,21 +999,6 @@ function clampSceneImageText(value: string, maxLength: number) {
|
||||
return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}…`;
|
||||
}
|
||||
|
||||
function describeDangerLevel(dangerLevel: string) {
|
||||
const normalized = dangerLevel.trim().toLowerCase();
|
||||
if (normalized === 'low' || normalized === '低')
|
||||
return '气氛相对平静,但暗藏细节张力';
|
||||
if (normalized === 'medium' || normalized === '中')
|
||||
return '带有明确的探索压力与潜在威胁';
|
||||
if (normalized === 'high' || normalized === '高')
|
||||
return '危险感强烈,空间中有明显压迫感';
|
||||
if (normalized === 'extreme' || normalized === '极高')
|
||||
return '极端危险,环境本身就像会吞没闯入者';
|
||||
return dangerLevel.trim()
|
||||
? `危险氛围:${dangerLevel.trim()}`
|
||||
: '危险气质保持克制但不可忽视';
|
||||
}
|
||||
|
||||
export const DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT = [
|
||||
'文字',
|
||||
'水印',
|
||||
@@ -1041,7 +1020,7 @@ export function buildCustomWorldSceneImagePrompt(
|
||||
CustomWorldProfile,
|
||||
'name' | 'subtitle' | 'summary' | 'tone' | 'playerGoal' | 'settingText'
|
||||
>,
|
||||
landmark: Pick<CustomWorldLandmark, 'name' | 'description' | 'dangerLevel'>,
|
||||
landmark: Pick<CustomWorldLandmark, 'name' | 'description'>,
|
||||
userPrompt = '',
|
||||
options: {
|
||||
hasReferenceImage?: boolean;
|
||||
@@ -1056,7 +1035,6 @@ export function buildCustomWorldSceneImagePrompt(
|
||||
const landmarkName = clampSceneImageText(landmark.name, 18) || '未命名场景';
|
||||
const landmarkDescription = clampSceneImageText(landmark.description, 96);
|
||||
const requestedVisual = clampSceneImageText(userPrompt, 120);
|
||||
const dangerMood = describeDangerLevel(landmark.dangerLevel);
|
||||
|
||||
return [
|
||||
'为横版 16:9 2D RPG 生成高完成度像素风场景背景,适合作为剧情探索与战斗底图。',
|
||||
@@ -1075,7 +1053,6 @@ export function buildCustomWorldSceneImagePrompt(
|
||||
`场景名称:${landmarkName}。`,
|
||||
landmarkDescription ? `场景描述:${landmarkDescription}。` : '',
|
||||
requestedVisual ? `本次想要生成的画面内容:${requestedVisual}。` : '',
|
||||
`${dangerMood}。`,
|
||||
'不要出现 UI、字幕、文字、水印、logo 或装饰边框,人物仅可作为很小的远景剪影,画面重点放在场景本身,不要遮挡下半部分的角色展示区域。',
|
||||
]
|
||||
.filter(Boolean)
|
||||
|
||||
@@ -339,7 +339,6 @@ function createLandmark(
|
||||
return {
|
||||
name: `场景${index + 1}`,
|
||||
description: `场景描述${index + 1}`,
|
||||
dangerLevel: 'high',
|
||||
sceneNpcNames: options?.storyNpcNames ?? [
|
||||
`世界NPC${index + 1}`,
|
||||
`世界NPC${index + 2}`,
|
||||
@@ -931,7 +930,6 @@ describe('ai orchestration fallbacks', () => {
|
||||
id: 'landmark-1',
|
||||
name: '雾潮码头',
|
||||
description: '被潮雾与旧升降机包围的码头。',
|
||||
dangerLevel: 'high',
|
||||
},
|
||||
userPrompt: '雨夜的栈桥横跨黑色海沟,塔楼灯火被潮雾吞没。',
|
||||
size: '1280*720',
|
||||
@@ -1002,7 +1000,6 @@ describe('ai orchestration fallbacks', () => {
|
||||
id: 'landmark-1',
|
||||
name: '雾潮码头',
|
||||
description: '被潮雾与旧升降机包围的码头。',
|
||||
dangerLevel: 'high',
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow('DashScope API key 无效。');
|
||||
|
||||
@@ -1987,7 +1987,6 @@ export async function generateCustomWorldSceneImage({
|
||||
id: landmark.id,
|
||||
name: landmark.name,
|
||||
description: landmark.description,
|
||||
dangerLevel: landmark.dangerLevel,
|
||||
},
|
||||
...(referenceImageSrc?.trim()
|
||||
? { referenceImageSrc: referenceImageSrc.trim() }
|
||||
|
||||
@@ -69,7 +69,6 @@ export interface CustomWorldSceneImageRequest {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
dangerLevel: string;
|
||||
};
|
||||
userPrompt?: string;
|
||||
prompt?: string;
|
||||
|
||||
@@ -86,7 +86,6 @@ describe('normalizeCustomWorldProfile', () => {
|
||||
{
|
||||
name: '北侧塌桥',
|
||||
description: '横跨裂谷的旧桥只剩半截石拱。',
|
||||
dangerLevel: 'high',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -191,7 +190,6 @@ describe('normalizeCustomWorldProfile', () => {
|
||||
{
|
||||
name: '北侧塌桥',
|
||||
description: '断桥上方还残留着旧索道。',
|
||||
dangerLevel: 'high',
|
||||
sceneNpcNames: ['梁砺'],
|
||||
connections: [
|
||||
{
|
||||
@@ -204,7 +202,6 @@ describe('normalizeCustomWorldProfile', () => {
|
||||
{
|
||||
name: '雾潮码头',
|
||||
description: '潮雾会把来路和去路都遮住一半。',
|
||||
dangerLevel: 'medium',
|
||||
sceneNpcNames: ['苏雾', '顾岚'],
|
||||
connections: [],
|
||||
},
|
||||
|
||||
@@ -115,7 +115,6 @@ export interface CustomWorldGenerationLandmarkOutline {
|
||||
name: string;
|
||||
description: string;
|
||||
visualDescription?: string;
|
||||
dangerLevel: string;
|
||||
sceneNpcNames: string[];
|
||||
connections: CustomWorldGenerationLandmarkConnectionOutline[];
|
||||
}
|
||||
@@ -125,7 +124,6 @@ export interface CustomWorldGenerationCampOutline {
|
||||
name: string;
|
||||
description: string;
|
||||
visualDescription?: string;
|
||||
dangerLevel: string;
|
||||
imageSrc?: string;
|
||||
sceneNpcIds?: string[];
|
||||
sceneNpcNames?: string[];
|
||||
@@ -714,7 +712,6 @@ export function normalizeCustomWorldGenerationFramework(
|
||||
camp: {
|
||||
name: fallback.camp?.name ?? '归舍',
|
||||
description: fallback.camp?.description ?? '',
|
||||
dangerLevel: fallback.camp?.dangerLevel ?? 'low',
|
||||
},
|
||||
playableNpcs: [],
|
||||
storyNpcs: [],
|
||||
@@ -786,7 +783,6 @@ export function buildCustomWorldRawProfileFromFramework(
|
||||
camp: {
|
||||
name: framework.camp.name,
|
||||
description: framework.camp.description,
|
||||
dangerLevel: framework.camp.dangerLevel,
|
||||
},
|
||||
playableNpcs: framework.playableNpcs.map((npc) => ({
|
||||
name: npc.name,
|
||||
@@ -816,7 +812,6 @@ export function buildCustomWorldRawProfileFromFramework(
|
||||
name: landmark.name,
|
||||
description: landmark.description,
|
||||
visualDescription: landmark.visualDescription,
|
||||
dangerLevel: landmark.dangerLevel,
|
||||
sceneNpcNames: [...landmark.sceneNpcNames],
|
||||
connections: landmark.connections.map((connection) => ({
|
||||
targetLandmarkName: connection.targetLandmarkName,
|
||||
@@ -1071,7 +1066,6 @@ function normalizeCampOutline(
|
||||
name: toText(item.name) || fallback.name,
|
||||
description: toText(item.description) || fallback.description,
|
||||
visualDescription: toText(item.visualDescription) || undefined,
|
||||
dangerLevel: toText(item.dangerLevel) || fallback.dangerLevel,
|
||||
imageSrc: toText(item.imageSrc) || undefined,
|
||||
sceneNpcIds: toStringArray(item.sceneNpcIds),
|
||||
sceneNpcNames: [
|
||||
@@ -1107,7 +1101,6 @@ function normalizeLandmarkOutlineList(value: unknown) {
|
||||
toText(item.description) ||
|
||||
truncateText(`${name}暗藏新的局势变化。`, 40),
|
||||
visualDescription: toText(item.visualDescription) || undefined,
|
||||
dangerLevel: toText(item.dangerLevel) || 'medium',
|
||||
sceneNpcNames: [
|
||||
...toStringArray(item.sceneNpcNames),
|
||||
...toStringArray(item.npcs, 'name'),
|
||||
@@ -1168,7 +1161,6 @@ function normalizeLandmarkDraftList(value: unknown) {
|
||||
name,
|
||||
description: toText(item.description),
|
||||
visualDescription: toText(item.visualDescription) || undefined,
|
||||
dangerLevel: toText(item.dangerLevel),
|
||||
imageSrc: toText(item.imageSrc) || undefined,
|
||||
sceneNpcIds: toStringArray(item.sceneNpcIds),
|
||||
sceneNpcNames: [
|
||||
@@ -1215,7 +1207,6 @@ function normalizeCampScene(
|
||||
name: toText(item.name) || fallback.name,
|
||||
description: toText(item.description) || fallback.description,
|
||||
visualDescription: toText(item.visualDescription) || undefined,
|
||||
dangerLevel: toText(item.dangerLevel) || fallback.dangerLevel,
|
||||
imageSrc: toText(item.imageSrc) || undefined,
|
||||
sceneNpcIds: toStringArray(item.sceneNpcIds),
|
||||
connections: toRecordArray(item.connections)
|
||||
@@ -1439,7 +1430,7 @@ export function buildCustomWorldReferenceText(
|
||||
.slice(0, 10)
|
||||
.map(
|
||||
(landmark) =>
|
||||
`- ${landmark.name}:${landmark.description};危险度:${landmark.dangerLevel};场景角色:${
|
||||
`- ${landmark.name}:${landmark.description};场景角色:${
|
||||
landmark.sceneNpcIds
|
||||
.map((npcId) => storyNpcById.get(npcId)?.name)
|
||||
.filter(Boolean)
|
||||
|
||||
@@ -75,7 +75,6 @@ describe('buildExpandedCustomWorldProfile', () => {
|
||||
id: 'landmark-1',
|
||||
name: '断桥旧哨',
|
||||
description: '旧哨火和断桥一起守着边城北口。',
|
||||
dangerLevel: 'high',
|
||||
sceneNpcIds: ['story-1'],
|
||||
connections: [],
|
||||
},
|
||||
|
||||
@@ -158,11 +158,6 @@ export function buildExpandedCustomWorldProfile(
|
||||
...landmark,
|
||||
id: landmark.id || createEntryId('landmark', landmark.name, index),
|
||||
description: clampText(landmark.description, 96),
|
||||
dangerLevel:
|
||||
landmark.dangerLevel ||
|
||||
(resolveCustomWorldCompatibilityTemplateWorldType(profile) === WorldType.XIANXIA
|
||||
? 'high'
|
||||
: 'medium'),
|
||||
}));
|
||||
const landmarkIdByReference = new Map<string, string>();
|
||||
landmarkDrafts.forEach((landmark) => {
|
||||
|
||||
@@ -14,7 +14,6 @@ type CampProfileSeed = Pick<
|
||||
| 'name'
|
||||
| 'description'
|
||||
| 'visualDescription'
|
||||
| 'dangerLevel'
|
||||
| 'imageSrc'
|
||||
| 'sceneNpcIds'
|
||||
| 'connections'
|
||||
@@ -92,7 +91,6 @@ export function buildFallbackCustomWorldCampScene(
|
||||
id: 'custom-scene-camp',
|
||||
name: fallbackName,
|
||||
description: buildFallbackCampDescription(profile, fallbackName),
|
||||
dangerLevel: 'low',
|
||||
sceneNpcIds: [],
|
||||
connections: [],
|
||||
narrativeResidues: null,
|
||||
@@ -110,7 +108,6 @@ export function resolveCustomWorldCampScene(
|
||||
name: camp?.name?.trim() || fallback.name,
|
||||
description: camp?.description?.trim() || fallback.description,
|
||||
visualDescription: camp?.visualDescription?.trim() || undefined,
|
||||
dangerLevel: camp?.dangerLevel?.trim() || fallback.dangerLevel,
|
||||
imageSrc: camp?.imageSrc?.trim() || undefined,
|
||||
sceneNpcIds: Array.isArray(camp?.sceneNpcIds)
|
||||
? [...new Set(camp.sceneNpcIds.map((entry) => entry.trim()).filter(Boolean))]
|
||||
|
||||
@@ -59,7 +59,6 @@ function createBaseProfile(): CustomWorldProfile {
|
||||
id: 'camp-1',
|
||||
name: '守夜营地',
|
||||
description: '潮线后的临时据点。',
|
||||
dangerLevel: 'medium',
|
||||
imageSrc: '/images/camp/camp.webp',
|
||||
sceneNpcIds: [],
|
||||
connections: [],
|
||||
@@ -69,7 +68,6 @@ function createBaseProfile(): CustomWorldProfile {
|
||||
id: 'landmark-1',
|
||||
name: '潮汐码头',
|
||||
description: '涨潮时会吞掉半截栈桥。',
|
||||
dangerLevel: 'high',
|
||||
imageSrc: '/images/landmark/docks.webp',
|
||||
sceneNpcIds: [],
|
||||
connections: [],
|
||||
|
||||
@@ -353,7 +353,7 @@ function inferRoleArchetypeLabel(
|
||||
}
|
||||
|
||||
function inferSceneBucketLabel(
|
||||
landmark: Pick<CustomWorldProfile['landmarks'][number], 'name' | 'description' | 'dangerLevel'>,
|
||||
landmark: Pick<CustomWorldProfile['landmarks'][number], 'name' | 'description'>,
|
||||
) {
|
||||
const source = `${landmark.name} ${landmark.description}`;
|
||||
|
||||
@@ -365,9 +365,7 @@ function inferSceneBucketLabel(
|
||||
if (/[洞|穴|地下|遗迹|墓]/u.test(source)) return '地底遗迹区';
|
||||
if (/[街|巷|城|镇|居]/u.test(source)) return '群落聚居区';
|
||||
|
||||
return landmark.dangerLevel === 'high' || landmark.dangerLevel === 'extreme'
|
||||
? '高压交汇区'
|
||||
: '叙事缓冲区';
|
||||
return '叙事缓冲区';
|
||||
}
|
||||
|
||||
function buildRoleArchetypes(profile: CustomWorldProfile) {
|
||||
@@ -388,7 +386,7 @@ function buildSceneBuckets(profile: CustomWorldProfile) {
|
||||
id: `scene-bucket-${index + 1}`,
|
||||
label: inferSceneBucketLabel(landmark),
|
||||
moodTags: dedupeStrings(
|
||||
[landmark.dangerLevel, ...splitToneTags(profile.tone)],
|
||||
splitToneTags(profile.tone),
|
||||
4,
|
||||
),
|
||||
keywords: dedupeStrings([landmark.name, landmark.description], 4),
|
||||
|
||||
@@ -112,7 +112,6 @@ describe('buildUserPrompt', () => {
|
||||
id: 'landmark-1',
|
||||
name: '断桥旧哨',
|
||||
description: '旧哨火和断桥一起守着边城北口。',
|
||||
dangerLevel: 'high',
|
||||
sceneNpcIds: ['story-1'],
|
||||
connections: [],
|
||||
},
|
||||
|
||||
@@ -375,7 +375,6 @@ export interface CustomWorldCampScene {
|
||||
name: string;
|
||||
description: string;
|
||||
visualDescription?: string;
|
||||
dangerLevel: string;
|
||||
imageSrc?: string;
|
||||
sceneNpcIds: string[];
|
||||
connections: CustomWorldSceneConnection[];
|
||||
@@ -387,7 +386,6 @@ export interface CustomWorldLandmark {
|
||||
name: string;
|
||||
description: string;
|
||||
visualDescription?: string;
|
||||
dangerLevel: string;
|
||||
imageSrc?: string;
|
||||
sceneNpcIds: string[];
|
||||
connections: CustomWorldSceneConnection[];
|
||||
|
||||
Reference in New Issue
Block a user