3 Commits

Author SHA1 Message Date
09d3fe59b3 1
Some checks failed
CI / verify (push) Has been cancelled
2026-04-26 21:07:55 +08:00
67161bd6d1 1 2026-04-26 20:50:58 +08:00
a3a9bfa194 Document Rust binding recovery and remove temp snapshots 2026-04-26 19:37:33 +08:00
59 changed files with 3642 additions and 852 deletions

View File

@@ -8,7 +8,7 @@
1. 战斗中一次点击只完成一个明确行为,不再做连续多轮击打、连续多 actor 轮转的 function 设计。
2. 战斗中除逃跑外,不再为每次动作额外触发剧情推理,而是直接结算数值并刷新下一轮战斗选项。
3. 只有在逃跑成功或战斗正式结束后,才触发一次剧情推理,生成脱战后的 storyText 与后续剧情选项。
3. 战斗动作、逃跑、战斗胜利、切磋结束与玩家死亡都不再触发剧情推理,只做确定性数值结算与固定流程选项。
---
@@ -143,24 +143,13 @@ ongoing battle 的本地/后端结果文本只负责说明这一次动作结算
3. 直接刷新新一轮战斗选项
4. `storyText` 直接使用本次结算结果文本,不请求 AI 续写
### 5.2 必须触发剧情推理的情况
### 5.2 战斗结束后的固定流程
以下情况必须触发剧情推理
战斗正式结束后仍然不触发剧情推理,直接走固定 UI 流程
1. `battle_escape_breakout` 执行后成功脱战
2. 任意战斗动作执行后,战斗正式结束
战斗正式结束包括:
- 敌方被击败
- 切磋结束
- 玩家被系统判定为本轮战斗已断开
此时系统行为为:
1. 先完成数值结算与状态落地
2. 再以“本次动作 + 本次战斗结果”为上下文触发一次剧情推理
3. 生成脱战后的 `storyText` 与非战斗态 options
1. 敌方被击败或切磋结束:展示结算文本,并只给一个“继续前进”选项。
2. 如果当前场景当前幕已经是最后一幕,则不再给“继续前进”,改为列出可前往的其他场景选项。
3. 玩家血量小于等于 0先播放死亡动画三秒后把玩家复活到第一个场景第一幕。
---
@@ -196,7 +185,7 @@ ongoing battle 的本地/后端结果文本只负责说明这一次动作结算
1. 后端 runtime 战斗 option 池切换到单行为模型
2. 后端 combat resolution 支持普通攻击 / 指定技能 / 恢复 / 战斗物品 / 逃跑
3. 后端只在逃跑或战斗结束后做剧情推理
3. 后端和前端都不在战斗流程里触发剧情推理,胜负只走固定结算选项
4. 前端支持透传战斗 option 的 `runtimePayload`
5. 前端支持 disabled battle option 展示
6. 文档、测试同步更新
@@ -228,6 +217,6 @@ ongoing battle 的本地/后端结果文本只负责说明这一次动作结算
- 每个技能一个独立技能项
- 逃跑
3. 点击普通攻击 / 恢复 / 使用物品 / 技能时,不请求新的剧情推理,直接返回结算结果并刷新下一轮战斗 options。
4. 点击逃跑成功后,请求一次剧情推理并切回脱战后的剧情 options。
5. 任意攻击或技能把敌人打死后,请求一次剧情推理并切回脱战后的剧情 options
4. 点击逃跑成功后,请求剧情推理,直接切回非战斗固定 options。
5. 任意攻击或技能把敌人打死后,请求剧情推理,直接显示“继续前进”或其他场景选项
6. 旧存档里残留旧 battle functionId 时,不会因为 function 不识别而报错。

View File

@@ -0,0 +1,45 @@
# 世界底稿开局场景批生成解耦说明 2026-04-26
## 背景
当前第一版世界底稿生成链路里,`framework` 阶段同时要求模型输出 `camp.sceneTaskDescription``camp.actBackgroundPromptTexts``camp.actEventDescriptions`。这让“世界核心骨架”和“开局场景多幕内容”混在同一次世界生成任务里,后续普通场景批生成又要单独生成相同粒度的场景任务、三幕事件、三幕背景和幕 NPC 分配。
这与《AI 原生多幕场景创作与玩法流程 PRD》中“开局场景不是特殊系统只是玩家开局所处的第一个场景”的约束不一致。开局场景应复用普通场景的批生成能力而不是由世界骨架阶段提前生成一套缩水版内容。
## 落地边界
1. `framework` 阶段只负责世界顶层信息和轻量 `camp` 占位:
- `name`
- `description`
2. 场景批生成阶段负责生成完整场景骨架:
- 场景名与描述
- 默认场景生图描述
- `sceneTaskDescription`
- `actBackgroundPromptTexts`
- `actEventDescriptions`
- `actNPCNames`
- `connectedLandmarkNames`
- `entryHook`
3. 批生成场景结果的第一项固定视为开局场景:
- 写回 `profile.camp`
- `camp.id` 缺失时固定为 `camp-1`
- `camp.kind` 固定为 `camp`
4. 批生成场景结果的其余项写入 `profile.landmarks`
5. `sceneChapterBlueprints` 仍由统一场景蓝图编译函数生成:
- 第 0 项来自 `camp`
- 后续项来自 `landmarks`
- 开局场景和普通场景共用三幕、NPC、任务、背景提示词规则
## 兼容策略
为了减少前端和存量链路改动,场景批生成 API 层仍沿用现有 `landmarks` JSON 字段名。字段名不再表示“只包含普通地标”,而表示“本批生成的场景条目”,其中第一项是 opening/camp 场景。
如果模型没有返回任何场景条目,则继续使用 `framework.camp` 的轻量占位构造兜底开局场景;这只是异常兜底,不是主生成路径。
## 验收点
1. 世界骨架 prompt 不再要求 `camp.sceneTaskDescription``camp.actBackgroundPromptTexts``camp.actEventDescriptions`
2. 场景批生成 prompt 明确要求第一项是开局场景。
3. 生成后的 `profile.camp` 来自场景批生成第一项,而不是来自世界骨架阶段的开局多幕内容。
4. `profile.landmarks[0]` 是第一个普通场景,不再重复包含开局场景。
5. `sceneChapterBlueprints[0].sceneId === "camp-1"`,且仍包含 3 幕背景与事件描述。

View File

@@ -4,6 +4,7 @@
## 文档列表
- [RPG_AND_AGENT_CHAT_TRUE_SSE_STREAMING_2026-04-26.md](./RPG_AND_AGENT_CHAT_TRUE_SSE_STREAMING_2026-04-26.md):记录 RPG 运行时 NPC 聊天、RPG/自定义世界 Agent 与大鱼 Agent 从“拼完整 SSE 字符串后一次性返回”改为 `mpsc + Sse<Event>` 真流式输出的后端落地口径。
- [RPG_BATTLE_HEALTHBAR_AND_ACTION_PRESENTATION_FIX_2026-04-26.md](./RPG_BATTLE_HEALTHBAR_AND_ACTION_PRESENTATION_FIX_2026-04-26.md):记录 RPG 战斗血条安全锚点、服务端战斗回包前端短表现,以及 `battle_use_skill` 指定技能兜底结算的修复口径。
- [SPACETIMEDB_TABLE_CATALOG.md](./SPACETIMEDB_TABLE_CATALOG.md):持续维护当前 SpacetimeDB 表目录,按领域说明每张表的作用、字段结构、索引和常用 `spacetime sql` 查询模板。
- [RPG_OPENING_SCENE_ACT_IMAGE_PRESENTATION_SYNC_2026-04-26.md](./RPG_OPENING_SCENE_ACT_IMAGE_PRESENTATION_SYNC_2026-04-26.md):记录开局场景与普通场景复用同一场景展示解析服务,修复列表幕缩略图和详情幕背景预览图片不一致的问题。

View File

@@ -0,0 +1,51 @@
# RPG 与 Agent 聊天真流式 SSE 修复2026-04-26
## 背景
RPG 运行时 NPC 聊天和创作 Agent 聊天此前都使用 `platform-llm.stream_text(...)`,但部分 HTTP handler 会把 `reply_delta` 先拼进内存字符串,等模型完整返回、建议生成和会话落库完成后才一次性响应。前端虽然消费的是 SSE 协议,实际体验仍接近非流式。
本次落地把这类路由改成真正边生成边输出:
1. RPG 运行时 NPC 聊天:`POST /api/runtime/chat/npc/turn/stream`
2. RPG/自定义世界创作 Agent`POST /api/runtime/custom-world/agent/sessions/:sessionId/messages/stream`
3. 大鱼吃小鱼创作 Agent`POST /api/runtime/big-fish/agent/sessions/:sessionId/messages/stream`
拼图 Agent 已经使用同类 `mpsc + Sse<Event>` 结构,本轮只作为参照,不重复改动。
## 设计约束
1. 前端 SSE contract 不变,继续消费 `reply_delta / complete / session / done / error`
2. `reply_delta` 必须在模型生成过程中实时发送,不再等待完整 handler 结束。
3. Agent 的最终 `session` 仍必须等 SpacetimeDB `finalize_*` 成功后再发送,保证前端真相态与表内状态一致。
4. RPG NPC 聊天的建议、好感变化、敌对终止判定仍在完整回复后计算,最终通过 `complete` 一次性返回。
5. LLM 不可用或失败时RPG NPC 聊天继续走确定性兜底,仍发送至少一条 `reply_delta``complete`
## 落地方案
### Axum SSE 输出
相关 handler 改为返回 `Sse::new(async_stream::stream! { ... })`。模型回调不能直接 `yield`,因此使用:
1. `tokio::sync::mpsc::unbounded_channel::<String>()`
2. LLM turn 回调把最新可见回复写入 `reply_tx`
3. 外层 `tokio::select!` 同时等待 LLM 完成和 `reply_rx.recv()`
4. 每次收到文本立即 `yield Event::default().event("reply_delta")`
### Agent 写回顺序
Agent 路由保持原有 submit/finalize 分工:
1. `submit_*_message` 先写入用户消息与 operation。
2. `run_*_agent_turn` 运行模型,同时把 `replyText` 增量推给 SSE。
3. turn 完成后构造 `finalize_*_agent_message` 输入并写回 SpacetimeDB。
4. finalize 成功后再读取或映射最新 session发送 `session``done`
5. finalize 失败时发送 `error` 并结束 SSE。
## 验收
1. `cargo fmt -p api-server`
2. `cargo check -p api-server`
3. `cargo test -p api-server runtime_chat`
4. `cargo test -p api-server creation_agent_llm_turn`
5. `node scripts/check-encoding.mjs docs/technical/RPG_AND_AGENT_CHAT_TRUE_SSE_STREAMING_2026-04-26.md server-rs/crates/api-server/src/runtime_chat.rs server-rs/crates/api-server/src/custom_world.rs server-rs/crates/api-server/src/big_fish.rs docs/technical/README.md`
6. 修改后端代码后,使用 `npm run api-server:maincloud` 重启后端。

View File

@@ -0,0 +1,38 @@
# RPG 战斗确定性结束流程
更新时间:`2026-04-26`
## 背景
旧战斗单行为 PRD 允许在逃跑成功或战斗结束后再触发一次剧情推理。新的运行时规则改为:战斗过程与战斗结果都不再请求剧情推理,所有胜负出口都由确定性状态与固定选项驱动。
## 落地规则
1. 战斗中的 `battle_attack_basic / battle_use_skill / battle_recover_breath / inventory_use / battle_escape_breakout` 只结算数值、血量、冷却、物品与战斗状态,不再请求 `generateNextStep` 或 Rust `generate_action_story_payload` 的战斗推理。
2. 玩家血量小于等于 0 时,先展示死亡动画;三秒后复活到当前世界的第一个场景第一幕。
3. 复活状态必须清理战斗、遭遇、NPC 交互、战斗特效、当前 NPC 战斗结果,并把生命与灵力恢复到最大值。
4. 战斗胜利或切磋结束时,弹出固定选项:
- 若当前场景仍有下一幕:只显示“继续前进”,并把下一幕运行时状态与可用选项挂到 `deferredRuntimeState / deferredOptions`,点击后只揭开下一幕选项,不触发剧情推理。
- 若当前场景已是最后一幕:显示可前往的其他场景选项,每个选项透传 `runtimePayload.targetSceneId`
5. “继续前进”和“前往其他场景”都是本地状态推进,不展示额外说明面板,不把规则描述写入 UI。
## 工程落点
1. `src/hooks/rpg-runtime-story/postBattleFlow.ts`
- 生成死亡复活状态。
- 生成战斗胜利后的固定 `StoryMoment`
- 解析下一幕或其他场景选项。
2. `src/hooks/rpg-runtime-story/storyChoiceContinuation.ts`
- 本地 fallback 胜利分支不再调用 `generateStoryForState(...)`
- 玩家死亡分支播放死亡态并延迟复活。
3. `src/hooks/rpg-runtime-story/storyChoiceRuntime.ts`
- 服务端 runtime action 回包若是战斗死亡或胜利,同样覆盖为固定流程。
4. `server-rs/crates/api-server/src/runtime_story/compat/ai.rs`
- 战斗 outcome 不再触发 reasoned story payload。
## 验收
1. 战斗中普通攻击、技能、恢复、物品、逃跑不会触发剧情推理。
2. 玩家被打到 0 血时先播放死亡动画,约三秒后复活到第一个场景第一幕。
3. 战斗胜利后只出现“继续前进”;最后一幕后出现其他场景入口。
4. 服务端 runtime action 与本地 fallback 两条链表现一致。

View File

@@ -18,6 +18,8 @@
3. `battle_use_skill` 的本地兜底结算必须尊重 `runtimePayload.skillId`,不能重新随机挑技能。
4. `battle_recover_breath` 的本地兜底不能伪装成攻击动作;它只做恢复、冷却推进和后续敌方压力。
5. 服务端战斗回包如果带 `presentation.battle`,前端先播放一次短动作和血量变化,再落 `hydratedSnapshot.gameState`,避免选项点击后血量直接跳变。
6. 战斗中任一角色血量发生变化时,表现层根据前后血量差派生一次浮字:伤害使用红色负数,治疗使用绿色正数;该浮字只表达已经结算的血量差,不参与数值计算。
7. 伤害浮字出现时,受击角色形象需要沿自身背向短距离后退再恢复;该后撤只作用在角色形象视觉容器,不改变 `playerX``sceneHostileNpcs.xMeters`、同伴站位或任何战斗结算字段。
## 验收点
@@ -25,3 +27,5 @@
- 点击具体技能按钮时,播放与结算使用同一个 `skillId`
- 点击恢复时不会出现玩家同时释放攻击技能的错位表现。
- 走服务端 runtime action 的战斗选项仍以 server-rs 返回快照为最终状态。
- 受到伤害时,角色形象上方出现类似 `-9` 的红色浮字,并有短暂后撤回弹。
- 获得治疗时,角色形象上方出现类似 `+1` 的绿色浮字,不触发受击后撤。

View File

@@ -0,0 +1,27 @@
# RPG 创作结果页新增实体同步修复 2026-04-26
## 问题
Agent 草稿结果页点击“新增可扮演角色”后,后端生成请求已完成,但前端提示:
`生成请求已完成,但结果页未收到新增内容,请返回创作页重新打开草稿后重试。`
触发点在结果页实体生成完成后的数量校验:前端会执行 `generate_characters`,等待操作完成后重新拉取 Agent session再把 session 编译成结果页 `CustomWorldProfile`。如果新 profile 中对应实体数量没有增加,就显示该错误。
## 根因
当前链路已经约定 `session.draftProfile` 是 Agent 草稿与 RPG 运行时的真相源,但 `rpgCreationPreviewAdapter.buildPreviewFromSession()` 仍优先读取 `session.resultPreview.preview`
当后端 action 已把新增角色写入 `draftProfile`,但 `resultPreview` 仍是旧快照时,结果页会继续消费旧预览,导致数量校验误判为“没有新增内容”。
## 修复
- `buildCustomWorldProfileFromAgentSession()` 改为优先归一化 `session.draftProfile`
- `resultPreview.preview` 只作为 `draftProfile` 缺失时的兼容 fallback。
- 更新单测覆盖“session 同时存在 draftProfile 与 resultPreview 时,结果页 profile 必须来自 draftProfile”。
## 后续约束
- Agent 草稿结果页新增、删除、进入世界都应以 `draftProfile` 为数据源。
- `resultPreview` 继续承载发布质量、blocker、预览外壳信息不再作为结果页实体列表的优先数据源。
- 若新增实体字段在结果页缺失,应扩展 `draftProfile` 的归一化兼容,而不是把实体读取切回 `resultPreview.preview`

View File

@@ -0,0 +1,102 @@
# RPG 创作编辑器历史素材复用设计
日期:`2026-04-26`
## 1. 本次目标
编辑场景角色与编辑场景图片时,玩家可以复用历史生成素材:
1. 场景角色形象区删除 `基于预设素材修改` 入口,以及它打开的预设拼装编辑下游。
2. 场景角色形象区新增 `使用历史素材`,打开独立素材面板。
3. 场景图片的幕背景配置新增 `使用历史素材`,打开独立素材面板。
4. 历史素材面板需要展示所有账号过去生成过的对应类型素材,并明确标注素材归属账号。
5. 选择素材后直接应用到当前角色或当前幕背景,不触发新一轮生成。
## 2. 数据真相
历史素材不从前端草稿 profile 扫描,也不从预设素材目录扫描。
历史素材统一来自 SpacetimeDB 的 `asset_object` 表:
| 使用位置 | `asset_kind` | 应用字段 |
| --- | --- | --- |
| 场景角色形象 | `character_visual` | `imageSrc``generatedVisualAssetId` |
| 场景幕背景 | `scene_image` | `backgroundImageSrc``backgroundAssetId` |
`asset_object.owner_user_id` 是归属账号标注主源。为空时 UI 标注为 `未记录账号`,不能隐藏归属栏。
## 3. 后端接口
新增只读接口:
```text
GET /api/assets/history?kind=character_visual
GET /api/assets/history?kind=scene_image
```
返回字段只包含前端选择所需脱敏信息:
1. `assetObjectId`
2. `assetKind`
3. `imageSrc`
4. `ownerUserId`
5. `ownerLabel`
6. `profileId`
7. `entityId`
8. `createdAt`
9. `updatedAt`
`asset_object` 仍保持 private table。读取历史列表必须通过 SpacetimeDB procedure + Axum facade不能让前端直接订阅整表。
## 4. SpacetimeDB 设计
`module-assets` 新增:
1. `AssetHistoryListInput`
2. `AssetHistoryEntrySnapshot`
3. `AssetHistoryListResult`
`spacetime-module` 新增 procedure
```rust
list_asset_history_and_return(input: AssetHistoryListInput) -> AssetHistoryListResult
```
实现规则:
1. 只允许 `character_visual``scene_image`
2.`asset_kind` 过滤。
3.`created_at` 倒序返回。
4. 默认最多返回 120 条,允许调用方传更小 limit。
5. `image_src``object_key` 转为兼容代理路径:`/{object_key}`
## 5. 前端交互
历史素材面板为独立弹层,不在当前面板下方展开。
角色历史素材卡片:
1. 展示素材图。
2. 展示归属账号。
3. 展示创建时间。
4. 点击 `使用` 后写入当前草稿角色:
- `imageSrc = imageSrc`
- `generatedVisualAssetId = assetObjectId`
- `generatedAnimationSetId = undefined`
- `animationMap = undefined`
场景历史素材卡片:
1. 展示 16:9 预览。
2. 展示归属账号。
3. 展示创建时间。
4. 点击 `使用` 后写入当前幕:
- `backgroundImageSrc = imageSrc`
- `backgroundAssetId = assetObjectId`
## 6. 非目标
1. 不新增图片生成模型能力。
2. 不把历史素材复制成新 OSS 对象。
3. 不修改旧预设素材目录。
4. 不恢复 server-node 或 PostgreSQL 链路。

View File

@@ -0,0 +1,24 @@
# RPG 创作场景幕资产一致性修复 2026-04-26
## 背景
当前世界草稿和场景编辑器存在三类一致性问题:
1. 世界草稿生成后,开局场景的三幕可能没有默认主角色。
2. 开局场景列表层、幕卡片层、幕背景配置弹层可能显示不同图片。
3. 幕背景智能生成弹层的默认提示词可能退回规则拼接文本,且预览图和外层当前幕不一致。
## 落地约束
1. 后端草稿生成必须为 `sceneChapterBlueprints[*].acts[*]` 写入稳定的幕级字段:`encounterNpcIds``primaryNpcId``oppositeNpcId``eventDescription``backgroundPromptText`
2. 开局场景 `camp` 在生成角色名单之前建立,但最终编译草稿时必须基于已生成的场景角色,为三幕自动补默认主角色,不允许把“开局关键角色”这类占位词留到可编辑草稿里。
3. `backgroundPromptText` 必须优先来自模型生成的自然画面描述;缺失时才使用规则兜底,兜底也要基于真实主角色名。
4. 前端场景编辑器展示某一幕时,列表卡、幕卡、配置弹层、智能生成弹层都应读取同一个幕级 `backgroundImageSrc`;只有旧草稿缺幕图时才展示场景主图作为视觉兜底,保存时不得把兜底图反写到所有幕。
5. 智能生成幕背景时,默认提示词必须使用当前幕 `backgroundPromptText`,不再用标题、摘要、目标拼接替代。
## 验收点
1. 新生成的开局三幕每幕都有非空 `primaryNpcId`,并且第一位 `encounterNpcIds[0]``primaryNpcId` 一致。
2. 普通场景与开局场景都能在幕背景生图 prompt 中写入真实主角色名。
3. 开局场景第 2 幕在列表层、编辑卡片层、配置弹层、智能生成弹层中的预览图保持一致。
4. 点击“跟随场景主图”只影响当前幕,不会把同一张图同步覆盖到三幕。

View File

@@ -0,0 +1,58 @@
# RPG 创作世界角色维度信息编辑落地说明
更新时间:`2026-04-26`
## 1. 背景
统一角色属性系统把一个世界中“角色能力如何被理解”收口到 `CustomWorldProfile.attributeSchema.slots`。这六个 slot 是世界级设定,不是单个角色自己的六个字段。
当前结果页世界页可以展示角色维度,但编辑世界信息时只能修改世界名称、概述、基调、目标等文本,尚不能手动修订六个维度本身的信息。
## 2. 本次目标
在“编辑世界信息”独立面板中允许用户编辑六个角色维度的信息:
1. 修改 `attributeSchema.slots` 中每个维度的 `name``definition``positiveSignals``negativeSignals``combatUseText``socialUseText``explorationUseText`
2. 不在可扮演角色或场景角色编辑器中新增单角色六维数值编辑。
3. 保存时同步更新 `profile.attributeSchema`
4.`profile.ownedSettingLayers.ruleProfile.attributeSchema` 存在,同步写入同一份 schema避免世界档案和设定层出现双源漂移。
5. 前端只负责编辑结构化文本,不新增属性结算逻辑。
## 3. 交互设计
入口位置:
- 世界页点击“世界概述”里的编辑按钮
- 打开现有“编辑世界信息”面板
- 在基础世界文本字段下方增加“角色维度”区块
每个维度展示并允许编辑:
- 维度名称
- 定义
- 正向信号
- 负向信号
- 战斗体现
- 社交体现
- 探索体现
正向信号与负向信号使用逗号、中文逗号或换行拆分成数组。
## 4. 数据落点
保存路径:
- `profile.attributeSchema.slots[n]`
- `profile.ownedSettingLayers.ruleProfile.attributeSchema.slots[n]`,仅当 `ownedSettingLayers` 已存在时同步
不修改:
- `profile.playableNpcs[n].attributeProfile`
- `profile.storyNpcs[n].attributeProfile`
## 5. 验收
1. 世界信息面板能看到六个角色维度。
2. 修改任一维度名称、定义、信号或三类用途说明后,保存到 `profile.attributeSchema.slots`
3. 编辑角色自身时不出现单角色六维数值输入区。
4. UI 仍读取当前世界 schema不回退写死旧四维文案。

View File

@@ -0,0 +1,19 @@
# 世界档案 PC 布局扩展2026-04-26
## 背景
世界档案结果页和各实体编辑子面板在 PC 端仍沿用偏窄的弹窗与两列列表布局,宽屏下画布利用率不足。移动端现有单列滚动、底部弹层和触控布局已经稳定,本次不改变移动端结构。
## 落地规则
1. 世界档案主页面只在 `xl` 及以上断点扩展画布宽度与内部间距,移动端和 `sm/md/lg` 断点保持原有布局。
2. 场景、可扮演角色、场景角色列表在 PC 端提高列数上限,让宽屏能显示更多卡片。
3. 世界 Tab 在 PC 端由窄两列调整为更充分的三列信息布局,减少首屏纵向堆叠。
4. 世界信息、基本设定、角色与场景编辑子面板在 PC 端使用更宽的 `max-width` 与更高可用高度;移动端仍保持 `h-[92vh]` 的底部弹层。
5. 不新增说明类 UI 文案,不改写现有中文内容,不改变保存、删除、生成、发布等行为。
## 验收点
1. PC 端世界档案结果页内容区域能接近占满工作区宽度,列表在 2K 宽屏下可呈现更多列。
2. PC 端打开世界、基本设定、角色、场景子面板时,弹窗宽度明显大于旧版 `sm:max-w-2xl` 默认宽度。
3. 移动端仍为单列滚动列表,编辑子面板仍从底部弹出且宽度为全屏。

View File

