1
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
? '背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。'
|
? '背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。'
|
||||||
|
|||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
10
server-node/package-lock.json
generated
10
server-node/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}`));
|
||||||
|
|||||||
@@ -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
|
||||||
|
)`,
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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' }),
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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)!;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
321
server-node/src/services/customWorldAgentTestHelpers.ts
Normal file
321
server-node/src/services/customWorldAgentTestHelpers.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
'观测台立在湿冷高处,铁质风标、旧灯架和被潮气侵白的石阶一起构成了压迫感很强的前景。',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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' ||
|
||||||
|
|||||||
@@ -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) ||
|
||||||
|
|||||||
593
server-node/src/services/eightAnchorCompatibilityService.ts
Normal file
593
server-node/src/services/eightAnchorCompatibilityService.ts
Normal 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) || '还在收集你的世界锚点。';
|
||||||
|
}
|
||||||
784
server-node/src/services/eightAnchorPromptBuilder.ts
Normal file
784
server-node/src/services/eightAnchorPromptBuilder.ts
Normal 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. judgementSummary:1 到 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 为 true,conversationMode 必须优先判为 force_complete
|
||||||
|
2. 如果用户核心意图是修正旧方向,userInputSignal 优先判为 correction,conversationMode 通常优先考虑 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
420
server-node/src/services/eightAnchorSingleTurnService.test.ts
Normal file
420
server-node/src/services/eightAnchorSingleTurnService.test.ts
Normal 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);
|
||||||
|
});
|
||||||
322
server-node/src/services/eightAnchorSingleTurnService.ts
Normal file
322
server-node/src/services/eightAnchorSingleTurnService.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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, '');
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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('提示词');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(' '),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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('当前设定已齐备,可以进入下一阶段');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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('守灯会与沉船商盟争夺航道解释权');
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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('快捷动作');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
105
src/components/custom-world-agent/EightAnchorProgressBar.tsx
Normal file
105
src/components/custom-world-agent/EightAnchorProgressBar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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="我的"
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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('玩家幻想');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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() ||
|
||||||
|
'正在整理当前共创设定。'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
105
src/services/storageService.test.ts
Normal file
105
src/services/storageService.test.ts
Normal 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,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user