From 5032701c387d066faa327545684afffd7c2869f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=89=A9?= <253518756@qq.com> Date: Sat, 18 Apr 2026 13:05:29 +0800 Subject: [PATCH] 1 --- .gitignore | 3 + ..._ANCHOR_CO_CREATION_FLOW_PRD_2026-04-16.md | 699 ++++++++++- ...IGHT_ANCHOR_MINIMUM_LOOP_PRD_2026-04-17.md | 1082 +++++++++++++++++ packages/shared/src/assets/qwenSprite.ts | 25 +- .../shared/src/contracts/customWorldAgent.ts | 64 + .../story-npc-1/workflow-cache.json | 81 +- scripts/run-caddy-dev.mjs | 2 + scripts/smoke-same-origin-stack.ts | 32 +- server-node/package-lock.json | 10 + server-node/package.json | 1 + server-node/src/app.test.ts | 382 ++++-- server-node/src/app.ts | 6 + server-node/src/db/migrations.ts | 17 + .../assets/characterAssetRoutes.test.ts | 448 ++++++- .../modules/assets/characterAssetRoutes.ts | 410 ++++++- .../src/repositories/runtimeRepository.ts | 22 +- server-node/src/routes/customWorldAgent.ts | 23 + .../customWorldAgentFoundationDraftService.ts | 56 +- .../services/customWorldAgentOrchestrator.ts | 795 +++++------- .../services/customWorldAgentPhase2.test.ts | 14 +- .../services/customWorldAgentPhase3.test.ts | 15 +- .../services/customWorldAgentPhase4.test.ts | 13 +- .../services/customWorldAgentPhase5.test.ts | 9 +- .../services/customWorldAgentSessionStore.ts | 216 +++- .../services/customWorldAgentTestHelpers.ts | 321 +++++ ...customWorldEntityGenerationService.test.ts | 157 +++ .../customWorldEntityGenerationService.ts | 73 +- .../services/customWorldWorkSummaryService.ts | 6 + .../eightAnchorCompatibilityService.ts | 593 +++++++++ .../src/services/eightAnchorPromptBuilder.ts | 784 ++++++++++++ .../eightAnchorSingleTurnService.test.ts | 420 +++++++ .../services/eightAnchorSingleTurnService.ts | 322 +++++ server-node/src/services/llmClient.ts | 86 ++ src/components/CharacterAnimator.tsx | 21 +- src/components/CustomWorldEntityCatalog.tsx | 356 ++++-- .../CustomWorldEntityEditorModal.tsx | 31 +- src/components/CustomWorldGenerationView.tsx | 84 +- src/components/CustomWorldResultView.test.tsx | 62 + src/components/CustomWorldResultView.tsx | 3 +- .../CustomWorldRoleAssetStudioModal.tsx | 467 ++++--- .../characterAssetWorkflowModel.ts | 148 ++- .../customWorldRolePromptDefaults.test.ts | 51 + .../customWorldRolePromptDefaults.ts | 92 +- ...ustomWorldAgentClarificationPanel.test.tsx | 2 +- .../CustomWorldAgentClarificationPanel.tsx | 2 +- .../CustomWorldAgentComposer.tsx | 52 +- .../CustomWorldAgentDraftDrawer.tsx | 2 +- .../CustomWorldAgentHeader.tsx | 3 +- ...ustomWorldAgentIntentSummaryPanel.test.tsx | 2 +- .../CustomWorldAgentIntentSummaryPanel.tsx | 4 +- .../CustomWorldAgentQuickActions.tsx | 2 +- .../CustomWorldAgentSummaryPanel.tsx | 2 +- .../CustomWorldAgentThread.test.tsx | 28 + .../CustomWorldAgentThread.tsx | 41 +- ...omWorldAgentWorkspace.interaction.test.tsx | 496 ++------ .../CustomWorldAgentWorkspace.test.tsx | 91 +- .../CustomWorldAgentWorkspace.tsx | 665 +--------- .../EightAnchorProgressBar.tsx | 105 ++ .../game-shell/PlatformHomeView.tsx | 57 +- .../game-shell/PlatformWorldDetailView.tsx | 46 +- ...meSelectionFlow.agent.interaction.test.tsx | 114 +- .../game-shell/PreGameSelectionFlow.tsx | 146 ++- src/data/customWorldLibrary.ts | 14 + src/data/customWorldSceneGraph.ts | 1 + src/services/aiService.ts | 98 ++ src/services/customWorld.ts | 40 + .../customWorldAgentDraftResult.test.ts | 25 + src/services/customWorldAgentDraftResult.ts | 1 + ...customWorldAgentGenerationProgress.test.ts | 30 +- .../customWorldAgentGenerationProgress.ts | 190 ++- src/services/storageService.test.ts | 105 ++ src/services/storageService.ts | 38 +- src/tools/QwenSpriteSheetTool.tsx | 10 +- src/types/core.ts | 5 + src/types/customWorld.ts | 6 + tools/Caddyfile.dev | 6 + vite.config.ts | 20 + 77 files changed, 8538 insertions(+), 2413 deletions(-) create mode 100644 docs/prd/AI_NATIVE_AGENT_FIRST_EIGHT_ANCHOR_MINIMUM_LOOP_PRD_2026-04-17.md create mode 100644 server-node/src/services/customWorldAgentTestHelpers.ts create mode 100644 server-node/src/services/customWorldEntityGenerationService.test.ts create mode 100644 server-node/src/services/eightAnchorCompatibilityService.ts create mode 100644 server-node/src/services/eightAnchorPromptBuilder.ts create mode 100644 server-node/src/services/eightAnchorSingleTurnService.test.ts create mode 100644 server-node/src/services/eightAnchorSingleTurnService.ts create mode 100644 src/components/asset-studio/customWorldRolePromptDefaults.test.ts create mode 100644 src/components/custom-world-agent/EightAnchorProgressBar.tsx create mode 100644 src/services/storageService.test.ts diff --git a/.gitignore b/.gitignore index 2ba6b519..2b9317b9 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ temp*build*/ !/server-node/logs/.gitkeep /server-node/data/* !/server-node/data/.gitkeep +/public/generated-animations +/public/generated-character-drafts +/public/generated-characters diff --git a/docs/prd/AI_NATIVE_AGENT_FIRST_EIGHT_ANCHOR_CO_CREATION_FLOW_PRD_2026-04-16.md b/docs/prd/AI_NATIVE_AGENT_FIRST_EIGHT_ANCHOR_CO_CREATION_FLOW_PRD_2026-04-16.md index 6160dd37..1ce142bc 100644 --- a/docs/prd/AI_NATIVE_AGENT_FIRST_EIGHT_ANCHOR_CO_CREATION_FLOW_PRD_2026-04-16.md +++ b/docs/prd/AI_NATIVE_AGENT_FIRST_EIGHT_ANCHOR_CO_CREATION_FLOW_PRD_2026-04-16.md @@ -1,6 +1,6 @@ # AI 原生 Agent-First 八锚点共创流程 PRD -更新时间:`2026-04-16` +更新时间:`2026-04-17` ## 0. 文档目的 @@ -23,7 +23,7 @@ ## 1.1 一句话定义 -让玩家通过与一个懂 RPG 剧情策划方法的 Agent 对话,在自然聊天中逐步明确作品方向、玩家视角、剧情发动机和世界统一母题;同时由 Express 后端把这些聊天沉淀成结构化八锚点状态,并支持确认、锁定、补缺和进入后续世界底稿生成。 +让玩家通过与一个懂 RPG 剧情策划方法的 Agent 对话,在自然聊天中逐步明确作品方向、玩家视角、剧情发动机和世界统一母题;同时由 Express 后端把这些聊天沉淀成结构化八锚点状态,并支持确认、锁定、补缺、真实进度反馈和进入后续世界底稿生成。 ## 1.2 产品定位 @@ -33,7 +33,7 @@ 它应当是: -**一个会启发玩家表达、会主动总结当前理解、会识别缺口并只追问关键问题、最终把共创结果沉淀成结构化创作锚点的 Agent 共创流程。** +**一个会启发玩家表达、会主动总结当前理解、会识别缺口并只追问一个高杠杆问题、最终把共创结果沉淀成结构化创作锚点的 Agent 共创流程。** ## 1.3 目标用户 @@ -50,6 +50,7 @@ - Agent 是否会少问废话 - 摘要是否准确 - 锚点是否可编辑、可锁定、可回看 +- 进度条是否真实 ## 1.4 成功标准 @@ -61,6 +62,9 @@ 4. 玩家在任意时刻都能看懂“现在这个世界已经定了什么、还有什么没定、Agent 正在为什么追问”。 5. 当前锚点状态能直接进入下一阶段,生成世界底稿、关键角色、关键地点和主线第一幕。 6. 所有锚点状态更新、确认、锁定、冲突判断和完成度裁决都在 Express 后端完成,前端只负责表现和输入。 +7. 八锚点阶段的平均问答轮次控制在 `15` 轮左右。 +8. Agent 每轮只问 `1` 个主问题。 +9. 进度区不再显示抽象阶段说明,而是显示基于真实完成度的弹性进度条。 ## 1.5 本次不做什么 @@ -80,7 +84,7 @@ 现有文档已经证明,“最小锚点 + AI 初稿卡 + 系统托管层”方向是对的。 -但如果直接把锚点做成显式卡片或显式问题列表,会出现 4 个体验问题: +但如果直接把锚点做成显式卡片或显式问题列表,会出现 5 个体验问题: 1. 玩家会有表单焦虑 - 明明只是有一个灵感,却像在填写策划需求单 @@ -94,6 +98,9 @@ 4. 锚点层级混乱时,玩家会觉得问题很多但抓不住重点 - 不知道先定体验,还是先定设定,还是先定剧情 +5. 当前“阶段提示”过于抽象 + - 玩家看不到真实完成度,也不知道为什么还没结束 + ## 2.2 新方案的核心判断 更合理的方式不是让玩家“填写八个锚点”,而是让 Agent 围绕八个锚点做 3 件事: @@ -105,7 +112,7 @@ - 把玩家已经表达的内容收束成清晰锚点 3. 补缺 - - 只追问当前最影响后续生成质量的缺口 + - 只追问当前最影响后续生成质量的一个缺口 也就是说: @@ -183,6 +190,7 @@ 3. 每聊一两轮,我都能明显看到这个世界变得更成形。 4. 如果我一开始只说了一个模糊点子,Agent 也能把我带进状态。 5. 如果我说得已经很多,Agent 不会浪费时间问明显问题。 +6. 进度条会诚实反馈进展,而不是每轮机械上涨。 ## 4.2 业务目标 @@ -200,6 +208,9 @@ 4. 可控性 - 锚点状态清晰、可回看、可锁定、可修改 +5. 效率 + - 平均轮次稳定控制在 `15` 轮左右 + --- ## 5. 核心原则 @@ -221,7 +232,7 @@ 当多个锚点都不完整时,Agent 不应平均追问。 -系统必须基于优先级只选择当前最影响后续生成质量的 `1~2` 个问题。 +系统必须基于优先级只选择当前最影响后续生成质量的 `1` 个问题。 默认优先级如下: @@ -249,6 +260,8 @@ 2. 哪些已经比较稳 3. 接下来只差什么就能往下走 +但即使在做总结的轮次里,也只能保留 `1` 个主问题,不允许总结后再追加第二个待答问题。 + ## 5.4 显式区分确认与推断 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. 整体流程体验 @@ -288,6 +334,32 @@ Agent 可以根据玩家的话做合理推断,但不能把推断伪装成已 5. `共识确认` 6. `进入世界底稿生成` +## 6.2 轮次预算概览 + +建议把八锚点阶段的平均轮次预算控制为: + +1. `第 1~3 轮` + - 接住灵感并快速建立方向盘层 + +2. `第 4~9 轮` + - 高密度补齐剧情发动机层 + +3. `第 10~12 轮` + - 收束标志元素、硬规则和暗线节奏 + +4. `第 13~15 轮` + - 做共识确认、补最后高风险缺口、准备进入下一阶段 + +软上限: + +- `15` 轮 + +硬上限: + +- `18` 轮 + +如果超过 `18` 轮仍未达到可用底稿标准,系统必须触发“收束模式”,优先产出当前最好版本,而不是继续无上限追问。 + --- ## 7. 阶段设计 @@ -326,6 +398,10 @@ Agent 可以根据玩家的话做合理推断,但不能把推断伪装成已 - 世界已经开始成形了 - 下一步很容易答 +### 轮次预算要求 + +阶段 A 默认只占 `1~2` 轮。 + ## 7.2 阶段 B:方向盘收束 ### 目标 @@ -359,6 +435,10 @@ Agent 可以根据玩家的话做合理推断,但不能把推断伪装成已 2. 玩家幻想包含 `身份或追求` 与 `失去恐惧或代价` 3. 主题边界至少包含 `1` 条风格方向与 `1` 条禁忌边界 +### 轮次预算要求 + +阶段 B 默认应在 `3~4` 轮内完成,不应为了把语言润色到完美而过度追问。 + ## 7.3 阶段 C:剧情发动机补齐 ### 目标 @@ -394,6 +474,12 @@ Agent 可以根据玩家的话做合理推断,但不能把推断伪装成已 3. 至少有 `2` 条关键关系骨架 4. 暗线与揭示节奏至少明确 `1` 条隐藏真相和 `1` 条延后揭示意图 +### 轮次预算要求 + +阶段 C 是信息密度最高的阶段,默认占用 `5~6` 轮预算。 + +如果玩家单轮回答已经同时覆盖开场、冲突、关系和暗线,系统必须允许跳过后续冗余追问,直接推进。 + ## 7.4 阶段 D:世界统一母题收束 ### 目标 @@ -416,6 +502,10 @@ Agent 可以根据玩家的话做合理推断,但不能把推断伪装成已 1. 至少确认 `2~5` 个标志元素 2. 至少确认 `1~3` 条硬规则 +### 轮次预算要求 + +阶段 D 默认应在 `2~3` 轮内完成。 + ## 7.5 阶段 E:共识确认 ### 目标 @@ -442,6 +532,12 @@ Agent 必须输出一份结构化但仍然易读的摘要,分成三类: 2. 锁定部分锚点后进入下一阶段 3. 指定某个锚点继续精修 +### 轮次预算要求 + +阶段 E 默认只占 `1~2` 轮。 + +如果用户没有明确异议,系统应倾向于推进,而不是反复征求确认。 + ## 7.6 阶段 F:进入世界底稿生成 ### 目标 @@ -474,19 +570,28 @@ Agent 必须输出一份结构化但仍然易读的摘要,分成三类: - 用 `2~4` 条短句总结已浮现的锚点 3. `补缺` - - 只问 `1` 个主问题,必要时附 `1` 个轻量补充问法 + - 只问 `1` 个主问题 -## 8.2 禁止行为 +## 8.2 单问约束 + +为了保证单轮只问一个问题,Agent 回复生成时必须经过单问检查: + +1. 回复中只允许一个主问句。 +2. 若存在第二个问号,默认判定为违规。 +3. “你更偏 A、B、C 还是 D”这类选项式问法视为一个问题。 +4. “你更偏 A 吗?如果不是为什么”这类双槽位问法视为两个问题,禁止输出。 + +## 8.3 禁止行为 这一阶段禁止 Agent 出现以下回复模式: 1. 连续大段夸赞,没有实质推进 2. 把玩家原话换个说法重复一遍就结束 -3. 一次抛出 `5` 个以上问题 +3. 一次抛出 `2` 个以上待答槽位 4. 在锚点未稳定前自动生成成批设定 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. 前台交互设计 @@ -512,21 +701,26 @@ Agent 必须输出一份结构化但仍然易读的摘要,分成三类: 沿用现有创作工作区,不新开页面。 -只在现有 Agent 工作区中新增更明确的锚点反馈区和阶段反馈区。 +只在现有 Agent 工作区中新增更明确的锚点反馈区和进度条区。 ## 9.2 工作区组成 -八锚点阶段的工作区默认包含三块: +八锚点阶段的工作区默认包含四块: 1. `左侧或主区:聊天流` - 玩家输入 - Agent 回复 - 阶段性总结 -2. `侧边摘要区:当前世界底子` +2. `顶部进度条区:当前完成进度` + - 弹性真实进度条 + - 当前模式提示 + - 剩余预算提示 + +3. `侧边摘要区:当前世界底子` - 以易读摘要展示八锚点当前状态 -3. `底部操作区:下一步动作` +4. `底部操作区:下一步动作` - 继续聊 - 确认这一版 - 锁定当前理解 @@ -549,15 +743,152 @@ Agent 必须输出一份结构化但仍然易读的摘要,分成三类: 3. `待补充` 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. 生成下一轮提问建议 5. 生成阶段性摘要 6. 判断是否进入下一阶段 +7. 计算真实进度与显示进度 +8. 管理轮次预算 ## 10.2 结构化状态模型 @@ -585,9 +918,12 @@ type AnchorField = { value: T | null; status: AnchorStatus; confidence: number; + completionScore: number; sourceMessageIds: string[]; summary: string; openQuestions: string[]; + lastUpdatedAt: string; + conflictFlags: string[]; }; type EightAnchorDraft = { @@ -600,6 +936,17 @@ type EightAnchorDraft = { hiddenLines: AnchorField; iconicElements: AnchorField; 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; }; ``` @@ -662,11 +1009,13 @@ type EightAnchorDraft = { 2. 判断新增内容是确认、补充还是冲突 3. 生成新的锚点摘要 4. 重新计算缺口优先级 -5. 产出下一轮 Agent 回复所需的: +5. 重新计算进度条 +6. 产出下一轮 Agent 回复所需的: - 当前理解 - 待补问题 - 禁止重复问的问题 - - 推荐阶段标签 + - 推荐进度条文案 + - 当前预算模式 ## 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. 完成度判断 @@ -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 阶段完成判定 系统不要求八个锚点都达到满分才允许进入下一阶段。 @@ -705,6 +1118,32 @@ type EightAnchorDraft = { 满足以上条件即可进入 `ready` +## 11.3 轮次达标判定 + +上线后的核心效率指标必须包含: + +1. 平均轮次 `<= 15` +2. `P75` 轮次 `<= 18` +3. 单轮双问违规率 `< 1%` +4. 重复问题率 `< 8%` + +## 11.4 收束模式 + +当出现以下任一情况时,系统进入收束模式: + +1. 当前轮次 `> 15` +2. 连续 `2` 轮进度增长 `< 2%` +3. 用户明显疲劳 + - 极短回答 + - 连续说“你帮我定” + - 明显不想继续细抠 + +进入收束模式后: + +1. 只追问能一问收束多个缺口的问题 +2. 优先确认而不是继续发散 +3. 必要时允许以“高置信推断 + 玩家确认”完成低风险锚点 + --- ## 12. 示例体验脚本 @@ -776,6 +1215,8 @@ Agent 不应回复成八问表: - 继续打磨 - 锁定并继续 - 放弃 +6. 单轮是否违规出现多个问题 +7. 进度条上涨、停滞、回退的次数与原因 ## 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; + 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. 八锚点结构化状态 -2. 锚点状态标签 +2. 真实进度条计算器 3. 单轮提炼 + 单问题追问 4. 共识确认摘要 5. 进入下一阶段的后端判定 -## 15.2 第二阶段 +## 16.2 第二阶段 再补: 1. 冲突检测 2. 更细的完成度评分 -3. 阶段提示语 +3. 轮次预算器 4. 指定锚点重聊 5. 锁定后禁止自动改写 -## 15.3 第三阶段 +## 16.3 第三阶段 继续补: 1. 更强的 Agent 提问策略 2. 更丰富的摘要模板 -3. 基于锚点的底稿质量评估 +3. harness 与发布门禁 4. 对不同题材的提问风格适配 --- -## 16. 验收标准 +## 17. 验收标准 本 PRD 对应功能完成后,至少必须满足: 1. 玩家只输入一段模糊灵感时,Agent 能给出有效提炼和一个高杠杆追问。 2. 玩家连续多轮输入后,八锚点摘要会持续更新,不只是聊天记录增长。 -3. 工作区能稳定显示每个锚点的当前状态。 +3. 工作区能稳定显示真实进度条和每个锚点的当前状态。 4. Agent 不会在同一锚点已高置信完成后继续反复追问。 5. 玩家可明确确认当前理解、锁定部分锚点或指定某个锚点继续打磨。 6. 八锚点状态能被后端判定为 `ready` 并进入世界底稿生成。 7. 前端不承担锚点完成度判断、冲突裁决和下一步阶段判断。 -8. 相关测试、`check:encoding` 通过。 +8. 平均轮次控制在 `15` 轮左右,且 `P75` 不超过 `18` 轮。 +9. 单轮双问违规率低于 `1%`。 +10. harness、相关测试、`check:encoding` 通过。 --- -## 17. 一句话结论 +## 18. 一句话结论 八锚点真正应该做成的,不是一套问卷,也不是一堆字段,而是: -**一个由 Agent 主导的启发式共创流程:先接住灵感,再提炼方向,再补齐剧情发动机,最后把玩家和 Agent 的共识沉淀成可运行的世界底子。** +**一个由 Agent 主导、带真实进度条、受轮次预算约束、并可被 harness 持续校验的启发式共创流程:先接住灵感,再提炼方向,再补齐剧情发动机,最后把玩家和 Agent 的共识沉淀成可运行的世界底子。** diff --git a/docs/prd/AI_NATIVE_AGENT_FIRST_EIGHT_ANCHOR_MINIMUM_LOOP_PRD_2026-04-17.md b/docs/prd/AI_NATIVE_AGENT_FIRST_EIGHT_ANCHOR_MINIMUM_LOOP_PRD_2026-04-17.md new file mode 100644 index 00000000..ac003211 --- /dev/null +++ b/docs/prd/AI_NATIVE_AGENT_FIRST_EIGHT_ANCHOR_MINIMUM_LOOP_PRD_2026-04-17.md @@ -0,0 +1,1082 @@ +# AI 原生 Agent-First 八锚点最小闭环 PRD + +更新时间:`2026-04-17` + +## 0. 文档目的 + +这份 PRD 只覆盖八锚点共创流程的**最小可上线闭环**。 + +它不讨论完整八阶段创作产品,也不覆盖角色卡、地点卡、世界底稿生成等后续能力。 + +这份文档只回答一个问题: + +**如果现在只做最小闭环,怎样把每轮聊天收束成一次模型调用,并让模型同时输出“新的完整八锚点 + 当前进度百分比 + 给用户的回复内容”。** + +本文件只遵循以下主张: + +1. 每轮只做一次模型调用 +2. 当前八锚点整包直接作为上下文输入 +3. 模型输出新的完整八锚点内容,直接覆盖旧内容 +4. 不做 merge +5. 不做服务端 progress 计算 +6. 不做输出校验 +7. 不做模板回复或 fallback + +--- + +## 1. 一句话定义 + +最小闭环 = `用户输入 -> 把提示词 + 当前八锚点内容 + 用户聊天记录发给模型 -> 模型返回新的完整八锚点内容 + progress + 回复文本 -> 服务端直接保存并回给前端` + +--- + +## 2. 本次范围 + +## 2.1 纳入范围 + +最小闭环必须同时包含以下能力: + +1. Agent 会话收消息 +2. 当前八锚点内容持久化 +3. 单次模型调用 +4. 新八锚点整包覆盖旧八锚点 +5. progress 百分比展示 +6. 模型回复直出给用户 + +## 2.2 明确不做 + +本次不做: + +1. 世界底稿生成 +2. 角色卡 / 地点卡 / 势力卡编译 +3. 锚点 merge 逻辑 +4. 服务端 progress 计算 +5. 输出校验与 repair +6. deterministic fallback +7. 摘要面板 +8. 局部锁定与局部重生成 + +原则: + +**先把“一轮一调用、一包一覆盖”的最短主链打通。** + +--- + +## 3. 最小闭环目标 + +这个最小闭环上线后,至少必须满足: + +1. 每轮只进行 `1` 次模型调用。 +2. 输入固定为:提示词、当前八锚点内容、用户聊天记录。 +3. 输出固定为:新的八锚点内容、progress 百分比、模型回复用户的内容。 +4. 新八锚点内容直接覆盖旧八锚点内容。 +5. progress 最低为 `0`,不允许为负数。 +6. 前端不显示摘要面板,只显示聊天流和进度条。 + +--- + +## 4. 最小闭环总链路 + +最小闭环固定为以下链路: + +```text +用户发送消息 +-> 服务端读取当前八锚点内容 + 聊天记录 +-> 组装单次模型 prompt +-> 调用模型 +-> 得到新的完整八锚点内容 + progress + reply +-> 服务端直接覆盖保存八锚点内容 +-> 服务端保存 progress +-> 服务端保存 reply +-> 前端刷新聊天流和进度条 +-> 等待下一轮用户输入 +``` + +本方案中: + +1. 不存在 Intent Extraction 与 Reply Rendering 的拆分 +2. 不存在服务端 question selector +3. 不存在 deterministic ready 判定 +4. 不存在 reply fallback + +--- + +## 5. 最小闭环模块划分 + +建议最小闭环涉及以下模块: + +- `packages/shared/src/contracts/customWorldAgent.ts` +- `server-node/src/services/customWorldAgentSessionStore.ts` +- `server-node/src/services/customWorldAgentOrchestrator.ts` +- `server-node/src/services/eightAnchorSingleTurnService.ts` +- `server-node/src/services/eightAnchorPromptBuilder.ts` +- `src/components/custom-world-agent/CustomWorldAgentWorkspace.tsx` +- `src/components/custom-world-agent/EightAnchorProgressBar.tsx` + +本方案下,不再需要以下最小闭环模块: + +- `server-node/src/services/eightAnchorQuestionSelector.ts` +- `server-node/src/services/eightAnchorReplyRenderService.ts` +- `server-node/src/services/eightAnchorResponseValidator.ts` +- `src/components/custom-world-agent/EightAnchorSummaryPanel.tsx` + +--- + +## 6. 数据结构 + +## 6.1 八锚点内容 + +本方案不强调复杂状态字段,不在最小闭环里维护 `missing / inferred / confirmed / locked` 四态。 + +最小闭环只保存“当前完整八锚点内容”。 + +```ts +type EightAnchorContent = { + worldPromise: WorldPromiseValue | null; + playerFantasy: PlayerFantasyValue | null; + themeBoundary: ThemeBoundaryValue | null; + playerEntryPoint: PlayerEntryPointValue | null; + coreConflict: CoreConflictValue | null; + keyRelationships: KeyRelationshipValue[]; + hiddenLines: HiddenLineValue | null; + iconicElements: IconicElementValue | null; +}; +``` + +## 6.2 会话快照 + +```ts +type EightAnchorSessionSnapshot = { + sessionId: string; + currentTurn: number; + anchorContent: EightAnchorContent; + progressPercent: number; + lastAssistantReply: string | null; +}; +``` + +--- + +## 7. 输入与输出定义 + +## 7.1 模型输入 + +每轮输入只包含三类内容: + +1. 提示词 +2. 当前八锚点内容 +3. 用户聊天记录 + +建议服务端组装后的输入结构为: + +```ts +type SingleTurnModelInput = { + prompt: string; + currentAnchorContent: EightAnchorContent; + chatHistory: Array<{ + role: 'user' | 'assistant'; + content: string; + }>; +}; +``` + +### 输入约束 + +1. `currentAnchorContent` 必须始终传完整对象,不传 patch。 +2. `chatHistory` 可以做长度裁剪,但保留最近有效上下文。 +3. 如果当前没有八锚点内容,则传空壳初始对象。 + +## 7.2 模型输出 + +每轮输出固定包含三类内容: + +1. 新的完整八锚点内容 +2. progress 百分比 +3. 模型回复用户的内容 + +```ts +type SingleTurnModelOutput = { + nextAnchorContent: EightAnchorContent; + progressPercent: number; + replyText: string; +}; +``` + +### 输出约束 + +1. `nextAnchorContent` 必须是新的完整八锚点对象,不是 patch。 +2. `progressPercent` 最低为 `0`,不允许为负数。 +3. `replyText` 直接发送给用户。 + +--- + +## 8. 运行机制 + +## 8.1 旧八锚点的使用方式 + +每轮调用模型时,都将上一版完整八锚点内容作为上下文输入。 + +模型应基于: + +1. 上一版八锚点内容 +2. 新增聊天信息 + +重新产出一版新的完整八锚点内容。 + +## 8.2 新八锚点的保存方式 + +模型返回的 `nextAnchorContent` 直接覆盖当前保存的 `anchorContent`。 + +规则只有一条: + +`新输出覆盖旧输出` + +本方案不做: + +1. 字段级 merge +2. patch 合并 +3. inferred / confirmed 区分 +4. conflict merge + +## 8.3 progress 的使用方式 + +`progressPercent` 完全由模型输出,服务端不再计算。 + +服务端只负责: + +1. 保存模型给出的 progress +2. 下发给前端显示 + +本方案不做: + +1. 基于八锚点完成度计算 progress +2. 服务端纠正 progress +3. progress 回退逻辑 + +唯一硬约束是: + +`progressPercent >= 0` + +## 8.4 回复的使用方式 + +`replyText` 由模型直接输出,服务端直接发送给用户。 + +本方案不做: + +1. 回复重写 +2. deterministic 模板拼装 +3. fallback 回复 + +--- + +## 9. 单次模型调用的提示词机制 + +## 9.1 目标 + +通过一次模型调用,同时完成: + +1. 理解用户本轮输入 +2. 结合上一版八锚点内容更新世界设定 +3. 输出新的完整八锚点内容 +4. 判断当前 progress +5. 生成对用户的回复 + +这套机制的重点不是“写一个固定 prompt”,而是: + +**在不拆多次调用的前提下,让同一个 prompt 随轮次、进度、用户输入质量和改口情况动态变化。** + +## 9.2 提示词组织结构 + +单次模型 prompt 固定拆成六段: + +1. `base system prompt` +2. `global hard rules` +3. `dynamic mode rules` +4. `current anchor context` +5. `chat history context` +6. `output contract reminder` + +其中: + +1. `base system prompt` + - 永久不变的角色定义 + +2. `global hard rules` + - 永久不变的输出约束 + +3. `dynamic mode rules` + - 每轮根据状态变化的行为指令 + +4. `current anchor context` + - 当前完整八锚点内容 + +5. `chat history context` + - 用户聊天记录 + +6. `output contract reminder` + - 强化单次输出的 JSON 格式 + +## 9.3 固定 base system prompt + +以下内容建议作为固定 system prompt 原文: + +```text +你是一个负责“八锚点共创”的世界设定 Agent。 + +你正在和用户一起共创一个游戏世界。每一轮你都必须读取: +1. 当前完整八锚点内容 +2. 用户聊天记录 + +然后输出: +1. 一版新的完整八锚点内容 +2. 当前 progress 百分比 +3. 一段直接回复用户的话 + +你必须把“新的完整八锚点内容”视为下一轮的唯一有效版本。 +你的输出会直接覆盖上一版八锚点内容。 + +你不是在做局部 patch。 +你不是在做解释报告。 +你不是在给开发者写分析。 +你是在同时完成: +1. 世界设定更新 +2. 当前推进程度判断 +3. 对用户的共创回复 +``` + +## 9.4 固定 global hard rules + +以下内容建议固定拼在 base system prompt 后面: + +```text +全局硬约束: + +1. 必须输出完整的八锚点内容,而不是只输出变化部分。 +2. 新的八锚点内容会直接覆盖旧内容,因此不得随意丢失仍然成立的重要信息。 +3. 如果用户明确修正旧设定,必须在新的八锚点内容中直接体现修正结果。 +4. 如果用户输入信息不足,可以保留上一版中仍然成立的内容。 +5. progressPercent 最低为 0,不允许为负数。 +6. replyText 会直接发送给用户,因此要自然、直接、可继续聊天。 +7. 不要输出额外解释,不要输出 markdown 代码块,不要输出开发备注。 +8. replyText 不要写成长篇策划文,不要展开大段世界观百科。 +9. replyText 默认只推进当前最关键的一步,不要同时抛出很多话题。 +10. 你输出的 JSON 必须可以被直接解析。 +``` + +## 9.5 动态机制总览 + +动态变化只发生在 `dynamic mode rules` 这一段。 + +服务端每轮根据当前状态生成一个 `PromptDynamicState`: + +```ts +type PromptDynamicState = { + currentTurn: number; + progressPercent: number; + userInputSignal: 'rich' | 'normal' | 'sparse' | 'correction' | 'delegate'; + driftRisk: 'low' | 'medium' | 'high'; + quickFillRequested: boolean; + conversationMode: + | 'bootstrap' + | 'expand' + | 'compress' + | 'repair_direction' + | 'force_complete' + | 'closing'; +}; +``` + +`dynamic mode rules` 只由这些信号驱动,不依赖服务端额外计算复杂逻辑。 + +## 9.6 动态状态判定规则 + +### 9.6.1 `currentTurn` + +直接来自会话轮次。 + +### 9.6.2 `progressPercent` + +直接来自上一轮模型已保存的 progress。 + +### 9.6.3 `userInputSignal` + +建议用轻量规则判定: + +1. `rich` + - 本轮用户输入明显包含多个设定点 + - 例如同时提到身份、冲突、气质、关系 + +2. `normal` + - 本轮输入有效,但主要集中在一个方向 + +3. `sparse` + - 本轮输入很短、很虚、缺少具体设定 + +4. `correction` + - 用户明确表达推翻、修正、改方向 + - 如“不是这个意思”“改成”“我想换成” + +5. `delegate` + - 用户明确把决定权交给 Agent + - 如“你帮我想”“你来定” + +### 9.6.6 `quickFillRequested` + +当用户点击 `一键补全剩余设定` 时: + +- `quickFillRequested = true` + +否则: + +- `quickFillRequested = false` + +### 9.6.4 `driftRisk` + +建议按以下轻量规则判断: + +1. `high` + - 最近两轮用户多次改口 + - 或当前 progress 已较高但用户突然改大方向 + +2. `medium` + - 话题开始扩散、用户同时抛很多新题材词 + +3. `low` + - 用户在沿着当前方向补充 + +### 9.6.5 `conversationMode` + +建议按以下规则生成: + +1. `bootstrap` + - `currentTurn <= 3` 或 `progressPercent < 15` + +2. `expand` + - `currentTurn <= 10` 且 `progressPercent < 65` + +3. `compress` + - `currentTurn > 10` 或 `progressPercent >= 65` + +4. `repair_direction` + - `userInputSignal === 'correction'` 或 `driftRisk === 'high'` + +5. `force_complete` + - `quickFillRequested === true` + +6. `closing` + - `progressPercent >= 85` 或 `currentTurn >= 15` + +优先级: + +`force_complete > repair_direction > closing > compress > expand > bootstrap` + +## 9.7 dynamic mode rules 详细模板 + +### 9.7.1 `bootstrap` 模式提示词 + +```text +当前模式:bootstrap + +目标: +1. 先把世界的基本方向抓住 +2. 不要一次塞太多新设定 +3. 回复要降低用户开口压力 + +本轮行为要求: +1. 优先从用户输入里抓世界承诺、玩家幻想、主题边界的线索 +2. 如果用户信息很少,不要强行把八个锚点全部补满 +3. replyText 要像共创搭档,而不是像审问 +4. 默认只推进一个最关键的问题方向 +``` + +### 9.7.2 `expand` 模式提示词 + +```text +当前模式:expand + +目标: +1. 在保持现有方向的前提下,把八锚点逐步补全 +2. 尽量让一轮输入覆盖多个锚点 + +本轮行为要求: +1. 继续保留上一版里仍成立的设定 +2. 优先把用户本轮输入映射进多个锚点,而不是只更新一个字段 +3. replyText 要明确体现“你已经理解了哪些内容” +4. 不要突然大幅改写已经成形的世界 +``` + +### 9.7.3 `compress` 模式提示词 + +```text +当前模式:compress + +目标: +1. 开始收束八锚点 +2. 减少无效发散 +3. 让 progress 更接近可进入下一阶段 + +本轮行为要求: +1. 新的八锚点内容优先保留稳定内容,不要无端重写 +2. 对用户本轮输入做高密度吸收 +3. replyText 要更聚焦,不要绕圈 +4. 默认只推进当前最影响 completion 的一步 +``` + +### 9.7.4 `repair_direction` 模式提示词 + +```text +当前模式:repair_direction + +目标: +1. 处理用户对既有设定的修正 +2. 避免世界方向飘散或自相矛盾 + +本轮行为要求: +1. 如果用户明确改口,新的八锚点内容必须体现修正后的方向 +2. 对已经不再成立的旧设定,不要机械保留 +3. progressPercent 可以停滞,也可以小幅回落,但不能为负 +4. replyText 要承认用户的修正,并顺着修正后的方向继续聊 +``` + +### 9.7.5 `closing` 模式提示词 + +```text +当前模式:closing + +目标: +1. 尽量形成一版可用的八锚点底子 +2. 不再继续发散新世界观 + +本轮行为要求: +1. 优先收束,而不是扩写 +2. 不要大改已经成形的核心设定 +3. progressPercent 接近完成时,replyText 要更像确认与推进 +4. 如果用户没有大改方向,尽量让下一版内容更稳定 +``` + +### 9.7.6 `force_complete` 模式提示词 + +```text +当前模式:force_complete + +目标: +1. 基于当前方向直接补齐剩余设定 +2. 生成一版尽量完整、可进入下一阶段的八锚点内容 +3. 结束当前收集阶段 + +本轮行为要求: +1. 尽量保留已经形成的世界方向 +2. 对明显缺失的锚点进行合理补全 +3. 不要继续拉长聊天,不要再追问用户 +4. progressPercent 直接输出为 100 +5. replyText 要自然引导用户点击“生成游戏设定草稿” +``` + +## 9.8 针对 `userInputSignal` 的附加动态指令 + +服务端除了注入 mode rules,还应根据 `userInputSignal` 追加一小段附加规则。 + +### 9.8.1 `rich` + +```text +本轮用户输入信息密度高。 +请尽量从这一轮里提取多个锚点,不要只更新单一方向。 +如果一条输入同时影响世界承诺、冲突和关系,请在新的完整八锚点中一起体现。 +``` + +### 9.8.2 `normal` + +```text +本轮用户输入为正常补充。 +请优先顺着当前方向稳定更新,不要主动扩写太多新设定。 +``` + +### 9.8.3 `sparse` + +```text +本轮用户输入较少或较虚。 +请保留上一版中仍然成立的内容,不要为了凑完整度而强行发明过多新设定。 +replyText 要让用户容易继续往下说。 +``` + +### 9.8.4 `correction` + +```text +本轮用户在修正或推翻旧设定。 +请优先吸收修正,不要机械复读旧版本。 +新的完整八锚点内容必须以修正后的方向为准。 +``` + +### 9.8.5 `delegate` + +```text +本轮用户把部分决定权交给你。 +你可以在 replyText 中给出有限度的建议,但不要突然补满整套设定。 +新的完整八锚点内容仍应尽量建立在已有世界方向上,而不是完全重做。 +``` + +## 9.9 一键补全触发时的附加动态指令 + +当 `quickFillRequested = true` 时,服务端必须在动态规则后追加这段控制文本: + +```text +用户刚刚主动点击了“一键补全剩余设定”。 + +这表示用户接受你基于当前方向自动补完剩余八锚点内容。 + +本轮要求: +1. 不要再继续提问 +2. 直接输出一版尽量完整的八锚点内容 +3. progressPercent 直接输出为 100 +4. replyText 要告诉用户现在可以进入“生成游戏设定草稿” +``` + +## 9.10 当前八锚点上下文模板 + +建议原文如下: + +```text +当前完整八锚点内容如下。 +你必须把它视为上一版有效世界底子。 + +如果用户没有否定其中某部分内容,且该部分仍然成立,可以继续保留。 +如果用户明确修正了某部分内容,新的完整八锚点内容必须体现修正后的版本。 + +当前完整八锚点内容: +{current_anchor_content_json} +``` + +## 9.11 用户聊天记录上下文模板 + +建议原文如下: + +```text +以下是用户聊天记录。 +请重点理解最近几轮里用户新增、修正、强调的设定信息。 +不要把早期已经被用户否定的内容继续当成最终结论。 + +用户聊天记录: +{chat_history_json} +``` + +## 9.12 输出 contract 提醒模板 + +虽然本方案不做服务端校验,但 prompt 末尾仍应固定提醒一次输出格式。 + +建议原文如下: + +```text +请严格按以下 JSON 结构输出,不要输出其他文字: +{ + "nextAnchorContent": { + "worldPromise": ..., + "playerFantasy": ..., + "themeBoundary": ..., + "playerEntryPoint": ..., + "coreConflict": ..., + "keyRelationships": ..., + "hiddenLines": ..., + "iconicElements": ... + }, + "progressPercent": 0, + "replyText": "..." +} +``` + +## 9.13 完整单次 prompt 组装顺序 + +服务端每轮应按以下顺序拼接: + +```text +[base system prompt] + +[global hard rules] + +[dynamic mode rules] + +[userInputSignal extra rules] + +[quick fill extra rules if needed] + +[current anchor context] + +[chat history context] + +[output contract reminder] +``` + +## 9.14 动态 prompt 组装伪代码 + +```ts +function buildEightAnchorSingleTurnPrompt(input: { + currentTurn: number; + progressPercent: number; + quickFillRequested: boolean; + currentAnchorContent: EightAnchorContent; + chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>; +}) { + const userInputSignal = detectUserInputSignal(input.chatHistory); + const driftRisk = detectDriftRisk(input.chatHistory, input.currentAnchorContent); + const conversationMode = pickConversationMode({ + currentTurn: input.currentTurn, + progressPercent: input.progressPercent, + userInputSignal, + driftRisk, + quickFillRequested: input.quickFillRequested, + }); + + return [ + BASE_SYSTEM_PROMPT, + GLOBAL_HARD_RULES, + MODE_RULES[conversationMode], + USER_SIGNAL_RULES[userInputSignal], + input.quickFillRequested ? QUICK_FILL_EXTRA_RULES : null, + renderCurrentAnchorContext(input.currentAnchorContent), + renderChatHistoryContext(input.chatHistory), + OUTPUT_CONTRACT_REMINDER, + ].filter(Boolean).join('\n\n'); +} +``` + +## 9.15 动态变化示例 + +### 示例 A:第 1 轮,用户只说一句模糊灵感 + +状态: + +- `currentTurn = 1` +- `progressPercent = 0` +- `userInputSignal = sparse` +- `conversationMode = bootstrap` + +动态注入结果: + +1. 使用 `bootstrap` 模式规则 +2. 使用 `sparse` 附加规则 +3. 模型会更保守保留空内容,并在 replyText 里轻推下一步 + +### 示例 B:第 6 轮,用户一口气补了身份、冲突、关系 + +状态: + +- `currentTurn = 6` +- `progressPercent = 42` +- `userInputSignal = rich` +- `conversationMode = expand` + +动态注入结果: + +1. 使用 `expand` 模式规则 +2. 使用 `rich` 附加规则 +3. 模型被明确要求一轮吸收多个锚点 + +### 示例 C:第 12 轮,用户开始改口 + +状态: + +- `currentTurn = 12` +- `progressPercent = 71` +- `userInputSignal = correction` +- `driftRisk = high` +- `conversationMode = repair_direction` + +动态注入结果: + +1. 强制切到 `repair_direction` +2. 明确要求模型以修正后的方向重写新的完整八锚点 +3. 允许 progress 停滞或小幅回落,但不允许负数 + +### 示例 D:第 16 轮,世界已接近完成 + +状态: + +- `currentTurn = 16` +- `progressPercent = 88` +- `userInputSignal = normal` +- `conversationMode = closing` + +动态注入结果: + +1. 使用 `closing` 模式规则 +2. 模型会更偏向收束与确认 +3. replyText 不应再大规模发散新设定 + +### 示例 E:用户点击“一键补全剩余设定” + +状态: + +- `quickFillRequested = true` +- `conversationMode = force_complete` + +动态注入结果: + +1. 强制切到 `force_complete` +2. 追加 quick fill 附加规则 +3. 模型本轮直接输出 `progressPercent = 100` +4. replyText 不再继续追问,而是引导进入草稿生成 + +## 9.16 详细完整提示词示例 + +以下给出一版可直接实现的完整拼装结果示例: + +```text +你是一个负责“八锚点共创”的世界设定 Agent。 + +你正在和用户一起共创一个游戏世界。每一轮你都必须读取: +1. 当前完整八锚点内容 +2. 用户聊天记录 + +然后输出: +1. 一版新的完整八锚点内容 +2. 当前 progress 百分比 +3. 一段直接回复用户的话 + +你必须把“新的完整八锚点内容”视为下一轮的唯一有效版本。 +你的输出会直接覆盖上一版八锚点内容。 + +你不是在做局部 patch。 +你不是在做解释报告。 +你不是在给开发者写分析。 +你是在同时完成: +1. 世界设定更新 +2. 当前推进程度判断 +3. 对用户的共创回复 + +全局硬约束: +1. 必须输出完整的八锚点内容,而不是只输出变化部分。 +2. 新的八锚点内容会直接覆盖旧内容,因此不得随意丢失仍然成立的重要信息。 +3. 如果用户明确修正旧设定,必须在新的八锚点内容中直接体现修正结果。 +4. 如果用户输入信息不足,可以保留上一版中仍然成立的内容。 +5. progressPercent 最低为 0,不允许为负数。 +6. replyText 会直接发送给用户,因此要自然、直接、可继续聊天。 +7. 不要输出额外解释,不要输出 markdown 代码块,不要输出开发备注。 +8. replyText 不要写成长篇策划文,不要展开大段世界观百科。 +9. replyText 默认只推进当前最关键的一步,不要同时抛出很多话题。 +10. 你输出的 JSON 必须可以被直接解析。 + +当前模式:compress + +目标: +1. 开始收束八锚点 +2. 减少无效发散 +3. 让 progress 更接近可进入下一阶段 + +本轮行为要求: +1. 新的八锚点内容优先保留稳定内容,不要无端重写 +2. 对用户本轮输入做高密度吸收 +3. replyText 要更聚焦,不要绕圈 +4. 默认只推进当前最影响 completion 的一步 + +本轮用户输入信息密度高。 +请尽量从这一轮里提取多个锚点,不要只更新单一方向。 +如果一条输入同时影响世界承诺、冲突和关系,请在新的完整八锚点中一起体现。 + +当前完整八锚点内容如下。 +你必须把它视为上一版有效世界底子。 + +如果用户没有否定其中某部分内容,且该部分仍然成立,可以继续保留。 +如果用户明确修正了某部分内容,新的完整八锚点内容必须体现修正后的版本。 + +当前完整八锚点内容: +{current_anchor_content_json} + +以下是用户聊天记录。 +请重点理解最近几轮里用户新增、修正、强调的设定信息。 +不要把早期已经被用户否定的内容继续当成最终结论。 + +用户聊天记录: +{chat_history_json} + +请严格按以下 JSON 结构输出,不要输出其他文字: +{ + "nextAnchorContent": { + "worldPromise": ..., + "playerFantasy": ..., + "themeBoundary": ..., + "playerEntryPoint": ..., + "coreConflict": ..., + "keyRelationships": ..., + "hiddenLines": ..., + "iconicElements": ... + }, + "progressPercent": 0, + "replyText": "..." +} +``` + +--- + +## 10. 最小前端展示 + +本方案下,前端只保留三块: + +1. 聊天流 +2. 进度条 / 生成按钮区 +3. 一键补全剩余设定入口 + +不再显示: + +1. 摘要面板 +2. 锚点状态标签 +3. clarification panel + +## 10.1 进度条 + +进度条完全读取服务端保存的 `progressPercent`。 + +前端不做: + +1. 再计算 +2. 再推断 +3. 再修正 + +### 10.1.1 默认状态 + +当 `progressPercent < 100` 时,顶部区域显示为普通进度条。 + +显示内容: + +1. 当前 progress 百分比 +2. 一句简短状态文案 +3. 下方的“一键补全剩余设定”入口 + +### 10.1.2 满进度后的形态切换 + +当 `progressPercent >= 100` 时,原本的自然进度条位置不再显示为细长进度条,而是切换为一个大按钮。 + +按钮文案: + +`生成游戏设定草稿` + +这个按钮应占据原进度条主视觉位置,让用户直观理解: + +`八锚点收集已完成,下一步就是进入草稿生成。` + +### 10.1.3 100% 后的交互规则 + +当进度达到 `100%` 后: + +1. 隐藏普通进度条填充效果 +2. 将同位置替换为主操作按钮 +3. 按钮点击后进入“游戏设定草稿生成”流程 + +本 PRD 不展开草稿生成内部逻辑,但必须定义这个点击行为是: + +`从八锚点最小闭环进入下一阶段的正式 handoff` + +### 10.1.4 按钮启用条件 + +`生成游戏设定草稿` 大按钮的启用条件只有一个: + +- `progressPercent >= 100` + +本方案下不再额外引入服务端 `ready` 判定来控制按钮是否显示。 + +## 10.2 一键补全剩余设定 + +在进度条区域下方,固定提供一个次级操作: + +`一键补全剩余设定` + +### 10.2.1 入口目标 + +这个入口的意义不是继续聊天,而是: + +`用户允许 Agent 直接用当前上下文补完剩余八锚点缺口,并直接进入满进度状态。` + +### 10.2.2 点击后的规则 + +当用户点击 `一键补全剩余设定` 后,本轮调用模型时,服务端需额外注入一个明确控制指令: + +- 用户授权你基于当前方向补齐剩余未成形设定 +- 本轮需要直接产出一版尽量完整的八锚点内容 +- 本轮 `progressPercent` 直接输出为 `100` + +也就是说: + +`一键补全剩余设定` 点击后,进度条直接拉满。 + +### 10.2.3 点击后的 UI 表现 + +点击后: + +1. 等待当前单次模型调用返回 +2. 返回成功后直接把 `progressPercent` 显示为 `100` +3. 原进度条区域立即切换成 `生成游戏设定草稿` 大按钮 + +### 10.2.4 点击后的模型行为约束 + +服务端需要在 prompt 的动态规则里追加专门指令: + +```text +用户刚刚触发了“一键补全剩余设定”。 + +本轮目标不是继续慢慢收集,而是基于当前已形成的方向,直接补齐一版尽量完整、可进入下一阶段的八锚点内容。 + +要求: +1. 尽量保留当前世界方向 +2. 尽量补全仍为空缺或过于薄弱的设定 +3. 本轮 progressPercent 直接输出为 100 +4. replyText 不再继续追问,而是引导用户进入“生成游戏设定草稿” +``` + +### 10.2.5 风险接受 + +本方案下,`一键补全剩余设定` 是显式接受风险的快捷路径。 + +它意味着: + +1. 用户允许模型用当前上下文自动补完剩余缺口 +2. 不再要求逐轮自然收集到 100% +3. 以更快进入草稿生成为优先目标 + +## 10.3 聊天流 + +聊天流直接展示模型产出的 `replyText`。 + +--- + +## 11. 服务端职责 + +本方案下,服务端职责极简化为: + +1. 收集输入 +2. 组装 prompt +3. 调用模型 +4. 读取模型输出 +5. 直接覆盖保存新的八锚点内容 +6. 保存 progressPercent +7. 保存 replyText +8. 回给前端 + +本方案下,服务端不负责: + +1. merge +2. 校验 +3. repair +4. fallback +5. progress 计算 +6. reply 生成 + +--- + +## 12. 最小闭环验收标准 + +本闭环完成后,至少必须满足: + +1. 每轮只有 `1` 次模型调用。 +2. 每轮输入包含:提示词、当前八锚点内容、用户聊天记录。 +3. 每轮输出包含:新的完整八锚点内容、progressPercent、replyText。 +4. `nextAnchorContent` 会直接覆盖当前八锚点内容。 +5. progressPercent 最低为 `0`,不出现负数。 +6. 前端只显示聊天流和进度条。 +7. 不存在摘要面板。 +8. 不存在 merge、校验、fallback 三条链路。 + +--- + +## 13. 一句话结论 + +八锚点最小闭环在这版方案里被进一步压缩成: + +**每轮只做一次模型调用,把“当前八锚点内容 + 用户聊天记录”交给模型,由模型直接返回“新的完整八锚点内容 + progress + 回复”,服务端只负责覆盖保存和透传显示。** diff --git a/packages/shared/src/assets/qwenSprite.ts b/packages/shared/src/assets/qwenSprite.ts index e0f884c3..796dea4e 100644 --- a/packages/shared/src/assets/qwenSprite.ts +++ b/packages/shared/src/assets/qwenSprite.ts @@ -127,14 +127,13 @@ export function getActionTemplateById(id: QwenSpriteActionTemplateId) { export function buildMasterPrompt(characterBrief: string) { return [ '单人,2D 横版游戏角色标准设定图,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。', - `视角要求:${SIDE_FACING_RIGHT_TEXT}`, - `主体要求:${SUBJECT_ONLY_TEXT}`, - `画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。${CLEAN_BACKGROUND_TEXT}`, - `风格要求:${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT} 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,道具/权杖/武器如有则存在关系合理,便于后续连续动作生成。`, - CONCEPT_INTERPRETATION_TEXT, - HUMANLIKE_PRIORITY_TEXT, - CONCEPT_HIERARCHY_TEXT, - THEME_APPLICATION_BOUNDARY_TEXT, + `视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。`, + `主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。`, + `画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。`, + `风格要求:Q版大头身动作角色,头身比固定控制在 2 到 3 头身,头部占比明显更大,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,道具/权杖/武器如有则存在关系合理,便于后续连续动作生成。`, + '请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,只有当文字设定明确要求鱼尾、触手身体、伞盖头部、无双腿、漂浮体、凝胶体或其他非人结构时,才改为对应非人身体。', + '主题词默认只作用在角色自身的服装剪裁、材质、纹样、饰品、武器和发光细节上,不要把主题词自动扩写成背景建筑、自然场景、漂浮装饰或额外环境物件。', + '视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。', characterBrief.trim(), ] .filter(Boolean) @@ -149,11 +148,11 @@ export function buildVideoActionPrompt(options: { }) { return [ `单人全身角色动作视频,动作主题是 ${options.actionTemplate.label}。`, - `角色固定为图1同一角色,保持右向斜侧身动作视角,镜头稳定,轮廓清晰,不要退化成完全 90 度纯右视图。${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT}`, - CONCEPT_INTERPRETATION_TEXT, - HUMANLIKE_PRIORITY_TEXT, - CONCEPT_HIERARCHY_TEXT, - THEME_APPLICATION_BOUNDARY_TEXT, + `角色固定为图1同一角色,保持右向斜侧身动作视角,镜头稳定,轮廓清晰,不要退化成完全 90 度纯右视图。`, + `视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。`, + `主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。`, + `画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。`, + `风格要求:Q版大头身动作角色,头身比固定控制在 2 到 3 头身,头部占比明显更大,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,道具/权杖/武器如有则存在关系合理,便于后续连续动作生成。`, `动作结构:${options.actionTemplate.sequenceLines.join(';')}。结尾要求:${options.actionTemplate.ending}。`, options.useChromaKey ? '背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。' diff --git a/packages/shared/src/contracts/customWorldAgent.ts b/packages/shared/src/contracts/customWorldAgent.ts index a2a8029d..203826a2 100644 --- a/packages/shared/src/contracts/customWorldAgent.ts +++ b/packages/shared/src/contracts/customWorldAgent.ts @@ -1,6 +1,65 @@ export type CustomWorldWorkStatus = 'draft' | 'published'; 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 { workId: string; sourceType: CustomWorldWorkSource; @@ -284,6 +343,10 @@ export interface CustomWorldAssetCoverageSummary { export interface CustomWorldAgentSessionSnapshot { sessionId: string; + currentTurn: number; + anchorContent: EightAnchorContent; + progressPercent: number; + lastAssistantReply: string | null; stage: CustomWorldAgentStage; focusCardId: string | null; creatorIntent: Record | null; @@ -351,6 +414,7 @@ export interface CreateCustomWorldAgentSessionResponse { export interface SendCustomWorldAgentMessageRequest { clientMessageId: string; text: string; + quickFillRequested?: boolean; focusCardId?: string | null; selectedCardIds?: string[]; } diff --git a/public/generated-character-drafts/story-npc-1/workflow-cache.json b/public/generated-character-drafts/story-npc-1/workflow-cache.json index 9f9d8a67..56bc64f8 100644 --- a/public/generated-character-drafts/story-npc-1/workflow-cache.json +++ b/public/generated-character-drafts/story-npc-1/workflow-cache.json @@ -1,10 +1,77 @@ { "characterId": "story-npc-艾莉丝-1", - "visualPromptText": "一位身着机械风格服饰的女性,侧身朝右,立于蒸汽灯塔顶端,脚下是工厂的金属平台,她手持机械魔杖,背后的机械羽翼展开,眼神专注而冷静,散发着旧秩序守护者的威严。", - "animationPromptText": "艾莉丝优雅地操控着机械装置和魔法,她的动作流畅而果断,时而挥动魔杖释放能量,时而借助机械羽翼在空中灵活移动,战斗中的她充满了冷静与自信,每一个动作都展现出她对局势的精准判断和对力量的掌控。", - "visualDrafts": [], - "selectedVisualDraftId": "", - "selectedAnimation": "idle", - "animationMap": null, - "updatedAt": "2026-04-16T12:19:15.547Z" + "visualPromptText": "机甲战士", + "animationPromptText": "", + "visualDrafts": [ + { + "id": "candidate-1", + "label": "候选 1", + "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" } diff --git a/scripts/run-caddy-dev.mjs b/scripts/run-caddy-dev.mjs index 41ac4f5c..b2f6a010 100644 --- a/scripts/run-caddy-dev.mjs +++ b/scripts/run-caddy-dev.mjs @@ -79,12 +79,14 @@ if (!existsSync(path.join(distRoot, 'index.html'))) { } 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); const caddyBinary = resolveCaddyBinary(); console.log('[serve:caddy] listen=:8080'); console.log(`[serve:caddy] CADDY_SITE_ROOT=${mergedEnv.CADDY_SITE_ROOT}`); +console.log(`[serve:caddy] CADDY_PUBLIC_ROOT=${mergedEnv.CADDY_PUBLIC_ROOT}`); console.log(`[serve:caddy] CADDY_API_UPSTREAM=${mergedEnv.CADDY_API_UPSTREAM}`); console.log(`[serve:caddy] config=${caddyConfigPath}`); diff --git a/scripts/smoke-same-origin-stack.ts b/scripts/smoke-same-origin-stack.ts index 649430b7..fdf49c06 100644 --- a/scripts/smoke-same-origin-stack.ts +++ b/scripts/smoke-same-origin-stack.ts @@ -20,6 +20,7 @@ const runtimeNodePath = fs.existsSync(bundledNodePath) : process.execPath; const serverBuildPath = path.join(repoRoot, 'server-node', 'dist', 'server.js'); const webBuildPath = path.join(repoRoot, 'dist', 'index.html'); +const publicRoot = path.join(repoRoot, 'public'); const proxyPort = 18080; const nodePort = 18081; const proxyBaseUrl = `http://127.0.0.1:${proxyPort}`; @@ -130,6 +131,18 @@ function contentTypeFor(filePath: string) { if (filePath.endsWith('.json')) { 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'; } @@ -138,15 +151,24 @@ function resolveStaticFile(urlPath: string) { const cleanPath = decodeURIComponent(urlPath.split('?')[0] || '/'); const normalizedPath = cleanPath === '/' ? '/index.html' : cleanPath; const trimmedRelativePath = normalizedPath.replace(/^\/+/u, ''); - const candidatePath = path.resolve(repoRoot, 'dist', trimmedRelativePath); const distRoot = path.resolve(repoRoot, 'dist'); + const publicCandidatePath = path.resolve(publicRoot, trimmedRelativePath); + const distCandidatePath = path.resolve(distRoot, trimmedRelativePath); if ( - candidatePath.startsWith(distRoot) && - fs.existsSync(candidatePath) && - fs.statSync(candidatePath).isFile() + publicCandidatePath.startsWith(publicRoot) && + fs.existsSync(publicCandidatePath) && + fs.statSync(publicCandidatePath).isFile() ) { - return candidatePath; + return publicCandidatePath; + } + + if ( + distCandidatePath.startsWith(distRoot) && + fs.existsSync(distCandidatePath) && + fs.statSync(distCandidatePath).isFile() + ) { + return distCandidatePath; } return webBuildPath; diff --git a/server-node/package-lock.json b/server-node/package-lock.json index da59b4bf..05a7f211 100644 --- a/server-node/package-lock.json +++ b/server-node/package-lock.json @@ -19,6 +19,7 @@ "pino": "^9.9.5", "pino-http": "^10.5.0", "pino-roll": "^3.1.0", + "pngjs": "^7.0.0", "zod": "^4.1.8" }, "devDependencies": { @@ -2264,6 +2265,15 @@ "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", diff --git a/server-node/package.json b/server-node/package.json index 363b0187..e93b8928 100644 --- a/server-node/package.json +++ b/server-node/package.json @@ -22,6 +22,7 @@ "pino": "^9.9.5", "pino-http": "^10.5.0", "pino-roll": "^3.1.0", + "pngjs": "^7.0.0", "zod": "^4.1.8" }, "devDependencies": { diff --git a/server-node/src/app.test.ts b/server-node/src/app.test.ts index b4cea1cb..09b1786d 100644 --- a/server-node/src/app.test.ts +++ b/server-node/src/app.test.ts @@ -12,6 +12,8 @@ import type { AppConfig } from './config.ts'; import { prepareEventStreamResponse } from './http.ts'; import { requestIdMiddleware } from './middleware/requestId.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'; type TestConfigOverrides = Partial< @@ -29,6 +31,16 @@ type TestConfigOverrides = Partial< type TestAppContext = Awaited>; +function installTestCustomWorldAgentSingleTurnLlm(context: TestAppContext) { + context.customWorldAgentOrchestrator = new CustomWorldAgentOrchestrator( + context.customWorldAgentSessions, + null, + { + singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), + }, + ); +} + function createTestConfig( testName: string, 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 () => { const app = express(); 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; + }; + + 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; + }; + + 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; + }; + + 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 () => { await withTestServer('custom-world-works', async ({ baseUrl }) => { 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 () => { - await withTestServer('custom-world-agent-messages', async ({ baseUrl }) => { - const entry = await authEntry(baseUrl, 'cw_agent', 'secret123'); + await withTestServer( + 'custom-world-agent-messages', + async ({ baseUrl, context }) => { + installTestCustomWorldAgentSingleTurnLlm(context); + const entry = await authEntry(baseUrl, 'cw_agent', 'secret123'); - const createResponse = await httpRequest( - `${baseUrl}/api/runtime/custom-world/agent/sessions`, - withBearer(entry.token, { - method: 'POST', - body: JSON.stringify({ - seedText: '一个围绕灯塔与沉船秘术的边境世界。', + const createResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world/agent/sessions`, + withBearer(entry.token, { + method: 'POST', + body: JSON.stringify({ + seedText: '一个围绕灯塔与沉船秘术的边境世界。', + }), }), - }), - ); - const created = (await createResponse.json()) as { - session: { - sessionId: string; - messages: Array<{ role: string }>; + ); + const created = (await createResponse.json()) as { + session: { + sessionId: string; + messages: Array<{ role: string }>; + }; }; - }; - assert.equal(createResponse.status, 200); - assert.equal(created.session.messages[0]?.role, 'assistant'); + assert.equal(createResponse.status, 200); + assert.equal(created.session.messages[0]?.role, 'assistant'); - const messageResponse = await httpRequest( - `${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(created.session.sessionId)}/messages`, - withBearer(entry.token, { - method: 'POST', - body: JSON.stringify({ - clientMessageId: 'client-1', - text: '玩家是一个被迫回到故乡灯塔的失职守望者。', - focusCardId: null, - selectedCardIds: [], + const messageResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(created.session.sessionId)}/messages`, + withBearer(entry.token, { + method: 'POST', + body: JSON.stringify({ + clientMessageId: 'client-1', + text: '玩家是一个被迫回到故乡灯塔的失职守望者。', + focusCardId: null, + selectedCardIds: [], + }), }), - }), - ); - const messagePayload = (await messageResponse.json()) as { - operation: { - operationId: string; - status: string; - progress: number; + ); + const messagePayload = (await messageResponse.json()) as { + operation: { + operationId: string; + status: string; + progress: number; + }; }; - }; - assert.equal(messageResponse.status, 200); - assert.equal(messagePayload.operation.status, 'queued'); - assert.equal(messagePayload.operation.progress, 10); + assert.equal(messageResponse.status, 200); + assert.equal(messagePayload.operation.status, 'queued'); + assert.equal(messagePayload.operation.progress, 10); - let operationText = ''; + let operationText = ''; - for (let attempt = 0; attempt < 20; attempt += 1) { - const operationResponse = await httpRequest( - `${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(created.session.sessionId)}/operations/${encodeURIComponent(messagePayload.operation.operationId)}`, + for (let attempt = 0; attempt < 20; attempt += 1) { + const operationResponse = await httpRequest( + `${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: { Authorization: `Bearer ${entry.token}`, }, }, ); - assert.equal(operationResponse.status, 200); - operationText = await operationResponse.text(); + const sessionPayload = (await sessionResponse.json()) as { + stage: string; + creatorIntent: { + playerPremise?: string | null; + } | null; + messages: Array<{ role: string; text: string }>; + pendingClarifications: Array<{ question: string }>; + }; - 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: { - 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); - }); + 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 () => { @@ -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 () => { await withTestServer( 'custom-world-agent-phase3-http', - async ({ baseUrl }) => { + async ({ baseUrl, context }) => { + installTestCustomWorldAgentSingleTurnLlm(context); const entry = await authEntry(baseUrl, 'cw_agent_phase3', 'secret123'); const readySession = await createReadyCustomWorldAgentSession({ baseUrl, @@ -2568,7 +2739,10 @@ test('custom world agent draft_foundation action rejects not-ready sessions over assert.equal(actionResponse.status, 400); 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( 'custom-world-agent-phase4-update-http', async ({ baseUrl, context }) => { + installTestCustomWorldAgentSingleTurnLlm(context); const entry = await authEntry( baseUrl, 'cw_agent_phase4_update', @@ -2718,6 +2893,7 @@ test('custom world agent generate_characters action appends character cards over await withTestServer( 'custom-world-agent-phase4-generate-characters-http', async ({ baseUrl, context }) => { + installTestCustomWorldAgentSingleTurnLlm(context); const entry = await authEntry(baseUrl, 'cw_agent_p4_ch', 'secret123'); const session = await createObjectRefiningCustomWorldAgentSession({ baseUrl, @@ -2817,6 +2993,7 @@ test('custom world agent generate_landmarks action appends landmark cards over h await withTestServer( 'custom-world-agent-phase4-generate-landmarks-http', async ({ baseUrl, context }) => { + installTestCustomWorldAgentSingleTurnLlm(context); const entry = await authEntry(baseUrl, 'cw_agent_p4_lm', 'secret123'); const session = await createObjectRefiningCustomWorldAgentSession({ baseUrl, @@ -3305,14 +3482,11 @@ test('profile browse history supports batch sync, dedupe ordering, isolation and '2026-04-16T12:00:00.000Z', ); - const viewerHistoryResponse = await httpRequest( - browseHistoryUrl, - { - headers: { - Authorization: `Bearer ${viewer.token}`, - }, + const viewerHistoryResponse = await httpRequest(browseHistoryUrl, { + headers: { + Authorization: `Bearer ${viewer.token}`, }, - ); + }); const viewerHistoryPayload = (await viewerHistoryResponse.json()) as { entries: Array<{ profileId: string; @@ -3346,14 +3520,11 @@ test('profile browse history supports batch sync, dedupe ordering, isolation and ['world-1', 'world-2'], ); - const authorHistoryResponse = await httpRequest( - browseHistoryUrl, - { - headers: { - Authorization: `Bearer ${author.token}`, - }, + const authorHistoryResponse = await httpRequest(browseHistoryUrl, { + headers: { + Authorization: `Bearer ${author.token}`, }, - ); + }); const authorHistoryPayload = (await authorHistoryResponse.json()) as { entries: Array; }; @@ -3374,14 +3545,11 @@ test('profile browse history supports batch sync, dedupe ordering, isolation and assert.equal(clearResponse.status, 200); assert.deepEqual(clearPayload.entries, []); - const clearedHistoryResponse = await httpRequest( - browseHistoryUrl, - { - headers: { - Authorization: `Bearer ${viewer.token}`, - }, + const clearedHistoryResponse = await httpRequest(browseHistoryUrl, { + headers: { + Authorization: `Bearer ${viewer.token}`, }, - ); + }); const clearedHistoryPayload = (await clearedHistoryResponse.json()) as { entries: Array; }; diff --git a/server-node/src/app.ts b/server-node/src/app.ts index b0efbc43..e716912c 100644 --- a/server-node/src/app.ts +++ b/server-node/src/app.ts @@ -146,6 +146,12 @@ export function createApp(context: AppContext) { withRouteMeta({ routeVersion: '2026-04-08' }), createRuntimeRoutes(context), ); + app.use( + express.static(context.config.publicDir, { + fallthrough: true, + index: false, + }), + ); app.use((request, _response, next) => { next(notFound(`接口不存在:${request.method} ${request.originalUrl}`)); diff --git a/server-node/src/db/migrations.ts b/server-node/src/db/migrations.ts index a2a15d95..b84fd58f 100644 --- a/server-node/src/db/migrations.ts +++ b/server-node/src/db/migrations.ts @@ -299,4 +299,21 @@ export const databaseMigrations: readonly DatabaseMigration[] = [ 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 + )`, + ], + }, ]; diff --git a/server-node/src/modules/assets/characterAssetRoutes.test.ts b/server-node/src/modules/assets/characterAssetRoutes.test.ts index 137e4f18..abec89fa 100644 --- a/server-node/src/modules/assets/characterAssetRoutes.test.ts +++ b/server-node/src/modules/assets/characterAssetRoutes.test.ts @@ -6,6 +6,7 @@ import path from 'node:path'; import test from 'node:test'; import express from 'express'; +import { PNG } from 'pngjs'; import type { AppConfig } from '../../config.js'; import { createCharacterAssetRoutes } from './characterAssetRoutes.js'; @@ -16,6 +17,31 @@ const PNG_BUFFER = Buffer.from( ); 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( projectRoot: 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') { res.statusCode = 200; res.setHeader('Content-Type', 'image/png'); - res.end(PNG_BUFFER); + res.end(GREEN_SCREEN_PNG_BUFFER); 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)); 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 () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-video-')); 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); + }); + }, + ); +}); diff --git a/server-node/src/modules/assets/characterAssetRoutes.ts b/server-node/src/modules/assets/characterAssetRoutes.ts index 8065deed..ed2a2113 100644 --- a/server-node/src/modules/assets/characterAssetRoutes.ts +++ b/server-node/src/modules/assets/characterAssetRoutes.ts @@ -7,7 +7,8 @@ import http, { import https from 'node:https'; 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 { 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_CHARACTER_VISUAL_MODEL = 'wan2.7-image-pro'; 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_MOTION_TRANSFER_MODEL = 'wan2.2-animate-move'; const DASHSCOPE_IMAGE_TASK_POLL_INTERVAL_MS = 2500; @@ -107,6 +109,67 @@ type DecodedMediaPayload = { 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 = { visualPromptText: 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; + primaryPrompt: string; + fallbackPrompt?: string; + extraHeaders?: Record; +}) { + 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: { roleKind: string; characterBriefText: string; @@ -463,13 +612,24 @@ async function writeJsonObjectFile( } function decodeMediaDataUrl(dataUrl: string): DecodedMediaPayload { - const matched = /^data:([^;]+);base64,(.+)$/u.exec(dataUrl); + const matched = /^data:([^,]+),(.+)$/u.exec(dataUrl); if (!matched) { throw new Error('不支持的媒体数据,要求使用 Base64 Data URL。'); } - const mimeType = matched[1]; + const metadata = matched[1]; 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 = (() => { switch (mimeType) { case 'image/jpeg': @@ -484,6 +644,8 @@ function decodeMediaDataUrl(dataUrl: string): DecodedMediaPayload { return 'mov'; case 'video/x-msvideo': return 'avi'; + case 'video/webm': + return 'webm'; default: return mimeType.split('/')[1] ?? 'bin'; } @@ -552,6 +714,15 @@ async function resolveMediaSourcePayload( }; } +async function resolveCharacterVisualPayload( + rootDir: string, + source: string, +): Promise { + return applyChromaKeyToMediaPayload( + await resolveMediaSourcePayload(rootDir, source), + ); +} + async function resolveMediaSourceAsDataUrl( rootDir: string, source: string, @@ -982,19 +1153,32 @@ function buildNpcAnimationPrompt(options: { animation: string; promptText: string; useChromaKey: boolean; + loop: boolean; characterBriefText?: string; actionTemplateId?: string; }) { + const characterBrief = buildCompactAnimationCharacterBrief( + options.characterBriefText ?? '', + ); + const actionDetailText = sanitizeAnimationPromptText(options.promptText, 140); + const loopRule = options.loop + ? '这是循环动作,直接进入动作循环中段,不要开场静止站桩,不要把主参考图原样作为第一帧。' + : '这是非循环动作,首帧和尾帧都要回到参考主图角色形象,中段完成动作变化。'; + if (options.actionTemplateId) { - return buildVideoActionPrompt({ - actionTemplate: getActionTemplateById( - options.actionTemplateId as Parameters[0], - ), - actionDetailText: options.promptText, - useChromaKey: options.useChromaKey, - characterBrief: - options.characterBriefText?.trim() || `${options.animation} 动作角色`, - }); + return [ + buildVideoActionPrompt({ + actionTemplate: getActionTemplateById( + options.actionTemplateId as Parameters[0], + ), + actionDetailText, + useChromaKey: options.useChromaKey, + characterBrief: characterBrief || `${options.animation} 动作角色`, + }), + loopRule, + ] + .filter(Boolean) + .join(' '); } return [ @@ -1004,15 +1188,50 @@ function buildNpcAnimationPrompt(options: { options.useChromaKey ? '背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。' : '背景简洁纯净,无复杂场景。', - options.characterBriefText?.trim() - ? `角色设定:${options.characterBriefText.trim()}` + characterBrief + ? `角色设定:${characterBrief}` : '', - options.promptText.trim(), + actionDetailText, + loopRule, ] .filter(Boolean) .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( config: AppConfig, req: IncomingMessage & { body?: unknown }, @@ -1318,7 +1537,7 @@ async function handleGenerateCharacterVisuals( const imageSrc = await writeDraftBinaryFile( rootDir, path.posix.join(draftRelativeDir, fileName), - imageResponse.body, + applyGreenScreenAlphaToPngBuffer(imageResponse.body), ); return { @@ -1475,6 +1694,7 @@ async function handleGenerateCharacterAnimation( Number.isFinite(body.durationSeconds) ? Math.max(1, Math.min(8, Math.round(body.durationSeconds))) : 4; + const loop = body.loop === true; const useChromaKey = body.useChromaKey !== false; const resolution = typeof body.resolution === 'string' && body.resolution.trim() @@ -1487,15 +1707,28 @@ async function handleGenerateCharacterAnimation( : runtimeEnv.DASHSCOPE_CHARACTER_IMAGE_SEQUENCE_MODEL || runtimeEnv.DASHSCOPE_CHARACTER_VISUAL_MODEL || DEFAULT_CHARACTER_VISUAL_MODEL; - const videoModel = + const requestedVideoModel = typeof body.videoModel === 'string' && body.videoModel.trim() ? body.videoModel.trim() : runtimeEnv.DASHSCOPE_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 = videoModel === 'wan2.2-kf2v-flash' ? 5 : requestedDurationSeconds; - const normalizedResolution = - videoModel === 'wan2.2-kf2v-flash' ? '480P' : resolution; + const normalizedResolution = getLowestSupportedVideoResolution( + videoModel, + videoModel === 'wan2.2-kf2v-flash' ? '480P' : resolution, + ); const referenceVideoModel = typeof body.referenceVideoModel === 'string' && body.referenceVideoModel.trim() @@ -1707,13 +1940,21 @@ async function handleGenerateCharacterAnimation( animation, promptText, useChromaKey, + loop, characterBriefText, actionTemplateId, }); + const fallbackPrompt = buildFallbackModerationSafeAnimationPrompt({ + animation, + loop, + useChromaKey, + }); activePrompt = finalPrompt; activeModel = videoModel; 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 uploadFileToDashScope( baseUrl, @@ -1722,9 +1963,12 @@ async function handleGenerateCharacterAnimation( `${characterId}-${animation}-visual`, await resolveMediaSourcePayload(rootDir, visualSource), ); - const lastFrameRef = lastFrameImageDataUrl + const resolvedLastFrameSource = !loop + ? lastFrameImageDataUrl || visualSource + : ''; + const lastFrameRef = resolvedLastFrameSource ? isKf2vFlash - ? await resolveMediaSourceAsDataUrl(rootDir, lastFrameImageDataUrl) + ? await resolveMediaSourceAsDataUrl(rootDir, resolvedLastFrameSource) : await uploadFileToDashScope( baseUrl, apiKey, @@ -1732,47 +1976,59 @@ async function handleGenerateCharacterAnimation( `${characterId}-${animation}-last-frame`, await resolveMediaSourcePayload( rootDir, - lastFrameImageDataUrl, + resolvedLastFrameSource, ), ) : ''; - const inputPayload = - isKf2vFlash + const createVideoRequestBody = (prompt: string) => ({ + model: videoModel, + input: isKf2vFlash ? { - prompt: finalPrompt, + prompt, first_frame_url: visualInputRef, ...(lastFrameRef ? { last_frame_url: lastFrameRef } : {}), } - : { - prompt: finalPrompt, - media: [ - { type: 'first_frame', url: visualInputRef }, - ...(lastFrameRef - ? [{ type: 'last_frame', url: lastFrameRef }] - : []), - ], - }; + : isWan26I2vFlash + ? { + prompt, + img_url: visualInputRef, + } + : { + 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 ? `${baseUrl}/services/aigc/image2video/video-synthesis` : `${baseUrl}/services/aigc/video-generation/video-synthesis`; - const createTaskResponse = await proxyJsonRequest( - videoSynthesisEndpoint, - apiKey, - { - model: videoModel, - input: inputPayload, - parameters: { - duration: durationSeconds, - resolution: normalizedResolution, - ...(isKf2vFlash ? { prompt_extend: true, watermark: false } : {}), + const { response: createTaskResponse, prompt: submittedPrompt } = + await proxyJsonRequestWithPromptFallback({ + urlString: videoSynthesisEndpoint, + apiKey, + buildBody: createVideoRequestBody, + primaryPrompt: finalPrompt, + fallbackPrompt, + extraHeaders: { + 'X-DashScope-Async': 'enable', + 'X-DashScope-OssResourceResolve': 'enable', }, - }, - { - 'X-DashScope-Async': 'enable', - 'X-DashScope-OssResourceResolve': 'enable', - }, - ); + }); + + activePrompt = submittedPrompt; if ( createTaskResponse.statusCode < 200 || @@ -1809,7 +2065,7 @@ async function handleGenerateCharacterAnimation( animation, strategy, model: videoModel, - prompt: finalPrompt, + prompt: submittedPrompt, createdAt, updatedAt: createdAt, }); @@ -1859,7 +2115,7 @@ async function handleGenerateCharacterAnimation( model: videoModel, strategy, animation, - prompt: finalPrompt, + prompt: submittedPrompt, createdAt: new Date().toISOString(), videoUrl, }, @@ -1877,7 +2133,7 @@ async function handleGenerateCharacterAnimation( animation, strategy, model: videoModel, - prompt: finalPrompt, + prompt: submittedPrompt, createdAt, updatedAt: new Date().toISOString(), result: { @@ -1891,7 +2147,7 @@ async function handleGenerateCharacterAnimation( taskId, strategy: 'image-to-video', model: videoModel, - prompt: finalPrompt, + prompt: submittedPrompt, previewVideoPath, }); return; @@ -1923,6 +2179,7 @@ async function handleGenerateCharacterAnimation( animation, promptText, useChromaKey, + loop, characterBriefText, }); activePrompt = finalPrompt; @@ -2081,8 +2338,8 @@ async function handleGenerateCharacterAnimation( } if (strategy === 'reference-to-video') { - const uploadedReferenceUrls = await Promise.all([ - ...referenceImageDataUrls.map(async (source, index) => + const uploadedReferenceImages = await Promise.all( + referenceImageDataUrls.map(async (source, index) => uploadFileToDashScope( baseUrl, apiKey, @@ -2091,7 +2348,9 @@ async function handleGenerateCharacterAnimation( await resolveMediaSourcePayload(rootDir, source), ), ), - ...referenceVideoDataUrls.map(async (source, index) => + ); + const uploadedReferenceVideos = await Promise.all( + referenceVideoDataUrls.map(async (source, index) => uploadFileToDashScope( baseUrl, apiKey, @@ -2100,9 +2359,13 @@ async function handleGenerateCharacterAnimation( await resolveMediaSourcePayload(rootDir, source), ), ), - ]); + ); - if (uploadedReferenceUrls.length === 0) { + if ( + !visualUrl && + uploadedReferenceImages.length === 0 && + uploadedReferenceVideos.length === 0 + ) { sendJson(res, 400, { error: { message: '参考生视频至少需要一张参考图或一段参考视频。' }, }); @@ -2113,6 +2376,7 @@ async function handleGenerateCharacterAnimation( animation, promptText, useChromaKey, + loop, characterBriefText, }); activePrompt = finalPrompt; @@ -2124,11 +2388,24 @@ async function handleGenerateCharacterAnimation( model: referenceVideoModel, input: { 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: { duration: durationSeconds, - resolution, + resolution: getLowestSupportedVideoResolution( + referenceVideoModel, + resolution, + ), prompt_optimizer: true, }, }, @@ -2688,7 +2965,7 @@ async function handlePublishCharacterVisual( ); await mkdir(visualDir, { recursive: true }); - const masterPayload = await resolveMediaSourcePayload( + const masterPayload = await resolveCharacterVisualPayload( rootDir, selectedPreviewSource, ); @@ -2697,7 +2974,7 @@ async function handlePublishCharacterVisual( const previewImagePaths: string[] = []; for (let index = 0; index < previewSources.length; index += 1) { - const previewPayload = await resolveMediaSourcePayload( + const previewPayload = await resolveCharacterVisualPayload( rootDir, previewSources[index] ?? '', ); @@ -2904,6 +3181,11 @@ async function handlePublishCharacterAnimation( startFrame: 1, extension: frameExtension, basePath, + frameWidth, + frameHeight, + fps, + loop, + ...(previewVideoPath ? { previewVideoPath } : {}), }; } diff --git a/server-node/src/repositories/runtimeRepository.ts b/server-node/src/repositories/runtimeRepository.ts index 0bbf48a6..eb9b60a7 100644 --- a/server-node/src/repositories/runtimeRepository.ts +++ b/server-node/src/repositories/runtimeRepository.ts @@ -428,7 +428,8 @@ export class RuntimeRepository implements RuntimeRepositoryPort { landmark_count AS "landmarkCount" FROM custom_world_profiles WHERE user_id = $1 - AND profile_id = $2`, + AND profile_id = $2 + AND deleted_at IS NULL`, [userId, profileId], ); @@ -887,6 +888,7 @@ export class RuntimeRepository implements RuntimeRepositoryPort { landmark_count AS "landmarkCount" FROM custom_world_profiles WHERE user_id = $1 + AND deleted_at IS NULL ORDER BY updated_at DESC LIMIT $2`, [userId, MAX_CUSTOM_WORLD_PROFILES], @@ -923,6 +925,7 @@ export class RuntimeRepository implements RuntimeRepositoryPort { ON CONFLICT (user_id, profile_id) DO UPDATE SET payload_json = EXCLUDED.payload_json, updated_at = EXCLUDED.updated_at, + deleted_at = NULL, author_display_name = EXCLUDED.author_display_name, world_name = EXCLUDED.world_name, subtitle = EXCLUDED.subtitle, @@ -959,10 +962,17 @@ export class RuntimeRepository implements RuntimeRepositoryPort { } async deleteCustomWorldProfile(userId: string, profileId: string) { + const deletedAt = new Date().toISOString(); await this.db.query( - `DELETE FROM custom_world_profiles - WHERE user_id = $1 AND profile_id = $2`, - [userId, profileId], + `UPDATE custom_world_profiles + SET deleted_at = $1, + 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); @@ -1172,6 +1182,7 @@ export class RuntimeRepository implements RuntimeRepositoryPort { landmark_count AS "landmarkCount" FROM custom_world_profiles WHERE visibility = 'published' + AND deleted_at IS NULL ORDER BY published_at DESC, updated_at DESC LIMIT $1`, [MAX_PUBLIC_CUSTOM_WORLD_PROFILES], @@ -1202,7 +1213,8 @@ export class RuntimeRepository implements RuntimeRepositoryPort { FROM custom_world_profiles WHERE user_id = $1 AND profile_id = $2 - AND visibility = 'published'`, + AND visibility = 'published' + AND deleted_at IS NULL`, [ownerUserId, profileId], ); diff --git a/server-node/src/routes/customWorldAgent.ts b/server-node/src/routes/customWorldAgent.ts index d7f74b41..33e8762f 100644 --- a/server-node/src/routes/customWorldAgent.ts +++ b/server-node/src/routes/customWorldAgent.ts @@ -18,6 +18,7 @@ const createSessionSchema = z.object({ const sendMessageSchema = z.object({ clientMessageId: 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), 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( '/sessions/:sessionId/actions', routeMeta({ operation: 'runtime.customWorldAgent.executeAction' }), diff --git a/server-node/src/services/customWorldAgentFoundationDraftService.ts b/server-node/src/services/customWorldAgentFoundationDraftService.ts index 904a03de..4760763f 100644 --- a/server-node/src/services/customWorldAgentFoundationDraftService.ts +++ b/server-node/src/services/customWorldAgentFoundationDraftService.ts @@ -5,6 +5,7 @@ import type { CustomWorldFoundationDraftLandmark, CustomWorldFoundationDraftProfile, CustomWorldFoundationDraftThread, + EightAnchorContent, } from '../../../packages/shared/src/contracts/customWorldAgent.js'; import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.js'; import { @@ -36,6 +37,13 @@ import { type CustomWorldCreatorIntentRecord, normalizeCreatorIntentRecord, } from './customWorldAgentIntentExtractionService.js'; +import { + buildCreatorIntentFromEightAnchorContent, + buildDraftSummaryFromEightAnchorContent, + buildDraftTitleFromEightAnchorContent, + buildEightAnchorFoundationText, + normalizeEightAnchorContent, +} from './eightAnchorCompatibilityService.js'; import type { UpstreamLlmClient } from './llmClient.js'; function toText(value: unknown) { @@ -923,7 +931,15 @@ function sanitizeJsonLikeText(text: string) { function buildFoundationGenerationSeedText(params: { intent: CustomWorldCreatorIntentRecord; anchorPack: unknown; + anchorContent?: EightAnchorContent | null; }) { + const anchorText = params.anchorContent + ? buildEightAnchorFoundationText(params.anchorContent) + : ''; + if (anchorText) { + return anchorText; + } + const anchorRecord = toRecord(params.anchorPack); const anchorSummary = toText(anchorRecord?.creatorIntentSummary); if (anchorSummary) { @@ -1574,12 +1590,14 @@ async function buildFoundationDraftProfileWithLlm(params: { llmClient: UpstreamLlmClient; creatorIntent: CustomWorldCreatorIntentRecord; anchorPack: unknown; + anchorContent?: EightAnchorContent | null; signal?: AbortSignal; onProgress?: DraftProgressCallback; }) { const settingText = buildFoundationGenerationSeedText({ intent: params.creatorIntent, anchorPack: params.anchorPack, + anchorContent: params.anchorContent, }); await emitDraftProgress(params.onProgress, { @@ -1720,22 +1738,14 @@ export class CustomWorldAgentFoundationDraftService { private generateFallbackDraft(params: { creatorIntent: unknown; anchorPack: unknown; + anchorContent?: EightAnchorContent | null; }): CustomWorldFoundationDraftProfile { - const intent = normalizeCreatorIntentRecord(params.creatorIntent) ?? { - sourceMode: 'freeform' as const, - rawSettingText: '', - worldHook: '', - themeKeywords: [], - toneDirectives: [], - playerPremise: '', - openingSituation: '', - coreConflicts: [], - keyFactions: [], - keyCharacters: [], - keyLandmarks: [], - iconicElements: [], - forbiddenDirectives: [], - }; + const normalizedAnchorContent = normalizeEightAnchorContent( + params.anchorContent, + ); + const intent = + normalizeCreatorIntentRecord(params.creatorIntent) ?? + buildCreatorIntentFromEightAnchorContent(normalizedAnchorContent); const anchorPack = toRecord(params.anchorPack); const worldHook = clampText(intent.worldHook || intent.rawSettingText, 72) || @@ -1757,6 +1767,8 @@ export class CustomWorldAgentFoundationDraftService { openingSituation, coreConflict: coreConflicts[0] || '', }); + const anchorDraftTitle = + buildDraftTitleFromEightAnchorContent(normalizedAnchorContent); const factions = buildFactions({ intent, coreConflicts, @@ -1815,7 +1827,10 @@ export class CustomWorldAgentFoundationDraftService { ); return { - name: worldName, + name: + anchorDraftTitle && anchorDraftTitle !== '未命名草稿' + ? anchorDraftTitle + : worldName, subtitle: clampText( [ @@ -1845,6 +1860,7 @@ export class CustomWorldAgentFoundationDraftService { openingSituation, iconicElements, sourceAnchorSummary: + buildDraftSummaryFromEightAnchorContent(normalizedAnchorContent) || toText(anchorPack?.creatorIntentSummary) || buildDraftSummaryFromIntent(intent) || summary, @@ -1854,10 +1870,15 @@ export class CustomWorldAgentFoundationDraftService { async generate(params: { creatorIntent: unknown; anchorPack: unknown; + anchorContent?: EightAnchorContent | null; signal?: AbortSignal; onProgress?: DraftProgressCallback; }): Promise { - const intent = normalizeCreatorIntentRecord(params.creatorIntent); + const intent = + normalizeCreatorIntentRecord(params.creatorIntent) ?? + buildCreatorIntentFromEightAnchorContent( + normalizeEightAnchorContent(params.anchorContent), + ); if (!this.llmClient || !intent) { return this.generateFallbackDraft(params); @@ -1867,6 +1888,7 @@ export class CustomWorldAgentFoundationDraftService { llmClient: this.llmClient, creatorIntent: intent, anchorPack: params.anchorPack, + anchorContent: params.anchorContent, signal: params.signal, onProgress: params.onProgress, }); diff --git a/server-node/src/services/customWorldAgentOrchestrator.ts b/server-node/src/services/customWorldAgentOrchestrator.ts index a6a32f44..2a2dd4b9 100644 --- a/server-node/src/services/customWorldAgentOrchestrator.ts +++ b/server-node/src/services/customWorldAgentOrchestrator.ts @@ -1,4 +1,5 @@ import crypto from 'node:crypto'; +import type { Request, Response } from 'express'; import type { CreateCustomWorldAgentSessionRequest, @@ -14,6 +15,7 @@ import type { SendCustomWorldAgentMessageResponse, } from '../../../packages/shared/src/contracts/customWorldAgent.js'; import { badRequest, notFound } from '../errors.js'; +import { prepareEventStreamResponse } from '../http.js'; import { CustomWorldAgentAssetBridgeService } from './customWorldAgentAssetBridgeService.js'; import { CustomWorldAgentChangeSummaryService } from './customWorldAgentChangeSummaryService.js'; import { @@ -31,7 +33,6 @@ import { CustomWorldAgentEntityGenerationService } from './customWorldAgentEntit import { CustomWorldAgentFoundationDraftService } from './customWorldAgentFoundationDraftService.js'; import { buildAnchorPackFromIntent, - buildCreatorIntentDisplayText, buildDraftSummaryFromIntent, buildDraftTitleFromIntent, createEmptyCreatorIntentRecord, @@ -49,11 +50,16 @@ import { type CustomWorldAgentSessionRecord, CustomWorldAgentSessionStore, } from './customWorldAgentSessionStore.js'; +import { + buildAnchorPackFromEightAnchorContent, + buildCreatorIntentFromEightAnchorContent, + buildEightAnchorContentFromCreatorIntent, + estimateProgressPercentFromAnchorContent, +} from './eightAnchorCompatibilityService.js'; +import { EightAnchorSingleTurnService } from './eightAnchorSingleTurnService.js'; import type { UpstreamLlmClient } from './llmClient.js'; const PHASE2_FORCE_FAIL_TOKEN = '__phase1_force_fail__'; -const AUTO_COMPLETE_PATTERN = /自动补全|默认方案|帮我补全/u; - function truncateText(value: string, maxLength: number) { if (value.length <= maxLength) { return value; @@ -137,46 +143,10 @@ function buildSuggestedActions( 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']) { const phaseDetail = type === 'draft_foundation' - ? '正在把已确认锚点编成第一版世界底稿。' + ? '正在把已确认设定编成第一版世界底稿。' : type === 'update_draft_card' ? '正在把这次设定改动写回草稿。' : type === 'generate_characters' @@ -184,10 +154,10 @@ function buildOperation(type: CustomWorldAgentOperationRecord['type']) { : type === 'generate_landmarks' ? '正在围绕当前底稿补出新地点。' : type === 'generate_role_assets' - ? '正在准备角色资产工坊入口。' - : type === 'sync_role_assets' - ? '正在把角色资产结果写回世界草稿。' - : '正在整理这一轮新增的世界锚点。'; + ? '正在准备角色资产工坊入口。' + : type === 'sync_role_assets' + ? '正在把角色资产结果写回世界草稿。' + : '正在整理这一轮新增的世界设定。'; return { operationId: `operation-${crypto.randomBytes(10).toString('hex')}`, @@ -223,20 +193,10 @@ function buildRoleAssetSyncResultText(params: { 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( pendingClarifications: CustomWorldPendingClarification[], ) { - return pendingClarifications.map( - (entry, index) => `${index + 1}. ${entry.question}`, - ); + return pendingClarifications.map((entry) => entry.question.trim()); } function composeAssistantReply(params: { @@ -250,7 +210,7 @@ function composeAssistantReply(params: { return [ params.openingText, params.isReady - ? '最小锚点已经齐备。' + ? '当前设定已经齐备。' : questionLines.slice(0, 1).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: { relatedOperationId: string; draftProfile: unknown; @@ -555,31 +294,6 @@ function buildFoundationDraftAssistantMessage(params: { } 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: { relatedOperationId: string; text: string; @@ -594,6 +308,19 @@ function buildActionResultMessage(params: { } 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 { private readonly foundationDraftService: CustomWorldAgentFoundationDraftService; @@ -605,9 +332,14 @@ export class CustomWorldAgentOrchestrator { private readonly assetBridgeService: CustomWorldAgentAssetBridgeService; + private readonly eightAnchorSingleTurnService: EightAnchorSingleTurnService; + constructor( private readonly sessionStore: CustomWorldAgentSessionStore, - private readonly llmClient: UpstreamLlmClient | null = null, + llmClient: UpstreamLlmClient | null = null, + options: { + singleTurnLlmClient?: UpstreamLlmClient | null; + } = {}, ) { this.foundationDraftService = new CustomWorldAgentFoundationDraftService( llmClient, @@ -618,6 +350,9 @@ export class CustomWorldAgentOrchestrator { ); this.changeSummaryService = new CustomWorldAgentChangeSummaryService(); this.assetBridgeService = new CustomWorldAgentAssetBridgeService(); + this.eightAnchorSingleTurnService = new EightAnchorSingleTurnService( + (options.singleTurnLlmClient ?? llmClient) ?? undefined, + ); } async createSession( @@ -634,59 +369,35 @@ export class CustomWorldAgentOrchestrator { : {}; const creatorIntent = mergeCreatorIntentRecord(baseIntent, seedPatch); const derivedState = buildDerivedState(creatorIntent, Boolean(seedText)); + const anchorContent = buildEightAnchorContentFromCreatorIntent(creatorIntent); + const progressPercent = seedText + ? estimateProgressPercentFromAnchorContent(anchorContent) + : 0; const fallbackWelcomeMessage = buildWelcomeMessage({ seedText, intent: creatorIntent, pendingClarifications: derivedState.pendingClarifications, 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, { seedText, - welcomeMessage: initialAssistantTurn.reply, + welcomeMessage: fallbackWelcomeMessage, + currentTurn: 0, + anchorContent, + progressPercent, + lastAssistantReply: fallbackWelcomeMessage, creatorIntent, creatorIntentReadiness: derivedState.readiness, - anchorPack: derivedState.anchorPack, + anchorPack: buildAnchorPackFromEightAnchorContent( + anchorContent, + progressPercent, + ), draftProfile: derivedState.draftProfile, pendingClarifications: derivedState.pendingClarifications, - stage: derivedState.stage, + stage: progressPercent >= 100 ? 'foundation_review' : 'collecting_intent', suggestedActions: derivedState.suggestedActions, - recommendedReplies: initialAssistantTurn.recommendedReplies, + recommendedReplies: [], }); return (await this.sessionStore.getSnapshot( @@ -723,6 +434,7 @@ export class CustomWorldAgentOrchestrator { sessionId, operationId: operation.operationId, latestUserText: trimmedText, + quickFillRequested: Boolean(payload.quickFillRequested), }); 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( userId: string, sessionId: string, @@ -741,14 +515,8 @@ export class CustomWorldAgentOrchestrator { } if (payload.action === 'draft_foundation') { - if (session.stage !== 'foundation_review') { - throw badRequest( - 'draft_foundation is only available during foundation_review', - ); - } - - if (!session.creatorIntentReadiness.isReady) { - throw badRequest('draft_foundation requires a ready session'); + if (session.progressPercent < 100) { + throw badRequest('draft_foundation requires progressPercent >= 100'); } const operation = buildOperation('draft_foundation'); @@ -919,57 +687,151 @@ export class CustomWorldAgentOrchestrator { return this.draftCompiler.getDraftCardDetail(session.draftProfile, cardId); } - private async generateAssistantTurn(params: { - session: CustomWorldAgentSessionRecord; + private async applyMessageTurn(params: { + userId: string; + sessionId: string; latestUserText: string; - fallbackReply: string; - intent: CustomWorldCreatorIntentRecord; - pendingClarifications: CustomWorldPendingClarification[]; - isReady: boolean; + quickFillRequested: boolean; + relatedOperationId?: string | null; + onReplyUpdate?: (text: string) => void; }) { - const fallbackReplies = buildFallbackRecommendedReplies({ - pendingClarifications: params.pendingClarifications, - isReady: params.isReady, + const latestSession = (await this.sessionStore.get( + params.userId, + 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 { - reply: params.fallbackReply, - recommendedReplies: fallbackReplies, - }; - } - - 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, - }; - } + return (await this.sessionStore.getSnapshot( + params.userId, + params.sessionId, + )) as CustomWorldAgentSessionSnapshot; } private async processDraftFoundationOperation(params: { @@ -983,7 +845,7 @@ export class CustomWorldAgentOrchestrator { await this.sessionStore.updateOperation(userId, sessionId, operationId, { status: 'running', phaseLabel: '生成世界底稿', - phaseDetail: '正在根据已确认锚点编译第一版世界结构。', + phaseDetail: '正在根据已确认设定编译第一版世界结构。', progress: 38, }); @@ -997,16 +859,22 @@ export class CustomWorldAgentOrchestrator { throw new Error('custom world agent session not found'); } - if ( - latestSession.stage !== 'foundation_review' || - !latestSession.creatorIntentReadiness.isReady - ) { - throw new Error('session is not ready for draft_foundation'); + if (latestSession.progressPercent < 100) { + throw new Error('session progressPercent is below 100'); } + const creatorIntent = buildCreatorIntentFromEightAnchorContent( + latestSession.anchorContent, + ); + const anchorPack = buildAnchorPackFromEightAnchorContent( + latestSession.anchorContent, + latestSession.progressPercent, + ); + const draftProfile = await this.foundationDraftService.generate({ - creatorIntent: latestSession.creatorIntent, - anchorPack: latestSession.anchorPack, + creatorIntent, + anchorPack, + anchorContent: latestSession.anchorContent, onProgress: async (progress) => { await this.sessionStore.updateOperation( userId, @@ -1040,6 +908,8 @@ export class CustomWorldAgentOrchestrator { await this.sessionStore.replaceDerivedState(userId, sessionId, { stage: nextStage, + creatorIntent, + anchorPack, draftProfile: draftProfile as unknown as Record, draftCards, assetCoverage, @@ -1070,7 +940,7 @@ export class CustomWorldAgentOrchestrator { await this.sessionStore.updateOperation(userId, sessionId, operationId, { status: 'failed', phaseLabel: '底稿生成失败', - phaseDetail: '这一轮没有成功把锚点编成世界底稿。', + phaseDetail: '这一轮没有成功把设定编成世界底稿。', progress: 100, error: error instanceof Error ? error.message : 'draft foundation failed', @@ -1596,14 +1466,23 @@ export class CustomWorldAgentOrchestrator { sessionId: string; operationId: string; latestUserText: string; + quickFillRequested: boolean; }) { - const { userId, sessionId, operationId, latestUserText } = params; + const { + userId, + sessionId, + operationId, + latestUserText, + quickFillRequested, + } = params; try { await this.sessionStore.updateOperation(userId, sessionId, operationId, { status: 'running', - phaseLabel: '提取世界锚点', - phaseDetail: '正在把这轮自然语言补充整理成结构化创作意图。', + phaseLabel: quickFillRequested ? '补全剩余设定' : '整理当前设定', + phaseDetail: quickFillRequested + ? '正在基于当前方向补齐剩余设定。' + : '正在把这轮输入沉淀成新的完整设定。', progress: 45, }); @@ -1621,107 +1500,27 @@ export class CustomWorldAgentOrchestrator { 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 = (latestSession.stage === 'object_refining' || latestSession.stage === 'visual_refining') && 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, { - 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( + await this.applyMessageTurn({ userId, sessionId, - assistantMessage, - ); - await this.sessionStore.replaceDerivedState(userId, sessionId, { - recommendedReplies, + latestUserText, + quickFillRequested, + relatedOperationId: operationId, }); await this.sessionStore.updateOperation(userId, sessionId, operationId, { status: 'completed', - phaseLabel: '锚点已更新', + phaseLabel: '设定已更新', phaseDetail: shouldPreserveDraftStage ? '这轮补充已挂回当前底稿语境,现有草稿卡保持可继续浏览。' - : derivedState.readiness.isReady - ? '最小锚点已齐备,可以进入下一阶段。' - : '这一轮的创作锚点和澄清问题已经同步完成。', + : quickFillRequested + ? '剩余设定已补全,现在可以进入游戏设定草稿生成。' + : '这一轮的设定更新已经完成。', progress: 100, error: null, }); @@ -1729,7 +1528,7 @@ export class CustomWorldAgentOrchestrator { await this.sessionStore.updateOperation(userId, sessionId, operationId, { status: 'failed', phaseLabel: '处理失败', - phaseDetail: '这一轮消息没有成功沉淀为创作锚点。', + phaseDetail: '这一轮消息没有成功沉淀为当前设定。', progress: 100, error: error instanceof Error ? error.message : 'process message failed', diff --git a/server-node/src/services/customWorldAgentPhase2.test.ts b/server-node/src/services/customWorldAgentPhase2.test.ts index 5f151a95..483c51cd 100644 --- a/server-node/src/services/customWorldAgentPhase2.test.ts +++ b/server-node/src/services/customWorldAgentPhase2.test.ts @@ -13,6 +13,7 @@ import { } from './customWorldAgentIntentExtractionService.js'; import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js'; import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js'; +import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js'; import { listCustomWorldWorkSummaries } from './customWorldWorkSummaryService.js'; 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 () => { const runtimeRepository = createRuntimeRepositoryStub(); const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); - const orchestrator = new CustomWorldAgentOrchestrator(sessionStore); + const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { + singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), + }); const userId = 'user-phase2-ready'; const createdSession = await orchestrator.createSession(userId, { @@ -193,6 +196,9 @@ test('phase2 orchestrator advances session to foundation_review when minimal anc ), /列岛世界/u, ); + assert.ok( + createdSession.messages[0]?.text.includes('1.') === false, + ); const message1 = await orchestrator.submitMessage( userId, @@ -246,7 +252,7 @@ test('phase2 orchestrator advances session to foundation_review when minimal anc snapshot?.messages.some( (message) => 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 () => { const runtimeRepository = createRuntimeRepositoryStub(); const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); - const orchestrator = new CustomWorldAgentOrchestrator(sessionStore); + const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { + singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), + }); const userId = 'user-phase2-summary'; const createdSession = await orchestrator.createSession(userId, { diff --git a/server-node/src/services/customWorldAgentPhase3.test.ts b/server-node/src/services/customWorldAgentPhase3.test.ts index e0a8ea64..bdcda008 100644 --- a/server-node/src/services/customWorldAgentPhase3.test.ts +++ b/server-node/src/services/customWorldAgentPhase3.test.ts @@ -5,6 +5,7 @@ import type { CustomWorldSessionRecord } from '../../../packages/shared/src/cont import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js'; import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js'; import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js'; +import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js'; import { listCustomWorldWorkSummaries } from './customWorldWorkSummaryService.js'; function createRuntimeRepositoryStub(): RuntimeRepositoryPort { @@ -151,7 +152,9 @@ async function createReadySession( test('phase3 ready session can execute draft_foundation and expose card detail', async () => { const runtimeRepository = createRuntimeRepositoryStub(); const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); - const orchestrator = new CustomWorldAgentOrchestrator(sessionStore); + const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { + singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), + }); const userId = 'user-phase3-draft'; 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 () => { const runtimeRepository = createRuntimeRepositoryStub(); 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 createdSession = await orchestrator.createSession(userId, { seedText: '一个被潮雾切开的列岛世界。', @@ -220,14 +225,16 @@ test('phase3 draft_foundation rejects not-ready session', async () => { orchestrator.executeAction(userId, createdSession.sessionId, { action: 'draft_foundation', }), - /ready session|foundation_review/u, + /progressPercent >= 100|draft_foundation/u, ); }); test('phase3 work summaries prefer compiled foundation draft fields', async () => { const runtimeRepository = createRuntimeRepositoryStub(); const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); - const orchestrator = new CustomWorldAgentOrchestrator(sessionStore); + const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { + singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), + }); const userId = 'user-phase3-summary'; const readySession = await createReadySession(orchestrator, userId); diff --git a/server-node/src/services/customWorldAgentPhase4.test.ts b/server-node/src/services/customWorldAgentPhase4.test.ts index e1c393f7..f4e586d9 100644 --- a/server-node/src/services/customWorldAgentPhase4.test.ts +++ b/server-node/src/services/customWorldAgentPhase4.test.ts @@ -6,6 +6,7 @@ import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js'; import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js'; import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js'; +import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js'; import { listCustomWorldWorkSummaries } from './customWorldWorkSummaryService.js'; function createRuntimeRepositoryStub(): RuntimeRepositoryPort { @@ -161,7 +162,9 @@ async function createObjectRefiningSession( test('phase4 update_draft_card writes back draft profile and recompiles summaries', async () => { const runtimeRepository = createRuntimeRepositoryStub(); const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); - const orchestrator = new CustomWorldAgentOrchestrator(sessionStore); + const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { + singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), + }); const userId = 'user-phase4-edit'; const session = await createObjectRefiningSession(orchestrator, userId); 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 () => { const runtimeRepository = createRuntimeRepositoryStub(); const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); - const orchestrator = new CustomWorldAgentOrchestrator(sessionStore); + const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { + singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), + }); const userId = 'user-phase4-characters'; const session = await createObjectRefiningSession(orchestrator, userId); 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 () => { const runtimeRepository = createRuntimeRepositoryStub(); const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); - const orchestrator = new CustomWorldAgentOrchestrator(sessionStore); + const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { + singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), + }); const userId = 'user-phase4-landmarks'; const session = await createObjectRefiningSession(orchestrator, userId); const baselineProfile = normalizeFoundationDraftProfile(session.draftProfile)!; diff --git a/server-node/src/services/customWorldAgentPhase5.test.ts b/server-node/src/services/customWorldAgentPhase5.test.ts index 414cdeec..8463122b 100644 --- a/server-node/src/services/customWorldAgentPhase5.test.ts +++ b/server-node/src/services/customWorldAgentPhase5.test.ts @@ -6,6 +6,7 @@ import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js'; import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js'; import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js'; +import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js'; function createRuntimeRepositoryStub(): RuntimeRepositoryPort { 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 () => { const runtimeRepository = createRuntimeRepositoryStub(); 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 session = await createObjectRefiningSession(orchestrator, userId); 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 () => { const runtimeRepository = createRuntimeRepositoryStub(); 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 session = await createObjectRefiningSession(orchestrator, userId); const characterCard = session.draftCards.find((card) => card.kind === 'character'); diff --git a/server-node/src/services/customWorldAgentSessionStore.ts b/server-node/src/services/customWorldAgentSessionStore.ts index 73cc6da1..0310a321 100644 --- a/server-node/src/services/customWorldAgentSessionStore.ts +++ b/server-node/src/services/customWorldAgentSessionStore.ts @@ -1,15 +1,16 @@ import crypto from 'node:crypto'; import type { - CustomWorldAssetCoverageSummary, CreatorIntentReadiness, CustomWorldAgentMessage, CustomWorldAgentOperationRecord, CustomWorldAgentSessionSnapshot, CustomWorldAgentStage, + CustomWorldAssetCoverageSummary, CustomWorldDraftCardSummary, CustomWorldPendingClarification, CustomWorldSuggestedAction, + EightAnchorContent, } from '../../../packages/shared/src/contracts/customWorldAgent.js'; import type { CustomWorldSessionRecord as LegacyCustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js'; import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js'; @@ -19,15 +20,19 @@ import { resolveCreatorIntentStage, } from './customWorldAgentClarificationService.js'; import { - buildAnchorPackFromIntent, - buildDraftSummaryFromIntent, - buildDraftTitleFromIntent, - createEmptyCreatorIntentRecord, - extractCreatorIntentPatch, - mergeCreatorIntentRecord, normalizeCreatorIntentRecord, } from './customWorldAgentIntentExtractionService.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 = 'custom-world-agent-session-'; @@ -36,6 +41,10 @@ export type CustomWorldAgentSessionRecord = { sessionId: string; userId: string; seedText: string; + currentTurn: number; + anchorContent: EightAnchorContent; + progressPercent: number; + lastAssistantReply: string | null; stage: CustomWorldAgentStage; focusCardId: string | null; creatorIntent: Record | null; @@ -69,6 +78,10 @@ export type CustomWorldAgentSessionRecord = { type CreateSessionInput = { seedText?: string; welcomeMessage: string; + currentTurn?: number; + anchorContent?: EightAnchorContent; + progressPercent?: number; + lastAssistantReply?: string | null; pendingClarifications: CustomWorldAgentSessionRecord['pendingClarifications']; creatorIntent?: CustomWorldAgentSessionRecord['creatorIntent']; creatorIntentReadiness?: CreatorIntentReadiness; @@ -169,20 +182,95 @@ function hasUserInput(record: CustomWorldAgentSessionRecord) { } function buildCompatibleCreatorIntent(record: CustomWorldAgentSessionRecord) { - const existingIntent = - normalizeCreatorIntentRecord(record.creatorIntent) ?? - createEmptyCreatorIntentRecord('freeform'); + const compatibleAnchorIntent = buildCreatorIntentFromEightAnchorContent( + normalizeEightAnchorContent( + (record as Record).anchorContent ?? null, + ), + ); - if (!record.seedText.trim()) { - return existingIntent; + if ( + 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({ - currentIntent: existingIntent, - latestUserMessage: record.seedText, - }); + return normalizeCreatorIntentRecord(record.creatorIntent); +} - return mergeCreatorIntentRecord(existingIntent, seedPatch); +function buildCompatibleCurrentTurn(record: CustomWorldAgentSessionRecord) { + if (typeof (record as Record).currentTurn === 'number') { + return Math.max( + 0, + Math.round((record as Record).currentTurn as number), + ); + } + + return record.messages.filter((message) => message.role === 'user').length; +} + +function buildCompatibleAnchorContent(record: CustomWorldAgentSessionRecord) { + const normalized = normalizeEightAnchorContent( + (record as Record).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).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).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) { @@ -239,8 +327,8 @@ function buildCompatiblePendingClarifications( function buildCompatibleDraftProfile( record: CustomWorldAgentSessionRecord, - creatorIntent: ReturnType, ) { + const anchorContent = buildCompatibleAnchorContent(record); const existingDraftProfile = toRecord(record.draftProfile); const hasFoundationContent = Boolean( existingDraftProfile && @@ -258,20 +346,21 @@ function buildCompatibleDraftProfile( name: toText(existingDraftProfile?.name) || toText(existingDraftProfile?.title) || - buildDraftTitleFromIntent(creatorIntent), + buildDraftTitleFromEightAnchorContent(anchorContent), summary: toText(existingDraftProfile?.summary) || - buildDraftSummaryFromIntent(creatorIntent), + buildDraftSummaryFromEightAnchorContent(anchorContent), }; } return { ...(existingDraftProfile ?? {}), title: - toText(existingDraftProfile?.title) || buildDraftTitleFromIntent(creatorIntent), + toText(existingDraftProfile?.title) || + buildDraftTitleFromEightAnchorContent(anchorContent), summary: toText(existingDraftProfile?.summary) || - buildDraftSummaryFromIntent(creatorIntent), + buildDraftSummaryFromEightAnchorContent(anchorContent), }; } @@ -381,35 +470,58 @@ function buildCompatibleAssetCoverage( function applyCompatibility(record: CustomWorldAgentSessionRecord) { 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 = - record.stage === 'collecting_intent' || - record.stage === 'clarifying' || - record.stage === 'foundation_review' - ? resolveCreatorIntentStage({ - hasUserInput: hasUserInput(record), - readiness: creatorIntentReadiness, - }) - : record.stage; + record.stage === 'object_refining' || + record.stage === 'visual_refining' || + record.stage === 'long_tail_review' || + record.stage === 'ready_to_publish' || + record.stage === 'published' + ? record.stage + : progressPercent >= 100 + ? ('foundation_review' as const) + : resolveCreatorIntentStage({ + hasUserInput: hasUserInput(record), + readiness: creatorIntentReadiness, + }); const pendingClarifications = buildCompatiblePendingClarifications({ ...record, creatorIntent, creatorIntentReadiness, }); - const draftProfile = buildCompatibleDraftProfile(record, creatorIntent); + const draftProfile = buildCompatibleDraftProfile(record); return { ...record, + currentTurn, + anchorContent, + progressPercent, + lastAssistantReply, stage, creatorIntent, creatorIntentReadiness, anchorPack: record.anchorPack && Object.keys(record.anchorPack).length > 0 ? record.anchorPack - : buildAnchorPackFromIntent(creatorIntent, { - completedKeys: creatorIntentReadiness.completedKeys, - missingKeys: creatorIntentReadiness.missingKeys, - }), + : buildAnchorPackFromEightAnchorContent(anchorContent, progressPercent), draftProfile, pendingClarifications, suggestedActions: buildCompatibleSuggestedActions({ @@ -430,6 +542,10 @@ function toSnapshot( ): CustomWorldAgentSessionSnapshot { return { sessionId: record.sessionId, + currentTurn: record.currentTurn, + anchorContent: cloneRecord(record.anchorContent), + progressPercent: record.progressPercent, + lastAssistantReply: record.lastAssistantReply, stage: record.stage, focusCardId: record.focusCardId, creatorIntent: cloneRecord(record.creatorIntent), @@ -491,6 +607,15 @@ export class CustomWorldAgentSessionStore { sessionId, userId, 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', focusCardId: null, creatorIntent: cloneRecord(input.creatorIntent ?? {}), @@ -567,6 +692,10 @@ export class CustomWorldAgentSessionStore { patch: Partial< Pick< CustomWorldAgentSessionRecord, + | 'currentTurn' + | 'anchorContent' + | 'progressPercent' + | 'lastAssistantReply' | 'stage' | 'creatorIntent' | 'creatorIntentReadiness' @@ -584,6 +713,21 @@ export class CustomWorldAgentSessionStore { >, ) { 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) { record.stage = patch.stage; } diff --git a/server-node/src/services/customWorldAgentTestHelpers.ts b/server-node/src/services/customWorldAgentTestHelpers.ts new file mode 100644 index 00000000..0ae37588 --- /dev/null +++ b/server-node/src/services/customWorldAgentTestHelpers.ts @@ -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; +} diff --git a/server-node/src/services/customWorldEntityGenerationService.test.ts b/server-node/src/services/customWorldEntityGenerationService.test.ts new file mode 100644 index 00000000..4817d6b3 --- /dev/null +++ b/server-node/src/services/customWorldEntityGenerationService.test.ts @@ -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, + '观测台立在湿冷高处,铁质风标、旧灯架和被潮气侵白的石阶一起构成了压迫感很强的前景。', + ); +}); diff --git a/server-node/src/services/customWorldEntityGenerationService.ts b/server-node/src/services/customWorldEntityGenerationService.ts index 4e0854d9..d0e3002e 100644 --- a/server-node/src/services/customWorldEntityGenerationService.ts +++ b/server-node/src/services/customWorldEntityGenerationService.ts @@ -15,6 +15,9 @@ type ParsedRole = { title: string; role: string; description: string; + visualDescription: string; + actionDescription: string; + sceneVisualDescription: string; backstory: string; personality: string; motivation: string; @@ -34,6 +37,7 @@ type ParsedLandmark = { id: string; name: string; description: string; + visualDescription: string; dangerLevel: string; sceneNpcIds: string[]; connections: ParsedLandmarkConnection[]; @@ -220,6 +224,9 @@ function normalizeRole(value: unknown): ParsedRole | null { title: title || role || '角色', role, description: toText(record.description), + visualDescription: toText(record.visualDescription), + actionDescription: toText(record.actionDescription), + sceneVisualDescription: toText(record.sceneVisualDescription), backstory: toText(record.backstory), personality: toText(record.personality), motivation: toText(record.motivation), @@ -275,6 +282,7 @@ function normalizeLandmark(value: unknown): ParsedLandmark | null { id, name, description: toText(record.description), + visualDescription: toText(record.visualDescription), dangerLevel: toText(record.dangerLevel, 'medium'), sceneNpcIds: toStringArray(record.sceneNpcIds, 12), connections, @@ -326,7 +334,11 @@ function buildRoleReferenceText(roles: ParsedRole[], emptyText: string) { role.backstory || '未写' } / 性格:${role.personality || '未写'} / 动机:${ role.motivation || '未写' - } / 标签:${role.tags.join('、') || '暂无'}`, + } / 形象:${role.visualDescription || '未写'} / 动作表现:${ + role.actionDescription || '未写' + } / 场景画面:${role.sceneVisualDescription || '未写'} / 标签:${ + role.tags.join('、') || '暂无' + }`, ) .join('\n'); } @@ -361,7 +373,9 @@ function buildLandmarkReferenceText(profile: ParsedProfile) { return `${index + 1}. ${landmark.name} / 危险度:${ landmark.dangerLevel || 'medium' - } / 描述:${landmark.description || '未写'} / 场景角色:${ + } / 描述:${landmark.description || '未写'} / 画面:${ + landmark.visualDescription || '未写' + } / 场景角色:${ sceneNpcNames || '暂无' } / 连接:${connectionNames || '暂无'}`; }) @@ -437,6 +451,24 @@ function buildFallbackRoleDraft( : `长期活跃于当前世界暗面,能补足场景视角的关键角色。`, 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( `他与${profile.name}当前正在扩张的冲突链紧密相连,知道一些还未公开的内情。`, 80, @@ -535,6 +567,10 @@ function buildFallbackLandmarkDraft(profile: ParsedProfile) { `承接${profile.name}当前主冲突的一处关键新场景,适合继续向外扩张世界关系网。`, 72, ), + visualDescription: clampText( + `这里延续${profile.name}当前主冲突的视觉气质,能看到明确的空间层次、可站立地面、核心建筑或地貌,以及仍在运转的局势痕迹。`, + 88, + ), dangerLevel: 'medium', sceneNpcNames, connections: targetLandmarkNames.map((targetLandmarkName, index) => ({ @@ -560,6 +596,9 @@ function buildPlayablePrompt(profile: ParsedProfile) { '- 必须与当前世界设定、已有可扮演角色、已有场景角色、已有场景形成互补,不要重复定位。', '- 必须保留明确的协作价值、成长空间和入队理由。', '- 不要生成泛用模板角色,必须让角色与当前世界的具体势力、地点、冲突或禁忌发生绑定。', + '- visualDescription 只写与角色设定相关的外形、服装、材质、武器、体态、色彩和识别特征,禁止写角色以外的周边环境等与角色不想管的设定。', + '- actionDescription 只写这个角色的动作表现与战斗气质,不要写镜头切换或参数。', + '- sceneVisualDescription 只写该角色首次登场或主要活动区域的场景画面感,不要写“提示词”字样。', '- 只返回 JSON,不要输出解释或 Markdown。', 'JSON 结构:', '{', @@ -568,6 +607,9 @@ function buildPlayablePrompt(profile: ParsedProfile) { ' "title": "称号",', ' "role": "身份",', ' "description": "一句到两句定位描述",', + ' "visualDescription": "角色形象描述",', + ' "actionDescription": "动作表现描述",', + ' "sceneVisualDescription": "角色关联场景画面描述",', ' "backstory": "背景经历",', ' "personality": "性格特点",', ' "motivation": "当前动机",', @@ -608,6 +650,9 @@ function buildStoryPrompt(profile: ParsedProfile) { '- 必须与当前世界设定、已有可扮演角色、已有场景角色、已有场景形成互补,不要重复定位。', '- 必须像能直接进入游戏的场景角色,而不是抽象设定条目。', '- 角色应与具体场景、关系链或局势变化发生绑定。', + '- visualDescription 只写与角色设定匹配的外形、服装、材质、武器、体态、色彩和识别特征,不要写“提示词”、镜头参数或构图规则。', + '- actionDescription 只写这个角色的动作表现与战斗气质,不要写镜头切换或参数。', + '- sceneVisualDescription 只写该角色首次登场或主要活动区域的场景画面感,不要写“提示词”字样。', '- 只返回 JSON,不要输出解释或 Markdown。', 'JSON 结构:', '{', @@ -616,6 +661,9 @@ function buildStoryPrompt(profile: ParsedProfile) { ' "title": "称号",', ' "role": "身份",', ' "description": "一句到两句定位描述",', + ' "visualDescription": "角色形象描述",', + ' "actionDescription": "动作表现描述",', + ' "sceneVisualDescription": "角色关联场景画面描述",', ' "backstory": "背景经历",', ' "personality": "性格特点",', ' "motivation": "当前动机",', @@ -656,12 +704,14 @@ function buildLandmarkPrompt(profile: ParsedProfile) { '- 必须与当前世界设定、已有可扮演角色、已有场景角色、已有场景形成互补,不要重复。', '- 必须给出适合出现在这个新场景里的 sceneNpcNames,且只能从已有场景角色里选择至少 3 个名字。', '- 必须给出 connections,且 targetLandmarkName 只能引用已有场景名,不要连向自己。', + '- visualDescription 只写这个场景的空间层次、地面、主体建筑或自然景观、氛围、色彩和可见装置,不要写“提示词”、镜头参数或构图规则。', '- 只返回 JSON,不要输出解释或 Markdown。', 'JSON 结构:', '{', ' "landmark": {', ' "name": "场景名",', ' "description": "场景描述",', + ' "visualDescription": "场景画面描述",', ' "dangerLevel": "low|medium|high|extreme",', ' "sceneNpcNames": ["场景角色1", "场景角色2", "场景角色3"],', ' "connections": [', @@ -737,6 +787,21 @@ function sanitizeGeneratedRole( toText(record?.description, fallbackDraft.description), 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), personality: clampText( toText(record?.personality, fallbackDraft.personality), @@ -962,6 +1027,10 @@ function sanitizeGeneratedLandmark(rawValue: unknown, profile: ParsedProfile) { toText(record?.description, fallbackDraft.description), 140, ), + visualDescription: clampText( + toText(record?.visualDescription, fallbackDraft.visualDescription), + 180, + ), dangerLevel: (() => { const level = toText(record?.dangerLevel, fallbackDraft.dangerLevel); return level === 'low' || diff --git a/server-node/src/services/customWorldWorkSummaryService.ts b/server-node/src/services/customWorldWorkSummaryService.ts index 96fa5465..15bca835 100644 --- a/server-node/src/services/customWorldWorkSummaryService.ts +++ b/server-node/src/services/customWorldWorkSummaryService.ts @@ -21,6 +21,10 @@ import type { CustomWorldAgentSessionRecord, CustomWorldAgentSessionStore, } from './customWorldAgentSessionStore.js'; +import { + buildDraftSummaryFromEightAnchorContent, + buildDraftTitleFromEightAnchorContent, +} from './eightAnchorCompatibilityService.js'; function toText(value: unknown) { return typeof value === 'string' ? value.trim() : ''; @@ -64,6 +68,7 @@ function resolveDraftTitle(session: CustomWorldAgentSessionRecord) { return ( draftProfile?.name || + buildDraftTitleFromEightAnchorContent(session.anchorContent) || buildDraftTitleFromIntent(intent) || toText(session.draftProfile?.title) || truncateText(session.seedText, 18) || @@ -78,6 +83,7 @@ function resolveDraftSummary(session: CustomWorldAgentSessionRecord) { return ( draftProfile?.summary || + buildDraftSummaryFromEightAnchorContent(session.anchorContent) || compiledSummary || toText(session.draftProfile?.summary) || truncateText(session.seedText, 72) || diff --git a/server-node/src/services/eightAnchorCompatibilityService.ts b/server-node/src/services/eightAnchorCompatibilityService.ts new file mode 100644 index 00000000..61754933 --- /dev/null +++ b/server-node/src/services/eightAnchorCompatibilityService.ts @@ -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) { + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + + 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) || '还在收集你的世界锚点。'; +} diff --git a/server-node/src/services/eightAnchorPromptBuilder.ts b/server-node/src/services/eightAnchorPromptBuilder.ts new file mode 100644 index 00000000..dfc749dc --- /dev/null +++ b/server-node/src/services/eightAnchorPromptBuilder.ts @@ -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 = { + 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 = { + 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, + }; +} diff --git a/server-node/src/services/eightAnchorSingleTurnService.test.ts b/server-node/src/services/eightAnchorSingleTurnService.test.ts new file mode 100644 index 00000000..17c75831 --- /dev/null +++ b/server-node/src/services/eightAnchorSingleTurnService.test.ts @@ -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); +}); diff --git a/server-node/src/services/eightAnchorSingleTurnService.ts b/server-node/src/services/eightAnchorSingleTurnService.ts new file mode 100644 index 00000000..63ed9321 --- /dev/null +++ b/server-node/src/services/eightAnchorSingleTurnService.ts @@ -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; + } + } +} diff --git a/server-node/src/services/llmClient.ts b/server-node/src/services/llmClient.ts index e33c99ec..f60d4b78 100644 --- a/server-node/src/services/llmClient.ts +++ b/server-node/src/services/llmClient.ts @@ -309,6 +309,92 @@ export class UpstreamLlmClient { 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( request: ExpressRequest, body: Record, diff --git a/src/components/CharacterAnimator.tsx b/src/components/CharacterAnimator.tsx index 92875d5d..3e69f2d6 100644 --- a/src/components/CharacterAnimator.tsx +++ b/src/components/CharacterAnimator.tsx @@ -8,6 +8,7 @@ interface CharacterAnimatorProps { className?: string; style?: React.CSSProperties; imageClassName?: string; + playbackRate?: number; } const DEFAULT_ANIMATIONS: Record = { @@ -42,6 +43,7 @@ export const CharacterAnimator: React.FC = ({ className, style, imageClassName, + playbackRate = 1, }) => { const [frameIndex, setFrameIndex] = useState(1); const config = @@ -51,6 +53,13 @@ export const CharacterAnimator: React.FC = ({ DEFAULT_ANIMATIONS[AnimationState.IDLE]; const startFrame = config.startFrame ?? 1; 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 = [ state, config.basePath ?? '', @@ -60,6 +69,8 @@ export const CharacterAnimator: React.FC = ({ config.extension ?? 'png', startFrame, frameCount, + fps, + effectivePlaybackRate, ].join('::'); useEffect(() => { @@ -73,10 +84,16 @@ export const CharacterAnimator: React.FC = ({ setFrameIndex(prev => { return prev >= endFrame ? startFrame : prev + 1; }); - }, 100); + }, Math.max(40, Math.round(1000 / (fps * effectivePlaybackRate)))); return () => window.clearInterval(interval); - }, [animationSignature, frameCount, startFrame]); + }, [ + animationSignature, + effectivePlaybackRate, + fps, + frameCount, + startFrame, + ]); const frameNumber = frameIndex.toString().padStart(2, '0'); const normalizedBasePath = config.basePath?.replace(/\/+$/u, ''); diff --git a/src/components/CustomWorldEntityCatalog.tsx b/src/components/CustomWorldEntityCatalog.tsx index a6b9330d..1c35b315 100644 --- a/src/components/CustomWorldEntityCatalog.tsx +++ b/src/components/CustomWorldEntityCatalog.tsx @@ -1,3 +1,7 @@ +import type { + EightAnchorContent, + KeyRelationshipValue, +} from '../../packages/shared/src/contracts/customWorldAgent'; import { type ReactNode, useDeferredValue, @@ -13,10 +17,7 @@ import { resolveCustomWorldLandmarkImageMap, } from '../data/customWorldVisuals'; import { resolveCustomWorldCampScene } from '../services/customWorldCamp'; -import { - buildCustomWorldCreatorIntentFoundationText, - normalizeCustomWorldCreatorIntent, -} from '../services/customWorldCreatorIntent'; +import { normalizeCustomWorldCreatorIntent } from '../services/customWorldCreatorIntent'; import { AnimationState, Character, CustomWorldProfile } from '../types'; import { getNineSliceStyle, UI_CHROME } from '../uiAssets'; import { CharacterAnimator } from './CharacterAnimator'; @@ -348,6 +349,226 @@ function compactTextList(values: Array) { 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) + : 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( profile: CustomWorldProfile, campScene: ReturnType, @@ -365,71 +586,85 @@ function buildOpeningSceneSearchText( function buildStructuredFoundationEntries(profile: CustomWorldProfile) { const creatorIntent = normalizeCustomWorldCreatorIntent(profile.creatorIntent); - const relationshipSeed = creatorIntent?.keyCharacters[0]; - const relationshipText = relationshipSeed - ? compactTextList([ - relationshipSeed.name, - relationshipSeed.role, - 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(';'); + const anchorContent = getProfileAnchorContent(profile); + const fallbackRelationshipText = + buildRelationshipSeedText(creatorIntent?.keyCharacters[0]) || + profile.playableNpcs[0]?.relationshipHooks.join(';') || + profile.storyNpcs[0]?.relationshipHooks.join(';') || + ''; return [ { - id: 'world-hook', - label: '世界一句话', - value: - creatorIntent?.worldHook || - profile.anchorPack?.worldSummary || - profile.summary, + id: 'world-promise', + label: '世界承诺', + value: compactTextList([ + anchorContent.worldPromise?.hook || '', + anchorContent.worldPromise?.differentiator || '', + anchorContent.worldPromise?.desiredExperience || '', + ]).join(';'), }, { - id: 'player-opening', - label: '玩家开局', - value: playerOpeningText || profile.playerGoal, + id: 'player-fantasy', + label: '玩家幻想', + value: compactTextList([ + anchorContent.playerFantasy?.playerRole || '', + anchorContent.playerFantasy?.corePursuit || '', + anchorContent.playerFantasy?.fearOfLoss || '', + ]).join(';'), }, { - id: 'theme-tone', - label: '主题气质', - value: themeToneText || profile.tone, + id: 'theme-boundary', + label: '主题边界', + 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', label: '核心冲突', - value: - creatorIntent?.coreConflicts.join(';') || - profile.coreConflicts.join(';') || - profile.summary, + value: compactTextList([ + anchorContent.coreConflict?.surfaceConflicts.join('、') || '', + anchorContent.coreConflict?.hiddenCrisis || '', + anchorContent.coreConflict?.firstTouchedConflict || '', + ]).join(';'), }, { - id: 'relationship-seed', + id: 'key-relationships', label: '关键关系', value: - relationshipText || - profile.playableNpcs[0]?.relationshipHooks.join(';') || - profile.storyNpcs[0]?.relationshipHooks.join(';') || - '待补充', + anchorContent.keyRelationships.map(buildKeyRelationshipText).join('\n') || + fallbackRelationshipText, + }, + { + id: 'hidden-lines', + label: '暗线与揭示', + value: compactTextList([ + anchorContent.hiddenLines?.hiddenTruths.join('、') || '', + anchorContent.hiddenLines?.misdirectionHints.join('、') || '', + anchorContent.hiddenLines?.revealPacing || '', + ]).join(';'), }, { id: 'iconic-elements', label: '标志元素', - value: - creatorIntent?.iconicElements.join('、') || - profile.anchorPack?.motifDirectives.join('、') || - '待补充', + value: compactTextList([ + anchorContent.iconicElements?.iconicMotifs.join('、') || '', + anchorContent.iconicElements?.institutionsOrArtifacts.join('、') || '', + anchorContent.iconicElements?.hardRules.join('、') || '', + ]).join(';'), }, ]; } @@ -594,12 +829,6 @@ export function CustomWorldEntityCatalog({ () => buildStructuredFoundationEntries(profile), [profile], ); - const structuredFoundationSourceText = useMemo( - () => - buildCustomWorldCreatorIntentFoundationText(profile.creatorIntent).trim() || - profile.settingText.trim(), - [profile.creatorIntent, profile.settingText], - ); const normalizedCreatorIntent = useMemo( () => normalizeCustomWorldCreatorIntent(profile.creatorIntent), [profile.creatorIntent], @@ -907,9 +1136,6 @@ export function CustomWorldEntityCatalog({ } >
-
- 解析字段 -
{structuredFoundationEntries.map((entry) => (
{entry.label}
-
+
{entry.value || '待补充'}
))}
- {structuredFoundationSourceText ? ( -
-
- 锚点原文 -
-
- {structuredFoundationSourceText} -
-
- ) : null}
diff --git a/src/components/CustomWorldEntityEditorModal.tsx b/src/components/CustomWorldEntityEditorModal.tsx index c657d586..151d4ef8 100644 --- a/src/components/CustomWorldEntityEditorModal.tsx +++ b/src/components/CustomWorldEntityEditorModal.tsx @@ -1,4 +1,5 @@ import type { ChangeEvent } from 'react'; +import type { CSSProperties } from 'react'; import { Children, type ReactNode, useEffect, useMemo, useState } from 'react'; import { createPortal } from 'react-dom'; @@ -70,6 +71,18 @@ interface CustomWorldEntityEditorModalProps { 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 [ BACKSTORY_UNLOCK_AFFINITY_EASED, BACKSTORY_UNLOCK_AFFINITY_FRIENDLY, @@ -2051,6 +2064,10 @@ function RoleSkillEditorModal({ }, } satisfies Character; }, [draft.actionPreviewConfig, role]); + const actionPreviewFrameStyle = useMemo( + () => getAnimationPreviewFrameStyle(draft.actionPreviewConfig, 320), + [draft.actionPreviewConfig], + ); const handleGenerateAction = async () => { if (!role.imageSrc || !role.generatedVisualAssetId) { @@ -2090,14 +2107,15 @@ function RoleSkillEditorModal({ visualSource: role.imageSrc, referenceImageDataUrls: [], referenceVideoDataUrls: [], + lastFrameImageDataUrl: role.imageSrc, frameCount: 8, fps: 10, durationSeconds: 3, loop: false, useChromaKey: true, - resolution: '720P', + resolution: '480P', imageSequenceModel: 'wan2.7-image-pro', - videoModel: 'wan2.7-i2v', + videoModel: 'wan2.2-kf2v-flash', referenceVideoModel: 'wan2.7-r2v', motionTransferModel: 'wan2.2-animate-move', } satisfies CharacterAnimationGenerationPayload); @@ -2155,9 +2173,9 @@ function RoleSkillEditorModal({ >
-
+
{previewCharacter && draft.actionPreviewConfig ? ( -
+
) : null; } + +export { CustomWorldEntityEditorModal }; +export default CustomWorldEntityEditorModal; diff --git a/src/components/CustomWorldGenerationView.tsx b/src/components/CustomWorldGenerationView.tsx index b9f69f81..4419cf31 100644 --- a/src/components/CustomWorldGenerationView.tsx +++ b/src/components/CustomWorldGenerationView.tsx @@ -1,10 +1,12 @@ import { motion } from 'motion/react'; import type { CustomWorldGenerationProgress } from '../../packages/shared/src/contracts/runtime'; +import type { CustomWorldStructuredAnchorEntry } from '../services/customWorldAgentGenerationProgress'; import { getNineSliceStyle, UI_CHROME } from '../uiAssets'; interface CustomWorldGenerationViewProps { settingText: string; + anchorEntries?: CustomWorldStructuredAnchorEntry[]; progress: CustomWorldGenerationProgress | null; isGenerating: boolean; error: string | null; @@ -22,6 +24,7 @@ interface CustomWorldGenerationViewProps { activeBadgeLabel?: string; pausedBadgeLabel?: string; idleBadgeLabel?: string; + structuredEmptyText?: string; } function formatDuration(ms: number) { @@ -47,6 +50,7 @@ function getProgressPercentage(progress: CustomWorldGenerationProgress | null) { export function CustomWorldGenerationView({ settingText, + anchorEntries = [], progress, isGenerating, error, @@ -64,9 +68,11 @@ export function CustomWorldGenerationView({ activeBadgeLabel = '世界建设中', pausedBadgeLabel = '生成已暂停', idleBadgeLabel = '等待操作', + structuredEmptyText = '正在整理当前设定结构,请稍后。', }: CustomWorldGenerationViewProps) { const progressValue = getProgressPercentage(progress); const steps = progress?.steps ?? []; + const hasStructuredAnchors = anchorEntries.length > 0; const estimatedWaitText = progress?.estimatedRemainingMs != null ? `预计还需 ${formatDuration(progress.estimatedRemainingMs)}` @@ -100,36 +106,6 @@ export function CustomWorldGenerationView({
-
-
-
-
- {settingTitle} -
-
- {settingDescription} -
-
- -
-
- {settingText} -
-
-
+ +
+
+
+
+ {settingTitle} +
+
+ {settingDescription} +
+
+ +
+ {hasStructuredAnchors ? ( +
+ {anchorEntries.map((entry) => ( +
+
+ {entry.label} +
+
+ {entry.value} +
+
+ ))} +
+ ) : ( +
+ {settingText || structuredEmptyText} +
+ )} +
); diff --git a/src/components/CustomWorldResultView.test.tsx b/src/components/CustomWorldResultView.test.tsx index 40fd17e7..f1134b83 100644 --- a/src/components/CustomWorldResultView.test.tsx +++ b/src/components/CustomWorldResultView.test.tsx @@ -26,6 +26,7 @@ vi.mock('./CustomWorldNpcVisualEditor', () => ({ vi.mock('./CustomWorldEntityEditorModal', () => ({ CustomWorldEntityEditorModal: () => null, + default: () => null, })); async function loadAiService() { @@ -165,6 +166,50 @@ const baseProfile = { description: '玩家最初落脚的旧灯塔内院。', 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: [ { id: 'landmark-1', @@ -242,3 +287,20 @@ test('clicking新增可扮演角色 shows pending item, disables button, and mar expect(screen.getAllByText('新').length).toBeGreaterThan(0); }); + +test('world basic setting renders eight anchor fields and hides legacy parsed/source copy', () => { + render(); + + 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(); +}); diff --git a/src/components/CustomWorldResultView.tsx b/src/components/CustomWorldResultView.tsx index cae73225..519d4177 100644 --- a/src/components/CustomWorldResultView.tsx +++ b/src/components/CustomWorldResultView.tsx @@ -18,9 +18,8 @@ import { CustomWorldEntityCatalog, type ResultTab, } from './CustomWorldEntityCatalog'; -import { +import CustomWorldEntityEditorModal, { type CustomWorldEditorTarget, - CustomWorldEntityEditorModal, } from './CustomWorldEntityEditorModal'; interface CustomWorldResultViewProps { diff --git a/src/components/CustomWorldRoleAssetStudioModal.tsx b/src/components/CustomWorldRoleAssetStudioModal.tsx index d04f0b7b..87d1eb3c 100644 --- a/src/components/CustomWorldRoleAssetStudioModal.tsx +++ b/src/components/CustomWorldRoleAssetStudioModal.tsx @@ -3,6 +3,7 @@ import { RefreshCcw, } from 'lucide-react'; import { + type CSSProperties, type ChangeEvent, type ReactNode, useEffect, @@ -14,11 +15,11 @@ import { createPortal } from 'react-dom'; import { ROLE_TEMPLATE_CHARACTERS } from '../data/characterPresets'; import { AnimationState, + type CharacterAnimationConfig, type Character, } from '../types'; import { buildAnimationClipFromVideoSource, - normalizeMasterVisualSourceToDataUrl, readFileAsDataUrl, } from './asset-studio/characterAssetWorkflowModel'; import { @@ -27,7 +28,6 @@ import { type CharacterVisualDraft, fetchCharacterWorkflowCache, generateCharacterAnimationDraft, - generateCharacterPromptBundle, generateCharacterVisualCandidates, publishCharacterAnimationAssets, publishCharacterVisualAsset, @@ -41,6 +41,9 @@ type EditableCustomWorldRole = { name: string; title: string; role: string; + visualDescription?: string; + actionDescription?: string; + sceneVisualDescription?: string; description?: string; backstory?: 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({ title, subtitle, @@ -357,6 +379,86 @@ function hasGeneratedAnimation( 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 | 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: { workingRole: EditableCustomWorldRole; selectedTemplate: (typeof ROLE_TEMPLATE_CHARACTERS)[number] | null; @@ -399,7 +501,6 @@ export function CustomWorldRoleAssetStudioModal({ syncBusy = false, visualPointCost = 20, animationPointCost = 60, - priorityTier = roleKind === 'playable' ? 'hero' : 'featured', }: { role: EditableCustomWorldRole; roleKind: 'playable' | 'story'; @@ -420,7 +521,6 @@ export function CustomWorldRoleAssetStudioModal({ syncBusy?: boolean; visualPointCost?: number; animationPointCost?: number; - priorityTier?: 'hero' | 'featured' | 'supporting'; }) { const [workingRole, setWorkingRole] = useState(role); const baseRole = useMemo( @@ -429,6 +529,9 @@ export function CustomWorldRoleAssetStudioModal({ name: role.name, title: role.title, role: role.role, + visualDescription: role.visualDescription, + actionDescription: role.actionDescription, + sceneVisualDescription: role.sceneVisualDescription, description: role.description, backstory: role.backstory, personality: role.personality, @@ -450,13 +553,16 @@ export function CustomWorldRoleAssetStudioModal({ role.generatedVisualAssetId, role.id, role.imageSrc, + role.actionDescription, role.motivation, role.name, role.personality, role.role, + role.sceneVisualDescription, role.tags, role.templateCharacterId, role.title, + role.visualDescription, ], ); const initialPromptBundle = useMemo( @@ -481,8 +587,14 @@ export function CustomWorldRoleAssetStudioModal({ const [animationPromptText, setAnimationPromptText] = useState( initialPromptBundle.animationPromptText, ); - const [animationStatus, setAnimationStatus] = useState(null); - const [isGeneratingAnimations, setIsGeneratingAnimations] = useState(false); + const [animationStatusByKey, setAnimationStatusByKey] = useState< + Partial> + >({}); + const [generatingAnimationMap, setGeneratingAnimationMap] = useState< + Partial> + >({}); + const [animationPreviewPlaybackRate, setAnimationPreviewPlaybackRate] = + useState(DEFAULT_ANIMATION_PLAYBACK_RATE); const [saveStatus, setSaveStatus] = useState(null); const [isSavingToRole, setIsSavingToRole] = useState(false); const [isHydratingCache, setIsHydratingCache] = useState(true); @@ -503,37 +615,6 @@ export function CustomWorldRoleAssetStudioModal({ ), [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( () => [ @@ -558,8 +639,8 @@ export function CustomWorldRoleAssetStudioModal({ const selectedVisualDraft = visualDrafts.find((draft) => draft.id === selectedVisualDraftId) ?? null; const previewImageSrc = - selectedVisualDraft?.imageSrc ?? workingRole.imageSrc ?? + selectedVisualDraft?.imageSrc ?? selectedTemplate?.portrait ?? ''; const hasGeneratedVisualPreview = Boolean( @@ -576,6 +657,23 @@ export function CustomWorldRoleAssetStudioModal({ }), [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 = referenceImageDataUrls.length > 0 ? 'image-to-image' : 'text-to-image'; @@ -589,7 +687,9 @@ export function CustomWorldRoleAssetStudioModal({ setSelectedVisualDraftId(''); setSelectedAnimation(CORE_ACTIONS[0]?.animation ?? AnimationState.IDLE); setVisualStatus(null); - setAnimationStatus(null); + setAnimationStatusByKey({}); + setGeneratingAnimationMap({}); + setAnimationPreviewPlaybackRate(DEFAULT_ANIMATION_PLAYBACK_RATE); setSaveStatus(null); setIsHydratingCache(true); @@ -643,64 +743,6 @@ export function CustomWorldRoleAssetStudioModal({ 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(() => { if (isHydratingCache) { return; @@ -742,6 +784,15 @@ export function CustomWorldRoleAssetStudioModal({ workingRole.imageSrc, ]); + useEffect(() => { + setAnimationPreviewPlaybackRate( + resolveAnimationPlaybackRate( + selectedActionConfig, + selectedAnimationConfig, + ), + ); + }, [selectedActionConfig, selectedAnimationConfig]); + const confirmPointSpend = (params: { kindLabel: string; points: number; @@ -774,38 +825,35 @@ export function CustomWorldRoleAssetStudioModal({ event.target.value = ''; }; - const applyVisualDraftToWorkflow = async ( - draft: CharacterVisualDraft, - draftList = visualDrafts, - ) => { - const normalizedVisual = await normalizeMasterVisualSourceToDataUrl( - draft.imageSrc, - { - applyChromaKey: true, - }, - ); - const result = await publishCharacterVisualAsset({ - characterId: workingRole.id, - sourceMode: visualSourceMode, - promptText: visualPromptText, - selectedPreviewSource: normalizedVisual.dataUrl, - previewSources: [normalizedVisual.dataUrl], - width: normalizedVisual.width, - height: normalizedVisual.height, - updateCharacterOverride: false, - }); + const applyVisualDraftToWorkflow = async (draft: CharacterVisualDraft) => { + setIsApplyingVisual(true); + try { + const result = await publishCharacterVisualAsset({ + characterId: workingRole.id, + sourceMode: visualSourceMode, + promptText: visualPromptText, + selectedPreviewSource: draft.imageSrc, + previewSources: [draft.imageSrc], + width: draft.width, + height: draft.height, + updateCharacterOverride: false, + }); - const nextRole = mergeRole(workingRole, { - imageSrc: result.portraitPath, - generatedVisualAssetId: result.assetId, - generatedAnimationSetId: undefined, - animationMap: undefined, - }); - setWorkingRole(nextRole); - setSelectedVisualDraftId(draft.id); - setAnimationStatus(null); - setSaveStatus(null); - setVisualStatus('角色形象已更新,可继续生成动作。'); + const nextRole = mergeRole(workingRole, { + imageSrc: result.portraitPath, + generatedVisualAssetId: result.assetId, + generatedAnimationSetId: undefined, + animationMap: undefined, + }); + setWorkingRole(nextRole); + setSelectedVisualDraftId(draft.id); + setAnimationStatusByKey({}); + setGeneratingAnimationMap({}); + setSaveStatus(null); + setVisualStatus('角色形象已更新,可继续生成动作。'); + } finally { + setIsApplyingVisual(false); + } }; const handleGenerateVisuals = async () => { @@ -835,7 +883,7 @@ export function CustomWorldRoleAssetStudioModal({ }); setVisualDrafts(result.drafts); if (result.drafts[0]) { - await applyVisualDraftToWorkflow(result.drafts[0], result.drafts); + await applyVisualDraftToWorkflow(result.drafts[0]); setVisualStatus('角色形象已生成,如不满意可直接重新生成。'); } else { setSelectedVisualDraftId(''); @@ -855,6 +903,8 @@ export function CustomWorldRoleAssetStudioModal({ throw new Error('请先生成角色形象,再生成动作。'); } + const isLoopAction = config.loop; + const result = await generateCharacterAnimationDraft({ characterId: workingRole.id, strategy: 'image-to-video', @@ -865,14 +915,15 @@ export function CustomWorldRoleAssetStudioModal({ visualSource: workingRole.imageSrc, referenceImageDataUrls: [], referenceVideoDataUrls: [], + lastFrameImageDataUrl: isLoopAction ? undefined : workingRole.imageSrc, frameCount: config.frameCount, fps: config.fps, durationSeconds: config.durationSeconds, loop: config.loop, useChromaKey: true, - resolution: '720P', + resolution: isLoopAction ? '720P' : '480P', imageSequenceModel: 'wan2.7-image-pro', - videoModel: 'wan2.7-i2v', + videoModel: isLoopAction ? 'wan2.6-i2v-flash' : 'wan2.2-kf2v-flash', referenceVideoModel: 'wan2.7-r2v', motionTransferModel: 'wan2.2-animate-move', } satisfies CharacterAnimationGenerationPayload); @@ -887,6 +938,8 @@ export function CustomWorldRoleAssetStudioModal({ loop: config.loop, frameCount: config.frameCount, applyChromaKey: true, + sampleStartRatio: config.loop ? 0.12 : 0, + sampleEndRatio: config.loop ? 0.94 : 1, }); }; @@ -895,6 +948,12 @@ export function CustomWorldRoleAssetStudioModal({ return; } + const actionConfig = selectedActionConfig; + const requestedPlaybackRate = animationPreviewPlaybackRate; + if (generatingAnimationMap[actionConfig.animation]) { + return; + } + if ( !confirmPointSpend({ kindLabel: '动作草稿生成', @@ -905,16 +964,22 @@ export function CustomWorldRoleAssetStudioModal({ return; } - setIsGeneratingAnimations(true); - setAnimationStatus(null); + setGeneratingAnimationMap((current) => ({ + ...current, + [actionConfig.animation]: true, + })); + setAnimationStatusByKey((current) => ({ + ...current, + [actionConfig.animation]: null, + })); try { - const clip = await generateActionClip(selectedActionConfig); + const clip = await generateActionClip(actionConfig); const result = await publishCharacterAnimationAssets({ characterId: workingRole.id, visualAssetId: workingRole.generatedVisualAssetId!, animations: { - [selectedActionConfig.animation]: { + [actionConfig.animation]: { framesDataUrls: clip.frames, fps: clip.fps, loop: clip.loop, @@ -926,24 +991,38 @@ export function CustomWorldRoleAssetStudioModal({ updateCharacterOverride: false, }); - const nextRole = mergeRole(workingRole, { - generatedAnimationSetId: result.animationSetId, - animationMap: { - ...((workingRole.animationMap ?? {}) as Record), - ...(result.animationMap as NonNullable< - EditableCustomWorldRole['animationMap'] - >), - }, - }); - setWorkingRole(nextRole); - setSaveStatus(null); - setAnimationStatus(`${selectedActionConfig.label} 动作已更新。`); - } catch (error) { - setAnimationStatus( - error instanceof Error ? error.message : '生成角色动作失败。', + setWorkingRole((current) => + mergeRole(current, { + generatedAnimationSetId: result.animationSetId, + animationMap: applyPlaybackRateToAnimationMap({ + animationMap: { + ...((current.animationMap ?? {}) as Record), + ...(result.animationMap as NonNullable< + EditableCustomWorldRole['animationMap'] + >), + }, + animation: actionConfig.animation, + actionConfig, + playbackRate: requestedPlaybackRate, + }), + }), ); + setSaveStatus(null); + setAnimationStatusByKey((current) => ({ + ...current, + [actionConfig.animation]: `${actionConfig.label} 动作已更新。`, + })); + } catch (error) { + setAnimationStatusByKey((current) => ({ + ...current, + [actionConfig.animation]: + error instanceof Error ? error.message : '生成角色动作失败。', + })); } finally { - setIsGeneratingAnimations(false); + setGeneratingAnimationMap((current) => ({ + ...current, + [actionConfig.animation]: false, + })); } }; @@ -999,7 +1078,7 @@ export function CustomWorldRoleAssetStudioModal({ isHydratingCache || isGeneratingVisuals || isApplyingVisual || - isGeneratingAnimations || + hasAnyGeneratingAnimations || isSavingToRole || syncBusy } @@ -1109,20 +1188,25 @@ export function CustomWorldRoleAssetStudioModal({
-
+
{previewCharacter && hasGeneratedAnimation(workingRole, selectedAnimation) ? ( -
- +
+
+ +
) : previewImageSrc ? ( {workingRole.name} ) : (
暂无动作预览
@@ -1130,10 +1214,53 @@ export function CustomWorldRoleAssetStudioModal({
+ +
+ { + 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 + | undefined, + animation: selectedAnimation, + actionConfig: selectedActionConfig, + playbackRate: nextPlaybackRate, + }); + + return nextAnimationMap === current.animationMap + ? current + : mergeRole(current, { + animationMap: nextAnimationMap, + }); + }); + }} + className="w-full accent-sky-400" + /> +
+ 0.25x + {animationPreviewPlaybackRate.toFixed(2)}x + 1.50x +
+
+
+
{CORE_ACTIONS.map((item) => { const isSelected = item.animation === selectedAnimation; const isReady = hasGeneratedAnimation(workingRole, item.animation); + const isGenerating = generatingAnimationMap[item.animation] === true; return (
- - {isReady ? '已生成' : '待生成'} + + {isGenerating ? '生成中' : isReady ? '已生成' : '待生成'}
@@ -1175,11 +1308,11 @@ export function CustomWorldRoleAssetStudioModal({
} - label={isGeneratingAnimations ? '生成中...' : '生成动作'} + label={isSelectedAnimationGenerating ? '生成中...' : '生成动作'} subLabel={`消耗${animationPointCost}叙世币`} onClick={() => void handleGenerateAnimation()} disabled={ - isGeneratingAnimations || + isSelectedAnimationGenerating || !workingRole.imageSrc || !workingRole.generatedVisualAssetId || syncBusy @@ -1188,9 +1321,9 @@ export function CustomWorldRoleAssetStudioModal({ />
- {animationStatus ? ( + {selectedAnimationStatus ? (
- {animationStatus} + {selectedAnimationStatus}
) : null}
diff --git a/src/components/asset-studio/characterAssetWorkflowModel.ts b/src/components/asset-studio/characterAssetWorkflowModel.ts index d5a7c4c1..d3c613a8 100644 --- a/src/components/asset-studio/characterAssetWorkflowModel.ts +++ b/src/components/asset-studio/characterAssetWorkflowModel.ts @@ -726,16 +726,60 @@ function applyGreenScreenAlpha( const blue = pixels[index + 2] ?? 0; const alpha = pixels[index + 3] ?? 0; const greenLead = green - Math.max(red, blue); + const greenRatio = green / Math.max(1, red + blue); if (alpha === 0) { continue; } - if (green > 96 && greenLead > 34) { - const fade = Math.max(0, 255 - greenLead * 5); - pixels[index + 3] = Math.min(alpha, fade); - if (greenLead > 60) { - pixels[index + 3] = 0; + if (green > 72 && greenLead > 20 && greenRatio > 0.72) { + let nextAlpha = Math.min(alpha, Math.max(0, 255 - greenLead * 6)); + + if (green > 120 && greenLead > 48 && greenRatio > 1.12) { + 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; frameHeight?: number; applyChromaKey?: boolean; + sampleStartRatio?: number; + sampleEndRatio?: number; }, ) { const video = await loadVideoFromSource(videoSource); @@ -877,6 +923,15 @@ export async function buildAnimationClipFromVideoSource( 2, 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 frames: string[] = []; @@ -884,7 +939,10 @@ export async function buildAnimationClipFromVideoSource( const progress = options.loop ? frameIndex / derivedFrameCount : 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); @@ -912,6 +970,84 @@ export async function buildAnimationClipFromVideoSource( } 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((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( character: Character, animation: AnimationState, diff --git a/src/components/asset-studio/customWorldRolePromptDefaults.test.ts b/src/components/asset-studio/customWorldRolePromptDefaults.test.ts new file mode 100644 index 00000000..a4403343 --- /dev/null +++ b/src/components/asset-studio/customWorldRolePromptDefaults.test.ts @@ -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('提示词'); + }); +}); diff --git a/src/components/asset-studio/customWorldRolePromptDefaults.ts b/src/components/asset-studio/customWorldRolePromptDefaults.ts index 51cbe13a..9b7ef763 100644 --- a/src/components/asset-studio/customWorldRolePromptDefaults.ts +++ b/src/components/asset-studio/customWorldRolePromptDefaults.ts @@ -2,6 +2,9 @@ export type PromptDefaultRole = { name: string; title: string; role: string; + visualDescription?: string; + actionDescription?: string; + sceneVisualDescription?: string; description?: string; backstory?: string; personality?: string; @@ -20,57 +23,52 @@ function cleanSeedText(value: string | undefined, maxLength: number) { return (value ?? '').replace(/\s+/gu, ' ').trim().slice(0, maxLength); } +function compactDescription(parts: Array, maxLength: number) { + return parts + .map((item) => cleanSeedText(item, maxLength)) + .filter(Boolean) + .join(' ') + .slice(0, maxLength); +} + export function buildDefaultRolePromptBundle( role: PromptDefaultRole, ): CustomWorldRolePromptBundle { - const characterName = cleanSeedText(role.name, 40) || '该角色'; - const roleAnchor = - [cleanSeedText(role.title, 60), cleanSeedText(role.role, 60)] - .filter(Boolean) - .join(' / ') || '关键角色'; - const descriptionAnchor = - cleanSeedText(role.description, 220) || - cleanSeedText(role.backstory, 260) || - cleanSeedText(role.personality, 160) || - '识别度鲜明'; - const combatAnchor = - cleanSeedText(role.combatStyle, 180) || - cleanSeedText(role.motivation, 180) || - '动作重心稳定'; - const tagAnchor = - role.tags && role.tags.length > 0 - ? `保留 ${role.tags.slice(0, 8).join('、')} 的角色识别点。` - : ''; + const roleLabel = [cleanSeedText(role.name, 40), cleanSeedText(role.title, 40)] + .filter(Boolean) + .join(','); + const fallbackVisualDescription = compactDescription( + [ + roleLabel || cleanSeedText(role.role, 40), + role.description, + role.personality, + role.tags && role.tags.length > 0 ? role.tags.slice(0, 8).join('、') : '', + ], + 220, + ); + const fallbackActionDescription = compactDescription( + [ + role.actionDescription, + role.combatStyle, + role.motivation, + role.personality, + ], + 180, + ); + const generatedSceneDescription = cleanSeedText(role.sceneVisualDescription, 220); + const fallbackSceneDescription = compactDescription( + [ + role.backstory, + role.description, + role.motivation, + ], + 220, + ); return { - visualPromptText: [ - `${characterName},${roleAnchor}。`, - '单人全身,2D 横版 RPG 角色主图,侧身朝右,脚底完整可见,服装、发型、武器与轮廓保持稳定清楚。', - `外观气质围绕:${descriptionAnchor}。`, - `动作识别点参考:${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(' '), + visualPromptText: + cleanSeedText(role.visualDescription, 220) || fallbackVisualDescription, + animationPromptText: fallbackActionDescription, + scenePromptText: generatedSceneDescription || fallbackSceneDescription, }; } diff --git a/src/components/custom-world-agent/CustomWorldAgentClarificationPanel.test.tsx b/src/components/custom-world-agent/CustomWorldAgentClarificationPanel.test.tsx index 776516e2..583189b5 100644 --- a/src/components/custom-world-agent/CustomWorldAgentClarificationPanel.test.tsx +++ b/src/components/custom-world-agent/CustomWorldAgentClarificationPanel.test.tsx @@ -42,5 +42,5 @@ test('clarification panel shows pending questions and ready state', () => { expect(pendingHtml).toContain('待补充问题'); expect(pendingHtml).toContain('玩家是谁,故事开场时卡在什么处境里'); - expect(readyHtml).toContain('最小锚点已齐备,可以进入下一阶段'); + expect(readyHtml).toContain('当前设定已齐备,可以进入下一阶段'); }); diff --git a/src/components/custom-world-agent/CustomWorldAgentClarificationPanel.tsx b/src/components/custom-world-agent/CustomWorldAgentClarificationPanel.tsx index 334a2412..649e4b2f 100644 --- a/src/components/custom-world-agent/CustomWorldAgentClarificationPanel.tsx +++ b/src/components/custom-world-agent/CustomWorldAgentClarificationPanel.tsx @@ -19,7 +19,7 @@ export function CustomWorldAgentClarificationPanel({ 下一阶段
- 最小锚点已齐备,可以进入下一阶段 + 当前设定已齐备,可以进入下一阶段
); diff --git a/src/components/custom-world-agent/CustomWorldAgentComposer.tsx b/src/components/custom-world-agent/CustomWorldAgentComposer.tsx index c1178424..1f82e20a 100644 --- a/src/components/custom-world-agent/CustomWorldAgentComposer.tsx +++ b/src/components/custom-world-agent/CustomWorldAgentComposer.tsx @@ -7,9 +7,6 @@ type CustomWorldAgentComposerProps = { disabled: boolean; onSubmit: (payload: SendCustomWorldAgentMessageRequest) => void; textareaRef?: RefObject; - onSummaryClick?: () => void; - onAutoCompleteClick?: () => void; - showAutoComplete?: boolean; }; function createClientMessageId() { @@ -27,9 +24,6 @@ export function CustomWorldAgentComposer({ disabled, onSubmit, textareaRef, - onSummaryClick, - onAutoCompleteClick, - showAutoComplete = true, }: CustomWorldAgentComposerProps) { const [text, setText] = useState(''); @@ -49,28 +43,8 @@ export function CustomWorldAgentComposer({ }; return ( -
-
-
- - {showAutoComplete ? ( - - ) : null} -
+
+