Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ebc3b28af | ||
|
|
b6600c1f06 | ||
|
|
d0612b399c | ||
|
|
37b3397e4a | ||
|
|
23672e857b | ||
|
|
4831db1763 | ||
|
|
1f935a3206 |
@@ -0,0 +1,7 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
*.ico binary
|
||||
*.png binary
|
||||
*.webp binary
|
||||
*.wav binary
|
||||
*.mp3 binary
|
||||
@@ -1928,10 +1928,12 @@ dependencies = [
|
||||
"nana-engine",
|
||||
"nana-runtime",
|
||||
"nana-store",
|
||||
"openlapp",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -29,7 +29,7 @@ serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.145"
|
||||
sha2 = "0.10.9"
|
||||
thiserror = "2.0.17"
|
||||
tokio = { version = "1.44.2", features = ["rt", "time"] }
|
||||
tokio = { version = "1.44.2", features = ["rt", "sync", "time"] }
|
||||
ts-rs = { version = "11.1.0", features = ["serde-compat"] }
|
||||
|
||||
[workspace.lints.rust]
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
|
||||
## 当前阶段
|
||||
|
||||
M0 契约基线已建立;M1 状态、投影与持久化主链已经接通,M2 正在进入真实模型循环。
|
||||
M0 契约基线已建立;M1 状态、投影与持久化主链已经接通;M2 已具备可取消的真实模型
|
||||
回合,以及面向长分支的上下文检查点与滚动压缩主链。Windows 原生构建基线已经建立,
|
||||
真实在线 LAPP 与视觉验收仍待关闭。
|
||||
|
||||
目前包括:
|
||||
|
||||
@@ -16,26 +18,54 @@ M0 契约基线已建立;M1 状态、投影与持久化主链已经接通,M2
|
||||
- 最小娜娜内容包;
|
||||
- 原子化状态 reducer,覆盖关系、许诺、知识、持有物、时钟与判定;
|
||||
- 保持同一语义的内存与 SQLite StoryStore,支持事务追加、恢复和回溯分叉;
|
||||
- SQLite schema v1、旧库完整性校验、WAL、busy timeout 与未来版本拒绝;
|
||||
- SQLite schema v3,以及从 schema v1 / v2 的无损迁移、旧库完整性校验、WAL、
|
||||
busy timeout 与未来版本拒绝;
|
||||
- 写入与恢复时的状态哈希校验;
|
||||
- `RuntimeState → PlayerView` 脱敏投影与隐藏信息 canary 测试;
|
||||
- `TurnPlan → reducer → SQLite → PlayerView` 单轮提交链;
|
||||
- 随故事节点持久化并恢复 scene、expression、pose、beats 与 suggestions;
|
||||
- 确定性的场景上下文编译器,按固定顺序筛选角色卡、Persona、世界书、剧情事件与可见记忆;
|
||||
- 缓存友好的 prompt schema v3:稳定资源前缀、当前分支检查点 / 原文尾部和动态本轮
|
||||
内容各自分区;模型上下文窗口、输出上限、工具续调用与安全余量统一进入预算;
|
||||
- 长分支超过预算时,由当前选中的同一个 LAPP 模型通过严格的摘要工具滚动压缩;摘要
|
||||
只接受当前分支的玩家安全叙事投影,始终是可删除、可重建的非权威缓存;
|
||||
- 检查点通过稳定前缀与叙事来源 SHA-256 校验,只能沿当前祖先链继承;损坏、过期或
|
||||
来自兄弟分支的缓存不会参与提示词;
|
||||
- 新检查点先作为待提交结果保留,最终与普通节点或重生成节点在同一存储事务中提交;
|
||||
取消、超时、陈旧线路或无效输出不会留下半轮节点或孤立检查点;
|
||||
- 可继续的模型回复会在提交前按同一模型预算验证其“下一轮最新原文”占用;即使输出
|
||||
符合静态 Schema,只要会让后续回合永久无法编排,就不会写入线路;
|
||||
- 内存与 SQLite 后端均覆盖 500 节点祖先链读取和检查点查找,不再沿用界面层的
|
||||
200 节点深度限制;
|
||||
- 引擎掌控的类型化隐藏检定循环,模型只收到定性结果,不能指定或读取骰点、难度与精确数值;
|
||||
- 基于 `lapp-rs` 原生工具消息的 LAPP TurnPlan provider 与严格输出校验;
|
||||
- 从任意当前线路历史节点创建真实持久化分支,旧线路与新线路保持隔离;
|
||||
- 列出、重命名、切换故事线路,并在关闭应用后恢复最后活动线路;
|
||||
- 应用内选择 LAPP profile 中声明了聊天与工具调用能力的模型,凭据仍只由 LAPP
|
||||
Vault 即时解析;
|
||||
- 应用内测试当前已应用模型的最小连接,不接收任意模型目标,也不返回供应商正文;
|
||||
- 回合支持停止生成与 90 秒应用截止时间;取消、超时或无效输出都不能产生半轮节点;
|
||||
- 缺凭据、配置错误、限流、供应商拒绝与网络不可用使用脱敏错误分类,并只允许一次
|
||||
明确的安全重试;
|
||||
- 可完整游玩的“天亮之前”纵切:接受许诺、隐藏搜索、获得车票、进入隧道、天亮前
|
||||
归来并结算许诺;
|
||||
- Turn 请求/结果校验、Fake Provider 与确定性世界书触发;
|
||||
- 可交互的主演出屏、持有物/线索/许诺、关系与回溯面板。
|
||||
|
||||
浏览器模式继续使用确定性的本地 Turn adapter,方便无桌面壳开发;Tauri
|
||||
模式已经通过 `submit_turn` 使用 SQLite 自动保存和恢复,并通过 `fork_branch`
|
||||
创建持久化分支。桌面端默认使用真实 LAPP provider;只有显式设置
|
||||
创建持久化分支。线路选择和名称也保存在同一 SQLite 存档中。桌面端默认使用真实
|
||||
LAPP provider;只有显式设置
|
||||
`NANA_STORY_PROVIDER=demo` 时才启用确定性的娜娜纵切实现。
|
||||
|
||||
Rust 1.96 下的核心测试、Clippy、契约生成检查、Tauri 全 target 类型检查与后端
|
||||
单元测试已经通过。当前 Linux Work 环境缺少 WebKitGTK 等桌面开发库,因此真实
|
||||
桌面窗口启动与 Windows 打包仍需在具备原生依赖的环境补跑。
|
||||
应用内“设置”只选择 LAPP profile 已有模型,不读取、保存或回显 API Key。若 profile
|
||||
缺失,或没有声明 `chat` 与 `tool-call` 能力的启用模型,界面会明确显示不可用。
|
||||
|
||||
Rust 1.96 下的 workspace 共 215 项测试通过:Contracts 1、Domain 5、Engine 21、
|
||||
Runtime 106、Store 65、Tauri 17;全 workspace Clippy、25 份契约 Schema 检查、
|
||||
TypeScript 检查、29 项 Web 测试与生产构建均已通过。Windows 原生构建基线与复现
|
||||
步骤见 [`docs/windows-developer-smoke.md`](docs/windows-developer-smoke.md),本轮
|
||||
不据此宣称真实在线 LAPP 或窗口视觉验收已经完成。
|
||||
|
||||
## 开发
|
||||
|
||||
|
||||
@@ -189,6 +189,26 @@
|
||||
"check_modifier": 1
|
||||
},
|
||||
"hidden_facts": ["电池仓里刻着青川站工作人员的编号"]
|
||||
},
|
||||
{
|
||||
"header": {
|
||||
"id": "nana.item.half_ticket",
|
||||
"kind": "item_spec",
|
||||
"schema_version": 1,
|
||||
"revision": "0.1.0",
|
||||
"content_hash": "sha256:6666666666666666666666666666666666666666666666666666666666666666",
|
||||
"dependencies": []
|
||||
},
|
||||
"name": "半张旧车票",
|
||||
"description": "从检修门缝里找到的受潮车票,背面写着“四点十七分,检修线”。",
|
||||
"tags": ["clue", "ticket", "sister"],
|
||||
"lore_refs": ["station_clock"],
|
||||
"mechanics": {
|
||||
"usable": false,
|
||||
"grants_tags": ["has_sister_clue"],
|
||||
"check_modifier": null
|
||||
},
|
||||
"hidden_facts": ["车票纤维中残留着封锁隧道深处的红色矿尘"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
93d800013b9bec3087490ae629039724076cf26d9bb9a5f8834c6aa7473b1ca7
|
||||
9862200858d46d8e15e10d56972039a85b69853ca35f4e79f19abaab784d91fc
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "BranchList",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"activeBranchId": {
|
||||
"type": "string"
|
||||
},
|
||||
"branches": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/BranchSummary"
|
||||
}
|
||||
},
|
||||
"storyId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"storyId",
|
||||
"activeBranchId",
|
||||
"branches"
|
||||
],
|
||||
"$defs": {
|
||||
"BranchSummary": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"branchId": {
|
||||
"type": "string"
|
||||
},
|
||||
"headLabel": {
|
||||
"type": "string"
|
||||
},
|
||||
"headNodeId": {
|
||||
"type": "string"
|
||||
},
|
||||
"isActive": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"sourceNodeId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"branchId",
|
||||
"name",
|
||||
"headNodeId",
|
||||
"headLabel",
|
||||
"isActive"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "LappConnectionTestResult",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"diagnosticCode": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"modelId": {
|
||||
"type": "string"
|
||||
},
|
||||
"ok": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"providerId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"ok",
|
||||
"providerId",
|
||||
"modelId",
|
||||
"message"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "LappSettings",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"availableModels": {
|
||||
"type": "array",
|
||||
"default": [],
|
||||
"items": {
|
||||
"$ref": "#/$defs/LappModelOption"
|
||||
}
|
||||
},
|
||||
"mode": {
|
||||
"$ref": "#/$defs/LappMode"
|
||||
},
|
||||
"selectedModelId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"selectedProviderId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"statusMessage": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"mode",
|
||||
"statusMessage"
|
||||
],
|
||||
"$defs": {
|
||||
"LappMode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"lapp",
|
||||
"demo",
|
||||
"unavailable"
|
||||
]
|
||||
},
|
||||
"LappModelOption": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"modelId": {
|
||||
"type": "string"
|
||||
},
|
||||
"modelName": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"providerId": {
|
||||
"type": "string"
|
||||
},
|
||||
"providerName": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"providerId",
|
||||
"modelId"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "RenameBranchRequest",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"branchId": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"storyId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"storyId",
|
||||
"branchId",
|
||||
"name"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "SwitchBranchRequest",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"branchId": {
|
||||
"type": "string"
|
||||
},
|
||||
"expectedActiveBranchId": {
|
||||
"type": "string"
|
||||
},
|
||||
"storyId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"storyId",
|
||||
"branchId",
|
||||
"expectedActiveBranchId"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "SwitchBranchResult",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"branchId": {
|
||||
"type": "string"
|
||||
},
|
||||
"playerView": {
|
||||
"$ref": "#/$defs/PlayerView"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"branchId",
|
||||
"playerView"
|
||||
],
|
||||
"$defs": {
|
||||
"ActionSuggestion": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"draft": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"label",
|
||||
"draft"
|
||||
]
|
||||
},
|
||||
"BeatKind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"narration",
|
||||
"dialogue",
|
||||
"action",
|
||||
"system"
|
||||
]
|
||||
},
|
||||
"HistoryNodeView": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"branchId": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"isCurrent": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"parentId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"branchId",
|
||||
"label",
|
||||
"isCurrent"
|
||||
]
|
||||
},
|
||||
"ItemPlacement": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"bag",
|
||||
"worn",
|
||||
"hand",
|
||||
"scene",
|
||||
"hidden"
|
||||
]
|
||||
},
|
||||
"KnowledgeCertainty": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"suspected",
|
||||
"reported",
|
||||
"confirmed"
|
||||
]
|
||||
},
|
||||
"PlayerItemView": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"condition": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"instanceId": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"placement": {
|
||||
"$ref": "#/$defs/ItemPlacement"
|
||||
},
|
||||
"quantity": {
|
||||
"type": "integer",
|
||||
"format": "uint32",
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"instanceId",
|
||||
"name",
|
||||
"description",
|
||||
"quantity",
|
||||
"placement",
|
||||
"condition"
|
||||
]
|
||||
},
|
||||
"PlayerKnowledgeView": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"certainty": {
|
||||
"$ref": "#/$defs/KnowledgeCertainty"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"summary": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"title",
|
||||
"summary",
|
||||
"certainty"
|
||||
]
|
||||
},
|
||||
"PlayerPromiseView": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/$defs/PromiseStatus"
|
||||
},
|
||||
"weight": {
|
||||
"$ref": "#/$defs/PromiseWeight"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"content",
|
||||
"status",
|
||||
"weight"
|
||||
]
|
||||
},
|
||||
"PlayerView": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"beats": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/PresentationBeat"
|
||||
}
|
||||
},
|
||||
"branchId": {
|
||||
"type": "string"
|
||||
},
|
||||
"canContinue": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"characterExpression": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"default": null
|
||||
},
|
||||
"characterName": {
|
||||
"type": "string"
|
||||
},
|
||||
"characterPose": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"default": null
|
||||
},
|
||||
"history": {
|
||||
"type": "array",
|
||||
"default": [],
|
||||
"items": {
|
||||
"$ref": "#/$defs/HistoryNodeView"
|
||||
}
|
||||
},
|
||||
"inventory": {
|
||||
"type": "array",
|
||||
"default": [],
|
||||
"items": {
|
||||
"$ref": "#/$defs/PlayerItemView"
|
||||
}
|
||||
},
|
||||
"knowledge": {
|
||||
"type": "array",
|
||||
"default": [],
|
||||
"items": {
|
||||
"$ref": "#/$defs/PlayerKnowledgeView"
|
||||
}
|
||||
},
|
||||
"nodeId": {
|
||||
"type": "string"
|
||||
},
|
||||
"promises": {
|
||||
"type": "array",
|
||||
"default": [],
|
||||
"items": {
|
||||
"$ref": "#/$defs/PlayerPromiseView"
|
||||
}
|
||||
},
|
||||
"relationship": {
|
||||
"$ref": "#/$defs/RelationshipView"
|
||||
},
|
||||
"sceneId": {
|
||||
"type": "string"
|
||||
},
|
||||
"sceneTitle": {
|
||||
"type": "string"
|
||||
},
|
||||
"storyId": {
|
||||
"type": "string"
|
||||
},
|
||||
"suggestions": {
|
||||
"type": "array",
|
||||
"default": [],
|
||||
"items": {
|
||||
"$ref": "#/$defs/ActionSuggestion"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"storyId",
|
||||
"nodeId",
|
||||
"branchId",
|
||||
"sceneId",
|
||||
"sceneTitle",
|
||||
"characterName",
|
||||
"beats",
|
||||
"relationship",
|
||||
"canContinue"
|
||||
]
|
||||
},
|
||||
"PresentationBeat": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"kind": {
|
||||
"$ref": "#/$defs/BeatKind"
|
||||
},
|
||||
"speaker": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"visual": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/$defs/VisualDirective"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"kind",
|
||||
"text"
|
||||
]
|
||||
},
|
||||
"PromiseStatus": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"proposed",
|
||||
"accepted",
|
||||
"fulfilled",
|
||||
"broken",
|
||||
"released",
|
||||
"impossible"
|
||||
]
|
||||
},
|
||||
"PromiseWeight": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"minor",
|
||||
"major"
|
||||
]
|
||||
},
|
||||
"RelationshipBand": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"distant",
|
||||
"guarded",
|
||||
"warming",
|
||||
"close",
|
||||
"bonded"
|
||||
]
|
||||
},
|
||||
"RelationshipView": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"affinity": {
|
||||
"$ref": "#/$defs/RelationshipBand"
|
||||
},
|
||||
"attachment": {
|
||||
"$ref": "#/$defs/RelationshipBand"
|
||||
},
|
||||
"hope": {
|
||||
"$ref": "#/$defs/RelationshipBand"
|
||||
},
|
||||
"intimacy": {
|
||||
"$ref": "#/$defs/RelationshipBand"
|
||||
},
|
||||
"respect": {
|
||||
"$ref": "#/$defs/RelationshipBand"
|
||||
},
|
||||
"trust": {
|
||||
"$ref": "#/$defs/RelationshipBand"
|
||||
},
|
||||
"updatedAtNode": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"affinity",
|
||||
"trust",
|
||||
"hope",
|
||||
"respect",
|
||||
"intimacy",
|
||||
"attachment"
|
||||
]
|
||||
},
|
||||
"VisualDirective": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"character": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"expression": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"pose": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"scene": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,10 @@
|
||||
"stale_node",
|
||||
"invalid_input",
|
||||
"invalid_model_output",
|
||||
"provider_configuration",
|
||||
"provider_credentials",
|
||||
"provider_rate_limited",
|
||||
"provider_rejected",
|
||||
"provider_unavailable",
|
||||
"cancelled",
|
||||
"timed_out",
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "UpdateLappSettingsRequest",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"modelId": {
|
||||
"type": "string"
|
||||
},
|
||||
"providerId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"providerId",
|
||||
"modelId"
|
||||
]
|
||||
}
|
||||
@@ -120,7 +120,27 @@ export type ForkBranchRequest = { storyId: string, currentBranchId: string, expe
|
||||
|
||||
export type ForkBranchResult = { branchId: string, playerView: PlayerView, };
|
||||
|
||||
export type TurnFailureCode = "stale_node" | "invalid_input" | "invalid_model_output" | "provider_unavailable" | "cancelled" | "timed_out" | "internal";
|
||||
export type BranchSummary = { branchId: string, name: string, headNodeId: string, headLabel: string, sourceNodeId: string | null, isActive: boolean, };
|
||||
|
||||
export type BranchList = { storyId: string, activeBranchId: string, branches: Array<BranchSummary>, };
|
||||
|
||||
export type SwitchBranchRequest = { storyId: string, branchId: string, expectedActiveBranchId: string, };
|
||||
|
||||
export type SwitchBranchResult = { branchId: string, playerView: PlayerView, };
|
||||
|
||||
export type RenameBranchRequest = { storyId: string, branchId: string, name: string, };
|
||||
|
||||
export type LappModelOption = { providerId: string, providerName: string | null, modelId: string, modelName: string | null, };
|
||||
|
||||
export type LappMode = "lapp" | "demo" | "unavailable";
|
||||
|
||||
export type LappSettings = { mode: LappMode, selectedProviderId: string | null, selectedModelId: string | null, availableModels: Array<LappModelOption>, statusMessage: string, };
|
||||
|
||||
export type UpdateLappSettingsRequest = { providerId: string, modelId: string, };
|
||||
|
||||
export type LappConnectionTestResult = { ok: boolean, providerId: string, modelId: string, message: string, diagnosticCode: string | null, };
|
||||
|
||||
export type TurnFailureCode = "stale_node" | "invalid_input" | "invalid_model_output" | "provider_configuration" | "provider_credentials" | "provider_rate_limited" | "provider_rejected" | "provider_unavailable" | "cancelled" | "timed_out" | "internal";
|
||||
|
||||
export type TurnFailure = { code: TurnFailureCode, message: string, retryable: boolean, };
|
||||
|
||||
|
||||
@@ -4,17 +4,19 @@ use std::{
|
||||
};
|
||||
|
||||
use nana_domain::{
|
||||
AcquisitionMode, ActionSuggestion, AppInfo, BeatKind, CharacterCard, CharacterJudgmentRule,
|
||||
CharacterStyle, CheckDifficulty, CheckRecord, CheckResult, ClockState, DemoPackSummary,
|
||||
ForkBranchRequest, ForkBranchResult, HistoryNodeView, ItemAcquisition, ItemInstance,
|
||||
ItemMechanics, ItemPlacement, ItemSpec, KnowledgeCertainty, KnowledgeRecord, Persona,
|
||||
PlayerItemView, PlayerKnowledgeView, PlayerPromiseView, PlayerView, PlotEvent, PlotModule,
|
||||
PlotOutcome, PlotPressure, PresentationBeat, PresentationCharacter, PresentationScene,
|
||||
PresentationSnapshot, Promise, PromiseStatus, PromiseWeight, RelationshipAdjustment,
|
||||
RelationshipAxes, RelationshipBand, RelationshipDimension, RelationshipState, RelationshipView,
|
||||
ResourceBundle, ResourceHeader, ResourceId, ResourceKind, ResourceRef, RuntimeState,
|
||||
SkillValue, StateDelta, StateOp, Story, StoryBinding, StoryNode, TurnFailure, TurnFailureCode,
|
||||
TurnIntent, TurnRequest, TurnResult, ValidationCode, ValidationIssue, ValidationReport,
|
||||
AcquisitionMode, ActionSuggestion, AppInfo, BeatKind, BranchList, BranchSummary, CharacterCard,
|
||||
CharacterJudgmentRule, CharacterStyle, CheckDifficulty, CheckRecord, CheckResult, ClockState,
|
||||
DemoPackSummary, ForkBranchRequest, ForkBranchResult, HistoryNodeView, ItemAcquisition,
|
||||
ItemInstance, ItemMechanics, ItemPlacement, ItemSpec, KnowledgeCertainty, KnowledgeRecord,
|
||||
LappConnectionTestResult, LappMode, LappModelOption, LappSettings, Persona, PlayerItemView,
|
||||
PlayerKnowledgeView, PlayerPromiseView, PlayerView, PlotEvent, PlotModule, PlotOutcome,
|
||||
PlotPressure, PresentationBeat, PresentationCharacter, PresentationScene, PresentationSnapshot,
|
||||
Promise, PromiseStatus, PromiseWeight, RelationshipAdjustment, RelationshipAxes,
|
||||
RelationshipBand, RelationshipDimension, RelationshipState, RelationshipView,
|
||||
RenameBranchRequest, ResourceBundle, ResourceHeader, ResourceId, ResourceKind, ResourceRef,
|
||||
RuntimeState, SkillValue, StateDelta, StateOp, Story, StoryBinding, StoryNode,
|
||||
SwitchBranchRequest, SwitchBranchResult, TurnFailure, TurnFailureCode, TurnIntent, TurnRequest,
|
||||
TurnResult, UpdateLappSettingsRequest, ValidationCode, ValidationIssue, ValidationReport,
|
||||
VisualDirective, WorldBook, WorldBookEntry,
|
||||
};
|
||||
use schemars::{JsonSchema, schema_for};
|
||||
@@ -33,7 +35,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if check {
|
||||
let mut stale = Vec::new();
|
||||
for (path, expected) in outputs {
|
||||
if fs::read(&path).ok().as_deref() != Some(expected.as_slice()) {
|
||||
let matches = fs::read(&path).ok().is_some_and(|actual| {
|
||||
normalize_line_endings(&actual) == normalize_line_endings(&expected)
|
||||
});
|
||||
if !matches {
|
||||
stale.push(path);
|
||||
}
|
||||
}
|
||||
@@ -55,6 +60,23 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalize_line_endings(bytes: &[u8]) -> Vec<u8> {
|
||||
let mut normalized = Vec::with_capacity(bytes.len());
|
||||
let mut index = 0;
|
||||
|
||||
while index < bytes.len() {
|
||||
if bytes[index] == b'\r' && bytes.get(index + 1) == Some(&b'\n') {
|
||||
normalized.push(b'\n');
|
||||
index += 2;
|
||||
} else {
|
||||
normalized.push(bytes[index]);
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
normalized
|
||||
}
|
||||
|
||||
fn generated_outputs(root: &Path) -> Result<GeneratedOutputs, Box<dyn std::error::Error>> {
|
||||
let mut outputs = Vec::new();
|
||||
let schema_dir = root.join("contracts/schema");
|
||||
@@ -74,6 +96,21 @@ fn generated_outputs(root: &Path) -> Result<GeneratedOutputs, Box<dyn std::error
|
||||
add_schema::<TurnResult>(&mut outputs, &schema_dir, "turn-result")?;
|
||||
add_schema::<ForkBranchRequest>(&mut outputs, &schema_dir, "fork-branch-request")?;
|
||||
add_schema::<ForkBranchResult>(&mut outputs, &schema_dir, "fork-branch-result")?;
|
||||
add_schema::<BranchList>(&mut outputs, &schema_dir, "branch-list")?;
|
||||
add_schema::<SwitchBranchRequest>(&mut outputs, &schema_dir, "switch-branch-request")?;
|
||||
add_schema::<SwitchBranchResult>(&mut outputs, &schema_dir, "switch-branch-result")?;
|
||||
add_schema::<RenameBranchRequest>(&mut outputs, &schema_dir, "rename-branch-request")?;
|
||||
add_schema::<LappSettings>(&mut outputs, &schema_dir, "lapp-settings")?;
|
||||
add_schema::<UpdateLappSettingsRequest>(
|
||||
&mut outputs,
|
||||
&schema_dir,
|
||||
"update-lapp-settings-request",
|
||||
)?;
|
||||
add_schema::<LappConnectionTestResult>(
|
||||
&mut outputs,
|
||||
&schema_dir,
|
||||
"lapp-connection-test-result",
|
||||
)?;
|
||||
add_schema::<TurnFailure>(&mut outputs, &schema_dir, "turn-failure")?;
|
||||
add_schema::<AppInfo>(&mut outputs, &schema_dir, "app-info")?;
|
||||
add_schema::<DemoPackSummary>(&mut outputs, &schema_dir, "demo-pack-summary")?;
|
||||
@@ -83,7 +120,10 @@ fn generated_outputs(root: &Path) -> Result<GeneratedOutputs, Box<dyn std::error
|
||||
outputs.push((root.join("contracts/ts/index.ts"), ts.into_bytes()));
|
||||
|
||||
let domain_source = fs::read(root.join("crates/nana-domain/src/lib.rs"))?;
|
||||
let source_hash = format!("{:x}\n", Sha256::digest(domain_source));
|
||||
let source_hash = format!(
|
||||
"{:x}\n",
|
||||
Sha256::digest(normalize_line_endings(&domain_source))
|
||||
);
|
||||
outputs.push((
|
||||
root.join("contracts/.source.sha256"),
|
||||
source_hash.into_bytes(),
|
||||
@@ -154,6 +194,16 @@ fn generated_declarations() -> String {
|
||||
TurnResult::decl(),
|
||||
ForkBranchRequest::decl(),
|
||||
ForkBranchResult::decl(),
|
||||
BranchSummary::decl(),
|
||||
BranchList::decl(),
|
||||
SwitchBranchRequest::decl(),
|
||||
SwitchBranchResult::decl(),
|
||||
RenameBranchRequest::decl(),
|
||||
LappModelOption::decl(),
|
||||
LappMode::decl(),
|
||||
LappSettings::decl(),
|
||||
UpdateLappSettingsRequest::decl(),
|
||||
LappConnectionTestResult::decl(),
|
||||
TurnFailureCode::decl(),
|
||||
TurnFailure::decl(),
|
||||
AppInfo::decl(),
|
||||
@@ -178,3 +228,20 @@ fn add_schema<T: JsonSchema + Serialize>(
|
||||
outputs.push((schema_dir.join(format!("{name}.schema.json")), bytes));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::normalize_line_endings;
|
||||
|
||||
#[test]
|
||||
fn source_hash_input_is_independent_of_checkout_line_endings() {
|
||||
assert_eq!(
|
||||
normalize_line_endings(b"first\r\nsecond\nthird\r"),
|
||||
b"first\nsecond\nthird\r"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_line_endings(b"first\nsecond\nthird\r"),
|
||||
b"first\nsecond\nthird\r"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -745,6 +745,102 @@ pub struct ForkBranchResult {
|
||||
pub player_view: PlayerView,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
pub struct BranchSummary {
|
||||
pub branch_id: String,
|
||||
pub name: String,
|
||||
pub head_node_id: String,
|
||||
pub head_label: String,
|
||||
pub source_node_id: Option<String>,
|
||||
pub is_active: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
pub struct BranchList {
|
||||
pub story_id: String,
|
||||
pub active_branch_id: String,
|
||||
pub branches: Vec<BranchSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
pub struct SwitchBranchRequest {
|
||||
pub story_id: String,
|
||||
pub branch_id: String,
|
||||
pub expected_active_branch_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
pub struct SwitchBranchResult {
|
||||
pub branch_id: String,
|
||||
pub player_view: PlayerView,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
pub struct RenameBranchRequest {
|
||||
pub story_id: String,
|
||||
pub branch_id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
pub struct LappModelOption {
|
||||
pub provider_id: String,
|
||||
pub provider_name: Option<String>,
|
||||
pub model_id: String,
|
||||
pub model_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum LappMode {
|
||||
Lapp,
|
||||
Demo,
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
pub struct LappSettings {
|
||||
pub mode: LappMode,
|
||||
pub selected_provider_id: Option<String>,
|
||||
pub selected_model_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub available_models: Vec<LappModelOption>,
|
||||
pub status_message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
pub struct UpdateLappSettingsRequest {
|
||||
pub provider_id: String,
|
||||
pub model_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
pub struct LappConnectionTestResult {
|
||||
pub ok: bool,
|
||||
pub provider_id: String,
|
||||
pub model_id: String,
|
||||
pub message: String,
|
||||
pub diagnostic_code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
@@ -759,6 +855,10 @@ pub enum TurnFailureCode {
|
||||
StaleNode,
|
||||
InvalidInput,
|
||||
InvalidModelOutput,
|
||||
ProviderConfiguration,
|
||||
ProviderCredentials,
|
||||
ProviderRateLimited,
|
||||
ProviderRejected,
|
||||
ProviderUnavailable,
|
||||
Cancelled,
|
||||
TimedOut,
|
||||
|
||||
@@ -2,15 +2,23 @@ use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use nana_domain::{
|
||||
CharacterCard, CheckDifficulty, CheckRecord, CheckResult, ItemPlacement, ItemSpec, Persona,
|
||||
ResourceBundle, RuntimeState, StateOp, TurnIntent, TurnRequest, stable_json_hash,
|
||||
ResourceBundle, RuntimeState, StateOp, StoryNode, TurnIntent, TurnRequest, stable_json_hash,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{InvalidModelOutputKind, ProviderError, TurnPlan, TurnPlanProvider};
|
||||
use crate::{
|
||||
BranchHistoryProjection, InvalidModelOutputKind, NarrativeCheckpoint, ProviderError,
|
||||
TurnContextPreparation, TurnControl, TurnPlan, TurnPlanProvider, provider_interruption,
|
||||
};
|
||||
|
||||
pub const HIDDEN_CHECK_TOOL_NAME: &str = "request_hidden_check";
|
||||
pub const DEFAULT_MAX_ADJUDICATION_STEPS: usize = 4;
|
||||
/// V1 permits one authoritative check before the final turn plan.
|
||||
///
|
||||
/// Keeping this limit explicit makes the continuation budget bounded and
|
||||
/// keeps one player action from silently turning into several unrelated rolls.
|
||||
pub const MAX_HIDDEN_CHECKS_PER_TURN: usize = 1;
|
||||
pub const DEFAULT_MAX_ADJUDICATION_STEPS: usize = MAX_HIDDEN_CHECKS_PER_TURN + 1;
|
||||
|
||||
/// A typed hidden-check request proposed by the narrative model.
|
||||
///
|
||||
@@ -105,6 +113,91 @@ pub trait AdjudicationModel {
|
||||
&mut self,
|
||||
input: AdjudicationModelInput<'_>,
|
||||
) -> Result<AdjudicationModelResponse, ProviderError>;
|
||||
|
||||
/// Respond with caller-selected safe branch history.
|
||||
///
|
||||
/// Existing models remain compatible and may ignore it. Narrative adapters
|
||||
/// should override this instead of loading branch nodes themselves.
|
||||
fn respond_with_history(
|
||||
&mut self,
|
||||
input: AdjudicationModelInput<'_>,
|
||||
branch_history: &BranchHistoryProjection,
|
||||
) -> Result<AdjudicationModelResponse, ProviderError> {
|
||||
let _ = branch_history;
|
||||
self.respond(input)
|
||||
}
|
||||
|
||||
/// Respond while observing the outer turn lifecycle.
|
||||
///
|
||||
/// Existing deterministic models remain source-compatible. Network-backed
|
||||
/// adapters should override this to interrupt their in-flight operation.
|
||||
fn respond_with_control(
|
||||
&mut self,
|
||||
input: AdjudicationModelInput<'_>,
|
||||
control: &TurnControl,
|
||||
) -> Result<AdjudicationModelResponse, ProviderError> {
|
||||
if let Some(interruption) = control.interruption() {
|
||||
return Err(provider_interruption(interruption));
|
||||
}
|
||||
let result = self.respond(input);
|
||||
if let Some(interruption) = control.interruption() {
|
||||
Err(provider_interruption(interruption))
|
||||
} else {
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// History-aware controlled response. The compatibility default keeps a
|
||||
/// model's existing cancellation behavior and ignores history.
|
||||
fn respond_with_history_and_control(
|
||||
&mut self,
|
||||
input: AdjudicationModelInput<'_>,
|
||||
branch_history: &BranchHistoryProjection,
|
||||
control: &TurnControl,
|
||||
) -> Result<AdjudicationModelResponse, ProviderError> {
|
||||
let _ = branch_history;
|
||||
self.respond_with_control(input, control)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
fn uses_context_checkpoints(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn prepare_turn_context_with_control(
|
||||
&mut self,
|
||||
request: &TurnRequest,
|
||||
state: &RuntimeState,
|
||||
source_nodes: &[StoryNode],
|
||||
expected_history_head_node_id: &str,
|
||||
checkpoint: Option<&NarrativeCheckpoint>,
|
||||
control: &TurnControl,
|
||||
) -> Result<TurnContextPreparation, ProviderError> {
|
||||
let _ = (
|
||||
request,
|
||||
state,
|
||||
source_nodes,
|
||||
expected_history_head_node_id,
|
||||
checkpoint,
|
||||
);
|
||||
if let Some(interruption) = control.interruption() {
|
||||
return Err(provider_interruption(interruption));
|
||||
}
|
||||
Ok(TurnContextPreparation::Unmanaged)
|
||||
}
|
||||
|
||||
fn validate_prospective_context_with_control(
|
||||
&mut self,
|
||||
state: &RuntimeState,
|
||||
node: &StoryNode,
|
||||
control: &TurnControl,
|
||||
) -> Result<(), ProviderError> {
|
||||
let _ = (state, node);
|
||||
if let Some(interruption) = control.interruption() {
|
||||
return Err(provider_interruption(interruption));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
@@ -145,8 +238,12 @@ pub enum AdjudicationError {
|
||||
DuplicateItem(String),
|
||||
#[error("check id was already used: {0}")]
|
||||
DuplicateCheckId(String),
|
||||
#[error("one player action may request at most one hidden check")]
|
||||
TooManyHiddenChecks,
|
||||
#[error("model supplied a RecordCheck state operation")]
|
||||
ModelSuppliedRecordCheck,
|
||||
#[error("regeneration requested a new hidden check")]
|
||||
RegenerationRequestedCheck,
|
||||
#[error("adjudication exceeded its tool-step budget")]
|
||||
StepBudgetExceeded,
|
||||
#[error("pushed check did not match the player-authorized failed check")]
|
||||
@@ -376,6 +473,11 @@ impl<Model> AdjudicatingTurnPlanProvider<Model> {
|
||||
&self.model
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn model_mut(&mut self) -> &mut Model {
|
||||
&mut self.model
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn into_model(self) -> Model {
|
||||
self.model
|
||||
@@ -387,6 +489,50 @@ impl<Model: AdjudicationModel> AdjudicatingTurnPlanProvider<Model> {
|
||||
&mut self,
|
||||
request: &TurnRequest,
|
||||
state: &RuntimeState,
|
||||
) -> Result<TurnPlan, AdjudicationRunError> {
|
||||
self.plan_adjudicated_turn_with_history_and_control(
|
||||
request,
|
||||
state,
|
||||
&BranchHistoryProjection::default(),
|
||||
&TurnControl::new(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn plan_adjudicated_turn_with_history(
|
||||
&mut self,
|
||||
request: &TurnRequest,
|
||||
state: &RuntimeState,
|
||||
branch_history: &BranchHistoryProjection,
|
||||
) -> Result<TurnPlan, AdjudicationRunError> {
|
||||
self.plan_adjudicated_turn_with_history_and_control(
|
||||
request,
|
||||
state,
|
||||
branch_history,
|
||||
&TurnControl::new(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Run the model/check/model loop without persisting any partial result.
|
||||
pub fn plan_adjudicated_turn_with_control(
|
||||
&mut self,
|
||||
request: &TurnRequest,
|
||||
state: &RuntimeState,
|
||||
control: &TurnControl,
|
||||
) -> Result<TurnPlan, AdjudicationRunError> {
|
||||
self.plan_adjudicated_turn_with_history_and_control(
|
||||
request,
|
||||
state,
|
||||
&BranchHistoryProjection::default(),
|
||||
control,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn plan_adjudicated_turn_with_history_and_control(
|
||||
&mut self,
|
||||
request: &TurnRequest,
|
||||
state: &RuntimeState,
|
||||
branch_history: &BranchHistoryProjection,
|
||||
control: &TurnControl,
|
||||
) -> Result<TurnPlan, AdjudicationRunError> {
|
||||
let mut records = Vec::new();
|
||||
let mut last_outcome = None;
|
||||
@@ -401,13 +547,21 @@ impl<Model: AdjudicationModel> AdjudicatingTurnPlanProvider<Model> {
|
||||
AdjudicationModelInput::BeginTurn { request, state },
|
||||
AdjudicationModelInput::CheckResolved,
|
||||
);
|
||||
let response = self.model.respond(input)?;
|
||||
let response =
|
||||
self.model
|
||||
.respond_with_history_and_control(input, branch_history, control)?;
|
||||
let tool_call = exactly_one_tool(response)?;
|
||||
match tool_call {
|
||||
AdjudicationToolCall::RequestHiddenCheck(proposed) => {
|
||||
if matches!(request.intent, TurnIntent::Regenerate) {
|
||||
return Err(AdjudicationError::RegenerationRequestedCheck.into());
|
||||
}
|
||||
if !check_ids.insert(proposed.check_id.clone()) {
|
||||
return Err(AdjudicationError::DuplicateCheckId(proposed.check_id).into());
|
||||
}
|
||||
if records.len() >= MAX_HIDDEN_CHECKS_PER_TURN {
|
||||
return Err(AdjudicationError::TooManyHiddenChecks.into());
|
||||
}
|
||||
if matches!(request.intent, TurnIntent::PushCheck) && !records.is_empty() {
|
||||
return Err(AdjudicationError::PushedCheckMismatch.into());
|
||||
}
|
||||
@@ -464,6 +618,85 @@ impl<Model: AdjudicationModel> TurnPlanProvider for AdjudicatingTurnPlanProvider
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn plan_turn_with_control(
|
||||
&mut self,
|
||||
request: &TurnRequest,
|
||||
state: &RuntimeState,
|
||||
control: &TurnControl,
|
||||
) -> Result<TurnPlan, ProviderError> {
|
||||
self.plan_adjudicated_turn_with_control(request, state, control)
|
||||
.map_err(|error| match error {
|
||||
AdjudicationRunError::Provider(error) => error,
|
||||
AdjudicationRunError::Rejected(_) => ProviderError::InvalidModelOutput {
|
||||
kind: InvalidModelOutputKind::InvalidPlan,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn plan_turn_with_history(
|
||||
&mut self,
|
||||
request: &TurnRequest,
|
||||
state: &RuntimeState,
|
||||
branch_history: &BranchHistoryProjection,
|
||||
) -> Result<TurnPlan, ProviderError> {
|
||||
self.plan_adjudicated_turn_with_history(request, state, branch_history)
|
||||
.map_err(|error| match error {
|
||||
AdjudicationRunError::Provider(error) => error,
|
||||
AdjudicationRunError::Rejected(_) => ProviderError::InvalidModelOutput {
|
||||
kind: InvalidModelOutputKind::InvalidPlan,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn plan_turn_with_history_and_control(
|
||||
&mut self,
|
||||
request: &TurnRequest,
|
||||
state: &RuntimeState,
|
||||
branch_history: &BranchHistoryProjection,
|
||||
control: &TurnControl,
|
||||
) -> Result<TurnPlan, ProviderError> {
|
||||
self.plan_adjudicated_turn_with_history_and_control(request, state, branch_history, control)
|
||||
.map_err(|error| match error {
|
||||
AdjudicationRunError::Provider(error) => error,
|
||||
AdjudicationRunError::Rejected(_) => ProviderError::InvalidModelOutput {
|
||||
kind: InvalidModelOutputKind::InvalidPlan,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn uses_context_checkpoints(&self) -> bool {
|
||||
self.model.uses_context_checkpoints()
|
||||
}
|
||||
|
||||
fn prepare_turn_context_with_control(
|
||||
&mut self,
|
||||
request: &TurnRequest,
|
||||
state: &RuntimeState,
|
||||
source_nodes: &[StoryNode],
|
||||
expected_history_head_node_id: &str,
|
||||
checkpoint: Option<&NarrativeCheckpoint>,
|
||||
control: &TurnControl,
|
||||
) -> Result<TurnContextPreparation, ProviderError> {
|
||||
self.model.prepare_turn_context_with_control(
|
||||
request,
|
||||
state,
|
||||
source_nodes,
|
||||
expected_history_head_node_id,
|
||||
checkpoint,
|
||||
control,
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_prospective_context_with_control(
|
||||
&mut self,
|
||||
state: &RuntimeState,
|
||||
node: &StoryNode,
|
||||
control: &TurnControl,
|
||||
) -> Result<(), ProviderError> {
|
||||
self.model
|
||||
.validate_prospective_context_with_control(state, node, control)
|
||||
}
|
||||
}
|
||||
|
||||
fn select_bound_actor<'a, T>(
|
||||
@@ -1071,7 +1304,22 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_distinct_checks_are_buffered_until_one_final_plan() {
|
||||
fn regeneration_cannot_request_a_new_hidden_check() {
|
||||
let model = ScriptedModel::new([tool(AdjudicationToolCall::RequestHiddenCheck(
|
||||
hidden_check("replacement_check"),
|
||||
))]);
|
||||
let mut provider = AdjudicatingTurnPlanProvider::new(model, catalog());
|
||||
|
||||
assert!(matches!(
|
||||
provider.plan_adjudicated_turn(&request(TurnIntent::Regenerate), &state()),
|
||||
Err(AdjudicationRunError::Rejected(
|
||||
AdjudicationError::RegenerationRequestedCheck
|
||||
))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_distinct_check_is_rejected_for_one_player_action() {
|
||||
let mut second = hidden_check("check_2");
|
||||
second.actor_id = "nana".into();
|
||||
second.skill = "Listen".into();
|
||||
@@ -1085,19 +1333,12 @@ mod tests {
|
||||
]);
|
||||
let mut provider = AdjudicatingTurnPlanProvider::new(model, catalog());
|
||||
|
||||
let planned = provider
|
||||
.plan_adjudicated_turn(&request(TurnIntent::SpeakOrAct), &state())
|
||||
.expect("two checks then one plan");
|
||||
let ids = planned
|
||||
.delta
|
||||
.ops
|
||||
.iter()
|
||||
.filter_map(|op| match op {
|
||||
StateOp::RecordCheck { check } => Some(check.id.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(ids, ["check_1", "check_2"]);
|
||||
assert!(matches!(
|
||||
provider.plan_adjudicated_turn(&request(TurnIntent::SpeakOrAct), &state()),
|
||||
Err(AdjudicationRunError::Rejected(
|
||||
AdjudicationError::TooManyHiddenChecks
|
||||
))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1371,7 +1612,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loop_stops_at_the_configured_step_budget() {
|
||||
fn loop_stops_before_a_second_hidden_check_even_with_a_larger_step_budget() {
|
||||
let mut provider = AdjudicatingTurnPlanProvider::with_max_steps(
|
||||
ScriptedModel::new([
|
||||
tool(AdjudicationToolCall::RequestHiddenCheck(hidden_check(
|
||||
@@ -1382,12 +1623,12 @@ mod tests {
|
||||
))),
|
||||
]),
|
||||
catalog(),
|
||||
2,
|
||||
3,
|
||||
);
|
||||
assert!(matches!(
|
||||
provider.plan_adjudicated_turn(&request(TurnIntent::SpeakOrAct), &state()),
|
||||
Err(AdjudicationRunError::Rejected(
|
||||
AdjudicationError::StepBudgetExceeded
|
||||
AdjudicationError::TooManyHiddenChecks
|
||||
))
|
||||
));
|
||||
}
|
||||
@@ -1492,13 +1733,17 @@ mod tests {
|
||||
#[test]
|
||||
fn model_failures_are_forwarded_and_rejections_are_redacted_by_provider_trait() {
|
||||
let mut unavailable = AdjudicatingTurnPlanProvider::new(
|
||||
ScriptedModel::new([Err(ProviderError::Upstream { code: None })]),
|
||||
ScriptedModel::new([Err(ProviderError::Upstream {
|
||||
code: None,
|
||||
status: None,
|
||||
})]),
|
||||
catalog(),
|
||||
);
|
||||
assert!(matches!(
|
||||
unavailable.plan_adjudicated_turn(&request(TurnIntent::SpeakOrAct), &state()),
|
||||
Err(AdjudicationRunError::Provider(ProviderError::Upstream {
|
||||
code: None
|
||||
code: None,
|
||||
status: None
|
||||
}))
|
||||
));
|
||||
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use nana_store::{
|
||||
CanonicalSha256, ContextCheckpointError, ContextCheckpointInput, StoredContextCheckpoint,
|
||||
};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::context::{
|
||||
MAX_NARRATIVE_CHECKPOINT_SUMMARY_BYTES, NarrativeCheckpoint, NarrativeCheckpointSourceHash,
|
||||
NarrativeCheckpointSummary, StablePrefixHash, SummaryClassification,
|
||||
};
|
||||
|
||||
/// A strict runtime-to-storage mapping failure.
|
||||
///
|
||||
/// Loading is deliberately different: a malformed disposable cache row is a
|
||||
/// cache miss, not a story/storage failure. Saving a freshly produced runtime
|
||||
/// checkpoint must instead explain why it could not cross the storage
|
||||
/// boundary.
|
||||
#[derive(Debug, Error, PartialEq, Eq)]
|
||||
pub enum CheckpointStoreMappingError {
|
||||
#[error("context checkpoint summary has the wrong classification")]
|
||||
InvalidSummaryClassification,
|
||||
#[error("context checkpoint summary is blank")]
|
||||
BlankSummary,
|
||||
#[error("context checkpoint summary exceeds the runtime byte limit")]
|
||||
SummaryTooLarge,
|
||||
#[error("context checkpoint summary could not be serialized")]
|
||||
SummarySerialization,
|
||||
#[error("context checkpoint stable-prefix hash is not canonical")]
|
||||
InvalidStablePrefixHash,
|
||||
#[error("context checkpoint source hash is not canonical")]
|
||||
InvalidSourceHash,
|
||||
#[error("context checkpoint could not be represented by the store: {0}")]
|
||||
InvalidStoredCheckpoint(ContextCheckpointError),
|
||||
}
|
||||
|
||||
/// Converts a structurally loaded storage record into a runtime checkpoint.
|
||||
///
|
||||
/// Checkpoints are only a disposable optimization. Any malformed JSON,
|
||||
/// classification, summary, or typed hash is therefore treated as a cache
|
||||
/// miss. In particular, this function never turns corrupt cache contents into
|
||||
/// a [`nana_store::StoreError`].
|
||||
#[must_use]
|
||||
pub fn runtime_checkpoint_from_stored(
|
||||
stored: &StoredContextCheckpoint,
|
||||
) -> Option<NarrativeCheckpoint> {
|
||||
runtime_checkpoint_from_parts(StoredCheckpointParts {
|
||||
story_id: stored.story_id(),
|
||||
at_node_id: stored.at_node_id(),
|
||||
covered_through_node_id: stored.covered_through_node_id(),
|
||||
retained_from_node_id: stored.retained_from_node_id(),
|
||||
checkpoint_schema_version: stored.checkpoint_schema_version(),
|
||||
prompt_schema_version: stored.prompt_schema_version(),
|
||||
stable_prefix_hash: stored.stable_prefix_hash().as_str(),
|
||||
summary_json: stored.summary_json(),
|
||||
source_hash: stored.source_hash().as_str(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Converts a trusted runtime checkpoint into the store's checked cache type.
|
||||
///
|
||||
/// Only [`NarrativeCheckpointSummary`] is serialized into `summary_json`; range
|
||||
/// metadata and hashes stay in their dedicated store columns.
|
||||
pub fn stored_checkpoint_from_runtime(
|
||||
checkpoint: &NarrativeCheckpoint,
|
||||
) -> Result<StoredContextCheckpoint, CheckpointStoreMappingError> {
|
||||
validate_summary(&checkpoint.summary)?;
|
||||
|
||||
let summary_json = serde_json::to_string(&checkpoint.summary)
|
||||
.map_err(|_| CheckpointStoreMappingError::SummarySerialization)?;
|
||||
let stable_prefix_hash = CanonicalSha256::from_str(checkpoint.stable_prefix_hash.as_str())
|
||||
.map_err(|_| CheckpointStoreMappingError::InvalidStablePrefixHash)?;
|
||||
let source_hash = CanonicalSha256::from_str(checkpoint.source_hash.as_str())
|
||||
.map_err(|_| CheckpointStoreMappingError::InvalidSourceHash)?;
|
||||
|
||||
StoredContextCheckpoint::new(ContextCheckpointInput {
|
||||
story_id: checkpoint.story_id.clone(),
|
||||
at_node_id: checkpoint.at_node_id.clone(),
|
||||
covered_through_node_id: checkpoint.covered_through_node_id.clone(),
|
||||
retained_from_node_id: checkpoint.retained_from_node_id.clone(),
|
||||
checkpoint_schema_version: checkpoint.checkpoint_schema_version,
|
||||
prompt_schema_version: checkpoint.prompt_schema_version,
|
||||
stable_prefix_hash,
|
||||
summary_json,
|
||||
source_hash,
|
||||
})
|
||||
.map_err(CheckpointStoreMappingError::InvalidStoredCheckpoint)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct StoredCheckpointParts<'a> {
|
||||
story_id: &'a str,
|
||||
at_node_id: &'a str,
|
||||
covered_through_node_id: &'a str,
|
||||
retained_from_node_id: Option<&'a str>,
|
||||
checkpoint_schema_version: u32,
|
||||
prompt_schema_version: u32,
|
||||
stable_prefix_hash: &'a str,
|
||||
summary_json: &'a str,
|
||||
source_hash: &'a str,
|
||||
}
|
||||
|
||||
fn runtime_checkpoint_from_parts(parts: StoredCheckpointParts<'_>) -> Option<NarrativeCheckpoint> {
|
||||
let summary = serde_json::from_str::<NarrativeCheckpointSummary>(parts.summary_json).ok()?;
|
||||
validate_summary(&summary).ok()?;
|
||||
let stable_prefix_hash =
|
||||
StablePrefixHash::try_from(parts.stable_prefix_hash.to_owned()).ok()?;
|
||||
let source_hash = NarrativeCheckpointSourceHash::try_from(parts.source_hash.to_owned()).ok()?;
|
||||
|
||||
Some(NarrativeCheckpoint {
|
||||
story_id: parts.story_id.to_owned(),
|
||||
at_node_id: parts.at_node_id.to_owned(),
|
||||
covered_through_node_id: parts.covered_through_node_id.to_owned(),
|
||||
retained_from_node_id: parts.retained_from_node_id.map(str::to_owned),
|
||||
checkpoint_schema_version: parts.checkpoint_schema_version,
|
||||
prompt_schema_version: parts.prompt_schema_version,
|
||||
stable_prefix_hash,
|
||||
summary,
|
||||
source_hash,
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_summary(
|
||||
summary: &NarrativeCheckpointSummary,
|
||||
) -> Result<(), CheckpointStoreMappingError> {
|
||||
if summary.classification != SummaryClassification::NonAuthoritativeNarrative {
|
||||
return Err(CheckpointStoreMappingError::InvalidSummaryClassification);
|
||||
}
|
||||
if summary.text.trim().is_empty() {
|
||||
return Err(CheckpointStoreMappingError::BlankSummary);
|
||||
}
|
||||
if summary.text.len() > MAX_NARRATIVE_CHECKPOINT_SUMMARY_BYTES {
|
||||
return Err(CheckpointStoreMappingError::SummaryTooLarge);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
CheckpointStoreMappingError, StoredCheckpointParts, runtime_checkpoint_from_parts,
|
||||
runtime_checkpoint_from_stored, stored_checkpoint_from_runtime,
|
||||
};
|
||||
use crate::context::{
|
||||
MAX_NARRATIVE_CHECKPOINT_SUMMARY_BYTES, NARRATIVE_CHECKPOINT_SCHEMA_VERSION,
|
||||
NarrativeCheckpoint, NarrativeCheckpointSourceHash, NarrativeCheckpointSummary,
|
||||
SCENE_PROMPT_SCHEMA_VERSION, StablePrefixHash, SummaryClassification,
|
||||
};
|
||||
|
||||
const HASH_A: &str = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
const HASH_B: &str = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
||||
|
||||
fn runtime_checkpoint() -> NarrativeCheckpoint {
|
||||
NarrativeCheckpoint {
|
||||
story_id: "story_demo".to_owned(),
|
||||
at_node_id: "node_004".to_owned(),
|
||||
covered_through_node_id: "node_002".to_owned(),
|
||||
retained_from_node_id: Some("node_003".to_owned()),
|
||||
checkpoint_schema_version: NARRATIVE_CHECKPOINT_SCHEMA_VERSION,
|
||||
prompt_schema_version: SCENE_PROMPT_SCHEMA_VERSION,
|
||||
stable_prefix_hash: StablePrefixHash::try_from(HASH_A.to_owned())
|
||||
.expect("valid stable-prefix hash"),
|
||||
summary: NarrativeCheckpointSummary {
|
||||
classification: SummaryClassification::NonAuthoritativeNarrative,
|
||||
text: "娜娜记得玩家答应在天亮前回来。".to_owned(),
|
||||
},
|
||||
source_hash: NarrativeCheckpointSourceHash::try_from(HASH_B.to_owned())
|
||||
.expect("valid source hash"),
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_parts(summary_json: &str) -> StoredCheckpointParts<'_> {
|
||||
StoredCheckpointParts {
|
||||
story_id: "story_demo",
|
||||
at_node_id: "node_004",
|
||||
covered_through_node_id: "node_002",
|
||||
retained_from_node_id: Some("node_003"),
|
||||
checkpoint_schema_version: NARRATIVE_CHECKPOINT_SCHEMA_VERSION,
|
||||
prompt_schema_version: SCENE_PROMPT_SCHEMA_VERSION,
|
||||
stable_prefix_hash: HASH_A,
|
||||
summary_json,
|
||||
source_hash: HASH_B,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_serializes_only_the_summary_payload() {
|
||||
let runtime = runtime_checkpoint();
|
||||
let stored =
|
||||
stored_checkpoint_from_runtime(&runtime).expect("runtime checkpoint should map");
|
||||
|
||||
let expected_summary =
|
||||
serde_json::to_string(&runtime.summary).expect("summary should serialize");
|
||||
assert_eq!(stored.summary_json(), expected_summary);
|
||||
assert!(!stored.summary_json().contains("story_demo"));
|
||||
assert!(!stored.summary_json().contains("node_004"));
|
||||
assert_eq!(runtime_checkpoint_from_stored(&stored), Some(runtime));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_summary_json_is_a_cache_miss() {
|
||||
assert_eq!(
|
||||
runtime_checkpoint_from_parts(valid_parts("{not-json")),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_summary_classification_is_a_cache_miss() {
|
||||
let summary = r#"{"classification":"authoritative_fact","text":"should miss"}"#;
|
||||
assert_eq!(runtime_checkpoint_from_parts(valid_parts(summary)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_or_oversized_summary_is_a_cache_miss() {
|
||||
let blank = r#"{"classification":"non_authoritative_narrative","text":" \n\t "}"#;
|
||||
assert_eq!(runtime_checkpoint_from_parts(valid_parts(blank)), None);
|
||||
|
||||
let oversized_text = "x".repeat(MAX_NARRATIVE_CHECKPOINT_SUMMARY_BYTES + 1);
|
||||
let oversized = serde_json::to_string(&NarrativeCheckpointSummary {
|
||||
classification: SummaryClassification::NonAuthoritativeNarrative,
|
||||
text: oversized_text,
|
||||
})
|
||||
.expect("summary should serialize");
|
||||
assert_eq!(runtime_checkpoint_from_parts(valid_parts(&oversized)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_typed_hashes_are_cache_misses() {
|
||||
let summary =
|
||||
serde_json::to_string(&runtime_checkpoint().summary).expect("summary should serialize");
|
||||
|
||||
let mut invalid_stable = valid_parts(&summary);
|
||||
invalid_stable.stable_prefix_hash =
|
||||
"sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
assert_eq!(runtime_checkpoint_from_parts(invalid_stable), None);
|
||||
|
||||
let mut invalid_source = valid_parts(&summary);
|
||||
invalid_source.source_hash = "sha256:not-a-hash";
|
||||
assert_eq!(runtime_checkpoint_from_parts(invalid_source), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_to_store_rejects_bad_summary_before_persistence() {
|
||||
let mut checkpoint = runtime_checkpoint();
|
||||
checkpoint.summary.text = " \t ".to_owned();
|
||||
assert_eq!(
|
||||
stored_checkpoint_from_runtime(&checkpoint),
|
||||
Err(CheckpointStoreMappingError::BlankSummary)
|
||||
);
|
||||
|
||||
checkpoint.summary.text = "x".repeat(MAX_NARRATIVE_CHECKPOINT_SUMMARY_BYTES + 1);
|
||||
assert_eq!(
|
||||
stored_checkpoint_from_runtime(&checkpoint),
|
||||
Err(CheckpointStoreMappingError::SummaryTooLarge)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU8, Ordering},
|
||||
};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const READY: u8 = 0;
|
||||
const RUNNING: u8 = 1;
|
||||
const CANCELLED: u8 = 2;
|
||||
const TIMED_OUT: u8 = 3;
|
||||
const COMMITTING: u8 = 4;
|
||||
const FINISHED: u8 = 5;
|
||||
|
||||
/// Why a turn stopped before reaching its commit boundary.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TurnInterruption {
|
||||
Cancelled,
|
||||
TimedOut,
|
||||
}
|
||||
|
||||
/// One-shot cancellation and deadline control for a single turn attempt.
|
||||
///
|
||||
/// Clones refer to the same attempt. Cancellation is accepted until the turn
|
||||
/// atomically claims its commit boundary; after that boundary, the append is
|
||||
/// allowed to finish and [`Self::cancel`] returns `false`. Deadlines use the
|
||||
/// same boundary. This makes interruption and persistence race in one
|
||||
/// well-defined place instead of checking a best-effort flag immediately
|
||||
/// before an append.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TurnControl {
|
||||
state: Arc<AtomicU8>,
|
||||
deadline: Option<Instant>,
|
||||
}
|
||||
|
||||
impl TurnControl {
|
||||
/// Create a turn control without an application deadline.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: Arc::new(AtomicU8::new(READY)),
|
||||
deadline: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a turn control whose deadline starts now.
|
||||
#[must_use]
|
||||
pub fn with_timeout(timeout: Duration) -> Self {
|
||||
Self {
|
||||
state: Arc::new(AtomicU8::new(READY)),
|
||||
deadline: Instant::now().checked_add(timeout),
|
||||
}
|
||||
}
|
||||
|
||||
/// Request cancellation.
|
||||
///
|
||||
/// Returns `true` when cancellation already owns or wins the boundary.
|
||||
/// Returns `false` when timeout won, commit started, or the attempt ended.
|
||||
#[must_use]
|
||||
pub fn cancel(&self) -> bool {
|
||||
loop {
|
||||
let state = self.state.load(Ordering::Acquire);
|
||||
match state {
|
||||
READY | RUNNING => {
|
||||
if self
|
||||
.state
|
||||
.compare_exchange(state, CANCELLED, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_ok()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
CANCELLED => return true,
|
||||
TIMED_OUT | COMMITTING | FINISHED => return false,
|
||||
_ => unreachable!("invalid turn cancellation state"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether cancellation owns the turn boundary.
|
||||
#[must_use]
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
self.state.load(Ordering::Acquire) == CANCELLED
|
||||
}
|
||||
|
||||
/// Whether the application deadline owns the turn boundary.
|
||||
#[must_use]
|
||||
pub fn is_timed_out(&self) -> bool {
|
||||
let _ = self.interruption();
|
||||
self.state.load(Ordering::Acquire) == TIMED_OUT
|
||||
}
|
||||
|
||||
/// Return and, when due, atomically claim the current interruption.
|
||||
#[must_use]
|
||||
pub fn interruption(&self) -> Option<TurnInterruption> {
|
||||
loop {
|
||||
let state = self.state.load(Ordering::Acquire);
|
||||
match state {
|
||||
CANCELLED => return Some(TurnInterruption::Cancelled),
|
||||
TIMED_OUT => return Some(TurnInterruption::TimedOut),
|
||||
READY | RUNNING
|
||||
if self
|
||||
.deadline
|
||||
.is_some_and(|deadline| Instant::now() >= deadline) =>
|
||||
{
|
||||
if self
|
||||
.state
|
||||
.compare_exchange(state, TIMED_OUT, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_ok()
|
||||
{
|
||||
return Some(TurnInterruption::TimedOut);
|
||||
}
|
||||
}
|
||||
READY | RUNNING | COMMITTING | FINISHED => return None,
|
||||
_ => unreachable!("invalid turn cancellation state"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the attempt can no longer be started or cancelled.
|
||||
#[must_use]
|
||||
pub fn is_terminal(&self) -> bool {
|
||||
let _ = self.interruption();
|
||||
matches!(
|
||||
self.state.load(Ordering::Acquire),
|
||||
CANCELLED | TIMED_OUT | FINISHED
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn begin_attempt(&self) -> Result<TurnAttempt<'_>, BeginAttemptError> {
|
||||
if let Some(interruption) = self.interruption() {
|
||||
return Err(interruption.into());
|
||||
}
|
||||
match self
|
||||
.state
|
||||
.compare_exchange(READY, RUNNING, Ordering::AcqRel, Ordering::Acquire)
|
||||
{
|
||||
Ok(_) => Ok(TurnAttempt { control: self }),
|
||||
Err(CANCELLED) => Err(BeginAttemptError::Cancelled),
|
||||
Err(TIMED_OUT) => Err(BeginAttemptError::TimedOut),
|
||||
Err(_) => Err(BeginAttemptError::AlreadyUsed),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn begin_commit(&self) -> Result<(), BeginCommitError> {
|
||||
if let Some(interruption) = self.interruption() {
|
||||
return Err(interruption.into());
|
||||
}
|
||||
match self
|
||||
.state
|
||||
.compare_exchange(RUNNING, COMMITTING, Ordering::AcqRel, Ordering::Acquire)
|
||||
{
|
||||
Ok(_) => Ok(()),
|
||||
Err(CANCELLED) => Err(BeginCommitError::Cancelled),
|
||||
Err(TIMED_OUT) => Err(BeginCommitError::TimedOut),
|
||||
Err(_) => Err(BeginCommitError::InvalidState),
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(&self) {
|
||||
let state = self.state.load(Ordering::Acquire);
|
||||
if matches!(state, RUNNING | COMMITTING) {
|
||||
let _ =
|
||||
self.state
|
||||
.compare_exchange(state, FINISHED, Ordering::AcqRel, Ordering::Acquire);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TurnControl {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum BeginAttemptError {
|
||||
Cancelled,
|
||||
TimedOut,
|
||||
AlreadyUsed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum BeginCommitError {
|
||||
Cancelled,
|
||||
TimedOut,
|
||||
InvalidState,
|
||||
}
|
||||
|
||||
pub(crate) struct TurnAttempt<'a> {
|
||||
control: &'a TurnControl,
|
||||
}
|
||||
|
||||
impl Drop for TurnAttempt<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.control.finish();
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TurnInterruption> for BeginAttemptError {
|
||||
fn from(value: TurnInterruption) -> Self {
|
||||
match value {
|
||||
TurnInterruption::Cancelled => Self::Cancelled,
|
||||
TurnInterruption::TimedOut => Self::TimedOut,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TurnInterruption> for BeginCommitError {
|
||||
fn from(value: TurnInterruption) -> Self {
|
||||
match value {
|
||||
TurnInterruption::Cancelled => Self::Cancelled,
|
||||
TurnInterruption::TimedOut => Self::TimedOut,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
use super::{BeginAttemptError, BeginCommitError, TurnControl};
|
||||
|
||||
#[test]
|
||||
fn cancellation_is_idempotent_before_commit() {
|
||||
let cancellation = TurnControl::new();
|
||||
let peer = cancellation.clone();
|
||||
|
||||
assert!(peer.cancel());
|
||||
assert!(cancellation.cancel());
|
||||
assert!(cancellation.is_cancelled());
|
||||
assert!(cancellation.is_terminal());
|
||||
assert!(matches!(
|
||||
cancellation.begin_attempt(),
|
||||
Err(BeginAttemptError::Cancelled)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_claim_has_a_single_race_winner() {
|
||||
let cancellation = TurnControl::new();
|
||||
let attempt = cancellation.begin_attempt().expect("start attempt");
|
||||
|
||||
cancellation.begin_commit().expect("claim commit");
|
||||
assert!(!cancellation.cancel());
|
||||
assert!(!cancellation.is_cancelled());
|
||||
assert!(!cancellation.is_terminal());
|
||||
|
||||
drop(attempt);
|
||||
assert!(cancellation.is_terminal());
|
||||
assert!(!cancellation.cancel());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_blocks_commit_and_handles_are_one_shot() {
|
||||
let cancellation = TurnControl::new();
|
||||
let attempt = cancellation.begin_attempt().expect("start attempt");
|
||||
|
||||
assert!(cancellation.cancel());
|
||||
assert_eq!(
|
||||
cancellation.begin_commit(),
|
||||
Err(BeginCommitError::Cancelled)
|
||||
);
|
||||
drop(attempt);
|
||||
assert!(cancellation.is_terminal());
|
||||
assert!(matches!(
|
||||
cancellation.begin_attempt(),
|
||||
Err(BeginAttemptError::Cancelled)
|
||||
));
|
||||
|
||||
let finished = TurnControl::new();
|
||||
drop(finished.begin_attempt().expect("start finished attempt"));
|
||||
assert!(finished.is_terminal());
|
||||
assert!(!finished.cancel());
|
||||
assert!(matches!(
|
||||
finished.begin_attempt(),
|
||||
Err(BeginAttemptError::AlreadyUsed)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deadline_uses_the_same_pre_commit_boundary() {
|
||||
let timed_out = TurnControl::with_timeout(Duration::ZERO);
|
||||
|
||||
assert!(timed_out.is_timed_out());
|
||||
assert!(timed_out.is_terminal());
|
||||
assert!(!timed_out.cancel());
|
||||
assert!(matches!(
|
||||
timed_out.begin_attempt(),
|
||||
Err(BeginAttemptError::TimedOut)
|
||||
));
|
||||
|
||||
let committing = TurnControl::with_timeout(Duration::from_secs(30));
|
||||
let attempt = committing.begin_attempt().expect("start attempt");
|
||||
committing.begin_commit().expect("claim commit");
|
||||
assert!(!committing.is_timed_out());
|
||||
drop(attempt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{
|
||||
NarrativeCheckpointSourceEntry, NarrativeCheckpointSummary, ProviderError,
|
||||
SummaryClassification, TurnControl,
|
||||
};
|
||||
|
||||
pub const MAX_CONTEXT_SUMMARY_BYTES: usize = 64 * 1024;
|
||||
pub const MAX_CONTEXT_SUMMARY_SOURCE_ENTRIES: usize = 512;
|
||||
|
||||
/// Player-safe, contiguous narrative material selected by the context planner.
|
||||
///
|
||||
/// It has no representation for runtime state, state deltas, hidden checks,
|
||||
/// private inventory, credentials, or provider responses. Source identities
|
||||
/// are retained for host validation and are not delegated to the model.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SummaryRequest {
|
||||
prior_summary: Option<NarrativeCheckpointSummary>,
|
||||
prior_covered_through_node_id: Option<String>,
|
||||
entries: Vec<NarrativeCheckpointSourceEntry>,
|
||||
max_summary_bytes: usize,
|
||||
}
|
||||
|
||||
impl SummaryRequest {
|
||||
pub fn new(
|
||||
prior_summary: Option<NarrativeCheckpointSummary>,
|
||||
prior_covered_through_node_id: Option<String>,
|
||||
entries: Vec<NarrativeCheckpointSourceEntry>,
|
||||
max_summary_bytes: usize,
|
||||
) -> Result<Self, SummaryRequestError> {
|
||||
if max_summary_bytes == 0 || max_summary_bytes > MAX_CONTEXT_SUMMARY_BYTES {
|
||||
return Err(SummaryRequestError::InvalidSummaryLimit);
|
||||
}
|
||||
if entries.is_empty() {
|
||||
return Err(SummaryRequestError::EmptyEntries);
|
||||
}
|
||||
if entries.len() > MAX_CONTEXT_SUMMARY_SOURCE_ENTRIES {
|
||||
return Err(SummaryRequestError::TooManyEntries);
|
||||
}
|
||||
if prior_summary.is_some() != prior_covered_through_node_id.is_some() {
|
||||
return Err(SummaryRequestError::IncompletePriorSummary);
|
||||
}
|
||||
if prior_summary.as_ref().is_some_and(|summary| {
|
||||
summary.classification != SummaryClassification::NonAuthoritativeNarrative
|
||||
}) {
|
||||
return Err(SummaryRequestError::InvalidPriorClassification);
|
||||
}
|
||||
if prior_summary
|
||||
.as_ref()
|
||||
.is_some_and(|summary| summary.text.trim().is_empty())
|
||||
{
|
||||
return Err(SummaryRequestError::EmptyPriorSummary);
|
||||
}
|
||||
if prior_summary
|
||||
.as_ref()
|
||||
.is_some_and(|summary| summary.text.len() > MAX_CONTEXT_SUMMARY_BYTES)
|
||||
{
|
||||
return Err(SummaryRequestError::PriorSummaryTooLarge);
|
||||
}
|
||||
|
||||
let mut node_ids = BTreeSet::new();
|
||||
for node_id in entries.iter().map(|entry| &entry.node_id) {
|
||||
if node_id.trim().is_empty() {
|
||||
return Err(SummaryRequestError::EmptyNodeId);
|
||||
}
|
||||
if !node_ids.insert(node_id.clone()) {
|
||||
return Err(SummaryRequestError::DuplicateNodeId(node_id.clone()));
|
||||
}
|
||||
}
|
||||
if let Some(prior_covered) = prior_covered_through_node_id.as_deref() {
|
||||
if prior_covered.trim().is_empty() {
|
||||
return Err(SummaryRequestError::EmptyNodeId);
|
||||
}
|
||||
if entries[0].parent_id.as_deref() != Some(prior_covered) {
|
||||
return Err(SummaryRequestError::NonContiguousEntries);
|
||||
}
|
||||
} else if entries[0].parent_id.is_some() {
|
||||
return Err(SummaryRequestError::NonContiguousEntries);
|
||||
}
|
||||
if entries
|
||||
.windows(2)
|
||||
.any(|pair| pair[1].parent_id.as_deref() != Some(pair[0].node_id.as_str()))
|
||||
{
|
||||
return Err(SummaryRequestError::NonContiguousEntries);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
prior_summary,
|
||||
prior_covered_through_node_id,
|
||||
entries,
|
||||
max_summary_bytes,
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn prior_summary(&self) -> Option<&NarrativeCheckpointSummary> {
|
||||
self.prior_summary.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn prior_covered_through_node_id(&self) -> Option<&str> {
|
||||
self.prior_covered_through_node_id.as_deref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn entries(&self) -> &[NarrativeCheckpointSourceEntry] {
|
||||
&self.entries
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn max_summary_bytes(&self) -> usize {
|
||||
self.max_summary_bytes
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn covered_through_node_id(&self) -> &str {
|
||||
self.entries
|
||||
.last()
|
||||
.map_or("", |entry| entry.node_id.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error, Clone, PartialEq, Eq)]
|
||||
pub enum SummaryRequestError {
|
||||
#[error("a summary request requires at least one complete narrative entry")]
|
||||
EmptyEntries,
|
||||
#[error("a summary request contains too many narrative entries")]
|
||||
TooManyEntries,
|
||||
#[error("a summary request contains an empty source node id")]
|
||||
EmptyNodeId,
|
||||
#[error("a summary request repeats source node id `{0}`")]
|
||||
DuplicateNodeId(String),
|
||||
#[error("a summary request must provide both prior summary and covered node, or neither")]
|
||||
IncompletePriorSummary,
|
||||
#[error("a prior summary has an unsupported classification")]
|
||||
InvalidPriorClassification,
|
||||
#[error("summary source entries are not one contiguous path")]
|
||||
NonContiguousEntries,
|
||||
#[error("a prior narrative summary cannot be empty")]
|
||||
EmptyPriorSummary,
|
||||
#[error("a prior narrative summary exceeds the summary size limit")]
|
||||
PriorSummaryTooLarge,
|
||||
#[error("the requested narrative summary size limit is invalid")]
|
||||
InvalidSummaryLimit,
|
||||
}
|
||||
|
||||
/// The only model-authored value accepted from a summary call.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SummaryResult {
|
||||
text: String,
|
||||
}
|
||||
|
||||
impl SummaryResult {
|
||||
pub fn new(text: String, max_summary_bytes: usize) -> Result<Self, SummaryResultError> {
|
||||
if text.trim().is_empty() {
|
||||
return Err(SummaryResultError::Empty);
|
||||
}
|
||||
if max_summary_bytes == 0
|
||||
|| max_summary_bytes > MAX_CONTEXT_SUMMARY_BYTES
|
||||
|| text.len() > max_summary_bytes
|
||||
{
|
||||
return Err(SummaryResultError::TooLarge);
|
||||
}
|
||||
Ok(Self { text })
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn text(&self) -> &str {
|
||||
&self.text
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn into_checkpoint_summary(self) -> NarrativeCheckpointSummary {
|
||||
NarrativeCheckpointSummary {
|
||||
classification: SummaryClassification::NonAuthoritativeNarrative,
|
||||
text: self.text,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SummaryResultError {
|
||||
#[error("a narrative summary cannot be empty")]
|
||||
Empty,
|
||||
#[error("a narrative summary exceeds the summary size limit")]
|
||||
TooLarge,
|
||||
}
|
||||
|
||||
/// Provider-neutral seam for one same-model narrative compaction call.
|
||||
///
|
||||
/// Implementations must use only [`SummaryRequest`], observe the existing turn
|
||||
/// control, and return no persistence metadata. The runtime stamps hashes,
|
||||
/// schema versions, and covered ranges after validating the result.
|
||||
pub trait ContextSummaryModel {
|
||||
fn summarize_with_control(
|
||||
&mut self,
|
||||
request: &SummaryRequest,
|
||||
control: &TurnControl,
|
||||
) -> Result<SummaryResult, ProviderError>;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use nana_domain::{BeatKind, PresentationBeat, VisualDirective};
|
||||
|
||||
use super::{
|
||||
MAX_CONTEXT_SUMMARY_BYTES, SummaryRequest, SummaryRequestError, SummaryResult,
|
||||
SummaryResultError,
|
||||
};
|
||||
use crate::{
|
||||
BranchHistoryCharacter, BranchHistoryScene, NarrativeCheckpointSourceEntry,
|
||||
NarrativeCheckpointSummary, SummaryClassification,
|
||||
};
|
||||
|
||||
fn entry(node_id: &str, parent_id: Option<&str>) -> NarrativeCheckpointSourceEntry {
|
||||
NarrativeCheckpointSourceEntry {
|
||||
node_id: node_id.into(),
|
||||
parent_id: parent_id.map(str::to_owned),
|
||||
user_input: "Wait here.".into(),
|
||||
scene: BranchHistoryScene {
|
||||
id: "station".into(),
|
||||
title: "Station".into(),
|
||||
},
|
||||
character: BranchHistoryCharacter {
|
||||
id: "nana".into(),
|
||||
name: "Nana".into(),
|
||||
expression: Some("guarded".into()),
|
||||
pose: None,
|
||||
},
|
||||
beats: vec![PresentationBeat {
|
||||
id: format!("beat_{node_id}"),
|
||||
kind: BeatKind::Dialogue,
|
||||
speaker: Some("Nana".into()),
|
||||
text: "I will wait.".into(),
|
||||
visual: Some(VisualDirective {
|
||||
character: Some("nana".into()),
|
||||
expression: Some("guarded".into()),
|
||||
pose: None,
|
||||
scene: None,
|
||||
}),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_request_owns_an_exact_non_repeating_source_sequence() {
|
||||
let request = SummaryRequest::new(
|
||||
Some(NarrativeCheckpointSummary {
|
||||
classification: SummaryClassification::NonAuthoritativeNarrative,
|
||||
text: "Earlier events.".into(),
|
||||
}),
|
||||
Some("node_root".into()),
|
||||
vec![
|
||||
entry("node_1", Some("node_root")),
|
||||
entry("node_2", Some("node_1")),
|
||||
],
|
||||
1_024,
|
||||
)
|
||||
.expect("valid request");
|
||||
|
||||
assert_eq!(request.covered_through_node_id(), "node_2");
|
||||
let result = SummaryResult::new("Self-contained recap.".into(), 1_024).expect("summary");
|
||||
let summary = result.into_checkpoint_summary();
|
||||
assert_eq!(
|
||||
summary.classification,
|
||||
SummaryClassification::NonAuthoritativeNarrative
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_request_rejects_empty_duplicate_and_oversized_values() {
|
||||
assert_eq!(
|
||||
SummaryRequest::new(None, None, Vec::new(), 1_024),
|
||||
Err(SummaryRequestError::EmptyEntries)
|
||||
);
|
||||
assert!(matches!(
|
||||
SummaryRequest::new(
|
||||
Some(NarrativeCheckpointSummary {
|
||||
classification: SummaryClassification::NonAuthoritativeNarrative,
|
||||
text: "Earlier.".into(),
|
||||
}),
|
||||
Some("node_1".into()),
|
||||
vec![entry("node_2", Some("node_other"))],
|
||||
1_024,
|
||||
),
|
||||
Err(SummaryRequestError::NonContiguousEntries)
|
||||
));
|
||||
assert_eq!(
|
||||
SummaryResult::new(String::new(), 1_024),
|
||||
Err(SummaryResultError::Empty)
|
||||
);
|
||||
assert_eq!(
|
||||
SummaryResult::new(
|
||||
"x".repeat(MAX_CONTEXT_SUMMARY_BYTES + 1),
|
||||
MAX_CONTEXT_SUMMARY_BYTES,
|
||||
),
|
||||
Err(SummaryResultError::TooLarge)
|
||||
);
|
||||
assert_eq!(
|
||||
SummaryRequest::new(None, None, vec![entry("node_1", None)], 0),
|
||||
Err(SummaryRequestError::InvalidSummaryLimit)
|
||||
);
|
||||
assert_eq!(
|
||||
SummaryResult::new("12345".into(), 4),
|
||||
Err(SummaryResultError::TooLarge)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
# Current-Branch Context Checkpoints
|
||||
|
||||
Status: design frozen for the Wave 7 implementation slice.
|
||||
|
||||
## Purpose
|
||||
|
||||
Long stories must stay inside the selected LAPP model's real context window without silently
|
||||
dropping the current input, triggered world-book entries, runtime facts, or recent committed
|
||||
scenes. A checkpoint is a disposable narrative cache. It is never an authoritative source for
|
||||
flags, relationships, promises, inventory, knowledge, checks, or branch structure.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Checkpoint persistence starts only after these contracts are represented in code:
|
||||
|
||||
1. The selected LAPP model's `context_window` and `max_output_tokens` reach the Runtime provider.
|
||||
2. The stable prompt prefix has a deterministic resource fingerprint.
|
||||
3. The narrative source path has a canonical ordered hash that covers committed public story
|
||||
content, not only `RuntimeState`.
|
||||
4. `Regenerate` excludes the replaced node from narrative history, constrains the replacement with
|
||||
the target node's committed post-state, creates a sibling on a new branch, and reuses existing
|
||||
hidden-check outcomes.
|
||||
|
||||
## Prompt budget
|
||||
|
||||
The provider computes one explicit budget before every model request:
|
||||
|
||||
```text
|
||||
hard input budget =
|
||||
model context window
|
||||
- requested output tokens
|
||||
- system prompt and tool schema
|
||||
- message framing overhead
|
||||
- reserved hidden-check continuation overhead
|
||||
- safety margin
|
||||
```
|
||||
|
||||
- Requested output is `min(model.max_output_tokens ?? 4096, 4096)`.
|
||||
- A missing context window uses a conservative 16,384-token V1 fallback, but the resulting
|
||||
`BudgetSource::Assumed` diagnostic must remain visible to the connection/settings layer. The
|
||||
fallback must not be silent.
|
||||
- Cross-provider V1 estimation treats every serialized UTF-8 byte as at most one token. This may
|
||||
compact early but must not optimistically overfill a model window.
|
||||
- Compression starts at 70% of the hard input budget and compacts back below a lower watermark.
|
||||
- The complete dynamic tail is budgeted first. If it does not fit by itself, the request fails with
|
||||
`DynamicTailTooLarge`.
|
||||
- At least one recent committed node remains verbatim. A single oversized node fails with
|
||||
`HistoryEntryTooLarge`; beat text is never truncated.
|
||||
- Every initial and hidden-check continuation `ChatInput` must remain within budget.
|
||||
|
||||
## Prompt layout
|
||||
|
||||
Prompt schema v3 will replace the current v2 history array with one production checkpoint-aware
|
||||
encoder:
|
||||
|
||||
```text
|
||||
prompt_schema_version
|
||||
stable_prefix
|
||||
branch_context
|
||||
checkpoint?
|
||||
raw_tail[]
|
||||
dynamic_tail
|
||||
```
|
||||
|
||||
`stable_prefix` remains byte-identical while the bound resources do not change. `dynamic_tail`
|
||||
always contains the current input and the complete safe state projection. A checkpoint replaces
|
||||
only a continuous oldest prefix of `raw_tail`.
|
||||
|
||||
## Checkpoint record
|
||||
|
||||
SQLite schema v3 adds a cache table keyed by the immutable host node, not by branch:
|
||||
|
||||
```sql
|
||||
CREATE TABLE context_checkpoints (
|
||||
story_id TEXT NOT NULL,
|
||||
at_node_id TEXT NOT NULL,
|
||||
covered_through_node_id TEXT NOT NULL,
|
||||
retained_from_node_id TEXT,
|
||||
checkpoint_schema_version INTEGER NOT NULL,
|
||||
prompt_schema_version INTEGER NOT NULL,
|
||||
stable_prefix_hash TEXT NOT NULL,
|
||||
summary_json TEXT NOT NULL,
|
||||
source_hash TEXT NOT NULL,
|
||||
|
||||
PRIMARY KEY (story_id, at_node_id),
|
||||
FOREIGN KEY (story_id, at_node_id)
|
||||
REFERENCES nodes (story_id, node_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (story_id, covered_through_node_id)
|
||||
REFERENCES nodes (story_id, node_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (story_id, retained_from_node_id)
|
||||
REFERENCES nodes (story_id, node_id) ON DELETE CASCADE
|
||||
);
|
||||
```
|
||||
|
||||
- The nearest checkpoint is found only by walking the current node's `parent_id` chain.
|
||||
- Shared ancestors naturally share a checkpoint; sibling-only descendants are unreachable.
|
||||
- The summary is self-contained. A new checkpoint replaces the old summary instead of nesting a
|
||||
chain of summaries in the prompt.
|
||||
- Successful checkpoint generation may be persisted independently on the existing host node.
|
||||
Failure of the later story-generation call may leave this harmless cache in place.
|
||||
- Deleting every checkpoint must leave nodes, branch heads, materialized state, and `PlayerView`
|
||||
byte-identical.
|
||||
|
||||
## Source and resource hashes
|
||||
|
||||
The model never supplies either hash.
|
||||
|
||||
`stable_prefix_hash` covers the exact canonical stable-prefix encoding, including character,
|
||||
Persona, resource provenance, and prompt safety/version fields.
|
||||
|
||||
`source_hash` covers:
|
||||
|
||||
```text
|
||||
checkpoint source schema version
|
||||
prompt schema version
|
||||
stable_prefix_hash
|
||||
story id
|
||||
ordered root-to-covered sequence of:
|
||||
node id
|
||||
parent id
|
||||
player input
|
||||
public scene
|
||||
public character visual state
|
||||
committed beats
|
||||
```
|
||||
|
||||
Unselected suggestions, state delta, exact relationship values, checks, NPC private inventory,
|
||||
untriggered world-book content, provider responses, credentials, and chain-of-thought never enter
|
||||
the source manifest or compression request.
|
||||
|
||||
## Regenerate boundary
|
||||
|
||||
`Regenerate` is a full alternative rendering of one committed player choice:
|
||||
|
||||
- the replaced node is not included in the new prompt;
|
||||
- model history ends at the replaced node's parent, while the target node's authoritative
|
||||
post-state supplies only the already-committed facts and fixed qualitative check outcomes;
|
||||
- the replacement creates a sibling on a new runtime-generated branch;
|
||||
- hidden checks from the original action are supplied as qualitative fixed outcomes and are not
|
||||
rolled again;
|
||||
- a model cannot submit forged `RecordCheck` operations;
|
||||
- old nodes, descendants, and checkpoints remain immutable and become naturally unreachable from
|
||||
the new branch unless they are shared ancestors.
|
||||
|
||||
The sibling keeps the original player action identity, authoritative delta, terminal state, and
|
||||
exact hidden-check records. Those immutable records intentionally retain their original source-node
|
||||
provenance; the new branch is an alternative presentation of that same committed action, not a new
|
||||
roll or a new state transition.
|
||||
|
||||
## Summary safety
|
||||
|
||||
The summary may retain public causal order, actual player choices, public NPC actions, revealed
|
||||
facts with their certainty, unresolved conflicts, shared goals, and observable emotional residue.
|
||||
It may not become a trigger or rules input.
|
||||
|
||||
The summary must not contain:
|
||||
|
||||
- exact dice mechanics or relationship numbers;
|
||||
- private NPC knowledge, private inventory, or unrevealed item provenance;
|
||||
- hidden flags, clocks, event conditions, or untriggered world-book entries;
|
||||
- unselected suggestions, cancelled output, sibling-branch content, or inferred player thoughts;
|
||||
- credentials, request headers, raw provider bodies, or chain-of-thought.
|
||||
|
||||
The current player inventory projection must not be fed to the summarizer until ownership and
|
||||
acquisition fields have their own player-knowledge visibility boundary.
|
||||
|
||||
## Failure semantics
|
||||
|
||||
- No valid checkpoint and history above the high watermark returns `NeedsCompaction`; history is
|
||||
never silently shortened.
|
||||
- Invalid schema, source hash, stable-prefix hash, range, or summary size makes a checkpoint
|
||||
unusable and rebuildable.
|
||||
- Broken authoritative ancestry or state remains a hard store error and is not downgraded to a
|
||||
cache miss.
|
||||
- Cancelled, timed-out, rate-limited, or malformed compression produces no story node and moves no
|
||||
branch head.
|
||||
- A first load of a very long legacy branch uses bounded rolling chunks and keeps only the final
|
||||
self-contained summary.
|
||||
|
||||
## Required gates
|
||||
|
||||
- Exact window boundary, one-token overflow, unknown-model-limit fallback, and smaller-model switch.
|
||||
- Every normal and hidden-check request stays within the computed budget.
|
||||
- 500-node first compaction and incremental compaction from an existing checkpoint.
|
||||
- v2-to-v3 migration, rollback, restart recovery, cache deletion, and corruption rejection.
|
||||
- Root, shared-ancestor, pre/post-checkpoint fork, regenerate, and sibling-canary isolation.
|
||||
- Stable-prefix cache identity and one cache break only when a checkpoint rotates.
|
||||
- Hidden canaries for checks, exact relationships, NPC facts/items, untriggered resources,
|
||||
unselected suggestions, credentials, and provider bodies.
|
||||
@@ -0,0 +1,68 @@
|
||||
# M2 第五波状态
|
||||
|
||||
日期:2026-07-28
|
||||
|
||||
## 基线
|
||||
|
||||
本轮从已推送到私有 Gitea 的 `449515f` 开始,继续保持 Rust 领域类型为契约唯一事实
|
||||
来源,以及“模型提出 TurnPlan、引擎裁决、SQLite 单次提交、PlayerView 脱敏投影”
|
||||
的主链。
|
||||
|
||||
## 已完成
|
||||
|
||||
### 可恢复的故事线路
|
||||
|
||||
- 新增线路列表、线路摘要、切换与重命名契约、Tauri 命令和 Vue 界面。
|
||||
- `StoryStore` 同时支持内存与 SQLite 的线路枚举、活动线路 CAS 切换与名称校验。
|
||||
- 回溯创建的新线路自动成为活动线路;普通回合只允许提交到当前活动线路。
|
||||
- SQLite schema 升级到 v2,增加 `branch_metadata`、`story_sessions` 与
|
||||
`app_settings`,并能无损迁移 wave4 的 schema v1。
|
||||
- 关闭并重启后恢复最后活动线路;原线路与新线路的节点、检定、许诺和物品继续隔离。
|
||||
|
||||
### LAPP 模型设置
|
||||
|
||||
- 设置面板只列出 LAPP profile 中已启用且显式声明 `chat`、`tool-call` 能力的模型。
|
||||
- 选择项只持久化 canonical provider/model ID;API Key 和其他凭据不进入应用数据库、
|
||||
DTO、日志或 `PlayerView`,仍由 LAPP Vault 在实际生成时即时解析。
|
||||
- 模型切换会先建立新的 LAPP adjudication provider,成功后再原子保存选择并替换运行
|
||||
provider;缺 profile、缺能力或初始化失败都会给出明确错误。
|
||||
- `NANA_STORY_PROVIDER=demo` 继续作为显式、不可在界面内覆盖的确定性模式。
|
||||
|
||||
### “天亮之前”完整纵切
|
||||
|
||||
确定性纵切现在可连续完成:
|
||||
|
||||
1. 玩家亲自答应天亮前回来,娜娜接受许诺。
|
||||
2. 可信运行时完成一次隐藏搜索判定。
|
||||
3. 玩家确认检修门线索并获得“半张旧车票”。
|
||||
4. 玩家进入封锁隧道,时钟与关系按规则推进。
|
||||
5. 玩家在天亮前返回,许诺结算为已履行,故事进入不可继续的终局。
|
||||
|
||||
终局仍可通过回溯创建另一条线路。纵切总测试覆盖完成故事、关闭并重启、从根节点
|
||||
分叉、推进另一线路、再次重启,并验证完成线路与替代线路互不污染。
|
||||
|
||||
## 验证结果
|
||||
|
||||
- Rust 1.96 `cargo fmt --check` 通过。
|
||||
- 111 项核心 Rust 测试通过:Domain 5、Engine 21、Runtime 53、Store 32。
|
||||
- 11 项 Tauri 后端测试通过。
|
||||
- 核心与 Tauri Clippy `-D warnings` 通过,Tauri 全 target 类型检查通过。
|
||||
- 24 份契约 Schema、TypeScript DTO 与 Rust 源哈希一致。
|
||||
- TypeScript 严格检查、18 项 Web 测试和 Vite 生产构建通过。
|
||||
|
||||
Tauri 的 Rust 检查和后端测试继续使用空的本机 GUI 链接占位库;它证明应用代码、宏和
|
||||
后端测试可编译执行,不等同于真实 WebKitGTK 窗口启动。
|
||||
|
||||
## 尚未关闭
|
||||
|
||||
- 真实 Linux/Windows 桌面窗口与安装包。
|
||||
- 使用用户实际 LAPP profile、Vault 凭据和在线模型的端到端冒烟。
|
||||
- 流式演出、取消生成和面向玩家的重试/诊断细分。
|
||||
- 正式角色立绘、场景素材与最终应用图标。
|
||||
|
||||
## 下一波
|
||||
|
||||
1. 在真实桌面环境完成在线 LAPP 冒烟与错误恢复。
|
||||
2. 将单次非流式回复升级为可取消的流式演出,但仍保持整轮一次提交。
|
||||
3. 增加新故事/内容包导入入口,并冻结首个可分发存档兼容版本。
|
||||
4. 建立 Windows 构建、签名与安装升级 CI。
|
||||
@@ -0,0 +1,82 @@
|
||||
# M2 第六波状态
|
||||
|
||||
日期:2026-07-28
|
||||
|
||||
## 基线
|
||||
|
||||
本轮从已推送的 `integration/v1@1f935a3` 开始,目标是把真实模型回合从“能调用”
|
||||
收敛为“可停止、可超时、失败不产生半轮、玩家能安全恢复”,并准备一套可在真实
|
||||
Windows 开发机重复执行的桌面冒烟流程。
|
||||
|
||||
## 已完成
|
||||
|
||||
### 可取消的原子回合
|
||||
|
||||
- 新增一次性 `TurnControl`,取消、90 秒截止时间与 SQLite 提交竞争同一个原子边界。
|
||||
- Tauri 在调度后台回合前同步登记 `actionId`,消除“提交后立刻停止”找不到回合的窗口。
|
||||
- 停止命令不等待故事操作锁;提交已经赢得边界时,原提交结果仍是唯一权威,界面会明确
|
||||
告知完整回合已安全写入。
|
||||
- LAPP 请求运行在隔离的原生请求线程;同步 Vault 解析卡住时,取消或超时仍能及时返回,
|
||||
reducer 与 SQLite 不会收到半轮结果。
|
||||
- 中断后的 LAPP executor 会先发布退休状态,Tauri 在向界面返回前重建 provider;同一
|
||||
`TurnRequest` 与 `actionId` 可进行一次安全重试。
|
||||
- 故事回合与连接测试共享原生调用单飞闸门。旧 Vault/HTTP 调用真正退出前不会再启动
|
||||
新的原生模型调用,避免连续取消累积后台线程。
|
||||
|
||||
### 错误恢复与连接测试
|
||||
|
||||
- 后端只根据 LAPP 稳定错误码和 HTTP 状态分类配置错误、凭据不可用、限流、供应商拒绝、
|
||||
网络不可用、超时与无效响应;供应商正文不会进入 `TurnFailure` 或玩家界面。
|
||||
- 界面只对后端标记为可重试的失败显示一次“重试本轮”,并复用原请求和动作 ID。
|
||||
- 新增“测试当前连接”。Tauri 命令不接受 provider/model 参数,只读取当前已应用设置,
|
||||
因而不能被前端用于探测任意模型。
|
||||
- 连接测试在独立线程执行,受 35 秒外层超时与单飞保护;返回值只包含 provider/model
|
||||
标识、成功状态和稳定诊断码,不包含凭据或供应商响应正文。
|
||||
- 连接成功只证明最小聊天请求可用;隐藏判定工具调用仍需在真实故事回合中验证。
|
||||
|
||||
### Windows 开发机冒烟
|
||||
|
||||
- 新增 `scripts/windows-smoke.ps1`,检查干净且已跟踪远端的 `integration/v1`、Rust
|
||||
1.96 MSVC host、Node 24、精确 pnpm、锁文件一致性,以及相邻 `lapp-rs` 的来源、
|
||||
固定提交与干净工作树。
|
||||
- 脚本依次执行全量 `pnpm verify` 和 `pnpm tauri build --no-bundle`;只有全部通过后
|
||||
才可选启动桌面窗口。
|
||||
- `-Launch` 强制使用 Windows 临时目录下的显式隔离存档路径。应用会 canonicalize
|
||||
路径并拒绝相对路径、临时目录根和链接逃逸;脚本不会删除该目录,便于重启恢复验证。
|
||||
- `-Demo` 先验证窗口、SQLite 恢复、终局与双线路隔离;默认 LAPP 模式再验证 profile、
|
||||
Vault、最小连接和一轮真实工具调用。
|
||||
|
||||
完整操作见 [`docs/windows-developer-smoke.md`](../windows-developer-smoke.md)。
|
||||
|
||||
## 验证结果
|
||||
|
||||
- Rust 1.96 `cargo fmt --check` 通过。
|
||||
- 119 项核心 Rust 测试通过:Domain 5、Engine 21、Runtime 61、Store 32。
|
||||
- Tauri 后端 **18** 项测试通过,包括取消前置竞态、provider 退休后同动作重试、
|
||||
连接单飞、隔离存档路径和既有文件数据库纵切。
|
||||
- 核心与 Tauri Clippy `-D warnings` 通过,Tauri 全 target 类型检查通过。
|
||||
- 25 份契约 Schema、TypeScript DTO 与 Rust 源哈希一致,Schema 清单本身也受检查。
|
||||
- TypeScript 严格检查、29 项 Web 测试和 Vite 生产构建通过。
|
||||
|
||||
Tauri 检查和后端测试在当前 Linux Work 环境继续使用空的 GUI 链接占位库;它们证明
|
||||
Rust/Tauri 代码、宏和测试能够编译执行,不等同于真实 WebView2 窗口或 Windows 二进制。
|
||||
PowerShell 脚本也尚未在本环境解析执行。
|
||||
|
||||
## 明确限制
|
||||
|
||||
- 固定版 `lapp-rs@5ba3c659…` 的流事件只有文本增量、结束与用量,不包含工具调用片段,
|
||||
且流式 API 不返回最终 `ChatResponse`。因此本轮实现的是可取消的非流式可信回合;
|
||||
在 LAPP 增加可验证的工具调用流与最终响应前,不能安全地把隐藏检定/TurnPlan 改成
|
||||
真正流式提交。
|
||||
- Rust 无法强杀正在执行的 Windows 原生 Vault 调用。单飞闸门会阻止新的调用,应用与
|
||||
SQLite 仍能及时结束本轮;若原生调用永久不返回,后续模型调用需等待或重启应用。
|
||||
- 某些供应商可能已在取消生效前接收请求,因此“未写入故事”不等于绝对不会产生一次
|
||||
供应商计费。消除这一点需要 `lapp-rs` 提供可取消、分阶段的凭据解析与请求 API。
|
||||
|
||||
## 尚待真实 PC 关闭
|
||||
|
||||
1. 在 Windows 上实际执行脚本,确认完整验证与 `tauri build --no-bundle`。
|
||||
2. 使用隔离存档启动 Demo 窗口,完成两次重启、终局与双线路隔离。
|
||||
3. 使用真实 LAPP profile/Vault 运行最小连接测试及至少一轮隐藏判定工具调用。
|
||||
4. 记录 Windows 版本、架构、被测提交和首个失败点;不得回传凭据、profile、数据库或
|
||||
完整供应商响应。
|
||||
@@ -0,0 +1,59 @@
|
||||
# M2 第七波本地开工状态
|
||||
|
||||
日期:2026-07-28
|
||||
|
||||
## 本地基线
|
||||
|
||||
- 从私有 Gitea 安全恢复 `integration/v1@23672e857b`,并通过 `git fsck` 检查对象完整性。
|
||||
- 下载时使用的临时仓库只读 Token 已在 Gitea 撤销;临时配置、bundle 与内存中的凭据均
|
||||
已清理。
|
||||
- `origin` 已恢复为 Gitea SSH 地址,分支仍跟踪 `origin/integration/v1`。当前执行环境的
|
||||
网络代理会关闭 SSH 2222 端口,因此本轮只提交到本地,不把“已配置公钥”误报为“已成功
|
||||
推送”。
|
||||
- 相邻 `lapp-rs` 已固定在 `5ba3c659e1536ec4bee16340faca603940a5cb17`,未修改其源码。
|
||||
- 本地基线提交为 `37b3397 chore: establish wave 7 local baseline`。
|
||||
|
||||
## 已完成
|
||||
|
||||
### Windows 可重复基线
|
||||
|
||||
- 新增 `.gitattributes`,固定文本 LF 与图片、音频二进制属性。
|
||||
- Node 与 Rust 契约源码哈希在计算前统一 LF,避免 Windows CRLF checkout 产生假漂移。
|
||||
- 增加 LF / CRLF 哈希等价测试。
|
||||
- 安装并锁定 Node 24、pnpm 10.29.2、Rust 1.96.0、rustfmt 与 clippy。
|
||||
- 使用锁文件安装 JavaScript 依赖。
|
||||
|
||||
### 当前分支连续上下文
|
||||
|
||||
- Store 新增按 `(story_id, node_id)` 和 `parent_id` 读取根到当前节点祖先链的接口。
|
||||
- Memory 与 SQLite 后端均检测缺失父节点和父链循环;读取不依赖 `branch_id`,允许分叉
|
||||
复用共享祖先,同时不会遍历兄弟分支。
|
||||
- Runtime 在生成本轮前自动投影当前祖先链,并把已提交的玩家输入、演出节拍、场景和角色
|
||||
视觉状态传给 Provider;未选择的行动建议不会被误当成历史事实。
|
||||
- LAPP 普通回合与隐藏判定回合共用同一套缓存友好上下文协议:
|
||||
`stable_prefix → branch_history → dynamic_tail`。
|
||||
- 动态状态只包含可用于角色扮演的安全投影,包括关系阶段与玩家可见持有物的归属、取得
|
||||
方式;精确关系数值、隐藏骰点、NPC 私物、未触发世界书和状态 delta 仍不能进入提示。
|
||||
- Tauri 的线路恢复复用 Store 祖先链接口,不再保留旧的 200 节点读取上限。
|
||||
|
||||
## 验证
|
||||
|
||||
- `node scripts/verify-contracts.mjs`:25 份契约无漂移。
|
||||
- `pnpm verify:web`:TypeScript 检查、5 个测试文件 / 29 项测试及生产构建通过。
|
||||
- `cargo metadata --no-deps`:通过,相邻 `lapp-rs` 路径依赖可解析。
|
||||
- `cargo fmt --all -- --check`:通过。
|
||||
- `git diff --check`:通过。
|
||||
|
||||
完整 Rust 测试、Clippy、Tauri 后端测试和 Windows 桌面编译尚未关闭,原因不是项目依赖
|
||||
解析,而是本机缺少 Microsoft C++ Build Tools 与 Windows SDK,`rustc` 当前找不到
|
||||
`link.exe`。
|
||||
|
||||
## 下一步
|
||||
|
||||
1. 安装 Microsoft C++ Build Tools 与 Windows SDK 后立即运行 `pnpm verify:rust` 和
|
||||
`pnpm tauri build --no-bundle`。
|
||||
2. 为长分支实现非权威上下文检查点、来源哈希和 SQLite v3 迁移;历史不得静默截断。
|
||||
3. 在连续上下文门禁关闭后,实现“风险预检 → 玩家确认 → 隐藏判定失败 → 推骰 / 重新
|
||||
生成”纵向切片。
|
||||
4. 最后在隔离存档上启动 Demo 窗口,验证重启恢复、终局、回溯和双线路隔离,再运行真实
|
||||
LAPP 最小连接与隐藏判定工具调用。
|
||||
@@ -0,0 +1,113 @@
|
||||
# M2 第七波:当前分支上下文检查点
|
||||
|
||||
日期:2026-07-29
|
||||
|
||||
## 本轮结果
|
||||
|
||||
本轮完成了长分支上下文主链:运行时按实际 LAPP 模型预算编排 prompt schema v3;
|
||||
超出预算时使用当前选中的同一个模型生成玩家安全的滚动摘要;摘要与来源指纹组成
|
||||
非权威检查点,并在最终回合成功时与新故事节点原子提交。历史不会为了适配窗口而被
|
||||
静默截断。
|
||||
|
||||
这套机制适用于普通继续与重生成。它不引入导演 Agent、独立摘要模型、向量记忆库或
|
||||
跨分支记忆。
|
||||
|
||||
## SQLite schema v3
|
||||
|
||||
- 新增 `context_checkpoints` 缓存表,以不可变的故事节点作为 host,保存覆盖范围、
|
||||
prompt / checkpoint schema 版本、稳定前缀哈希、摘要和叙事来源哈希。
|
||||
- schema v1 与 v2 数据库都能迁移至 v3;迁移不会修改故事节点、线路头、物化状态或
|
||||
活动线路。
|
||||
- Memory 与 SQLite Store 使用同一检查点语义:只沿目标节点的 `parent_id` 祖先链
|
||||
查找最近的可达检查点,兄弟分支和其他故事不可见。
|
||||
- 缓存行的类型、版本、大小、规范 SHA-256、host 和覆盖范围都会被校验。损坏或未知
|
||||
版本的行被当作可重建的 cache miss,并继续查找更早的有效祖先;权威祖先链损坏仍是
|
||||
硬错误。
|
||||
- 删除全部检查点不会改变节点、线路、状态哈希或 `PlayerView`。
|
||||
- 内存和 SQLite 后端均覆盖 500 节点祖先链读取与检查点查找,长线路不再受界面层
|
||||
200 节点展示限制。
|
||||
|
||||
## 缓存友好的 prompt schema v3
|
||||
|
||||
模型输入固定分成三段:
|
||||
|
||||
```text
|
||||
stable_prefix
|
||||
branch_context
|
||||
checkpoint?
|
||||
raw_tail[]
|
||||
dynamic_tail
|
||||
```
|
||||
|
||||
- `stable_prefix` 只包含角色、Persona、剧情模块、绑定世界书的版本来源及稳定安全
|
||||
规则;资源未变化时保持字节一致,便于供应商前缀缓存命中。
|
||||
- `branch_context` 使用一个已验证的祖先检查点加最近原文;至少保留最新的已提交节点,
|
||||
节拍文本不会被截断。
|
||||
- `dynamic_tail` 保留本轮输入、实际触发的世界书条目、剧情事件、可见状态与重生成的
|
||||
固定定性结果,不允许被摘要替代。
|
||||
- 输入预算来自当前 LAPP 模型的 `context_window` 和 `max_output_tokens`;缺失元数据
|
||||
使用显式标记的保守回退。输出、system、工具 schema、消息 framing、一次隐藏检定
|
||||
续调用与安全余量都会先从窗口中扣除。
|
||||
- 完整请求在发给 provider 前再次预检。动态尾部、固定部分或单个历史节点本身无法
|
||||
容纳时会失败关闭,而不是删字、截断或越过模型窗口。
|
||||
- V1 每次玩家行动最多进行一次隐藏检定;这使为工具调用及其续写保留的预算存在明确
|
||||
上界。初始请求会明确提示该限制,检定结果返回后以及重生成回合只向模型暴露
|
||||
`submit_turn_plan`,不会再提供第二次检定工具。
|
||||
- 可继续的候选节点会在 `begin_commit` 前,以提交后的状态、同一资源编译器和同一
|
||||
模型预算验证一次最小“下一轮继续”上下文。即使回复满足静态字段上限,只要它作为
|
||||
强制保留的最新原文会让下一轮无法编排,本轮就整体失败且不移动线路头。
|
||||
|
||||
## 同模型滚动摘要
|
||||
|
||||
- 压缩复用当前 TurnPlan provider 的同一 LAPP executor、模型、调用闸门和
|
||||
`TurnControl`,不会暗中切换模型或绕过供应商配置。
|
||||
- 每次摘要请求都是新的单用途会话,只开放一个严格的 `submit_context_summary`
|
||||
工具;正常文本、额外工具调用、未知字段、空白或超大摘要都会被拒绝。
|
||||
- 首次加载超长分支时按有界块滚动摘要;后续压缩把上一个自包含摘要与新增的连续节点
|
||||
一起压成新的自包含摘要,不在 prompt 中嵌套摘要链。
|
||||
- 摘要来源仅包含当前根到覆盖节点路径上的实际玩家输入、公开场景、角色视觉状态和
|
||||
已提交演出节拍。
|
||||
- 精确关系值、骰点和难度、状态 delta、NPC 私物和私密知识、未触发世界书、未选择
|
||||
建议、兄弟分支、凭据、原始供应商响应与推理过程都不进入摘要请求。
|
||||
- 摘要分类由运行时固定为 `non_authoritative_narrative`;模型只能提供文本,摘要不能
|
||||
作为规则触发、状态恢复或判定事实来源。
|
||||
|
||||
## 原子提交与中断边界
|
||||
|
||||
- 压缩发生在模型生成正式 TurnPlan 之前。生成的检查点先停留在内存中,不会立即写库。
|
||||
- 普通回合使用 `append_node_with_checkpoint`,重生成使用
|
||||
`append_regenerated_node_with_checkpoint`;节点、物化状态、线路头与待提交检查点
|
||||
在同一 Store 原子操作中落库。
|
||||
- 检查点必须 host 在提交前的历史头上,并与该祖先链的覆盖范围一致。陈旧线路、
|
||||
错误故事、无效范围或状态不一致都会使整次写入失败,节点与检查点均不落库。
|
||||
- 取消或超时可发生在摘要、正式模型调用或最终提交边界之前。只要中断先于
|
||||
`begin_commit`,待提交检查点与故事节点都会被丢弃,线路头保持不变。
|
||||
- 一旦 `begin_commit` 成功,取消不再与写入竞争;调用方接收该原子提交的确定结果。
|
||||
- 重生成继续复用原玩家行动、权威 delta、隐藏检定与终局状态;检查点只覆盖待替换
|
||||
节点的父路径,旧节点及旧分支保持不可变。
|
||||
|
||||
## 验证
|
||||
|
||||
- Rust workspace:**215** 项测试通过。
|
||||
- Contracts:1。
|
||||
- Domain:5。
|
||||
- Engine:21。
|
||||
- Runtime:106。
|
||||
- Store:65。
|
||||
- Tauri 后端:17。
|
||||
- `cargo clippy --workspace --all-targets -- -D warnings`:通过。
|
||||
- Web:5 个测试文件 / **29** 项测试、TypeScript 严格检查与生产构建通过。
|
||||
- 契约:**25** 份 JSON Schema 与 TypeScript DTO 无漂移。
|
||||
|
||||
测试覆盖包括预算边界、prompt schema v3 编排、同模型严格摘要工具、滚动与增量摘要、
|
||||
检查点哈希和失效、兄弟分支隔离、schema v1 / v2 迁移、缓存删除、500 节点祖先链、
|
||||
节点与检查点原子提交、压缩后取消不移动线路头、隐藏检定工具单次暴露,以及合法但
|
||||
会阻断后续上下文的超长回复在提交前被拒绝。
|
||||
|
||||
## 尚未关闭
|
||||
|
||||
- 尚未使用用户实际 LAPP profile、Vault 凭据和在线模型完成端到端调用;本报告不宣称
|
||||
在线 LAPP 冒烟通过。
|
||||
- 本轮没有完成窗口截图或人工视觉检查;本报告不宣称视觉验收通过。
|
||||
- Gitea SSH 推送在当前执行环境中仍未验证;本轮成果当前只能视为本地工作树状态,
|
||||
不能宣称已经同步到远端。
|
||||
@@ -0,0 +1,72 @@
|
||||
# M2 第七波 Windows 基线与上下文前置状态
|
||||
|
||||
日期:2026-07-29
|
||||
|
||||
## 本机 Windows 基线
|
||||
|
||||
- 已安装 Visual Studio 2022 Build Tools 17.14、MSVC x64 工具链与 Windows 11 SDK
|
||||
10.0.26100;`cl.exe`、`link.exe`、`rc.exe` 均可用。
|
||||
- `scripts/windows-smoke.ps1` 现在会通过 `vswhere` 选择具备 C++ 工具链的 Visual
|
||||
Studio,并自动载入 x64 开发环境。普通 PowerShell 不再需要先手工运行
|
||||
`VsDevCmd.bat`。
|
||||
- 使用现有应用图标生成 Tauri 的 Windows ICO、macOS ICNS、Linux PNG 及后续移动端
|
||||
图标集合,关闭了 Windows 资源编译缺少 `icon.ico` 的阻塞。
|
||||
- release 模式的 Windows 桌面程序已构建:
|
||||
`target/release/nana-story-app.exe`。
|
||||
|
||||
## Task 5 前置契约
|
||||
|
||||
### 模型预算
|
||||
|
||||
- `OpenLappChatExecutor` 从实际选中的 LAPP 模型读取 `context_window` 与
|
||||
`max_output_tokens`。
|
||||
- 缺少或无效模型元数据时显式标为 `Assumed`,使用 16,384 / 4,096 的保守 V1 回退;
|
||||
被应用上限或窗口边界收紧时标为 `Capped` 并保留原始来源。
|
||||
- 预算值只能通过校验构造,始终保证输出预算大于零且小于上下文窗口;上层可读取来源、
|
||||
fallback 与 cap 诊断。
|
||||
- 普通回合、隐藏判定初始调用和所有工具续调用使用同一个模型预算;单回合输出上限为
|
||||
4,096 tokens。
|
||||
|
||||
### 检查点来源
|
||||
|
||||
- 增加稳定前缀与叙事来源的强类型 SHA-256 指纹。
|
||||
- 稳定前缀显式覆盖角色、Persona、剧情模块与绑定世界书的版本来源;持久化读取只接受
|
||||
规范化的 `sha256:` 小写十六进制值。
|
||||
- 叙事来源只接受连续的根到目标节点路径,覆盖节点 ID、父节点、玩家实际输入、公开场景、
|
||||
角色视觉状态和完整演出节拍。
|
||||
- 状态 delta、精确关系、隐藏判定、NPC 私物、未选择建议和兄弟分支均不能进入来源投影。
|
||||
- [上下文检查点设计](../context-checkpoint-design.md) 已冻结预算、提示布局、SQLite v3、
|
||||
失效规则和安全边界。
|
||||
|
||||
### 重新生成
|
||||
|
||||
- `Regenerate` 的叙事历史截止到待替换节点的父节点;模型使用目标节点的安全 post-state
|
||||
与原判定定性结果生成兄弟节点,不再接收旧演出或骰点细节。
|
||||
- 原玩家行动标识、authoritative delta、隐藏判定、终局状态与物化状态原样复用;模型
|
||||
只能替换演出,也不能让成功 / 失败结果反转。
|
||||
- 终局节点允许重生成演出,但普通继续行动仍会被终局保护拦截。
|
||||
- 新版本使用独立分支并切为活动线路,旧节点、旧线路和后代保持不可变。
|
||||
- Memory 与 SQLite 均在单次原子操作中校验来源线路、节点、delta 和物化状态。
|
||||
|
||||
## 验证
|
||||
|
||||
- Rust workspace:171 项测试通过。
|
||||
- Runtime:83 项。
|
||||
- Store:44 项。
|
||||
- Tauri 后端:17 项。
|
||||
- Domain / Engine / Contracts:27 项。
|
||||
- `cargo clippy --workspace --all-targets -- -D warnings`:通过。
|
||||
- Rust 契约生成器 `--check`:通过。
|
||||
- Web:5 个测试文件 / 29 项测试、TypeScript 检查与生产构建通过。
|
||||
- 契约:25 份 Schema 与 TypeScript DTO 无漂移。
|
||||
- `pnpm tauri build --no-bundle`:通过,生成 Windows release 可执行文件。
|
||||
- 隔离 Demo 已启动,窗口枚举标题为《听娜娜讲故事》,进程保持响应并创建独立 SQLite
|
||||
存档。为重建 release 文件现已关闭该进程;隔离存档仍保留。自动截图组件不支持该
|
||||
Tauri 窗口,因此本报告不宣称视觉验收完成。
|
||||
|
||||
## 尚未关闭
|
||||
|
||||
- 当前执行环境仍会关闭 Gitea SSH 2222 连接,本地提交暂时不能推送。
|
||||
- SQLite v3 检查点表、同模型摘要工具、500 节点滚动压缩与实际预算编排仍属于 Task 5
|
||||
主体。
|
||||
- Demo 重启恢复、终局 / 双线路人工操作和真实 LAPP 在线调用仍待后续冒烟。
|
||||
@@ -0,0 +1,137 @@
|
||||
# Windows 开发机冒烟
|
||||
|
||||
这份流程用于在真实 Windows 桌面环境关闭三道门:仓库全量验证、Tauri 桌面编译,以及
|
||||
窗口/SQLite/LAPP 的人工冒烟。`scripts/windows-smoke.ps1` 不负责克隆、Git 认证、
|
||||
工具链安装或 LAPP 凭据配置;它没有 API Key、Token 或密码参数,也不会读取、打印或
|
||||
保存这些值。
|
||||
|
||||
## 1. 在脚本之外准备仓库
|
||||
|
||||
先用 SSH Key 或 Git Credential Manager 完成 Gitea 认证,再在单独的终端克隆仓库。
|
||||
不要把 Token 或密码写进 Git URL、命令参数、脚本、仓库文件或回传日志。
|
||||
|
||||
目录必须保持相邻:
|
||||
|
||||
```text
|
||||
workspace\
|
||||
├─ lapp-rs\
|
||||
└─ nana-story\
|
||||
```
|
||||
|
||||
示例命令中的地址不含凭据:
|
||||
|
||||
```powershell
|
||||
git clone --branch integration/v1 https://git.klarkxy.xyz/klarkxy/nana-story.git
|
||||
git clone https://github.com/openlapp/lapp-rs.git
|
||||
git -C .\lapp-rs checkout 5ba3c659e1536ec4bee16340faca603940a5cb17
|
||||
```
|
||||
|
||||
还需预先安装 Windows 的 Tauri 2 原生开发依赖、Microsoft C++ Build Tools、WebView2、
|
||||
Git、rustup、Rust 1.96.0 MSVC host(含 `rustfmt` 和 `clippy`)、Node.js 24+,以及
|
||||
`package.json` 指定版本的 pnpm。脚本会通过 `vswhere` 自动载入 x64 C++ 开发环境并
|
||||
检查 `cl.exe`、`link.exe` 与 `rc.exe`,但不会自动安装或升级工具链。下文使用 PowerShell
|
||||
7 的 `pwsh`;脚本也只使用 Windows PowerShell 5.1 支持的语法,可将 `pwsh` 换成
|
||||
`powershell.exe`。
|
||||
|
||||
## 2. 跑机械门禁
|
||||
|
||||
第一次安装 JavaScript 依赖时,必须显式给出开关:
|
||||
|
||||
```powershell
|
||||
pwsh -NoProfile -File .\scripts\windows-smoke.ps1 -InstallDependencies
|
||||
```
|
||||
|
||||
后续运行不会修改依赖:
|
||||
|
||||
```powershell
|
||||
pwsh -NoProfile -File .\scripts\windows-smoke.ps1
|
||||
```
|
||||
|
||||
脚本依次执行:
|
||||
|
||||
1. 检查 Git、Rust 1.96.0 MSVC host、`rustfmt`、`clippy`、Node.js 24+ 和精确 pnpm 版本;
|
||||
2. 从 `origin` 获取最新 `integration/v1`,检查本地分支干净、正确跟踪该远端且与
|
||||
最新远端提交一致,并打印被测提交;Git 认证或网络失败会直接停止;
|
||||
3. 检查相邻 `lapp-rs` 的 origin、提交和工作树都与 `lapp-rs.lock` 固定来源一致;
|
||||
4. 检查现有 `node_modules` 的 pnpm 版本及内置锁文件与仓库 `pnpm-lock.yaml` 一致;
|
||||
5. 执行非交互式 `pnpm verify`;
|
||||
6. 执行 `pnpm tauri build --no-bundle`,验证真实 Windows 桌面目标。
|
||||
|
||||
任一步失败都会停止,且不会继续启动应用。
|
||||
|
||||
## 3. 启动桌面窗口
|
||||
|
||||
先用不需要 LAPP 的确定性模式验证窗口和存档:
|
||||
|
||||
```powershell
|
||||
$smokeData = Join-Path ([System.IO.Path]::GetTempPath()) "nana-story-wave6-demo"
|
||||
pwsh -NoProfile -File .\scripts\windows-smoke.ps1 -Launch -Demo -SmokeDataPath $smokeData
|
||||
```
|
||||
|
||||
`-Launch` 只会在所有机械门禁通过后执行 `pnpm tauri dev`。关闭窗口会结束命令。
|
||||
`-Demo` 只影响这次进程,不会写入系统配置。`-SmokeDataPath` 必须指向 Windows 临时
|
||||
目录的子目录;应用只在这里创建冒烟数据库,不会碰正常的开发存档。复用同一路径用于
|
||||
重启恢复测试;想从全新数据库开始时换一个新的目录名,脚本本身不会删除任何目录。
|
||||
脚本在创建前后都会拒绝路径链中的 junction、符号链接和其他 reparse point,应用还会
|
||||
再次 canonicalize 并验证最终路径。
|
||||
|
||||
在线冒烟前,应在系统 LAPP 中另行准备默认 profile:
|
||||
|
||||
- 至少一个已启用模型同时声明 `chat` 与 `tool-call` 能力;
|
||||
- 供应商凭据能由系统 Vault 解析;
|
||||
- 凭据不进入 `nana-story` 设置、仓库、命令行或回传材料。
|
||||
|
||||
准备好后,不带 `-Demo` 启动:
|
||||
|
||||
```powershell
|
||||
$lappSmokeData = Join-Path ([System.IO.Path]::GetTempPath()) "nana-story-wave6-lapp"
|
||||
pwsh -NoProfile -File .\scripts\windows-smoke.ps1 -Launch -SmokeDataPath $lappSmokeData
|
||||
```
|
||||
|
||||
打开设置页,确认显示“LAPP 已就绪”并能选择预期模型。“测试当前连接”只验证最小聊天
|
||||
请求;通过后仍需提交一轮包含隐藏判定的行动,才能验证真实工具调用。只记录成功/失败
|
||||
和界面错误码;不要复制 profile、Vault 内容、请求头或供应商原始响应。
|
||||
|
||||
在线模式还需验证一次取消恢复:
|
||||
|
||||
1. 提交一条会触发模型生成、但尚未改变故事节点的行动;
|
||||
2. 在生成完成前点击“停止生成”,确认界面显示本轮未写入、节点与存档状态不变;
|
||||
3. 点击唯一一次“重试本轮”,确认同一行动能完整完成;
|
||||
4. 若停止请求输给了提交边界,界面应明确显示完整回合已安全写入,不能同时显示“未写入”;
|
||||
5. 再提交一条会触发隐藏判定的行动,确认工具调用结束后只产生一个新故事节点。
|
||||
|
||||
若 Windows Vault 自身永久卡住,应用会结束当前回合并阻止新的原生模型调用;此时关闭
|
||||
应用、修复 Vault 后再启动。Rust 无法强杀已进入系统原生凭据调用的线程。
|
||||
|
||||
## 4. SQLite 与重启冒烟
|
||||
|
||||
确定性模式中按界面建议完成以下检查:
|
||||
|
||||
1. 接受“天亮前回来”的许诺,继续调查并取得半张旧车票;
|
||||
2. 关闭窗口,再次使用同一个 `$smokeData` 执行
|
||||
`-Launch -Demo -SmokeDataPath $smokeData`;
|
||||
3. 确认场景、时钟、许诺、物品和当前线路恢复到关闭前状态;
|
||||
4. 完成进入隧道和天亮前归来的终局;
|
||||
5. 从历史节点创建新线路,重命名并切换到新线路;
|
||||
6. 再次关闭和启动,确认活动线路仍是新线路;
|
||||
7. 切回原线路,确认终局、许诺、物品和节点没有被新线路污染。
|
||||
|
||||
冒烟数据库文件名是 `nana-story.sqlite3`,位于本轮显式传入的 `$smokeData`,正常的
|
||||
`$env:APPDATA\dev.nanastory.app\` 不会被读写。不要编辑或上传数据库;本轮只确认文件
|
||||
存在、重启可恢复且线路互相隔离。
|
||||
|
||||
## 5. 回传结果
|
||||
|
||||
请回传以下信息:
|
||||
|
||||
- Windows 版本与 CPU 架构;
|
||||
- 脚本的全部 `[ok]`/`[run]` 结果及最终退出码;
|
||||
- `pnpm verify` 与 `pnpm tauri build --no-bundle` 是通过还是首个失败阶段;
|
||||
- 窗口是否启动,关闭后命令是否正常退出;
|
||||
- Demo 的两次重启恢复、终局与双线路隔离是否通过;
|
||||
- 在线 LAPP 的 profile 就绪状态、所选 provider/model 标识,以及一轮工具调用是否成功;
|
||||
- 在线回合的停止、节点不变、一次重试和取消/提交竞态提示是否符合上述语义;
|
||||
- 若失败,附首个错误的文本或截图和复现步骤。
|
||||
|
||||
发送前删除用户名、绝对路径和其他个人信息。不要发送 API Key、Token、密码、Git
|
||||
凭据、LAPP profile/Vault 文件、数据库文件、完整供应商响应或完整环境变量列表。
|
||||
@@ -9,7 +9,8 @@ const schemaDirectory = resolve(root, "contracts/schema");
|
||||
|
||||
const source = await readFile(sourcePath);
|
||||
const expectedHash = (await readFile(hashPath, "utf8")).trim();
|
||||
const actualHash = createHash("sha256").update(source).digest("hex");
|
||||
const normalizedSource = source.toString("utf8").replaceAll("\r\n", "\n");
|
||||
const actualHash = createHash("sha256").update(normalizedSource).digest("hex");
|
||||
|
||||
if (actualHash !== expectedHash) {
|
||||
throw new Error(
|
||||
@@ -23,28 +24,36 @@ const schemaFiles = (await readdir(schemaDirectory))
|
||||
|
||||
const required = [
|
||||
"app-info.schema.json",
|
||||
"branch-list.schema.json",
|
||||
"character-card.schema.json",
|
||||
"demo-pack-summary.schema.json",
|
||||
"fork-branch-request.schema.json",
|
||||
"fork-branch-result.schema.json",
|
||||
"item-spec.schema.json",
|
||||
"lapp-connection-test-result.schema.json",
|
||||
"lapp-settings.schema.json",
|
||||
"persona.schema.json",
|
||||
"player-view.schema.json",
|
||||
"plot-module.schema.json",
|
||||
"presentation-snapshot.schema.json",
|
||||
"rename-branch-request.schema.json",
|
||||
"resource-bundle.schema.json",
|
||||
"resource-header.schema.json",
|
||||
"runtime-state.schema.json",
|
||||
"story-node.schema.json",
|
||||
"switch-branch-request.schema.json",
|
||||
"switch-branch-result.schema.json",
|
||||
"turn-failure.schema.json",
|
||||
"turn-request.schema.json",
|
||||
"turn-result.schema.json",
|
||||
"update-lapp-settings-request.schema.json",
|
||||
"world-book.schema.json"
|
||||
];
|
||||
|
||||
for (const name of required) {
|
||||
if (!schemaFiles.includes(name)) {
|
||||
throw new Error(`Missing generated schema: contracts/schema/${name}`);
|
||||
}
|
||||
if (JSON.stringify(schemaFiles) !== JSON.stringify(required)) {
|
||||
throw new Error(
|
||||
`Generated schema manifest mismatch.\nExpected: ${required.join(", ")}\nActual: ${schemaFiles.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
for (const name of schemaFiles) {
|
||||
|
||||
@@ -0,0 +1,568 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Runs the Windows developer smoke gates for nana-story.
|
||||
|
||||
.DESCRIPTION
|
||||
Checks the pinned developer toolchain and adjacent lapp-rs checkout, optionally
|
||||
installs JavaScript dependencies, then runs the repository verification and a
|
||||
non-bundled Tauri production build. With -Launch, it starts `pnpm tauri dev`
|
||||
after all gates pass.
|
||||
|
||||
This script deliberately has no credential parameters. Git authentication and
|
||||
LAPP profile/Vault setup must be completed outside this process.
|
||||
|
||||
.PARAMETER InstallDependencies
|
||||
Runs `pnpm install --frozen-lockfile`. Without this switch, the script never
|
||||
installs dependencies and requires an existing pnpm node_modules layout.
|
||||
|
||||
.PARAMETER Launch
|
||||
Starts the desktop application with `pnpm tauri dev` after verification.
|
||||
|
||||
.PARAMETER Demo
|
||||
Uses the deterministic demo provider for the optional desktop launch. This
|
||||
switch is only valid together with -Launch.
|
||||
|
||||
.PARAMETER SmokeDataPath
|
||||
Absolute child path below the Windows temporary directory used as isolated app
|
||||
data for -Launch. Reuse the same path to test restart recovery; choose a new
|
||||
path to start from a fresh database. The script never deletes this directory.
|
||||
#>
|
||||
[CmdletBinding(PositionalBinding = $false)]
|
||||
param(
|
||||
[switch]$InstallDependencies,
|
||||
[switch]$Launch,
|
||||
[switch]$Demo,
|
||||
[string]$SmokeDataPath
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Find-NativeCommand {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string[]]$Names,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$DisplayName
|
||||
)
|
||||
|
||||
foreach ($name in $Names) {
|
||||
$command = Get-Command -Name $name -CommandType Application -ErrorAction SilentlyContinue |
|
||||
Select-Object -First 1
|
||||
if ($null -ne $command) {
|
||||
return $command.Source
|
||||
}
|
||||
}
|
||||
|
||||
throw "$DisplayName was not found on PATH. Install it outside this script and retry."
|
||||
}
|
||||
|
||||
function Invoke-NativeCapture {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$FilePath,
|
||||
|
||||
[string[]]$ArgumentList = @(),
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Label
|
||||
)
|
||||
|
||||
$output = @(& $FilePath @ArgumentList 2>&1)
|
||||
$exitCode = $LASTEXITCODE
|
||||
$text = ($output | ForEach-Object { $_.ToString() }) -join [Environment]::NewLine
|
||||
if ($exitCode -ne 0) {
|
||||
throw "$Label failed with exit code $exitCode. $text"
|
||||
}
|
||||
|
||||
return $text.Trim()
|
||||
}
|
||||
|
||||
function Invoke-NativeChecked {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$FilePath,
|
||||
|
||||
[string[]]$ArgumentList = @(),
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Label
|
||||
)
|
||||
|
||||
Write-Host ("[run] {0}" -f $Label)
|
||||
& $FilePath @ArgumentList
|
||||
$exitCode = $LASTEXITCODE
|
||||
if ($exitCode -ne 0) {
|
||||
throw "$Label failed with exit code $exitCode."
|
||||
}
|
||||
}
|
||||
|
||||
function Normalize-GitRemote {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Url
|
||||
)
|
||||
|
||||
$normalized = $Url.Trim()
|
||||
if ($normalized -match "^git@([^:]+):(.+)$") {
|
||||
$normalized = "https://$($Matches[1])/$($Matches[2])"
|
||||
}
|
||||
elseif ($normalized -match "^ssh://git@([^/]+)/(.+)$") {
|
||||
$normalized = "https://$($Matches[1])/$($Matches[2])"
|
||||
}
|
||||
$normalized = $normalized.TrimEnd("/")
|
||||
if ($normalized.EndsWith(".git", [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
$normalized = $normalized.Substring(0, $normalized.Length - 4)
|
||||
}
|
||||
return $normalized.ToLowerInvariant()
|
||||
}
|
||||
|
||||
function Assert-TemporaryChildWithoutReparsePoint {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$TemporaryRoot,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$TargetPath
|
||||
)
|
||||
|
||||
$root = [System.IO.Path]::GetFullPath($TemporaryRoot).TrimEnd(
|
||||
[System.IO.Path]::DirectorySeparatorChar,
|
||||
[System.IO.Path]::AltDirectorySeparatorChar
|
||||
)
|
||||
$target = [System.IO.Path]::GetFullPath($TargetPath)
|
||||
$rootPrefix = $root + [System.IO.Path]::DirectorySeparatorChar
|
||||
if (-not $target.StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "-SmokeDataPath must be a child of the Windows temporary directory."
|
||||
}
|
||||
if (
|
||||
(Test-Path -LiteralPath $target) -and
|
||||
-not (Test-Path -LiteralPath $target -PathType Container)
|
||||
) {
|
||||
throw "-SmokeDataPath exists but is not a directory."
|
||||
}
|
||||
|
||||
$existingAncestor = $target
|
||||
while (-not (Test-Path -LiteralPath $existingAncestor -PathType Container)) {
|
||||
$parent = [System.IO.Directory]::GetParent($existingAncestor)
|
||||
if ($null -eq $parent) {
|
||||
throw "Could not resolve an existing ancestor for -SmokeDataPath."
|
||||
}
|
||||
$existingAncestor = $parent.FullName
|
||||
}
|
||||
if (
|
||||
-not [string]::Equals(
|
||||
$existingAncestor,
|
||||
$root,
|
||||
[System.StringComparison]::OrdinalIgnoreCase
|
||||
) -and
|
||||
-not $existingAncestor.StartsWith(
|
||||
$rootPrefix,
|
||||
[System.StringComparison]::OrdinalIgnoreCase
|
||||
)
|
||||
) {
|
||||
throw "-SmokeDataPath resolves through an ancestor outside the temporary directory."
|
||||
}
|
||||
|
||||
if ($existingAncestor.Length -gt $rootPrefix.Length) {
|
||||
$relativeAncestor = $existingAncestor.Substring($rootPrefix.Length)
|
||||
$cursor = $root
|
||||
foreach ($part in ($relativeAncestor -split "[\\/]")) {
|
||||
if ([string]::IsNullOrWhiteSpace($part)) {
|
||||
continue
|
||||
}
|
||||
$cursor = Join-Path -Path $cursor -ChildPath $part
|
||||
$attributes = (Get-Item -LiteralPath $cursor -Force).Attributes
|
||||
if (($attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) {
|
||||
throw "-SmokeDataPath must not traverse a junction, symlink, or other reparse point."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $target
|
||||
}
|
||||
|
||||
function Import-VisualStudioBuildEnvironment {
|
||||
$originalPath = $env:Path
|
||||
$programFilesX86 = [Environment]::GetFolderPath(
|
||||
[Environment+SpecialFolder]::ProgramFilesX86
|
||||
)
|
||||
$vswherePath = Join-Path `
|
||||
-Path $programFilesX86 `
|
||||
-ChildPath "Microsoft Visual Studio\Installer\vswhere.exe"
|
||||
if (-not (Test-Path -LiteralPath $vswherePath -PathType Leaf)) {
|
||||
throw "Visual Studio Installer's vswhere.exe was not found. Install Microsoft C++ Build Tools."
|
||||
}
|
||||
|
||||
$installationPath = (
|
||||
& $vswherePath `
|
||||
-latest `
|
||||
-products "*" `
|
||||
-requires "Microsoft.VisualStudio.Component.VC.Tools.x86.x64" `
|
||||
-property installationPath
|
||||
).Trim()
|
||||
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($installationPath)) {
|
||||
throw "Microsoft C++ Build Tools were not found."
|
||||
}
|
||||
|
||||
$devCommandPath = Join-Path `
|
||||
-Path $installationPath `
|
||||
-ChildPath "Common7\Tools\VsDevCmd.bat"
|
||||
if (-not (Test-Path -LiteralPath $devCommandPath -PathType Leaf)) {
|
||||
throw "VsDevCmd.bat was not found in the selected Visual Studio installation."
|
||||
}
|
||||
|
||||
$commandLine = 'call "' + $devCommandPath + '" -arch=x64 -host_arch=x64 >nul && set'
|
||||
$environmentLines = @(& $env:ComSpec /d /c $commandLine)
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Visual Studio x64 developer environment initialization failed."
|
||||
}
|
||||
|
||||
$developerPath = $null
|
||||
foreach ($line in $environmentLines) {
|
||||
$separator = $line.IndexOf("=")
|
||||
if ($separator -le 0) {
|
||||
continue
|
||||
}
|
||||
if ($line.StartsWith("PATH=", [System.StringComparison]::Ordinal)) {
|
||||
$developerPath = $line.Substring($separator + 1)
|
||||
continue
|
||||
}
|
||||
[Environment]::SetEnvironmentVariable(
|
||||
$line.Substring(0, $separator),
|
||||
$line.Substring($separator + 1),
|
||||
"Process"
|
||||
)
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($developerPath)) {
|
||||
throw "Visual Studio did not publish a developer PATH."
|
||||
}
|
||||
$pathSegments = @($developerPath)
|
||||
$userProfilePath = $env:USERPROFILE
|
||||
if ([string]::IsNullOrWhiteSpace($userProfilePath)) {
|
||||
$userProfilePath = [Environment]::GetFolderPath(
|
||||
[Environment+SpecialFolder]::UserProfile
|
||||
)
|
||||
}
|
||||
$cargoBinPath = Join-Path `
|
||||
-Path $userProfilePath `
|
||||
-ChildPath ".cargo\bin"
|
||||
if (Test-Path -LiteralPath (Join-Path -Path $cargoBinPath -ChildPath "rustup.exe") -PathType Leaf) {
|
||||
$pathSegments += $cargoBinPath
|
||||
}
|
||||
$pathSegments += $originalPath
|
||||
$env:Path = $pathSegments -join [System.IO.Path]::PathSeparator
|
||||
|
||||
foreach ($requiredTool in @("cl.exe", "link.exe", "rc.exe")) {
|
||||
$tool = Get-Command `
|
||||
-Name $requiredTool `
|
||||
-CommandType Application `
|
||||
-ErrorAction SilentlyContinue |
|
||||
Select-Object -First 1
|
||||
if ($null -eq $tool) {
|
||||
throw "$requiredTool was not found after loading Visual Studio Build Tools and the Windows SDK."
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ("[ok] Visual Studio x64 C++ Build Tools and Windows SDK from {0}" -f $installationPath)
|
||||
}
|
||||
|
||||
if ($Demo -and -not $Launch) {
|
||||
throw "-Demo is only valid together with -Launch."
|
||||
}
|
||||
if ($Launch -and [string]::IsNullOrWhiteSpace($SmokeDataPath)) {
|
||||
throw "-Launch requires -SmokeDataPath so smoke runs cannot touch the normal app database."
|
||||
}
|
||||
if (-not $Launch -and -not [string]::IsNullOrWhiteSpace($SmokeDataPath)) {
|
||||
throw "-SmokeDataPath is only valid together with -Launch."
|
||||
}
|
||||
|
||||
Import-VisualStudioBuildEnvironment
|
||||
|
||||
$projectRoot = [System.IO.Path]::GetFullPath((Join-Path -Path $PSScriptRoot -ChildPath ".."))
|
||||
$packageJsonPath = Join-Path -Path $projectRoot -ChildPath "package.json"
|
||||
$lappLockPath = Join-Path -Path $projectRoot -ChildPath "lapp-rs.lock"
|
||||
$modulesManifestPath = Join-Path -Path $projectRoot -ChildPath "node_modules\.modules.yaml"
|
||||
$installedLockPath = Join-Path -Path $projectRoot -ChildPath "node_modules\.pnpm\lock.yaml"
|
||||
$workspaceLockPath = Join-Path -Path $projectRoot -ChildPath "pnpm-lock.yaml"
|
||||
|
||||
if (-not (Test-Path -LiteralPath $packageJsonPath -PathType Leaf)) {
|
||||
throw "package.json was not found next to the scripts directory."
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $lappLockPath -PathType Leaf)) {
|
||||
throw "lapp-rs.lock was not found in the project root."
|
||||
}
|
||||
|
||||
$gitPath = Find-NativeCommand -Names @("git.exe", "git") -DisplayName "Git"
|
||||
$rustupPath = Find-NativeCommand -Names @("rustup.exe", "rustup") -DisplayName "rustup"
|
||||
$nodePath = Find-NativeCommand -Names @("node.exe", "node") -DisplayName "Node.js"
|
||||
$pnpmPath = Find-NativeCommand -Names @("pnpm.cmd", "pnpm.exe", "pnpm") -DisplayName "pnpm"
|
||||
|
||||
$gitVersion = Invoke-NativeCapture -FilePath $gitPath -ArgumentList @("--version") -Label "Git version check"
|
||||
Write-Host ("[ok] {0}" -f $gitVersion)
|
||||
|
||||
$installedToolchains = Invoke-NativeCapture `
|
||||
-FilePath $rustupPath `
|
||||
-ArgumentList @("toolchain", "list") `
|
||||
-Label "rustup toolchain check"
|
||||
if ($installedToolchains -notmatch "(?m)^1\.96\.0(?:-|\s|$)") {
|
||||
throw "Rust 1.96.0 is not installed. Install that toolchain outside this script and retry."
|
||||
}
|
||||
|
||||
$rustVersion = Invoke-NativeCapture `
|
||||
-FilePath $rustupPath `
|
||||
-ArgumentList @("run", "1.96.0", "rustc", "--version") `
|
||||
-Label "Rust 1.96 version check"
|
||||
if ($rustVersion -notmatch "^rustc 1\.96\.0(?:\s|$)") {
|
||||
throw "Expected rustc 1.96.0, received: $rustVersion"
|
||||
}
|
||||
$rustVerboseVersion = Invoke-NativeCapture `
|
||||
-FilePath $rustupPath `
|
||||
-ArgumentList @("run", "1.96.0", "rustc", "-vV") `
|
||||
-Label "Rust host check"
|
||||
if ($rustVerboseVersion -notmatch "(?m)^host:\s+\S+-pc-windows-msvc\s*$") {
|
||||
throw "Rust 1.96.0 must use a *-pc-windows-msvc host for this Windows smoke."
|
||||
}
|
||||
|
||||
$installedComponents = Invoke-NativeCapture `
|
||||
-FilePath $rustupPath `
|
||||
-ArgumentList @("component", "list", "--toolchain", "1.96.0", "--installed") `
|
||||
-Label "Rust component check"
|
||||
if ($installedComponents -notmatch "(?m)^rustfmt-") {
|
||||
throw "rustfmt is missing from Rust 1.96.0. Install it outside this script and retry."
|
||||
}
|
||||
if ($installedComponents -notmatch "(?m)^clippy-") {
|
||||
throw "clippy is missing from Rust 1.96.0. Install it outside this script and retry."
|
||||
}
|
||||
Write-Host ("[ok] {0}; rustfmt and clippy are installed" -f $rustVersion)
|
||||
|
||||
$nodeVersion = Invoke-NativeCapture -FilePath $nodePath -ArgumentList @("--version") -Label "Node.js version check"
|
||||
if ($nodeVersion -notmatch "^v(\d+)\.(\d+)\.(\d+)") {
|
||||
throw "Could not parse the Node.js version: $nodeVersion"
|
||||
}
|
||||
$nodeMajor = [int]$Matches[1]
|
||||
if ($nodeMajor -lt 24) {
|
||||
throw "Node.js 24 or newer is required; received $nodeVersion."
|
||||
}
|
||||
Write-Host ("[ok] Node.js {0}" -f $nodeVersion)
|
||||
|
||||
$packageManifest = Get-Content -LiteralPath $packageJsonPath -Raw | ConvertFrom-Json
|
||||
$packageManager = [string]$packageManifest.packageManager
|
||||
if ($packageManager -notmatch "^pnpm@(.+)$") {
|
||||
throw "package.json does not declare an exact pnpm packageManager version."
|
||||
}
|
||||
$expectedPnpmVersion = $Matches[1]
|
||||
$pnpmVersion = Invoke-NativeCapture -FilePath $pnpmPath -ArgumentList @("--version") -Label "pnpm version check"
|
||||
if ($pnpmVersion -ne $expectedPnpmVersion) {
|
||||
throw "pnpm $expectedPnpmVersion is required; received $pnpmVersion."
|
||||
}
|
||||
Write-Host ("[ok] pnpm {0}" -f $pnpmVersion)
|
||||
|
||||
$insideWorkTree = Invoke-NativeCapture `
|
||||
-FilePath $gitPath `
|
||||
-ArgumentList @("-C", $projectRoot, "rev-parse", "--is-inside-work-tree") `
|
||||
-Label "nana-story Git checkout check"
|
||||
if ($insideWorkTree -ne "true") {
|
||||
throw "The project directory is not a Git working tree."
|
||||
}
|
||||
$projectBranch = Invoke-NativeCapture `
|
||||
-FilePath $gitPath `
|
||||
-ArgumentList @("-C", $projectRoot, "branch", "--show-current") `
|
||||
-Label "nana-story branch check"
|
||||
if ($projectBranch -ne "integration/v1") {
|
||||
throw "nana-story must be checked out on integration/v1; received '$projectBranch'."
|
||||
}
|
||||
$projectCommit = Invoke-NativeCapture `
|
||||
-FilePath $gitPath `
|
||||
-ArgumentList @("-C", $projectRoot, "rev-parse", "HEAD") `
|
||||
-Label "nana-story commit check"
|
||||
if ($projectCommit -notmatch "^[0-9a-fA-F]{40}$") {
|
||||
throw "nana-story HEAD is not a valid Git commit."
|
||||
}
|
||||
$projectWorkTreeStatus = Invoke-NativeCapture `
|
||||
-FilePath $gitPath `
|
||||
-ArgumentList @("-C", $projectRoot, "status", "--porcelain=v1", "--untracked-files=all") `
|
||||
-Label "nana-story clean-worktree check"
|
||||
if (-not [string]::IsNullOrWhiteSpace($projectWorkTreeStatus)) {
|
||||
throw "nana-story has tracked, staged, or untracked changes; smoke evidence would be ambiguous."
|
||||
}
|
||||
$projectUpstream = Invoke-NativeCapture `
|
||||
-FilePath $gitPath `
|
||||
-ArgumentList @("-C", $projectRoot, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}") `
|
||||
-Label "nana-story upstream check"
|
||||
if ($projectUpstream -ne "origin/integration/v1") {
|
||||
throw "integration/v1 must track origin/integration/v1; received '$projectUpstream'."
|
||||
}
|
||||
Invoke-NativeChecked `
|
||||
-FilePath $gitPath `
|
||||
-ArgumentList @(
|
||||
"-C",
|
||||
$projectRoot,
|
||||
"fetch",
|
||||
"--prune",
|
||||
"origin",
|
||||
"+refs/heads/integration/v1:refs/remotes/origin/integration/v1"
|
||||
) `
|
||||
-Label "fetch current origin/integration/v1"
|
||||
$projectUpstreamCommit = Invoke-NativeCapture `
|
||||
-FilePath $gitPath `
|
||||
-ArgumentList @("-C", $projectRoot, "rev-parse", $projectUpstream) `
|
||||
-Label "nana-story upstream commit check"
|
||||
if ($projectUpstreamCommit -ne $projectCommit) {
|
||||
throw "nana-story is ahead of or behind its local origin/integration/v1 reference. Pull the published branch and retry."
|
||||
}
|
||||
Write-Host (
|
||||
"[ok] nana-story integration/v1 at {0}" -f $projectCommit.Substring(0, 12)
|
||||
)
|
||||
|
||||
$lappLock = Get-Content -LiteralPath $lappLockPath -Raw
|
||||
if ($lappLock -notmatch '(?m)^\s*commit\s*=\s*"([0-9a-fA-F]{40})"\s*$') {
|
||||
throw "lapp-rs.lock does not contain a valid pinned commit."
|
||||
}
|
||||
$expectedLappCommit = $Matches[1].ToLowerInvariant()
|
||||
if ($lappLock -notmatch '(?m)^\s*repository\s*=\s*"([^"]+)"\s*$') {
|
||||
throw "lapp-rs.lock does not contain a repository."
|
||||
}
|
||||
$expectedLappRepository = Normalize-GitRemote -Url $Matches[1]
|
||||
if ($lappLock -notmatch '(?m)^\s*development_path\s*=\s*"([^"]+)"\s*$') {
|
||||
throw "lapp-rs.lock does not contain a development_path."
|
||||
}
|
||||
$lappDevelopmentPath = $Matches[1]
|
||||
$lappPath = [System.IO.Path]::GetFullPath(
|
||||
(Join-Path -Path $projectRoot -ChildPath $lappDevelopmentPath)
|
||||
)
|
||||
if (-not (Test-Path -LiteralPath (Join-Path -Path $lappPath -ChildPath "Cargo.toml") -PathType Leaf)) {
|
||||
throw "The adjacent lapp-rs checkout required by lapp-rs.lock was not found."
|
||||
}
|
||||
$actualLappCommit = Invoke-NativeCapture `
|
||||
-FilePath $gitPath `
|
||||
-ArgumentList @("-C", $lappPath, "rev-parse", "HEAD") `
|
||||
-Label "lapp-rs commit check"
|
||||
$actualLappCommit = $actualLappCommit.ToLowerInvariant()
|
||||
if ($actualLappCommit -ne $expectedLappCommit) {
|
||||
throw "lapp-rs is not at the commit pinned by lapp-rs.lock."
|
||||
}
|
||||
$actualLappRepository = Invoke-NativeCapture `
|
||||
-FilePath $gitPath `
|
||||
-ArgumentList @("-C", $lappPath, "remote", "get-url", "origin") `
|
||||
-Label "lapp-rs origin check"
|
||||
if ((Normalize-GitRemote -Url $actualLappRepository) -ne $expectedLappRepository) {
|
||||
throw "lapp-rs origin does not match the repository pinned by lapp-rs.lock."
|
||||
}
|
||||
$lappWorkTreeStatus = Invoke-NativeCapture `
|
||||
-FilePath $gitPath `
|
||||
-ArgumentList @("-C", $lappPath, "status", "--porcelain=v1", "--untracked-files=all") `
|
||||
-Label "lapp-rs clean-worktree check"
|
||||
if (-not [string]::IsNullOrWhiteSpace($lappWorkTreeStatus)) {
|
||||
throw "lapp-rs has tracked, staged, or untracked changes; Cargo would not be building the exact pin."
|
||||
}
|
||||
Write-Host ("[ok] lapp-rs matches {0}" -f $expectedLappCommit.Substring(0, 12))
|
||||
|
||||
# Keep all automated gates deterministic and noninteractive. Visual Studio's
|
||||
# developer shell is imported into this process; environment values are never
|
||||
# printed or persisted by this script.
|
||||
$env:CI = "true"
|
||||
$env:NO_COLOR = "1"
|
||||
$env:CARGO_TERM_COLOR = "never"
|
||||
$env:RUSTUP_TOOLCHAIN = "1.96.0"
|
||||
$env:COREPACK_ENABLE_DOWNLOAD_PROMPT = "0"
|
||||
|
||||
$previousStoryDataDir = [Environment]::GetEnvironmentVariable(
|
||||
"NANA_STORY_SMOKE_DATA_DIR",
|
||||
"Process"
|
||||
)
|
||||
$previousStoryProvider = [Environment]::GetEnvironmentVariable("NANA_STORY_PROVIDER", "Process")
|
||||
$resolvedSmokeDataPath = $null
|
||||
if ($Launch) {
|
||||
$temporaryRoot = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath())
|
||||
$resolvedSmokeDataPath = Assert-TemporaryChildWithoutReparsePoint `
|
||||
-TemporaryRoot $temporaryRoot `
|
||||
-TargetPath $SmokeDataPath
|
||||
}
|
||||
|
||||
$locationPushed = $false
|
||||
try {
|
||||
Push-Location -LiteralPath $projectRoot
|
||||
$locationPushed = $true
|
||||
|
||||
if ($InstallDependencies) {
|
||||
Invoke-NativeChecked `
|
||||
-FilePath $pnpmPath `
|
||||
-ArgumentList @("--reporter=append-only", "install", "--frozen-lockfile") `
|
||||
-Label "pnpm install --frozen-lockfile"
|
||||
}
|
||||
|
||||
if (
|
||||
-not (Test-Path -LiteralPath $modulesManifestPath -PathType Leaf) -or
|
||||
-not (Test-Path -LiteralPath $installedLockPath -PathType Leaf)
|
||||
) {
|
||||
throw "Dependencies are missing. Re-run with -InstallDependencies to install them explicitly."
|
||||
}
|
||||
$modulesManifest = Get-Content -LiteralPath $modulesManifestPath -Raw | ConvertFrom-Json
|
||||
if (
|
||||
-not ($modulesManifest.PSObject.Properties.Name -contains "packageManager") -or
|
||||
[string]$modulesManifest.packageManager -ne "pnpm@$expectedPnpmVersion"
|
||||
) {
|
||||
throw "node_modules was installed by a different pnpm version. Re-run with -InstallDependencies."
|
||||
}
|
||||
$workspaceLockHash = (Get-FileHash -LiteralPath $workspaceLockPath -Algorithm SHA256).Hash
|
||||
$installedLockHash = (Get-FileHash -LiteralPath $installedLockPath -Algorithm SHA256).Hash
|
||||
if ($workspaceLockHash -ne $installedLockHash) {
|
||||
throw "node_modules does not match pnpm-lock.yaml. Re-run with -InstallDependencies."
|
||||
}
|
||||
Write-Host "[ok] node_modules matches the pinned pnpm version and lockfile."
|
||||
|
||||
Invoke-NativeChecked `
|
||||
-FilePath $pnpmPath `
|
||||
-ArgumentList @("--reporter=append-only", "verify") `
|
||||
-Label "pnpm verify"
|
||||
|
||||
Invoke-NativeChecked `
|
||||
-FilePath $pnpmPath `
|
||||
-ArgumentList @("--reporter=append-only", "tauri", "build", "--no-bundle") `
|
||||
-Label "pnpm tauri build --no-bundle"
|
||||
|
||||
Write-Host "[ok] Windows developer verification and non-bundled desktop build passed."
|
||||
|
||||
if ($Launch) {
|
||||
New-Item -ItemType Directory -Path $resolvedSmokeDataPath -Force | Out-Null
|
||||
$null = Assert-TemporaryChildWithoutReparsePoint `
|
||||
-TemporaryRoot $temporaryRoot `
|
||||
-TargetPath $resolvedSmokeDataPath
|
||||
$env:NANA_STORY_SMOKE_DATA_DIR = $resolvedSmokeDataPath
|
||||
if ($Demo) {
|
||||
$env:NANA_STORY_PROVIDER = "demo"
|
||||
Write-Host "[run] pnpm tauri dev (deterministic demo provider)"
|
||||
}
|
||||
else {
|
||||
Remove-Item -Path Env:NANA_STORY_PROVIDER -ErrorAction SilentlyContinue
|
||||
Write-Host "[run] pnpm tauri dev (default LAPP provider)"
|
||||
}
|
||||
|
||||
Invoke-NativeChecked `
|
||||
-FilePath $pnpmPath `
|
||||
-ArgumentList @("tauri", "dev") `
|
||||
-Label "pnpm tauri dev"
|
||||
}
|
||||
else {
|
||||
Write-Host "[done] Re-run with -Launch, optionally with -Demo, to open the desktop app."
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if ($locationPushed) {
|
||||
Pop-Location
|
||||
}
|
||||
if ($null -eq $previousStoryDataDir) {
|
||||
Remove-Item -Path Env:NANA_STORY_SMOKE_DATA_DIR -ErrorAction SilentlyContinue
|
||||
}
|
||||
else {
|
||||
$env:NANA_STORY_SMOKE_DATA_DIR = $previousStoryDataDir
|
||||
}
|
||||
if ($null -eq $previousStoryProvider) {
|
||||
Remove-Item -Path Env:NANA_STORY_PROVIDER -ErrorAction SilentlyContinue
|
||||
}
|
||||
else {
|
||||
$env:NANA_STORY_PROVIDER = $previousStoryProvider
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,11 @@ nana-domain.workspace = true
|
||||
nana-engine.workspace = true
|
||||
nana-runtime.workspace = true
|
||||
nana-store.workspace = true
|
||||
openlapp.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tauri = { version = "2", features = [] }
|
||||
tokio.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
|
After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -1,8 +1,12 @@
|
||||
import { flushPromises, mount, type VueWrapper } from "@vue/test-utils";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import App from "./App.vue";
|
||||
|
||||
afterEach(() => {
|
||||
delete window.__TAURI_INTERNALS__;
|
||||
});
|
||||
|
||||
async function mountLoadedApp(): Promise<VueWrapper> {
|
||||
const wrapper = mount(App, { attachTo: document.body });
|
||||
await flushPromises();
|
||||
@@ -52,7 +56,7 @@ describe("App", () => {
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain("我答应你,天亮前一定回来。");
|
||||
expect(wrapper.get(".statusline span").text()).toBe("node_002");
|
||||
expect(wrapper.get(".character").attributes("data-expression")).toBe("uneasy");
|
||||
expect(wrapper.get(".character").attributes("data-expression")).toBe("relieved");
|
||||
expect(wrapper.get(".character").attributes("data-pose")).toBe("holding_coat");
|
||||
});
|
||||
expect(wrapper.get(".send-button").text()).toBe("发送");
|
||||
@@ -73,6 +77,99 @@ describe("App", () => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("cancels an in-flight turn without committing and explicitly retries it", async () => {
|
||||
const wrapper = await mountLoadedApp();
|
||||
const composer = wrapper.get<HTMLTextAreaElement>('textarea[aria-label="自由输入"]');
|
||||
const startingNode = wrapper.get(".statusline span").text();
|
||||
|
||||
await composer.setValue("我先听听雨声。");
|
||||
await wrapper.get("form.composer").trigger("submit");
|
||||
expect(wrapper.get(".cancel-turn-button").text()).toBe("停止生成");
|
||||
expect(wrapper.get(".turn-status").text()).toContain("只有完整回应通过校验后");
|
||||
|
||||
await wrapper.get(".cancel-turn-button").trigger("click");
|
||||
expect(wrapper.get(".cancel-turn-button").text()).toBe("停止中…");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.get(".turn-status").text()).toContain("已停止生成");
|
||||
expect(wrapper.get(".turn-status").text()).toContain("本轮没有写入故事");
|
||||
});
|
||||
expect(wrapper.get(".statusline span").text()).toBe(startingNode);
|
||||
expect(wrapper.find(".beat--action").exists()).toBe(false);
|
||||
expect(wrapper.get(".retry-turn-button").text()).toBe("重试本轮");
|
||||
|
||||
await wrapper.get(".retry-turn-button").trigger("click");
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.get(".statusline span").text()).toBe("node_002");
|
||||
expect(wrapper.text()).toContain("我先听听雨声。");
|
||||
});
|
||||
expect(wrapper.find(".retry-turn-button").exists()).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("allows only one safe retry for the same paid model action", async () => {
|
||||
const wrapper = await mountLoadedApp();
|
||||
const composer = wrapper.get<HTMLTextAreaElement>('textarea[aria-label="自由输入"]');
|
||||
|
||||
await composer.setValue("我再确认一次。");
|
||||
await wrapper.get("form.composer").trigger("submit");
|
||||
await wrapper.get(".cancel-turn-button").trigger("click");
|
||||
await vi.waitFor(() => expect(wrapper.find(".retry-turn-button").exists()).toBe(true));
|
||||
|
||||
await wrapper.get(".retry-turn-button").trigger("click");
|
||||
await wrapper.get(".cancel-turn-button").trigger("click");
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.get(".turn-status").text()).toContain("已经使用过一次安全重试");
|
||||
});
|
||||
expect(wrapper.find(".retry-turn-button").exists()).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("reports a complete commit when cancellation loses the atomic boundary", async () => {
|
||||
const wrapper = await mountLoadedApp();
|
||||
const composer = wrapper.get<HTMLTextAreaElement>('textarea[aria-label="自由输入"]');
|
||||
|
||||
await composer.setValue("我先确认这条线路。");
|
||||
await wrapper.get("form.composer").trigger("submit");
|
||||
window.__TAURI_INTERNALS__ = {
|
||||
invoke: vi.fn().mockResolvedValue(false)
|
||||
};
|
||||
await wrapper.get(".cancel-turn-button").trigger("click");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.get(".statusline span").text()).toBe("node_002");
|
||||
expect(wrapper.get(".turn-status").text()).toContain("已经完整完成");
|
||||
expect(wrapper.get(".turn-status").text()).toContain("安全写入故事");
|
||||
});
|
||||
|
||||
delete window.__TAURI_INTERNALS__;
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("finishes the playable before-dawn slice and exposes its settled records", async () => {
|
||||
const wrapper = await mountLoadedApp();
|
||||
const composer = wrapper.get<HTMLTextAreaElement>('textarea[aria-label="自由输入"]');
|
||||
await composer.setValue("我答应你,天亮前一定回来。");
|
||||
await wrapper.get("form.composer").trigger("submit");
|
||||
await vi.waitFor(() => expect(wrapper.get(".statusline span").text()).toBe("node_002"));
|
||||
|
||||
for (const nodeId of ["node_003", "node_004", "node_005"]) {
|
||||
await wrapper.get(".continue-button").trigger("click");
|
||||
await vi.waitFor(() => expect(wrapper.get(".statusline span").text()).toBe(nodeId));
|
||||
}
|
||||
|
||||
expect(wrapper.text()).toContain("你回来了");
|
||||
expect(wrapper.text()).toContain("第一幕终 · 天亮之前");
|
||||
expect(composer.attributes("disabled")).toBeDefined();
|
||||
expect(wrapper.get(".send-button").attributes("disabled")).toBeDefined();
|
||||
await wrapper.findAll(".top-actions button")[0].trigger("click");
|
||||
const records = wrapper.get("#records-panel").text();
|
||||
expect(records).toContain("半张旧车票");
|
||||
expect(records).toContain("封锁隧道的检修门");
|
||||
expect(records).toContain("已履行");
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("opens mutually exclusive, PlayerView-only record and relationship panels", async () => {
|
||||
const wrapper = await mountLoadedApp();
|
||||
const [recordsButton, relationshipButton] = wrapper.findAll(".top-actions button");
|
||||
@@ -125,6 +222,44 @@ describe("App", () => {
|
||||
expect(status[1]?.text()).toContain("branch_fork_");
|
||||
});
|
||||
expect(wrapper.find("#history-panel").exists()).toBe(false);
|
||||
|
||||
await historyButton.trigger("click");
|
||||
expect(wrapper.findAll(".branch-list li")).toHaveLength(2);
|
||||
expect(wrapper.get(".branch-list").text()).toContain("线路 2");
|
||||
await wrapper
|
||||
.findAll(".branch-list li")[0]!
|
||||
.findAll(".branch-actions button")
|
||||
.at(-1)!
|
||||
.trigger("click");
|
||||
await vi.waitFor(() => {
|
||||
const status = wrapper.findAll(".statusline span");
|
||||
expect(status[0]?.text()).toBe("node_002");
|
||||
expect(status[1]?.text()).toBe("branch_main");
|
||||
});
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("keeps provider credentials outside the unobtrusive model settings panel", async () => {
|
||||
const wrapper = await mountLoadedApp();
|
||||
await wrapper.get('button[aria-label="模型设置"]').trigger("click");
|
||||
|
||||
const panel = wrapper.get("#settings-panel");
|
||||
expect(panel.text()).toContain("确定性演示");
|
||||
expect(panel.text()).toContain("API Key 与供应商凭据不会进入本应用");
|
||||
expect(panel.text()).not.toContain("secret");
|
||||
expect(panel.get("select").attributes("disabled")).toBeDefined();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("does not advertise a real LAPP connection check in deterministic preview", async () => {
|
||||
const wrapper = await mountLoadedApp();
|
||||
await wrapper.get('button[aria-label="模型设置"]').trigger("click");
|
||||
|
||||
const testButton = wrapper.get(".connection-test-button");
|
||||
expect(testButton.attributes("disabled")).toBeDefined();
|
||||
expect(wrapper.get("#settings-panel").text()).toContain("确定性演示");
|
||||
expect(wrapper.find(".connection-result").exists()).toBe(false);
|
||||
expect(wrapper.get("#settings-panel").text()).not.toMatch(/api[_-]?key\s*[:=]/i);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
|
||||
@@ -3,24 +3,42 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
|
||||
import { useDemo } from "./app/useDemo";
|
||||
|
||||
type PanelName = "records" | "relationship" | "history";
|
||||
type PanelName = "records" | "relationship" | "history" | "settings";
|
||||
|
||||
const {
|
||||
appInfo,
|
||||
branchList,
|
||||
busy,
|
||||
canRetryTurn,
|
||||
cancellingTurn,
|
||||
error,
|
||||
lastSubmittedIntent,
|
||||
loading,
|
||||
lappSettings,
|
||||
lappTestStatus,
|
||||
pack,
|
||||
playerView,
|
||||
testingLappConnection,
|
||||
turnFailure,
|
||||
turnInFlight,
|
||||
turnNotice,
|
||||
cancelActiveTurn,
|
||||
clearLappTestStatus,
|
||||
forkBranch,
|
||||
renameBranch,
|
||||
retryTurn,
|
||||
selectLappModel,
|
||||
submitTurn,
|
||||
switchBranch,
|
||||
testLappConnection,
|
||||
turnError
|
||||
} = useDemo();
|
||||
const draft = ref("");
|
||||
const activePanel = ref<PanelName | null>(null);
|
||||
const selectedHistoryNode = ref<string | null>(null);
|
||||
const closeButton = ref<HTMLButtonElement | null>(null);
|
||||
const branchNameDrafts = ref<Record<string, string>>({});
|
||||
const lappSelection = ref("");
|
||||
|
||||
const sceneLabel = computed(() => {
|
||||
if (!playerView.value) return "载入场景";
|
||||
@@ -107,6 +125,64 @@ async function continueFromSelectedNode() {
|
||||
}
|
||||
}
|
||||
|
||||
async function switchToBranch(branchId: string) {
|
||||
if (busy.value || branchId === playerView.value?.branchId) return;
|
||||
await switchBranch(branchId);
|
||||
}
|
||||
|
||||
function setBranchNameDraft(branchId: string, event: Event) {
|
||||
const target = event.target;
|
||||
if (target instanceof HTMLInputElement) {
|
||||
branchNameDrafts.value[branchId] = target.value;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveBranchName(branchId: string, currentName: string) {
|
||||
const name = branchNameDrafts.value[branchId] ?? currentName;
|
||||
await renameBranch(branchId, name);
|
||||
}
|
||||
|
||||
function modelOptionValue(providerId: string, modelId: string) {
|
||||
return `${encodeURIComponent(providerId)}|${encodeURIComponent(modelId)}`;
|
||||
}
|
||||
|
||||
function setLappSelection(event: Event) {
|
||||
const target = event.target;
|
||||
if (target instanceof HTMLSelectElement) {
|
||||
lappSelection.value = target.value;
|
||||
clearLappTestStatus();
|
||||
}
|
||||
}
|
||||
|
||||
const selectedLappValue = computed(() => {
|
||||
if (lappSelection.value) return lappSelection.value;
|
||||
const providerId = lappSettings.value?.selectedProviderId;
|
||||
const modelId = lappSettings.value?.selectedModelId;
|
||||
return providerId && modelId ? modelOptionValue(providerId, modelId) : "";
|
||||
});
|
||||
|
||||
const appliedLappValue = computed(() => {
|
||||
const providerId = lappSettings.value?.selectedProviderId;
|
||||
const modelId = lappSettings.value?.selectedModelId;
|
||||
return providerId && modelId ? modelOptionValue(providerId, modelId) : "";
|
||||
});
|
||||
|
||||
async function saveLappSelection() {
|
||||
const [providerId, modelId] = selectedLappValue.value.split("|").map(decodeURIComponent);
|
||||
if (!providerId || !modelId) return;
|
||||
await selectLappModel(providerId, modelId);
|
||||
}
|
||||
|
||||
const canTestLappConnection = computed(() => {
|
||||
const settings = lappSettings.value;
|
||||
if (!settings) return false;
|
||||
return (
|
||||
settings.mode === "lapp" &&
|
||||
Boolean(settings.selectedProviderId && settings.selectedModelId) &&
|
||||
selectedLappValue.value === appliedLappValue.value
|
||||
);
|
||||
});
|
||||
|
||||
function handleEscape(event: KeyboardEvent) {
|
||||
if (event.key === "Escape" && activePanel.value) {
|
||||
closePanel();
|
||||
@@ -150,6 +226,16 @@ onBeforeUnmount(() => window.removeEventListener("keydown", handleEscape));
|
||||
>
|
||||
回溯
|
||||
</button>
|
||||
<button
|
||||
class="settings-button"
|
||||
type="button"
|
||||
aria-label="模型设置"
|
||||
aria-controls="settings-panel"
|
||||
:aria-expanded="activePanel === 'settings'"
|
||||
@click="openPanel('settings')"
|
||||
>
|
||||
设置
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@@ -177,7 +263,9 @@ onBeforeUnmount(() => window.removeEventListener("keydown", handleEscape));
|
||||
<div class="character-coat" />
|
||||
</div>
|
||||
|
||||
<div class="chapter-chip">第一幕 · 雨夜车站</div>
|
||||
<div class="chapter-chip">
|
||||
{{ playerView.canContinue ? "第一幕 · 雨夜车站" : "第一幕终 · 天亮之前" }}
|
||||
</div>
|
||||
|
||||
<article class="dialogue-panel" aria-label="最近演出">
|
||||
<ol class="beat-list">
|
||||
@@ -206,7 +294,7 @@ onBeforeUnmount(() => window.removeEventListener("keydown", handleEscape));
|
||||
aria-label="自由输入"
|
||||
placeholder="说些什么,或者描述你的行动……"
|
||||
rows="2"
|
||||
:disabled="busy"
|
||||
:disabled="busy || !playerView.canContinue"
|
||||
/>
|
||||
<button
|
||||
class="continue-button"
|
||||
@@ -214,17 +302,60 @@ onBeforeUnmount(() => window.removeEventListener("keydown", handleEscape));
|
||||
:disabled="busy || !playerView.canContinue"
|
||||
@click="continueStory"
|
||||
>
|
||||
{{ busy && lastSubmittedIntent === "continue" ? "继续中…" : "继续" }}
|
||||
{{ turnInFlight && lastSubmittedIntent === "continue" ? "继续中…" : "继续" }}
|
||||
</button>
|
||||
<button class="send-button" type="submit" :disabled="busy || draft.trim().length === 0">
|
||||
{{ busy && lastSubmittedIntent === "speak_or_act" ? "发送中…" : "发送" }}
|
||||
<button
|
||||
class="send-button"
|
||||
type="submit"
|
||||
:disabled="busy || !playerView.canContinue || draft.trim().length === 0"
|
||||
>
|
||||
{{ turnInFlight && lastSubmittedIntent === "speak_or_act" ? "发送中…" : "发送" }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="turn-status" aria-live="polite">
|
||||
<span v-if="busy">故事正在回应,请稍候。</span>
|
||||
<span v-else-if="turnError">请求未完成:{{ turnError }}</span>
|
||||
</p>
|
||||
<section class="turn-status" aria-live="polite" aria-label="回合状态">
|
||||
<div v-if="turnInFlight" class="turn-status-card turn-status-card--progress">
|
||||
<span>
|
||||
<strong>{{ cancellingTurn ? "正在停止本轮" : "故事正在回应" }}</strong>
|
||||
<small>
|
||||
{{
|
||||
cancellingTurn
|
||||
? "等待引擎确认;未完成的回合不会写入故事。"
|
||||
: turnNotice ?? "只有完整回应通过校验后,才会写入当前线路。"
|
||||
}}
|
||||
</small>
|
||||
</span>
|
||||
<button
|
||||
class="cancel-turn-button"
|
||||
type="button"
|
||||
:disabled="cancellingTurn"
|
||||
@click="cancelActiveTurn"
|
||||
>
|
||||
{{ cancellingTurn ? "停止中…" : "停止生成" }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-else-if="turnFailure" class="turn-status-card turn-status-card--failure">
|
||||
<span>
|
||||
<strong>{{ turnFailure.title }}</strong>
|
||||
<small>{{ turnFailure.message }} {{ turnFailure.noCommitMessage }}</small>
|
||||
</span>
|
||||
<button
|
||||
v-if="turnFailure.retryable"
|
||||
class="retry-turn-button"
|
||||
type="button"
|
||||
:disabled="!canRetryTurn"
|
||||
@click="retryTurn"
|
||||
>
|
||||
重试本轮
|
||||
</button>
|
||||
</div>
|
||||
<div v-else-if="turnNotice" class="turn-status-card turn-status-card--notice">
|
||||
<span>{{ turnNotice }}</span>
|
||||
</div>
|
||||
<div v-else-if="turnError" class="turn-status-card turn-status-card--failure">
|
||||
<span>操作未完成:{{ turnError }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="statusline">
|
||||
<span>{{ playerView.nodeId }}</span>
|
||||
@@ -249,7 +380,9 @@ onBeforeUnmount(() => window.removeEventListener("keydown", handleEscape));
|
||||
? "随身记录"
|
||||
: activePanel === "relationship"
|
||||
? `与${playerView.characterName}的关系`
|
||||
: "故事回溯"
|
||||
: activePanel === "history"
|
||||
? "故事线路"
|
||||
: "模型设置"
|
||||
}}
|
||||
</h2>
|
||||
</div>
|
||||
@@ -326,7 +459,48 @@ onBeforeUnmount(() => window.removeEventListener("keydown", handleEscape));
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="panel-content history-panel">
|
||||
<div v-else-if="activePanel === 'history'" class="panel-content history-panel">
|
||||
<section class="branch-section" aria-labelledby="branch-list-title">
|
||||
<h3 id="branch-list-title">已有线路</h3>
|
||||
<ul v-if="branchList" class="branch-list">
|
||||
<li
|
||||
v-for="branch in branchList.branches"
|
||||
:key="branch.branchId"
|
||||
:class="{ active: branch.isActive }"
|
||||
>
|
||||
<div class="branch-heading">
|
||||
<div>
|
||||
<strong>{{ branch.name }}</strong>
|
||||
<small>{{ branch.headLabel }}</small>
|
||||
</div>
|
||||
<span v-if="branch.isActive">当前</span>
|
||||
</div>
|
||||
<div class="branch-actions">
|
||||
<input
|
||||
:value="branchNameDrafts[branch.branchId] ?? branch.name"
|
||||
:aria-label="`重命名${branch.name}`"
|
||||
maxlength="40"
|
||||
:disabled="busy"
|
||||
@input="setBranchNameDraft(branch.branchId, $event)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="busy"
|
||||
@click="saveBranchName(branch.branchId, branch.name)"
|
||||
>
|
||||
保存名称
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="busy || branch.isActive"
|
||||
@click="switchToBranch(branch.branchId)"
|
||||
>
|
||||
{{ branch.isActive ? "正在游玩" : "切换到这里" }}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<p class="panel-intro">从旧节点继续会创建一条新线路,当前线路仍会完整保留。</p>
|
||||
<ol class="history-list">
|
||||
<li v-for="node in playerView.history" :key="node.id">
|
||||
@@ -359,6 +533,77 @@ onBeforeUnmount(() => window.removeEventListener("keydown", handleEscape));
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="panel-content settings-panel">
|
||||
<p class="panel-intro">
|
||||
这里只选择系统 LAPP profile 中已有的模型。API Key 与供应商凭据不会进入本应用。
|
||||
</p>
|
||||
<div v-if="lappSettings" class="settings-card">
|
||||
<div class="settings-status">
|
||||
<strong>
|
||||
{{
|
||||
lappSettings.mode === "lapp"
|
||||
? "LAPP 已就绪"
|
||||
: lappSettings.mode === "demo"
|
||||
? "确定性演示"
|
||||
: "LAPP 不可用"
|
||||
}}
|
||||
</strong>
|
||||
<span :data-mode="lappSettings.mode">{{ lappSettings.mode }}</span>
|
||||
</div>
|
||||
<p>{{ lappSettings.statusMessage }}</p>
|
||||
<label for="lapp-model">叙事模型</label>
|
||||
<select
|
||||
id="lapp-model"
|
||||
:value="selectedLappValue"
|
||||
:disabled="busy || lappSettings.mode === 'demo' || !lappSettings.availableModels.length"
|
||||
@change="setLappSelection"
|
||||
>
|
||||
<option value="" disabled>选择一个支持工具调用的聊天模型</option>
|
||||
<option
|
||||
v-for="model in lappSettings.availableModels"
|
||||
:key="`${model.providerId}/${model.modelId}`"
|
||||
:value="modelOptionValue(model.providerId, model.modelId)"
|
||||
>
|
||||
{{ model.providerName ?? model.providerId }} · {{ model.modelName ?? model.modelId }}
|
||||
</option>
|
||||
</select>
|
||||
<div class="settings-actions">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="busy || !selectedLappValue || lappSettings.mode === 'demo'"
|
||||
@click="saveLappSelection"
|
||||
>
|
||||
应用模型
|
||||
</button>
|
||||
<button
|
||||
class="connection-test-button"
|
||||
type="button"
|
||||
:disabled="busy || !canTestLappConnection"
|
||||
@click="testLappConnection"
|
||||
>
|
||||
{{ testingLappConnection ? "测试中…" : "测试当前连接" }}
|
||||
</button>
|
||||
</div>
|
||||
<small class="connection-privacy">
|
||||
测试只确认当前模型能否完成最小请求,不读取、展示或保存 API Key。
|
||||
</small>
|
||||
<small
|
||||
v-if="lappSettings.mode === 'lapp' && selectedLappValue !== appliedLappValue"
|
||||
class="connection-privacy"
|
||||
>
|
||||
请先应用所选模型,再测试这条连接。
|
||||
</small>
|
||||
<p
|
||||
v-if="lappTestStatus"
|
||||
class="connection-result"
|
||||
:data-tone="lappTestStatus.tone"
|
||||
role="status"
|
||||
>
|
||||
{{ lappTestStatus.message }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -2,11 +2,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import initialView from "../../fixtures/player-view/initial.json";
|
||||
import type {
|
||||
BranchList,
|
||||
ForkBranchRequest,
|
||||
ForkBranchResult,
|
||||
LappSettings,
|
||||
PlayerView,
|
||||
SwitchBranchRequest,
|
||||
SwitchBranchResult,
|
||||
TurnRequest,
|
||||
TurnResult
|
||||
TurnResult,
|
||||
UpdateLappSettingsRequest
|
||||
} from "@contracts";
|
||||
|
||||
const invokeMock = vi.hoisted(() => vi.fn());
|
||||
@@ -15,7 +20,16 @@ vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: invokeMock
|
||||
}));
|
||||
|
||||
import { forkBranch, submitTurn } from "./bridge";
|
||||
import {
|
||||
cancelTurn,
|
||||
forkBranch,
|
||||
getBranchList,
|
||||
getLappSettings,
|
||||
submitTurn,
|
||||
switchBranch,
|
||||
testLappConnection,
|
||||
updateLappSettings
|
||||
} from "./bridge";
|
||||
|
||||
const request: TurnRequest = {
|
||||
storyId: initialView.storyId,
|
||||
@@ -34,6 +48,7 @@ describe("Tauri bridge", () => {
|
||||
|
||||
afterEach(() => {
|
||||
delete window.__TAURI_INTERNALS__;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("sends only TurnRequest to the submit_turn command", async () => {
|
||||
@@ -50,6 +65,44 @@ describe("Tauri bridge", () => {
|
||||
expect(invokeMock).toHaveBeenCalledWith("submit_turn", { request });
|
||||
});
|
||||
|
||||
it("uses narrow commands for cancellation and a credential-free connection test", async () => {
|
||||
const connectionResult = {
|
||||
ok: true,
|
||||
providerId: "provider",
|
||||
modelId: "model",
|
||||
message: "connected",
|
||||
diagnosticCode: null
|
||||
};
|
||||
invokeMock.mockResolvedValueOnce(true).mockResolvedValueOnce(connectionResult);
|
||||
|
||||
await expect(cancelTurn(request.actionId)).resolves.toBe(true);
|
||||
await expect(testLappConnection()).resolves.toEqual(connectionResult);
|
||||
expect(invokeMock.mock.calls).toEqual([
|
||||
["cancel_turn", { actionId: request.actionId }],
|
||||
["test_lapp_connection"]
|
||||
]);
|
||||
});
|
||||
|
||||
it("deterministically discards a cancelled browser-preview result", async () => {
|
||||
delete window.__TAURI_INTERNALS__;
|
||||
vi.useFakeTimers();
|
||||
const pending = submitTurn(request, initialView as PlayerView);
|
||||
const rejection = expect(pending).rejects.toMatchObject({
|
||||
code: "cancelled",
|
||||
retryable: true
|
||||
});
|
||||
|
||||
await expect(cancelTurn(request.actionId)).resolves.toBe(true);
|
||||
await vi.runAllTimersAsync();
|
||||
await rejection;
|
||||
await expect(
|
||||
testLappConnection()
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
diagnosticCode: "browser_preview"
|
||||
});
|
||||
});
|
||||
|
||||
it("sends only the typed fork request to the fork_branch command", async () => {
|
||||
const forkRequest: ForkBranchRequest = {
|
||||
storyId: initialView.storyId,
|
||||
@@ -70,4 +123,46 @@ describe("Tauri bridge", () => {
|
||||
await expect(forkBranch(forkRequest, initialView as PlayerView)).resolves.toEqual(expected);
|
||||
expect(invokeMock).toHaveBeenCalledWith("fork_branch", { request: forkRequest });
|
||||
});
|
||||
|
||||
it("uses typed commands for branch sessions and LAPP model selection", async () => {
|
||||
const branches: BranchList = {
|
||||
storyId: initialView.storyId,
|
||||
activeBranchId: initialView.branchId,
|
||||
branches: []
|
||||
};
|
||||
const switchRequest: SwitchBranchRequest = {
|
||||
storyId: initialView.storyId,
|
||||
branchId: "branch_second",
|
||||
expectedActiveBranchId: initialView.branchId
|
||||
};
|
||||
const switched: SwitchBranchResult = {
|
||||
branchId: "branch_second",
|
||||
playerView: { ...(initialView as PlayerView), branchId: "branch_second" }
|
||||
};
|
||||
const settings: LappSettings = {
|
||||
mode: "lapp",
|
||||
selectedProviderId: "provider",
|
||||
selectedModelId: "model",
|
||||
availableModels: [],
|
||||
statusMessage: "ready"
|
||||
};
|
||||
const update: UpdateLappSettingsRequest = { providerId: "provider", modelId: "model" };
|
||||
|
||||
invokeMock
|
||||
.mockResolvedValueOnce(branches)
|
||||
.mockResolvedValueOnce(switched)
|
||||
.mockResolvedValueOnce(settings)
|
||||
.mockResolvedValueOnce(settings);
|
||||
|
||||
await expect(getBranchList()).resolves.toEqual(branches);
|
||||
await expect(switchBranch(switchRequest)).resolves.toEqual(switched);
|
||||
await expect(getLappSettings()).resolves.toEqual(settings);
|
||||
await expect(updateLappSettings(update)).resolves.toEqual(settings);
|
||||
expect(invokeMock.mock.calls).toEqual([
|
||||
["get_branch_list"],
|
||||
["switch_branch", { request: switchRequest }],
|
||||
["get_lapp_settings"],
|
||||
["update_lapp_settings", { request: update }]
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,16 +3,26 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
import initialView from "../../fixtures/player-view/initial.json";
|
||||
import type {
|
||||
AppInfo,
|
||||
BranchList,
|
||||
DemoPackSummary,
|
||||
ForkBranchRequest,
|
||||
ForkBranchResult,
|
||||
LappConnectionTestResult,
|
||||
LappSettings,
|
||||
PlayerView,
|
||||
RenameBranchRequest,
|
||||
SwitchBranchRequest,
|
||||
SwitchBranchResult,
|
||||
TurnRequest,
|
||||
TurnResult
|
||||
TurnResult,
|
||||
UpdateLappSettingsRequest
|
||||
} from "@contracts";
|
||||
|
||||
import { forkDemoBranch, submitDemoTurn } from "./turnAdapter";
|
||||
|
||||
const browserActiveTurns = new Set<string>();
|
||||
const browserCancelledTurns = new Set<string>();
|
||||
|
||||
function isTauri(): boolean {
|
||||
return typeof window !== "undefined" && window.__TAURI_INTERNALS__ !== undefined;
|
||||
}
|
||||
@@ -45,22 +55,144 @@ export async function getDemoPackSummary(): Promise<DemoPackSummary> {
|
||||
worldBooks: 1,
|
||||
personas: 1,
|
||||
plotModules: 1,
|
||||
itemSpecs: 1
|
||||
itemSpecs: 2
|
||||
};
|
||||
}
|
||||
return invoke<DemoPackSummary>("get_demo_pack_summary");
|
||||
}
|
||||
|
||||
export async function getBranchList(): Promise<BranchList> {
|
||||
if (!isTauri()) {
|
||||
return {
|
||||
storyId: initialView.storyId,
|
||||
activeBranchId: initialView.branchId,
|
||||
branches: [
|
||||
{
|
||||
branchId: initialView.branchId,
|
||||
name: "主线路",
|
||||
headNodeId: initialView.nodeId,
|
||||
headLabel: "雨夜车站",
|
||||
sourceNodeId: null,
|
||||
isActive: true
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
return invoke<BranchList>("get_branch_list");
|
||||
}
|
||||
|
||||
export async function switchBranch(
|
||||
request: SwitchBranchRequest,
|
||||
cachedView?: PlayerView
|
||||
): Promise<SwitchBranchResult> {
|
||||
if (!isTauri()) {
|
||||
if (!cachedView || cachedView.branchId !== request.branchId) {
|
||||
throw new Error("browser preview does not have that branch snapshot");
|
||||
}
|
||||
return { branchId: request.branchId, playerView: cachedView };
|
||||
}
|
||||
return invoke<SwitchBranchResult>("switch_branch", { request });
|
||||
}
|
||||
|
||||
export async function renameBranch(
|
||||
request: RenameBranchRequest,
|
||||
current: BranchList
|
||||
): Promise<BranchList> {
|
||||
if (!isTauri()) {
|
||||
return {
|
||||
...current,
|
||||
branches: current.branches.map((branch) =>
|
||||
branch.branchId === request.branchId ? { ...branch, name: request.name.trim() } : branch
|
||||
)
|
||||
};
|
||||
}
|
||||
return invoke<BranchList>("rename_branch", { request });
|
||||
}
|
||||
|
||||
export async function getLappSettings(): Promise<LappSettings> {
|
||||
if (!isTauri()) {
|
||||
return {
|
||||
mode: "demo",
|
||||
selectedProviderId: null,
|
||||
selectedModelId: null,
|
||||
availableModels: [],
|
||||
statusMessage: "浏览器预览使用确定性纵切;桌面端读取系统 LAPP profile。"
|
||||
};
|
||||
}
|
||||
return invoke<LappSettings>("get_lapp_settings");
|
||||
}
|
||||
|
||||
export async function updateLappSettings(
|
||||
request: UpdateLappSettingsRequest
|
||||
): Promise<LappSettings> {
|
||||
if (!isTauri()) {
|
||||
throw new Error("浏览器预览不能修改 LAPP 模型。");
|
||||
}
|
||||
return invoke<LappSettings>("update_lapp_settings", { request });
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests only the currently applied LAPP model. The desktop command owns profile
|
||||
* and credential access; no secret or provider response crosses this boundary.
|
||||
*/
|
||||
export async function testLappConnection(
|
||||
): Promise<LappConnectionTestResult> {
|
||||
if (!isTauri()) {
|
||||
await Promise.resolve();
|
||||
return {
|
||||
ok: true,
|
||||
providerId: "browser-preview",
|
||||
modelId: "deterministic-demo",
|
||||
message: "browser preview is deterministic",
|
||||
diagnosticCode: "browser_preview"
|
||||
};
|
||||
}
|
||||
|
||||
return invoke<LappConnectionTestResult>("test_lapp_connection");
|
||||
}
|
||||
|
||||
export async function submitTurn(
|
||||
request: TurnRequest,
|
||||
currentView: PlayerView
|
||||
): Promise<TurnResult> {
|
||||
if (!isTauri()) {
|
||||
return submitDemoTurn(request, currentView);
|
||||
browserActiveTurns.add(request.actionId);
|
||||
try {
|
||||
const result = await submitDemoTurn(request, currentView);
|
||||
if (browserCancelledTurns.has(request.actionId)) {
|
||||
throw {
|
||||
code: "cancelled",
|
||||
message: "browser preview turn was cancelled",
|
||||
retryable: true
|
||||
};
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
browserActiveTurns.delete(request.actionId);
|
||||
browserCancelledTurns.delete(request.actionId);
|
||||
}
|
||||
}
|
||||
return invoke<TurnResult>("submit_turn", { request });
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests cancellation of an uncommitted turn. The original submit_turn
|
||||
* promise remains authoritative: it must settle as either a complete commit or
|
||||
* a structured `cancelled` failure.
|
||||
*/
|
||||
export async function cancelTurn(actionId: string): Promise<boolean> {
|
||||
if (!isTauri()) {
|
||||
if (browserActiveTurns.has(actionId)) {
|
||||
browserCancelledTurns.add(actionId);
|
||||
await Promise.resolve();
|
||||
return true;
|
||||
}
|
||||
await Promise.resolve();
|
||||
return false;
|
||||
}
|
||||
return invoke<boolean>("cancel_turn", { actionId });
|
||||
}
|
||||
|
||||
export async function forkBranch(
|
||||
request: ForkBranchRequest,
|
||||
currentView: PlayerView
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
describeConnectionDiagnostic,
|
||||
describeConnectionFailure,
|
||||
describeOperationFailure,
|
||||
describeTurnFailure
|
||||
} from "./errors";
|
||||
|
||||
describe("player-safe failure copy", () => {
|
||||
it("offers an immediate retry only for retryable turn failures", () => {
|
||||
const retryable = describeTurnFailure({
|
||||
code: "provider_unavailable",
|
||||
message: "upstream secret diagnostic",
|
||||
retryable: true
|
||||
});
|
||||
const invalid = describeTurnFailure({
|
||||
code: "invalid_input",
|
||||
message: "private validation detail",
|
||||
retryable: false
|
||||
});
|
||||
|
||||
expect(retryable.retryable).toBe(true);
|
||||
expect(retryable.noCommitMessage).toContain("没有写入故事");
|
||||
expect(invalid.retryable).toBe(false);
|
||||
});
|
||||
|
||||
it("does not echo provider or hidden-check diagnostics", () => {
|
||||
const privateDetail = "roll=97 target=42 api_key=do-not-show";
|
||||
const turn = describeTurnFailure({
|
||||
code: "invalid_model_output",
|
||||
message: privateDetail,
|
||||
retryable: true
|
||||
});
|
||||
|
||||
expect(JSON.stringify(turn)).not.toContain(privateDetail);
|
||||
expect(describeOperationFailure({ message: privateDetail })).not.toContain(privateDetail);
|
||||
expect(describeConnectionFailure({ message: privateDetail })).not.toContain(privateDetail);
|
||||
expect(describeConnectionDiagnostic("VAULT_CREDENTIAL_NOT_FOUND")).not.toContain(privateDetail);
|
||||
});
|
||||
|
||||
it("does not suggest blindly retrying a stale branch", () => {
|
||||
const failure = describeTurnFailure({
|
||||
code: "stale_node",
|
||||
retryable: true
|
||||
});
|
||||
|
||||
expect(failure.retryable).toBe(false);
|
||||
expect(failure.message).toContain("重新打开");
|
||||
});
|
||||
|
||||
it("distinguishes safe provider recovery actions", () => {
|
||||
const credentials = describeTurnFailure({
|
||||
code: "provider_credentials",
|
||||
retryable: true,
|
||||
message: "api_key=must-not-cross-boundary"
|
||||
});
|
||||
const rateLimited = describeTurnFailure({
|
||||
code: "provider_rate_limited",
|
||||
retryable: true
|
||||
});
|
||||
const rejected = describeTurnFailure({
|
||||
code: "provider_rejected",
|
||||
retryable: true
|
||||
});
|
||||
|
||||
expect(credentials.retryable).toBe(false);
|
||||
expect(credentials.message).toContain("LAPP Vault");
|
||||
expect(JSON.stringify(credentials)).not.toContain("must-not-cross-boundary");
|
||||
expect(rateLimited.retryable).toBe(true);
|
||||
expect(rejected.retryable).toBe(false);
|
||||
});
|
||||
|
||||
it("uses connection diagnostic codes without displaying provider text", () => {
|
||||
expect(describeConnectionDiagnostic("WAIT_TIMEOUT")).toContain("超时");
|
||||
expect(describeConnectionDiagnostic("VAULT_CREDENTIAL_NOT_FOUND")).toContain("LAPP Vault");
|
||||
expect(describeConnectionFailure({ code: "connection_test_in_progress" })).toContain(
|
||||
"正在进行"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
export interface PlayerTurnFailure {
|
||||
code: string;
|
||||
title: string;
|
||||
message: string;
|
||||
retryable: boolean;
|
||||
noCommitMessage: string;
|
||||
}
|
||||
|
||||
interface CommandFailureShape {
|
||||
code?: unknown;
|
||||
retryable?: unknown;
|
||||
}
|
||||
|
||||
interface FailureCopy {
|
||||
title: string;
|
||||
message: string;
|
||||
retryableByDefault: boolean;
|
||||
supportsImmediateRetry: boolean;
|
||||
}
|
||||
|
||||
const NO_COMMIT_MESSAGE = "本轮没有写入故事,当前节点保持不变。";
|
||||
|
||||
const TURN_FAILURE_COPY: Record<string, FailureCopy> = {
|
||||
cancelled: {
|
||||
title: "已停止生成",
|
||||
message: "这次回应已被完整丢弃。",
|
||||
retryableByDefault: true,
|
||||
supportsImmediateRetry: true
|
||||
},
|
||||
timed_out: {
|
||||
title: "等待模型超时",
|
||||
message: "模型没有及时完成回应,可以重试本轮。",
|
||||
retryableByDefault: true,
|
||||
supportsImmediateRetry: true
|
||||
},
|
||||
provider_unavailable: {
|
||||
title: "模型暂时不可用",
|
||||
message: "请检查模型设置或网络连接,也可以稍后重试。",
|
||||
retryableByDefault: true,
|
||||
supportsImmediateRetry: true
|
||||
},
|
||||
provider_configuration: {
|
||||
title: "模型配置不可用",
|
||||
message: "请在设置中选择一个支持聊天与工具调用的 LAPP 模型。",
|
||||
retryableByDefault: false,
|
||||
supportsImmediateRetry: false
|
||||
},
|
||||
provider_credentials: {
|
||||
title: "模型凭据不可用",
|
||||
message: "请在 LAPP Vault 中检查当前模型引用的凭据。",
|
||||
retryableByDefault: false,
|
||||
supportsImmediateRetry: false
|
||||
},
|
||||
provider_rate_limited: {
|
||||
title: "模型请求过于频繁",
|
||||
message: "供应商暂时限流,请稍等片刻后重试本轮。",
|
||||
retryableByDefault: true,
|
||||
supportsImmediateRetry: true
|
||||
},
|
||||
provider_rejected: {
|
||||
title: "模型拒绝了本轮请求",
|
||||
message: "当前请求无法由所选模型处理,请检查模型兼容性或切换模型。",
|
||||
retryableByDefault: false,
|
||||
supportsImmediateRetry: false
|
||||
},
|
||||
invalid_model_output: {
|
||||
title: "模型回应无法使用",
|
||||
message: "回应未通过完整性校验,可以重新生成本轮。",
|
||||
retryableByDefault: true,
|
||||
supportsImmediateRetry: true
|
||||
},
|
||||
storage_unavailable: {
|
||||
title: "存档暂时不可用",
|
||||
message: "故事未能安全保存,请稍后重试。",
|
||||
retryableByDefault: true,
|
||||
supportsImmediateRetry: true
|
||||
},
|
||||
internal: {
|
||||
title: "本轮未能完成",
|
||||
message: "应用遇到临时问题,可以重试本轮。",
|
||||
retryableByDefault: true,
|
||||
supportsImmediateRetry: true
|
||||
},
|
||||
turn_in_progress: {
|
||||
title: "已有一轮正在生成",
|
||||
message: "请等待正在进行的回合结束,再重试本轮。",
|
||||
retryableByDefault: true,
|
||||
supportsImmediateRetry: true
|
||||
},
|
||||
stale_node: {
|
||||
title: "故事线路已经变化",
|
||||
message: "当前画面不是最新节点,请重新打开对应线路后再行动。",
|
||||
retryableByDefault: false,
|
||||
supportsImmediateRetry: false
|
||||
},
|
||||
invalid_input: {
|
||||
title: "这次行动无法提交",
|
||||
message: "请调整输入后再试。",
|
||||
retryableByDefault: false,
|
||||
supportsImmediateRetry: false
|
||||
}
|
||||
};
|
||||
|
||||
function commandFailure(reason: unknown): CommandFailureShape | null {
|
||||
if (typeof reason !== "object" || reason === null) return null;
|
||||
return reason as CommandFailureShape;
|
||||
}
|
||||
|
||||
function normalizedCode(reason: unknown): string {
|
||||
const code = commandFailure(reason)?.code;
|
||||
return typeof code === "string" && code.trim() ? code.trim().toLowerCase() : "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a backend failure into player-safe copy.
|
||||
*
|
||||
* The backend message is intentionally not echoed: provider diagnostics may
|
||||
* contain implementation details that do not belong in PlayerView-facing UI.
|
||||
*/
|
||||
export function describeTurnFailure(reason: unknown): PlayerTurnFailure {
|
||||
const failure = commandFailure(reason);
|
||||
const code = normalizedCode(reason);
|
||||
const copy = TURN_FAILURE_COPY[code] ?? {
|
||||
title: "请求没有完成",
|
||||
message: "本轮没有产生可用的回应。",
|
||||
retryableByDefault: false,
|
||||
supportsImmediateRetry: true
|
||||
};
|
||||
const retryable =
|
||||
copy.supportsImmediateRetry &&
|
||||
(typeof failure?.retryable === "boolean" ? failure.retryable : copy.retryableByDefault);
|
||||
|
||||
return {
|
||||
code,
|
||||
title: copy.title,
|
||||
message: copy.message,
|
||||
retryable,
|
||||
noCommitMessage: NO_COMMIT_MESSAGE
|
||||
};
|
||||
}
|
||||
|
||||
export function describeOperationFailure(reason: unknown): string {
|
||||
switch (normalizedCode(reason)) {
|
||||
case "timed_out":
|
||||
return "操作等待超时,请稍后重试。";
|
||||
case "provider_unavailable":
|
||||
return "当前模型不可用,请检查模型设置。";
|
||||
case "storage_unavailable":
|
||||
return "故事存档暂时不可用,请稍后重试。";
|
||||
case "stale_node":
|
||||
return "故事线路已经变化,请重新打开对应线路。";
|
||||
case "invalid_input":
|
||||
return "提交的内容无法使用,请检查后重试。";
|
||||
default:
|
||||
return "操作没有完成,请稍后重试。";
|
||||
}
|
||||
}
|
||||
|
||||
export function describeConnectionFailure(reason: unknown): string {
|
||||
switch (normalizedCode(reason)) {
|
||||
case "timed_out":
|
||||
return "连接测试超时,请检查网络后重试。";
|
||||
case "connection_test_in_progress":
|
||||
return "已有一项连接测试或故事回合正在进行,请等待它结束。";
|
||||
case "provider_configuration":
|
||||
return "当前模型无法初始化,请重新应用一个兼容的 LAPP 模型。";
|
||||
case "provider_credentials":
|
||||
return "当前模型凭据不可用,请在 LAPP Vault 中检查配置。";
|
||||
case "provider_unavailable":
|
||||
return "当前模型无法连接,请检查 LAPP profile 与网络。";
|
||||
case "invalid_input":
|
||||
return "当前模型配置不完整,请先应用可用模型。";
|
||||
default:
|
||||
return "连接测试没有完成;没有读取或显示任何凭据。";
|
||||
}
|
||||
}
|
||||
|
||||
export function describeConnectionDiagnostic(code: string | null): string {
|
||||
switch (code?.toUpperCase()) {
|
||||
case "ENV_SECRET_MISSING":
|
||||
case "VAULT_BACKEND_UNAVAILABLE":
|
||||
case "VAULT_CREDENTIAL_NOT_FOUND":
|
||||
case "VAULT_BINDING_MISMATCH":
|
||||
case "VAULT_ACCESS_DENIED":
|
||||
return "LAPP profile 引用的凭据当前不可用,请在 LAPP Vault 中检查配置。";
|
||||
case "WAIT_TIMEOUT":
|
||||
return "最小请求等待超时,请检查网络后重试。";
|
||||
case "HTTP_REQUEST_FAILED":
|
||||
case "HTTP_STATUS":
|
||||
return "供应商连接失败,请检查网络与模型服务状态。";
|
||||
case "INVALID_RESPONSE":
|
||||
return "模型返回了无法识别的响应,请确认所选协议与模型兼容。";
|
||||
default:
|
||||
return "当前模型没有完成最小请求,请检查 LAPP profile 与网络。";
|
||||
}
|
||||
}
|
||||
@@ -60,4 +60,51 @@ describe("local turn adapter", () => {
|
||||
expect(advanced.playerView.branchId).toBe(initialView.branchId);
|
||||
expect(advanced.playerView.nodeId).toBe("node_002");
|
||||
});
|
||||
|
||||
it("plays the promise, hidden-search clue, tunnel, and return vertical slice", async () => {
|
||||
const promise = await submitDemoTurn(
|
||||
{
|
||||
...request("speak_or_act", "我答应你,天亮前一定回来。"),
|
||||
actionId: "slice_promise"
|
||||
},
|
||||
initialView as PlayerView
|
||||
);
|
||||
expect(promise.playerView.promises[0]?.status).toBe("accepted");
|
||||
|
||||
const clue = await submitDemoTurn(
|
||||
{
|
||||
...request("continue", ""),
|
||||
branchId: promise.playerView.branchId,
|
||||
expectedNodeId: promise.playerView.nodeId,
|
||||
actionId: "slice_clue"
|
||||
},
|
||||
promise.playerView
|
||||
);
|
||||
expect(clue.playerView.knowledge.at(-1)?.title).toBe("封锁隧道的检修门");
|
||||
expect(clue.playerView.inventory.at(-1)?.name).toBe("半张旧车票");
|
||||
|
||||
const tunnel = await submitDemoTurn(
|
||||
{
|
||||
...request("continue", ""),
|
||||
branchId: clue.playerView.branchId,
|
||||
expectedNodeId: clue.playerView.nodeId,
|
||||
actionId: "slice_tunnel"
|
||||
},
|
||||
clue.playerView
|
||||
);
|
||||
expect(tunnel.playerView.history.at(-1)?.label).toBe("进入封锁隧道");
|
||||
|
||||
const returned = await submitDemoTurn(
|
||||
{
|
||||
...request("continue", ""),
|
||||
branchId: tunnel.playerView.branchId,
|
||||
expectedNodeId: tunnel.playerView.nodeId,
|
||||
actionId: "slice_return"
|
||||
},
|
||||
tunnel.playerView
|
||||
);
|
||||
expect(returned.playerView.promises[0]?.status).toBe("fulfilled");
|
||||
expect(returned.playerView.canContinue).toBe(false);
|
||||
expect(returned.playerView.beats.at(-1)?.text).toContain("你回来了");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
} from "@contracts";
|
||||
|
||||
const DEMO_LATENCY_MS = 120;
|
||||
type DemoPhase = "promise" | "investigate" | "enter" | "return" | "regular";
|
||||
|
||||
function nextNodeId(currentNodeId: string): string {
|
||||
const match = currentNodeId.match(/^(.*?)(\d+)$/);
|
||||
@@ -17,7 +18,134 @@ function nextNodeId(currentNodeId: string): string {
|
||||
return `${prefix}${String(Number(digits) + 1).padStart(digits.length, "0")}`;
|
||||
}
|
||||
|
||||
function replyBeats(request: TurnRequest): PresentationBeat[] {
|
||||
function demoPhase(request: TurnRequest, currentView: PlayerView): DemoPhase {
|
||||
if (
|
||||
request.input.includes("天亮前") &&
|
||||
request.input.includes("回来") &&
|
||||
!currentView.promises.some((promise) => promise.status === "accepted")
|
||||
) {
|
||||
return "promise";
|
||||
}
|
||||
if (currentView.history.some((node) => node.label === "进入封锁隧道")) return "return";
|
||||
if (currentView.knowledge.some((record) => record.id.startsWith("knowledge_maintenance_door"))) {
|
||||
return "enter";
|
||||
}
|
||||
if (currentView.promises.some((promise) => promise.status === "accepted")) return "investigate";
|
||||
return "regular";
|
||||
}
|
||||
|
||||
function replyBeats(request: TurnRequest, phase: DemoPhase): PresentationBeat[] {
|
||||
if (phase === "promise") {
|
||||
return [
|
||||
{
|
||||
id: `${request.actionId}_player`,
|
||||
kind: "action",
|
||||
speaker: "你",
|
||||
text: request.input,
|
||||
visual: null
|
||||
},
|
||||
{
|
||||
id: `${request.actionId}_nana`,
|
||||
kind: "dialogue",
|
||||
speaker: "娜娜",
|
||||
text: "娜娜看了你一会儿,终于松开攥紧外套的手。“好。我等你到天亮。”",
|
||||
visual: {
|
||||
character: "nana",
|
||||
expression: "relieved",
|
||||
pose: "holding_coat",
|
||||
scene: null
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
if (phase === "investigate") {
|
||||
return [
|
||||
{
|
||||
id: `${request.actionId}_search`,
|
||||
kind: "narration",
|
||||
speaker: null,
|
||||
text: "你打开旧手电,沿着站台边缘寻找。斜光扫过积水,一道检修门和半张旧车票显了出来。",
|
||||
visual: {
|
||||
character: null,
|
||||
expression: null,
|
||||
pose: null,
|
||||
scene: "station_maintenance_door"
|
||||
}
|
||||
},
|
||||
{
|
||||
id: `${request.actionId}_nana`,
|
||||
kind: "dialogue",
|
||||
speaker: "娜娜",
|
||||
text: "“这是我妹妹的字。门后通向封锁隧道。”",
|
||||
visual: {
|
||||
character: "nana",
|
||||
expression: "startled",
|
||||
pose: "reaching_out",
|
||||
scene: null
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
if (phase === "enter") {
|
||||
return [
|
||||
{
|
||||
id: `${request.actionId}_door`,
|
||||
kind: "narration",
|
||||
speaker: null,
|
||||
text: "检修门在肩膀的撞击下松开。手电光照见没过鞋面的水和向深处延伸的脚印。",
|
||||
visual: {
|
||||
character: null,
|
||||
expression: null,
|
||||
pose: null,
|
||||
scene: "sealed_tunnel"
|
||||
}
|
||||
},
|
||||
{
|
||||
id: `${request.actionId}_nana`,
|
||||
kind: "dialogue",
|
||||
speaker: "娜娜",
|
||||
text: "“我留在这里。你答应过会回来,所以我等。”",
|
||||
visual: {
|
||||
character: "nana",
|
||||
expression: "determined",
|
||||
pose: "at_door",
|
||||
scene: null
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
if (phase === "return") {
|
||||
return [
|
||||
{
|
||||
id: `${request.actionId}_return`,
|
||||
kind: "narration",
|
||||
speaker: null,
|
||||
text: "天色发白前,你重新推开检修门。娜娜仍坐在原处。",
|
||||
visual: {
|
||||
character: "nana",
|
||||
expression: "disbelieving",
|
||||
pose: "waiting",
|
||||
scene: "station_before_dawn"
|
||||
}
|
||||
},
|
||||
{
|
||||
id: `${request.actionId}_nana`,
|
||||
kind: "dialogue",
|
||||
speaker: "娜娜",
|
||||
text: "“你回来了。那我也会把剩下的事告诉你。”",
|
||||
visual: {
|
||||
character: "nana",
|
||||
expression: "relieved",
|
||||
pose: "lowered_guard",
|
||||
scene: null
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
if (request.intent === "continue") {
|
||||
return [
|
||||
{
|
||||
@@ -78,8 +206,58 @@ export async function submitDemoTurn(
|
||||
|
||||
const committedNodeId = nextNodeId(currentView.nodeId);
|
||||
const previousHistory = currentView.history.map((node) => ({ ...node, isCurrent: false }));
|
||||
const beats = replyBeats(request);
|
||||
const phase = demoPhase(request, currentView);
|
||||
const beats = replyBeats(request, phase);
|
||||
const finalVisual = [...beats].reverse().find((beat) => beat.visual)?.visual;
|
||||
const historyLabel = {
|
||||
promise: "天亮前的许诺",
|
||||
investigate: "检修门的线索",
|
||||
enter: "进入封锁隧道",
|
||||
return: "天亮前归来",
|
||||
regular: request.intent === "continue" ? "雨声中的停顿" : "回应娜娜"
|
||||
}[phase];
|
||||
const promises =
|
||||
phase === "promise"
|
||||
? [
|
||||
...currentView.promises,
|
||||
{
|
||||
id: `promise_return_before_dawn_${request.actionId}`,
|
||||
content: "天亮前一定回来",
|
||||
status: "accepted" as const,
|
||||
weight: "major" as const
|
||||
}
|
||||
]
|
||||
: phase === "return"
|
||||
? currentView.promises.map((promise) =>
|
||||
promise.status === "accepted" ? { ...promise, status: "fulfilled" as const } : promise
|
||||
)
|
||||
: currentView.promises;
|
||||
const knowledge =
|
||||
phase === "investigate"
|
||||
? [
|
||||
...currentView.knowledge,
|
||||
{
|
||||
id: `knowledge_maintenance_door_${request.actionId}`,
|
||||
title: "封锁隧道的检修门",
|
||||
summary: "旧站台下方的检修门通向封锁隧道,妹妹留下的车票指向四点十七分。",
|
||||
certainty: "confirmed" as const
|
||||
}
|
||||
]
|
||||
: currentView.knowledge;
|
||||
const inventory =
|
||||
phase === "investigate"
|
||||
? [
|
||||
...currentView.inventory,
|
||||
{
|
||||
instanceId: `item_half_ticket_${request.actionId}`,
|
||||
name: "半张旧车票",
|
||||
description: "受潮的车票背面写着“四点十七分,检修线”。",
|
||||
quantity: 1,
|
||||
placement: "bag" as const,
|
||||
condition: "damp"
|
||||
}
|
||||
]
|
||||
: currentView.inventory;
|
||||
|
||||
return {
|
||||
committedNodeId,
|
||||
@@ -89,13 +267,18 @@ export async function submitDemoTurn(
|
||||
characterExpression: finalVisual?.expression ?? currentView.characterExpression,
|
||||
characterPose: finalVisual?.pose ?? currentView.characterPose,
|
||||
beats: [...currentView.beats, ...beats].slice(-8),
|
||||
promises,
|
||||
knowledge,
|
||||
inventory,
|
||||
suggestions: phase === "return" ? [] : currentView.suggestions,
|
||||
canContinue: phase !== "return",
|
||||
history: [
|
||||
...previousHistory,
|
||||
{
|
||||
id: committedNodeId,
|
||||
parentId: currentView.nodeId,
|
||||
branchId: currentView.branchId,
|
||||
label: request.intent === "continue" ? "雨声中的停顿" : "回应娜娜",
|
||||
label: historyLabel,
|
||||
isCurrent: true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,47 +1,91 @@
|
||||
import { onMounted, ref } from "vue";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
|
||||
import type { AppInfo, DemoPackSummary, PlayerView, TurnIntent, TurnRequest } from "@contracts";
|
||||
import type {
|
||||
AppInfo,
|
||||
BranchList,
|
||||
DemoPackSummary,
|
||||
LappSettings,
|
||||
PlayerView,
|
||||
TurnIntent,
|
||||
TurnRequest
|
||||
} from "@contracts";
|
||||
|
||||
import {
|
||||
cancelTurn as cancelRuntimeTurn,
|
||||
forkBranch as forkRuntimeBranch,
|
||||
getAppInfo,
|
||||
getBranchList,
|
||||
getDemoPackSummary,
|
||||
getDemoPlayerView,
|
||||
submitTurn as submitRuntimeTurn
|
||||
getLappSettings,
|
||||
renameBranch as renameRuntimeBranch,
|
||||
submitTurn as submitRuntimeTurn,
|
||||
switchBranch as switchRuntimeBranch,
|
||||
testLappConnection as testRuntimeLappConnection,
|
||||
updateLappSettings
|
||||
} from "./bridge";
|
||||
import {
|
||||
describeConnectionDiagnostic,
|
||||
describeConnectionFailure,
|
||||
describeOperationFailure,
|
||||
describeTurnFailure,
|
||||
type PlayerTurnFailure
|
||||
} from "./errors";
|
||||
|
||||
function errorMessage(reason: unknown): string {
|
||||
if (reason instanceof Error) return reason.message;
|
||||
if (
|
||||
typeof reason === "object" &&
|
||||
reason !== null &&
|
||||
"message" in reason &&
|
||||
typeof reason.message === "string"
|
||||
) {
|
||||
return reason.message;
|
||||
}
|
||||
return String(reason);
|
||||
type TurnPhase = "idle" | "submitting" | "cancelling";
|
||||
|
||||
interface TurnAttempt {
|
||||
request: TurnRequest;
|
||||
sourceView: PlayerView;
|
||||
intent: TurnIntent;
|
||||
retryCount: number;
|
||||
cancellationRequested: boolean;
|
||||
}
|
||||
|
||||
export interface LappTestStatus {
|
||||
tone: "success" | "error" | "preview";
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function useDemo() {
|
||||
const appInfo = ref<AppInfo | null>(null);
|
||||
const pack = ref<DemoPackSummary | null>(null);
|
||||
const playerView = ref<PlayerView | null>(null);
|
||||
const branchList = ref<BranchList | null>(null);
|
||||
const lappSettings = ref<LappSettings | null>(null);
|
||||
const loading = ref(true);
|
||||
const error = ref<string | null>(null);
|
||||
const busy = ref(false);
|
||||
const turnError = ref<string | null>(null);
|
||||
const turnFailure = ref<PlayerTurnFailure | null>(null);
|
||||
const turnNotice = ref<string | null>(null);
|
||||
const turnPhase = ref<TurnPhase>("idle");
|
||||
const lastSubmittedIntent = ref<TurnIntent | null>(null);
|
||||
const testingLappConnection = ref(false);
|
||||
const lappTestStatus = ref<LappTestStatus | null>(null);
|
||||
const branchViews = new Map<string, PlayerView>();
|
||||
let activeTurn: TurnAttempt | null = null;
|
||||
let retryableTurn: TurnAttempt | null = null;
|
||||
|
||||
const turnInFlight = computed(() => turnPhase.value !== "idle");
|
||||
const cancellingTurn = computed(() => turnPhase.value === "cancelling");
|
||||
const canRetryTurn = computed(
|
||||
() => !busy.value && Boolean(retryableTurn && turnFailure.value?.retryable)
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
[appInfo.value, pack.value, playerView.value] = await Promise.all([
|
||||
getAppInfo(),
|
||||
getDemoPackSummary(),
|
||||
getDemoPlayerView()
|
||||
]);
|
||||
[appInfo.value, pack.value, playerView.value, branchList.value, lappSettings.value] =
|
||||
await Promise.all([
|
||||
getAppInfo(),
|
||||
getDemoPackSummary(),
|
||||
getDemoPlayerView(),
|
||||
getBranchList(),
|
||||
getLappSettings()
|
||||
]);
|
||||
if (playerView.value) branchViews.set(playerView.value.branchId, playerView.value);
|
||||
} catch (reason) {
|
||||
error.value = errorMessage(reason);
|
||||
error.value = describeOperationFailure(reason);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -64,26 +108,110 @@ export function useDemo() {
|
||||
input: intent === "continue" ? "" : normalizedInput
|
||||
};
|
||||
|
||||
await runTurn({
|
||||
request,
|
||||
sourceView: currentView,
|
||||
intent,
|
||||
retryCount: 0,
|
||||
cancellationRequested: false
|
||||
});
|
||||
}
|
||||
|
||||
async function runTurn(attempt: TurnAttempt): Promise<void> {
|
||||
if (busy.value) return;
|
||||
|
||||
busy.value = true;
|
||||
turnError.value = null;
|
||||
lastSubmittedIntent.value = intent;
|
||||
turnFailure.value = null;
|
||||
turnNotice.value = null;
|
||||
retryableTurn = null;
|
||||
turnPhase.value = "submitting";
|
||||
lastSubmittedIntent.value = attempt.intent;
|
||||
activeTurn = attempt;
|
||||
|
||||
try {
|
||||
const result = await submitRuntimeTurn(request, currentView);
|
||||
const result = await submitRuntimeTurn(attempt.request, attempt.sourceView);
|
||||
const cancellationWasRequested = attempt.cancellationRequested;
|
||||
playerView.value = result.playerView;
|
||||
branchViews.set(result.playerView.branchId, result.playerView);
|
||||
updateBranchHead(result.playerView);
|
||||
turnNotice.value = cancellationWasRequested
|
||||
? "本轮在停止请求生效前已经完整完成,并已安全写入故事。"
|
||||
: null;
|
||||
} catch (reason) {
|
||||
turnError.value = errorMessage(reason);
|
||||
const failure = describeTurnFailure(reason);
|
||||
const canRetry = failure.retryable && attempt.retryCount === 0;
|
||||
turnFailure.value = canRetry
|
||||
? failure
|
||||
: {
|
||||
...failure,
|
||||
retryable: false,
|
||||
message:
|
||||
attempt.retryCount > 0
|
||||
? `${failure.message} 本轮已经使用过一次安全重试。`
|
||||
: failure.message
|
||||
};
|
||||
retryableTurn = canRetry ? attempt : null;
|
||||
} finally {
|
||||
if (activeTurn === attempt) activeTurn = null;
|
||||
turnPhase.value = "idle";
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelActiveTurn(): Promise<void> {
|
||||
const attempt = activeTurn;
|
||||
if (!attempt || turnPhase.value !== "submitting") return;
|
||||
|
||||
attempt.cancellationRequested = true;
|
||||
turnPhase.value = "cancelling";
|
||||
turnNotice.value = null;
|
||||
try {
|
||||
const accepted = await cancelRuntimeTurn(attempt.request.actionId);
|
||||
if (!accepted && activeTurn === attempt) {
|
||||
turnPhase.value = "submitting";
|
||||
turnNotice.value = "停止请求未被接受;正在等待本轮的最终结果。";
|
||||
}
|
||||
} catch (reason) {
|
||||
if (activeTurn === attempt) {
|
||||
turnPhase.value = "submitting";
|
||||
turnNotice.value = `${describeOperationFailure(reason)} 当前回合仍在等待完成。`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function retryTurn(): Promise<void> {
|
||||
const attempt = retryableTurn;
|
||||
const currentView = playerView.value;
|
||||
if (!attempt || !currentView || busy.value) return;
|
||||
|
||||
if (
|
||||
currentView.storyId !== attempt.request.storyId ||
|
||||
currentView.branchId !== attempt.request.branchId ||
|
||||
currentView.nodeId !== attempt.request.expectedNodeId
|
||||
) {
|
||||
retryableTurn = null;
|
||||
turnFailure.value = describeTurnFailure({
|
||||
code: "stale_node",
|
||||
retryable: false
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep the original action id: desktop retries can therefore be idempotent.
|
||||
await runTurn({
|
||||
...attempt,
|
||||
retryCount: attempt.retryCount + 1,
|
||||
cancellationRequested: false
|
||||
});
|
||||
}
|
||||
|
||||
async function forkBranch(sourceNodeId: string): Promise<void> {
|
||||
const currentView = playerView.value;
|
||||
if (!currentView || busy.value || sourceNodeId === currentView.nodeId) return;
|
||||
|
||||
busy.value = true;
|
||||
turnError.value = null;
|
||||
clearTurnFeedback();
|
||||
lastSubmittedIntent.value = null;
|
||||
|
||||
try {
|
||||
@@ -98,23 +226,215 @@ export function useDemo() {
|
||||
currentView
|
||||
);
|
||||
playerView.value = result.playerView;
|
||||
branchViews.set(currentView.branchId, currentView);
|
||||
branchViews.set(result.playerView.branchId, result.playerView);
|
||||
const branches = branchList.value;
|
||||
if (branches) {
|
||||
const nextOrdinal = branches.branches.length + 1;
|
||||
branchList.value = {
|
||||
storyId: branches.storyId,
|
||||
activeBranchId: result.branchId,
|
||||
branches: [
|
||||
...branches.branches.map((branch) => ({ ...branch, isActive: false })),
|
||||
{
|
||||
branchId: result.branchId,
|
||||
name: `线路 ${nextOrdinal}`,
|
||||
headNodeId: result.playerView.nodeId,
|
||||
headLabel: currentHistoryLabel(result.playerView),
|
||||
sourceNodeId,
|
||||
isActive: true
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
} catch (reason) {
|
||||
turnError.value = errorMessage(reason);
|
||||
turnError.value = describeOperationFailure(reason);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function switchBranch(branchId: string): Promise<void> {
|
||||
const currentView = playerView.value;
|
||||
const branches = branchList.value;
|
||||
if (!currentView || !branches || busy.value || branchId === currentView.branchId) return;
|
||||
|
||||
busy.value = true;
|
||||
clearTurnFeedback();
|
||||
lastSubmittedIntent.value = null;
|
||||
branchViews.set(currentView.branchId, currentView);
|
||||
|
||||
try {
|
||||
const result = await switchRuntimeBranch(
|
||||
{
|
||||
storyId: currentView.storyId,
|
||||
branchId,
|
||||
expectedActiveBranchId: branches.activeBranchId
|
||||
},
|
||||
branchViews.get(branchId)
|
||||
);
|
||||
playerView.value = result.playerView;
|
||||
branchViews.set(result.branchId, result.playerView);
|
||||
branchList.value = {
|
||||
...branches,
|
||||
activeBranchId: result.branchId,
|
||||
branches: branches.branches.map((branch) => ({
|
||||
...branch,
|
||||
isActive: branch.branchId === result.branchId
|
||||
}))
|
||||
};
|
||||
} catch (reason) {
|
||||
turnError.value = describeOperationFailure(reason);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function renameBranch(branchId: string, name: string): Promise<void> {
|
||||
const branches = branchList.value;
|
||||
const normalized = name.trim();
|
||||
if (!branches || busy.value || !normalized) return;
|
||||
|
||||
busy.value = true;
|
||||
clearTurnFeedback();
|
||||
try {
|
||||
branchList.value = await renameRuntimeBranch(
|
||||
{ storyId: branches.storyId, branchId, name: normalized },
|
||||
branches
|
||||
);
|
||||
} catch (reason) {
|
||||
turnError.value = describeOperationFailure(reason);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function selectLappModel(providerId: string, modelId: string): Promise<void> {
|
||||
if (busy.value) return;
|
||||
busy.value = true;
|
||||
lappTestStatus.value = null;
|
||||
try {
|
||||
lappSettings.value = await updateLappSettings({ providerId, modelId });
|
||||
} catch (reason) {
|
||||
lappTestStatus.value = {
|
||||
tone: "error",
|
||||
message: describeOperationFailure(reason)
|
||||
};
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function testLappConnection(): Promise<void> {
|
||||
const settings = lappSettings.value;
|
||||
if (!settings || busy.value) return;
|
||||
if (
|
||||
settings.mode !== "lapp" ||
|
||||
!settings.selectedProviderId ||
|
||||
!settings.selectedModelId
|
||||
) {
|
||||
lappTestStatus.value = {
|
||||
tone: "error",
|
||||
message:
|
||||
settings.mode === "demo"
|
||||
? "确定性演示模式不连接外部模型。"
|
||||
: "请先应用一个支持聊天与工具调用的模型。"
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
busy.value = true;
|
||||
testingLappConnection.value = true;
|
||||
lappTestStatus.value = null;
|
||||
try {
|
||||
const result = await testRuntimeLappConnection();
|
||||
if (result.diagnosticCode === "browser_preview") {
|
||||
lappTestStatus.value = {
|
||||
tone: "preview",
|
||||
message: "浏览器演示路径正常;真实 LAPP 连接需在桌面端测试。"
|
||||
};
|
||||
} else if (result.ok) {
|
||||
lappTestStatus.value = {
|
||||
tone: "success",
|
||||
message: "最小聊天请求成功;工具调用仍需在实际故事回合中验证。"
|
||||
};
|
||||
} else {
|
||||
lappTestStatus.value = {
|
||||
tone: "error",
|
||||
message: describeConnectionDiagnostic(result.diagnosticCode)
|
||||
};
|
||||
}
|
||||
} catch (reason) {
|
||||
lappTestStatus.value = {
|
||||
tone: "error",
|
||||
message: describeConnectionFailure(reason)
|
||||
};
|
||||
} finally {
|
||||
testingLappConnection.value = false;
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearLappTestStatus(): void {
|
||||
lappTestStatus.value = null;
|
||||
}
|
||||
|
||||
function clearTurnFeedback(): void {
|
||||
turnError.value = null;
|
||||
turnFailure.value = null;
|
||||
turnNotice.value = null;
|
||||
retryableTurn = null;
|
||||
}
|
||||
|
||||
function currentHistoryLabel(view: PlayerView): string {
|
||||
return view.history.find((node) => node.isCurrent)?.label ?? view.sceneTitle;
|
||||
}
|
||||
|
||||
function updateBranchHead(view: PlayerView): void {
|
||||
const branches = branchList.value;
|
||||
if (!branches) return;
|
||||
branchList.value = {
|
||||
...branches,
|
||||
activeBranchId: view.branchId,
|
||||
branches: branches.branches.map((branch) =>
|
||||
branch.branchId === view.branchId
|
||||
? {
|
||||
...branch,
|
||||
headNodeId: view.nodeId,
|
||||
headLabel: currentHistoryLabel(view),
|
||||
isActive: true
|
||||
}
|
||||
: { ...branch, isActive: false }
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
appInfo,
|
||||
branchList,
|
||||
busy,
|
||||
canRetryTurn,
|
||||
cancellingTurn,
|
||||
error,
|
||||
lastSubmittedIntent,
|
||||
lappSettings,
|
||||
lappTestStatus,
|
||||
loading,
|
||||
pack,
|
||||
playerView,
|
||||
testingLappConnection,
|
||||
turnError,
|
||||
turnFailure,
|
||||
turnInFlight,
|
||||
turnNotice,
|
||||
cancelActiveTurn,
|
||||
clearLappTestStatus,
|
||||
forkBranch,
|
||||
renameBranch,
|
||||
retryTurn,
|
||||
selectLappModel,
|
||||
submitTurn,
|
||||
turnError
|
||||
switchBranch,
|
||||
testLappConnection
|
||||
};
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ body,
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
@@ -103,6 +105,8 @@ button {
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible {
|
||||
outline: 2px solid #f0c1ae;
|
||||
outline-offset: 3px;
|
||||
@@ -427,15 +431,86 @@ textarea:focus-visible {
|
||||
|
||||
.turn-status {
|
||||
position: absolute;
|
||||
right: 18%;
|
||||
bottom: 18px;
|
||||
z-index: 14;
|
||||
right: 7%;
|
||||
bottom: 108px;
|
||||
z-index: 16;
|
||||
width: min(620px, 72%);
|
||||
margin: 0;
|
||||
color: rgb(241 196 178 / 72%);
|
||||
font-size: 11px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.turn-status-card {
|
||||
display: flex;
|
||||
min-height: 54px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding: 10px 12px 10px 16px;
|
||||
color: rgb(245 241 234 / 78%);
|
||||
font-size: 12px;
|
||||
background: rgb(11 14 23 / 94%);
|
||||
border: 1px solid rgb(255 255 255 / 12%);
|
||||
border-radius: 11px;
|
||||
box-shadow: 0 12px 34px rgb(0 0 0 / 32%);
|
||||
backdrop-filter: blur(16px);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.turn-status-card > span {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.turn-status-card strong {
|
||||
color: rgb(247 243 237 / 88%);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.turn-status-card small {
|
||||
color: rgb(245 241 234 / 48%);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.turn-status-card--progress {
|
||||
border-color: rgb(184 205 222 / 24%);
|
||||
}
|
||||
|
||||
.turn-status-card--failure {
|
||||
border-color: rgb(231 167 167 / 32%);
|
||||
}
|
||||
|
||||
.turn-status-card--notice {
|
||||
min-height: 40px;
|
||||
color: rgb(211 224 231 / 72%);
|
||||
}
|
||||
|
||||
.turn-status-card button {
|
||||
flex: 0 0 auto;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
color: rgb(245 241 234 / 78%);
|
||||
background: rgb(255 255 255 / 5%);
|
||||
border: 1px solid rgb(255 255 255 / 14%);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.turn-status-card button:hover:not(:disabled) {
|
||||
color: #f4c8b5;
|
||||
border-color: rgb(231 184 164 / 38%);
|
||||
}
|
||||
|
||||
.turn-status-card button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.46;
|
||||
}
|
||||
|
||||
.turn-status-card .retry-turn-button {
|
||||
color: #241b1a;
|
||||
background: #e7b8a4;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.statusline {
|
||||
position: absolute;
|
||||
right: 8%;
|
||||
@@ -647,6 +722,179 @@ textarea:focus-visible {
|
||||
margin: 20px 0 0;
|
||||
}
|
||||
|
||||
.branch-section {
|
||||
padding-bottom: 24px;
|
||||
margin-bottom: 24px;
|
||||
border-bottom: 1px solid rgb(255 255 255 / 8%);
|
||||
}
|
||||
|
||||
.branch-section h3 {
|
||||
margin: 0 0 12px;
|
||||
color: rgb(245 241 234 / 54%);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.branch-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.branch-list li {
|
||||
padding: 14px;
|
||||
background: rgb(255 255 255 / 3%);
|
||||
border: 1px solid rgb(255 255 255 / 8%);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.branch-list li.active {
|
||||
background: rgb(231 184 164 / 7%);
|
||||
border-color: rgb(231 184 164 / 32%);
|
||||
}
|
||||
|
||||
.branch-heading,
|
||||
.branch-actions,
|
||||
.settings-status,
|
||||
.settings-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.branch-heading div {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.branch-heading small {
|
||||
color: rgb(245 241 234 / 38%);
|
||||
}
|
||||
|
||||
.branch-heading > span,
|
||||
.settings-status span {
|
||||
padding: 3px 8px;
|
||||
color: #eac0ae;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
background: rgb(231 184 164 / 10%);
|
||||
border: 1px solid rgb(231 184 164 / 28%);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.branch-actions {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.branch-actions input,
|
||||
.settings-card select {
|
||||
min-width: 0;
|
||||
color: rgb(245 241 234 / 82%);
|
||||
background: rgb(8 10 17 / 55%);
|
||||
border: 1px solid rgb(255 255 255 / 12%);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.branch-actions input {
|
||||
flex: 1;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.branch-actions button,
|
||||
.settings-actions button {
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
color: rgb(245 241 234 / 72%);
|
||||
background: rgb(255 255 255 / 4%);
|
||||
border: 1px solid rgb(255 255 255 / 12%);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.branch-actions button:disabled,
|
||||
.settings-actions button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.42;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
background: rgb(7 9 15 / 36%);
|
||||
border: 1px solid rgb(255 255 255 / 8%);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.settings-card p {
|
||||
margin: 0;
|
||||
color: rgb(245 241 234 / 48%);
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.settings-card label {
|
||||
color: rgb(245 241 234 / 58%);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.settings-card select {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.settings-card option {
|
||||
color: #161923;
|
||||
background: #f3f0eb;
|
||||
}
|
||||
|
||||
.settings-actions {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.settings-actions button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.settings-actions .connection-test-button {
|
||||
color: #eac0ae;
|
||||
border-color: rgb(231 184 164 / 24%);
|
||||
}
|
||||
|
||||
.settings-card .connection-privacy {
|
||||
color: rgb(245 241 234 / 34%);
|
||||
font-size: 10px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.settings-card .connection-result {
|
||||
padding: 10px 12px;
|
||||
color: rgb(211 224 231 / 72%);
|
||||
font-size: 12px;
|
||||
background: rgb(123 164 190 / 8%);
|
||||
border: 1px solid rgb(123 164 190 / 18%);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.settings-card .connection-result[data-tone="success"] {
|
||||
color: #b9dac6;
|
||||
background: rgb(118 184 143 / 8%);
|
||||
border-color: rgb(118 184 143 / 20%);
|
||||
}
|
||||
|
||||
.settings-card .connection-result[data-tone="error"] {
|
||||
color: #e7b0aa;
|
||||
background: rgb(205 113 113 / 8%);
|
||||
border-color: rgb(205 113 113 / 20%);
|
||||
}
|
||||
|
||||
.settings-status span[data-mode="unavailable"] {
|
||||
color: #e7a7a7;
|
||||
border-color: rgb(231 167 167 / 28%);
|
||||
}
|
||||
|
||||
.history-list {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
# Implementation Plan: Wave 7 本地 Windows 闭环
|
||||
|
||||
## Overview
|
||||
|
||||
从 `integration/v1@23672e857bfa854930b0c8ab8aca316a95ba5d09` 继续开发。先关闭真实
|
||||
Windows 基线暴露出的可重复性问题,再补齐真实模型的当前分支连续上下文与超预算检查点,
|
||||
随后实现“风险预检 → 玩家确认 → 隐藏检定失败 → 可推骰 / 可重新生成”的纵向切片。
|
||||
每个阶段都必须保持回合原子提交、分支隔离和 `PlayerView` 脱敏边界。
|
||||
|
||||
## Architecture Decisions
|
||||
|
||||
- 继续使用 `integration/v1`,不在本地另起一套重建工程。
|
||||
- `lapp-rs` 保持相邻 path dependency,并固定到 `5ba3c659…`。
|
||||
- 契约源码哈希统一按 LF 规范化;同时用 `.gitattributes` 固定文本文件换行,避免
|
||||
Windows `core.autocrlf=true` 产生假漂移。
|
||||
- 模型上下文固定为“系统约束 → 稳定资源 → 当前分支检查点 / 原始历史 → 本轮动态尾部”,
|
||||
提升前缀缓存命中;兄弟分支内容不得进入上下文。
|
||||
- 压缩检查点是可丢弃缓存,不是事实来源;删除检查点不得改变 `RuntimeState`。
|
||||
- 风险提示是“不落节点的预检结果”,只有玩家确认后才进入现有隐藏判定与原子提交链。
|
||||
- `Regenerate` 复用原行动的判定记录;`PushCheck` 是带明确后果的新行动和新节点。
|
||||
- 在重新生成 / 推骰语义闭环前,不扩大到内容包导入、TTS、生图或第二完整角色。
|
||||
|
||||
## Dependency Graph
|
||||
|
||||
```text
|
||||
Windows 可重复契约门禁
|
||||
└─ 精确 Node/pnpm + Rust/MSVC 工具链
|
||||
└─ Wave 6 全量基线
|
||||
└─ 当前分支连续上下文
|
||||
└─ 超预算检查点压缩
|
||||
└─ 风险预检契约
|
||||
├─ Runtime / Store 语义
|
||||
└─ Tauri 窄命令
|
||||
└─ Vue 确认、推骰、重新生成界面
|
||||
└─ Windows Demo + LAPP 冒烟
|
||||
```
|
||||
|
||||
## Task 1: 修复跨平台契约哈希
|
||||
|
||||
**Description:** 消除 Windows CRLF checkout 对契约源码哈希的影响,同时固定未来 checkout
|
||||
的文本换行。
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- [x] 干净 Windows checkout 上 `node scripts/verify-contracts.mjs` 通过。
|
||||
- [x] Rust 生成器对 LF 与 CRLF 输入计算相同源码哈希。
|
||||
- [x] Linux 既有 `.source.sha256` 不发生无意义变化。
|
||||
|
||||
**Verification:**
|
||||
|
||||
- [x] `node scripts/verify-contracts.mjs`
|
||||
- [x] `cargo test -p nana-contracts`
|
||||
- [x] `cargo run -p nana-contracts -- --check`
|
||||
- [x] `git diff --check`
|
||||
|
||||
**Dependencies:** None
|
||||
|
||||
**Files likely touched:**
|
||||
|
||||
- `.gitattributes`
|
||||
- `scripts/verify-contracts.mjs`
|
||||
- `crates/nana-contracts/src/main.rs`
|
||||
|
||||
**Estimated scope:** Medium
|
||||
|
||||
## Task 2: 恢复锁定的本地工具链
|
||||
|
||||
**Description:** 让 Windows 开发机满足仓库已声明的精确依赖,不修改项目依赖版本。
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- [x] Node.js 24+ 与 pnpm 10.29.2 可用。
|
||||
- [x] Rust 1.96.0、rustfmt、clippy、MSVC Build Tools、Windows SDK 可用。
|
||||
- [x] 相邻 `lapp-rs` 位于固定提交且工作树干净。
|
||||
|
||||
**Verification:**
|
||||
|
||||
- [x] `node --version`
|
||||
- [x] `pnpm --version`
|
||||
- [x] `rustc --version`
|
||||
- [x] `cargo clippy --version`
|
||||
- [x] `git -C ..\lapp-rs rev-parse HEAD`
|
||||
|
||||
**Dependencies:** Task 1 可并行
|
||||
|
||||
**Files likely touched:** None(仅本机工具链与依赖目录)
|
||||
|
||||
**Estimated scope:** Medium
|
||||
|
||||
## Task 3: 关闭 Wave 6 Windows 机械门禁
|
||||
|
||||
**Description:** 在真实 Windows 目标上重新验证既有 119 项核心 Rust、18 项 Tauri、
|
||||
29 项 Web 测试和无 bundle 桌面编译。
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- [x] 依赖安装使用锁文件且不修改锁文件。
|
||||
- [x] `pnpm verify` 等价的 Web / Rust / 契约门禁全绿。
|
||||
- [x] `pnpm tauri build --no-bundle` 成功。
|
||||
|
||||
**Verification:**
|
||||
|
||||
- [ ] `powershell.exe -NoProfile -File .\scripts\windows-smoke.ps1 -InstallDependencies`
|
||||
|
||||
**Dependencies:** Tasks 1-2
|
||||
|
||||
**Files likely touched:** None
|
||||
|
||||
**Estimated scope:** Small
|
||||
|
||||
## Checkpoint: Windows 基线
|
||||
|
||||
- [x] 工作树只包含已审阅的 Wave 7 变更。
|
||||
- [x] 契约、Web、Rust、Tauri 门禁均可重复。
|
||||
- [x] 记录首个无法自动关闭的环境阻塞。
|
||||
|
||||
## Task 4: 恢复当前分支连续上下文
|
||||
|
||||
**Description:** Store 提供按 `parent_id` 追溯的祖先链;Runtime 将稳定资源、当前分支
|
||||
历史和本轮输入按固定顺序编译给 LAPP,解决真实模型第二轮起失忆的问题。
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- [x] 第三轮模型输入包含根到当前节点的前两轮玩家输入和演出节拍。
|
||||
- [x] 分叉后只包含本分支祖先,兄弟分支文本和隐藏状态不泄漏。
|
||||
- [x] 系统约束、角色卡和 Persona 在连续回合中保持逐字节稳定,本轮输入位于尾部。
|
||||
- [x] 精确骰点、目标值、状态 delta、NPC 隐藏物品和未触发世界书不进入上下文。
|
||||
|
||||
**Verification:**
|
||||
|
||||
- [x] Store 祖先链测试覆盖分叉共享祖先。
|
||||
- [x] Runtime 捕获模型输入的三轮与兄弟分支隔离测试。
|
||||
- [x] 既有 PlayerView / 上下文泄密 canary 通过。
|
||||
|
||||
**Dependencies:** Checkpoint: Windows 基线
|
||||
|
||||
**Files likely touched:**
|
||||
|
||||
- `crates/nana-store/src/lib.rs`
|
||||
- `crates/nana-runtime/src/context.rs`
|
||||
- `crates/nana-runtime/src/lib.rs`
|
||||
- `crates/nana-runtime/src/lapp_provider.rs`
|
||||
- `src-tauri/src/lib.rs`
|
||||
|
||||
**Estimated scope:** Medium
|
||||
|
||||
## Task 5: 增加超预算分支检查点
|
||||
|
||||
**Description:** 根据模型上下文窗口预算保留“最近祖先检查点 + 检查点后原始节点 + 本轮
|
||||
输入”;超预算时使用同一 LAPP 模型压缩旧段,并把检查点作为当前节点的非权威缓存。
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- [ ] 500 节点分支恢复不扫描无关分支并保持在输入预算内。
|
||||
- [ ] 检查点只继承自当前祖先链,来源哈希不符时拒绝使用。
|
||||
- [ ] 删除检查点后 `RuntimeState`、分支头和 `PlayerView` 不变。
|
||||
- [ ] 压缩取消、超时或畸形输出不创建节点、不移动分支头。
|
||||
|
||||
**Verification:**
|
||||
|
||||
- [ ] SQLite v2 → v3 无损迁移测试。
|
||||
- [ ] 预算边界、检查点继承、来源哈希和删除重建测试。
|
||||
- [ ] 压缩调用复用既有取消控制与原生单飞闸门。
|
||||
|
||||
**Dependencies:** Task 4
|
||||
|
||||
**Files likely touched:**
|
||||
|
||||
- `crates/nana-domain/src/lib.rs`
|
||||
- `crates/nana-store/src/lib.rs`
|
||||
- `crates/nana-runtime/src/context.rs`
|
||||
- `crates/nana-runtime/src/lapp_provider.rs`
|
||||
- `src-tauri/src/lib.rs`
|
||||
|
||||
**Estimated scope:** Medium(Core 与 Runtime 可在内部契约冻结后并行)
|
||||
|
||||
## Checkpoint: 连续上下文
|
||||
|
||||
- [ ] 三轮真实上下文连续。
|
||||
- [ ] 兄弟分支隔离。
|
||||
- [ ] 稳定前缀可缓存。
|
||||
- [ ] 500 节点与超预算检查点测试通过。
|
||||
|
||||
## Task 6: 增加不落节点的风险预检契约
|
||||
|
||||
**Description:** 高风险玩家行动先返回脱敏风险描述和确认令牌,不调用隐藏判定、不推进
|
||||
状态、不创建故事节点。
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- [ ] 普通行动仍沿用现有单次提交路径。
|
||||
- [ ] 高风险行动首次提交只返回玩家可理解的风险提示。
|
||||
- [ ] 预检结果不包含骰点、目标值、NPC 隐藏信息或精确关系数值。
|
||||
|
||||
**Verification:**
|
||||
|
||||
- [ ] Domain / Runtime 契约测试。
|
||||
- [ ] Tauri 命令测试证明预检不写 SQLite。
|
||||
- [ ] PlayerView 泄密 canary 继续通过。
|
||||
|
||||
**Dependencies:** Checkpoint: 连续上下文
|
||||
|
||||
**Files likely touched:**
|
||||
|
||||
- `crates/nana-domain/src/lib.rs`
|
||||
- `crates/nana-runtime/src/`
|
||||
- `src-tauri/src/lib.rs`
|
||||
- `contracts/`
|
||||
|
||||
**Estimated scope:** Medium
|
||||
|
||||
## Task 7: 完成确认、推骰与重新生成纵切
|
||||
|
||||
**Description:** 把现有 `Regenerate` / `PushCheck` 领域语义接到 Tauri 与 Vue,让玩家在失败
|
||||
后选择承担结果、推骰或重新生成叙述。
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- [ ] 确认风险后只产生一个完整节点。
|
||||
- [ ] `Regenerate` 复用原判定,不允许刷骰。
|
||||
- [ ] `PushCheck` 明示后果并创建新行动、新判定和新节点。
|
||||
- [ ] 回溯后两条线路的判定、物品、许诺和关系互不污染。
|
||||
|
||||
**Verification:**
|
||||
|
||||
- [ ] Runtime / Store 端到端测试。
|
||||
- [ ] Tauri 命令测试。
|
||||
- [ ] Vue 交互测试覆盖确认、取消、推骰、重新生成。
|
||||
- [ ] 完整 `pnpm verify`。
|
||||
|
||||
**Dependencies:** Task 6
|
||||
|
||||
**Files likely touched:**
|
||||
|
||||
- `crates/nana-runtime/src/adjudication.rs`
|
||||
- `crates/nana-store/src/lib.rs`
|
||||
- `src-tauri/src/lib.rs`
|
||||
- `src/app/`
|
||||
- `src/App.vue`
|
||||
|
||||
**Estimated scope:** Medium(按契约、后端、前端三个小提交交付)
|
||||
|
||||
## Checkpoint: 可玩纵切
|
||||
|
||||
- [ ] 风险提示先于隐藏检定。
|
||||
- [ ] 失败、推骰、重新生成的语义可被玩家区分。
|
||||
- [ ] 取消、超时和畸形响应仍不产生半轮。
|
||||
- [ ] Demo 与真实 LAPP 各完成一次隐藏判定。
|
||||
|
||||
## Task 8: Windows 桌面冒烟与交付
|
||||
|
||||
**Description:** 启动隔离存档的 Demo 窗口,验证两次重启、终局、回溯与双线路隔离;随后
|
||||
执行真实 LAPP 最小连接及隐藏判定。
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- [ ] Demo 窗口启动并正常退出。
|
||||
- [ ] 两次重启恢复、终局和双线路隔离通过。
|
||||
- [ ] 真实 LAPP 连接测试及一轮工具调用通过,日志无凭据。
|
||||
|
||||
**Verification:**
|
||||
|
||||
- [ ] `scripts/windows-smoke.ps1 -Launch -Demo`
|
||||
- [ ] `scripts/windows-smoke.ps1 -Launch`
|
||||
|
||||
**Dependencies:** Task 7
|
||||
|
||||
**Files likely touched:** 仅状态报告;若冒烟发现缺陷则另建小任务。
|
||||
|
||||
**Estimated scope:** Medium
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
| Risk | Impact | Mitigation |
|
||||
|---|---|---|
|
||||
| Gitea SSH 2222 被本机代理拦截 | 无法自动 fetch/push | 本地开发不依赖 fetch;提交前使用用户终端 SSH 或短期 HTTPS 凭据 |
|
||||
| Rust/MSVC 未安装 | 无法关闭 Tauri 门禁 | 先完成纯文本跨平台修复;工具链作为显式环境任务 |
|
||||
| 精确 pnpm 下载不稳定 | Web 验证延迟 | 保留锁文件;优先使用 Corepack 官方包并重试,不升级项目版本 |
|
||||
| 祖先链错误按 branch_id 过滤 | 分叉后丢失共享历史 | 只按 parent_id 追溯并做兄弟分支隔离测试 |
|
||||
| 压缩摘要被误当事实 | 删除缓存后剧情状态变化 | 检查点保持非权威,状态仍只由根状态 + delta 重建 |
|
||||
| 风险预检变成第二套状态机 | 原子性回归 | 预检只产生短期确认数据,唯一 reducer / SQLite 提交路径不变 |
|
||||
| 重新生成可刷骰 | 破坏公平性 | 判定绑定原 action/check;只有 PushCheck 创建新判定 |
|
||||
|
||||
## Open Questions
|
||||
|
||||
- 无产品阻塞问题。Rust/MSVC 安装若需要管理员交互,将在 Task 2 单独请求用户操作。
|
||||
@@ -0,0 +1,62 @@
|
||||
# Wave 7 Checklist
|
||||
|
||||
## 本地恢复
|
||||
|
||||
- [x] 从私有 Gitea 恢复 `integration/v1@23672e857b`。
|
||||
- [x] 确认 Git 对象完整且工作树干净。
|
||||
- [x] 临时只读 Token 已撤销,认证临时文件与 bundle 已删除。
|
||||
- [x] 克隆相邻 `lapp-rs` 并固定到 `5ba3c659…`。
|
||||
- [ ] 恢复可 fetch/push 的远端认证路径。
|
||||
|
||||
## Task 1:跨平台契约门禁
|
||||
|
||||
- [x] 添加文本换行约束。
|
||||
- [x] Node 契约检查按 LF 规范化源码。
|
||||
- [x] Rust 契约生成器按 LF 规范化源码。
|
||||
- [x] 增加 LF / CRLF 等价测试。
|
||||
- [x] Windows 上运行 Node 契约检查。
|
||||
- [x] Rust 生成器检查与测试。
|
||||
|
||||
## Task 2:本机工具链
|
||||
|
||||
- [x] Node.js 24+。
|
||||
- [x] WebView2。
|
||||
- [x] pnpm 10.29.2。
|
||||
- [x] Rust 1.96.0、rustfmt、clippy。
|
||||
- [x] Microsoft C++ Build Tools 与 Windows SDK。
|
||||
- [x] 安装锁定的 JavaScript 依赖。
|
||||
|
||||
## Task 3:Wave 6 门禁
|
||||
|
||||
- [x] `pnpm verify:web`(25 份契约、29 项 Web 测试及生产构建通过)
|
||||
- [x] `pnpm verify:rust` 等价门禁(171 项 Rust 测试与严格 Clippy 通过)
|
||||
- [x] `pnpm tauri build --no-bundle`
|
||||
|
||||
## Task 4-5:连续上下文与检查点
|
||||
|
||||
- [x] Store 按 parent_id 读取当前祖先链。
|
||||
- [x] 第三轮包含前两轮原始剧情。
|
||||
- [x] 兄弟分支上下文隔离。
|
||||
- [x] 稳定前缀与动态尾部固定编排。
|
||||
- [x] LAPP 模型预算与缺省来源贯通。
|
||||
- [x] 稳定前缀与叙事来源哈希。
|
||||
- [x] `Regenerate` 从父节点创建兄弟分支并复用原判定 / 状态。
|
||||
- [ ] 超预算检查点与来源哈希。
|
||||
- [ ] SQLite v3 迁移与 500 节点测试。
|
||||
|
||||
## Task 6-7:风险 / 判定纵切
|
||||
|
||||
- [ ] 风险预检不落节点。
|
||||
- [ ] 玩家确认后进入隐藏判定。
|
||||
- [ ] 失败后提供推骰入口。
|
||||
- [x] Runtime / Store 重新生成复用原判定。
|
||||
- [ ] 推骰创建新行动与新判定。
|
||||
- [ ] Vue / Tauri / Runtime / Store 测试全绿。
|
||||
|
||||
## Task 8:桌面冒烟
|
||||
|
||||
- [x] 隔离 Demo 窗口启动并创建存档。
|
||||
- [ ] 重启恢复。
|
||||
- [ ] 终局与双线路隔离。
|
||||
- [ ] 真实 LAPP 连接。
|
||||
- [ ] 真实隐藏判定工具调用。
|
||||