@@ -29,6 +29,7 @@
- 单轮变化限制在 `[-3, 3]`
5. `chatDirective.forceExitAfterTurn / closingMode=foreshadow_close` 时不生成建议,返回空数组,并在 `complete.chatDirective.forceExit` 中显式告知前端退出。
6. LLM 未配置或失败时继续返回后端兜底 SSE保证相遇和点击聊天链路不断。
7. 在 Node 已完成平台账号鉴权并通过内部密钥转发到 Rust API 的链路中,`/api/runtime/chat/` 必须纳入 Rust 内部转发鉴权白名单;否则 NPC 主动开场会在进入 `runtime_chat` handler 前被 401 拒绝前端只能收到“NPC 聊天续写失败”。
## 暂不落地
@@ -46,11 +47,15 @@
- 优先 `LlmClient.stream_text(...)` 生成 `reply_delta`
- 再调用 `request_text(...)` 生成建议。
- 计算 `affinityDelta / affinityText / chatDirective` 后输出 `complete`
3. 修改 `server-rs/crates/api-server/src/main.rs`
- SSE stream 返回给 Axum 时要求 `'static`,进入 stream 的玩家输入必须先转成 owned `String`,不能把 handler 栈上的 `&str` 借入 stream。
3. 修改 `server-rs/crates/api-server/src/auth.rs`
- `allows_internal_forwarded_auth(...)` 允许 `/api/runtime/chat/`,与 big-fish、puzzle 的内部转发鉴权策略保持一致。
- 单测覆盖 `/api/runtime/chat/npc/turn/stream`,防止后续新增 runtime 路由时再次遗漏内部转发白名单。
4. 修改 `server-rs/crates/api-server/src/main.rs`
- 注册 `runtime_chat_prompt` 模块。
## 验收
1. `cargo fmt -p api-server`
2. `cargo check -p api-server`
3. `node scripts/check-encoding.mjs docs/technical/RUNTIME_NPC_CHAT_LLM_MIGRATION_2026-04-25.md server-rs/crates/api-server/src/runtime_chat.rs server-rs/crates/api-server/src/runtime_chat_prompt.rs server-rs/crates/api-server/src/main.rs`
3. `node scripts/check-encoding.mjs docs/technical/RUNTIME_NPC_CHAT_LLM_MIGRATION_2026-04-25.md server-rs/crates/api-server/src/auth.rs server-rs/crates/api-server/src/runtime_chat.rs server-rs/crates/api-server/src/runtime_chat_prompt.rs server-rs/crates/api-server/src/main.rs`

View File

@@ -111,6 +111,12 @@ npm run dev:rust:logs -- --follow
2. 仅供测试断言使用的辅助函数使用 `#[cfg(test)]` 限定,避免进入 `cargo run -p api-server` 的普通二进制编译。
3. 已无调用入口且无迁移价值的映射函数直接删除;如果后续新增同类 SpacetimeDB 记录映射,再按实际调用路径补回,避免提前保留死代码。
Maincloud API 重启补充:
1. `npm run api-server:maincloud` 会先读取 `.env``.env.local`,把 `GENARRATIVE_SPACETIME_MAINCLOUD_*` 映射为 `api-server` 使用的 `GENARRATIVE_SPACETIME_*`,再运行 `cargo run -p api-server --manifest-path server-rs/Cargo.toml`
2. Windows 下脚本会尽力停止本仓库 `server-rs/target/debug/api-server.exe` 对应的旧进程,避免 cargo 重新编译时 exe 被占用。
3. 旧进程已经退出或清理过程中出现瞬时等待失败时,不应阻断新的 `api-server` 启动;脚本只记录清理失败并继续启动。
## 3. Ubuntu 发布包脚本
入口:

View File

