This commit is contained in:
2026-04-18 13:05:29 +08:00
parent 09d4c0c31b
commit 5032701c38
77 changed files with 8538 additions and 2413 deletions

3
.gitignore vendored
View File

@@ -11,3 +11,6 @@ temp*build*/
!/server-node/logs/.gitkeep !/server-node/logs/.gitkeep
/server-node/data/* /server-node/data/*
!/server-node/data/.gitkeep !/server-node/data/.gitkeep
/public/generated-animations
/public/generated-character-drafts
/public/generated-characters

View File

@@ -1,6 +1,6 @@
# AI 原生 Agent-First 八锚点共创流程 PRD # AI 原生 Agent-First 八锚点共创流程 PRD
更新时间:`2026-04-16` 更新时间:`2026-04-17`
## 0. 文档目的 ## 0. 文档目的
@@ -23,7 +23,7 @@
## 1.1 一句话定义 ## 1.1 一句话定义
让玩家通过与一个懂 RPG 剧情策划方法的 Agent 对话,在自然聊天中逐步明确作品方向、玩家视角、剧情发动机和世界统一母题;同时由 Express 后端把这些聊天沉淀成结构化八锚点状态,并支持确认、锁定、补缺和进入后续世界底稿生成。 让玩家通过与一个懂 RPG 剧情策划方法的 Agent 对话,在自然聊天中逐步明确作品方向、玩家视角、剧情发动机和世界统一母题;同时由 Express 后端把这些聊天沉淀成结构化八锚点状态,并支持确认、锁定、补缺、真实进度反馈和进入后续世界底稿生成。
## 1.2 产品定位 ## 1.2 产品定位
@@ -33,7 +33,7 @@
它应当是: 它应当是:
**一个会启发玩家表达、会主动总结当前理解、会识别缺口并只追问关键问题、最终把共创结果沉淀成结构化创作锚点的 Agent 共创流程。** **一个会启发玩家表达、会主动总结当前理解、会识别缺口并只追问一个高杠杆问题、最终把共创结果沉淀成结构化创作锚点的 Agent 共创流程。**
## 1.3 目标用户 ## 1.3 目标用户
@@ -50,6 +50,7 @@
- Agent 是否会少问废话 - Agent 是否会少问废话
- 摘要是否准确 - 摘要是否准确
- 锚点是否可编辑、可锁定、可回看 - 锚点是否可编辑、可锁定、可回看
- 进度条是否真实
## 1.4 成功标准 ## 1.4 成功标准
@@ -61,6 +62,9 @@
4. 玩家在任意时刻都能看懂“现在这个世界已经定了什么、还有什么没定、Agent 正在为什么追问”。 4. 玩家在任意时刻都能看懂“现在这个世界已经定了什么、还有什么没定、Agent 正在为什么追问”。
5. 当前锚点状态能直接进入下一阶段,生成世界底稿、关键角色、关键地点和主线第一幕。 5. 当前锚点状态能直接进入下一阶段,生成世界底稿、关键角色、关键地点和主线第一幕。
6. 所有锚点状态更新、确认、锁定、冲突判断和完成度裁决都在 Express 后端完成,前端只负责表现和输入。 6. 所有锚点状态更新、确认、锁定、冲突判断和完成度裁决都在 Express 后端完成,前端只负责表现和输入。
7. 八锚点阶段的平均问答轮次控制在 `15` 轮左右。
8. Agent 每轮只问 `1` 个主问题。
9. 进度区不再显示抽象阶段说明,而是显示基于真实完成度的弹性进度条。
## 1.5 本次不做什么 ## 1.5 本次不做什么
@@ -80,7 +84,7 @@
现有文档已经证明,“最小锚点 + AI 初稿卡 + 系统托管层”方向是对的。 现有文档已经证明,“最小锚点 + AI 初稿卡 + 系统托管层”方向是对的。
但如果直接把锚点做成显式卡片或显式问题列表,会出现 4 个体验问题: 但如果直接把锚点做成显式卡片或显式问题列表,会出现 5 个体验问题:
1. 玩家会有表单焦虑 1. 玩家会有表单焦虑
- 明明只是有一个灵感,却像在填写策划需求单 - 明明只是有一个灵感,却像在填写策划需求单
@@ -94,6 +98,9 @@
4. 锚点层级混乱时,玩家会觉得问题很多但抓不住重点 4. 锚点层级混乱时,玩家会觉得问题很多但抓不住重点
- 不知道先定体验,还是先定设定,还是先定剧情 - 不知道先定体验,还是先定设定,还是先定剧情
5. 当前“阶段提示”过于抽象
- 玩家看不到真实完成度,也不知道为什么还没结束
## 2.2 新方案的核心判断 ## 2.2 新方案的核心判断
更合理的方式不是让玩家“填写八个锚点”,而是让 Agent 围绕八个锚点做 3 件事: 更合理的方式不是让玩家“填写八个锚点”,而是让 Agent 围绕八个锚点做 3 件事:
@@ -105,7 +112,7 @@
- 把玩家已经表达的内容收束成清晰锚点 - 把玩家已经表达的内容收束成清晰锚点
3. 补缺 3. 补缺
- 只追问当前最影响后续生成质量的缺口 - 只追问当前最影响后续生成质量的一个缺口
也就是说: 也就是说:
@@ -183,6 +190,7 @@
3. 每聊一两轮,我都能明显看到这个世界变得更成形。 3. 每聊一两轮,我都能明显看到这个世界变得更成形。
4. 如果我一开始只说了一个模糊点子Agent 也能把我带进状态。 4. 如果我一开始只说了一个模糊点子Agent 也能把我带进状态。
5. 如果我说得已经很多Agent 不会浪费时间问明显问题。 5. 如果我说得已经很多Agent 不会浪费时间问明显问题。
6. 进度条会诚实反馈进展,而不是每轮机械上涨。
## 4.2 业务目标 ## 4.2 业务目标
@@ -200,6 +208,9 @@
4. 可控性 4. 可控性
- 锚点状态清晰、可回看、可锁定、可修改 - 锚点状态清晰、可回看、可锁定、可修改
5. 效率
- 平均轮次稳定控制在 `15` 轮左右
--- ---
## 5. 核心原则 ## 5. 核心原则
@@ -221,7 +232,7 @@
当多个锚点都不完整时Agent 不应平均追问。 当多个锚点都不完整时Agent 不应平均追问。
系统必须基于优先级只选择当前最影响后续生成质量的 `1~2` 个问题。 系统必须基于优先级只选择当前最影响后续生成质量的 `1` 个问题。
默认优先级如下: 默认优先级如下:
@@ -249,6 +260,8 @@
2. 哪些已经比较稳 2. 哪些已经比较稳
3. 接下来只差什么就能往下走 3. 接下来只差什么就能往下走
但即使在做总结的轮次里,也只能保留 `1` 个主问题,不允许总结后再追加第二个待答问题。
## 5.4 显式区分确认与推断 ## 5.4 显式区分确认与推断
Agent 可以根据玩家的话做合理推断,但不能把推断伪装成已确认事实。 Agent 可以根据玩家的话做合理推断,但不能把推断伪装成已确认事实。
@@ -273,6 +286,39 @@ Agent 可以根据玩家的话做合理推断,但不能把推断伪装成已
除非玩家主动要求。 除非玩家主动要求。
## 5.6 进度必须真实,但允许弹性跳跃
进度条不能按固定阶段平均切分,也不能做成“每聊一轮就涨一点”的假进度。
进度必须建立在真实可用信息之上:
1. 玩家一句话如果高质量覆盖多个锚点,进度可以明显跳升。
2. 玩家回答空泛、重复或自相矛盾时,进度可以停滞。
3. 如果后续出现重大冲突,进度允许小幅回退,但回退必须有明确原因。
## 5.7 轮次预算必须是产品硬约束
八锚点阶段不是无限对话。
系统必须默认带着 `平均 15 轮` 的预算意识工作,而不是等聊散了再补救。
这意味着:
1. 每轮问题都必须追求最高信息增益。
2. 一条回答应尽可能更新多个锚点。
3. 越接近预算上限,问题越要偏向“收束多个缺口”的高杠杆问法。
## 5.8 单轮只问一个主问题
为了降低回答负担和提高回答质量Agent 每轮只允许问 `1` 个主问题。
这里的“一个主问题”定义为:
1. 只允许一个明确待答槽位。
2. 不允许并列追问多个不同认知动作。
3. 不允许出现两个以上问号。
4. 可以附带示例选项,但示例不得构成新的独立问题。
--- ---
## 6. 整体流程体验 ## 6. 整体流程体验
@@ -288,6 +334,32 @@ Agent 可以根据玩家的话做合理推断,但不能把推断伪装成已
5. `共识确认` 5. `共识确认`
6. `进入世界底稿生成` 6. `进入世界底稿生成`
## 6.2 轮次预算概览
建议把八锚点阶段的平均轮次预算控制为:
1. `第 1~3 轮`
- 接住灵感并快速建立方向盘层
2. `第 4~9 轮`
- 高密度补齐剧情发动机层
3. `第 10~12 轮`
- 收束标志元素、硬规则和暗线节奏
4. `第 13~15 轮`
- 做共识确认、补最后高风险缺口、准备进入下一阶段
软上限:
- `15`
硬上限:
- `18`
如果超过 `18` 轮仍未达到可用底稿标准,系统必须触发“收束模式”,优先产出当前最好版本,而不是继续无上限追问。
--- ---
## 7. 阶段设计 ## 7. 阶段设计
@@ -326,6 +398,10 @@ Agent 可以根据玩家的话做合理推断,但不能把推断伪装成已
- 世界已经开始成形了 - 世界已经开始成形了
- 下一步很容易答 - 下一步很容易答
### 轮次预算要求
阶段 A 默认只占 `1~2` 轮。
## 7.2 阶段 B方向盘收束 ## 7.2 阶段 B方向盘收束
### 目标 ### 目标
@@ -359,6 +435,10 @@ Agent 可以根据玩家的话做合理推断,但不能把推断伪装成已
2. 玩家幻想包含 `身份或追求``失去恐惧或代价` 2. 玩家幻想包含 `身份或追求``失去恐惧或代价`
3. 主题边界至少包含 `1` 条风格方向与 `1` 条禁忌边界 3. 主题边界至少包含 `1` 条风格方向与 `1` 条禁忌边界
### 轮次预算要求
阶段 B 默认应在 `3~4` 轮内完成,不应为了把语言润色到完美而过度追问。
## 7.3 阶段 C剧情发动机补齐 ## 7.3 阶段 C剧情发动机补齐
### 目标 ### 目标
@@ -394,6 +474,12 @@ Agent 可以根据玩家的话做合理推断,但不能把推断伪装成已
3. 至少有 `2` 条关键关系骨架 3. 至少有 `2` 条关键关系骨架
4. 暗线与揭示节奏至少明确 `1` 条隐藏真相和 `1` 条延后揭示意图 4. 暗线与揭示节奏至少明确 `1` 条隐藏真相和 `1` 条延后揭示意图
### 轮次预算要求
阶段 C 是信息密度最高的阶段,默认占用 `5~6` 轮预算。
如果玩家单轮回答已经同时覆盖开场、冲突、关系和暗线,系统必须允许跳过后续冗余追问,直接推进。
## 7.4 阶段 D世界统一母题收束 ## 7.4 阶段 D世界统一母题收束
### 目标 ### 目标
@@ -416,6 +502,10 @@ Agent 可以根据玩家的话做合理推断,但不能把推断伪装成已
1. 至少确认 `2~5` 个标志元素 1. 至少确认 `2~5` 个标志元素
2. 至少确认 `1~3` 条硬规则 2. 至少确认 `1~3` 条硬规则
### 轮次预算要求
阶段 D 默认应在 `2~3` 轮内完成。
## 7.5 阶段 E共识确认 ## 7.5 阶段 E共识确认
### 目标 ### 目标
@@ -442,6 +532,12 @@ Agent 必须输出一份结构化但仍然易读的摘要,分成三类:
2. 锁定部分锚点后进入下一阶段 2. 锁定部分锚点后进入下一阶段
3. 指定某个锚点继续精修 3. 指定某个锚点继续精修
### 轮次预算要求
阶段 E 默认只占 `1~2` 轮。
如果用户没有明确异议,系统应倾向于推进,而不是反复征求确认。
## 7.6 阶段 F进入世界底稿生成 ## 7.6 阶段 F进入世界底稿生成
### 目标 ### 目标
@@ -474,19 +570,28 @@ Agent 必须输出一份结构化但仍然易读的摘要,分成三类:
-`2~4` 条短句总结已浮现的锚点 -`2~4` 条短句总结已浮现的锚点
3. `补缺` 3. `补缺`
- 只问 `1` 个主问题,必要时附 `1` 个轻量补充问法 - 只问 `1` 个主问题
## 8.2 禁止行为 ## 8.2 单问约束
为了保证单轮只问一个问题Agent 回复生成时必须经过单问检查:
1. 回复中只允许一个主问句。
2. 若存在第二个问号,默认判定为违规。
3. “你更偏 A、B、C 还是 D”这类选项式问法视为一个问题。
4. “你更偏 A 吗?如果不是为什么”这类双槽位问法视为两个问题,禁止输出。
## 8.3 禁止行为
这一阶段禁止 Agent 出现以下回复模式: 这一阶段禁止 Agent 出现以下回复模式:
1. 连续大段夸赞,没有实质推进 1. 连续大段夸赞,没有实质推进
2. 把玩家原话换个说法重复一遍就结束 2. 把玩家原话换个说法重复一遍就结束
3. 一次抛出 `5` 个以上问题 3. 一次抛出 `2` 个以上待答槽位
4. 在锚点未稳定前自动生成成批设定 4. 在锚点未稳定前自动生成成批设定
5. 把推断写成已确认事实 5. 把推断写成已确认事实
## 8.3 提问模板原则 ## 8.4 提问模板原则
提问模板必须符合: 提问模板必须符合:
@@ -504,6 +609,90 @@ Agent 必须输出一份结构化但仍然易读的摘要,分成三类:
- 坏问题: - 坏问题:
- “请详细描述你的主题母题、叙事支柱、隐性线索分发策略与世界统一意象。” - “请详细描述你的主题母题、叙事支柱、隐性线索分发策略与世界统一意象。”
## 8.5 问题选择器
后端必须有一个明确的问题选择器,而不是把“下一问”完全交给模型自由发挥。
建议每轮按以下顺序计算候选问题:
1. 生成当前未完成锚点列表
2. 为每个锚点估算 `信息增益分`
3. 为每个锚点估算 `轮次紧迫分`
4. 为每个锚点估算 `重复惩罚分`
5. 选择总分最高的一个锚点生成问题
建议公式:
```ts
questionPriority =
informationGainWeight * infoGain
+ turnPressureWeight * turnPressure
+ stageWeight * stageUrgency
- repetitionPenaltyWeight * repetitionPenalty
- userFatigueWeight * fatiguePenalty;
```
其中:
- `infoGain`
- 回答这个问题后,理论上能补齐多少高权重字段
- `turnPressure`
- 当前剩余预算越少,越倾向选择能一次补多个缺口的问题
- `repetitionPenalty`
- 近期已经问过、或用户已经回答过相近信息时提升惩罚
- `fatiguePenalty`
- 用户最近回答很短、明显迟疑或连续改口时,惩罚高负担问法
## 8.6 轮次预算器
后端必须维护一个 `TurnBudgetController`,至少负责:
1. 记录当前已进行轮次
2. 估算剩余轮次
3. 计算当前是否需要进入收束模式
4. 给问题选择器提供预算压力信号
建议状态:
```ts
type TurnBudgetState = {
currentTurn: number;
softLimit: number; // 15
hardLimit: number; // 18
budgetPressure: number; // 0 ~ 1
mode: 'normal' | 'compress' | 'closing';
};
```
模式说明:
1. `normal`
- `1~10` 轮,允许适度启发和探索
2. `compress`
- `11~15` 轮,优先提能一次收束多个缺口的问题
3. `closing`
- `16~18` 轮,只补高风险缺口并准备确认总结
## 8.7 单轮回答利用率
为了把平均轮次压到 `15` 轮,系统不能只从玩家回答里抽取被提问的那一个锚点。
每轮用户回答都必须做全量扫描,尝试更新全部八个锚点。
例如玩家回答“我想让玩家一开始是被宗门追杀的弃徒,因为他手里有改命神器的钥匙”,系统至少应同步更新:
1. 玩家幻想
2. 玩家切入口
3. 核心冲突
4. 标志元素
而不是只更新“玩家切入口”。
--- ---
## 9. 前台交互设计 ## 9. 前台交互设计
@@ -512,21 +701,26 @@ Agent 必须输出一份结构化但仍然易读的摘要,分成三类:
沿用现有创作工作区,不新开页面。 沿用现有创作工作区,不新开页面。
只在现有 Agent 工作区中新增更明确的锚点反馈区和阶段反馈区。 只在现有 Agent 工作区中新增更明确的锚点反馈区和进度条区。
## 9.2 工作区组成 ## 9.2 工作区组成
八锚点阶段的工作区默认包含块: 八锚点阶段的工作区默认包含块:
1. `左侧或主区:聊天流` 1. `左侧或主区:聊天流`
- 玩家输入 - 玩家输入
- Agent 回复 - Agent 回复
- 阶段性总结 - 阶段性总结
2. `侧边摘要区:当前世界底子` 2. `顶部进度条区:当前完成进度`
- 弹性真实进度条
- 当前模式提示
- 剩余预算提示
3. `侧边摘要区:当前世界底子`
- 以易读摘要展示八锚点当前状态 - 以易读摘要展示八锚点当前状态
3. `底部操作区:下一步动作` 4. `底部操作区:下一步动作`
- 继续聊 - 继续聊
- 确认这一版 - 确认这一版
- 锁定当前理解 - 锁定当前理解
@@ -549,15 +743,152 @@ Agent 必须输出一份结构化但仍然易读的摘要,分成三类:
3. `待补充` 3. `待补充`
4. `已锁定` 4. `已锁定`
## 9.4 阶段提示 ## 9.4 进度条设计
工作区应始终用一句短提示明确当前阶段,例如: 原本的“阶段提示区域”改为进度条区域。
- 正在帮你收束作品方向 进度条必须满足三个要求:
- 正在补齐剧情冲突和关系发动机
- 正在确认这个世界最有记忆点的母题
禁止在 UI 上默认显示大段规则说明文字。 1. 玩家能直观看到完成度
2. 进度变化必须和真实锚点收集结果绑定
3. 玩家能理解为什么此刻涨得快、涨得慢或小幅回退
### 9.4.1 展示组成
进度条区域默认展示:
1. 一条 `0~100%` 的主进度条
2. 进度副标题
- 例如:`已完成 6/8 个锚点,正在收束核心冲突`
3. 预算提示
- 例如:`第 8 / 15 轮`
4. 模式标签
- `正常推进`
- `压缩收束`
- `准备确认`
### 9.4.2 真实进度定义
进度条不是按轮次走,而是按 `八锚点完成度加权总分` 走。
建议总分公式:
```ts
progressScore =
sum(anchorWeight[i] * anchorCompletion[i])
- contradictionPenalty
- unresolvedRiskPenalty;
```
其中:
- `anchorCompletion[i]`
- 每个锚点的完成度,取值 `0 ~ 1`
- `anchorWeight[i]`
- 每个锚点的权重
- `contradictionPenalty`
- 已发现但未解决的设定冲突惩罚
- `unresolvedRiskPenalty`
- 高风险缺口尚未补齐时的惩罚
### 9.4.3 建议权重
```ts
worldPromise: 0.16
playerFantasy: 0.14
themeBoundary: 0.10
playerEntryPoint: 0.13
coreConflict: 0.18
keyRelationships: 0.12
hiddenLines: 0.07
iconicElements: 0.10
```
解释:
1. `核心冲突``世界承诺` 权重最高
2. `玩家幻想``玩家切入口` 决定代入感,权重次高
3. `暗线与揭示节奏` 重要,但在首轮底稿阶段允许相对后置
### 9.4.4 弹性机制
进度条必须允许以下行为:
1. `跨锚点跳升`
- 玩家一条高质量回答覆盖多个锚点时,进度可一次上涨 `8%~18%`
2. `停滞`
- 用户回答没有提供新信息时,进度可保持不动
3. `轻微回退`
- 当新输入推翻已确认内容,进度允许回退 `2%~6%`
但不允许:
1. 因为一次轻微改词大幅掉进度
2. 每轮机械上涨固定百分比
3. 进入确认阶段后无意义来回跳动
### 9.4.5 玩家可见文案要求
当进度上涨时,副标题应尽量解释原因,例如:
- `玩家切入口和核心冲突已经明确,进度明显前进`
当进度停滞时:
- `目前信息更像补充语气,关键缺口还在关键关系`
当进度回退时:
- `你刚刚调整了世界气质,相关冲突需要重新确认,所以进度小幅回收`
禁止只显示空泛文案,如“处理中”“继续努力”。
## 9.5 进度条映射规则
为了避免玩家在很早时看到过低进度产生挫败感,建议采用“真实分数 + 轻度前期提振”的映射。
内部真实分:
```ts
rawProgress = clamp(progressScore, 0, 1);
```
前台显示分:
```ts
displayProgress =
rawProgress < 0.2
? rawProgress * 1.25
: rawProgress < 0.8
? rawProgress * 1.05
: rawProgress;
```
要求:
1. 显示分不得虚高到越过真实完成区间含义
2. `85%` 以上必须基本意味着已接近可确认状态
3. `100%` 只在进入 `ready` 后显示
## 9.6 预算提示规则
预算提示必须与轮次预算器联动:
1. `1~10` 轮显示:
- `正在铺底,别急着一次想全`
2. `11~15` 轮显示:
- `开始收束高杠杆设定`
3. `16~18` 轮显示:
- `准备用当前最好版本进入底稿`
禁止显示“剩余 3 次提问机会”这类压迫感过强的说法。
--- ---
@@ -573,6 +904,8 @@ Express 后端必须作为唯一真实状态源,负责:
4. 生成下一轮提问建议 4. 生成下一轮提问建议
5. 生成阶段性摘要 5. 生成阶段性摘要
6. 判断是否进入下一阶段 6. 判断是否进入下一阶段
7. 计算真实进度与显示进度
8. 管理轮次预算
## 10.2 结构化状态模型 ## 10.2 结构化状态模型
@@ -585,9 +918,12 @@ type AnchorField<T> = {
value: T | null; value: T | null;
status: AnchorStatus; status: AnchorStatus;
confidence: number; confidence: number;
completionScore: number;
sourceMessageIds: string[]; sourceMessageIds: string[];
summary: string; summary: string;
openQuestions: string[]; openQuestions: string[];
lastUpdatedAt: string;
conflictFlags: string[];
}; };
type EightAnchorDraft = { type EightAnchorDraft = {
@@ -600,6 +936,17 @@ type EightAnchorDraft = {
hiddenLines: AnchorField<HiddenLineValue>; hiddenLines: AnchorField<HiddenLineValue>;
iconicElements: AnchorField<IconicElementValue>; iconicElements: AnchorField<IconicElementValue>;
phase: 'spark' | 'direction' | 'engine' | 'motif' | 'review' | 'ready'; phase: 'spark' | 'direction' | 'engine' | 'motif' | 'review' | 'ready';
progress: {
rawScore: number;
displayScore: number;
completedAnchorCount: number;
contradictionPenalty: number;
unresolvedRiskPenalty: number;
mode: 'normal' | 'compress' | 'closing';
currentTurn: number;
softLimit: number;
hardLimit: number;
};
readyForFoundationDraft: boolean; readyForFoundationDraft: boolean;
}; };
``` ```
@@ -662,11 +1009,13 @@ type EightAnchorDraft = {
2. 判断新增内容是确认、补充还是冲突 2. 判断新增内容是确认、补充还是冲突
3. 生成新的锚点摘要 3. 生成新的锚点摘要
4. 重新计算缺口优先级 4. 重新计算缺口优先级
5. 产出下一轮 Agent 回复所需的: 5. 重新计算进度条
6. 产出下一轮 Agent 回复所需的:
- 当前理解 - 当前理解
- 待补问题 - 待补问题
- 禁止重复问的问题 - 禁止重复问的问题
- 推荐阶段标签 - 推荐进度条文案
- 当前预算模式
## 10.5 冲突处理 ## 10.5 冲突处理
@@ -676,6 +1025,53 @@ type EightAnchorDraft = {
- “你前面更像想做冷硬末日,现在这轮开始偏浪漫奇谭了,我先不自动改,想确认你是准备转方向,还是只想让其中一条支线更柔一点?” - “你前面更像想做冷硬末日,现在这轮开始偏浪漫奇谭了,我先不自动改,想确认你是准备转方向,还是只想让其中一条支线更柔一点?”
## 10.6 进度计算器
后端必须实现 `ProgressEvaluator`,用于从结构化锚点状态生成真实进度。
建议输入:
1. 八锚点字段完成度
2. 字段置信度
3. 锚点状态
4. 未解决冲突
5. 当前轮次
建议输出:
```ts
type ProgressEvaluation = {
rawScore: number;
displayScore: number;
completedAnchorCount: number;
gainReason: string | null;
stallReason: string | null;
fallbackReason: string | null;
};
```
## 10.7 问题与进度联动
每次提问前,后端必须先检查:
1. 当前进度停滞的主因是什么
2. 哪个问题最可能让进度跨过下一关键阈值
3. 该问题是否会造成过高认知负担
关键阈值建议为:
1. `25%`
- 方向盘初步成型
2. `50%`
- 玩家已能看懂这世界大致怎么玩
3. `75%`
- 八锚点已具备可生成底稿的主体骨架
4. `90%`
- 仅剩低风险补强或确认
--- ---
## 11. 完成度判断 ## 11. 完成度判断
@@ -691,6 +1087,23 @@ type EightAnchorDraft = {
- 世界承诺只有“修仙世界”不能算完成 - 世界承诺只有“修仙世界”不能算完成
- 世界承诺如果是“一个靠借寿续命维持秩序的仙朝里,玩家要在飞升诱惑和众生寿债之间做选择”,则可判定为高完成度 - 世界承诺如果是“一个靠借寿续命维持秩序的仙朝里,玩家要在飞升诱惑和众生寿债之间做选择”,则可判定为高完成度
建议完成度分级:
1. `0.0 ~ 0.24`
- 只有题材词,没有可执行信息
2. `0.25 ~ 0.49`
- 已有方向,但缺少差异点或关键约束
3. `0.50 ~ 0.74`
- 已可用于底稿生成,但仍有明显模糊区
4. `0.75 ~ 0.89`
- 高质量可用
5. `0.90 ~ 1.0`
- 已确认或已锁定,且可稳定约束后续生成
## 11.2 阶段完成判定 ## 11.2 阶段完成判定
系统不要求八个锚点都达到满分才允许进入下一阶段。 系统不要求八个锚点都达到满分才允许进入下一阶段。
@@ -705,6 +1118,32 @@ type EightAnchorDraft = {
满足以上条件即可进入 `ready` 满足以上条件即可进入 `ready`
## 11.3 轮次达标判定
上线后的核心效率指标必须包含:
1. 平均轮次 `<= 15`
2. `P75` 轮次 `<= 18`
3. 单轮双问违规率 `< 1%`
4. 重复问题率 `< 8%`
## 11.4 收束模式
当出现以下任一情况时,系统进入收束模式:
1. 当前轮次 `> 15`
2. 连续 `2` 轮进度增长 `< 2%`
3. 用户明显疲劳
- 极短回答
- 连续说“你帮我定”
- 明显不想继续细抠
进入收束模式后:
1. 只追问能一问收束多个缺口的问题
2. 优先确认而不是继续发散
3. 必要时允许以“高置信推断 + 玩家确认”完成低风险锚点
--- ---
## 12. 示例体验脚本 ## 12. 示例体验脚本
@@ -776,6 +1215,8 @@ Agent 不应回复成八问表:
- 继续打磨 - 继续打磨
- 锁定并继续 - 锁定并继续
- 放弃 - 放弃
6. 单轮是否违规出现多个问题
7. 进度条上涨、停滞、回退的次数与原因
## 14.2 质量评估指标 ## 14.2 质量评估指标
@@ -789,56 +1230,246 @@ Agent 不应回复成八问表:
--- ---
## 15. 实现拆分建议 ## 15. Harness 机制
## 15.1 第一阶段 ## 15.1 目标
这套流程必须有专门的 harness而不是只靠人工体验几轮聊天判断好不好。
harness 的目标是稳定评测 4 类问题:
1. 是否真的做到了单轮只问一个问题
2. 是否真的能把平均轮次压到 `15` 轮左右
3. 进度条是否真实反映锚点完成度,而不是假涨
4. 八锚点提取、总结、追问是否真的有效
## 15.2 总体结构
建议 harness 分成三层:
1. `回放层`
- 用固定用户画像和固定答案回放会话
2. `裁判层`
- 对每轮 Agent 输出做结构检查和质量打分
3. `报表层`
- 输出轮次、进度、锚点覆盖率、违规率等指标
## 15.3 用例集设计
至少维护以下 8 类标准用例:
1. `一句话灵感型`
- 用户一开始只给一个模糊钩子
2. `高密度设定型`
- 用户首轮就给出大量设定
3. `犹豫改口型`
- 用户中途多次修改方向
4. `高配合短答型`
- 用户每轮只回一句,但都直击重点
5. `低配合泛答型`
- 用户经常回答很虚
6. `强审美主导型`
- 用户更在乎气质,不擅长冲突设计
7. `强剧情主导型`
- 用户冲突很强,但世界母题薄弱
8. `让 Agent 代定型`
- 用户频繁说“你帮我想”
## 15.4 每个 harness 用例的输入结构
建议每个用例包含:
```ts
type EightAnchorHarnessCase = {
id: string;
title: string;
userProfile: string;
hiddenGroundTruth: {
desiredAnchors: Partial<EightAnchorDraft>;
mustAskTopics: string[];
forbiddenAssumptions: string[];
};
scriptedUserPolicy: {
responseStyle: 'short' | 'rich' | 'hesitant' | 'contradictory';
allowAgentInferenceLevel: 'low' | 'medium' | 'high';
maxTurnsBeforeFatigue: number;
};
scriptedTurns: Array<{
whenAgentAsksAbout?: string[];
userReply: string;
}>;
};
```
## 15.5 裁判规则
每轮会话结束后,裁判层至少检查:
1. `单问检查`
- 是否只有一个主问题
2. `重复问题检查`
- 是否反复追问用户已高置信回答过的内容
3. `提取准确率检查`
- 本轮从用户回答中提取的锚点是否合理
4. `进度真实性检查`
- 进度上涨是否对应真实信息增量
5. `轮次预算检查`
- 当前策略是否仍符合 `15` 轮平均目标
6. `收束能力检查`
- 接近预算上限时是否切换到更高杠杆问法
## 15.6 单问检查器
单问检查器必须是硬规则,不只靠 LLM 主观判断。
建议组合以下方法:
1. 文本规则检查
- 问号数量
- 疑问词数量
- 是否存在并列问句连接词
2. 结构化输出检查
- Agent 回复生成时同步输出 `questionSlotCount`
3. 裁判模型复核
- 判断是否存在多个待答槽位
判定原则:
1. 任一检查器判定为多问,则记违规
2. harness 报表必须输出具体违规回复文本
## 15.7 进度真实性检查器
进度真实性检查器应对每轮打出:
1. `expectedProgressDelta`
- 基于真实新增信息量推测的合理涨幅
2. `actualProgressDelta`
- 系统实际涨幅
3. `deltaGap`
- 两者差值
若出现以下情况则记风险:
1. 用户几乎没提供新信息,但进度上涨 `> 6%`
2. 用户一轮补齐多个高权重锚点,但进度上涨 `< 3%`
3. 发生重大冲突却没有小幅回退
## 15.8 轮次预算检查器
轮次预算检查器至少输出:
1. 总轮次
2. 到达 `50% / 75% / ready` 所用轮次
3. 各阶段平均用时
4. 是否超过软上限
5. 是否超过硬上限
## 15.9 报表输出
每次 harness 跑完,至少输出以下报表字段:
```ts
type EightAnchorHarnessReport = {
caseId: string;
totalTurns: number;
reachedReady: boolean;
finalProgress: number;
averageProgressGainPerTurn: number;
multiQuestionViolationCount: number;
repeatedQuestionCount: number;
extractionAccuracyScore: number;
progressTruthfulnessScore: number;
anchorCoverageScore: number;
closingEfficiencyScore: number;
notes: string[];
};
```
## 15.10 发布门禁
八锚点流程上线前harness 至少要满足:
1. 标准用例集平均轮次 `<= 15`
2. `P75` 总轮次 `<= 18`
3. 单问违规率 `< 1%`
4. 进度真实性得分 `>= 0.85`
5. 锚点覆盖率得分 `>= 0.9`
6. 高密度设定型用例在 `8` 轮内能达到 `75%` 进度
7. 低配合泛答型用例也能在 `18` 轮内进入可确认状态或可用收束状态
---
## 16. 实现拆分建议
## 16.1 第一阶段
先做最小闭环: 先做最小闭环:
1. 八锚点结构化状态 1. 八锚点结构化状态
2. 锚点状态标签 2. 真实进度条计算器
3. 单轮提炼 + 单问题追问 3. 单轮提炼 + 单问题追问
4. 共识确认摘要 4. 共识确认摘要
5. 进入下一阶段的后端判定 5. 进入下一阶段的后端判定
## 15.2 第二阶段 ## 16.2 第二阶段
再补: 再补:
1. 冲突检测 1. 冲突检测
2. 更细的完成度评分 2. 更细的完成度评分
3. 阶段提示语 3. 轮次预算器
4. 指定锚点重聊 4. 指定锚点重聊
5. 锁定后禁止自动改写 5. 锁定后禁止自动改写
## 15.3 第三阶段 ## 16.3 第三阶段
继续补: 继续补:
1. 更强的 Agent 提问策略 1. 更强的 Agent 提问策略
2. 更丰富的摘要模板 2. 更丰富的摘要模板
3. 基于锚点的底稿质量评估 3. harness 与发布门禁
4. 对不同题材的提问风格适配 4. 对不同题材的提问风格适配
--- ---
## 16. 验收标准 ## 17. 验收标准
本 PRD 对应功能完成后,至少必须满足: 本 PRD 对应功能完成后,至少必须满足:
1. 玩家只输入一段模糊灵感时Agent 能给出有效提炼和一个高杠杆追问。 1. 玩家只输入一段模糊灵感时Agent 能给出有效提炼和一个高杠杆追问。
2. 玩家连续多轮输入后,八锚点摘要会持续更新,不只是聊天记录增长。 2. 玩家连续多轮输入后,八锚点摘要会持续更新,不只是聊天记录增长。
3. 工作区能稳定显示每个锚点的当前状态。 3. 工作区能稳定显示真实进度条和每个锚点的当前状态。
4. Agent 不会在同一锚点已高置信完成后继续反复追问。 4. Agent 不会在同一锚点已高置信完成后继续反复追问。
5. 玩家可明确确认当前理解、锁定部分锚点或指定某个锚点继续打磨。 5. 玩家可明确确认当前理解、锁定部分锚点或指定某个锚点继续打磨。
6. 八锚点状态能被后端判定为 `ready` 并进入世界底稿生成。 6. 八锚点状态能被后端判定为 `ready` 并进入世界底稿生成。
7. 前端不承担锚点完成度判断、冲突裁决和下一步阶段判断。 7. 前端不承担锚点完成度判断、冲突裁决和下一步阶段判断。
8. 相关测试、`check:encoding` 通过 8. 平均轮次控制在 `15` 轮左右,且 `P75` 不超过 `18`
9. 单轮双问违规率低于 `1%`
10. harness、相关测试、`check:encoding` 通过。
--- ---
## 17. 一句话结论 ## 18. 一句话结论
八锚点真正应该做成的,不是一套问卷,也不是一堆字段,而是: 八锚点真正应该做成的,不是一套问卷,也不是一堆字段,而是:
**一个由 Agent 主导的启发式共创流程:先接住灵感,再提炼方向,再补齐剧情发动机,最后把玩家和 Agent 的共识沉淀成可运行的世界底子。** **一个由 Agent 主导、带真实进度条、受轮次预算约束、并可被 harness 持续校验的启发式共创流程:先接住灵感,再提炼方向,再补齐剧情发动机,最后把玩家和 Agent 的共识沉淀成可运行的世界底子。**

File diff suppressed because it is too large Load Diff

View File

@@ -127,14 +127,13 @@ export function getActionTemplateById(id: QwenSpriteActionTemplateId) {
export function buildMasterPrompt(characterBrief: string) { export function buildMasterPrompt(characterBrief: string) {
return [ return [
'单人2D 横版游戏角色标准设定图,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。', '单人2D 横版游戏角色标准设定图,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。',
`视角要求:${SIDE_FACING_RIGHT_TEXT}`, `视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。`,
`主体要求:${SUBJECT_ONLY_TEXT}`, `主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。`,
`画面要求1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。${CLEAN_BACKGROUND_TEXT}`, `画面要求1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。`,
`风格要求:${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT} 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,道具/权杖/武器如有则存在关系合理,便于后续连续动作生成。`, `风格要求:Q版大头身动作角色头身比固定控制在 2 到 3 头身,头部占比明显更大,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,道具/权杖/武器如有则存在关系合理,便于后续连续动作生成。`,
CONCEPT_INTERPRETATION_TEXT, '请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,只有当文字设定明确要求鱼尾、触手身体、伞盖头部、无双腿、漂浮体、凝胶体或其他非人结构时,才改为对应非人身体。',
HUMANLIKE_PRIORITY_TEXT, '主题词默认只作用在角色自身的服装剪裁、材质、纹样、饰品、武器和发光细节上,不要把主题词自动扩写成背景建筑、自然场景、漂浮装饰或额外环境物件。',
CONCEPT_HIERARCHY_TEXT, '视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。',
THEME_APPLICATION_BOUNDARY_TEXT,
characterBrief.trim(), characterBrief.trim(),
] ]
.filter(Boolean) .filter(Boolean)
@@ -149,11 +148,11 @@ export function buildVideoActionPrompt(options: {
}) { }) {
return [ return [
`单人全身角色动作视频,动作主题是 ${options.actionTemplate.label}`, `单人全身角色动作视频,动作主题是 ${options.actionTemplate.label}`,
`角色固定为图1同一角色保持右向斜侧身动作视角镜头稳定轮廓清晰不要退化成完全 90 度纯右视图。${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT}`, `角色固定为图1同一角色保持右向斜侧身动作视角镜头稳定轮廓清晰不要退化成完全 90 度纯右视图。`,
CONCEPT_INTERPRETATION_TEXT, `视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。`,
HUMANLIKE_PRIORITY_TEXT, `主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。`,
CONCEPT_HIERARCHY_TEXT, `画面要求1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。`,
THEME_APPLICATION_BOUNDARY_TEXT, `风格要求Q版大头身动作角色头身比固定控制在 2 到 3 头身,头部占比明显更大,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,道具/权杖/武器如有则存在关系合理,便于后续连续动作生成。`,
`动作结构:${options.actionTemplate.sequenceLines.join('')}。结尾要求:${options.actionTemplate.ending}`, `动作结构:${options.actionTemplate.sequenceLines.join('')}。结尾要求:${options.actionTemplate.ending}`,
options.useChromaKey options.useChromaKey
? '背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。' ? '背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。'

View File

@@ -1,6 +1,65 @@
export type CustomWorldWorkStatus = 'draft' | 'published'; export type CustomWorldWorkStatus = 'draft' | 'published';
export type CustomWorldWorkSource = 'agent_session' | 'published_profile'; export type CustomWorldWorkSource = 'agent_session' | 'published_profile';
export interface WorldPromiseValue {
hook: string;
differentiator: string;
desiredExperience: string;
}
export interface PlayerFantasyValue {
playerRole: string;
corePursuit: string;
fearOfLoss: string;
}
export interface ThemeBoundaryValue {
toneKeywords: string[];
aestheticDirectives: string[];
forbiddenDirectives: string[];
}
export interface PlayerEntryPointValue {
openingIdentity: string;
openingProblem: string;
entryMotivation: string;
}
export interface CoreConflictValue {
surfaceConflicts: string[];
hiddenCrisis: string;
firstTouchedConflict: string;
}
export interface KeyRelationshipValue {
pairs: string;
relationshipType: string;
secretOrCost: string;
}
export interface HiddenLineValue {
hiddenTruths: string[];
misdirectionHints: string[];
revealPacing: string;
}
export interface IconicElementValue {
iconicMotifs: string[];
institutionsOrArtifacts: string[];
hardRules: string[];
}
export interface EightAnchorContent {
worldPromise: WorldPromiseValue | null;
playerFantasy: PlayerFantasyValue | null;
themeBoundary: ThemeBoundaryValue | null;
playerEntryPoint: PlayerEntryPointValue | null;
coreConflict: CoreConflictValue | null;
keyRelationships: KeyRelationshipValue[];
hiddenLines: HiddenLineValue | null;
iconicElements: IconicElementValue | null;
}
export interface CustomWorldWorkSummary { export interface CustomWorldWorkSummary {
workId: string; workId: string;
sourceType: CustomWorldWorkSource; sourceType: CustomWorldWorkSource;
@@ -284,6 +343,10 @@ export interface CustomWorldAssetCoverageSummary {
export interface CustomWorldAgentSessionSnapshot { export interface CustomWorldAgentSessionSnapshot {
sessionId: string; sessionId: string;
currentTurn: number;
anchorContent: EightAnchorContent;
progressPercent: number;
lastAssistantReply: string | null;
stage: CustomWorldAgentStage; stage: CustomWorldAgentStage;
focusCardId: string | null; focusCardId: string | null;
creatorIntent: Record<string, unknown> | null; creatorIntent: Record<string, unknown> | null;
@@ -351,6 +414,7 @@ export interface CreateCustomWorldAgentSessionResponse {
export interface SendCustomWorldAgentMessageRequest { export interface SendCustomWorldAgentMessageRequest {
clientMessageId: string; clientMessageId: string;
text: string; text: string;
quickFillRequested?: boolean;
focusCardId?: string | null; focusCardId?: string | null;
selectedCardIds?: string[]; selectedCardIds?: string[];
} }

View File

@@ -1,10 +1,77 @@
{ {
"characterId": "story-npc-艾莉丝-1", "characterId": "story-npc-艾莉丝-1",
"visualPromptText": "一位身着机械风格服饰的女性,侧身朝右,立于蒸汽灯塔顶端,脚下是工厂的金属平台,她手持机械魔杖,背后的机械羽翼展开,眼神专注而冷静,散发着旧秩序守护者的威严。", "visualPromptText": "机甲战士",
"animationPromptText": "艾莉丝优雅地操控着机械装置和魔法,她的动作流畅而果断,时而挥动魔杖释放能量,时而借助机械羽翼在空中灵活移动,战斗中的她充满了冷静与自信,每一个动作都展现出她对局势的精准判断和对力量的掌控。", "animationPromptText": "",
"visualDrafts": [], "visualDrafts": [
"selectedVisualDraftId": "", {
"selectedAnimation": "idle", "id": "candidate-1",
"animationMap": null, "label": "候选 1",
"updatedAt": "2026-04-16T12:19:15.547Z" "imageSrc": "/generated-character-drafts/story-npc-1/visual/visual-draft-1776435990280/candidate-01.png",
"width": 1024,
"height": 1024
}
],
"selectedVisualDraftId": "candidate-1",
"selectedAnimation": "die",
"imageSrc": "/generated-characters/story-npc-1/visual/visual-1776435992391/master.png",
"generatedVisualAssetId": "visual-1776435992391",
"generatedAnimationSetId": "animation-set-1776486977156",
"animationMap": {
"run": {
"folder": "run",
"prefix": "frame",
"frames": 8,
"startFrame": 1,
"extension": "png",
"basePath": "/generated-animations/story-npc-1/animation-set-1776443276472/run"
},
"idle": {
"folder": "idle",
"prefix": "frame",
"frames": 8,
"startFrame": 1,
"extension": "png",
"basePath": "/generated-animations/story-npc-1/animation-set-1776443491652/idle"
},
"attack": {
"folder": "attack",
"prefix": "frame",
"frames": 8,
"startFrame": 1,
"extension": "png",
"basePath": "/generated-animations/story-npc-1/animation-set-1776444184214/attack",
"frameWidth": 192,
"frameHeight": 256,
"fps": 12,
"loop": false,
"previewVideoPath": "/generated-character-drafts/story-npc-1/animation/attack/animation-video-1776444183292/preview.mp4"
},
"die": {
"folder": "die",
"prefix": "frame",
"frames": 8,
"startFrame": 1,
"extension": "png",
"basePath": "/generated-animations/story-npc-1/animation-set-1776486965437/die",
"frameWidth": 192,
"frameHeight": 256,
"fps": 8,
"loop": false,
"previewVideoPath": "/generated-character-drafts/story-npc-1/animation/die/animation-video-1776486963933/preview.mp4"
},
"hurt": {
"folder": "hurt",
"prefix": "frame",
"frames": 6,
"startFrame": 1,
"extension": "png",
"basePath": "/generated-animations/story-npc-1/animation-set-1776486977156/hurt",
"frameWidth": 192,
"frameHeight": 256,
"fps": 10,
"loop": false,
"previewVideoPath": "/generated-character-drafts/story-npc-1/animation/hurt/animation-video-1776486975795/preview.mp4"
}
},
"updatedAt": "2026-04-18T04:37:03.271Z"
} }

View File

@@ -79,12 +79,14 @@ if (!existsSync(path.join(distRoot, 'index.html'))) {
} }
mergedEnv.CADDY_SITE_ROOT = mergedEnv.CADDY_SITE_ROOT || normalizePathForCaddy(distRoot); mergedEnv.CADDY_SITE_ROOT = mergedEnv.CADDY_SITE_ROOT || normalizePathForCaddy(distRoot);
mergedEnv.CADDY_PUBLIC_ROOT = mergedEnv.CADDY_PUBLIC_ROOT || normalizePathForCaddy(path.join(repoRoot, 'public'));
mergedEnv.CADDY_API_UPSTREAM = resolveApiUpstream(mergedEnv); mergedEnv.CADDY_API_UPSTREAM = resolveApiUpstream(mergedEnv);
const caddyBinary = resolveCaddyBinary(); const caddyBinary = resolveCaddyBinary();
console.log('[serve:caddy] listen=:8080'); console.log('[serve:caddy] listen=:8080');
console.log(`[serve:caddy] CADDY_SITE_ROOT=${mergedEnv.CADDY_SITE_ROOT}`); console.log(`[serve:caddy] CADDY_SITE_ROOT=${mergedEnv.CADDY_SITE_ROOT}`);
console.log(`[serve:caddy] CADDY_PUBLIC_ROOT=${mergedEnv.CADDY_PUBLIC_ROOT}`);
console.log(`[serve:caddy] CADDY_API_UPSTREAM=${mergedEnv.CADDY_API_UPSTREAM}`); console.log(`[serve:caddy] CADDY_API_UPSTREAM=${mergedEnv.CADDY_API_UPSTREAM}`);
console.log(`[serve:caddy] config=${caddyConfigPath}`); console.log(`[serve:caddy] config=${caddyConfigPath}`);

View File

@@ -20,6 +20,7 @@ const runtimeNodePath = fs.existsSync(bundledNodePath)
: process.execPath; : process.execPath;
const serverBuildPath = path.join(repoRoot, 'server-node', 'dist', 'server.js'); const serverBuildPath = path.join(repoRoot, 'server-node', 'dist', 'server.js');
const webBuildPath = path.join(repoRoot, 'dist', 'index.html'); const webBuildPath = path.join(repoRoot, 'dist', 'index.html');
const publicRoot = path.join(repoRoot, 'public');
const proxyPort = 18080; const proxyPort = 18080;
const nodePort = 18081; const nodePort = 18081;
const proxyBaseUrl = `http://127.0.0.1:${proxyPort}`; const proxyBaseUrl = `http://127.0.0.1:${proxyPort}`;
@@ -130,6 +131,18 @@ function contentTypeFor(filePath: string) {
if (filePath.endsWith('.json')) { if (filePath.endsWith('.json')) {
return 'application/json; charset=utf-8'; return 'application/json; charset=utf-8';
} }
if (filePath.endsWith('.png')) {
return 'image/png';
}
if (filePath.endsWith('.jpg') || filePath.endsWith('.jpeg')) {
return 'image/jpeg';
}
if (filePath.endsWith('.webp')) {
return 'image/webp';
}
if (filePath.endsWith('.svg')) {
return 'image/svg+xml; charset=utf-8';
}
return 'application/octet-stream'; return 'application/octet-stream';
} }
@@ -138,15 +151,24 @@ function resolveStaticFile(urlPath: string) {
const cleanPath = decodeURIComponent(urlPath.split('?')[0] || '/'); const cleanPath = decodeURIComponent(urlPath.split('?')[0] || '/');
const normalizedPath = cleanPath === '/' ? '/index.html' : cleanPath; const normalizedPath = cleanPath === '/' ? '/index.html' : cleanPath;
const trimmedRelativePath = normalizedPath.replace(/^\/+/u, ''); const trimmedRelativePath = normalizedPath.replace(/^\/+/u, '');
const candidatePath = path.resolve(repoRoot, 'dist', trimmedRelativePath);
const distRoot = path.resolve(repoRoot, 'dist'); const distRoot = path.resolve(repoRoot, 'dist');
const publicCandidatePath = path.resolve(publicRoot, trimmedRelativePath);
const distCandidatePath = path.resolve(distRoot, trimmedRelativePath);
if ( if (
candidatePath.startsWith(distRoot) && publicCandidatePath.startsWith(publicRoot) &&
fs.existsSync(candidatePath) && fs.existsSync(publicCandidatePath) &&
fs.statSync(candidatePath).isFile() fs.statSync(publicCandidatePath).isFile()
) { ) {
return candidatePath; return publicCandidatePath;
}
if (
distCandidatePath.startsWith(distRoot) &&
fs.existsSync(distCandidatePath) &&
fs.statSync(distCandidatePath).isFile()
) {
return distCandidatePath;
} }
return webBuildPath; return webBuildPath;

View File

@@ -19,6 +19,7 @@
"pino": "^9.9.5", "pino": "^9.9.5",
"pino-http": "^10.5.0", "pino-http": "^10.5.0",
"pino-roll": "^3.1.0", "pino-roll": "^3.1.0",
"pngjs": "^7.0.0",
"zod": "^4.1.8" "zod": "^4.1.8"
}, },
"devDependencies": { "devDependencies": {
@@ -2264,6 +2265,15 @@
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/pngjs": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
"integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
"license": "MIT",
"engines": {
"node": ">=14.19.0"
}
},
"node_modules/postgres-array": { "node_modules/postgres-array": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",

View File

@@ -22,6 +22,7 @@
"pino": "^9.9.5", "pino": "^9.9.5",
"pino-http": "^10.5.0", "pino-http": "^10.5.0",
"pino-roll": "^3.1.0", "pino-roll": "^3.1.0",
"pngjs": "^7.0.0",
"zod": "^4.1.8" "zod": "^4.1.8"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -12,6 +12,8 @@ import type { AppConfig } from './config.ts';
import { prepareEventStreamResponse } from './http.ts'; import { prepareEventStreamResponse } from './http.ts';
import { requestIdMiddleware } from './middleware/requestId.ts'; import { requestIdMiddleware } from './middleware/requestId.ts';
import { createAppContext } from './server.ts'; import { createAppContext } from './server.ts';
import { CustomWorldAgentOrchestrator } from './services/customWorldAgentOrchestrator.js';
import { createTestCustomWorldAgentSingleTurnLlmClient } from './services/customWorldAgentTestHelpers.js';
import { httpRequest, type TestRequestInit } from './testHttp.ts'; import { httpRequest, type TestRequestInit } from './testHttp.ts';
type TestConfigOverrides = Partial< type TestConfigOverrides = Partial<
@@ -29,6 +31,16 @@ type TestConfigOverrides = Partial<
type TestAppContext = Awaited<ReturnType<typeof createAppContext>>; type TestAppContext = Awaited<ReturnType<typeof createAppContext>>;
function installTestCustomWorldAgentSingleTurnLlm(context: TestAppContext) {
context.customWorldAgentOrchestrator = new CustomWorldAgentOrchestrator(
context.customWorldAgentSessions,
null,
{
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
},
);
}
function createTestConfig( function createTestConfig(
testName: string, testName: string,
overrides: TestConfigOverrides = {}, overrides: TestConfigOverrides = {},
@@ -1698,6 +1710,37 @@ test('authenticated missing routes return unified not found errors', async () =>
}); });
}); });
test('public runtime assets are served from the app root', async () => {
const publicDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'genarrative-public-static-'),
);
const generatedDir = path.join(
publicDir,
'generated-character-drafts',
'test-npc',
'visual',
'visual-draft-1',
);
fs.mkdirSync(generatedDir, { recursive: true });
const imagePath = path.join(generatedDir, 'candidate-01.png');
fs.writeFileSync(imagePath, Buffer.from([0x89, 0x50, 0x4e, 0x47]));
await withTestServer(
'public-runtime-assets',
async ({ baseUrl }) => {
const response = await httpRequest(
`${baseUrl}/generated-character-drafts/test-npc/visual/visual-draft-1/candidate-01.png`,
);
assert.equal(response.status, 200);
assert.equal(response.headers.get('content-type'), 'image/png');
},
{
publicDir,
},
);
});
test('stream responses also carry api version and route metadata headers', async () => { test('stream responses also carry api version and route metadata headers', async () => {
const app = express(); const app = express();
app.use(requestIdMiddleware); app.use(requestIdMiddleware);
@@ -2146,6 +2189,129 @@ test('custom worlds stay private until published and then appear in the public g
}); });
}); });
test('deleting a custom world uses soft delete and hides the work from library and gallery', async () => {
await withTestServer(
'custom-world-soft-delete',
async ({ baseUrl, context }) => {
const owner = await authEntry(baseUrl, 'soft_delete_owner', 'secret123');
const viewer = await authEntry(
baseUrl,
'soft_delete_viewer',
'secret123',
);
const upsertResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world-library/world-soft-delete`,
withBearer(owner.token, {
method: 'PUT',
body: JSON.stringify({
profile: {
id: 'world-soft-delete',
name: '潮雾裂港',
subtitle: '被旧航灯切开的海港',
summary: '用于验证作品删除软删除逻辑的测试世界。',
playableNpcs: [],
storyNpcs: [],
landmarks: [],
},
}),
}),
);
assert.equal(upsertResponse.status, 200);
const publishResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world-library/world-soft-delete/publish`,
withBearer(owner.token, {
method: 'POST',
}),
);
assert.equal(publishResponse.status, 200);
const deleteResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world-library/world-soft-delete`,
withBearer(owner.token, {
method: 'DELETE',
}),
);
const deletePayload = (await deleteResponse.json()) as {
entries: Array<unknown>;
};
assert.equal(deleteResponse.status, 200);
assert.deepEqual(deletePayload.entries, []);
const ownerLibraryResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world-library`,
{
headers: {
Authorization: `Bearer ${owner.token}`,
},
},
);
const ownerLibraryPayload = (await ownerLibraryResponse.json()) as {
entries: Array<unknown>;
};
assert.equal(ownerLibraryResponse.status, 200);
assert.deepEqual(ownerLibraryPayload.entries, []);
const galleryResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world-gallery`,
{
headers: {
Authorization: `Bearer ${viewer.token}`,
},
},
);
const galleryPayload = (await galleryResponse.json()) as {
entries: Array<unknown>;
};
assert.equal(galleryResponse.status, 200);
assert.deepEqual(galleryPayload.entries, []);
const galleryDetailResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world-gallery/${encodeURIComponent(owner.user.id)}/${encodeURIComponent('world-soft-delete')}`,
{
headers: {
Authorization: `Bearer ${viewer.token}`,
},
},
);
assert.equal(galleryDetailResponse.status, 404);
const persistedRows = await context.db.query<{
profileId: string;
visibility: string;
publishedAt: string | null;
deletedAt: string | null;
payload: {
name?: string;
};
}>(
`SELECT profile_id AS "profileId",
visibility,
published_at AS "publishedAt",
deleted_at AS "deletedAt",
payload_json AS payload
FROM custom_world_profiles
WHERE user_id = $1
AND profile_id = $2`,
[owner.user.id, 'world-soft-delete'],
);
assert.equal(persistedRows.rows.length, 1);
assert.equal(persistedRows.rows[0]?.profileId, 'world-soft-delete');
assert.equal(persistedRows.rows[0]?.visibility, 'draft');
assert.equal(persistedRows.rows[0]?.publishedAt, null);
assert.ok(persistedRows.rows[0]?.deletedAt);
assert.equal(persistedRows.rows[0]?.payload?.name, '潮雾裂港');
},
);
});
test('custom world works endpoint returns draft sessions and published worlds together', async () => { test('custom world works endpoint returns draft sessions and published worlds together', async () => {
await withTestServer('custom-world-works', async ({ baseUrl }) => { await withTestServer('custom-world-works', async ({ baseUrl }) => {
const entry = await authEntry(baseUrl, 'cw_works', 'secret123'); const entry = await authEntry(baseUrl, 'cw_works', 'secret123');
@@ -2231,107 +2397,111 @@ test('custom world works endpoint returns draft sessions and published worlds to
}); });
test('custom world agent session accepts messages and exposes completed operations', async () => { test('custom world agent session accepts messages and exposes completed operations', async () => {
await withTestServer('custom-world-agent-messages', async ({ baseUrl }) => { await withTestServer(
const entry = await authEntry(baseUrl, 'cw_agent', 'secret123'); 'custom-world-agent-messages',
async ({ baseUrl, context }) => {
installTestCustomWorldAgentSingleTurnLlm(context);
const entry = await authEntry(baseUrl, 'cw_agent', 'secret123');
const createResponse = await httpRequest( const createResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world/agent/sessions`, `${baseUrl}/api/runtime/custom-world/agent/sessions`,
withBearer(entry.token, { withBearer(entry.token, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
seedText: '一个围绕灯塔与沉船秘术的边境世界。', seedText: '一个围绕灯塔与沉船秘术的边境世界。',
}),
}), }),
}), );
); const created = (await createResponse.json()) as {
const created = (await createResponse.json()) as { session: {
session: { sessionId: string;
sessionId: string; messages: Array<{ role: string }>;
messages: Array<{ role: string }>; };
}; };
};
assert.equal(createResponse.status, 200); assert.equal(createResponse.status, 200);
assert.equal(created.session.messages[0]?.role, 'assistant'); assert.equal(created.session.messages[0]?.role, 'assistant');
const messageResponse = await httpRequest( const messageResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(created.session.sessionId)}/messages`, `${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(created.session.sessionId)}/messages`,
withBearer(entry.token, { withBearer(entry.token, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
clientMessageId: 'client-1', clientMessageId: 'client-1',
text: '玩家是一个被迫回到故乡灯塔的失职守望者。', text: '玩家是一个被迫回到故乡灯塔的失职守望者。',
focusCardId: null, focusCardId: null,
selectedCardIds: [], selectedCardIds: [],
}),
}), }),
}), );
); const messagePayload = (await messageResponse.json()) as {
const messagePayload = (await messageResponse.json()) as { operation: {
operation: { operationId: string;
operationId: string; status: string;
status: string; progress: number;
progress: number; };
}; };
};
assert.equal(messageResponse.status, 200); assert.equal(messageResponse.status, 200);
assert.equal(messagePayload.operation.status, 'queued'); assert.equal(messagePayload.operation.status, 'queued');
assert.equal(messagePayload.operation.progress, 10); assert.equal(messagePayload.operation.progress, 10);
let operationText = ''; let operationText = '';
for (let attempt = 0; attempt < 20; attempt += 1) { for (let attempt = 0; attempt < 20; attempt += 1) {
const operationResponse = await httpRequest( const operationResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(created.session.sessionId)}/operations/${encodeURIComponent(messagePayload.operation.operationId)}`, `${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(created.session.sessionId)}/operations/${encodeURIComponent(messagePayload.operation.operationId)}`,
{
headers: {
Authorization: `Bearer ${entry.token}`,
},
},
);
assert.equal(operationResponse.status, 200);
operationText = await operationResponse.text();
if (/"status":"completed"/u.test(operationText)) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
assert.match(operationText, /"status":"completed"/u);
assert.match(operationText, /"progress":100/u);
const sessionResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(created.session.sessionId)}`,
{ {
headers: { headers: {
Authorization: `Bearer ${entry.token}`, Authorization: `Bearer ${entry.token}`,
}, },
}, },
); );
assert.equal(operationResponse.status, 200); const sessionPayload = (await sessionResponse.json()) as {
operationText = await operationResponse.text(); stage: string;
creatorIntent: {
playerPremise?: string | null;
} | null;
messages: Array<{ role: string; text: string }>;
pendingClarifications: Array<{ question: string }>;
};
if (/"status":"completed"/u.test(operationText)) { assert.equal(sessionResponse.status, 200);
break; assert.equal(sessionPayload.stage, 'clarifying');
} assert.ok(
sessionPayload.messages.some((message) => message.role === 'user'),
await new Promise((resolve) => setTimeout(resolve, 25)); );
} assert.ok(
sessionPayload.messages.some((message) => message.role === 'assistant'),
assert.match(operationText, /"status":"completed"/u); );
assert.match(operationText, /"progress":100/u); assert.match(
sessionPayload.creatorIntent?.playerPremise ?? '',
const sessionResponse = await httpRequest( /|/u,
`${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(created.session.sessionId)}`, );
{ assert.ok(sessionPayload.pendingClarifications.length > 0);
headers: { },
Authorization: `Bearer ${entry.token}`, );
},
},
);
const sessionPayload = (await sessionResponse.json()) as {
stage: string;
creatorIntent: {
playerPremise?: string | null;
} | null;
messages: Array<{ role: string; text: string }>;
pendingClarifications: Array<{ question: string }>;
};
assert.equal(sessionResponse.status, 200);
assert.equal(sessionPayload.stage, 'clarifying');
assert.ok(
sessionPayload.messages.some((message) => message.role === 'user'),
);
assert.ok(
sessionPayload.messages.some((message) => message.role === 'assistant'),
);
assert.match(
sessionPayload.creatorIntent?.playerPremise ?? '',
/|/u,
);
assert.ok(sessionPayload.pendingClarifications.length > 0);
});
}); });
test('custom world agent missing session returns 404', async () => { test('custom world agent missing session returns 404', async () => {
@@ -2433,7 +2603,8 @@ test('custom world agent operation can fail and expose failed status', async ()
test('custom world agent draft_foundation action generates draft cards and card detail over http', async () => { test('custom world agent draft_foundation action generates draft cards and card detail over http', async () => {
await withTestServer( await withTestServer(
'custom-world-agent-phase3-http', 'custom-world-agent-phase3-http',
async ({ baseUrl }) => { async ({ baseUrl, context }) => {
installTestCustomWorldAgentSingleTurnLlm(context);
const entry = await authEntry(baseUrl, 'cw_agent_phase3', 'secret123'); const entry = await authEntry(baseUrl, 'cw_agent_phase3', 'secret123');
const readySession = await createReadyCustomWorldAgentSession({ const readySession = await createReadyCustomWorldAgentSession({
baseUrl, baseUrl,
@@ -2568,7 +2739,10 @@ test('custom world agent draft_foundation action rejects not-ready sessions over
assert.equal(actionResponse.status, 400); assert.equal(actionResponse.status, 400);
assert.equal(actionPayload.error.code, 'BAD_REQUEST'); assert.equal(actionPayload.error.code, 'BAD_REQUEST');
assert.match(actionPayload.error.message, /foundation_review|ready/u); assert.match(
actionPayload.error.message,
/progressPercent >= 100|draft_foundation/u,
);
}, },
); );
}); });
@@ -2577,6 +2751,7 @@ test('custom world agent update_draft_card action updates draft profile and card
await withTestServer( await withTestServer(
'custom-world-agent-phase4-update-http', 'custom-world-agent-phase4-update-http',
async ({ baseUrl, context }) => { async ({ baseUrl, context }) => {
installTestCustomWorldAgentSingleTurnLlm(context);
const entry = await authEntry( const entry = await authEntry(
baseUrl, baseUrl,
'cw_agent_phase4_update', 'cw_agent_phase4_update',
@@ -2718,6 +2893,7 @@ test('custom world agent generate_characters action appends character cards over
await withTestServer( await withTestServer(
'custom-world-agent-phase4-generate-characters-http', 'custom-world-agent-phase4-generate-characters-http',
async ({ baseUrl, context }) => { async ({ baseUrl, context }) => {
installTestCustomWorldAgentSingleTurnLlm(context);
const entry = await authEntry(baseUrl, 'cw_agent_p4_ch', 'secret123'); const entry = await authEntry(baseUrl, 'cw_agent_p4_ch', 'secret123');
const session = await createObjectRefiningCustomWorldAgentSession({ const session = await createObjectRefiningCustomWorldAgentSession({
baseUrl, baseUrl,
@@ -2817,6 +2993,7 @@ test('custom world agent generate_landmarks action appends landmark cards over h
await withTestServer( await withTestServer(
'custom-world-agent-phase4-generate-landmarks-http', 'custom-world-agent-phase4-generate-landmarks-http',
async ({ baseUrl, context }) => { async ({ baseUrl, context }) => {
installTestCustomWorldAgentSingleTurnLlm(context);
const entry = await authEntry(baseUrl, 'cw_agent_p4_lm', 'secret123'); const entry = await authEntry(baseUrl, 'cw_agent_p4_lm', 'secret123');
const session = await createObjectRefiningCustomWorldAgentSession({ const session = await createObjectRefiningCustomWorldAgentSession({
baseUrl, baseUrl,
@@ -3305,14 +3482,11 @@ test('profile browse history supports batch sync, dedupe ordering, isolation and
'2026-04-16T12:00:00.000Z', '2026-04-16T12:00:00.000Z',
); );
const viewerHistoryResponse = await httpRequest( const viewerHistoryResponse = await httpRequest(browseHistoryUrl, {
browseHistoryUrl, headers: {
{ Authorization: `Bearer ${viewer.token}`,
headers: {
Authorization: `Bearer ${viewer.token}`,
},
}, },
); });
const viewerHistoryPayload = (await viewerHistoryResponse.json()) as { const viewerHistoryPayload = (await viewerHistoryResponse.json()) as {
entries: Array<{ entries: Array<{
profileId: string; profileId: string;
@@ -3346,14 +3520,11 @@ test('profile browse history supports batch sync, dedupe ordering, isolation and
['world-1', 'world-2'], ['world-1', 'world-2'],
); );
const authorHistoryResponse = await httpRequest( const authorHistoryResponse = await httpRequest(browseHistoryUrl, {
browseHistoryUrl, headers: {
{ Authorization: `Bearer ${author.token}`,
headers: {
Authorization: `Bearer ${author.token}`,
},
}, },
); });
const authorHistoryPayload = (await authorHistoryResponse.json()) as { const authorHistoryPayload = (await authorHistoryResponse.json()) as {
entries: Array<unknown>; entries: Array<unknown>;
}; };
@@ -3374,14 +3545,11 @@ test('profile browse history supports batch sync, dedupe ordering, isolation and
assert.equal(clearResponse.status, 200); assert.equal(clearResponse.status, 200);
assert.deepEqual(clearPayload.entries, []); assert.deepEqual(clearPayload.entries, []);
const clearedHistoryResponse = await httpRequest( const clearedHistoryResponse = await httpRequest(browseHistoryUrl, {
browseHistoryUrl, headers: {
{ Authorization: `Bearer ${viewer.token}`,
headers: {
Authorization: `Bearer ${viewer.token}`,
},
}, },
); });
const clearedHistoryPayload = (await clearedHistoryResponse.json()) as { const clearedHistoryPayload = (await clearedHistoryResponse.json()) as {
entries: Array<unknown>; entries: Array<unknown>;
}; };

View File

@@ -146,6 +146,12 @@ export function createApp(context: AppContext) {
withRouteMeta({ routeVersion: '2026-04-08' }), withRouteMeta({ routeVersion: '2026-04-08' }),
createRuntimeRoutes(context), createRuntimeRoutes(context),
); );
app.use(
express.static(context.config.publicDir, {
fallthrough: true,
index: false,
}),
);
app.use((request, _response, next) => { app.use((request, _response, next) => {
next(notFound(`接口不存在:${request.method} ${request.originalUrl}`)); next(notFound(`接口不存在:${request.method} ${request.originalUrl}`));

View File

@@ -299,4 +299,21 @@ export const databaseMigrations: readonly DatabaseMigration[] = [
ON user_browse_history (user_id, visited_at DESC)`, ON user_browse_history (user_id, visited_at DESC)`,
], ],
}, },
{
id: '20260417_013_custom_world_profile_soft_delete',
name: 'custom world profile soft delete',
statements: [
`ALTER TABLE custom_world_profiles
ADD COLUMN IF NOT EXISTS deleted_at TEXT`,
`CREATE INDEX IF NOT EXISTS custom_world_profiles_user_deleted_updated_idx
ON custom_world_profiles (user_id, deleted_at, updated_at DESC)`,
`CREATE INDEX IF NOT EXISTS custom_world_profiles_gallery_live_idx
ON custom_world_profiles (
visibility,
deleted_at,
published_at DESC,
updated_at DESC
)`,
],
},
]; ];

View File

@@ -6,6 +6,7 @@ import path from 'node:path';
import test from 'node:test'; import test from 'node:test';
import express from 'express'; import express from 'express';
import { PNG } from 'pngjs';
import type { AppConfig } from '../../config.js'; import type { AppConfig } from '../../config.js';
import { createCharacterAssetRoutes } from './characterAssetRoutes.js'; import { createCharacterAssetRoutes } from './characterAssetRoutes.js';
@@ -16,6 +17,31 @@ const PNG_BUFFER = Buffer.from(
); );
const MP4_BUFFER = Buffer.from('mock-video'); const MP4_BUFFER = Buffer.from('mock-video');
function createGreenScreenFixturePngBuffer() {
const png = new PNG({ width: 2, height: 1 });
png.data[0] = 0;
png.data[1] = 255;
png.data[2] = 0;
png.data[3] = 255;
png.data[4] = 220;
png.data[5] = 48;
png.data[6] = 72;
png.data[7] = 255;
return PNG.sync.write(png);
}
function readPngAlphaValues(buffer: Buffer) {
const png = PNG.sync.read(buffer);
return Array.from({ length: png.width * png.height }, (_, index) => {
return png.data[index * 4 + 3] ?? 0;
});
}
const GREEN_SCREEN_PNG_BUFFER = createGreenScreenFixturePngBuffer();
function createTestConfig( function createTestConfig(
projectRoot: string, projectRoot: string,
dashScopeBaseUrl: string, dashScopeBaseUrl: string,
@@ -165,7 +191,7 @@ test('character visual generation converts public reference images into data url
if (req.method === 'GET' && url.pathname === '/downloads/visual.png') { if (req.method === 'GET' && url.pathname === '/downloads/visual.png') {
res.statusCode = 200; res.statusCode = 200;
res.setHeader('Content-Type', 'image/png'); res.setHeader('Content-Type', 'image/png');
res.end(PNG_BUFFER); res.end(GREEN_SCREEN_PNG_BUFFER);
return; return;
} }
@@ -219,6 +245,7 @@ test('character visual generation converts public reference images into data url
const savedDraftPath = path.join(tempRoot, 'public', payload.drafts[0]!.imageSrc.slice(1)); const savedDraftPath = path.join(tempRoot, 'public', payload.drafts[0]!.imageSrc.slice(1));
assert.equal(fs.existsSync(savedDraftPath), true); assert.equal(fs.existsSync(savedDraftPath), true);
assert.deepEqual(readPngAlphaValues(fs.readFileSync(savedDraftPath)), [0, 255]);
}); });
}, },
); );
@@ -404,6 +431,110 @@ test('character workflow cache skips rewriting unchanged payloads', async () =>
); );
}); });
test('character animation publish returns frame dimensions in animation map', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-animation-publish-'));
await withAssetRouteServer(
createTestConfig(tempRoot, 'http://127.0.0.1:9/api/v1'),
async (assetBaseUrl) => {
const response = await fetch(`${assetBaseUrl}/api/assets/character-animation/publish`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
characterId: 'harbor-guide',
visualAssetId: 'visual-1',
updateCharacterOverride: false,
animations: {
run: {
framesDataUrls: [`data:image/png;base64,${PNG_BUFFER.toString('base64')}`],
fps: 12,
loop: true,
frameWidth: 144,
frameHeight: 192,
previewVideoPath: '/generated-character-drafts/harbor-guide/animation/run/preview.mp4',
},
},
}),
});
assert.equal(response.status, 200);
const payload = (await response.json()) as {
animationMap: Record<
string,
{
frameWidth?: number;
frameHeight?: number;
fps?: number;
loop?: boolean;
previewVideoPath?: string;
}
>;
};
assert.equal(payload.animationMap.run?.frameWidth, 144);
assert.equal(payload.animationMap.run?.frameHeight, 192);
assert.equal(payload.animationMap.run?.fps, 12);
assert.equal(payload.animationMap.run?.loop, true);
assert.equal(
payload.animationMap.run?.previewVideoPath,
'/generated-character-drafts/harbor-guide/animation/run/preview.mp4',
);
},
);
});
test('character visual publish removes green screen before saving master and previews', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-visual-publish-'));
const publicDir = path.join(tempRoot, 'public');
fs.mkdirSync(publicDir, { recursive: true });
fs.writeFileSync(path.join(publicDir, 'draft.png'), GREEN_SCREEN_PNG_BUFFER);
await withAssetRouteServer(
createTestConfig(tempRoot, 'http://127.0.0.1:9/api/v1'),
async (assetBaseUrl) => {
const response = await fetch(`${assetBaseUrl}/api/assets/character-visual/publish`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
characterId: 'harbor-guide',
sourceMode: 'image-to-image',
promptText: '潮雾港向导',
selectedPreviewSource: '/draft.png',
previewSources: ['/draft.png'],
width: 1024,
height: 1024,
updateCharacterOverride: false,
}),
});
assert.equal(response.status, 200);
const payload = (await response.json()) as {
portraitPath: string;
};
const savedMasterPath = path.join(tempRoot, 'public', payload.portraitPath.slice(1));
const savedPreviewPath = path.join(
tempRoot,
'public',
'generated-characters',
'harbor-guide',
'visual',
path.basename(path.dirname(savedMasterPath)),
'preview-1.png',
);
assert.equal(fs.existsSync(savedMasterPath), true);
assert.equal(fs.existsSync(savedPreviewPath), true);
assert.deepEqual(readPngAlphaValues(fs.readFileSync(savedMasterPath)), [0, 255]);
assert.deepEqual(readPngAlphaValues(fs.readFileSync(savedPreviewPath)), [0, 255]);
},
);
});
test('character animation image-to-video flow uploads a public visual source and submits the resolved oss url', async () => { test('character animation image-to-video flow uploads a public visual source and submits the resolved oss url', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-video-')); const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-video-'));
const publicDir = path.join(tempRoot, 'public'); const publicDir = path.join(tempRoot, 'public');
@@ -524,3 +655,318 @@ test('character animation image-to-video flow uploads a public visual source and
}, },
); );
}); });
test('character animation non-loop image-to-video uses first and last master frames', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-kf2v-'));
const publicDir = path.join(tempRoot, 'public');
fs.mkdirSync(publicDir, { recursive: true });
fs.writeFileSync(path.join(publicDir, 'visual.png'), PNG_BUFFER);
let videoSynthesisPayloadText = '';
await withHttpServer(
(dashScopeBaseUrl) => async (req, res) => {
const url = new URL(req.url || '/', dashScopeBaseUrl);
if (
req.method === 'POST' &&
url.pathname === '/api/v1/services/aigc/image2video/video-synthesis'
) {
videoSynthesisPayloadText = (await readRequestBody(req)).toString('utf8');
sendJson(res, {
output: {
task_id: 'video-task-kf2v-1',
},
});
return;
}
if (req.method === 'GET' && url.pathname === '/api/v1/tasks/video-task-kf2v-1') {
sendJson(res, {
output: {
task_status: 'SUCCEEDED',
video_url: `${dashScopeBaseUrl}/downloads/preview.mp4`,
},
});
return;
}
if (req.method === 'GET' && url.pathname === '/downloads/preview.mp4') {
res.statusCode = 200;
res.setHeader('Content-Type', 'video/mp4');
res.end(MP4_BUFFER);
return;
}
res.statusCode = 404;
res.end('not found');
},
async (dashScopeBaseUrl) => {
const config = createTestConfig(tempRoot, `${dashScopeBaseUrl}/api/v1`);
await withAssetRouteServer(config, async (assetBaseUrl) => {
const response = await fetch(
`${assetBaseUrl}/api/assets/character-animation/generate`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
characterId: 'harbor-guide',
strategy: 'image-to-video',
animation: 'attack',
promptText: '短促挥击后收招',
characterBriefText: '旧港守望者',
visualSource: '/visual.png',
referenceImageDataUrls: [],
referenceVideoDataUrls: [],
frameCount: 8,
fps: 8,
durationSeconds: 4,
loop: false,
useChromaKey: true,
resolution: '720P',
imageSequenceModel: 'wan2.7-image-pro',
videoModel: 'wan2.7-i2v',
referenceVideoModel: 'wan2.7-r2v',
motionTransferModel: 'wan2.2-animate-move',
}),
},
);
assert.equal(response.status, 200);
const videoPayload = JSON.parse(videoSynthesisPayloadText) as {
model: string;
input: {
first_frame_url?: string;
last_frame_url?: string;
};
parameters: {
resolution?: string;
};
};
assert.equal(videoPayload.model, 'wan2.2-kf2v-flash');
assert.match(videoPayload.input.first_frame_url ?? '', /^data:image\/png;base64,/u);
assert.match(videoPayload.input.last_frame_url ?? '', /^data:image\/png;base64,/u);
assert.equal(videoPayload.parameters.resolution, '480P');
});
},
);
});
test('character animation loop image-to-video uses wan2.6-i2v-flash with img_url only', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-i2v-loop-'));
const publicDir = path.join(tempRoot, 'public');
fs.mkdirSync(publicDir, { recursive: true });
fs.writeFileSync(path.join(publicDir, 'visual.png'), PNG_BUFFER);
let videoSynthesisPayloadText = '';
await withHttpServer(
(dashScopeBaseUrl) => async (req, res) => {
const url = new URL(req.url || '/', dashScopeBaseUrl);
if (
req.method === 'POST' &&
url.pathname === '/api/v1/services/aigc/video-generation/video-synthesis'
) {
videoSynthesisPayloadText = (await readRequestBody(req)).toString('utf8');
sendJson(res, {
output: {
task_id: 'video-task-i2v-loop-1',
},
});
return;
}
if (req.method === 'GET' && url.pathname === '/api/v1/tasks/video-task-i2v-loop-1') {
sendJson(res, {
output: {
task_status: 'SUCCEEDED',
video_url: `${dashScopeBaseUrl}/downloads/preview.mp4`,
},
});
return;
}
if (req.method === 'GET' && url.pathname === '/downloads/preview.mp4') {
res.statusCode = 200;
res.setHeader('Content-Type', 'video/mp4');
res.end(MP4_BUFFER);
return;
}
res.statusCode = 404;
res.end('not found');
},
async (dashScopeBaseUrl) => {
const config = createTestConfig(tempRoot, `${dashScopeBaseUrl}/api/v1`);
await withAssetRouteServer(config, async (assetBaseUrl) => {
const response = await fetch(
`${assetBaseUrl}/api/assets/character-animation/generate`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
characterId: 'harbor-guide',
strategy: 'image-to-video',
animation: 'run',
promptText: '稳定循环奔跑',
characterBriefText: '旧港守望者',
visualSource: '/visual.png',
referenceImageDataUrls: [],
referenceVideoDataUrls: [],
frameCount: 8,
fps: 8,
durationSeconds: 4,
loop: true,
useChromaKey: true,
resolution: '720P',
imageSequenceModel: 'wan2.7-image-pro',
videoModel: 'wan2.6-i2v-flash',
referenceVideoModel: 'wan2.7-r2v',
motionTransferModel: 'wan2.2-animate-move',
}),
},
);
assert.equal(response.status, 200);
const videoPayload = JSON.parse(videoSynthesisPayloadText) as {
model: string;
input: {
img_url?: string;
first_frame_url?: string;
last_frame_url?: string;
};
parameters: {
audio?: boolean;
resolution?: string;
};
};
assert.equal(videoPayload.model, 'wan2.6-i2v-flash');
assert.match(videoPayload.input.img_url ?? '', /^data:image\/png;base64,/u);
assert.equal(videoPayload.input.first_frame_url, undefined);
assert.equal(videoPayload.input.last_frame_url, undefined);
assert.equal(videoPayload.parameters.audio, false);
assert.equal(videoPayload.parameters.resolution, '720P');
});
},
);
});
test('character animation reference-to-video can use only reference image media', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-r2v-'));
const publicDir = path.join(tempRoot, 'public');
fs.mkdirSync(publicDir, { recursive: true });
fs.writeFileSync(path.join(publicDir, 'visual.png'), PNG_BUFFER);
let videoSynthesisPayloadText = '';
await withHttpServer(
(dashScopeBaseUrl) => async (req, res) => {
const url = new URL(req.url || '/', dashScopeBaseUrl);
if (req.method === 'GET' && url.pathname === '/api/v1/uploads') {
sendJson(res, {
data: {
upload_host: `${dashScopeBaseUrl}/upload`,
upload_dir: 'uploads/test-dir',
policy: 'policy',
signature: 'signature',
oss_access_key_id: 'oss-key',
},
});
return;
}
if (req.method === 'POST' && url.pathname === '/upload') {
await readRequestBody(req);
res.statusCode = 200;
res.end('ok');
return;
}
if (
req.method === 'POST' &&
url.pathname === '/api/v1/services/aigc/video-generation/video-synthesis'
) {
videoSynthesisPayloadText = (await readRequestBody(req)).toString('utf8');
sendJson(res, {
output: {
task_id: 'video-task-r2v-1',
},
});
return;
}
if (req.method === 'GET' && url.pathname === '/api/v1/tasks/video-task-r2v-1') {
sendJson(res, {
output: {
task_status: 'SUCCEEDED',
video_url: `${dashScopeBaseUrl}/downloads/preview.mp4`,
},
});
return;
}
if (req.method === 'GET' && url.pathname === '/downloads/preview.mp4') {
res.statusCode = 200;
res.setHeader('Content-Type', 'video/mp4');
res.end(MP4_BUFFER);
return;
}
res.statusCode = 404;
res.end('not found');
},
async (dashScopeBaseUrl) => {
const config = createTestConfig(tempRoot, `${dashScopeBaseUrl}/api/v1`);
await withAssetRouteServer(config, async (assetBaseUrl) => {
const response = await fetch(
`${assetBaseUrl}/api/assets/character-animation/generate`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
characterId: 'harbor-guide',
strategy: 'reference-to-video',
animation: 'run',
promptText: '稳定循环奔跑',
characterBriefText: '旧港守望者',
visualSource: '/visual.png',
referenceImageDataUrls: [],
referenceVideoDataUrls: [],
frameCount: 8,
fps: 8,
durationSeconds: 4,
loop: true,
useChromaKey: true,
resolution: '720P',
imageSequenceModel: 'wan2.7-image-pro',
videoModel: 'wan2.7-i2v',
referenceVideoModel: 'wan2.7-r2v',
motionTransferModel: 'wan2.2-animate-move',
}),
},
);
assert.equal(response.status, 200);
const videoPayload = JSON.parse(videoSynthesisPayloadText) as {
input: {
media: Array<{ type: string; url: string }>;
};
};
assert.equal(videoPayload.input.media[0]?.type, 'reference_image');
assert.match(videoPayload.input.media[0]?.url ?? '', /^oss:\/\/uploads\/test-dir\//u);
assert.equal(videoPayload.input.media.length, 1);
});
},
);
});

View File

@@ -7,7 +7,8 @@ import http, {
import https from 'node:https'; import https from 'node:https';
import path from 'node:path'; import path from 'node:path';
import { type NextFunction, type Request, type Response,Router } from 'express'; import { type NextFunction, type Request, type Response, Router } from 'express';
import { PNG } from 'pngjs';
import { import {
buildMasterPrompt, buildMasterPrompt,
@@ -32,6 +33,7 @@ const CHARACTER_ANIMATION_TEMPLATES_PATH = '/api/assets/character-animation/temp
const DEFAULT_DASHSCOPE_BASE_URL = 'https://dashscope.aliyuncs.com/api/v1'; const DEFAULT_DASHSCOPE_BASE_URL = 'https://dashscope.aliyuncs.com/api/v1';
const DEFAULT_CHARACTER_VISUAL_MODEL = 'wan2.7-image-pro'; const DEFAULT_CHARACTER_VISUAL_MODEL = 'wan2.7-image-pro';
const DEFAULT_CHARACTER_VIDEO_MODEL = 'wan2.2-kf2v-flash'; const DEFAULT_CHARACTER_VIDEO_MODEL = 'wan2.2-kf2v-flash';
const DEFAULT_CHARACTER_LOOP_VIDEO_MODEL = 'wan2.6-i2v-flash';
const DEFAULT_CHARACTER_REFERENCE_VIDEO_MODEL = 'wan2.7-r2v'; const DEFAULT_CHARACTER_REFERENCE_VIDEO_MODEL = 'wan2.7-r2v';
const DEFAULT_CHARACTER_MOTION_TRANSFER_MODEL = 'wan2.2-animate-move'; const DEFAULT_CHARACTER_MOTION_TRANSFER_MODEL = 'wan2.2-animate-move';
const DASHSCOPE_IMAGE_TASK_POLL_INTERVAL_MS = 2500; const DASHSCOPE_IMAGE_TASK_POLL_INTERVAL_MS = 2500;
@@ -107,6 +109,67 @@ type DecodedMediaPayload = {
extension: string; extension: string;
}; };
function applyGreenScreenAlphaToPngBuffer(buffer: Buffer) {
try {
const png = PNG.sync.read(buffer);
const pixels = png.data;
let changed = false;
for (let index = 0; index < pixels.length; index += 4) {
const red = pixels[index] ?? 0;
const green = pixels[index + 1] ?? 0;
const blue = pixels[index + 2] ?? 0;
const alpha = pixels[index + 3] ?? 0;
const greenRatio = green / Math.max(1, red + blue);
if (alpha === 0) {
continue;
}
const greenLead = green - Math.max(red, blue);
if (green <= 72 || greenLead <= 20 || greenRatio <= 0.72) {
continue;
}
let nextAlpha = Math.min(alpha, Math.max(0, 255 - greenLead * 6));
if (green > 120 && greenLead > 48 && greenRatio > 1.12) {
nextAlpha = 0;
}
if (nextAlpha === alpha) {
continue;
}
pixels[index + 3] = nextAlpha;
if (nextAlpha > 0) {
pixels[index + 1] = Math.min(
green,
Math.max(red, blue) + Math.max(6, Math.round(greenLead * 0.18)),
);
}
changed = true;
}
return changed ? PNG.sync.write(png) : buffer;
} catch {
return buffer;
}
}
function applyChromaKeyToMediaPayload(payload: DecodedMediaPayload) {
if (payload.mimeType !== 'image/png' && payload.extension !== 'png') {
return payload;
}
return {
...payload,
buffer: applyGreenScreenAlphaToPngBuffer(payload.buffer),
mimeType: 'image/png',
extension: 'png',
} satisfies DecodedMediaPayload;
}
type CharacterPromptBundle = { type CharacterPromptBundle = {
visualPromptText: string; visualPromptText: string;
animationPromptText: string; animationPromptText: string;
@@ -355,6 +418,92 @@ function sanitizeCharacterPromptBundle(
}; };
} }
function sanitizeAnimationPromptText(value: string, maxLength: number) {
return value
.replace(/\s+/gu, ' ')
.replace(/||||||||/gu, '')
.replace(/||/gu, '')
.replace(/|/gu, '')
.replace(/|/gu, '')
.trim()
.slice(0, maxLength);
}
function buildCompactAnimationCharacterBrief(value: string) {
const normalized = sanitizeAnimationPromptText(value, 160);
if (!normalized) {
return '';
}
return normalized
.split(/[\/|\n,;]+/u)
.map((item) => item.trim())
.filter(Boolean)
.slice(0, 4)
.join('');
}
function isInappropriateContentMessage(value: string) {
return /finappropriate-content|inappropriate content||/iu.test(
value,
);
}
async function proxyJsonRequestWithPromptFallback(params: {
urlString: string;
apiKey: string;
buildBody: (prompt: string) => Record<string, unknown>;
primaryPrompt: string;
fallbackPrompt?: string;
extraHeaders?: Record<string, string>;
}) {
const firstResponse = await proxyJsonRequest(
params.urlString,
params.apiKey,
params.buildBody(params.primaryPrompt),
params.extraHeaders,
);
if (firstResponse.statusCode >= 200 && firstResponse.statusCode < 300) {
return {
response: firstResponse,
prompt: params.primaryPrompt,
moderationFallbackApplied: false,
};
}
const fallbackPrompt = params.fallbackPrompt?.trim() ?? '';
const errorMessage = extractApiErrorMessage(
firstResponse.bodyText,
'视频生成请求失败。',
);
if (
!fallbackPrompt ||
fallbackPrompt === params.primaryPrompt ||
!isInappropriateContentMessage(errorMessage)
) {
return {
response: firstResponse,
prompt: params.primaryPrompt,
moderationFallbackApplied: false,
};
}
const secondResponse = await proxyJsonRequest(
params.urlString,
params.apiKey,
params.buildBody(fallbackPrompt),
params.extraHeaders,
);
return {
response: secondResponse,
prompt: fallbackPrompt,
moderationFallbackApplied: true,
};
}
function buildCharacterPromptBundleUserPrompt(params: { function buildCharacterPromptBundleUserPrompt(params: {
roleKind: string; roleKind: string;
characterBriefText: string; characterBriefText: string;
@@ -463,13 +612,24 @@ async function writeJsonObjectFile(
} }
function decodeMediaDataUrl(dataUrl: string): DecodedMediaPayload { function decodeMediaDataUrl(dataUrl: string): DecodedMediaPayload {
const matched = /^data:([^;]+);base64,(.+)$/u.exec(dataUrl); const matched = /^data:([^,]+),(.+)$/u.exec(dataUrl);
if (!matched) { if (!matched) {
throw new Error('不支持的媒体数据,要求使用 Base64 Data URL。'); throw new Error('不支持的媒体数据,要求使用 Base64 Data URL。');
} }
const mimeType = matched[1]; const metadata = matched[1];
const base64Payload = matched[2]; const base64Payload = matched[2];
const metadataParts = metadata
.split(';')
.map((item) => item.trim())
.filter(Boolean);
const mimeType = metadataParts[0] ?? 'application/octet-stream';
const isBase64 = metadataParts.some((item) => item.toLowerCase() === 'base64');
if (!isBase64) {
throw new Error('不支持的媒体数据,要求使用 Base64 Data URL。');
}
const extension = (() => { const extension = (() => {
switch (mimeType) { switch (mimeType) {
case 'image/jpeg': case 'image/jpeg':
@@ -484,6 +644,8 @@ function decodeMediaDataUrl(dataUrl: string): DecodedMediaPayload {
return 'mov'; return 'mov';
case 'video/x-msvideo': case 'video/x-msvideo':
return 'avi'; return 'avi';
case 'video/webm':
return 'webm';
default: default:
return mimeType.split('/')[1] ?? 'bin'; return mimeType.split('/')[1] ?? 'bin';
} }
@@ -552,6 +714,15 @@ async function resolveMediaSourcePayload(
}; };
} }
async function resolveCharacterVisualPayload(
rootDir: string,
source: string,
): Promise<DecodedMediaPayload> {
return applyChromaKeyToMediaPayload(
await resolveMediaSourcePayload(rootDir, source),
);
}
async function resolveMediaSourceAsDataUrl( async function resolveMediaSourceAsDataUrl(
rootDir: string, rootDir: string,
source: string, source: string,
@@ -982,19 +1153,32 @@ function buildNpcAnimationPrompt(options: {
animation: string; animation: string;
promptText: string; promptText: string;
useChromaKey: boolean; useChromaKey: boolean;
loop: boolean;
characterBriefText?: string; characterBriefText?: string;
actionTemplateId?: string; actionTemplateId?: string;
}) { }) {
const characterBrief = buildCompactAnimationCharacterBrief(
options.characterBriefText ?? '',
);
const actionDetailText = sanitizeAnimationPromptText(options.promptText, 140);
const loopRule = options.loop
? '这是循环动作,直接进入动作循环中段,不要开场静止站桩,不要把主参考图原样作为第一帧。'
: '这是非循环动作,首帧和尾帧都要回到参考主图角色形象,中段完成动作变化。';
if (options.actionTemplateId) { if (options.actionTemplateId) {
return buildVideoActionPrompt({ return [
actionTemplate: getActionTemplateById( buildVideoActionPrompt({
options.actionTemplateId as Parameters<typeof getActionTemplateById>[0], actionTemplate: getActionTemplateById(
), options.actionTemplateId as Parameters<typeof getActionTemplateById>[0],
actionDetailText: options.promptText, ),
useChromaKey: options.useChromaKey, actionDetailText,
characterBrief: useChromaKey: options.useChromaKey,
options.characterBriefText?.trim() || `${options.animation} 动作角色`, characterBrief: characterBrief || `${options.animation} 动作角色`,
}); }),
loopRule,
]
.filter(Boolean)
.join(' ');
} }
return [ return [
@@ -1004,15 +1188,50 @@ function buildNpcAnimationPrompt(options: {
options.useChromaKey options.useChromaKey
? '背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。' ? '背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。'
: '背景简洁纯净,无复杂场景。', : '背景简洁纯净,无复杂场景。',
options.characterBriefText?.trim() characterBrief
? `角色设定:${options.characterBriefText.trim()}` ? `角色设定:${characterBrief}`
: '', : '',
options.promptText.trim(), actionDetailText,
loopRule,
] ]
.filter(Boolean) .filter(Boolean)
.join(' '); .join(' ');
} }
function buildFallbackModerationSafeAnimationPrompt(options: {
animation: string;
loop: boolean;
useChromaKey: boolean;
}) {
return [
`单人全身角色动作视频,动作主题是 ${options.animation}`,
'角色固定为同一人,右向斜侧身,镜头稳定,轮廓清楚。',
options.loop
? '循环动作直接进入稳定循环,不要静止开场,不要定格首帧。'
: '非循环动作首尾回到角色标准站姿,中段完成动作变化。',
options.useChromaKey
? '背景为纯绿色绿幕,无其他人物和场景元素。'
: '背景简洁纯净。',
]
.filter(Boolean)
.join(' ');
}
function getLowestSupportedVideoResolution(model: string, fallback: string) {
switch (model) {
case 'wan2.6-i2v-flash':
case 'wan2.6-i2v':
case 'wan2.6-i2v-us':
return '720P';
case 'wan2.2-kf2v-flash':
case 'wan2.2-i2v-flash':
case 'wan2.5-i2v-preview':
return '480P';
default:
return fallback;
}
}
async function handleGenerateCharacterPromptBundle( async function handleGenerateCharacterPromptBundle(
config: AppConfig, config: AppConfig,
req: IncomingMessage & { body?: unknown }, req: IncomingMessage & { body?: unknown },
@@ -1318,7 +1537,7 @@ async function handleGenerateCharacterVisuals(
const imageSrc = await writeDraftBinaryFile( const imageSrc = await writeDraftBinaryFile(
rootDir, rootDir,
path.posix.join(draftRelativeDir, fileName), path.posix.join(draftRelativeDir, fileName),
imageResponse.body, applyGreenScreenAlphaToPngBuffer(imageResponse.body),
); );
return { return {
@@ -1475,6 +1694,7 @@ async function handleGenerateCharacterAnimation(
Number.isFinite(body.durationSeconds) Number.isFinite(body.durationSeconds)
? Math.max(1, Math.min(8, Math.round(body.durationSeconds))) ? Math.max(1, Math.min(8, Math.round(body.durationSeconds)))
: 4; : 4;
const loop = body.loop === true;
const useChromaKey = body.useChromaKey !== false; const useChromaKey = body.useChromaKey !== false;
const resolution = const resolution =
typeof body.resolution === 'string' && body.resolution.trim() typeof body.resolution === 'string' && body.resolution.trim()
@@ -1487,15 +1707,28 @@ async function handleGenerateCharacterAnimation(
: runtimeEnv.DASHSCOPE_CHARACTER_IMAGE_SEQUENCE_MODEL || : runtimeEnv.DASHSCOPE_CHARACTER_IMAGE_SEQUENCE_MODEL ||
runtimeEnv.DASHSCOPE_CHARACTER_VISUAL_MODEL || runtimeEnv.DASHSCOPE_CHARACTER_VISUAL_MODEL ||
DEFAULT_CHARACTER_VISUAL_MODEL; DEFAULT_CHARACTER_VISUAL_MODEL;
const videoModel = const requestedVideoModel =
typeof body.videoModel === 'string' && body.videoModel.trim() typeof body.videoModel === 'string' && body.videoModel.trim()
? body.videoModel.trim() ? body.videoModel.trim()
: runtimeEnv.DASHSCOPE_CHARACTER_VIDEO_MODEL || : runtimeEnv.DASHSCOPE_CHARACTER_VIDEO_MODEL ||
DEFAULT_CHARACTER_VIDEO_MODEL; DEFAULT_CHARACTER_VIDEO_MODEL;
const loopVideoModel =
runtimeEnv.DASHSCOPE_CHARACTER_LOOP_VIDEO_MODEL ||
(requestedVideoModel === 'wan2.2-kf2v-flash'
? DEFAULT_CHARACTER_LOOP_VIDEO_MODEL
: requestedVideoModel) ||
DEFAULT_CHARACTER_LOOP_VIDEO_MODEL;
const keyframeVideoModel =
runtimeEnv.DASHSCOPE_CHARACTER_KEYFRAME_VIDEO_MODEL ||
DEFAULT_CHARACTER_VIDEO_MODEL;
const videoModel =
strategy === 'image-to-video' ? (loop ? loopVideoModel : keyframeVideoModel) : requestedVideoModel;
const durationSeconds = const durationSeconds =
videoModel === 'wan2.2-kf2v-flash' ? 5 : requestedDurationSeconds; videoModel === 'wan2.2-kf2v-flash' ? 5 : requestedDurationSeconds;
const normalizedResolution = const normalizedResolution = getLowestSupportedVideoResolution(
videoModel === 'wan2.2-kf2v-flash' ? '480P' : resolution; videoModel,
videoModel === 'wan2.2-kf2v-flash' ? '480P' : resolution,
);
const referenceVideoModel = const referenceVideoModel =
typeof body.referenceVideoModel === 'string' && typeof body.referenceVideoModel === 'string' &&
body.referenceVideoModel.trim() body.referenceVideoModel.trim()
@@ -1707,13 +1940,21 @@ async function handleGenerateCharacterAnimation(
animation, animation,
promptText, promptText,
useChromaKey, useChromaKey,
loop,
characterBriefText, characterBriefText,
actionTemplateId, actionTemplateId,
}); });
const fallbackPrompt = buildFallbackModerationSafeAnimationPrompt({
animation,
loop,
useChromaKey,
});
activePrompt = finalPrompt; activePrompt = finalPrompt;
activeModel = videoModel; activeModel = videoModel;
const isKf2vFlash = videoModel === 'wan2.2-kf2v-flash'; const isKf2vFlash = videoModel === 'wan2.2-kf2v-flash';
const visualInputRef = isKf2vFlash const isWan26I2vFlash = videoModel === 'wan2.6-i2v-flash';
const visualInputRef =
isKf2vFlash || isWan26I2vFlash
? await resolveMediaSourceAsDataUrl(rootDir, visualSource) ? await resolveMediaSourceAsDataUrl(rootDir, visualSource)
: await uploadFileToDashScope( : await uploadFileToDashScope(
baseUrl, baseUrl,
@@ -1722,9 +1963,12 @@ async function handleGenerateCharacterAnimation(
`${characterId}-${animation}-visual`, `${characterId}-${animation}-visual`,
await resolveMediaSourcePayload(rootDir, visualSource), await resolveMediaSourcePayload(rootDir, visualSource),
); );
const lastFrameRef = lastFrameImageDataUrl const resolvedLastFrameSource = !loop
? lastFrameImageDataUrl || visualSource
: '';
const lastFrameRef = resolvedLastFrameSource
? isKf2vFlash ? isKf2vFlash
? await resolveMediaSourceAsDataUrl(rootDir, lastFrameImageDataUrl) ? await resolveMediaSourceAsDataUrl(rootDir, resolvedLastFrameSource)
: await uploadFileToDashScope( : await uploadFileToDashScope(
baseUrl, baseUrl,
apiKey, apiKey,
@@ -1732,47 +1976,59 @@ async function handleGenerateCharacterAnimation(
`${characterId}-${animation}-last-frame`, `${characterId}-${animation}-last-frame`,
await resolveMediaSourcePayload( await resolveMediaSourcePayload(
rootDir, rootDir,
lastFrameImageDataUrl, resolvedLastFrameSource,
), ),
) )
: ''; : '';
const inputPayload = const createVideoRequestBody = (prompt: string) => ({
isKf2vFlash model: videoModel,
input: isKf2vFlash
? { ? {
prompt: finalPrompt, prompt,
first_frame_url: visualInputRef, first_frame_url: visualInputRef,
...(lastFrameRef ? { last_frame_url: lastFrameRef } : {}), ...(lastFrameRef ? { last_frame_url: lastFrameRef } : {}),
} }
: { : isWan26I2vFlash
prompt: finalPrompt, ? {
media: [ prompt,
{ type: 'first_frame', url: visualInputRef }, img_url: visualInputRef,
...(lastFrameRef }
? [{ type: 'last_frame', url: lastFrameRef }] : {
: []), prompt,
], media: [
}; { type: 'first_frame', url: visualInputRef },
...(lastFrameRef
? [{ type: 'last_frame', url: lastFrameRef }]
: []),
],
},
parameters: {
duration: durationSeconds,
resolution: normalizedResolution,
...(isKf2vFlash
? { prompt_extend: true, watermark: false }
: {}),
...(isWan26I2vFlash ? { audio: false } : {}),
},
});
const videoSynthesisEndpoint = isKf2vFlash const videoSynthesisEndpoint = isKf2vFlash
? `${baseUrl}/services/aigc/image2video/video-synthesis` ? `${baseUrl}/services/aigc/image2video/video-synthesis`
: `${baseUrl}/services/aigc/video-generation/video-synthesis`; : `${baseUrl}/services/aigc/video-generation/video-synthesis`;
const createTaskResponse = await proxyJsonRequest( const { response: createTaskResponse, prompt: submittedPrompt } =
videoSynthesisEndpoint, await proxyJsonRequestWithPromptFallback({
apiKey, urlString: videoSynthesisEndpoint,
{ apiKey,
model: videoModel, buildBody: createVideoRequestBody,
input: inputPayload, primaryPrompt: finalPrompt,
parameters: { fallbackPrompt,
duration: durationSeconds, extraHeaders: {
resolution: normalizedResolution, 'X-DashScope-Async': 'enable',
...(isKf2vFlash ? { prompt_extend: true, watermark: false } : {}), 'X-DashScope-OssResourceResolve': 'enable',
}, },
}, });
{
'X-DashScope-Async': 'enable', activePrompt = submittedPrompt;
'X-DashScope-OssResourceResolve': 'enable',
},
);
if ( if (
createTaskResponse.statusCode < 200 || createTaskResponse.statusCode < 200 ||
@@ -1809,7 +2065,7 @@ async function handleGenerateCharacterAnimation(
animation, animation,
strategy, strategy,
model: videoModel, model: videoModel,
prompt: finalPrompt, prompt: submittedPrompt,
createdAt, createdAt,
updatedAt: createdAt, updatedAt: createdAt,
}); });
@@ -1859,7 +2115,7 @@ async function handleGenerateCharacterAnimation(
model: videoModel, model: videoModel,
strategy, strategy,
animation, animation,
prompt: finalPrompt, prompt: submittedPrompt,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
videoUrl, videoUrl,
}, },
@@ -1877,7 +2133,7 @@ async function handleGenerateCharacterAnimation(
animation, animation,
strategy, strategy,
model: videoModel, model: videoModel,
prompt: finalPrompt, prompt: submittedPrompt,
createdAt, createdAt,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
result: { result: {
@@ -1891,7 +2147,7 @@ async function handleGenerateCharacterAnimation(
taskId, taskId,
strategy: 'image-to-video', strategy: 'image-to-video',
model: videoModel, model: videoModel,
prompt: finalPrompt, prompt: submittedPrompt,
previewVideoPath, previewVideoPath,
}); });
return; return;
@@ -1923,6 +2179,7 @@ async function handleGenerateCharacterAnimation(
animation, animation,
promptText, promptText,
useChromaKey, useChromaKey,
loop,
characterBriefText, characterBriefText,
}); });
activePrompt = finalPrompt; activePrompt = finalPrompt;
@@ -2081,8 +2338,8 @@ async function handleGenerateCharacterAnimation(
} }
if (strategy === 'reference-to-video') { if (strategy === 'reference-to-video') {
const uploadedReferenceUrls = await Promise.all([ const uploadedReferenceImages = await Promise.all(
...referenceImageDataUrls.map(async (source, index) => referenceImageDataUrls.map(async (source, index) =>
uploadFileToDashScope( uploadFileToDashScope(
baseUrl, baseUrl,
apiKey, apiKey,
@@ -2091,7 +2348,9 @@ async function handleGenerateCharacterAnimation(
await resolveMediaSourcePayload(rootDir, source), await resolveMediaSourcePayload(rootDir, source),
), ),
), ),
...referenceVideoDataUrls.map(async (source, index) => );
const uploadedReferenceVideos = await Promise.all(
referenceVideoDataUrls.map(async (source, index) =>
uploadFileToDashScope( uploadFileToDashScope(
baseUrl, baseUrl,
apiKey, apiKey,
@@ -2100,9 +2359,13 @@ async function handleGenerateCharacterAnimation(
await resolveMediaSourcePayload(rootDir, source), await resolveMediaSourcePayload(rootDir, source),
), ),
), ),
]); );
if (uploadedReferenceUrls.length === 0) { if (
!visualUrl &&
uploadedReferenceImages.length === 0 &&
uploadedReferenceVideos.length === 0
) {
sendJson(res, 400, { sendJson(res, 400, {
error: { message: '参考生视频至少需要一张参考图或一段参考视频。' }, error: { message: '参考生视频至少需要一张参考图或一段参考视频。' },
}); });
@@ -2113,6 +2376,7 @@ async function handleGenerateCharacterAnimation(
animation, animation,
promptText, promptText,
useChromaKey, useChromaKey,
loop,
characterBriefText, characterBriefText,
}); });
activePrompt = finalPrompt; activePrompt = finalPrompt;
@@ -2124,11 +2388,24 @@ async function handleGenerateCharacterAnimation(
model: referenceVideoModel, model: referenceVideoModel,
input: { input: {
prompt: finalPrompt, prompt: finalPrompt,
reference_urls: [visualUrl, ...uploadedReferenceUrls], media: [
{ type: 'reference_image', url: visualUrl },
...uploadedReferenceImages.map((url) => ({
type: 'reference_image' as const,
url,
})),
...uploadedReferenceVideos.map((url) => ({
type: 'reference_video' as const,
url,
})),
],
}, },
parameters: { parameters: {
duration: durationSeconds, duration: durationSeconds,
resolution, resolution: getLowestSupportedVideoResolution(
referenceVideoModel,
resolution,
),
prompt_optimizer: true, prompt_optimizer: true,
}, },
}, },
@@ -2688,7 +2965,7 @@ async function handlePublishCharacterVisual(
); );
await mkdir(visualDir, { recursive: true }); await mkdir(visualDir, { recursive: true });
const masterPayload = await resolveMediaSourcePayload( const masterPayload = await resolveCharacterVisualPayload(
rootDir, rootDir,
selectedPreviewSource, selectedPreviewSource,
); );
@@ -2697,7 +2974,7 @@ async function handlePublishCharacterVisual(
const previewImagePaths: string[] = []; const previewImagePaths: string[] = [];
for (let index = 0; index < previewSources.length; index += 1) { for (let index = 0; index < previewSources.length; index += 1) {
const previewPayload = await resolveMediaSourcePayload( const previewPayload = await resolveCharacterVisualPayload(
rootDir, rootDir,
previewSources[index] ?? '', previewSources[index] ?? '',
); );
@@ -2904,6 +3181,11 @@ async function handlePublishCharacterAnimation(
startFrame: 1, startFrame: 1,
extension: frameExtension, extension: frameExtension,
basePath, basePath,
frameWidth,
frameHeight,
fps,
loop,
...(previewVideoPath ? { previewVideoPath } : {}),
}; };
} }

View File

@@ -428,7 +428,8 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
landmark_count AS "landmarkCount" landmark_count AS "landmarkCount"
FROM custom_world_profiles FROM custom_world_profiles
WHERE user_id = $1 WHERE user_id = $1
AND profile_id = $2`, AND profile_id = $2
AND deleted_at IS NULL`,
[userId, profileId], [userId, profileId],
); );
@@ -887,6 +888,7 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
landmark_count AS "landmarkCount" landmark_count AS "landmarkCount"
FROM custom_world_profiles FROM custom_world_profiles
WHERE user_id = $1 WHERE user_id = $1
AND deleted_at IS NULL
ORDER BY updated_at DESC ORDER BY updated_at DESC
LIMIT $2`, LIMIT $2`,
[userId, MAX_CUSTOM_WORLD_PROFILES], [userId, MAX_CUSTOM_WORLD_PROFILES],
@@ -923,6 +925,7 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
ON CONFLICT (user_id, profile_id) DO UPDATE SET ON CONFLICT (user_id, profile_id) DO UPDATE SET
payload_json = EXCLUDED.payload_json, payload_json = EXCLUDED.payload_json,
updated_at = EXCLUDED.updated_at, updated_at = EXCLUDED.updated_at,
deleted_at = NULL,
author_display_name = EXCLUDED.author_display_name, author_display_name = EXCLUDED.author_display_name,
world_name = EXCLUDED.world_name, world_name = EXCLUDED.world_name,
subtitle = EXCLUDED.subtitle, subtitle = EXCLUDED.subtitle,
@@ -959,10 +962,17 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
} }
async deleteCustomWorldProfile(userId: string, profileId: string) { async deleteCustomWorldProfile(userId: string, profileId: string) {
const deletedAt = new Date().toISOString();
await this.db.query( await this.db.query(
`DELETE FROM custom_world_profiles `UPDATE custom_world_profiles
WHERE user_id = $1 AND profile_id = $2`, SET deleted_at = $1,
[userId, profileId], updated_at = $1,
visibility = 'draft',
published_at = NULL
WHERE user_id = $2
AND profile_id = $3
AND deleted_at IS NULL`,
[deletedAt, userId, profileId],
); );
return this.listCustomWorldProfiles(userId); return this.listCustomWorldProfiles(userId);
@@ -1172,6 +1182,7 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
landmark_count AS "landmarkCount" landmark_count AS "landmarkCount"
FROM custom_world_profiles FROM custom_world_profiles
WHERE visibility = 'published' WHERE visibility = 'published'
AND deleted_at IS NULL
ORDER BY published_at DESC, updated_at DESC ORDER BY published_at DESC, updated_at DESC
LIMIT $1`, LIMIT $1`,
[MAX_PUBLIC_CUSTOM_WORLD_PROFILES], [MAX_PUBLIC_CUSTOM_WORLD_PROFILES],
@@ -1202,7 +1213,8 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
FROM custom_world_profiles FROM custom_world_profiles
WHERE user_id = $1 WHERE user_id = $1
AND profile_id = $2 AND profile_id = $2
AND visibility = 'published'`, AND visibility = 'published'
AND deleted_at IS NULL`,
[ownerUserId, profileId], [ownerUserId, profileId],
); );

View File

@@ -18,6 +18,7 @@ const createSessionSchema = z.object({
const sendMessageSchema = z.object({ const sendMessageSchema = z.object({
clientMessageId: z.string().trim().min(1), clientMessageId: z.string().trim().min(1),
text: z.string().trim().min(1), text: z.string().trim().min(1),
quickFillRequested: z.boolean().optional().default(false),
focusCardId: z.string().trim().nullable().optional().default(null), focusCardId: z.string().trim().nullable().optional().default(null),
selectedCardIds: z.array(z.string().trim().min(1)).optional().default([]), selectedCardIds: z.array(z.string().trim().min(1)).optional().default([]),
}); });
@@ -134,6 +135,28 @@ export function createCustomWorldAgentRoutes(context: AppContext) {
}), }),
); );
router.post(
'/sessions/:sessionId/messages/stream',
routeMeta({ operation: 'runtime.customWorldAgent.streamMessage' }),
asyncHandler(async (request, response) => {
const sessionId = readParam(request.params.sessionId);
if (!sessionId) {
throw badRequest('sessionId is required');
}
const payload = sendMessageSchema.parse(
request.body,
) as SendCustomWorldAgentMessageRequest;
await context.customWorldAgentOrchestrator.streamMessage({
request,
response,
userId: request.userId!,
sessionId,
payload,
});
}),
);
router.post( router.post(
'/sessions/:sessionId/actions', '/sessions/:sessionId/actions',
routeMeta({ operation: 'runtime.customWorldAgent.executeAction' }), routeMeta({ operation: 'runtime.customWorldAgent.executeAction' }),

View File

@@ -5,6 +5,7 @@ import type {
CustomWorldFoundationDraftLandmark, CustomWorldFoundationDraftLandmark,
CustomWorldFoundationDraftProfile, CustomWorldFoundationDraftProfile,
CustomWorldFoundationDraftThread, CustomWorldFoundationDraftThread,
EightAnchorContent,
} from '../../../packages/shared/src/contracts/customWorldAgent.js'; } from '../../../packages/shared/src/contracts/customWorldAgent.js';
import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.js'; import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.js';
import { import {
@@ -36,6 +37,13 @@ import {
type CustomWorldCreatorIntentRecord, type CustomWorldCreatorIntentRecord,
normalizeCreatorIntentRecord, normalizeCreatorIntentRecord,
} from './customWorldAgentIntentExtractionService.js'; } from './customWorldAgentIntentExtractionService.js';
import {
buildCreatorIntentFromEightAnchorContent,
buildDraftSummaryFromEightAnchorContent,
buildDraftTitleFromEightAnchorContent,
buildEightAnchorFoundationText,
normalizeEightAnchorContent,
} from './eightAnchorCompatibilityService.js';
import type { UpstreamLlmClient } from './llmClient.js'; import type { UpstreamLlmClient } from './llmClient.js';
function toText(value: unknown) { function toText(value: unknown) {
@@ -923,7 +931,15 @@ function sanitizeJsonLikeText(text: string) {
function buildFoundationGenerationSeedText(params: { function buildFoundationGenerationSeedText(params: {
intent: CustomWorldCreatorIntentRecord; intent: CustomWorldCreatorIntentRecord;
anchorPack: unknown; anchorPack: unknown;
anchorContent?: EightAnchorContent | null;
}) { }) {
const anchorText = params.anchorContent
? buildEightAnchorFoundationText(params.anchorContent)
: '';
if (anchorText) {
return anchorText;
}
const anchorRecord = toRecord(params.anchorPack); const anchorRecord = toRecord(params.anchorPack);
const anchorSummary = toText(anchorRecord?.creatorIntentSummary); const anchorSummary = toText(anchorRecord?.creatorIntentSummary);
if (anchorSummary) { if (anchorSummary) {
@@ -1574,12 +1590,14 @@ async function buildFoundationDraftProfileWithLlm(params: {
llmClient: UpstreamLlmClient; llmClient: UpstreamLlmClient;
creatorIntent: CustomWorldCreatorIntentRecord; creatorIntent: CustomWorldCreatorIntentRecord;
anchorPack: unknown; anchorPack: unknown;
anchorContent?: EightAnchorContent | null;
signal?: AbortSignal; signal?: AbortSignal;
onProgress?: DraftProgressCallback; onProgress?: DraftProgressCallback;
}) { }) {
const settingText = buildFoundationGenerationSeedText({ const settingText = buildFoundationGenerationSeedText({
intent: params.creatorIntent, intent: params.creatorIntent,
anchorPack: params.anchorPack, anchorPack: params.anchorPack,
anchorContent: params.anchorContent,
}); });
await emitDraftProgress(params.onProgress, { await emitDraftProgress(params.onProgress, {
@@ -1720,22 +1738,14 @@ export class CustomWorldAgentFoundationDraftService {
private generateFallbackDraft(params: { private generateFallbackDraft(params: {
creatorIntent: unknown; creatorIntent: unknown;
anchorPack: unknown; anchorPack: unknown;
anchorContent?: EightAnchorContent | null;
}): CustomWorldFoundationDraftProfile { }): CustomWorldFoundationDraftProfile {
const intent = normalizeCreatorIntentRecord(params.creatorIntent) ?? { const normalizedAnchorContent = normalizeEightAnchorContent(
sourceMode: 'freeform' as const, params.anchorContent,
rawSettingText: '', );
worldHook: '', const intent =
themeKeywords: [], normalizeCreatorIntentRecord(params.creatorIntent) ??
toneDirectives: [], buildCreatorIntentFromEightAnchorContent(normalizedAnchorContent);
playerPremise: '',
openingSituation: '',
coreConflicts: [],
keyFactions: [],
keyCharacters: [],
keyLandmarks: [],
iconicElements: [],
forbiddenDirectives: [],
};
const anchorPack = toRecord(params.anchorPack); const anchorPack = toRecord(params.anchorPack);
const worldHook = const worldHook =
clampText(intent.worldHook || intent.rawSettingText, 72) || clampText(intent.worldHook || intent.rawSettingText, 72) ||
@@ -1757,6 +1767,8 @@ export class CustomWorldAgentFoundationDraftService {
openingSituation, openingSituation,
coreConflict: coreConflicts[0] || '', coreConflict: coreConflicts[0] || '',
}); });
const anchorDraftTitle =
buildDraftTitleFromEightAnchorContent(normalizedAnchorContent);
const factions = buildFactions({ const factions = buildFactions({
intent, intent,
coreConflicts, coreConflicts,
@@ -1815,7 +1827,10 @@ export class CustomWorldAgentFoundationDraftService {
); );
return { return {
name: worldName, name:
anchorDraftTitle && anchorDraftTitle !== '未命名草稿'
? anchorDraftTitle
: worldName,
subtitle: subtitle:
clampText( clampText(
[ [
@@ -1845,6 +1860,7 @@ export class CustomWorldAgentFoundationDraftService {
openingSituation, openingSituation,
iconicElements, iconicElements,
sourceAnchorSummary: sourceAnchorSummary:
buildDraftSummaryFromEightAnchorContent(normalizedAnchorContent) ||
toText(anchorPack?.creatorIntentSummary) || toText(anchorPack?.creatorIntentSummary) ||
buildDraftSummaryFromIntent(intent) || buildDraftSummaryFromIntent(intent) ||
summary, summary,
@@ -1854,10 +1870,15 @@ export class CustomWorldAgentFoundationDraftService {
async generate(params: { async generate(params: {
creatorIntent: unknown; creatorIntent: unknown;
anchorPack: unknown; anchorPack: unknown;
anchorContent?: EightAnchorContent | null;
signal?: AbortSignal; signal?: AbortSignal;
onProgress?: DraftProgressCallback; onProgress?: DraftProgressCallback;
}): Promise<CustomWorldFoundationDraftProfile> { }): Promise<CustomWorldFoundationDraftProfile> {
const intent = normalizeCreatorIntentRecord(params.creatorIntent); const intent =
normalizeCreatorIntentRecord(params.creatorIntent) ??
buildCreatorIntentFromEightAnchorContent(
normalizeEightAnchorContent(params.anchorContent),
);
if (!this.llmClient || !intent) { if (!this.llmClient || !intent) {
return this.generateFallbackDraft(params); return this.generateFallbackDraft(params);
@@ -1867,6 +1888,7 @@ export class CustomWorldAgentFoundationDraftService {
llmClient: this.llmClient, llmClient: this.llmClient,
creatorIntent: intent, creatorIntent: intent,
anchorPack: params.anchorPack, anchorPack: params.anchorPack,
anchorContent: params.anchorContent,
signal: params.signal, signal: params.signal,
onProgress: params.onProgress, onProgress: params.onProgress,
}); });

View File

@@ -1,4 +1,5 @@
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import type { Request, Response } from 'express';
import type { import type {
CreateCustomWorldAgentSessionRequest, CreateCustomWorldAgentSessionRequest,
@@ -14,6 +15,7 @@ import type {
SendCustomWorldAgentMessageResponse, SendCustomWorldAgentMessageResponse,
} from '../../../packages/shared/src/contracts/customWorldAgent.js'; } from '../../../packages/shared/src/contracts/customWorldAgent.js';
import { badRequest, notFound } from '../errors.js'; import { badRequest, notFound } from '../errors.js';
import { prepareEventStreamResponse } from '../http.js';
import { CustomWorldAgentAssetBridgeService } from './customWorldAgentAssetBridgeService.js'; import { CustomWorldAgentAssetBridgeService } from './customWorldAgentAssetBridgeService.js';
import { CustomWorldAgentChangeSummaryService } from './customWorldAgentChangeSummaryService.js'; import { CustomWorldAgentChangeSummaryService } from './customWorldAgentChangeSummaryService.js';
import { import {
@@ -31,7 +33,6 @@ import { CustomWorldAgentEntityGenerationService } from './customWorldAgentEntit
import { CustomWorldAgentFoundationDraftService } from './customWorldAgentFoundationDraftService.js'; import { CustomWorldAgentFoundationDraftService } from './customWorldAgentFoundationDraftService.js';
import { import {
buildAnchorPackFromIntent, buildAnchorPackFromIntent,
buildCreatorIntentDisplayText,
buildDraftSummaryFromIntent, buildDraftSummaryFromIntent,
buildDraftTitleFromIntent, buildDraftTitleFromIntent,
createEmptyCreatorIntentRecord, createEmptyCreatorIntentRecord,
@@ -49,11 +50,16 @@ import {
type CustomWorldAgentSessionRecord, type CustomWorldAgentSessionRecord,
CustomWorldAgentSessionStore, CustomWorldAgentSessionStore,
} from './customWorldAgentSessionStore.js'; } from './customWorldAgentSessionStore.js';
import {
buildAnchorPackFromEightAnchorContent,
buildCreatorIntentFromEightAnchorContent,
buildEightAnchorContentFromCreatorIntent,
estimateProgressPercentFromAnchorContent,
} from './eightAnchorCompatibilityService.js';
import { EightAnchorSingleTurnService } from './eightAnchorSingleTurnService.js';
import type { UpstreamLlmClient } from './llmClient.js'; import type { UpstreamLlmClient } from './llmClient.js';
const PHASE2_FORCE_FAIL_TOKEN = '__phase1_force_fail__'; const PHASE2_FORCE_FAIL_TOKEN = '__phase1_force_fail__';
const AUTO_COMPLETE_PATTERN = /||/u;
function truncateText(value: string, maxLength: number) { function truncateText(value: string, maxLength: number) {
if (value.length <= maxLength) { if (value.length <= maxLength) {
return value; return value;
@@ -137,46 +143,10 @@ function buildSuggestedActions(
return actions; return actions;
} }
function buildAutoCompletePatch(intent: CustomWorldCreatorIntentRecord) {
return {
worldHook:
intent.worldHook ||
intent.rawSettingText ||
'一个被未知异象改变秩序的边境世界。',
playerPremise: intent.playerPremise || '玩家是被卷入核心危机的返乡者。',
openingSituation:
intent.openingSituation || '开局时,玩家正抵达危机爆发的现场。',
themeKeywords:
intent.themeKeywords.length > 0 ? intent.themeKeywords : ['奇幻'],
toneDirectives:
intent.toneDirectives.length > 0 ? intent.toneDirectives : ['悬疑'],
coreConflicts:
intent.coreConflicts.length > 0
? intent.coreConflicts
: ['旧秩序与新威胁正在争夺世界的未来。'],
keyCharacters:
intent.keyCharacters.length > 0
? intent.keyCharacters
: [
{
id: 'auto-key-character-1',
name: '未命名关键人物',
role: '关键关系',
publicMask: '看似能帮助玩家的人。',
hiddenHook: '掌握一条会改变局势的暗线。',
relationToPlayer: '旧识',
notes: '自动补全,可继续修改。',
},
],
iconicElements:
intent.iconicElements.length > 0 ? intent.iconicElements : ['失落信标'],
};
}
function buildOperation(type: CustomWorldAgentOperationRecord['type']) { function buildOperation(type: CustomWorldAgentOperationRecord['type']) {
const phaseDetail = const phaseDetail =
type === 'draft_foundation' type === 'draft_foundation'
? '正在把已确认锚点编成第一版世界底稿。' ? '正在把已确认设定编成第一版世界底稿。'
: type === 'update_draft_card' : type === 'update_draft_card'
? '正在把这次设定改动写回草稿。' ? '正在把这次设定改动写回草稿。'
: type === 'generate_characters' : type === 'generate_characters'
@@ -184,10 +154,10 @@ function buildOperation(type: CustomWorldAgentOperationRecord['type']) {
: type === 'generate_landmarks' : type === 'generate_landmarks'
? '正在围绕当前底稿补出新地点。' ? '正在围绕当前底稿补出新地点。'
: type === 'generate_role_assets' : type === 'generate_role_assets'
? '正在准备角色资产工坊入口。' ? '正在准备角色资产工坊入口。'
: type === 'sync_role_assets' : type === 'sync_role_assets'
? '正在把角色资产结果写回世界草稿。' ? '正在把角色资产结果写回世界草稿。'
: '正在整理这一轮新增的世界锚点。'; : '正在整理这一轮新增的世界设定。';
return { return {
operationId: `operation-${crypto.randomBytes(10).toString('hex')}`, operationId: `operation-${crypto.randomBytes(10).toString('hex')}`,
@@ -223,20 +193,10 @@ function buildRoleAssetSyncResultText(params: {
return `已把「${params.roleName}」的角色资产写回草稿,当前状态:${params.assetStatusLabel}`; return `已把「${params.roleName}」的角色资产写回草稿,当前状态:${params.assetStatusLabel}`;
} }
function getRecentUserMessages(session: CustomWorldAgentSessionRecord) {
return session.messages
.filter((message) => message.role === 'user')
.map((message) => message.text.trim())
.filter(Boolean)
.slice(-12);
}
function buildQuestionLines( function buildQuestionLines(
pendingClarifications: CustomWorldPendingClarification[], pendingClarifications: CustomWorldPendingClarification[],
) { ) {
return pendingClarifications.map( return pendingClarifications.map((entry) => entry.question.trim());
(entry, index) => `${index + 1}. ${entry.question}`,
);
} }
function composeAssistantReply(params: { function composeAssistantReply(params: {
@@ -250,7 +210,7 @@ function composeAssistantReply(params: {
return [ return [
params.openingText, params.openingText,
params.isReady params.isReady
? '最小锚点已经齐备。' ? '当前设定已经齐备。'
: questionLines.slice(0, 1).join('\n'), : questionLines.slice(0, 1).join('\n'),
].join('\n'); ].join('\n');
} }
@@ -311,227 +271,6 @@ function buildWelcomeMessage(params: {
}); });
} }
function buildAssistantMessage(params: {
latestUserText: string;
relatedOperationId: string;
intent: CustomWorldCreatorIntentRecord;
pendingClarifications: CustomWorldPendingClarification[];
isReady: boolean;
}) {
return {
id: `message-${crypto.randomBytes(8).toString('hex')}`,
role: 'assistant',
kind: params.isReady ? 'summary' : 'clarification',
text: composeAssistantReply({
openingText: `收到:${truncateText(params.latestUserText, 88)}`,
intent: params.intent,
pendingClarifications: params.pendingClarifications,
isReady: params.isReady,
}),
createdAt: new Date().toISOString(),
relatedOperationId: params.relatedOperationId,
} satisfies CustomWorldAgentMessage;
}
function buildAgentSystemPrompt(params: {
isReady: boolean;
hasAnyAnchors: boolean;
}) {
const baseInstructions = [
'你是一个专业的RPG游戏剧情策划通过对话帮助用户补全结构化世界锚点。',
'',
'# 核心原则',
'- 像创作者搭档,不要写系统说明,不要列规则,不要提到 JSON 或后端',
'- 用中文自然回复,语气专业但友好',
'- 不要重复追问用户已经明确回答过的信息',
'- 每次只聚焦一个关键问题,帮助用户高效推进',
'',
'# 输出格式',
'必须输出严格的 JSON 格式:{“reply”:”...”,”recommendedReplies”:[“...”,”...”,”...”]}',
'',
];
if (params.isReady) {
return [
...baseInstructions,
'# 当前阶段:设定已齐备',
'',
'## reply 字段要求',
'- 第一段:明确回应并收住用户刚刚给出的具体设定',
'- 第二段:明确告诉用户关键设定已经足够,可以生成第一版游戏草稿了',
'- 最后:自然询问是否现在开始生成草稿',
'- 整体要短,聚焦推进',
'',
'## recommendedReplies 字段要求',
'- 必须正好 3 条',
'- 每条都是用户下一句可以直接发送的话',
'- 第 1 条:表达开始生成草稿(例如:”现在开始生成草稿”)',
'- 第 2 条:让 Agent 总结当前设定(例如:”先总结一下当前设定”)',
'- 第 3 条:继续补充设定内容(例如:”我还想再补充一点”)',
].join('\n');
}
// When anchors are empty, use inspirational questioning strategy
if (!params.hasAnyAnchors) {
return [
...baseInstructions,
'# 当前阶段:初始启发',
'',
'## reply 字段要求',
'- 第一段:如果用户刚进入对话还没说话,用欢迎语气开场(例如:”想创造一个什么样的世界?”)',
'- 第一段:如果用户已经说了话,简短回应用户的输入',
'- 第二段:提出一个开放性、启发性的问题,帮助用户构思世界的核心概念',
'- 问题应该是高层次的,关于世界类型、主题、核心理念,而不是具体细节',
'- 例如:世界的整体风格、故事的核心主题、想传达的感觉',
'- 避免过早询问具体设定细节(如魔法系统、科技水平等)',
'',
'## recommendedReplies 字段要求',
'- 必须正好 3 条',
'- 3 条都是对当前问题的不同方向的回答',
'- 每条回答应该代表一种不同的世界类型或主题方向',
'- 回答要具体但不过于详细,给用户启发和选择空间',
].join('\n');
}
return [
...baseInstructions,
'# 当前阶段:收集设定中',
'',
'## reply 字段要求',
'- 第一段:明确回应并收住用户上一次给出的具体落地设定(不能只说”收到”)',
'- 第二段:固定只追问 1 个当前最关键、最能推进游戏设定的问题',
'- 这个问题必须帮助你更快拿到作品最核心的设定信息',
'- 必要时给一个很短的示例,帮助用户高效回答',
'',
'## recommendedReplies 字段要求',
'- 必须正好 3 条',
'- 3 条都必须是对当前这一个问题的直接回答',
'- 不允许继续提问',
'- 不允许写成”你先帮我””继续问我”这种让 Agent 行动的句子',
'- 回答要尽量具体,优先提供能推进作品设定的核心信息',
].join('\n');
}
function buildAgentUserPrompt(params: {
session: CustomWorldAgentSessionRecord;
latestUserText: string;
intent: CustomWorldCreatorIntentRecord;
pendingClarifications: CustomWorldPendingClarification[];
isReady: boolean;
}) {
const recentMessages = params.session.messages
.slice(-18)
.map((message) => `${message.role}: ${message.text}`)
.join('\n');
const pendingQuestions = params.pendingClarifications
.slice(0, 1)
.map((entry) => `${entry.label}: ${entry.question}`)
.join('\n');
return [
'# 当前结构化世界锚点',
buildCreatorIntentDisplayText(params.intent) || '暂无',
'',
`# 锚点是否齐备`,
params.isReady ? '是' : '否',
'',
pendingQuestions ? `# 待确认问题\n${pendingQuestions}\n` : '',
'# 最近对话',
recentMessages || '暂无',
'',
'# 用户最新输入',
params.latestUserText,
]
.filter(Boolean)
.join('\n');
}
function parseAssistantTurnJson(text: string) {
try {
const parsed = JSON.parse(text) as {
reply?: unknown;
recommendedReplies?: unknown;
};
const reply = typeof parsed.reply === 'string' ? parsed.reply.trim() : '';
const recommendedReplies = Array.isArray(parsed.recommendedReplies)
? parsed.recommendedReplies
.map((item) => (typeof item === 'string' ? item.trim() : ''))
.filter(Boolean)
.slice(0, 3)
: [];
return {
reply,
recommendedReplies,
};
} catch {
return {
reply: '',
recommendedReplies: [],
};
}
}
function buildFallbackRecommendedReplies(params: {
pendingClarifications: CustomWorldPendingClarification[];
isReady: boolean;
}) {
if (params.isReady) {
return ['现在开始生成草稿', '先总结一下当前设定', '我还想再补充一点'];
}
const nextQuestion = params.pendingClarifications[0];
if (!nextQuestion) {
return ['继续', '给我一个默认方案', '先总结一下'];
}
if (nextQuestion.targetKey === 'world_hook') {
return [
'一个被潮雾切开的列岛世界。',
'一个旧神遗产复苏的边境世界。',
'一个灯塔决定航路生死的海雾世界。',
];
}
if (nextQuestion.targetKey === 'player_premise') {
return [
'玩家是被迫返乡的失职守灯人。',
'玩家是背着旧案回来的流亡航海士。',
'玩家是被逐出组织的前探路员。',
];
}
if (nextQuestion.targetKey === 'theme_and_tone') {
return [
'整体偏冷峻、潮湿、悬疑。',
'气质偏压迫、克制、带一点宿命感。',
'我想要浪漫外壳下的阴冷悬疑。',
];
}
if (nextQuestion.targetKey === 'core_conflict') {
return [
'核心冲突是旧航路解释权之争。',
'主要危机是被封印的灾难正在重演。',
'核心矛盾是守旧势力和新秩序正面冲突。',
];
}
if (nextQuestion.targetKey === 'relationship_seed') {
return [
'关键人物是玩家的旧友兼宿敌。',
'她表面帮助玩家,其实另有立场。',
'关键钩子是玩家必须再次相信曾经背叛自己的人。',
];
}
return [
'标志性元素是潮雾钟声。',
'标志性规则是夜里不能出海。',
'地标意象是永不熄灭的盐火灯塔。',
];
}
function buildFoundationDraftAssistantMessage(params: { function buildFoundationDraftAssistantMessage(params: {
relatedOperationId: string; relatedOperationId: string;
draftProfile: unknown; draftProfile: unknown;
@@ -555,31 +294,6 @@ function buildFoundationDraftAssistantMessage(params: {
} satisfies CustomWorldAgentMessage; } satisfies CustomWorldAgentMessage;
} }
function buildObjectRefiningAssistantMessage(params: {
latestUserText: string;
relatedOperationId: string;
draftProfile: unknown;
}) {
const profile = normalizeFoundationDraftProfile(params.draftProfile);
const leadCharacter = profile?.playableNpcs[0];
const leadLandmark = profile?.landmarks[0];
return {
id: `message-${crypto.randomBytes(8).toString('hex')}`,
role: 'assistant',
kind: 'summary',
text: [
`我先把你这轮补充挂回当前底稿语境里:${truncateText(params.latestUserText, 88)}`,
'',
profile?.summary || '当前底稿仍然保留,你可以继续围绕已有卡片精修。',
'',
`现在更适合直接看卡继续收紧内容${leadCharacter ? `,角色建议先看「${leadCharacter.name}` : ''}${leadLandmark ? `,地点建议先看「${leadLandmark.name}` : ''}`,
].join('\n'),
createdAt: new Date().toISOString(),
relatedOperationId: params.relatedOperationId,
} satisfies CustomWorldAgentMessage;
}
function buildActionResultMessage(params: { function buildActionResultMessage(params: {
relatedOperationId: string; relatedOperationId: string;
text: string; text: string;
@@ -594,6 +308,19 @@ function buildActionResultMessage(params: {
} satisfies CustomWorldAgentMessage; } satisfies CustomWorldAgentMessage;
} }
function writeSseEvent(
response: Response,
event: string,
data: unknown,
) {
if (response.writableEnded) {
return;
}
response.write(`event: ${event}\n`);
response.write(`data: ${JSON.stringify(data)}\n\n`);
}
export class CustomWorldAgentOrchestrator { export class CustomWorldAgentOrchestrator {
private readonly foundationDraftService: CustomWorldAgentFoundationDraftService; private readonly foundationDraftService: CustomWorldAgentFoundationDraftService;
@@ -605,9 +332,14 @@ export class CustomWorldAgentOrchestrator {
private readonly assetBridgeService: CustomWorldAgentAssetBridgeService; private readonly assetBridgeService: CustomWorldAgentAssetBridgeService;
private readonly eightAnchorSingleTurnService: EightAnchorSingleTurnService;
constructor( constructor(
private readonly sessionStore: CustomWorldAgentSessionStore, private readonly sessionStore: CustomWorldAgentSessionStore,
private readonly llmClient: UpstreamLlmClient | null = null, llmClient: UpstreamLlmClient | null = null,
options: {
singleTurnLlmClient?: UpstreamLlmClient | null;
} = {},
) { ) {
this.foundationDraftService = new CustomWorldAgentFoundationDraftService( this.foundationDraftService = new CustomWorldAgentFoundationDraftService(
llmClient, llmClient,
@@ -618,6 +350,9 @@ export class CustomWorldAgentOrchestrator {
); );
this.changeSummaryService = new CustomWorldAgentChangeSummaryService(); this.changeSummaryService = new CustomWorldAgentChangeSummaryService();
this.assetBridgeService = new CustomWorldAgentAssetBridgeService(); this.assetBridgeService = new CustomWorldAgentAssetBridgeService();
this.eightAnchorSingleTurnService = new EightAnchorSingleTurnService(
(options.singleTurnLlmClient ?? llmClient) ?? undefined,
);
} }
async createSession( async createSession(
@@ -634,59 +369,35 @@ export class CustomWorldAgentOrchestrator {
: {}; : {};
const creatorIntent = mergeCreatorIntentRecord(baseIntent, seedPatch); const creatorIntent = mergeCreatorIntentRecord(baseIntent, seedPatch);
const derivedState = buildDerivedState(creatorIntent, Boolean(seedText)); const derivedState = buildDerivedState(creatorIntent, Boolean(seedText));
const anchorContent = buildEightAnchorContentFromCreatorIntent(creatorIntent);
const progressPercent = seedText
? estimateProgressPercentFromAnchorContent(anchorContent)
: 0;
const fallbackWelcomeMessage = buildWelcomeMessage({ const fallbackWelcomeMessage = buildWelcomeMessage({
seedText, seedText,
intent: creatorIntent, intent: creatorIntent,
pendingClarifications: derivedState.pendingClarifications, pendingClarifications: derivedState.pendingClarifications,
isReady: derivedState.readiness.isReady, isReady: derivedState.readiness.isReady,
}); });
const initialAssistantTurn = await this.generateAssistantTurn({
session: {
sessionId: 'preview',
userId,
seedText,
stage: derivedState.stage,
focusCardId: null,
creatorIntent,
creatorIntentReadiness: derivedState.readiness,
anchorPack: derivedState.anchorPack,
lockState: {},
draftProfile: derivedState.draftProfile,
messages: [],
draftCards: [],
pendingClarifications: derivedState.pendingClarifications,
suggestedActions: derivedState.suggestedActions,
recommendedReplies: [],
qualityFindings: [],
assetCoverage: {
roleAssets: [],
sceneAssets: [],
allRoleAssetsReady: false,
allSceneAssetsReady: false,
},
operations: [],
checkpoints: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
latestUserText: seedText,
fallbackReply: fallbackWelcomeMessage,
intent: creatorIntent,
pendingClarifications: derivedState.pendingClarifications,
isReady: derivedState.readiness.isReady,
});
const record = await this.sessionStore.create(userId, { const record = await this.sessionStore.create(userId, {
seedText, seedText,
welcomeMessage: initialAssistantTurn.reply, welcomeMessage: fallbackWelcomeMessage,
currentTurn: 0,
anchorContent,
progressPercent,
lastAssistantReply: fallbackWelcomeMessage,
creatorIntent, creatorIntent,
creatorIntentReadiness: derivedState.readiness, creatorIntentReadiness: derivedState.readiness,
anchorPack: derivedState.anchorPack, anchorPack: buildAnchorPackFromEightAnchorContent(
anchorContent,
progressPercent,
),
draftProfile: derivedState.draftProfile, draftProfile: derivedState.draftProfile,
pendingClarifications: derivedState.pendingClarifications, pendingClarifications: derivedState.pendingClarifications,
stage: derivedState.stage, stage: progressPercent >= 100 ? 'foundation_review' : 'collecting_intent',
suggestedActions: derivedState.suggestedActions, suggestedActions: derivedState.suggestedActions,
recommendedReplies: initialAssistantTurn.recommendedReplies, recommendedReplies: [],
}); });
return (await this.sessionStore.getSnapshot( return (await this.sessionStore.getSnapshot(
@@ -723,6 +434,7 @@ export class CustomWorldAgentOrchestrator {
sessionId, sessionId,
operationId: operation.operationId, operationId: operation.operationId,
latestUserText: trimmedText, latestUserText: trimmedText,
quickFillRequested: Boolean(payload.quickFillRequested),
}); });
return { return {
@@ -730,6 +442,68 @@ export class CustomWorldAgentOrchestrator {
}; };
} }
async streamMessage(params: {
request: Request;
response: Response;
userId: string;
sessionId: string;
payload: SendCustomWorldAgentMessageRequest;
}) {
const session = await this.sessionStore.get(params.userId, params.sessionId);
if (!session) {
throw notFound('custom world agent session not found');
}
prepareEventStreamResponse(params.request, params.response);
const trimmedText = params.payload.text.trim();
const userMessage = buildUserMessage(
trimmedText,
params.payload.clientMessageId,
);
await this.sessionStore.appendMessage(
params.userId,
params.sessionId,
userMessage,
);
let latestReplyText = '';
try {
const nextSession = await this.applyMessageTurn({
userId: params.userId,
sessionId: params.sessionId,
latestUserText: trimmedText,
quickFillRequested: Boolean(params.payload.quickFillRequested),
relatedOperationId: null,
onReplyUpdate: (text) => {
if (!text.trim() || text === latestReplyText) {
return;
}
latestReplyText = text;
writeSseEvent(params.response, 'reply_delta', {
text,
});
},
});
writeSseEvent(params.response, 'session', {
session: nextSession,
});
writeSseEvent(params.response, 'done', {
ok: true,
});
} catch (error) {
writeSseEvent(params.response, 'error', {
message:
error instanceof Error ? error.message : 'stream custom world message failed',
});
} finally {
params.response.end();
}
}
async executeAction( async executeAction(
userId: string, userId: string,
sessionId: string, sessionId: string,
@@ -741,14 +515,8 @@ export class CustomWorldAgentOrchestrator {
} }
if (payload.action === 'draft_foundation') { if (payload.action === 'draft_foundation') {
if (session.stage !== 'foundation_review') { if (session.progressPercent < 100) {
throw badRequest( throw badRequest('draft_foundation requires progressPercent >= 100');
'draft_foundation is only available during foundation_review',
);
}
if (!session.creatorIntentReadiness.isReady) {
throw badRequest('draft_foundation requires a ready session');
} }
const operation = buildOperation('draft_foundation'); const operation = buildOperation('draft_foundation');
@@ -919,57 +687,151 @@ export class CustomWorldAgentOrchestrator {
return this.draftCompiler.getDraftCardDetail(session.draftProfile, cardId); return this.draftCompiler.getDraftCardDetail(session.draftProfile, cardId);
} }
private async generateAssistantTurn(params: { private async applyMessageTurn(params: {
session: CustomWorldAgentSessionRecord; userId: string;
sessionId: string;
latestUserText: string; latestUserText: string;
fallbackReply: string; quickFillRequested: boolean;
intent: CustomWorldCreatorIntentRecord; relatedOperationId?: string | null;
pendingClarifications: CustomWorldPendingClarification[]; onReplyUpdate?: (text: string) => void;
isReady: boolean;
}) { }) {
const fallbackReplies = buildFallbackRecommendedReplies({ const latestSession = (await this.sessionStore.get(
pendingClarifications: params.pendingClarifications, params.userId,
isReady: params.isReady, params.sessionId,
)) as CustomWorldAgentSessionRecord | null;
if (!latestSession) {
throw new Error('custom world agent session not found');
}
const shouldPreserveDraftStage =
(latestSession.stage === 'object_refining' ||
latestSession.stage === 'visual_refining') &&
latestSession.draftCards.length > 0;
const assistantTurn = await this.eightAnchorSingleTurnService.streamTurn(
{
currentTurn: latestSession.currentTurn + 1,
progressPercent: latestSession.progressPercent,
quickFillRequested: params.quickFillRequested,
currentAnchorContent: latestSession.anchorContent,
chatHistory: latestSession.messages
.filter(
(message): message is CustomWorldAgentMessage =>
(message.role === 'user' || message.role === 'assistant') &&
Boolean(message.text.trim()),
)
.map((message) => ({
role: message.role,
content: message.text,
})),
},
{
onReplyUpdate: params.onReplyUpdate,
},
);
const nextCreatorIntent = buildCreatorIntentFromEightAnchorContent(
assistantTurn.nextAnchorContent,
);
const progressPercent = Math.max(
0,
Math.min(100, Math.round(assistantTurn.progressPercent)),
);
const creatorIntentReadiness =
progressPercent >= 100
? {
isReady: true,
completedKeys: [
'world_hook',
'player_premise',
'theme_and_tone',
'core_conflict',
'relationship_seed',
'iconic_element',
],
missingKeys: [],
}
: evaluateCreatorIntentReadiness(nextCreatorIntent);
const derivedState = buildDerivedState(nextCreatorIntent, true);
const preservedStage =
latestSession.stage === 'visual_refining'
? ('visual_refining' as const)
: ('object_refining' as const);
const shouldStayInDraftStage =
shouldPreserveDraftStage && progressPercent >= 100;
const nextStage = shouldStayInDraftStage
? preservedStage
: derivedState.stage;
const assistantMessage = {
id: `message-${crypto.randomBytes(8).toString('hex')}`,
role: 'assistant',
kind: 'chat',
text: assistantTurn.replyText,
createdAt: new Date().toISOString(),
relatedOperationId: params.relatedOperationId ?? null,
} satisfies CustomWorldAgentMessage;
await this.sessionStore.replaceDerivedState(params.userId, params.sessionId, {
currentTurn: latestSession.currentTurn + 1,
anchorContent: assistantTurn.nextAnchorContent,
progressPercent,
lastAssistantReply: assistantTurn.replyText,
stage: nextStage,
focusCardId: shouldStayInDraftStage ? latestSession.focusCardId : null,
creatorIntent: nextCreatorIntent,
creatorIntentReadiness,
anchorPack: buildAnchorPackFromEightAnchorContent(
assistantTurn.nextAnchorContent,
progressPercent,
),
draftProfile: shouldStayInDraftStage
? latestSession.draftProfile
: progressPercent >= 100
? {
title: buildDraftTitleFromIntent(nextCreatorIntent),
summary: buildDraftSummaryFromIntent(nextCreatorIntent),
}
: derivedState.draftProfile,
draftCards: shouldStayInDraftStage ? latestSession.draftCards : [],
assetCoverage: shouldStayInDraftStage
? latestSession.assetCoverage
: rebuildRoleAssetCoverage(
progressPercent >= 100
? {
title: buildDraftTitleFromIntent(nextCreatorIntent),
summary: buildDraftSummaryFromIntent(nextCreatorIntent),
}
: derivedState.draftProfile,
),
pendingClarifications:
progressPercent >= 100 ? [] : derivedState.pendingClarifications,
suggestedActions: shouldStayInDraftStage
? buildSuggestedActions({
stage: preservedStage,
isReady: true,
draftProfile: latestSession.draftProfile,
draftCards: latestSession.draftCards,
})
: progressPercent >= 100
? [
{
id: 'draft_foundation',
type: 'draft_foundation',
label: '生成游戏设定草稿',
},
]
: [],
recommendedReplies: [],
}); });
await this.sessionStore.appendMessage(
params.userId,
params.sessionId,
assistantMessage,
);
if (!this.llmClient) { return (await this.sessionStore.getSnapshot(
return { params.userId,
reply: params.fallbackReply, params.sessionId,
recommendedReplies: fallbackReplies, )) as CustomWorldAgentSessionSnapshot;
};
}
try {
const content = await this.llmClient.requestMessageContent({
systemPrompt: buildAgentSystemPrompt({
isReady: params.isReady,
hasAnyAnchors: hasMeaningfulCreatorIntentRecord(params.intent),
}),
userPrompt: buildAgentUserPrompt({
session: params.session,
latestUserText: params.latestUserText,
intent: params.intent,
pendingClarifications: params.pendingClarifications,
isReady: params.isReady,
}),
timeoutMs: 60000,
debugLabel: 'custom-world-agent-chat-turn',
});
const parsed = parseAssistantTurnJson(content);
return {
reply: parsed.reply || params.fallbackReply,
recommendedReplies:
parsed.recommendedReplies.length === 3
? parsed.recommendedReplies
: fallbackReplies,
};
} catch {
return {
reply: params.fallbackReply,
recommendedReplies: fallbackReplies,
};
}
} }
private async processDraftFoundationOperation(params: { private async processDraftFoundationOperation(params: {
@@ -983,7 +845,7 @@ export class CustomWorldAgentOrchestrator {
await this.sessionStore.updateOperation(userId, sessionId, operationId, { await this.sessionStore.updateOperation(userId, sessionId, operationId, {
status: 'running', status: 'running',
phaseLabel: '生成世界底稿', phaseLabel: '生成世界底稿',
phaseDetail: '正在根据已确认锚点编译第一版世界结构。', phaseDetail: '正在根据已确认设定编译第一版世界结构。',
progress: 38, progress: 38,
}); });
@@ -997,16 +859,22 @@ export class CustomWorldAgentOrchestrator {
throw new Error('custom world agent session not found'); throw new Error('custom world agent session not found');
} }
if ( if (latestSession.progressPercent < 100) {
latestSession.stage !== 'foundation_review' || throw new Error('session progressPercent is below 100');
!latestSession.creatorIntentReadiness.isReady
) {
throw new Error('session is not ready for draft_foundation');
} }
const creatorIntent = buildCreatorIntentFromEightAnchorContent(
latestSession.anchorContent,
);
const anchorPack = buildAnchorPackFromEightAnchorContent(
latestSession.anchorContent,
latestSession.progressPercent,
);
const draftProfile = await this.foundationDraftService.generate({ const draftProfile = await this.foundationDraftService.generate({
creatorIntent: latestSession.creatorIntent, creatorIntent,
anchorPack: latestSession.anchorPack, anchorPack,
anchorContent: latestSession.anchorContent,
onProgress: async (progress) => { onProgress: async (progress) => {
await this.sessionStore.updateOperation( await this.sessionStore.updateOperation(
userId, userId,
@@ -1040,6 +908,8 @@ export class CustomWorldAgentOrchestrator {
await this.sessionStore.replaceDerivedState(userId, sessionId, { await this.sessionStore.replaceDerivedState(userId, sessionId, {
stage: nextStage, stage: nextStage,
creatorIntent,
anchorPack,
draftProfile: draftProfile as unknown as Record<string, unknown>, draftProfile: draftProfile as unknown as Record<string, unknown>,
draftCards, draftCards,
assetCoverage, assetCoverage,
@@ -1070,7 +940,7 @@ export class CustomWorldAgentOrchestrator {
await this.sessionStore.updateOperation(userId, sessionId, operationId, { await this.sessionStore.updateOperation(userId, sessionId, operationId, {
status: 'failed', status: 'failed',
phaseLabel: '底稿生成失败', phaseLabel: '底稿生成失败',
phaseDetail: '这一轮没有成功把锚点编成世界底稿。', phaseDetail: '这一轮没有成功把设定编成世界底稿。',
progress: 100, progress: 100,
error: error:
error instanceof Error ? error.message : 'draft foundation failed', error instanceof Error ? error.message : 'draft foundation failed',
@@ -1596,14 +1466,23 @@ export class CustomWorldAgentOrchestrator {
sessionId: string; sessionId: string;
operationId: string; operationId: string;
latestUserText: string; latestUserText: string;
quickFillRequested: boolean;
}) { }) {
const { userId, sessionId, operationId, latestUserText } = params; const {
userId,
sessionId,
operationId,
latestUserText,
quickFillRequested,
} = params;
try { try {
await this.sessionStore.updateOperation(userId, sessionId, operationId, { await this.sessionStore.updateOperation(userId, sessionId, operationId, {
status: 'running', status: 'running',
phaseLabel: '提取世界锚点', phaseLabel: quickFillRequested ? '补全剩余设定' : '整理当前设定',
phaseDetail: '正在把这轮自然语言补充整理成结构化创作意图。', phaseDetail: quickFillRequested
? '正在基于当前方向补齐剩余设定。'
: '正在把这轮输入沉淀成新的完整设定。',
progress: 45, progress: 45,
}); });
@@ -1621,107 +1500,27 @@ export class CustomWorldAgentOrchestrator {
throw new Error('custom world agent session not found'); throw new Error('custom world agent session not found');
} }
const currentIntent =
normalizeCreatorIntentRecord(latestSession.creatorIntent) ??
createEmptyCreatorIntentRecord('freeform');
const recentMessages = getRecentUserMessages(latestSession).slice(0, -1);
const intentPatch = extractCreatorIntentPatch({
currentIntent,
latestUserMessage: latestUserText,
recentMessages,
});
const nextIntent = mergeCreatorIntentRecord(
currentIntent,
AUTO_COMPLETE_PATTERN.test(latestUserText)
? {
...intentPatch,
...buildAutoCompletePatch(currentIntent),
}
: intentPatch,
);
const derivedState = buildDerivedState(nextIntent, true);
const shouldPreserveDraftStage = const shouldPreserveDraftStage =
(latestSession.stage === 'object_refining' || (latestSession.stage === 'object_refining' ||
latestSession.stage === 'visual_refining') && latestSession.stage === 'visual_refining') &&
latestSession.draftCards.length > 0; latestSession.draftCards.length > 0;
const preservedStage =
latestSession.stage === 'visual_refining'
? ('visual_refining' as const)
: ('object_refining' as const);
const nextSuggestedActions = shouldPreserveDraftStage
? buildSuggestedActions({
stage: preservedStage,
isReady: true,
draftProfile: latestSession.draftProfile,
draftCards: latestSession.draftCards,
})
: derivedState.suggestedActions;
await this.sessionStore.replaceDerivedState(userId, sessionId, { await this.applyMessageTurn({
stage: shouldPreserveDraftStage ? preservedStage : derivedState.stage,
creatorIntent: nextIntent,
creatorIntentReadiness: derivedState.readiness,
anchorPack: derivedState.anchorPack,
draftProfile: shouldPreserveDraftStage
? latestSession.draftProfile
: derivedState.draftProfile,
pendingClarifications: shouldPreserveDraftStage
? latestSession.pendingClarifications
: derivedState.pendingClarifications,
suggestedActions: nextSuggestedActions,
draftCards: shouldPreserveDraftStage
? latestSession.draftCards
: undefined,
});
const fallbackAssistantMessage = shouldPreserveDraftStage
? buildObjectRefiningAssistantMessage({
latestUserText,
relatedOperationId: operationId,
draftProfile: latestSession.draftProfile,
})
: buildAssistantMessage({
latestUserText,
relatedOperationId: operationId,
intent: nextIntent,
pendingClarifications: derivedState.pendingClarifications,
isReady: derivedState.readiness.isReady,
});
const assistantTurn = shouldPreserveDraftStage
? {
reply: fallbackAssistantMessage.text,
recommendedReplies: [] as string[],
}
: await this.generateAssistantTurn({
session: latestSession,
latestUserText,
fallbackReply: fallbackAssistantMessage.text,
intent: nextIntent,
pendingClarifications: derivedState.pendingClarifications,
isReady: derivedState.readiness.isReady,
});
const assistantMessage = {
...fallbackAssistantMessage,
text: assistantTurn.reply,
};
const recommendedReplies = assistantTurn.recommendedReplies;
await this.sessionStore.appendMessage(
userId, userId,
sessionId, sessionId,
assistantMessage, latestUserText,
); quickFillRequested,
await this.sessionStore.replaceDerivedState(userId, sessionId, { relatedOperationId: operationId,
recommendedReplies,
}); });
await this.sessionStore.updateOperation(userId, sessionId, operationId, { await this.sessionStore.updateOperation(userId, sessionId, operationId, {
status: 'completed', status: 'completed',
phaseLabel: '锚点已更新', phaseLabel: '设定已更新',
phaseDetail: shouldPreserveDraftStage phaseDetail: shouldPreserveDraftStage
? '这轮补充已挂回当前底稿语境,现有草稿卡保持可继续浏览。' ? '这轮补充已挂回当前底稿语境,现有草稿卡保持可继续浏览。'
: derivedState.readiness.isReady : quickFillRequested
? '最小锚点已齐备,可以进入下一阶段。' ? '剩余设定已补全,现在可以进入游戏设定草稿生成。'
: '这一轮的创作锚点和澄清问题已经同步完成。', : '这一轮的设定更新已经完成。',
progress: 100, progress: 100,
error: null, error: null,
}); });
@@ -1729,7 +1528,7 @@ export class CustomWorldAgentOrchestrator {
await this.sessionStore.updateOperation(userId, sessionId, operationId, { await this.sessionStore.updateOperation(userId, sessionId, operationId, {
status: 'failed', status: 'failed',
phaseLabel: '处理失败', phaseLabel: '处理失败',
phaseDetail: '这一轮消息没有成功沉淀为创作锚点。', phaseDetail: '这一轮消息没有成功沉淀为当前设定。',
progress: 100, progress: 100,
error: error:
error instanceof Error ? error.message : 'process message failed', error instanceof Error ? error.message : 'process message failed',

View File

@@ -13,6 +13,7 @@ import {
} from './customWorldAgentIntentExtractionService.js'; } from './customWorldAgentIntentExtractionService.js';
import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js'; import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js';
import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js'; import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js';
import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js';
import { listCustomWorldWorkSummaries } from './customWorldWorkSummaryService.js'; import { listCustomWorldWorkSummaries } from './customWorldWorkSummaryService.js';
function createRuntimeRepositoryStub(): RuntimeRepositoryPort { function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
@@ -178,7 +179,9 @@ test('phase2 clarification service only keeps the top highest leverage gap', ()
test('phase2 orchestrator advances session to foundation_review when minimal anchors are complete', async () => { test('phase2 orchestrator advances session to foundation_review when minimal anchors are complete', async () => {
const runtimeRepository = createRuntimeRepositoryStub(); const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore); const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
});
const userId = 'user-phase2-ready'; const userId = 'user-phase2-ready';
const createdSession = await orchestrator.createSession(userId, { const createdSession = await orchestrator.createSession(userId, {
@@ -193,6 +196,9 @@ test('phase2 orchestrator advances session to foundation_review when minimal anc
), ),
//u, //u,
); );
assert.ok(
createdSession.messages[0]?.text.includes('1.') === false,
);
const message1 = await orchestrator.submitMessage( const message1 = await orchestrator.submitMessage(
userId, userId,
@@ -246,7 +252,7 @@ test('phase2 orchestrator advances session to foundation_review when minimal anc
snapshot?.messages.some( snapshot?.messages.some(
(message) => (message) =>
message.role === 'assistant' && message.role === 'assistant' &&
message.text.includes('最小锚点已经齐备'), /|稿/u.test(message.text),
), ),
); );
}); });
@@ -254,7 +260,9 @@ test('phase2 orchestrator advances session to foundation_review when minimal anc
test('phase2 work summaries compile draft title and summary from creator intent', async () => { test('phase2 work summaries compile draft title and summary from creator intent', async () => {
const runtimeRepository = createRuntimeRepositoryStub(); const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore); const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
});
const userId = 'user-phase2-summary'; const userId = 'user-phase2-summary';
const createdSession = await orchestrator.createSession(userId, { const createdSession = await orchestrator.createSession(userId, {

View File

@@ -5,6 +5,7 @@ import type { CustomWorldSessionRecord } from '../../../packages/shared/src/cont
import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js'; import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js';
import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js'; import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js';
import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js'; import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js';
import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js';
import { listCustomWorldWorkSummaries } from './customWorldWorkSummaryService.js'; import { listCustomWorldWorkSummaries } from './customWorldWorkSummaryService.js';
function createRuntimeRepositoryStub(): RuntimeRepositoryPort { function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
@@ -151,7 +152,9 @@ async function createReadySession(
test('phase3 ready session can execute draft_foundation and expose card detail', async () => { test('phase3 ready session can execute draft_foundation and expose card detail', async () => {
const runtimeRepository = createRuntimeRepositoryStub(); const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore); const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
});
const userId = 'user-phase3-draft'; const userId = 'user-phase3-draft';
const readySession = await createReadySession(orchestrator, userId); const readySession = await createReadySession(orchestrator, userId);
@@ -209,7 +212,9 @@ test('phase3 ready session can execute draft_foundation and expose card detail',
test('phase3 draft_foundation rejects not-ready session', async () => { test('phase3 draft_foundation rejects not-ready session', async () => {
const runtimeRepository = createRuntimeRepositoryStub(); const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore); const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
});
const userId = 'user-phase3-not-ready'; const userId = 'user-phase3-not-ready';
const createdSession = await orchestrator.createSession(userId, { const createdSession = await orchestrator.createSession(userId, {
seedText: '一个被潮雾切开的列岛世界。', seedText: '一个被潮雾切开的列岛世界。',
@@ -220,14 +225,16 @@ test('phase3 draft_foundation rejects not-ready session', async () => {
orchestrator.executeAction(userId, createdSession.sessionId, { orchestrator.executeAction(userId, createdSession.sessionId, {
action: 'draft_foundation', action: 'draft_foundation',
}), }),
/ready session|foundation_review/u, /progressPercent >= 100|draft_foundation/u,
); );
}); });
test('phase3 work summaries prefer compiled foundation draft fields', async () => { test('phase3 work summaries prefer compiled foundation draft fields', async () => {
const runtimeRepository = createRuntimeRepositoryStub(); const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore); const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
});
const userId = 'user-phase3-summary'; const userId = 'user-phase3-summary';
const readySession = await createReadySession(orchestrator, userId); const readySession = await createReadySession(orchestrator, userId);

View File

@@ -6,6 +6,7 @@ import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js
import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js'; import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js';
import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js'; import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js';
import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js'; import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js';
import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js';
import { listCustomWorldWorkSummaries } from './customWorldWorkSummaryService.js'; import { listCustomWorldWorkSummaries } from './customWorldWorkSummaryService.js';
function createRuntimeRepositoryStub(): RuntimeRepositoryPort { function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
@@ -161,7 +162,9 @@ async function createObjectRefiningSession(
test('phase4 update_draft_card writes back draft profile and recompiles summaries', async () => { test('phase4 update_draft_card writes back draft profile and recompiles summaries', async () => {
const runtimeRepository = createRuntimeRepositoryStub(); const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore); const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
});
const userId = 'user-phase4-edit'; const userId = 'user-phase4-edit';
const session = await createObjectRefiningSession(orchestrator, userId); const session = await createObjectRefiningSession(orchestrator, userId);
const characterCard = session.draftCards.find((card) => card.kind === 'character'); const characterCard = session.draftCards.find((card) => card.kind === 'character');
@@ -220,7 +223,9 @@ test('phase4 update_draft_card writes back draft profile and recompiles summarie
test('phase4 generate_characters appends story npcs and updates work summary counts', async () => { test('phase4 generate_characters appends story npcs and updates work summary counts', async () => {
const runtimeRepository = createRuntimeRepositoryStub(); const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore); const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
});
const userId = 'user-phase4-characters'; const userId = 'user-phase4-characters';
const session = await createObjectRefiningSession(orchestrator, userId); const session = await createObjectRefiningSession(orchestrator, userId);
const baselineProfile = normalizeFoundationDraftProfile(session.draftProfile)!; const baselineProfile = normalizeFoundationDraftProfile(session.draftProfile)!;
@@ -274,7 +279,9 @@ test('phase4 generate_characters appends story npcs and updates work summary cou
test('phase4 generate_landmarks appends new landmark cards and checkpoints', async () => { test('phase4 generate_landmarks appends new landmark cards and checkpoints', async () => {
const runtimeRepository = createRuntimeRepositoryStub(); const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore); const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
});
const userId = 'user-phase4-landmarks'; const userId = 'user-phase4-landmarks';
const session = await createObjectRefiningSession(orchestrator, userId); const session = await createObjectRefiningSession(orchestrator, userId);
const baselineProfile = normalizeFoundationDraftProfile(session.draftProfile)!; const baselineProfile = normalizeFoundationDraftProfile(session.draftProfile)!;

View File

@@ -6,6 +6,7 @@ import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js
import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js'; import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js';
import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js'; import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js';
import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js'; import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js';
import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js';
function createRuntimeRepositoryStub(): RuntimeRepositoryPort { function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
const sessionsByUser = new Map< const sessionsByUser = new Map<
@@ -160,7 +161,9 @@ async function createObjectRefiningSession(
test('phase5 generate_role_assets only allows a single role and moves session into visual_refining', async () => { test('phase5 generate_role_assets only allows a single role and moves session into visual_refining', async () => {
const runtimeRepository = createRuntimeRepositoryStub(); const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore); const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
});
const userId = 'user-phase5-generate-role-assets'; const userId = 'user-phase5-generate-role-assets';
const session = await createObjectRefiningSession(orchestrator, userId); const session = await createObjectRefiningSession(orchestrator, userId);
const characterIds = session.draftCards const characterIds = session.draftCards
@@ -201,7 +204,9 @@ test('phase5 generate_role_assets only allows a single role and moves session in
test('phase5 sync_role_assets writes fields back, updates coverage and recompiles character cards', async () => { test('phase5 sync_role_assets writes fields back, updates coverage and recompiles character cards', async () => {
const runtimeRepository = createRuntimeRepositoryStub(); const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore); const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
});
const userId = 'user-phase5-sync-role-assets'; const userId = 'user-phase5-sync-role-assets';
const session = await createObjectRefiningSession(orchestrator, userId); const session = await createObjectRefiningSession(orchestrator, userId);
const characterCard = session.draftCards.find((card) => card.kind === 'character'); const characterCard = session.draftCards.find((card) => card.kind === 'character');

View File

@@ -1,15 +1,16 @@
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import type { import type {
CustomWorldAssetCoverageSummary,
CreatorIntentReadiness, CreatorIntentReadiness,
CustomWorldAgentMessage, CustomWorldAgentMessage,
CustomWorldAgentOperationRecord, CustomWorldAgentOperationRecord,
CustomWorldAgentSessionSnapshot, CustomWorldAgentSessionSnapshot,
CustomWorldAgentStage, CustomWorldAgentStage,
CustomWorldAssetCoverageSummary,
CustomWorldDraftCardSummary, CustomWorldDraftCardSummary,
CustomWorldPendingClarification, CustomWorldPendingClarification,
CustomWorldSuggestedAction, CustomWorldSuggestedAction,
EightAnchorContent,
} from '../../../packages/shared/src/contracts/customWorldAgent.js'; } from '../../../packages/shared/src/contracts/customWorldAgent.js';
import type { CustomWorldSessionRecord as LegacyCustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js'; import type { CustomWorldSessionRecord as LegacyCustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js';
import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js'; import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js';
@@ -19,15 +20,19 @@ import {
resolveCreatorIntentStage, resolveCreatorIntentStage,
} from './customWorldAgentClarificationService.js'; } from './customWorldAgentClarificationService.js';
import { import {
buildAnchorPackFromIntent,
buildDraftSummaryFromIntent,
buildDraftTitleFromIntent,
createEmptyCreatorIntentRecord,
extractCreatorIntentPatch,
mergeCreatorIntentRecord,
normalizeCreatorIntentRecord, normalizeCreatorIntentRecord,
} from './customWorldAgentIntentExtractionService.js'; } from './customWorldAgentIntentExtractionService.js';
import { rebuildRoleAssetCoverage } from './customWorldAgentRoleAssetStateService.js'; import { rebuildRoleAssetCoverage } from './customWorldAgentRoleAssetStateService.js';
import {
buildAnchorPackFromEightAnchorContent,
buildCreatorIntentFromEightAnchorContent,
buildDraftSummaryFromEightAnchorContent,
buildDraftTitleFromEightAnchorContent,
buildEightAnchorContentFromCreatorIntent,
createEmptyEightAnchorContent,
estimateProgressPercentFromAnchorContent,
normalizeEightAnchorContent,
} from './eightAnchorCompatibilityService.js';
export const CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX = export const CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX =
'custom-world-agent-session-'; 'custom-world-agent-session-';
@@ -36,6 +41,10 @@ export type CustomWorldAgentSessionRecord = {
sessionId: string; sessionId: string;
userId: string; userId: string;
seedText: string; seedText: string;
currentTurn: number;
anchorContent: EightAnchorContent;
progressPercent: number;
lastAssistantReply: string | null;
stage: CustomWorldAgentStage; stage: CustomWorldAgentStage;
focusCardId: string | null; focusCardId: string | null;
creatorIntent: Record<string, unknown> | null; creatorIntent: Record<string, unknown> | null;
@@ -69,6 +78,10 @@ export type CustomWorldAgentSessionRecord = {
type CreateSessionInput = { type CreateSessionInput = {
seedText?: string; seedText?: string;
welcomeMessage: string; welcomeMessage: string;
currentTurn?: number;
anchorContent?: EightAnchorContent;
progressPercent?: number;
lastAssistantReply?: string | null;
pendingClarifications: CustomWorldAgentSessionRecord['pendingClarifications']; pendingClarifications: CustomWorldAgentSessionRecord['pendingClarifications'];
creatorIntent?: CustomWorldAgentSessionRecord['creatorIntent']; creatorIntent?: CustomWorldAgentSessionRecord['creatorIntent'];
creatorIntentReadiness?: CreatorIntentReadiness; creatorIntentReadiness?: CreatorIntentReadiness;
@@ -169,20 +182,95 @@ function hasUserInput(record: CustomWorldAgentSessionRecord) {
} }
function buildCompatibleCreatorIntent(record: CustomWorldAgentSessionRecord) { function buildCompatibleCreatorIntent(record: CustomWorldAgentSessionRecord) {
const existingIntent = const compatibleAnchorIntent = buildCreatorIntentFromEightAnchorContent(
normalizeCreatorIntentRecord(record.creatorIntent) ?? normalizeEightAnchorContent(
createEmptyCreatorIntentRecord('freeform'); (record as Record<string, unknown>).anchorContent ?? null,
),
);
if (!record.seedText.trim()) { if (
return existingIntent; compatibleAnchorIntent &&
(compatibleAnchorIntent.worldHook ||
compatibleAnchorIntent.rawSettingText ||
compatibleAnchorIntent.playerPremise ||
compatibleAnchorIntent.openingSituation ||
compatibleAnchorIntent.coreConflicts.length > 0 ||
compatibleAnchorIntent.keyCharacters.length > 0 ||
compatibleAnchorIntent.iconicElements.length > 0)
) {
return compatibleAnchorIntent;
} }
const seedPatch = extractCreatorIntentPatch({ return normalizeCreatorIntentRecord(record.creatorIntent);
currentIntent: existingIntent, }
latestUserMessage: record.seedText,
});
return mergeCreatorIntentRecord(existingIntent, seedPatch); function buildCompatibleCurrentTurn(record: CustomWorldAgentSessionRecord) {
if (typeof (record as Record<string, unknown>).currentTurn === 'number') {
return Math.max(
0,
Math.round((record as Record<string, unknown>).currentTurn as number),
);
}
return record.messages.filter((message) => message.role === 'user').length;
}
function buildCompatibleAnchorContent(record: CustomWorldAgentSessionRecord) {
const normalized = normalizeEightAnchorContent(
(record as Record<string, unknown>).anchorContent ?? null,
);
if (
normalized.worldPromise ||
normalized.playerFantasy ||
normalized.themeBoundary ||
normalized.playerEntryPoint ||
normalized.coreConflict ||
normalized.keyRelationships.length > 0 ||
normalized.hiddenLines ||
normalized.iconicElements
) {
return normalized;
}
return buildEightAnchorContentFromCreatorIntent(
buildCompatibleCreatorIntent(record),
);
}
function buildCompatibleProgressPercent(record: CustomWorldAgentSessionRecord) {
const rawProgress = (record as Record<string, unknown>).progressPercent;
if (typeof rawProgress === 'number' && Number.isFinite(rawProgress)) {
return Math.max(0, Math.min(100, Math.round(rawProgress)));
}
if (
record.stage === 'foundation_review' ||
record.stage === 'object_refining' ||
record.stage === 'visual_refining' ||
record.stage === 'long_tail_review' ||
record.stage === 'ready_to_publish' ||
record.stage === 'published'
) {
return 100;
}
return estimateProgressPercentFromAnchorContent(
buildCompatibleAnchorContent(record),
);
}
function buildCompatibleLastAssistantReply(record: CustomWorldAgentSessionRecord) {
const existingReply = (record as Record<string, unknown>).lastAssistantReply;
if (typeof existingReply === 'string') {
return existingReply;
}
const lastAssistantMessage = [...record.messages]
.reverse()
.find((message) => message.role === 'assistant' && message.text.trim());
return lastAssistantMessage?.text ?? null;
} }
function buildCompatibleReadiness(record: CustomWorldAgentSessionRecord) { function buildCompatibleReadiness(record: CustomWorldAgentSessionRecord) {
@@ -239,8 +327,8 @@ function buildCompatiblePendingClarifications(
function buildCompatibleDraftProfile( function buildCompatibleDraftProfile(
record: CustomWorldAgentSessionRecord, record: CustomWorldAgentSessionRecord,
creatorIntent: ReturnType<typeof buildCompatibleCreatorIntent>,
) { ) {
const anchorContent = buildCompatibleAnchorContent(record);
const existingDraftProfile = toRecord(record.draftProfile); const existingDraftProfile = toRecord(record.draftProfile);
const hasFoundationContent = Boolean( const hasFoundationContent = Boolean(
existingDraftProfile && existingDraftProfile &&
@@ -258,20 +346,21 @@ function buildCompatibleDraftProfile(
name: name:
toText(existingDraftProfile?.name) || toText(existingDraftProfile?.name) ||
toText(existingDraftProfile?.title) || toText(existingDraftProfile?.title) ||
buildDraftTitleFromIntent(creatorIntent), buildDraftTitleFromEightAnchorContent(anchorContent),
summary: summary:
toText(existingDraftProfile?.summary) || toText(existingDraftProfile?.summary) ||
buildDraftSummaryFromIntent(creatorIntent), buildDraftSummaryFromEightAnchorContent(anchorContent),
}; };
} }
return { return {
...(existingDraftProfile ?? {}), ...(existingDraftProfile ?? {}),
title: title:
toText(existingDraftProfile?.title) || buildDraftTitleFromIntent(creatorIntent), toText(existingDraftProfile?.title) ||
buildDraftTitleFromEightAnchorContent(anchorContent),
summary: summary:
toText(existingDraftProfile?.summary) || toText(existingDraftProfile?.summary) ||
buildDraftSummaryFromIntent(creatorIntent), buildDraftSummaryFromEightAnchorContent(anchorContent),
}; };
} }
@@ -381,35 +470,58 @@ function buildCompatibleAssetCoverage(
function applyCompatibility(record: CustomWorldAgentSessionRecord) { function applyCompatibility(record: CustomWorldAgentSessionRecord) {
const creatorIntent = buildCompatibleCreatorIntent(record); const creatorIntent = buildCompatibleCreatorIntent(record);
const creatorIntentReadiness = evaluateCreatorIntentReadiness(creatorIntent); const currentTurn = buildCompatibleCurrentTurn(record);
const anchorContent = buildCompatibleAnchorContent(record);
const progressPercent = buildCompatibleProgressPercent(record);
const lastAssistantReply = buildCompatibleLastAssistantReply(record);
const creatorIntentReadiness =
progressPercent >= 100
? {
isReady: true,
completedKeys: [
'world_hook',
'player_premise',
'theme_and_tone',
'core_conflict',
'relationship_seed',
'iconic_element',
],
missingKeys: [],
}
: evaluateCreatorIntentReadiness(creatorIntent);
const stage = const stage =
record.stage === 'collecting_intent' || record.stage === 'object_refining' ||
record.stage === 'clarifying' || record.stage === 'visual_refining' ||
record.stage === 'foundation_review' record.stage === 'long_tail_review' ||
? resolveCreatorIntentStage({ record.stage === 'ready_to_publish' ||
hasUserInput: hasUserInput(record), record.stage === 'published'
readiness: creatorIntentReadiness, ? record.stage
}) : progressPercent >= 100
: record.stage; ? ('foundation_review' as const)
: resolveCreatorIntentStage({
hasUserInput: hasUserInput(record),
readiness: creatorIntentReadiness,
});
const pendingClarifications = buildCompatiblePendingClarifications({ const pendingClarifications = buildCompatiblePendingClarifications({
...record, ...record,
creatorIntent, creatorIntent,
creatorIntentReadiness, creatorIntentReadiness,
}); });
const draftProfile = buildCompatibleDraftProfile(record, creatorIntent); const draftProfile = buildCompatibleDraftProfile(record);
return { return {
...record, ...record,
currentTurn,
anchorContent,
progressPercent,
lastAssistantReply,
stage, stage,
creatorIntent, creatorIntent,
creatorIntentReadiness, creatorIntentReadiness,
anchorPack: anchorPack:
record.anchorPack && Object.keys(record.anchorPack).length > 0 record.anchorPack && Object.keys(record.anchorPack).length > 0
? record.anchorPack ? record.anchorPack
: buildAnchorPackFromIntent(creatorIntent, { : buildAnchorPackFromEightAnchorContent(anchorContent, progressPercent),
completedKeys: creatorIntentReadiness.completedKeys,
missingKeys: creatorIntentReadiness.missingKeys,
}),
draftProfile, draftProfile,
pendingClarifications, pendingClarifications,
suggestedActions: buildCompatibleSuggestedActions({ suggestedActions: buildCompatibleSuggestedActions({
@@ -430,6 +542,10 @@ function toSnapshot(
): CustomWorldAgentSessionSnapshot { ): CustomWorldAgentSessionSnapshot {
return { return {
sessionId: record.sessionId, sessionId: record.sessionId,
currentTurn: record.currentTurn,
anchorContent: cloneRecord(record.anchorContent),
progressPercent: record.progressPercent,
lastAssistantReply: record.lastAssistantReply,
stage: record.stage, stage: record.stage,
focusCardId: record.focusCardId, focusCardId: record.focusCardId,
creatorIntent: cloneRecord(record.creatorIntent), creatorIntent: cloneRecord(record.creatorIntent),
@@ -491,6 +607,15 @@ export class CustomWorldAgentSessionStore {
sessionId, sessionId,
userId, userId,
seedText: input.seedText?.trim() ?? '', seedText: input.seedText?.trim() ?? '',
currentTurn: Math.max(0, Math.round(input.currentTurn ?? 0)),
anchorContent: normalizeEightAnchorContent(
input.anchorContent ?? createEmptyEightAnchorContent(),
),
progressPercent: Math.max(
0,
Math.min(100, Math.round(input.progressPercent ?? 0)),
),
lastAssistantReply: input.lastAssistantReply ?? input.welcomeMessage,
stage: input.stage ?? 'collecting_intent', stage: input.stage ?? 'collecting_intent',
focusCardId: null, focusCardId: null,
creatorIntent: cloneRecord(input.creatorIntent ?? {}), creatorIntent: cloneRecord(input.creatorIntent ?? {}),
@@ -567,6 +692,10 @@ export class CustomWorldAgentSessionStore {
patch: Partial< patch: Partial<
Pick< Pick<
CustomWorldAgentSessionRecord, CustomWorldAgentSessionRecord,
| 'currentTurn'
| 'anchorContent'
| 'progressPercent'
| 'lastAssistantReply'
| 'stage' | 'stage'
| 'creatorIntent' | 'creatorIntent'
| 'creatorIntentReadiness' | 'creatorIntentReadiness'
@@ -584,6 +713,21 @@ export class CustomWorldAgentSessionStore {
>, >,
) { ) {
return this.mutate(userId, sessionId, (record) => { return this.mutate(userId, sessionId, (record) => {
if (typeof patch.currentTurn === 'number') {
record.currentTurn = Math.max(0, Math.round(patch.currentTurn));
}
if (patch.anchorContent !== undefined) {
record.anchorContent = normalizeEightAnchorContent(patch.anchorContent);
}
if (typeof patch.progressPercent === 'number') {
record.progressPercent = Math.max(
0,
Math.min(100, Math.round(patch.progressPercent)),
);
}
if (patch.lastAssistantReply !== undefined) {
record.lastAssistantReply = patch.lastAssistantReply;
}
if (patch.stage) { if (patch.stage) {
record.stage = patch.stage; record.stage = patch.stage;
} }

View File

@@ -0,0 +1,321 @@
import type { EightAnchorContent } from '../../../packages/shared/src/contracts/customWorldAgent.js';
import type { UpstreamLlmClient } from './llmClient.js';
import {
extractCreatorIntentPatch,
mergeCreatorIntentRecord,
} from './customWorldAgentIntentExtractionService.js';
import {
buildCreatorIntentFromEightAnchorContent,
buildEightAnchorContentFromCreatorIntent,
createEmptyEightAnchorContent,
estimateProgressPercentFromAnchorContent,
normalizeEightAnchorContent,
} from './eightAnchorCompatibilityService.js';
type TestChatMessage = {
role: 'user' | 'assistant';
content: string;
};
function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function shouldReplaceWorldPromise(params: {
latestUserText: string;
hasExistingWorldPromise: boolean;
}) {
if (!params.hasExistingWorldPromise) {
return true;
}
return /(|||||||||)/u.test(
params.latestUserText,
);
}
function buildAutoCompletePatch(intent: ReturnType<
typeof buildCreatorIntentFromEightAnchorContent
>) {
return {
worldHook:
intent.worldHook ||
intent.rawSettingText ||
'一个被未知异象改变秩序的边境世界。',
playerPremise: intent.playerPremise || '玩家是被卷入核心危机的返乡者。',
openingSituation:
intent.openingSituation || '开局时,玩家正抵达危机爆发的现场。',
themeKeywords:
intent.themeKeywords.length > 0 ? intent.themeKeywords : ['奇幻'],
toneDirectives:
intent.toneDirectives.length > 0 ? intent.toneDirectives : ['悬疑'],
coreConflicts:
intent.coreConflicts.length > 0
? intent.coreConflicts
: ['旧秩序与新威胁正在争夺世界的未来。'],
keyCharacters:
intent.keyCharacters.length > 0
? intent.keyCharacters
: [
{
id: 'auto-key-character-1',
name: '未命名关键人物',
role: '关键关系',
publicMask: '看似能帮助玩家的人。',
hiddenHook: '掌握一条会改变局势的暗线。',
relationToPlayer: '旧识',
notes: '自动补全,可继续修改。',
},
],
iconicElements:
intent.iconicElements.length > 0 ? intent.iconicElements : ['失落信标'],
};
}
function buildReplyText(params: {
nextAnchorContent: EightAnchorContent;
progressPercent: number;
quickFillRequested: boolean;
latestUserText: string;
}) {
if (params.quickFillRequested || params.progressPercent >= 100) {
return '这一版已经收住了,现在可以直接生成游戏设定草稿。';
}
if (/(|||)/u.test(params.latestUserText)) {
return '我已经按你刚刚修正后的方向重收了一版,现在这条主线会更稳。';
}
if (!params.nextAnchorContent.worldPromise?.hook) {
return '方向我先接住了一点。这个世界最抓人的那句核心设定,你想怎么钉住?';
}
if (!params.nextAnchorContent.playerFantasy?.playerRole) {
return '世界底色已经有了。你最想让玩家以什么身份卷进来?';
}
if (!params.nextAnchorContent.playerEntryPoint?.openingProblem) {
return '大方向先稳住了。故事开场时,玩家先撞上的麻烦是什么?';
}
if (!params.nextAnchorContent.coreConflict?.surfaceConflicts.length) {
return '现在气质和身份都更清楚了。接下来最值得钉住的,是这个世界正在爆开的主要冲突。';
}
return '这轮信息我已经收进当前版本里了,你可以继续往下补,也可以让我顺着这条线继续收束。';
}
function extractJsonBlock(text: string, marker: string) {
const markerIndex = text.indexOf(marker);
if (markerIndex < 0) {
return null;
}
let startIndex = markerIndex + marker.length;
while (startIndex < text.length && /\s/u.test(text[startIndex] ?? '')) {
startIndex += 1;
}
const firstCharacter = text[startIndex];
if (firstCharacter !== '{' && firstCharacter !== '[') {
return null;
}
const closingCharacter = firstCharacter === '{' ? '}' : ']';
let depth = 0;
let insideString = false;
let escaping = false;
for (let index = startIndex; index < text.length; index += 1) {
const character = text[index] ?? '';
if (insideString) {
if (escaping) {
escaping = false;
continue;
}
if (character === '\\') {
escaping = true;
continue;
}
if (character === '"') {
insideString = false;
}
continue;
}
if (character === '"') {
insideString = true;
continue;
}
if (character === firstCharacter) {
depth += 1;
continue;
}
if (character === closingCharacter) {
depth -= 1;
if (depth === 0) {
return text.slice(startIndex, index + 1);
}
}
}
return null;
}
function parsePromptInput(text: string) {
const anchorJson = extractJsonBlock(text, '当前完整设定结构:');
const chatJson = extractJsonBlock(text, '用户聊天记录:');
const currentAnchorContent = anchorJson
? normalizeEightAnchorContent(JSON.parse(anchorJson))
: createEmptyEightAnchorContent();
const chatHistory = chatJson
? (JSON.parse(chatJson) as TestChatMessage[])
: [];
const quickFillRequested =
text.includes('是否要求自动补全:是') ||
text.includes('conversationMode: force_complete') ||
text.includes('用户刚刚主动要求你自动补全剩余设定');
return {
currentAnchorContent,
chatHistory,
quickFillRequested,
};
}
export function buildTestEightAnchorTurn(params: {
currentAnchorContent: EightAnchorContent;
chatHistory: TestChatMessage[];
quickFillRequested: boolean;
}) {
const latestUserText =
[...params.chatHistory]
.reverse()
.find((entry) => entry.role === 'user' && entry.content.trim())?.content ??
'';
const currentIntent = buildCreatorIntentFromEightAnchorContent(
params.currentAnchorContent,
);
const intentPatch = extractCreatorIntentPatch({
currentIntent,
latestUserMessage: latestUserText,
recentMessages: params.chatHistory
.filter((entry) => entry.role === 'user')
.slice(-6, -1)
.map((entry) => entry.content),
});
const mergedIntent = mergeCreatorIntentRecord(
currentIntent,
params.quickFillRequested
? {
...intentPatch,
...buildAutoCompletePatch(currentIntent),
}
: intentPatch,
);
if (
!shouldReplaceWorldPromise({
latestUserText,
hasExistingWorldPromise: Boolean(currentIntent.worldHook),
})
) {
mergedIntent.worldHook = currentIntent.worldHook;
}
const nextAnchorContent = buildEightAnchorContentFromCreatorIntent(mergedIntent);
const progressPercent = params.quickFillRequested
? 100
: estimateProgressPercentFromAnchorContent(nextAnchorContent);
return {
replyText: buildReplyText({
nextAnchorContent,
progressPercent,
quickFillRequested: params.quickFillRequested,
latestUserText,
}),
progressPercent,
nextAnchorContent,
};
}
function buildStateInferenceFromPrompt(text: string) {
const { chatHistory, quickFillRequested } = parsePromptInput(text);
const latestUserText =
[...chatHistory]
.reverse()
.find((entry) => entry.role === 'user' && entry.content.trim())?.content ??
'';
const correction = /(|||||)/u.test(latestUserText);
const delegate = /(||||)/u.test(
latestUserText,
);
if (quickFillRequested) {
return {
userInputSignal: delegate ? 'delegate' : 'normal',
driftRisk: correction ? 'high' : 'medium',
conversationMode: 'force_complete',
judgementSummary: '用户希望系统直接补完,这一轮应优先补齐剩余设定并结束收集阶段。',
};
}
if (correction) {
return {
userInputSignal: 'correction',
driftRisk: 'high',
conversationMode: 'repair_direction',
judgementSummary: '用户正在修正旧方向,正式生成时要让修正后的版本直接接管当前语境。',
};
}
if (latestUserText.length < 20) {
return {
userInputSignal: delegate ? 'delegate' : 'sparse',
driftRisk: 'low',
conversationMode: 'bootstrap',
judgementSummary: '这轮新增信息较少,正式生成时应先低压力接住方向,再只推进一个最好回答的问题。',
};
}
return {
userInputSignal: latestUserText.length >= 40 ? 'rich' : 'normal',
driftRisk: 'low',
conversationMode: 'expand',
judgementSummary: '这轮是在顺着现有方向继续补充,正式生成时应吸收新增细节并往前推进一步。',
};
}
export function createTestCustomWorldAgentSingleTurnLlmClient() {
return {
requestMessageContent: async (params) => {
if (params.systemPrompt.includes('创作状态识别器')) {
return JSON.stringify(buildStateInferenceFromPrompt(params.userPrompt));
}
const promptInput = parsePromptInput(
[params.systemPrompt, params.userPrompt].join('\n\n'),
);
return JSON.stringify(buildTestEightAnchorTurn(promptInput));
},
streamMessageContent: async (params) => {
const promptInput = parsePromptInput(
[params.systemPrompt, params.userPrompt].join('\n\n'),
);
const output = buildTestEightAnchorTurn(promptInput);
const jsonText = JSON.stringify({
replyText: output.replyText,
progressPercent: output.progressPercent,
nextAnchorContent: output.nextAnchorContent,
});
params.onUpdate?.(jsonText);
return jsonText;
},
} as UpstreamLlmClient;
}

View File

@@ -0,0 +1,157 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { generateCustomWorldEntity } from './customWorldEntityGenerationService.js';
import type { UpstreamLlmClient } from './llmClient.js';
function createProfile() {
return {
name: '裂潮边城',
settingText: '裂潮重新逼近边城,旧封桥令也被重新翻出。',
summary: '一座在裂潮与旧案之间摇摇欲坠的边城。',
tone: '紧绷、克制、暗流涌动',
playerGoal: '查清封桥旧令背后的真正操盘者',
playableNpcs: [
{
id: 'playable-1',
name: '沈砺',
title: '灰炬向导',
role: '边路同行者',
description: '熟悉裂潮边路的向导。',
visualDescription: '灰斗篷和旧路标是他最显眼的识别点。',
actionDescription: '先试探风向,再用短弓牵制。',
sceneVisualDescription: '他常在旧边路哨点出现。',
backstory: '曾在旧撤离线里失去整支同行队。',
personality: '谨慎寡言。',
motivation: '想查清旧撤离线再次失控的原因。',
combatStyle: '短弓牵制后贴近补刀。',
initialAffinity: 18,
relationshipHooks: ['旧撤离线'],
tags: ['裂潮', '向导'],
},
],
storyNpcs: [
{
id: 'story-1',
name: '梁砺',
title: '断桥巡守',
role: '巡守',
description: '守着旧桥与哨火的人。',
visualDescription: '披着旧制巡守外袍,枪柄磨损很重。',
actionDescription: '先立枪封路,再逼近压线。',
sceneVisualDescription: '多出现在断桥和潮湿石阶附近。',
backstory: '旧案爆发时,他是最后一个封桥的人。',
personality: '直接、警觉。',
motivation: '不想再让封桥旧案被人利用。',
combatStyle: '长枪压线。',
initialAffinity: 6,
relationshipHooks: ['断桥'],
tags: ['巡守'],
},
],
landmarks: [
{
id: 'landmark-1',
name: '旧潮栈桥',
description: '裂潮来时最先响起铁索声的旧栈桥。',
visualDescription: '铁索、旧桩和盐雾一起压在栈桥上。',
dangerLevel: 'medium',
sceneNpcIds: ['story-1'],
connections: [],
},
],
};
}
test('generateCustomWorldEntity returns role-side visual descriptions from the same model response', async () => {
const llmClient = {
requestMessageContent: async () =>
JSON.stringify({
playableNpc: {
name: '顾潮音',
title: '潮港校灯人',
role: '边港同行者',
description: '在港区高处替玩家校正风向与路标的人。',
visualDescription:
'深蓝防潮外套压着风痕,腰侧悬着校灯尺和短刃,整个人像常年站在高处看潮线的人。',
actionDescription:
'先借高处观察和校灯信号牵制敌人,再突然贴近切断退路。',
sceneVisualDescription:
'他第一次出现的高台边缘挂着潮湿风旗,脚下是被盐雾浸白的木板和仍亮着的旧校灯。',
backstory: '曾负责港区夜航校灯,后被卷进旧案。',
personality: '沉稳、寡言、观察细。',
motivation: '想在港区秩序彻底失控前找到还能守住的线。',
combatStyle: '高差观察后快速切入。',
initialAffinity: 24,
relationshipHooks: ['夜航校灯', '旧港案'],
tags: ['港区', '校灯'],
publicSummary: '港区里很少有人比他更熟悉夜里的风向。',
chapterTeasers: ['他盯风向比盯人更久。', '旧港案在他身上没过去。', '他一直在等某个信号。', '他还藏着最后一次校灯记录。'],
chapterContents: ['他总先校风向。', '旧港案改变了他的站位。', '他真正守的是港区里还没断的线。', '最后那份校灯记录能指向操盘者。'],
skills: [
{ name: '校灯试探', summary: '先用灯信号试探敌我位置。', style: '起手压制' },
{ name: '斜坡切入', summary: '借高差快速贴近改线。', style: '机动周旋' },
{ name: '潮线封口', summary: '看准潮线后一口气断掉退路。', style: '爆发终结' },
],
initialItems: [
{ name: '校灯尺', category: '武器', quantity: 1, rarity: 'rare', description: '兼具校灯与近战功能。', tags: ['港区'] },
{ name: '旧港图片', category: '专属物品', quantity: 1, rarity: 'rare', description: '记着他自己的旧线路。', tags: ['旧案'] },
{ name: '潮雾止血包', category: '消耗品', quantity: 2, rarity: 'uncommon', description: '港区常备。', tags: ['补给'] },
],
},
}),
} as UpstreamLlmClient;
const result = await generateCustomWorldEntity(llmClient, {
profile: createProfile(),
kind: 'playable',
});
assert.equal(result.kind, 'playable');
assert.equal(
result.entity.visualDescription,
'深蓝防潮外套压着风痕,腰侧悬着校灯尺和短刃,整个人像常年站在高处看潮线的人。',
);
assert.equal(
result.entity.actionDescription,
'先借高处观察和校灯信号牵制敌人,再突然贴近切断退路。',
);
assert.equal(
result.entity.sceneVisualDescription,
'他第一次出现的高台边缘挂着潮湿风旗,脚下是被盐雾浸白的木板和仍亮着的旧校灯。',
);
});
test('generateCustomWorldEntity returns landmark visual descriptions from the same model response', async () => {
const llmClient = {
requestMessageContent: async () =>
JSON.stringify({
landmark: {
name: '回潮观测台',
description: '能俯瞰旧港和裂潮边缘的新观测点。',
visualDescription:
'观测台立在湿冷高处,铁质风标、旧灯架和被潮气侵白的石阶一起构成了压迫感很强的前景。',
dangerLevel: 'high',
sceneNpcNames: ['梁砺'],
connections: [
{
targetLandmarkName: '旧潮栈桥',
relativePosition: 'forward',
summary: '沿风雨走廊可直接回到旧潮栈桥',
},
],
},
}),
} as UpstreamLlmClient;
const result = await generateCustomWorldEntity(llmClient, {
profile: createProfile(),
kind: 'landmark',
});
assert.equal(result.kind, 'landmark');
assert.equal(
result.entity.visualDescription,
'观测台立在湿冷高处,铁质风标、旧灯架和被潮气侵白的石阶一起构成了压迫感很强的前景。',
);
});

View File

@@ -15,6 +15,9 @@ type ParsedRole = {
title: string; title: string;
role: string; role: string;
description: string; description: string;
visualDescription: string;
actionDescription: string;
sceneVisualDescription: string;
backstory: string; backstory: string;
personality: string; personality: string;
motivation: string; motivation: string;
@@ -34,6 +37,7 @@ type ParsedLandmark = {
id: string; id: string;
name: string; name: string;
description: string; description: string;
visualDescription: string;
dangerLevel: string; dangerLevel: string;
sceneNpcIds: string[]; sceneNpcIds: string[];
connections: ParsedLandmarkConnection[]; connections: ParsedLandmarkConnection[];
@@ -220,6 +224,9 @@ function normalizeRole(value: unknown): ParsedRole | null {
title: title || role || '角色', title: title || role || '角色',
role, role,
description: toText(record.description), description: toText(record.description),
visualDescription: toText(record.visualDescription),
actionDescription: toText(record.actionDescription),
sceneVisualDescription: toText(record.sceneVisualDescription),
backstory: toText(record.backstory), backstory: toText(record.backstory),
personality: toText(record.personality), personality: toText(record.personality),
motivation: toText(record.motivation), motivation: toText(record.motivation),
@@ -275,6 +282,7 @@ function normalizeLandmark(value: unknown): ParsedLandmark | null {
id, id,
name, name,
description: toText(record.description), description: toText(record.description),
visualDescription: toText(record.visualDescription),
dangerLevel: toText(record.dangerLevel, 'medium'), dangerLevel: toText(record.dangerLevel, 'medium'),
sceneNpcIds: toStringArray(record.sceneNpcIds, 12), sceneNpcIds: toStringArray(record.sceneNpcIds, 12),
connections, connections,
@@ -326,7 +334,11 @@ function buildRoleReferenceText(roles: ParsedRole[], emptyText: string) {
role.backstory || '未写' role.backstory || '未写'
} / 性格:${role.personality || '未写'} / 动机:${ } / 性格:${role.personality || '未写'} / 动机:${
role.motivation || '未写' role.motivation || '未写'
} / 标签${role.tags.join('、') || '暂无'}`, } / 形象${role.visualDescription || '未写'} / 动作表现:${
role.actionDescription || '未写'
} / 场景画面:${role.sceneVisualDescription || '未写'} / 标签:${
role.tags.join('、') || '暂无'
}`,
) )
.join('\n'); .join('\n');
} }
@@ -361,7 +373,9 @@ function buildLandmarkReferenceText(profile: ParsedProfile) {
return `${index + 1}. ${landmark.name} / 危险度:${ return `${index + 1}. ${landmark.name} / 危险度:${
landmark.dangerLevel || 'medium' landmark.dangerLevel || 'medium'
} / 描述:${landmark.description || '未写'} / 场景角色${ } / 描述:${landmark.description || '未写'} / 画面${
landmark.visualDescription || '未写'
} / 场景角色:${
sceneNpcNames || '暂无' sceneNpcNames || '暂无'
} / 连接:${connectionNames || '暂无'}`; } / 连接:${connectionNames || '暂无'}`;
}) })
@@ -437,6 +451,24 @@ function buildFallbackRoleDraft(
: `长期活跃于当前世界暗面,能补足场景视角的关键角色。`, : `长期活跃于当前世界暗面,能补足场景视角的关键角色。`,
60, 60,
), ),
visualDescription: clampText(
kind === 'playable'
? `他保留着适合长期同行的鲜明外形识别点,服装、装备和体态都能直接看出其职责、出身和会如何与玩家并肩行动。`
: `他身上带着与当前局势强绑定的外观痕迹,衣着、器具和整体气质会暴露其长期活动环境与所站的位置。`,
96,
),
actionDescription: clampText(
kind === 'playable'
? '动作表现偏向协作推进与稳定压制,起手克制,发力明确,收招干净。'
: '动作表现偏向试探、牵制与借势,节奏谨慎,但关键时刻会突然加重攻击或位移。',
72,
),
sceneVisualDescription: clampText(
profile.landmarks[0]?.description
? `他的主要活动空间与${profile.landmarks[0].name}相连,场景里能看到${profile.landmarks[0].description}`
: `他的主要活动空间与${profile.name}当前冲突线直接相关,环境里会留下势力痕迹、旧装置和仍在运转的局势线索。`,
96,
),
backstory: clampText( backstory: clampText(
`他与${profile.name}当前正在扩张的冲突链紧密相连,知道一些还未公开的内情。`, `他与${profile.name}当前正在扩张的冲突链紧密相连,知道一些还未公开的内情。`,
80, 80,
@@ -535,6 +567,10 @@ function buildFallbackLandmarkDraft(profile: ParsedProfile) {
`承接${profile.name}当前主冲突的一处关键新场景,适合继续向外扩张世界关系网。`, `承接${profile.name}当前主冲突的一处关键新场景,适合继续向外扩张世界关系网。`,
72, 72,
), ),
visualDescription: clampText(
`这里延续${profile.name}当前主冲突的视觉气质,能看到明确的空间层次、可站立地面、核心建筑或地貌,以及仍在运转的局势痕迹。`,
88,
),
dangerLevel: 'medium', dangerLevel: 'medium',
sceneNpcNames, sceneNpcNames,
connections: targetLandmarkNames.map((targetLandmarkName, index) => ({ connections: targetLandmarkNames.map((targetLandmarkName, index) => ({
@@ -560,6 +596,9 @@ function buildPlayablePrompt(profile: ParsedProfile) {
'- 必须与当前世界设定、已有可扮演角色、已有场景角色、已有场景形成互补,不要重复定位。', '- 必须与当前世界设定、已有可扮演角色、已有场景角色、已有场景形成互补,不要重复定位。',
'- 必须保留明确的协作价值、成长空间和入队理由。', '- 必须保留明确的协作价值、成长空间和入队理由。',
'- 不要生成泛用模板角色,必须让角色与当前世界的具体势力、地点、冲突或禁忌发生绑定。', '- 不要生成泛用模板角色,必须让角色与当前世界的具体势力、地点、冲突或禁忌发生绑定。',
'- visualDescription 只写与角色设定相关的外形、服装、材质、武器、体态、色彩和识别特征,禁止写角色以外的周边环境等与角色不想管的设定。',
'- actionDescription 只写这个角色的动作表现与战斗气质,不要写镜头切换或参数。',
'- sceneVisualDescription 只写该角色首次登场或主要活动区域的场景画面感,不要写“提示词”字样。',
'- 只返回 JSON不要输出解释或 Markdown。', '- 只返回 JSON不要输出解释或 Markdown。',
'JSON 结构:', 'JSON 结构:',
'{', '{',
@@ -568,6 +607,9 @@ function buildPlayablePrompt(profile: ParsedProfile) {
' "title": "称号",', ' "title": "称号",',
' "role": "身份",', ' "role": "身份",',
' "description": "一句到两句定位描述",', ' "description": "一句到两句定位描述",',
' "visualDescription": "角色形象描述",',
' "actionDescription": "动作表现描述",',
' "sceneVisualDescription": "角色关联场景画面描述",',
' "backstory": "背景经历",', ' "backstory": "背景经历",',
' "personality": "性格特点",', ' "personality": "性格特点",',
' "motivation": "当前动机",', ' "motivation": "当前动机",',
@@ -608,6 +650,9 @@ function buildStoryPrompt(profile: ParsedProfile) {
'- 必须与当前世界设定、已有可扮演角色、已有场景角色、已有场景形成互补,不要重复定位。', '- 必须与当前世界设定、已有可扮演角色、已有场景角色、已有场景形成互补,不要重复定位。',
'- 必须像能直接进入游戏的场景角色,而不是抽象设定条目。', '- 必须像能直接进入游戏的场景角色,而不是抽象设定条目。',
'- 角色应与具体场景、关系链或局势变化发生绑定。', '- 角色应与具体场景、关系链或局势变化发生绑定。',
'- visualDescription 只写与角色设定匹配的外形、服装、材质、武器、体态、色彩和识别特征,不要写“提示词”、镜头参数或构图规则。',
'- actionDescription 只写这个角色的动作表现与战斗气质,不要写镜头切换或参数。',
'- sceneVisualDescription 只写该角色首次登场或主要活动区域的场景画面感,不要写“提示词”字样。',
'- 只返回 JSON不要输出解释或 Markdown。', '- 只返回 JSON不要输出解释或 Markdown。',
'JSON 结构:', 'JSON 结构:',
'{', '{',
@@ -616,6 +661,9 @@ function buildStoryPrompt(profile: ParsedProfile) {
' "title": "称号",', ' "title": "称号",',
' "role": "身份",', ' "role": "身份",',
' "description": "一句到两句定位描述",', ' "description": "一句到两句定位描述",',
' "visualDescription": "角色形象描述",',
' "actionDescription": "动作表现描述",',
' "sceneVisualDescription": "角色关联场景画面描述",',
' "backstory": "背景经历",', ' "backstory": "背景经历",',
' "personality": "性格特点",', ' "personality": "性格特点",',
' "motivation": "当前动机",', ' "motivation": "当前动机",',
@@ -656,12 +704,14 @@ function buildLandmarkPrompt(profile: ParsedProfile) {
'- 必须与当前世界设定、已有可扮演角色、已有场景角色、已有场景形成互补,不要重复。', '- 必须与当前世界设定、已有可扮演角色、已有场景角色、已有场景形成互补,不要重复。',
'- 必须给出适合出现在这个新场景里的 sceneNpcNames且只能从已有场景角色里选择至少 3 个名字。', '- 必须给出适合出现在这个新场景里的 sceneNpcNames且只能从已有场景角色里选择至少 3 个名字。',
'- 必须给出 connections且 targetLandmarkName 只能引用已有场景名,不要连向自己。', '- 必须给出 connections且 targetLandmarkName 只能引用已有场景名,不要连向自己。',
'- visualDescription 只写这个场景的空间层次、地面、主体建筑或自然景观、氛围、色彩和可见装置,不要写“提示词”、镜头参数或构图规则。',
'- 只返回 JSON不要输出解释或 Markdown。', '- 只返回 JSON不要输出解释或 Markdown。',
'JSON 结构:', 'JSON 结构:',
'{', '{',
' "landmark": {', ' "landmark": {',
' "name": "场景名",', ' "name": "场景名",',
' "description": "场景描述",', ' "description": "场景描述",',
' "visualDescription": "场景画面描述",',
' "dangerLevel": "low|medium|high|extreme",', ' "dangerLevel": "low|medium|high|extreme",',
' "sceneNpcNames": ["场景角色1", "场景角色2", "场景角色3"],', ' "sceneNpcNames": ["场景角色1", "场景角色2", "场景角色3"],',
' "connections": [', ' "connections": [',
@@ -737,6 +787,21 @@ function sanitizeGeneratedRole(
toText(record?.description, fallbackDraft.description), toText(record?.description, fallbackDraft.description),
120, 120,
), ),
visualDescription: clampText(
toText(record?.visualDescription, fallbackDraft.visualDescription),
180,
),
actionDescription: clampText(
toText(record?.actionDescription, fallbackDraft.actionDescription),
140,
),
sceneVisualDescription: clampText(
toText(
record?.sceneVisualDescription,
fallbackDraft.sceneVisualDescription,
),
180,
),
backstory: clampText(toText(record?.backstory, fallbackDraft.backstory), 260), backstory: clampText(toText(record?.backstory, fallbackDraft.backstory), 260),
personality: clampText( personality: clampText(
toText(record?.personality, fallbackDraft.personality), toText(record?.personality, fallbackDraft.personality),
@@ -962,6 +1027,10 @@ function sanitizeGeneratedLandmark(rawValue: unknown, profile: ParsedProfile) {
toText(record?.description, fallbackDraft.description), toText(record?.description, fallbackDraft.description),
140, 140,
), ),
visualDescription: clampText(
toText(record?.visualDescription, fallbackDraft.visualDescription),
180,
),
dangerLevel: (() => { dangerLevel: (() => {
const level = toText(record?.dangerLevel, fallbackDraft.dangerLevel); const level = toText(record?.dangerLevel, fallbackDraft.dangerLevel);
return level === 'low' || return level === 'low' ||

View File

@@ -21,6 +21,10 @@ import type {
CustomWorldAgentSessionRecord, CustomWorldAgentSessionRecord,
CustomWorldAgentSessionStore, CustomWorldAgentSessionStore,
} from './customWorldAgentSessionStore.js'; } from './customWorldAgentSessionStore.js';
import {
buildDraftSummaryFromEightAnchorContent,
buildDraftTitleFromEightAnchorContent,
} from './eightAnchorCompatibilityService.js';
function toText(value: unknown) { function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : ''; return typeof value === 'string' ? value.trim() : '';
@@ -64,6 +68,7 @@ function resolveDraftTitle(session: CustomWorldAgentSessionRecord) {
return ( return (
draftProfile?.name || draftProfile?.name ||
buildDraftTitleFromEightAnchorContent(session.anchorContent) ||
buildDraftTitleFromIntent(intent) || buildDraftTitleFromIntent(intent) ||
toText(session.draftProfile?.title) || toText(session.draftProfile?.title) ||
truncateText(session.seedText, 18) || truncateText(session.seedText, 18) ||
@@ -78,6 +83,7 @@ function resolveDraftSummary(session: CustomWorldAgentSessionRecord) {
return ( return (
draftProfile?.summary || draftProfile?.summary ||
buildDraftSummaryFromEightAnchorContent(session.anchorContent) ||
compiledSummary || compiledSummary ||
toText(session.draftProfile?.summary) || toText(session.draftProfile?.summary) ||
truncateText(session.seedText, 72) || truncateText(session.seedText, 72) ||

View File

@@ -0,0 +1,593 @@
import type {
CoreConflictValue,
EightAnchorContent,
HiddenLineValue,
IconicElementValue,
KeyRelationshipValue,
PlayerEntryPointValue,
PlayerFantasyValue,
ThemeBoundaryValue,
WorldPromiseValue,
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
import {
buildAnchorPackFromIntent,
createEmptyCreatorIntentRecord,
type CreatorCharacterSeedRecord,
type CustomWorldCreatorIntentRecord,
} from './customWorldAgentIntentExtractionService.js';
function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function toStringArray(value: unknown, maxCount = 8) {
if (!Array.isArray(value)) {
return [];
}
return [...new Set(value.map((item) => toText(item)).filter(Boolean))].slice(
0,
maxCount,
);
}
function compactLines(items: Array<string | null | undefined>) {
return items.map((item) => toText(item)).filter(Boolean).join('');
}
function clampText(value: string, maxLength: number) {
const normalized = value.replace(/\s+/gu, ' ').trim();
if (!normalized) {
return '';
}
if (normalized.length <= maxLength) {
return normalized;
}
return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}`;
}
function createId(prefix: string, index: number) {
return `${prefix}-${index + 1}`;
}
function splitRelationshipPair(value: string) {
const segments = value
.split(/[/&|]/u)
.map((item) => item.trim())
.flatMap((item) => item.split(/(?:|)/u))
.map((item) => item.trim())
.filter(Boolean);
const meaningful = segments.filter(
(item) => item !== '玩家' && item !== '主角' && item !== '我',
);
return {
leadName: meaningful[0] || segments[0] || '',
relationToPlayer:
segments.length >= 2 ? segments.join(' / ') : value.trim(),
};
}
function normalizeWorldPromise(value: unknown): WorldPromiseValue | null {
if (!value || typeof value !== 'object') {
return null;
}
const item = value as Record<string, unknown>;
const nextValue = {
hook: toText(item.hook),
differentiator: toText(item.differentiator),
desiredExperience: toText(item.desiredExperience),
} satisfies WorldPromiseValue;
return Object.values(nextValue).some(Boolean) ? nextValue : null;
}
function normalizePlayerFantasy(value: unknown): PlayerFantasyValue | null {
if (!value || typeof value !== 'object') {
return null;
}
const item = value as Record<string, unknown>;
const nextValue = {
playerRole: toText(item.playerRole),
corePursuit: toText(item.corePursuit),
fearOfLoss: toText(item.fearOfLoss),
} satisfies PlayerFantasyValue;
return Object.values(nextValue).some(Boolean) ? nextValue : null;
}
function normalizeThemeBoundary(value: unknown): ThemeBoundaryValue | null {
if (!value || typeof value !== 'object') {
return null;
}
const item = value as Record<string, unknown>;
const nextValue = {
toneKeywords: toStringArray(item.toneKeywords, 8),
aestheticDirectives: toStringArray(item.aestheticDirectives, 8),
forbiddenDirectives: toStringArray(item.forbiddenDirectives, 8),
} satisfies ThemeBoundaryValue;
return Object.values(nextValue).some((entry) => entry.length > 0)
? nextValue
: null;
}
function normalizePlayerEntryPoint(value: unknown): PlayerEntryPointValue | null {
if (!value || typeof value !== 'object') {
return null;
}
const item = value as Record<string, unknown>;
const nextValue = {
openingIdentity: toText(item.openingIdentity),
openingProblem: toText(item.openingProblem),
entryMotivation: toText(item.entryMotivation),
} satisfies PlayerEntryPointValue;
return Object.values(nextValue).some(Boolean) ? nextValue : null;
}
function normalizeCoreConflict(value: unknown): CoreConflictValue | null {
if (!value || typeof value !== 'object') {
return null;
}
const item = value as Record<string, unknown>;
const nextValue = {
surfaceConflicts: toStringArray(item.surfaceConflicts, 6),
hiddenCrisis: toText(item.hiddenCrisis),
firstTouchedConflict: toText(item.firstTouchedConflict),
} satisfies CoreConflictValue;
return (
nextValue.surfaceConflicts.length > 0 ||
nextValue.hiddenCrisis ||
nextValue.firstTouchedConflict
)
? nextValue
: null;
}
function normalizeRelationship(value: unknown): KeyRelationshipValue | null {
if (!value || typeof value !== 'object') {
return null;
}
const item = value as Record<string, unknown>;
const nextValue = {
pairs: toText(item.pairs),
relationshipType: toText(item.relationshipType),
secretOrCost: toText(item.secretOrCost),
} satisfies KeyRelationshipValue;
return Object.values(nextValue).some(Boolean) ? nextValue : null;
}
function normalizeHiddenLines(value: unknown): HiddenLineValue | null {
if (!value || typeof value !== 'object') {
return null;
}
const item = value as Record<string, unknown>;
const nextValue = {
hiddenTruths: toStringArray(item.hiddenTruths, 6),
misdirectionHints: toStringArray(item.misdirectionHints, 6),
revealPacing: toText(item.revealPacing),
} satisfies HiddenLineValue;
return (
nextValue.hiddenTruths.length > 0 ||
nextValue.misdirectionHints.length > 0 ||
nextValue.revealPacing
)
? nextValue
: null;
}
function normalizeIconicElements(value: unknown): IconicElementValue | null {
if (!value || typeof value !== 'object') {
return null;
}
const item = value as Record<string, unknown>;
const nextValue = {
iconicMotifs: toStringArray(item.iconicMotifs, 8),
institutionsOrArtifacts: toStringArray(item.institutionsOrArtifacts, 8),
hardRules: toStringArray(item.hardRules, 8),
} satisfies IconicElementValue;
return (
nextValue.iconicMotifs.length > 0 ||
nextValue.institutionsOrArtifacts.length > 0 ||
nextValue.hardRules.length > 0
)
? nextValue
: null;
}
export function createEmptyEightAnchorContent(): EightAnchorContent {
return {
worldPromise: null,
playerFantasy: null,
themeBoundary: null,
playerEntryPoint: null,
coreConflict: null,
keyRelationships: [],
hiddenLines: null,
iconicElements: null,
};
}
export function normalizeEightAnchorContent(value: unknown): EightAnchorContent {
if (!value || typeof value !== 'object') {
return createEmptyEightAnchorContent();
}
const item = value as Record<string, unknown>;
return {
worldPromise: normalizeWorldPromise(item.worldPromise),
playerFantasy: normalizePlayerFantasy(item.playerFantasy),
themeBoundary: normalizeThemeBoundary(item.themeBoundary),
playerEntryPoint: normalizePlayerEntryPoint(item.playerEntryPoint),
coreConflict: normalizeCoreConflict(item.coreConflict),
keyRelationships: Array.isArray(item.keyRelationships)
? item.keyRelationships
.map((entry) => normalizeRelationship(entry))
.filter((entry): entry is KeyRelationshipValue => Boolean(entry))
.slice(0, 4)
: [],
hiddenLines: normalizeHiddenLines(item.hiddenLines),
iconicElements: normalizeIconicElements(item.iconicElements),
};
}
export function buildEightAnchorContentFromCreatorIntent(
intent: CustomWorldCreatorIntentRecord | null | undefined,
): EightAnchorContent {
if (!intent) {
return createEmptyEightAnchorContent();
}
const themeBoundary =
intent.themeKeywords.length > 0 ||
intent.toneDirectives.length > 0 ||
intent.forbiddenDirectives.length > 0
? {
toneKeywords: [...intent.themeKeywords],
aestheticDirectives: [...intent.toneDirectives],
forbiddenDirectives: [...intent.forbiddenDirectives],
}
: null;
const firstCharacter = intent.keyCharacters[0] ?? null;
return normalizeEightAnchorContent({
worldPromise:
intent.worldHook || intent.rawSettingText
? {
hook: intent.worldHook,
differentiator: intent.rawSettingText,
desiredExperience: compactLines([
intent.themeKeywords[0],
intent.toneDirectives[0],
]),
}
: null,
playerFantasy:
intent.playerPremise || intent.coreConflicts[0]
? {
playerRole: intent.playerPremise,
corePursuit: intent.coreConflicts[0] ?? '',
fearOfLoss: firstCharacter?.hiddenHook ?? '',
}
: null,
themeBoundary,
playerEntryPoint:
intent.playerPremise || intent.openingSituation
? {
openingIdentity: intent.playerPremise,
openingProblem: intent.openingSituation,
entryMotivation: intent.coreConflicts[0] ?? '',
}
: null,
coreConflict:
intent.coreConflicts.length > 0
? {
surfaceConflicts: intent.coreConflicts.slice(0, 3),
hiddenCrisis: intent.keyCharacters[0]?.hiddenHook ?? '',
firstTouchedConflict: intent.coreConflicts[0] ?? '',
}
: null,
keyRelationships: intent.keyCharacters.map((entry) => ({
pairs: compactLines([
entry.name,
entry.relationToPlayer ? `与玩家 ${entry.relationToPlayer}` : '',
]),
relationshipType: entry.role,
secretOrCost: entry.hiddenHook,
})),
hiddenLines:
intent.keyCharacters.some((entry) => entry.hiddenHook) ||
intent.forbiddenDirectives.length > 0
? {
hiddenTruths: intent.keyCharacters
.map((entry) => entry.hiddenHook)
.filter(Boolean)
.slice(0, 3),
misdirectionHints: intent.forbiddenDirectives.slice(0, 3),
revealPacing: '',
}
: null,
iconicElements:
intent.iconicElements.length > 0
? {
iconicMotifs: intent.iconicElements.slice(0, 4),
institutionsOrArtifacts: [],
hardRules: intent.forbiddenDirectives.slice(0, 3),
}
: null,
});
}
export function buildCreatorIntentFromEightAnchorContent(
anchorContent: EightAnchorContent,
): CustomWorldCreatorIntentRecord {
const nextIntent = createEmptyCreatorIntentRecord('freeform');
const normalizedContent = normalizeEightAnchorContent(anchorContent);
const keyCharacters: CreatorCharacterSeedRecord[] =
normalizedContent.keyRelationships.map((entry, index) => {
const parsedPair = splitRelationshipPair(entry.pairs);
return {
id: createId('creator-character', index),
name: parsedPair.leadName || `关键人物${index + 1}`,
role: entry.relationshipType,
publicMask: '',
hiddenHook: entry.secretOrCost,
relationToPlayer: parsedPair.relationToPlayer,
notes: '',
};
});
const worldHook = compactLines([
normalizedContent.worldPromise?.hook,
normalizedContent.worldPromise?.differentiator,
]);
const playerPremise = compactLines([
normalizedContent.playerFantasy?.playerRole,
normalizedContent.playerEntryPoint?.openingIdentity,
]);
const openingSituation = compactLines([
normalizedContent.playerEntryPoint?.openingProblem,
normalizedContent.playerEntryPoint?.entryMotivation,
]);
const coreConflicts = [
...(normalizedContent.coreConflict?.surfaceConflicts ?? []),
normalizedContent.coreConflict?.hiddenCrisis ?? '',
].filter(Boolean);
const iconicElements = [
...(normalizedContent.iconicElements?.iconicMotifs ?? []),
...(normalizedContent.iconicElements?.institutionsOrArtifacts ?? []),
].filter(Boolean);
const forbiddenDirectives = [
...(normalizedContent.themeBoundary?.forbiddenDirectives ?? []),
...(normalizedContent.iconicElements?.hardRules ?? []),
].filter(Boolean);
return {
...nextIntent,
rawSettingText: compactLines([
normalizedContent.worldPromise?.differentiator,
normalizedContent.playerFantasy?.corePursuit,
normalizedContent.hiddenLines?.hiddenTruths[0],
]),
worldHook,
themeKeywords: normalizedContent.themeBoundary?.toneKeywords ?? [],
toneDirectives: normalizedContent.themeBoundary?.aestheticDirectives ?? [],
playerPremise,
openingSituation,
coreConflicts: [...new Set(coreConflicts)].slice(0, 6),
keyCharacters,
iconicElements: [...new Set(iconicElements)].slice(0, 8),
forbiddenDirectives: [...new Set(forbiddenDirectives)].slice(0, 8),
} satisfies CustomWorldCreatorIntentRecord;
}
function scoreFilledField(filled: boolean, score: number) {
return filled ? score : 0;
}
export function estimateProgressPercentFromAnchorContent(
anchorContent: EightAnchorContent,
) {
const normalized = normalizeEightAnchorContent(anchorContent);
const progress =
scoreFilledField(Boolean(normalized.worldPromise?.hook), 14) +
scoreFilledField(Boolean(normalized.playerFantasy?.playerRole), 12) +
scoreFilledField(
Boolean(
normalized.themeBoundary?.toneKeywords.length ||
normalized.themeBoundary?.aestheticDirectives.length,
),
12,
) +
scoreFilledField(
Boolean(normalized.playerEntryPoint?.openingProblem),
12,
) +
scoreFilledField(
Boolean(normalized.coreConflict?.surfaceConflicts.length),
16,
) +
scoreFilledField(normalized.keyRelationships.length > 0, 14) +
scoreFilledField(
Boolean(
normalized.hiddenLines?.hiddenTruths.length ||
normalized.hiddenLines?.revealPacing,
),
8,
) +
scoreFilledField(
Boolean(
normalized.iconicElements?.iconicMotifs.length ||
normalized.iconicElements?.institutionsOrArtifacts.length,
),
12,
);
return Math.max(0, Math.min(100, Math.round(progress)));
}
export function buildAnchorPackFromEightAnchorContent(
anchorContent: EightAnchorContent,
progressPercent: number,
) {
const creatorIntent = buildCreatorIntentFromEightAnchorContent(anchorContent);
return buildAnchorPackFromIntent(creatorIntent, {
completedKeys: progressPercent >= 100 ? ['eight_anchor_minimum_loop'] : [],
missingKeys: progressPercent >= 100 ? [] : ['eight_anchor_minimum_loop'],
});
}
export function buildEightAnchorFoundationText(anchorContent: EightAnchorContent) {
const normalized = normalizeEightAnchorContent(anchorContent);
return [
normalized.worldPromise
? `世界承诺:${compactLines([
normalized.worldPromise.hook,
normalized.worldPromise.differentiator,
normalized.worldPromise.desiredExperience,
])}`
: '',
normalized.playerFantasy
? `玩家幻想:${compactLines([
normalized.playerFantasy.playerRole,
normalized.playerFantasy.corePursuit,
normalized.playerFantasy.fearOfLoss,
])}`
: '',
normalized.themeBoundary
? `主题边界:${compactLines([
normalized.themeBoundary.toneKeywords.join('、'),
normalized.themeBoundary.aestheticDirectives.join('、'),
normalized.themeBoundary.forbiddenDirectives.join('、'),
])}`
: '',
normalized.playerEntryPoint
? `玩家切入口:${compactLines([
normalized.playerEntryPoint.openingIdentity,
normalized.playerEntryPoint.openingProblem,
normalized.playerEntryPoint.entryMotivation,
])}`
: '',
normalized.coreConflict
? `核心冲突:${compactLines([
normalized.coreConflict.surfaceConflicts.join('、'),
normalized.coreConflict.hiddenCrisis,
normalized.coreConflict.firstTouchedConflict,
])}`
: '',
normalized.keyRelationships.length > 0
? `关键关系:${normalized.keyRelationships
.map((entry) =>
compactLines([
entry.pairs,
entry.relationshipType,
entry.secretOrCost,
]),
)
.filter(Boolean)
.join('')}`
: '',
normalized.hiddenLines
? `暗线与揭示:${compactLines([
normalized.hiddenLines.hiddenTruths.join('、'),
normalized.hiddenLines.misdirectionHints.join('、'),
normalized.hiddenLines.revealPacing,
])}`
: '',
normalized.iconicElements
? `标志元素:${compactLines([
normalized.iconicElements.iconicMotifs.join('、'),
normalized.iconicElements.institutionsOrArtifacts.join('、'),
normalized.iconicElements.hardRules.join('、'),
])}`
: '',
]
.filter(Boolean)
.join('\n');
}
export function buildDraftTitleFromEightAnchorContent(
anchorContent: EightAnchorContent,
) {
const normalized = normalizeEightAnchorContent(anchorContent);
const candidate = clampText(
normalized.worldPromise?.hook ||
normalized.worldPromise?.differentiator ||
normalized.iconicElements?.iconicMotifs[0] ||
normalized.playerFantasy?.playerRole ||
'',
24,
);
return candidate || '未命名草稿';
}
export function buildDraftSummaryFromEightAnchorContent(
anchorContent: EightAnchorContent,
) {
const normalized = normalizeEightAnchorContent(anchorContent);
const summary = [
compactLines([
normalized.worldPromise?.hook,
normalized.worldPromise?.differentiator,
normalized.worldPromise?.desiredExperience,
]),
compactLines([
normalized.playerFantasy?.playerRole,
normalized.playerFantasy?.corePursuit,
normalized.playerFantasy?.fearOfLoss,
]),
compactLines([
normalized.playerEntryPoint?.openingIdentity,
normalized.playerEntryPoint?.openingProblem,
normalized.playerEntryPoint?.entryMotivation,
]),
compactLines([
normalized.coreConflict?.surfaceConflicts.join('、'),
normalized.coreConflict?.hiddenCrisis,
normalized.coreConflict?.firstTouchedConflict,
]),
normalized.keyRelationships.length > 0
? normalized.keyRelationships
.map((entry) =>
compactLines([
entry.pairs,
entry.relationshipType,
entry.secretOrCost,
]),
)
.filter(Boolean)
.join('')
: '',
compactLines([
normalized.iconicElements?.iconicMotifs.join('、'),
normalized.iconicElements?.institutionsOrArtifacts.join('、'),
normalized.iconicElements?.hardRules.join('、'),
]),
]
.filter(Boolean)
.join(' · ');
return clampText(summary, 180) || '还在收集你的世界锚点。';
}

View File

@@ -0,0 +1,784 @@
import type {
EightAnchorContent,
HiddenLineValue,
IconicElementValue,
KeyRelationshipValue,
ThemeBoundaryValue,
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
import {
createEmptyEightAnchorContent,
normalizeEightAnchorContent,
} from './eightAnchorCompatibilityService.js';
export type PromptUserInputSignal =
| 'rich'
| 'normal'
| 'sparse'
| 'correction'
| 'delegate';
export type PromptDriftRisk = 'low' | 'medium' | 'high';
export type PromptConversationMode =
| 'bootstrap'
| 'expand'
| 'compress'
| 'repair_direction'
| 'force_complete'
| 'closing';
export type PromptDynamicState = {
currentTurn: number;
progressPercent: number;
userInputSignal: PromptUserInputSignal;
driftRisk: PromptDriftRisk;
quickFillRequested: boolean;
conversationMode: PromptConversationMode;
judgementSummary: string;
};
export type PromptDynamicStateInference = {
userInputSignal?: unknown;
driftRisk?: unknown;
conversationMode?: unknown;
judgementSummary?: unknown;
};
const BASE_SYSTEM_PROMPT = `你是一个负责共创游戏世界设定的专业策划。
你正在和用户一起共创一个游戏世界。每一轮你都必须读取:
1. 当前完整设定结构
2. 用户聊天记录
然后输出:
1. 一版新的完整设定结构
2. 当前 progress 百分比
3. 一段直接回复用户的话
你必须把“新的完整设定结构”视为下一轮的唯一有效版本。
你的输出会直接覆盖上一版设定结构。
你不是在做局部 patch。
你不是在做解释报告。
你不是在给开发者写分析。
你是在同时完成:
1. 世界设定更新
2. 当前推进程度判断
3. 对用户的共创回复`;
const GLOBAL_HARD_RULES = `全局硬约束:
1. 必须输出完整的设定结构,而不是只输出变化部分。
2. 新的设定结构会直接覆盖旧内容,因此不得随意丢失仍然成立的重要信息。
3. 如果用户明确修正旧设定,必须在新的设定结构中直接体现修正结果。
4. 如果用户输入信息不足,可以保留上一版中仍然成立的内容。
5. progressPercent 最低为 0不允许为负数。
6. replyText 会直接发送给用户,因此要自然、直接、可继续聊天。
7. 不要输出额外解释,不要输出 markdown 代码块,不要输出开发备注。
8. replyText 不要写成长篇策划文,不要展开大段世界观百科。
9. replyText 默认只推进当前最关键的一步,不要同时抛出很多话题。
10. replyText 不要提及“八锚点”“锚点”“结构字段”“框架字段”等内部概念词。
11. 你输出的 JSON 必须可以被直接解析。
12. 输出字段顺序必须固定为replyText、progressPercent、nextAnchorContent。`;
const MODE_RULES: Record<PromptConversationMode, string> = {
bootstrap: `当前模式bootstrap
目标:
1. 先把世界的基本方向抓住
2. 不要一次塞太多新设定
3. 回复要降低用户开口压力
本轮行为要求:
1. 优先从用户输入里抓世界方向、玩家视角、主题边界的线索
2. 如果用户信息很少,不要强行把整套结构一次补满
3. replyText 要像共创搭档,而不是像审问
4. 默认只推进一个最关键的问题方向
5. 如果用户刚开口,优先给“被理解感”,再轻轻推进下一步
6. 可以用一句很短的话先确认你抓到的核心方向,再提一个最好回答的问题
7. 不要把问题问得像表单采集,不要一口气追问多个维度
用户体验要求:
1. 让用户觉得“现在很容易继续往下说”
2. 不要制造被考试、被拷问、被策划问卷追着跑的感觉
3. replyText 最好短、稳、可接话
4. 如果用户信息很少,也不要显得冷淡或机械`,
expand: `当前模式expand
目标:
1. 在保持现有方向的前提下,把设定结构逐步补全
2. 尽量让一轮输入覆盖多个关键维度
本轮行为要求:
1. 继续保留上一版里仍成立的设定
2. 优先把用户本轮输入映射进多个关键维度,而不是只更新一个字段
3. replyText 要明确体现“你已经理解了哪些内容”
4. 不要突然大幅改写已经成形的世界
5. 如果用户这一轮给了多条有效信息replyText 应先把这些信息自然串起来,再决定下一步
6. 可以适度替用户整理,但不要把回复写成总结报告
7. 默认继续往前推一步,不要在还没必要时突然收束或突然跳到成稿感
用户体验要求:
1. 让用户感到“我刚说的内容都被接住了”
2. 回复里可以带一点顺势整理感,但不要太像会议纪要
3. 不要无视用户刚提供的高价值细节
4. 不要让用户觉得系统在自顾自重写世界`,
compress: `当前模式compress
目标:
1. 开始收束当前设定
2. 减少无效发散
3. 让 progress 更接近可进入下一阶段
本轮行为要求:
1. 新的设定结构优先保留稳定内容,不要无端重写
2. 对用户本轮输入做高密度吸收
3. replyText 要更聚焦,不要绕圈
4. 默认只推进当前最影响 completion 的一步
5. 如果用户还在补细节,优先把细节挂回现有骨架,而不是继续开新分支
6. 可以适度提醒“还差哪类关键空位”,但不要把回复写成 checklist
7. 如果已有信息足够replyText 可以更像“确认并收束”,少一点继续发散式追问
用户体验要求:
1. 让用户感觉世界正在变得更稳,而不是越来越散
2. 让推进感更明确,但不要显得催促
3. 回复语气应更笃定一些,减少反复横跳
4. 不要把用户刚补进来的细节又冲淡掉`,
repair_direction: `当前模式repair_direction
目标:
1. 处理用户对既有设定的修正
2. 避免世界方向飘散或自相矛盾
本轮行为要求:
1. 如果用户明确改口,新的设定结构必须体现修正后的方向
2. 对已经不再成立的旧设定,不要机械保留
3. progressPercent 可以停滞,也可以小幅回落,但不能为负
4. replyText 要承认用户的修正,并顺着修正后的方向继续聊
5. 先处理“改掉什么”,再决定“往哪里继续推”
6. 不要一边口头承认用户修正,一边在设定结构里偷偷留住旧方向
7. 如果修正幅度很大replyText 可以帮助用户确认新方向已经接管当前语境
用户体验要求:
1. 让用户感到“我刚刚的纠偏真的生效了”
2. 不要和用户辩论旧方案为什么也行
3. 不要表现出对修正的不情愿
4. 回复要体现重心已经切到新方向,而不是停留在旧世界观惯性里`,
force_complete: `当前模式force_complete
目标:
1. 基于当前方向直接补齐剩余设定
2. 生成一版尽量完整、可进入下一阶段的设定结构
3. 结束当前收集阶段
本轮行为要求:
1. 尽量保留已经形成的世界方向
2. 对明显缺失的关键维度进行合理补全
3. 不要继续拉长聊天,不要再追问用户
4. progressPercent 直接输出为 100
5. replyText 要自然引导用户点击“生成游戏设定草稿”
6. 补全时要优先做“顺着已有方向补齐”,而不是突然换题材、换气质、换主冲突
7. 可以让结果更完整,但不要补得过满、过死、过像定稿圣经
8. replyText 更像阶段完成提示,不再像继续采集信息的对话
用户体验要求:
1. 让用户感到“系统已经帮我把能补的补好了”
2. 不要在这一步突然冒出很多陌生设定把用户吓出戏
3. 回复要有完成感,但不要太官话
4. 清楚告诉用户下一步可以做什么`,
closing: `当前模式closing
目标:
1. 尽量形成一版可用的设定底子
2. 不再继续发散新世界观
本轮行为要求:
1. 优先收束,而不是扩写
2. 不要大改已经成形的核心设定
3. progressPercent 接近完成时replyText 要更像确认与推进
4. 如果用户没有大改方向,尽量让下一版内容更稳定
5. 可以轻微补足缺口,但不要再大开新支线
6. replyText 应减少探索式措辞,增加“已经基本成形”的稳定感
7. 如果只差少量空位,优先把这些空位自然补平,而不是重新打开大话题
用户体验要求:
1. 让用户感觉作品已经快成了,而不是还在无穷试探
2. 回复可以更像确认和轻推,不要继续像前期那样频繁试探
3. 保持留白感,不要把所有东西都一次说死
4. 让用户自然过渡到下一阶段,而不是突然被切断对话`,
};
const USER_SIGNAL_RULES: Record<PromptUserInputSignal, string> = {
rich: `本轮用户输入信息密度高。
请尽量从这一轮里提取多个锚点,不要只更新单一方向。
如果一条输入同时影响世界方向、冲突和关系,请在新的完整设定结构中一起体现。`,
normal: `本轮用户输入为正常补充。
请优先顺着当前方向稳定更新,不要主动扩写太多新设定。`,
sparse: `本轮用户输入较少或较虚。
请保留上一版中仍然成立的内容,不要为了凑完整度而强行发明过多新设定。
replyText 要让用户容易继续往下说。`,
correction: `本轮用户在修正或推翻旧设定。
请优先吸收修正,不要机械复读旧版本。
新的完整设定结构必须以修正后的方向为准。`,
delegate: `本轮用户把部分决定权交给你。
你可以在 replyText 中给出有限度的建议,但不要突然补满整套设定。
新的完整设定结构仍应尽量建立在已有世界方向上,而不是完全重做。`,
};
const QUICK_FILL_EXTRA_RULES = `用户刚刚主动要求你自动补全剩余设定。
这表示用户接受你基于当前方向自动补完剩余设定。
本轮要求:
1. 不要再继续提问
2. 直接输出一版尽量完整的设定结构
3. progressPercent 直接输出为 100
4. replyText 要告诉用户现在可以进入“生成游戏设定草稿”`;
const STATE_INFERENCE_SYSTEM_PROMPT = `你是正式生成世界设定前的一步“创作状态识别器”。
你的职责不是直接生成新设定,而是先判断:下一轮正式生成应该用什么推进策略,尤其要判断 replyText 应该更偏确认、吸收、收束、纠偏,还是启发式提问。
你必须综合以下信息判断:
1. 当前轮次 currentTurn
2. 当前完成度 progressPercent
3. 用户是否要求自动补全 quickFillRequested
4. 当前完整设定结构
5. 最近聊天记录,尤其是最近 1 到 3 轮用户消息
你需要输出 4 个字段:
1. userInputSignal只能是 rich / normal / sparse / correction / delegate
2. driftRisk只能是 low / medium / high
3. conversationMode只能是 bootstrap / expand / compress / repair_direction / force_complete / closing
4. judgementSummary1 到 2 句中文,概括你为什么这样判断,以及正式生成时最该注意什么
请按下面的语义判断。
一、userInputSignal 定义
1. rich
- 用户这一轮给了多条可直接落地的有效信息
- 这些信息可能同时覆盖世界方向、玩家处境、开局事件、冲突、关系、标志元素中的多个
- 正式生成时应优先高密度吸收,不要只更新一个点
2. normal
- 用户在顺着当前方向做正常补充
- 信息量中等,有明确新增内容,但没有明显推翻旧方向,也没有把决定权交给系统
- 正式生成时应稳定推进并自然接住用户内容
3. sparse
- 用户输入很短、很虚、很笼统,或几乎没有新增有效事实
- 例如只有一个题材词、一个气质词、一句很概括的话、一个很短的倾向表达
- 这种情况下,正式生成阶段的 replyText 应优先采用启发式提问
- 启发式提问的要求是:只问一个最容易回答、最能推动落地设计的问题
4. correction
- 用户这轮核心动作是在修正、替换、推翻、重定向旧设定
- 即使文字不长,只要主意图是“之前那个不对,现在改成这个”,也应优先判为 correction
- correction 的优先级高于 rich 和 normal
5. delegate
- 用户把部分决定权交给系统
- 例如“你来定”“你帮我补”“按你觉得合理的来”“先给我一个默认方案”
- delegate 关注的是授权关系,不只是信息多寡
二、driftRisk 定义
1. low
- 当前轮输入与已有方向基本一致
- 没有明显改口或冲突
2. medium
- 当前轮带来一定方向变化或扩张
- 还没有明显推翻旧方向,但如果处理不好,容易让设定开始发散
3. high
- 用户明确纠偏、改口、替换方向,或最近多轮反复修正
- 这时最重要的是防止旧方向重新回流到正式生成结果里
三、conversationMode 选择原则
1. bootstrap
- 适用于前期、信息少、核心方向未稳定
- replyText 更适合低压力确认和单点启发
2. expand
- 适用于方向已成形,正在顺着现有路线继续补充
- replyText 更适合总结已接住的内容并往前推一步
3. compress
- 适用于中后段,已有骨架,需要开始收束
- replyText 更适合聚焦最关键缺口,而不是继续开支线
4. repair_direction
- 适用于用户正在纠偏
- replyText 更适合先承认修正,再沿修正后的方向继续推进
5. force_complete
- 适用于用户明确要求自动补全
- replyText 不再提问,而应给出完成感和下一步引导
6. closing
- 适用于接近完成但并非强制一键补全
- replyText 更像确认与收束,而不是前期式探索
四、优先级规则
1. 如果 quickFillRequested 为 trueconversationMode 必须优先判为 force_complete
2. 如果用户核心意图是修正旧方向userInputSignal 优先判为 correctionconversationMode 通常优先考虑 repair_direction
3. 如果用户核心意图是授权系统替他补完userInputSignal 优先判为 delegate
4. 只有在没有明显纠偏、也没有明确自动补全要求时,才主要依据 currentTurn、progressPercent 和信息密度,在 bootstrap / expand / compress / closing 之间选择
五、关于 replyText 风格的专门判断要求
1. 如果用户输入较少、较虚或不够落地,正式生成阶段的 replyText 应采用启发式提问
2. 启发式提问一次最多只能提 1 个问题,不能连问两个或更多
3. 启发式提问必须问“最能推动当前设计落地”的那个问题,而不是泛泛而谈
4. 如果用户输入已经足够 rich就不要再机械提问优先吸收和推进
5. 如果用户在 correction 或 delegate 状态下replyText 是否提问要服从更高目标:纠偏生效或代为补全,不要机械套 sparse 的问法
六、关于 replyText 用语的硬约束
1. replyText 禁止提及内部结构名、锚点名、字段名、schema 名、框架词
2. 禁止出现这类内部表达:世界承诺、玩家幻想、主题边界、玩家入口、核心冲突、关键关系、隐藏线、标志元素、字段、结构、模块、八锚点
3. replyText 只能用通俗、直接、面向创作沟通的语言回应用户
4. replyText 应该围绕用户正在讨论的具体内容来落地,比如身份、开场处境、冲突、人物关系、地点、规则、气质,而不是抽象谈结构
5. judgementSummary 可以简洁提到“这轮更适合启发式提问”或“这轮应优先吸收修正”,但也不要堆内部术语
七、关于 judgementSummary 的写法
1. 必须简洁,不要写成长篇分析
2. 必须直接服务于下一轮正式生成
3. 最好同时包含两层信息:
- 为什么这么判断
- 正式生成时最该优先做什么,或最该避免什么
八、硬性约束
1. 只能输出 JSON不能输出解释、代码块或额外说明
2. 不能发明上下文里不存在的设定事实
3. 你的任务是“判断生成策略”,不是“代替正式生成直接写新设定”
4. 即使信息不完全,也必须在给定枚举里选出最合理的一组状态
5. judgementSummary 必须是中文
6. 输出值必须严格落在给定枚举中`;
const STATE_INFERENCE_OUTPUT_CONTRACT = `请严格按以下 JSON 结构输出,不要输出其他文字:
{
"userInputSignal": "normal",
"driftRisk": "low",
"conversationMode": "expand",
"judgementSummary": ""
}`;
const OUTPUT_CONTRACT_REMINDER = `请严格按以下 JSON 结构输出,不要输出其他文字:
{
"replyText": "",
"progressPercent": 0,
"nextAnchorContent": {
"worldPromise": {
"hook": "",
"differentiator": "",
"desiredExperience": ""
},
"playerFantasy": {
"playerRole": "",
"corePursuit": "",
"fearOfLoss": ""
},
"themeBoundary": {
"toneKeywords": [],
"aestheticDirectives": [],
"forbiddenDirectives": []
},
"playerEntryPoint": {
"openingIdentity": "",
"openingProblem": "",
"entryMotivation": ""
},
"coreConflict": {
"surfaceConflicts": [],
"hiddenCrisis": "",
"firstTouchedConflict": ""
},
"keyRelationships": [
{
"pairs": "",
"relationshipType": "",
"secretOrCost": ""
}
],
"hiddenLines": {
"hiddenTruths": [],
"misdirectionHints": [],
"revealPacing": ""
},
"iconicElements": {
"iconicMotifs": [],
"institutionsOrArtifacts": [],
"hardRules": []
}
}
}`;
function toJson(value: unknown) {
return JSON.stringify(value, null, 2);
}
function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function getLatestUserText(
chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>,
) {
return (
[...chatHistory]
.reverse()
.find((entry) => entry.role === 'user' && entry.content.trim())?.content ??
''
);
}
function includesAny(text: string, patterns: RegExp[]) {
return patterns.some((pattern) => pattern.test(text));
}
function isPromptUserInputSignal(
value: unknown,
): value is PromptUserInputSignal {
return (
value === 'rich' ||
value === 'normal' ||
value === 'sparse' ||
value === 'correction' ||
value === 'delegate'
);
}
function isPromptDriftRisk(value: unknown): value is PromptDriftRisk {
return value === 'low' || value === 'medium' || value === 'high';
}
function isPromptConversationMode(
value: unknown,
): value is PromptConversationMode {
return (
value === 'bootstrap' ||
value === 'expand' ||
value === 'compress' ||
value === 'repair_direction' ||
value === 'force_complete' ||
value === 'closing'
);
}
export function detectUserInputSignal(
chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>,
): PromptUserInputSignal {
const latestUserText = getLatestUserText(chatHistory).trim();
if (!latestUserText) {
return 'sparse';
}
if (includesAny(latestUserText, [/(||||||)/u])) {
return 'correction';
}
if (includesAny(latestUserText, [/(|||)/u])) {
return 'delegate';
}
const segments = latestUserText
.split(/[\n]/u)
.map((item) => item.trim())
.filter(Boolean);
if (latestUserText.length <= 10 || segments.length <= 1) {
return 'sparse';
}
if (segments.length >= 3 || latestUserText.length >= 60) {
return 'rich';
}
return 'normal';
}
function summarizeDynamicState(
state: Pick<
PromptDynamicState,
'userInputSignal' | 'driftRisk' | 'conversationMode'
>,
) {
return `输入信号=${state.userInputSignal},漂移风险=${state.driftRisk},本轮模式=${state.conversationMode}。正式生成时按这组状态执行。`;
}
function isThemeBoundaryFilled(value: ThemeBoundaryValue | null) {
return Boolean(
value &&
(value.toneKeywords.length > 0 ||
value.aestheticDirectives.length > 0 ||
value.forbiddenDirectives.length > 0),
);
}
function isRelationshipsFilled(value: KeyRelationshipValue[]) {
return value.length > 0;
}
function isHiddenLinesFilled(value: HiddenLineValue | null) {
return Boolean(
value &&
(value.hiddenTruths.length > 0 ||
value.misdirectionHints.length > 0 ||
value.revealPacing),
);
}
function isIconicElementsFilled(value: IconicElementValue | null) {
return Boolean(
value &&
(value.iconicMotifs.length > 0 ||
value.institutionsOrArtifacts.length > 0 ||
value.hardRules.length > 0),
);
}
export function detectDriftRisk(params: {
chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>;
anchorContent: EightAnchorContent;
progressPercent: number;
}) {
const latestUserText = getLatestUserText(params.chatHistory).trim();
const recentUserMessages = params.chatHistory
.filter((entry) => entry.role === 'user')
.slice(-3)
.map((entry) => entry.content.trim())
.filter(Boolean);
const correctionCount = recentUserMessages.filter((entry) =>
/(||||||)/u.test(entry),
).length;
if (
correctionCount >= 2 ||
(params.progressPercent >= 65 &&
/(|||||)/u.test(latestUserText))
) {
return 'high' as const;
}
const normalizedContent = normalizeEightAnchorContent(params.anchorContent);
const filledCount = [
Boolean(normalizedContent.worldPromise),
Boolean(normalizedContent.playerFantasy),
isThemeBoundaryFilled(normalizedContent.themeBoundary),
Boolean(normalizedContent.playerEntryPoint),
Boolean(normalizedContent.coreConflict),
isRelationshipsFilled(normalizedContent.keyRelationships),
isHiddenLinesFilled(normalizedContent.hiddenLines),
isIconicElementsFilled(normalizedContent.iconicElements),
].filter(Boolean).length;
if (filledCount >= 3 && latestUserText.length >= 40) {
return 'medium' as const;
}
return 'low' as const;
}
export function pickConversationMode(params: {
currentTurn: number;
progressPercent: number;
userInputSignal: PromptUserInputSignal;
driftRisk: PromptDriftRisk;
quickFillRequested: boolean;
}) {
if (params.quickFillRequested) {
return 'force_complete' as const;
}
if (
params.userInputSignal === 'correction' ||
params.driftRisk === 'high'
) {
return 'repair_direction' as const;
}
if (params.progressPercent >= 85 || params.currentTurn >= 15) {
return 'closing' as const;
}
if (params.currentTurn > 10 || params.progressPercent >= 65) {
return 'compress' as const;
}
if (params.currentTurn <= 10 && params.progressPercent < 65) {
return 'expand' as const;
}
return 'bootstrap' as const;
}
function buildRuleBasedPromptDynamicState(input: {
currentTurn: number;
progressPercent: number;
quickFillRequested: boolean;
currentAnchorContent: EightAnchorContent;
chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>;
}): PromptDynamicState {
const userInputSignal = detectUserInputSignal(input.chatHistory);
const driftRisk = detectDriftRisk({
chatHistory: input.chatHistory,
anchorContent: input.currentAnchorContent,
progressPercent: input.progressPercent,
});
const conversationMode = pickConversationMode({
currentTurn: input.currentTurn,
progressPercent: input.progressPercent,
userInputSignal,
driftRisk,
quickFillRequested: input.quickFillRequested,
});
return {
currentTurn: input.currentTurn,
progressPercent: input.progressPercent,
userInputSignal,
driftRisk,
quickFillRequested: input.quickFillRequested,
conversationMode,
judgementSummary: summarizeDynamicState({
userInputSignal,
driftRisk,
conversationMode,
}),
};
}
export function buildPromptDynamicState(input: {
currentTurn: number;
progressPercent: number;
quickFillRequested: boolean;
currentAnchorContent: EightAnchorContent;
chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>;
}, inference?: PromptDynamicStateInference | null): PromptDynamicState {
const fallbackState = buildRuleBasedPromptDynamicState(input);
if (!inference) {
return fallbackState;
}
const userInputSignal = isPromptUserInputSignal(inference.userInputSignal)
? inference.userInputSignal
: fallbackState.userInputSignal;
const driftRisk = isPromptDriftRisk(inference.driftRisk)
? inference.driftRisk
: fallbackState.driftRisk;
const conversationMode = isPromptConversationMode(inference.conversationMode)
? inference.conversationMode
: fallbackState.conversationMode;
const judgementSummary =
toText(inference.judgementSummary) ||
summarizeDynamicState({
userInputSignal,
driftRisk,
conversationMode,
});
return {
currentTurn: input.currentTurn,
progressPercent: input.progressPercent,
userInputSignal,
driftRisk,
quickFillRequested: input.quickFillRequested,
conversationMode,
judgementSummary,
};
}
export function buildPromptDynamicStateInferencePrompt(input: {
currentTurn: number;
progressPercent: number;
quickFillRequested: boolean;
currentAnchorContent: EightAnchorContent;
chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>;
}) {
const currentAnchorContent =
normalizeEightAnchorContent(input.currentAnchorContent) ??
createEmptyEightAnchorContent();
return {
systemPrompt: [
STATE_INFERENCE_SYSTEM_PROMPT,
STATE_INFERENCE_OUTPUT_CONTRACT,
].join('\n\n'),
userPrompt: [
`当前轮次:${input.currentTurn}`,
`当前完成度:${input.progressPercent}`,
`是否要求自动补全:${input.quickFillRequested ? '是' : '否'}`,
renderCurrentAnchorContext(currentAnchorContent),
renderChatHistoryContext(input.chatHistory),
].join('\n\n'),
};
}
function renderDynamicStateContext(dynamicState: PromptDynamicState) {
return `上一轮预判得到的创作状态如下。
正式生成时必须把它作为本轮策略输入直接执行,不要重新另起一套判断。
创作状态:
- userInputSignal: ${dynamicState.userInputSignal}
- driftRisk: ${dynamicState.driftRisk}
- conversationMode: ${dynamicState.conversationMode}
- judgementSummary: ${dynamicState.judgementSummary}`;
}
function renderCurrentAnchorContext(anchorContent: EightAnchorContent) {
return `当前完整设定结构如下。
你必须把它视为上一版有效世界底子。
如果用户没有否定其中某部分内容,且该部分仍然成立,可以继续保留。
如果用户明确修正了某部分内容,新的完整设定结构必须体现修正后的版本。
当前完整设定结构:
${toJson(normalizeEightAnchorContent(anchorContent))}`;
}
function renderChatHistoryContext(
chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>,
) {
return `以下是用户聊天记录。
请重点理解最近几轮里用户新增、修正、强调的设定信息。
不要把早期已经被用户否定的内容继续当成最终结论。
用户聊天记录:
${toJson(chatHistory)}`;
}
export function buildEightAnchorSingleTurnPrompt(input: {
currentTurn: number;
progressPercent: number;
quickFillRequested: boolean;
currentAnchorContent: EightAnchorContent;
chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>;
dynamicState?: PromptDynamicStateInference | PromptDynamicState | null;
}) {
const currentAnchorContent =
normalizeEightAnchorContent(input.currentAnchorContent) ??
createEmptyEightAnchorContent();
const dynamicState = buildPromptDynamicState({
...input,
currentAnchorContent,
}, input.dynamicState);
return {
prompt: [
BASE_SYSTEM_PROMPT,
GLOBAL_HARD_RULES,
MODE_RULES[dynamicState.conversationMode],
USER_SIGNAL_RULES[dynamicState.userInputSignal],
dynamicState.quickFillRequested ? QUICK_FILL_EXTRA_RULES : null,
renderDynamicStateContext(dynamicState),
renderCurrentAnchorContext(currentAnchorContent),
renderChatHistoryContext(input.chatHistory),
OUTPUT_CONTRACT_REMINDER,
]
.filter(Boolean)
.join('\n\n'),
dynamicState,
};
}

View File

@@ -0,0 +1,420 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js';
import { EightAnchorSingleTurnService } from './eightAnchorSingleTurnService.js';
import type { UpstreamLlmClient } from './llmClient.js';
test('eight anchor single turn service updates anchors from model output', async () => {
const service = new EightAnchorSingleTurnService(
createTestCustomWorldAgentSingleTurnLlmClient(),
);
const result = await service.runTurn({
currentTurn: 2,
progressPercent: 18,
quickFillRequested: false,
currentAnchorContent: {
worldPromise: {
hook: '一个被潮雾改写航线秩序的群岛世界。',
differentiator: '',
desiredExperience: '',
},
playerFantasy: null,
themeBoundary: null,
playerEntryPoint: null,
coreConflict: null,
keyRelationships: [],
hiddenLines: null,
iconicElements: null,
},
chatHistory: [
{
role: 'assistant',
content: '现在世界底色有了,你最想让玩家以什么身份卷进来?',
},
{
role: 'user',
content:
'玩家是被迫返乡的守灯人继承人,开场时刚回到港口就发现禁航区亮起了假航灯。',
},
],
});
assert.ok(result.nextAnchorContent.worldPromise?.hook);
assert.match(
result.nextAnchorContent.playerFantasy?.playerRole ?? '',
//u,
);
assert.match(
result.nextAnchorContent.playerEntryPoint?.openingProblem ?? '',
//u,
);
assert.ok(result.progressPercent >= 20);
assert.ok(result.replyText.length > 0);
});
test('eight anchor single turn service forces completion from model output when quick fill is requested', async () => {
const service = new EightAnchorSingleTurnService(
createTestCustomWorldAgentSingleTurnLlmClient(),
);
const result = await service.runTurn({
currentTurn: 6,
progressPercent: 62,
quickFillRequested: true,
currentAnchorContent: {
worldPromise: {
hook: '一个被潮雾改写航线秩序的群岛世界。',
differentiator: '所有人都要向旧灯塔借路。',
desiredExperience: '压抑、悬疑',
},
playerFantasy: {
playerRole: '玩家是被迫返乡的守灯人继承人。',
corePursuit: '查清沉船夜背后的真相。',
fearOfLoss: '',
},
themeBoundary: null,
playerEntryPoint: null,
coreConflict: null,
keyRelationships: [],
hiddenLines: null,
iconicElements: null,
},
chatHistory: [
{
role: 'user',
content: '请直接一键补全剩余设定。',
},
],
});
assert.equal(result.progressPercent, 100);
assert.ok(result.nextAnchorContent.coreConflict);
assert.ok(result.nextAnchorContent.keyRelationships.length > 0);
assert.match(result.replyText, /稿/u);
});
test('eight anchor single turn service keeps the current anchors unchanged when llm is unavailable', async () => {
const service = new EightAnchorSingleTurnService();
const currentAnchorContent = {
worldPromise: {
hook: '一个被潮雾改写航线秩序的群岛世界。',
differentiator: '所有人都要向旧灯塔借路。',
desiredExperience: '压抑、悬疑',
},
playerFantasy: null,
themeBoundary: null,
playerEntryPoint: null,
coreConflict: null,
keyRelationships: [],
hiddenLines: null,
iconicElements: null,
};
const result = await service.runTurn({
currentTurn: 2,
progressPercent: 24,
quickFillRequested: false,
currentAnchorContent,
chatHistory: [
{
role: 'user',
content: '玩家是被迫返乡的守灯人继承人。',
},
],
});
assert.deepEqual(result.nextAnchorContent, currentAnchorContent);
assert.equal(result.progressPercent, 24);
assert.match(result.replyText, //u);
});
test('eight anchor single turn service runs state inference before formal generation and injects it into the next prompt', async () => {
const inferenceCalls: Array<{
debugLabel?: string;
systemPrompt: string;
userPrompt: string;
}> = [];
const streamCalls: Array<{
debugLabel?: string;
systemPrompt: string;
userPrompt: string;
}> = [];
const streamedReplyUpdates: string[] = [];
const llmClient = {
requestMessageContent: async (params) => {
inferenceCalls.push({
debugLabel: params.debugLabel,
systemPrompt: params.systemPrompt,
userPrompt: params.userPrompt,
});
if (params.debugLabel === 'custom-world-eight-anchor-state-inference') {
return JSON.stringify({
userInputSignal: 'correction',
driftRisk: 'high',
conversationMode: 'repair_direction',
judgementSummary:
'用户正在修正既有方向,正式生成时要优先吸收修正并避免沿用旧设定。',
});
}
throw new Error('formal generation should use streamMessageContent');
},
streamMessageContent: async (params) => {
streamCalls.push({
debugLabel: params.debugLabel,
systemPrompt: params.systemPrompt,
userPrompt: params.userPrompt,
});
params.onUpdate?.('{"replyText":"我先按你修正后的');
params.onUpdate?.(
'{"replyText":"我先按你修正后的方向收住了,现在这套悬念会更稳一些。',
);
return JSON.stringify({
nextAnchorContent: {
worldPromise: {
hook: '一个以旧航线骗局为核心悬念的群岛世界。',
differentiator: '假航灯会改写整片海域的生路判断。',
desiredExperience: '压迫、悬疑、潮湿',
},
playerFantasy: {
playerRole: '玩家是返乡的守灯人继承人。',
corePursuit: '查清旧航线骗局的源头。',
fearOfLoss: '失去家族仅剩的航线名誉。',
},
themeBoundary: {
toneKeywords: ['压迫'],
aestheticDirectives: ['潮湿群岛'],
forbiddenDirectives: [],
},
playerEntryPoint: {
openingIdentity: '返乡继承人',
openingProblem: '港口重新亮起假航灯',
entryMotivation: '阻止更多船只误入禁航区',
},
coreConflict: {
surfaceConflicts: ['假航灯骗局重新启动'],
hiddenCrisis: '有人借旧航线秩序收割整座群岛',
firstTouchedConflict: '玩家返乡当晚就撞上假航灯',
},
keyRelationships: [
{
pairs: '玩家 vs 旧港校灯人',
relationshipType: '旧识互疑',
secretOrCost: '对方知道家族旧案',
},
],
hiddenLines: {
hiddenTruths: ['假航灯背后藏着旧案延续'],
misdirectionHints: ['表面像海盗所为'],
revealPacing: '先见异常,再见旧案,再见操盘者',
},
iconicElements: {
iconicMotifs: ['假航灯', '潮雾'],
institutionsOrArtifacts: ['旧灯塔'],
hardRules: ['错误航灯会把船引向死路'],
},
},
progressPercent: 58,
replyText: '我先按你修正后的方向收住了,现在这套悬念会更稳一些。',
});
},
} as UpstreamLlmClient;
const service = new EightAnchorSingleTurnService(llmClient);
const result = await service.streamTurn(
{
currentTurn: 4,
progressPercent: 44,
quickFillRequested: false,
currentAnchorContent: {
worldPromise: {
hook: '一个被潮雾改写航线秩序的群岛世界。',
differentiator: '',
desiredExperience: '',
},
playerFantasy: null,
themeBoundary: null,
playerEntryPoint: null,
coreConflict: null,
keyRelationships: [],
hiddenLines: null,
iconicElements: null,
},
chatHistory: [
{
role: 'assistant',
content: '我们先把世界方向定住,你最想强调哪种悬念?',
},
{
role: 'user',
content: '不是海怪方向,改成旧航线骗局,假航灯才是这世界真正的危险。',
},
],
},
{
onReplyUpdate: (text) => {
streamedReplyUpdates.push(text);
},
},
);
assert.equal(inferenceCalls.length, 1);
assert.equal(streamCalls.length, 1);
assert.equal(
inferenceCalls[0]?.debugLabel,
'custom-world-eight-anchor-state-inference',
);
assert.equal(
streamCalls[0]?.debugLabel,
'custom-world-eight-anchor-single-turn',
);
assert.match(
streamCalls[0]?.systemPrompt ?? '',
/userInputSignal: correction/u,
);
assert.match(
streamCalls[0]?.systemPrompt ?? '',
/conversationMode: repair_direction/u,
);
assert.match(
streamCalls[0]?.systemPrompt ?? '',
//u,
);
assert.deepEqual(streamedReplyUpdates, [
'我先按你修正后的',
'我先按你修正后的方向收住了,现在这套悬念会更稳一些。',
]);
assert.equal(result.progressPercent, 58);
assert.match(result.replyText, //u);
});
test('eight anchor single turn service falls back to rule-based state when inference fails and still completes formal generation', async () => {
const inferenceCalls: Array<{
debugLabel?: string;
systemPrompt: string;
}> = [];
const streamCalls: Array<{
debugLabel?: string;
systemPrompt: string;
userPrompt: string;
}> = [];
const llmClient = {
requestMessageContent: async (params) => {
inferenceCalls.push({
debugLabel: params.debugLabel,
systemPrompt: params.systemPrompt,
});
throw new Error('state inference failed');
},
streamMessageContent: async (params) => {
streamCalls.push({
debugLabel: params.debugLabel,
systemPrompt: params.systemPrompt,
userPrompt: params.userPrompt,
});
return JSON.stringify({
nextAnchorContent: {
worldPromise: {
hook: '一个被潮雾改写航线秩序的群岛世界。',
differentiator: '所有人都要向旧灯塔借路。',
desiredExperience: '压抑、悬疑',
},
playerFantasy: {
playerRole: '玩家是被迫返乡的守灯人继承人。',
corePursuit: '查清沉船夜背后的真相。',
fearOfLoss: '',
},
themeBoundary: {
toneKeywords: ['压抑'],
aestheticDirectives: ['潮雾群岛'],
forbiddenDirectives: [],
},
playerEntryPoint: {
openingIdentity: '返乡继承人',
openingProblem: '港口重新亮起假航灯',
entryMotivation: '堵住灾难扩散',
},
coreConflict: {
surfaceConflicts: ['禁航区异动'],
hiddenCrisis: '旧航线秩序正在被人篡改',
firstTouchedConflict: '返乡第一晚就撞上假航灯',
},
keyRelationships: [
{
pairs: '玩家 vs 港区旧识',
relationshipType: '彼此试探',
secretOrCost: '对方知道旧沉船夜的真相碎片',
},
],
hiddenLines: {
hiddenTruths: ['旧沉船夜不是意外'],
misdirectionHints: ['所有线索都先指向海盗'],
revealPacing: '先异常,再旧案,再真凶',
},
iconicElements: {
iconicMotifs: ['假航灯'],
institutionsOrArtifacts: ['旧灯塔'],
hardRules: ['错误航灯会直接改写生路判断'],
},
},
progressPercent: 64,
replyText: '我先顺着你这轮修正把设定收住了,接下来可以继续往冲突和关系上补。',
});
},
} as UpstreamLlmClient;
const service = new EightAnchorSingleTurnService(llmClient);
const result = await service.runTurn({
currentTurn: 3,
progressPercent: 40,
quickFillRequested: false,
currentAnchorContent: {
worldPromise: {
hook: '一个被潮雾改写航线秩序的群岛世界。',
differentiator: '',
desiredExperience: '',
},
playerFantasy: null,
themeBoundary: null,
playerEntryPoint: null,
coreConflict: null,
keyRelationships: [],
hiddenLines: null,
iconicElements: null,
},
chatHistory: [
{
role: 'user',
content: '不是海怪,改成旧航线骗局。',
},
],
});
assert.equal(inferenceCalls.length, 1);
assert.equal(streamCalls.length, 1);
assert.equal(
inferenceCalls[0]?.debugLabel,
'custom-world-eight-anchor-state-inference',
);
assert.equal(
streamCalls[0]?.debugLabel,
'custom-world-eight-anchor-single-turn',
);
assert.match(
streamCalls[0]?.systemPrompt ?? '',
/userInputSignal: correction/u,
);
assert.match(
streamCalls[0]?.systemPrompt ?? '',
/conversationMode: repair_direction/u,
);
assert.equal(result.progressPercent, 64);
assert.match(result.replyText, //u);
});

View File

@@ -0,0 +1,322 @@
import type { EightAnchorContent } from '../../../packages/shared/src/contracts/customWorldAgent.js';
import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.js';
import {
createEmptyEightAnchorContent,
normalizeEightAnchorContent,
} from './eightAnchorCompatibilityService.js';
import {
buildEightAnchorSingleTurnPrompt,
buildPromptDynamicState,
buildPromptDynamicStateInferencePrompt,
} from './eightAnchorPromptBuilder.js';
import type { UpstreamLlmClient } from './llmClient.js';
type SingleTurnChatMessage = {
role: 'user' | 'assistant';
content: string;
};
export type SingleTurnModelOutput = {
nextAnchorContent: EightAnchorContent;
progressPercent: number;
replyText: string;
};
function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function normalizeOutputValue(value: unknown) {
return normalizeEightAnchorContent(value ?? createEmptyEightAnchorContent());
}
function clampProgressPercent(value: unknown) {
if (typeof value !== 'number' || Number.isNaN(value)) {
return 0;
}
return Math.max(0, Math.min(100, Math.round(value)));
}
function decodeEscapedCharacter(
value: string,
input: string,
index: number,
): { decoded: string; nextIndex: number } | null {
if (value === '"' || value === '\\' || value === '/') {
return {
decoded: value,
nextIndex: index + 1,
};
}
if (value === 'b') {
return {
decoded: '\b',
nextIndex: index + 1,
};
}
if (value === 'f') {
return {
decoded: '\f',
nextIndex: index + 1,
};
}
if (value === 'n') {
return {
decoded: '\n',
nextIndex: index + 1,
};
}
if (value === 'r') {
return {
decoded: '\r',
nextIndex: index + 1,
};
}
if (value === 't') {
return {
decoded: '\t',
nextIndex: index + 1,
};
}
if (value === 'u') {
const hex = input.slice(index + 1, index + 5);
if (!/^[\da-fA-F]{4}$/u.test(hex)) {
return null;
}
return {
decoded: String.fromCharCode(Number.parseInt(hex, 16)),
nextIndex: index + 5,
};
}
return {
decoded: value,
nextIndex: index + 1,
};
}
function extractReplyTextFromPartialJson(text: string) {
const keyIndex = text.indexOf('"replyText"');
if (keyIndex < 0) {
return {
text: '',
started: false,
completed: false,
};
}
const colonIndex = text.indexOf(':', keyIndex);
if (colonIndex < 0) {
return {
text: '',
started: false,
completed: false,
};
}
let stringStartIndex = colonIndex + 1;
while (
stringStartIndex < text.length &&
/\s/u.test(text[stringStartIndex] ?? '')
) {
stringStartIndex += 1;
}
if (text[stringStartIndex] !== '"') {
return {
text: '',
started: false,
completed: false,
};
}
let cursor = stringStartIndex + 1;
let decoded = '';
while (cursor < text.length) {
const character = text[cursor] ?? '';
if (character === '"') {
return {
text: decoded,
started: true,
completed: true,
};
}
if (character === '\\') {
const escaped = decodeEscapedCharacter(
text[cursor + 1] ?? '',
text,
cursor + 1,
);
if (!escaped) {
break;
}
decoded += escaped.decoded;
cursor = escaped.nextIndex;
continue;
}
decoded += character;
cursor += 1;
}
return {
text: decoded,
started: true,
completed: false,
};
}
function buildUnavailableOutput(
input: {
progressPercent: number;
currentAnchorContent: EightAnchorContent;
},
reason: 'unavailable' | 'failed',
) {
return {
nextAnchorContent: normalizeEightAnchorContent(input.currentAnchorContent),
progressPercent: Math.max(0, Math.min(100, Math.round(input.progressPercent))),
replyText:
reason === 'unavailable'
? '当前模型不可用,这一轮设定先保留上一版。你可以稍后重试。'
: '这一轮设定还没成功更新,我先保留上一版。你可以再发一次,我继续接着收。',
} satisfies SingleTurnModelOutput;
}
export class EightAnchorSingleTurnService {
constructor(private readonly llmClient?: UpstreamLlmClient) {}
private async resolveDynamicState(input: {
currentTurn: number;
progressPercent: number;
quickFillRequested: boolean;
currentAnchorContent: EightAnchorContent;
chatHistory: SingleTurnChatMessage[];
}) {
const fallbackState = buildPromptDynamicState(input);
if (!this.llmClient) {
return fallbackState;
}
const { systemPrompt, userPrompt } =
buildPromptDynamicStateInferencePrompt(input);
try {
const content = await this.llmClient.requestMessageContent({
systemPrompt,
userPrompt,
timeoutMs: 45000,
debugLabel: 'custom-world-eight-anchor-state-inference',
});
const parsed = parseJsonResponseText(content) as {
userInputSignal?: unknown;
driftRisk?: unknown;
conversationMode?: unknown;
judgementSummary?: unknown;
};
return buildPromptDynamicState(input, parsed);
} catch {
return fallbackState;
}
}
async runTurn(input: {
currentTurn: number;
progressPercent: number;
quickFillRequested: boolean;
currentAnchorContent: EightAnchorContent;
chatHistory: SingleTurnChatMessage[];
}) {
return this.streamTurn(input);
}
async streamTurn(
input: {
currentTurn: number;
progressPercent: number;
quickFillRequested: boolean;
currentAnchorContent: EightAnchorContent;
chatHistory: SingleTurnChatMessage[];
},
options: {
onReplyUpdate?: (text: string) => void;
} = {},
) {
const normalizedInput = {
...input,
currentAnchorContent: normalizeEightAnchorContent(input.currentAnchorContent),
chatHistory: input.chatHistory.slice(-16),
};
if (!this.llmClient) {
const unavailableOutput = buildUnavailableOutput(
normalizedInput,
'unavailable',
);
options.onReplyUpdate?.(unavailableOutput.replyText);
return unavailableOutput;
}
const dynamicState = await this.resolveDynamicState(normalizedInput);
const { prompt } = buildEightAnchorSingleTurnPrompt({
...normalizedInput,
dynamicState,
});
let latestReplyText = '';
try {
const content = await this.llmClient.streamMessageContent({
systemPrompt: prompt,
userPrompt: '请按约定输出这一轮的 JSON。',
timeoutMs: 60000,
debugLabel: 'custom-world-eight-anchor-single-turn',
onUpdate: (partialText) => {
const replyProgress = extractReplyTextFromPartialJson(partialText);
if (
replyProgress.started &&
replyProgress.text !== latestReplyText
) {
latestReplyText = replyProgress.text;
options.onReplyUpdate?.(latestReplyText);
}
},
});
const parsed = parseJsonResponseText(content) as {
nextAnchorContent?: unknown;
progressPercent?: unknown;
replyText?: unknown;
};
const nextAnchorContent = normalizeOutputValue(parsed.nextAnchorContent);
const progressPercent = normalizedInput.quickFillRequested
? 100
: clampProgressPercent(parsed.progressPercent);
const replyText =
toText(parsed.replyText) ||
buildUnavailableOutput(normalizedInput, 'failed').replyText;
if (replyText !== latestReplyText) {
options.onReplyUpdate?.(replyText);
}
return {
nextAnchorContent,
progressPercent,
replyText,
} satisfies SingleTurnModelOutput;
} catch {
const unavailableOutput = buildUnavailableOutput(
normalizedInput,
'failed',
);
if (unavailableOutput.replyText !== latestReplyText) {
options.onReplyUpdate?.(unavailableOutput.replyText);
}
return unavailableOutput;
}
}
}

View File

@@ -309,6 +309,92 @@ export class UpstreamLlmClient {
return content; return content;
} }
async streamMessageContent(params: {
systemPrompt: string;
userPrompt: string;
model?: string;
signal?: AbortSignal;
timeoutMs?: number;
debugLabel?: string;
onUpdate?: (text: string) => void;
}) {
const response = await this.requestCompletion(
{
model: params.model,
stream: true,
messages: [
{ role: 'system', content: params.systemPrompt },
{ role: 'user', content: params.userPrompt },
],
},
{
signal: params.signal,
timeoutMs: params.timeoutMs,
debugLabel: params.debugLabel,
},
);
if (!response.body) {
throw upstreamError('LLM 流式响应体不可用');
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
let accumulatedText = '';
for (;;) {
const { done, value } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
while (buffer.includes('\n\n')) {
const boundary = buffer.indexOf('\n\n');
const eventBlock = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
for (const rawLine of eventBlock.split(/\r?\n/u)) {
const line = rawLine.trim();
if (!line.startsWith('data:')) {
continue;
}
const data = line.slice(5).trim();
if (!data || data === '[DONE]') {
continue;
}
try {
const parsed = JSON.parse(data) as {
choices?: Array<{
delta?: {
content?: string;
};
}>;
};
const delta = parsed.choices?.[0]?.delta?.content;
if (typeof delta === 'string' && delta.length > 0) {
accumulatedText += delta;
params.onUpdate?.(accumulatedText);
}
} catch {
// Ignore malformed SSE frames from the upstream model.
}
}
}
}
const content = accumulatedText.trim();
if (!content) {
throw upstreamError('LLM 返回内容为空');
}
return content;
}
async forwardCompletion( async forwardCompletion(
request: ExpressRequest, request: ExpressRequest,
body: Record<string, unknown>, body: Record<string, unknown>,

View File

@@ -8,6 +8,7 @@ interface CharacterAnimatorProps {
className?: string; className?: string;
style?: React.CSSProperties; style?: React.CSSProperties;
imageClassName?: string; imageClassName?: string;
playbackRate?: number;
} }
const DEFAULT_ANIMATIONS: Record<AnimationState, CharacterAnimationConfig> = { const DEFAULT_ANIMATIONS: Record<AnimationState, CharacterAnimationConfig> = {
@@ -42,6 +43,7 @@ export const CharacterAnimator: React.FC<CharacterAnimatorProps> = ({
className, className,
style, style,
imageClassName, imageClassName,
playbackRate = 1,
}) => { }) => {
const [frameIndex, setFrameIndex] = useState(1); const [frameIndex, setFrameIndex] = useState(1);
const config = const config =
@@ -51,6 +53,13 @@ export const CharacterAnimator: React.FC<CharacterAnimatorProps> = ({
DEFAULT_ANIMATIONS[AnimationState.IDLE]; DEFAULT_ANIMATIONS[AnimationState.IDLE];
const startFrame = config.startFrame ?? 1; const startFrame = config.startFrame ?? 1;
const frameCount = config.frames; const frameCount = config.frames;
const fps =
typeof config.fps === 'number' && Number.isFinite(config.fps)
? Math.max(1, config.fps)
: 10;
const effectivePlaybackRate = Number.isFinite(playbackRate)
? Math.max(0.1, playbackRate)
: 1;
const animationSignature = [ const animationSignature = [
state, state,
config.basePath ?? '', config.basePath ?? '',
@@ -60,6 +69,8 @@ export const CharacterAnimator: React.FC<CharacterAnimatorProps> = ({
config.extension ?? 'png', config.extension ?? 'png',
startFrame, startFrame,
frameCount, frameCount,
fps,
effectivePlaybackRate,
].join('::'); ].join('::');
useEffect(() => { useEffect(() => {
@@ -73,10 +84,16 @@ export const CharacterAnimator: React.FC<CharacterAnimatorProps> = ({
setFrameIndex(prev => { setFrameIndex(prev => {
return prev >= endFrame ? startFrame : prev + 1; return prev >= endFrame ? startFrame : prev + 1;
}); });
}, 100); }, Math.max(40, Math.round(1000 / (fps * effectivePlaybackRate))));
return () => window.clearInterval(interval); return () => window.clearInterval(interval);
}, [animationSignature, frameCount, startFrame]); }, [
animationSignature,
effectivePlaybackRate,
fps,
frameCount,
startFrame,
]);
const frameNumber = frameIndex.toString().padStart(2, '0'); const frameNumber = frameIndex.toString().padStart(2, '0');
const normalizedBasePath = config.basePath?.replace(/\/+$/u, ''); const normalizedBasePath = config.basePath?.replace(/\/+$/u, '');

View File

@@ -1,3 +1,7 @@
import type {
EightAnchorContent,
KeyRelationshipValue,
} from '../../packages/shared/src/contracts/customWorldAgent';
import { import {
type ReactNode, type ReactNode,
useDeferredValue, useDeferredValue,
@@ -13,10 +17,7 @@ import {
resolveCustomWorldLandmarkImageMap, resolveCustomWorldLandmarkImageMap,
} from '../data/customWorldVisuals'; } from '../data/customWorldVisuals';
import { resolveCustomWorldCampScene } from '../services/customWorldCamp'; import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
import { import { normalizeCustomWorldCreatorIntent } from '../services/customWorldCreatorIntent';
buildCustomWorldCreatorIntentFoundationText,
normalizeCustomWorldCreatorIntent,
} from '../services/customWorldCreatorIntent';
import { AnimationState, Character, CustomWorldProfile } from '../types'; import { AnimationState, Character, CustomWorldProfile } from '../types';
import { getNineSliceStyle, UI_CHROME } from '../uiAssets'; import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { CharacterAnimator } from './CharacterAnimator'; import { CharacterAnimator } from './CharacterAnimator';
@@ -348,6 +349,226 @@ function compactTextList(values: Array<string | null | undefined>) {
return values.map((value) => value?.trim() ?? '').filter(Boolean); return values.map((value) => value?.trim() ?? '').filter(Boolean);
} }
function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function toTextArray(value: unknown) {
return Array.isArray(value)
? value.map((item) => toText(item)).filter(Boolean)
: [];
}
function toRecord(value: unknown) {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function buildRelationshipSeedText(value: unknown) {
const record = toRecord(value);
if (!record) {
return '';
}
return compactTextList([
toText(record.name),
toText(record.role),
toText(record.relationToPlayer)
? `与玩家:${toText(record.relationToPlayer)}`
: '',
toText(record.hiddenHook) ? `代价/暗线:${toText(record.hiddenHook)}` : '',
]).join('');
}
function buildKeyRelationshipText(value: KeyRelationshipValue) {
return compactTextList([
value.pairs,
value.relationshipType,
value.secretOrCost ? `代价/秘密:${value.secretOrCost}` : '',
]).join('');
}
function buildAnchorContentFromProfileFallback(
profile: CustomWorldProfile,
): EightAnchorContent {
const creatorIntent = normalizeCustomWorldCreatorIntent(profile.creatorIntent);
const relationshipSeed = creatorIntent?.keyCharacters[0] ?? null;
return {
worldPromise: {
hook:
creatorIntent?.worldHook ||
profile.anchorPack?.worldSummary ||
profile.summary,
differentiator: profile.subtitle || profile.settingText,
desiredExperience:
compactTextList([
creatorIntent?.toneDirectives.join('、') || '',
profile.tone,
]).join('') || profile.tone,
},
playerFantasy: {
playerRole: creatorIntent?.playerPremise || profile.playerGoal,
corePursuit: profile.playerGoal,
fearOfLoss:
relationshipSeed?.hiddenHook ||
creatorIntent?.coreConflicts[0] ||
profile.coreConflicts[0] ||
'',
},
themeBoundary: {
toneKeywords: compactTextList([
creatorIntent?.themeKeywords.join('、') || '',
creatorIntent?.toneDirectives.join('、') || '',
]),
aestheticDirectives: compactTextList([profile.tone, profile.subtitle]),
forbiddenDirectives: creatorIntent?.forbiddenDirectives ?? [],
},
playerEntryPoint: {
openingIdentity: creatorIntent?.playerPremise || '',
openingProblem:
creatorIntent?.openingSituation || profile.coreConflicts[0] || '',
entryMotivation: profile.playerGoal,
},
coreConflict: {
surfaceConflicts:
creatorIntent?.coreConflicts.length
? creatorIntent.coreConflicts
: profile.coreConflicts,
hiddenCrisis:
relationshipSeed?.hiddenHook ||
profile.summary ||
profile.settingText,
firstTouchedConflict:
creatorIntent?.openingSituation ||
profile.coreConflicts[0] ||
profile.playerGoal,
},
keyRelationships: relationshipSeed
? [
{
pairs: compactTextList([
relationshipSeed.name,
relationshipSeed.role,
]).join(' · '),
relationshipType: relationshipSeed.relationToPlayer || '',
secretOrCost: relationshipSeed.hiddenHook || '',
},
]
: [],
hiddenLines: {
hiddenTruths: compactTextList([
relationshipSeed?.hiddenHook || '',
profile.summary,
]),
misdirectionHints: compactTextList([
profile.subtitle,
profile.majorFactions[0] || '',
]),
revealPacing:
creatorIntent?.openingSituation ||
profile.coreConflicts[0] ||
profile.playerGoal,
},
iconicElements: {
iconicMotifs:
creatorIntent?.iconicElements.length
? creatorIntent.iconicElements
: compactTextList([
profile.anchorPack?.motifDirectives.join('、') || '',
profile.landmarks[0]?.name || '',
]),
institutionsOrArtifacts: compactTextList([
profile.camp?.name || '',
profile.majorFactions[0] || '',
]),
hardRules: compactTextList([profile.playerGoal, profile.coreConflicts[0] || '']),
},
};
}
function getProfileAnchorContent(profile: CustomWorldProfile) {
const anchorContentRecord = profile.anchorContent;
if (!anchorContentRecord) {
return buildAnchorContentFromProfileFallback(profile);
}
const worldPromiseRecord = toRecord(anchorContentRecord.worldPromise);
const playerFantasyRecord = toRecord(anchorContentRecord.playerFantasy);
const themeBoundaryRecord = toRecord(anchorContentRecord.themeBoundary);
const playerEntryPointRecord = toRecord(anchorContentRecord.playerEntryPoint);
const coreConflictRecord = toRecord(anchorContentRecord.coreConflict);
const hiddenLinesRecord = toRecord(anchorContentRecord.hiddenLines);
const iconicElementsRecord = toRecord(anchorContentRecord.iconicElements);
return {
worldPromise: worldPromiseRecord
? {
hook: toText(worldPromiseRecord.hook),
differentiator: toText(worldPromiseRecord.differentiator),
desiredExperience: toText(worldPromiseRecord.desiredExperience),
}
: null,
playerFantasy: playerFantasyRecord
? {
playerRole: toText(playerFantasyRecord.playerRole),
corePursuit: toText(playerFantasyRecord.corePursuit),
fearOfLoss: toText(playerFantasyRecord.fearOfLoss),
}
: null,
themeBoundary: themeBoundaryRecord
? {
toneKeywords: toTextArray(themeBoundaryRecord.toneKeywords),
aestheticDirectives: toTextArray(
themeBoundaryRecord.aestheticDirectives,
),
forbiddenDirectives: toTextArray(themeBoundaryRecord.forbiddenDirectives),
}
: null,
playerEntryPoint: playerEntryPointRecord
? {
openingIdentity: toText(playerEntryPointRecord.openingIdentity),
openingProblem: toText(playerEntryPointRecord.openingProblem),
entryMotivation: toText(playerEntryPointRecord.entryMotivation),
}
: null,
coreConflict: coreConflictRecord
? {
surfaceConflicts: toTextArray(coreConflictRecord.surfaceConflicts),
hiddenCrisis: toText(coreConflictRecord.hiddenCrisis),
firstTouchedConflict: toText(coreConflictRecord.firstTouchedConflict),
}
: null,
keyRelationships: Array.isArray(anchorContentRecord.keyRelationships)
? anchorContentRecord.keyRelationships
.map((entry) => toRecord(entry))
.filter(Boolean)
.map((entry) => ({
pairs: toText(entry?.pairs),
relationshipType: toText(entry?.relationshipType),
secretOrCost: toText(entry?.secretOrCost),
}))
: [],
hiddenLines: hiddenLinesRecord
? {
hiddenTruths: toTextArray(hiddenLinesRecord.hiddenTruths),
misdirectionHints: toTextArray(hiddenLinesRecord.misdirectionHints),
revealPacing: toText(hiddenLinesRecord.revealPacing),
}
: null,
iconicElements: iconicElementsRecord
? {
iconicMotifs: toTextArray(iconicElementsRecord.iconicMotifs),
institutionsOrArtifacts: toTextArray(
iconicElementsRecord.institutionsOrArtifacts,
),
hardRules: toTextArray(iconicElementsRecord.hardRules),
}
: null,
} satisfies EightAnchorContent;
}
function buildOpeningSceneSearchText( function buildOpeningSceneSearchText(
profile: CustomWorldProfile, profile: CustomWorldProfile,
campScene: ReturnType<typeof resolveCustomWorldCampScene>, campScene: ReturnType<typeof resolveCustomWorldCampScene>,
@@ -365,71 +586,85 @@ function buildOpeningSceneSearchText(
function buildStructuredFoundationEntries(profile: CustomWorldProfile) { function buildStructuredFoundationEntries(profile: CustomWorldProfile) {
const creatorIntent = normalizeCustomWorldCreatorIntent(profile.creatorIntent); const creatorIntent = normalizeCustomWorldCreatorIntent(profile.creatorIntent);
const relationshipSeed = creatorIntent?.keyCharacters[0]; const anchorContent = getProfileAnchorContent(profile);
const relationshipText = relationshipSeed const fallbackRelationshipText =
? compactTextList([ buildRelationshipSeedText(creatorIntent?.keyCharacters[0]) ||
relationshipSeed.name, profile.playableNpcs[0]?.relationshipHooks.join('') ||
relationshipSeed.role, profile.storyNpcs[0]?.relationshipHooks.join('') ||
relationshipSeed.relationToPlayer '';
? `与玩家:${relationshipSeed.relationToPlayer}`
: '',
relationshipSeed.hiddenHook
? `暗线:${relationshipSeed.hiddenHook}`
: '',
]).join(' · ')
: '';
const themeToneText = compactTextList([
creatorIntent?.themeKeywords.join('、') || '',
creatorIntent?.toneDirectives.join('、') || '',
]).join(' / ');
const playerOpeningText = compactTextList([
creatorIntent?.playerPremise || '',
creatorIntent?.openingSituation || '',
]).join('');
return [ return [
{ {
id: 'world-hook', id: 'world-promise',
label: '世界一句话', label: '世界承诺',
value: value: compactTextList([
creatorIntent?.worldHook || anchorContent.worldPromise?.hook || '',
profile.anchorPack?.worldSummary || anchorContent.worldPromise?.differentiator || '',
profile.summary, anchorContent.worldPromise?.desiredExperience || '',
]).join(''),
}, },
{ {
id: 'player-opening', id: 'player-fantasy',
label: '玩家开局', label: '玩家幻想',
value: playerOpeningText || profile.playerGoal, value: compactTextList([
anchorContent.playerFantasy?.playerRole || '',
anchorContent.playerFantasy?.corePursuit || '',
anchorContent.playerFantasy?.fearOfLoss || '',
]).join(''),
}, },
{ {
id: 'theme-tone', id: 'theme-boundary',
label: '主题气质', label: '主题边界',
value: themeToneText || profile.tone, value: compactTextList([
anchorContent.themeBoundary?.toneKeywords.join('、') || '',
anchorContent.themeBoundary?.aestheticDirectives.join('、') || '',
anchorContent.themeBoundary?.forbiddenDirectives.length
? `避免:${anchorContent.themeBoundary.forbiddenDirectives.join('、')}`
: '',
]).join(''),
},
{
id: 'player-entry-point',
label: '玩家切入口',
value: compactTextList([
anchorContent.playerEntryPoint?.openingIdentity || '',
anchorContent.playerEntryPoint?.openingProblem || '',
anchorContent.playerEntryPoint?.entryMotivation || '',
]).join(''),
}, },
{ {
id: 'core-conflict', id: 'core-conflict',
label: '核心冲突', label: '核心冲突',
value: value: compactTextList([
creatorIntent?.coreConflicts.join('') || anchorContent.coreConflict?.surfaceConflicts.join('') || '',
profile.coreConflicts.join('') || anchorContent.coreConflict?.hiddenCrisis || '',
profile.summary, anchorContent.coreConflict?.firstTouchedConflict || '',
]).join(''),
}, },
{ {
id: 'relationship-seed', id: 'key-relationships',
label: '关键关系', label: '关键关系',
value: value:
relationshipText || anchorContent.keyRelationships.map(buildKeyRelationshipText).join('\n') ||
profile.playableNpcs[0]?.relationshipHooks.join('') || fallbackRelationshipText,
profile.storyNpcs[0]?.relationshipHooks.join('') || },
'待补充', {
id: 'hidden-lines',
label: '暗线与揭示',
value: compactTextList([
anchorContent.hiddenLines?.hiddenTruths.join('、') || '',
anchorContent.hiddenLines?.misdirectionHints.join('、') || '',
anchorContent.hiddenLines?.revealPacing || '',
]).join(''),
}, },
{ {
id: 'iconic-elements', id: 'iconic-elements',
label: '标志元素', label: '标志元素',
value: value: compactTextList([
creatorIntent?.iconicElements.join('、') || anchorContent.iconicElements?.iconicMotifs.join('、') || '',
profile.anchorPack?.motifDirectives.join('、') || anchorContent.iconicElements?.institutionsOrArtifacts.join('、') || '',
'待补充', anchorContent.iconicElements?.hardRules.join('、') || '',
]).join(''),
}, },
]; ];
} }
@@ -594,12 +829,6 @@ export function CustomWorldEntityCatalog({
() => buildStructuredFoundationEntries(profile), () => buildStructuredFoundationEntries(profile),
[profile], [profile],
); );
const structuredFoundationSourceText = useMemo(
() =>
buildCustomWorldCreatorIntentFoundationText(profile.creatorIntent).trim() ||
profile.settingText.trim(),
[profile.creatorIntent, profile.settingText],
);
const normalizedCreatorIntent = useMemo( const normalizedCreatorIntent = useMemo(
() => normalizeCustomWorldCreatorIntent(profile.creatorIntent), () => normalizeCustomWorldCreatorIntent(profile.creatorIntent),
[profile.creatorIntent], [profile.creatorIntent],
@@ -907,9 +1136,6 @@ export function CustomWorldEntityCatalog({
} }
> >
<div className="space-y-3"> <div className="space-y-3">
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-500">
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{structuredFoundationEntries.map((entry) => ( {structuredFoundationEntries.map((entry) => (
<div <div
@@ -919,22 +1145,12 @@ export function CustomWorldEntityCatalog({
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-500"> <div className="text-[11px] font-bold tracking-[0.18em] text-zinc-500">
{entry.label} {entry.label}
</div> </div>
<div className="mt-2 text-sm leading-7 text-zinc-100"> <div className="mt-2 whitespace-pre-line text-sm leading-7 text-zinc-100">
{entry.value || '待补充'} {entry.value || '待补充'}
</div> </div>
</div> </div>
))} ))}
</div> </div>
{structuredFoundationSourceText ? (
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-4">
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-500">
</div>
<div className="mt-2 whitespace-pre-line text-sm leading-7 text-zinc-200">
{structuredFoundationSourceText}
</div>
</div>
) : null}
</div> </div>
</Section> </Section>
</> </>

View File

@@ -1,4 +1,5 @@
import type { ChangeEvent } from 'react'; import type { ChangeEvent } from 'react';
import type { CSSProperties } from 'react';
import { Children, type ReactNode, useEffect, useMemo, useState } from 'react'; import { Children, type ReactNode, useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
@@ -70,6 +71,18 @@ interface CustomWorldEntityEditorModalProps {
onProfileChange: (profile: CustomWorldProfile) => void; onProfileChange: (profile: CustomWorldProfile) => void;
} }
function getAnimationPreviewFrameStyle(
_config: CharacterAnimationConfig | null | undefined,
targetSize: number,
) {
return {
width: `${targetSize}px`,
height: `${targetSize}px`,
maxWidth: '100%',
maxHeight: '100%',
} satisfies CSSProperties;
}
const [ const [
BACKSTORY_UNLOCK_AFFINITY_EASED, BACKSTORY_UNLOCK_AFFINITY_EASED,
BACKSTORY_UNLOCK_AFFINITY_FRIENDLY, BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
@@ -2051,6 +2064,10 @@ function RoleSkillEditorModal({
}, },
} satisfies Character; } satisfies Character;
}, [draft.actionPreviewConfig, role]); }, [draft.actionPreviewConfig, role]);
const actionPreviewFrameStyle = useMemo(
() => getAnimationPreviewFrameStyle(draft.actionPreviewConfig, 320),
[draft.actionPreviewConfig],
);
const handleGenerateAction = async () => { const handleGenerateAction = async () => {
if (!role.imageSrc || !role.generatedVisualAssetId) { if (!role.imageSrc || !role.generatedVisualAssetId) {
@@ -2090,14 +2107,15 @@ function RoleSkillEditorModal({
visualSource: role.imageSrc, visualSource: role.imageSrc,
referenceImageDataUrls: [], referenceImageDataUrls: [],
referenceVideoDataUrls: [], referenceVideoDataUrls: [],
lastFrameImageDataUrl: role.imageSrc,
frameCount: 8, frameCount: 8,
fps: 10, fps: 10,
durationSeconds: 3, durationSeconds: 3,
loop: false, loop: false,
useChromaKey: true, useChromaKey: true,
resolution: '720P', resolution: '480P',
imageSequenceModel: 'wan2.7-image-pro', imageSequenceModel: 'wan2.7-image-pro',
videoModel: 'wan2.7-i2v', videoModel: 'wan2.2-kf2v-flash',
referenceVideoModel: 'wan2.7-r2v', referenceVideoModel: 'wan2.7-r2v',
motionTransferModel: 'wan2.2-animate-move', motionTransferModel: 'wan2.2-animate-move',
} satisfies CharacterAnimationGenerationPayload); } satisfies CharacterAnimationGenerationPayload);
@@ -2155,9 +2173,9 @@ function RoleSkillEditorModal({
> >
<div className="space-y-4"> <div className="space-y-4">
<div className="rounded-2xl border border-white/8 bg-black/20 p-4"> <div className="rounded-2xl border border-white/8 bg-black/20 p-4">
<div className="flex min-h-[10rem] items-center justify-center rounded-2xl border border-white/10 bg-black/25 p-3"> <div className="flex min-h-[20rem] items-center justify-center rounded-2xl border border-white/10 bg-black/25 p-3">
{previewCharacter && draft.actionPreviewConfig ? ( {previewCharacter && draft.actionPreviewConfig ? (
<div className="h-40 w-40"> <div style={actionPreviewFrameStyle}>
<CharacterAnimator <CharacterAnimator
state={AnimationState.ATTACK} state={AnimationState.ATTACK}
character={previewCharacter} character={previewCharacter}
@@ -4297,7 +4315,7 @@ function createLandmark(profile: CustomWorldProfile): CustomWorldLandmark {
}; };
} }
export function CustomWorldEntityEditorModal({ function CustomWorldEntityEditorModal({
profile, profile,
target, target,
onClose, onClose,
@@ -4422,3 +4440,6 @@ export function CustomWorldEntityEditorModal({
/> />
) : null; ) : null;
} }
export { CustomWorldEntityEditorModal };
export default CustomWorldEntityEditorModal;

View File

@@ -1,10 +1,12 @@
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import type { CustomWorldGenerationProgress } from '../../packages/shared/src/contracts/runtime'; import type { CustomWorldGenerationProgress } from '../../packages/shared/src/contracts/runtime';
import type { CustomWorldStructuredAnchorEntry } from '../services/customWorldAgentGenerationProgress';
import { getNineSliceStyle, UI_CHROME } from '../uiAssets'; import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
interface CustomWorldGenerationViewProps { interface CustomWorldGenerationViewProps {
settingText: string; settingText: string;
anchorEntries?: CustomWorldStructuredAnchorEntry[];
progress: CustomWorldGenerationProgress | null; progress: CustomWorldGenerationProgress | null;
isGenerating: boolean; isGenerating: boolean;
error: string | null; error: string | null;
@@ -22,6 +24,7 @@ interface CustomWorldGenerationViewProps {
activeBadgeLabel?: string; activeBadgeLabel?: string;
pausedBadgeLabel?: string; pausedBadgeLabel?: string;
idleBadgeLabel?: string; idleBadgeLabel?: string;
structuredEmptyText?: string;
} }
function formatDuration(ms: number) { function formatDuration(ms: number) {
@@ -47,6 +50,7 @@ function getProgressPercentage(progress: CustomWorldGenerationProgress | null) {
export function CustomWorldGenerationView({ export function CustomWorldGenerationView({
settingText, settingText,
anchorEntries = [],
progress, progress,
isGenerating, isGenerating,
error, error,
@@ -64,9 +68,11 @@ export function CustomWorldGenerationView({
activeBadgeLabel = '世界建设中', activeBadgeLabel = '世界建设中',
pausedBadgeLabel = '生成已暂停', pausedBadgeLabel = '生成已暂停',
idleBadgeLabel = '等待操作', idleBadgeLabel = '等待操作',
structuredEmptyText = '正在整理当前设定结构,请稍后。',
}: CustomWorldGenerationViewProps) { }: CustomWorldGenerationViewProps) {
const progressValue = getProgressPercentage(progress); const progressValue = getProgressPercentage(progress);
const steps = progress?.steps ?? []; const steps = progress?.steps ?? [];
const hasStructuredAnchors = anchorEntries.length > 0;
const estimatedWaitText = const estimatedWaitText =
progress?.estimatedRemainingMs != null progress?.estimatedRemainingMs != null
? `预计还需 ${formatDuration(progress.estimatedRemainingMs)}` ? `预计还需 ${formatDuration(progress.estimatedRemainingMs)}`
@@ -100,36 +106,6 @@ export function CustomWorldGenerationView({
</div> </div>
<div className="flex flex-none flex-col gap-4 xl:min-h-0 xl:flex-1"> <div className="flex flex-none flex-col gap-4 xl:min-h-0 xl:flex-1">
<section
className="pixel-nine-slice pixel-panel overflow-hidden"
style={getNineSliceStyle(UI_CHROME.storyPanel, {
paddingX: 16,
paddingY: 14,
})}
>
<div className="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<div className="text-[11px] font-bold tracking-[0.2em] text-sky-100/85">
{settingTitle}
</div>
<div className="mt-1 text-sm text-zinc-400">
{settingDescription}
</div>
</div>
<button
type="button"
onClick={onEditSetting}
disabled={isGenerating}
className={`rounded-full border border-white/10 bg-black/20 px-3 py-1.5 text-[11px] text-zinc-300 transition-colors hover:text-white ${isGenerating ? 'cursor-not-allowed opacity-40' : ''}`}
>
{settingActionLabel}
</button>
</div>
<div className="whitespace-pre-line rounded-2xl border border-white/8 bg-black/22 px-4 py-4 text-sm leading-7 text-zinc-200 md:max-h-[16rem] md:overflow-y-auto">
{settingText}
</div>
</section>
<section <section
className="pixel-nine-slice pixel-panel flex flex-col xl:min-h-0 xl:flex-1" className="pixel-nine-slice pixel-panel flex flex-col xl:min-h-0 xl:flex-1"
style={getNineSliceStyle(UI_CHROME.panel, { style={getNineSliceStyle(UI_CHROME.panel, {
@@ -265,6 +241,54 @@ export function CustomWorldGenerationView({
) : null} ) : null}
</div> </div>
</section> </section>
<section
className="pixel-nine-slice pixel-panel overflow-hidden"
style={getNineSliceStyle(UI_CHROME.storyPanel, {
paddingX: 16,
paddingY: 14,
})}
>
<div className="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<div className="text-[11px] font-bold tracking-[0.2em] text-sky-100/85">
{settingTitle}
</div>
<div className="mt-1 text-sm text-zinc-400">
{settingDescription}
</div>
</div>
<button
type="button"
onClick={onEditSetting}
disabled={isGenerating}
className={`rounded-full border border-white/10 bg-black/20 px-3 py-1.5 text-[11px] text-zinc-300 transition-colors hover:text-white ${isGenerating ? 'cursor-not-allowed opacity-40' : ''}`}
>
{settingActionLabel}
</button>
</div>
{hasStructuredAnchors ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{anchorEntries.map((entry) => (
<div
key={entry.id}
className="rounded-2xl border border-white/8 bg-black/22 px-4 py-4"
>
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-500">
{entry.label}
</div>
<div className="mt-2 whitespace-pre-line text-sm leading-7 text-zinc-100">
{entry.value}
</div>
</div>
))}
</div>
) : (
<div className="whitespace-pre-line rounded-2xl border border-white/8 bg-black/22 px-4 py-4 text-sm leading-7 text-zinc-200 md:max-h-[16rem] md:overflow-y-auto">
{settingText || structuredEmptyText}
</div>
)}
</section>
</div> </div>
</div> </div>
); );

View File

@@ -26,6 +26,7 @@ vi.mock('./CustomWorldNpcVisualEditor', () => ({
vi.mock('./CustomWorldEntityEditorModal', () => ({ vi.mock('./CustomWorldEntityEditorModal', () => ({
CustomWorldEntityEditorModal: () => null, CustomWorldEntityEditorModal: () => null,
default: () => null,
})); }));
async function loadAiService() { async function loadAiService() {
@@ -165,6 +166,50 @@ const baseProfile = {
description: '玩家最初落脚的旧灯塔内院。', description: '玩家最初落脚的旧灯塔内院。',
dangerLevel: 'medium', dangerLevel: 'medium',
}, },
anchorContent: {
worldPromise: {
hook: '被海雾反复改写航路的群岛世界。',
differentiator: '旧灯塔与禁航令共同决定谁能活着穿过去。',
desiredExperience: '压抑、悬疑、潮湿',
},
playerFantasy: {
playerRole: '玩家是被迫返乡的守灯人继承者。',
corePursuit: '查清沉钟异动与失控航路的真相。',
fearOfLoss: '失去家族留下的最后航路坐标。',
},
themeBoundary: {
toneKeywords: ['压抑', '悬疑'],
aestheticDirectives: ['潮湿群岛', '冷雾港口'],
forbiddenDirectives: ['热血少年漫'],
},
playerEntryPoint: {
openingIdentity: '返乡守灯人继承者',
openingProblem: '首夜就撞见禁航区假航灯重亮',
entryMotivation: '阻止更多船只误入死潮',
},
coreConflict: {
surfaceConflicts: ['守潮盟与沉钟会争夺航路解释权'],
hiddenCrisis: '有人借假航灯持续清洗整片群岛的旧证据',
firstTouchedConflict: '玩家回港当夜就被卷进禁航区封锁',
},
keyRelationships: [
{
pairs: '玩家 vs 沈砺',
relationshipType: '旧友互疑',
secretOrCost: '他掌握沉船夜的关键视角',
},
],
hiddenLines: {
hiddenTruths: ['沉钟异动和旧案灭口是同一条线'],
misdirectionHints: ['表面看像海雾自然失控'],
revealPacing: '先见异常,再见旧案,再见操盘者',
},
iconicElements: {
iconicMotifs: ['假航灯', '沉钟回响'],
institutionsOrArtifacts: ['旧灯塔', '禁航碑'],
hardRules: ['错误航灯会把船引进必死水域'],
},
},
landmarks: [ landmarks: [
{ {
id: 'landmark-1', id: 'landmark-1',
@@ -242,3 +287,20 @@ test('clicking新增可扮演角色 shows pending item, disables button, and mar
expect(screen.getAllByText('新').length).toBeGreaterThan(0); expect(screen.getAllByText('新').length).toBeGreaterThan(0);
}); });
test('world basic setting renders eight anchor fields and hides legacy parsed/source copy', () => {
render(<ResultViewHarness />);
expect(screen.getByText('世界承诺')).toBeTruthy();
expect(screen.getByText('玩家幻想')).toBeTruthy();
expect(screen.getByText('主题边界')).toBeTruthy();
expect(screen.getByText('玩家切入口')).toBeTruthy();
expect(screen.getByText('核心冲突')).toBeTruthy();
expect(screen.getByText('关键关系')).toBeTruthy();
expect(screen.getByText('暗线与揭示')).toBeTruthy();
expect(screen.getByText('标志元素')).toBeTruthy();
expect(screen.queryByText('解析字段')).toBeNull();
expect(screen.queryByText('锚点原文')).toBeNull();
expect(screen.getByText(//u)).toBeTruthy();
expect(screen.getByText(/线/u)).toBeTruthy();
});

View File

@@ -18,9 +18,8 @@ import {
CustomWorldEntityCatalog, CustomWorldEntityCatalog,
type ResultTab, type ResultTab,
} from './CustomWorldEntityCatalog'; } from './CustomWorldEntityCatalog';
import { import CustomWorldEntityEditorModal, {
type CustomWorldEditorTarget, type CustomWorldEditorTarget,
CustomWorldEntityEditorModal,
} from './CustomWorldEntityEditorModal'; } from './CustomWorldEntityEditorModal';
interface CustomWorldResultViewProps { interface CustomWorldResultViewProps {

View File

@@ -3,6 +3,7 @@ import {
RefreshCcw, RefreshCcw,
} from 'lucide-react'; } from 'lucide-react';
import { import {
type CSSProperties,
type ChangeEvent, type ChangeEvent,
type ReactNode, type ReactNode,
useEffect, useEffect,
@@ -14,11 +15,11 @@ import { createPortal } from 'react-dom';
import { ROLE_TEMPLATE_CHARACTERS } from '../data/characterPresets'; import { ROLE_TEMPLATE_CHARACTERS } from '../data/characterPresets';
import { import {
AnimationState, AnimationState,
type CharacterAnimationConfig,
type Character, type Character,
} from '../types'; } from '../types';
import { import {
buildAnimationClipFromVideoSource, buildAnimationClipFromVideoSource,
normalizeMasterVisualSourceToDataUrl,
readFileAsDataUrl, readFileAsDataUrl,
} from './asset-studio/characterAssetWorkflowModel'; } from './asset-studio/characterAssetWorkflowModel';
import { import {
@@ -27,7 +28,6 @@ import {
type CharacterVisualDraft, type CharacterVisualDraft,
fetchCharacterWorkflowCache, fetchCharacterWorkflowCache,
generateCharacterAnimationDraft, generateCharacterAnimationDraft,
generateCharacterPromptBundle,
generateCharacterVisualCandidates, generateCharacterVisualCandidates,
publishCharacterAnimationAssets, publishCharacterAnimationAssets,
publishCharacterVisualAsset, publishCharacterVisualAsset,
@@ -41,6 +41,9 @@ type EditableCustomWorldRole = {
name: string; name: string;
title: string; title: string;
role: string; role: string;
visualDescription?: string;
actionDescription?: string;
sceneVisualDescription?: string;
description?: string; description?: string;
backstory?: string; backstory?: string;
personality?: string; personality?: string;
@@ -112,6 +115,25 @@ const CORE_ACTIONS: CustomWorldAiActionConfig[] = [
}, },
]; ];
const DEFAULT_ANIMATION_PLAYBACK_RATE = 0.75;
const MIN_ANIMATION_PLAYBACK_RATE = 0.25;
const MAX_ANIMATION_PLAYBACK_RATE = 1.5;
function clampAnimationPlaybackRate(value: number) {
if (!Number.isFinite(value)) {
return DEFAULT_ANIMATION_PLAYBACK_RATE;
}
return Math.min(
MAX_ANIMATION_PLAYBACK_RATE,
Math.max(MIN_ANIMATION_PLAYBACK_RATE, value),
);
}
function roundAnimationFps(value: number) {
return Math.round(value * 100) / 100;
}
function ModalShell({ function ModalShell({
title, title,
subtitle, subtitle,
@@ -357,6 +379,86 @@ function hasGeneratedAnimation(
return Boolean(entry?.basePath || entry?.spriteSheetPath); return Boolean(entry?.basePath || entry?.spriteSheetPath);
} }
function getAnimationPreviewFrameStyle(
_config: CharacterAnimationConfig | null | undefined,
targetSize: number,
) {
return {
width: `${targetSize}px`,
height: `${targetSize}px`,
maxWidth: '100%',
maxHeight: '100%',
} satisfies CSSProperties;
}
function getAnimationPreviewViewportStyle(size: number) {
return {
width: `${size}px`,
height: `${size}px`,
maxWidth: '100%',
} satisfies CSSProperties;
}
function resolveAnimationPlaybackRate(
actionConfig: CustomWorldAiActionConfig | undefined,
animationConfig: CharacterAnimationConfig | null | undefined,
) {
if (!actionConfig) {
return DEFAULT_ANIMATION_PLAYBACK_RATE;
}
const configuredFps =
typeof animationConfig?.fps === 'number' &&
Number.isFinite(animationConfig.fps)
? animationConfig.fps
: null;
if (!configuredFps) {
return DEFAULT_ANIMATION_PLAYBACK_RATE;
}
return clampAnimationPlaybackRate(configuredFps / actionConfig.fps);
}
function applyPlaybackRateToAnimationMap(params: {
animationMap: Record<string, unknown> | undefined;
animation: AnimationState;
actionConfig: CustomWorldAiActionConfig;
playbackRate: number;
}) {
const { animationMap, animation, actionConfig, playbackRate } = params;
if (!animationMap) {
return animationMap;
}
const currentConfig = animationMap[animation];
if (!currentConfig || typeof currentConfig !== 'object') {
return animationMap;
}
const currentAnimationConfig = currentConfig as CharacterAnimationConfig;
const nextFps = roundAnimationFps(
Math.max(1, actionConfig.fps * clampAnimationPlaybackRate(playbackRate)),
);
const currentFps =
typeof currentAnimationConfig.fps === 'number' &&
Number.isFinite(currentAnimationConfig.fps)
? roundAnimationFps(currentAnimationConfig.fps)
: null;
if (currentFps === nextFps) {
return animationMap;
}
return {
...animationMap,
[animation]: {
...currentAnimationConfig,
fps: nextFps,
},
};
}
function buildAnimationPreviewCharacter(params: { function buildAnimationPreviewCharacter(params: {
workingRole: EditableCustomWorldRole; workingRole: EditableCustomWorldRole;
selectedTemplate: (typeof ROLE_TEMPLATE_CHARACTERS)[number] | null; selectedTemplate: (typeof ROLE_TEMPLATE_CHARACTERS)[number] | null;
@@ -399,7 +501,6 @@ export function CustomWorldRoleAssetStudioModal({
syncBusy = false, syncBusy = false,
visualPointCost = 20, visualPointCost = 20,
animationPointCost = 60, animationPointCost = 60,
priorityTier = roleKind === 'playable' ? 'hero' : 'featured',
}: { }: {
role: EditableCustomWorldRole; role: EditableCustomWorldRole;
roleKind: 'playable' | 'story'; roleKind: 'playable' | 'story';
@@ -420,7 +521,6 @@ export function CustomWorldRoleAssetStudioModal({
syncBusy?: boolean; syncBusy?: boolean;
visualPointCost?: number; visualPointCost?: number;
animationPointCost?: number; animationPointCost?: number;
priorityTier?: 'hero' | 'featured' | 'supporting';
}) { }) {
const [workingRole, setWorkingRole] = useState<EditableCustomWorldRole>(role); const [workingRole, setWorkingRole] = useState<EditableCustomWorldRole>(role);
const baseRole = useMemo<EditableCustomWorldRole>( const baseRole = useMemo<EditableCustomWorldRole>(
@@ -429,6 +529,9 @@ export function CustomWorldRoleAssetStudioModal({
name: role.name, name: role.name,
title: role.title, title: role.title,
role: role.role, role: role.role,
visualDescription: role.visualDescription,
actionDescription: role.actionDescription,
sceneVisualDescription: role.sceneVisualDescription,
description: role.description, description: role.description,
backstory: role.backstory, backstory: role.backstory,
personality: role.personality, personality: role.personality,
@@ -450,13 +553,16 @@ export function CustomWorldRoleAssetStudioModal({
role.generatedVisualAssetId, role.generatedVisualAssetId,
role.id, role.id,
role.imageSrc, role.imageSrc,
role.actionDescription,
role.motivation, role.motivation,
role.name, role.name,
role.personality, role.personality,
role.role, role.role,
role.sceneVisualDescription,
role.tags, role.tags,
role.templateCharacterId, role.templateCharacterId,
role.title, role.title,
role.visualDescription,
], ],
); );
const initialPromptBundle = useMemo( const initialPromptBundle = useMemo(
@@ -481,8 +587,14 @@ export function CustomWorldRoleAssetStudioModal({
const [animationPromptText, setAnimationPromptText] = useState( const [animationPromptText, setAnimationPromptText] = useState(
initialPromptBundle.animationPromptText, initialPromptBundle.animationPromptText,
); );
const [animationStatus, setAnimationStatus] = useState<string | null>(null); const [animationStatusByKey, setAnimationStatusByKey] = useState<
const [isGeneratingAnimations, setIsGeneratingAnimations] = useState(false); Partial<Record<AnimationState, string | null>>
>({});
const [generatingAnimationMap, setGeneratingAnimationMap] = useState<
Partial<Record<AnimationState, boolean>>
>({});
const [animationPreviewPlaybackRate, setAnimationPreviewPlaybackRate] =
useState(DEFAULT_ANIMATION_PLAYBACK_RATE);
const [saveStatus, setSaveStatus] = useState<string | null>(null); const [saveStatus, setSaveStatus] = useState<string | null>(null);
const [isSavingToRole, setIsSavingToRole] = useState(false); const [isSavingToRole, setIsSavingToRole] = useState(false);
const [isHydratingCache, setIsHydratingCache] = useState(true); const [isHydratingCache, setIsHydratingCache] = useState(true);
@@ -503,37 +615,6 @@ export function CustomWorldRoleAssetStudioModal({
), ),
[workingRole, selectedTemplate], [workingRole, selectedTemplate],
); );
const promptSeedKey = useMemo(
() =>
[
roleKind,
workingRole.name,
workingRole.title,
workingRole.role,
workingRole.description ?? '',
workingRole.backstory ?? '',
workingRole.personality ?? '',
workingRole.motivation ?? '',
workingRole.combatStyle ?? '',
(workingRole.tags ?? []).join('|'),
selectedTemplate?.name ?? '',
selectedTemplate?.title ?? '',
].join('||'),
[
roleKind,
selectedTemplate?.name,
selectedTemplate?.title,
workingRole.name,
workingRole.title,
workingRole.role,
workingRole.description,
workingRole.backstory,
workingRole.personality,
workingRole.motivation,
workingRole.combatStyle,
workingRole.tags,
],
);
const roleSnapshotKey = useMemo( const roleSnapshotKey = useMemo(
() => () =>
[ [
@@ -558,8 +639,8 @@ export function CustomWorldRoleAssetStudioModal({
const selectedVisualDraft = const selectedVisualDraft =
visualDrafts.find((draft) => draft.id === selectedVisualDraftId) ?? null; visualDrafts.find((draft) => draft.id === selectedVisualDraftId) ?? null;
const previewImageSrc = const previewImageSrc =
selectedVisualDraft?.imageSrc ??
workingRole.imageSrc ?? workingRole.imageSrc ??
selectedVisualDraft?.imageSrc ??
selectedTemplate?.portrait ?? selectedTemplate?.portrait ??
''; '';
const hasGeneratedVisualPreview = Boolean( const hasGeneratedVisualPreview = Boolean(
@@ -576,6 +657,23 @@ export function CustomWorldRoleAssetStudioModal({
}), }),
[selectedTemplate, workingRole], [selectedTemplate, workingRole],
); );
const selectedAnimationConfig = previewCharacter?.animationMap?.[
selectedAnimation
] as CharacterAnimationConfig | undefined;
const selectedAnimationStatus = animationStatusByKey[selectedAnimation] ?? null;
const isSelectedAnimationGenerating =
generatingAnimationMap[selectedAnimation] === true;
const hasAnyGeneratingAnimations = Object.values(generatingAnimationMap).some(
(value) => value === true,
);
const animationPreviewFrameStyle = useMemo(
() => getAnimationPreviewFrameStyle(selectedAnimationConfig, 440),
[selectedAnimationConfig],
);
const animationPreviewViewportStyle = useMemo(
() => getAnimationPreviewViewportStyle(440),
[],
);
const visualSourceMode = const visualSourceMode =
referenceImageDataUrls.length > 0 ? 'image-to-image' : 'text-to-image'; referenceImageDataUrls.length > 0 ? 'image-to-image' : 'text-to-image';
@@ -589,7 +687,9 @@ export function CustomWorldRoleAssetStudioModal({
setSelectedVisualDraftId(''); setSelectedVisualDraftId('');
setSelectedAnimation(CORE_ACTIONS[0]?.animation ?? AnimationState.IDLE); setSelectedAnimation(CORE_ACTIONS[0]?.animation ?? AnimationState.IDLE);
setVisualStatus(null); setVisualStatus(null);
setAnimationStatus(null); setAnimationStatusByKey({});
setGeneratingAnimationMap({});
setAnimationPreviewPlaybackRate(DEFAULT_ANIMATION_PLAYBACK_RATE);
setSaveStatus(null); setSaveStatus(null);
setIsHydratingCache(true); setIsHydratingCache(true);
@@ -643,64 +743,6 @@ export function CustomWorldRoleAssetStudioModal({
roleSnapshotKey, roleSnapshotKey,
]); ]);
useEffect(() => {
if (!characterBriefText.trim()) {
return;
}
let cancelled = false;
void generateCharacterPromptBundle({
roleKind,
characterName: workingRole.name,
roleTitle: workingRole.title,
roleLabel: workingRole.role,
description: workingRole.description,
backstory: workingRole.backstory,
personality: workingRole.personality,
motivation: workingRole.motivation,
combatStyle: workingRole.combatStyle,
tags: workingRole.tags,
characterBriefText,
})
.then((result) => {
if (cancelled) {
return;
}
setVisualPromptText((current) =>
current.trim() && current !== initialPromptBundle.visualPromptText
? current
: result.visualPromptText,
);
setAnimationPromptText((current) =>
current.trim() && current !== initialPromptBundle.animationPromptText
? current
: result.animationPromptText,
);
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, [
characterBriefText,
initialPromptBundle.animationPromptText,
initialPromptBundle.visualPromptText,
promptSeedKey,
roleKind,
workingRole.backstory,
workingRole.combatStyle,
workingRole.description,
workingRole.motivation,
workingRole.name,
workingRole.personality,
workingRole.role,
workingRole.tags,
workingRole.title,
]);
useEffect(() => { useEffect(() => {
if (isHydratingCache) { if (isHydratingCache) {
return; return;
@@ -742,6 +784,15 @@ export function CustomWorldRoleAssetStudioModal({
workingRole.imageSrc, workingRole.imageSrc,
]); ]);
useEffect(() => {
setAnimationPreviewPlaybackRate(
resolveAnimationPlaybackRate(
selectedActionConfig,
selectedAnimationConfig,
),
);
}, [selectedActionConfig, selectedAnimationConfig]);
const confirmPointSpend = (params: { const confirmPointSpend = (params: {
kindLabel: string; kindLabel: string;
points: number; points: number;
@@ -774,38 +825,35 @@ export function CustomWorldRoleAssetStudioModal({
event.target.value = ''; event.target.value = '';
}; };
const applyVisualDraftToWorkflow = async ( const applyVisualDraftToWorkflow = async (draft: CharacterVisualDraft) => {
draft: CharacterVisualDraft, setIsApplyingVisual(true);
draftList = visualDrafts, try {
) => { const result = await publishCharacterVisualAsset({
const normalizedVisual = await normalizeMasterVisualSourceToDataUrl( characterId: workingRole.id,
draft.imageSrc, sourceMode: visualSourceMode,
{ promptText: visualPromptText,
applyChromaKey: true, selectedPreviewSource: draft.imageSrc,
}, previewSources: [draft.imageSrc],
); width: draft.width,
const result = await publishCharacterVisualAsset({ height: draft.height,
characterId: workingRole.id, updateCharacterOverride: false,
sourceMode: visualSourceMode, });
promptText: visualPromptText,
selectedPreviewSource: normalizedVisual.dataUrl,
previewSources: [normalizedVisual.dataUrl],
width: normalizedVisual.width,
height: normalizedVisual.height,
updateCharacterOverride: false,
});
const nextRole = mergeRole(workingRole, { const nextRole = mergeRole(workingRole, {
imageSrc: result.portraitPath, imageSrc: result.portraitPath,
generatedVisualAssetId: result.assetId, generatedVisualAssetId: result.assetId,
generatedAnimationSetId: undefined, generatedAnimationSetId: undefined,
animationMap: undefined, animationMap: undefined,
}); });
setWorkingRole(nextRole); setWorkingRole(nextRole);
setSelectedVisualDraftId(draft.id); setSelectedVisualDraftId(draft.id);
setAnimationStatus(null); setAnimationStatusByKey({});
setSaveStatus(null); setGeneratingAnimationMap({});
setVisualStatus('角色形象已更新,可继续生成动作。'); setSaveStatus(null);
setVisualStatus('角色形象已更新,可继续生成动作。');
} finally {
setIsApplyingVisual(false);
}
}; };
const handleGenerateVisuals = async () => { const handleGenerateVisuals = async () => {
@@ -835,7 +883,7 @@ export function CustomWorldRoleAssetStudioModal({
}); });
setVisualDrafts(result.drafts); setVisualDrafts(result.drafts);
if (result.drafts[0]) { if (result.drafts[0]) {
await applyVisualDraftToWorkflow(result.drafts[0], result.drafts); await applyVisualDraftToWorkflow(result.drafts[0]);
setVisualStatus('角色形象已生成,如不满意可直接重新生成。'); setVisualStatus('角色形象已生成,如不满意可直接重新生成。');
} else { } else {
setSelectedVisualDraftId(''); setSelectedVisualDraftId('');
@@ -855,6 +903,8 @@ export function CustomWorldRoleAssetStudioModal({
throw new Error('请先生成角色形象,再生成动作。'); throw new Error('请先生成角色形象,再生成动作。');
} }
const isLoopAction = config.loop;
const result = await generateCharacterAnimationDraft({ const result = await generateCharacterAnimationDraft({
characterId: workingRole.id, characterId: workingRole.id,
strategy: 'image-to-video', strategy: 'image-to-video',
@@ -865,14 +915,15 @@ export function CustomWorldRoleAssetStudioModal({
visualSource: workingRole.imageSrc, visualSource: workingRole.imageSrc,
referenceImageDataUrls: [], referenceImageDataUrls: [],
referenceVideoDataUrls: [], referenceVideoDataUrls: [],
lastFrameImageDataUrl: isLoopAction ? undefined : workingRole.imageSrc,
frameCount: config.frameCount, frameCount: config.frameCount,
fps: config.fps, fps: config.fps,
durationSeconds: config.durationSeconds, durationSeconds: config.durationSeconds,
loop: config.loop, loop: config.loop,
useChromaKey: true, useChromaKey: true,
resolution: '720P', resolution: isLoopAction ? '720P' : '480P',
imageSequenceModel: 'wan2.7-image-pro', imageSequenceModel: 'wan2.7-image-pro',
videoModel: 'wan2.7-i2v', videoModel: isLoopAction ? 'wan2.6-i2v-flash' : 'wan2.2-kf2v-flash',
referenceVideoModel: 'wan2.7-r2v', referenceVideoModel: 'wan2.7-r2v',
motionTransferModel: 'wan2.2-animate-move', motionTransferModel: 'wan2.2-animate-move',
} satisfies CharacterAnimationGenerationPayload); } satisfies CharacterAnimationGenerationPayload);
@@ -887,6 +938,8 @@ export function CustomWorldRoleAssetStudioModal({
loop: config.loop, loop: config.loop,
frameCount: config.frameCount, frameCount: config.frameCount,
applyChromaKey: true, applyChromaKey: true,
sampleStartRatio: config.loop ? 0.12 : 0,
sampleEndRatio: config.loop ? 0.94 : 1,
}); });
}; };
@@ -895,6 +948,12 @@ export function CustomWorldRoleAssetStudioModal({
return; return;
} }
const actionConfig = selectedActionConfig;
const requestedPlaybackRate = animationPreviewPlaybackRate;
if (generatingAnimationMap[actionConfig.animation]) {
return;
}
if ( if (
!confirmPointSpend({ !confirmPointSpend({
kindLabel: '动作草稿生成', kindLabel: '动作草稿生成',
@@ -905,16 +964,22 @@ export function CustomWorldRoleAssetStudioModal({
return; return;
} }
setIsGeneratingAnimations(true); setGeneratingAnimationMap((current) => ({
setAnimationStatus(null); ...current,
[actionConfig.animation]: true,
}));
setAnimationStatusByKey((current) => ({
...current,
[actionConfig.animation]: null,
}));
try { try {
const clip = await generateActionClip(selectedActionConfig); const clip = await generateActionClip(actionConfig);
const result = await publishCharacterAnimationAssets({ const result = await publishCharacterAnimationAssets({
characterId: workingRole.id, characterId: workingRole.id,
visualAssetId: workingRole.generatedVisualAssetId!, visualAssetId: workingRole.generatedVisualAssetId!,
animations: { animations: {
[selectedActionConfig.animation]: { [actionConfig.animation]: {
framesDataUrls: clip.frames, framesDataUrls: clip.frames,
fps: clip.fps, fps: clip.fps,
loop: clip.loop, loop: clip.loop,
@@ -926,24 +991,38 @@ export function CustomWorldRoleAssetStudioModal({
updateCharacterOverride: false, updateCharacterOverride: false,
}); });
const nextRole = mergeRole(workingRole, { setWorkingRole((current) =>
generatedAnimationSetId: result.animationSetId, mergeRole(current, {
animationMap: { generatedAnimationSetId: result.animationSetId,
...((workingRole.animationMap ?? {}) as Record<string, unknown>), animationMap: applyPlaybackRateToAnimationMap({
...(result.animationMap as NonNullable< animationMap: {
EditableCustomWorldRole['animationMap'] ...((current.animationMap ?? {}) as Record<string, unknown>),
>), ...(result.animationMap as NonNullable<
}, EditableCustomWorldRole['animationMap']
}); >),
setWorkingRole(nextRole); },
setSaveStatus(null); animation: actionConfig.animation,
setAnimationStatus(`${selectedActionConfig.label} 动作已更新。`); actionConfig,
} catch (error) { playbackRate: requestedPlaybackRate,
setAnimationStatus( }),
error instanceof Error ? error.message : '生成角色动作失败。', }),
); );
setSaveStatus(null);
setAnimationStatusByKey((current) => ({
...current,
[actionConfig.animation]: `${actionConfig.label} 动作已更新。`,
}));
} catch (error) {
setAnimationStatusByKey((current) => ({
...current,
[actionConfig.animation]:
error instanceof Error ? error.message : '生成角色动作失败。',
}));
} finally { } finally {
setIsGeneratingAnimations(false); setGeneratingAnimationMap((current) => ({
...current,
[actionConfig.animation]: false,
}));
} }
}; };
@@ -999,7 +1078,7 @@ export function CustomWorldRoleAssetStudioModal({
isHydratingCache || isHydratingCache ||
isGeneratingVisuals || isGeneratingVisuals ||
isApplyingVisual || isApplyingVisual ||
isGeneratingAnimations || hasAnyGeneratingAnimations ||
isSavingToRole || isSavingToRole ||
syncBusy syncBusy
} }
@@ -1109,20 +1188,25 @@ export function CustomWorldRoleAssetStudioModal({
<Section title="动作"> <Section title="动作">
<div className="space-y-4"> <div className="space-y-4">
<div className="rounded-3xl border border-white/10 bg-[linear-gradient(180deg,rgba(19,24,39,0.96),rgba(8,10,17,0.92))] p-4"> <div className="rounded-3xl border border-white/10 bg-[linear-gradient(180deg,rgba(19,24,39,0.96),rgba(8,10,17,0.92))] p-4">
<div className="flex min-h-[16rem] items-center justify-center rounded-2xl border border-white/10 bg-black/20 p-4"> <div className="flex min-h-[28rem] items-center justify-center rounded-2xl border border-white/10 bg-black/20 p-4">
{previewCharacter && hasGeneratedAnimation(workingRole, selectedAnimation) ? ( {previewCharacter && hasGeneratedAnimation(workingRole, selectedAnimation) ? (
<div className="h-[220px] w-[220px]"> <div
<CharacterAnimator className="flex items-center justify-center"
state={selectedAnimation} style={animationPreviewViewportStyle}
character={previewCharacter} >
className="h-full w-full" <div style={animationPreviewFrameStyle}>
/> <CharacterAnimator
state={selectedAnimation}
character={previewCharacter}
className="h-full w-full"
/>
</div>
</div> </div>
) : previewImageSrc ? ( ) : previewImageSrc ? (
<img <img
src={previewImageSrc} src={previewImageSrc}
alt={workingRole.name} alt={workingRole.name}
className="max-h-[16rem] w-full object-contain" className="max-h-[28rem] w-full object-contain"
/> />
) : ( ) : (
<div className="px-4 text-sm text-zinc-500"></div> <div className="px-4 text-sm text-zinc-500"></div>
@@ -1130,10 +1214,53 @@ export function CustomWorldRoleAssetStudioModal({
</div> </div>
</div> </div>
<Field label="预览速度">
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
<input
type="range"
min="0.25"
max="1.5"
step="0.05"
value={animationPreviewPlaybackRate}
onChange={(event) => {
const nextPlaybackRate = clampAnimationPlaybackRate(
Number.parseFloat(event.target.value) ||
DEFAULT_ANIMATION_PLAYBACK_RATE,
);
setAnimationPreviewPlaybackRate(nextPlaybackRate);
setSaveStatus(null);
setWorkingRole((current) => {
const nextAnimationMap = applyPlaybackRateToAnimationMap({
animationMap: current.animationMap as
| Record<string, unknown>
| undefined,
animation: selectedAnimation,
actionConfig: selectedActionConfig,
playbackRate: nextPlaybackRate,
});
return nextAnimationMap === current.animationMap
? current
: mergeRole(current, {
animationMap: nextAnimationMap,
});
});
}}
className="w-full accent-sky-400"
/>
<div className="mt-2 flex items-center justify-between text-[11px] text-zinc-400">
<span>0.25x</span>
<span>{animationPreviewPlaybackRate.toFixed(2)}x</span>
<span>1.50x</span>
</div>
</div>
</Field>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5"> <div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
{CORE_ACTIONS.map((item) => { {CORE_ACTIONS.map((item) => {
const isSelected = item.animation === selectedAnimation; const isSelected = item.animation === selectedAnimation;
const isReady = hasGeneratedAnimation(workingRole, item.animation); const isReady = hasGeneratedAnimation(workingRole, item.animation);
const isGenerating = generatingAnimationMap[item.animation] === true;
return ( return (
<button <button
key={item.animation} key={item.animation}
@@ -1151,11 +1278,17 @@ export function CustomWorldRoleAssetStudioModal({
{item.label} {item.label}
</div> </div>
<div className="mt-1 text-[11px] text-zinc-400"> <div className="mt-1 text-[11px] text-zinc-400">
{isSelected ? '当前预览' : '点击切换'} {isGenerating
? '后台生成中'
: isSelected
? '当前预览'
: '点击切换'}
</div> </div>
</div> </div>
<StatusBadge tone={isReady ? 'green' : 'zinc'}> <StatusBadge
{isReady ? '已生成' : '待生成'} tone={isGenerating ? 'amber' : isReady ? 'green' : 'zinc'}
>
{isGenerating ? '生成中' : isReady ? '已生成' : '待生成'}
</StatusBadge> </StatusBadge>
</div> </div>
</button> </button>
@@ -1175,11 +1308,11 @@ export function CustomWorldRoleAssetStudioModal({
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<ActionButton <ActionButton
icon={<RefreshCcw className="h-4 w-4" />} icon={<RefreshCcw className="h-4 w-4" />}
label={isGeneratingAnimations ? '生成中...' : '生成动作'} label={isSelectedAnimationGenerating ? '生成中...' : '生成动作'}
subLabel={`消耗${animationPointCost}叙世币`} subLabel={`消耗${animationPointCost}叙世币`}
onClick={() => void handleGenerateAnimation()} onClick={() => void handleGenerateAnimation()}
disabled={ disabled={
isGeneratingAnimations || isSelectedAnimationGenerating ||
!workingRole.imageSrc || !workingRole.imageSrc ||
!workingRole.generatedVisualAssetId || !workingRole.generatedVisualAssetId ||
syncBusy syncBusy
@@ -1188,9 +1321,9 @@ export function CustomWorldRoleAssetStudioModal({
/> />
</div> </div>
{animationStatus ? ( {selectedAnimationStatus ? (
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300"> <div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
{animationStatus} {selectedAnimationStatus}
</div> </div>
) : null} ) : null}
</div> </div>

View File

@@ -726,16 +726,60 @@ function applyGreenScreenAlpha(
const blue = pixels[index + 2] ?? 0; const blue = pixels[index + 2] ?? 0;
const alpha = pixels[index + 3] ?? 0; const alpha = pixels[index + 3] ?? 0;
const greenLead = green - Math.max(red, blue); const greenLead = green - Math.max(red, blue);
const greenRatio = green / Math.max(1, red + blue);
if (alpha === 0) { if (alpha === 0) {
continue; continue;
} }
if (green > 96 && greenLead > 34) { if (green > 72 && greenLead > 20 && greenRatio > 0.72) {
const fade = Math.max(0, 255 - greenLead * 5); let nextAlpha = Math.min(alpha, Math.max(0, 255 - greenLead * 6));
pixels[index + 3] = Math.min(alpha, fade);
if (greenLead > 60) { if (green > 120 && greenLead > 48 && greenRatio > 1.12) {
pixels[index + 3] = 0; nextAlpha = 0;
}
pixels[index + 3] = nextAlpha;
if (nextAlpha > 0) {
pixels[index + 1] = Math.min(
green,
Math.max(red, blue) + Math.max(6, Math.round(greenLead * 0.18)),
);
}
}
}
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const index = (y * width + x) * 4;
const alpha = pixels[index + 3] ?? 0;
if (alpha === 0) {
continue;
}
const red = pixels[index] ?? 0;
const green = pixels[index + 1] ?? 0;
const blue = pixels[index + 2] ?? 0;
const neighborAlphaValues = [
x > 0 ? (pixels[index - 1] ?? 255) : 255,
x + 1 < width ? (pixels[index + 7] ?? 255) : 255,
y > 0 ? (pixels[index - width * 4 + 3] ?? 255) : 255,
y + 1 < height ? (pixels[index + width * 4 + 3] ?? 255) : 255,
];
const touchesTransparentEdge = neighborAlphaValues.some(
(value) => value < 16,
);
if (!touchesTransparentEdge) {
continue;
}
if (green > Math.max(red, blue) + 4) {
pixels[index + 1] = Math.max(
Math.max(red, blue),
green - Math.round((green - Math.max(red, blue)) * 0.8),
);
} }
} }
} }
@@ -866,6 +910,8 @@ export async function buildAnimationClipFromVideoSource(
frameWidth?: number; frameWidth?: number;
frameHeight?: number; frameHeight?: number;
applyChromaKey?: boolean; applyChromaKey?: boolean;
sampleStartRatio?: number;
sampleEndRatio?: number;
}, },
) { ) {
const video = await loadVideoFromSource(videoSource); const video = await loadVideoFromSource(videoSource);
@@ -877,6 +923,15 @@ export async function buildAnimationClipFromVideoSource(
2, 2,
options.frameCount ?? Math.round(duration * Math.max(1, options.fps)), options.frameCount ?? Math.round(duration * Math.max(1, options.fps)),
); );
const sampleStartRatio = Math.min(
0.85,
Math.max(0, options.sampleStartRatio ?? 0),
);
const sampleEndRatio = Math.min(
1,
Math.max(sampleStartRatio + 0.05, options.sampleEndRatio ?? 1),
);
const sampleWindowDuration = duration * (sampleEndRatio - sampleStartRatio);
const { canvas, context } = createCanvas(frameWidth, frameHeight); const { canvas, context } = createCanvas(frameWidth, frameHeight);
const frames: string[] = []; const frames: string[] = [];
@@ -884,7 +939,10 @@ export async function buildAnimationClipFromVideoSource(
const progress = options.loop const progress = options.loop
? frameIndex / derivedFrameCount ? frameIndex / derivedFrameCount
: frameIndex / Math.max(1, derivedFrameCount - 1); : frameIndex / Math.max(1, derivedFrameCount - 1);
const targetTime = Math.min(duration - 0.001, duration * progress); const targetTime = Math.min(
duration - 0.001,
duration * sampleStartRatio + sampleWindowDuration * progress,
);
await seekVideo(video, targetTime); await seekVideo(video, targetTime);
@@ -912,6 +970,84 @@ export async function buildAnimationClipFromVideoSource(
} satisfies DraftAnimationClip; } satisfies DraftAnimationClip;
} }
async function buildReferenceVideoFromFrameSources(
frameSources: string[],
options: {
fps?: number;
width?: number;
height?: number;
repeatLoops?: number;
} = {},
) {
if (typeof MediaRecorder === 'undefined') {
throw new Error('当前浏览器不支持 MediaRecorder无法生成参考视频。');
}
const images = await Promise.all(
frameSources.map((frameSource) => loadImageFromSource(frameSource)),
);
const width = options.width ?? GENERATED_FRAME_WIDTH;
const height = options.height ?? GENERATED_FRAME_HEIGHT;
const fps = Math.max(1, options.fps ?? 8);
const repeatLoops = Math.max(1, options.repeatLoops ?? 2);
const { canvas, context } = createCanvas(width, height);
const stream = canvas.captureStream(fps);
const mimeType = pickRecordMimeType();
const recorder = mimeType
? new MediaRecorder(stream, { mimeType })
: new MediaRecorder(stream);
const chunks: BlobPart[] = [];
recorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunks.push(event.data);
}
};
const stopPromise = new Promise<Blob>((resolve) => {
recorder.onstop = () => {
resolve(new Blob(chunks, { type: recorder.mimeType || 'video/webm' }));
};
});
recorder.start();
for (let loopIndex = 0; loopIndex < repeatLoops; loopIndex += 1) {
for (const image of images) {
context.clearRect(0, 0, canvas.width, canvas.height);
drawContainedImage(context, image, {
width: canvas.width,
height: canvas.height,
});
await waitFrame(Math.max(40, Math.round(1000 / fps)));
}
}
await waitFrame(80);
recorder.stop();
const blob = await stopPromise;
return blobToDataUrl(blob);
}
export async function buildReferenceVideoFromMasterAnimation(
masterSource: string,
animation: AnimationState,
options: {
fps?: number;
repeatLoops?: number;
width?: number;
height?: number;
} = {},
) {
const clip = await buildAnimationClipFromMaster(masterSource, animation);
return buildReferenceVideoFromFrameSources(clip.frames, {
fps: options.fps ?? clip.fps,
repeatLoops: options.repeatLoops ?? 2,
width: options.width ?? clip.frameWidth,
height: options.height ?? clip.frameHeight,
});
}
function getCharacterAnimationConfig( function getCharacterAnimationConfig(
character: Character, character: Character,
animation: AnimationState, animation: AnimationState,

View File

@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import { buildDefaultRolePromptBundle } from './customWorldRolePromptDefaults';
describe('buildDefaultRolePromptBundle', () => {
it('prefers model-generated role descriptions instead of rule-based assembly', () => {
const result = buildDefaultRolePromptBundle({
name: '沈砺',
title: '灰炬向导',
role: '边路同行者',
visualDescription:
'灰黑短斗篷压着风痕,肩侧挂着旧路标与短弓,整个人像常年在裂潮边路里行走的人。',
actionDescription:
'起手先观察风向和站位,再用短弓牵制后迅速贴近补刀,动作克制但很准。',
sceneVisualDescription:
'他常出现的边路哨点铺着潮湿石板,旧灯火和风旗一直在晃,空气里带着将散未散的盐雾。',
description: '熟悉裂潮边路的灰炬向导。',
});
expect(result.visualPromptText).toBe(
'灰黑短斗篷压着风痕,肩侧挂着旧路标与短弓,整个人像常年在裂潮边路里行走的人。',
);
expect(result.animationPromptText).toBe(
'起手先观察风向和站位,再用短弓牵制后迅速贴近补刀,动作克制但很准。',
);
expect(result.scenePromptText).toBe(
'他常出现的边路哨点铺着潮湿石板,旧灯火和风旗一直在晃,空气里带着将散未散的盐雾。',
);
});
it('falls back to compact role descriptions without reintroducing built-in prompt rules', () => {
const result = buildDefaultRolePromptBundle({
name: '顾潮音',
title: '港口守望者',
role: '场景角色',
description: '总在潮雾港高处盯着来往船影的守望者。',
personality: '寡言、敏锐、先看人再开口。',
combatStyle: '长枪封线后借高差压制。',
motivation: '想在港口旧秩序彻底崩掉前找出新的站位。',
backstory: '他把许多没说出口的旧案痕迹留在港口高处。',
tags: ['潮雾港', '守望', '旧案'],
});
expect(result.visualPromptText).toContain('总在潮雾港高处盯着来往船影的守望者。');
expect(result.animationPromptText).toContain('长枪封线后借高差压制。');
expect(result.scenePromptText).toContain('他把许多没说出口的旧案痕迹留在港口高处。');
expect(result.visualPromptText).not.toContain('2D 横版 RPG');
expect(result.visualPromptText).not.toContain('纯绿色绿幕');
expect(result.visualPromptText).not.toContain('提示词');
});
});

View File

@@ -2,6 +2,9 @@ export type PromptDefaultRole = {
name: string; name: string;
title: string; title: string;
role: string; role: string;
visualDescription?: string;
actionDescription?: string;
sceneVisualDescription?: string;
description?: string; description?: string;
backstory?: string; backstory?: string;
personality?: string; personality?: string;
@@ -20,57 +23,52 @@ function cleanSeedText(value: string | undefined, maxLength: number) {
return (value ?? '').replace(/\s+/gu, ' ').trim().slice(0, maxLength); return (value ?? '').replace(/\s+/gu, ' ').trim().slice(0, maxLength);
} }
function compactDescription(parts: Array<string | undefined>, maxLength: number) {
return parts
.map((item) => cleanSeedText(item, maxLength))
.filter(Boolean)
.join(' ')
.slice(0, maxLength);
}
export function buildDefaultRolePromptBundle( export function buildDefaultRolePromptBundle(
role: PromptDefaultRole, role: PromptDefaultRole,
): CustomWorldRolePromptBundle { ): CustomWorldRolePromptBundle {
const characterName = cleanSeedText(role.name, 40) || '该角色'; const roleLabel = [cleanSeedText(role.name, 40), cleanSeedText(role.title, 40)]
const roleAnchor = .filter(Boolean)
[cleanSeedText(role.title, 60), cleanSeedText(role.role, 60)] .join('');
.filter(Boolean) const fallbackVisualDescription = compactDescription(
.join(' / ') || '关键角色'; [
const descriptionAnchor = roleLabel || cleanSeedText(role.role, 40),
cleanSeedText(role.description, 220) || role.description,
cleanSeedText(role.backstory, 260) || role.personality,
cleanSeedText(role.personality, 160) || role.tags && role.tags.length > 0 ? role.tags.slice(0, 8).join('、') : '',
'识别度鲜明'; ],
const combatAnchor = 220,
cleanSeedText(role.combatStyle, 180) || );
cleanSeedText(role.motivation, 180) || const fallbackActionDescription = compactDescription(
'动作重心稳定'; [
const tagAnchor = role.actionDescription,
role.tags && role.tags.length > 0 role.combatStyle,
? `保留 ${role.tags.slice(0, 8).join('、')} 的角色识别点。` role.motivation,
: ''; role.personality,
],
180,
);
const generatedSceneDescription = cleanSeedText(role.sceneVisualDescription, 220);
const fallbackSceneDescription = compactDescription(
[
role.backstory,
role.description,
role.motivation,
],
220,
);
return { return {
visualPromptText: [ visualPromptText:
`${characterName}${roleAnchor}`, cleanSeedText(role.visualDescription, 220) || fallbackVisualDescription,
'单人全身2D 横版 RPG 角色主图,侧身朝右,脚底完整可见,服装、发型、武器与轮廓保持稳定清楚。', animationPromptText: fallbackActionDescription,
`外观气质围绕:${descriptionAnchor}`, scenePromptText: generatedSceneDescription || fallbackSceneDescription,
`动作识别点参考:${combatAnchor}`,
tagAnchor,
'构图干净,主体明确,不做正面立绘,不做夸张透视。',
]
.filter(Boolean)
.join(' '),
animationPromptText: [
`${characterName}核心动作试片。`,
'保持同一角色的服装、发型、武器和体型一致,镜头稳定,侧身朝右,动作连贯自然。',
`动作气质参考:${combatAnchor}`,
role.personality ? `角色状态补充:${cleanSeedText(role.personality, 160)}` : '',
'起手清楚,发力明确,收招干净,避免漂移、乱摆和形体变形。',
]
.filter(Boolean)
.join(' '),
scenePromptText: [
`${characterName}关联主场景,适合作为首次登场区域或常驻活动空间。`,
'16:9 横版 RPG 场景背景,上半部分突出中远景氛围,下半部分是清晰可站立地面。',
`场景叙事气质围绕:${descriptionAnchor}`,
role.backstory ? `环境背景可埋入:${cleanSeedText(role.backstory, 260)}` : '',
role.motivation ? `场景目标暗示可参考:${cleanSeedText(role.motivation, 160)}` : '',
'整体风格统一克制,适合作为剧情探索与战斗底图。',
]
.filter(Boolean)
.join(' '),
}; };
} }

View File

@@ -42,5 +42,5 @@ test('clarification panel shows pending questions and ready state', () => {
expect(pendingHtml).toContain('待补充问题'); expect(pendingHtml).toContain('待补充问题');
expect(pendingHtml).toContain('玩家是谁,故事开场时卡在什么处境里'); expect(pendingHtml).toContain('玩家是谁,故事开场时卡在什么处境里');
expect(readyHtml).toContain('最小锚点已齐备,可以进入下一阶段'); expect(readyHtml).toContain('当前设定已齐备,可以进入下一阶段');
}); });

View File

@@ -19,7 +19,7 @@ export function CustomWorldAgentClarificationPanel({
</div> </div>
<div className="mt-2 text-lg font-semibold text-white"> <div className="mt-2 text-lg font-semibold text-white">
</div> </div>
</section> </section>
); );

View File

@@ -7,9 +7,6 @@ type CustomWorldAgentComposerProps = {
disabled: boolean; disabled: boolean;
onSubmit: (payload: SendCustomWorldAgentMessageRequest) => void; onSubmit: (payload: SendCustomWorldAgentMessageRequest) => void;
textareaRef?: RefObject<HTMLTextAreaElement | null>; textareaRef?: RefObject<HTMLTextAreaElement | null>;
onSummaryClick?: () => void;
onAutoCompleteClick?: () => void;
showAutoComplete?: boolean;
}; };
function createClientMessageId() { function createClientMessageId() {
@@ -27,9 +24,6 @@ export function CustomWorldAgentComposer({
disabled, disabled,
onSubmit, onSubmit,
textareaRef, textareaRef,
onSummaryClick,
onAutoCompleteClick,
showAutoComplete = true,
}: CustomWorldAgentComposerProps) { }: CustomWorldAgentComposerProps) {
const [text, setText] = useState(''); const [text, setText] = useState('');
@@ -49,28 +43,8 @@ export function CustomWorldAgentComposer({
}; };
return ( return (
<div className="rounded-[1.75rem] border border-white/10 bg-[#111318]/95 p-4"> <div className="shrink-0 rounded-[1.75rem] border border-white/10 bg-[#111318]/95 p-4">
<div className="flex flex-col gap-3"> <div className="relative">
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={onSummaryClick}
disabled={disabled}
className="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-zinc-200 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
>
</button>
{showAutoComplete ? (
<button
type="button"
onClick={onAutoCompleteClick}
disabled={disabled}
className="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-zinc-200 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
>
</button>
) : null}
</div>
<textarea <textarea
ref={textareaRef} ref={textareaRef}
value={text} value={text}
@@ -81,21 +55,19 @@ export function CustomWorldAgentComposer({
submit(); submit();
} }
}} }}
rows={2} rows={3}
disabled={disabled} disabled={disabled}
placeholder="输入消息" placeholder="输入消息"
className="w-full resize-none rounded-[1.35rem] border border-white/10 bg-black/30 px-4 py-2.5 text-sm leading-6 text-white outline-none transition focus:border-emerald-300/35 disabled:cursor-not-allowed disabled:opacity-60" className="w-full resize-none rounded-[1.35rem] border border-white/10 bg-black/30 px-4 pb-12 pr-20 pt-3 text-sm leading-6 text-white outline-none transition focus:border-emerald-300/35 disabled:cursor-not-allowed disabled:opacity-60"
/> />
<div className="flex justify-end"> <button
<button type="button"
type="button" onClick={submit}
onClick={submit} disabled={disabled || !text.trim()}
disabled={disabled || !text.trim()} className="absolute bottom-3 right-3 rounded-full border border-emerald-300/20 bg-emerald-500/10 px-4 py-2 text-sm font-medium text-emerald-100 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
className="rounded-full border border-emerald-300/20 bg-emerald-500/10 px-4 py-2 text-sm font-medium text-emerald-100 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45" >
>
</button>
</button>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -107,7 +107,7 @@ export function CustomWorldAgentDraftDrawer({
</div> </div>
) : ( ) : (
<div className="mt-3 text-sm leading-7 text-zinc-400"> <div className="mt-3 text-sm leading-7 text-zinc-400">
稿 稿
</div> </div>
)} )}
</div> </div>

View File

@@ -4,7 +4,7 @@ type CustomWorldAgentHeaderProps = {
export function CustomWorldAgentHeader({ onBack }: CustomWorldAgentHeaderProps) { export function CustomWorldAgentHeader({ onBack }: CustomWorldAgentHeaderProps) {
return ( return (
<div className="flex items-center justify-between gap-3 rounded-[1.5rem] border border-white/10 bg-[#111318]/95 px-4 py-3"> <div className="flex items-center rounded-[1.5rem] border border-white/10 bg-[#111318]/95 px-4 py-3">
<button <button
type="button" type="button"
onClick={onBack} onClick={onBack}
@@ -12,7 +12,6 @@ export function CustomWorldAgentHeader({ onBack }: CustomWorldAgentHeaderProps)
> >
</button> </button>
<div className="text-sm font-semibold text-white">Agent</div>
</div> </div>
); );
} }

View File

@@ -32,7 +32,7 @@ test('intent summary panel shows collected custom world anchors', () => {
/>, />,
); );
expect(html).toContain('已收集锚点'); expect(html).toContain('已收集设定');
expect(html).toContain('世界一句话'); expect(html).toContain('世界一句话');
expect(html).toContain('一个被潮雾切开的列岛世界'); expect(html).toContain('一个被潮雾切开的列岛世界');
expect(html).toContain('守灯会与沉船商盟争夺航道解释权'); expect(html).toContain('守灯会与沉船商盟争夺航道解释权');

View File

@@ -58,7 +58,7 @@ export function CustomWorldAgentIntentSummaryPanel({
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div> <div>
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400"> <div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
</div> </div>
<div className="mt-2 text-lg font-semibold text-white"> <div className="mt-2 text-lg font-semibold text-white">
{resolvedReadiness.isReady ? '创作输入已齐备' : '继续收世界骨架'} {resolvedReadiness.isReady ? '创作输入已齐备' : '继续收世界骨架'}
@@ -91,7 +91,7 @@ export function CustomWorldAgentIntentSummaryPanel({
</div> </div>
) : ( ) : (
<div className="mt-4 rounded-[1.15rem] border border-dashed border-white/10 bg-black/22 px-4 py-5 text-sm text-zinc-400"> <div className="mt-4 rounded-[1.15rem] border border-dashed border-white/10 bg-black/22 px-4 py-5 text-sm text-zinc-400">
</div> </div>
)} )}
</section> </section>

View File

@@ -121,7 +121,7 @@ export function CustomWorldAgentQuickActions({
)) ))
) : !draftAction || !canDraftFoundation ? ( ) : !draftAction || !canDraftFoundation ? (
<QuickActionButton <QuickActionButton
label={showEntityActions ? '继续精修当前草稿' : '继续补充锚点'} label={showEntityActions ? '继续精修当前草稿' : '继续补充设定'}
onClick={() => onFocusSuggestedAction()} onClick={() => onFocusSuggestedAction()}
disabled={disabled} disabled={disabled}
/> />

View File

@@ -27,7 +27,7 @@ export function CustomWorldAgentSummaryPanel({
const pendingCount = session.pendingClarifications.length; const pendingCount = session.pendingClarifications.length;
const { title, summary } = readSummaryText( const { title, summary } = readSummaryText(
session.draftProfile, session.draftProfile,
'第一阶段先收住世界锚点,后续阶段再把这里整理成更完整的世界底稿摘要。', '第一阶段先收住世界设定,后续阶段再把这里整理成更完整的世界底稿摘要。',
); );
return ( return (

View File

@@ -63,3 +63,31 @@ test('filters empty recommended replies and avoids duplicate key warnings', () =
expect(duplicateKeyCalls).toHaveLength(0); expect(duplicateKeyCalls).toHaveLength(0);
}); });
test('renders a streaming assistant bubble without timestamps', () => {
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
}
render(
<CustomWorldAgentThread
messages={[
{
id: 'message-1',
role: 'user',
kind: 'chat',
text: '我想做一个潮湿压抑的海上世界。',
createdAt: '2026-04-16T10:01:00.000Z',
relatedOperationId: null,
},
]}
streamingReplyText="那我先顺着这个方向收一下,开场时你更想让玩家撞上什么麻烦"
isStreamingReply
/>,
);
expect(
screen.getByText(//u),
).toBeTruthy();
expect(screen.queryByText('10:01')).toBeNull();
});

View File

@@ -6,24 +6,16 @@ type CustomWorldAgentThreadProps = {
messages: CustomWorldAgentMessage[]; messages: CustomWorldAgentMessage[];
recommendedReplies?: string[]; recommendedReplies?: string[];
onRecommendedReply?: (text: string) => void; onRecommendedReply?: (text: string) => void;
streamingReplyText?: string;
isStreamingReply?: boolean;
}; };
function formatMessageTime(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
return new Intl.DateTimeFormat('zh-CN', {
hour: '2-digit',
minute: '2-digit',
}).format(date);
}
export function CustomWorldAgentThread({ export function CustomWorldAgentThread({
messages, messages,
recommendedReplies = [], recommendedReplies = [],
onRecommendedReply, onRecommendedReply,
streamingReplyText = '',
isStreamingReply = false,
}: CustomWorldAgentThreadProps) { }: CustomWorldAgentThreadProps) {
const bottomRef = useRef<HTMLDivElement | null>(null); const bottomRef = useRef<HTMLDivElement | null>(null);
const visibleRecommendedReplies = [ const visibleRecommendedReplies = [
@@ -42,10 +34,10 @@ export function CustomWorldAgentThread({
behavior: 'smooth', behavior: 'smooth',
block: 'end', block: 'end',
}); });
}, [messages]); }, [messages, streamingReplyText, isStreamingReply]);
return ( return (
<div className="flex min-h-[20rem] flex-1 flex-col overflow-y-auto rounded-[1.75rem] border border-white/10 bg-[#111318]/95 p-4"> <div className="flex h-full min-h-0 flex-1 flex-col overflow-y-auto rounded-[1.75rem] border border-white/10 bg-[#111318]/95 p-4">
{messages.length === 0 ? ( {messages.length === 0 ? (
<div className="m-auto text-sm text-zinc-400"> <div className="m-auto text-sm text-zinc-400">
@@ -73,9 +65,6 @@ export function CustomWorldAgentThread({
}`} }`}
> >
<div className="whitespace-pre-wrap">{message.text}</div> <div className="whitespace-pre-wrap">{message.text}</div>
<div className="mt-2 text-[11px] text-zinc-400">
{formatMessageTime(message.createdAt)}
</div>
{!isUser && {!isUser &&
index === lastAssistantMessageIndex && index === lastAssistantMessageIndex &&
visibleRecommendedReplies.length > 0 ? ( visibleRecommendedReplies.length > 0 ? (
@@ -96,6 +85,24 @@ export function CustomWorldAgentThread({
</div> </div>
); );
})} })}
{isStreamingReply ? (
<div className="flex justify-start">
<div className="max-w-[90%] rounded-[1.4rem] border border-white/10 bg-white/6 px-4 py-3 text-sm leading-7 text-zinc-100 sm:max-w-[82%]">
{streamingReplyText ? (
<div className="whitespace-pre-wrap">
{streamingReplyText}
<span className="ml-1 inline-block h-4 w-1 animate-pulse rounded-full bg-emerald-200/80 align-[-2px]" />
</div>
) : (
<div className="flex items-center gap-1.5 py-1">
<span className="h-2 w-2 animate-pulse rounded-full bg-zinc-300/70 [animation-delay:-0.2s]" />
<span className="h-2 w-2 animate-pulse rounded-full bg-zinc-300/70 [animation-delay:-0.1s]" />
<span className="h-2 w-2 animate-pulse rounded-full bg-zinc-300/70" />
</div>
)}
</div>
</div>
) : null}
<div ref={bottomRef} /> <div ref={bottomRef} />
</div> </div>
)} )}

View File

@@ -1,483 +1,161 @@
/* @vitest-environment jsdom */ /* @vitest-environment jsdom */
import { render, screen, waitFor } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { beforeEach, expect, test, vi } from 'vitest'; import { beforeEach, expect, test, vi } from 'vitest';
import type { import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
CustomWorldAgentSessionSnapshot,
CustomWorldDraftCardDetail,
} from '../../../packages/shared/src/contracts/customWorldAgent';
import { getCustomWorldAgentCardDetail } from '../../services/aiService';
import { CustomWorldAgentWorkspace } from './CustomWorldAgentWorkspace'; import { CustomWorldAgentWorkspace } from './CustomWorldAgentWorkspace';
vi.mock('../../services/aiService', () => ({
getCustomWorldAgentCardDetail: vi.fn(),
}));
vi.mock('../CustomWorldRoleAssetStudioModal', () => ({
CustomWorldRoleAssetStudioModal: ({
role,
onPublishSuccess,
}: {
role: { name: string };
onPublishSuccess?: (
payload: {
roleId: string;
portraitPath: string;
generatedVisualAssetId: string;
generatedAnimationSetId?: string | null;
animationMap?: Record<string, unknown> | null;
},
options?: { closeAfterSync?: boolean },
) => void;
}) => (
<div>
<div>{role.name}</div>
<button
type="button"
onClick={() =>
onPublishSuccess?.(
{
roleId: 'character-1',
portraitPath: '/generated/character-1.png',
generatedVisualAssetId: 'visual-character-1',
generatedAnimationSetId: 'animation-set-character-1',
animationMap: {
idle: { basePath: '/generated/character-1/idle' },
run: { basePath: '/generated/character-1/run' },
attack: { basePath: '/generated/character-1/attack' },
hurt: { basePath: '/generated/character-1/hurt' },
die: { basePath: '/generated/character-1/die' },
},
},
{
closeAfterSync: true,
},
)
}
>
</button>
</div>
),
}));
const detailById: Record<string, CustomWorldDraftCardDetail> = {
'world-foundation': {
id: 'world-foundation',
kind: 'world',
title: '潮雾列岛',
sections: [
{
id: 'title',
label: '标题',
value: '潮雾列岛',
},
{
id: 'summary',
label: '摘要',
value: '这是第一版世界底稿。',
},
],
linkedIds: ['thread-1', 'character-1'],
locked: false,
editable: true,
editableSectionIds: ['title', 'summary'],
warningMessages: [],
},
'character-1': {
id: 'character-1',
kind: 'character',
title: '沈砺',
sections: [
{
id: 'name',
label: '角色名',
value: '沈砺',
},
{
id: 'summary',
label: '角色摘要',
value: '他像旧友,但也像一把始终没收回鞘的刀。',
},
],
linkedIds: ['thread-1'],
locked: false,
editable: true,
editableSectionIds: ['name', 'summary'],
warningMessages: [],
assetStatus: 'missing',
assetStatusLabel: '待生成主图',
},
'character-2': {
id: 'character-2',
kind: 'character',
title: '顾潮音',
sections: [
{
id: 'name',
label: '角色名',
value: '顾潮音',
},
{
id: 'summary',
label: '角色摘要',
value: '她总像比所有人更早知道海雾会往哪一侧压下来。',
},
],
linkedIds: ['thread-1'],
locked: false,
editable: true,
editableSectionIds: ['name', 'summary'],
warningMessages: [],
assetStatus: 'missing',
assetStatusLabel: '待生成主图',
},
};
const baseSession: CustomWorldAgentSessionSnapshot = { const baseSession: CustomWorldAgentSessionSnapshot = {
sessionId: 'custom-world-agent-session-1', sessionId: 'custom-world-agent-session-1',
stage: 'object_refining', currentTurn: 4,
focusCardId: 'world-foundation', anchorContent: {
worldPromise: {
hook: '一个被潮雾改写航线秩序的群岛世界。',
differentiator: '所有通路都要向未知代价借路。',
desiredExperience: '压迫、潮湿、悬疑',
},
playerFantasy: {
playerRole: '玩家是被迫返乡的旧航路继承人。',
corePursuit: '查清沉船夜背后的真相。',
fearOfLoss: '一旦失败,就会再次失去唯一还活着的旧友。',
},
themeBoundary: null,
playerEntryPoint: null,
coreConflict: null,
keyRelationships: [],
hiddenLines: null,
iconicElements: null,
},
progressPercent: 58,
lastAssistantReply: '世界和玩家视角已经有了,下一步我想把最明面的冲突钉住。',
stage: 'collecting_intent',
focusCardId: null,
creatorIntent: {}, creatorIntent: {},
creatorIntentReadiness: { creatorIntentReadiness: {
isReady: true, isReady: false,
completedKeys: [ completedKeys: ['world_hook', 'player_premise'],
'world_hook', missingKeys: ['theme_and_tone', 'core_conflict', 'relationship_seed', 'iconic_element'],
'player_premise',
'theme_and_tone',
'core_conflict',
'relationship_seed',
'iconic_element',
],
missingKeys: [],
}, },
anchorPack: {}, anchorPack: {},
lockState: {}, lockState: {},
draftProfile: { draftProfile: null,
name: '潮雾列岛',
storyNpcs: [
{
id: 'character-1',
name: '沈砺',
title: '守灯会旧友',
role: '航道向导',
publicMask: '守灯会里最熟悉旧航道的人。',
hiddenHook: '暗地里正在为沉船商盟引路。',
relationToPlayer: '旧友兼宿敌',
threadIds: ['thread-1'],
summary: '他像旧友,但也像一把始终没收回鞘的刀。',
},
],
},
messages: [ messages: [
{ {
id: 'message-1', id: 'message-1',
role: 'assistant', role: 'assistant',
kind: 'summary', kind: 'chat',
text: '当前底稿已经可以继续精修。', text: '先告诉我你想做一个怎样的世界。',
createdAt: new Date().toISOString(), createdAt: '2026-04-17T12:00:00.000Z',
relatedOperationId: null, relatedOperationId: null,
}, },
], ],
draftCards: [ draftCards: [],
{
id: 'world-foundation',
kind: 'world',
title: '潮雾列岛',
subtitle: '旧灯塔与航道争夺',
summary: '世界总卡已经生成。',
status: 'warning',
linkedIds: ['thread-1', 'character-1'],
warningCount: 1,
},
{
id: 'character-1',
kind: 'character',
title: '沈砺',
subtitle: '守灯会旧友',
summary: '他最了解旧航道,也最可能先背叛。',
status: 'suggested',
linkedIds: ['thread-1'],
warningCount: 0,
},
],
pendingClarifications: [], pendingClarifications: [],
suggestedActions: [ suggestedActions: [],
{ recommendedReplies: [],
id: 'request-summary',
type: 'request_summary',
label: '总结当前世界底稿',
targetId: null,
},
],
recommendedReplies: [
'现在开始生成草稿',
'先总结一下当前设定',
'我还想再补充一点',
],
qualityFindings: [], qualityFindings: [],
assetCoverage: { assetCoverage: {
roleAssets: [ roleAssets: [],
{
roleId: 'character-1',
roleName: '沈砺',
roleKind: 'story',
priorityTier: 'featured',
portraitPath: null,
generatedVisualAssetId: null,
generatedAnimationSetId: null,
status: 'missing',
missingAnimations: ['idle', 'run', 'attack', 'hurt', 'die'],
nextPointCost: 20,
},
],
sceneAssets: [], sceneAssets: [],
allRoleAssetsReady: false, allRoleAssetsReady: false,
allSceneAssetsReady: false, allSceneAssetsReady: false,
}, },
updatedAt: '2026-04-14T10:00:00.000Z', updatedAt: '2026-04-17T12:00:00.000Z',
}; };
beforeEach(() => { beforeEach(() => {
vi.mocked(getCustomWorldAgentCardDetail).mockImplementation(
async (_sessionId, cardId): Promise<CustomWorldDraftCardDetail> =>
detailById[cardId] ?? detailById['world-foundation']!,
);
if (!Element.prototype.scrollIntoView) { if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {}; Element.prototype.scrollIntoView = () => {};
} }
}); });
test('workspace loads detail, saves edits, opens generate actions, and reflects updated drawer cards', async () => { test('workspace sends summary request from progress area', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const onExecuteAction = vi.fn(); const onSubmitMessage = vi.fn();
const { rerender } = render( render(
<CustomWorldAgentWorkspace <CustomWorldAgentWorkspace
session={baseSession} session={baseSession}
activeOperation={null} activeOperation={null}
onBack={() => {}} onBack={() => {}}
onRefresh={() => {}} onSubmitMessage={onSubmitMessage}
onSubmitMessage={() => {}} onExecuteAction={() => {}}
onExecuteAction={onExecuteAction}
/>, />,
); );
await waitFor(() => { await user.click(screen.getByRole('button', { name: '总结当前设定' }));
expect(getCustomWorldAgentCardDetail).toHaveBeenCalledWith(
baseSession.sessionId,
'world-foundation',
);
});
expect(screen.getByText('卡片详情')).toBeTruthy(); expect(onSubmitMessage).toHaveBeenCalledWith(
expect(screen.queryByPlaceholderText('输入消息')).toBeNull(); expect.objectContaining({
expect(screen.queryByText('当前底稿已经可以继续精修。')).toBeNull(); text: '请总结一下当前已经成形的世界设定。',
}),
);
});
await user.click(screen.getByRole('button', { name: '编辑设定' })); test('workspace enables quick fill after at least two turns and submits quick fill request', async () => {
const summaryInput = screen.getByLabelText('摘要'); const user = userEvent.setup();
await user.clear(summaryInput); const onSubmitMessage = vi.fn();
await user.type(summaryInput, '这是更新后的世界摘要。');
await user.click(screen.getByRole('button', { name: '保存' }));
expect(onExecuteAction).toHaveBeenCalledWith({ render(
action: 'update_draft_card', <CustomWorldAgentWorkspace
cardId: 'world-foundation', session={baseSession}
sections: [ activeOperation={null}
{ onBack={() => {}}
sectionId: 'title', onSubmitMessage={onSubmitMessage}
value: '潮雾列岛', onExecuteAction={() => {}}
}, />,
{ );
sectionId: 'summary',
value: '这是更新后的世界摘要。',
},
],
});
await user.click(screen.getByRole('button', { name: //u })); await user.click(screen.getByRole('button', { name: '补全剩余设定' }));
await waitFor(() => {
expect(getCustomWorldAgentCardDetail).toHaveBeenLastCalledWith(
baseSession.sessionId,
'character-1',
);
});
const [generateCharacterButton] = screen.getAllByRole('button', { expect(onSubmitMessage).toHaveBeenCalledWith(
name: '新增角色', expect.objectContaining({
}); text: '请补全剩余设定。',
await user.click(generateCharacterButton!); quickFillRequested: true,
expect(screen.getByRole('button', { name: '生成角色' })).toBeTruthy(); }),
await user.click(screen.getByRole('button', { name: '生成角色' })); );
});
expect(onExecuteAction).toHaveBeenCalledWith({ test('workspace hides quick fill before two turns', () => {
action: 'generate_characters', render(
count: 2,
promptText: null,
anchorCardIds: ['character-1'],
});
const [generateLandmarkButton] = screen.getAllByRole('button', {
name: '新增场景',
});
await user.click(generateLandmarkButton!);
expect(screen.getByRole('button', { name: '生成场景' })).toBeTruthy();
await user.click(screen.getByRole('button', { name: '生成场景' }));
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'generate_landmarks',
count: 2,
promptText: null,
anchorCardIds: ['character-1'],
});
const [openRoleAssetsButton] = screen.getAllByRole('button', {
name: '角色资产',
});
await user.click(openRoleAssetsButton!);
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'generate_role_assets',
roleIds: ['character-1'],
});
rerender(
<CustomWorldAgentWorkspace <CustomWorldAgentWorkspace
session={{ session={{
...baseSession, ...baseSession,
stage: 'visual_refining', currentTurn: 1,
draftCards: [
...baseSession.draftCards,
{
id: 'character-2',
kind: 'character',
title: '顾潮音',
subtitle: '回潮记录员',
summary: '她会把每一次海雾异常都记到连自己都不愿复看的本子里。',
status: 'suggested',
linkedIds: ['thread-1'],
warningCount: 0,
},
],
updatedAt: '2026-04-14T10:05:00.000Z',
}}
activeOperation={{
operationId: 'operation-role-assets',
type: 'generate_role_assets',
status: 'completed',
phaseLabel: '角色资产工坊已就绪',
phaseDetail: '可以开始生成角色主图与动作。',
progress: 100,
error: null,
}} }}
activeOperation={null}
onBack={() => {}} onBack={() => {}}
onRefresh={() => {}}
onSubmitMessage={() => {}} onSubmitMessage={() => {}}
onExecuteAction={onExecuteAction} onExecuteAction={() => {}}
/>, />,
); );
await waitFor(() => { expect(screen.queryByRole('button', { name: '补全剩余设定' })).toBeNull();
expect(screen.getByText('顾潮音')).toBeTruthy(); });
});
expect(screen.getByText('角色资产工坊:沈砺')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '模拟同步角色资产' })); test('workspace exposes draft action when progress reaches 100', async () => {
expect(onExecuteAction).toHaveBeenCalledWith({ const user = userEvent.setup();
action: 'sync_role_assets', const onExecuteAction = vi.fn();
roleId: 'character-1',
portraitPath: '/generated/character-1.png',
generatedVisualAssetId: 'visual-character-1',
generatedAnimationSetId: 'animation-set-character-1',
animationMap: {
idle: { basePath: '/generated/character-1/idle' },
run: { basePath: '/generated/character-1/run' },
attack: { basePath: '/generated/character-1/attack' },
hurt: { basePath: '/generated/character-1/hurt' },
die: { basePath: '/generated/character-1/die' },
},
});
rerender( render(
<CustomWorldAgentWorkspace <CustomWorldAgentWorkspace
session={{ session={{
...baseSession, ...baseSession,
stage: 'visual_refining', progressPercent: 100,
draftCards: [ stage: 'foundation_review',
{
...baseSession.draftCards[0]!,
},
{
...baseSession.draftCards[1]!,
subtitle: '守灯会旧友 / 动作已就绪',
assetStatus: 'complete',
assetStatusLabel: '动作已就绪',
},
],
assetCoverage: {
roleAssets: [
{
roleId: 'character-1',
roleName: '沈砺',
roleKind: 'story',
priorityTier: 'featured',
portraitPath: '/generated/character-1.png',
generatedVisualAssetId: 'visual-character-1',
generatedAnimationSetId: 'animation-set-character-1',
status: 'complete',
missingAnimations: [],
nextPointCost: 0,
},
],
sceneAssets: [],
allRoleAssetsReady: true,
allSceneAssetsReady: false,
},
draftProfile: {
...baseSession.draftProfile,
storyNpcs: [
{
id: 'character-1',
name: '沈砺',
title: '守灯会旧友',
role: '航道向导',
publicMask: '守灯会里最熟悉旧航道的人。',
hiddenHook: '暗地里正在为沉船商盟引路。',
relationToPlayer: '旧友兼宿敌',
threadIds: ['thread-1'],
summary: '他像旧友,但也像一把始终没收回鞘的刀。',
imageSrc: '/generated/character-1.png',
generatedVisualAssetId: 'visual-character-1',
generatedAnimationSetId: 'animation-set-character-1',
animationMap: {
idle: { basePath: '/generated/character-1/idle' },
run: { basePath: '/generated/character-1/run' },
attack: { basePath: '/generated/character-1/attack' },
hurt: { basePath: '/generated/character-1/hurt' },
die: { basePath: '/generated/character-1/die' },
},
},
],
},
}}
activeOperation={{
operationId: 'operation-sync-role-assets',
type: 'sync_role_assets',
status: 'completed',
phaseLabel: '角色资产已同步',
phaseDetail: '角色资产已经写回草稿。',
progress: 100,
error: null,
}} }}
activeOperation={null}
onBack={() => {}} onBack={() => {}}
onRefresh={() => {}}
onSubmitMessage={() => {}} onSubmitMessage={() => {}}
onExecuteAction={onExecuteAction} onExecuteAction={onExecuteAction}
/>, />,
); );
await waitFor(() => { await user.click(screen.getByRole('button', { name: '生成游戏设定草稿' }));
expect(screen.getAllByText('动作已就绪').length).toBeGreaterThan(0);
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'draft_foundation',
}); });
}); });

View File

@@ -3,73 +3,59 @@ import { expect, test } from 'vitest';
import { CustomWorldAgentWorkspace } from './CustomWorldAgentWorkspace'; import { CustomWorldAgentWorkspace } from './CustomWorldAgentWorkspace';
test('custom world agent workspace renders draft workspace instead of chat after draft cards appear', () => { test('custom world agent workspace renders minimum loop chat layout', () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<CustomWorldAgentWorkspace <CustomWorldAgentWorkspace
session={{ session={{
sessionId: 'custom-world-agent-session-1', sessionId: 'custom-world-agent-session-1',
stage: 'object_refining', currentTurn: 3,
focusCardId: 'world-foundation', anchorContent: {
worldPromise: {
hook: '一个被潮雾改写航线秩序的群岛世界。',
differentiator: '所有人都要为每一次借路付出代价。',
desiredExperience: '压迫、悬疑、带一点海上传奇感',
},
playerFantasy: null,
themeBoundary: null,
playerEntryPoint: null,
coreConflict: null,
keyRelationships: [],
hiddenLines: null,
iconicElements: null,
},
progressPercent: 42,
lastAssistantReply: '我先把世界底色收住了,接下来想确认玩家会怎么被卷进来。',
stage: 'collecting_intent',
focusCardId: null,
creatorIntent: {}, creatorIntent: {},
creatorIntentReadiness: { creatorIntentReadiness: {
isReady: true, isReady: false,
completedKeys: [ completedKeys: ['world_hook'],
'world_hook', missingKeys: [
'player_premise', 'player_premise',
'theme_and_tone', 'theme_and_tone',
'core_conflict', 'core_conflict',
'relationship_seed', 'relationship_seed',
'iconic_element', 'iconic_element',
], ],
missingKeys: [],
}, },
anchorPack: {}, anchorPack: {},
lockState: {}, lockState: {},
draftProfile: { draftProfile: null,
name: '潮雾列岛',
},
messages: [ messages: [
{ {
id: 'message-1', id: 'message-1',
role: 'assistant', role: 'assistant',
kind: 'summary', kind: 'chat',
text: '欢迎。当前底稿已经可以继续精修。', text: '先告诉我你想做一个怎样的世界。',
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
relatedOperationId: null, relatedOperationId: null,
}, },
], ],
draftCards: [ draftCards: [],
{
id: 'world-foundation',
kind: 'world',
title: '潮雾列岛',
subtitle: '旧灯塔与航道争夺',
summary: '世界总卡已经生成。',
status: 'warning',
linkedIds: ['thread-1', 'character-1'],
warningCount: 1,
},
{
id: 'character-1',
kind: 'character',
title: '沈砺',
subtitle: '守灯会旧友',
summary: '他最了解旧航道,也最可能先背叛。',
status: 'suggested',
linkedIds: ['thread-1'],
warningCount: 0,
},
],
pendingClarifications: [], pendingClarifications: [],
suggestedActions: [ suggestedActions: [],
{ recommendedReplies: [],
id: 'request-summary',
type: 'request_summary',
label: '总结当前世界底稿',
targetId: null,
},
],
recommendedReplies: ['现在开始生成草稿', '先总结一下当前设定'],
qualityFindings: [], qualityFindings: [],
assetCoverage: { assetCoverage: {
roleAssets: [], roleAssets: [],
@@ -81,17 +67,20 @@ test('custom world agent workspace renders draft workspace instead of chat after
}} }}
activeOperation={null} activeOperation={null}
onBack={() => {}} onBack={() => {}}
onRefresh={() => {}}
onSubmitMessage={() => {}} onSubmitMessage={() => {}}
onExecuteAction={() => {}} onExecuteAction={() => {}}
/>, />,
); );
expect(html).toContain('卡片详情'); expect(html).toContain('创作进度');
expect(html).toContain('快捷动作'); expect(html).toContain('42%');
expect(html).toContain('草稿抽屉'); expect(html).toContain('输入消息');
expect(html).not.toContain('首轮草稿会先确认这 6 项信息'); expect(html).toContain('总结当前设定');
expect(html).not.toContain('现在开始生成草稿'); expect(html).toContain('补全剩余设定');
expect(html).not.toContain('欢迎。当前底稿已经可以继续精修。'); expect(html).not.toContain('Agent');
expect(html).not.toContain('输入消息'); expect(html).not.toContain('刷新');
expect(html).not.toContain('当前轮次');
expect(html).not.toContain('当前状态');
expect(html).not.toContain('草稿抽屉');
expect(html).not.toContain('快捷动作');
}); });

View File

@@ -1,60 +1,25 @@
import { useEffect, useState } from 'react';
import type { import type {
CustomWorldAgentActionRequest, CustomWorldAgentActionRequest,
CustomWorldAgentOperationRecord, CustomWorldAgentOperationRecord,
CustomWorldAgentSessionSnapshot, CustomWorldAgentSessionSnapshot,
CustomWorldDraftCardDetail,
SendCustomWorldAgentMessageRequest, SendCustomWorldAgentMessageRequest,
} from '../../../packages/shared/src/contracts/customWorldAgent'; } from '../../../packages/shared/src/contracts/customWorldAgent';
import { getCustomWorldAgentCardDetail } from '../../services/aiService';
import { CustomWorldRoleAssetStudioModal } from '../CustomWorldRoleAssetStudioModal';
import { CustomWorldAgentComposer } from './CustomWorldAgentComposer'; import { CustomWorldAgentComposer } from './CustomWorldAgentComposer';
import { CustomWorldAgentDraftDetailPanel } from './CustomWorldAgentDraftDetailPanel';
import { CustomWorldAgentDraftDrawer } from './CustomWorldAgentDraftDrawer';
import { CustomWorldAgentHeader } from './CustomWorldAgentHeader'; import { CustomWorldAgentHeader } from './CustomWorldAgentHeader';
import { CustomWorldAgentOperationBanner } from './CustomWorldAgentOperationBanner'; import { CustomWorldAgentOperationBanner } from './CustomWorldAgentOperationBanner';
import { CustomWorldAgentQuickActions } from './CustomWorldAgentQuickActions';
import { CustomWorldAgentThread } from './CustomWorldAgentThread'; import { CustomWorldAgentThread } from './CustomWorldAgentThread';
import { CustomWorldDraftCardDetailModal } from './CustomWorldDraftCardDetailModal'; import { EightAnchorProgressBar } from './EightAnchorProgressBar';
import { CustomWorldGenerateEntityModal } from './CustomWorldGenerateEntityModal';
type WorkspaceRoleAssetTarget = {
id: string;
name: string;
title: string;
role: string;
description?: string;
backstory?: string;
personality?: string;
motivation?: string;
combatStyle?: string;
tags?: string[];
imageSrc?: string;
generatedVisualAssetId?: string;
generatedAnimationSetId?: string;
animationMap?: Record<string, unknown>;
};
type CustomWorldAgentWorkspaceProps = { type CustomWorldAgentWorkspaceProps = {
session: CustomWorldAgentSessionSnapshot | null; session: CustomWorldAgentSessionSnapshot | null;
activeOperation: CustomWorldAgentOperationRecord | null; activeOperation: CustomWorldAgentOperationRecord | null;
streamingReplyText?: string;
isStreamingReply?: boolean;
onBack: () => void; onBack: () => void;
onRefresh: () => void;
onSubmitMessage: (payload: SendCustomWorldAgentMessageRequest) => void; onSubmitMessage: (payload: SendCustomWorldAgentMessageRequest) => void;
onExecuteAction: (payload: CustomWorldAgentActionRequest) => void; onExecuteAction: (payload: CustomWorldAgentActionRequest) => void;
}; };
const TOTAL_READINESS_STEPS = 6;
const READINESS_ITEMS = [
{ key: 'world_hook', label: '世界核心' },
{ key: 'player_premise', label: '玩家开局' },
{ key: 'theme_and_tone', label: '主题气质' },
{ key: 'core_conflict', label: '核心冲突' },
{ key: 'relationship_seed', label: '关键关系' },
{ key: 'iconic_element', label: '标志元素' },
] as const;
function createClientMessageId() { function createClientMessageId() {
if ( if (
typeof crypto !== 'undefined' && typeof crypto !== 'undefined' &&
@@ -66,284 +31,15 @@ function createClientMessageId() {
return `client-message-${Date.now()}`; return `client-message-${Date.now()}`;
} }
function resolveInitialCardId(session: CustomWorldAgentSessionSnapshot | null) {
if (!session || session.draftCards.length === 0) {
return null;
}
return (
session.focusCardId ||
session.draftCards.find((card) => card.kind === 'world')?.id ||
session.draftCards[0]?.id ||
null
);
}
function buildRecommendedReplies(session: CustomWorldAgentSessionSnapshot) {
return session.recommendedReplies;
}
function toRecord(value: unknown) {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function toRecordArray(value: unknown) {
return Array.isArray(value)
? value.filter(
(item): item is Record<string, unknown> =>
Boolean(item) && typeof item === 'object' && !Array.isArray(item),
)
: [];
}
function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function resolveRoleAssetTarget(
session: CustomWorldAgentSessionSnapshot | null,
roleId: string | null,
) {
if (!session || !roleId) {
return null;
}
const draftProfile = toRecord(session.draftProfile);
if (!draftProfile) {
return null;
}
const playableRole = toRecordArray(draftProfile.playableNpcs).find(
(item) => toText(item.id) === roleId,
);
const storyRole = toRecordArray(draftProfile.storyNpcs).find(
(item) => toText(item.id) === roleId,
);
const role = playableRole ?? storyRole;
if (!role) {
return null;
}
const assetSummary =
session.assetCoverage.roleAssets.find((entry) => entry.roleId === roleId) ??
null;
return {
role: {
id: roleId,
name: toText(role.name) || '未命名角色',
title: toText(role.title) || toText(role.role) || '关键角色',
role: toText(role.role) || toText(role.title) || '关键角色',
description: toText(role.summary),
backstory: toText(role.hiddenHook) || undefined,
personality: toText(role.publicMask) || undefined,
motivation: toText(role.relationToPlayer) || undefined,
combatStyle: toText(role.role) || undefined,
tags: Array.isArray(role.threadIds)
? role.threadIds
.map((item) => toText(item))
.filter(Boolean)
.slice(0, 4)
: [],
imageSrc: toText(role.imageSrc) || undefined,
generatedVisualAssetId: toText(role.generatedVisualAssetId) || undefined,
generatedAnimationSetId:
toText(role.generatedAnimationSetId) || undefined,
animationMap: toRecord(role.animationMap) ?? undefined,
} satisfies WorkspaceRoleAssetTarget,
roleKind: playableRole ? ('playable' as const) : ('story' as const),
assetSummary,
};
}
function CustomWorldAgentReadinessBar(props: {
completedKeys: string[];
isReady: boolean;
busy: boolean;
onStartDraft: () => void;
}) {
const { completedKeys, isReady, busy, onStartDraft } = props;
const completedKeySet = new Set(completedKeys);
const completedCount = READINESS_ITEMS.filter((item) =>
completedKeySet.has(item.key),
).length;
return (
<div className="rounded-[1.35rem] border border-white/10 bg-[#111318]/95 px-4 py-3">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<div className="text-xs font-semibold tracking-[0.12em] text-zinc-300">
稿 6
</div>
</div>
<div className="text-xs text-zinc-400">
{Math.min(completedCount, TOTAL_READINESS_STEPS)}/
{TOTAL_READINESS_STEPS}
</div>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="grid min-w-0 flex-1 grid-cols-3 gap-2 sm:grid-cols-6">
{READINESS_ITEMS.map((item) => (
<div
key={item.key}
className={`rounded-2xl border px-2.5 py-2 text-center text-[11px] ${
completedKeySet.has(item.key)
? 'border-emerald-300/25 bg-emerald-500/10 text-emerald-100'
: 'border-white/10 bg-black/18 text-zinc-500'
}`}
>
{item.label}
</div>
))}
</div>
<div className="flex items-center justify-between gap-3 sm:justify-end">
{isReady ? (
<button
type="button"
onClick={onStartDraft}
disabled={busy}
className="rounded-full border border-emerald-300/20 bg-emerald-500/10 px-3 py-1.5 text-xs font-medium text-emerald-100 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
>
{busy ? '生成中' : '开始生成草稿'}
</button>
) : null}
</div>
</div>
</div>
);
}
export function CustomWorldAgentWorkspace({ export function CustomWorldAgentWorkspace({
session, session,
activeOperation, activeOperation,
streamingReplyText = '',
isStreamingReply = false,
onBack, onBack,
onSubmitMessage, onSubmitMessage,
onExecuteAction, onExecuteAction,
}: CustomWorldAgentWorkspaceProps) { }: CustomWorldAgentWorkspaceProps) {
const [selectedCardId, setSelectedCardId] = useState<string | null>(() =>
resolveInitialCardId(session),
);
const [detail, setDetail] = useState<CustomWorldDraftCardDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [detailModalOpen, setDetailModalOpen] = useState(false);
const [editMode, setEditMode] = useState(false);
const [autoCompleteConfirmOpen, setAutoCompleteConfirmOpen] = useState(false);
const [generateEntityMode, setGenerateEntityMode] = useState<
'character' | 'landmark' | null
>(null);
const [requestedRoleAssetTargetId, setRequestedRoleAssetTargetId] = useState<
string | null
>(null);
const [activeRoleAssetTargetId, setActiveRoleAssetTargetId] = useState<
string | null
>(null);
const [showRoleAssetStudio, setShowRoleAssetStudio] = useState(false);
const [closeRoleAssetStudioAfterSync, setCloseRoleAssetStudioAfterSync] =
useState(false);
useEffect(() => {
if (!session) {
setSelectedCardId(null);
return;
}
const availableCardIds = new Set(session.draftCards.map((card) => card.id));
if (session.focusCardId && availableCardIds.has(session.focusCardId)) {
setSelectedCardId(session.focusCardId);
return;
}
setSelectedCardId((current) => {
if (current && availableCardIds.has(current)) {
return current;
}
return resolveInitialCardId(session);
});
}, [session]);
useEffect(() => {
setEditMode(false);
}, [detail?.id]);
useEffect(() => {
if (!requestedRoleAssetTargetId || !activeOperation) {
return;
}
if (activeOperation.type !== 'generate_role_assets') {
return;
}
if (activeOperation.status === 'completed') {
setActiveRoleAssetTargetId(requestedRoleAssetTargetId);
setShowRoleAssetStudio(true);
setRequestedRoleAssetTargetId(null);
setDetailModalOpen(false);
return;
}
if (activeOperation.status === 'failed') {
setRequestedRoleAssetTargetId(null);
}
}, [activeOperation, requestedRoleAssetTargetId]);
useEffect(() => {
if (!activeOperation || activeOperation.type !== 'sync_role_assets') {
return;
}
if (activeOperation.status === 'completed') {
if (closeRoleAssetStudioAfterSync) {
setShowRoleAssetStudio(false);
}
setCloseRoleAssetStudioAfterSync(false);
return;
}
if (activeOperation.status === 'failed') {
setCloseRoleAssetStudioAfterSync(false);
}
}, [activeOperation, closeRoleAssetStudioAfterSync]);
useEffect(() => {
if (!session?.sessionId || !selectedCardId) {
setDetail(null);
setDetailLoading(false);
return;
}
let cancelled = false;
setDetailLoading(true);
void getCustomWorldAgentCardDetail(session.sessionId, selectedCardId)
.then((nextDetail) => {
if (cancelled) {
return;
}
setDetail(nextDetail);
})
.catch(() => {
if (cancelled) {
return;
}
setDetail(null);
})
.finally(() => {
if (!cancelled) {
setDetailLoading(false);
}
});
return () => {
cancelled = true;
};
}, [selectedCardId, session?.sessionId, session?.updatedAt]);
if (!session) { if (!session) {
return ( return (
<div className="mx-auto flex h-full w-full max-w-4xl items-center justify-center rounded-[1.75rem] border border-white/10 bg-black/20 px-6 py-8 text-center text-sm text-zinc-400"> <div className="mx-auto flex h-full w-full max-w-4xl items-center justify-center rounded-[1.75rem] border border-white/10 bg-black/20 px-6 py-8 text-center text-sm text-zinc-400">
@@ -354,339 +50,58 @@ export function CustomWorldAgentWorkspace({
const isBusy = const isBusy =
activeOperation?.status === 'queued' || activeOperation?.status === 'queued' ||
activeOperation?.status === 'running'; activeOperation?.status === 'running' ||
const canStartDraft = isStreamingReply;
session.creatorIntentReadiness.isReady &&
session.stage === 'foundation_review';
const showAutoCompleteButton =
!session.creatorIntentReadiness.isReady &&
session.creatorIntentReadiness.completedKeys.includes('world_hook');
const showDraftWorkspace =
session.stage !== 'foundation_review' && session.draftCards.length > 0;
const showAgentConversation = !showDraftWorkspace;
const selectedCard =
session.draftCards.find((card) => card.id === selectedCardId) ?? null;
const recommendedReplies = buildRecommendedReplies(session);
const selectedRoleAssetContext = resolveRoleAssetTarget(
session,
activeRoleAssetTargetId,
);
const openRoleAssetStudio = (roleId: string | null) => { const submitMessage = (text: string, quickFillRequested = false) => {
if (!roleId) {
return;
}
setRequestedRoleAssetTargetId(roleId);
onExecuteAction({
action: 'generate_role_assets',
roleIds: [roleId],
});
};
const submitTextMessage = (text: string) => {
onSubmitMessage({ onSubmitMessage({
clientMessageId: createClientMessageId(), clientMessageId: createClientMessageId(),
text, text,
focusCardId: selectedCardId, quickFillRequested,
selectedCardIds: selectedCardId ? [selectedCardId] : [], focusCardId: null,
selectedCardIds: [],
}); });
}; };
const submitSummaryRequest = () => {
submitTextMessage(
showDraftWorkspace
? '帮我总结当前世界底稿,并指出下一步最值得精修的卡片。'
: '帮我总结当前设定,并指出下一步最值得补的世界锚点。',
);
};
const submitAutoCompleteRequest = () => {
submitTextMessage(
session.creatorIntentReadiness.isReady
? '基于当前设定,帮我自动补强还可以更清晰的细节。'
: '请根据当前信息自动补全还缺的设定,并给我一版默认方案。',
);
setAutoCompleteConfirmOpen(false);
};
const handleRecommendedReply = (reply: string) => {
if (canStartDraft && reply.includes('生成草稿')) {
onExecuteAction({
action: 'draft_foundation',
});
return;
}
submitTextMessage(reply);
};
const openGenerateModal = (mode: 'character' | 'landmark') => {
setGenerateEntityMode(mode);
};
return ( return (
<div className="mx-auto flex h-full min-h-0 w-full max-w-[1500px] flex-col gap-3"> <div className="mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col gap-3 overflow-hidden px-1 sm:px-0">
{!showDraftWorkspace ? <CustomWorldAgentHeader onBack={onBack} /> : null} <CustomWorldAgentHeader onBack={onBack} />
{!showDraftWorkspace ? (
<CustomWorldAgentReadinessBar
completedKeys={session.creatorIntentReadiness.completedKeys}
isReady={canStartDraft}
busy={isBusy}
onStartDraft={() => {
onExecuteAction({
action: 'draft_foundation',
});
}}
/>
) : null}
<CustomWorldAgentOperationBanner operation={activeOperation} />
{showDraftWorkspace ? ( <EightAnchorProgressBar
<div className="grid min-h-0 flex-1 gap-3 xl:grid-cols-[20rem_minmax(0,1fr)]"> currentTurn={session.currentTurn}
<div className="flex min-h-0 flex-col gap-3 xl:overflow-hidden"> progressPercent={session.progressPercent}
<CustomWorldAgentQuickActions
suggestedActions={session.suggestedActions}
disabled={isBusy}
canDraftFoundation={canStartDraft}
showEntityActions
showSummaryAction={false}
showRoleAssetAction={selectedCard?.kind === 'character'}
onRequestSummary={submitSummaryRequest}
onDraftFoundation={() => {
onExecuteAction({
action: 'draft_foundation',
});
}}
onGenerateCharacter={() => {
openGenerateModal('character');
}}
onGenerateLandmark={() => {
openGenerateModal('landmark');
}}
onGenerateRoleAssets={() => {
openRoleAssetStudio(selectedCardId);
}}
onFocusSuggestedAction={(action) => {
if (action?.targetId) {
setSelectedCardId(action.targetId);
return;
}
if (session.draftCards[0]) {
setSelectedCardId(session.draftCards[0].id);
}
}}
/>
<div className="xl:min-h-0 xl:overflow-y-auto">
<CustomWorldAgentDraftDrawer
draftCards={session.draftCards}
activeCardId={selectedCardId}
onSelectCard={(cardId) => {
setSelectedCardId(cardId);
}}
/>
</div>
</div>
<div className="min-h-0 xl:overflow-y-auto">
<CustomWorldAgentDraftDetailPanel
detail={detail}
loading={detailLoading}
busy={isBusy}
editMode={editMode}
onClose={() => {
setSelectedCardId(null);
setDetailModalOpen(false);
setEditMode(false);
}}
onStartEdit={() => {
setEditMode(true);
}}
onCancelEdit={() => {
setEditMode(false);
}}
onSave={(sections) => {
if (!detail) {
return;
}
setEditMode(false);
onExecuteAction({
action: 'update_draft_card',
cardId: detail.id,
sections,
});
}}
onGenerateCharacter={() => {
openGenerateModal('character');
}}
onGenerateLandmark={() => {
openGenerateModal('landmark');
}}
onOpenRoleAssetStudio={() => {
openRoleAssetStudio(detail?.id ?? selectedCardId);
}}
/>
</div>
</div>
) : (
<>
{showAgentConversation ? (
<>
<CustomWorldAgentThread
messages={session.messages}
recommendedReplies={recommendedReplies}
onRecommendedReply={handleRecommendedReply}
/>
<CustomWorldAgentComposer
disabled={isBusy}
onSubmit={onSubmitMessage}
onSummaryClick={submitSummaryRequest}
onAutoCompleteClick={() => setAutoCompleteConfirmOpen(true)}
showAutoComplete={showAutoCompleteButton}
/>
</>
) : null}
</>
)}
{autoCompleteConfirmOpen ? (
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm">
<div className="w-full max-w-md rounded-[1.5rem] border border-white/10 bg-[#111318] px-5 py-5 shadow-[0_20px_60px_rgba(0,0,0,0.45)]">
<div className="text-base font-semibold text-white">
</div>
<div className="mt-3 text-sm leading-7 text-zinc-300">
</div>
<div className="mt-4 flex justify-end gap-2">
<button
type="button"
onClick={() => setAutoCompleteConfirmOpen(false)}
className="rounded-full border border-white/10 bg-white/5 px-4 py-2 text-sm text-zinc-200 transition hover:text-white"
>
</button>
<button
type="button"
onClick={submitAutoCompleteRequest}
className="rounded-full border border-emerald-300/20 bg-emerald-500/10 px-4 py-2 text-sm text-emerald-100 transition hover:text-white"
>
</button>
</div>
</div>
</div>
) : null}
<CustomWorldDraftCardDetailModal
open={detailModalOpen}
detail={detail}
loading={detailLoading}
busy={isBusy}
editMode={editMode}
onClose={() => {
setDetailModalOpen(false);
setEditMode(false);
}}
onStartEdit={() => {
setEditMode(true);
}}
onCancelEdit={() => {
setEditMode(false);
}}
onSave={(sections) => {
if (!detail) {
return;
}
setEditMode(false);
setDetailModalOpen(false);
onExecuteAction({
action: 'update_draft_card',
cardId: detail.id,
sections,
});
}}
onGenerateCharacter={() => {
setDetailModalOpen(false);
openGenerateModal('character');
}}
onGenerateLandmark={() => {
setDetailModalOpen(false);
openGenerateModal('landmark');
}}
onOpenRoleAssetStudio={() => {
setDetailModalOpen(false);
openRoleAssetStudio(detail?.id ?? selectedCardId);
}}
/>
<CustomWorldGenerateEntityModal
open={generateEntityMode !== null}
mode={generateEntityMode ?? 'character'}
anchorCardTitle={selectedCard?.title ?? detail?.title ?? null}
disabled={isBusy} disabled={isBusy}
onClose={() => { onSummaryClick={() => {
setGenerateEntityMode(null); submitMessage('请总结一下当前已经成形的世界设定。');
}} }}
onSubmit={({ count, promptText }) => { onQuickFill={() => {
if (!generateEntityMode) { submitMessage('请补全剩余设定。', true);
return; }}
} onGenerateDraft={() => {
onExecuteAction({ onExecuteAction({
action: action: 'draft_foundation',
generateEntityMode === 'character'
? 'generate_characters'
: 'generate_landmarks',
count,
promptText: promptText || null,
anchorCardIds: selectedCardId ? [selectedCardId] : [],
}); });
setGenerateEntityMode(null);
}} }}
/> />
{showRoleAssetStudio && selectedRoleAssetContext ? ( {activeOperation?.type !== 'process_message' ? (
<CustomWorldRoleAssetStudioModal <CustomWorldAgentOperationBanner operation={activeOperation} />
role={selectedRoleAssetContext.role}
roleKind={selectedRoleAssetContext.roleKind}
priorityTier={
selectedRoleAssetContext.assetSummary?.priorityTier ??
(selectedRoleAssetContext.roleKind === 'playable'
? 'hero'
: 'featured')
}
visualPointCost={
selectedRoleAssetContext.assetSummary?.status === 'missing'
? (selectedRoleAssetContext.assetSummary?.nextPointCost ?? 20)
: 20
}
animationPointCost={
selectedRoleAssetContext.assetSummary?.status === 'missing'
? 60
: (selectedRoleAssetContext.assetSummary?.nextPointCost ?? 60)
}
syncBusy={
activeOperation?.type === 'sync_role_assets' &&
(activeOperation.status === 'queued' ||
activeOperation.status === 'running')
}
onPublishSuccess={(payload, options) => {
setCloseRoleAssetStudioAfterSync(Boolean(options?.closeAfterSync));
onExecuteAction({
action: 'sync_role_assets',
...payload,
});
}}
onClose={() => {
setShowRoleAssetStudio(false);
setCloseRoleAssetStudioAfterSync(false);
}}
/>
) : null} ) : null}
<div className="min-h-0 flex-1 overflow-hidden">
<div className="h-full min-h-[18rem] lg:min-h-0">
<CustomWorldAgentThread
messages={session.messages}
streamingReplyText={streamingReplyText}
isStreamingReply={isStreamingReply}
/>
</div>
</div>
<CustomWorldAgentComposer
disabled={isBusy}
onSubmit={onSubmitMessage}
/>
</div> </div>
); );
} }

View File

@@ -0,0 +1,105 @@
type EightAnchorProgressBarProps = {
currentTurn: number;
progressPercent: number;
disabled: boolean;
onSummaryClick: () => void;
onQuickFill: () => void;
onGenerateDraft: () => void;
};
function clampProgress(progressPercent: number) {
if (!Number.isFinite(progressPercent)) {
return 0;
}
return Math.max(0, Math.min(100, Math.round(progressPercent)));
}
function resolveProgressHint(progressPercent: number) {
if (progressPercent >= 100) {
return '当前设定已经收束完成,可以进入草稿生成';
}
if (progressPercent >= 75) {
return '正在收束成一版可进入草稿的世界底子';
}
if (progressPercent >= 45) {
return '世界方向已经成形,继续补关键骨架';
}
if (progressPercent >= 15) {
return '先把玩家视角、开局和冲突线钉稳';
}
return '先抓住这个世界最关键的方向';
}
export function EightAnchorProgressBar({
currentTurn,
progressPercent,
disabled,
onSummaryClick,
onQuickFill,
onGenerateDraft,
}: EightAnchorProgressBarProps) {
const normalizedProgress = clampProgress(progressPercent);
const isCompleted = normalizedProgress >= 100;
const canQuickFill = currentTurn >= 2;
return (
<div className="rounded-[1.75rem] border border-white/10 bg-[#111318]/95 p-4">
<div className="flex flex-col gap-3">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-xs font-semibold tracking-[0.14em] text-zinc-300">
</div>
<div className="mt-1 text-sm text-zinc-400">
{resolveProgressHint(normalizedProgress)}
</div>
</div>
<div className="text-lg font-semibold text-white">
{normalizedProgress}%
</div>
</div>
<div className="h-3 overflow-hidden rounded-full bg-white/8">
<div
className="h-full rounded-full bg-[linear-gradient(90deg,#d8ffd9_0%,#6ee7b7_45%,#34d399_100%)] transition-[width] duration-500"
style={{ width: `${Math.max(6, normalizedProgress)}%` }}
/>
</div>
<div className="flex items-center justify-between gap-3">
<button
type="button"
onClick={onSummaryClick}
disabled={disabled}
className="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-zinc-200 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
>
</button>
{isCompleted ? (
<button
type="button"
onClick={onGenerateDraft}
disabled={disabled}
className="flex min-h-[3rem] items-center justify-center rounded-[1.1rem] border border-emerald-300/25 bg-emerald-500/12 px-4 py-3 text-sm font-semibold text-emerald-50 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
>
稿
</button>
) : canQuickFill ? (
<button
type="button"
onClick={onQuickFill}
disabled={disabled}
className="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-zinc-200 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
>
</button>
) : null}
</div>
</div>
</div>
);
}

View File

@@ -36,7 +36,7 @@ import {
resolvePlatformWorldLeadPortrait, resolvePlatformWorldLeadPortrait,
} from './platformWorldPresentation'; } from './platformWorldPresentation';
export type PlatformHomeTab = 'home' | 'create' | 'discover' | 'profile'; export type PlatformHomeTab = 'home' | 'create' | 'profile';
function SectionHeader({ title, detail }: { title: string; detail: string }) { function SectionHeader({ title, detail }: { title: string; detail: string }) {
return ( return (
@@ -443,8 +443,6 @@ export function PlatformHomeView({
const tabIcons = { const tabIcons = {
home: "/Icons/Admurin's Pixel Items/Admurin's Pixel Items/Miscellaneous/Singles/192_RustyTrinket_House.png", home: "/Icons/Admurin's Pixel Items/Admurin's Pixel Items/Miscellaneous/Singles/192_RustyTrinket_House.png",
create: '/Icons/01_Scroll.png', create: '/Icons/01_Scroll.png',
discover:
"/Icons/Admurin's Pixel Items/Admurin's Pixel Items/Miscellaneous/Singles/321_Compass.png",
profile: '/UI/Icon_Eq_Head.png', profile: '/UI/Icon_Eq_Head.png',
} as const; } as const;
const recentPlayItems = savedSnapshot const recentPlayItems = savedSnapshot
@@ -599,49 +597,6 @@ export function PlatformHomeView({
); );
} }
if (activeTab === 'discover') {
content = (
<div className="space-y-4 pb-2">
<section
className="pixel-nine-slice"
style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 18,
paddingY: 16,
})}
>
<div className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-[10px] tracking-[0.2em] text-zinc-100">
DISCOVER
</div>
<div className="mt-4 text-3xl font-black text-white"></div>
<div className="mt-2 max-w-[28rem] text-sm leading-6 text-zinc-300">
便
</div>
</section>
<section>
<SectionHeader title="最近上新" detail="先看广场里的新内容" />
{isLoadingPlatform ? (
<EmptyShelf text="正在读取推荐内容..." />
) : latestEntries.length > 0 ? (
<div className="flex gap-3 overflow-x-auto pb-1 scrollbar-hide">
{latestEntries.map((entry: CustomWorldGalleryCard) => (
<WorldCard
key={`${entry.ownerUserId}:${entry.profileId}:discover`}
entry={entry}
badge={formatPlatformWorldTime(entry.publishedAt)}
metaLabel={describePlatformThemeLabel(entry.themeMode)}
onClick={() => onOpenGalleryDetail(entry)}
/>
))}
</div>
) : (
<EmptyShelf text="发现频道暂时还没有可展示的内容。" />
)}
</section>
</div>
);
}
if (activeTab === 'profile') { if (activeTab === 'profile') {
content = ( content = (
<div className="space-y-4 pb-2"> <div className="space-y-4 pb-2">
@@ -918,7 +873,7 @@ export function PlatformHomeView({
))} ))}
</div> </div>
) : ( ) : (
<EmptyShelf text="你最近还没有浏览过作品详情,去首页或发现逛一逛吧。" /> <EmptyShelf text="你最近还没有浏览过作品详情,去首页逛一逛吧。" />
)} )}
</section> </section>
@@ -988,7 +943,7 @@ export function PlatformHomeView({
className="mt-4 border-t border-white/5 pt-3" className="mt-4 border-t border-white/5 pt-3"
style={{ paddingBottom: 'calc(env(safe-area-inset-bottom) + 0.2rem)' }} style={{ paddingBottom: 'calc(env(safe-area-inset-bottom) + 0.2rem)' }}
> >
<div className="grid h-14 grid-cols-4 gap-1 rounded-[1.2rem] bg-black/18 px-1 py-1"> <div className="grid h-14 grid-cols-3 gap-1 rounded-[1.2rem] bg-black/18 px-1 py-1">
<PlatformTabButton <PlatformTabButton
active={activeTab === 'home'} active={activeTab === 'home'}
label="首页" label="首页"
@@ -1001,12 +956,6 @@ export function PlatformHomeView({
iconSrc={tabIcons.create} iconSrc={tabIcons.create}
onClick={() => onTabChange('create')} onClick={() => onTabChange('create')}
/> />
<PlatformTabButton
active={activeTab === 'discover'}
label="发现"
iconSrc={tabIcons.discover}
onClick={() => onTabChange('discover')}
/>
<PlatformTabButton <PlatformTabButton
active={activeTab === 'profile'} active={activeTab === 'profile'}
label="我的" label="我的"

View File

@@ -48,6 +48,7 @@ export function PlatformWorldDetailView({
onStartGame, onStartGame,
onContinueEdit, onContinueEdit,
onPublish, onPublish,
onDelete,
onUnpublish, onUnpublish,
}: { }: {
entry: CustomWorldLibraryEntry<CustomWorldProfile>; entry: CustomWorldLibraryEntry<CustomWorldProfile>;
@@ -57,17 +58,21 @@ export function PlatformWorldDetailView({
onStartGame: () => void; onStartGame: () => void;
onContinueEdit?: (() => void) | null; onContinueEdit?: (() => void) | null;
onPublish?: (() => void) | null; onPublish?: (() => void) | null;
onDelete?: (() => void) | null;
onUnpublish?: (() => void) | null; onUnpublish?: (() => void) | null;
}) { }) {
const coverImage = resolvePlatformWorldCoverImage(entry); const coverImage = resolvePlatformWorldCoverImage(entry);
const leadPortrait = resolvePlatformWorldLeadPortrait(entry); const leadPortrait = resolvePlatformWorldLeadPortrait(entry);
const previewCharacters = buildCustomWorldPlayableCharacters(entry.profile).slice( const previewCharacters = buildCustomWorldPlayableCharacters(
0, entry.profile,
3, ).slice(0, 3);
);
const previewLandmarks = entry.profile.landmarks.slice(0, 3); const previewLandmarks = entry.profile.landmarks.slice(0, 3);
const tags = [ const tags = [
...new Set(buildPlatformWorldTags(entry).map((tag) => tag.trim()).filter(Boolean)), ...new Set(
buildPlatformWorldTags(entry)
.map((tag) => tag.trim())
.filter(Boolean),
),
].slice(0, 3); ].slice(0, 3);
return ( return (
@@ -89,7 +94,10 @@ export function PlatformWorldDetailView({
<div className="space-y-4 pb-2"> <div className="space-y-4 pb-2">
<div <div
className="pixel-nine-slice relative overflow-hidden" className="pixel-nine-slice relative overflow-hidden"
style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 18, paddingY: 16 })} style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 18,
paddingY: 16,
})}
> >
{coverImage ? ( {coverImage ? (
<img <img
@@ -150,7 +158,10 @@ export function PlatformWorldDetailView({
<div className="grid gap-4 lg:grid-cols-[1.2fr_0.8fr]"> <div className="grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
<div <div
className="pixel-nine-slice" className="pixel-nine-slice"
style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 16, paddingY: 14 })} style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 16,
paddingY: 14,
})}
> >
<div className="text-[10px] tracking-[0.22em] text-zinc-500"> <div className="text-[10px] tracking-[0.22em] text-zinc-500">
@@ -160,13 +171,17 @@ export function PlatformWorldDetailView({
<div className="text-[10px] tracking-[0.18em] text-zinc-500"> <div className="text-[10px] tracking-[0.18em] text-zinc-500">
</div> </div>
<div className="mt-2 text-lg font-bold">{entry.playableNpcCount}</div> <div className="mt-2 text-lg font-bold">
{entry.playableNpcCount}
</div>
</div> </div>
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-3"> <div className="rounded-xl border border-white/10 bg-black/20 px-3 py-3">
<div className="text-[10px] tracking-[0.18em] text-zinc-500"> <div className="text-[10px] tracking-[0.18em] text-zinc-500">
</div> </div>
<div className="mt-2 text-lg font-bold">{entry.landmarkCount}</div> <div className="mt-2 text-lg font-bold">
{entry.landmarkCount}
</div>
</div> </div>
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-3"> <div className="rounded-xl border border-white/10 bg-black/20 px-3 py-3">
<div className="text-[10px] tracking-[0.18em] text-zinc-500"> <div className="text-[10px] tracking-[0.18em] text-zinc-500">
@@ -231,7 +246,10 @@ export function PlatformWorldDetailView({
<div <div
className="pixel-nine-slice" className="pixel-nine-slice"
style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 16, paddingY: 14 })} style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 16,
paddingY: 14,
})}
> >
<div className="text-[10px] tracking-[0.22em] text-zinc-500"> <div className="text-[10px] tracking-[0.22em] text-zinc-500">
@@ -265,6 +283,14 @@ export function PlatformWorldDetailView({
disabled={isMutating} disabled={isMutating}
/> />
) : null} ) : null}
{onDelete ? (
<ActionButton
label="删除作品"
onClick={onDelete}
tone="danger"
disabled={isMutating}
/>
) : null}
</div> </div>
{error ? ( {error ? (
<div className="mt-4 rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100"> <div className="mt-4 rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">

View File

@@ -11,10 +11,12 @@ import {
executeCustomWorldAgentAction, executeCustomWorldAgentAction,
getCustomWorldAgentOperation, getCustomWorldAgentOperation,
getCustomWorldAgentSession, getCustomWorldAgentSession,
streamCustomWorldAgentMessage,
} from '../../services/aiService'; } from '../../services/aiService';
import type { AuthUser } from '../../services/authService'; import type { AuthUser } from '../../services/authService';
import { import {
clearProfileBrowseHistory, clearProfileBrowseHistory,
deleteCustomWorldProfile,
getProfileDashboard, getProfileDashboard,
listCustomWorldGallery, listCustomWorldGallery,
listCustomWorldLibrary, listCustomWorldLibrary,
@@ -35,11 +37,12 @@ vi.mock('../../services/aiService', () => ({
generateCustomWorldProfile: vi.fn(), generateCustomWorldProfile: vi.fn(),
getCustomWorldAgentOperation: vi.fn(), getCustomWorldAgentOperation: vi.fn(),
getCustomWorldAgentSession: vi.fn(), getCustomWorldAgentSession: vi.fn(),
sendCustomWorldAgentMessage: vi.fn(), streamCustomWorldAgentMessage: vi.fn(),
})); }));
vi.mock('../../services/storageService', () => ({ vi.mock('../../services/storageService', () => ({
clearProfileBrowseHistory: vi.fn(), clearProfileBrowseHistory: vi.fn(),
deleteCustomWorldProfile: vi.fn(),
getCustomWorldGalleryDetail: vi.fn(), getCustomWorldGalleryDetail: vi.fn(),
getProfileDashboard: vi.fn(), getProfileDashboard: vi.fn(),
listCustomWorldGallery: vi.fn(), listCustomWorldGallery: vi.fn(),
@@ -78,6 +81,53 @@ vi.mock('../custom-world-agent/CustomWorldAgentWorkspace', () => ({
const mockSession: CustomWorldAgentSessionSnapshot = { const mockSession: CustomWorldAgentSessionSnapshot = {
sessionId: 'custom-world-agent-session-1', sessionId: 'custom-world-agent-session-1',
currentTurn: 0,
anchorContent: {
worldPromise: {
hook: '被海雾吞没的旧航路群岛。',
differentiator: '灯塔与禁航令共同决定谁能穿过死潮。',
desiredExperience: '压抑、潮湿、悬疑',
},
playerFantasy: {
playerRole: '玩家是被迫返乡的守灯人继承者。',
corePursuit: '查清沉船夜与假航灯的关系。',
fearOfLoss: '失去家族最后一条可信航线。',
},
themeBoundary: {
toneKeywords: ['压抑', '悬疑'],
aestheticDirectives: ['潮湿群岛', '冷雾港口'],
forbiddenDirectives: ['轻喜冒险'],
},
playerEntryPoint: {
openingIdentity: '返乡守灯人继承者',
openingProblem: '回港首夜撞见禁航区假航灯重亮',
entryMotivation: '阻止更多船只误入死潮',
},
coreConflict: {
surfaceConflicts: ['守灯会与航运公会争夺航路解释权'],
hiddenCrisis: '有人在借假航灯持续清洗旧案证据',
firstTouchedConflict: '玩家返乡当夜就被卷进封航冲突',
},
keyRelationships: [
{
pairs: '玩家 vs 沈砺',
relationshipType: '旧友互疑',
secretOrCost: '他知道沉船夜的另一半真相',
},
],
hiddenLines: {
hiddenTruths: ['沉船夜与假航灯骗局属于同一操盘链条'],
misdirectionHints: ['表面像海雾自然失控'],
revealPacing: '先见异常,再见旧案,再见操盘者',
},
iconicElements: {
iconicMotifs: ['假航灯', '沉钟回响'],
institutionsOrArtifacts: ['旧灯塔', '禁航碑'],
hardRules: ['错误航灯会把船引进必死水域'],
},
},
progressPercent: 0,
lastAssistantReply: '先告诉我你想做一个怎样的 RPG 世界。',
stage: 'clarifying', stage: 'clarifying',
focusCardId: null, focusCardId: null,
creatorIntent: {}, creatorIntent: {},
@@ -180,6 +230,7 @@ beforeEach(() => {
vi.mocked(listProfileBrowseHistory).mockResolvedValue([]); vi.mocked(listProfileBrowseHistory).mockResolvedValue([]);
vi.mocked(upsertProfileBrowseHistory).mockResolvedValue([]); vi.mocked(upsertProfileBrowseHistory).mockResolvedValue([]);
vi.mocked(clearProfileBrowseHistory).mockResolvedValue([]); vi.mocked(clearProfileBrowseHistory).mockResolvedValue([]);
vi.mocked(deleteCustomWorldProfile).mockResolvedValue([]);
vi.mocked(upsertCustomWorldProfile).mockResolvedValue({ vi.mocked(upsertCustomWorldProfile).mockResolvedValue({
entry: { entry: {
ownerUserId: 'user-1', ownerUserId: 'user-1',
@@ -226,6 +277,7 @@ beforeEach(() => {
error: null, error: null,
}); });
vi.mocked(getCustomWorldAgentSession).mockResolvedValue(mockSession); vi.mocked(getCustomWorldAgentSession).mockResolvedValue(mockSession);
vi.mocked(streamCustomWorldAgentMessage).mockResolvedValue(mockSession);
}); });
test('create tab opens game type modal, keeps AIRP and visual novel locked, and enters agent workspace for RPG', async () => { test('create tab opens game type modal, keeps AIRP and visual novel locked, and enters agent workspace for RPG', async () => {
@@ -284,6 +336,10 @@ test('starting draft generation leaves the agent workspace and shows the generat
expect(await screen.findByText('世界草稿生成进度')).toBeTruthy(); expect(await screen.findByText('世界草稿生成进度')).toBeTruthy();
expect(screen.queryByText(/Agent/u)).toBeNull(); expect(screen.queryByText(/Agent/u)).toBeNull();
expect(screen.getAllByText('生成世界底稿').length).toBeGreaterThan(0); expect(screen.getAllByText('生成世界底稿').length).toBeGreaterThan(0);
expect(screen.getByText('当前锚点信息')).toBeTruthy();
expect(screen.getByText('世界承诺')).toBeTruthy();
expect(screen.getByText(/穿/u)).toBeTruthy();
expect(screen.queryByText('先告诉我你想做一个怎样的 RPG 世界。')).toBeNull();
}); });
test('existing draft sessions enter the legacy result layout directly', async () => { test('existing draft sessions enter the legacy result layout directly', async () => {
@@ -448,6 +504,60 @@ test('profile tab loads server browse history and can clear it after confirmatio
}); });
expect( expect(
screen.getByText('你最近还没有浏览过作品详情,去首页或发现逛一逛吧。'), screen.getByText('你最近还没有浏览过作品详情,去首页逛一逛吧。'),
).toBeTruthy();
});
test('owned world detail can delete a work and return to the create tab list', async () => {
const user = userEvent.setup();
vi.spyOn(window, 'confirm').mockReturnValue(true);
vi.mocked(listCustomWorldLibrary).mockResolvedValue([
{
ownerUserId: 'user-1',
profileId: 'world-delete-1',
profile: {
id: 'world-delete-1',
name: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '用于测试删除流程的作品。',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清旧案。',
majorFactions: ['守灯会'],
coreConflicts: ['雾潮正在逼近港口'],
playableNpcs: [],
storyNpcs: [],
landmarks: [],
} as never,
visibility: 'draft',
publishedAt: null,
updatedAt: '2026-04-16T12:00:00.000Z',
authorDisplayName: '测试玩家',
worldName: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summaryText: '用于测试删除流程的作品。',
coverImageSrc: null,
themeMode: 'tide',
playableNpcCount: 0,
landmarkCount: 0,
},
]);
vi.mocked(deleteCustomWorldProfile).mockResolvedValue([]);
render(<TestWrapper />);
await user.click(screen.getByRole('button', { name: '创作' }));
await user.click(await screen.findByText('潮雾列岛'));
await user.click(await screen.findByRole('button', { name: '删除作品' }));
await waitFor(() => {
expect(deleteCustomWorldProfile).toHaveBeenCalledWith('world-delete-1');
});
await waitFor(() => {
expect(screen.queryByRole('button', { name: '删除作品' })).toBeNull();
});
expect(
screen.getByText('你还没有保存任何自定义世界,先创建一个草稿开始吧。'),
).toBeTruthy(); ).toBeTruthy();
}); });

View File

@@ -10,6 +10,7 @@ import {
} from 'react'; } from 'react';
import type { import type {
CustomWorldAgentMessage,
CustomWorldAgentActionRequest, CustomWorldAgentActionRequest,
CustomWorldAgentOperationRecord, CustomWorldAgentOperationRecord,
CustomWorldAgentSessionSnapshot, CustomWorldAgentSessionSnapshot,
@@ -27,10 +28,11 @@ import {
executeCustomWorldAgentAction, executeCustomWorldAgentAction,
getCustomWorldAgentOperation, getCustomWorldAgentOperation,
getCustomWorldAgentSession, getCustomWorldAgentSession,
sendCustomWorldAgentMessage, streamCustomWorldAgentMessage,
} from '../../services/aiService'; } from '../../services/aiService';
import { buildCustomWorldProfileFromAgentDraft } from '../../services/customWorldAgentDraftResult'; import { buildCustomWorldProfileFromAgentDraft } from '../../services/customWorldAgentDraftResult';
import { import {
buildAgentDraftFoundationAnchorEntries,
buildAgentDraftFoundationGenerationProgress, buildAgentDraftFoundationGenerationProgress,
buildAgentDraftFoundationSettingText, buildAgentDraftFoundationSettingText,
isDraftFoundationOperation, isDraftFoundationOperation,
@@ -55,6 +57,7 @@ import {
} from '../../services/platformBrowseHistory'; } from '../../services/platformBrowseHistory';
import { import {
clearProfileBrowseHistory, clearProfileBrowseHistory,
deleteCustomWorldProfile,
getCustomWorldGalleryDetail, getCustomWorldGalleryDetail,
getProfileDashboard, getProfileDashboard,
listCustomWorldGallery, listCustomWorldGallery,
@@ -66,10 +69,7 @@ import {
upsertCustomWorldProfile, upsertCustomWorldProfile,
upsertProfileBrowseHistory, upsertProfileBrowseHistory,
} from '../../services/storageService'; } from '../../services/storageService';
import { import { type CustomWorldProfile, type GameState } from '../../types';
type CustomWorldProfile,
type GameState,
} from '../../types';
import { useAuthUi } from '../auth/AuthUiContext'; import { useAuthUi } from '../auth/AuthUiContext';
import { PlatformCreationTypeModal } from './PlatformCreationTypeModal'; import { PlatformCreationTypeModal } from './PlatformCreationTypeModal';
import { type PlatformHomeTab, PlatformHomeView } from './PlatformHomeView'; import { type PlatformHomeTab, PlatformHomeView } from './PlatformHomeView';
@@ -141,6 +141,16 @@ function createFailedAgentOperation(params: {
}; };
} }
function buildOptimisticAgentMessage(
payload: Pick<CustomWorldAgentMessage, 'id' | 'role' | 'kind' | 'text'>,
): CustomWorldAgentMessage {
return {
...payload,
createdAt: new Date().toISOString(),
relatedOperationId: null,
};
}
function buildAgentSeedTextFromProfile(profile: CustomWorldProfile) { function buildAgentSeedTextFromProfile(profile: CustomWorldProfile) {
return ( return (
buildCustomWorldCreatorIntentGenerationText(profile.creatorIntent).trim() || buildCustomWorldCreatorIntentGenerationText(profile.creatorIntent).trim() ||
@@ -215,6 +225,8 @@ export function PreGameSelectionFlow({
useState<CustomWorldAgentSessionSnapshot | null>(null); useState<CustomWorldAgentSessionSnapshot | null>(null);
const [agentOperation, setAgentOperation] = const [agentOperation, setAgentOperation] =
useState<CustomWorldAgentOperationRecord | null>(null); useState<CustomWorldAgentOperationRecord | null>(null);
const [streamingAgentReplyText, setStreamingAgentReplyText] = useState('');
const [isStreamingAgentReply, setIsStreamingAgentReply] = useState(false);
const [isLoadingAgentSession, setIsLoadingAgentSession] = useState(false); const [isLoadingAgentSession, setIsLoadingAgentSession] = useState(false);
const [customWorldError, setCustomWorldError] = useState<string | null>(null); const [customWorldError, setCustomWorldError] = useState<string | null>(null);
const [platformError, setPlatformError] = useState<string | null>(null); const [platformError, setPlatformError] = useState<string | null>(null);
@@ -457,6 +469,8 @@ export function PreGameSelectionFlow({
if (!activeAgentSessionId) { if (!activeAgentSessionId) {
setAgentSession(null); setAgentSession(null);
setIsLoadingAgentSession(false); setIsLoadingAgentSession(false);
setStreamingAgentReplyText('');
setIsStreamingAgentReply(false);
return; return;
} }
@@ -479,6 +493,8 @@ export function PreGameSelectionFlow({
); );
setAgentSession(null); setAgentSession(null);
setAgentOperation(null); setAgentOperation(null);
setStreamingAgentReplyText('');
setIsStreamingAgentReply(false);
persistAgentUiState(null, null); persistAgentUiState(null, null);
setPlatformTab('create'); setPlatformTab('create');
setSelectionStage('platform'); setSelectionStage('platform');
@@ -636,6 +652,10 @@ export function PreGameSelectionFlow({
() => buildAgentDraftFoundationSettingText(agentSession), () => buildAgentDraftFoundationSettingText(agentSession),
[agentSession], [agentSession],
); );
const agentDraftAnchorPreviewEntries = useMemo(
() => buildAgentDraftFoundationAnchorEntries(agentSession),
[agentSession],
);
const agentDraftResultProfile = useMemo( const agentDraftResultProfile = useMemo(
() => buildCustomWorldProfileFromAgentDraft(agentSession), () => buildCustomWorldProfileFromAgentDraft(agentSession),
[agentSession], [agentSession],
@@ -794,23 +814,63 @@ export function PreGameSelectionFlow({
return; return;
} }
const optimisticUserMessage = buildOptimisticAgentMessage({
id: payload.clientMessageId,
role: 'user',
kind: 'chat',
text: payload.text.trim(),
});
setAgentOperation(null);
persistAgentUiState(activeAgentSessionId, null);
setStreamingAgentReplyText('');
setIsStreamingAgentReply(true);
setAgentSession((current) =>
current
? {
...current,
messages: [...current.messages, optimisticUserMessage],
updatedAt: optimisticUserMessage.createdAt,
}
: current,
);
try { try {
const { operation } = await sendCustomWorldAgentMessage( const nextSession = await streamCustomWorldAgentMessage(
activeAgentSessionId, activeAgentSessionId,
payload, payload,
{
onUpdate: (text) => {
setStreamingAgentReplyText(text);
},
},
); );
setAgentOperation(operation); setAgentSession(nextSession);
persistAgentUiState(activeAgentSessionId, operation.operationId); setAgentOperation(null);
setStreamingAgentReplyText('');
} catch (error) { } catch (error) {
const errorMessage = resolveErrorMessage(error, '发送共创消息失败。'); const errorMessage = resolveErrorMessage(error, '发送共创消息失败。');
setAgentOperation( setAgentSession((current) =>
createFailedAgentOperation({ current
type: 'process_message', ? {
phaseLabel: '发送消息失败', ...current,
error: errorMessage, messages: [
}), ...current.messages,
buildOptimisticAgentMessage({
id: `message-error-${Date.now()}`,
role: 'assistant',
kind: 'warning',
text: errorMessage,
}),
],
updatedAt: new Date().toISOString(),
}
: current,
); );
setStreamingAgentReplyText('');
persistAgentUiState(activeAgentSessionId, null); persistAgentUiState(activeAgentSessionId, null);
} finally {
setIsStreamingAgentReply(false);
} }
}; };
@@ -858,6 +918,8 @@ export function PreGameSelectionFlow({
const leaveAgentWorkspace = () => { const leaveAgentWorkspace = () => {
setPlatformTab('create'); setPlatformTab('create');
setAgentOperation(null); setAgentOperation(null);
setStreamingAgentReplyText('');
setIsStreamingAgentReply(false);
setGeneratedCustomWorldProfile(null); setGeneratedCustomWorldProfile(null);
setCustomWorldAutoSaveError(null); setCustomWorldAutoSaveError(null);
setCustomWorldAutoSaveState('idle'); setCustomWorldAutoSaveState('idle');
@@ -1058,11 +1120,7 @@ export function PreGameSelectionFlow({
customWorldAutoSaveTimeoutRef.current = null; customWorldAutoSaveTimeoutRef.current = null;
} }
}; };
}, [ }, [generatedCustomWorldProfile, saveGeneratedCustomWorld, selectionStage]);
generatedCustomWorldProfile,
saveGeneratedCustomWorld,
selectionStage,
]);
const openSavedCustomWorldEditor = ( const openSavedCustomWorldEditor = (
entry: CustomWorldLibraryEntry<CustomWorldProfile>, entry: CustomWorldLibraryEntry<CustomWorldProfile>,
@@ -1070,7 +1128,8 @@ export function PreGameSelectionFlow({
setSelectedDetailEntry(entry); setSelectedDetailEntry(entry);
const normalizedProfile = normalizeAgentBackedProfile(entry.profile); const normalizedProfile = normalizeAgentBackedProfile(entry.profile);
setGeneratedCustomWorldProfile(normalizedProfile); setGeneratedCustomWorldProfile(normalizedProfile);
lastAutoSavedProfileSignatureRef.current = JSON.stringify(normalizedProfile); lastAutoSavedProfileSignatureRef.current =
JSON.stringify(normalizedProfile);
setCustomWorldAutoSaveState('saved'); setCustomWorldAutoSaveState('saved');
setCustomWorldAutoSaveError(null); setCustomWorldAutoSaveError(null);
setCustomWorldError(null); setCustomWorldError(null);
@@ -1129,6 +1188,36 @@ export function PreGameSelectionFlow({
} }
}; };
const handleDeleteSelectedWorld = async () => {
if (!selectedDetailEntry || isMutatingDetail) {
return;
}
const confirmed = window.confirm(
`确认删除作品《${selectedDetailEntry.worldName}》吗?删除后会从你的作品列表和公开广场中移除。`,
);
if (!confirmed) {
return;
}
setIsMutatingDetail(true);
setDetailError(null);
try {
const entries = await deleteCustomWorldProfile(
selectedDetailEntry.profileId,
);
setSavedCustomWorldEntries(entries);
setSelectedDetailEntry(null);
setPlatformTab('create');
setSelectionStage('platform');
setPublishedGalleryEntries(await listCustomWorldGallery());
} catch (error) {
setDetailError(resolveErrorMessage(error, '删除自定义世界失败。'));
} finally {
setIsMutatingDetail(false);
}
};
const isSelectedWorldOwned = Boolean( const isSelectedWorldOwned = Boolean(
selectedDetailEntry && selectedDetailEntry &&
savedCustomWorldEntries.some( savedCustomWorldEntries.some(
@@ -1228,6 +1317,9 @@ export function PreGameSelectionFlow({
? handleUnpublishSelectedWorld ? handleUnpublishSelectedWorld
: null : null
} }
onDelete={
isSelectedWorldOwned ? handleDeleteSelectedWorld : null
}
/> />
)} )}
</motion.div> </motion.div>
@@ -1250,13 +1342,9 @@ export function PreGameSelectionFlow({
<CustomWorldAgentWorkspace <CustomWorldAgentWorkspace
session={agentSession} session={agentSession}
activeOperation={agentOperation} activeOperation={agentOperation}
streamingReplyText={streamingAgentReplyText}
isStreamingReply={isStreamingAgentReply}
onBack={leaveAgentWorkspace} onBack={leaveAgentWorkspace}
onRefresh={() => {
if (!activeAgentSessionId) {
return;
}
void syncAgentSessionSnapshot(activeAgentSessionId);
}}
onSubmitMessage={(payload) => { onSubmitMessage={(payload) => {
void submitAgentMessage(payload); void submitAgentMessage(payload);
}} }}
@@ -1290,6 +1378,7 @@ export function PreGameSelectionFlow({
> >
<CustomWorldGenerationView <CustomWorldGenerationView
settingText={activeGenerationSettingText} settingText={activeGenerationSettingText}
anchorEntries={agentDraftAnchorPreviewEntries}
progress={activeGenerationProgress} progress={activeGenerationProgress}
isGenerating={isActiveGenerationRunning} isGenerating={isActiveGenerationRunning}
error={activeGenerationError} error={activeGenerationError}
@@ -1300,10 +1389,10 @@ export function PreGameSelectionFlow({
backLabel="返回工作区" backLabel="返回工作区"
settingActionLabel="回到工作区" settingActionLabel="回到工作区"
retryLabel="重新生成草稿" retryLabel="重新生成草稿"
settingTitle="当前共创设定" settingTitle="当前锚点信息"
settingDescription={ settingDescription={
isAgentDraftGenerationView isAgentDraftGenerationView
? '这批锚点会被整理成第一版世界底稿与草稿卡。' ? '将按当前八锚点结构编译第一版世界底稿与草稿卡。'
: undefined : undefined
} }
progressTitle={ progressTitle={
@@ -1385,7 +1474,6 @@ export function PreGameSelectionFlow({
void openRpgAgentWorkspace(); void openRpgAgentWorkspace();
}} }}
/> />
</> </>
); );
} }

View File

@@ -1,3 +1,4 @@
import type { EightAnchorContent } from '../../packages/shared/src/contracts/customWorldAgent';
import { import {
isRecord, isRecord,
readStoredJson, readStoredJson,
@@ -551,6 +552,11 @@ function normalizeCharacterAnimationConfig(
const extension = toText(value.extension); const extension = toText(value.extension);
const file = toText(value.file); const file = toText(value.file);
const basePath = toText(value.basePath); const basePath = toText(value.basePath);
const frameWidth = toOptionalInteger(value.frameWidth);
const frameHeight = toOptionalInteger(value.frameHeight);
const fps = toOptionalNumber(value.fps);
const loop = typeof value.loop === 'boolean' ? value.loop : undefined;
const previewVideoPath = toText(value.previewVideoPath);
return { return {
folder, folder,
@@ -560,6 +566,11 @@ function normalizeCharacterAnimationConfig(
...(extension ? { extension } : {}), ...(extension ? { extension } : {}),
...(file ? { file } : {}), ...(file ? { file } : {}),
...(basePath ? { basePath } : {}), ...(basePath ? { basePath } : {}),
...(frameWidth ? { frameWidth: Math.max(1, frameWidth) } : {}),
...(frameHeight ? { frameHeight: Math.max(1, frameHeight) } : {}),
...(fps ? { fps: Math.max(1, fps) } : {}),
...(typeof loop === 'boolean' ? { loop } : {}),
...(previewVideoPath ? { previewVideoPath } : {}),
}; };
} }
@@ -974,6 +985,9 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
preserveStructuredRecordArray<KnowledgeFact>(value.knowledgeFacts), preserveStructuredRecordArray<KnowledgeFact>(value.knowledgeFacts),
threadContracts: threadContracts:
preserveStructuredRecordArray<ThreadContract>(value.threadContracts), preserveStructuredRecordArray<ThreadContract>(value.threadContracts),
anchorContent: preserveStructuredRecord<EightAnchorContent>(
value.anchorContent,
),
creatorIntent: normalizeCustomWorldCreatorIntent(value.creatorIntent), creatorIntent: normalizeCustomWorldCreatorIntent(value.creatorIntent),
anchorPack: anchorPack:
value.anchorPack && typeof value.anchorPack === 'object' value.anchorPack && typeof value.anchorPack === 'object'

View File

@@ -383,6 +383,7 @@ export function normalizeCustomWorldLandmarks(params: {
id: landmark.id, id: landmark.id,
name: landmark.name, name: landmark.name,
description: landmark.description, description: landmark.description,
visualDescription: landmark.visualDescription,
dangerLevel: landmark.dangerLevel, dangerLevel: landmark.dangerLevel,
imageSrc: landmark.imageSrc, imageSrc: landmark.imageSrc,
narrativeResidues: landmark.narrativeResidues, narrativeResidues: landmark.narrativeResidues,

View File

@@ -629,6 +629,104 @@ export async function sendCustomWorldAgentMessage(
); );
} }
export async function streamCustomWorldAgentMessage(
sessionId: string,
payload: SendCustomWorldAgentMessageRequest,
options: TextStreamOptions = {},
) {
const response = await fetchWithApiAuth(
`${RUNTIME_API_BASE}/custom-world/agent/sessions/${encodeURIComponent(sessionId)}/messages/stream`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
);
if (!response.ok) {
const responseText = await response.text();
throw new Error(parseApiErrorMessage(responseText, '发送共创消息失败'));
}
if (!response.body) {
throw new Error('streaming response body is unavailable');
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
let finalSession: CustomWorldAgentSessionSnapshot | null = null;
for (;;) {
const { done, value } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
while (buffer.includes('\n\n')) {
const boundary = buffer.indexOf('\n\n');
const eventBlock = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
let eventName = 'message';
const dataLines: string[] = [];
for (const rawLine of eventBlock.split(/\r?\n/u)) {
const line = rawLine.trim();
if (line.startsWith('event:')) {
eventName = line.slice(6).trim() || 'message';
continue;
}
if (line.startsWith('data:')) {
dataLines.push(line.slice(5).trim());
}
}
if (dataLines.length === 0) {
continue;
}
const data = dataLines.join('\n');
let parsed: Record<string, unknown> | null = null;
try {
parsed = JSON.parse(data) as Record<string, unknown>;
} catch {
parsed = null;
}
if (eventName === 'reply_delta' && parsed) {
const text = parsed.text;
if (typeof text === 'string') {
options.onUpdate?.(text);
}
continue;
}
if (eventName === 'session' && parsed?.session) {
finalSession = parsed.session as CustomWorldAgentSessionSnapshot;
continue;
}
if (eventName === 'error' && parsed) {
const message =
typeof parsed.message === 'string' && parsed.message.trim()
? parsed.message.trim()
: '发送共创消息失败';
throw new Error(message);
}
}
}
if (!finalSession) {
throw new Error('共创消息流式结果不完整');
}
return finalSession;
}
export async function executeCustomWorldAgentAction( export async function executeCustomWorldAgentAction(
sessionId: string, sessionId: string,
payload: CustomWorldAgentActionRequest, payload: CustomWorldAgentActionRequest,

View File

@@ -1,3 +1,4 @@
import type { EightAnchorContent } from '../../packages/shared/src/contracts/customWorldAgent';
import { import {
AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS, AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS,
DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY, DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY,
@@ -93,6 +94,9 @@ export interface CustomWorldGenerationRoleOutline {
title: string; title: string;
role: string; role: string;
description: string; description: string;
visualDescription?: string;
actionDescription?: string;
sceneVisualDescription?: string;
initialAffinity: number; initialAffinity: number;
relationshipHooks: string[]; relationshipHooks: string[];
tags: string[]; tags: string[];
@@ -107,6 +111,7 @@ export interface CustomWorldGenerationLandmarkConnectionOutline {
export interface CustomWorldGenerationLandmarkOutline { export interface CustomWorldGenerationLandmarkOutline {
name: string; name: string;
description: string; description: string;
visualDescription?: string;
dangerLevel: string; dangerLevel: string;
sceneNpcNames: string[]; sceneNpcNames: string[];
connections: CustomWorldGenerationLandmarkConnectionOutline[]; connections: CustomWorldGenerationLandmarkConnectionOutline[];
@@ -148,6 +153,12 @@ function toFiniteInteger(value: unknown) {
: undefined; : undefined;
} }
function toFiniteNumber(value: unknown) {
return typeof value === 'number' && Number.isFinite(value)
? value
: undefined;
}
function toRecordArray(value: unknown) { function toRecordArray(value: unknown) {
return Array.isArray(value) return Array.isArray(value)
? (value.filter((item) => item && typeof item === 'object') as Array< ? (value.filter((item) => item && typeof item === 'object') as Array<
@@ -198,6 +209,11 @@ function normalizeGeneratedAnimationConfig(
const extension = toText(item.extension); const extension = toText(item.extension);
const file = toText(item.file); const file = toText(item.file);
const basePath = toText(item.basePath); const basePath = toText(item.basePath);
const frameWidth = toFiniteInteger(item.frameWidth);
const frameHeight = toFiniteInteger(item.frameHeight);
const fps = toFiniteNumber(item.fps);
const loop = typeof item.loop === 'boolean' ? item.loop : undefined;
const previewVideoPath = toText(item.previewVideoPath);
return { return {
folder, folder,
@@ -207,6 +223,11 @@ function normalizeGeneratedAnimationConfig(
...(extension ? { extension } : {}), ...(extension ? { extension } : {}),
...(file ? { file } : {}), ...(file ? { file } : {}),
...(basePath ? { basePath } : {}), ...(basePath ? { basePath } : {}),
...(frameWidth ? { frameWidth: Math.max(1, frameWidth) } : {}),
...(frameHeight ? { frameHeight: Math.max(1, frameHeight) } : {}),
...(fps ? { fps: Math.max(1, fps) } : {}),
...(typeof loop === 'boolean' ? { loop } : {}),
...(previewVideoPath ? { previewVideoPath } : {}),
}; };
} }
@@ -762,6 +783,9 @@ export function buildCustomWorldRawProfileFromFramework(
title: npc.title, title: npc.title,
role: npc.role, role: npc.role,
description: npc.description, description: npc.description,
visualDescription: npc.visualDescription,
actionDescription: npc.actionDescription,
sceneVisualDescription: npc.sceneVisualDescription,
initialAffinity: npc.initialAffinity, initialAffinity: npc.initialAffinity,
relationshipHooks: [...npc.relationshipHooks], relationshipHooks: [...npc.relationshipHooks],
tags: [...npc.tags], tags: [...npc.tags],
@@ -771,6 +795,9 @@ export function buildCustomWorldRawProfileFromFramework(
title: npc.title, title: npc.title,
role: npc.role, role: npc.role,
description: npc.description, description: npc.description,
visualDescription: npc.visualDescription,
actionDescription: npc.actionDescription,
sceneVisualDescription: npc.sceneVisualDescription,
initialAffinity: npc.initialAffinity, initialAffinity: npc.initialAffinity,
relationshipHooks: [...npc.relationshipHooks], relationshipHooks: [...npc.relationshipHooks],
tags: [...npc.tags], tags: [...npc.tags],
@@ -778,6 +805,7 @@ export function buildCustomWorldRawProfileFromFramework(
landmarks: framework.landmarks.map((landmark) => ({ landmarks: framework.landmarks.map((landmark) => ({
name: landmark.name, name: landmark.name,
description: landmark.description, description: landmark.description,
visualDescription: landmark.visualDescription,
dangerLevel: landmark.dangerLevel, dangerLevel: landmark.dangerLevel,
sceneNpcNames: [...landmark.sceneNpcNames], sceneNpcNames: [...landmark.sceneNpcNames],
connections: landmark.connections.map((connection) => ({ connections: landmark.connections.map((connection) => ({
@@ -812,6 +840,9 @@ function normalizeRoleProfile(
title, title,
role, role,
description: toText(item.description), description: toText(item.description),
visualDescription: toText(item.visualDescription),
actionDescription: toText(item.actionDescription),
sceneVisualDescription: toText(item.sceneVisualDescription),
backstory: toText(item.backstory), backstory: toText(item.backstory),
personality: toText(item.personality), personality: toText(item.personality),
motivation: toText(item.motivation) || toText(item.description), motivation: toText(item.motivation) || toText(item.description),
@@ -923,6 +954,9 @@ function normalizeRoleOutlineList(
description: description:
toText(item.description) || toText(item.description) ||
truncateText(`${name || title}在世界中以${role}身份活动。`, 36), truncateText(`${name || title}在世界中以${role}身份活动。`, 36),
visualDescription: toText(item.visualDescription) || undefined,
actionDescription: toText(item.actionDescription) || undefined,
sceneVisualDescription: toText(item.sceneVisualDescription) || undefined,
initialAffinity: normalizeInitialAffinity( initialAffinity: normalizeInitialAffinity(
item.initialAffinity, item.initialAffinity,
options.defaultAffinity, options.defaultAffinity,
@@ -973,6 +1007,7 @@ function normalizeLandmarkOutlineList(value: unknown) {
description: description:
toText(item.description) || toText(item.description) ||
truncateText(`${name}暗藏新的局势变化。`, 40), truncateText(`${name}暗藏新的局势变化。`, 40),
visualDescription: toText(item.visualDescription) || undefined,
dangerLevel: toText(item.dangerLevel) || 'medium', dangerLevel: toText(item.dangerLevel) || 'medium',
sceneNpcNames: [ sceneNpcNames: [
...toStringArray(item.sceneNpcNames), ...toStringArray(item.sceneNpcNames),
@@ -1033,6 +1068,7 @@ function normalizeLandmarkDraftList(value: unknown) {
id: toText(item.id) || createEntryId('landmark', name, index), id: toText(item.id) || createEntryId('landmark', name, index),
name, name,
description: toText(item.description), description: toText(item.description),
visualDescription: toText(item.visualDescription) || undefined,
dangerLevel: toText(item.dangerLevel), dangerLevel: toText(item.dangerLevel),
imageSrc: toText(item.imageSrc) || undefined, imageSrc: toText(item.imageSrc) || undefined,
sceneNpcIds: toStringArray(item.sceneNpcIds), sceneNpcIds: toStringArray(item.sceneNpcIds),
@@ -1165,6 +1201,10 @@ export function normalizeCustomWorldProfile(
item.storyGraph && typeof item.storyGraph === 'object' item.storyGraph && typeof item.storyGraph === 'object'
? (item.storyGraph as WorldStoryGraph) ? (item.storyGraph as WorldStoryGraph)
: null, : null,
anchorContent:
item.anchorContent && typeof item.anchorContent === 'object'
? (item.anchorContent as EightAnchorContent)
: null,
creatorIntent: normalizeCustomWorldCreatorIntent(item.creatorIntent), creatorIntent: normalizeCustomWorldCreatorIntent(item.creatorIntent),
anchorPack: anchorPack:
item.anchorPack && typeof item.anchorPack === 'object' item.anchorPack && typeof item.anchorPack === 'object'

View File

@@ -13,6 +13,31 @@ afterEach(() => {
const session: CustomWorldAgentSessionSnapshot = { const session: CustomWorldAgentSessionSnapshot = {
sessionId: 'session-1', sessionId: 'session-1',
currentTurn: 6,
anchorContent: {
worldPromise: {
hook: '被海雾吞没的旧航路群岛',
differentiator: '灯塔与禁航令共同决定谁能活着穿过去。',
desiredExperience: '压抑、悬疑、潮湿',
},
playerFantasy: {
playerRole: '玩家回到群岛调查沉船真相。',
corePursuit: '找出失控航路背后的真相。',
fearOfLoss: '失去最后一个还能对上旧案的人。',
},
themeBoundary: null,
playerEntryPoint: null,
coreConflict: null,
keyRelationships: [],
hiddenLines: null,
iconicElements: {
iconicMotifs: ['会移动的海雾'],
institutionsOrArtifacts: ['旧灯塔'],
hardRules: [],
},
},
progressPercent: 100,
lastAssistantReply: '八锚点已经齐备,可以进入游戏设定草稿生成。',
stage: 'object_refining', stage: 'object_refining',
focusCardId: null, focusCardId: null,
creatorIntent: { creatorIntent: {

View File

@@ -231,6 +231,7 @@ export function buildCustomWorldProfileFromAgentDraft(
imageSrc: toText(draftProfile.camp.imageSrc) || undefined, imageSrc: toText(draftProfile.camp.imageSrc) || undefined,
} }
: undefined, : undefined,
anchorContent: session.anchorContent,
creatorIntent: session.creatorIntent, creatorIntent: session.creatorIntent,
anchorPack: session.anchorPack, anchorPack: session.anchorPack,
lockState: session.lockState, lockState: session.lockState,

View File

@@ -22,6 +22,31 @@ const baseOperation: CustomWorldAgentOperationRecord = {
const baseSession: CustomWorldAgentSessionSnapshot = { const baseSession: CustomWorldAgentSessionSnapshot = {
sessionId: 'session-1', sessionId: 'session-1',
currentTurn: 8,
anchorContent: {
worldPromise: {
hook: '海雾、旧灯塔和失控航路交织的边缘群岛',
differentiator: '每次借路都要向海雾付出新的代价。',
desiredExperience: '压抑、悬疑、潮湿',
},
playerFantasy: {
playerRole: '玩家刚回到群岛,准备调查父亲沉船的真相。',
corePursuit: '查清沉船夜和禁航区异动的因果。',
fearOfLoss: '再失去唯一还敢接近真相的人。',
},
themeBoundary: null,
playerEntryPoint: null,
coreConflict: null,
keyRelationships: [],
hiddenLines: null,
iconicElements: {
iconicMotifs: ['会移动的海雾'],
institutionsOrArtifacts: ['旧灯塔'],
hardRules: [],
},
},
progressPercent: 100,
lastAssistantReply: '八锚点已经收束完成,可以进入游戏设定草稿生成。',
stage: 'foundation_review', stage: 'foundation_review',
focusCardId: null, focusCardId: null,
creatorIntent: { creatorIntent: {
@@ -121,11 +146,12 @@ test('builds readable draft setting text from creator intent first', () => {
expect(settingText).toContain('标志元素'); expect(settingText).toContain('标志元素');
}); });
test('falls back to latest user message when creator intent is unavailable', () => { test('falls back to anchor content when creator intent is unavailable', () => {
const settingText = buildAgentDraftFoundationSettingText({ const settingText = buildAgentDraftFoundationSettingText({
...baseSession, ...baseSession,
creatorIntent: null, creatorIntent: null,
}); });
expect(settingText).toBe('我想做一个被海雾吞没的旧航路世界。'); expect(settingText).toContain('世界承诺');
expect(settingText).toContain('玩家幻想');
}); });

View File

@@ -1,6 +1,7 @@
import type { import type {
CustomWorldAgentOperationRecord, CustomWorldAgentOperationRecord,
CustomWorldAgentSessionSnapshot, CustomWorldAgentSessionSnapshot,
EightAnchorContent,
} from '../../packages/shared/src/contracts/customWorldAgent'; } from '../../packages/shared/src/contracts/customWorldAgent';
import type { import type {
CustomWorldGenerationProgress, CustomWorldGenerationProgress,
@@ -11,6 +12,187 @@ import {
normalizeCustomWorldCreatorIntent, normalizeCustomWorldCreatorIntent,
} from './customWorldCreatorIntent'; } from './customWorldCreatorIntent';
export type CustomWorldStructuredAnchorEntry = {
id: string;
label: string;
value: string;
};
function joinText(items: Array<string | null | undefined>) {
return items.filter(Boolean).join('');
}
function buildEightAnchorFoundationText(anchorContent: EightAnchorContent) {
return [
anchorContent.worldPromise
? `世界承诺:${joinText([
anchorContent.worldPromise.hook,
anchorContent.worldPromise.differentiator,
anchorContent.worldPromise.desiredExperience,
])}`
: '',
anchorContent.playerFantasy
? `玩家幻想:${joinText([
anchorContent.playerFantasy.playerRole,
anchorContent.playerFantasy.corePursuit,
anchorContent.playerFantasy.fearOfLoss,
])}`
: '',
anchorContent.themeBoundary
? `主题边界:${joinText([
anchorContent.themeBoundary.toneKeywords.join('、'),
anchorContent.themeBoundary.aestheticDirectives.join('、'),
anchorContent.themeBoundary.forbiddenDirectives.join('、'),
])}`
: '',
anchorContent.playerEntryPoint
? `玩家切入口:${joinText([
anchorContent.playerEntryPoint.openingIdentity,
anchorContent.playerEntryPoint.openingProblem,
anchorContent.playerEntryPoint.entryMotivation,
])}`
: '',
anchorContent.coreConflict
? `核心冲突:${joinText([
anchorContent.coreConflict.surfaceConflicts.join('、'),
anchorContent.coreConflict.hiddenCrisis,
anchorContent.coreConflict.firstTouchedConflict,
])}`
: '',
anchorContent.keyRelationships.length > 0
? `关键关系:${anchorContent.keyRelationships
.map((entry) =>
joinText([entry.pairs, entry.relationshipType, entry.secretOrCost]),
)
.join('')}`
: '',
anchorContent.hiddenLines
? `暗线与揭示:${joinText([
anchorContent.hiddenLines.hiddenTruths.join('、'),
anchorContent.hiddenLines.misdirectionHints.join('、'),
anchorContent.hiddenLines.revealPacing,
])}`
: '',
anchorContent.iconicElements
? `标志元素:${joinText([
anchorContent.iconicElements.iconicMotifs.join('、'),
anchorContent.iconicElements.institutionsOrArtifacts.join('、'),
anchorContent.iconicElements.hardRules.join('、'),
])}`
: '',
]
.filter(Boolean)
.join('\n');
}
export function buildAgentDraftFoundationAnchorEntries(
session: CustomWorldAgentSessionSnapshot | null | undefined,
): CustomWorldStructuredAnchorEntry[] {
if (!session) {
return [];
}
const anchorContent = session.anchorContent;
return [
{
id: 'world-promise',
label: '世界承诺',
value: anchorContent.worldPromise
? joinText([
anchorContent.worldPromise.hook,
anchorContent.worldPromise.differentiator,
anchorContent.worldPromise.desiredExperience,
])
: '',
},
{
id: 'player-fantasy',
label: '玩家幻想',
value: anchorContent.playerFantasy
? joinText([
anchorContent.playerFantasy.playerRole,
anchorContent.playerFantasy.corePursuit,
anchorContent.playerFantasy.fearOfLoss,
])
: '',
},
{
id: 'theme-boundary',
label: '主题边界',
value: anchorContent.themeBoundary
? joinText([
anchorContent.themeBoundary.toneKeywords.join('、'),
anchorContent.themeBoundary.aestheticDirectives.join('、'),
anchorContent.themeBoundary.forbiddenDirectives.length > 0
? `避免:${anchorContent.themeBoundary.forbiddenDirectives.join('、')}`
: '',
])
: '',
},
{
id: 'player-entry-point',
label: '玩家切入口',
value: anchorContent.playerEntryPoint
? joinText([
anchorContent.playerEntryPoint.openingIdentity,
anchorContent.playerEntryPoint.openingProblem,
anchorContent.playerEntryPoint.entryMotivation,
])
: '',
},
{
id: 'core-conflict',
label: '核心冲突',
value: anchorContent.coreConflict
? joinText([
anchorContent.coreConflict.surfaceConflicts.join('、'),
anchorContent.coreConflict.hiddenCrisis,
anchorContent.coreConflict.firstTouchedConflict,
])
: '',
},
{
id: 'key-relationships',
label: '关键关系',
value:
anchorContent.keyRelationships.length > 0
? anchorContent.keyRelationships
.map((entry) =>
joinText([
entry.pairs,
entry.relationshipType,
entry.secretOrCost ? `代价/秘密:${entry.secretOrCost}` : '',
]),
)
.join('\n')
: '',
},
{
id: 'hidden-lines',
label: '暗线与揭示',
value: anchorContent.hiddenLines
? joinText([
anchorContent.hiddenLines.hiddenTruths.join('、'),
anchorContent.hiddenLines.misdirectionHints.join('、'),
anchorContent.hiddenLines.revealPacing,
])
: '',
},
{
id: 'iconic-elements',
label: '标志元素',
value: anchorContent.iconicElements
? joinText([
anchorContent.iconicElements.iconicMotifs.join('、'),
anchorContent.iconicElements.institutionsOrArtifacts.join('、'),
anchorContent.iconicElements.hardRules.join('、'),
])
: '',
},
].filter((entry) => entry.value.trim());
}
const AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS = [ const AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS = [
{ {
id: 'queue', id: 'queue',
@@ -192,5 +374,11 @@ export function buildAgentDraftFoundationSettingText(
.reverse() .reverse()
.find((message) => message.role === 'user' && message.text.trim()); .find((message) => message.role === 'user' && message.text.trim());
return latestUserMessage?.text.trim() ?? '正在整理当前共创设定。'; const anchorSettingText = buildEightAnchorFoundationText(session.anchorContent);
return (
anchorSettingText ||
latestUserMessage?.text.trim() ||
'正在整理当前共创设定。'
);
} }

View File

@@ -0,0 +1,105 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { requestJsonMock } = vi.hoisted(() => ({
requestJsonMock: vi.fn(),
}));
import {
clearProfileBrowseHistory,
listProfileBrowseHistory,
syncProfileBrowseHistory,
upsertProfileBrowseHistory,
} from './storageService';
vi.mock('./apiClient', () => ({
requestJson: requestJsonMock,
}));
describe('storageService browse history routes', () => {
beforeEach(() => {
requestJsonMock.mockReset();
requestJsonMock.mockResolvedValue({ entries: [] });
});
it('reads browse history from the runtime profile route', async () => {
await listProfileBrowseHistory();
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/profile/browse-history',
expect.objectContaining({ method: 'GET' }),
'读取浏览历史失败',
expect.objectContaining({
retry: expect.objectContaining({ maxRetries: 1 }),
}),
);
});
it('writes browse history through the runtime profile route', async () => {
await upsertProfileBrowseHistory({
ownerUserId: 'user-1',
profileId: 'profile-1',
worldName: '测试世界',
subtitle: '测试副标题',
summaryText: '测试摘要',
coverImageSrc: null,
themeMode: 'mythic',
authorDisplayName: '测试作者',
});
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/profile/browse-history',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}),
'写入浏览历史失败',
expect.objectContaining({
retry: expect.objectContaining({
maxRetries: 1,
retryUnsafeMethods: true,
}),
}),
);
});
it('syncs browse history through the runtime profile route', async () => {
await syncProfileBrowseHistory([
{
ownerUserId: 'user-1',
profileId: 'profile-1',
worldName: '测试世界',
subtitle: '测试副标题',
summaryText: '测试摘要',
coverImageSrc: null,
themeMode: 'mythic',
authorDisplayName: '测试作者',
},
]);
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/profile/browse-history',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}),
'同步浏览历史失败',
expect.any(Object),
);
});
it('clears browse history through the runtime profile route', async () => {
await clearProfileBrowseHistory();
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/profile/browse-history',
expect.objectContaining({ method: 'DELETE' }),
'清空浏览历史失败',
expect.objectContaining({
retry: expect.objectContaining({
maxRetries: 1,
retryUnsafeMethods: true,
}),
}),
);
});
});

View File

@@ -61,28 +61,6 @@ function requestRuntimeJson<T>(
); );
} }
function requestProfileJson<T>(
path: string,
init: RequestInit,
fallbackMessage: string,
options: RuntimeRequestOptions = {},
) {
const method = (init.method ?? 'GET').toUpperCase();
const retry =
options.retry ??
(method === 'GET' ? RUNTIME_READ_RETRY : RUNTIME_WRITE_RETRY);
return requestJson<T>(
`/api/profile${path}`,
{
...init,
signal: options.signal,
},
fallbackMessage,
{ retry },
);
}
export async function getSaveSnapshot(options: RuntimeRequestOptions = {}) { export async function getSaveSnapshot(options: RuntimeRequestOptions = {}) {
const snapshot = await requestRuntimeJson<HydratedSavedGameSnapshot | null>( const snapshot = await requestRuntimeJson<HydratedSavedGameSnapshot | null>(
'/save/snapshot', '/save/snapshot',
@@ -315,8 +293,8 @@ export async function getCustomWorldGalleryDetail(
export async function listProfileBrowseHistory( export async function listProfileBrowseHistory(
options: RuntimeRequestOptions = {}, options: RuntimeRequestOptions = {},
) { ) {
const response = await requestProfileJson<PlatformBrowseHistoryResponse>( const response = await requestRuntimeJson<PlatformBrowseHistoryResponse>(
'/browse-history', '/profile/browse-history',
{ method: 'GET' }, { method: 'GET' },
'读取浏览历史失败', '读取浏览历史失败',
options, options,
@@ -329,8 +307,8 @@ export async function upsertProfileBrowseHistory(
entry: PlatformBrowseHistoryWriteEntry, entry: PlatformBrowseHistoryWriteEntry,
options: RuntimeRequestOptions = {}, options: RuntimeRequestOptions = {},
) { ) {
const response = await requestProfileJson<PlatformBrowseHistoryResponse>( const response = await requestRuntimeJson<PlatformBrowseHistoryResponse>(
'/browse-history', '/profile/browse-history',
{ {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -347,8 +325,8 @@ export async function syncProfileBrowseHistory(
entries: PlatformBrowseHistoryWriteEntry[], entries: PlatformBrowseHistoryWriteEntry[],
options: RuntimeRequestOptions = {}, options: RuntimeRequestOptions = {},
) { ) {
const response = await requestProfileJson<PlatformBrowseHistoryResponse>( const response = await requestRuntimeJson<PlatformBrowseHistoryResponse>(
'/browse-history', '/profile/browse-history',
{ {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -366,8 +344,8 @@ export async function syncProfileBrowseHistory(
export async function clearProfileBrowseHistory( export async function clearProfileBrowseHistory(
options: RuntimeRequestOptions = {}, options: RuntimeRequestOptions = {},
) { ) {
const response = await requestProfileJson<PlatformBrowseHistoryResponse>( const response = await requestRuntimeJson<PlatformBrowseHistoryResponse>(
'/browse-history', '/profile/browse-history',
{ method: 'DELETE' }, { method: 'DELETE' },
'清空浏览历史失败', '清空浏览历史失败',
options, options,

View File

@@ -405,6 +405,7 @@ export default function QwenSpriteSheetTool() {
setSheetStatus(null); setSheetStatus(null);
try { try {
if (actionGenerationMode === 'image-to-video') { if (actionGenerationMode === 'image-to-video') {
const isLoopAction = actionTemplate.loop;
const result = await generateCharacterAnimationDraft({ const result = await generateCharacterAnimationDraft({
characterId: assetKey || 'qwen-sprite-tool', characterId: assetKey || 'qwen-sprite-tool',
strategy: 'image-to-video', strategy: 'image-to-video',
@@ -413,20 +414,21 @@ export default function QwenSpriteSheetTool() {
visualSource: selectedMasterSource, visualSource: selectedMasterSource,
referenceImageDataUrls: [], referenceImageDataUrls: [],
referenceVideoDataUrls: [], referenceVideoDataUrls: [],
lastFrameImageDataUrl: isLoopAction ? undefined : selectedMasterSource,
frameCount: 16, frameCount: 16,
fps: actionTemplate.defaultFps, fps: actionTemplate.defaultFps,
durationSeconds: FIXED_IMAGE_TO_VIDEO_DURATION_SECONDS, durationSeconds: FIXED_IMAGE_TO_VIDEO_DURATION_SECONDS,
loop: actionTemplate.loop, loop: actionTemplate.loop,
useChromaKey, useChromaKey,
resolution: FIXED_IMAGE_TO_VIDEO_RESOLUTION, resolution: isLoopAction ? '720P' : FIXED_IMAGE_TO_VIDEO_RESOLUTION,
imageSequenceModel: 'wan2.7-image-pro', imageSequenceModel: 'wan2.7-image-pro',
videoModel: FIXED_IMAGE_TO_VIDEO_MODEL, videoModel: isLoopAction ? 'wan2.6-i2v-flash' : FIXED_IMAGE_TO_VIDEO_MODEL,
referenceVideoModel: 'wan2.7-r2v', referenceVideoModel: 'wan2.7-r2v',
motionTransferModel: 'wan2.2-animate-move', motionTransferModel: 'wan2.2-animate-move',
}); });
if (result.strategy !== 'image-to-video') { if (result.strategy !== 'image-to-video') {
throw new Error('图生视频接口返回了非预期结果。'); throw new Error('动作生成接口返回了非预期结果。');
} }
const clip = await buildAnimationClipFromVideoSource( const clip = await buildAnimationClipFromVideoSource(
@@ -439,6 +441,8 @@ export default function QwenSpriteSheetTool() {
frameWidth: GENERATED_FRAME_WIDTH, frameWidth: GENERATED_FRAME_WIDTH,
frameHeight: GENERATED_FRAME_HEIGHT, frameHeight: GENERATED_FRAME_HEIGHT,
applyChromaKey: useChromaKey, applyChromaKey: useChromaKey,
sampleStartRatio: actionTemplate.loop ? 0.12 : 0,
sampleEndRatio: actionTemplate.loop ? 0.94 : 1,
}, },
); );
const composedSheet = await composeSpriteSheetFromFrames(clip.frames, { const composedSheet = await composeSpriteSheetFromFrames(clip.frames, {

View File

@@ -40,6 +40,11 @@ export interface CharacterAnimationConfig {
extension?: string; extension?: string;
file?: string; file?: string;
basePath?: string; basePath?: string;
frameWidth?: number;
frameHeight?: number;
fps?: number;
loop?: boolean;
previewVideoPath?: string;
} }
export type SkillStyle = 'burst' | 'steady' | 'mobility' | 'finisher' | 'projectile'; export type SkillStyle = 'burst' | 'steady' | 'mobility' | 'finisher' | 'projectile';

View File

@@ -1,3 +1,4 @@
import type { EightAnchorContent } from '../../packages/shared/src/contracts/customWorldAgent';
import type { import type {
ItemAttributeResonance, ItemAttributeResonance,
RoleAttributeProfile, RoleAttributeProfile,
@@ -219,6 +220,9 @@ export interface CustomWorldRoleProfile {
title: string; title: string;
role: string; role: string;
description: string; description: string;
visualDescription?: string;
actionDescription?: string;
sceneVisualDescription?: string;
backstory: string; backstory: string;
personality: string; personality: string;
motivation: string; motivation: string;
@@ -318,6 +322,7 @@ export interface CustomWorldLandmark {
id: string; id: string;
name: string; name: string;
description: string; description: string;
visualDescription?: string;
dangerLevel: string; dangerLevel: string;
imageSrc?: string; imageSrc?: string;
sceneNpcIds: string[]; sceneNpcIds: string[];
@@ -347,6 +352,7 @@ export interface CustomWorldProfile {
storyGraph?: WorldStoryGraph | null; storyGraph?: WorldStoryGraph | null;
knowledgeFacts?: KnowledgeFact[] | null; knowledgeFacts?: KnowledgeFact[] | null;
threadContracts?: ThreadContract[] | null; threadContracts?: ThreadContract[] | null;
anchorContent?: EightAnchorContent | null;
creatorIntent?: CustomWorldCreatorIntent | null; creatorIntent?: CustomWorldCreatorIntent | null;
anchorPack?: CustomWorldAnchorPack | null; anchorPack?: CustomWorldAnchorPack | null;
lockState?: CustomWorldLockState | null; lockState?: CustomWorldLockState | null;

View File

@@ -9,6 +9,12 @@
reverse_proxy {$CADDY_API_UPSTREAM} reverse_proxy {$CADDY_API_UPSTREAM}
} }
@public_assets path /branding/* /character/* /generated-character-drafts/* /generated-characters/* /generated-custom-world-scenes/* /generated-qwen-sprites/* /Icons/* /Pixel* /scene_bg/* /UI/*
handle @public_assets {
root * {$CADDY_PUBLIC_ROOT}
file_server
}
handle /healthz { handle /healthz {
respond "ok" 200 respond "ok" 200
} }

View File

@@ -49,6 +49,26 @@ export default defineConfig(({mode}) => {
// Do not modify; file watching is disabled to prevent flickering during agent edits. // Do not modify; file watching is disabled to prevent flickering during agent edits.
hmr: process.env.DISABLE_HMR !== 'true', hmr: process.env.DISABLE_HMR !== 'true',
proxy: { proxy: {
'/generated-character-drafts': {
target: runtimeServerTarget,
changeOrigin: true,
secure: false,
},
'/generated-characters': {
target: runtimeServerTarget,
changeOrigin: true,
secure: false,
},
'/generated-custom-world-scenes': {
target: runtimeServerTarget,
changeOrigin: true,
secure: false,
},
'/generated-qwen-sprites': {
target: runtimeServerTarget,
changeOrigin: true,
secure: false,
},
'/api/auth': { '/api/auth': {
target: runtimeServerTarget, target: runtimeServerTarget,
changeOrigin: true, changeOrigin: true,