3 Commits

Author SHA1 Message Date
323aa94c87 Merge remote-tracking branch 'origin/server_node'
Some checks failed
CI / verify (push) Has been cancelled
2026-04-08 19:16:55 +08:00
a02f7b6414 Simplify custom world result editing controls 2026-04-08 19:07:46 +08:00
victo
a83841ff2d feat: migrate runtime backend to node server 2026-04-08 16:41:29 +08:00
186 changed files with 17022 additions and 1499 deletions

View File

@@ -13,6 +13,21 @@ VITE_LLM_PROXY_BASE_URL="/api/llm"
# Optional frontend override for the local custom-world scene image proxy path.
VITE_SCENE_IMAGE_PROXY_BASE_URL="/api/custom-world/scene-image"
# Local Node backend address and target used by Vite's dev proxy for runtime API routes.
NODE_SERVER_ADDR=":8081"
NODE_SERVER_TARGET="http://127.0.0.1:8081"
# Local Caddy upstream target used for dist-based testing.
CADDY_API_UPSTREAM="http://127.0.0.1:8081"
# Node backend SQLite database path.
SQLITE_PATH=""
# Node backend JWT settings.
JWT_SECRET="CHANGE_ME_FOR_PRODUCTION"
# 当前默认签发永久 JWT此字段暂未使用后续如果恢复有限期 token 再启用。
JWT_EXPIRES_IN="7d"
# Model name for chat completions.
VITE_LLM_MODEL="doubao-1-5-pro-32k-character-250715"
@@ -21,7 +36,7 @@ DASHSCOPE_BASE_URL="https://dashscope.aliyuncs.com/api/v1"
DASHSCOPE_API_KEY="sk-65a0c6fa5e294b9887ace860f9d65990"
# Optional model name for custom-world scene image generation.
DASHSCOPE_IMAGE_MODEL="wan2.2-t2i-flash"
DASHSCOPE_IMAGE_MODEL="wan2.7-image"
# Optional model names for character asset studio.
DASHSCOPE_CHARACTER_VISUAL_MODEL="wan2.7-image-pro"

5
.gitignore vendored
View File