@@ -8,19 +8,19 @@
Maincloud 发布不复用本地 `spacetime.local.json`,避免误把本地开发库名发布到云端。需要显式提供:
| 变量 | 用途 |
| --- | --- |
| `GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE` | Maincloud 数据库名,发布脚本优先读取 |
| 变量 | 用途 |
| -------------------------------------------- | ------------------------------------------------------------ |
| `GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE` | Maincloud 数据库名,发布脚本优先读取 |
| `GENARRATIVE_SPACETIME_MAINCLOUD_SERVER_URL` | Maincloud 服务地址,默认 `https://maincloud.spacetimedb.com` |
| `GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN` | `api-server` 连接 Maincloud 时使用的 token |
| `GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN` | `api-server` 连接 Maincloud 时使用的 token |
兼容 `api-server` 现有变量:
| 变量 | 用途 |
| --- | --- |
| `GENARRATIVE_SPACETIME_SERVER_URL` | `api-server` 实际连接地址 |
| `GENARRATIVE_SPACETIME_DATABASE` | `api-server` 实际连接数据库 |
| `GENARRATIVE_SPACETIME_TOKEN` | `api-server` 实际连接 token |
| 变量 | 用途 |
| ---------------------------------- | --------------------------- |
| `GENARRATIVE_SPACETIME_SERVER_URL` | `api-server` 实际连接地址 |
| `GENARRATIVE_SPACETIME_DATABASE` | `api-server` 实际连接数据库 |
| `GENARRATIVE_SPACETIME_TOKEN` | `api-server` 实际连接 token |
## npm 命令
@@ -50,10 +50,12 @@ npm run api-server:maincloud
1.`.env``.env.local` 读取默认环境。
2.`GENARRATIVE_SPACETIME_MAINCLOUD_*` 映射为 `api-server` 已支持的 `GENARRATIVE_SPACETIME_*`
3. 启动 `cargo run -p api-server --manifest-path server-rs/Cargo.toml`
3. 在 Windows 启动前检查 `server-rs/target/debug/api-server.exe` 对应的旧进程;如果旧进程仍在运行,先停止它,避免 Rust 编译阶段覆盖 exe 时出现 `failed to remove file ... 拒绝访问。 (os error 5)`
4. 启动 `cargo run -p api-server --manifest-path server-rs/Cargo.toml`
## 设计约束
- Maincloud 数据库名必须显式配置,不能默认读取本地 `spacetime.local.json`
- 发布脚本只处理 SpacetimeDB 模块发布,不启动本地 SpacetimeDB。
- `api-server` 继续通过 `SpacetimeClientConfig``server_url / database / token` 连接数据库,不在前端增加逻辑。
- Windows 进程清理只能匹配本仓库 `server-rs/target/debug/api-server.exe` 的完整路径,不能按进程名泛化清理,避免影响其他 Rust 服务。

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,536 @@
data: {"choices":[{"delta":{"content":"{","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"\n","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":" ","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":" \"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"reply","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"Text","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"\":","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":" \"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"好","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"的","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"根据","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"你的","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"要求","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"我","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"为","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"你","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"补齐","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"了","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"拼图","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"的","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"设定","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"。","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"主题","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"是","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"可爱","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"小狗","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"的","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"温馨","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"瞬间","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"画面","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"主体","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"是","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"一只","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"正在","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"打","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"盹","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"的","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"金毛","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"犬","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"躺在","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"柔软","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"的","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"地毯","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"上","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"旁边","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"有","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"一个","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"毛绒","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"玩具","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"。","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"视觉","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"气质","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"是","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"温暖","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"、","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"柔和","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"的","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"色调","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"整体","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"给","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"人","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"一种","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"温馨","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"、","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"舒适","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"的","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"感觉","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"。","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"拼图","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"记忆","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"点","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"可以","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"是","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"小狗","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"的","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"轮廓","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"、","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"毛发","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"纹理","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"、","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"玩具","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"的","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"形状","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"和","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"颜色","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"等","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"。","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"标签","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"为","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"小狗","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"温馨","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"可爱","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"禁忌","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"是","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"避免","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"出现","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"血腥","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"、","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"暴力","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"、","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"恐怖","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"等","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"内容","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"。","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"你","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"可以","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"根据","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"这些","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"设定","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"生成","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"结果","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"页","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"了","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"。","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"\",","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"\n","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":" ","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":" \"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"progress","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"Percent","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"\":","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":" ","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"1","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"0","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"0","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":",","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"\n","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":" ","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":" \"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"next","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"Anchor","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"Pack","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"\":","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":" {","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"\n","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":" ","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":" \"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"theme","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"Promise","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"\":","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":" {","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"\n","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":" ","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":" \"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"key","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"\":","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":" \"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"theme","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"Promise","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"\",","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"\n","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":" ","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":" \"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"label","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"\":","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":" \"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"题材","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"承诺","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"\",","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"\n","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":" ","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":" \"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"value","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"\":","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":" \"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"可爱","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"小狗","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"的","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"温馨","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"瞬间","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"\",","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"\n","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":" ","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":" \"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"status","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"\":","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":" \"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"confirmed","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"\"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"\n","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":" ","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":" },","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"\n","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":" ","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":" \"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"visual","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"Subject","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"\":","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":" {","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"\n","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":" ","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":" \"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"key","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"\":","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":" \"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"visual","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"Subject","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"\",","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"\n","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":" ","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":" \"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"label","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"\":","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":" \"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"画面","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"主体","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"\",","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"\n","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":" ","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":" \"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"value","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"\":","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":" \"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"一只","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"正在","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"打","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"盹","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"的","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"金毛","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"犬","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"躺在","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"柔软","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"的","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"地毯","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"上","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"旁边","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"有","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"一个","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"毛绒","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"玩具","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"\",","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"\n","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":" ","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":" \"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"status","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"\":","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":" \"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"confirmed","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"\"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"\n","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":" ","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":" },","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"\n","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":" ","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":" \"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"visual","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"M","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"ood","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"\":","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":" {","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}
data: {"choices":[{"delta":{"content":"\n","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null}

View File

@@ -1,8 +1,12 @@
import {spawn} from 'node:child_process';
import {existsSync, readFileSync} from 'node:fs';
import {resolve} from 'node:path';
import { execFileSync, spawn } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const repoRoot = process.cwd();
const apiServerExePath = resolve(
repoRoot,
'server-rs/target/debug/api-server.exe',
);
function loadEnvFile(path, target) {
if (!existsSync(path)) {
@@ -30,7 +34,7 @@ function loadEnvFile(path, target) {
}
}
const mergedEnv = {...process.env};
const mergedEnv = { ...process.env };
loadEnvFile(resolve(repoRoot, '.env'), mergedEnv);
loadEnvFile(resolve(repoRoot, '.env.local'), mergedEnv);
@@ -56,6 +60,56 @@ if (!mergedEnv.GENARRATIVE_SPACETIME_DATABASE) {
process.exit(1);
}
function stopExistingWindowsApiServer() {
if (process.platform !== 'win32') {
return;
}
// Windows 下 cargo 重新编译时无法覆盖仍在运行的 exe只清理本仓库 target 内的旧进程。
const command = [
'$ErrorActionPreference = "Continue"',
'$target = [System.IO.Path]::GetFullPath($env:GENARRATIVE_API_SERVER_EXE_TARGET)',
'$processes = Get-Process -Name api-server -ErrorAction SilentlyContinue | Where-Object {',
' $_.Path -and ([System.IO.Path]::GetFullPath($_.Path) -ieq $target)',
'}',
'foreach ($process in $processes) {',
' try {',
' Stop-Process -Id $process.Id -Force -ErrorAction Stop',
' Wait-Process -Id $process.Id -Timeout 5 -ErrorAction SilentlyContinue',
' Write-Output $process.Id',
' } catch {',
' Write-Error "[api-server:maincloud] 忽略旧进程清理瞬时失败 pid=$($process.Id): $($_.Exception.Message)"',
' }',
'}',
'exit 0',
].join('\n');
const output = execFileSync(
'powershell.exe',
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', command],
{
encoding: 'utf8',
env: {
...process.env,
GENARRATIVE_API_SERVER_EXE_TARGET: apiServerExePath,
},
},
).trim();
if (output) {
console.log(`[api-server:maincloud] 已停止旧 api-server 进程: ${output}`);
}
}
try {
stopExistingWindowsApiServer();
} catch (error) {
console.error(
`[api-server:maincloud] 清理旧 api-server 进程失败: ${error.message}`,
);
process.exit(1);
}
console.log(
`[api-server:maincloud] SpacetimeDB ${mergedEnv.GENARRATIVE_SPACETIME_DATABASE} @ ${mergedEnv.GENARRATIVE_SPACETIME_SERVER_URL}`,
);
@@ -67,7 +121,6 @@ const child = spawn(
cwd: repoRoot,
env: mergedEnv,
stdio: 'inherit',
shell: process.platform === 'win32',
},
);

View File

@@ -1,28 +0,0 @@
{
"next_user_id": 2,
"users_by_username": {
"phone_00000002": {
"user": {
"id": "user_00000001",
"public_user_code": "SY-00000001",
"username": "phone_00000002",
"display_name": "138****8111",
"phone_number_masked": "138****8111",
"login_method": "Phone",
"binding_status": "Active",
"wechat_bound": false,
"token_version": 1
},
"password_hash": "$argon2id$v=19$m=19456,t=2,p=1$qnArSgOrZvcQxap4KAMMnA$+K+gQgf7h0jQibJLuvAlOeHnNNYutTvLVDAyo1hqS/o",
"password_login_enabled": false,
"phone_number": "+8613800138111"
}
},
"phone_to_user_id": {
"+8613800138111": "user_00000001"
},
"sessions_by_id": {},
"session_id_by_refresh_token_hash": {},
"wechat_identity_by_provider_uid": {},
"user_id_by_provider_union_id": {}
}

View File

@@ -1,28 +0,0 @@
{
"next_user_id": 2,
"users_by_username": {
"phone_00000002": {
"user": {
"id": "user_00000001",
"public_user_code": "SY-00000001",
"username": "phone_00000002",
"display_name": "138****8112",
"phone_number_masked": "138****8112",
"login_method": "Phone",
"binding_status": "Active",
"wechat_bound": false,
"token_version": 1
},
"password_hash": "$argon2id$v=19$m=19456,t=2,p=1$0HR2g/fKOw9EFHz7BuYtGg$cpXb5KBwbEXPxPJHA4Bk1U7NtM97GhGTq7VK6jCJ+lA",
"password_login_enabled": false,
"phone_number": "+8613800138112"
}
},
"phone_to_user_id": {
"+8613800138112": "user_00000001"
},
"sessions_by_id": {},
"session_id_by_refresh_token_hash": {},
"wechat_identity_by_provider_uid": {},
"user_id_by_provider_union_id": {}
}

View File

@@ -1,28 +0,0 @@
{
"next_user_id": 2,
"users_by_username": {
"phone_00000002": {
"user": {
"id": "user_00000001",
"public_user_code": "SY-00000001",
"username": "phone_00000002",
"display_name": "138****8110",
"phone_number_masked": "138****8110",
"login_method": "Phone",
"binding_status": "Active",
"wechat_bound": false,
"token_version": 1
},
"password_hash": "$argon2id$v=19$m=19456,t=2,p=1$fEeSrVyialDeb8rarDSpdA$HFihZiuCOyaz8F5iNukmobeiHI/EpYWdeQzhbIYR4zk",
"password_login_enabled": false,
"phone_number": "+8613800138110"
}
},
"phone_to_user_id": {
"+8613800138110": "user_00000001"
},
"sessions_by_id": {},
"session_id_by_refresh_token_hash": {},
"wechat_identity_by_provider_uid": {},
"user_id_by_provider_union_id": {}
}

View File

@@ -23,7 +23,7 @@ use crate::{
},
assets::{
bind_asset_object_to_entity, confirm_asset_object, create_direct_upload_ticket,
create_sts_upload_credentials, get_asset_read_url,
create_sts_upload_credentials, get_asset_history, get_asset_read_url,
},
auth::{
attach_refresh_session_token, inspect_auth_claims, inspect_refresh_session_cookie,
@@ -394,6 +394,13 @@ pub fn build_router(state: AppState) -> Router {
get(get_character_workflow_cache),
)
.route("/api/assets/read-url", get(get_asset_read_url))
.route(
"/api/assets/history",
get(get_asset_history).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/settings",
get(get_runtime_settings)

View File

@@ -14,10 +14,11 @@ use platform_oss::{
};
use serde_json::{Value, json};
use shared_contracts::assets::{
AssetBindingPayload, AssetObjectPayload, AssetReadUrlPayload, BindAssetObjectRequest,
BindAssetObjectResponse, ConfirmAssetObjectAccessPolicy, ConfirmAssetObjectRequest,
ConfirmAssetObjectResponse, CreateDirectUploadTicketRequest, CreateDirectUploadTicketResponse,
DirectUploadTicketPayload, GetAssetReadUrlResponse, GetReadUrlQuery,
AssetBindingPayload, AssetHistoryEntryPayload, AssetHistoryListResponse, AssetHistoryQuery,
AssetObjectPayload, AssetReadUrlPayload, BindAssetObjectRequest, BindAssetObjectResponse,
ConfirmAssetObjectAccessPolicy, ConfirmAssetObjectRequest, ConfirmAssetObjectResponse,
CreateDirectUploadTicketRequest, CreateDirectUploadTicketResponse, DirectUploadTicketPayload,
GetAssetReadUrlResponse, GetReadUrlQuery,
};
use spacetime_client::SpacetimeClientError;
@@ -111,6 +112,51 @@ pub async fn get_asset_read_url(
))
}
pub async fn get_asset_history(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Query(query): Query<AssetHistoryQuery>,
) -> Result<Json<Value>, AppError> {
let asset_kind = query.kind.trim().to_string();
if asset_kind != "character_visual" && asset_kind != "scene_image" {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"field": "kind",
"message": "历史素材类型只支持 character_visual 或 scene_image",
})),
);
}
let entries = state
.spacetime_client()
.list_asset_history(module_assets::AssetHistoryListInput {
asset_kind,
limit: query.limit.unwrap_or(120).clamp(1, 120),
})
.await
.map_err(map_confirm_asset_object_error)?;
Ok(json_success_body(
Some(&request_context),
AssetHistoryListResponse {
assets: entries
.into_iter()
.map(|entry| AssetHistoryEntryPayload {
owner_label: format_asset_owner_label(entry.owner_user_id.as_deref()),
asset_object_id: entry.asset_object_id,
asset_kind: entry.asset_kind,
image_src: entry.image_src,
owner_user_id: entry.owner_user_id,
profile_id: entry.profile_id,
entity_id: entry.entity_id,
created_at: entry.created_at,
updated_at: entry.updated_at,
})
.collect(),
},
))
}
pub async fn create_sts_upload_credentials(
Extension(_request_context): Extension<RequestContext>,
) -> Result<Json<Value>, AppError> {
@@ -232,6 +278,16 @@ fn resolve_object_key_from_query(query: &GetReadUrlQuery) -> Option<String> {
.map(|value| value.trim_start_matches('/').to_string())
}
fn format_asset_owner_label(owner_user_id: Option<&str>) -> String {
let Some(owner_user_id) = owner_user_id
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return "未记录账号".to_string();
};
format!("账号 {owner_user_id}")
}
async fn build_confirm_asset_object_upsert_input(
oss_client: &platform_oss::OssClient,
payload: ConfirmAssetObjectRequest,

View File

@@ -189,7 +189,9 @@ fn extract_bearer_token(headers: &HeaderMap) -> Result<String, AppError> {
fn allows_internal_forwarded_auth(path: &str) -> bool {
// Node 代理已经完成平台账号 JWT 校验Rust 运行时只信任这些明确的内部转发路径。
path.starts_with("/api/runtime/big-fish/") || path.starts_with("/api/runtime/puzzle/")
path.starts_with("/api/runtime/big-fish/")
|| path.starts_with("/api/runtime/chat/")
|| path.starts_with("/api/runtime/puzzle/")
}
fn try_build_internal_forwarded_claims(
@@ -282,6 +284,9 @@ mod tests {
assert!(allows_internal_forwarded_auth(
"/api/runtime/big-fish/sessions"
));
assert!(allows_internal_forwarded_auth(
"/api/runtime/chat/npc/turn/stream"
));
assert!(allows_internal_forwarded_auth("/api/runtime/puzzle/works"));
assert!(!allows_internal_forwarded_auth("/api/auth/me"));
}

View File

@@ -1,13 +1,17 @@
use std::{
collections::BTreeMap,
convert::Infallible,
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
use axum::{
Json,
extract::{Extension, Path, State, rejection::JsonRejection},
http::{HeaderName, StatusCode, header},
response::{IntoResponse, Response},
http::StatusCode,
response::{
IntoResponse, Response,
sse::{Event, Sse},
},
};
use module_assets::{
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input,
@@ -323,93 +327,107 @@ pub async fn stream_big_fish_message(
big_fish_error_response(&request_context, map_big_fish_client_error(error))
})?;
let quick_fill_requested = payload.quick_fill_requested.unwrap_or(false);
let mut draft_writer = AiGenerationDraftWriter::new(AiGenerationDraftContext::new(
"big_fish",
owner_user_id.as_str(),
session_id.as_str(),
payload.client_message_id.as_str(),
"大鱼吃小鱼模板生成草稿",
));
if let Err(error) = draft_writer.ensure_started(state.spacetime_client()).await {
tracing::warn!(error = %error, "大鱼吃小鱼模板生成草稿任务启动失败,主生成流程继续执行");
}
let draft_sink = AiGenerationDraftSink::new(
AiGenerationDraftContext::new(
let state = state.clone();
let session_id_for_stream = session_id.clone();
let owner_user_id_for_stream = owner_user_id.clone();
let client_message_id_for_stream = payload.client_message_id.clone();
let stream = async_stream::stream! {
let mut draft_writer = AiGenerationDraftWriter::new(AiGenerationDraftContext::new(
"big_fish",
owner_user_id.as_str(),
session_id.as_str(),
payload.client_message_id.as_str(),
owner_user_id_for_stream.as_str(),
session_id_for_stream.as_str(),
client_message_id_for_stream.as_str(),
"大鱼吃小鱼模板生成草稿",
),
state.spacetime_client().clone(),
);
let mut streamed_reply_text = String::new();
let turn_result = run_big_fish_agent_turn(
BigFishAgentTurnRequest {
llm_client: state.llm_client(),
session: &submitted_session,
quick_fill_requested,
},
|text| {
draft_sink.persist_visible_text_async(text);
streamed_reply_text = text.to_string();
},
)
.await;
if !streamed_reply_text.is_empty() {
draft_writer
.persist_visible_text(state.spacetime_client(), streamed_reply_text.as_str())
.await;
}
let reply_text = match &turn_result {
Ok(result) => result.assistant_reply_text.clone(),
Err(error) => error.to_string(),
};
let finalize_input = match turn_result {
Ok(turn_result) => build_finalize_record_input(
session_id.clone(),
owner_user_id.clone(),
build_prefixed_uuid_id("big-fish-message-"),
turn_result,
current_utc_micros(),
),
Err(error) => build_failed_finalize_record_input(
session_id.clone(),
owner_user_id.clone(),
&submitted_session,
error.to_string(),
current_utc_micros(),
),
};
let session = state
.spacetime_client()
.finalize_big_fish_agent_message(finalize_input)
.await
.map_err(|error| {
big_fish_error_response(&request_context, map_big_fish_client_error(error))
})?;
));
if let Err(error) = draft_writer.ensure_started(state.spacetime_client()).await {
tracing::warn!(error = %error, "大鱼吃小鱼模板生成草稿任务启动失败,主生成流程继续执行");
}
let (reply_tx, mut reply_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
// 与 RPG/拼图 Agent 保持同一语义回复先流式展示session 真相仍等 finalize 后下发。
let turn_result = {
let run_turn = run_big_fish_agent_turn(
BigFishAgentTurnRequest {
llm_client: state.llm_client(),
session: &submitted_session,
quick_fill_requested,
},
move |text| {
let _ = reply_tx.send(text.to_string());
},
);
tokio::pin!(run_turn);
let session_response = map_big_fish_session_response(session);
let mut sse_body = String::new();
append_sse_event(
&request_context,
&mut sse_body,
"reply_delta",
&json!({ "text": if streamed_reply_text.is_empty() { reply_text } else { streamed_reply_text } }),
)?;
append_sse_event(
&request_context,
&mut sse_body,
"session",
&json!({ "session": session_response }),
)?;
append_sse_event(
&request_context,
&mut sse_body,
"done",
&json!({ "ok": true }),
)?;
Ok(build_event_stream_response(sse_body))
loop {
// 每个 replyText 增量同时写草稿表并推给 SSE避免前端等待完整模型响应。
tokio::select! {
result = &mut run_turn => break result,
maybe_text = reply_rx.recv() => {
if let Some(text) = maybe_text {
draft_writer
.persist_visible_text(state.spacetime_client(), text.as_str())
.await;
yield Ok::<Event, Infallible>(big_fish_sse_json_event_or_error(
"reply_delta",
json!({ "text": text }),
));
}
}
}
}
};
while let Some(text) = reply_rx.recv().await {
draft_writer
.persist_visible_text(state.spacetime_client(), text.as_str())
.await;
yield Ok::<Event, Infallible>(big_fish_sse_json_event_or_error(
"reply_delta",
json!({ "text": text }),
));
}
let finalize_input = match turn_result {
Ok(turn_result) => build_finalize_record_input(
session_id_for_stream.clone(),
owner_user_id_for_stream.clone(),
build_prefixed_uuid_id("big-fish-message-"),
turn_result,
current_utc_micros(),
),
Err(error) => build_failed_finalize_record_input(
session_id_for_stream.clone(),
owner_user_id_for_stream.clone(),
&submitted_session,
error.to_string(),
current_utc_micros(),
),
};
let session = match state
.spacetime_client()
.finalize_big_fish_agent_message(finalize_input)
.await
{
Ok(session) => session,
Err(error) => {
yield Ok::<Event, Infallible>(big_fish_sse_json_event_or_error(
"error",
json!({ "message": error.to_string() }),
));
return;
}
};
let session_response = map_big_fish_session_response(session);
yield Ok::<Event, Infallible>(big_fish_sse_json_event_or_error(
"session",
json!({ "session": session_response }),
));
yield Ok::<Event, Infallible>(big_fish_sse_json_event_or_error(
"done",
json!({ "ok": true }),
));
};
Ok(Sse::new(stream).into_response())
}
pub async fn execute_big_fish_action(
@@ -1706,40 +1724,20 @@ fn big_fish_bad_request(request_context: &RequestContext, message: &str) -> Resp
)
}
fn append_sse_event(
request_context: &RequestContext,
body: &mut String,
event: &str,
payload: &Value,
) -> Result<(), Response> {
let payload_text = serde_json::to_string(payload).map_err(|error| {
big_fish_error_response(
request_context,
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "big-fish",
"message": format!("SSE payload 序列化失败:{error}"),
})),
)
})?;
body.push_str("event: ");
body.push_str(event);
body.push('\n');
body.push_str("data: ");
body.push_str(&payload_text);
body.push_str("\n\n");
Ok(())
fn big_fish_sse_json_event_or_error(event_name: &str, payload: Value) -> Event {
match serde_json::to_string(&payload) {
Ok(payload_text) => Event::default().event(event_name).data(payload_text),
Err(_) => big_fish_sse_error_event_message("SSE payload 序列化失败".to_string()),
}
}
fn build_event_stream_response(body: String) -> Response {
(
[
(header::CONTENT_TYPE, "text/event-stream; charset=utf-8"),
(header::CACHE_CONTROL, "no-cache"),
(HeaderName::from_static("x-accel-buffering"), "no"),
],
body,
)
.into_response()
fn big_fish_sse_error_event_message(message: String) -> Event {
let payload = format!(
"{{\"message\":{}}}",
serde_json::to_string(&message)
.unwrap_or_else(|_| "\"SSE 错误事件序列化失败\"".to_string())
);
Event::default().event("error").data(payload)
}
fn map_big_fish_client_error(error: SpacetimeClientError) -> AppError {

View File

@@ -901,34 +901,49 @@ pub async fn stream_custom_world_agent_message(
if let Err(error) = draft_writer.ensure_started(state.spacetime_client()).await {
tracing::warn!(error = %error, "自定义世界模板生成草稿任务启动失败,主生成流程继续执行");
}
let draft_sink = AiGenerationDraftSink::new(
AiGenerationDraftContext::new(
"custom_world",
owner_user_id_for_stream.as_str(),
session_id_for_stream.as_str(),
operation_id.as_str(),
"自定义世界模板生成草稿",
),
state.spacetime_client().clone(),
);
// 聊天回复必须等本轮模型解析、进度与会话快照全部落库后,
// 再随最终 session 一次性返回,避免玩家先看到回复而进度仍停在旧状态。
let turn_result = run_custom_world_agent_turn(
CustomWorldAgentTurnRequest {
llm_client: state.llm_client(),
session: &session,
quick_fill_requested,
focus_card_id,
},
move |text| {
draft_sink.persist_visible_text_async(text);
},
)
.await;
if let Ok(result) = &turn_result {
let (reply_tx, mut reply_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
// Agent turn 仍负责完整 JSON 解析和最终写回;这里把 replyText 增量桥接成前端可见的 SSE 分片。
let turn_result = {
let run_turn = run_custom_world_agent_turn(
CustomWorldAgentTurnRequest {
llm_client: state.llm_client(),
session: &session,
quick_fill_requested,
focus_card_id,
},
move |text| {
let _ = reply_tx.send(text.to_string());
},
);
tokio::pin!(run_turn);
loop {
// 不等待最终 session 落库即可先推送回复进度session/done 仍在 finalize 成功后发送。
tokio::select! {
result = &mut run_turn => break result,
maybe_text = reply_rx.recv() => {
if let Some(text) = maybe_text {
draft_writer
.persist_visible_text(state.spacetime_client(), text.as_str())
.await;
yield Ok::<Event, Infallible>(custom_world_sse_json_event_or_error(
"reply_delta",
json!({ "text": text }),
));
}
}
}
}
};
while let Some(text) = reply_rx.recv().await {
draft_writer
.persist_visible_text(state.spacetime_client(), result.assistant_reply_text.as_str())
.persist_visible_text(state.spacetime_client(), text.as_str())
.await;
yield Ok::<Event, Infallible>(custom_world_sse_json_event_or_error(
"reply_delta",
json!({ "text": text }),
));
}
let finalize_input = match turn_result {

View File

@@ -75,7 +75,7 @@ pub async fn generate_custom_world_foundation_draft(
.await?;
framework["storyNpcs"] = JsonValue::Array(story_outlines.clone());
let landmarks = generate_foundation_landmark_seed_entries(
let generated_scene_entries = generate_foundation_landmark_seed_entries(
llm_client,
&framework,
FOUNDATION_DRAFT_LANDMARK_COUNT,
@@ -83,7 +83,7 @@ pub async fn generate_custom_world_foundation_draft(
&mut on_progress,
)
.await?;
framework["landmarks"] = JsonValue::Array(landmarks.clone());
framework["landmarks"] = JsonValue::Array(generated_scene_entries.clone());
let playable_narrative = expand_foundation_role_entries(
llm_client,
@@ -137,7 +137,7 @@ pub async fn generate_custom_world_foundation_draft(
framework,
playable_detailed,
story_detailed,
landmarks,
generated_scene_entries,
session,
setting_text.as_str(),
);
@@ -304,6 +304,7 @@ async fn generate_foundation_landmark_seed_entries(
}
let batch_count = (total_count - merged_entries.len()).min(FOUNDATION_LANDMARK_BATCH_SIZE);
let forbidden_names = names_from_entries(&merged_entries);
let is_opening_batch = batch_index == 0 && merged_entries.is_empty();
emit_foundation_draft_progress(
on_progress,
"生成关键场景",
@@ -319,13 +320,19 @@ async fn generate_foundation_landmark_seed_entries(
);
let raw = request_foundation_json_stage(
llm_client,
build_custom_world_landmark_seed_batch_prompt(framework, batch_count, &forbidden_names),
build_custom_world_landmark_seed_batch_prompt(
framework,
batch_count,
&forbidden_names,
is_opening_batch,
),
format!("agent-foundation-landmark-seed-batch-{}", batch_index + 1).as_str(),
|response_text| {
build_custom_world_landmark_seed_batch_json_repair_prompt(
response_text,
batch_count,
&forbidden_names,
is_opening_batch,
)
},
format!(
@@ -714,7 +721,7 @@ fn build_foundation_draft_profile_from_framework(
framework: JsonValue,
playable_detailed: Vec<JsonValue>,
story_detailed: Vec<JsonValue>,
landmarks: Vec<JsonValue>,
generated_scene_entries: Vec<JsonValue>,
session: &CustomWorldAgentSessionRecord,
setting_text: &str,
) -> JsonMap<String, JsonValue> {
@@ -793,9 +800,14 @@ fn build_foundation_draft_profile_from_framework(
setting_text,
),
);
let camp = framework.get("camp").cloned().unwrap_or_else(
let fallback_camp = framework.get("camp").cloned().unwrap_or_else(
|| json!({ "name": "开局归处", "description": "玩家进入世界后的第一处落脚点。" }),
);
let playable_detailed = assign_role_ids(playable_detailed, "playable-npc");
let story_detailed = assign_role_ids(story_detailed, "story-npc");
let scene_role_refs = collect_scene_role_refs(&story_detailed);
let (camp, landmarks) =
split_generated_scenes_into_camp_and_landmarks(fallback_camp, generated_scene_entries);
object.insert("camp".to_string(), camp.clone());
object.insert(
"playableNpcs".to_string(),
@@ -803,7 +815,7 @@ fn build_foundation_draft_profile_from_framework(
);
object.insert("storyNpcs".to_string(), JsonValue::Array(story_detailed));
let scene_chapter_blueprints =
build_scene_chapter_blueprints_from_camp_and_landmarks(&camp, &landmarks);
build_scene_chapter_blueprints_from_camp_and_landmarks(&camp, &landmarks, &scene_role_refs);
object.insert("landmarks".to_string(), JsonValue::Array(landmarks));
object.insert("chapters".to_string(), JsonValue::Array(Vec::new()));
object.insert(
@@ -1095,9 +1107,50 @@ fn stable_ascii_slug(value: &str) -> String {
format!("{hash:08x}")
}
fn split_generated_scenes_into_camp_and_landmarks(
fallback_camp: JsonValue,
generated_scene_entries: Vec<JsonValue>,
) -> (JsonValue, Vec<JsonValue>) {
let mut entries = generated_scene_entries.into_iter();
let opening_scene = entries.next().unwrap_or(fallback_camp);
let camp = normalize_generated_opening_scene(opening_scene);
let landmarks = entries.collect::<Vec<_>>();
(camp, landmarks)
}
fn normalize_generated_opening_scene(scene: JsonValue) -> JsonValue {
let mut object = scene.as_object().cloned().unwrap_or_default();
let name = object
.get("name")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("开局归处")
.to_string();
let description = object
.get("description")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("玩家进入世界后的第一处落脚点。")
.to_string();
object.insert("id".to_string(), JsonValue::String("camp-1".to_string()));
object.insert("kind".to_string(), JsonValue::String("camp".to_string()));
object.insert("name".to_string(), JsonValue::String(name));
object.insert("description".to_string(), JsonValue::String(description));
JsonValue::Object(object)
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct SceneRoleRef {
id: String,
name: String,
}
fn build_scene_chapter_blueprints_from_camp_and_landmarks(
camp: &JsonValue,
landmarks: &[JsonValue],
scene_role_refs: &[SceneRoleRef],
) -> Vec<JsonValue> {
let mut blueprints = Vec::with_capacity(landmarks.len() + 1);
blueprints.push(build_scene_chapter_blueprint_from_scene(
@@ -1105,12 +1158,19 @@ fn build_scene_chapter_blueprints_from_camp_and_landmarks(
0,
"camp",
"开局归处",
scene_role_refs,
));
blueprints.extend(build_scene_chapter_blueprints_from_landmarks(
landmarks,
scene_role_refs,
));
blueprints.extend(build_scene_chapter_blueprints_from_landmarks(landmarks));
blueprints
}
fn build_scene_chapter_blueprints_from_landmarks(landmarks: &[JsonValue]) -> Vec<JsonValue> {
fn build_scene_chapter_blueprints_from_landmarks(
landmarks: &[JsonValue],
scene_role_refs: &[SceneRoleRef],
) -> Vec<JsonValue> {
// 幕背景描述必须来自关键场景生成步骤,不能在草稿合成阶段再用规则句拼接。
landmarks
.iter()
@@ -1121,6 +1181,7 @@ fn build_scene_chapter_blueprints_from_landmarks(landmarks: &[JsonValue]) -> Vec
chapter_index,
"saved-landmark",
"关键场景",
scene_role_refs,
)
})
.collect()
@@ -1131,6 +1192,7 @@ fn build_scene_chapter_blueprint_from_scene(
chapter_index: usize,
id_prefix: &str,
fallback_name_prefix: &str,
scene_role_refs: &[SceneRoleRef],
) -> JsonValue {
let scene_name = json_text(scene, "name")
.unwrap_or_else(|| format!("{}{}", fallback_name_prefix, chapter_index + 1));
@@ -1144,6 +1206,13 @@ fn build_scene_chapter_blueprint_from_scene(
let act_npc_names = json_string_array(scene, "actNPCNames")
.or_else(|| json_string_array(scene, "sceneNpcNames"))
.unwrap_or_default();
let resolved_act_roles = resolve_scene_act_roles(&act_npc_names, scene_role_refs);
let scene_npc_ids = dedupe_text_values(
&resolved_act_roles
.iter()
.map(|role| role.id.clone())
.collect::<Vec<_>>(),
);
json!({
"id": scene_id.clone(),
@@ -1152,13 +1221,14 @@ fn build_scene_chapter_blueprint_from_scene(
"summary": summary,
"sceneTaskDescription": scene_task_description,
"linkedLandmarkIds": [scene_id.clone()],
"sceneNpcIds": scene_npc_ids,
"acts": (0..3)
.map(|act_index| build_scene_act_blueprint_from_landmark(
&scene_id,
&summary,
&act_prompts,
&act_events,
&act_npc_names,
&resolved_act_roles,
act_index,
))
.collect::<Vec<_>>(),
@@ -1170,7 +1240,7 @@ fn build_scene_act_blueprint_from_landmark(
scene_summary: &str,
act_prompts: &[String],
act_events: &[String],
act_npc_names: &[String],
act_roles: &[SceneRoleRef],
act_index: usize,
) -> JsonValue {
let act_title = if act_index == 0 {
@@ -1184,10 +1254,17 @@ fn build_scene_act_blueprint_from_landmark(
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let opposite_npc_id = act_npc_names
let opposite_role = act_roles
.get(act_index)
.or_else(|| act_npc_names.first())
.cloned()
.or_else(|| act_roles.first())
.cloned();
let opposite_npc_id = opposite_role
.as_ref()
.map(|role| role.id.clone())
.unwrap_or_default();
let opposite_role_name = opposite_role
.as_ref()
.map(|role| role.name.clone())
.unwrap_or_default();
let event_description = act_events
.get(act_index)
@@ -1196,12 +1273,16 @@ fn build_scene_act_blueprint_from_landmark(
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| {
build_default_act_event_description(scene_summary, opposite_npc_id.as_str(), act_index)
build_default_act_event_description(
scene_summary,
opposite_role_name.as_str(),
act_index,
)
});
let background_prompt = prompt.unwrap_or_else(|| {
build_default_act_background_prompt(
scene_summary,
opposite_npc_id.as_str(),
opposite_role_name.as_str(),
event_description.as_str(),
act_index,
)
@@ -1212,21 +1293,23 @@ fn build_scene_act_blueprint_from_landmark(
"title": act_title,
"summary": scene_summary,
"backgroundPromptText": background_prompt,
"encounterNpcIds": build_act_encounter_npc_ids(act_npc_names, opposite_npc_id.as_str()),
"encounterNpcIds": build_act_encounter_npc_ids(act_roles, opposite_npc_id.as_str()),
"primaryNpcId": opposite_npc_id,
"oppositeNpcId": opposite_npc_id,
"primaryRoleName": opposite_role_name,
"oppositeRoleName": opposite_role_name,
"eventDescription": event_description,
})
}
fn build_act_encounter_npc_ids(act_npc_names: &[String], primary_npc_id: &str) -> Vec<String> {
let mut names = Vec::with_capacity(act_npc_names.len().max(1));
fn build_act_encounter_npc_ids(act_roles: &[SceneRoleRef], primary_npc_id: &str) -> Vec<String> {
let mut names = Vec::with_capacity(act_roles.len().max(1));
let primary = primary_npc_id.trim();
if !primary.is_empty() {
names.push(primary.to_string());
}
for name in act_npc_names {
let normalized = name.trim();
for role in act_roles {
let normalized = role.id.trim();
if normalized.is_empty() || names.iter().any(|item| item == normalized) {
continue;
}
@@ -1235,6 +1318,98 @@ fn build_act_encounter_npc_ids(act_npc_names: &[String], primary_npc_id: &str) -
names
}
fn assign_role_ids(entries: Vec<JsonValue>, id_prefix: &str) -> Vec<JsonValue> {
entries
.into_iter()
.enumerate()
.map(|(index, entry)| assign_role_id(entry, id_prefix, index))
.collect()
}
fn assign_role_id(mut entry: JsonValue, id_prefix: &str, index: usize) -> JsonValue {
let name = json_text(&entry, "name").unwrap_or_else(|| format!("角色{}", index + 1));
let fallback_id = format!("{}-{}", id_prefix, stable_ascii_slug(name.as_str()));
let Some(object) = entry.as_object_mut() else {
return json!({
"id": fallback_id,
"name": name,
});
};
if object
.get("id")
.and_then(JsonValue::as_str)
.map(str::trim)
.is_none_or(str::is_empty)
{
object.insert("id".to_string(), JsonValue::String(fallback_id));
}
entry
}
fn collect_scene_role_refs(entries: &[JsonValue]) -> Vec<SceneRoleRef> {
entries
.iter()
.filter_map(|entry| {
let name = json_text(entry, "name")?;
let id = json_text(entry, "id")?;
Some(SceneRoleRef { id, name })
})
.collect()
}
fn resolve_scene_act_roles(
requested_role_names: &[String],
scene_role_refs: &[SceneRoleRef],
) -> Vec<SceneRoleRef> {
let mut resolved = requested_role_names
.iter()
.filter_map(|name| resolve_scene_role_ref(name, scene_role_refs))
.collect::<Vec<_>>();
if resolved.is_empty() {
resolved.extend(scene_role_refs.iter().take(3).cloned());
}
dedupe_scene_role_refs(resolved)
}
fn resolve_scene_role_ref(
name_or_id: &str,
scene_role_refs: &[SceneRoleRef],
) -> Option<SceneRoleRef> {
let normalized = name_or_id.trim();
if normalized.is_empty() {
return None;
}
scene_role_refs
.iter()
.find(|role| role.name == normalized || role.id == normalized)
.cloned()
}
fn dedupe_scene_role_refs(entries: Vec<SceneRoleRef>) -> Vec<SceneRoleRef> {
let mut seen = Vec::new();
let mut result = Vec::new();
for entry in entries {
if entry.id.trim().is_empty() || seen.iter().any(|id| id == &entry.id) {
continue;
}
seen.push(entry.id.clone());
result.push(entry);
}
result
}
fn dedupe_text_values(values: &[String]) -> Vec<String> {
let mut result = Vec::new();
for value in values {
let normalized = value.trim();
if normalized.is_empty() || result.iter().any(|item| item == normalized) {
continue;
}
result.push(normalized.to_string());
}
result
}
fn build_default_scene_task_description(scene_name: &str, scene_summary: &str) -> String {
if scene_summary.trim().is_empty() {
return format!(
@@ -1374,66 +1549,15 @@ fn normalize_framework_shape(framework: &mut JsonValue, setting_text: &str) {
"description".to_string(),
JsonValue::String(camp_description.clone()),
);
if !camp
.get("sceneTaskDescription")
.and_then(JsonValue::as_str)
.map(str::trim)
.is_some_and(|value| !value.is_empty())
{
camp.insert(
"sceneTaskDescription".to_string(),
JsonValue::String(build_default_scene_task_description(
camp_name.as_str(),
camp_description.as_str(),
)),
);
}
if !camp
.get("actBackgroundPromptTexts")
.and_then(JsonValue::as_array)
.is_some_and(|items| items.len() == 3)
{
// 中文注释:开局场景也必须进入逐幕生图队列;模型漏字段时用 camp 信息生成可用的三幕画面描述。
camp.insert(
"actBackgroundPromptTexts".to_string(),
JsonValue::Array(
(0..3)
.map(|index| {
let event_description = build_default_act_event_description(
camp_description.as_str(),
"开局关键角色",
index,
);
JsonValue::String(build_default_act_background_prompt(
camp_description.as_str(),
"开局关键角色",
event_description.as_str(),
index,
))
})
.collect(),
),
);
}
if !camp
.get("actEventDescriptions")
.and_then(JsonValue::as_array)
.is_some_and(|items| items.len() == 3)
{
camp.insert(
"actEventDescriptions".to_string(),
JsonValue::Array(
(0..3)
.map(|index| {
JsonValue::String(build_default_act_event_description(
camp_description.as_str(),
"开局关键角色",
index,
))
})
.collect(),
),
);
// 中文注释framework 只保留开局归处占位;完整开局场景任务与三幕内容统一交给场景批生成阶段。
for generated_scene_key in [
"sceneTaskDescription",
"actBackgroundPromptTexts",
"actEventDescriptions",
"actNPCNames",
"sceneNpcNames",
] {
camp.remove(generated_scene_key);
}
}
}
@@ -2024,7 +2148,18 @@ mod tests {
"actNPCNames": ["灯童丁", "档吏庚", "灯童丁"]
})];
let blueprints = build_scene_chapter_blueprints_from_landmarks(&landmarks);
let scene_role_refs = vec![
SceneRoleRef {
id: "story-npc-lamp-child".to_string(),
name: "灯童丁".to_string(),
},
SceneRoleRef {
id: "story-npc-archive-clerk".to_string(),
name: "档吏庚".to_string(),
},
];
let blueprints =
build_scene_chapter_blueprints_from_landmarks(&landmarks, &scene_role_refs);
let acts = blueprints[0]
.get("acts")
.and_then(JsonValue::as_array)
@@ -2043,10 +2178,23 @@ mod tests {
"首次进入雾港码头时,查明黑潮船骨与灯童丁目击证词的关联。"
))
);
assert_eq!(acts[0].get("oppositeNpcId"), Some(&json!("灯童丁")));
assert_eq!(acts[0].get("primaryNpcId"), Some(&json!("灯童丁")));
assert_eq!(acts[1].get("oppositeNpcId"), Some(&json!("档吏庚")));
assert_eq!(acts[1].get("primaryNpcId"), Some(&json!("档吏庚")));
assert_eq!(
acts[0].get("oppositeNpcId"),
Some(&json!("story-npc-lamp-child"))
);
assert_eq!(
acts[0].get("primaryNpcId"),
Some(&json!("story-npc-lamp-child"))
);
assert_eq!(acts[0].get("primaryRoleName"), Some(&json!("灯童丁")));
assert_eq!(
acts[1].get("oppositeNpcId"),
Some(&json!("story-npc-archive-clerk"))
);
assert_eq!(
acts[1].get("primaryNpcId"),
Some(&json!("story-npc-archive-clerk"))
);
assert_eq!(
acts[0].get("eventDescription"),
Some(&json!(
@@ -2081,7 +2229,16 @@ mod tests {
"actBackgroundPromptTexts": ["斗技台晨雾未散,石阶旁少年们列队观望。", "木桩与兵器架围出训练区,族徽旗帜在风里猎猎。", "暮色压下斗技场,中央擂台留出一对一交锋空间。"]
})];
let blueprints = build_scene_chapter_blueprints_from_camp_and_landmarks(camp, &landmarks);
let scene_role_refs = vec![SceneRoleRef {
id: "story-npc-mentor".to_string(),
name: "药师长老".to_string(),
}];
let blueprints = build_scene_chapter_blueprints_from_camp_and_landmarks(
camp,
&landmarks,
&scene_role_refs,
);
let opening_chapter = &blueprints[0];
let opening_acts = opening_chapter
.get("acts")
@@ -2106,6 +2263,18 @@ mod tests {
.and_then(JsonValue::as_str)
.is_some_and(|value| !value.trim().is_empty())
}));
assert!(opening_acts.iter().all(|act| {
act.get("primaryNpcId")
.and_then(JsonValue::as_str)
.is_some_and(|value| value == "story-npc-mentor")
}));
assert!(opening_acts.iter().all(|act| {
act.get("encounterNpcIds")
.and_then(JsonValue::as_array)
.and_then(|items| items.first())
.and_then(JsonValue::as_str)
.is_some_and(|value| value == "story-npc-mentor")
}));
assert_eq!(blueprints.len(), 2);
}
@@ -2377,7 +2546,11 @@ mod tests {
assert!(request_text.contains("attributeSchema"));
assert!(request_text.contains("可扮演角色框架名单"));
assert!(request_text.contains("场景角色框架名单"));
assert!(request_text.contains("关键场景框架名单"));
assert!(request_text.contains("场景框架名单"));
assert!(request_text.contains("第一条场景必须是玩家进入世界时所在的开局场景"));
assert!(request_text.contains("camp 只表示玩家开局时的落脚处占位"));
assert!(!request_text.contains("camp.sceneTaskDescription"));
assert!(!request_text.contains("camp.actBackgroundPromptTexts"));
assert!(request_text.contains("actNPCNames"));
assert!(!request_text.contains("\"sceneNpcNames\""));
assert!(request_text.contains("connectedLandmarkNames"));
@@ -2434,6 +2607,43 @@ mod tests {
.and_then(JsonValue::as_str)
.is_some()
);
assert_eq!(
draft_profile
.get("camp")
.and_then(|entry| entry.get("name"))
.and_then(JsonValue::as_str),
Some("旧灯塔")
);
assert_eq!(
draft_profile
.get("camp")
.and_then(|entry| entry.get("id"))
.and_then(JsonValue::as_str),
Some("camp-1")
);
assert_eq!(
draft_profile
.get("camp")
.and_then(|entry| entry.get("sceneTaskDescription"))
.and_then(JsonValue::as_str),
Some("首次进入旧灯塔时,追查被篡改的灯火航线记录。")
);
assert_eq!(
draft_profile
.get("landmarks")
.and_then(JsonValue::as_array)
.map(Vec::len),
Some(1)
);
assert_eq!(
draft_profile
.get("landmarks")
.and_then(JsonValue::as_array)
.and_then(|entries| entries.first())
.and_then(|entry| entry.get("name"))
.and_then(JsonValue::as_str),
Some("沉船湾")
);
assert_eq!(
draft_profile
.get("sceneChapterBlueprints")
@@ -2462,19 +2672,57 @@ mod tests {
.and_then(JsonValue::as_array)
.and_then(|items| items.first())
.and_then(JsonValue::as_str),
Some("灯童丁")
Some("船魂戊")
);
assert_eq!(
draft_profile
.get("sceneChapterBlueprints")
.and_then(JsonValue::as_array)
.and_then(|entries| entries.get(1))
.and_then(|entries| entries.first())
.and_then(|entry| entry.get("acts"))
.and_then(JsonValue::as_array)
.and_then(|acts| acts.get(1))
.and_then(|act| act.get("primaryNpcId"))
.and_then(JsonValue::as_str),
Some("档吏庚")
Some("story-npc-0192680e")
);
assert_eq!(
draft_profile
.get("sceneChapterBlueprints")
.and_then(JsonValue::as_array)
.and_then(|entries| entries.first())
.and_then(|entry| entry.get("acts"))
.and_then(JsonValue::as_array)
.and_then(|acts| acts.first())
.and_then(|act| act.get("primaryNpcId"))
.and_then(JsonValue::as_str),
Some("story-npc-01b5406b")
);
assert_eq!(
draft_profile
.get("sceneChapterBlueprints")
.and_then(JsonValue::as_array)
.and_then(|entries| entries.first())
.and_then(|entry| entry.get("acts"))
.and_then(JsonValue::as_array)
.and_then(|acts| acts.first())
.and_then(|act| act.get("encounterNpcIds"))
.and_then(JsonValue::as_array)
.and_then(|items| items.first())
.and_then(JsonValue::as_str),
Some("story-npc-01b5406b")
);
assert_eq!(
draft_profile
.get("sceneChapterBlueprints")
.and_then(JsonValue::as_array)
.and_then(|entries| entries.first())
.and_then(|entry| entry.get("acts"))
.and_then(JsonValue::as_array)
.and_then(|acts| acts.first())
.and_then(|act| act.get("primaryRoleName"))
.and_then(JsonValue::as_str),
Some("灯童丁")
);
assert_eq!(
draft_profile
@@ -2486,8 +2734,54 @@ mod tests {
.and_then(|acts| acts.first())
.and_then(|act| act.get("primaryNpcId"))
.and_then(JsonValue::as_str),
Some("灯童丁")
Some("story-npc-01fc0701")
);
assert_eq!(
draft_profile
.get("sceneChapterBlueprints")
.and_then(JsonValue::as_array)
.and_then(|entries| entries.get(1))
.and_then(|entry| entry.get("acts"))
.and_then(JsonValue::as_array)
.and_then(|acts| acts.get(1))
.and_then(|act| act.get("primaryNpcId"))
.and_then(JsonValue::as_str),
Some("story-npc-01acae6c")
);
}
#[test]
fn generated_scene_batch_first_entry_becomes_opening_camp() {
let fallback_camp = json!({
"name": "世界骨架占位归处",
"description": "只来自 framework 的轻量占位。"
});
let generated_scenes = vec![
json!({
"name": "旧灯塔",
"description": "雾中仍亮着错位灯火",
"sceneTaskDescription": "首次进入旧灯塔时,追查被篡改的灯火航线记录。",
"actBackgroundPromptTexts": ["", "", ""],
"actEventDescriptions": ["", "", ""],
}),
json!({
"name": "沉船湾",
"description": "退潮后露出旧船骨"
}),
];
let (camp, landmarks) =
split_generated_scenes_into_camp_and_landmarks(fallback_camp, generated_scenes);
assert_eq!(camp.get("id"), Some(&json!("camp-1")));
assert_eq!(camp.get("kind"), Some(&json!("camp")));
assert_eq!(camp.get("name"), Some(&json!("旧灯塔")));
assert_eq!(
camp.get("sceneTaskDescription"),
Some(&json!("首次进入旧灯塔时,追查被篡改的灯火航线记录。"))
);
assert_eq!(landmarks.len(), 1);
assert_eq!(landmarks[0].get("name"), Some(&json!("沉船湾")));
}
fn llm_response(content: &str) -> String {

View File

@@ -6,7 +6,7 @@ pub(crate) fn build_custom_world_framework_prompt(setting_text: &str) -> String
[
"请先根据下面的玩家设定创建一份“世界核心骨架”,后续我会分步骤生成角色名单、场景名单和详细档案。".to_string(),
"你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
"这一步只保留世界顶层信息与一个开局归处场景,不要输出 playableNpcs、storyNpcs、landmarks也不要展开人物地图细节。".to_string(),
"这一步只保留世界顶层信息与一个开局归处占位,不要输出 playableNpcs、storyNpcs、landmarks也不要展开人物地图细节或多幕场景内容".to_string(),
"玩家设定:".to_string(),
setting_text.trim().to_string(),
"".to_string(),
@@ -33,10 +33,7 @@ pub(crate) fn build_custom_world_framework_prompt(setting_text: &str) -> String
" },".to_string(),
" \"camp\": {".to_string(),
" \"name\": \"开局归处名称\",".to_string(),
" \"description\": \"这是玩家进入世界后的第一处落脚点描述\",".to_string(),
" \"sceneTaskDescription\": \"首次进入该场景时要生成的章节任务核心上下文\",".to_string(),
" \"actBackgroundPromptTexts\": [\"开局第一幕背景画面描述\", \"开局第二幕背景画面描述\", \"开局第三幕背景画面描述\"],".to_string(),
" \"actEventDescriptions\": [\"开局第一幕事件描述\", \"开局第二幕事件描述\", \"开局第三幕事件描述\"],".to_string(),
" \"description\": \"这是玩家进入世界后的第一处落脚点描述\"".to_string(),
" }".to_string(),
"}".to_string(),
"".to_string(),
@@ -45,10 +42,7 @@ pub(crate) fn build_custom_world_framework_prompt(setting_text: &str) -> String
"- 这一步只输出顶层 10 个字段name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、attributeSchema、camp。".to_string(),
"- 这是一个完全独立的自定义世界;不要在任何正文里直接写出“武侠世界”“仙侠世界”等现成世界名。".to_string(),
"- templateWorldType 只是系统兼容字段,不代表正文应当引用的世界名称。".to_string(),
"- camp 必须表示玩家开局时的落脚处,更接近归舍、住处、栖居、前哨居所这类“家/归处”的概念。".to_string(),
"- camp.sceneTaskDescription 必须描述玩家首次进入开局场景时要完成的核心任务,会作为游戏章节任务生成上下文,控制在 24 到 56 个汉字内。".to_string(),
"- camp.actEventDescriptions 必须恰好 3 条,分别描述每一幕发生的事件;第 1 幕负责铺垫,第 2 幕必须让冲突升级,第 3 幕必须形成高潮或关键抉择;事件必须和当前幕对面的角色强相关,控制在 24 到 56 个汉字内。".to_string(),
"- camp.actBackgroundPromptTexts 必须恰好 3 条,分别对应第 1/2/3 幕背景图画面内容描述;每条必须基于同序号 actEventDescriptions 和相关角色写出画面主体、站位空间、冲突痕迹与氛围,能直接交给生图模型,控制在 40 到 90 个汉字内。".to_string(),
"- camp 表示玩家开局时的落脚处占位,更接近归舍、住处、栖居、前哨居所这类“家/归处”的概念;不要在这一步生成开局场景任务、三幕事件或三幕背景".to_string(),
"- 不要输出 playableNpcs、storyNpcs、landmarks、items也不要输出任何角色和地图细节。".to_string(),
"- majorFactions 保持 2 到 3 个coreConflicts 保持 2 到 3 个。".to_string(),
"- attributeSchema 必须是本世界专属的角色六维属性体系slots 必须恰好 6 个slotId 固定为 axis_a 到 axis_f维度名必须是 2 到 4 个汉字且互不重复。".to_string(),
@@ -68,7 +62,7 @@ pub(crate) fn build_custom_world_framework_json_repair_prompt(response_text: &st
"不要输出 playableNpcs、storyNpcs、landmarks、items 或任何其他字段。",
"majorFactions 与 coreConflicts 必须是字符串数组。",
"attributeSchema 必须是对象,且包含 schemaName 与 slotsslots 必须恰好 6 个slotId 固定为 axis_a 到 axis_f。",
"camp 必须是对象且包含name、description、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions",
"camp 必须是对象,且包含name、description。",
"原始文本:",
response_text.trim(),
].join("\n")
@@ -154,16 +148,26 @@ pub(crate) fn build_custom_world_landmark_seed_batch_prompt(
framework: &JsonValue,
batch_count: usize,
forbidden_names: &[String],
is_opening_batch: bool,
) -> String {
let story_npc_names = names_from_entries(&array_field(framework, "storyNpcs"));
[
"请根据下面的世界核心信息,生成一批关键场景框架名单。".to_string(),
"这一步必须一次性生成场景骨架、地点默认生图描述、逐幕背景描述、幕 NPC 分配和相连场景信息。".to_string(),
"请根据下面的世界核心信息,批量生成场景框架名单。".to_string(),
if is_opening_batch {
"这一步必须一次性生成开局场景和普通关键场景的场景骨架、默认生图描述、逐幕背景描述、幕 NPC 分配和相连场景信息。".to_string()
} else {
"这一步必须一次性生成普通关键场景的场景骨架、默认生图描述、逐幕背景描述、幕 NPC 分配和相连场景信息。".to_string()
},
"你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
"世界核心信息:".to_string(),
build_framework_summary_text(framework, 0),
if story_npc_names.is_empty() { "".to_string() } else { format!("可用场景角色名单:{}", story_npc_names.join("")) },
if forbidden_names.is_empty() { "".to_string() } else { format!("这些地点已经生成,禁止重复:{}", forbidden_names.join("")) },
if is_opening_batch {
"第一条场景必须是玩家进入世界时所在的开局场景,后续条目才是普通关键场景。".to_string()
} else {
"本批只生成普通关键场景,不要再生成开局场景。".to_string()
},
if forbidden_names.is_empty() { "".to_string() } else { format!("这些场景已经生成,禁止重复:{}", forbidden_names.join("")) },
"".to_string(),
"输出 JSON 模板:".to_string(),
"{".to_string(),
@@ -183,16 +187,21 @@ pub(crate) fn build_custom_world_landmark_seed_batch_prompt(
"}".to_string(),
"".to_string(),
"要求:".to_string(),
format!("- 必须生成恰好 {batch_count} 个关键场景。"),
"- 这是一个完全独立的自定义世界;地点名称必须直接服务玩家输入主题。".to_string(),
"- 名称必须具体且互不重复,不要使用 地点1、场景1 之类的占位名。".to_string(),
"- 每个地点只保留name、description、visualDescription、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions、actNPCNames、connectedLandmarkNames、entryHook。".to_string(),
if is_opening_batch {
format!("- 必须生成恰好 {batch_count} 个场景,第 1 个必须是开局场景。")
} else {
format!("- 必须生成恰好 {batch_count} 个普通关键场景,不能包含开局场景。")
},
if is_opening_batch { "- 开局场景也必须按普通场景同级规则生成完整字段,不能只给 camp 简介。".to_string() } else { "".to_string() },
"- 这是一个完全独立的自定义世界;场景名称必须直接服务玩家输入主题。".to_string(),
"- 名称必须具体且互不重复,不要使用 地点1、场景1、开局场景 之类的占位名。".to_string(),
"- 每个场景只保留name、description、visualDescription、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions、actNPCNames、connectedLandmarkNames、entryHook。".to_string(),
"- sceneTaskDescription 必须描述玩家首次进入该场景时要完成的核心任务,会作为游戏章节任务生成上下文,控制在 24 到 56 个汉字内。".to_string(),
"- visualDescription 是打开场景背景图像生成面板时默认填入的场景描述,必须具体到画面主体、远近景层次、地面可站立区域和氛围识别点,控制在 32 到 80 个汉字内。".to_string(),
"- actNPCNames 只能引用上方可用场景角色名单中的名字,表示第 1/2/3 幕各自的主场景角色;如果名单为空,输出空数组。".to_string(),
"- 可用场景角色名单非空时actNPCNames 必须恰好 3 个;可以重复使用同一角色,但每一项都必须服务对应幕事件。".to_string(),
"- actNPCNames[n] 会成为第 n+1 幕对面主角色;三幕事件和幕背景必须围绕对应角色的行动、阻碍、试探或求助展开。".to_string(),
"- connectedLandmarkNames 优先引用本批或已知关键场景名称,每个地点 1 到 3 个;只有 1 个地点时可以输出空数组。".to_string(),
"- connectedLandmarkNames 优先引用本批或已知场景名称,每个场景 1 到 3 个;只有 1 个场景时可以输出空数组。".to_string(),
"- entryHook 控制在 16 到 36 个汉字内。".to_string(),
"- actEventDescriptions 必须恰好 3 条,分别描述每一幕发生的事件;第 1 幕负责铺垫,第 2 幕必须让冲突升级,第 3 幕必须形成高潮或关键抉择;事件必须和当前幕对面的角色强相关,控制在 24 到 56 个汉字内。".to_string(),
"- actBackgroundPromptTexts 必须恰好 3 条,分别对应这个场景章节的第 1/2/3 幕背景图画面内容描述;每条都必须基于同序号 actEventDescriptions、当前地点和可出场角色直接写出画面主体、站位空间、冲突痕迹与氛围控制在 40 到 90 个汉字内。".to_string(),
@@ -207,14 +216,16 @@ pub(crate) fn build_custom_world_landmark_seed_batch_json_repair_prompt(
response_text: &str,
expected_count: usize,
forbidden_names: &[String],
is_opening_batch: bool,
) -> String {
[
"下面这段文本本应是自定义世界关键场景框架名单批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。".to_string(),
"下面这段文本本应是自定义世界场景框架名单批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。".to_string(),
"请只输出修复后的 JSON 对象。".to_string(),
"顶层必须只包含一个 landmarks 数组。".to_string(),
format!("必须保留恰好 {expected_count}地点对象。"),
format!("必须保留恰好 {expected_count}场景对象。"),
if is_opening_batch { "第一项必须是开局场景,且字段粒度与普通场景一致。".to_string() } else { "本批只保留普通关键场景,不要包含开局场景。".to_string() },
if forbidden_names.is_empty() { "".to_string() } else { format!("禁止使用这些重复名:{}", forbidden_names.join("")) },
"每个地点只包含name、description、visualDescription、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions、actNPCNames、connectedLandmarkNames、entryHook。".to_string(),
"每个场景只包含name、description、visualDescription、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions、actNPCNames、connectedLandmarkNames、entryHook。".to_string(),
"如果缺少字段字符串补空字符串actBackgroundPromptTexts、actEventDescriptions、actNPCNames 和 connectedLandmarkNames 补空数组。".to_string(),
"不要输出 items 或任何其他字段。".to_string(),
"原始文本:".to_string(),

View File

@@ -1,12 +1,16 @@
use axum::{
Json,
extract::{Extension, State},
http::{StatusCode, header},
response::{IntoResponse, Response},
http::StatusCode,
response::{
IntoResponse, Response,
sse::{Event, Sse},
},
};
use platform_llm::{LlmMessage, LlmTextRequest};
use serde::Deserialize;
use serde_json::{Value, json};
use std::convert::Infallible;
use crate::{
http_error::AppError,
@@ -58,7 +62,7 @@ pub async fn stream_runtime_npc_chat_turn(
let npc_name = read_string_field(&payload.encounter, "npcName")
.or_else(|| read_string_field(&payload.encounter, "name"))
.unwrap_or_else(|| "对方".to_string());
let player_message = payload.player_message.trim();
let player_message = payload.player_message.trim().to_string();
if player_message.is_empty() && !payload.npc_initiates_conversation {
return Err(runtime_chat_error_response(
&request_context,
@@ -69,75 +73,106 @@ pub async fn stream_runtime_npc_chat_turn(
));
}
let llm_result =
generate_llm_npc_chat_turn(&state, &request_context, &payload, &npc_name).await;
let (mut body, npc_reply, suggestions, function_suggestions, force_exit) = match llm_result {
Some(result) => result,
None => {
let npc_reply = build_deterministic_npc_reply(
npc_name.as_str(),
player_message,
payload.npc_initiates_conversation,
);
let force_exit = should_force_chat_exit(payload.chat_directive.as_ref())
|| should_hostile_chat_breakoff_deterministically(
player_message,
payload.chat_directive.as_ref(),
);
let suggestions = if force_exit {
Vec::new()
} else {
build_deterministic_chat_suggestions(npc_name.as_str(), player_message)
};
let function_suggestions = if force_exit {
Vec::new()
} else {
build_fallback_function_suggestions(payload.chat_directive.as_ref())
};
let mut body = String::new();
append_sse_event(
&request_context,
&mut body,
let stream = async_stream::stream! {
let (reply_tx, mut reply_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
// `platform-llm` 在当前任务内持续回调增量文本;外层用 channel 把增量转成真正的 SSE 分片。
let llm_turn = generate_llm_npc_chat_turn(
&state,
&payload,
&npc_name,
move |text| {
let _ = reply_tx.send(text.to_string());
},
);
tokio::pin!(llm_turn);
let llm_result = loop {
// 模型尚未结束时优先把已收到的累计回复推出去,避免等完整建议生成后才一次性返回。
tokio::select! {
result = &mut llm_turn => break result,
maybe_text = reply_rx.recv() => {
if let Some(text) = maybe_text {
yield Ok::<Event, Infallible>(runtime_chat_sse_json_event_or_error(
"reply_delta",
json!({ "text": text }),
));
}
}
}
};
while let Some(text) = reply_rx.recv().await {
yield Ok::<Event, Infallible>(runtime_chat_sse_json_event_or_error(
"reply_delta",
&json!({ "text": npc_reply }),
)?;
(
body,
npc_reply,
suggestions,
function_suggestions,
force_exit,
)
json!({ "text": text }),
));
}
};
let chatted_count = read_number_field(&payload.npc_state, "chattedCount").unwrap_or(0.0);
let affinity_delta = if payload.npc_initiates_conversation {
0
} else {
compute_npc_chat_affinity_delta(player_message, npc_reply.as_str(), chatted_count)
};
let complete_payload = json!({
"npcReply": npc_reply,
"affinityDelta": affinity_delta,
"affinityText": describe_affinity_shift(affinity_delta),
"suggestions": suggestions,
"functionSuggestions": function_suggestions,
"pendingQuestOffer": null,
"chatDirective": build_completion_directive(payload.chat_directive.as_ref(), force_exit),
});
let (npc_reply, suggestions, function_suggestions, force_exit) = match llm_result {
Some(result) => result,
None => {
let npc_reply = build_deterministic_npc_reply(
npc_name.as_str(),
player_message.as_str(),
payload.npc_initiates_conversation,
);
let force_exit = should_force_chat_exit(payload.chat_directive.as_ref())
|| should_hostile_chat_breakoff_deterministically(
player_message.as_str(),
payload.chat_directive.as_ref(),
);
let suggestions = if force_exit {
Vec::new()
} else {
build_deterministic_chat_suggestions(npc_name.as_str(), player_message.as_str())
};
let function_suggestions = if force_exit {
Vec::new()
} else {
build_fallback_function_suggestions(payload.chat_directive.as_ref())
};
yield Ok::<Event, Infallible>(runtime_chat_sse_json_event_or_error(
"reply_delta",
json!({ "text": npc_reply }),
));
(npc_reply, suggestions, function_suggestions, force_exit)
}
};
append_sse_event(&request_context, &mut body, "complete", &complete_payload)?;
body.push_str("data: [DONE]\n\n");
Ok(build_event_stream_response(body))
let chatted_count = read_number_field(&payload.npc_state, "chattedCount").unwrap_or(0.0);
let affinity_delta = if payload.npc_initiates_conversation {
0
} else {
compute_npc_chat_affinity_delta(player_message.as_str(), npc_reply.as_str(), chatted_count)
};
let complete_payload = json!({
"npcReply": npc_reply,
"affinityDelta": affinity_delta,
"affinityText": describe_affinity_shift(affinity_delta),
"suggestions": suggestions,
"functionSuggestions": function_suggestions,
"pendingQuestOffer": null,
"chatDirective": build_completion_directive(payload.chat_directive.as_ref(), force_exit),
});
yield Ok::<Event, Infallible>(runtime_chat_sse_json_event_or_error(
"complete",
complete_payload,
));
yield Ok::<Event, Infallible>(Event::default().data("[DONE]"));
};
Ok(Sse::new(stream).into_response())
}
async fn generate_llm_npc_chat_turn(
async fn generate_llm_npc_chat_turn<F>(
state: &AppState,
request_context: &RequestContext,
payload: &NpcChatTurnRequest,
npc_name: &str,
) -> Option<(String, String, Vec<String>, Vec<Value>, bool)> {
mut on_reply_update: F,
) -> Option<(String, Vec<String>, Vec<Value>, bool)>
where
F: FnMut(&str),
{
let llm_client = state.llm_client()?;
let character = payload
.character
@@ -160,7 +195,6 @@ async fn generate_llm_npc_chat_turn(
chat_directive: payload.chat_directive.as_ref(),
};
let mut body = String::new();
let reply_prompt = build_npc_chat_turn_reply_prompt(&prompt_input);
let mut reply_request = LlmTextRequest::new(vec![
LlmMessage::system(NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT),
@@ -171,12 +205,7 @@ async fn generate_llm_npc_chat_turn(
let reply_response = llm_client
.stream_text(reply_request, |delta| {
let _ = append_sse_event(
request_context,
&mut body,
"reply_delta",
&json!({ "text": delta.accumulated_text }),
);
on_reply_update(delta.accumulated_text.as_str());
})
.await
.ok()?;
@@ -189,7 +218,7 @@ async fn generate_llm_npc_chat_turn(
});
if should_force_chat_exit(payload.chat_directive.as_ref()) {
return Some((body, npc_reply, Vec::new(), Vec::new(), true));
return Some((npc_reply, Vec::new(), Vec::new(), true));
}
let suggestion_prompt =
@@ -224,13 +253,7 @@ async fn generate_llm_npc_chat_turn(
suggestions = build_fallback_npc_chat_suggestions(payload.player_message.as_str());
}
Some((
body,
npc_reply,
suggestions,
function_suggestions,
force_exit,
))
Some((npc_reply, suggestions, function_suggestions, force_exit))
}
fn build_deterministic_npc_reply(
@@ -595,39 +618,20 @@ fn describe_affinity_shift(affinity_delta: i32) -> &'static str {
"这轮对话暂时没有带来明显关系变化。"
}
fn append_sse_event(
request_context: &RequestContext,
body: &mut String,
event: &str,
payload: &Value,
) -> Result<(), Response> {
let payload_text = serde_json::to_string(payload).map_err(|error| {
runtime_chat_error_response(
request_context,
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "runtime-chat",
"message": format!("SSE payload 序列化失败:{error}"),
})),
)
})?;
body.push_str("event: ");
body.push_str(event);
body.push('\n');
body.push_str("data: ");
body.push_str(&payload_text);
body.push_str("\n\n");
Ok(())
fn runtime_chat_sse_json_event_or_error(event_name: &str, payload: Value) -> Event {
match serde_json::to_string(&payload) {
Ok(payload_text) => Event::default().event(event_name).data(payload_text),
Err(_) => runtime_chat_sse_error_event_message("SSE payload 序列化失败".to_string()),
}
}
fn build_event_stream_response(body: String) -> Response {
(
[
(header::CONTENT_TYPE, "text/event-stream; charset=utf-8"),
(header::CACHE_CONTROL, "no-cache"),
],
body,
)
.into_response()
fn runtime_chat_sse_error_event_message(message: String) -> Event {
let payload = format!(
"{{\"message\":{}}}",
serde_json::to_string(&message)
.unwrap_or_else(|_| "\"SSE 错误事件序列化失败\"".to_string())
);
Event::default().event("error").data(payload)
}
fn runtime_chat_error_response(request_context: &RequestContext, error: AppError) -> Response {

View File

@@ -212,11 +212,10 @@ pub(super) async fn generate_reasoned_story_payload(
}
pub(super) fn should_generate_reasoned_combat_story(
battle: Option<&RuntimeBattlePresentation>,
_battle: Option<&RuntimeBattlePresentation>,
) -> bool {
battle
.and_then(|presentation| presentation.outcome.as_deref())
.is_some_and(|outcome| matches!(outcome, "victory" | "spar_complete" | "escaped"))
// 战斗动作、逃跑、胜利、切磋结束与死亡都只走确定性结算,避免战斗链路再次触发剧情推理。
false
}
pub(super) fn build_action_story_history(

View File

@@ -1913,7 +1913,7 @@ fn runtime_story_quest_turn_in_marks_quest_rewards_and_affinity() {
}
#[test]
fn runtime_story_reasoned_combat_story_guard_only_targets_terminal_outcomes() {
fn runtime_story_reasoned_combat_story_guard_blocks_all_battle_outcomes() {
assert!(!should_generate_reasoned_combat_story(None));
assert!(!should_generate_reasoned_combat_story(Some(
&RuntimeBattlePresentation {
@@ -1924,7 +1924,7 @@ fn runtime_story_reasoned_combat_story_guard_only_targets_terminal_outcomes() {
outcome: Some("ongoing".to_string()),
}
)));
assert!(should_generate_reasoned_combat_story(Some(
assert!(!should_generate_reasoned_combat_story(Some(
&RuntimeBattlePresentation {
target_id: Some("npc_merchant_01".to_string()),
target_name: Some("沈七".to_string()),
@@ -1933,7 +1933,7 @@ fn runtime_story_reasoned_combat_story_guard_only_targets_terminal_outcomes() {
outcome: Some("victory".to_string()),
}
)));
assert!(should_generate_reasoned_combat_story(Some(
assert!(!should_generate_reasoned_combat_story(Some(
&RuntimeBattlePresentation {
target_id: Some("npc_merchant_01".to_string()),
target_name: Some("沈七".to_string()),

View File

@@ -56,6 +56,34 @@ pub struct AssetObjectProcedureResult {
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetHistoryListInput {
pub asset_kind: String,
pub limit: u32,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetHistoryEntrySnapshot {
pub asset_object_id: String,
pub asset_kind: String,
pub image_src: String,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub entity_id: Option<String>,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetHistoryListResult {
pub ok: bool,
pub entries: Vec<AssetHistoryEntrySnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetEntityBindingProcedureResult {
@@ -151,6 +179,18 @@ pub struct AssetObjectRecord {
pub updated_at: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AssetHistoryEntryRecord {
pub asset_object_id: String,
pub asset_kind: String,
pub image_src: String,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub entity_id: Option<String>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ConfirmAssetObjectResult {
pub record: AssetObjectRecord,
@@ -297,6 +337,21 @@ pub fn build_asset_object_record(snapshot: AssetObjectUpsertSnapshot) -> AssetOb
}
}
pub fn build_asset_history_entry_record(
snapshot: AssetHistoryEntrySnapshot,
) -> AssetHistoryEntryRecord {
AssetHistoryEntryRecord {
asset_object_id: snapshot.asset_object_id,
asset_kind: snapshot.asset_kind,
image_src: snapshot.image_src,
owner_user_id: snapshot.owner_user_id,
profile_id: snapshot.profile_id,
entity_id: snapshot.entity_id,
created_at: format_timestamp_micros(snapshot.created_at_micros),
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
}
}
#[allow(clippy::too_many_arguments)]
pub fn build_asset_entity_binding_input(
binding_id: String,

View File

@@ -5,12 +5,14 @@ mod asset_object_service;
pub use asset_object_core::{
ASSET_BINDING_ID_PREFIX, ASSET_OBJECT_ID_PREFIX, AssetEntityBindingInput,
AssetEntityBindingProcedureResult, AssetEntityBindingRecord, AssetEntityBindingSnapshot,
AssetObjectAccessPolicy, AssetObjectFieldError, AssetObjectProcedureResult, AssetObjectRecord,
AssetObjectUpsertInput, AssetObjectUpsertSnapshot, ConfirmAssetObjectInput,
ConfirmAssetObjectResult, INITIAL_ASSET_OBJECT_VERSION, build_asset_entity_binding_input,
build_asset_entity_binding_record, build_asset_object_record, build_asset_object_upsert_input,
generate_asset_binding_id, generate_asset_object_id, normalize_optional_value,
validate_asset_entity_binding_fields, validate_asset_object_fields,
AssetHistoryEntryRecord, AssetHistoryEntrySnapshot, AssetHistoryListInput,
AssetHistoryListResult, AssetObjectAccessPolicy, AssetObjectFieldError,
AssetObjectProcedureResult, AssetObjectRecord, AssetObjectUpsertInput,
AssetObjectUpsertSnapshot, ConfirmAssetObjectInput, ConfirmAssetObjectResult,
INITIAL_ASSET_OBJECT_VERSION, build_asset_entity_binding_input,
build_asset_entity_binding_record, build_asset_history_entry_record, build_asset_object_record,
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
normalize_optional_value, validate_asset_entity_binding_fields, validate_asset_object_fields,
};
#[cfg(feature = "server-service")]
pub use asset_object_service::{

View File

@@ -84,6 +84,34 @@ pub struct BindAssetObjectRequest {
pub profile_id: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AssetHistoryQuery {
pub kind: String,
#[serde(default)]
pub limit: Option<u32>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AssetHistoryEntryPayload {
pub asset_object_id: String,
pub asset_kind: String,
pub image_src: String,
pub owner_user_id: Option<String>,
pub owner_label: String,
pub profile_id: Option<String>,
pub entity_id: Option<String>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AssetHistoryListResponse {
pub assets: Vec<AssetHistoryEntryPayload>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum CharacterVisualSourceMode {

View File

@@ -39,6 +39,38 @@
4. `src/lib.rs` 已通过 `#[rustfmt::skip] pub mod module_bindings;` 显式阻止 workspace 级 `cargo fmt` 继续递归格式化该目录。
5. Windows 下直接把 Rust bindings 输出到本目录时SpacetimeDB CLI `2.1.0` 的生成后 formatter 可能因为路径参数总长触发 `文件名或扩展名太长`;仓库脚本会先输出到短临时目录,再同步回本目录。
### 2.1.1 绑定缺文件恢复流程
`mod.rs` 已声明 `*_table` 模块,但目录内缺少对应 `*_table.rs` 文件,说明 Rust bindings 刷新不完整。不要手工补 generated code统一在仓库根目录执行
```powershell
spacetime generate --no-config --lang rust --include-private --out-dir .\server-rs\crates\spacetime-client\src\module_bindings --module-path .\server-rs\crates\spacetime-module --yes
```
这里必须带 `--no-config`:仓库根目录的 `spacetime.json` 同时配置了 TypeScript 与 Rust 两个生成目标,直接追加 `--lang` / `--out-dir` 会触发 SpacetimeDB CLI 的多目标参数冲突。
生成后用以下命令确认 `mod.rs` 声明的模块都有落盘文件:
```powershell
$modFile = 'server-rs\crates\spacetime-client\src\module_bindings\mod.rs'
$dir = 'server-rs\crates\spacetime-client\src\module_bindings'
$mods = Select-String -Path $modFile -Pattern '^pub mod ([a-zA-Z0-9_]+);' |
ForEach-Object { $_.Matches[0].Groups[1].Value }
$missing = @()
foreach ($m in $mods) {
if (-not (Test-Path (Join-Path $dir ($m + '.rs')))) {
$missing += $m
}
}
if ($missing.Count -eq 0) { 'missing module files: 0' } else { $missing }
```
最后至少执行:
```powershell
cargo check -p spacetime-client --manifest-path server-rs\Cargo.toml
```
## 3. 边界约束
1. `spacetime-client` 只承接 SpacetimeDB 客户端访问适配,不承接具体业务模块的规则实现。

View File

@@ -1,6 +1,26 @@
use super::*;
impl SpacetimeClient {
pub async fn list_asset_history(
&self,
input: module_assets::AssetHistoryListInput,
) -> Result<Vec<AssetHistoryEntryRecord>, SpacetimeClientError> {
let procedure_input = input.into();
self.call_after_connect(move |connection, sender| {
connection.procedures().list_asset_history_and_return_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_asset_history_list_result);
send_once(&sender, mapped);
},
);
})
.await
}
pub async fn confirm_asset_object(
&self,
input: module_assets::AssetObjectUpsertInput,

View File

@@ -72,8 +72,8 @@ use module_ai::{
AiTextChunkAppendInput as DomainAiTextChunkAppendInput,
};
use module_assets::{
AssetEntityBindingRecord, AssetObjectAccessPolicy, AssetObjectRecord,
build_asset_entity_binding_record, build_asset_object_record,
AssetEntityBindingRecord, AssetHistoryEntryRecord, AssetObjectAccessPolicy, AssetObjectRecord,
build_asset_entity_binding_record, build_asset_history_entry_record, build_asset_object_record,
};
use module_combat::{
BattleMode as DomainBattleMode, BattleStateInput as DomainBattleStateInput,

View File

@@ -37,6 +37,15 @@ impl From<module_assets::AssetObjectUpsertInput> for AssetObjectUpsertInput {
}
}
impl From<module_assets::AssetHistoryListInput> for AssetHistoryListInput {
fn from(input: module_assets::AssetHistoryListInput) -> Self {
Self {
asset_kind: input.asset_kind,
limit: input.limit,
}
}
}
impl From<module_runtime::RuntimeSettingGetInput> for RuntimeSettingGetInput {
fn from(input: module_runtime::RuntimeSettingGetInput) -> Self {
Self {
@@ -500,6 +509,25 @@ pub(crate) fn map_entity_binding_procedure_result(
))
}
pub(crate) fn map_asset_history_list_result(
result: AssetHistoryListResult,
) -> Result<Vec<AssetHistoryEntryRecord>, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::Procedure(
result
.error_message
.unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()),
));
}
Ok(result
.entries
.into_iter()
.map(map_asset_history_entry_snapshot)
.map(build_asset_history_entry_record)
.collect())
}
pub(crate) fn map_runtime_setting_procedure_result(
result: RuntimeSettingProcedureResult,
) -> Result<RuntimeSettingsRecord, SpacetimeClientError> {
@@ -1430,6 +1458,21 @@ pub(crate) fn map_snapshot(
}
}
pub(crate) fn map_asset_history_entry_snapshot(
snapshot: AssetHistoryEntrySnapshot,
) -> module_assets::AssetHistoryEntrySnapshot {
module_assets::AssetHistoryEntrySnapshot {
asset_object_id: snapshot.asset_object_id,
asset_kind: snapshot.asset_kind,
image_src: snapshot.image_src,
owner_user_id: snapshot.owner_user_id,
profile_id: snapshot.profile_id,
entity_id: snapshot.entity_id,
created_at_micros: snapshot.created_at_micros,
updated_at_micros: snapshot.updated_at_micros,
}
}
pub(crate) fn map_runtime_setting_snapshot(
snapshot: RuntimeSettingSnapshot,
) -> module_runtime::RuntimeSettingSnapshot {

View File

@@ -0,0 +1,22 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct AssetHistoryEntrySnapshot {
pub asset_object_id: String,
pub asset_kind: String,
pub image_src: String,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub entity_id: Option<String>,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
impl __sdk::InModule for AssetHistoryEntrySnapshot {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,16 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct AssetHistoryListInput {
pub asset_kind: String,
pub limit: u32,
}
impl __sdk::InModule for AssetHistoryListInput {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,19 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::asset_history_entry_snapshot_type::AssetHistoryEntrySnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct AssetHistoryListResult {
pub ok: bool,
pub entries: Vec<AssetHistoryEntrySnapshot>,
pub error_message: Option<String>,
}
impl __sdk::InModule for AssetHistoryListResult {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,59 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::asset_history_list_input_type::AssetHistoryListInput;
use super::asset_history_list_result_type::AssetHistoryListResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct ListAssetHistoryAndReturnArgs {
pub input: AssetHistoryListInput,
}
impl __sdk::InModule for ListAssetHistoryAndReturnArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `list_asset_history_and_return`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait list_asset_history_and_return {
fn list_asset_history_and_return(&self, input: AssetHistoryListInput) {
self.list_asset_history_and_return_then(input, |_, _| {});
}
fn list_asset_history_and_return_then(
&self,
input: AssetHistoryListInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<AssetHistoryListResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl list_asset_history_and_return for super::RemoteProcedures {
fn list_asset_history_and_return_then(
&self,
input: AssetHistoryListInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<AssetHistoryListResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, AssetHistoryListResult>(
"list_asset_history_and_return",
ListAssetHistoryAndReturnArgs { input },
__callback,
);
}
}

View File

@@ -42,6 +42,9 @@ pub mod asset_entity_binding_input_type;
pub mod asset_entity_binding_procedure_result_type;
pub mod asset_entity_binding_snapshot_type;
pub mod asset_entity_binding_type;
pub mod asset_history_entry_snapshot_type;
pub mod asset_history_list_input_type;
pub mod asset_history_list_result_type;
pub mod asset_object_access_policy_type;
pub mod asset_object_procedure_result_type;
pub mod asset_object_type;
@@ -236,6 +239,7 @@ pub mod inventory_mutation_input_type;
pub mod inventory_mutation_type;
pub mod inventory_slot_snapshot_type;
pub mod inventory_slot_type;
pub mod list_asset_history_and_return_procedure;
pub mod list_big_fish_works_procedure;
pub mod list_custom_world_gallery_entries_procedure;
pub mod list_custom_world_profiles_procedure;
@@ -499,6 +503,9 @@ pub use asset_entity_binding_input_type::AssetEntityBindingInput;
pub use asset_entity_binding_procedure_result_type::AssetEntityBindingProcedureResult;
pub use asset_entity_binding_snapshot_type::AssetEntityBindingSnapshot;
pub use asset_entity_binding_type::AssetEntityBinding;
pub use asset_history_entry_snapshot_type::AssetHistoryEntrySnapshot;
pub use asset_history_list_input_type::AssetHistoryListInput;
pub use asset_history_list_result_type::AssetHistoryListResult;
pub use asset_object_access_policy_type::AssetObjectAccessPolicy;
pub use asset_object_procedure_result_type::AssetObjectProcedureResult;
pub use asset_object_type::AssetObject;
@@ -693,6 +700,7 @@ pub use inventory_mutation_input_type::InventoryMutationInput;
pub use inventory_mutation_type::InventoryMutation;
pub use inventory_slot_snapshot_type::InventorySlotSnapshot;
pub use inventory_slot_type::InventorySlot;
pub use list_asset_history_and_return_procedure::list_asset_history_and_return;
pub use list_big_fish_works_procedure::list_big_fish_works;
pub use list_custom_world_gallery_entries_procedure::list_custom_world_gallery_entries;
pub use list_custom_world_profiles_procedure::list_custom_world_profiles;

View File

@@ -1,5 +1,9 @@
use crate::*;
const ASSET_HISTORY_MAX_LIMIT: usize = 120;
const ASSET_HISTORY_CHARACTER_VISUAL_KIND: &str = "character_visual";
const ASSET_HISTORY_SCENE_IMAGE_KIND: &str = "scene_image";
#[spacetimedb::table(
accessor = asset_object,
index(accessor = by_bucket_object_key, btree(columns = [bucket, object_key]))
@@ -54,6 +58,26 @@ pub fn confirm_asset_object_and_return(
}
}
// 历史素材只返回编辑器复用所需的脱敏字段asset_object 本表继续保持 private。
#[spacetimedb::procedure]
pub fn list_asset_history_and_return(
ctx: &mut ProcedureContext,
input: AssetHistoryListInput,
) -> AssetHistoryListResult {
match ctx.try_with_tx(|tx| list_asset_history(tx, input.clone())) {
Ok(entries) => AssetHistoryListResult {
ok: true,
entries,
error_message: None,
},
Err(message) => AssetHistoryListResult {
ok: false,
entries: Vec::new(),
error_message: Some(message),
},
}
}
pub(crate) fn upsert_asset_object(
ctx: &ReducerContext,
input: AssetObjectUpsertInput,
@@ -167,3 +191,52 @@ pub(crate) fn has_asset_object(ctx: &ReducerContext, asset_object_id: &str) -> b
.iter()
.any(|row| row.asset_object_id == asset_object_id)
}
fn list_asset_history(
ctx: &ReducerContext,
input: AssetHistoryListInput,
) -> Result<Vec<AssetHistoryEntrySnapshot>, String> {
let asset_kind = input.asset_kind.trim();
if asset_kind != ASSET_HISTORY_CHARACTER_VISUAL_KIND
&& asset_kind != ASSET_HISTORY_SCENE_IMAGE_KIND
{
return Err("历史素材类型只支持 character_visual 或 scene_image".to_string());
}
let limit = usize::try_from(input.limit)
.unwrap_or(ASSET_HISTORY_MAX_LIMIT)
.clamp(1, ASSET_HISTORY_MAX_LIMIT);
let mut entries = ctx
.db
.asset_object()
.iter()
.filter(|row| row.asset_kind == asset_kind)
.map(|row| AssetHistoryEntrySnapshot {
asset_object_id: row.asset_object_id,
asset_kind: row.asset_kind,
image_src: object_key_to_legacy_image_src(row.object_key.as_str()),
owner_user_id: row.owner_user_id,
profile_id: row.profile_id,
entity_id: row.entity_id,
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
})
.collect::<Vec<_>>();
entries.sort_by(|left, right| {
right
.created_at_micros
.cmp(&left.created_at_micros)
.then_with(|| right.asset_object_id.cmp(&left.asset_object_id))
});
entries.truncate(limit);
Ok(entries)
}
fn object_key_to_legacy_image_src(object_key: &str) -> String {
let normalized = object_key.trim().trim_start_matches('/');
if normalized.is_empty() {
return String::new();
}
format!("/{normalized}")
}

View File

@@ -68,15 +68,19 @@ function Section({
badge,
actions,
children,
className = '',
}: {
title: string;
subtitle?: string;
badge?: ReactNode;
actions?: ReactNode;
children: ReactNode;
className?: string;
}) {
return (
<div className="platform-surface platform-surface--soft px-3.5 py-3">
<div
className={`platform-surface platform-surface--soft px-3.5 py-3 ${className}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-xs font-bold tracking-[0.16em] text-white">
@@ -220,9 +224,7 @@ function PendingEntityCard({
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
{title}
</div>
<div className="mt-1 text-xs leading-6">
{phaseLabel}
</div>
<div className="mt-1 text-xs leading-6">{phaseLabel}</div>
</div>
<div className="platform-pill platform-pill--cool px-2.5 py-1 text-[10px]">
{Math.round(progress)}%
@@ -286,9 +288,11 @@ function buildSceneChapterSearchText(
}
function buildSceneTaskDescriptionText(sceneChapters: SceneChapterBlueprint[]) {
return compactTextList(
sceneChapters.map((chapter) => chapter.sceneTaskDescription),
)[0] ?? '';
return (
compactTextList(
sceneChapters.map((chapter) => chapter.sceneTaskDescription),
)[0] ?? ''
);
}
function SceneActPreviewStrip({
@@ -364,9 +368,7 @@ function CatalogCard({
onClick={disabled ? undefined : onClick}
aria-disabled={disabled}
className={`w-full rounded-[1.3rem] border p-2.5 text-left transition-colors xl:p-3 ${
isSelected
? 'border-rose-300/35 bg-rose-500/10'
: 'platform-subpanel'
isSelected ? 'border-rose-300/35 bg-rose-500/10' : 'platform-subpanel'
}`}
>
<div className="flex items-start gap-3 xl:gap-3.5">
@@ -388,7 +390,9 @@ function CatalogCard({
<div className="mt-1.5 text-sm leading-5 text-zinc-300 xl:line-clamp-2">
{description || '暂无描述'}
</div>
{actions ? <div className="mt-2 flex flex-wrap gap-2">{actions}</div> : null}
{actions ? (
<div className="mt-2 flex flex-wrap gap-2">{actions}</div>
) : null}
</div>
</div>
</div>
@@ -402,9 +406,7 @@ function CatalogCard({
onClick={disabled ? undefined : onClick}
aria-disabled={disabled}
className={`w-full rounded-[1.4rem] border p-3 text-left transition-colors ${
isSelected
? 'border-rose-300/35 bg-rose-500/10'
: 'platform-subpanel'
isSelected ? 'border-rose-300/35 bg-rose-500/10' : 'platform-subpanel'
}`}
>
<div className="space-y-3">
@@ -816,17 +818,19 @@ export function CustomWorldEntityCatalog({
return (
<div
ref={scrollContainerRef}
className="h-full min-h-0 space-y-3 overflow-y-auto overscroll-contain pr-1 scrollbar-hide xl:space-y-4 xl:pr-2"
className="h-full min-h-0 space-y-3 overflow-y-auto overscroll-contain pr-1 scrollbar-hide xl:space-y-4 xl:pr-2 2xl:space-y-5 2xl:pr-3"
>
<div className="px-1 pb-1 text-center xl:rounded-[2rem] xl:border xl:border-[var(--platform-subpanel-border)] xl:bg-white/55 xl:px-6 xl:py-4 xl:text-left xl:shadow-[0_18px_70px_rgba(255,79,139,0.08)] xl:backdrop-blur-sm">
<div className="px-1 pb-1 text-center xl:flex xl:items-end xl:justify-between xl:gap-6 xl:rounded-[2rem] xl:border xl:border-[var(--platform-subpanel-border)] xl:bg-white/55 xl:px-6 xl:py-3 xl:text-left xl:shadow-[0_18px_70px_rgba(255,79,139,0.08)] xl:backdrop-blur-sm 2xl:px-7">
<div className="text-[11px] font-bold tracking-[0.28em] text-zinc-500">
</div>
<div className="mt-2 text-3xl font-black text-[var(--platform-text-strong)] sm:text-[2.2rem] xl:mt-1 xl:text-[2rem]">
{profile.name}
</div>
<div className="mt-2 text-sm tracking-[0.18em] text-zinc-400 xl:mt-1 xl:text-xs">
{profile.subtitle}
<div className="min-w-0 xl:flex xl:flex-1 xl:items-end xl:justify-between xl:gap-5">
<div className="mt-2 truncate text-3xl font-black text-[var(--platform-text-strong)] sm:text-[2.2rem] xl:mt-0 xl:text-[2rem] 2xl:text-[2.25rem]">
{profile.name}
</div>
<div className="mt-2 min-w-0 text-sm tracking-[0.18em] text-zinc-400 xl:mt-0 xl:max-w-[34rem] xl:truncate xl:text-right xl:text-xs">
{profile.subtitle}
</div>
</div>
</div>
@@ -898,7 +902,7 @@ export function CustomWorldEntityCatalog({
</div>
{activeTab === 'world' ? (
<div className="space-y-3 xl:grid xl:grid-cols-[0.8fr_1.2fr] xl:items-start xl:gap-3 xl:space-y-0">
<div className="space-y-3 xl:grid xl:grid-cols-[minmax(18rem,0.82fr)_minmax(0,1fr)_minmax(24rem,1.08fr)] xl:items-start xl:gap-3 xl:space-y-0 2xl:gap-4">
<Section title="档案规模">
<div className="grid grid-cols-3 gap-2 text-center text-[11px] text-zinc-300">
<div className="platform-subpanel rounded-xl px-2 py-3">
@@ -926,7 +930,7 @@ export function CustomWorldEntityCatalog({
title="角色维度"
subtitle={profile.attributeSchema?.schemaName}
>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 xl:grid-cols-2 2xl:grid-cols-3">
{attributeSlots.map((slot) => (
<div
key={slot.slotId}
@@ -963,19 +967,20 @@ export function CustomWorldEntityCatalog({
)
}
>
<div className="space-y-3 text-sm leading-7 text-zinc-300">
<p>{profile.summary}</p>
<div className="platform-banner platform-banner--warning rounded-2xl px-3 py-3">
线{profile.playerGoal}
</div>
<div className="platform-subpanel rounded-2xl px-3 py-3">
{profile.tone}
</div>
<div className="space-y-3 text-sm leading-7 text-zinc-300">
<p>{profile.summary}</p>
<div className="platform-banner platform-banner--warning rounded-2xl px-3 py-3">
线{profile.playerGoal}
</div>
<div className="platform-subpanel rounded-2xl px-3 py-3">
{profile.tone}
</div>
</div>
</Section>
<Section
title="基本设定"
className="xl:col-span-3"
actions={
readOnly ? (
<SmallButton
@@ -1006,14 +1011,16 @@ export function CustomWorldEntityCatalog({
</div>
{entry.value ? (
<div className="mt-3 flex flex-wrap gap-2">
{parseFoundationTagText(entry.value).map((tag, index) => (
<span
key={`${entry.id}-${index}-${tag}`}
className="rounded-full border border-white/10 bg-white/[0.06] px-3 py-1 text-xs leading-5 text-zinc-100"
>
{tag}
</span>
))}
{parseFoundationTagText(entry.value).map(
(tag, index) => (
<span
key={`${entry.id}-${index}-${tag}`}
className="rounded-full border border-white/10 bg-white/[0.06] px-3 py-1 text-xs leading-5 text-zinc-100"
>
{tag}
</span>
),
)}
</div>
) : (
<div className="mt-2 text-sm leading-7 text-zinc-100">
@@ -1029,7 +1036,7 @@ export function CustomWorldEntityCatalog({
) : null}
{activeTab === 'playable' ? (
<div className="space-y-3 xl:grid xl:grid-cols-2 xl:gap-3 xl:space-y-0 2xl:grid-cols-3">
<div className="space-y-3 xl:grid xl:grid-cols-3 xl:gap-3 xl:space-y-0 2xl:grid-cols-4 2xl:gap-4">
{pendingGeneratedEntity?.kind === 'playable' ? (
<PendingEntityCard
title={pendingGeneratedEntity.title}
@@ -1060,7 +1067,9 @@ export function CustomWorldEntityCatalog({
<CatalogCard
title={role.name}
description={description || '暂无描述'}
badge={recentPlayableIdSet.has(role.id) ? <NewBadge /> : null}
badge={
recentPlayableIdSet.has(role.id) ? <NewBadge /> : null
}
isSelectionMode={false}
isSelected={false}
layout="compact"
@@ -1093,9 +1102,9 @@ export function CustomWorldEntityCatalog({
className="h-full w-full object-cover object-top"
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-[rgba(255,255,255,0.64)] px-3 text-center text-xs font-semibold tracking-[0.16em] text-[var(--platform-text-soft)]">
{role.name.slice(0, 4) || '角色'}
</div>
<div className="flex h-full w-full items-center justify-center bg-[rgba(255,255,255,0.64)] px-3 text-center text-xs font-semibold tracking-[0.16em] text-[var(--platform-text-soft)]">
{role.name.slice(0, 4) || '角色'}
</div>
)
}
/>
@@ -1140,7 +1149,7 @@ export function CustomWorldEntityCatalog({
) : null}
{activeTab === 'story' ? (
<div className="space-y-3 xl:grid xl:grid-cols-2 xl:gap-3 xl:space-y-0 2xl:grid-cols-3">
<div className="space-y-3 xl:grid xl:grid-cols-3 xl:gap-3 xl:space-y-0 2xl:grid-cols-4 2xl:gap-4">
{pendingGeneratedEntity?.kind === 'story' ? (
<PendingEntityCard
title={pendingGeneratedEntity.title}
@@ -1200,7 +1209,7 @@ export function CustomWorldEntityCatalog({
) : null}
{activeTab === 'landmarks' ? (
<div className="space-y-3 xl:grid xl:grid-cols-2 xl:gap-3 xl:space-y-0 2xl:grid-cols-3">
<div className="space-y-3 xl:grid xl:grid-cols-3 xl:gap-3 xl:space-y-0 2xl:grid-cols-4 2xl:gap-4">
{pendingGeneratedEntity?.kind === 'landmark' ? (
<PendingEntityCard
title={pendingGeneratedEntity.title}
@@ -1218,16 +1227,15 @@ export function CustomWorldEntityCatalog({
`scene-entry-${index}-${scene.name.trim() || scene.kind}`,
)}
title={scene.name}
description={
compactTextList([
scene.kind === 'camp'
? `开局场景 · ${scene.description}`
: scene.description,
scene.sceneTaskDescription,
]).join(' / ')
}
description={compactTextList([
scene.kind === 'camp'
? `开局场景 · ${scene.description}`
: scene.description,
scene.sceneTaskDescription,
]).join(' / ')}
badge={
scene.kind === 'landmark' && recentLandmarkIdSet.has(scene.id) ? (
scene.kind === 'landmark' &&
recentLandmarkIdSet.has(scene.id) ? (
<NewBadge />
) : null
}
@@ -1270,4 +1278,3 @@ export function CustomWorldEntityCatalog({
</div>
);
}

View File

@@ -1,6 +1,13 @@
/* @vitest-environment jsdom */
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { afterEach, expect, test, vi } from 'vitest';
@@ -191,6 +198,7 @@ function createProfile(): CustomWorldProfile {
attributeSchema: {
id: 'schema-1',
worldId: 'world-1',
schemaName: '潮雾六维',
schemaVersion: 1,
generatedFrom: {
worldType: 'WUXIA',
@@ -199,7 +207,68 @@ function createProfile(): CustomWorldProfile {
tone: '压抑、潮湿、带着未解旧伤。',
conflictCore: '旧航道归属',
},
slots: [],
slots: [
{
slotId: 'axis_a',
name: '骨势',
definition: '扛住压力并正面推进的底子。',
positiveSignals: ['硬顶'],
negativeSignals: ['畏缩'],
combatUseText: '正面承压与破阵。',
socialUseText: '在谈判里稳住立场。',
explorationUseText: '穿过危险地形。',
},
{
slotId: 'axis_b',
name: '身法',
definition: '抢位、转场与把握节奏的能力。',
positiveSignals: ['灵动'],
negativeSignals: ['迟滞'],
combatUseText: '移动换位。',
socialUseText: '捕捉话锋。',
explorationUseText: '快速穿行。',
},
{
slotId: 'axis_c',
name: '眼脉',
definition: '看破破绽、拆解局势的能力。',
positiveSignals: ['洞察'],
negativeSignals: ['误判'],
combatUseText: '识破招式。',
socialUseText: '辨别谎言。',
explorationUseText: '发现线索。',
},
{
slotId: 'axis_d',
name: '心焰',
definition: '决断、压迫与坚持意志的能力。',
positiveSignals: ['果断'],
negativeSignals: ['犹疑'],
combatUseText: '强行压制。',
socialUseText: '立威推进。',
explorationUseText: '面对险境不退。',
},
{
slotId: 'axis_e',
name: '尘缘',
definition: '处理人情、承诺和关系牵引的能力。',
positiveSignals: ['守信'],
negativeSignals: ['冷漠'],
combatUseText: '协作配合。',
socialUseText: '建立信任。',
explorationUseText: '借助人脉。',
},
{
slotId: 'axis_f',
name: '玄息',
definition: '调息、稳态和久战的能力。',
positiveSignals: ['沉稳'],
negativeSignals: ['浮躁'],
combatUseText: '续战恢复。',
socialUseText: '保持耐心。',
explorationUseText: '长线跋涉。',
},
],
},
playableNpcs: [createPlayableRole('playable-1', '沈砺')],
storyNpcs: [createStoryRole('story-1', '顾潮音')],
@@ -684,6 +753,57 @@ test('基本设定目标打开独立编辑面板', () => {
expect(screen.queryByText('编辑世界信息')).toBeNull();
});
test('世界信息面板可以编辑六个角色维度信息', async () => {
const user = userEvent.setup();
const savedProfileRef: { current: CustomWorldProfile | null } = {
current: null,
};
render(
<RpgCreationEntityEditorModal
profile={createProfile()}
target={{ kind: 'world' }}
onClose={() => {}}
onProfileChange={(profile) => {
savedProfileRef.current = profile;
}}
/>,
);
expect(screen.getByText('角色维度')).toBeTruthy();
const nameInputs = screen.getAllByLabelText('维度名称');
await user.clear(nameInputs[0]!);
await user.type(nameInputs[0]!, '潮骨');
const definitionFields = screen.getAllByLabelText('定义');
await user.clear(definitionFields[0]!);
await user.type(definitionFields[0]!, '顶住潮压并正面推进的角色底色。');
const positiveSignalFields = screen.getAllByLabelText('正向信号');
fireEvent.change(positiveSignalFields[0]!, {
target: { value: '硬顶, 护阵' },
});
const combatFields = screen.getAllByLabelText('战斗体现');
await user.clear(combatFields[0]!);
await user.type(combatFields[0]!, '正面压线与护住阵脚。');
await user.click(screen.getByRole('button', { name: //u }));
expect(savedProfileRef.current?.attributeSchema.slots[0]?.name).toBe(
'潮骨',
);
expect(savedProfileRef.current?.attributeSchema.slots[0]?.definition).toBe(
'顶住潮压并正面推进的角色底色。',
);
expect(
savedProfileRef.current?.attributeSchema.slots[0]?.positiveSignals,
).toEqual(['硬顶', '护阵']);
expect(savedProfileRef.current?.attributeSchema.slots[0]?.combatUseText).toBe(
'正面压线与护住阵脚。',
);
});
test('可扮演角色列表使用缩略卡片并点击进入编辑', async () => {
const user = userEvent.setup();
const handleEditTarget = vi.fn();
@@ -821,11 +941,15 @@ test('场景图片保存后会同步更新编辑页和场景列表', async () =>
const savedSceneChapter = savedProfile.sceneChapterBlueprints?.find(
(entry) => entry.sceneId === 'landmark-1',
);
expect(
savedSceneChapter?.acts.every(
(act) => act.backgroundImageSrc === '/generated-custom-world-scenes/updated-scene.png',
),
).toBe(true);
expect(savedSceneChapter?.acts[0]?.backgroundImageSrc).toBe(
'/generated-custom-world-scenes/updated-scene.png',
);
expect(savedSceneChapter?.acts[1]?.backgroundImageSrc).not.toBe(
'/generated-custom-world-scenes/updated-scene.png',
);
expect(savedSceneChapter?.acts[2]?.backgroundImageSrc).not.toBe(
'/generated-custom-world-scenes/updated-scene.png',
);
});
test('开局场景图片保存后会同步更新编辑页和场景列表', async () => {
@@ -899,11 +1023,15 @@ test('开局场景图片保存后会同步更新编辑页和场景列表', async
const savedSceneChapter = savedProfile.sceneChapterBlueprints?.find(
(entry) => entry.sceneId === 'custom-scene-camp',
);
expect(
savedSceneChapter?.acts.every(
(act) => act.backgroundImageSrc === '/generated-custom-world-scenes/updated-camp.png',
),
).toBe(true);
expect(savedSceneChapter?.acts[0]?.backgroundImageSrc).toBe(
'/generated-custom-world-scenes/updated-camp.png',
);
expect(savedSceneChapter?.acts[1]?.backgroundImageSrc).not.toBe(
'/generated-custom-world-scenes/updated-camp.png',
);
expect(savedSceneChapter?.acts[2]?.backgroundImageSrc).not.toBe(
'/generated-custom-world-scenes/updated-camp.png',
);
});
test('开局场景在场景配置面板中与普通场景使用同级参数并可保存', async () => {
@@ -960,6 +1088,8 @@ test('开局场景在场景配置面板中与普通场景使用同级参数并
test('开局场景列表与详情幕预览复用同一套幕级图片', async () => {
const profile = createProfileWithSceneChapters();
profile.sceneChapterBlueprints![0]!.acts[1]!.backgroundPromptText =
'第二幕专属背景提示';
const user = userEvent.setup();
render(
@@ -1003,6 +1133,53 @@ test('开局场景列表与详情幕预览复用同一套幕级图片', async ()
);
});
test('开局场景幕背景智能生成复用当前幕图片和幕级提示词', async () => {
mockedRpgCreationAssetClient.generateSceneImage.mockClear();
mockedRpgCreationAssetClient.generateSceneImage.mockResolvedValue({
imageSrc: '/generated-custom-world-scenes/camp-act-2-ai.png',
assetId: 'asset-camp-act-2',
model: 'wan2.2-t2i-flash',
size: '1280*720',
taskId: 'task-camp-act-2',
prompt: '第二幕专属背景提示',
});
const profile = createProfileWithSceneChapters();
profile.sceneChapterBlueprints![0]!.acts[1]!.backgroundPromptText =
'第二幕专属背景提示';
const user = userEvent.setup();
render(
<RpgCreationEntityEditorModal
profile={profile}
target={{ kind: 'camp' }}
onClose={() => {}}
onProfileChange={() => {}}
/>,
);
await user.click(within(getSceneActCard(1)).getByRole('button', { name: '配置背景' }));
await waitFor(() => {
expect(screen.getByText('配置幕背景第2幕')).toBeTruthy();
});
await user.click(screen.getByRole('button', { name: 'AI生成' }));
await waitFor(() => {
expect(screen.getByText('智能生成:潮灯居')).toBeTruthy();
});
expect(screen.getByRole('img', { name: '潮灯居' }).getAttribute('src')).toBe(
'/generated-custom-world-scenes/camp-act-2.png',
);
await user.click(screen.getByRole('button', { name: '开始生成' }));
await waitFor(() => {
expect(mockedRpgCreationAssetClient.generateSceneImage).toHaveBeenCalledTimes(1);
});
const payload = mockedRpgCreationAssetClient.generateSceneImage.mock.calls[0]?.[0];
expect(payload?.userPrompt).toBe('第二幕专属背景提示');
});
test('普通场景世界地图会包含开局场景并高亮当前场景', async () => {
const user = userEvent.setup();

View File

@@ -1,4 +1,5 @@
import {motion} from 'motion/react';
import {type ReactNode, useEffect, useMemo, useRef, useState} from 'react';
import {getCharacterById} from '../../data/characterPresets';
import {getFacingTowardPlayer, MONSTERS_BY_WORLD} from '../../data/hostileNpcs';
@@ -16,20 +17,24 @@ import {HostileNpcAnimator} from '../HostileNpcAnimator';
import {MedievalNpcAnimator} from '../MedievalNpcAnimator';
import {getRenderableNpcFacing} from '../npcRenderUtils';
import {ResolvedAssetImage} from '../ResolvedAssetImage';
import {NpcAffinityEffectBadge} from './NpcAffinityEffectBadge';
import {
buildCombatFeedbackEvents,
type CombatFeedbackEvent,
type CombatFeedbackHealthSample,
} from './combatFeedback';
import {
CHARACTER_COMBAT_HP_TOP_PX,
DialogueBubbleIcon,
type GameCanvasEntitySelection,
GENERIC_NPC_SCENE_SCALE,
CHARACTER_COMBAT_HP_TOP_PX,
getCompanionSlotOffset,
getEncounterCharacterBottomOffsetPx,
getEncounterCharacterOpponentBottom,
getHostileNpcSceneBottomOffsetPx,
getMonsterWorldLeft,
getNpcCombatHpTop,
getSceneNpcVisualBottomOffsetPx,
getSceneEntityZIndex,
getSceneNpcVisualBottomOffsetPx,
HpBar,
mapHostileNpcAnimationToCharacterState,
MONSTER_RENDER_OFFSETS,
@@ -40,6 +45,7 @@ import {
SceneEncounterNpcSprite,
SceneEntityButton,
} from './GameCanvasShared';
import {NpcAffinityEffectBadge} from './NpcAffinityEffectBadge';
type MonsterSpriteConfig = (typeof MONSTERS_BY_WORLD)[WorldType.WUXIA][number];
@@ -87,6 +93,88 @@ interface GameCanvasEntityLayerProps {
playerX: number;
}
function CombatFloatingNumber({
event,
onDone,
}: {
event: CombatFeedbackEvent;
onDone: (eventId: string) => void;
}) {
const isHealing = event.delta > 0;
const deltaText = `${isHealing ? '+' : ''}${event.delta}`;
const colorClass = isHealing ? 'text-emerald-200' : 'text-rose-200';
const glowClass = isHealing
? 'drop-shadow-[0_0_8px_rgba(52,211,153,0.9)]'
: 'drop-shadow-[0_0_8px_rgba(248,113,113,0.9)]';
return (
<motion.div
key={event.id}
initial={{opacity: 0, y: 10, scale: 0.76}}
animate={{opacity: [0, 1, 1, 0], y: [10, -12, -31, -50], scale: [0.76, 1.22, 1, 0.9]}}
transition={{duration: 0.92, ease: 'easeOut'}}
onAnimationComplete={() => onDone(event.id)}
className={`pointer-events-none absolute -top-16 left-1/2 z-[14] -translate-x-1/2 text-lg font-black leading-none ${colorClass} ${glowClass}`}
data-testid={`combat-feedback-${event.targetKey}`}
aria-label={`战斗数值 ${deltaText}`}
>
<span className="[-webkit-text-stroke:1px_rgba(24,24,27,0.76)]">
{deltaText}
</span>
</motion.div>
);
}
function CombatFeedbackNumbers({
events,
onDone,
}: {
events: CombatFeedbackEvent[];
onDone: (eventId: string) => void;
}) {
return (
<>
{events.map(event => (
<CombatFloatingNumber key={event.id} event={event} onDone={onDone} />
))}
</>
);
}
function getLatestDamageFeedback(events: CombatFeedbackEvent[]) {
for (let index = events.length - 1; index >= 0; index -= 1) {
const event = events[index];
if (event?.delta && event.delta < 0) return event;
}
return null;
}
function CombatReactiveSpriteFrame({
events,
facing,
className = ROLE_CHARACTER_FRAME_CLASS,
children,
}: {
events: CombatFeedbackEvent[];
facing: 'left' | 'right';
className?: string;
children: ReactNode;
}) {
const latestDamage = getLatestDamageFeedback(events);
const retreatX = facing === 'right' ? -12 : 12;
return (
<motion.div
className={className}
animate={latestDamage ? {x: [0, retreatX, 0]} : {x: 0}}
transition={{duration: 0.28, ease: 'easeOut'}}
>
{children}
</motion.div>
);
}
export function GameCanvasEntityLayer({
companions,
currentScenePreset,
@@ -122,13 +210,79 @@ export function GameCanvasEntityLayer({
monsterAnchorMeters,
playerX,
}: GameCanvasEntityLayerProps) {
const [combatFeedbackEvents, setCombatFeedbackEvents] = useState<CombatFeedbackEvent[]>([]);
const previousCombatSamplesRef = useRef<Map<string, CombatFeedbackHealthSample> | null>(null);
const combatFeedbackSequenceRef = useRef(0);
const shouldRenderPeacefulEncounter =
Boolean(encounter) && (!inBattle || sceneCombatants.length === 0);
const combatHealthSamples = useMemo<CombatFeedbackHealthSample[]>(
() => {
if (!inBattle) return [];
return [
{key: 'player', kind: 'player', hp: playerHp},
...companions.map(companion => ({
key: `companion:${companion.npcId}`,
kind: 'companion' as const,
hp: companion.hp,
})),
...sceneCombatants.map(hostileNpc => ({
key: `hostile:${hostileNpc.id}`,
kind: 'hostile' as const,
hp: hostileNpc.hp,
})),
];
},
[companions, inBattle, playerHp, sceneCombatants],
);
const combatFeedbackByTarget = useMemo(() => {
const feedbackByTarget = new Map<string, CombatFeedbackEvent[]>();
combatFeedbackEvents.forEach(event => {
feedbackByTarget.set(event.targetKey, [
...(feedbackByTarget.get(event.targetKey) ?? []),
event,
]);
});
return feedbackByTarget;
}, [combatFeedbackEvents]);
const removeCombatFeedbackEvent = (eventId: string) => {
setCombatFeedbackEvents(events => events.filter(event => event.id !== eventId));
};
useEffect(() => {
if (!inBattle) {
previousCombatSamplesRef.current = null;
setCombatFeedbackEvents([]);
return;
}
const previousSamples = previousCombatSamplesRef.current;
if (previousSamples) {
const result = buildCombatFeedbackEvents(
previousSamples,
combatHealthSamples,
combatFeedbackSequenceRef.current,
);
if (result.events.length > 0) {
setCombatFeedbackEvents(events => [...events.slice(-8), ...result.events]);
}
combatFeedbackSequenceRef.current = result.nextSequence;
}
previousCombatSamplesRef.current = new Map(
combatHealthSamples.map(sample => [sample.key, sample]),
);
}, [combatHealthSamples, inBattle]);
return (
<>
{companions.map(companion => {
const slotOffset = getCompanionSlotOffset(companion.slot);
const feedbackTargetKey = `companion:${companion.npcId}`;
const feedbackEvents = combatFeedbackByTarget.get(feedbackTargetKey) ?? [];
const companionFacing = companion.facing ?? 'right';
return (
<motion.div
key={`${companion.npcId}-${companion.recruitToken ?? 'steady'}-${sceneTransitionToken}`}
@@ -172,6 +326,7 @@ export function GameCanvasEntityLayer({
ariaLabel={`查看${companion.character.name}详情`}
className="relative flex w-28 flex-col items-center"
>
<CombatFeedbackNumbers events={feedbackEvents} onDone={removeCombatFeedbackEvent} />
{inBattle && (
<div
className="absolute left-1/2 -translate-x-1/2"
@@ -180,15 +335,15 @@ export function GameCanvasEntityLayer({
<HpBar hp={companion.hp} maxHp={companion.maxHp} tone="emerald" />
</div>
)}
<div className={ROLE_CHARACTER_FRAME_CLASS}>
<CombatReactiveSpriteFrame events={feedbackEvents} facing={companionFacing}>
<div className={companion.hp <= 0 ? 'opacity-45 grayscale' : undefined}>
<RoleCharacterSprite
state={sceneTransitionPhase === 'idle' ? companion.animationState : AnimationState.RUN}
character={companion.character}
facing={sceneTransitionPhase === 'idle' ? (companion.facing ?? 'right') : 'right'}
facing={sceneTransitionPhase === 'idle' ? companionFacing : 'right'}
/>
</div>
</div>
</CombatReactiveSpriteFrame>
</SceneEntityButton>
</div>
</div>
@@ -217,6 +372,10 @@ export function GameCanvasEntityLayer({
}}
>
<div className="relative">
<CombatFeedbackNumbers
events={combatFeedbackByTarget.get('player') ?? []}
onDone={removeCombatFeedbackEvent}
/>
{inBattle && (
<div
className="absolute left-1/2 -translate-x-1/2"
@@ -231,7 +390,10 @@ export function GameCanvasEntityLayer({
className="relative block"
>
<div className="relative">
<div className={ROLE_CHARACTER_FRAME_CLASS}>
<CombatReactiveSpriteFrame
events={combatFeedbackByTarget.get('player') ?? []}
facing={effectivePlayerFacing}
>
{playerCharacter && (
<RoleCharacterSprite
state={effectivePlayerAnimationState}
@@ -239,7 +401,7 @@ export function GameCanvasEntityLayer({
facing={effectivePlayerFacing}
/>
)}
</div>
</CombatReactiveSpriteFrame>
</div>
{shouldShowPlayerDialogueIcon && (
<div className="absolute -top-9 right-1">
@@ -270,6 +432,8 @@ export function GameCanvasEntityLayer({
npcCharacter ? npcEncounter?.characterId : null,
npcCharacter ? null : npcEncounter?.monsterPresetId,
);
const feedbackTargetKey = `hostile:${hostileNpc.id}`;
const feedbackEvents = combatFeedbackByTarget.get(feedbackTargetKey) ?? [];
const hostileNpcBottomOffsetPx =
npcMonsterConfig
? getHostileNpcSceneBottomOffsetPx(npcMonsterConfig)
@@ -303,6 +467,7 @@ export function GameCanvasEntityLayer({
ariaLabel={`查看${hostileNpc.name}详情`}
className="relative flex w-28 flex-col items-center"
>
<CombatFeedbackNumbers events={feedbackEvents} onDone={removeCombatFeedbackEvent} />
{inBattle && (
<div
className="absolute left-1/2 -translate-x-1/2"
@@ -311,7 +476,7 @@ export function GameCanvasEntityLayer({
<HpBar hp={hostileNpc.hp} maxHp={hostileNpc.maxHp} tone="rose" />
</div>
)}
<div className={ROLE_CHARACTER_FRAME_CLASS}>
<CombatReactiveSpriteFrame events={feedbackEvents} facing={npcSceneSpriteFacing}>
{npcCharacter ? (
<RoleCharacterSprite
state={hostileNpc.characterAnimation ?? mapHostileNpcAnimationToCharacterState(hostileNpc.animation)}
@@ -335,7 +500,7 @@ export function GameCanvasEntityLayer({
scale={GENERIC_NPC_SCENE_SCALE}
/>
)}
</div>
</CombatReactiveSpriteFrame>
{dialogueIndicator?.showEncounter && hostileNpc.animation !== 'move' && (
<div className="absolute -top-9 left-1">
<DialogueBubbleIcon

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest';
import {
buildCombatFeedbackEvents,
type CombatFeedbackHealthSample,
} from './combatFeedback';
function toSample(key: string, hp: number): CombatFeedbackHealthSample {
return {
key,
kind: key.startsWith('hostile') ? 'hostile' : 'player',
hp,
};
}
describe('combatFeedback', () => {
it('creates red damage and green healing deltas from committed hp changes', () => {
const previous = new Map([
['player', toSample('player', 20)],
['hostile:npc-liu', toSample('hostile:npc-liu', 8)],
]);
const result = buildCombatFeedbackEvents(
previous,
[
toSample('player', 11),
toSample('hostile:npc-liu', 9),
],
4,
);
expect(result.events).toEqual([
{
id: 'player:5',
targetKey: 'player',
kind: 'player',
delta: -9,
},
{
id: 'hostile:npc-liu:6',
targetKey: 'hostile:npc-liu',
kind: 'hostile',
delta: 1,
},
]);
expect(result.nextSequence).toBe(6);
});
it('ignores first render samples and unchanged hp', () => {
const result = buildCombatFeedbackEvents(
new Map([['player', toSample('player', 20)]]),
[
toSample('player', 20),
toSample('companion:npc-chen', 6),
],
0,
);
expect(result.events).toEqual([]);
expect(result.nextSequence).toBe(0);
});
});

View File

@@ -0,0 +1,44 @@
export type CombatFeedbackTargetKind = 'player' | 'companion' | 'hostile';
export interface CombatFeedbackHealthSample {
key: string;
kind: CombatFeedbackTargetKind;
hp: number;
}
export interface CombatFeedbackEvent {
id: string;
targetKey: string;
kind: CombatFeedbackTargetKind;
delta: number;
}
export function buildCombatFeedbackEvents(
previousSamples: Map<string, CombatFeedbackHealthSample>,
currentSamples: CombatFeedbackHealthSample[],
sequence: number,
) {
let nextSequence = sequence;
const events: CombatFeedbackEvent[] = [];
currentSamples.forEach(sample => {
const previous = previousSamples.get(sample.key);
if (!previous) return;
const delta = sample.hp - previous.hp;
if (delta === 0) return;
nextSequence += 1;
events.push({
id: `${sample.key}:${nextSequence}`,
targetKey: sample.key,
kind: sample.kind,
delta,
});
});
return {
events,
nextSequence,
};
}

View File

@@ -26,10 +26,10 @@ import { RESOLVED_ENTITY_X_METERS } from '../../data/sceneEncounterPreviews';
import { buildEncounterFromSceneNpc } from '../../data/scenePresets';
import { EDITOR_ITEM_CATALOG_API_PATH } from '../../editor/shared/editorApiClient';
import { fetchJson } from '../../editor/shared/jsonClient';
import { useCombatFlow } from '../../hooks/useCombatFlow';
import { useNpcInteractionFlow } from '../../hooks/useNpcInteractionFlow';
import { useRpgRuntimeStory } from '../../hooks/rpg-runtime-story/useRpgRuntimeStory';
import { useRpgSessionBootstrap } from '../../hooks/rpg-session/useRpgSessionBootstrap';
import { useCombatFlow } from '../../hooks/useCombatFlow';
import { useNpcInteractionFlow } from '../../hooks/useNpcInteractionFlow';
import { buildSkillActionPrompt } from '../../prompts/customWorldEntityActionPrompts';
import type { CustomWorldSceneImageResult } from '../../services/aiTypes';
import { resolveCustomWorldCampScene } from '../../services/customWorldCamp';
@@ -37,18 +37,22 @@ import {
buildDefaultCustomWorldCoverProfile,
resolveCustomWorldCoverPresentation,
} from '../../services/customWorldCover';
import {
getCustomWorldFoundationAnchorContent,
parseFoundationTagText,
type CustomWorldFoundationEntryId,
} from '../../services/customWorldFoundationEntries';
import { createEmptyCustomWorldCreatorIntent } from '../../services/customWorldCreatorIntent';
import {
type CustomWorldCoverAssetResult,
generateCustomWorldCoverImage,
uploadCustomWorldCoverImage,
} from '../../services/customWorldCoverAssetService';
import { rpgCreationAssetClient } from '../../services/rpg-creation/rpgCreationAssetClient';
import { createEmptyCustomWorldCreatorIntent } from '../../services/customWorldCreatorIntent';
import {
type CustomWorldFoundationEntryId,
getCustomWorldFoundationAnchorContent,
parseFoundationTagText,
} from '../../services/customWorldFoundationEntries';
import {
rpgCreationAssetClient,
type RpgCreationHistoryAsset,
type RpgCreationHistoryAssetKind,
} from '../../services/rpg-creation/rpgCreationAssetClient';
import { createEmptyStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine';
import {
AnimationState,
@@ -81,23 +85,16 @@ import {
import { useAuthUi } from '../auth/AuthUiContext';
import { CharacterAnimator } from '../CharacterAnimator';
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
import { buildDefaultCustomWorldNpcVisual } from '../customWorldNpcVisualDefaults';
import {
CustomWorldNpcPortrait,
CustomWorldNpcVisualEditor,
} from '../CustomWorldNpcVisualEditor';
import { RpgCreationRoleAssetStudioModal } from '../rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal';
import { CustomWorldNpcPortrait } from '../CustomWorldNpcVisualEditor';
import {
RoleCharacterSprite,
SceneEncounterNpcSprite,
} from '../game-canvas/GameCanvasShared';
import { PixelIcon } from '../PixelIcon';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import { RpgCreationRoleAssetStudioModal } from '../rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal';
import { RpgRuntimeShell } from '../rpg-runtime-shell';
import {
createLandmarkDraft,
createPlayableNpcDraft,
createStoryNpcDraft,
resolveEditableLandmark,
resolveEditablePlayableNpc,
resolveEditableStoryNpc,
@@ -135,9 +132,9 @@ function getAnimationPreviewFrameStyle(
}
const [
BACKSTORY_UNLOCK_AFFINITY_EASED,
BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
BACKSTORY_UNLOCK_AFFINITY_TRUSTED,
,
,
,
BACKSTORY_UNLOCK_AFFINITY_CLOSE,
] = AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS;
@@ -211,10 +208,6 @@ function dedupeTextValues(values: Array<string | null | undefined>) {
];
}
function compactTextList(values: Array<string | null | undefined>) {
return values.map((value) => value?.trim() ?? '').filter(Boolean);
}
function moveArrayItem<T>(values: T[], fromIndex: number, toIndex: number) {
if (
fromIndex < 0 ||
@@ -572,6 +565,8 @@ function sanitizeSceneChapterBlueprint(params: {
actGoal: currentAct?.actGoal?.trim() || fallbackAct.actGoal,
transitionHook:
currentAct?.transitionHook?.trim() || fallbackAct.transitionHook,
backgroundAssetId:
currentAct?.backgroundAssetId?.trim() || fallbackAct.backgroundAssetId,
} satisfies SceneActBlueprint;
});
@@ -618,7 +613,7 @@ function resolveSceneCompatibilityImageSrc(params: {
const firstActImageSrc =
params.chapter.acts[0]?.backgroundImageSrc?.trim() || '';
// 中文注释:创作侧只暴露一张场景显示图,列表、幕卡片和背景配置弹层都从这里取图,避免同一场景在不同层级显示不同图片
// 中文注释:场景卡片只读取当前幕已保存图片;场景主图只给没有幕图的旧草稿兜底,不能反向覆盖每一幕
return firstActImageSrc || currentImageSrc || resolvedImageSrc || undefined;
}
@@ -1047,7 +1042,7 @@ function ModalShell({
}
>
<div
className={`platform-modal-shell flex h-[92vh] w-full flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.6)] sm:h-auto sm:max-h-[min(92vh,56rem)] ${usePixelFont ? 'fusion-pixel-app' : `platform-ui-shell platform-theme ${platformThemeClass}`} ${panelClassName} sm:rounded-[1.75rem]`}
className={`platform-modal-shell flex h-[92vh] w-full flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.6)] sm:h-auto sm:max-h-[min(92vh,56rem)] xl:max-h-[min(94vh,64rem)] ${usePixelFont ? 'fusion-pixel-app' : `platform-ui-shell platform-theme ${platformThemeClass}`} ${panelClassName} sm:rounded-[1.75rem]`}
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between gap-3 border-b border-white/10 px-4 py-4 sm:px-5">
@@ -1372,50 +1367,6 @@ function ImagePreview({
);
}
function ImageField({
label,
value,
onChange,
fallbackLabel,
tone = 'square',
showInput = true,
previewOverlay,
footer,
}: {
label: string;
value?: string;
onChange: (value: string) => void;
fallbackLabel: string;
tone?: 'square' | 'landscape';
showInput?: boolean;
previewOverlay?: ReactNode;
footer?: ReactNode;
}) {
return (
<div className="space-y-3">
<div className="text-[11px] font-bold tracking-[0.14em] text-zinc-300">
{label}
</div>
<ImagePreview
src={value}
alt={label}
fallbackLabel={fallbackLabel}
tone={tone}
>
{previewOverlay}
</ImagePreview>
{showInput ? (
<TextInput
value={value ?? ''}
onChange={onChange}
placeholder="支持填写项目内图片路径或外链地址"
/>
) : null}
{footer}
</div>
);
}
function ActionButton({
label,
onClick,
@@ -1457,6 +1408,128 @@ function ActionButton({
);
}
function formatHistoryAssetDate(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value || '';
}
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function HistoryAssetPickerModal({
title,
kind,
tone,
onSelect,
onClose,
}: {
title: string;
kind: RpgCreationHistoryAssetKind;
tone: 'square' | 'landscape';
onSelect: (asset: RpgCreationHistoryAsset) => void;
onClose: () => void;
}) {
const [assets, setAssets] = useState<RpgCreationHistoryAsset[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let isCancelled = false;
setIsLoading(true);
setError(null);
rpgCreationAssetClient
.listHistoryAssets({ kind, limit: 120 })
.then((nextAssets) => {
if (!isCancelled) {
setAssets(nextAssets);
}
})
.catch((loadError) => {
if (!isCancelled) {
setError(
loadError instanceof Error ? loadError.message : '历史素材读取失败。',
);
}
})
.finally(() => {
if (!isCancelled) {
setIsLoading(false);
}
});
return () => {
isCancelled = true;
};
}, [kind]);
return (
<ModalShell
title={title}
onClose={onClose}
overlayClassName="z-[99]"
panelClassName="sm:max-w-5xl"
>
<div className="space-y-4">
{error ? (
<div className="rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
{error}
</div>
) : null}
{isLoading ? (
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-8 text-center text-sm text-zinc-300">
...
</div>
) : assets.length === 0 && !error ? (
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-8 text-center text-sm text-zinc-300">
</div>
) : (
<div
className={`grid gap-3 ${
tone === 'landscape'
? 'sm:grid-cols-2 xl:grid-cols-3'
: 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-4'
}`}
>
{assets.map((asset) => (
<div
key={asset.assetObjectId}
className="overflow-hidden rounded-2xl border border-white/10 bg-black/20"
>
<ImagePreview
src={asset.imageSrc}
alt={asset.ownerLabel}
fallbackLabel="素材"
tone={tone}
/>
<div className="space-y-2 px-3 py-3">
<div className="truncate text-xs font-semibold text-zinc-100">
{asset.ownerLabel || '未记录账号'}
</div>
<div className="text-[11px] leading-5 text-zinc-400">
{formatHistoryAssetDate(asset.createdAt)}
</div>
<ActionButton
label="使用"
onClick={() => onSelect(asset)}
tone="sky"
className="w-full"
/>
</div>
</div>
))}
</div>
)}
</div>
</ModalShell>
);
}
const SCENE_ACT_SLOT_LAYOUTS = [
{
left: '77%',
@@ -2681,12 +2754,14 @@ function SceneImageGenerationModal({
profile,
landmark,
initialPromptText,
initialPreviewImageSrc,
onApply,
onClose,
}: {
profile: CustomWorldProfile;
landmark: CustomWorldLandmark;
initialPromptText?: string;
initialPreviewImageSrc?: string | null;
onApply: (result: CustomWorldSceneImageResult) => void;
onClose: () => void;
}) {
@@ -2704,6 +2779,10 @@ function SceneImageGenerationModal({
const [isExitConfirmOpen, setIsExitConfirmOpen] = useState(false);
const originalImageSrc = useMemo(() => {
const initialPreview = initialPreviewImageSrc?.trim() || '';
if (initialPreview) {
return initialPreview;
}
const landmarkIndex = profile.landmarks.findIndex(
(entry) => entry.id === landmark.id,
);
@@ -2717,7 +2796,7 @@ function SceneImageGenerationModal({
.map((entry) => entry.imageSrc)
.filter((imageSrc): imageSrc is string => Boolean(imageSrc)),
);
}, [landmark, profile]);
}, [initialPreviewImageSrc, landmark, profile]);
const previewImageSrc = latestResult?.imageSrc || originalImageSrc;
@@ -2944,14 +3023,18 @@ function SceneActBackgroundModal({
actLabel: string;
currentImageSrc?: string | null;
fallbackImageSrc?: string | null;
onApply: (imageSrc?: string | null) => void;
onApply: (imageSrc?: string | null, assetId?: string | null) => void;
onClose: () => void;
}) {
const presetImages = useMemo(() => getAllCustomWorldSceneImages(), []);
const [draftImageSrc, setDraftImageSrc] = useDraft(
currentImageSrc?.trim() || '',
);
const [draftAssetId, setDraftAssetId] = useDraft(
act.backgroundAssetId?.trim() || '',
);
const [isAiGenerateOpen, setIsAiGenerateOpen] = useState(false);
const [isHistoryPickerOpen, setIsHistoryPickerOpen] = useState(false);
const previewImageSrc = draftImageSrc || fallbackImageSrc || '';
return (
@@ -2972,13 +3055,20 @@ function SceneActBackgroundModal({
<div className="mt-3 flex flex-wrap gap-3">
<ActionButton
label="跟随场景主图"
onClick={() => setDraftImageSrc('')}
onClick={() => {
setDraftImageSrc('');
setDraftAssetId('');
}}
tone="sky"
/>
<ActionButton
label="AI生成"
onClick={() => setIsAiGenerateOpen(true)}
/>
<ActionButton
label="使用历史素材"
onClick={() => setIsHistoryPickerOpen(true)}
/>
</div>
</div>
@@ -3023,7 +3113,7 @@ function SceneActBackgroundModal({
<ActionButton
label="保存背景"
onClick={() => {
onApply(draftImageSrc || fallbackImageSrc || undefined);
onApply(draftImageSrc || undefined, draftAssetId);
onClose();
}}
tone="sky"
@@ -3038,14 +3128,31 @@ function SceneActBackgroundModal({
landmark={landmark}
initialPromptText={
act.backgroundPromptText?.trim() ||
compactTextList([act.title, act.summary, act.actGoal]).join('')
landmark.visualDescription?.trim() ||
landmark.description.trim() ||
landmark.name.trim()
}
initialPreviewImageSrc={previewImageSrc}
onApply={(result) => {
setDraftImageSrc(result.imageSrc);
setDraftAssetId(result.assetId);
}}
onClose={() => setIsAiGenerateOpen(false)}
/>
) : null}
{isHistoryPickerOpen ? (
<HistoryAssetPickerModal
title="使用历史素材"
kind="scene_image"
tone="landscape"
onSelect={(asset) => {
setDraftImageSrc(asset.imageSrc);
setDraftAssetId(asset.assetObjectId);
setIsHistoryPickerOpen(false);
}}
onClose={() => setIsHistoryPickerOpen(false)}
/>
) : null}
</>
);
}
@@ -4704,45 +4811,6 @@ function InitialItemsEditor({
);
}
function StoryNpcVisualEditorModal({
npc,
visual,
onChange,
onOpenAiStudio,
onClose,
}: {
npc: CustomWorldNpc;
visual: NonNullable<CustomWorldNpc['visual']>;
onChange: (visual: NonNullable<CustomWorldNpc['visual']>) => void;
onOpenAiStudio?: () => void;
onClose: () => void;
}) {
return (
<ModalShell
title={`修改形象:${npc.name}`}
subtitle="在独立面板中组合中世纪奇幻角色形象,左侧预览会保持吸顶。"
onClose={onClose}
panelClassName="sm:max-w-6xl"
overlayClassName="z-[99]"
>
<CustomWorldNpcVisualEditor
npc={{
id: npc.id,
name: npc.name,
role: npc.role,
description: npc.description,
}}
value={visual}
onChange={onChange}
onAiGenerate={() => {
onClose();
onOpenAiStudio?.();
}}
/>
</ModalShell>
);
}
export function WorldEditor({
profile,
onSave,
@@ -4759,6 +4827,7 @@ export function WorldEditor({
title="编辑世界信息"
subtitle="修改后的内容会直接反映在结果页,并会作为进入世界前的最终档案。"
onClose={onClose}
panelClassName="sm:max-w-4xl xl:max-w-6xl 2xl:max-w-7xl"
>
<div className="space-y-4">
<Field label="世界名称">
@@ -4822,6 +4891,24 @@ export function WorldEditor({
rows={4}
/>
</Field>
<WorldAttributeSchemaEditor
value={draft.attributeSchema}
onChange={(attributeSchema) =>
setDraft((current) => ({
...current,
attributeSchema,
ownedSettingLayers: current.ownedSettingLayers
? {
...current.ownedSettingLayers,
ruleProfile: {
...current.ownedSettingLayers.ruleProfile,
attributeSchema,
},
}
: current.ownedSettingLayers,
}))
}
/>
<SaveBar
onClose={onClose}
onSave={() => {
@@ -4932,6 +5019,110 @@ function applyFoundationDraftToProfile(
};
}
function WorldAttributeSchemaEditor({
value,
onChange,
}: {
value: CustomWorldProfile['attributeSchema'];
onChange: (value: CustomWorldProfile['attributeSchema']) => void;
}) {
const updateSlot = (
slotId: string,
patch: Partial<CustomWorldProfile['attributeSchema']['slots'][number]>,
) => {
onChange({
...value,
slots: value.slots.map((slot) =>
slot.slotId === slotId ? { ...slot, ...patch } : slot,
),
});
};
return (
<SectionPanel title="角色维度" subtitle={value.schemaName || '世界能力维度'}>
<div className="space-y-3">
{value.slots.map((slot) => (
<div
key={slot.slotId}
className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
>
<div className="grid gap-3 sm:grid-cols-[10rem_minmax(0,1fr)]">
<Field label="维度名称">
<TextInput
value={slot.name}
onChange={(name) => updateSlot(slot.slotId, { name })}
/>
</Field>
<Field label="定义">
<TextArea
value={slot.definition}
onChange={(definition) =>
updateSlot(slot.slotId, { definition })
}
rows={2}
/>
</Field>
</div>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<Field label="正向信号">
<TextArea
value={commaText(slot.positiveSignals)}
onChange={(text) =>
updateSlot(slot.slotId, {
positiveSignals: parseCommaText(text),
})
}
rows={2}
/>
</Field>
<Field label="负向信号">
<TextArea
value={commaText(slot.negativeSignals)}
onChange={(text) =>
updateSlot(slot.slotId, {
negativeSignals: parseCommaText(text),
})
}
rows={2}
/>
</Field>
</div>
<div className="mt-3 grid gap-3 sm:grid-cols-3">
<Field label="战斗体现">
<TextArea
value={slot.combatUseText}
onChange={(combatUseText) =>
updateSlot(slot.slotId, { combatUseText })
}
rows={2}
/>
</Field>
<Field label="社交体现">
<TextArea
value={slot.socialUseText}
onChange={(socialUseText) =>
updateSlot(slot.slotId, { socialUseText })
}
rows={2}
/>
</Field>
<Field label="探索体现">
<TextArea
value={slot.explorationUseText}
onChange={(explorationUseText) =>
updateSlot(slot.slotId, { explorationUseText })
}
rows={2}
/>
</Field>
</div>
</div>
))}
</div>
</SectionPanel>
);
}
export function WorldFoundationEditor({
profile,
onSave,
@@ -4948,7 +5139,7 @@ export function WorldFoundationEditor({
<ModalShell
title="编辑基本设定"
onClose={onClose}
panelClassName="sm:max-w-4xl"
panelClassName="sm:max-w-5xl xl:max-w-7xl 2xl:max-w-[92rem]"
>
<div className="space-y-4">
{FOUNDATION_EDITOR_FIELDS.map((field) => (
@@ -5059,12 +5250,12 @@ export function PlayableNpcEditor({
}
setIsCloseConfirmOpen(true);
};
return (
<>
<ModalShell
title={mode === 'create' ? '新增可扮演角色' : `编辑角色:${npc.name}`}
onClose={handleRequestClose}
panelClassName="sm:max-w-4xl xl:max-w-6xl 2xl:max-w-7xl"
disableClose={isAiAssetStudioOpen || isCloseConfirmOpen}
>
<div className="space-y-4">
@@ -5110,7 +5301,20 @@ export function PlayableNpcEditor({
</div>
</div>
</div>
) : null}
) : (
<div className="rounded-2xl border border-white/8 bg-black/20 p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-300">
</div>
<ActionButton
label="AI生成"
onClick={() => setIsAiAssetStudioOpen(true)}
tone="sky"
/>
</div>
</div>
)}
<Field label="名称">
<TextInput
value={draft.name}
@@ -5289,8 +5493,8 @@ export function StoryNpcEditor({
onClose: () => void;
}) {
const [draft, setDraft] = useDraft(npc);
const [isVisualEditorOpen, setIsVisualEditorOpen] = useState(false);
const [isAiAssetStudioOpen, setIsAiAssetStudioOpen] = useState(false);
const [isHistoryPickerOpen, setIsHistoryPickerOpen] = useState(false);
const [isCloseConfirmOpen, setIsCloseConfirmOpen] = useState(false);
const initialSnapshot = useMemo(() => JSON.stringify(npc), [npc]);
const draftSnapshot = useMemo(() => JSON.stringify(draft), [draft]);
@@ -5322,14 +5526,14 @@ export function StoryNpcEditor({
}
setIsCloseConfirmOpen(true);
};
return (
<>
<ModalShell
title={mode === 'create' ? '新增场景角色' : `编辑场景角色:${npc.name}`}
onClose={handleRequestClose}
panelClassName="sm:max-w-4xl xl:max-w-6xl 2xl:max-w-7xl"
disableClose={
isVisualEditorOpen || isAiAssetStudioOpen || isCloseConfirmOpen
isHistoryPickerOpen || isAiAssetStudioOpen || isCloseConfirmOpen
}
>
<div className="space-y-4">
@@ -5350,8 +5554,8 @@ export function StoryNpcEditor({
<div className="min-w-0 space-y-3">
<div className="flex flex-wrap gap-3">
<ActionButton
label="基于预设素材修改"
onClick={() => setIsVisualEditorOpen(true)}
label="使用历史素材"
onClick={() => setIsHistoryPickerOpen(true)}
tone="sky"
/>
<ActionButton
@@ -5509,23 +5713,22 @@ export function StoryNpcEditor({
}}
showClose={false}
/>
{isVisualEditorOpen ? (
<StoryNpcVisualEditorModal
npc={draft}
visual={
draft.visual ??
buildDefaultCustomWorldNpcVisual({
id: draft.id,
name: draft.name,
role: draft.role,
description: draft.description,
})
}
onChange={(visual) =>
setDraft((current) => ({ ...current, visual }))
}
onOpenAiStudio={() => setIsAiAssetStudioOpen(true)}
onClose={() => setIsVisualEditorOpen(false)}
{isHistoryPickerOpen ? (
<HistoryAssetPickerModal
title="使用历史素材"
kind="character_visual"
tone="square"
onSelect={(asset) => {
setDraft((current) => ({
...current,
imageSrc: asset.imageSrc,
generatedVisualAssetId: asset.assetObjectId,
generatedAnimationSetId: undefined,
animationMap: undefined,
}));
setIsHistoryPickerOpen(false);
}}
onClose={() => setIsHistoryPickerOpen(false)}
/>
) : null}
{isAiAssetStudioOpen ? (
@@ -5935,14 +6138,29 @@ export function LandmarkEditor({
}));
};
const updateSceneActSharedBackground = (imageSrc?: string | null) => {
const resolvedImageSrc = imageSrc?.trim() || compatibilityImageSrc || '';
const updateSceneActBackground = (
actIndex: number,
imageSrc?: string | null,
assetId?: string | null,
) => {
const resolvedImageSrc = imageSrc?.trim() || '';
const normalizedAssetId = assetId?.trim();
updateSceneChapterDraft((current) => ({
...current,
acts: current.acts.map((act) => ({
...act,
backgroundImageSrc: resolvedImageSrc || undefined,
})),
acts: current.acts.map((act, currentActIndex) =>
currentActIndex === actIndex
? {
...act,
backgroundImageSrc: resolvedImageSrc || undefined,
backgroundAssetId:
normalizedAssetId !== undefined
? normalizedAssetId || undefined
: resolvedImageSrc
? act.backgroundAssetId
: undefined,
}
: act,
),
}));
};
@@ -6094,6 +6312,7 @@ export function LandmarkEditor({
: `编辑场景:${landmark.name || (isOpeningScene ? '开局场景' : '未命名场景')}`
}
onClose={handleRequestClose}
panelClassName="sm:max-w-5xl xl:max-w-7xl 2xl:max-w-[96rem]"
>
<div className="space-y-4">
<Field label="名称">
@@ -6196,7 +6415,8 @@ export function LandmarkEditor({
<SceneActStagePreview
actLabel={actLabel}
imageSrc={
act.backgroundImageSrc?.trim() || compatibilityImageSrc
act.backgroundImageSrc?.trim() ||
compatibilityImageSrc
}
fallbackImageSrc={resolvedDraftImageSrc}
previewCharacter={previewPlayableCharacter}
@@ -6302,11 +6522,16 @@ export function LandmarkEditor({
}
act={activeSceneActBackgroundDraft}
currentImageSrc={
activeSceneActBackgroundDraft.backgroundImageSrc?.trim() ||
compatibilityImageSrc
activeSceneActBackgroundDraft.backgroundImageSrc?.trim() || ''
}
fallbackImageSrc={compatibilityImageSrc || resolvedDraftImageSrc}
onApply={updateSceneActSharedBackground}
onApply={(imageSrc, assetId) =>
updateSceneActBackground(
activeSceneActBackgroundIndex,
imageSrc,
assetId,
)
}
onClose={() => setActiveSceneActBackgroundIndex(null)}
/>
) : null}

View File

@@ -29,9 +29,17 @@ export interface RpgCreationResultViewProps {
onOpenCoverEditor?: () => void;
onPublishWorld?: () => Promise<void> | void;
onTestWorld?: () => void;
onDeleteEntities?: (kind: 'story' | 'landmark', ids: string[]) => Promise<void> | void;
onDeleteEntities?: (
kind: 'story' | 'landmark',
ids: string[],
) => Promise<void> | void;
onGenerateEntity?:
| ((kind: EntityGenerationKind) => Promise<{ profile?: CustomWorldProfile | null } | void> | { profile?: CustomWorldProfile | null } | void)
| ((
kind: EntityGenerationKind,
) =>
| Promise<{ profile?: CustomWorldProfile | null } | void>
| { profile?: CustomWorldProfile | null }
| void)
| undefined;
onProfileChange: (profile: CustomWorldProfile) => void;
readOnly?: boolean;
@@ -126,7 +134,7 @@ export function RpgCreationResultView({
: handleDeleteLandmarks;
return (
<div className="platform-remap-surface flex h-full min-h-0 flex-col">
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full flex-col xl:max-w-[min(100%,98rem)] xl:px-1 2xl:max-w-[min(100%,112rem)]">
<RpgCreationResultHeader
autoSaveState={autoSaveState}
backLabel={backLabel}
@@ -150,7 +158,9 @@ export function RpgCreationResultView({
: createLabel
}
onCreateAction={
readOnly || (compactAgentResultMode && !onGenerateEntity) || !createTarget
readOnly ||
(compactAgentResultMode && !onGenerateEntity) ||
!createTarget
? undefined
: () => {
if (activeTab === 'playable') {
@@ -168,9 +178,7 @@ export function RpgCreationResultView({
setEditorTarget(createTarget);
}
}
createActionDisabled={Boolean(
isGenerating || pendingGeneratedEntity,
)}
createActionDisabled={Boolean(isGenerating || pendingGeneratedEntity)}
pendingGeneratedEntity={pendingGeneratedEntity}
recentGeneratedIds={recentGeneratedIds}
readOnly={readOnly}
@@ -206,7 +214,12 @@ export function RpgCreationResultView({
publishBlockers.length <= 0 &&
qualityFindings.some((entry) => entry.severity === 'warning') ? (
<div className="platform-banner platform-banner--info mt-3 rounded-2xl text-sm leading-6">
{qualityFindings.filter((entry) => entry.severity === 'warning').length} warning
{' '}
{
qualityFindings.filter((entry) => entry.severity === 'warning')
.length
}{' '}
warning
</div>
) : null}
{!error && localGenerationError ? (
@@ -214,7 +227,9 @@ export function RpgCreationResultView({
{localGenerationError}
</div>
) : null}
{assetDebugEnabled ? <RpgCreationAssetDebugPanel profile={profile} /> : null}
{assetDebugEnabled ? (
<RpgCreationAssetDebugPanel profile={profile} />
) : null}
<RpgCreationResultActionBar
editActionLabel={editActionLabel}

View File

@@ -18,6 +18,7 @@ export type EditorJsonResourceId =
(typeof EDITOR_JSON_RESOURCE_IDS)[keyof typeof EDITOR_JSON_RESOURCE_IDS];
export const ASSET_API_PATHS = {
assetHistory: `${ASSETS_API_BASE_PATH}/history`,
characterWorkflowCache: `${ASSETS_API_BASE_PATH}/character-workflow-cache`,
characterVisualGenerate: `${ASSETS_API_BASE_PATH}/character-visual/generate`,
characterVisualPublish: `${ASSETS_API_BASE_PATH}/character-visual/publish`,

View File

@@ -814,9 +814,11 @@ export function buildBattlePlan({
playerActionMode: 'idle' as const,
activeCombatEffects: [],
scrollWorld: false,
inBattle: simulatedState.currentNpcBattleOutcome === 'spar_complete'
? false
: simulatedState.sceneHostileNpcs.length > 0,
inBattle:
simulatedState.currentNpcBattleOutcome === 'spar_complete' ||
simulatedState.playerHp <= 0
? false
: simulatedState.sceneHostileNpcs.length > 0,
sceneHostileNpcs: resetCombatPresentation(simulatedState.sceneHostileNpcs, simulatedState.playerX),
},
};

View File

@@ -523,7 +523,7 @@ describe('createStoryChoiceActions', () => {
expect(handleNpcInteraction).toHaveBeenCalledWith(option);
});
it('reopens npc chat instead of running generic follow-up after local npc victory', async () => {
it('uses deterministic continue option after local npc victory', async () => {
const encounter: Encounter = {
id: 'npc-opponent',
kind: 'npc',
@@ -611,31 +611,31 @@ describe('createStoryChoiceActions', () => {
await handleChoice(option);
expect(handleNpcBattleConversationContinuation).toHaveBeenCalledWith(
expect(handleNpcBattleConversationContinuation).not.toHaveBeenCalled();
expect(setGameState).toHaveBeenCalledWith(
expect.objectContaining({
nextState: expect.objectContaining({
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
}),
encounter,
actionText: '挥刀抢攻',
resultText: '山道客已经败下阵来。胜利奖励:无战利品。',
battleMode: 'fight',
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
inBattle: false,
}),
);
expect(generateStoryForState).not.toHaveBeenCalled();
expect(setCurrentStory).not.toHaveBeenCalledWith(
createFallbackStory('战后续写'),
expect(setCurrentStory).toHaveBeenCalledWith(
expect.objectContaining({
text: '山道客已经败下阵来。胜利奖励:无战利品。',
options: [
expect.objectContaining({
functionId: 'story_continue_adventure',
actionText: '继续前进',
}),
],
}),
);
});
it('injects an escape resolution into the immediate story context before ai continuation', async () => {
it('settles escape locally without ai continuation', async () => {
const mockedGenerateNextStep = vi.mocked(generateNextStep);
mockedGenerateNextStep.mockResolvedValue({
storyText: '你落到山道外侧,呼吸总算稳了下来。',
options: [],
});
const state = {
...createBaseState(),
@@ -667,6 +667,7 @@ describe('createStoryChoiceActions', () => {
playerX: -1.2,
};
const setBattleReward = vi.fn();
const setCurrentStory = vi.fn();
const incrementRuntimeStats = vi.fn((inputState: GameState) => inputState);
const buildStoryContextFromState = vi.fn(() => ({
playerHp: 100,
@@ -685,7 +686,7 @@ describe('createStoryChoiceActions', () => {
currentStory: createFallbackStory(),
isLoading: false,
setGameState: vi.fn(),
setCurrentStory: vi.fn(),
setCurrentStory,
setAiError: vi.fn(),
setIsLoading: vi.fn(),
setBattleReward,
@@ -723,20 +724,11 @@ describe('createStoryChoiceActions', () => {
await handleChoice(option);
expect(mockedGenerateNextStep).toHaveBeenCalledTimes(1);
const history = mockedGenerateNextStep.mock.calls[0]?.[3] as StoryMoment[];
expect(history.map((entry) => `${entry.historyRole}:${entry.text}`)).toEqual([
'action:挥刀抢攻',
'result:你已成功逃脱,与山狼的交战已经被甩开,对方暂时落在身后,当前不再处于战斗状态。',
]);
expect(buildStoryContextFromState).toHaveBeenCalledWith(
expect(mockedGenerateNextStep).not.toHaveBeenCalled();
expect(buildStoryContextFromState).not.toHaveBeenCalled();
expect(setCurrentStory).toHaveBeenCalledWith(
expect.objectContaining({
inBattle: false,
sceneHostileNpcs: [],
}),
expect.objectContaining({
lastFunctionId: 'battle_escape_breakout',
recentActionResult: '你已成功逃脱,与山狼的交战已经被甩开,对方暂时落在身后,当前不再处于战斗状态。',
text: '你已成功逃脱,与山狼的交战已经被甩开,对方暂时落在身后,当前不再处于战斗状态。',
}),
);
expect(setBattleReward).toHaveBeenCalledTimes(1);

View File

@@ -0,0 +1,213 @@
import { getScenePresetById, getScenePresetsByWorld } from '../../data/scenePresets';
import {
advanceSceneActRuntimeState,
buildInitialSceneActRuntimeState,
getSceneConnectionDirectionText,
resolveSceneActProgression,
} from '../../services/customWorldSceneActRuntime';
import { createEmptyStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine';
import {
AnimationState,
type GameState,
type ScenePresetInfo,
type StoryMoment,
type StoryOption,
} from '../../types';
const CONTINUE_ADVENTURE_FUNCTION_ID = 'story_continue_adventure';
const TRAVEL_NEXT_SCENE_FUNCTION_ID = 'idle_travel_next_scene';
function buildBaseFlowVisuals(): StoryOption['visuals'] {
return {
playerAnimation: AnimationState.RUN,
playerMoveMeters: 0.9,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
};
}
function buildContinueOption(): StoryOption {
return {
functionId: CONTINUE_ADVENTURE_FUNCTION_ID,
actionText: '继续前进',
text: '继续前进',
priority: 1,
visuals: buildBaseFlowVisuals(),
};
}
function buildTravelOption(scene: ScenePresetInfo, actionText: string): StoryOption {
return {
functionId: TRAVEL_NEXT_SCENE_FUNCTION_ID,
actionText,
text: actionText,
priority: 2,
visuals: buildBaseFlowVisuals(),
runtimePayload: {
targetSceneId: scene.id,
},
};
}
export function buildSceneTravelOptions(state: GameState): StoryOption[] {
if (!state.worldType) {
return [];
}
const currentSceneId = state.currentScenePreset?.id ?? null;
const currentScene = currentSceneId
? getScenePresetById(state.worldType, currentSceneId)
: null;
const connectionOptions =
currentScene?.connections
?.map((connection) => {
const scene = getScenePresetById(state.worldType!, connection.sceneId);
if (!scene || scene.id === currentSceneId) {
return null;
}
const directionText = getSceneConnectionDirectionText(connection.relativePosition);
return buildTravelOption(scene, `${directionText},前往${scene.name}`);
})
.filter((option): option is StoryOption => Boolean(option)) ?? [];
if (connectionOptions.length > 0) {
return connectionOptions;
}
return getScenePresetsByWorld(state.worldType)
.filter((scene) => scene.id !== currentSceneId)
.slice(0, 4)
.map((scene) => buildTravelOption(scene, `前往${scene.name}`));
}
export function buildPostBattleVictoryState(state: GameState) {
return {
...state,
currentEncounter: null,
npcInteractionActive: false,
sceneHostileNpcs: [],
inBattle: false,
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
animationState: AnimationState.IDLE,
playerActionMode: 'idle' as const,
activeCombatEffects: [],
scrollWorld: false,
} satisfies GameState;
}
export function buildPostBattleVictoryStory(
state: GameState,
resultText: string,
fallbackOptions: StoryOption[] = [],
): { state: GameState; story: StoryMoment } {
const progress = resolveSceneActProgression({
profile: state.customWorldProfile,
sceneId: state.currentScenePreset?.id ?? null,
storyEngineMemory: state.storyEngineMemory,
});
const nextActState = progress
? advanceSceneActRuntimeState({ progress })
: null;
const nextState = nextActState
? {
...state,
storyEngineMemory: {
...(state.storyEngineMemory ?? createEmptyStoryEngineMemoryState()),
currentSceneActState: nextActState,
},
}
: state;
if (progress?.isLastAct) {
return {
state: nextState,
story: {
text: resultText,
options: buildSceneTravelOptions(nextState),
streaming: false,
},
};
}
const deferredOptions =
fallbackOptions.length > 0
? fallbackOptions
: buildSceneTravelOptions(nextState);
return {
state: nextState,
story: {
text: resultText,
options: [buildContinueOption()],
deferredOptions,
deferredRuntimeState: nextActState
? {
storyEngineMemory: nextState.storyEngineMemory,
}
: undefined,
streaming: false,
},
};
}
export function buildRevivedFirstSceneState(state: GameState): GameState {
const firstScene = state.worldType
? getScenePresetsByWorld(state.worldType)[0] ?? state.currentScenePreset
: state.currentScenePreset;
const storyEngineMemory =
state.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
const firstActState = buildInitialSceneActRuntimeState({
profile: state.customWorldProfile,
sceneId: firstScene?.id ?? null,
storyEngineMemory: undefined,
});
return {
...state,
currentScenePreset: firstScene,
currentEncounter: null,
npcInteractionActive: false,
sceneHostileNpcs: [],
playerX: 0,
playerFacing: 'right',
playerHp: state.playerMaxHp,
playerMana: state.playerMaxMana,
inBattle: false,
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
animationState: AnimationState.IDLE,
playerActionMode: 'idle',
activeCombatEffects: [],
scrollWorld: false,
storyEngineMemory: {
...storyEngineMemory,
currentSceneActState: firstActState,
},
};
}
export function buildDeathStory(state: GameState): StoryMoment {
const firstSceneName =
state.worldType
? getScenePresetsByWorld(state.worldType)[0]?.name
: state.currentScenePreset?.name;
return {
text: firstSceneName
? `你在战斗中倒下,随后在${firstSceneName}重新醒来。`
: '你在战斗中倒下,随后重新醒来。',
options: [buildContinueOption()],
streaming: false,
};
}

View File

@@ -5,6 +5,7 @@ import { generateNextStep } from '../../services/aiService';
import type { StoryGenerationContext } from '../../services/aiTypes';
import { appendStoryEngineCarrierMemory } from '../../services/storyEngine/echoMemory';
import { createHistoryMoment } from '../../services/storyHistory';
import { AnimationState } from '../../types';
import type {
Character,
Encounter,
@@ -13,11 +14,17 @@ import type {
StoryOption,
} from '../../types';
import type { EscapePlaybackSync } from '../combat/escapeFlow';
import type { BattlePlan } from '../combat/battlePlan';
import type { ResolvedChoiceState } from '../combat/resolvedChoice';
import {
buildDeathStory,
buildPostBattleVictoryState,
buildPostBattleVictoryStory,
buildRevivedFirstSceneState,
} from './postBattleFlow';
import {
buildCombatResolutionContextText,
buildHostileNpcBattleReward,
buildReasonedOptionCatalog,
} from './storyChoiceRuntime';
import type { BattleRewardSummary } from './uiTypes';
@@ -77,6 +84,73 @@ type IncrementRuntimeStats = (
increments: RuntimeStatsIncrements,
) => GameState;
const PLAYER_REVIVE_DELAY_MS = 3000;
function sleep(ms: number) {
return new Promise((resolve) => globalThis.setTimeout(resolve, ms));
}
function buildLocalCombatResultText(params: {
option: StoryOption;
battlePlan: BattlePlan | null;
afterSequence: GameState;
combatResolutionContextText: string | null;
}) {
if (params.combatResolutionContextText) {
return params.combatResolutionContextText;
}
const turns = params.battlePlan?.turns ?? [];
const dealtDamage = turns
.filter((turn) => turn.actor === 'player' || turn.actor === 'companion')
.reduce((sum, turn) => sum + turn.damage, 0);
const takenDamage = turns
.filter((turn) => turn.actor === 'monster' && turn.target === 'player')
.reduce((sum, turn) => sum + turn.damage, 0);
if (params.afterSequence.playerHp <= 0) {
return takenDamage > 0
? `你承受了${takenDamage}点伤害,气血归零。`
: '你在战斗中倒下,气血归零。';
}
const details = [
dealtDamage > 0 ? `造成${dealtDamage}点伤害` : null,
takenDamage > 0 ? `承受${takenDamage}点伤害` : null,
].filter(Boolean);
return details.length > 0
? `${params.option.actionText}完成,${details.join('')}`
: `${params.option.actionText}完成,双方仍在对峙。`;
}
function buildDeterministicStoryForState(params: {
state: GameState;
character: Character;
resultText: string;
availableOptions: StoryOption[] | null;
buildFallbackStoryForState: BuildFallbackStoryForState;
}) {
if (params.availableOptions?.length) {
return {
text: params.resultText,
options: params.availableOptions,
streaming: false,
} satisfies StoryMoment;
}
const fallbackStory = params.buildFallbackStoryForState(
params.state,
params.character,
params.resultText,
);
return {
...fallbackStory,
text: params.resultText,
streaming: false,
} satisfies StoryMoment;
}
export async function runLocalStoryChoiceContinuation(params: {
gameState: GameState;
currentStory: StoryMoment | null;
@@ -159,6 +233,9 @@ export async function runLocalStoryChoiceContinuation(params: {
params.character,
);
const projectedState = resolvedChoice.afterSequence;
const shouldUseDeterministicCombatFlow =
resolvedChoice.optionKind === 'battle' ||
resolvedChoice.optionKind === 'escape';
const shouldUseLocalNpcVictory = Boolean(
baseChoiceState.currentBattleNpcId &&
resolvedChoice.optionKind === 'battle' &&
@@ -206,7 +283,7 @@ export async function runLocalStoryChoiceContinuation(params: {
]
: history;
const responsePromise = shouldUseLocalNpcVictory
const responsePromise = shouldUseLocalNpcVictory || shouldUseDeterministicCombatFlow
? Promise.resolve(null)
: generateNextStep(
params.gameState.worldType!,
@@ -229,7 +306,7 @@ export async function runLocalStoryChoiceContinuation(params: {
() => undefined,
);
const playbackSync: EscapePlaybackSync | undefined =
resolvedChoice.optionKind === 'escape'
resolvedChoice.optionKind === 'escape' && !shouldUseDeterministicCombatFlow
? { waitForStoryResponse: responseSettledPromise }
: undefined;
const actionPromise = params.playResolvedChoice(
@@ -286,66 +363,122 @@ export async function runLocalStoryChoiceContinuation(params: {
...victory.nextState,
storyHistory: nextHistory,
};
const postBattleOptionCatalog =
baseChoiceState.currentNpcBattleMode === 'spar' &&
nextState.currentEncounter
? buildReasonedOptionCatalog(
params.buildNpcStory(
nextState,
params.character,
nextState.currentEncounter,
).options,
)
: null;
fallbackState = nextState;
params.setGameState(nextState);
if (
nextState.currentEncounter &&
params.handleNpcBattleConversationContinuation({
nextState,
encounter: nextState.currentEncounter,
character: params.character,
actionText: params.option.actionText,
resultText: victory.resultText,
battleMode: baseChoiceState.currentNpcBattleMode!,
})
) {
return;
}
try {
const nextStory = await params.generateStoryForState({
state: nextState,
character: params.character,
history: nextHistory,
choice: params.option.actionText,
lastFunctionId: params.option.functionId,
optionCatalog: postBattleOptionCatalog,
});
const recoveredState = applyStoryReasoningRecovery(nextState);
params.setGameState(recoveredState);
params.setCurrentStory(nextStory);
} catch (storyError) {
console.error(
'Failed to continue npc battle resolution story:',
storyError,
);
params.setAiError(
storyError instanceof Error
? storyError.message
: '未知智能生成错误',
);
params.setCurrentStory(
params.buildFallbackStoryForState(
nextState,
params.character,
victory.resultText,
),
);
}
const postBattleState = buildPostBattleVictoryState(nextState);
const postBattle = buildPostBattleVictoryStory(
postBattleState,
victory.resultText,
params.getAvailableOptionsForState(postBattleState, params.character) ?? [],
);
fallbackState = postBattle.state;
params.setGameState(postBattle.state);
params.setCurrentStory(postBattle.story);
return;
}
}
if (shouldUseDeterministicCombatFlow) {
const defeatedHostileNpcIds =
resolvedChoice.optionKind === 'escape' || baseChoiceState.currentBattleNpcId
? []
: params
.getResolvedSceneHostileNpcs(baseChoiceState)
.map((hostileNpc) => hostileNpc.id)
.filter(
(hostileNpcId) =>
!params
.getResolvedSceneHostileNpcs(afterSequence)
.some((hostileNpc) => hostileNpc.id === hostileNpcId),
);
const resultText = buildLocalCombatResultText({
option: params.option,
battlePlan: resolvedChoice.battlePlan,
afterSequence,
combatResolutionContextText,
});
const nextHistory = [
...baseChoiceState.storyHistory,
createHistoryMoment(params.option.actionText, 'action'),
createHistoryMoment(resultText, 'result'),
];
const nextState = params.incrementRuntimeStats(
{
...params.updateQuestLog(afterSequence, (quests) =>
applyQuestProgressFromHostileNpcDefeat(
quests,
baseChoiceState.currentScenePreset?.id ?? null,
defeatedHostileNpcIds,
),
),
storyHistory: nextHistory,
},
{
hostileNpcsDefeated: defeatedHostileNpcIds.length,
},
);
if (projectedBattleReward) {
params.setBattleReward(projectedBattleReward);
}
if (nextState.playerHp <= 0) {
const deathState = {
...nextState,
animationState: AnimationState.DIE,
playerActionMode: 'idle' as const,
inBattle: false,
activeCombatEffects: [],
scrollWorld: false,
};
fallbackState = deathState;
params.setGameState(deathState);
await sleep(PLAYER_REVIVE_DELAY_MS);
const revivedState = {
...buildRevivedFirstSceneState(deathState),
storyHistory: [
...nextHistory,
createHistoryMoment('你在第一个场景第一幕重新醒来。', 'result'),
],
};
fallbackState = revivedState;
params.setGameState(revivedState);
params.setCurrentStory(buildDeathStory(revivedState));
return;
}
if (
resolvedChoice.optionKind === 'battle' &&
(!nextState.inBattle || nextState.currentNpcBattleOutcome === 'spar_complete')
) {
const postBattleState = buildPostBattleVictoryState(nextState);
const postBattle = buildPostBattleVictoryStory(
postBattleState,
resultText,
params.getAvailableOptionsForState(postBattleState, params.character) ?? [],
);
fallbackState = postBattle.state;
params.setGameState(postBattle.state);
params.setCurrentStory(postBattle.story);
return;
}
const availableOptions = params.getAvailableOptionsForState(
nextState,
params.character,
);
fallbackState = nextState;
params.setGameState(nextState);
params.setCurrentStory(
buildDeterministicStoryForState({
state: nextState,
character: params.character,
resultText,
availableOptions,
buildFallbackStoryForState: params.buildFallbackStoryForState,
}),
);
return;
}
if (responseResult.status === 'rejected') {
throw responseResult.reason;
}

View File

@@ -18,6 +18,12 @@ import {
StoryOption,
} from '../../types';
import { resolveRpgRuntimeChoice } from '.';
import {
buildDeathStory,
buildPostBattleVictoryState,
buildPostBattleVictoryStory,
buildRevivedFirstSceneState,
} from './postBattleFlow';
import type { BattleRewardSummary } from './uiTypes';
type RuntimeStatsIncrements = Partial<
@@ -42,6 +48,8 @@ function sleep(ms: number) {
return new Promise((resolve) => globalThis.setTimeout(resolve, ms));
}
const PLAYER_REVIVE_DELAY_MS = 3000;
export function buildReasonedOptionCatalog(options: StoryOption[]) {
const seenFunctionIds = new Set<string>();
@@ -320,6 +328,44 @@ export async function runServerRuntimeChoiceAction(params: {
turnVisualMs: params.turnVisualMs ?? 820,
});
}
const battle = response?.presentation.battle;
if (battle && hydratedSnapshot.gameState.playerHp <= 0) {
const deathState = {
...hydratedSnapshot.gameState,
animationState: AnimationState.DIE,
playerActionMode: 'idle' as const,
inBattle: false,
activeCombatEffects: [],
scrollWorld: false,
};
params.setGameState(deathState);
await sleep(PLAYER_REVIVE_DELAY_MS);
const revivedState = buildRevivedFirstSceneState(deathState);
params.setGameState(revivedState);
params.setCurrentStory(buildDeathStory(revivedState));
return;
}
if (
battle?.outcome === 'victory' ||
battle?.outcome === 'spar_complete'
) {
const resultText =
response?.presentation.resultText || nextStory.text || params.option.actionText;
const postBattleState = buildPostBattleVictoryState(
hydratedSnapshot.gameState,
);
const postBattle = buildPostBattleVictoryStory(
postBattleState,
resultText,
nextStory.options,
);
params.setGameState(postBattle.state);
params.setCurrentStory(postBattle.story);
return;
}
params.setGameState(hydratedSnapshot.gameState);
params.setCurrentStory(nextStory);
} catch (error) {
@@ -424,4 +470,15 @@ async function playServerBattlePresentation(params: {
playerActionMode: 'idle',
});
await sleep(Math.max(180, Math.round(params.turnVisualMs * 0.45)));
if (params.finalState.playerHp <= 0) {
params.setGameState({
...params.finalState,
animationState: AnimationState.DIE,
playerActionMode: 'idle',
inBattle: false,
activeCombatEffects: [],
scrollWorld: false,
});
}
}

View File

@@ -1,19 +1,37 @@
import type { CustomWorldSceneImageRequest, CustomWorldSceneImageResult } from '../aiTypes';
import {
generateCustomWorldCoverImage,
uploadCustomWorldCoverImage,
} from '../customWorldCoverAssetService';
import { requestJson } from '../apiClient';
import { ASSET_API_PATHS } from '../../editor/shared/editorApiClient';
import type {
CustomWorldLandmark,
CustomWorldNpc,
CustomWorldPlayableNpc,
CustomWorldProfile,
} from '../../types';
import type {
CustomWorldSceneImageRequest,
CustomWorldSceneImageResult,
} from '../aiTypes';
import { requestJson } from '../apiClient';
import {
generateCustomWorldCoverImage,
uploadCustomWorldCoverImage,
} from '../customWorldCoverAssetService';
import { requestRpgCreationPostJson } from './rpgCreationRequestHelpers';
const RPG_CREATION_ASSET_API_BASE = '/api/custom-world';
export type RpgCreationHistoryAssetKind = 'character_visual' | 'scene_image';
export type RpgCreationHistoryAsset = {
assetObjectId: string;
assetKind: RpgCreationHistoryAssetKind;
imageSrc: string;
ownerUserId?: string | null;
ownerLabel: string;
profileId?: string | null;
entityId?: string | null;
createdAt: string;
updatedAt: string;
};
export async function generateRpgWorldSceneImage(
payload: CustomWorldSceneImageRequest,
) {
@@ -28,6 +46,24 @@ export async function generateRpgWorldSceneImage(
);
}
export async function listRpgCreationHistoryAssets(payload: {
kind: RpgCreationHistoryAssetKind;
limit?: number;
}) {
const params = new URLSearchParams({ kind: payload.kind });
if (payload.limit) {
params.set('limit', String(payload.limit));
}
const response = await requestJson<{ assets: RpgCreationHistoryAsset[] }>(
`${ASSET_API_PATHS.assetHistory}?${params.toString()}`,
{ method: 'GET' },
'读取历史素材失败',
);
return response.assets;
}
export async function generateRpgWorldSceneNpc(payload: {
profile: CustomWorldProfile;
landmarkId: string;
@@ -101,6 +137,7 @@ export async function generateRpgWorldLandmark(payload: {
* 保留封面资产服务的既有边界,不把逻辑重新塞回 `aiService.ts`。
*/
export const rpgCreationAssetClient = {
listHistoryAssets: listRpgCreationHistoryAssets,
generateSceneImage: generateRpgWorldSceneImage,
generateSceneNpc: generateRpgWorldSceneNpc,
generatePlayableNpc: generateRpgWorldPlayableNpc,

View File

@@ -202,12 +202,13 @@ test('buildRpgCreationPreviewFromResultPreview normalizes server preview envelop
expect(profile?.settingText).toBe('被海雾吞没的旧航路群岛');
});
test('buildRpgCreationPreviewFromSession prefers server result preview', () => {
test('buildRpgCreationPreviewFromSession prefers agent draft profile', () => {
const profile = buildRpgCreationPreviewFromSession(sessionWithPreview);
expect(profile?.name).toBe('服务端结果预览');
expect(profile?.summary).toBe('结果页应该优先消费 session.resultPreview。');
expect(profile?.id).toBe('preview-profile-1');
expect(profile?.name).toBe('只作为 fallback 的本地草稿名');
expect(profile?.summary).toBe('fallback');
expect(profile?.id).toBe('draft-profile-1');
expect(profile?.playableNpcs[0]?.id).toBe('draft-playable-1');
});
test('buildRpgCreationPreviewFromSession does not require resultPreview', () => {

View File

@@ -15,14 +15,14 @@ export function buildCustomWorldProfileFromAgentSession(
session: CustomWorldAgentSessionSnapshot | null | undefined,
): CustomWorldProfile | null {
return (
buildCustomWorldProfileFromResultPreview(session?.resultPreview) ??
normalizeCustomWorldProfileRecord(session?.draftProfile ?? null)
normalizeCustomWorldProfileRecord(session?.draftProfile ?? null) ??
buildCustomWorldProfileFromResultPreview(session?.resultPreview)
);
}
/**
* 这是工作包 A 提供的新命名兼容层。
* 主入口保持命名稳定,优先消费服务端 resultPreview,缺失时回退到 draftProfile
* 主入口保持命名稳定,优先消费 Agent 草稿真相源,缺失时回退到 resultPreview
*/
export const rpgCreationPreviewAdapter = {
buildPreviewFromSession: buildCustomWorldProfileFromAgentSession,