This commit is contained in:
Generated
+1
@@ -1928,6 +1928,7 @@ dependencies = [
|
|||||||
"nana-engine",
|
"nana-engine",
|
||||||
"nana-runtime",
|
"nana-runtime",
|
||||||
"nana-store",
|
"nana-store",
|
||||||
|
"openlapp",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
|
|||||||
@@ -25,14 +25,24 @@ M0 契约基线已建立;M1 状态、投影与持久化主链已经接通,M2
|
|||||||
- 引擎掌控的类型化隐藏检定循环,模型只收到定性结果,不能指定或读取骰点、难度与精确数值;
|
- 引擎掌控的类型化隐藏检定循环,模型只收到定性结果,不能指定或读取骰点、难度与精确数值;
|
||||||
- 基于 `lapp-rs` 原生工具消息的 LAPP TurnPlan provider 与严格输出校验;
|
- 基于 `lapp-rs` 原生工具消息的 LAPP TurnPlan provider 与严格输出校验;
|
||||||
- 从任意当前线路历史节点创建真实持久化分支,旧线路与新线路保持隔离;
|
- 从任意当前线路历史节点创建真实持久化分支,旧线路与新线路保持隔离;
|
||||||
|
- 列出、重命名、切换故事线路,并在关闭应用后恢复最后活动线路;
|
||||||
|
- SQLite schema v2 与从 wave4 schema v1 的无损迁移;
|
||||||
|
- 应用内选择 LAPP profile 中声明了聊天与工具调用能力的模型,凭据仍只由 LAPP
|
||||||
|
Vault 即时解析;
|
||||||
|
- 可完整游玩的“天亮之前”纵切:接受许诺、隐藏搜索、获得车票、进入隧道、天亮前
|
||||||
|
归来并结算许诺;
|
||||||
- Turn 请求/结果校验、Fake Provider 与确定性世界书触发;
|
- Turn 请求/结果校验、Fake Provider 与确定性世界书触发;
|
||||||
- 可交互的主演出屏、持有物/线索/许诺、关系与回溯面板。
|
- 可交互的主演出屏、持有物/线索/许诺、关系与回溯面板。
|
||||||
|
|
||||||
浏览器模式继续使用确定性的本地 Turn adapter,方便无桌面壳开发;Tauri
|
浏览器模式继续使用确定性的本地 Turn adapter,方便无桌面壳开发;Tauri
|
||||||
模式已经通过 `submit_turn` 使用 SQLite 自动保存和恢复,并通过 `fork_branch`
|
模式已经通过 `submit_turn` 使用 SQLite 自动保存和恢复,并通过 `fork_branch`
|
||||||
创建持久化分支。桌面端默认使用真实 LAPP provider;只有显式设置
|
创建持久化分支。线路选择和名称也保存在同一 SQLite 存档中。桌面端默认使用真实
|
||||||
|
LAPP provider;只有显式设置
|
||||||
`NANA_STORY_PROVIDER=demo` 时才启用确定性的娜娜纵切实现。
|
`NANA_STORY_PROVIDER=demo` 时才启用确定性的娜娜纵切实现。
|
||||||
|
|
||||||
|
应用内“设置”只选择 LAPP profile 已有模型,不读取、保存或回显 API Key。若 profile
|
||||||
|
缺失,或没有声明 `chat` 与 `tool-call` 能力的启用模型,界面会明确显示不可用。
|
||||||
|
|
||||||
Rust 1.96 下的核心测试、Clippy、契约生成检查、Tauri 全 target 类型检查与后端
|
Rust 1.96 下的核心测试、Clippy、契约生成检查、Tauri 全 target 类型检查与后端
|
||||||
单元测试已经通过。当前 Linux Work 环境缺少 WebKitGTK 等桌面开发库,因此真实
|
单元测试已经通过。当前 Linux Work 环境缺少 WebKitGTK 等桌面开发库,因此真实
|
||||||
桌面窗口启动与 Windows 打包仍需在具备原生依赖的环境补跑。
|
桌面窗口启动与 Windows 打包仍需在具备原生依赖的环境补跑。
|
||||||
|
|||||||
@@ -189,6 +189,26 @@
|
|||||||
"check_modifier": 1
|
"check_modifier": 1
|
||||||
},
|
},
|
||||||
"hidden_facts": ["电池仓里刻着青川站工作人员的编号"]
|
"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
|
6a3351b1936a09050428f73f854b2a89c96a98b049657d87bce78cf84d46e9eb
|
||||||
|
|||||||
@@ -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,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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,6 +120,24 @@ export type ForkBranchRequest = { storyId: string, currentBranchId: string, expe
|
|||||||
|
|
||||||
export type ForkBranchResult = { branchId: string, playerView: PlayerView, };
|
export type ForkBranchResult = { branchId: string, playerView: PlayerView, };
|
||||||
|
|
||||||
|
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 TurnFailureCode = "stale_node" | "invalid_input" | "invalid_model_output" | "provider_unavailable" | "cancelled" | "timed_out" | "internal";
|
export type TurnFailureCode = "stale_node" | "invalid_input" | "invalid_model_output" | "provider_unavailable" | "cancelled" | "timed_out" | "internal";
|
||||||
|
|
||||||
export type TurnFailure = { code: TurnFailureCode, message: string, retryable: boolean, };
|
export type TurnFailure = { code: TurnFailureCode, message: string, retryable: boolean, };
|
||||||
|
|||||||
@@ -4,18 +4,20 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use nana_domain::{
|
use nana_domain::{
|
||||||
AcquisitionMode, ActionSuggestion, AppInfo, BeatKind, CharacterCard, CharacterJudgmentRule,
|
AcquisitionMode, ActionSuggestion, AppInfo, BeatKind, BranchList, BranchSummary, CharacterCard,
|
||||||
CharacterStyle, CheckDifficulty, CheckRecord, CheckResult, ClockState, DemoPackSummary,
|
CharacterJudgmentRule, CharacterStyle, CheckDifficulty, CheckRecord, CheckResult, ClockState,
|
||||||
ForkBranchRequest, ForkBranchResult, HistoryNodeView, ItemAcquisition, ItemInstance,
|
DemoPackSummary, ForkBranchRequest, ForkBranchResult, HistoryNodeView, ItemAcquisition,
|
||||||
ItemMechanics, ItemPlacement, ItemSpec, KnowledgeCertainty, KnowledgeRecord, Persona,
|
ItemInstance, ItemMechanics, ItemPlacement, ItemSpec, KnowledgeCertainty, KnowledgeRecord,
|
||||||
PlayerItemView, PlayerKnowledgeView, PlayerPromiseView, PlayerView, PlotEvent, PlotModule,
|
LappMode, LappModelOption, LappSettings, Persona, PlayerItemView, PlayerKnowledgeView,
|
||||||
PlotOutcome, PlotPressure, PresentationBeat, PresentationCharacter, PresentationScene,
|
PlayerPromiseView, PlayerView, PlotEvent, PlotModule, PlotOutcome, PlotPressure,
|
||||||
PresentationSnapshot, Promise, PromiseStatus, PromiseWeight, RelationshipAdjustment,
|
PresentationBeat, PresentationCharacter, PresentationScene, PresentationSnapshot, Promise,
|
||||||
RelationshipAxes, RelationshipBand, RelationshipDimension, RelationshipState, RelationshipView,
|
PromiseStatus, PromiseWeight, RelationshipAdjustment, RelationshipAxes, RelationshipBand,
|
||||||
|
RelationshipDimension, RelationshipState, RelationshipView, RenameBranchRequest,
|
||||||
ResourceBundle, ResourceHeader, ResourceId, ResourceKind, ResourceRef, RuntimeState,
|
ResourceBundle, ResourceHeader, ResourceId, ResourceKind, ResourceRef, RuntimeState,
|
||||||
SkillValue, StateDelta, StateOp, Story, StoryBinding, StoryNode, TurnFailure, TurnFailureCode,
|
SkillValue, StateDelta, StateOp, Story, StoryBinding, StoryNode, SwitchBranchRequest,
|
||||||
TurnIntent, TurnRequest, TurnResult, ValidationCode, ValidationIssue, ValidationReport,
|
SwitchBranchResult, TurnFailure, TurnFailureCode, TurnIntent, TurnRequest, TurnResult,
|
||||||
VisualDirective, WorldBook, WorldBookEntry,
|
UpdateLappSettingsRequest, ValidationCode, ValidationIssue, ValidationReport, VisualDirective,
|
||||||
|
WorldBook, WorldBookEntry,
|
||||||
};
|
};
|
||||||
use schemars::{JsonSchema, schema_for};
|
use schemars::{JsonSchema, schema_for};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
@@ -74,6 +76,16 @@ fn generated_outputs(root: &Path) -> Result<GeneratedOutputs, Box<dyn std::error
|
|||||||
add_schema::<TurnResult>(&mut outputs, &schema_dir, "turn-result")?;
|
add_schema::<TurnResult>(&mut outputs, &schema_dir, "turn-result")?;
|
||||||
add_schema::<ForkBranchRequest>(&mut outputs, &schema_dir, "fork-branch-request")?;
|
add_schema::<ForkBranchRequest>(&mut outputs, &schema_dir, "fork-branch-request")?;
|
||||||
add_schema::<ForkBranchResult>(&mut outputs, &schema_dir, "fork-branch-result")?;
|
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::<TurnFailure>(&mut outputs, &schema_dir, "turn-failure")?;
|
add_schema::<TurnFailure>(&mut outputs, &schema_dir, "turn-failure")?;
|
||||||
add_schema::<AppInfo>(&mut outputs, &schema_dir, "app-info")?;
|
add_schema::<AppInfo>(&mut outputs, &schema_dir, "app-info")?;
|
||||||
add_schema::<DemoPackSummary>(&mut outputs, &schema_dir, "demo-pack-summary")?;
|
add_schema::<DemoPackSummary>(&mut outputs, &schema_dir, "demo-pack-summary")?;
|
||||||
@@ -154,6 +166,15 @@ fn generated_declarations() -> String {
|
|||||||
TurnResult::decl(),
|
TurnResult::decl(),
|
||||||
ForkBranchRequest::decl(),
|
ForkBranchRequest::decl(),
|
||||||
ForkBranchResult::decl(),
|
ForkBranchResult::decl(),
|
||||||
|
BranchSummary::decl(),
|
||||||
|
BranchList::decl(),
|
||||||
|
SwitchBranchRequest::decl(),
|
||||||
|
SwitchBranchResult::decl(),
|
||||||
|
RenameBranchRequest::decl(),
|
||||||
|
LappModelOption::decl(),
|
||||||
|
LappMode::decl(),
|
||||||
|
LappSettings::decl(),
|
||||||
|
UpdateLappSettingsRequest::decl(),
|
||||||
TurnFailureCode::decl(),
|
TurnFailureCode::decl(),
|
||||||
TurnFailure::decl(),
|
TurnFailure::decl(),
|
||||||
AppInfo::decl(),
|
AppInfo::decl(),
|
||||||
|
|||||||
@@ -745,6 +745,91 @@ pub struct ForkBranchResult {
|
|||||||
pub player_view: PlayerView,
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
#[ts(rename_all = "camelCase")]
|
#[ts(rename_all = "camelCase")]
|
||||||
|
|||||||
@@ -79,13 +79,34 @@ pub struct OpenLappChatExecutor {
|
|||||||
|
|
||||||
impl OpenLappChatExecutor {
|
impl OpenLappChatExecutor {
|
||||||
pub fn from_profile(profile: &Profile) -> Result<Self, ProviderError> {
|
pub fn from_profile(profile: &Profile) -> Result<Self, ProviderError> {
|
||||||
|
Self::from_profile_with_selector(profile, ModelSelector::Default("chat".to_owned()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_profile_and_model(
|
||||||
|
profile: &Profile,
|
||||||
|
provider_id: &str,
|
||||||
|
model_id: &str,
|
||||||
|
) -> Result<Self, ProviderError> {
|
||||||
|
Self::from_profile_with_selector(
|
||||||
|
profile,
|
||||||
|
ModelSelector::Explicit {
|
||||||
|
provider_id: provider_id.to_owned(),
|
||||||
|
model: model_id.to_owned(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_profile_with_selector(
|
||||||
|
profile: &Profile,
|
||||||
|
selector: ModelSelector,
|
||||||
|
) -> Result<Self, ProviderError> {
|
||||||
let (commands, receiver) = mpsc::channel();
|
let (commands, receiver) = mpsc::channel();
|
||||||
let (initialized, initialization) = mpsc::sync_channel(1);
|
let (initialized, initialization) = mpsc::sync_channel(1);
|
||||||
let profile = profile.clone();
|
let profile = profile.clone();
|
||||||
|
|
||||||
let _worker = thread::Builder::new()
|
let _worker = thread::Builder::new()
|
||||||
.name("nana-lapp-chat".into())
|
.name("nana-lapp-chat".into())
|
||||||
.spawn(move || run_chat_worker(profile, receiver, initialized))
|
.spawn(move || run_chat_worker(profile, selector, receiver, initialized))
|
||||||
.map_err(|_| ProviderError::Configuration { code: None })?;
|
.map_err(|_| ProviderError::Configuration { code: None })?;
|
||||||
|
|
||||||
initialization
|
initialization
|
||||||
@@ -120,6 +141,7 @@ struct ChatCommand {
|
|||||||
#[allow(clippy::needless_pass_by_value)]
|
#[allow(clippy::needless_pass_by_value)]
|
||||||
fn run_chat_worker(
|
fn run_chat_worker(
|
||||||
profile: Profile,
|
profile: Profile,
|
||||||
|
selector: ModelSelector,
|
||||||
commands: mpsc::Receiver<ChatCommand>,
|
commands: mpsc::Receiver<ChatCommand>,
|
||||||
initialized: mpsc::SyncSender<Result<(), ProviderError>>,
|
initialized: mpsc::SyncSender<Result<(), ProviderError>>,
|
||||||
) {
|
) {
|
||||||
@@ -131,11 +153,7 @@ fn run_chat_worker(
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let resolver: Arc<dyn CredentialResolver> = Arc::new(DefaultCredentialResolver::system());
|
let resolver: Arc<dyn CredentialResolver> = Arc::new(DefaultCredentialResolver::system());
|
||||||
let client = match Client::new(
|
let client = match Client::new(&profile, &selector, resolver) {
|
||||||
&profile,
|
|
||||||
&ModelSelector::Default("chat".to_owned()),
|
|
||||||
resolver,
|
|
||||||
) {
|
|
||||||
Ok(client) => client,
|
Ok(client) => client,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
let _ = initialized.send(Err(ProviderError::Configuration {
|
let _ = initialized.send(Err(ProviderError::Configuration {
|
||||||
@@ -212,6 +230,16 @@ impl LappAdjudicationModel<OpenLappChatExecutor> {
|
|||||||
pub fn from_profile(profile: &Profile, bundle: ResourceBundle) -> Result<Self, ProviderError> {
|
pub fn from_profile(profile: &Profile, bundle: ResourceBundle) -> Result<Self, ProviderError> {
|
||||||
OpenLappChatExecutor::from_profile(profile).map(|executor| Self::new(executor, bundle))
|
OpenLappChatExecutor::from_profile(profile).map(|executor| Self::new(executor, bundle))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn from_profile_and_model(
|
||||||
|
profile: &Profile,
|
||||||
|
provider_id: &str,
|
||||||
|
model_id: &str,
|
||||||
|
bundle: ResourceBundle,
|
||||||
|
) -> Result<Self, ProviderError> {
|
||||||
|
OpenLappChatExecutor::from_profile_and_model(profile, provider_id, model_id)
|
||||||
|
.map(|executor| Self::new(executor, bundle))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<Executor: ChatExecutor> AdjudicationModel for LappAdjudicationModel<Executor> {
|
impl<Executor: ChatExecutor> AdjudicationModel for LappAdjudicationModel<Executor> {
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use nana_domain::{RuntimeState, StoryNode, stable_json_hash};
|
use nana_domain::{RuntimeState, StoryNode, stable_json_hash};
|
||||||
use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params};
|
use rusqlite::{Connection, OptionalExtension, Transaction, TransactionBehavior, params};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
const SCHEMA_VERSION: i64 = 1;
|
const SCHEMA_VERSION: i64 = 2;
|
||||||
|
const LEGACY_SCHEMA_VERSION: i64 = 1;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
const BUSY_TIMEOUT_MILLIS: i64 = 5_000;
|
const BUSY_TIMEOUT_MILLIS: i64 = 5_000;
|
||||||
const BUSY_TIMEOUT: Duration = Duration::from_secs(5);
|
const BUSY_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
@@ -38,6 +39,15 @@ pub enum StoreError {
|
|||||||
Poisoned,
|
Poisoned,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct StoredBranch {
|
||||||
|
pub branch_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub head_node_id: String,
|
||||||
|
pub source_node_id: Option<String>,
|
||||||
|
pub ordinal: u32,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Error, PartialEq, Eq)]
|
#[derive(Debug, Error, PartialEq, Eq)]
|
||||||
pub enum ForkError {
|
pub enum ForkError {
|
||||||
#[error("branch already exists: {story_id}/{branch_id}")]
|
#[error("branch already exists: {story_id}/{branch_id}")]
|
||||||
@@ -85,6 +95,19 @@ pub trait StoryStore: Send + Sync {
|
|||||||
fn load_state(&self, story_id: &str, branch_id: &str) -> Result<RuntimeState, StoreError>;
|
fn load_state(&self, story_id: &str, branch_id: &str) -> Result<RuntimeState, StoreError>;
|
||||||
|
|
||||||
fn load_node(&self, story_id: &str, node_id: &str) -> Result<StoryNode, StoreError>;
|
fn load_node(&self, story_id: &str, node_id: &str) -> Result<StoryNode, StoreError>;
|
||||||
|
|
||||||
|
fn list_branches(&self, story_id: &str) -> Result<Vec<StoredBranch>, StoreError>;
|
||||||
|
|
||||||
|
fn active_branch(&self, story_id: &str) -> Result<String, StoreError>;
|
||||||
|
|
||||||
|
fn switch_active_branch(
|
||||||
|
&self,
|
||||||
|
story_id: &str,
|
||||||
|
expected_active_branch_id: &str,
|
||||||
|
branch_id: &str,
|
||||||
|
) -> Result<RuntimeState, StoreError>;
|
||||||
|
|
||||||
|
fn rename_branch(&self, story_id: &str, branch_id: &str, name: &str) -> Result<(), StoreError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
@@ -92,6 +115,8 @@ struct MemoryData {
|
|||||||
nodes: BTreeMap<(String, String), StoryNode>,
|
nodes: BTreeMap<(String, String), StoryNode>,
|
||||||
states: BTreeMap<(String, String), RuntimeState>,
|
states: BTreeMap<(String, String), RuntimeState>,
|
||||||
branch_heads: BTreeMap<(String, String), String>,
|
branch_heads: BTreeMap<(String, String), String>,
|
||||||
|
branch_metadata: BTreeMap<(String, String), StoredBranch>,
|
||||||
|
active_branches: BTreeMap<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deterministic test and development store.
|
/// Deterministic test and development store.
|
||||||
@@ -182,7 +207,36 @@ impl StoryStore for InMemoryStoryStore {
|
|||||||
// all-or-nothing transaction boundary required from the SQLite store.
|
// all-or-nothing transaction boundary required from the SQLite store.
|
||||||
data.nodes.insert(node_key.clone(), node.clone());
|
data.nodes.insert(node_key.clone(), node.clone());
|
||||||
data.states.insert(node_key, state.clone());
|
data.states.insert(node_key, state.clone());
|
||||||
data.branch_heads.insert(branch_key, node.id.clone());
|
data.branch_heads
|
||||||
|
.insert(branch_key.clone(), node.id.clone());
|
||||||
|
if !data.branch_metadata.contains_key(&branch_key) {
|
||||||
|
let ordinal = u32::try_from(
|
||||||
|
data.branch_metadata
|
||||||
|
.keys()
|
||||||
|
.filter(|(story_id, _)| story_id == &node.story_id)
|
||||||
|
.count()
|
||||||
|
+ 1,
|
||||||
|
)
|
||||||
|
.unwrap_or(u32::MAX);
|
||||||
|
data.branch_metadata.insert(
|
||||||
|
branch_key,
|
||||||
|
StoredBranch {
|
||||||
|
branch_id: node.branch_id.clone(),
|
||||||
|
name: default_branch_name(ordinal),
|
||||||
|
head_node_id: node.id.clone(),
|
||||||
|
source_node_id: None,
|
||||||
|
ordinal,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
data.active_branches
|
||||||
|
.entry(node.story_id.clone())
|
||||||
|
.or_insert_with(|| node.branch_id.clone());
|
||||||
|
} else if let Some(metadata) = data
|
||||||
|
.branch_metadata
|
||||||
|
.get_mut(&(node.story_id.clone(), node.branch_id.clone()))
|
||||||
|
{
|
||||||
|
metadata.head_node_id.clone_from(&node.id);
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,7 +272,27 @@ impl StoryStore for InMemoryStoryStore {
|
|||||||
|
|
||||||
// Validation is complete before the only mutation.
|
// Validation is complete before the only mutation.
|
||||||
data.branch_heads
|
data.branch_heads
|
||||||
.insert(branch_key, source_node_id.to_owned());
|
.insert(branch_key.clone(), source_node_id.to_owned());
|
||||||
|
let ordinal = u32::try_from(
|
||||||
|
data.branch_metadata
|
||||||
|
.keys()
|
||||||
|
.filter(|(stored_story_id, _)| stored_story_id == story_id)
|
||||||
|
.count()
|
||||||
|
+ 1,
|
||||||
|
)
|
||||||
|
.unwrap_or(u32::MAX);
|
||||||
|
data.branch_metadata.insert(
|
||||||
|
branch_key,
|
||||||
|
StoredBranch {
|
||||||
|
branch_id: new_branch_id.to_owned(),
|
||||||
|
name: default_branch_name(ordinal),
|
||||||
|
head_node_id: source_node_id.to_owned(),
|
||||||
|
source_node_id: Some(source_node_id.to_owned()),
|
||||||
|
ordinal,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
data.active_branches
|
||||||
|
.insert(story_id.to_owned(), new_branch_id.to_owned());
|
||||||
Ok(restored)
|
Ok(restored)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,6 +331,86 @@ impl StoryStore for InMemoryStoryStore {
|
|||||||
.cloned()
|
.cloned()
|
||||||
.ok_or_else(|| StoreError::ParentNotFound(node_id.to_owned()))
|
.ok_or_else(|| StoreError::ParentNotFound(node_id.to_owned()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn list_branches(&self, story_id: &str) -> Result<Vec<StoredBranch>, StoreError> {
|
||||||
|
let data = self.lock()?;
|
||||||
|
if !data
|
||||||
|
.nodes
|
||||||
|
.keys()
|
||||||
|
.any(|(stored_story_id, _)| stored_story_id == story_id)
|
||||||
|
{
|
||||||
|
return Err(StoreError::StoryNotFound(story_id.to_owned()));
|
||||||
|
}
|
||||||
|
let mut branches = data
|
||||||
|
.branch_metadata
|
||||||
|
.iter()
|
||||||
|
.filter(|((stored_story_id, _), _)| stored_story_id == story_id)
|
||||||
|
.map(|(_, branch)| branch.clone())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
branches.sort_by_key(|branch| branch.ordinal);
|
||||||
|
Ok(branches)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn active_branch(&self, story_id: &str) -> Result<String, StoreError> {
|
||||||
|
let data = self.lock()?;
|
||||||
|
data.active_branches
|
||||||
|
.get(story_id)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| StoreError::StoryNotFound(story_id.to_owned()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn switch_active_branch(
|
||||||
|
&self,
|
||||||
|
story_id: &str,
|
||||||
|
expected_active_branch_id: &str,
|
||||||
|
branch_id: &str,
|
||||||
|
) -> Result<RuntimeState, StoreError> {
|
||||||
|
let mut data = self.lock()?;
|
||||||
|
let active = data
|
||||||
|
.active_branches
|
||||||
|
.get(story_id)
|
||||||
|
.ok_or_else(|| StoreError::StoryNotFound(story_id.to_owned()))?;
|
||||||
|
if active != expected_active_branch_id {
|
||||||
|
return Err(StoreError::StaleBranchHead {
|
||||||
|
expected: active.clone(),
|
||||||
|
actual: expected_active_branch_id.to_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let head = data
|
||||||
|
.branch_heads
|
||||||
|
.get(&(story_id.to_owned(), branch_id.to_owned()))
|
||||||
|
.ok_or_else(|| StoreError::BranchNotFound {
|
||||||
|
story_id: story_id.to_owned(),
|
||||||
|
branch_id: branch_id.to_owned(),
|
||||||
|
})?
|
||||||
|
.clone();
|
||||||
|
let node = data
|
||||||
|
.nodes
|
||||||
|
.get(&(story_id.to_owned(), head.clone()))
|
||||||
|
.ok_or(StoreError::StateMismatch("branch head has no node"))?;
|
||||||
|
let state = data
|
||||||
|
.states
|
||||||
|
.get(&(story_id.to_owned(), head.clone()))
|
||||||
|
.ok_or(StoreError::StateMismatch("branch head has no state"))?;
|
||||||
|
let restored = restore_state_for_branch(node, state, story_id, &head, branch_id)?;
|
||||||
|
data.active_branches
|
||||||
|
.insert(story_id.to_owned(), branch_id.to_owned());
|
||||||
|
Ok(restored)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rename_branch(&self, story_id: &str, branch_id: &str, name: &str) -> Result<(), StoreError> {
|
||||||
|
let name = validate_branch_name(name)?;
|
||||||
|
let mut data = self.lock()?;
|
||||||
|
let branch = data
|
||||||
|
.branch_metadata
|
||||||
|
.get_mut(&(story_id.to_owned(), branch_id.to_owned()))
|
||||||
|
.ok_or_else(|| StoreError::BranchNotFound {
|
||||||
|
story_id: story_id.to_owned(),
|
||||||
|
branch_id: branch_id.to_owned(),
|
||||||
|
})?;
|
||||||
|
name.clone_into(&mut branch.name);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Durable `SQLite` implementation of the append-only story store.
|
/// Durable `SQLite` implementation of the append-only story store.
|
||||||
@@ -347,6 +501,42 @@ impl SqliteStoryStore {
|
|||||||
Ok(state)
|
Ok(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_app_setting(&self, key: &str) -> Result<Option<String>, StoreError> {
|
||||||
|
let connection = self.lock()?;
|
||||||
|
connection
|
||||||
|
.query_row(
|
||||||
|
"SELECT value FROM app_settings WHERE key = ?1",
|
||||||
|
params![key],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(StoreError::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_app_setting(&self, key: &str, value: &str) -> Result<(), StoreError> {
|
||||||
|
let connection = self.lock()?;
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO app_settings (key, value) VALUES (?1, ?2)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||||
|
params![key, value],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_app_settings(&self, entries: &[(&str, &str)]) -> Result<(), StoreError> {
|
||||||
|
let mut connection = self.lock()?;
|
||||||
|
let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||||
|
for (key, value) in entries {
|
||||||
|
transaction.execute(
|
||||||
|
"INSERT INTO app_settings (key, value) VALUES (?1, ?2)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||||
|
params![key, value],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
transaction.commit()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn from_connection(
|
fn from_connection(
|
||||||
mut connection: Connection,
|
mut connection: Connection,
|
||||||
database_kind: DatabaseKind,
|
database_kind: DatabaseKind,
|
||||||
@@ -451,6 +641,7 @@ impl StoryStore for SqliteStoryStore {
|
|||||||
DO UPDATE SET head_node_id = excluded.head_node_id",
|
DO UPDATE SET head_node_id = excluded.head_node_id",
|
||||||
params![node.story_id, node.branch_id, node.id],
|
params![node.story_id, node.branch_id, node.id],
|
||||||
)?;
|
)?;
|
||||||
|
ensure_branch_session(&transaction, node)?;
|
||||||
transaction.commit()?;
|
transaction.commit()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -523,6 +714,31 @@ impl StoryStore for SqliteStoryStore {
|
|||||||
VALUES (?1, ?2, ?3)",
|
VALUES (?1, ?2, ?3)",
|
||||||
params![story_id, new_branch_id, source_node_id],
|
params![story_id, new_branch_id, source_node_id],
|
||||||
)?;
|
)?;
|
||||||
|
let ordinal = transaction.query_row(
|
||||||
|
"SELECT COALESCE(MAX(ordinal), 0) + 1
|
||||||
|
FROM branch_metadata WHERE story_id = ?1",
|
||||||
|
params![story_id],
|
||||||
|
|row| row.get::<_, u32>(0),
|
||||||
|
)?;
|
||||||
|
transaction.execute(
|
||||||
|
"INSERT INTO branch_metadata (
|
||||||
|
story_id, branch_id, name, source_node_id, ordinal
|
||||||
|
) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||||
|
params![
|
||||||
|
story_id,
|
||||||
|
new_branch_id,
|
||||||
|
default_branch_name(ordinal),
|
||||||
|
source_node_id,
|
||||||
|
ordinal
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
transaction.execute(
|
||||||
|
"INSERT INTO story_sessions (story_id, active_branch_id)
|
||||||
|
VALUES (?1, ?2)
|
||||||
|
ON CONFLICT(story_id)
|
||||||
|
DO UPDATE SET active_branch_id = excluded.active_branch_id",
|
||||||
|
params![story_id, new_branch_id],
|
||||||
|
)?;
|
||||||
transaction.commit()?;
|
transaction.commit()?;
|
||||||
Ok(restored)
|
Ok(restored)
|
||||||
}
|
}
|
||||||
@@ -599,6 +815,130 @@ impl StoryStore for SqliteStoryStore {
|
|||||||
validate_loaded_node(&node, story_id, node_id, &stored.0, stored.1.as_deref())?;
|
validate_loaded_node(&node, story_id, node_id, &stored.0, stored.1.as_deref())?;
|
||||||
Ok(node)
|
Ok(node)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn list_branches(&self, story_id: &str) -> Result<Vec<StoredBranch>, StoreError> {
|
||||||
|
let connection = self.lock()?;
|
||||||
|
let story_exists = connection.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM nodes WHERE story_id = ?1)",
|
||||||
|
params![story_id],
|
||||||
|
|row| row.get::<_, bool>(0),
|
||||||
|
)?;
|
||||||
|
if !story_exists {
|
||||||
|
return Err(StoreError::StoryNotFound(story_id.to_owned()));
|
||||||
|
}
|
||||||
|
let mut statement = connection.prepare(
|
||||||
|
"SELECT branch_heads.branch_id, branch_metadata.name,
|
||||||
|
branch_heads.head_node_id, branch_metadata.source_node_id,
|
||||||
|
branch_metadata.ordinal
|
||||||
|
FROM branch_heads
|
||||||
|
JOIN branch_metadata
|
||||||
|
ON branch_metadata.story_id = branch_heads.story_id
|
||||||
|
AND branch_metadata.branch_id = branch_heads.branch_id
|
||||||
|
WHERE branch_heads.story_id = ?1
|
||||||
|
ORDER BY branch_metadata.ordinal, branch_heads.branch_id",
|
||||||
|
)?;
|
||||||
|
statement
|
||||||
|
.query_map(params![story_id], |row| {
|
||||||
|
Ok(StoredBranch {
|
||||||
|
branch_id: row.get(0)?,
|
||||||
|
name: row.get(1)?,
|
||||||
|
head_node_id: row.get(2)?,
|
||||||
|
source_node_id: row.get(3)?,
|
||||||
|
ordinal: row.get(4)?,
|
||||||
|
})
|
||||||
|
})?
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
.map_err(StoreError::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn active_branch(&self, story_id: &str) -> Result<String, StoreError> {
|
||||||
|
let connection = self.lock()?;
|
||||||
|
connection
|
||||||
|
.query_row(
|
||||||
|
"SELECT active_branch_id FROM story_sessions WHERE story_id = ?1",
|
||||||
|
params![story_id],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.optional()?
|
||||||
|
.ok_or_else(|| StoreError::StoryNotFound(story_id.to_owned()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn switch_active_branch(
|
||||||
|
&self,
|
||||||
|
story_id: &str,
|
||||||
|
expected_active_branch_id: &str,
|
||||||
|
branch_id: &str,
|
||||||
|
) -> Result<RuntimeState, StoreError> {
|
||||||
|
let mut connection = self.lock()?;
|
||||||
|
let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||||
|
let active = transaction
|
||||||
|
.query_row(
|
||||||
|
"SELECT active_branch_id FROM story_sessions WHERE story_id = ?1",
|
||||||
|
params![story_id],
|
||||||
|
|row| row.get::<_, String>(0),
|
||||||
|
)
|
||||||
|
.optional()?
|
||||||
|
.ok_or_else(|| StoreError::StoryNotFound(story_id.to_owned()))?;
|
||||||
|
if active != expected_active_branch_id {
|
||||||
|
return Err(StoreError::StaleBranchHead {
|
||||||
|
expected: active,
|
||||||
|
actual: expected_active_branch_id.to_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let stored = load_branch_state(&transaction, story_id, branch_id)?;
|
||||||
|
transaction.execute(
|
||||||
|
"UPDATE story_sessions SET active_branch_id = ?2 WHERE story_id = ?1",
|
||||||
|
params![story_id, branch_id],
|
||||||
|
)?;
|
||||||
|
transaction.commit()?;
|
||||||
|
Ok(stored)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rename_branch(&self, story_id: &str, branch_id: &str, name: &str) -> Result<(), StoreError> {
|
||||||
|
let name = validate_branch_name(name)?;
|
||||||
|
let connection = self.lock()?;
|
||||||
|
let changed = connection.execute(
|
||||||
|
"UPDATE branch_metadata SET name = ?3
|
||||||
|
WHERE story_id = ?1 AND branch_id = ?2",
|
||||||
|
params![story_id, branch_id, name],
|
||||||
|
)?;
|
||||||
|
if changed == 0 {
|
||||||
|
return Err(StoreError::BranchNotFound {
|
||||||
|
story_id: story_id.to_owned(),
|
||||||
|
branch_id: branch_id.to_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_branch_session(
|
||||||
|
transaction: &Transaction<'_>,
|
||||||
|
node: &StoryNode,
|
||||||
|
) -> Result<(), StoreError> {
|
||||||
|
let ordinal = transaction.query_row(
|
||||||
|
"SELECT COALESCE(MAX(ordinal), 0) + 1
|
||||||
|
FROM branch_metadata WHERE story_id = ?1",
|
||||||
|
params![node.story_id],
|
||||||
|
|row| row.get::<_, u32>(0),
|
||||||
|
)?;
|
||||||
|
transaction.execute(
|
||||||
|
"INSERT OR IGNORE INTO branch_metadata (
|
||||||
|
story_id, branch_id, name, source_node_id, ordinal
|
||||||
|
) VALUES (?1, ?2, ?3, NULL, ?4)",
|
||||||
|
params![
|
||||||
|
node.story_id,
|
||||||
|
node.branch_id,
|
||||||
|
default_branch_name(ordinal),
|
||||||
|
ordinal
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
transaction.execute(
|
||||||
|
"INSERT OR IGNORE INTO story_sessions (story_id, active_branch_id)
|
||||||
|
VALUES (?1, ?2)",
|
||||||
|
params![node.story_id, node.branch_id],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn configure_connection(connection: &Connection) -> Result<(), StoreError> {
|
fn configure_connection(connection: &Connection) -> Result<(), StoreError> {
|
||||||
@@ -641,11 +981,23 @@ fn configure_journal(
|
|||||||
fn initialize_schema(connection: &mut Connection) -> Result<(), StoreError> {
|
fn initialize_schema(connection: &mut Connection) -> Result<(), StoreError> {
|
||||||
match schema_version(connection)? {
|
match schema_version(connection)? {
|
||||||
SCHEMA_VERSION => validate_schema(connection, SCHEMA_VERSION),
|
SCHEMA_VERSION => validate_schema(connection, SCHEMA_VERSION),
|
||||||
|
LEGACY_SCHEMA_VERSION => migrate_legacy_schema(connection),
|
||||||
0 => initialize_unversioned_schema(connection),
|
0 => initialize_unversioned_schema(connection),
|
||||||
found => Err(unsupported_schema_version(found)),
|
found => Err(unsupported_schema_version(found)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn migrate_legacy_schema(connection: &mut Connection) -> Result<(), StoreError> {
|
||||||
|
let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||||
|
validate_legacy_schema(&transaction, LEGACY_SCHEMA_VERSION)?;
|
||||||
|
create_wave5_tables(&transaction)?;
|
||||||
|
backfill_wave5_tables(&transaction)?;
|
||||||
|
validate_schema(&transaction, SCHEMA_VERSION)?;
|
||||||
|
transaction.execute_batch(&format!("PRAGMA user_version = {SCHEMA_VERSION};"))?;
|
||||||
|
transaction.commit()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn initialize_unversioned_schema(connection: &mut Connection) -> Result<(), StoreError> {
|
fn initialize_unversioned_schema(connection: &mut Connection) -> Result<(), StoreError> {
|
||||||
let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||||
let version = schema_version(&transaction)?;
|
let version = schema_version(&transaction)?;
|
||||||
@@ -657,15 +1009,29 @@ fn initialize_unversioned_schema(connection: &mut Connection) -> Result<(), Stor
|
|||||||
transaction.commit()?;
|
transaction.commit()?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
if version == LEGACY_SCHEMA_VERSION {
|
||||||
|
validate_legacy_schema(&transaction, LEGACY_SCHEMA_VERSION)?;
|
||||||
|
create_wave5_tables(&transaction)?;
|
||||||
|
backfill_wave5_tables(&transaction)?;
|
||||||
|
validate_schema(&transaction, SCHEMA_VERSION)?;
|
||||||
|
transaction.execute_batch(&format!("PRAGMA user_version = {SCHEMA_VERSION};"))?;
|
||||||
|
transaction.commit()?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
if version != 0 {
|
if version != 0 {
|
||||||
return Err(unsupported_schema_version(version));
|
return Err(unsupported_schema_version(version));
|
||||||
}
|
}
|
||||||
|
|
||||||
if schema_has_user_objects(&transaction)? {
|
if schema_has_user_objects(&transaction)? {
|
||||||
|
if validate_schema(&transaction, 0).is_ok() {
|
||||||
|
transaction.execute_batch(&format!("PRAGMA user_version = {SCHEMA_VERSION};"))?;
|
||||||
|
transaction.commit()?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
// Wave 2 databases have this exact unversioned layout. Validate every
|
// Wave 2 databases have this exact unversioned layout. Validate every
|
||||||
// required table, column, foreign key, and index before adopting them;
|
// required table, column, foreign key, and index before adopting them;
|
||||||
// a partial legacy database must never be repaired with IF NOT EXISTS.
|
// a partial legacy database must never be repaired with IF NOT EXISTS.
|
||||||
validate_schema(&transaction, 0)?;
|
validate_legacy_schema(&transaction, 0)?;
|
||||||
} else {
|
} else {
|
||||||
transaction.execute_batch(
|
transaction.execute_batch(
|
||||||
"CREATE TABLE nodes (
|
"CREATE TABLE nodes (
|
||||||
@@ -704,14 +1070,82 @@ fn initialize_unversioned_schema(connection: &mut Connection) -> Result<(), Stor
|
|||||||
ON DELETE RESTRICT
|
ON DELETE RESTRICT
|
||||||
);",
|
);",
|
||||||
)?;
|
)?;
|
||||||
validate_schema(&transaction, SCHEMA_VERSION)?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
create_wave5_tables(&transaction)?;
|
||||||
|
backfill_wave5_tables(&transaction)?;
|
||||||
|
validate_schema(&transaction, SCHEMA_VERSION)?;
|
||||||
transaction.execute_batch(&format!("PRAGMA user_version = {SCHEMA_VERSION};"))?;
|
transaction.execute_batch(&format!("PRAGMA user_version = {SCHEMA_VERSION};"))?;
|
||||||
transaction.commit()?;
|
transaction.commit()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn create_wave5_tables(connection: &Connection) -> Result<(), StoreError> {
|
||||||
|
connection.execute_batch(
|
||||||
|
"CREATE TABLE branch_metadata (
|
||||||
|
story_id TEXT NOT NULL,
|
||||||
|
branch_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
source_node_id TEXT,
|
||||||
|
ordinal INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (story_id, branch_id),
|
||||||
|
UNIQUE (story_id, ordinal),
|
||||||
|
FOREIGN KEY (story_id, branch_id)
|
||||||
|
REFERENCES branch_heads (story_id, branch_id)
|
||||||
|
ON DELETE RESTRICT,
|
||||||
|
FOREIGN KEY (story_id, source_node_id)
|
||||||
|
REFERENCES nodes (story_id, node_id)
|
||||||
|
ON DELETE RESTRICT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE story_sessions (
|
||||||
|
story_id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
active_branch_id TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (story_id, active_branch_id)
|
||||||
|
REFERENCES branch_heads (story_id, branch_id)
|
||||||
|
ON DELETE RESTRICT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE app_settings (
|
||||||
|
key TEXT NOT NULL PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);",
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn backfill_wave5_tables(connection: &Connection) -> Result<(), StoreError> {
|
||||||
|
connection.execute_batch(
|
||||||
|
"WITH ranked AS (
|
||||||
|
SELECT story_id, branch_id,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY story_id
|
||||||
|
ORDER BY CASE WHEN branch_id = 'branch_main' THEN 0 ELSE 1 END,
|
||||||
|
branch_id
|
||||||
|
) AS ordinal
|
||||||
|
FROM branch_heads
|
||||||
|
)
|
||||||
|
INSERT INTO branch_metadata (
|
||||||
|
story_id, branch_id, name, source_node_id, ordinal
|
||||||
|
)
|
||||||
|
SELECT story_id, branch_id,
|
||||||
|
CASE WHEN ordinal = 1 THEN '主线路'
|
||||||
|
ELSE '线路 ' || ordinal END,
|
||||||
|
NULL, ordinal
|
||||||
|
FROM ranked;
|
||||||
|
|
||||||
|
INSERT INTO story_sessions (story_id, active_branch_id)
|
||||||
|
SELECT story_id,
|
||||||
|
COALESCE(
|
||||||
|
MAX(CASE WHEN branch_id = 'branch_main' THEN branch_id END),
|
||||||
|
MIN(branch_id)
|
||||||
|
)
|
||||||
|
FROM branch_heads
|
||||||
|
GROUP BY story_id;",
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn schema_version(connection: &Connection) -> Result<i64, StoreError> {
|
fn schema_version(connection: &Connection) -> Result<i64, StoreError> {
|
||||||
connection
|
connection
|
||||||
.query_row("PRAGMA user_version", [], |row| row.get(0))
|
.query_row("PRAGMA user_version", [], |row| row.get(0))
|
||||||
@@ -766,6 +1200,14 @@ struct ExpectedForeignKey<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn validate_schema(connection: &Connection, version: i64) -> Result<(), StoreError> {
|
fn validate_schema(connection: &Connection, version: i64) -> Result<(), StoreError> {
|
||||||
|
validate_legacy_schema(connection, version)?;
|
||||||
|
validate_branch_metadata_schema(connection, version)?;
|
||||||
|
validate_story_sessions_schema(connection, version)?;
|
||||||
|
validate_app_settings_schema(connection, version)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_legacy_schema(connection: &Connection, version: i64) -> Result<(), StoreError> {
|
||||||
validate_nodes_schema(connection, version)?;
|
validate_nodes_schema(connection, version)?;
|
||||||
validate_materialized_states_schema(connection, version)?;
|
validate_materialized_states_schema(connection, version)?;
|
||||||
validate_branch_heads_schema(connection, version)?;
|
validate_branch_heads_schema(connection, version)?;
|
||||||
@@ -773,6 +1215,140 @@ fn validate_schema(connection: &Connection, version: i64) -> Result<(), StoreErr
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_branch_metadata_schema(
|
||||||
|
connection: &Connection,
|
||||||
|
version: i64,
|
||||||
|
) -> Result<(), StoreError> {
|
||||||
|
validate_table(
|
||||||
|
connection,
|
||||||
|
version,
|
||||||
|
"branch_metadata",
|
||||||
|
&[
|
||||||
|
ExpectedColumn {
|
||||||
|
name: "story_id",
|
||||||
|
declared_type: "TEXT",
|
||||||
|
not_null: true,
|
||||||
|
primary_key_position: 1,
|
||||||
|
},
|
||||||
|
ExpectedColumn {
|
||||||
|
name: "branch_id",
|
||||||
|
declared_type: "TEXT",
|
||||||
|
not_null: true,
|
||||||
|
primary_key_position: 2,
|
||||||
|
},
|
||||||
|
ExpectedColumn {
|
||||||
|
name: "name",
|
||||||
|
declared_type: "TEXT",
|
||||||
|
not_null: true,
|
||||||
|
primary_key_position: 0,
|
||||||
|
},
|
||||||
|
ExpectedColumn {
|
||||||
|
name: "source_node_id",
|
||||||
|
declared_type: "TEXT",
|
||||||
|
not_null: false,
|
||||||
|
primary_key_position: 0,
|
||||||
|
},
|
||||||
|
ExpectedColumn {
|
||||||
|
name: "ordinal",
|
||||||
|
declared_type: "INTEGER",
|
||||||
|
not_null: true,
|
||||||
|
primary_key_position: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
&[
|
||||||
|
ExpectedForeignKey {
|
||||||
|
sequence: 0,
|
||||||
|
referenced_table: "nodes",
|
||||||
|
from_column: "story_id",
|
||||||
|
to_column: "story_id",
|
||||||
|
on_delete: "RESTRICT",
|
||||||
|
},
|
||||||
|
ExpectedForeignKey {
|
||||||
|
sequence: 1,
|
||||||
|
referenced_table: "nodes",
|
||||||
|
from_column: "source_node_id",
|
||||||
|
to_column: "node_id",
|
||||||
|
on_delete: "RESTRICT",
|
||||||
|
},
|
||||||
|
ExpectedForeignKey {
|
||||||
|
sequence: 0,
|
||||||
|
referenced_table: "branch_heads",
|
||||||
|
from_column: "story_id",
|
||||||
|
to_column: "story_id",
|
||||||
|
on_delete: "RESTRICT",
|
||||||
|
},
|
||||||
|
ExpectedForeignKey {
|
||||||
|
sequence: 1,
|
||||||
|
referenced_table: "branch_heads",
|
||||||
|
from_column: "branch_id",
|
||||||
|
to_column: "branch_id",
|
||||||
|
on_delete: "RESTRICT",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_story_sessions_schema(connection: &Connection, version: i64) -> Result<(), StoreError> {
|
||||||
|
validate_table(
|
||||||
|
connection,
|
||||||
|
version,
|
||||||
|
"story_sessions",
|
||||||
|
&[
|
||||||
|
ExpectedColumn {
|
||||||
|
name: "story_id",
|
||||||
|
declared_type: "TEXT",
|
||||||
|
not_null: true,
|
||||||
|
primary_key_position: 1,
|
||||||
|
},
|
||||||
|
ExpectedColumn {
|
||||||
|
name: "active_branch_id",
|
||||||
|
declared_type: "TEXT",
|
||||||
|
not_null: true,
|
||||||
|
primary_key_position: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
&[
|
||||||
|
ExpectedForeignKey {
|
||||||
|
sequence: 0,
|
||||||
|
referenced_table: "branch_heads",
|
||||||
|
from_column: "story_id",
|
||||||
|
to_column: "story_id",
|
||||||
|
on_delete: "RESTRICT",
|
||||||
|
},
|
||||||
|
ExpectedForeignKey {
|
||||||
|
sequence: 1,
|
||||||
|
referenced_table: "branch_heads",
|
||||||
|
from_column: "active_branch_id",
|
||||||
|
to_column: "branch_id",
|
||||||
|
on_delete: "RESTRICT",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_app_settings_schema(connection: &Connection, version: i64) -> Result<(), StoreError> {
|
||||||
|
validate_table(
|
||||||
|
connection,
|
||||||
|
version,
|
||||||
|
"app_settings",
|
||||||
|
&[
|
||||||
|
ExpectedColumn {
|
||||||
|
name: "key",
|
||||||
|
declared_type: "TEXT",
|
||||||
|
not_null: true,
|
||||||
|
primary_key_position: 1,
|
||||||
|
},
|
||||||
|
ExpectedColumn {
|
||||||
|
name: "value",
|
||||||
|
declared_type: "TEXT",
|
||||||
|
not_null: true,
|
||||||
|
primary_key_position: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn validate_nodes_schema(connection: &Connection, version: i64) -> Result<(), StoreError> {
|
fn validate_nodes_schema(connection: &Connection, version: i64) -> Result<(), StoreError> {
|
||||||
validate_table(
|
validate_table(
|
||||||
connection,
|
connection,
|
||||||
@@ -1086,6 +1662,71 @@ fn validate_new_branch_id(branch_id: &str) -> Result<(), ForkError> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_branch_name(ordinal: u32) -> String {
|
||||||
|
if ordinal == 1 {
|
||||||
|
"主线路".to_owned()
|
||||||
|
} else {
|
||||||
|
format!("线路 {ordinal}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_branch_name(name: &str) -> Result<String, StoreError> {
|
||||||
|
const MAX_BRANCH_NAME_CHARS: usize = 40;
|
||||||
|
|
||||||
|
let normalized = name.trim();
|
||||||
|
if normalized.is_empty()
|
||||||
|
|| normalized.chars().count() > MAX_BRANCH_NAME_CHARS
|
||||||
|
|| normalized.chars().any(char::is_control)
|
||||||
|
{
|
||||||
|
return Err(StoreError::StateMismatch("invalid branch name"));
|
||||||
|
}
|
||||||
|
Ok(normalized.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_branch_state(
|
||||||
|
connection: &Connection,
|
||||||
|
story_id: &str,
|
||||||
|
branch_id: &str,
|
||||||
|
) -> Result<RuntimeState, StoreError> {
|
||||||
|
let stored = connection
|
||||||
|
.query_row(
|
||||||
|
"SELECT branch_heads.head_node_id, nodes.branch_id, nodes.parent_id,
|
||||||
|
nodes.node_json, materialized_states.state_json
|
||||||
|
FROM branch_heads
|
||||||
|
JOIN nodes
|
||||||
|
ON nodes.story_id = branch_heads.story_id
|
||||||
|
AND nodes.node_id = branch_heads.head_node_id
|
||||||
|
LEFT JOIN materialized_states
|
||||||
|
ON materialized_states.story_id = branch_heads.story_id
|
||||||
|
AND materialized_states.node_id = branch_heads.head_node_id
|
||||||
|
WHERE branch_heads.story_id = ?1
|
||||||
|
AND branch_heads.branch_id = ?2",
|
||||||
|
params![story_id, branch_id],
|
||||||
|
|row| {
|
||||||
|
Ok((
|
||||||
|
row.get::<_, String>(0)?,
|
||||||
|
row.get::<_, String>(1)?,
|
||||||
|
row.get::<_, Option<String>>(2)?,
|
||||||
|
row.get::<_, String>(3)?,
|
||||||
|
row.get::<_, Option<String>>(4)?,
|
||||||
|
))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.optional()?
|
||||||
|
.ok_or_else(|| StoreError::BranchNotFound {
|
||||||
|
story_id: story_id.to_owned(),
|
||||||
|
branch_id: branch_id.to_owned(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let node = deserialize_node(&stored.3)?;
|
||||||
|
validate_loaded_node(&node, story_id, &stored.0, &stored.1, stored.2.as_deref())?;
|
||||||
|
let state_json = stored
|
||||||
|
.4
|
||||||
|
.ok_or(StoreError::StateMismatch("branch head has no state"))?;
|
||||||
|
let state = deserialize_state(&state_json)?;
|
||||||
|
restore_state_for_branch(&node, &state, story_id, &stored.0, branch_id)
|
||||||
|
}
|
||||||
|
|
||||||
fn validate_materialized_state(node: &StoryNode, state: &RuntimeState) -> Result<(), StoreError> {
|
fn validate_materialized_state(node: &StoryNode, state: &RuntimeState) -> Result<(), StoreError> {
|
||||||
if node.story_id != state.story_id {
|
if node.story_id != state.story_id {
|
||||||
return Err(StoreError::StateMismatch("story_id"));
|
return Err(StoreError::StateMismatch("story_id"));
|
||||||
@@ -1566,6 +2207,52 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn assert_lists_renames_and_switches_active_branches(store: &impl InspectableStoryStore) {
|
||||||
|
seed_historical_main(store);
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.active_branch("story_demo")
|
||||||
|
.expect("initial active branch"),
|
||||||
|
"branch_main"
|
||||||
|
);
|
||||||
|
|
||||||
|
store
|
||||||
|
.fork_branch("story_demo", "node_001", "branch_second")
|
||||||
|
.expect("fork becomes active");
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.active_branch("story_demo")
|
||||||
|
.expect("forked active branch"),
|
||||||
|
"branch_second"
|
||||||
|
);
|
||||||
|
let branches = store.list_branches("story_demo").expect("branch list");
|
||||||
|
assert_eq!(branches.len(), 2);
|
||||||
|
assert_eq!(branches[0].name, "主线路");
|
||||||
|
assert_eq!(branches[1].name, "线路 2");
|
||||||
|
assert_eq!(branches[1].source_node_id.as_deref(), Some("node_001"));
|
||||||
|
|
||||||
|
store
|
||||||
|
.rename_branch("story_demo", "branch_second", " 等娜娜的线路 ")
|
||||||
|
.expect("rename branch");
|
||||||
|
assert_eq!(
|
||||||
|
store.list_branches("story_demo").expect("renamed list")[1].name,
|
||||||
|
"等娜娜的线路"
|
||||||
|
);
|
||||||
|
|
||||||
|
let switched = store
|
||||||
|
.switch_active_branch("story_demo", "branch_second", "branch_main")
|
||||||
|
.expect("switch back to main");
|
||||||
|
assert_eq!(switched.current_branch, "branch_main");
|
||||||
|
assert_eq!(switched.current_node, "node_002");
|
||||||
|
assert_eq!(
|
||||||
|
store.switch_active_branch("story_demo", "branch_second", "branch_second"),
|
||||||
|
Err(StoreError::StaleBranchHead {
|
||||||
|
expected: "branch_main".to_owned(),
|
||||||
|
actual: "branch_second".to_owned(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn memory_appends_and_loads_the_branch_head() {
|
fn memory_appends_and_loads_the_branch_head() {
|
||||||
let store = InMemoryStoryStore::new();
|
let store = InMemoryStoryStore::new();
|
||||||
@@ -1602,6 +2289,18 @@ mod tests {
|
|||||||
assert_creates_independent_branches_from_history(&store);
|
assert_creates_independent_branches_from_history(&store);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn memory_lists_renames_and_switches_active_branches() {
|
||||||
|
let store = InMemoryStoryStore::new();
|
||||||
|
assert_lists_renames_and_switches_active_branches(&store);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sqlite_lists_renames_and_switches_active_branches() {
|
||||||
|
let store = SqliteStoryStore::open_in_memory().expect("in-memory SQLite store");
|
||||||
|
assert_lists_renames_and_switches_active_branches(&store);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn memory_rejects_invalid_duplicate_and_unknown_forks_atomically() {
|
fn memory_rejects_invalid_duplicate_and_unknown_forks_atomically() {
|
||||||
let store = InMemoryStoryStore::new();
|
let store = InMemoryStoryStore::new();
|
||||||
@@ -2065,6 +2764,76 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sqlite_migrates_wave4_schema_and_restores_branch_session_metadata() {
|
||||||
|
let database = TemporaryDatabase::new();
|
||||||
|
{
|
||||||
|
let store = SqliteStoryStore::open(database.path()).expect("file SQLite store");
|
||||||
|
store
|
||||||
|
.append_node(
|
||||||
|
&node("node_001", None, "branch_main"),
|
||||||
|
&state("node_001", "branch_main"),
|
||||||
|
)
|
||||||
|
.expect("root append");
|
||||||
|
store
|
||||||
|
.connection
|
||||||
|
.lock()
|
||||||
|
.expect("SQLite connection lock")
|
||||||
|
.execute_batch(
|
||||||
|
"DROP TABLE story_sessions;
|
||||||
|
DROP TABLE branch_metadata;
|
||||||
|
DROP TABLE app_settings;
|
||||||
|
PRAGMA user_version = 1;",
|
||||||
|
)
|
||||||
|
.expect("simulate Wave 4 schema");
|
||||||
|
}
|
||||||
|
|
||||||
|
let migrated = SqliteStoryStore::open(database.path()).expect("migrated Wave 4 store");
|
||||||
|
assert_eq!(
|
||||||
|
migrated.active_branch("story_demo").expect("active branch"),
|
||||||
|
"branch_main"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
migrated.list_branches("story_demo").expect("branch list")[0].name,
|
||||||
|
"主线路"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
migrated
|
||||||
|
.connection
|
||||||
|
.lock()
|
||||||
|
.expect("SQLite connection lock")
|
||||||
|
.query_row("PRAGMA user_version", [], |row| row.get::<_, i64>(0))
|
||||||
|
.expect("schema version"),
|
||||||
|
SCHEMA_VERSION
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sqlite_persists_app_settings_without_exposing_them_to_story_state() {
|
||||||
|
let database = TemporaryDatabase::new();
|
||||||
|
{
|
||||||
|
let store = SqliteStoryStore::open(database.path()).expect("file SQLite store");
|
||||||
|
store
|
||||||
|
.set_app_settings(&[("lapp.provider_id", "provider"), ("lapp.model_id", "model")])
|
||||||
|
.expect("settings transaction");
|
||||||
|
}
|
||||||
|
let reopened = SqliteStoryStore::open(database.path()).expect("reopened store");
|
||||||
|
assert_eq!(
|
||||||
|
reopened
|
||||||
|
.get_app_setting("lapp.provider_id")
|
||||||
|
.expect("provider setting")
|
||||||
|
.as_deref(),
|
||||||
|
Some("provider")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
reopened
|
||||||
|
.get_app_setting("lapp.model_id")
|
||||||
|
.expect("model setting")
|
||||||
|
.as_deref(),
|
||||||
|
Some("model")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sqlite_rejects_an_unknown_future_schema_version_without_changing_it() {
|
fn sqlite_rejects_an_unknown_future_schema_version_without_changing_it() {
|
||||||
let database = TemporaryDatabase::new();
|
let database = TemporaryDatabase::new();
|
||||||
@@ -2152,7 +2921,9 @@ mod tests {
|
|||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
SqliteStoryStore::open(database.path()),
|
SqliteStoryStore::open(database.path()),
|
||||||
Err(StoreError::Sqlite(message))
|
Err(StoreError::Sqlite(message))
|
||||||
if message.contains("schema version 1 is incomplete or incompatible")
|
if message.contains(&format!(
|
||||||
|
"schema version {SCHEMA_VERSION} is incomplete or incompatible"
|
||||||
|
))
|
||||||
));
|
));
|
||||||
|
|
||||||
let connection = Connection::open(database.path()).expect("reopen raw SQLite database");
|
let connection = Connection::open(database.path()).expect("reopen raw SQLite database");
|
||||||
|
|||||||
@@ -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。
|
||||||
@@ -18,6 +18,7 @@ nana-domain.workspace = true
|
|||||||
nana-engine.workspace = true
|
nana-engine.workspace = true
|
||||||
nana-runtime.workspace = true
|
nana-runtime.workspace = true
|
||||||
nana-store.workspace = true
|
nana-store.workspace = true
|
||||||
|
openlapp.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = [] }
|
||||||
|
|||||||
+982
-24
File diff suppressed because it is too large
Load Diff
+51
-1
@@ -52,7 +52,7 @@ describe("App", () => {
|
|||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(wrapper.text()).toContain("我答应你,天亮前一定回来。");
|
expect(wrapper.text()).toContain("我答应你,天亮前一定回来。");
|
||||||
expect(wrapper.get(".statusline span").text()).toBe("node_002");
|
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(".character").attributes("data-pose")).toBe("holding_coat");
|
||||||
});
|
});
|
||||||
expect(wrapper.get(".send-button").text()).toBe("发送");
|
expect(wrapper.get(".send-button").text()).toBe("发送");
|
||||||
@@ -73,6 +73,30 @@ describe("App", () => {
|
|||||||
wrapper.unmount();
|
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 () => {
|
it("opens mutually exclusive, PlayerView-only record and relationship panels", async () => {
|
||||||
const wrapper = await mountLoadedApp();
|
const wrapper = await mountLoadedApp();
|
||||||
const [recordsButton, relationshipButton] = wrapper.findAll(".top-actions button");
|
const [recordsButton, relationshipButton] = wrapper.findAll(".top-actions button");
|
||||||
@@ -125,6 +149,32 @@ describe("App", () => {
|
|||||||
expect(status[1]?.text()).toContain("branch_fork_");
|
expect(status[1]?.text()).toContain("branch_fork_");
|
||||||
});
|
});
|
||||||
expect(wrapper.find("#history-panel").exists()).toBe(false);
|
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();
|
wrapper.unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+157
-6
@@ -3,24 +3,31 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
|
|||||||
|
|
||||||
import { useDemo } from "./app/useDemo";
|
import { useDemo } from "./app/useDemo";
|
||||||
|
|
||||||
type PanelName = "records" | "relationship" | "history";
|
type PanelName = "records" | "relationship" | "history" | "settings";
|
||||||
|
|
||||||
const {
|
const {
|
||||||
appInfo,
|
appInfo,
|
||||||
|
branchList,
|
||||||
busy,
|
busy,
|
||||||
error,
|
error,
|
||||||
lastSubmittedIntent,
|
lastSubmittedIntent,
|
||||||
loading,
|
loading,
|
||||||
|
lappSettings,
|
||||||
pack,
|
pack,
|
||||||
playerView,
|
playerView,
|
||||||
forkBranch,
|
forkBranch,
|
||||||
|
renameBranch,
|
||||||
|
selectLappModel,
|
||||||
submitTurn,
|
submitTurn,
|
||||||
|
switchBranch,
|
||||||
turnError
|
turnError
|
||||||
} = useDemo();
|
} = useDemo();
|
||||||
const draft = ref("");
|
const draft = ref("");
|
||||||
const activePanel = ref<PanelName | null>(null);
|
const activePanel = ref<PanelName | null>(null);
|
||||||
const selectedHistoryNode = ref<string | null>(null);
|
const selectedHistoryNode = ref<string | null>(null);
|
||||||
const closeButton = ref<HTMLButtonElement | null>(null);
|
const closeButton = ref<HTMLButtonElement | null>(null);
|
||||||
|
const branchNameDrafts = ref<Record<string, string>>({});
|
||||||
|
const lappSelection = ref("");
|
||||||
|
|
||||||
const sceneLabel = computed(() => {
|
const sceneLabel = computed(() => {
|
||||||
if (!playerView.value) return "载入场景";
|
if (!playerView.value) return "载入场景";
|
||||||
@@ -107,6 +114,47 @@ 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) : "";
|
||||||
|
});
|
||||||
|
|
||||||
|
async function saveLappSelection() {
|
||||||
|
const [providerId, modelId] = selectedLappValue.value.split("|").map(decodeURIComponent);
|
||||||
|
if (!providerId || !modelId) return;
|
||||||
|
await selectLappModel(providerId, modelId);
|
||||||
|
}
|
||||||
|
|
||||||
function handleEscape(event: KeyboardEvent) {
|
function handleEscape(event: KeyboardEvent) {
|
||||||
if (event.key === "Escape" && activePanel.value) {
|
if (event.key === "Escape" && activePanel.value) {
|
||||||
closePanel();
|
closePanel();
|
||||||
@@ -150,6 +198,16 @@ onBeforeUnmount(() => window.removeEventListener("keydown", handleEscape));
|
|||||||
>
|
>
|
||||||
回溯
|
回溯
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="settings-button"
|
||||||
|
type="button"
|
||||||
|
aria-label="模型设置"
|
||||||
|
aria-controls="settings-panel"
|
||||||
|
:aria-expanded="activePanel === 'settings'"
|
||||||
|
@click="openPanel('settings')"
|
||||||
|
>
|
||||||
|
设置
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -177,7 +235,9 @@ onBeforeUnmount(() => window.removeEventListener("keydown", handleEscape));
|
|||||||
<div class="character-coat" />
|
<div class="character-coat" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="chapter-chip">第一幕 · 雨夜车站</div>
|
<div class="chapter-chip">
|
||||||
|
{{ playerView.canContinue ? "第一幕 · 雨夜车站" : "第一幕终 · 天亮之前" }}
|
||||||
|
</div>
|
||||||
|
|
||||||
<article class="dialogue-panel" aria-label="最近演出">
|
<article class="dialogue-panel" aria-label="最近演出">
|
||||||
<ol class="beat-list">
|
<ol class="beat-list">
|
||||||
@@ -206,7 +266,7 @@ onBeforeUnmount(() => window.removeEventListener("keydown", handleEscape));
|
|||||||
aria-label="自由输入"
|
aria-label="自由输入"
|
||||||
placeholder="说些什么,或者描述你的行动……"
|
placeholder="说些什么,或者描述你的行动……"
|
||||||
rows="2"
|
rows="2"
|
||||||
:disabled="busy"
|
:disabled="busy || !playerView.canContinue"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
class="continue-button"
|
class="continue-button"
|
||||||
@@ -216,7 +276,11 @@ onBeforeUnmount(() => window.removeEventListener("keydown", handleEscape));
|
|||||||
>
|
>
|
||||||
{{ busy && lastSubmittedIntent === "continue" ? "继续中…" : "继续" }}
|
{{ busy && lastSubmittedIntent === "continue" ? "继续中…" : "继续" }}
|
||||||
</button>
|
</button>
|
||||||
<button class="send-button" type="submit" :disabled="busy || draft.trim().length === 0">
|
<button
|
||||||
|
class="send-button"
|
||||||
|
type="submit"
|
||||||
|
:disabled="busy || !playerView.canContinue || draft.trim().length === 0"
|
||||||
|
>
|
||||||
{{ busy && lastSubmittedIntent === "speak_or_act" ? "发送中…" : "发送" }}
|
{{ busy && lastSubmittedIntent === "speak_or_act" ? "发送中…" : "发送" }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -249,7 +313,9 @@ onBeforeUnmount(() => window.removeEventListener("keydown", handleEscape));
|
|||||||
? "随身记录"
|
? "随身记录"
|
||||||
: activePanel === "relationship"
|
: activePanel === "relationship"
|
||||||
? `与${playerView.characterName}的关系`
|
? `与${playerView.characterName}的关系`
|
||||||
: "故事回溯"
|
: activePanel === "history"
|
||||||
|
? "故事线路"
|
||||||
|
: "模型设置"
|
||||||
}}
|
}}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -326,7 +392,48 @@ onBeforeUnmount(() => window.removeEventListener("keydown", handleEscape));
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
<p class="panel-intro">从旧节点继续会创建一条新线路,当前线路仍会完整保留。</p>
|
||||||
<ol class="history-list">
|
<ol class="history-list">
|
||||||
<li v-for="node in playerView.history" :key="node.id">
|
<li v-for="node in playerView.history" :key="node.id">
|
||||||
@@ -359,6 +466,50 @@ onBeforeUnmount(() => window.removeEventListener("keydown", handleEscape));
|
|||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:disabled="busy || !selectedLappValue || lappSettings.mode === 'demo'"
|
||||||
|
@click="saveLappSelection"
|
||||||
|
>
|
||||||
|
{{ busy ? "正在应用…" : "应用模型" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
+56
-2
@@ -2,11 +2,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
import initialView from "../../fixtures/player-view/initial.json";
|
import initialView from "../../fixtures/player-view/initial.json";
|
||||||
import type {
|
import type {
|
||||||
|
BranchList,
|
||||||
ForkBranchRequest,
|
ForkBranchRequest,
|
||||||
ForkBranchResult,
|
ForkBranchResult,
|
||||||
|
LappSettings,
|
||||||
PlayerView,
|
PlayerView,
|
||||||
|
SwitchBranchRequest,
|
||||||
|
SwitchBranchResult,
|
||||||
TurnRequest,
|
TurnRequest,
|
||||||
TurnResult
|
TurnResult,
|
||||||
|
UpdateLappSettingsRequest
|
||||||
} from "@contracts";
|
} from "@contracts";
|
||||||
|
|
||||||
const invokeMock = vi.hoisted(() => vi.fn());
|
const invokeMock = vi.hoisted(() => vi.fn());
|
||||||
@@ -15,7 +20,14 @@ vi.mock("@tauri-apps/api/core", () => ({
|
|||||||
invoke: invokeMock
|
invoke: invokeMock
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { forkBranch, submitTurn } from "./bridge";
|
import {
|
||||||
|
forkBranch,
|
||||||
|
getBranchList,
|
||||||
|
getLappSettings,
|
||||||
|
submitTurn,
|
||||||
|
switchBranch,
|
||||||
|
updateLappSettings
|
||||||
|
} from "./bridge";
|
||||||
|
|
||||||
const request: TurnRequest = {
|
const request: TurnRequest = {
|
||||||
storyId: initialView.storyId,
|
storyId: initialView.storyId,
|
||||||
@@ -70,4 +82,46 @@ describe("Tauri bridge", () => {
|
|||||||
await expect(forkBranch(forkRequest, initialView as PlayerView)).resolves.toEqual(expected);
|
await expect(forkBranch(forkRequest, initialView as PlayerView)).resolves.toEqual(expected);
|
||||||
expect(invokeMock).toHaveBeenCalledWith("fork_branch", { request: forkRequest });
|
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 }]
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+78
-2
@@ -3,12 +3,18 @@ import { invoke } from "@tauri-apps/api/core";
|
|||||||
import initialView from "../../fixtures/player-view/initial.json";
|
import initialView from "../../fixtures/player-view/initial.json";
|
||||||
import type {
|
import type {
|
||||||
AppInfo,
|
AppInfo,
|
||||||
|
BranchList,
|
||||||
DemoPackSummary,
|
DemoPackSummary,
|
||||||
ForkBranchRequest,
|
ForkBranchRequest,
|
||||||
ForkBranchResult,
|
ForkBranchResult,
|
||||||
|
LappSettings,
|
||||||
PlayerView,
|
PlayerView,
|
||||||
|
RenameBranchRequest,
|
||||||
|
SwitchBranchRequest,
|
||||||
|
SwitchBranchResult,
|
||||||
TurnRequest,
|
TurnRequest,
|
||||||
TurnResult
|
TurnResult,
|
||||||
|
UpdateLappSettingsRequest
|
||||||
} from "@contracts";
|
} from "@contracts";
|
||||||
|
|
||||||
import { forkDemoBranch, submitDemoTurn } from "./turnAdapter";
|
import { forkDemoBranch, submitDemoTurn } from "./turnAdapter";
|
||||||
@@ -45,12 +51,82 @@ export async function getDemoPackSummary(): Promise<DemoPackSummary> {
|
|||||||
worldBooks: 1,
|
worldBooks: 1,
|
||||||
personas: 1,
|
personas: 1,
|
||||||
plotModules: 1,
|
plotModules: 1,
|
||||||
itemSpecs: 1
|
itemSpecs: 2
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return invoke<DemoPackSummary>("get_demo_pack_summary");
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
export async function submitTurn(
|
export async function submitTurn(
|
||||||
request: TurnRequest,
|
request: TurnRequest,
|
||||||
currentView: PlayerView
|
currentView: PlayerView
|
||||||
|
|||||||
@@ -60,4 +60,51 @@ describe("local turn adapter", () => {
|
|||||||
expect(advanced.playerView.branchId).toBe(initialView.branchId);
|
expect(advanced.playerView.branchId).toBe(initialView.branchId);
|
||||||
expect(advanced.playerView.nodeId).toBe("node_002");
|
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("你回来了");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+186
-3
@@ -8,6 +8,7 @@ import type {
|
|||||||
} from "@contracts";
|
} from "@contracts";
|
||||||
|
|
||||||
const DEMO_LATENCY_MS = 120;
|
const DEMO_LATENCY_MS = 120;
|
||||||
|
type DemoPhase = "promise" | "investigate" | "enter" | "return" | "regular";
|
||||||
|
|
||||||
function nextNodeId(currentNodeId: string): string {
|
function nextNodeId(currentNodeId: string): string {
|
||||||
const match = currentNodeId.match(/^(.*?)(\d+)$/);
|
const match = currentNodeId.match(/^(.*?)(\d+)$/);
|
||||||
@@ -17,7 +18,134 @@ function nextNodeId(currentNodeId: string): string {
|
|||||||
return `${prefix}${String(Number(digits) + 1).padStart(digits.length, "0")}`;
|
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") {
|
if (request.intent === "continue") {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -78,8 +206,58 @@ export async function submitDemoTurn(
|
|||||||
|
|
||||||
const committedNodeId = nextNodeId(currentView.nodeId);
|
const committedNodeId = nextNodeId(currentView.nodeId);
|
||||||
const previousHistory = currentView.history.map((node) => ({ ...node, isCurrent: false }));
|
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 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 {
|
return {
|
||||||
committedNodeId,
|
committedNodeId,
|
||||||
@@ -89,13 +267,18 @@ export async function submitDemoTurn(
|
|||||||
characterExpression: finalVisual?.expression ?? currentView.characterExpression,
|
characterExpression: finalVisual?.expression ?? currentView.characterExpression,
|
||||||
characterPose: finalVisual?.pose ?? currentView.characterPose,
|
characterPose: finalVisual?.pose ?? currentView.characterPose,
|
||||||
beats: [...currentView.beats, ...beats].slice(-8),
|
beats: [...currentView.beats, ...beats].slice(-8),
|
||||||
|
promises,
|
||||||
|
knowledge,
|
||||||
|
inventory,
|
||||||
|
suggestions: phase === "return" ? [] : currentView.suggestions,
|
||||||
|
canContinue: phase !== "return",
|
||||||
history: [
|
history: [
|
||||||
...previousHistory,
|
...previousHistory,
|
||||||
{
|
{
|
||||||
id: committedNodeId,
|
id: committedNodeId,
|
||||||
parentId: currentView.nodeId,
|
parentId: currentView.nodeId,
|
||||||
branchId: currentView.branchId,
|
branchId: currentView.branchId,
|
||||||
label: request.intent === "continue" ? "雨声中的停顿" : "回应娜娜",
|
label: historyLabel,
|
||||||
isCurrent: true
|
isCurrent: true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
+142
-3
@@ -1,12 +1,25 @@
|
|||||||
import { onMounted, ref } from "vue";
|
import { 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 {
|
import {
|
||||||
forkBranch as forkRuntimeBranch,
|
forkBranch as forkRuntimeBranch,
|
||||||
getAppInfo,
|
getAppInfo,
|
||||||
|
getBranchList,
|
||||||
getDemoPackSummary,
|
getDemoPackSummary,
|
||||||
getDemoPlayerView,
|
getDemoPlayerView,
|
||||||
|
getLappSettings,
|
||||||
|
renameBranch as renameRuntimeBranch,
|
||||||
|
switchBranch as switchRuntimeBranch,
|
||||||
|
updateLappSettings,
|
||||||
submitTurn as submitRuntimeTurn
|
submitTurn as submitRuntimeTurn
|
||||||
} from "./bridge";
|
} from "./bridge";
|
||||||
|
|
||||||
@@ -27,19 +40,26 @@ export function useDemo() {
|
|||||||
const appInfo = ref<AppInfo | null>(null);
|
const appInfo = ref<AppInfo | null>(null);
|
||||||
const pack = ref<DemoPackSummary | null>(null);
|
const pack = ref<DemoPackSummary | null>(null);
|
||||||
const playerView = ref<PlayerView | 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 loading = ref(true);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
const turnError = ref<string | null>(null);
|
const turnError = ref<string | null>(null);
|
||||||
const lastSubmittedIntent = ref<TurnIntent | null>(null);
|
const lastSubmittedIntent = ref<TurnIntent | null>(null);
|
||||||
|
const branchViews = new Map<string, PlayerView>();
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
[appInfo.value, pack.value, playerView.value] = await Promise.all([
|
[appInfo.value, pack.value, playerView.value, branchList.value, lappSettings.value] =
|
||||||
|
await Promise.all([
|
||||||
getAppInfo(),
|
getAppInfo(),
|
||||||
getDemoPackSummary(),
|
getDemoPackSummary(),
|
||||||
getDemoPlayerView()
|
getDemoPlayerView(),
|
||||||
|
getBranchList(),
|
||||||
|
getLappSettings()
|
||||||
]);
|
]);
|
||||||
|
if (playerView.value) branchViews.set(playerView.value.branchId, playerView.value);
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
error.value = errorMessage(reason);
|
error.value = errorMessage(reason);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -71,6 +91,8 @@ export function useDemo() {
|
|||||||
try {
|
try {
|
||||||
const result = await submitRuntimeTurn(request, currentView);
|
const result = await submitRuntimeTurn(request, currentView);
|
||||||
playerView.value = result.playerView;
|
playerView.value = result.playerView;
|
||||||
|
branchViews.set(result.playerView.branchId, result.playerView);
|
||||||
|
updateBranchHead(result.playerView);
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
turnError.value = errorMessage(reason);
|
turnError.value = errorMessage(reason);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -98,6 +120,27 @@ export function useDemo() {
|
|||||||
currentView
|
currentView
|
||||||
);
|
);
|
||||||
playerView.value = result.playerView;
|
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) {
|
} catch (reason) {
|
||||||
turnError.value = errorMessage(reason);
|
turnError.value = errorMessage(reason);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -105,16 +148,112 @@ export function useDemo() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
turnError.value = null;
|
||||||
|
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 = errorMessage(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;
|
||||||
|
turnError.value = null;
|
||||||
|
try {
|
||||||
|
branchList.value = await renameRuntimeBranch(
|
||||||
|
{ storyId: branches.storyId, branchId, name: normalized },
|
||||||
|
branches
|
||||||
|
);
|
||||||
|
} catch (reason) {
|
||||||
|
turnError.value = errorMessage(reason);
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectLappModel(providerId: string, modelId: string): Promise<void> {
|
||||||
|
if (busy.value) return;
|
||||||
|
busy.value = true;
|
||||||
|
turnError.value = null;
|
||||||
|
try {
|
||||||
|
lappSettings.value = await updateLappSettings({ providerId, modelId });
|
||||||
|
} catch (reason) {
|
||||||
|
turnError.value = errorMessage(reason);
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
return {
|
||||||
appInfo,
|
appInfo,
|
||||||
|
branchList,
|
||||||
busy,
|
busy,
|
||||||
error,
|
error,
|
||||||
lastSubmittedIntent,
|
lastSubmittedIntent,
|
||||||
loading,
|
loading,
|
||||||
|
lappSettings,
|
||||||
pack,
|
pack,
|
||||||
playerView,
|
playerView,
|
||||||
forkBranch,
|
forkBranch,
|
||||||
|
renameBranch,
|
||||||
|
selectLappModel,
|
||||||
submitTurn,
|
submitTurn,
|
||||||
|
switchBranch,
|
||||||
turnError
|
turnError
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ body,
|
|||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
textarea {
|
textarea {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
@@ -103,6 +105,8 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
button:focus-visible,
|
button:focus-visible,
|
||||||
|
input:focus-visible,
|
||||||
|
select:focus-visible,
|
||||||
textarea:focus-visible {
|
textarea:focus-visible {
|
||||||
outline: 2px solid #f0c1ae;
|
outline: 2px solid #f0c1ae;
|
||||||
outline-offset: 3px;
|
outline-offset: 3px;
|
||||||
@@ -647,6 +651,138 @@ textarea:focus-visible {
|
|||||||
margin: 20px 0 0;
|
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 {
|
||||||
|
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-card 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-card 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-status span[data-mode="unavailable"] {
|
||||||
|
color: #e7a7a7;
|
||||||
|
border-color: rgb(231 167 167 / 28%);
|
||||||
|
}
|
||||||
|
|
||||||
.history-list {
|
.history-list {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user