@@ -6,3 +6,8 @@ coverage/
*.log
/public/generated-custom-world-scenes
temp*build*/
/server-node/dist/
/server-node/logs/*
!/server-node/logs/.gitkeep
/server-node/data/*
!/server-node/data/.gitkeep

View File

@@ -2,7 +2,6 @@
## 项目约束
- 积极学习 `docs/` 与根目录经验文档后再开始实现。
- 不要擅自把现有中文文案、注释、剧情、文档改写成英文,除非用户明确要求翻译。
- 看到中文乱码时,不要直接沿用乱码文本,也不要用英文替换;先确认文件真实编码,再决定是否修改。
- 在 PowerShell 5.1 中读取或写入文本时,必须显式使用 UTF-8如果终端输出疑似乱码要用 `Get-Content -Encoding UTF8`、Python 或 Node 再次核对原文。
@@ -10,4 +9,80 @@
- 修改包含中文的文件后,优先运行仓库里的编码检查,确保没有把文本写坏。
- UI面板中不要默认写一些规则描述文案清爽一些按照游戏UI设计规范设计即可。
- UI设计需要兼顾网页端、移动端双端的使用体验确保在不同设备上都能正常显示和操作移动端优先考虑。
- 不要在gitignore中添加.env.local文件。
- 不要在gitignore中添加.env.local文件。
- 严格遵循简洁的代码风格
## 文档图谱
```text
docs/
├─ README.md
├─ audits/
│ ├─ README.md
│ ├─ FUNCTION_DESIGN_AUDIT_2026-04-03.md
│ ├─ ITEM_AND_BUILD_PRD_AUDIT_2026-04-05.md
│ ├─ engineering/
│ │ ├─ README.md
│ │ ├─ ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md
│ │ ├─ ENGINEERING_OPTIMIZATION_REVIEW_2026-03-30.md
│ │ ├─ ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md
│ │ └─ MONSTER_NPC_UNIFICATION_AUDIT_2026-04-06.md
│ └─ text/
│ ├─ README.md
│ ├─ CHINESE_MOJIBAKE_INVENTORY.md
│ ├─ EDITOR_GAME_PRESET_TEXT_AUDIT_2026-03-25.md
│ ├─ GAME_EDITOR_PRESET_TEXT_AUDIT_2026-03-29.md
│ ├─ GAME_EDITOR_PRESET_TEXT_AUDIT_2026-03-30.md
│ ├─ GAME_UI_PRESET_EDITOR_NPC_PROMPT_TEXT_AUDIT_2026-04-02_DEEP_SCAN.md
│ ├─ GAME_UI_PRESET_EDITOR_TEXT_AUDIT_2026-03-30_CONTINUED.md
│ ├─ GAME_UI_PRESET_EDITOR_TEXT_AUDIT_2026-03-31.md
│ ├─ GAME_UI_PRESET_EDITOR_TEXT_AUDIT_2026-04-01.md
│ └─ GAME_UI_PRESET_EDITOR_TEXT_AUDIT_2026-04-02.md
├─ design/
│ ├─ README.md
│ ├─ AI_NATIVE_RUNTIME_ITEM_SYSTEM_REDESIGN_2026-04-02.md
│ ├─ COMPANION_FIRST_CONTACT_RELATIONSHIP_AND_PRIVATE_CHAT_DESIGN_2026-04-04.md
│ ├─ CUSTOM_WORLD_CREATOR_INPUT_AND_AI_BOUNDARY_DESIGN_2026-04-06.md
│ ├─ EQUIPMENT_BUILD_AND_FORGE_LOOP_SYSTEM_DESIGN.md
│ └─ npc-conversation-situation-draft.md
├─ experience/
│ ├─ README.md
│ ├─ ADVENTURE_RUNTIME_DEV_EXPERIENCE.md
│ ├─ AGENT_UI_CHANGELOG.md
│ ├─ CODEX_IMPLEMENTATION_EXPERIENCE_2026-03-24.md
│ ├─ CODEX_PAST_WORK_EXPERIENCE_SUMMARY.md
│ ├─ MOBILE_UI_DEV_EXPERIENCE.md
│ ├─ PROJECT_DEVELOPMENT_EXPERIENCE.md
│ └─ PROJECT_WORK_EXPERIENCE_PLAYBOOK.md
├─ planning/
│ ├─ README.md
│ └─ CURRENT_GAME_ITERATION_PRIORITIES_2026-04-03.md
├─ prd/
│ ├─ AI_CHARACTER_VISUAL_ANIMATION_MVP_PRD_2026-04-04.md
│ ├─ AI_NATIVE_CLASSIC_RPG_EXPERIENCE_BENCHMARK_PRD_2026-04-06.md
│ ├─ AI_NATIVE_CROSS_GENRE_STORY_ENGINE_PRD_2026-04-06.md
│ ├─ AI_NATIVE_CUSTOM_WORLD_CREATION_FLOW_OPTIMIZATION_PRD_2026-04-06.md
│ ├─ AI_NATIVE_NARRATIVE_THREAD_ITEM_AND_WORLD_NPC_PRD_2026-04-06.md
│ ├─ AI_NATIVE_QUEST_SYSTEM_PRD_2026-04-02.md
│ ├─ AI_NATIVE_RUNTIME_ITEM_GENERATION_DESIGN.md
│ ├─ AI_NATIVE_STORY_ENGINE_PHASE1_IMPLEMENTATION_PLAN_2026-04-06.md
│ ├─ AI_NATIVE_STORY_ENGINE_PHASE2_IMPLEMENTATION_PLAN_2026-04-06.md
│ ├─ AI_NATIVE_STORY_ENGINE_PHASE3_IMPLEMENTATION_PLAN_2026-04-06.md
│ ├─ AI_NATIVE_STORY_ENGINE_PHASE4_IMPLEMENTATION_PLAN_2026-04-06.md
│ ├─ AI_NATIVE_STORY_ENGINE_PHASE5_IMPLEMENTATION_PLAN_2026-04-06.md
│ ├─ AI_NATIVE_STORY_ENGINE_PHASE6_IMPLEMENTATION_PLAN_2026-04-06.md
│ ├─ AI_NATIVE_UNIFIED_ROLE_ATTRIBUTE_SYSTEM_PRD_2026-04-02.md
│ ├─ BUILD_SYSTEM_ATTRIBUTE_SIMILARITY_PRD_2026-04-02.md
│ └─ RUNTIME_ITEM_GENERATION_CURRENT_SYSTEM_DESIGN.md
├─ reference/
│ ├─ README.md
│ └─ FUNCTION_SCRIPT_CATALOG_2026-04-04.md
└─ technical/
├─ README.md
├─ AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md
├─ GO_SERVER_RUNTIME_INTEGRATION_2026-04-07.md
├─ GO_SERVER_TASKLIST_2026-04-08.md
├─ NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md
├─ PIXELMOTION_TECHNICAL_BREAKDOWN_2026-04-04.md
└─ SERVER_DEPLOYMENT_AND_CORS_TECHNICAL_SOLUTION_2026-04-05.md
```

View File

@@ -0,0 +1,660 @@
# 自定义世界自有设定层优化方案
更新时间:`2026-04-08`
## 0. 目标
这份文档要解决的问题是:
**当前自定义世界虽然已经是唯一正式可玩的世界入口,但它底层仍依赖武侠 / 仙侠模板设定。**
本次优化目标不是直接删除这些依赖,而是把它们逐步改造成:
**属于自定义世界自身的设定层,并且这套设定层必须能通用于任何题材。**
同时必须满足一条底线:
**不能破坏当前自定义世界生成流程的任何可用功能。**
一句话定义:
**让自定义世界从“借模板世界运行”,升级成“拥有自有设定层、可跨题材运行”的架构。**
---
## 1. 设计原则
这次优化必须同时遵守 4 条原则:
1. 设定归自定义世界自身所有
- 运行时、生成期、表现层真正依赖的世界设定,应该落回 `CustomWorldProfile` 或由它直接编译出的配置。
2. 设定必须跨题材
- 新设定不能只是把“武侠 / 仙侠”换一个更抽象的名字。
- 它必须能支撑奇幻、科幻、悬疑、校园、末世、神话、现代、海洋、裂界等任何题材。
3. 优化优先做兼容迁移
- 不能先删旧字段,再补新结构。
- 必须先补新设定层,再逐步迁读,最后再让旧模板字段退化成兼容层。
4. 不能增加创作者负担
- 这次不是让创作者多填一堆底层 schema。
- 这些设定仍然应由 AI / 系统编译出来,只是所有权从模板世界转移到自定义世界自己。
---
## 2. 当前自定义世界实际依赖了什么
根据 [CUSTOM_WORLD_TEMPLATE_DEPENDENCY_INVENTORY_2026-04-08.md](../reference/CUSTOM_WORLD_TEMPLATE_DEPENDENCY_INVENTORY_2026-04-08.md),当前依赖可以归纳成 6 组:
1. 模板锚点字段
- `templateWorldType`
- `WorldTemplateType`
2. 规则桥接
- `resolveRuleWorldType(...)`
3. 主题与词汇底板
- `detectCustomWorldThemeMode(...)`
- `buildThemePackFromWorldProfile(...)`
4. 属性、资源词与经济 fallback
- 预设属性 schema
- 资源命名
- 初始货币规则
5. 内容骨架
- 角色模板骨架
- 怪物模板池
- 场景视觉参考池
6. prompt 兼容字段
- `framework` 生成仍要求输出 `templateWorldType`
这些东西现在还不能直接删,因为:
**它们不是单纯的预设世界残留,而是当前自定义世界生成与运行时的真实支撑层。**
---
## 3. 本次优化的核心思路
这次不建议新增很多彼此平行的新系统,而是把现有模板依赖统一收束成:
**自定义世界自己的 5 层设定层。**
这 5 层分别是:
1. 语义锚层
2. 规则层
3. 表现层
4. 原型参考层
5. 兼容迁移层
这样可以让现在分散在:
- 模板世界枚举
- 预设 schema
- 视觉参考池
- 怪物池
- ThemePack 底板
这些地方的依赖,被重新编译回 `CustomWorldProfile` 自己的配置。
---
## 4. 目标结构
## 4.1 语义锚层:替代 `templateWorldType`
当前 `templateWorldType` 实际在回答的不是“你是不是武侠”,而是:
1. 这个世界更接近哪类冲突结构
2. 这个世界更接近哪类制度和禁忌
3. 这个世界更接近哪类叙事载体与力量来源
所以应把它升级成一套真正属于自定义世界自己的语义锚:
```ts
interface CustomWorldSemanticAnchor {
genreSignals: string[];
conflictForms: string[];
institutionTypes: string[];
tabooTypes: string[];
carrierTypes: string[];
forceSystemTypes: string[];
atmosphereTags: string[];
}
```
这层应该回答:
1. 这个世界的主要矛盾像什么
2. 这个世界的秩序结构像什么
3. 这个世界的危险和禁忌像什么
4. 这个世界的线索、遗物、文书、证物、技术、仪式像什么
关键点:
- 这里不再出现“武侠 / 仙侠”的模板世界名
- 只保留通用语义
例如:
- `institutionTypes`
- 宗门
- 公司
- 学园
- 教团
- 调查局
- 舰队
- 部族
- `forceSystemTypes`
- 灵脉
- 科技
- 仪式
- 契约
- 血统
- 神谕
- 污染
这就天然跨题材了。
---
## 4.2 规则层:把 fallback 变成自定义世界自己的规则设定
当前很多规则仍然会回落到武侠 / 仙侠:
- 属性 schema
- 资源命名
- 经济命名
- 初始货币
优化后应由自定义世界自己持有:
```ts
interface CustomWorldRuleProfile {
attributeSchema: WorldAttributeSchema;
resourceLabels: {
hp: string;
mp: string;
maxHp: string;
maxMp: string;
damage: string;
guard: string;
range: string;
cooldown: string;
manaCost: string;
currency: string;
};
economyProfile: {
initialCurrency: number;
};
}
```
这意味着以后运行时读取逻辑应优先变成:
1. 先读 `profile.ruleProfile`
2. 再读 `profile.attributeSchema`
3. 最后才允许读兼容 fallback
而不是:
1. 先判断 `WUXIA / XIANXIA`
2. 再猜自定义世界应该像哪边
这层的原则是:
**规则是这个自定义世界自己的,不再借模板世界托管。**
---
## 4.3 表现层:把 ThemePack 变成自定义世界自身的派生物
当前 `ThemePack` 仍然是“预设底板 + 自定义词汇补丁”。
优化后应改成:
```ts
interface CustomWorldExpressionProfile {
themePack: ThemePack;
presentationTone: string[];
namingDirectives: string[];
clueDirectives: string[];
revealDirectives: string[];
}
```
也就是说:
- `ThemePack` 仍然可以保留
- 但它不再被理解为“武侠底板 / 仙侠底板的延伸”
- 它应当是:
- `semanticAnchor`
- `creatorIntent`
- `majorFactions`
- `coreConflicts`
- `world text`
共同编译出来的结果
这一步非常关键,因为它会决定:
1. 物件命名
2. 势力命名
3. 线索形式
4. 提示词词汇风格
5. reveal 风格
只有把这层做成自定义世界自己的派生物,后面跨题材才会真的稳。
---
## 4.4 原型参考层:把模板骨架改成通用原型库
当前自定义世界借用模板的最深部分是:
1. 角色模板骨架
2. 场景视觉参考池
3. 怪物模板池
这三类不能粗暴删除,但可以改造成:
**通用原型参考层。**
建议统一成:
```ts
interface CustomWorldReferenceProfile {
roleArchetypes: RoleArchetypeProfile[];
sceneBuckets: SceneArchetypeBucket[];
creatureArchetypes: CreatureArchetypeProfile[];
}
```
### 4.4.1 角色原型
角色原型应描述的是:
- 正面推进型
- 远程压制型
- 控场解构型
- 续航承压型
- 潜行爆发型
而不是:
- 剑之公主
- 神箭游侠
- 双刃旅者
也就是说:
**保留战斗骨架和技能骨架,不保留模板角色人格设定。**
### 4.4.2 场景原型
场景原型应描述的是:
- 高压入口区
- 雨湿街巷区
- 临水渡口区
- 仪式神殿区
- 高空通路区
- 工业热区
- 地底遗迹区
- 群落聚居区
而不是:
- 竹林古道
- 云海仙门
也就是说:
**保留“空间语义 -> 视觉参考”的逻辑,不保留模板世界场景名作为中心。**
### 4.4.3 生物原型
生物原型应描述的是:
- 潜伏袭击者
- 重甲承压者
- 群居骚扰者
- 远程威胁者
- 异化污染体
- 灵体回响体
- 机关守卫体
而不是:
- 某个武侠怪
- 某个仙侠怪
这样之后:
- 自定义世界依赖的是“通用原型”
- 原型再映射到底层素材与 preset
---
## 4.5 兼容迁移层:旧字段继续保留一段时间
为了不破坏当前流程,短期内不能直接删除:
- `templateWorldType`
- `WorldTemplateType`
- 以及相关 normalize / save / read 逻辑
这层应被降级成:
```ts
interface CustomWorldCompatibilityProfile {
legacyTemplateWorldType?: 'WUXIA' | 'XIANXIA' | null;
migrationVersion: string;
}
```
作用:
1. 旧存档兼容
2. 旧 prompt 兼容
3. 旧测试兼容
4. 旧工具链兼容
但它不再应是新生成世界的第一真相来源。
---
## 5. 现有每类依赖如何改造成自定义世界自己的设定
下面把现有依赖逐项映射成未来目标。
## 5.1 `templateWorldType`
当前作用:
- 世界锚点
- 规则 fallback
- 视觉和怪物参考入口
目标改造:
- 拆成:
- `semanticAnchor`
- `ruleProfile`
- `referenceProfile`
- `compatibilityProfile`
迁移方式:
1. 旧字段保留
2. 新字段生成后优先读新字段
3. 旧字段只做迁移 fallback
## 5.2 `resolveRuleWorldType(...)`
当前作用:
-`CUSTOM` 解析回 `WUXIA / XIANXIA`
目标改造:
- 改成:
```ts
resolveCustomWorldRuleProfile(profile)
```
运行时不再需要知道“它更像武侠还是仙侠”,而只需要知道:
- 这个世界的规则 profile 是什么
## 5.3 `getPresetWorldAttributeSchema(...)`
当前作用:
- 自定义世界 schema 的参考底板
目标改造:
- 把预设 schema 底板重构成:
- 通用 schema seeds
例如按功能分:
1. 承压轴
2. 机动轴
3. 洞察轴
4. 决断轴
5. 共鸣轴
6. 续航轴
然后由自定义世界的 `semanticAnchor + creatorIntent` 生成最终命名与说明。
## 5.4 `PRESET_CHARACTERS`
当前作用:
- 自定义世界角色的战斗模板和技能骨架
目标改造:
- 抽出:
- `RoleArchetypeProfile`
- `SkillArchetypeProfile`
也就是说:
- 还可以继续复用当前角色的战斗结构
- 但不应再让自定义世界依赖那些模板角色的人设文本
## 5.5 场景图片参考池
当前作用:
- 自定义世界默认场景图匹配
目标改造:
- 改成:
- `SceneArchetypeBucket`
每个 bucket 只表达通用空间语义,不表达模板世界名。
## 5.6 怪物池
当前作用:
- 自定义世界敌对单位匹配 preset
目标改造:
- 改成:
- `CreatureArchetypeProfile`
由 archetype 再映射到底层怪物素材与 preset。
## 5.7 `buildThemePackFromWorldProfile(...)`
当前作用:
- 以模板题材包为底生成自定义世界 ThemePack
目标改造:
- 变成:
- `buildThemePackFromCustomWorldSemanticAnchor(...)`
即 ThemePack 以自定义世界自己的语义锚和词汇锚为底。
---
## 6. 不破坏当前流程的迁移顺序
这是最关键的落地顺序。
## 阶段 A先给 `CustomWorldProfile` 补新设定层
先补:
1. `semanticAnchor`
2. `ruleProfile`
3. `expressionProfile`
4. `referenceProfile`
5. `compatibilityProfile`
这一步只做新增,不删旧字段。
## 阶段 B旧字段自动编译新字段
当前已有 profile、旧存档、旧生成结果先统一经过
```ts
compileOwnedSettingLayersFromLegacyTemplate(profile)
```
让:
- 旧世界也拥有新设定层
- 新运行时可优先消费新字段
## 阶段 C生成链开始直接产出新设定层
修改:
- `framework prompt`
- `normalizeCustomWorldGenerationFramework(...)`
- `customWorld.ts`
使新生成世界优先产出:
- `semanticAnchor`
- `ruleProfile hints`
- `referenceProfile hints`
而不是只产出 `templateWorldType`
## 阶段 D运行时逐步改读新设定层
优先改:
1. 资源词与货币
2. attribute schema
3. ThemePack
4. 视觉参考
5. 怪物映射
6. 角色原型引用
要求:
- 每次只切一层
- 每层都保留 fallback
## 阶段 E把旧模板字段降级为兼容层
当上面的读取已经都切走后:
- `templateWorldType` 就不再是主链依赖
- 只作为:
- migration
- 老存档兼容
- 老工具兼容
---
## 7. 推荐新增的最小字段
为了避免系统膨胀,建议只先补最小集合。
## 7.1 `CustomWorldOwnedSettingLayers`
```ts
interface CustomWorldOwnedSettingLayers {
semanticAnchor: CustomWorldSemanticAnchor;
ruleProfile: CustomWorldRuleProfile;
expressionProfile: CustomWorldExpressionProfile;
referenceProfile: CustomWorldReferenceProfile;
compatibilityProfile?: CustomWorldCompatibilityProfile | null;
}
```
然后挂到:
```ts
interface CustomWorldProfile {
ownedSettingLayers?: CustomWorldOwnedSettingLayers | null;
}
```
这样好处是:
1. 不用在 `CustomWorldProfile` 顶层堆太多字段
2. 迁移更集中
3. 后续删除兼容层更容易
---
## 8. 对当前功能链的保护要求
这次优化过程中,下面这些能力不能坏:
1. 自定义世界创建
2. 自定义世界保存 / 读取
3. 自定义世界角色生成
4. 自定义世界场景生成
5. 自定义世界开局
6. 自定义世界运行时的:
- 属性
- 资源词
- 经济
- 场景图
- 敌对实体
- ThemePack
- prompt 组织
也就是说:
**任何一次迭代都必须是“新层可用 + 旧层仍可兜底”。**
---
## 9. 验收标准
当下面这些标准成立时,说明这套优化开始有效:
1. 新生成的自定义世界不再必须依赖 `templateWorldType` 才能表达自身设定。
2. 运行时优先读取 `ownedSettingLayers`,而不是先问武侠 / 仙侠。
3. 自定义世界的属性、资源词、经济、视觉参考、怪物映射、ThemePack 都能从自身设定层派生出来。
4. 新设定层描述的是通用语义,不是模板世界换皮。
5. 当前自定义世界生成流程、旧存档、旧结果页工作台仍然可用。
---
## 10. 最后结论
如果目标是:
**让这些依赖都变成属于自定义世界的设定,而且这些设定要通用于任何题材。**
那么最正确的方向不是“继续弱化武侠 / 仙侠字样”,而是:
**把模板支撑层整体迁移成自定义世界自己的设定层。**
更具体地说,就是把当前依赖重组为:
1. 自定义世界自己的语义锚
2. 自定义世界自己的规则 profile
3. 自定义世界自己的表达 profile
4. 自定义世界自己的原型参考 profile
5. 只负责兼容的旧模板字段
这样之后,自定义世界才会真正从:
**模板依赖型生成架构**
迁移成:
**跨题材、自有设定层、且兼容当前流程的生成架构。**

View File

@@ -0,0 +1,656 @@
# 自定义世界去模板依赖与跨题材通用化优化设计
更新时间:`2026-04-08`
## 0. 目标
这份文档解决的是一个已经明确暴露出来的问题:
**当前玩家主流程虽然已经移除了武侠 / 仙侠两个预设世界,但自定义世界底层仍然依赖武侠 / 仙侠模板设定。**
本次优化的目标不是简单“删掉模板字段”,而是要在不破坏当前自定义世界生成流程的前提下,把这些依赖逐步改造成:
**属于自定义世界自身、且能通用于任何题材的设定层。**
一句话定义:
**让自定义世界从“挂靠武侠 / 仙侠模板运行”,升级成“基于通用世界设定层独立运行”。**
---
## 1. 优化原则
这次优化必须同时满足 3 条硬原则:
1. 设定归自定义世界自身所有
- 任何运行时、生成期、表现层真正依赖的设定,都应尽量落回 `CustomWorldProfile` 或由它直接编译出的通用配置,不再默认挂在 `WUXIA / XIANXIA` 两个模板世界上。
2. 设定必须跨题材通用
- 不能把“自定义世界去模板化”理解成“再做一套更抽象的武侠 / 仙侠替代字段”。
- 新结构必须能容纳奇幻、科幻、悬疑、末世、现代、神话、校园等任何题材。
3. 不能破坏当前自定义世界生成流程
- 现有 `framework -> themePack -> storyGraph -> role / landmark -> runtime` 主链必须继续能跑。
- 优化应以兼容迁移为主,而不是大爆破式重写。
---
## 2. 当前问题归纳
根据 [CUSTOM_WORLD_TEMPLATE_DEPENDENCY_INVENTORY_2026-04-08.md](../reference/CUSTOM_WORLD_TEMPLATE_DEPENDENCY_INVENTORY_2026-04-08.md),当前自定义世界仍依赖模板层的地方主要有:
1. 模板锚点类型
- `templateWorldType`
- `WorldTemplateType`
- `resolveRuleWorldType(...)`
2. 主题与规则回退
- `detectCustomWorldThemeMode(...)`
- `resolveCustomWorldAnchorWorldType(...)`
- `buildThemePackFromWorldProfile(...)` 底板
3. 属性与表现
- 预设世界属性 schema
- 资源词、数值命名、货币命名
4. 角色骨架
- `PRESET_CHARACTERS`
- 模板技能定义
- 模板 opening 接口
5. 场景与视觉参考
- 武侠 / 仙侠场景图片参考池
- 模板 camp 场景映射
6. 怪物模板池
- 武侠 / 仙侠怪物 preset 池
这些依赖本质上说明:
**当前自定义世界不是完全自足,而是“先生成自定义内容,再把它映射回两套模板世界骨架”。**
---
## 3. 这次优化不应该怎么做
先明确几个错误方向:
## 3.1 不能直接删掉 `templateWorldType`
如果直接删除:
- `templateWorldType`
- `WorldTemplateType`
- `WUXIA / XIANXIA` 相关回退
而不先补新的通用设定层,那么当前自定义世界会立刻失去:
1. 规则桥接
2. 主题底板
3. 场景图参考
4. 怪物池匹配
5. 属性 schema fallback
这会直接打断现有生成和运行链路。
## 3.2 不能把新设定继续写成“武侠 / 仙侠的抽象别名”
例如下面这种思路是不够的:
-`templateWorldType` 改名成 `worldFamily`
- 但值仍然只有两类近似武侠 / 仙侠的内部枚举
这不是真正跨题材,只是换了名字。
## 3.3 不能让创作者承担更多底层配置工作
这次优化不是让创作者额外填写:
- 怪物模板表
- 场景参考池
- 属性 schema 槽位
- 规则 profile
正确方向应该是:
**这些通用设定仍由系统生成 / 编译,但所有权回到自定义世界自身。**
---
## 4. 目标结构:把模板依赖改造成自定义世界自己的 4 层通用设定
为了避免系统越改越散,这次建议不要新增很多彼此平行的新系统,而是把现有模板依赖统一收束成 4 层通用设定。
## 4.1 第一层:世界语义锚层
这是替代 `templateWorldType` 的核心层。
它不再回答“你更像武侠还是仙侠”,而回答:
1. 这个世界的主要冲突形式是什么
2. 这个世界的制度、禁忌、叙事载体、力量来源是什么
3. 这个世界更接近哪类表现模式
建议由自定义世界自己持有一个通用结构,例如:
```ts
interface CustomWorldSemanticAnchor {
genreSignals: string[];
conflictModel: string[];
institutionHints: string[];
tabooHints: string[];
carrierHints: string[];
forceSystemHints: string[];
}
```
说明:
- 它是自定义世界自己的语义锚。
- 它可以表示:
- 宗门与灵脉
- 财团与实验体
- 学园与旧规
- 边境与裂界
- 海潮与失落群岛
- 神话与誓约
- 不再强制回落到武侠 / 仙侠二选一。
## 4.2 第二层:规则与表现配置层
这是替代:
- `resolveRuleWorldType(...)`
- 预设属性 schema fallback
- 资源命名 fallback
- 经济 fallback
建议改成由自定义世界持有一份通用 `WorldRuleProfile`
```ts
interface WorldRuleProfile {
attributeSchema: WorldAttributeSchema;
resourceLabels: {
hp: string;
mp: string;
maxHp: string;
maxMp: string;
damage: string;
guard: string;
range: string;
cooldown: string;
manaCost: string;
currency: string;
};
economyProfile: {
initialCurrency: number;
rarityValueScale: Record<string, number>;
};
}
```
关键点:
1. 以后运行时不要再优先问“这是武侠还是仙侠”。
2. 应该直接问:
- `profile.ruleProfile.attributeSchema`
- `profile.ruleProfile.resourceLabels`
- `profile.ruleProfile.economyProfile`
这样:
- 奇幻世界可以叫“体魄 / 法力”
- 末世世界可以叫“生命 / 电量”
- 校园悬疑世界甚至可以弱化“mana”概念改成“专注 / 压力”
## 4.3 第三层:内容骨架与参考层
这是替代:
- 预设角色模板骨架
- 武侠 / 仙侠场景图参考池
- 武侠 / 仙侠怪物 preset 池
这里不建议让自定义世界自己保存大批素材,而是建议让自定义世界持有:
**对内容骨架的“编译后引用配置”。**
建议统一成一个 `ContentReferenceProfile`
```ts
interface ContentReferenceProfile {
roleArchetypes: RoleArchetypeProfile[];
sceneReferenceBuckets: SceneReferenceBucket[];
creatureArchetypes: CreatureArchetypeProfile[];
}
```
其中每个子项都应是通用语义,而不是模板世界名:
1. `RoleArchetypeProfile`
- 例如:
- 正面压制型
- 远程游击型
- 灵术控制型
- 重装承压型
- 潜行刺击型
2. `SceneReferenceBucket`
- 例如:
- 高压门禁区
- 雨夜街巷区
- 高空通道区
- 神殿 / 仪式区
- 工业热区
- 潮湿临水区
3. `CreatureArchetypeProfile`
- 例如:
- 潜伏掠食者
- 重甲承压者
- 群居灵体
- 远程骚扰者
- 寄生污染体
关键点:
- 这些 archetype 可以继续映射到底层素材和 preset。
- 但对自定义世界来说,它依赖的是“通用原型”,不是“武侠怪物池 / 仙侠怪物池”。
## 4.4 第四层:叙事与词汇编译层
这是替代:
- 以模板世界为底的 `ThemePack` fallback
- prompt 中的模板世界兼容字段
建议做法:
1. `ThemePack` 继续保留,但它的来源变成:
- `semanticAnchor + creatorIntent + world text + content reference profile`
2. 生成框架 prompt 不再要求输出:
- `templateWorldType: WUXIA|XIANXIA`
3. 改为要求输出:
- 世界语义锚
- 规则表现关键词
- 冲突和制度线索
例如:
```ts
interface CustomWorldGenerationFramework {
name: string;
subtitle: string;
summary: string;
tone: string;
playerGoal: string;
semanticAnchor: CustomWorldSemanticAnchor;
majorFactions: string[];
coreConflicts: string[];
camp: CampOutline;
}
```
这样:
- 生成流程仍然是 `framework -> themePack -> storyGraph -> role / landmark`
- 只是 framework 的核心锚不再依赖预设世界枚举
---
## 5. 现有依赖如何一一改造
## 5.1 `templateWorldType` -> `semanticAnchor + ruleProfile`
当前用途:
- 表示自定义世界挂靠武侠还是仙侠
目标改造:
- 运行时不再直接读取 `templateWorldType`
- 改为读取:
- `semanticAnchor`
- `ruleProfile`
迁移策略:
1. 先保留 `templateWorldType` 作为兼容字段
2. 新增 `semanticAnchor / ruleProfile`
3. 由旧字段自动编译出新字段
4. 所有读取逻辑逐步切到新字段
5. 最后把 `templateWorldType` 降级为 migration-only 字段
## 5.2 `resolveRuleWorldType(...)` -> `resolveWorldRuleProfile(...)`
当前用途:
-`CUSTOM` 解析回 `WUXIA / XIANXIA`
目标改造:
- 不再返回模板 world type
- 直接返回自定义世界自己的 `ruleProfile`
即:
```ts
resolveWorldRuleProfile(worldType, customWorldProfile)
```
返回:
- `attributeSchema`
- `resourceLabels`
- `economyProfile`
- 其它规则信息
## 5.3 预设属性 schema -> 通用 attribute schema seed
当前用途:
- 用武侠 / 仙侠 schema 作为自定义世界 schema 参考底板
目标改造:
- 不再直接引用“武侠六脉 / 仙侠六轴”作为唯一底板
- 改为维护一组通用 `attribute schema seeds`
例如可按世界体验而不是题材命名:
1. 正面对抗型
2. 机动博弈型
3. 灵知洞察型
4. 共鸣契约型
5. 生存续航型
6. 高危推进型
然后由自定义世界的 `semanticAnchor` 决定如何组合或命名这些槽位。
## 5.4 预设角色模板 -> 通用角色原型骨架
当前用途:
-`PRESET_CHARACTERS` 选模板角色,再覆写自定义世界内容
目标改造:
- 把当前模板角色骨架抽成“通用战斗原型角色”
例如保留的是:
- 技能骨架
- 动作风格
- build 倾向
- 动画资源挂载方式
而不是保留“剑之公主 / 神箭游侠”这样的预设世界人格设定。
关键点:
- 动画素材和技能骨架可以保留
- 但运行时不应再依赖具体模板角色的人设文本
## 5.5 武侠 / 仙侠场景图参考池 -> 通用场景参考桶
当前用途:
- 用武侠 / 仙侠场景关键词为自定义世界匹配默认背景图
目标改造:
- 改成通用 `SceneReferenceBucket`
- 每个 bucket 对应一类空间语义
例如:
1. 落脚处 / 归舍
2. 高压入口
3. 雨湿街巷
4. 高空通路
5. 仪式空间
6. 工业热区
7. 临水空间
8. 地底遗迹
这样就能让:
- 科幻世界
- 校园世界
- 神话世界
- 末世世界
都共享同一套“空间语义 -> 视觉参考”的逻辑。
## 5.6 武侠 / 仙侠怪物池 -> 通用生物原型库
当前用途:
- 从武侠 / 仙侠怪物池里为自定义世界匹配怪物
目标改造:
- 改成通用 `CreatureArchetypeProfile`
- 再由 archetype 去映射底层 preset / 数值 / 动画素材
这样做的好处:
1. 自定义世界依赖的是“潜伏者 / 重压者 / 群居体 / 异化体”
2. 而不是“这更像武侠怪还是仙侠怪”
## 5.7 `ThemePack` 底板 -> 通用语义底板
当前用途:
- 自定义世界的 ThemePack 还是从预设题材包底板开始
目标改造:
- 让 ThemePack 直接从:
- `semanticAnchor`
- `creatorIntent`
- `majorFactions`
- `coreConflicts`
- `contentReferenceProfile`
编译出来
也就是说:
**ThemePack 应变成自定义世界自己的派生物,而不是模板世界的扩写版。**
---
## 6. 不破坏现有生成流程的迁移方案
这是这份文档最重要的部分。
正确做法不是一口气替换,而是兼容迁移。
## 阶段 A新增通用设定字段但不删旧字段
先做:
1.`CustomWorldProfile` 上新增:
- `semanticAnchor`
- `ruleProfile`
- `contentReferenceProfile`
2. 保留:
- `templateWorldType`
3. 由当前旧字段自动编译出新字段
目标:
- 先让新结构出现
- 但现有流程完全不受影响
## 阶段 B生成流程改为优先产出新字段
先改:
- `framework prompt`
- `customWorld.ts`
让 AI 先输出:
- 通用语义锚
- 规则提示
- 通用 archetype 线索
同时在 normalize 层继续兼容旧的 `templateWorldType`
目标:
- 新生成的自定义世界开始“原生带通用设定”
- 旧存档仍可继续读取
## 阶段 C运行时读取切到新设定
依次改:
1. 规则层
-`resolveRuleWorldType` 切到 `resolveWorldRuleProfile`
2. 属性层
- 优先读 `profile.ruleProfile.attributeSchema`
3. 资源词与经济层
- 优先读 `profile.ruleProfile.resourceLabels / economyProfile`
4. 场景图与怪物映射
- 优先读 `contentReferenceProfile`
目标:
- 让模板世界字段不再是运行时第一来源
## 阶段 D模板世界字段退化为兼容层
这一步完成后:
1. `templateWorldType` 只用于:
- 旧存档迁移
- 老测试兼容
- 数据修复 fallback
2. 不再用于:
- 正式生成主链
- 正式运行时主链
## 阶段 E再做深清理
只有当上面几步都完成后,才适合继续清理:
1. 非必要的模板回退逻辑
2. 非必要的主流程模板枚举消费点
3. 非必要的审计 / 工具硬编码
---
## 7. 对当前主链的兼容要求
这次优化过程中,下面这些链路必须始终可用:
1. `PreGameSelectionFlow -> generateCustomWorldProfile(...)`
2. `framework -> themePack -> storyGraph -> role / landmark`
3. 保存 / 读取自定义世界 profile
4. 自定义世界开局
5. 自定义世界角色与场景生成
6. 自定义世界运行时怪物 / 物品 / 场景图 /词汇表现
也就是说:
**任何迁移都必须先补新字段,再迁读,再退旧字段,不能先删旧字段。**
---
## 8. 建议新增或改造的最小数据结构
为了避免系统膨胀,这次不建议引入很多彼此割裂的新系统,建议只在 `CustomWorldProfile` 周边增量补三块。
## 8.1 `semanticAnchor`
```ts
interface CustomWorldSemanticAnchor {
genreSignals: string[];
conflictModel: string[];
institutionHints: string[];
tabooHints: string[];
carrierHints: string[];
forceSystemHints: string[];
}
```
## 8.2 `ruleProfile`
```ts
interface WorldRuleProfile {
attributeSchema: WorldAttributeSchema;
resourceLabels: {
hp: string;
mp: string;
maxHp: string;
maxMp: string;
damage: string;
guard: string;
range: string;
cooldown: string;
manaCost: string;
currency: string;
};
economyProfile: {
initialCurrency: number;
};
}
```
## 8.3 `contentReferenceProfile`
```ts
interface ContentReferenceProfile {
roleArchetypes: string[];
sceneReferenceBuckets: string[];
creatureArchetypes: string[];
}
```
这三块足够作为第一轮去模板化的最小自定义世界设定层。
---
## 9. 验收标准
做到以下几点,才能说明自定义世界真正开始脱离模板依赖:
1. 新生成的自定义世界框架不再需要直接输出 `WUXIA|XIANXIA` 才能工作。
2. 运行时核心逻辑优先读取 `semanticAnchor / ruleProfile / contentReferenceProfile`
3. 自定义世界的属性 schema、资源词、经济词不再默认直接回退到武侠 / 仙侠文案。
4. 自定义世界的角色骨架、场景视觉、怪物映射能通过通用 archetype 表达。
5. 现有生成流程、存档读取、运行时体验不受破坏。
6. 旧存档仍能通过兼容层运行。
---
## 10. 最后结论
当前自定义世界真正缺的,不是“再删一点武侠 / 仙侠字样”,而是:
**把模板世界支撑层改写成自定义世界自己的通用设定层。**
更准确地说,这次优化要完成的是:
1. 把模板锚点改成自定义世界自己的语义锚
2. 把模板回退改成自定义世界自己的规则配置
3. 把模板角色 / 场景 / 怪物参考改成通用原型引用
4. 把 ThemePack 和生成 prompt 从“依附模板世界”改成“直接从自定义世界自身编译”
同时整个过程必须遵守一条底线:
**任何优化都不能破坏当前自定义世界生成与运行主链。**
所以这不是“删模板”的问题,而是一次:
**在兼容现有流程前提下,把自定义世界从模板依赖型架构,迁移成真正跨题材、自足型架构。**

View File

@@ -5,6 +5,8 @@
## 文档列表
- [CUSTOM_WORLD_CREATOR_INPUT_AND_AI_BOUNDARY_DESIGN_2026-04-06.md](./CUSTOM_WORLD_CREATOR_INPUT_AND_AI_BOUNDARY_DESIGN_2026-04-06.md):自定义世界里创作者输入与 AI 分工边界设计。
- [CUSTOM_WORLD_TEMPLATE_DECOUPLING_AND_CROSS_GENRE_GENERALIZATION_DESIGN_2026-04-08.md](./CUSTOM_WORLD_TEMPLATE_DECOUPLING_AND_CROSS_GENRE_GENERALIZATION_DESIGN_2026-04-08.md):把自定义世界从武侠/仙侠模板依赖迁到跨题材通用设定层的优化设计。
- [CUSTOM_WORLD_SELF_OWNED_SETTING_LAYER_OPTIMIZATION_2026-04-08.md](./CUSTOM_WORLD_SELF_OWNED_SETTING_LAYER_OPTIMIZATION_2026-04-08.md):把模板依赖逐步迁成自定义世界自有设定层,并保证不破坏当前生成流程的优化方案。
- [AI_NATIVE_RUNTIME_ITEM_SYSTEM_REDESIGN_2026-04-02.md](./AI_NATIVE_RUNTIME_ITEM_SYSTEM_REDESIGN_2026-04-02.md):运行时物品生成系统重设计。
- [EQUIPMENT_BUILD_AND_FORGE_LOOP_SYSTEM_DESIGN.md](./EQUIPMENT_BUILD_AND_FORGE_LOOP_SYSTEM_DESIGN.md):配装构筑与合成/锻造闭环设计。
- [COMPANION_FIRST_CONTACT_RELATIONSHIP_AND_PRIVATE_CHAT_DESIGN_2026-04-04.md](./COMPANION_FIRST_CONTACT_RELATIONSHIP_AND_PRIVATE_CHAT_DESIGN_2026-04-04.md):角色首遇感、关系分层解锁、私聊系统设计。
@@ -15,6 +17,8 @@
- 做物品、Build、锻造相关需求时先看前两份。
- 做自定义世界创作工作台、创作者输入边界、AI 分工设计时,先看第一份。
- 做自定义世界去模板依赖、跨题材泛化、兼容迁移设计时,优先看新增的去模板化优化设计稿。
- 做“模板依赖如何真正变成自定义世界自有设定层”的具体迁移方案时,优先看新增的自有设定层优化方案。
- 做角色关系、同伴互动、对话表现时,先看后两份。
- 做剧情引擎章节化、场景闭环、章节任务接入时,优先看新增的场景章节设计稿。
- 如果要判断是否符合目标,再和 `docs/prd/` 中对应 PRD 对照阅读。

View File

@@ -0,0 +1,398 @@
# 自定义世界依赖的模板设定清单
更新时间:`2026-04-08`
## 0. 这份清单回答什么
这份文档只回答一个问题:
**当前仓库里,自定义世界仍然依赖哪些“模板世界设定”。**
这里的“模板世界设定”不是指玩家还能进入的 `武侠 / 仙侠` 预设世界流程,而是指:
1. 自定义世界在生成时仍借用哪些模板锚点。
2. 自定义世界在运行时仍复用哪些模板 schema、词库、怪物池、角色模板、图片参考池。
3. 哪些引用是“必须保留”,否则会直接伤到自定义世界。
4. 哪些引用只是编辑器 / 审计 / 测试残留,不属于自定义世界主链硬依赖。
一句话结论先说:
**当前主流程虽然已经不再让玩家进入武侠 / 仙侠预设世界,但自定义世界底层仍明确依赖“模板世界锚点层”。**
---
## 1. 依赖总览
自定义世界当前仍依赖的模板设定,主要分成 7 类:
1. 模板世界锚点类型
2. 主题判定与规则回退
3. 属性 schema 与术语体系
4. 角色模板与技能骨架
5. 场景图参考池与营地图像逻辑
6. 怪物 / 敌对实体模板池
7. 叙事 ThemePack 与生成 prompt 兼容字段
除此之外,还有一类是:
8. 编辑器 / 审计 / 测试中的残留模板引用
这类不一定属于自定义世界正式运行时硬依赖,但当前仓库里仍然存在。
---
## 2. 核心硬依赖
## 2.1 模板世界锚点类型
这是最底层、也是目前最不能直接删的部分。
相关文件:
- `src/types/core.ts`
- `src/types/customWorld.ts`
- `src/services/customWorld.ts`
- `src/data/customWorldLibrary.ts`
当前依赖点:
1. `WorldType.WUXIA / WorldType.XIANXIA / WorldType.CUSTOM`
- 自定义世界虽然运行时是 `CUSTOM`,但其模板锚点仍通过 `WUXIA / XIANXIA` 表达。
2. `WorldTemplateType = Exclude<WorldType, WorldType.CUSTOM>`
- 这就是“自定义世界挂靠哪个模板锚”的类型定义。
3. `CustomWorldProfile.templateWorldType`
- 当前自定义世界 profile 里明确保存这个字段。
- 它是兼容字段,但目前仍被多个系统直接消费。
4. `buildCustomWorldFrameworkPrompt(...)`
- 生成框架 prompt 仍要求模型输出:
- `"templateWorldType": "WUXIA|XIANXIA"`
5. `customWorldLibrary.normalizeProfile(...)`
- 本地读取自定义世界时,也会把 `templateWorldType` 归一化到:
- `WUXIA`
- `XIANXIA`
结论:
**如果直接删掉 `WUXIA / XIANXIA / WorldTemplateType / templateWorldType` 这层,自定义世界的生成、存档归一化和多处运行时回退都会直接断。**
---
## 2.2 主题判定与规则回退
相关文件:
- `src/services/customWorldTheme.ts`
- `src/data/customWorldRuntime.ts`
- `src/services/storyEngine/themePack.ts`
当前依赖点:
1. `detectCustomWorldThemeMode(profile)`
- 先把自定义世界识别成:
- `martial`
- `arcane`
- `machina`
- `tide`
- `rift`
- `mythic`
2. `resolveCustomWorldAnchorWorldType(profile)`
- 再把主题模式压回模板锚点:
- `arcane -> XIANXIA`
- 其它默认回到 `WUXIA`
3. `resolveRuleWorldType(worldType, customWorldProfile)`
- 这是当前运行时非常关键的桥接函数。
-`worldType === CUSTOM` 时,它会把规则世界解析成模板锚点。
- 如果没有 profile还会默认回退到 `WUXIA`
4. `buildThemePackFromWorldProfile(profile)`
- 自定义世界最终的 `ThemePack` 并不是纯空中生成。
- 它是从预设的主题包底板开始,再用自定义世界自己的词汇补进去。
结论:
**自定义世界现在不是完全脱离模板世界独立运行,而是“先判定自己的主题模式,再回落到模板锚点做规则支撑”。**
---
## 2.3 属性 schema 与术语体系
相关文件:
- `src/data/worldAttributeSchemas.ts`
- `src/services/attributeSchemaGenerator.ts`
- `src/services/customWorldPresentation.ts`
- `src/data/economy.ts`
当前依赖点:
1. `PRESET_WORLD_ATTRIBUTE_SCHEMAS`
- 当前仓库里有两套预设属性 schema
- 武侠:`江湖六脉`
- 仙侠:`灵界六轴`
2. `getPresetWorldAttributeSchema(...)`
- 多个地方仍然用它作为生成自定义世界 schema 的参考底板。
3. `generateWorldAttributeSchema(...)`
- 自定义世界自己的 attribute schema 虽然是生成的,
- 但内部会参考预设世界 schema 的槽位结构和 fallback 逻辑。
4. `getAttributeLabelsForWorld(...) / getResourceLabelsForWorld(...)`
- 如果当前没有显式自定义 world presentation会按 `WUXIA / XIANXIA` 走回退术语。
5. `getCurrencyName(...) / getInitialPlayerCurrency(...)`
- 经济层仍按模板世界决定初始货币命名和初始数量。
结论:
**自定义世界现在虽然有自己的 presentation 和 attribute schema但模板世界仍然是它们的 fallback 和参考骨架。**
---
## 2.4 角色模板与技能骨架
相关文件:
- `src/data/characterPresets.ts`
当前依赖点:
1. `PRESET_CHARACTERS`
- 自定义世界的可玩角色 / 场景角色运行时形态,是从现有预设角色模板变体化出来的。
2. `pickCustomWorldRoleTemplateCharacter(...)`
- 会从 `PRESET_CHARACTERS` 里挑一个模板角色作为骨架。
3. `buildCustomWorldRoleCharacter(...)`
- 会把自定义世界角色内容覆盖到模板角色上:
- 名字
- 背景
- 描述
- 技能文案
- 外观
- 等等
4. `buildCustomWorldSkillVariant(...)`
- 自定义世界角色技能的数值和命名,也是从模板技能定义变体生成出来的。
5. `adventureOpenings`
- 当前实现里,自定义世界角色 opening 同时写入:
- `WUXIA`
- `XIANXIA`
- `CUSTOM`
- 说明角色开局结构仍沿用模板世界的旧接口习惯。
6. `getCharacterHomeSceneId / getCharacterNpcSceneIds`
-`CUSTOM` 路径已经优先走自定义世界自己的 landmark 映射;
- 但非 CUSTOM 路径仍大量依赖模板世界的基础场景绑定表。
结论:
**自定义世界角色并不是从零独立建模,而是“自定义内容 + 预设角色模板骨架”的组合。**
---
## 2.5 场景图参考池与营地图像逻辑
相关文件:
- `src/data/customWorldVisuals.ts`
- `src/services/customWorldCamp.ts`
当前依赖点:
1. `WUXIA_SCENE_IMAGE_REFERENCES`
2. `XIANXIA_SCENE_IMAGE_REFERENCES`
3. `WORLD_SCENE_IMAGE_REFERENCES`
当前自定义世界场景图不是纯随机抽图,而是:
1. 先确定模板锚点世界
2. 再从对应模板世界的场景参考词池里匹配:
- 场景名称
- 关键词
- 图片参考
具体依赖:
1. `collectWorldSceneImagePool(worldType)`
- 按模板世界从背景包里抽参考池。
2. `buildSceneReferencePool(worldType)`
- 用武侠 / 仙侠各自的场景参考名和关键词构造图像匹配池。
3. `getDefaultCustomWorldSceneImage(...)`
- 自定义世界 landmark / camp / 场景默认图,会基于模板世界参考池挑选。
4. `resolveCustomWorldCampSceneImage(profile)`
- 开局归处场景图也依赖 `templateWorldType` 和主题判定。
结论:
**当前自定义世界的场景视觉虽然是独立输出,但“默认图像匹配逻辑”仍然是挂在武侠 / 仙侠两套参考池上的。**
---
## 2.6 怪物 / 敌对实体模板池
相关文件:
- `src/data/customWorldNpcMonsters.ts`
- `src/data/hostileNpcPresets.ts`
- `src/data/hostileNpcs.ts`
- `src/data/scenePresets.ts`
当前依赖点:
1. `resolveCustomWorldNpcMonsterPreset(...)`
- 自定义世界中带敌意的 NPC / 怪物,会从模板怪物池里找最接近的 preset。
2. `getMonsterPresetPool(worldType?)`
- 如果传了 worldType就取对应模板世界的怪物池。
- 如果没传,就把武侠和仙侠怪物池拼起来一起选。
3. `resolveRuleWorldType(...)`
- `hostileNpcPresets.ts``hostileNpcs.ts` 在处理 `CUSTOM` 时,会先解析到模板锚点,再决定取哪个怪物 preset / schema。
4. `getMonsterPresetsByWorld(...) / getHostileNpcPresetById(...)`
- 自定义世界的运行时怪物表现,目前仍然依赖这套模板怪物 preset 查询接口。
结论:
**自定义世界当前没有完全独立的怪物体系,仍然是基于武侠 / 仙侠预设怪物池做匹配和包装。**
---
## 2.7 叙事 ThemePack 与 prompt 兼容字段
相关文件:
- `src/services/customWorld.ts`
- `src/services/storyEngine/themePack.ts`
- `src/services/ai.ts`
当前依赖点:
1. 自定义世界框架 prompt 仍强制要求 `templateWorldType`
2. `normalizeCustomWorldGenerationFramework(...)`
- 会把模型输出的模板世界字段规范化
3. `buildThemePackFromWorldProfile(...)`
- 会以预设主题包为底,再混入自定义世界词汇
4. `ai.ts` 里某些 fallback 逻辑仍根据 `templateWorldType` 决定主题包回退
结论:
**自定义世界的 AI 生成链条目前明确假设“世界框架里存在模板锚点字段”。**
---
## 3. 运行时硬依赖 vs 非运行时残留
## 3.1 自定义世界正式运行时硬依赖
这些目前不能轻易删:
1. `WorldType.WUXIA / WorldType.XIANXIA / WorldTemplateType`
2. `CustomWorldProfile.templateWorldType`
3. `detectCustomWorldThemeMode / resolveCustomWorldAnchorWorldType / resolveRuleWorldType`
4. `PRESET_WORLD_ATTRIBUTE_SCHEMAS / getPresetWorldAttributeSchema`
5. `PRESET_CHARACTERS` 作为自定义角色模板骨架
6. 武侠 / 仙侠场景图参考池
7. 武侠 / 仙侠怪物 preset 池
8. `buildThemePackFromWorldProfile` 的模板底板
9. `customWorld.ts` 里生成框架 prompt 的 `templateWorldType` 字段约束
## 3.2 不是主流程硬依赖,但仓库里仍存在的残留引用
这些更多是编辑器 / 工具 / 审计 / 测试引用:
1. 预设编辑器
- `preset-editor/*`
2. 一些开发工具页
- 例如 `ItemCatalogEditor.tsx`
- `StateFunctionEditor.tsx`
3. 审计 / 报告工具
- `storyAuditReport.ts`
4. 各类基于武侠 / 仙侠的测试
5. 一些 UI 图标与世界按钮贴图
- 例如 `uiAssets.ts` 里的图标键位仍保留
这些不一定影响当前玩家主流程,但如果目标是“代码库级彻底清理”,它们也需要后续处理。
---
## 4. 当前最不能动的边界
如果前提是:
**不要动素材和自定义世界的任何设定。**
那么当前最不能直接删除的是:
1. `templateWorldType`
2. `WorldTemplateType`
3. `resolveRuleWorldType(...)`
4. `detectCustomWorldThemeMode(...)`
5. `resolveCustomWorldAnchorWorldType(...)`
6. `PRESET_WORLD_ATTRIBUTE_SCHEMAS`
7. `PRESET_CHARACTERS`
8. `customWorldVisuals.ts` 里的模板场景参考池
9. `customWorldNpcMonsters.ts` 对模板怪物池的映射
10. `themePack.ts` 的模板底板
原因很简单:
**这些不是“预设世界可玩入口”,而是自定义世界当前仍在使用的模板支撑层。**
---
## 5. 可以安全理解为“已从玩家主流程移除”的部分
目前已经可以视作从正式玩家流程移除的,是:
1. 世界选择页里的武侠 / 仙侠入口
2. 主流程的世界选择 API
3. 继续游戏入口中的武侠 / 仙侠旧存档
但这不等于:
- 代码库内部已经完全不再依赖模板世界
这两件事要分开看。
---
## 6. 最后结论
当前仓库里,自定义世界对模板设定的依赖,本质上是:
**“不再复用预设世界的玩家入口,但仍然复用预设世界的模板支撑层。”**
最准确的理解是:
1. 玩家已经不能直接进入武侠 / 仙侠预设世界
2. 但自定义世界仍借用武侠 / 仙侠作为:
- 模板锚点
- 规则回退
- 属性 schema 参考
- 角色模板骨架
- 场景图参考池
- 怪物模板池
- ThemePack 底板
所以如果后续要继续“深度清理”,正确顺序不是直接删光 `WUXIA / XIANXIA`,而应该是:
1. 先识别哪些是主流程入口,哪些是模板支撑层
2. 再决定是否要把自定义世界从“模板依赖型”重构成“完全自足型”
在那一步没做完之前,模板支撑层仍然是自定义世界当前可用性的真实依赖。

View File

@@ -3,8 +3,11 @@
## 当前入口
- [FUNCTION_SCRIPT_CATALOG_2026-04-04.md](./FUNCTION_SCRIPT_CATALOG_2026-04-04.md)Function 独立脚本目录与分类速查。
- [TASK_GENERATION_TRACE_2026-04-08.md](./TASK_GENERATION_TRACE_2026-04-08.md):任务描述、达成条件与奖励生成链路梳理。
- [CUSTOM_WORLD_TEMPLATE_DEPENDENCY_INVENTORY_2026-04-08.md](./CUSTOM_WORLD_TEMPLATE_DEPENDENCY_INVENTORY_2026-04-08.md):自定义世界当前仍依赖哪些模板世界设定的清单。
## 使用建议
- 需要快速定位 Function 脚本,而不是阅读长篇方案时,优先看这里。
- 需要判断“武侠 / 仙侠模板层”哪些还能删、哪些不能删时,优先看自定义世界模板依赖清单。
- 如果要评估 Function 分层是否合理,再配合 `docs/audits/FUNCTION_DESIGN_AUDIT_2026-04-03.md` 一起看。

View File

@@ -0,0 +1,248 @@
# 任务生成链路与简化建议
更新时间:`2026-04-08`
## 0. 简化版结论
推荐把任务系统收敛成一条主链:
1. `npcInteractions.ts`
- 只负责判断“现在是否适合接任务”,不再提前本地造整份任务预览。
2. `npcEncounterActions.ts`
- 玩家真正点击“接下委托”时,才调用 AI 任务导演。
3. `questDirector.ts + questPrompt.ts`
- 用 AI 原生剧情引擎根据当前局势生成任务意图。
4. `questFlow.ts`
- 只负责把 AI 意图编译成可追踪任务、生成本地奖励、推进步骤。
5. `goalDirector.ts`
- 把任务编译成“当前目标 / 下一步”。
6. `AdventurePanelOverlays.tsx`
- 只负责展示,不再自己承载任务生成逻辑。
一句话:
**任务内容主要由 AI 原生剧情引擎生成,本地代码只保留状态推进、奖励结算和失败兜底。**
## 1. 现在建议保留的主流程
## 1.1 接任务
推荐主流程:
`npcInteractions.ts`
-> 展示“可接任务”入口
-> `npcEncounterActions.ts`
-> `generateQuestForNpcEncounter(...)`
-> `questDirector.ts`
-> `questPrompt.ts`
-> `questFlow.ts`
-> 写入 `QuestLogEntry`
当前已经做的简化:
- NPC 面板不再为了预览,先本地生成一整份 fallback 任务。
- 现在只做任务机会判断,并提示“接取后将由 AI 剧情引擎根据当前局势生成具体目标、步骤与奖励”。
这样可以直接消掉一层不必要的双轨:
- 旧流程:
- 预览先本地生成
- 正式接取再 AI 生成
- 新流程:
- 预览只判断有没有任务机会
- 正式接取时再真正生成任务
## 1.2 任务描述
简化后应理解成:
- 任务描述的主来源是 AI 生成的 `QuestIntent.description`
- 本地只负责把它写入 `QuestLogEntry.description`
- UI 只负责展示 `quest.description`
主脚本:
- `src/services/questPrompt.ts`
- `src/services/questDirector.ts`
- `src/data/questFlow.ts`
## 1.3 达成条件
简化后应理解成:
- AI 负责给出“这件事大概要怎么做”的意图
- 本地把意图编译成 `steps / objective`
- UI 根据当前 `activeStep` 生成最短的“下一步”
也就是:
**AI 决定任务方向本地决定可追踪步骤UI 只显示当前这一步。**
主脚本:
- `src/data/questFlow.ts`
- `src/components/adventure-panel/AdventurePanelOverlays.tsx`
## 1.4 奖励
奖励不建议交给 AI 直接写死。
更稳的边界是:
- AI 只给 `rewardTheme`
- 本地生成具体金币、物品、好感奖励
- 交付时由本地状态系统结算
主脚本:
- `src/data/questFlow.ts`
- `src/data/runtimeItemDirector.ts`
- `src/hooks/story/sessionActions.ts`
- `src/hooks/story/npcEncounterActions.ts`
## 2. 你这次最关心的三个问题
## 2.1 任务描述怎么生成
主生成链:
- `src/services/questPrompt.ts`
- 约束 AI 输出 `title / description / summary / rewardTheme`
- `src/services/questDirector.ts`
- 请求 AI得到 `QuestIntent`
- `src/data/questFlow.ts`
- `compileQuestIntentToQuest(...)``description` 写入 `QuestLogEntry`
前台展示:
- `src/components/adventure-panel/AdventurePanelOverlays.tsx`
- 优先显示 `quest.description`
## 2.2 达成条件怎么生成
主生成链:
- `src/data/questFlow.ts`
- `buildPrimaryQuestStep(...)`
- `buildTalkBackStep(...)`
- 把 AI 任务意图编译成 `steps`
前台展示:
- `src/components/adventure-panel/AdventurePanelOverlays.tsx`
- `buildQuestConditionText(...)`
- 根据当前 `activeStep / objective` 重算成一句玩家看得懂的话
## 2.3 任务奖励怎么生成
主生成链:
- `src/data/questFlow.ts`
- `buildQuestReward(...)`
- `src/data/runtimeItemContext.ts`
- 给奖励物品生成器准备上下文
- `src/data/runtimeItemDirector.ts`
- 根据 seed 和频道生成奖励物品
- `src/data/runtimeItemNarrative.ts`
- 把奖励物品摊平回 `reward.items`
结算链:
- `src/hooks/story/sessionActions.ts`
- `src/hooks/story/npcEncounterActions.ts`
## 3. 哪些预设逻辑应该收缩
## 3.1 应该保留
- `buildFallbackQuestIntent(...)`
- 保留
- 但只作为 AI 失败兜底
- `buildQuestReward(...)`
- 保留
- 因为奖励和数值应该继续走本地规则
- `buildPrimaryQuestStep(...) / buildTalkBackStep(...)`
- 保留
- 因为任务必须能被本地追踪和结算
## 3.2 应该降级
- `buildQuestForEncounter(...)`
- 不再作为 NPC 面板预览主路径
- 只保留给 fallback 和测试使用
- `buildFallbackQuestIntent(...)` 里的多套模板
- 可以继续精简
- 最终只保留最小 2 到 3 种兜底 archetype 即可
## 3.3 应该谨慎接线
- `buildChapterQuestForScene(...)`
- 当前更像“备用章节任务生成器”
- 但我还没有在运行时主链里找到直接调用点
- `SCENE_CHAPTER_OVERRIDES`
- 不应该继续扩大
- 只保留少量关键样板场景
如果要坚持“AI 原生任务引擎”为主,这部分不应继续膨胀成大规模预设系统。
## 4. 脚本解释速查
| 脚本 | 作用 | 简化方案中的定位 |
| --- | --- | --- |
| `src/data/npcInteractions.ts` | 生成 NPC 面板选项、礼物/交易/帮助等交互入口 | 只负责任务机会判断和入口展示,不再预生成整任务 |
| `src/hooks/story/npcEncounterActions.ts` | 处理点击“接任务 / 交任务 / 切磋 / 离开”等真实执行逻辑 | 任务接取与交付的运行时主入口 |
| `src/services/questDirector.ts` | 调用 AI拿到任务意图 `QuestIntent` | AI 原生任务生成主入口 |
| `src/services/questPrompt.ts` | 组织任务 prompt约束 AI 输出格式 | AI 任务生成的提示词层 |
| `src/data/questFlow.ts` | 把任务意图编译成 quest、推进 steps、生成 reward、做 fallback | 任务数据层主脚本,保留但收缩预设分支 |
| `src/services/storyEngine/goalDirector.ts` | 把 quest / chapter / journeyBeat 编译成当前目标和下一步 | 负责“目标感”,不是负责生成任务本身 |
| `src/components/adventure-panel/AdventurePanelOverlays.tsx` | 展示任务描述、达成条件、奖励和日志 | 纯展示层 |
| `src/data/runtimeItemContext.ts` | 给奖励物品生成器准备上下文 | 奖励生成辅助层 |
| `src/data/runtimeItemDirector.ts` | 生成奖励物品 | 奖励物品生成主脚本 |
| `src/data/runtimeItemNarrative.ts` | 整理奖励物品和叙事 hint | 奖励辅助层 |
| `src/hooks/story/sessionActions.ts` | 统一处理领奖、章节同步等动作 | 非 NPC 面板路径下的奖励结算入口 |
## 5. 当前代码现状
截至这次整理,任务主链可以这样理解:
### 已经对齐到“AI 优先”的部分
- 真正点击“接下委托”时,优先调用 `generateQuestForNpcEncounter(...)`
- AI 返回任务意图后,再由 `questFlow.ts` 编译成本地任务
### 仍然是本地规则负责的部分
- `steps / objective` 推进
- 奖励数值和奖励物品
- 领奖与状态结算
### 仍然偏预设、但建议继续收缩的部分
- `buildFallbackQuestIntent(...)`
- `buildChapterQuestForScene(...)`
- `SCENE_CHAPTER_OVERRIDES`
## 6. 推荐后的最简架构
如果后面继续收缩,我建议把目标定成下面这版:
1. NPC 面板只判断“能不能接任务”
2. 点击接任务后,统一走 `questDirector.ts`
3. AI 只产出:
- `title`
- `description`
- `summary`
- `recommendedObjectiveKinds`
- `rewardTheme`
4. `questFlow.ts` 只负责:
- 编译 steps
- 生成 reward
- 推进 status
5. `goalDirector.ts` 只负责把 quest 变成“当前目标 / 下一步”
6. 章节任务生成器只做少量 fallback不再做大规模场景预设扩张
这样之后,代码层的职责会更清楚:
- AI 负责“这件事讲什么”
- 本地规则负责“这件事怎么追踪、怎么结算”
- UI 负责“把当前最重要的一步展示给玩家”

View File

@@ -0,0 +1,194 @@
# Node 后端知识图谱
日期:`2026-04-08`
## 1. 当前定位
当前运行时后端以 `server-node/` 为唯一有效服务端实现。
当前职责:
- 承接运行时鉴权
- 承接运行时持久化
- 承接运行时 AI 接口
- 为 Vite 前端提供开发期代理目标
当前不再使用:
- Vite 本地 API 插件 `scripts/dev-server/`
## 2. 技术栈
- HTTP 框架:`Express`
- 语言与构建:`TypeScript` + `tsx` + `esbuild`
- 数据库:`better-sqlite3`
- JWT`jose`
- 密码哈希:`@node-rs/argon2`
- 日志:`pino` + `pino-http` + `pino-roll`
## 3. 运行入口
推荐命令:
```bash
npm run dev:node
```
相关脚本:
- 根目录联调:`npm run dev:node`
- 单独启动后端开发模式:`npm run server-node:dev`
- 构建后端:`npm run server-node:build`
- 运行后端测试:`npm run server-node:test`
默认监听:
- 前端:`3000`
- Node 后端:`8081`
## 4. 目录与主入口
服务端主入口:
- `server-node/src/server.ts`
- `server-node/src/app.ts`
路由入口:
- `server-node/src/routes/authRoutes.ts`
- `server-node/src/routes/runtimeRoutes.ts`
基础设施:
- `server-node/src/config.ts`
- `server-node/src/logging.ts`
- `server-node/src/db.ts`
- `server-node/src/context.ts`
数据访问:
- `server-node/src/repositories/userRepository.ts`
- `server-node/src/repositories/runtimeRepository.ts`
鉴权相关:
- `server-node/src/auth/authService.ts`
- `server-node/src/auth/token.ts`
- `server-node/src/auth/password.ts`
- `server-node/src/middleware/auth.ts`
## 5. 鉴权模型
当前采用:
- 前端本地保存 `JWT + 自动生成的用户名密码`
- 请求头使用 `Authorization: Bearer <token>`
- 后端 middleware 统一解析出 `UserID`
- handler 不直接解析 token
当前账号策略:
- 默认自动匿名账号启动
- 本地无 JWT 时,前端会自动生成随机用户名密码并调用 `POST /api/auth/entry`
- 本地 JWT 失效但仍保留随机凭据时,前端自动重新调用 `auth/entry` 恢复同一账号
JWT 现状:
- 当前为永久签发
- claim 仍保留:`sub``iat``iss``ver`
- `logout` 通过递增 `token_version` 立即失效旧 token
## 6. 数据存储
当前数据库:
- 默认 SQLite 文件:`server-node/data/genarrative.sqlite`
- 可通过 `SQLITE_PATH` 覆盖
当前核心表:
- `users`
- `save_snapshots`
- `runtime_settings`
- `custom_world_profiles`
当前隔离原则:
- 所有运行时数据按用户隔离
## 7. 已承接接口
鉴权:
- `POST /api/auth/entry`
- `GET /api/auth/me`
- `POST /api/auth/logout`
运行时持久化:
- `GET /api/runtime/save/snapshot`
- `PUT /api/runtime/save/snapshot`
- `DELETE /api/runtime/save/snapshot`
- `GET /api/runtime/settings`
- `PUT /api/runtime/settings`
- `GET /api/runtime/custom-world-library`
- `PUT /api/runtime/custom-world-library/:profileId`
- `DELETE /api/runtime/custom-world-library/:profileId`
运行时 AI
- `POST /api/llm/chat/completions`
- `POST /api/custom-world/scene-image`
- `POST /api/runtime/story/initial`
- `POST /api/runtime/story/continue`
- `POST /api/runtime/custom-world/sessions`
- `GET /api/runtime/custom-world/sessions/:sessionId`
- `POST /api/runtime/custom-world/sessions/:sessionId/answers`
- `GET /api/runtime/custom-world/sessions/:sessionId/generate/stream`
- `POST /api/runtime/chat/character/suggestions`
- `POST /api/runtime/chat/character/summary`
- `POST /api/runtime/chat/character/reply/stream`
- `POST /api/runtime/chat/npc/dialogue/stream`
- `POST /api/runtime/chat/npc/recruit/stream`
- `POST /api/runtime/items/runtime-intent`
- `POST /api/runtime/quests/generate`
## 8. Story 与 Custom World 现状
Story
- Node 后端直接复用前端成熟 prompt 与归一化逻辑
- 服务端走 `src/services/ai.ts` 中的严格版 story 生成链
Custom World
- Node 后端直接复用前端现有多阶段生成编排
- 当前保留 `session + answers + SSE progress/result/error` 协议
- 前端已支持接收真实阶段进度对象
## 9. 前端接入点
鉴权与请求:
- `src/services/apiClient.ts`
- `src/services/authService.ts`
- `src/components/auth/AuthGate.tsx`
运行时服务层:
- `src/services/storageService.ts`
- `src/services/aiService.ts`
## 10. 当前 Vite 角色
Vite 当前只负责代理,不再提供本地 API 插件。
当前代理目标:
- `/api/auth`
- `/api/runtime`
- `/api/llm`
- `/api/custom-world/scene-image`
- `/api/ws`
全部转发到 Node 后端。

View File

@@ -4,6 +4,9 @@
## 文档列表
- [NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md](./NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md):当前 Node 运行时后端的技术栈、入口、鉴权、存储与接口知识图谱。
- [GO_SERVER_RUNTIME_INTEGRATION_2026-04-07.md](./GO_SERVER_RUNTIME_INTEGRATION_2026-04-07.md)Go 服务端接入、运行时持久化迁移与当前进展记录。
- [GO_SERVER_TASKLIST_2026-04-08.md](./GO_SERVER_TASKLIST_2026-04-08.md)Go 服务端已完成与未完成事项的执行清单。
- [AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md](./AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md)AI 生成角色形象与角色动画的技术路线。
- [ALIYUN_NPC_IMAGE_ANIMATION_EXPERIMENT_2026-04-07.md](./ALIYUN_NPC_IMAGE_ANIMATION_EXPERIMENT_2026-04-07.md):面向编辑器的阿里云 NPC 形象与动作实验方案,按 4 条生成链路对比。
- [PIXELMOTION_TECHNICAL_BREAKDOWN_2026-04-04.md](./PIXELMOTION_TECHNICAL_BREAKDOWN_2026-04-04.md)PixelMotion 产品形态与能力拆解。

View File

@@ -5,6 +5,11 @@
"type": "module",
"scripts": {
"dev": "node scripts/vite-cli.mjs --port=3000 --host=0.0.0.0",
"dev:node": "node scripts/dev-node.mjs",
"serve:caddy": "node scripts/run-caddy-dev.mjs",
"server-node:dev": "npm --prefix server-node run dev",
"server-node:build": "npm --prefix server-node run build",
"server-node:test": "npm --prefix server-node run test",
"build": "node scripts/build-gate.mjs",
"build:raw": "node scripts/vite-cli.mjs build",
"preview": "node scripts/vite-cli.mjs preview",

View File

@@ -0,0 +1,16 @@
{
"taskId": "15838831-6431-4867-a814-b6e7f1613169",
"kind": "animation",
"status": "completed",
"characterId": "qwen-sprite-demo",
"animation": "idle",
"strategy": "image-to-video",
"model": "wan2.2-kf2v-flash",
"prompt": "单人 NPC 全身动作视频,动作主题是 idle。 角色固定为同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。 动作连贯,避免服装、发型、面部、武器随机漂移。 背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。 单人全身角色动作视频,动作主题是 待机循环。 角色固定为图1同一角色始终侧身朝右镜头稳定轮廓清晰。Q版大头身动作角色头部占比明显更大约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。 即使角色带有怪物或海洋主题,也优先做成 Q版可爱的人形动作角色方便读图和后续动画化。 参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。 参考图不仅用于约束像素画风、颜色组织、头身比例、右朝向和镜头语言,还要严格约束身体结构骨架。生成结果必须优先沿用参考图的人形动作角色身体结构,包括头、躯干、双臂、双腿、站姿重心和整体头身比;可以变化发型、服装、主题配饰和材质,但不要脱离参考图的人形身体结构。 请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先生成人形拟人化角色,让主题词主要体现在服装、头饰、冠冕、权杖、纹样、材质和发光装饰上。 默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,这样更适合横版动作 sprite。只有当文字设定明确要求鱼尾、触手身体、伞盖头部、无双腿、漂浮体、凝胶体或其他非人结构时才改为对应非人身体。 视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。 示例:“水母国王”默认应理解为严格沿用参考图人形身体结构的国王角色,再穿带有水母主题的服装和配饰,例如半透明蓝紫色披肩或裙摆、像水母伞盖的王冠、荧光斑点、海洋质感袖摆、水母权杖,而不是完整水母怪物本体。 动作结构1-4 帧稳定站姿轻微呼吸起伏5-8 帧胸腔与肩膀轻微抬起衣摆极轻微变化9-12 帧呼气回落重心恢复13-16 帧:逐渐回到与首帧接近的站姿。结尾要求:第 16 帧自然衔接第 1 帧。 背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。 动作补充细节:动作清晰,幅度明确,适合后续抽帧成横版游戏精灵表。 角色设定:黑熊首领 目标是后续抽帧为横版动作游戏精灵表,因此不要镜头切换,不要景别变化,不要角色漂移。",
"createdAt": "2026-04-08T07:33:00.095Z",
"updatedAt": "2026-04-08T07:33:31.916Z",
"result": {
"previewVideoPath": "/generated-character-drafts/qwen-sprite-demo/animation/idle/animation-video-1775633611867/preview.mp4",
"draftRelativeDir": "generated-character-drafts/qwen-sprite-demo/animation/idle/animation-video-1775633611867"
}
}

View File

@@ -0,0 +1,16 @@
{
"taskId": "433b4f4f-d6f0-4b3b-8246-5ae4b626cb4c",
"kind": "animation",
"status": "completed",
"characterId": "qwen-sprite-demo",
"animation": "run",
"strategy": "image-to-video",
"model": "wan2.2-kf2v-flash",
"prompt": "单人 NPC 全身动作视频,动作主题是 run。 角色固定为同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。 动作连贯,避免服装、发型、面部、武器随机漂移。 背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。 单人全身角色动作视频,动作主题是 奔跑循环。 角色固定为图1同一角色始终侧身朝右镜头稳定轮廓清晰。Q版大头身动作角色头部占比明显更大约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。 即使角色带有怪物或海洋主题,也优先做成 Q版可爱的人形动作角色方便读图和后续动画化。 参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。 参考图不仅用于约束像素画风、颜色组织、头身比例、右朝向和镜头语言,还要严格约束身体结构骨架。生成结果必须优先沿用参考图的人形动作角色身体结构,包括头、躯干、双臂、双腿、站姿重心和整体头身比;可以变化发型、服装、主题配饰和材质,但不要脱离参考图的人形身体结构。 请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先生成人形拟人化角色,让主题词主要体现在服装、头饰、冠冕、权杖、纹样、材质和发光装饰上。 默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,这样更适合横版动作 sprite。只有当文字设定明确要求鱼尾、触手身体、伞盖头部、无双腿、漂浮体、凝胶体或其他非人结构时才改为对应非人身体。 视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。 示例:“水母国王”默认应理解为严格沿用参考图人形身体结构的国王角色,再穿带有水母主题的服装和配饰,例如半透明蓝紫色披肩或裙摆、像水母伞盖的王冠、荧光斑点、海洋质感袖摆、水母权杖,而不是完整水母怪物本体。 动作结构1-4 帧右腿前摆左腿后蹬身体略前倾5-8 帧双腿交叉经过身体下方手臂反向摆动9-12 帧左腿前摆右腿后蹬继续前倾13-16 帧:完成另一半跑步循环并回到可接第 1 帧的状态。结尾要求:第 16 帧能无缝接回第 1 帧。 背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。 动作补充细节:保持动作清晰、节奏明确、适合后续抽帧为 sprite sheet。 角色设定:黑熊首领 目标是后续抽帧为横版动作游戏精灵表,因此不要镜头切换,不要景别变化,不要角色漂移。",
"createdAt": "2026-04-08T07:34:34.233Z",
"updatedAt": "2026-04-08T07:35:08.207Z",
"result": {
"previewVideoPath": "/generated-character-drafts/qwen-sprite-demo/animation/run/animation-video-1775633708180/preview.mp4",
"draftRelativeDir": "generated-character-drafts/qwen-sprite-demo/animation/run/animation-video-1775633708180"
}
}

View File

@@ -0,0 +1,16 @@
{
"taskId": "5867f357-ac12-4e7c-848b-71c4274ba717",
"kind": "animation",
"status": "completed",
"characterId": "qwen-sprite-demo",
"animation": "idle",
"strategy": "image-to-video",
"model": "wan2.7-i2v",
"prompt": "单人 NPC 全身动作视频,动作主题是 idle。 角色固定为同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。 动作连贯,避免服装、发型、面部、武器随机漂移。 背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。 单人全身角色动作视频,动作主题是 待机循环。 角色固定为图1同一人始终侧身朝右镜头稳定轮廓清晰。Q版大头身动作角色头部占比明显更大约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。 参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。 动作结构1-4 帧稳定站姿轻微呼吸起伏5-8 帧胸腔与肩膀轻微抬起衣摆极轻微变化9-12 帧呼气回落重心恢复13-16 帧:逐渐回到与首帧接近的站姿。结尾要求:第 16 帧自然衔接第 1 帧。 背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。 动作补充细节:边跳边走 目标是后续抽帧为横版动作游戏精灵表,因此不要镜头切换,不要景别变化,不要角色漂移。",
"createdAt": "2026-04-08T05:02:04.795Z",
"updatedAt": "2026-04-08T05:05:09.119Z",
"result": {
"previewVideoPath": "/generated-character-drafts/qwen-sprite-demo/animation/idle/animation-video-1775624709088/preview.mp4",
"draftRelativeDir": "generated-character-drafts/qwen-sprite-demo/animation/idle/animation-video-1775624709088"
}
}

View File

@@ -0,0 +1,13 @@
{
"taskId": "92f0d503-7b86-4f43-9637-81e5119aa646",
"kind": "animation",
"status": "failed",
"characterId": "qwen-sprite-demo",
"animation": "idle",
"strategy": "image-to-video",
"model": "wan2.7-i2v",
"prompt": "单人 NPC 全身动作视频,动作主题是 idle。 角色固定为同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。 动作连贯,避免服装、发型、面部、武器随机漂移。 背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。 单人全身角色动作视频,动作主题是 待机循环。 角色固定为图1同一角色始终侧身朝右镜头稳定轮廓清晰。Q版大头身动作角色头部占比明显更大约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。 即使角色带有怪物或海洋主题,也优先做成 Q版可爱的人形动作角色方便读图和后续动画化。 参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。 风格参考图只用于约束像素画风、颜色组织、头身比例、身体结构、右朝向和镜头语言,不复制参考图的具体种族、发型、服装或武器。 请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先生成人形拟人化角色,让主题词主要体现在服装、头饰、冠冕、权杖、纹样、材质和发光装饰上。 默认优先使用人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,这样更适合横版动作 sprite。只有当文字设定明确要求鱼尾、触手身体、伞盖头部、无双腿、漂浮体、凝胶体或其他非人结构时才改为对应非人身体。 视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。 示例:“水母国王”默认应理解为人形国王角色,穿带有水母主题的服装和配饰,例如半透明蓝紫色披肩或裙摆、像水母伞盖的王冠、荧光斑点、海洋质感袖摆、水母权杖,而不是完整水母怪物本体。 只有在文字设定明确写出非人结构时,才保留对应非人身体,例如“非人本体、触手下肢、无双腿、半透明伞盖头部、漂浮水母身体”。 动作结构1-4 帧稳定站姿轻微呼吸起伏5-8 帧胸腔与肩膀轻微抬起衣摆极轻微变化9-12 帧呼气回落重心恢复13-16 帧:逐渐回到与首帧接近的站姿。结尾要求:第 16 帧自然衔接第 1 帧。 背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。 动作补充细节:动作清晰,幅度明确,适合后续抽帧成横版游戏精灵表。 角色设定:水母公主 目标是后续抽帧为横版动作游戏精灵表,因此不要镜头切换,不要景别变化,不要角色漂移。",
"createdAt": "2026-04-08T05:49:32.962Z",
"updatedAt": "2026-04-08T05:49:32.962Z",
"errorMessage": "{\"request_id\":\"260207f2-45ab-9fd2-82a1-21cce8ea0465\",\"output\":{\"task_id\":\"92f0d503-7b86-4f43-9637-81e5119aa646\",\"task_status\":\"FAILED\",\"submit_time\":\"2026-04-08 13:43:29.724\",\"scheduled_time\":\"2026-04-08 13:43:31.170\",\"end_time\":\"2026-04-08 13:49:32.185\",\"code\":\"DataInspectionFailed\",\"message\":\"Output data may contain inappropriate content.\"}}"
}

View File

@@ -0,0 +1,13 @@
{
"taskId": "bd19ca9c-e8c8-4c02-b016-d184c8457463",
"kind": "animation",
"status": "failed",
"characterId": "qwen-sprite-demo",
"animation": "idle",
"strategy": "image-to-video",
"model": "wan2.7-i2v",
"prompt": "单人 NPC 全身动作视频,动作主题是 idle。 角色固定为同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。 动作连贯,避免服装、发型、面部、武器随机漂移。 背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。 单人全身角色动作视频,动作主题是 待机循环。角色固定为图1同一人始终侧身朝右镜头稳定轮廓清晰。Q版大头身动作角色头部占比明显更大约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。动作结构1-4 帧稳定站姿轻微呼吸起伏5-8 帧胸腔与肩膀轻微抬起衣摆极轻微变化9-12 帧呼气回落重心恢复13-16 帧:逐渐回到与首帧接近的站姿。结尾要求:第 16 帧自然衔接第 1 帧。背景为纯绿色绿幕无其他人物和场景元素方便后期抽帧与抠像。动作补充细节头部轻微上下起伏整体保持可爱Q版待机感。目标是后续抽帧为横版动作游戏精灵表因此不要镜头切换不要景别变化不要角色漂移。",
"createdAt": "2026-04-08T04:49:32.756Z",
"updatedAt": "2026-04-08T04:49:32.756Z",
"errorMessage": "{\"request_id\":\"c6092653-cfc9-919c-bbc5-a8dc3fedbda8\",\"output\":{\"task_id\":\"bd19ca9c-e8c8-4c02-b016-d184c8457463\",\"task_status\":\"FAILED\",\"submit_time\":\"2026-04-08 12:48:32.251\",\"scheduled_time\":\"2026-04-08 12:48:34.671\",\"end_time\":\"2026-04-08 12:49:22.912\",\"code\":\"InvalidParameter\",\"message\":\"Input should be 'first_frame', 'last_frame', 'driving_audio' or 'first_clip': input.media.0.type\"}}"
}

View File

@@ -0,0 +1,13 @@
{
"taskId": "e0ca440a-8a50-45a7-88a2-9e550a582272",
"kind": "animation",
"status": "failed",
"characterId": "qwen-sprite-demo",
"animation": "idle",
"strategy": "image-to-video",
"model": "wan2.7-i2v",
"prompt": "单人 NPC 全身动作视频,动作主题是 idle。 角色固定为同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。 动作连贯,避免服装、发型、面部、武器随机漂移。 背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。 单人全身角色动作视频,动作主题是 待机循环。角色固定为图1同一人始终侧身朝右镜头稳定轮廓清晰。Q版大头身动作角色头部占比明显更大约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。动作结构1-4 帧稳定站姿轻微呼吸起伏5-8 帧胸腔与肩膀轻微抬起衣摆极轻微变化9-12 帧呼气回落重心恢复13-16 帧:逐渐回到与首帧接近的站姿。结尾要求:第 16 帧自然衔接第 1 帧。背景为纯绿色绿幕无其他人物和场景元素方便后期抽帧与抠像。动作补充细节头部轻微上下起伏整体保持可爱Q版待机感。目标是后续抽帧为横版动作游戏精灵表因此不要镜头切换不要景别变化不要角色漂移。",
"createdAt": "2026-04-08T04:53:22.156Z",
"updatedAt": "2026-04-08T04:53:22.156Z",
"errorMessage": "{\"request_id\":\"c76e2066-9ee8-9551-a687-f805c33deeca\",\"output\":{\"task_id\":\"e0ca440a-8a50-45a7-88a2-9e550a582272\",\"task_status\":\"FAILED\",\"submit_time\":\"2026-04-08 12:50:35.698\",\"scheduled_time\":\"2026-04-08 12:50:39.177\",\"end_time\":\"2026-04-08 12:53:07.303\",\"code\":\"DataInspectionFailed\",\"message\":\"Output data may contain inappropriate content.\"}}"
}

View File

@@ -0,0 +1,16 @@
{
"taskId": "ebaee9f5-9245-489b-a03f-2a84ea8ea936",
"kind": "animation",
"status": "completed",
"characterId": "qwen-sprite-demo",
"animation": "idle",
"strategy": "image-to-video",
"model": "wan2.2-kf2v-flash",
"prompt": "单人 NPC 全身动作视频,动作主题是 idle。 角色固定为同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。 动作连贯,避免服装、发型、面部、武器随机漂移。 背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。 单人全身角色动作视频,动作主题是 待机循环。角色固定为图1同一角色始终侧身朝右镜头稳定轮廓清晰。Q版大头身动作角色。",
"createdAt": "2026-04-08T07:28:15.160Z",
"updatedAt": "2026-04-08T07:28:48.456Z",
"result": {
"previewVideoPath": "/generated-character-drafts/qwen-sprite-demo/animation/idle/animation-video-1775633328425/preview.mp4",
"draftRelativeDir": "generated-character-drafts/qwen-sprite-demo/animation/idle/animation-video-1775633328425"
}
}

View File

@@ -0,0 +1,9 @@
{
"taskId": "5867f357-ac12-4e7c-848b-71c4274ba717",
"model": "wan2.7-i2v",
"strategy": "image-to-video",
"animation": "idle",
"prompt": "单人 NPC 全身动作视频,动作主题是 idle。 角色固定为同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。 动作连贯,避免服装、发型、面部、武器随机漂移。 背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。 单人全身角色动作视频,动作主题是 待机循环。 角色固定为图1同一人始终侧身朝右镜头稳定轮廓清晰。Q版大头身动作角色头部占比明显更大约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。 参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。 动作结构1-4 帧稳定站姿轻微呼吸起伏5-8 帧胸腔与肩膀轻微抬起衣摆极轻微变化9-12 帧呼气回落重心恢复13-16 帧:逐渐回到与首帧接近的站姿。结尾要求:第 16 帧自然衔接第 1 帧。 背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。 动作补充细节:边跳边走 目标是后续抽帧为横版动作游戏精灵表,因此不要镜头切换,不要景别变化,不要角色漂移。",
"createdAt": "2026-04-08T05:05:09.116Z",
"videoUrl": "https://dashscope-a717.oss-accelerate.aliyuncs.com/1d/c0/20260408/b1fbde2b/27777303-metadata_c156c3d3f8c73ab0.mp4?Expires=1775711089&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=q8juQS13NdFUUQ%2FeGC1%2B2xYHLlI%3D"
}

View File

@@ -0,0 +1,9 @@
{
"taskId": "ebaee9f5-9245-489b-a03f-2a84ea8ea936",
"model": "wan2.2-kf2v-flash",
"strategy": "image-to-video",
"animation": "idle",
"prompt": "单人 NPC 全身动作视频,动作主题是 idle。 角色固定为同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。 动作连贯,避免服装、发型、面部、武器随机漂移。 背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。 单人全身角色动作视频,动作主题是 待机循环。角色固定为图1同一角色始终侧身朝右镜头稳定轮廓清晰。Q版大头身动作角色。",
"createdAt": "2026-04-08T07:28:48.453Z",
"videoUrl": "https://dashscope-7c2c.oss-cn-shanghai.aliyuncs.com/1d/69/20260408/1ce55b62/ebaee9f5-9245-489b-a03f-2a84ea8ea936.mp4?Expires=1775719713&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=pd3vhAP%2FOb5IXC2iVMGkERXQyHc%3D"
}

View File

@@ -0,0 +1,9 @@
{
"taskId": "15838831-6431-4867-a814-b6e7f1613169",
"model": "wan2.2-kf2v-flash",
"strategy": "image-to-video",
"animation": "idle",
"prompt": "单人 NPC 全身动作视频,动作主题是 idle。 角色固定为同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。 动作连贯,避免服装、发型、面部、武器随机漂移。 背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。 单人全身角色动作视频,动作主题是 待机循环。 角色固定为图1同一角色始终侧身朝右镜头稳定轮廓清晰。Q版大头身动作角色头部占比明显更大约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。 即使角色带有怪物或海洋主题,也优先做成 Q版可爱的人形动作角色方便读图和后续动画化。 参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。 参考图不仅用于约束像素画风、颜色组织、头身比例、右朝向和镜头语言,还要严格约束身体结构骨架。生成结果必须优先沿用参考图的人形动作角色身体结构,包括头、躯干、双臂、双腿、站姿重心和整体头身比;可以变化发型、服装、主题配饰和材质,但不要脱离参考图的人形身体结构。 请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先生成人形拟人化角色,让主题词主要体现在服装、头饰、冠冕、权杖、纹样、材质和发光装饰上。 默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,这样更适合横版动作 sprite。只有当文字设定明确要求鱼尾、触手身体、伞盖头部、无双腿、漂浮体、凝胶体或其他非人结构时才改为对应非人身体。 视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。 示例:“水母国王”默认应理解为严格沿用参考图人形身体结构的国王角色,再穿带有水母主题的服装和配饰,例如半透明蓝紫色披肩或裙摆、像水母伞盖的王冠、荧光斑点、海洋质感袖摆、水母权杖,而不是完整水母怪物本体。 动作结构1-4 帧稳定站姿轻微呼吸起伏5-8 帧胸腔与肩膀轻微抬起衣摆极轻微变化9-12 帧呼气回落重心恢复13-16 帧:逐渐回到与首帧接近的站姿。结尾要求:第 16 帧自然衔接第 1 帧。 背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。 动作补充细节:动作清晰,幅度明确,适合后续抽帧成横版游戏精灵表。 角色设定:黑熊首领 目标是后续抽帧为横版动作游戏精灵表,因此不要镜头切换,不要景别变化,不要角色漂移。",
"createdAt": "2026-04-08T07:33:31.912Z",
"videoUrl": "https://dashscope-7c2c.oss-cn-shanghai.aliyuncs.com/1d/4f/20260408/1ce55b62/15838831-6431-4867-a814-b6e7f1613169.mp4?Expires=1775719998&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=WyNwCc%2BZ50%2BODrEm8cewZVywInk%3D"
}

View File

@@ -0,0 +1,9 @@
{
"taskId": "433b4f4f-d6f0-4b3b-8246-5ae4b626cb4c",
"model": "wan2.2-kf2v-flash",
"strategy": "image-to-video",
"animation": "run",
"prompt": "单人 NPC 全身动作视频,动作主题是 run。 角色固定为同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。 动作连贯,避免服装、发型、面部、武器随机漂移。 背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。 单人全身角色动作视频,动作主题是 奔跑循环。 角色固定为图1同一角色始终侧身朝右镜头稳定轮廓清晰。Q版大头身动作角色头部占比明显更大约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。 即使角色带有怪物或海洋主题,也优先做成 Q版可爱的人形动作角色方便读图和后续动画化。 参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。 参考图不仅用于约束像素画风、颜色组织、头身比例、右朝向和镜头语言,还要严格约束身体结构骨架。生成结果必须优先沿用参考图的人形动作角色身体结构,包括头、躯干、双臂、双腿、站姿重心和整体头身比;可以变化发型、服装、主题配饰和材质,但不要脱离参考图的人形身体结构。 请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先生成人形拟人化角色,让主题词主要体现在服装、头饰、冠冕、权杖、纹样、材质和发光装饰上。 默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,这样更适合横版动作 sprite。只有当文字设定明确要求鱼尾、触手身体、伞盖头部、无双腿、漂浮体、凝胶体或其他非人结构时才改为对应非人身体。 视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。 示例:“水母国王”默认应理解为严格沿用参考图人形身体结构的国王角色,再穿带有水母主题的服装和配饰,例如半透明蓝紫色披肩或裙摆、像水母伞盖的王冠、荧光斑点、海洋质感袖摆、水母权杖,而不是完整水母怪物本体。 动作结构1-4 帧右腿前摆左腿后蹬身体略前倾5-8 帧双腿交叉经过身体下方手臂反向摆动9-12 帧左腿前摆右腿后蹬继续前倾13-16 帧:完成另一半跑步循环并回到可接第 1 帧的状态。结尾要求:第 16 帧能无缝接回第 1 帧。 背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。 动作补充细节:保持动作清晰、节奏明确、适合后续抽帧为 sprite sheet。 角色设定:黑熊首领 目标是后续抽帧为横版动作游戏精灵表,因此不要镜头切换,不要景别变化,不要角色漂移。",
"createdAt": "2026-04-08T07:35:08.203Z",
"videoUrl": "https://dashscope-7c2c.oss-cn-shanghai.aliyuncs.com/1d/d9/20260408/1ce55b62/433b4f4f-d6f0-4b3b-8246-5ae4b626cb4c.mp4?Expires=1775720090&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=Lj5K18exe6OkFaKf6xCTAhZELdU%3D"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -0,0 +1,27 @@
{
"draftId": "qwen-master-1775621887918",
"kind": "master",
"model": "qwen-image-2.0",
"size": "1024*1024",
"promptText": "单人全身2D 横版游戏角色标准设定图,角色始终朝右侧,侧视角为主,站立待机姿态,脚底完整可见,武器完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。\n\n画面要求1:1 正方形画布,纯色浅背景,画面中心构图,角色完整置于画面中央,不要裁切头顶和脚底,不要多角色,不要复杂环境,不要镜头透视,不要特写。\n\n风格要求Q版大头身动作角色头部占比明显更大约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,武器握持合理,便于后续连续动作生成。\n\nQ版大头身少女冒险者头部占比更大约 2 到 3 头身,金棕色头发,明亮表情,轻甲或冒险服,动作角色轮廓清楚。",
"negativePrompt": "正面视角左朝向镜头透视半身像脚被裁切头顶被裁切多角色复杂背景武器消失武器换手额外手臂额外腿服装变化脸部变化模糊运动模糊文字水印UI 元素",
"promptExtend": true,
"seed": 1101,
"candidateCount": 2,
"referenceImageCount": 0,
"drafts": [
{
"id": "qwen-master-1775621887918-1",
"label": "主图 1",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775621887918/candidate-01.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/1c/20260408/76483b06/b258b423-90f9-4d09-9ca5-71c0ff84ef36.png?Expires=1776227687&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=6RbPDqWGIVf5BrEy%2FpScw0S891U%3D"
},
{
"id": "qwen-master-1775621887918-2",
"label": "主图 2",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775621887918/candidate-02.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/79/20260408/76483b06/35b45710-ee4a-41b1-bdbe-339df531f7bd.png?Expires=1776227687&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=SGyX9pdu%2BUqdsV9p41cmVbcDFpk%3D"
}
],
"createdAt": "2026-04-08T04:18:10.374Z"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@@ -0,0 +1,27 @@
{
"draftId": "qwen-master-1775621901628",
"kind": "master",
"model": "qwen-image-2.0",
"size": "1024*1024",
"promptText": "单人全身2D 横版游戏角色标准设定图,角色始终朝右侧,侧视角为主,站立待机姿态,脚底完整可见,武器完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。\n\n画面要求1:1 正方形画布,纯色浅背景,画面中心构图,角色完整置于画面中央,不要裁切头顶和脚底,不要多角色,不要复杂环境,不要镜头透视,不要特写。\n\n风格要求Q版大头身动作角色头部占比明显更大约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,武器握持合理,便于后续连续动作生成。\n\nQ版大头身少女冒险者头部占比更大约 2 到 3 头身,金棕色头发,明亮表情,轻甲或冒险服,动作角色轮廓清楚。",
"negativePrompt": "正面视角左朝向镜头透视半身像脚被裁切头顶被裁切多角色复杂背景武器消失武器换手额外手臂额外腿服装变化脸部变化模糊运动模糊文字水印UI 元素",
"promptExtend": true,
"seed": 1101,
"candidateCount": 2,
"referenceImageCount": 0,
"drafts": [
{
"id": "qwen-master-1775621901628-1",
"label": "主图 1",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775621901628/candidate-01.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/43/20260408/76483b06/3e1302c8-84f7-4794-baa6-b745d01c69f1.png?Expires=1776227701&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=7G%2FzQKhvDjFTDjJRVxNHnbGwh6I%3D"
},
{
"id": "qwen-master-1775621901628-2",
"label": "主图 2",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775621901628/candidate-02.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/b8/20260408/76483b06/6ea29373-6458-404e-b7be-c2fea0e72a36.png?Expires=1776227701&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=CjwS1ksjowlMWOHXY1k6I3SQb9Q%3D"
}
],
"createdAt": "2026-04-08T04:18:24.134Z"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@@ -0,0 +1,27 @@
{
"draftId": "qwen-master-1775621956284",
"kind": "master",
"model": "qwen-image-2.0",
"size": "1024*1024",
"promptText": "单人全身2D 横版游戏角色标准设定图,角色始终朝右侧,站立待机姿态,脚底完整可见,武器完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。\n\n画面要求1:1 正方形画布,纯色浅背景,画面中心构图,角色完整置于画面中央,不要裁切头顶和脚底,不要多角色,不要复杂环境,不要镜头透视,不要特写。\n\n风格要求Q版大头身动作角色头部占比明显更大约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,武器握持合理,便于后续连续动作生成。\n\nQ版大头身少女冒险者头部占比更大约 2 到 3 头身,金棕色头发,明亮表情,轻甲或冒险服,动作角色轮廓清楚。",
"negativePrompt": "正面视角左朝向镜头透视半身像脚被裁切头顶被裁切多角色复杂背景武器消失武器换手额外手臂额外腿服装变化脸部变化模糊运动模糊文字水印UI 元素",
"promptExtend": true,
"seed": 1101,
"candidateCount": 2,
"referenceImageCount": 0,
"drafts": [
{
"id": "qwen-master-1775621956284-1",
"label": "主图 1",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775621956284/candidate-01.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/78/20260408/76483b06/7bc06ee3-5087-4c76-9a63-c3a504f913e2.png?Expires=1776227755&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=w9NQpmwkjYPvUCXqka7Npy4DPxg%3D"
},
{
"id": "qwen-master-1775621956284-2",
"label": "主图 2",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775621956284/candidate-02.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/f3/20260408/76483b06/5cc25a8e-62d8-4d1e-a5cb-c7158f4a1a08.png?Expires=1776227755&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=gfbCHs09gqPQMPxsH5l87VGkD%2BI%3D"
}
],
"createdAt": "2026-04-08T04:19:18.856Z"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -0,0 +1,27 @@
{
"draftId": "qwen-master-1775624392456",
"kind": "master",
"model": "qwen-image-2.0",
"size": "1024*1024",
"promptText": "单人全身2D 横版游戏角色标准设定图,角色始终朝右侧,侧视角为主,站立待机姿态,脚底完整可见,武器完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。\n\n画面要求1:1 正方形画布,纯色浅背景,画面中心构图,角色完整置于画面中央,不要裁切头顶和脚底,不要多角色,不要复杂环境,不要镜头透视,不要特写。\n\n风格要求Q版大头身动作角色头部占比明显更大约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。 参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,武器握持合理,便于后续连续动作生成。\n\n海妖",
"negativePrompt": "正面视角左朝向镜头透视半身像脚被裁切头顶被裁切多角色复杂背景武器消失武器换手额外手臂额外腿服装变化脸部变化模糊运动模糊文字水印UI 元素",
"promptExtend": true,
"seed": 1101,
"candidateCount": 2,
"referenceImageCount": 1,
"drafts": [
{
"id": "qwen-master-1775624392456-1",
"label": "主图 1",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775624392456/candidate-01.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/6e/20260408/8b3aee91/ee234373-ea8c-442e-ba82-82d39512b2bd.png?Expires=1776230191&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=213U2JTKtHnMxYsVD%2BvEAdRaQls%3D"
},
{
"id": "qwen-master-1775624392456-2",
"label": "主图 2",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775624392456/candidate-02.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/d4/20260408/8b3aee91/ace63da1-4906-4545-8ee1-a5662a1524a8.png?Expires=1776230192&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=ZQrv0t1btJ9NuacOlu9jvGKntrQ%3D"
}
],
"createdAt": "2026-04-08T04:59:54.906Z"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -0,0 +1,27 @@
{
"draftId": "qwen-master-1775624876722",
"kind": "master",
"model": "qwen-image-2.0",
"size": "1024*1024",
"promptText": "单人全身2D 横版游戏角色标准设定图,角色始终朝右侧,侧视角为主,站立待机姿态,脚底完整可见,武器完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。\n画面要求1:1 正方形画布,纯色浅背景,画面中心构图,角色完整置于画面中央,不要裁切头顶和脚底,不要多角色,不要复杂环境,不要镜头透视,不要特写。\n风格要求Q版大头身动作角色头部占比明显更大约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。 参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,武器握持合理,便于后续连续动作生成。\n水母国王",
"negativePrompt": "正面视角左朝向镜头透视半身像脚被裁切头顶被裁切多角色复杂背景武器消失武器换手额外手臂额外腿服装变化脸部变化模糊运动模糊文字水印UI 元素",
"promptExtend": true,
"seed": 1101,
"candidateCount": 2,
"referenceImageCount": 1,
"drafts": [
{
"id": "qwen-master-1775624876722-1",
"label": "主图 1",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775624876722/candidate-01.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/42/20260408/8b3aee91/0770d986-ed4f-41ed-a8b0-8a265cb847f7.png?Expires=1776230676&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=bBtx%2B%2FOisKA0scmab3%2F4icAOQtM%3D"
},
{
"id": "qwen-master-1775624876722-2",
"label": "主图 2",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775624876722/candidate-02.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/f0/20260408/8b3aee91/eecc33d6-ec36-44cc-8d51-7dbf13ccc383.png?Expires=1776230676&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=vd7lRhTwthHRC4u4ah0ogzMDkeA%3D"
}
],
"createdAt": "2026-04-08T05:07:59.699Z"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@@ -0,0 +1,27 @@
{
"draftId": "qwen-master-1775625635778",
"kind": "master",
"model": "qwen-image-2.0",
"size": "1024*1024",
"promptText": "单人2D 横版游戏角色标准设定图,角色始终朝右侧,侧视角为主,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。\n画面要求1:1 正方形画布,纯色浅背景,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要多角色,不要复杂环境,不要镜头透视,不要特写。\n风格要求Q版大头身动作角色头部占比明显更大约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。 参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。 风格参考图只用于约束像素画风、颜色组织、头身比例、右朝向和镜头语言,不复制参考图的具体种族、身体结构、发型、服装或武器。 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,武器握持合理,便于后续连续动作生成。\n文字设定优先决定角色的物种、身体结构、肢体数量、头部形态、尾巴、触手、翅膀、下肢形式和材质特征。如果文字设定是非人、怪物、海洋生物、植物体、机械体、凝胶体或亡灵体就必须保留对应非人形态不要自动人类化。\n角色主体底部结构必须服从文字设定。如果角色是鱼尾、触手、漂浮体、凝胶体、蛇尾、机械履带或其他非人下肢结构就保持对应结构不要强行补成人类双腿、脚或站立姿势。\n水母国王",
"negativePrompt": "正面视角左朝向镜头透视半身像脚被裁切头顶被裁切多角色复杂背景武器消失武器换手额外手臂额外腿服装变化脸部变化模糊运动模糊文字水印UI 元素,不要默认生成普通人类,不要把非人设定自动改成人类,不要强行补成人类脸、人类双腿、人类四肢、人类耳朵或普通人类皮肤结构,除非文字设定明确要求",
"promptExtend": true,
"seed": 1101,
"candidateCount": 2,
"referenceImageCount": 1,
"drafts": [
{
"id": "qwen-master-1775625635778-1",
"label": "主图 1",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775625635778/candidate-01.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/fc/20260408/8b3aee91/5301a16e-1464-4b8f-a9a8-fa9dc8c9bc62.png?Expires=1776231435&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=jPUpwJUN%2Bz8eoK5dH2wPmAqS6Ew%3D"
},
{
"id": "qwen-master-1775625635778-2",
"label": "主图 2",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775625635778/candidate-02.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/84/20260408/8b3aee91/15ade2f0-e86a-4d18-bc1c-d665a2ba48ee.png?Expires=1776231435&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=quEzTquQCJ2r%2BEfq3AJVlEGV%2BHw%3D"
}
],
"createdAt": "2026-04-08T05:20:38.510Z"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@@ -0,0 +1,27 @@
{
"draftId": "qwen-master-1775626712086",
"kind": "master",
"model": "qwen-image-2.0",
"size": "1024*1024",
"promptText": "单人2D 横版游戏角色标准设定图,角色始终朝右侧,侧视角为主,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。\n画面要求1:1 正方形画布,纯色浅背景,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要多角色,不要复杂环境,不要镜头透视,不要特写。\n风格要求Q版大头身动作角色头部占比明显更大约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。 即使角色带有怪物或海洋主题,也优先做成 Q版可爱的人形动作角色方便读图和后续动画化。 参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。 风格参考图只用于约束像素画风、颜色组织、头身比例、右朝向和镜头语言,不复制参考图的具体种族、身体结构、发型、服装或武器。 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,道具/权杖/武器如有则存在关系合理,便于后续连续动作生成。\n请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构默认优先生成人形拟人化角色让主题词主要体现在服装、头饰、冠冕、权杖、纹样、材质和发光装饰上。\n默认优先使用人类或类人动作角色骨架保持清楚的头、躯干、手臂和双腿轮廓这样更适合横版动作 sprite。只有当文字设定明确要求鱼尾、触手身体、伞盖头部、无双腿、漂浮体、凝胶体或其他非人结构时才改为对应非人身体。\n视觉优先级应当是身体结构词第一身份词第二主题词第三。没有明确身体结构词时默认用人形拟人化表现再把主题词转译成服装和装饰。\n示例“水母国王”默认应理解为人形国王角色穿带有水母主题的服装和配饰例如半透明蓝紫色披肩或裙摆、像水母伞盖的王冠、荧光斑点、海洋质感袖摆、水母权杖而不是完整水母怪物本体。\n只有在文字设定明确写出非人结构时才保留对应非人身体例如“非人本体、触手下肢、无双腿、半透明伞盖头部、漂浮水母身体”。\n水母公主",
"negativePrompt": "正面视角左朝向镜头透视半身像脚被裁切头顶被裁切多角色复杂背景武器消失武器换手额外手臂额外腿服装变化脸部变化模糊运动模糊文字水印UI 元素,不要机械地把主题词直接画成完整怪物本体,除非文字设定明确要求非人身体",
"promptExtend": true,
"seed": 1101,
"candidateCount": 2,
"referenceImageCount": 1,
"drafts": [
{
"id": "qwen-master-1775626712086-1",
"label": "主图 1",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775626712086/candidate-01.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/9a/20260408/8b3aee91/a3ad1312-0bfb-4ed5-b852-a9222d6bdeae.png?Expires=1776232511&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=SsFMW587lgbD9J5hkw%2BTTUw4VPw%3D"
},
{
"id": "qwen-master-1775626712086-2",
"label": "主图 2",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775626712086/candidate-02.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/00/20260408/8b3aee91/0785aeca-5eba-4bab-8eba-05a5db22b8e4.png?Expires=1776232511&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=%2F42%2BwjoM0mtahK%2FwocvxadeuU6g%3D"
}
],
"createdAt": "2026-04-08T05:38:34.575Z"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@@ -0,0 +1,27 @@
{
"draftId": "qwen-master-1775627051206",
"kind": "master",
"model": "qwen-image-2.0",
"size": "1024*1024",
"promptText": "单人2D 横版游戏角色标准设定图,角色始终朝右侧,侧视角为主,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。\n画面要求1:1 正方形画布,纯色浅背景,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要多角色,不要复杂环境,不要镜头透视,不要特写。\n风格要求Q版大头身动作角色头部占比明显更大约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。 即使角色带有怪物或海洋主题,也优先做成 Q版可爱的人形动作角色方便读图和后续动画化。 参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。 风格参考图只用于约束像素画风、颜色组织、头身比例、身体结构、右朝向和镜头语言,不复制参考图的具体种族、发型、服装或武器。 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,道具/权杖/武器如有则存在关系合理,便于后续连续动作生成。\n请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构默认优先生成人形拟人化角色让主题词主要体现在服装、头饰、冠冕、权杖、纹样、材质和发光装饰上。\n默认优先使用人类或类人动作角色骨架保持清楚的头、躯干、手臂和双腿轮廓这样更适合横版动作 sprite。只有当文字设定明确要求鱼尾、触手身体、伞盖头部、无双腿、漂浮体、凝胶体或其他非人结构时才改为对应非人身体。\n视觉优先级应当是身体结构词第一身份词第二主题词第三。没有明确身体结构词时默认用人形拟人化表现再把主题词转译成服装和装饰。\n示例“水母国王”默认应理解为人形国王角色穿带有水母主题的服装和配饰例如半透明蓝紫色披肩或裙摆、像水母伞盖的王冠、荧光斑点、海洋质感袖摆、水母权杖而不是完整水母怪物本体。\n只有在文字设定明确写出非人结构时才保留对应非人身体例如“非人本体、触手下肢、无双腿、半透明伞盖头部、漂浮水母身体”。\n水母公主",
"negativePrompt": "正面视角左朝向镜头透视半身像脚被裁切头顶被裁切多角色复杂背景武器消失武器换手额外手臂额外腿服装变化脸部变化模糊运动模糊文字水印UI 元素,不要机械地把主题词直接画成完整怪物本体,除非文字设定明确要求非人身体",
"promptExtend": true,
"seed": 1101,
"candidateCount": 2,
"referenceImageCount": 1,
"drafts": [
{
"id": "qwen-master-1775627051206-1",
"label": "主图 1",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775627051206/candidate-01.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/28/20260408/8b3aee91/60fe96fa-3fe4-4ad5-89a5-62c154003c18.png?Expires=1776232850&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=XunqrTOrrh%2BBG4ToyejUXNHDxjw%3D"
},
{
"id": "qwen-master-1775627051206-2",
"label": "主图 2",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775627051206/candidate-02.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/4f/20260408/8b3aee91/9a1521ff-ec60-4d2a-8f0d-ef383cd5d154.png?Expires=1776232850&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=825mPHtUdTw0DHeG2wIeOhBpmlY%3D"
}
],
"createdAt": "2026-04-08T05:44:13.679Z"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@@ -0,0 +1,27 @@
{
"draftId": "qwen-master-1775627124102",
"kind": "master",
"model": "qwen-image-2.0",
"size": "1024*1024",
"promptText": "单人2D 横版游戏角色标准设定图,角色始终朝右侧,侧视角为主,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。\n画面要求1:1 正方形画布,纯色浅背景,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要多角色,不要复杂环境,不要镜头透视,不要特写。\n风格要求Q版大头身动作角色头部占比明显更大约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。 即使角色带有怪物或海洋主题,也优先做成 Q版可爱的人形动作角色方便读图和后续动画化。 参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。 风格参考图只用于约束像素画风、颜色组织、头身比例、身体结构、右朝向和镜头语言,不复制参考图的具体种族、发型、服装或武器。 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,道具/权杖/武器如有则存在关系合理,便于后续连续动作生成。\n请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构默认优先生成人形拟人化角色让主题词主要体现在服装、头饰、冠冕、权杖、纹样、材质和发光装饰上。\n默认优先使用人类或类人动作角色骨架保持清楚的头、躯干、手臂和双腿轮廓这样更适合横版动作 sprite。只有当文字设定明确要求鱼尾、触手身体、伞盖头部、无双腿、漂浮体、凝胶体或其他非人结构时才改为对应非人身体。\n视觉优先级应当是身体结构词第一身份词第二主题词第三。没有明确身体结构词时默认用人形拟人化表现再把主题词转译成服装和装饰。\n示例“水母国王”默认应理解为人形国王角色穿带有水母主题的服装和配饰例如半透明蓝紫色披肩或裙摆、像水母伞盖的王冠、荧光斑点、海洋质感袖摆、水母权杖而不是完整水母怪物本体。\n只有在文字设定明确写出非人结构时才保留对应非人身体例如“非人本体、触手下肢、无双腿、半透明伞盖头部、漂浮水母身体”。\n高贵的水母公主",
"negativePrompt": "正面视角左朝向镜头透视半身像脚被裁切头顶被裁切多角色复杂背景武器消失武器换手额外手臂额外腿服装变化脸部变化模糊运动模糊文字水印UI 元素,不要机械地把主题词直接画成完整怪物本体,除非文字设定明确要求非人身体",
"promptExtend": true,
"seed": 1101,
"candidateCount": 2,
"referenceImageCount": 1,
"drafts": [
{
"id": "qwen-master-1775627124102-1",
"label": "主图 1",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775627124102/candidate-01.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/b3/20260408/8b3aee91/c6ec3cf0-9b35-4eba-bf5f-e21072b6a924.png?Expires=1776232923&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=J9sr%2FCWxWs2BFY70eHdWCIwRugo%3D"
},
{
"id": "qwen-master-1775627124102-2",
"label": "主图 2",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775627124102/candidate-02.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/fd/20260408/8b3aee91/c2573141-524b-44ed-9200-152a18d0fbb4.png?Expires=1776232923&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=5fSRKI48uKApHz11F655w6khEFI%3D"
}
],
"createdAt": "2026-04-08T05:45:26.542Z"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@@ -0,0 +1,27 @@
{
"draftId": "qwen-master-1775629317889",
"kind": "master",
"model": "qwen-image-2.0",
"size": "1024*1024",
"promptText": "单人2D 横版游戏角色标准设定图,角色始终朝右侧,侧视角为主,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。\n画面要求1:1 正方形画布,纯色浅背景,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要多角色,不要复杂环境,不要镜头透视,不要特写。\n风格要求Q版大头身动作角色头部占比明显更大约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。 即使角色带有怪物或海洋主题,也优先做成 Q版可爱的人形动作角色方便读图和后续动画化。 参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。 风格参考图只用于约束像素画风、颜色组织、头身比例、身体结构、右朝向和镜头语言,不复制参考图的具体种族、发型、服装或武器。 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,道具/权杖/武器如有则存在关系合理,便于后续连续动作生成。\n请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构默认优先生成人形拟人化角色让主题词主要体现在服装、头饰、冠冕、权杖、纹样、材质和发光装饰上。\n默认优先使用人类或类人动作角色骨架保持清楚的头、躯干、手臂和双腿轮廓这样更适合横版动作 sprite。只有当文字设定明确要求鱼尾、触手身体、伞盖头部、无双腿、漂浮体、凝胶体或其他非人结构时才改为对应非人身体。\n视觉优先级应当是身体结构词第一身份词第二主题词第三。没有明确身体结构词时默认用人形拟人化表现再把主题词转译成服装和装饰。\n示例“水母国王”默认应理解为人形国王角色穿带有水母主题的服装和配饰例如半透明蓝紫色披肩或裙摆、像水母伞盖的王冠、荧光斑点、海洋质感袖摆、水母权杖而不是完整水母怪物本体。\n只有在文字设定明确写出非人结构时才保留对应非人身体例如“非人本体、触手下肢、无双腿、半透明伞盖头部、漂浮水母身体”。\n黑熊战神",
"negativePrompt": "正面视角左朝向镜头透视半身像脚被裁切头顶被裁切多角色复杂背景武器消失武器换手额外手臂额外腿服装变化脸部变化模糊运动模糊文字水印UI 元素,不要机械地把主题词直接画成完整怪物本体,除非文字设定明确要求非人身体",
"promptExtend": true,
"seed": 1101,
"candidateCount": 2,
"referenceImageCount": 1,
"drafts": [
{
"id": "qwen-master-1775629317889-1",
"label": "主图 1",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775629317889/candidate-01.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/67/20260408/8b3aee91/3567ac38-fc5f-4448-9923-cea09a0d318f.png?Expires=1776235117&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=f6h12%2BQ%2FAct29kHJfUFl4SpK4Wc%3D"
},
{
"id": "qwen-master-1775629317889-2",
"label": "主图 2",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775629317889/candidate-02.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/57/20260408/8b3aee91/0ee1d5d2-377c-4de0-98a6-7dd5495fd2f0.png?Expires=1776235117&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=wA0K8Kpl%2FeAvOUue2UAiNIkVJ0o%3D"
}
],
"createdAt": "2026-04-08T06:22:00.397Z"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@@ -0,0 +1,27 @@
{
"draftId": "qwen-master-1775630268164",
"kind": "master",
"model": "qwen-image-2.0",
"size": "1024*1024",
"promptText": "单人2D 横版游戏角色标准设定图,角色始终朝右侧,侧视角为主,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。\n画面要求1:1 正方形画布,纯色浅背景,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要多角色,不要复杂环境,不要镜头透视,不要特写。\n风格要求Q版大头身动作角色头部占比明显更大约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。 即使角色带有怪物或海洋主题,也优先做成 Q版可爱的人形动作角色方便读图和后续动画化。 参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。 参考图不仅用于约束像素画风、颜色组织、头身比例、右朝向和镜头语言,还要严格约束身体结构骨架。生成结果必须优先沿用参考图的人形动作角色身体结构,包括头、躯干、双臂、双腿、站姿重心和整体头身比;可以变化发型、服装、主题配饰和材质,但不要脱离参考图的人形身体结构。 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,道具/权杖/武器如有则存在关系合理,便于后续连续动作生成。\n请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构默认优先生成人形拟人化角色让主题词主要体现在服装、头饰、冠冕、权杖、纹样、材质和发光装饰上。\n默认优先使用参考图对应的人类或类人动作角色骨架保持清楚的头、躯干、手臂和双腿轮廓这样更适合横版动作 sprite。只有当文字设定明确要求鱼尾、触手身体、伞盖头部、无双腿、漂浮体、凝胶体或其他非人结构时才改为对应非人身体。\n视觉优先级应当是身体结构词第一身份词第二主题词第三。没有明确身体结构词时默认用人形拟人化表现再把主题词转译成服装和装饰。\n示例“水母国王”默认应理解为严格沿用参考图人形身体结构的国王角色再穿带有水母主题的服装和配饰例如半透明蓝紫色披肩或裙摆、像水母伞盖的王冠、荧光斑点、海洋质感袖摆、水母权杖而不是完整水母怪物本体。\n只有在文字设定明确写出非人结构时才保留对应非人身体例如“非人本体、触手下肢、无双腿、半透明伞盖头部、漂浮水母身体”。\n黑熊首领",
"negativePrompt": "正面视角左朝向镜头透视半身像脚被裁切头顶被裁切多角色复杂背景武器消失武器换手额外手臂额外腿服装变化脸部变化模糊运动模糊文字水印UI 元素,不要机械地把主题词直接画成完整怪物本体,除非文字设定明确要求非人身体",
"promptExtend": true,
"seed": 1101,
"candidateCount": 2,
"referenceImageCount": 1,
"drafts": [
{
"id": "qwen-master-1775630268164-1",
"label": "主图 1",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775630268164/candidate-01.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/54/20260408/8b3aee91/4357f0aa-7a1b-4331-a2a0-9ef78f92e3f3.png?Expires=1776236067&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=bwac03%2BDrGMucwsrsf8%2F4nlloRE%3D"
},
{
"id": "qwen-master-1775630268164-2",
"label": "主图 2",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775630268164/candidate-02.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/d9/20260408/8b3aee91/b0d395b6-43b6-449c-ac81-4e00584be65e.png?Expires=1776236067&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=xNAxVvSjij2SU4wdFFPyzkwC3gU%3D"
}
],
"createdAt": "2026-04-08T06:37:50.861Z"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@@ -0,0 +1,27 @@
{
"draftId": "qwen-master-1775631112051",
"kind": "master",
"model": "qwen-image-2.0",
"size": "1024*1024",
"promptText": "单人2D 横版游戏角色标准设定图,角色始终朝右侧,侧视角为主,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。\n画面要求1:1 正方形画布,纯色浅背景,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要多角色,不要复杂环境,不要镜头透视,不要特写。\n风格要求Q版大头身动作角色头部占比明显更大约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。 即使角色带有怪物或海洋主题,也优先做成 Q版可爱的人形动作角色方便读图和后续动画化。 参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。 参考图不仅用于约束像素画风、颜色组织、头身比例、右朝向和镜头语言,还要严格约束身体结构骨架。生成结果必须优先沿用参考图的人形动作角色身体结构,包括头、躯干、双臂、双腿、站姿重心和整体头身比;可以变化发型、服装、主题配饰和材质,但不要脱离参考图的人形身体结构。 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,道具/权杖/武器如有则存在关系合理,便于后续连续动作生成。\n请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构默认优先生成人形拟人化角色让主题词主要体现在服装、头饰、冠冕、权杖、纹样、材质和发光装饰上。\n默认优先使用参考图对应的人类或类人动作角色骨架保持清楚的头、躯干、手臂和双腿轮廓这样更适合横版动作 sprite。只有当文字设定明确要求鱼尾、触手身体、伞盖头部、无双腿、漂浮体、凝胶体或其他非人结构时才改为对应非人身体。\n视觉优先级应当是身体结构词第一身份词第二主题词第三。没有明确身体结构词时默认用人形拟人化表现再把主题词转译成服装和装饰。\n示例“水母国王”默认应理解为严格沿用参考图人形身体结构的国王角色再穿带有水母主题的服装和配饰例如半透明蓝紫色披肩或裙摆、像水母伞盖的王冠、荧光斑点、海洋质感袖摆、水母权杖而不是完整水母怪物本体。\n黑熊首领",
"negativePrompt": "正面视角左朝向镜头透视半身像脚被裁切头顶被裁切多角色复杂背景武器消失武器换手额外手臂额外腿服装变化脸部变化模糊运动模糊文字水印UI 元素,不要机械地把主题词直接画成完整怪物本体,除非文字设定明确要求非人身体",
"promptExtend": true,
"seed": 1101,
"candidateCount": 2,
"referenceImageCount": 1,
"drafts": [
{
"id": "qwen-master-1775631112051-1",
"label": "主图 1",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775631112051/candidate-01.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/0e/20260408/8b3aee91/74803635-ce09-481e-9a51-1287a5ef729f.png?Expires=1776236911&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=d6nWY0doc0bZCDLMq%2Fq3rmpserE%3D"
},
{
"id": "qwen-master-1775631112051-2",
"label": "主图 2",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775631112051/candidate-02.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/01/20260408/8b3aee91/5225636f-3b25-440a-82ce-477371f2d251.png?Expires=1776236911&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=SUY9fJdU4cDGZYcZAGNyL0EhhP0%3D"
}
],
"createdAt": "2026-04-08T06:51:56.620Z"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@@ -0,0 +1,27 @@
{
"draftId": "qwen-master-1775633556964",
"kind": "master",
"model": "qwen-image-2.0",
"size": "1024*1024",
"promptText": "单人2D 横版游戏角色标准设定图,角色始终朝右侧,侧视角为主,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。\n画面要求1:1 正方形画布,纯色浅背景,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要多角色,不要复杂环境,不要镜头透视,不要特写。\n风格要求Q版大头身动作角色头部占比明显更大约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。 即使角色带有怪物或海洋主题,也优先做成 Q版可爱的人形动作角色方便读图和后续动画化。 参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。 参考图不仅用于约束像素画风、颜色组织、头身比例、右朝向和镜头语言,还要严格约束身体结构骨架。生成结果必须优先沿用参考图的人形动作角色身体结构,包括头、躯干、双臂、双腿、站姿重心和整体头身比;可以变化发型、服装、主题配饰和材质,但不要脱离参考图的人形身体结构。 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,道具/权杖/武器如有则存在关系合理,便于后续连续动作生成。\n请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构默认优先生成人形拟人化角色让主题词主要体现在服装、头饰、冠冕、权杖、纹样、材质和发光装饰上。\n默认优先使用参考图对应的人类或类人动作角色骨架保持清楚的头、躯干、手臂和双腿轮廓这样更适合横版动作 sprite。只有当文字设定明确要求鱼尾、触手身体、伞盖头部、无双腿、漂浮体、凝胶体或其他非人结构时才改为对应非人身体。\n视觉优先级应当是身体结构词第一身份词第二主题词第三。没有明确身体结构词时默认用人形拟人化表现再把主题词转译成服装和装饰。\n示例“水母国王”默认应理解为严格沿用参考图人形身体结构的国王角色再穿带有水母主题的服装和配饰例如半透明蓝紫色披肩或裙摆、像水母伞盖的王冠、荧光斑点、海洋质感袖摆、水母权杖而不是完整水母怪物本体。\n黑熊首领",
"negativePrompt": "正面视角左朝向镜头透视半身像脚被裁切头顶被裁切多角色复杂背景武器消失武器换手额外手臂额外腿服装变化脸部变化模糊运动模糊文字水印UI 元素,不要机械地把主题词直接画成完整怪物本体,除非文字设定明确要求非人身体",
"promptExtend": true,
"seed": 1101,
"candidateCount": 2,
"referenceImageCount": 1,
"drafts": [
{
"id": "qwen-master-1775633556964-1",
"label": "主图 1",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775633556964/candidate-01.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/d9/20260408/8b3aee91/ea17e524-8af3-41a0-9246-1183e306b608.png?Expires=1776239356&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=GOIqUHovAYNxNzEBaIifBiWsUHU%3D"
},
{
"id": "qwen-master-1775633556964-2",
"label": "主图 2",
"imageSrc": "/generated-qwen-sprites/_drafts/master/qwen-master-1775633556964/candidate-02.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/2f/20260408/8b3aee91/6e921ff7-8c7a-4a24-bab7-b3bd47c60912.png?Expires=1776239356&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=7E7ef9RlGkM%2BFi8BtBdUk96ew4g%3D"
}
],
"createdAt": "2026-04-08T07:32:40.381Z"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

@@ -0,0 +1,27 @@
{
"draftId": "qwen-sheet-1775622034018",
"kind": "sheet",
"model": "qwen-image-2.0",
"size": "1024*1024",
"promptText": "使用图1作为唯一角色身份参考。生成一张 4x4 的 sprite sheet共 16 帧展示同一个角色的连续动作。角色始终朝右全身完整出现在每一个格子里脚底始终可见地面线高度基本一致角色在每一格中的尺度基本一致镜头固定不变不要切换景别不要切换视角不要左右翻转。Q版大头身动作角色头部占比明显更大约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。\n动作名待机循环\n是否循环是\n身体位移原地\n武器规则武器始终在主手位置稳定\n1-4 帧:稳定站姿,轻微呼吸起伏\n5-8 帧:胸腔与肩膀轻微抬起,衣摆极轻微变化\n9-12 帧:呼气回落,重心恢复\n13-16 帧:逐渐回到与首帧接近的站姿\n结尾要求第 16 帧自然衔接第 1 帧\n输出要求每一格都要清晰分开网格顺序从左到右、从上到下动作连续首尾关系明确轮廓稳定发型稳定服装结构稳定武器始终在正确的手中背景为纯浅色适合后续切成 sprite frames。\nQ版大头身少女冒险者头部占比更大约 2 到 3 头身,金棕色头发,明亮表情,轻甲或冒险服,动作角色轮廓清楚。\n每格边界清晰背景纯浅色适合后续切帧。",
"negativePrompt": "多角色左右朝向混乱前视图背视图镜头切换景别变化特写脚底裁切头顶裁切缺手缺脚额外肢体武器消失武器换手服装变化脸部变化发型变化动作不连续重复帧过多构图混乱背景复杂强透视运动模糊残影文字水印UI边框覆盖角色",
"promptExtend": false,
"seed": 2101,
"candidateCount": 2,
"referenceImageCount": 1,
"drafts": [
{
"id": "qwen-sheet-1775622034018-1",
"label": "精灵表 1",
"imageSrc": "/generated-qwen-sprites/_drafts/sheet/qwen-sheet-1775622034018/candidate-01.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/0a/20260408/8b3aee91/a60efc3a-dc00-42c3-90a0-987ffeb94973.png?Expires=1776227833&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=vRjvyHXKKq6IKjvRM9%2BoKnT8FNg%3D"
},
{
"id": "qwen-sheet-1775622034018-2",
"label": "精灵表 2",
"imageSrc": "/generated-qwen-sprites/_drafts/sheet/qwen-sheet-1775622034018/candidate-02.png",
"remoteUrl": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/ad/20260408/8b3aee91/28305000-f9b6-484b-a70b-5bb80ca55d08.png?Expires=1776227833&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=dILDDQDifvGxx3MFYopC%2BXtKoOs%3D"
}
],
"createdAt": "2026-04-08T04:20:37.029Z"
}

198
scripts/dev-node.mjs Normal file
View File

@@ -0,0 +1,198 @@
import {spawn} from 'node:child_process';
import {existsSync, readFileSync} from 'node:fs';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
const repoRoot = fileURLToPath(new URL('../', import.meta.url));
const serverRoot = fileURLToPath(new URL('../server-node/', import.meta.url));
const viteCliPath = fileURLToPath(new URL('./vite-cli.mjs', import.meta.url));
const envExamplePath = fileURLToPath(new URL('../.env.example', import.meta.url));
const envLocalPath = fileURLToPath(new URL('../.env.local', import.meta.url));
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
function parseEnvContents(contents) {
return contents
.split(/\r?\n/u)
.reduce((envMap, rawLine) => {
const line = rawLine.trim();
if (!line || line.startsWith('#')) {
return envMap;
}
const separatorIndex = line.indexOf('=');
if (separatorIndex < 0) {
return envMap;
}
const key = line.slice(0, separatorIndex).trim();
let value = line.slice(separatorIndex + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
envMap[key] = value;
return envMap;
}, {});
}
function readEnvFile(filePath) {
if (!existsSync(filePath)) {
return {};
}
return parseEnvContents(readFileSync(filePath, 'utf8'));
}
function resolveServerTarget(serverAddr) {
const trimmed = serverAddr.trim();
if (!trimmed) {
return 'http://127.0.0.1:8081';
}
if (/^https?:\/\//u.test(trimmed)) {
try {
const url = new URL(trimmed);
if (url.hostname === '0.0.0.0') {
url.hostname = '127.0.0.1';
}
return url.toString().replace(/\/$/u, '');
} catch {
return trimmed.replace(/\/$/u, '');
}
}
if (trimmed.startsWith(':')) {
return `http://127.0.0.1${trimmed}`;
}
if (trimmed.startsWith('0.0.0.0:')) {
return `http://127.0.0.1:${trimmed.slice('0.0.0.0:'.length)}`;
}
return `http://${trimmed}`;
}
const mergedEnv = {
...readEnvFile(envExamplePath),
...readEnvFile(envLocalPath),
...process.env,
};
mergedEnv.PROJECT_ROOT = mergedEnv.PROJECT_ROOT || repoRoot;
mergedEnv.NODE_SERVER_ADDR = mergedEnv.NODE_SERVER_ADDR || ':8081';
mergedEnv.NODE_SERVER_TARGET =
mergedEnv.NODE_SERVER_TARGET || resolveServerTarget(mergedEnv.NODE_SERVER_ADDR);
mergedEnv.SQLITE_PATH =
mergedEnv.SQLITE_PATH || path.join(repoRoot, 'server-node', 'data', 'genarrative.sqlite');
console.log(`[dev:node] PROJECT_ROOT=${mergedEnv.PROJECT_ROOT}`);
console.log(`[dev:node] NODE_SERVER_ADDR=${mergedEnv.NODE_SERVER_ADDR}`);
console.log(`[dev:node] NODE_SERVER_TARGET=${mergedEnv.NODE_SERVER_TARGET}`);
console.log(`[dev:node] SQLITE_PATH=${mergedEnv.SQLITE_PATH}`);
const children = new Set();
let shuttingDown = false;
let pendingExitCode = 0;
function stopChild(child) {
if (!child || child.exitCode !== null) {
return;
}
child.kill('SIGTERM');
setTimeout(() => {
if (child.exitCode === null) {
child.kill('SIGKILL');
}
}, 2000).unref();
}
function stopAllChildren() {
for (const child of children) {
stopChild(child);
}
}
function finalizeExit(code = 0) {
pendingExitCode = code;
if (children.size === 0) {
process.exit(pendingExitCode);
}
}
function requestShutdown(code = 0) {
if (!shuttingDown) {
shuttingDown = true;
pendingExitCode = code;
stopAllChildren();
}
finalizeExit(pendingExitCode);
}
function registerChild(name, child, siblingProvider) {
children.add(child);
child.on('error', (error) => {
console.error(`[dev:node] ${name} failed to start`, error);
requestShutdown(1);
});
child.on('exit', (code, signal) => {
children.delete(child);
if (!shuttingDown) {
const resolvedExitCode = code ?? 1;
const signalSuffix = signal ? ` (${signal})` : '';
console.error(
`[dev:node] ${name} exited with code ${resolvedExitCode}${signalSuffix}`,
);
const sibling = siblingProvider();
if (sibling) {
stopChild(sibling);
}
requestShutdown(resolvedExitCode);
return;
}
finalizeExit(pendingExitCode);
});
}
const serverProcess = spawn(npmCommand, ['run', 'dev'], {
cwd: serverRoot,
env: mergedEnv,
shell: process.platform === 'win32',
stdio: 'inherit',
});
const viteProcess = spawn(
process.execPath,
[viteCliPath, '--port=3000', '--host=0.0.0.0'],
{
cwd: repoRoot,
env: mergedEnv,
stdio: 'inherit',
},
);
registerChild('node server', serverProcess, () => viteProcess);
registerChild('vite dev server', viteProcess, () => serverProcess);
process.on('SIGINT', () => {
console.log('[dev:node] received SIGINT, shutting down...');
requestShutdown(0);
});
process.on('SIGTERM', () => {
console.log('[dev:node] received SIGTERM, shutting down...');
requestShutdown(0);
});

View File

@@ -9,6 +9,12 @@ import path from 'node:path';
import { loadEnv, type Plugin } from 'vite';
import {
buildMasterPrompt,
buildVideoActionPrompt,
getActionTemplateById,
} from '../../src/tools/qwenSpriteSheetToolModel';
const CHARACTER_VISUAL_GENERATE_PATH = '/api/character-visual/generate';
const CHARACTER_VISUAL_JOBS_PATH = '/api/character-visual/jobs/';
const CHARACTER_ANIMATION_GENERATE_PATH = '/api/animation/generate';
@@ -17,7 +23,7 @@ const CHARACTER_ANIMATION_IMPORT_VIDEO_PATH = '/api/animation/import-video';
const CHARACTER_ANIMATION_TEMPLATES_PATH = '/api/animation/templates';
const DEFAULT_DASHSCOPE_BASE_URL = 'https://dashscope.aliyuncs.com/api/v1';
const DEFAULT_CHARACTER_VISUAL_MODEL = 'wan2.7-image-pro';
const DEFAULT_CHARACTER_VIDEO_MODEL = 'wan2.7-i2v';
const DEFAULT_CHARACTER_VIDEO_MODEL = 'wan2.2-kf2v-flash';
const DEFAULT_CHARACTER_REFERENCE_VIDEO_MODEL = 'wan2.7-r2v';
const DEFAULT_CHARACTER_MOTION_TRANSFER_MODEL = 'wan2.2-animate-move';
const DASHSCOPE_IMAGE_TASK_POLL_INTERVAL_MS = 2500;
@@ -302,6 +308,18 @@ async function resolveMediaSourcePayload(
};
}
async function resolveMediaSourceAsDataUrl(
rootDir: string,
source: string,
) {
if (/^data:/u.test(source)) {
return source;
}
const payload = await resolveMediaSourcePayload(rootDir, source);
return `data:${payload.mimeType};base64,${payload.buffer.toString('base64')}`;
}
function requestResponse(
urlString: string,
options: {
@@ -684,14 +702,15 @@ function extractImageUrls(payload: Record<string, unknown>) {
return [...new Set(urls)];
}
function buildNpcVisualPrompt(promptText: string) {
const trimmed = promptText.trim();
return [
'单人 NPC 角色形象,全身,侧身朝右,站姿稳定,武器与手完整可见。',
'画面简洁,背景干净,角色轮廓清楚,适合后续做动作与裁切。',
'不要多人,不要复杂场景,不要夸张透视,不要截断脚底。',
trimmed || '江湖风格角色,服装完整,姿态自然。',
].join(' ');
function buildNpcVisualPrompt(
promptText: string,
characterBriefText = '',
) {
const mergedBrief = [characterBriefText.trim(), promptText.trim()]
.filter(Boolean)
.join('\n');
return buildMasterPrompt(mergedBrief || '江湖风格角色,服装完整,姿态自然。');
}
function buildImageSequencePrompt(
@@ -713,19 +732,36 @@ function buildImageSequencePrompt(
.join(' ');
}
function buildNpcAnimationPrompt(
animation: string,
promptText: string,
useChromaKey: boolean,
) {
function buildNpcAnimationPrompt(options: {
animation: string;
promptText: string;
useChromaKey: boolean;
characterBriefText?: string;
actionTemplateId?: string;
}) {
if (options.actionTemplateId) {
return buildVideoActionPrompt({
actionTemplate: getActionTemplateById(
options.actionTemplateId as Parameters<typeof getActionTemplateById>[0],
),
actionDetailText: options.promptText,
useChromaKey: options.useChromaKey,
characterBrief:
options.characterBriefText?.trim() || `${options.animation} 动作角色`,
});
}
return [
`单人 NPC 全身动作视频,动作主题是 ${animation}`,
`单人 NPC 全身动作视频,动作主题是 ${options.animation}`,
'角色固定为同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。',
'动作连贯,避免服装、发型、面部、武器随机漂移。',
useChromaKey
options.useChromaKey
? '背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。'
: '背景简洁纯净,无复杂场景。',
promptText.trim(),
options.characterBriefText?.trim()
? `角色设定:${options.characterBriefText.trim()}`
: '',
options.promptText.trim(),
]
.filter(Boolean)
.join(' ');
@@ -787,13 +823,17 @@ async function handleGenerateCharacterVisuals(
typeof body.characterId === 'string'
? body.characterId.trim()
: 'character';
const sourceMode =
typeof body.sourceMode === 'string' ? body.sourceMode.trim() : '';
const promptText =
typeof body.promptText === 'string' ? body.promptText.trim() : '';
const referenceImageDataUrls = isStringArray(body.referenceImageDataUrls)
? body.referenceImageDataUrls.slice(0, 4)
: [];
const sourceMode =
typeof body.sourceMode === 'string' ? body.sourceMode.trim() : '';
const promptText =
typeof body.promptText === 'string' ? body.promptText.trim() : '';
const characterBriefText =
typeof body.characterBriefText === 'string'
? body.characterBriefText.trim()
: '';
const referenceImageDataUrls = isStringArray(body.referenceImageDataUrls)
? body.referenceImageDataUrls.slice(0, 4)
: [];
const candidateCountRaw =
typeof body.candidateCount === 'number' ? body.candidateCount : 3;
const candidateCount = Math.max(
@@ -818,20 +858,20 @@ async function handleGenerateCharacterVisuals(
return;
}
if (!promptText && sourceMode === 'text-to-image') {
sendJson(res, 400, {
error: { message: '文生主形象需要填写角色设定。' },
});
if (!promptText && !characterBriefText && sourceMode === 'text-to-image') {
sendJson(res, 400, {
error: { message: '文生主形象需要填写角色设定。' },
});
return;
}
let activeTaskId = '';
let activePrompt = '';
try {
const finalPrompt = buildNpcVisualPrompt(promptText);
activePrompt = finalPrompt;
const content = [
{ text: finalPrompt },
let activeTaskId = '';
let activePrompt = '';
try {
const finalPrompt = buildNpcVisualPrompt(promptText, characterBriefText);
activePrompt = finalPrompt;
const content = [
{ text: finalPrompt },
...referenceImageDataUrls.map((image) => ({ image })),
];
const createTaskResponse = await proxyJsonRequest(
@@ -1059,6 +1099,14 @@ async function handleGenerateCharacterAnimation(
typeof body.animation === 'string' ? body.animation.trim() : 'idle';
const promptText =
typeof body.promptText === 'string' ? body.promptText.trim() : '';
const characterBriefText =
typeof body.characterBriefText === 'string'
? body.characterBriefText.trim()
: '';
const actionTemplateId =
typeof body.actionTemplateId === 'string'
? body.actionTemplateId.trim()
: '';
const visualSource =
typeof body.visualSource === 'string' ? body.visualSource.trim() : '';
const referenceImageDataUrls = isStringArray(body.referenceImageDataUrls)
@@ -1076,7 +1124,7 @@ async function handleGenerateCharacterAnimation(
typeof body.frameCount === 'number' && Number.isFinite(body.frameCount)
? Math.max(2, Math.min(16, Math.round(body.frameCount)))
: 8;
const durationSeconds =
const requestedDurationSeconds =
typeof body.durationSeconds === 'number' &&
Number.isFinite(body.durationSeconds)
? Math.max(1, Math.min(8, Math.round(body.durationSeconds)))
@@ -1098,6 +1146,10 @@ async function handleGenerateCharacterAnimation(
? body.videoModel.trim()
: runtimeEnv.DASHSCOPE_CHARACTER_VIDEO_MODEL ||
DEFAULT_CHARACTER_VIDEO_MODEL;
const durationSeconds =
videoModel === 'wan2.2-kf2v-flash' ? 5 : requestedDurationSeconds;
const normalizedResolution =
videoModel === 'wan2.2-kf2v-flash' ? '480P' : resolution;
const referenceVideoModel =
typeof body.referenceVideoModel === 'string' &&
body.referenceVideoModel.trim()
@@ -1295,62 +1347,70 @@ async function handleGenerateCharacterAnimation(
return;
}
const modelForVisualUpload =
strategy === 'reference-to-video'
? referenceVideoModel
: strategy === 'motion-transfer'
? motionTransferModel
: videoModel;
const visualUrl = await uploadFileToDashScope(
baseUrl,
apiKey,
modelForVisualUpload,
`${characterId}-${animation}-visual`,
await resolveMediaSourcePayload(rootDir, visualSource),
);
if (strategy === 'image-to-video') {
const finalPrompt = buildNpcAnimationPrompt(
const finalPrompt = buildNpcAnimationPrompt({
animation,
promptText,
useChromaKey,
);
characterBriefText,
actionTemplateId,
});
activePrompt = finalPrompt;
activeModel = videoModel;
const media = [
{ type: 'image', url: visualUrl, role: 'first_frame' },
...(lastFrameImageDataUrl
? [
{
type: 'image',
url: await uploadFileToDashScope(
baseUrl,
apiKey,
videoModel,
`${characterId}-${animation}-last-frame`,
await resolveMediaSourcePayload(
rootDir,
lastFrameImageDataUrl,
),
),
role: 'last_frame',
},
]
: []),
];
const isKf2vFlash = videoModel === 'wan2.2-kf2v-flash';
const visualInputRef = isKf2vFlash
? await resolveMediaSourceAsDataUrl(rootDir, visualSource)
: await uploadFileToDashScope(
baseUrl,
apiKey,
videoModel,
`${characterId}-${animation}-visual`,
await resolveMediaSourcePayload(rootDir, visualSource),
);
const lastFrameRef = lastFrameImageDataUrl
? isKf2vFlash
? await resolveMediaSourceAsDataUrl(rootDir, lastFrameImageDataUrl)
: await uploadFileToDashScope(
baseUrl,
apiKey,
videoModel,
`${characterId}-${animation}-last-frame`,
await resolveMediaSourcePayload(
rootDir,
lastFrameImageDataUrl,
),
)
: '';
const inputPayload =
isKf2vFlash
? {
prompt: finalPrompt,
first_frame_url: visualInputRef,
...(lastFrameRef ? { last_frame_url: lastFrameRef } : {}),
}
: {
prompt: finalPrompt,
media: [
{ type: 'first_frame', url: visualInputRef },
...(lastFrameRef
? [{ type: 'last_frame', url: lastFrameRef }]
: []),
],
};
const videoSynthesisEndpoint = isKf2vFlash
? `${baseUrl}/services/aigc/image2video/video-synthesis`
: `${baseUrl}/services/aigc/video-generation/video-synthesis`;
const createTaskResponse = await proxyJsonRequest(
`${baseUrl}/services/aigc/video-generation/video-synthesis`,
videoSynthesisEndpoint,
apiKey,
{
model: videoModel,
input: {
prompt: finalPrompt,
media,
},
input: inputPayload,
parameters: {
duration: durationSeconds,
resolution,
resolution: normalizedResolution,
...(isKf2vFlash ? { prompt_extend: true, watermark: false } : {}),
},
},
{
@@ -1482,6 +1542,20 @@ async function handleGenerateCharacterAnimation(
return;
}
const modelForVisualUpload =
strategy === 'reference-to-video'
? referenceVideoModel
: strategy === 'motion-transfer'
? motionTransferModel
: videoModel;
const visualUrl = await uploadFileToDashScope(
baseUrl,
apiKey,
modelForVisualUpload,
`${characterId}-${animation}-visual`,
await resolveMediaSourcePayload(rootDir, visualSource),
);
if (strategy === 'motion-transfer') {
if (referenceVideoDataUrls.length === 0) {
sendJson(res, 400, {
@@ -1490,11 +1564,12 @@ async function handleGenerateCharacterAnimation(
return;
}
const finalPrompt = buildNpcAnimationPrompt(
const finalPrompt = buildNpcAnimationPrompt({
animation,
promptText,
useChromaKey,
);
characterBriefText,
});
activePrompt = finalPrompt;
activeModel = motionTransferModel;
const referenceVideoUrl = await uploadFileToDashScope(
@@ -1679,11 +1754,12 @@ async function handleGenerateCharacterAnimation(
return;
}
const finalPrompt = buildNpcAnimationPrompt(
const finalPrompt = buildNpcAnimationPrompt({
animation,
promptText,
useChromaKey,
);
characterBriefText,
});
activePrompt = finalPrompt;
activeModel = referenceVideoModel;
const createTaskResponse = await proxyJsonRequest(

View File

@@ -27,7 +27,7 @@ const CHARACTER_VISUAL_PUBLISH_PATH = '/api/character-visual/publish';
const CHARACTER_ANIMATION_PUBLISH_PATH = '/api/animation/publish';
const CUSTOM_WORLD_SCENE_IMAGE_PATH = '/api/custom-world/scene-image';
const DEFAULT_DASHSCOPE_BASE_URL = 'https://dashscope.aliyuncs.com/api/v1';
const DEFAULT_DASHSCOPE_SCENE_IMAGE_MODEL = 'wan2.2-t2i-flash';
const DEFAULT_DASHSCOPE_SCENE_IMAGE_MODEL = 'wan2.7-image';
const DASHSCOPE_TASK_POLL_INTERVAL_MS = 2000;
const DASHSCOPE_TASK_TIMEOUT_MS = 150000;
@@ -541,6 +541,43 @@ async function resolveAssetSourcePayload(
};
}
async function resolveAssetSourceAsDataUrl(
rootDir: string,
source: string,
fallbackMessage: string,
) {
if (/^data:image\/[^;]+;base64,/u.test(source)) {
return source;
}
const payload = await resolveAssetSourcePayload(
rootDir,
source,
fallbackMessage,
);
const mimeType = (() => {
switch (payload.extension) {
case 'jpg':
case 'jpeg':
return 'image/jpeg';
case 'webp':
return 'image/webp';
default:
return 'image/png';
}
})();
return `data:${mimeType};base64,${payload.buffer.toString('base64')}`;
}
function resolveDashScopeSceneImageModel(model: string) {
if (/^wan2\.7-image(?:-pro)?$/u.test(model)) {
return model;
}
return DEFAULT_DASHSCOPE_SCENE_IMAGE_MODEL;
}
function resolveImageExtension(
contentTypeHeader: string | string[] | undefined,
sourceUrl: string,
@@ -657,6 +694,44 @@ function getDashScopeImageUrl(taskResponse: Record<string, unknown>) {
}
}
const choices = output && Array.isArray(output.choices) ? output.choices : [];
for (const choice of choices) {
if (!isRecordValue(choice)) {
continue;
}
const message = isRecordValue(choice.message) ? choice.message : null;
const content =
message && Array.isArray(message.content) ? message.content : [];
for (const entry of content) {
if (!isRecordValue(entry)) {
continue;
}
const imageUrl =
typeof entry.image === 'string' && entry.image.trim()
? entry.image.trim()
: typeof entry.url === 'string' && entry.url.trim()
? entry.url.trim()
: '';
if (imageUrl) {
return {
url: imageUrl,
actualPrompt:
typeof entry.actual_prompt === 'string' &&
entry.actual_prompt.trim()
? entry.actual_prompt.trim()
: typeof entry.revised_prompt === 'string' &&
entry.revised_prompt.trim()
? entry.revised_prompt.trim()
: undefined,
};
}
}
}
throw new Error('场景图片生成成功,但没有返回可下载的图片地址。');
}
@@ -886,10 +961,11 @@ function createCustomWorldSceneImagePlugin(
typeof body.size === 'string' && body.size.trim()
? body.size.trim()
: '1280*720';
const model =
const requestedModel =
typeof body.model === 'string' && body.model.trim()
? body.model.trim()
: defaultModel;
const model = resolveDashScopeSceneImageModel(requestedModel);
const worldName =
typeof body.worldName === 'string' ? body.worldName.trim() : '';
const profileId =
@@ -898,6 +974,10 @@ function createCustomWorldSceneImagePlugin(
typeof body.landmarkName === 'string' ? body.landmarkName.trim() : '';
const landmarkId =
typeof body.landmarkId === 'string' ? body.landmarkId.trim() : '';
const referenceImageSrc =
typeof body.referenceImageSrc === 'string'
? body.referenceImageSrc.trim()
: '';
if (!prompt) {
sendJson(res, 400, { error: { message: 'prompt is required.' } });
@@ -912,20 +992,37 @@ function createCustomWorldSceneImagePlugin(
}
try {
const messageContent: Array<{ image: string } | { text: string }> = [];
if (referenceImageSrc) {
messageContent.push({
image: await resolveAssetSourceAsDataUrl(
rootDir,
referenceImageSrc,
'参考图必须来自 public 目录或使用 Data URL。',
),
});
}
messageContent.push({ text: prompt });
const createTaskResponse = await proxyJsonRequest(
`${baseUrl}/services/aigc/text2image/image-synthesis`,
`${baseUrl}/services/aigc/image-generation/generation`,
apiKey,
{
model,
input: {
prompt,
...(negativePrompt ? { negative_prompt: negativePrompt } : {}),
messages: [
{
role: 'user',
content: messageContent,
},
],
},
parameters: {
n: 1,
size,
prompt_extend: true,
watermark: false,
...(negativePrompt ? { negative_prompt: negativePrompt } : {}),
},
},
{
@@ -1023,6 +1120,7 @@ function createCustomWorldSceneImagePlugin(
size,
prompt,
negativePrompt,
referenceImageSrc: referenceImageSrc || undefined,
actualPrompt: imageResult.actualPrompt,
remoteUrl: imageResult.url,
imageSrc,
@@ -1189,6 +1287,7 @@ function createCharacterVisualPublishPlugin(rootDir: string): Plugin {
typeof body.height === 'number' && Number.isFinite(body.height)
? body.height
: 1536;
const updateCharacterOverride = body.updateCharacterOverride !== false;
if (!characterId) {
sendJson(res, 400, { error: { message: 'characterId is required.' } });
@@ -1259,26 +1358,30 @@ function createCharacterVisualPublishPlugin(rootDir: string): Plugin {
'utf8',
);
const overrideMap = await readJsonObjectFile(characterOverridesFilePath);
const existingOverride = overrideMap[characterId];
const nextOverride =
existingOverride &&
typeof existingOverride === 'object' &&
!Array.isArray(existingOverride)
? { ...(existingOverride as Record<string, unknown>) }
: {};
nextOverride.generatedVisualAssetId = assetId;
nextOverride.portrait = masterImagePath;
overrideMap[characterId] = nextOverride;
await writeJsonObjectFile(characterOverridesFilePath, overrideMap);
let overrideMap: Record<string, unknown> = {};
if (updateCharacterOverride) {
overrideMap = await readJsonObjectFile(characterOverridesFilePath);
const existingOverride = overrideMap[characterId];
const nextOverride =
existingOverride &&
typeof existingOverride === 'object' &&
!Array.isArray(existingOverride)
? { ...(existingOverride as Record<string, unknown>) }
: {};
nextOverride.generatedVisualAssetId = assetId;
nextOverride.portrait = masterImagePath;
overrideMap[characterId] = nextOverride;
await writeJsonObjectFile(characterOverridesFilePath, overrideMap);
}
sendJson(res, 200, {
ok: true,
assetId,
portraitPath: masterImagePath,
overrideMap,
saveMessage:
'主形象已发布到 public/generated-characters并更新角色覆盖。',
saveMessage: updateCharacterOverride
? '主形象已发布到 public/generated-characters并更新角色覆盖。'
: '主形象已保存到 public/generated-characters可直接写回当前自定义世界角色。',
});
} catch (error) {
sendJson(res, 500, {
@@ -1333,6 +1436,7 @@ function createCharacterAnimationPublishPlugin(rootDir: string): Plugin {
!Array.isArray(body.animations)
? (body.animations as Record<string, unknown>)
: null;
const updateCharacterOverride = body.updateCharacterOverride !== false;
if (!characterId) {
sendJson(res, 400, { error: { message: 'characterId is required.' } });
@@ -1476,35 +1580,40 @@ function createCharacterAnimationPublishPlugin(rootDir: string): Plugin {
'utf8',
);
const overrideMap = await readJsonObjectFile(characterOverridesFilePath);
const existingOverride = overrideMap[characterId];
const nextOverride =
existingOverride &&
typeof existingOverride === 'object' &&
!Array.isArray(existingOverride)
? { ...(existingOverride as Record<string, unknown>) }
: {};
const existingAnimationMap =
nextOverride.animationMap &&
typeof nextOverride.animationMap === 'object' &&
!Array.isArray(nextOverride.animationMap)
? (nextOverride.animationMap as Record<string, unknown>)
: {};
nextOverride.generatedAnimationSetId = animationSetId;
nextOverride.generatedVisualAssetId = visualAssetId;
nextOverride.animationMap = {
...existingAnimationMap,
...nextAnimationMap,
};
overrideMap[characterId] = nextOverride;
await writeJsonObjectFile(characterOverridesFilePath, overrideMap);
let overrideMap: Record<string, unknown> = {};
if (updateCharacterOverride) {
overrideMap = await readJsonObjectFile(characterOverridesFilePath);
const existingOverride = overrideMap[characterId];
const nextOverride =
existingOverride &&
typeof existingOverride === 'object' &&
!Array.isArray(existingOverride)
? { ...(existingOverride as Record<string, unknown>) }
: {};
const existingAnimationMap =
nextOverride.animationMap &&
typeof nextOverride.animationMap === 'object' &&
!Array.isArray(nextOverride.animationMap)
? (nextOverride.animationMap as Record<string, unknown>)
: {};
nextOverride.generatedAnimationSetId = animationSetId;
nextOverride.generatedVisualAssetId = visualAssetId;
nextOverride.animationMap = {
...existingAnimationMap,
...nextAnimationMap,
};
overrideMap[characterId] = nextOverride;
await writeJsonObjectFile(characterOverridesFilePath, overrideMap);
}
sendJson(res, 200, {
ok: true,
animationSetId,
overrideMap,
saveMessage:
'基础动作资源已发布到 public/generated-animations并更新角色覆盖。',
animationMap: nextAnimationMap,
saveMessage: updateCharacterOverride
? '基础动作资源已发布到 public/generated-animations并更新角色覆盖。'
: '基础动作资源已保存到 public/generated-animations可直接写回当前自定义世界角色。',
});
} catch (error) {
sendJson(res, 500, {

149
scripts/run-caddy-dev.mjs Normal file
View File

@@ -0,0 +1,149 @@
import {spawn} from 'node:child_process';
import {existsSync, readFileSync} from 'node:fs';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
const repoRoot = fileURLToPath(new URL('../', import.meta.url));
const envExamplePath = fileURLToPath(new URL('../.env.example', import.meta.url));
const envLocalPath = fileURLToPath(new URL('../.env.local', import.meta.url));
const caddyConfigPath = fileURLToPath(new URL('../tools/Caddyfile.dev', import.meta.url));
const distRoot = fileURLToPath(new URL('../dist/', import.meta.url));
const bundledCaddyExe = fileURLToPath(new URL('../tools/caddy.exe', import.meta.url));
function parseEnvContents(contents) {
return contents
.split(/\r?\n/u)
.reduce((envMap, rawLine) => {
const line = rawLine.trim();
if (!line || line.startsWith('#')) {
return envMap;
}
const separatorIndex = line.indexOf('=');
if (separatorIndex < 0) {
return envMap;
}
const key = line.slice(0, separatorIndex).trim();
let value = line.slice(separatorIndex + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
envMap[key] = value;
return envMap;
}, {});
}
function readEnvFile(filePath) {
if (!existsSync(filePath)) {
return {};
}
return parseEnvContents(readFileSync(filePath, 'utf8'));
}
function normalizePathForCaddy(filePath) {
return path.resolve(filePath).replace(/\\/gu, '/');
}
function resolveApiUpstream(env) {
return (
env.CADDY_API_UPSTREAM
|| env.NODE_SERVER_TARGET
|| 'http://127.0.0.1:8081'
);
}
function resolveCaddyBinary() {
if (process.platform === 'win32' && existsSync(bundledCaddyExe)) {
return bundledCaddyExe;
}
return process.platform === 'win32' ? 'caddy.exe' : 'caddy';
}
const mergedEnv = {
...readEnvFile(envExamplePath),
...readEnvFile(envLocalPath),
...process.env,
};
if (!existsSync(path.join(distRoot, 'index.html'))) {
console.error('[serve:caddy] dist/index.html 不存在,请先运行 npm run build:raw');
process.exit(1);
}
mergedEnv.CADDY_SITE_ROOT = mergedEnv.CADDY_SITE_ROOT || normalizePathForCaddy(distRoot);
mergedEnv.CADDY_API_UPSTREAM = resolveApiUpstream(mergedEnv);
const caddyBinary = resolveCaddyBinary();
console.log('[serve:caddy] listen=:8080');
console.log(`[serve:caddy] CADDY_SITE_ROOT=${mergedEnv.CADDY_SITE_ROOT}`);
console.log(`[serve:caddy] CADDY_API_UPSTREAM=${mergedEnv.CADDY_API_UPSTREAM}`);
console.log(`[serve:caddy] config=${caddyConfigPath}`);
const caddyProcess = spawn(
caddyBinary,
['run', '--config', caddyConfigPath, '--adapter', 'caddyfile'],
{
cwd: repoRoot,
env: mergedEnv,
stdio: 'inherit',
shell: process.platform === 'win32' && !existsSync(bundledCaddyExe),
},
);
let shuttingDown = false;
function requestShutdown(code = 0) {
if (shuttingDown) {
return;
}
shuttingDown = true;
if (caddyProcess.exitCode === null) {
caddyProcess.kill('SIGTERM');
setTimeout(() => {
if (caddyProcess.exitCode === null) {
caddyProcess.kill('SIGKILL');
}
}, 2000).unref();
}
if (caddyProcess.exitCode !== null) {
process.exit(code);
}
}
caddyProcess.on('error', (error) => {
console.error('[serve:caddy] 启动 Caddy 失败', error);
process.exit(1);
});
caddyProcess.on('exit', (code, signal) => {
if (!shuttingDown) {
const resolvedExitCode = code ?? 1;
const signalSuffix = signal ? ` (${signal})` : '';
console.error(
`[serve:caddy] Caddy exited with code ${resolvedExitCode}${signalSuffix}`,
);
process.exit(resolvedExitCode);
}
});
process.on('SIGINT', () => {
console.log('[serve:caddy] received SIGINT, shutting down...');
requestShutdown(0);
});
process.on('SIGTERM', () => {
console.log('[serve:caddy] received SIGTERM, shutting down...');
requestShutdown(0);
});

13
server-node/build.mjs Normal file
View File

@@ -0,0 +1,13 @@
import esbuild from 'esbuild';
await esbuild.build({
entryPoints: ['src/server.ts'],
bundle: true,
platform: 'node',
format: 'esm',
target: 'node22',
outfile: 'dist/server.js',
sourcemap: true,
packages: 'external',
tsconfig: 'tsconfig.json',
});

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

2957
server-node/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
server-node/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "genarrative-server-node",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "node build.mjs",
"start": "node dist/server.js",
"test": "node --test --import tsx src/**/*.test.ts"
},
"dependencies": {
"@node-rs/argon2": "^2.0.2",
"better-sqlite3": "^12.4.1",
"cors": "^2.8.5",
"express": "^4.21.2",
"jose": "^6.1.0",
"pino": "^9.9.5",
"pino-http": "^10.5.0",
"pino-roll": "^3.1.0",
"zod": "^4.1.8"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/cors": "^2.8.18",
"@types/express": "^5.0.3",
"@types/node": "^24.6.0",
"esbuild": "^0.28.0",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
}
}

264
server-node/src/app.test.ts Normal file
View File

@@ -0,0 +1,264 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import type { AddressInfo } from 'node:net';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { createApp } from './app.js';
import type { AppConfig } from './config.js';
import { createAppContext } from './server.js';
function createTestConfig(testName: string): AppConfig {
const tempRoot = fs.mkdtempSync(
path.join(os.tmpdir(), `genarrative-server-node-${testName}-`),
);
return {
nodeEnv: 'test',
projectRoot: tempRoot,
publicDir: path.join(tempRoot, 'public'),
logsDir: path.join(tempRoot, 'logs'),
dataDir: path.join(tempRoot, 'data'),
sqlitePath: path.join(tempRoot, 'data', 'test.sqlite'),
serverAddr: ':0',
logLevel: 'silent',
jwtSecret: 'test-secret',
jwtExpiresIn: '7d',
jwtIssuer: 'genarrative-server-node-test',
llm: {
baseUrl: 'https://example.invalid',
apiKey: '',
model: 'test-model',
},
dashScope: {
baseUrl: 'https://example.invalid',
apiKey: '',
imageModel: 'test-image-model',
requestTimeoutMs: 1000,
},
};
}
async function withTestServer<T>(
testName: string,
run: (options: { baseUrl: string }) => Promise<T>,
) {
const context = createAppContext(createTestConfig(testName));
const app = createApp(context);
const server = await new Promise<import('node:http').Server>((resolve) => {
const nextServer = app.listen(0, '127.0.0.1', () => resolve(nextServer));
});
try {
const address = server.address() as AddressInfo;
return await run({
baseUrl: `http://127.0.0.1:${address.port}`,
});
} finally {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
context.db.close();
}
}
async function authEntry(baseUrl: string, username: string, password: string) {
const response = await fetch(`${baseUrl}/api/auth/entry`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username,
password,
}),
});
const payload = await response.json() as {
token: string;
user: {
id: string;
username: string;
};
};
assert.equal(response.status, 200);
assert.ok(payload.token);
return payload;
}
function withBearer(token: string, init: RequestInit = {}) {
return {
...init,
headers: {
...(init.headers ?? {}),
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
} satisfies RequestInit;
}
test('auth entry auto-registers, me works, logout invalidates old token', async () => {
await withTestServer('auth', async ({ baseUrl }) => {
const entry = await authEntry(baseUrl, 'hero_test', 'secret123');
const meResponse = await fetch(`${baseUrl}/api/auth/me`, {
headers: {
Authorization: `Bearer ${entry.token}`,
},
});
const mePayload = await meResponse.json() as {
user: {
username: string;
};
};
assert.equal(meResponse.status, 200);
assert.equal(mePayload.user.username, 'hero_test');
const logoutResponse = await fetch(
`${baseUrl}/api/auth/logout`,
withBearer(entry.token, { method: 'POST' }),
);
assert.equal(logoutResponse.status, 200);
const expiredResponse = await fetch(`${baseUrl}/api/auth/me`, {
headers: {
Authorization: `Bearer ${entry.token}`,
},
});
assert.equal(expiredResponse.status, 401);
});
});
test('issued jwt remains valid without exp until logout invalidates token version', async () => {
await withTestServer('permanent-jwt', async ({ baseUrl }) => {
const entry = await authEntry(baseUrl, 'hero_eternal', 'secret123');
const tokenParts = entry.token.split('.');
assert.equal(tokenParts.length, 3);
const payloadJson = JSON.parse(
Buffer.from(tokenParts[1] || '', 'base64url').toString('utf8'),
) as {
exp?: number;
sub?: string;
ver?: number;
};
assert.equal(typeof payloadJson.sub, 'string');
assert.equal(typeof payloadJson.ver, 'number');
assert.equal('exp' in payloadJson, false);
const meResponse = await fetch(`${baseUrl}/api/auth/me`, {
headers: {
Authorization: `Bearer ${entry.token}`,
},
});
assert.equal(meResponse.status, 200);
const logoutResponse = await fetch(
`${baseUrl}/api/auth/logout`,
withBearer(entry.token, { method: 'POST' }),
);
assert.equal(logoutResponse.status, 200);
const invalidatedResponse = await fetch(`${baseUrl}/api/auth/me`, {
headers: {
Authorization: `Bearer ${entry.token}`,
},
});
assert.equal(invalidatedResponse.status, 401);
});
});
test('runtime persistence is isolated by user', async () => {
await withTestServer('persistence', async ({ baseUrl }) => {
const userA = await authEntry(baseUrl, 'player_one', 'secret123');
const userB = await authEntry(baseUrl, 'player_two', 'secret123');
const saveResponse = await fetch(
`${baseUrl}/api/runtime/save/snapshot`,
withBearer(userA.token, {
method: 'PUT',
body: JSON.stringify({
gameState: { worldType: 'WUXIA', value: 1 },
bottomTab: 'adventure',
currentStory: { text: 'story A' },
}),
}),
);
assert.equal(saveResponse.status, 200);
const settingsResponse = await fetch(
`${baseUrl}/api/runtime/settings`,
withBearer(userA.token, {
method: 'PUT',
body: JSON.stringify({
musicVolume: 0.25,
}),
}),
);
assert.equal(settingsResponse.status, 200);
const libraryResponse = await fetch(
`${baseUrl}/api/runtime/custom-world-library/world-a`,
withBearer(userA.token, {
method: 'PUT',
body: JSON.stringify({
profile: {
id: 'world-a',
name: '世界 A',
},
}),
}),
);
assert.equal(libraryResponse.status, 200);
const userASave = await fetch(`${baseUrl}/api/runtime/save/snapshot`, {
headers: {
Authorization: `Bearer ${userA.token}`,
},
});
const userASavePayload = await userASave.json() as {
gameState: {
value: number;
};
};
assert.equal(userASavePayload.gameState.value, 1);
const userBSave = await fetch(`${baseUrl}/api/runtime/save/snapshot`, {
headers: {
Authorization: `Bearer ${userB.token}`,
},
});
const userBSavePayload = await userBSave.json();
assert.equal(userBSavePayload, null);
const userBSettings = await fetch(`${baseUrl}/api/runtime/settings`, {
headers: {
Authorization: `Bearer ${userB.token}`,
},
});
const userBSettingsPayload = await userBSettings.json() as {
musicVolume: number;
};
assert.equal(userBSettingsPayload.musicVolume, 0.42);
const userBLibrary = await fetch(
`${baseUrl}/api/runtime/custom-world-library`,
{
headers: {
Authorization: `Bearer ${userB.token}`,
},
},
);
const userBLibraryPayload = await userBLibrary.json() as {
profiles: unknown[];
};
assert.deepEqual(userBLibraryPayload.profiles, []);
});
});

69
server-node/src/app.ts Normal file
View File

@@ -0,0 +1,69 @@
import express from 'express';
import pinoHttp from 'pino-http';
import type { AppContext } from './context.js';
import { errorHandler } from './middleware/errorHandler.js';
import { requestIdMiddleware } from './middleware/requestId.js';
import { createAuthRoutes } from './routes/authRoutes.js';
import { createRuntimeRoutes } from './routes/runtimeRoutes.js';
export function createApp(context: AppContext) {
const app = express();
const createHttpLogger = pinoHttp as unknown as (options: Record<string, unknown>) => express.RequestHandler;
app.disable('x-powered-by');
app.use(requestIdMiddleware);
app.use(
createHttpLogger({
logger: context.logger,
genReqId: (request) => request.requestId,
customProps: (request: express.Request) => ({
request_id: request.requestId,
user_id: request.userId ?? null,
}),
customSuccessObject: (
request: express.Request,
response: express.Response,
baseObject: Record<string, unknown> & { responseTime?: number },
) => ({
...baseObject,
request_id: request.requestId,
user_id: request.userId ?? null,
method: request.method,
path: request.url,
status: response.statusCode,
latency_ms: baseObject.responseTime,
}),
customErrorObject: (
request: express.Request,
response: express.Response,
error: unknown,
baseObject: Record<string, unknown> & { responseTime?: number },
) => ({
...baseObject,
request_id: request.requestId,
user_id: request.userId ?? null,
method: request.method,
path: request.url,
status: response.statusCode,
latency_ms: baseObject.responseTime,
err: error,
}),
}),
);
app.use(express.json({ limit: '10mb' }));
app.get('/healthz', (_request, response) => {
response.json({
ok: true,
service: 'genarrative-node-server',
});
});
app.use('/api/auth', createAuthRoutes(context));
app.use('/api', createRuntimeRoutes(context));
app.use(errorHandler);
return app;
}

View File

@@ -0,0 +1,70 @@
import type { AppContext } from '../context.js';
import { badRequest, unauthorized } from '../errors.js';
import { hashPassword, verifyPassword } from './password.js';
import { signAccessToken } from './token.js';
const USERNAME_PATTERN = /^[A-Za-z0-9_]{3,24}$/u;
function normalizeUsername(username: string) {
return username.trim();
}
function validateCredentials(username: string, password: string) {
if (!USERNAME_PATTERN.test(username)) {
throw badRequest('用户名只允许 3 到 24 位字母、数字、下划线');
}
if (password.length < 6 || password.length > 128) {
throw badRequest('密码长度需要在 6 到 128 位之间');
}
}
export async function entryWithPassword(
context: AppContext,
usernameInput: string,
password: string,
) {
const username = normalizeUsername(usernameInput);
validateCredentials(username, password);
let user = context.userRepository.findByUsername(username);
if (!user) {
const passwordHash = await hashPassword(password);
user = context.userRepository.create(username, passwordHash);
} else {
const isValid = await verifyPassword(user.passwordHash, password);
if (!isValid) {
throw unauthorized('用户名或密码错误');
}
}
if (!user) {
throw new Error('failed to resolve user after auth entry');
}
const token = await signAccessToken(
{
userId: user.id,
tokenVersion: user.tokenVersion,
},
context.config,
);
return {
token,
user: {
id: user.id,
username: user.username,
},
};
}
export async function logoutUser(context: AppContext, userId: string) {
const user = context.userRepository.incrementTokenVersion(userId);
if (!user) {
throw unauthorized('用户不存在');
}
return {
ok: true as const,
};
}

View File

@@ -0,0 +1,16 @@
import { Algorithm, hash, verify } from '@node-rs/argon2';
export async function hashPassword(password: string) {
return hash(password, {
algorithm: Algorithm.Argon2id,
memoryCost: 19456,
timeCost: 2,
parallelism: 1,
});
}
export async function verifyPassword(passwordHash: string, password: string) {
return verify(passwordHash, password, {
algorithm: Algorithm.Argon2id,
});
}

View File

@@ -0,0 +1,46 @@
import { jwtVerify, SignJWT } from 'jose';
import type { AppConfig } from '../config.js';
import { unauthorized } from '../errors.js';
export type AccessTokenClaims = {
userId: string;
tokenVersion: number;
};
function getSecret(config: AppConfig) {
return new TextEncoder().encode(config.jwtSecret);
}
export async function signAccessToken(
claims: AccessTokenClaims,
config: AppConfig,
) {
return new SignJWT({ ver: claims.tokenVersion })
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
.setSubject(claims.userId)
.setIssuer(config.jwtIssuer)
.setIssuedAt()
.sign(getSecret(config));
}
export async function verifyAccessToken(token: string, config: AppConfig) {
try {
const { payload } = await jwtVerify(token, getSecret(config), {
issuer: config.jwtIssuer,
});
const userId = typeof payload.sub === 'string' ? payload.sub : '';
const tokenVersion = typeof payload.ver === 'number' ? payload.ver : NaN;
if (!userId || !Number.isFinite(tokenVersion)) {
throw unauthorized('JWT 内容无效');
}
return {
userId,
tokenVersion,
} satisfies AccessTokenClaims;
} catch (error) {
throw unauthorized('JWT 校验失败');
}
}

162
server-node/src/config.ts Normal file
View File

@@ -0,0 +1,162 @@
import fs from 'node:fs';
import path from 'node:path';
export type AppConfig = {
nodeEnv: string;
projectRoot: string;
publicDir: string;
logsDir: string;
dataDir: string;
sqlitePath: string;
serverAddr: string;
logLevel: 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace' | 'silent';
jwtSecret: string;
jwtExpiresIn: string;
jwtIssuer: string;
llm: {
baseUrl: string;
apiKey: string;
model: string;
};
dashScope: {
baseUrl: string;
apiKey: string;
imageModel: string;
requestTimeoutMs: number;
};
};
type LoadConfigOptions = {
env?: NodeJS.ProcessEnv;
projectRoot?: string;
};
function parseEnvContents(contents: string) {
return contents
.split(/\r?\n/u)
.reduce<Record<string, string>>((envMap, rawLine) => {
const line = rawLine.trim();
if (!line || line.startsWith('#')) {
return envMap;
}
const separatorIndex = line.indexOf('=');
if (separatorIndex < 0) {
return envMap;
}
const key = line.slice(0, separatorIndex).trim();
let value = line.slice(separatorIndex + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
envMap[key] = value;
return envMap;
}, {});
}
function readEnvFile(filePath: string) {
if (!fs.existsSync(filePath)) {
return {};
}
return parseEnvContents(fs.readFileSync(filePath, 'utf8'));
}
function resolveDefaultProjectRoot() {
const cwd = process.cwd();
return path.basename(cwd) === 'server-node'
? path.resolve(cwd, '..')
: cwd;
}
function readMergedEnv(projectRoot: string, processEnv: NodeJS.ProcessEnv) {
return {
...readEnvFile(path.join(projectRoot, '.env.example')),
...readEnvFile(path.join(projectRoot, '.env.local')),
...processEnv,
};
}
function readString(
env: Record<string, string | undefined>,
key: string,
fallback: string,
) {
const value = env[key]?.trim();
return value ? value : fallback;
}
function readPositiveInt(
env: Record<string, string | undefined>,
key: string,
fallback: number,
) {
const parsed = Number(env[key]);
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : fallback;
}
export function loadConfig(options: LoadConfigOptions = {}): AppConfig {
const projectRoot = options.projectRoot ?? resolveDefaultProjectRoot();
const env = readMergedEnv(projectRoot, options.env ?? process.env);
const logsDir = path.join(projectRoot, 'server-node', 'logs');
const dataDir = path.join(projectRoot, 'server-node', 'data');
return {
nodeEnv: readString(env, 'NODE_ENV', 'development'),
projectRoot,
publicDir: path.join(projectRoot, 'public'),
logsDir,
dataDir,
sqlitePath: readString(
env,
'SQLITE_PATH',
path.join(dataDir, 'genarrative.sqlite'),
),
serverAddr: readString(env, 'NODE_SERVER_ADDR', ':8081'),
logLevel: readString(env, 'LOG_LEVEL', 'info') as AppConfig['logLevel'],
jwtSecret: readString(env, 'JWT_SECRET', 'genarrative-dev-secret'),
jwtExpiresIn: readString(env, 'JWT_EXPIRES_IN', '7d'),
jwtIssuer: readString(env, 'JWT_ISSUER', 'genarrative-server-node'),
llm: {
baseUrl: readString(
env,
'LLM_BASE_URL',
'https://ark.cn-beijing.volces.com/api/v3',
),
apiKey:
env.LLM_API_KEY?.trim() ||
env.ARK_API_KEY?.trim() ||
env.VITE_LLM_API_KEY?.trim() ||
'',
model: readString(
env,
'LLM_MODEL',
readString(
env,
'VITE_LLM_MODEL',
'doubao-1-5-pro-32k-character-250715',
),
),
},
dashScope: {
baseUrl: readString(
env,
'DASHSCOPE_BASE_URL',
'https://dashscope.aliyuncs.com/api/v1',
),
apiKey: env.DASHSCOPE_API_KEY?.trim() || '',
imageModel: readString(env, 'DASHSCOPE_IMAGE_MODEL', 'wan2.2-t2i-flash'),
requestTimeoutMs: readPositiveInt(
env,
'DASHSCOPE_IMAGE_REQUEST_TIMEOUT_MS',
150000,
),
},
};
}

View File

@@ -0,0 +1,18 @@
import type { Logger } from 'pino';
import type { AppConfig } from './config.js';
import type { AppDatabase } from './db.js';
import { RuntimeRepository } from './repositories/runtimeRepository.js';
import { UserRepository } from './repositories/userRepository.js';
import { CustomWorldSessionStore } from './services/customWorldSessionStore.js';
import { UpstreamLlmClient } from './services/llmClient.js';
export type AppContext = {
config: AppConfig;
logger: Logger;
db: AppDatabase;
userRepository: UserRepository;
runtimeRepository: RuntimeRepository;
llmClient: UpstreamLlmClient;
customWorldSessions: CustomWorldSessionStore;
};

57
server-node/src/db.ts Normal file
View File

@@ -0,0 +1,57 @@
import fs from 'node:fs';
import path from 'node:path';
import Database from 'better-sqlite3';
import type { AppConfig } from './config.js';
const schemaSql = `
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
token_version INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS save_snapshots (
user_id TEXT PRIMARY KEY,
version INTEGER NOT NULL,
saved_at TEXT NOT NULL,
bottom_tab TEXT NOT NULL,
game_state_json TEXT NOT NULL,
current_story_json TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS runtime_settings (
user_id TEXT PRIMARY KEY,
music_volume REAL NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS custom_world_profiles (
user_id TEXT NOT NULL,
profile_id TEXT NOT NULL,
payload_json TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (user_id, profile_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
`;
export type AppDatabase = Database.Database;
export function createDatabase(config: AppConfig) {
const sqliteDir = path.dirname(config.sqlitePath);
fs.mkdirSync(sqliteDir, { recursive: true });
const db = new Database(config.sqlitePath);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(schemaSql);
return db;
}

35
server-node/src/errors.ts Normal file
View File

@@ -0,0 +1,35 @@
export class HttpError extends Error {
statusCode: number;
expose: boolean;
constructor(statusCode: number, message: string, expose = true) {
super(message);
this.name = 'HttpError';
this.statusCode = statusCode;
this.expose = expose;
}
}
export function badRequest(message: string) {
return new HttpError(400, message);
}
export function unauthorized(message = '未授权访问') {
return new HttpError(401, message);
}
export function forbidden(message = '禁止访问') {
return new HttpError(403, message);
}
export function notFound(message = '资源不存在') {
return new HttpError(404, message);
}
export function conflict(message: string) {
return new HttpError(409, message);
}
export function upstreamError(message: string) {
return new HttpError(502, message);
}

48
server-node/src/http.ts Normal file
View File

@@ -0,0 +1,48 @@
import type { NextFunction, Request, RequestHandler, Response } from 'express';
export function asyncHandler(
handler: (
request: Request,
response: Response,
next: NextFunction,
) => Promise<unknown> | unknown,
): RequestHandler {
return (request, response, next) => {
Promise.resolve(handler(request, response, next)).catch(next);
};
}
export function extractApiErrorMessage(
rawText: string,
fallbackMessage: string,
) {
if (!rawText.trim()) {
return fallbackMessage;
}
try {
const parsed = JSON.parse(rawText) as {
error?: { message?: string };
message?: string;
code?: string;
};
if (typeof parsed.error?.message === 'string' && parsed.error.message.trim()) {
return parsed.error.message.trim();
}
if (typeof parsed.message === 'string' && parsed.message.trim()) {
return parsed.message.trim();
}
if (typeof parsed.code === 'string' && parsed.code.trim()) {
return `${fallbackMessage}${parsed.code.trim()}`;
}
} catch {
// Ignore malformed json responses.
}
return rawText.trim() || fallbackMessage;
}
export function jsonClone<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}

View File

@@ -0,0 +1,65 @@
import fs from 'node:fs';
import path from 'node:path';
import pino, { type Logger } from 'pino';
import type { AppConfig } from './config.js';
const LOG_RETENTION_DAYS = 7;
function cleanupExpiredLogs(logsDir: string) {
if (!fs.existsSync(logsDir)) {
return;
}
const expiryTime = Date.now() - LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
for (const entry of fs.readdirSync(logsDir, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.startsWith('server.log')) {
continue;
}
const fullPath = path.join(logsDir, entry.name);
const stats = fs.statSync(fullPath);
if (stats.mtimeMs < expiryTime) {
fs.rmSync(fullPath, { force: true });
}
}
}
export function createLogger(config: AppConfig): Logger {
fs.mkdirSync(config.logsDir, { recursive: true });
cleanupExpiredLogs(config.logsDir);
const transport = pino.transport({
targets: [
{
target: 'pino-roll',
level: config.logLevel,
options: {
file: path.join(config.logsDir, 'server.log'),
mkdir: true,
size: '10m',
frequency: 'daily',
dateFormat: 'yyyy-MM-dd',
},
},
{
target: 'pino/file',
level: config.logLevel,
options: {
destination: 1,
},
},
],
});
return pino(
{
level: config.logLevel,
timestamp: pino.stdTimeFunctions.isoTime,
base: undefined,
},
transport,
);
}

View File

@@ -0,0 +1,40 @@
import type { NextFunction, Request, Response } from 'express';
import { verifyAccessToken } from '../auth/token.js';
import type { AppConfig } from '../config.js';
import { unauthorized } from '../errors.js';
import { type UserRepository } from '../repositories/userRepository.js';
function readBearerToken(request: Request) {
const authorization = request.header('authorization')?.trim() || '';
if (!authorization.startsWith('Bearer ')) {
return '';
}
return authorization.slice('Bearer '.length).trim();
}
export function requireJwtAuth(config: AppConfig, userRepository: UserRepository) {
return async (request: Request, _response: Response, next: NextFunction) => {
try {
const token = readBearerToken(request);
if (!token) {
throw unauthorized('缺少 Authorization Bearer Token');
}
const claims = await verifyAccessToken(token, config);
const user = userRepository.findById(claims.userId);
if (!user) {
throw unauthorized('用户不存在');
}
if (user.tokenVersion !== claims.tokenVersion) {
throw unauthorized('登录状态已失效,请重新登录');
}
request.auth = claims;
request.userId = claims.userId;
next();
} catch (error) {
next(error);
}
};
}

View File

@@ -0,0 +1,27 @@
import type { ErrorRequestHandler } from 'express';
import { HttpError } from '../errors.js';
export const errorHandler: ErrorRequestHandler = (error, request, response, _next) => {
const statusCode =
error instanceof HttpError ? error.statusCode : 500;
const message =
error instanceof HttpError
? error.message
: '服务器内部错误';
request.log?.error(
{
err: error,
request_id: request.requestId,
user_id: request.userId ?? null,
},
'request failed',
);
response.status(statusCode).json({
error: {
message,
},
});
};

View File

@@ -0,0 +1,8 @@
import crypto from 'node:crypto';
import type { RequestHandler } from 'express';
export const requestIdMiddleware: RequestHandler = (request, _response, next) => {
request.requestId = request.header('x-request-id')?.trim() || crypto.randomUUID();
next();
};

View File

@@ -0,0 +1,182 @@
import type { AppDatabase } from '../db.js';
const SAVE_SNAPSHOT_VERSION = 2;
const DEFAULT_MUSIC_VOLUME = 0.42;
const MAX_CUSTOM_WORLD_PROFILES = 12;
export type SavedSnapshot = {
version: number;
savedAt: string;
gameState: unknown;
bottomTab: string;
currentStory: unknown;
};
export type RuntimeSettings = {
musicVolume: number;
};
function parseJson<T>(value: string): T {
return JSON.parse(value) as T;
}
function toJson(value: unknown) {
return JSON.stringify(value ?? null);
}
export class RuntimeRepository {
constructor(private readonly db: AppDatabase) {}
getSnapshot(userId: string) {
const row = this.db
.prepare(
`SELECT version, saved_at, game_state_json, bottom_tab, current_story_json
FROM save_snapshots
WHERE user_id = ?`,
)
.get(userId) as
| {
version: number;
saved_at: string;
game_state_json: string;
bottom_tab: string;
current_story_json: string;
}
| undefined;
if (!row) {
return null;
}
return {
version: row.version,
savedAt: row.saved_at,
gameState: parseJson(row.game_state_json),
bottomTab: row.bottom_tab,
currentStory: parseJson(row.current_story_json),
} satisfies SavedSnapshot;
}
putSnapshot(userId: string, payload: Omit<SavedSnapshot, 'version'>) {
const snapshot = {
version: SAVE_SNAPSHOT_VERSION,
savedAt: payload.savedAt,
gameState: payload.gameState,
bottomTab: payload.bottomTab,
currentStory: payload.currentStory,
} satisfies SavedSnapshot;
const now = new Date().toISOString();
this.db
.prepare(
`INSERT INTO save_snapshots (
user_id, version, saved_at, bottom_tab, game_state_json, current_story_json, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
version = excluded.version,
saved_at = excluded.saved_at,
bottom_tab = excluded.bottom_tab,
game_state_json = excluded.game_state_json,
current_story_json = excluded.current_story_json,
updated_at = excluded.updated_at`,
)
.run(
userId,
snapshot.version,
snapshot.savedAt,
snapshot.bottomTab,
toJson(snapshot.gameState),
toJson(snapshot.currentStory),
now,
);
return snapshot;
}
deleteSnapshot(userId: string) {
this.db.prepare(`DELETE FROM save_snapshots WHERE user_id = ?`).run(userId);
}
getSettings(userId: string) {
const row = this.db
.prepare(
`SELECT music_volume
FROM runtime_settings
WHERE user_id = ?`,
)
.get(userId) as { music_volume: number } | undefined;
return {
musicVolume:
typeof row?.music_volume === 'number'
? row.music_volume
: DEFAULT_MUSIC_VOLUME,
} satisfies RuntimeSettings;
}
putSettings(userId: string, settings: RuntimeSettings) {
const nextSettings = {
musicVolume: Math.max(0, Math.min(1, settings.musicVolume)),
} satisfies RuntimeSettings;
this.db
.prepare(
`INSERT INTO runtime_settings (user_id, music_volume, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
music_volume = excluded.music_volume,
updated_at = excluded.updated_at`,
)
.run(userId, nextSettings.musicVolume, new Date().toISOString());
return nextSettings;
}
listCustomWorldProfiles(userId: string) {
const rows = this.db
.prepare(
`SELECT payload_json
FROM custom_world_profiles
WHERE user_id = ?
ORDER BY updated_at DESC
LIMIT ?`,
)
.all(userId, MAX_CUSTOM_WORLD_PROFILES) as Array<{ payload_json: string }>;
return rows.map((row) => parseJson<Record<string, unknown>>(row.payload_json));
}
upsertCustomWorldProfile(
userId: string,
profileId: string,
profile: Record<string, unknown>,
) {
const payload = {
...profile,
id: profileId,
};
this.db
.prepare(
`INSERT INTO custom_world_profiles (user_id, profile_id, payload_json, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(user_id, profile_id) DO UPDATE SET
payload_json = excluded.payload_json,
updated_at = excluded.updated_at`,
)
.run(userId, profileId, JSON.stringify(payload), new Date().toISOString());
return this.listCustomWorldProfiles(userId);
}
deleteCustomWorldProfile(userId: string, profileId: string) {
this.db
.prepare(
`DELETE FROM custom_world_profiles
WHERE user_id = ? AND profile_id = ?`,
)
.run(userId, profileId);
return this.listCustomWorldProfiles(userId);
}
}

View File

@@ -0,0 +1,88 @@
import crypto from 'node:crypto';
import type { AppDatabase } from '../db.js';
export type UserRecord = {
id: string;
username: string;
passwordHash: string;
tokenVersion: number;
createdAt: string;
updatedAt: string;
};
type UserRow = {
id: string;
username: string;
password_hash: string;
token_version: number;
created_at: string;
updated_at: string;
};
function toUserRecord(row: UserRow | undefined): UserRecord | null {
if (!row) {
return null;
}
return {
id: row.id,
username: row.username,
passwordHash: row.password_hash,
tokenVersion: row.token_version,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export class UserRepository {
constructor(private readonly db: AppDatabase) {}
findByUsername(username: string) {
const row = this.db
.prepare(
`SELECT id, username, password_hash, token_version, created_at, updated_at
FROM users
WHERE username = ?`,
)
.get(username) as UserRow | undefined;
return toUserRecord(row);
}
findById(userId: string) {
const row = this.db
.prepare(
`SELECT id, username, password_hash, token_version, created_at, updated_at
FROM users
WHERE id = ?`,
)
.get(userId) as UserRow | undefined;
return toUserRecord(row);
}
create(username: string, passwordHash: string) {
const now = new Date().toISOString();
const id = `user_${crypto.randomBytes(16).toString('hex')}`;
this.db
.prepare(
`INSERT INTO users (id, username, password_hash, token_version, created_at, updated_at)
VALUES (?, ?, ?, 1, ?, ?)`,
)
.run(id, username, passwordHash, now, now);
return this.findById(id);
}
incrementTokenVersion(userId: string) {
this.db
.prepare(
`UPDATE users
SET token_version = token_version + 1, updated_at = ?
WHERE id = ?`,
)
.run(new Date().toISOString(), userId);
return this.findById(userId);
}
}

View File

@@ -0,0 +1,53 @@
import { Router } from 'express';
import { z } from 'zod';
import { entryWithPassword, logoutUser } from '../auth/authService.js';
import type { AppContext } from '../context.js';
import { asyncHandler } from '../http.js';
import { requireJwtAuth } from '../middleware/auth.js';
const authEntrySchema = z.object({
username: z.string(),
password: z.string(),
});
export function createAuthRoutes(context: AppContext) {
const router = Router();
const requireAuth = requireJwtAuth(context.config, context.userRepository);
router.post(
'/entry',
asyncHandler(async (request, response) => {
const payload = authEntrySchema.parse(request.body);
response.json(
await entryWithPassword(context, payload.username, payload.password),
);
}),
);
router.get(
'/me',
requireAuth,
asyncHandler(async (request, response) => {
const user = context.userRepository.findById(request.userId!);
response.json({
user: user
? {
id: user.id,
username: user.username,
}
: null,
});
}),
);
router.post(
'/logout',
requireAuth,
asyncHandler(async (request, response) => {
response.json(await logoutUser(context, request.userId!));
}),
);
return router;
}

View File

@@ -0,0 +1,386 @@
import { Router } from 'express';
import { z } from 'zod';
import type { GameState } from '../../../src/types/game.js';
import type {
RuntimeItemGenerationContext,
RuntimeItemPlan,
} from '../../../src/types/runtimeItem.js';
import type { Encounter } from '../../../src/types/scene.js';
import type { AppContext } from '../context.js';
import { badRequest, notFound } from '../errors.js';
import { asyncHandler, jsonClone } from '../http.js';
import { requireJwtAuth } from '../middleware/auth.js';
import { plainTextRequestSchema } from '../services/chatService.js';
import { generateCustomWorldProfile } from '../services/customWorldGenerationService.js';
import { generateQuestForNpcEncounter } from '../services/questService.js';
import { generateRuntimeItemIntents } from '../services/runtimeItemService.js';
import { generateSceneImage, sceneImageSchema } from '../services/sceneImageService.js';
import {
generateHighQualityInitialStory,
generateHighQualityNextStory,
parseStoryRequest,
} from '../services/storyService.js';
const saveSnapshotSchema = z.object({
gameState: z.unknown(),
bottomTab: z.string().trim().min(1),
currentStory: z.unknown().nullable().optional().default(null),
savedAt: z.string().trim().optional().default(''),
});
const settingsSchema = z.object({
musicVolume: z.number().min(0).max(1),
});
const customWorldProfileSchema = z.object({
profile: z.record(z.string(), z.unknown()),
});
const customWorldSessionSchema = z.object({
settingText: z.string().trim().min(1),
creatorIntent: z.record(z.string(), z.unknown()).nullable().optional().default(null),
generationMode: z.enum(['fast', 'full']).default('fast'),
});
const customWorldAnswerSchema = z.object({
questionId: z.string().trim().min(1),
answer: z.string().trim().min(1),
});
const runtimeItemIntentSchema = z.object({
context: z.custom<RuntimeItemGenerationContext>(),
plans: z.array(z.custom<RuntimeItemPlan>()),
});
const questGenerationSchema = z.object({
state: z.custom<GameState>(),
encounter: z.custom<Encounter>(),
});
const llmProxySchema = z.record(z.string(), z.unknown());
function readParam(param: string | string[] | undefined) {
return Array.isArray(param) ? param[0]?.trim() || '' : param?.trim() || '';
}
export function createRuntimeRoutes(context: AppContext) {
const router = Router();
const requireAuth = requireJwtAuth(context.config, context.userRepository);
router.use(requireAuth);
router.post(
'/llm/chat/completions',
asyncHandler(async (request, response) => {
const body = llmProxySchema.parse(request.body);
await context.llmClient.forwardCompletion(body, response);
}),
);
router.post(
'/custom-world/scene-image',
asyncHandler(async (request, response) => {
const payload = sceneImageSchema.parse(request.body);
response.json(await generateSceneImage(context, payload));
}),
);
router.get(
'/runtime/save/snapshot',
asyncHandler(async (request, response) => {
response.json(context.runtimeRepository.getSnapshot(request.userId!) ?? null);
}),
);
router.put(
'/runtime/save/snapshot',
asyncHandler(async (request, response) => {
const payload = saveSnapshotSchema.parse(request.body);
response.json(
context.runtimeRepository.putSnapshot(request.userId!, {
savedAt: payload.savedAt || new Date().toISOString(),
gameState: payload.gameState,
bottomTab: payload.bottomTab,
currentStory: payload.currentStory ?? null,
}),
);
}),
);
router.delete(
'/runtime/save/snapshot',
asyncHandler(async (request, response) => {
context.runtimeRepository.deleteSnapshot(request.userId!);
response.json({ ok: true });
}),
);
router.get(
'/runtime/settings',
asyncHandler(async (request, response) => {
response.json(context.runtimeRepository.getSettings(request.userId!));
}),
);
router.put(
'/runtime/settings',
asyncHandler(async (request, response) => {
const payload = settingsSchema.parse(request.body);
response.json(context.runtimeRepository.putSettings(request.userId!, payload));
}),
);
router.get(
'/runtime/custom-world-library',
asyncHandler(async (request, response) => {
response.json({
profiles: context.runtimeRepository.listCustomWorldProfiles(request.userId!),
});
}),
);
router.put(
'/runtime/custom-world-library/:profileId',
asyncHandler(async (request, response) => {
const profileId = readParam(request.params.profileId);
if (!profileId) {
throw badRequest('profileId is required');
}
const payload = customWorldProfileSchema.parse(request.body);
response.json({
profiles: context.runtimeRepository.upsertCustomWorldProfile(
request.userId!,
profileId,
jsonClone(payload.profile),
),
});
}),
);
router.delete(
'/runtime/custom-world-library/:profileId',
asyncHandler(async (request, response) => {
const profileId = readParam(request.params.profileId);
if (!profileId) {
throw badRequest('profileId is required');
}
response.json({
profiles: context.runtimeRepository.deleteCustomWorldProfile(
request.userId!,
profileId,
),
});
}),
);
router.post(
'/runtime/story/initial',
asyncHandler(async (request, response) => {
const payload = parseStoryRequest(request.body);
response.json(await generateHighQualityInitialStory(payload));
}),
);
router.post(
'/runtime/story/continue',
asyncHandler(async (request, response) => {
const payload = parseStoryRequest(request.body);
response.json(await generateHighQualityNextStory(payload));
}),
);
router.post(
'/runtime/chat/character/suggestions',
asyncHandler(async (request, response) => {
const payload = plainTextRequestSchema.parse(request.body);
response.json({
text: await context.llmClient.requestMessageContent(payload),
});
}),
);
router.post(
'/runtime/chat/character/summary',
asyncHandler(async (request, response) => {
const payload = plainTextRequestSchema.parse(request.body);
response.json({
text: await context.llmClient.requestMessageContent(payload),
});
}),
);
router.post(
'/runtime/chat/character/reply/stream',
asyncHandler(async (request, response) => {
const payload = plainTextRequestSchema.parse(request.body);
await context.llmClient.forwardSseText({
...payload,
response,
});
}),
);
router.post(
'/runtime/chat/npc/dialogue/stream',
asyncHandler(async (request, response) => {
const payload = plainTextRequestSchema.parse(request.body);
await context.llmClient.forwardSseText({
...payload,
response,
});
}),
);
router.post(
'/runtime/chat/npc/recruit/stream',
asyncHandler(async (request, response) => {
const payload = plainTextRequestSchema.parse(request.body);
await context.llmClient.forwardSseText({
...payload,
response,
});
}),
);
router.post(
'/runtime/custom-world/sessions',
asyncHandler(async (request, response) => {
const payload = customWorldSessionSchema.parse(request.body);
response.json(
context.customWorldSessions.create(
request.userId!,
payload.settingText,
payload.creatorIntent,
payload.generationMode,
),
);
}),
);
router.get(
'/runtime/custom-world/sessions/:sessionId',
asyncHandler(async (request, response) => {
const session = context.customWorldSessions.get(
request.userId!,
readParam(request.params.sessionId),
);
if (!session) {
throw notFound('custom world session not found');
}
response.json(session);
}),
);
router.post(
'/runtime/custom-world/sessions/:sessionId/answers',
asyncHandler(async (request, response) => {
const payload = customWorldAnswerSchema.parse(request.body);
const session = context.customWorldSessions.answer(
request.userId!,
readParam(request.params.sessionId),
payload.questionId,
payload.answer,
);
if (!session) {
throw notFound('custom world session not found');
}
response.json(session);
}),
);
router.get(
'/runtime/custom-world/sessions/:sessionId/generate/stream',
asyncHandler(async (request, response) => {
const session = context.customWorldSessions.get(
request.userId!,
readParam(request.params.sessionId),
);
if (!session) {
throw notFound('custom world session not found');
}
response.status(200);
response.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
response.setHeader('Cache-Control', 'no-cache');
response.setHeader('Connection', 'keep-alive');
response.setHeader('X-Accel-Buffering', 'no');
const controller = new AbortController();
request.on('close', () => {
controller.abort();
});
const writeEvent = (event: string, payload: Record<string, unknown>) => {
response.write(`event: ${event}\n`);
response.write(`data: ${JSON.stringify(payload)}\n\n`);
};
writeEvent('progress', { phase: 'preparing', progress: 10 });
context.customWorldSessions.updateStatus(
request.userId!,
readParam(request.params.sessionId),
'generating',
);
writeEvent('progress', { phase: 'requesting_llm', progress: 45 });
try {
const profile = await generateCustomWorldProfile(context, session, {
signal: controller.signal,
onProgress: (progress) => {
writeEvent('progress', progress as unknown as Record<string, unknown>);
},
});
context.customWorldSessions.setResult(
request.userId!,
readParam(request.params.sessionId),
profile,
);
writeEvent('progress', { phase: 'completed', progress: 100 });
writeEvent('result', { profile });
writeEvent('done', { ok: true });
} catch (error) {
const message =
error instanceof Error ? error.message : 'custom world generation failed';
context.customWorldSessions.updateStatus(
request.userId!,
readParam(request.params.sessionId),
'generation_error',
message,
);
writeEvent('error', { message });
} finally {
response.end();
}
}),
);
router.post(
'/runtime/items/runtime-intent',
asyncHandler(async (request, response) => {
const payload = runtimeItemIntentSchema.parse(request.body);
response.json({
intents: await generateRuntimeItemIntents(context.llmClient, payload),
});
}),
);
router.post(
'/runtime/quests/generate',
asyncHandler(async (request, response) => {
const payload = questGenerationSchema.parse(request.body);
response.json(
await generateQuestForNpcEncounter(context.llmClient, payload),
);
}),
);
router.get('/ws/health', (_request, response) => {
response.json({
ok: true,
message: 'websocket routes reserved for future real-time support',
});
});
return router;
}

93
server-node/src/server.ts Normal file
View File

@@ -0,0 +1,93 @@
import { pathToFileURL } from 'node:url';
import { createApp } from './app.js';
import { type AppConfig,loadConfig } from './config.js';
import type { AppContext } from './context.js';
import { createDatabase } from './db.js';
import { createLogger } from './logging.js';
import { RuntimeRepository } from './repositories/runtimeRepository.js';
import { UserRepository } from './repositories/userRepository.js';
import { CustomWorldSessionStore } from './services/customWorldSessionStore.js';
import { UpstreamLlmClient } from './services/llmClient.js';
function resolveListenTarget(serverAddr: string) {
const trimmed = serverAddr.trim();
if (!trimmed) {
return { host: '0.0.0.0', port: 8081 };
}
if (trimmed.startsWith(':')) {
return {
host: '0.0.0.0',
port: Number(trimmed.slice(1)),
};
}
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
const url = new URL(trimmed);
return {
host: url.hostname,
port: Number(url.port || 80),
};
}
if (trimmed.includes(':')) {
const [host, portText] = trimmed.split(':');
return {
host: host || '0.0.0.0',
port: Number(portText),
};
}
return {
host: '0.0.0.0',
port: Number(trimmed),
};
}
export function createAppContext(config: AppConfig = loadConfig()) {
const logger = createLogger(config);
const db = createDatabase(config);
const context: AppContext = {
config,
logger,
db,
userRepository: new UserRepository(db),
runtimeRepository: new RuntimeRepository(db),
llmClient: new UpstreamLlmClient(config, logger),
customWorldSessions: new CustomWorldSessionStore(),
};
return context;
}
async function main() {
const context = createAppContext();
const app = createApp(context);
const { host, port } = resolveListenTarget(context.config.serverAddr);
const server = app.listen(port, host, () => {
context.logger.info(
{
host,
port,
sqlite_path: context.config.sqlitePath,
},
'server-node started',
);
});
const shutdown = () => {
context.logger.info('server-node shutting down');
server.close(() => {
context.db.close();
process.exit(0);
});
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
}
const isEntryPoint =
typeof process.argv[1] === 'string' &&
import.meta.url === pathToFileURL(process.argv[1]).href;
if (isEntryPoint) {
void main();
}

View File

@@ -0,0 +1,6 @@
import { z } from 'zod';
export const plainTextRequestSchema = z.object({
systemPrompt: z.string().trim().min(1),
userPrompt: z.string().trim().min(1),
});

View File

@@ -0,0 +1,29 @@
import {
type CustomWorldGenerationProgress,
generateCustomWorldProfile as generateCustomWorldProfileFromAi,
type GenerateCustomWorldProfileInput,
} from '../../../src/services/ai.js';
import type { AppContext } from '../context.js';
import type { CustomWorldSession } from './customWorldSessionStore.js';
export async function generateCustomWorldProfile(
_context: AppContext,
session: CustomWorldSession,
options: {
onProgress?: (progress: CustomWorldGenerationProgress) => void;
signal?: AbortSignal;
} = {},
) {
const input = {
settingText: session.settingText,
creatorIntent: session.creatorIntent,
generationMode: session.generationMode,
} satisfies GenerateCustomWorldProfileInput;
const profile = await generateCustomWorldProfileFromAi(input, {
onProgress: options.onProgress,
signal: options.signal,
});
return JSON.parse(JSON.stringify(profile)) as Record<string, unknown>;
}

View File

@@ -0,0 +1,174 @@
import crypto from 'node:crypto';
export type CustomWorldSessionStatus =
| 'clarifying'
| 'ready_to_generate'
| 'generating'
| 'completed'
| 'generation_error';
export type CustomWorldQuestion = {
id: string;
label: string;
question: string;
answer?: string;
};
export type CustomWorldSession = {
sessionId: string;
userId: string;
status: CustomWorldSessionStatus;
settingText: string;
creatorIntent: Record<string, unknown> | null;
generationMode: 'fast' | 'full';
questions: CustomWorldQuestion[];
result?: Record<string, unknown>;
lastError?: string;
createdAt: string;
updatedAt: string;
};
function cloneSession(session: CustomWorldSession) {
return JSON.parse(JSON.stringify(session)) as CustomWorldSession;
}
function hasPendingQuestion(questions: CustomWorldQuestion[]) {
return questions.some((question) => !question.answer?.trim());
}
function buildClarificationQuestions(
settingText: string,
creatorIntent: Record<string, unknown> | null,
) {
const questions: CustomWorldQuestion[] = [];
const worldHook =
typeof creatorIntent?.worldHook === 'string' ? creatorIntent.worldHook.trim() : '';
const playerPremise =
typeof creatorIntent?.playerPremise === 'string' ? creatorIntent.playerPremise.trim() : '';
const openingSituation =
typeof creatorIntent?.openingSituation === 'string'
? creatorIntent.openingSituation.trim()
: '';
const coreConflicts = Array.isArray(creatorIntent?.coreConflicts)
? creatorIntent.coreConflicts
: [];
if (!worldHook && settingText.trim().length < 24) {
questions.push({
id: 'world_hook',
label: '世界核心',
question: '请用一句话补充这个世界最核心的命题或独特卖点。',
});
}
if (!playerPremise) {
questions.push({
id: 'player_premise',
label: '玩家身份',
question: '玩家在这个世界里是什么身份、立场或来历?',
});
}
if (!openingSituation) {
questions.push({
id: 'opening_situation',
label: '开局处境',
question: '故事开局时,玩家正处于什么局面?',
});
}
if (coreConflicts.length === 0) {
questions.push({
id: 'core_conflict',
label: '核心冲突',
question: '这个世界当前最核心的冲突、危机或悬念是什么?',
});
}
return questions;
}
export class CustomWorldSessionStore {
private readonly sessions = new Map<string, Map<string, CustomWorldSession>>();
create(
userId: string,
settingText: string,
creatorIntent: Record<string, unknown> | null,
generationMode: 'fast' | 'full',
) {
const sessionId = `custom-world-session-${crypto.randomBytes(16).toString('hex')}`;
const now = new Date().toISOString();
const session: CustomWorldSession = {
sessionId,
userId,
status: 'ready_to_generate',
settingText,
creatorIntent,
generationMode,
questions: buildClarificationQuestions(settingText, creatorIntent),
createdAt: now,
updatedAt: now,
};
if (hasPendingQuestion(session.questions)) {
session.status = 'clarifying';
}
const userSessions = this.sessions.get(userId) ?? new Map<string, CustomWorldSession>();
userSessions.set(sessionId, session);
this.sessions.set(userId, userSessions);
return cloneSession(session);
}
get(userId: string, sessionId: string) {
const session = this.sessions.get(userId)?.get(sessionId);
return session ? cloneSession(session) : null;
}
answer(userId: string, sessionId: string, questionId: string, answer: string) {
const session = this.sessions.get(userId)?.get(sessionId);
if (!session) {
return null;
}
const question = session.questions.find((item) => item.id === questionId);
if (!question) {
return null;
}
question.answer = answer;
session.status = hasPendingQuestion(session.questions)
? 'clarifying'
: 'ready_to_generate';
session.updatedAt = new Date().toISOString();
return cloneSession(session);
}
updateStatus(
userId: string,
sessionId: string,
status: CustomWorldSessionStatus,
lastError = '',
) {
const session = this.sessions.get(userId)?.get(sessionId);
if (!session) {
return null;
}
session.status = status;
session.lastError = lastError || undefined;
session.updatedAt = new Date().toISOString();
return cloneSession(session);
}
setResult(userId: string, sessionId: string, result: Record<string, unknown>) {
const session = this.sessions.get(userId)?.get(sessionId);
if (!session) {
return null;
}
session.status = 'completed';
session.lastError = undefined;
session.result = JSON.parse(JSON.stringify(result)) as Record<string, unknown>;
session.updatedAt = new Date().toISOString();
return cloneSession(session);
}
}

Some files were not shown because too many files have changed in this diff Show More