From 1f935a32060580470c24d3d41ae19ebb2275e6da Mon Sep 17 00:00:00 2001
From: Codex
Date: Tue, 28 Jul 2026 04:42:40 -0400
Subject: [PATCH] feat(app): complete playable branch sessions
---
Cargo.lock | 1 +
README.md | 12 +-
content/nana-demo/bundle.json | 20 +
contracts/.source.sha256 | 2 +-
contracts/schema/branch-list.schema.json | 59 +
contracts/schema/lapp-settings.schema.json | 73 ++
.../schema/rename-branch-request.schema.json | 21 +
.../schema/switch-branch-request.schema.json | 21 +
.../schema/switch-branch-result.schema.json | 397 +++++++
.../update-lapp-settings-request.schema.json | 17 +
contracts/ts/index.ts | 18 +
crates/nana-contracts/src/main.rs | 43 +-
crates/nana-domain/src/lib.rs | 85 ++
crates/nana-runtime/src/lapp_provider.rs | 40 +-
crates/nana-store/src/lib.rs | 785 ++++++++++++-
docs/status/2026-07-28-m2-wave5.md | 68 ++
src-tauri/Cargo.toml | 1 +
src-tauri/src/lib.rs | 1006 ++++++++++++++++-
src/App.spec.ts | 52 +-
src/App.vue | 163 ++-
src/app/bridge.spec.ts | 58 +-
src/app/bridge.ts | 80 +-
src/app/turnAdapter.spec.ts | 47 +
src/app/turnAdapter.ts | 189 +++-
src/app/useDemo.ts | 145 ++-
src/styles/main.css | 136 +++
26 files changed, 3472 insertions(+), 67 deletions(-)
create mode 100644 contracts/schema/branch-list.schema.json
create mode 100644 contracts/schema/lapp-settings.schema.json
create mode 100644 contracts/schema/rename-branch-request.schema.json
create mode 100644 contracts/schema/switch-branch-request.schema.json
create mode 100644 contracts/schema/switch-branch-result.schema.json
create mode 100644 contracts/schema/update-lapp-settings-request.schema.json
create mode 100644 docs/status/2026-07-28-m2-wave5.md
diff --git a/Cargo.lock b/Cargo.lock
index 7059404..823dc47 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1928,6 +1928,7 @@ dependencies = [
"nana-engine",
"nana-runtime",
"nana-store",
+ "openlapp",
"serde",
"serde_json",
"tauri",
diff --git a/README.md b/README.md
index 92bee52..d1808fa 100644
--- a/README.md
+++ b/README.md
@@ -25,14 +25,24 @@ M0 契约基线已建立;M1 状态、投影与持久化主链已经接通,M2
- 引擎掌控的类型化隐藏检定循环,模型只收到定性结果,不能指定或读取骰点、难度与精确数值;
- 基于 `lapp-rs` 原生工具消息的 LAPP TurnPlan provider 与严格输出校验;
- 从任意当前线路历史节点创建真实持久化分支,旧线路与新线路保持隔离;
+- 列出、重命名、切换故事线路,并在关闭应用后恢复最后活动线路;
+- SQLite schema v2 与从 wave4 schema v1 的无损迁移;
+- 应用内选择 LAPP profile 中声明了聊天与工具调用能力的模型,凭据仍只由 LAPP
+ Vault 即时解析;
+- 可完整游玩的“天亮之前”纵切:接受许诺、隐藏搜索、获得车票、进入隧道、天亮前
+ 归来并结算许诺;
- Turn 请求/结果校验、Fake Provider 与确定性世界书触发;
- 可交互的主演出屏、持有物/线索/许诺、关系与回溯面板。
浏览器模式继续使用确定性的本地 Turn adapter,方便无桌面壳开发;Tauri
模式已经通过 `submit_turn` 使用 SQLite 自动保存和恢复,并通过 `fork_branch`
-创建持久化分支。桌面端默认使用真实 LAPP provider;只有显式设置
+创建持久化分支。线路选择和名称也保存在同一 SQLite 存档中。桌面端默认使用真实
+LAPP provider;只有显式设置
`NANA_STORY_PROVIDER=demo` 时才启用确定性的娜娜纵切实现。
+应用内“设置”只选择 LAPP profile 已有模型,不读取、保存或回显 API Key。若 profile
+缺失,或没有声明 `chat` 与 `tool-call` 能力的启用模型,界面会明确显示不可用。
+
Rust 1.96 下的核心测试、Clippy、契约生成检查、Tauri 全 target 类型检查与后端
单元测试已经通过。当前 Linux Work 环境缺少 WebKitGTK 等桌面开发库,因此真实
桌面窗口启动与 Windows 打包仍需在具备原生依赖的环境补跑。
diff --git a/content/nana-demo/bundle.json b/content/nana-demo/bundle.json
index 3dd34c1..691b601 100644
--- a/content/nana-demo/bundle.json
+++ b/content/nana-demo/bundle.json
@@ -189,6 +189,26 @@
"check_modifier": 1
},
"hidden_facts": ["电池仓里刻着青川站工作人员的编号"]
+ },
+ {
+ "header": {
+ "id": "nana.item.half_ticket",
+ "kind": "item_spec",
+ "schema_version": 1,
+ "revision": "0.1.0",
+ "content_hash": "sha256:6666666666666666666666666666666666666666666666666666666666666666",
+ "dependencies": []
+ },
+ "name": "半张旧车票",
+ "description": "从检修门缝里找到的受潮车票,背面写着“四点十七分,检修线”。",
+ "tags": ["clue", "ticket", "sister"],
+ "lore_refs": ["station_clock"],
+ "mechanics": {
+ "usable": false,
+ "grants_tags": ["has_sister_clue"],
+ "check_modifier": null
+ },
+ "hidden_facts": ["车票纤维中残留着封锁隧道深处的红色矿尘"]
}
]
}
diff --git a/contracts/.source.sha256 b/contracts/.source.sha256
index ea52d56..e02be82 100644
--- a/contracts/.source.sha256
+++ b/contracts/.source.sha256
@@ -1 +1 @@
-93d800013b9bec3087490ae629039724076cf26d9bb9a5f8834c6aa7473b1ca7
+6a3351b1936a09050428f73f854b2a89c96a98b049657d87bce78cf84d46e9eb
diff --git a/contracts/schema/branch-list.schema.json b/contracts/schema/branch-list.schema.json
new file mode 100644
index 0000000..46dcac2
--- /dev/null
+++ b/contracts/schema/branch-list.schema.json
@@ -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"
+ ]
+ }
+ }
+}
diff --git a/contracts/schema/lapp-settings.schema.json b/contracts/schema/lapp-settings.schema.json
new file mode 100644
index 0000000..bd9dcb2
--- /dev/null
+++ b/contracts/schema/lapp-settings.schema.json
@@ -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"
+ ]
+ }
+ }
+}
diff --git a/contracts/schema/rename-branch-request.schema.json b/contracts/schema/rename-branch-request.schema.json
new file mode 100644
index 0000000..5933b84
--- /dev/null
+++ b/contracts/schema/rename-branch-request.schema.json
@@ -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"
+ ]
+}
diff --git a/contracts/schema/switch-branch-request.schema.json b/contracts/schema/switch-branch-request.schema.json
new file mode 100644
index 0000000..ccf2f1d
--- /dev/null
+++ b/contracts/schema/switch-branch-request.schema.json
@@ -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"
+ ]
+}
diff --git a/contracts/schema/switch-branch-result.schema.json b/contracts/schema/switch-branch-result.schema.json
new file mode 100644
index 0000000..51b99e5
--- /dev/null
+++ b/contracts/schema/switch-branch-result.schema.json
@@ -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"
+ ]
+ }
+ }
+ }
+ }
+}
diff --git a/contracts/schema/update-lapp-settings-request.schema.json b/contracts/schema/update-lapp-settings-request.schema.json
new file mode 100644
index 0000000..5a51705
--- /dev/null
+++ b/contracts/schema/update-lapp-settings-request.schema.json
@@ -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"
+ ]
+}
diff --git a/contracts/ts/index.ts b/contracts/ts/index.ts
index 7d25367..8d700e3 100644
--- a/contracts/ts/index.ts
+++ b/contracts/ts/index.ts
@@ -120,6 +120,24 @@ export type ForkBranchRequest = { storyId: string, currentBranchId: string, expe
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, };
+
+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, 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 TurnFailure = { code: TurnFailureCode, message: string, retryable: boolean, };
diff --git a/crates/nana-contracts/src/main.rs b/crates/nana-contracts/src/main.rs
index 9df7f87..f3be8b6 100644
--- a/crates/nana-contracts/src/main.rs
+++ b/crates/nana-contracts/src/main.rs
@@ -4,18 +4,20 @@ use std::{
};
use nana_domain::{
- AcquisitionMode, ActionSuggestion, AppInfo, BeatKind, CharacterCard, CharacterJudgmentRule,
- CharacterStyle, CheckDifficulty, CheckRecord, CheckResult, ClockState, DemoPackSummary,
- ForkBranchRequest, ForkBranchResult, HistoryNodeView, ItemAcquisition, ItemInstance,
- ItemMechanics, ItemPlacement, ItemSpec, KnowledgeCertainty, KnowledgeRecord, Persona,
- PlayerItemView, PlayerKnowledgeView, PlayerPromiseView, PlayerView, PlotEvent, PlotModule,
- PlotOutcome, PlotPressure, PresentationBeat, PresentationCharacter, PresentationScene,
- PresentationSnapshot, Promise, PromiseStatus, PromiseWeight, RelationshipAdjustment,
- RelationshipAxes, RelationshipBand, RelationshipDimension, RelationshipState, RelationshipView,
+ AcquisitionMode, ActionSuggestion, AppInfo, BeatKind, BranchList, BranchSummary, CharacterCard,
+ CharacterJudgmentRule, CharacterStyle, CheckDifficulty, CheckRecord, CheckResult, ClockState,
+ DemoPackSummary, ForkBranchRequest, ForkBranchResult, HistoryNodeView, ItemAcquisition,
+ ItemInstance, ItemMechanics, ItemPlacement, ItemSpec, KnowledgeCertainty, KnowledgeRecord,
+ LappMode, LappModelOption, LappSettings, Persona, PlayerItemView, PlayerKnowledgeView,
+ PlayerPromiseView, PlayerView, PlotEvent, PlotModule, PlotOutcome, PlotPressure,
+ PresentationBeat, PresentationCharacter, PresentationScene, PresentationSnapshot, Promise,
+ PromiseStatus, PromiseWeight, RelationshipAdjustment, RelationshipAxes, RelationshipBand,
+ RelationshipDimension, RelationshipState, RelationshipView, RenameBranchRequest,
ResourceBundle, ResourceHeader, ResourceId, ResourceKind, ResourceRef, RuntimeState,
- SkillValue, StateDelta, StateOp, Story, StoryBinding, StoryNode, TurnFailure, TurnFailureCode,
- TurnIntent, TurnRequest, TurnResult, ValidationCode, ValidationIssue, ValidationReport,
- VisualDirective, WorldBook, WorldBookEntry,
+ SkillValue, StateDelta, StateOp, Story, StoryBinding, StoryNode, SwitchBranchRequest,
+ SwitchBranchResult, TurnFailure, TurnFailureCode, TurnIntent, TurnRequest, TurnResult,
+ UpdateLappSettingsRequest, ValidationCode, ValidationIssue, ValidationReport, VisualDirective,
+ WorldBook, WorldBookEntry,
};
use schemars::{JsonSchema, schema_for};
use serde::Serialize;
@@ -74,6 +76,16 @@ fn generated_outputs(root: &Path) -> Result(&mut outputs, &schema_dir, "turn-result")?;
add_schema::(&mut outputs, &schema_dir, "fork-branch-request")?;
add_schema::(&mut outputs, &schema_dir, "fork-branch-result")?;
+ add_schema::(&mut outputs, &schema_dir, "branch-list")?;
+ add_schema::(&mut outputs, &schema_dir, "switch-branch-request")?;
+ add_schema::(&mut outputs, &schema_dir, "switch-branch-result")?;
+ add_schema::(&mut outputs, &schema_dir, "rename-branch-request")?;
+ add_schema::(&mut outputs, &schema_dir, "lapp-settings")?;
+ add_schema::(
+ &mut outputs,
+ &schema_dir,
+ "update-lapp-settings-request",
+ )?;
add_schema::(&mut outputs, &schema_dir, "turn-failure")?;
add_schema::(&mut outputs, &schema_dir, "app-info")?;
add_schema::(&mut outputs, &schema_dir, "demo-pack-summary")?;
@@ -154,6 +166,15 @@ fn generated_declarations() -> String {
TurnResult::decl(),
ForkBranchRequest::decl(),
ForkBranchResult::decl(),
+ BranchSummary::decl(),
+ BranchList::decl(),
+ SwitchBranchRequest::decl(),
+ SwitchBranchResult::decl(),
+ RenameBranchRequest::decl(),
+ LappModelOption::decl(),
+ LappMode::decl(),
+ LappSettings::decl(),
+ UpdateLappSettingsRequest::decl(),
TurnFailureCode::decl(),
TurnFailure::decl(),
AppInfo::decl(),
diff --git a/crates/nana-domain/src/lib.rs b/crates/nana-domain/src/lib.rs
index f4d6749..5bdd978 100644
--- a/crates/nana-domain/src/lib.rs
+++ b/crates/nana-domain/src/lib.rs
@@ -745,6 +745,91 @@ pub struct ForkBranchResult {
pub player_view: PlayerView,
}
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
+#[serde(rename_all = "camelCase")]
+#[ts(rename_all = "camelCase")]
+pub struct BranchSummary {
+ pub branch_id: String,
+ pub name: String,
+ pub head_node_id: String,
+ pub head_label: String,
+ pub source_node_id: Option,
+ 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,
+}
+
+#[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,
+ pub model_id: String,
+ pub model_name: Option,
+}
+
+#[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,
+ pub selected_model_id: Option,
+ #[serde(default)]
+ pub available_models: Vec,
+ pub status_message: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
+#[serde(rename_all = "camelCase")]
+#[ts(rename_all = "camelCase")]
+pub struct UpdateLappSettingsRequest {
+ pub provider_id: String,
+ pub model_id: String,
+}
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
diff --git a/crates/nana-runtime/src/lapp_provider.rs b/crates/nana-runtime/src/lapp_provider.rs
index 14c9848..f1aa710 100644
--- a/crates/nana-runtime/src/lapp_provider.rs
+++ b/crates/nana-runtime/src/lapp_provider.rs
@@ -79,13 +79,34 @@ pub struct OpenLappChatExecutor {
impl OpenLappChatExecutor {
pub fn from_profile(profile: &Profile) -> Result {
+ 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::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 {
let (commands, receiver) = mpsc::channel();
let (initialized, initialization) = mpsc::sync_channel(1);
let profile = profile.clone();
let _worker = thread::Builder::new()
.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 })?;
initialization
@@ -120,6 +141,7 @@ struct ChatCommand {
#[allow(clippy::needless_pass_by_value)]
fn run_chat_worker(
profile: Profile,
+ selector: ModelSelector,
commands: mpsc::Receiver,
initialized: mpsc::SyncSender>,
) {
@@ -131,11 +153,7 @@ fn run_chat_worker(
return;
};
let resolver: Arc = Arc::new(DefaultCredentialResolver::system());
- let client = match Client::new(
- &profile,
- &ModelSelector::Default("chat".to_owned()),
- resolver,
- ) {
+ let client = match Client::new(&profile, &selector, resolver) {
Ok(client) => client,
Err(error) => {
let _ = initialized.send(Err(ProviderError::Configuration {
@@ -212,6 +230,16 @@ impl LappAdjudicationModel {
pub fn from_profile(profile: &Profile, bundle: ResourceBundle) -> Result {
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 {
+ OpenLappChatExecutor::from_profile_and_model(profile, provider_id, model_id)
+ .map(|executor| Self::new(executor, bundle))
+ }
}
impl AdjudicationModel for LappAdjudicationModel {
diff --git a/crates/nana-store/src/lib.rs b/crates/nana-store/src/lib.rs
index 0ea4605..684048b 100644
--- a/crates/nana-store/src/lib.rs
+++ b/crates/nana-store/src/lib.rs
@@ -6,10 +6,11 @@ use std::{
};
use nana_domain::{RuntimeState, StoryNode, stable_json_hash};
-use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params};
+use rusqlite::{Connection, OptionalExtension, Transaction, TransactionBehavior, params};
use thiserror::Error;
-const SCHEMA_VERSION: i64 = 1;
+const SCHEMA_VERSION: i64 = 2;
+const LEGACY_SCHEMA_VERSION: i64 = 1;
#[cfg(test)]
const BUSY_TIMEOUT_MILLIS: i64 = 5_000;
const BUSY_TIMEOUT: Duration = Duration::from_secs(5);
@@ -38,6 +39,15 @@ pub enum StoreError {
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,
+ pub ordinal: u32,
+}
+
#[derive(Debug, Error, PartialEq, Eq)]
pub enum ForkError {
#[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;
fn load_node(&self, story_id: &str, node_id: &str) -> Result;
+
+ fn list_branches(&self, story_id: &str) -> Result, StoreError>;
+
+ fn active_branch(&self, story_id: &str) -> Result;
+
+ fn switch_active_branch(
+ &self,
+ story_id: &str,
+ expected_active_branch_id: &str,
+ branch_id: &str,
+ ) -> Result;
+
+ fn rename_branch(&self, story_id: &str, branch_id: &str, name: &str) -> Result<(), StoreError>;
}
#[derive(Debug, Default)]
@@ -92,6 +115,8 @@ struct MemoryData {
nodes: BTreeMap<(String, String), StoryNode>,
states: BTreeMap<(String, String), RuntimeState>,
branch_heads: BTreeMap<(String, String), String>,
+ branch_metadata: BTreeMap<(String, String), StoredBranch>,
+ active_branches: BTreeMap,
}
/// Deterministic test and development store.
@@ -182,7 +207,36 @@ impl StoryStore for InMemoryStoryStore {
// all-or-nothing transaction boundary required from the SQLite store.
data.nodes.insert(node_key.clone(), node.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(())
}
@@ -218,7 +272,27 @@ impl StoryStore for InMemoryStoryStore {
// Validation is complete before the only mutation.
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)
}
@@ -257,6 +331,86 @@ impl StoryStore for InMemoryStoryStore {
.cloned()
.ok_or_else(|| StoreError::ParentNotFound(node_id.to_owned()))
}
+
+ fn list_branches(&self, story_id: &str) -> Result, 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::>();
+ branches.sort_by_key(|branch| branch.ordinal);
+ Ok(branches)
+ }
+
+ fn active_branch(&self, story_id: &str) -> Result {
+ 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 {
+ 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.
@@ -347,6 +501,42 @@ impl SqliteStoryStore {
Ok(state)
}
+ pub fn get_app_setting(&self, key: &str) -> Result
-
+
+
从旧节点继续会创建一条新线路,当前线路仍会完整保留。
-
@@ -359,6 +466,50 @@ onBeforeUnmount(() => window.removeEventListener("keydown", handleEscape));
+
+
+
+ 这里只选择系统 LAPP profile 中已有的模型。API Key 与供应商凭据不会进入本应用。
+
+
+
+
+ {{
+ lappSettings.mode === "lapp"
+ ? "LAPP 已就绪"
+ : lappSettings.mode === "demo"
+ ? "确定性演示"
+ : "LAPP 不可用"
+ }}
+
+ {{ lappSettings.mode }}
+
+
{{ lappSettings.statusMessage }}
+
+
+
+ {{ busy ? "正在应用…" : "应用模型" }}
+
+
+
diff --git a/src/app/bridge.spec.ts b/src/app/bridge.spec.ts
index a81d480..ae7edf8 100644
--- a/src/app/bridge.spec.ts
+++ b/src/app/bridge.spec.ts
@@ -2,11 +2,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import initialView from "../../fixtures/player-view/initial.json";
import type {
+ BranchList,
ForkBranchRequest,
ForkBranchResult,
+ LappSettings,
PlayerView,
+ SwitchBranchRequest,
+ SwitchBranchResult,
TurnRequest,
- TurnResult
+ TurnResult,
+ UpdateLappSettingsRequest
} from "@contracts";
const invokeMock = vi.hoisted(() => vi.fn());
@@ -15,7 +20,14 @@ vi.mock("@tauri-apps/api/core", () => ({
invoke: invokeMock
}));
-import { forkBranch, submitTurn } from "./bridge";
+import {
+ forkBranch,
+ getBranchList,
+ getLappSettings,
+ submitTurn,
+ switchBranch,
+ updateLappSettings
+} from "./bridge";
const request: TurnRequest = {
storyId: initialView.storyId,
@@ -70,4 +82,46 @@ describe("Tauri bridge", () => {
await expect(forkBranch(forkRequest, initialView as PlayerView)).resolves.toEqual(expected);
expect(invokeMock).toHaveBeenCalledWith("fork_branch", { request: forkRequest });
});
+
+ it("uses typed commands for branch sessions and LAPP model selection", async () => {
+ const branches: BranchList = {
+ storyId: initialView.storyId,
+ activeBranchId: initialView.branchId,
+ branches: []
+ };
+ const switchRequest: SwitchBranchRequest = {
+ storyId: initialView.storyId,
+ branchId: "branch_second",
+ expectedActiveBranchId: initialView.branchId
+ };
+ const switched: SwitchBranchResult = {
+ branchId: "branch_second",
+ playerView: { ...(initialView as PlayerView), branchId: "branch_second" }
+ };
+ const settings: LappSettings = {
+ mode: "lapp",
+ selectedProviderId: "provider",
+ selectedModelId: "model",
+ availableModels: [],
+ statusMessage: "ready"
+ };
+ const update: UpdateLappSettingsRequest = { providerId: "provider", modelId: "model" };
+
+ invokeMock
+ .mockResolvedValueOnce(branches)
+ .mockResolvedValueOnce(switched)
+ .mockResolvedValueOnce(settings)
+ .mockResolvedValueOnce(settings);
+
+ await expect(getBranchList()).resolves.toEqual(branches);
+ await expect(switchBranch(switchRequest)).resolves.toEqual(switched);
+ await expect(getLappSettings()).resolves.toEqual(settings);
+ await expect(updateLappSettings(update)).resolves.toEqual(settings);
+ expect(invokeMock.mock.calls).toEqual([
+ ["get_branch_list"],
+ ["switch_branch", { request: switchRequest }],
+ ["get_lapp_settings"],
+ ["update_lapp_settings", { request: update }]
+ ]);
+ });
});
diff --git a/src/app/bridge.ts b/src/app/bridge.ts
index 8209218..3788189 100644
--- a/src/app/bridge.ts
+++ b/src/app/bridge.ts
@@ -3,12 +3,18 @@ import { invoke } from "@tauri-apps/api/core";
import initialView from "../../fixtures/player-view/initial.json";
import type {
AppInfo,
+ BranchList,
DemoPackSummary,
ForkBranchRequest,
ForkBranchResult,
+ LappSettings,
PlayerView,
+ RenameBranchRequest,
+ SwitchBranchRequest,
+ SwitchBranchResult,
TurnRequest,
- TurnResult
+ TurnResult,
+ UpdateLappSettingsRequest
} from "@contracts";
import { forkDemoBranch, submitDemoTurn } from "./turnAdapter";
@@ -45,12 +51,82 @@ export async function getDemoPackSummary(): Promise {
worldBooks: 1,
personas: 1,
plotModules: 1,
- itemSpecs: 1
+ itemSpecs: 2
};
}
return invoke("get_demo_pack_summary");
}
+export async function getBranchList(): Promise {
+ if (!isTauri()) {
+ return {
+ storyId: initialView.storyId,
+ activeBranchId: initialView.branchId,
+ branches: [
+ {
+ branchId: initialView.branchId,
+ name: "主线路",
+ headNodeId: initialView.nodeId,
+ headLabel: "雨夜车站",
+ sourceNodeId: null,
+ isActive: true
+ }
+ ]
+ };
+ }
+ return invoke("get_branch_list");
+}
+
+export async function switchBranch(
+ request: SwitchBranchRequest,
+ cachedView?: PlayerView
+): Promise {
+ 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("switch_branch", { request });
+}
+
+export async function renameBranch(
+ request: RenameBranchRequest,
+ current: BranchList
+): Promise {
+ if (!isTauri()) {
+ return {
+ ...current,
+ branches: current.branches.map((branch) =>
+ branch.branchId === request.branchId ? { ...branch, name: request.name.trim() } : branch
+ )
+ };
+ }
+ return invoke("rename_branch", { request });
+}
+
+export async function getLappSettings(): Promise {
+ if (!isTauri()) {
+ return {
+ mode: "demo",
+ selectedProviderId: null,
+ selectedModelId: null,
+ availableModels: [],
+ statusMessage: "浏览器预览使用确定性纵切;桌面端读取系统 LAPP profile。"
+ };
+ }
+ return invoke("get_lapp_settings");
+}
+
+export async function updateLappSettings(
+ request: UpdateLappSettingsRequest
+): Promise {
+ if (!isTauri()) {
+ throw new Error("浏览器预览不能修改 LAPP 模型。");
+ }
+ return invoke("update_lapp_settings", { request });
+}
+
export async function submitTurn(
request: TurnRequest,
currentView: PlayerView
diff --git a/src/app/turnAdapter.spec.ts b/src/app/turnAdapter.spec.ts
index 13207cb..e1bd396 100644
--- a/src/app/turnAdapter.spec.ts
+++ b/src/app/turnAdapter.spec.ts
@@ -60,4 +60,51 @@ describe("local turn adapter", () => {
expect(advanced.playerView.branchId).toBe(initialView.branchId);
expect(advanced.playerView.nodeId).toBe("node_002");
});
+
+ it("plays the promise, hidden-search clue, tunnel, and return vertical slice", async () => {
+ const promise = await submitDemoTurn(
+ {
+ ...request("speak_or_act", "我答应你,天亮前一定回来。"),
+ actionId: "slice_promise"
+ },
+ initialView as PlayerView
+ );
+ expect(promise.playerView.promises[0]?.status).toBe("accepted");
+
+ const clue = await submitDemoTurn(
+ {
+ ...request("continue", ""),
+ branchId: promise.playerView.branchId,
+ expectedNodeId: promise.playerView.nodeId,
+ actionId: "slice_clue"
+ },
+ promise.playerView
+ );
+ expect(clue.playerView.knowledge.at(-1)?.title).toBe("封锁隧道的检修门");
+ expect(clue.playerView.inventory.at(-1)?.name).toBe("半张旧车票");
+
+ const tunnel = await submitDemoTurn(
+ {
+ ...request("continue", ""),
+ branchId: clue.playerView.branchId,
+ expectedNodeId: clue.playerView.nodeId,
+ actionId: "slice_tunnel"
+ },
+ clue.playerView
+ );
+ expect(tunnel.playerView.history.at(-1)?.label).toBe("进入封锁隧道");
+
+ const returned = await submitDemoTurn(
+ {
+ ...request("continue", ""),
+ branchId: tunnel.playerView.branchId,
+ expectedNodeId: tunnel.playerView.nodeId,
+ actionId: "slice_return"
+ },
+ tunnel.playerView
+ );
+ expect(returned.playerView.promises[0]?.status).toBe("fulfilled");
+ expect(returned.playerView.canContinue).toBe(false);
+ expect(returned.playerView.beats.at(-1)?.text).toContain("你回来了");
+ });
});
diff --git a/src/app/turnAdapter.ts b/src/app/turnAdapter.ts
index 60c76db..56cbdc3 100644
--- a/src/app/turnAdapter.ts
+++ b/src/app/turnAdapter.ts
@@ -8,6 +8,7 @@ import type {
} from "@contracts";
const DEMO_LATENCY_MS = 120;
+type DemoPhase = "promise" | "investigate" | "enter" | "return" | "regular";
function nextNodeId(currentNodeId: string): string {
const match = currentNodeId.match(/^(.*?)(\d+)$/);
@@ -17,7 +18,134 @@ function nextNodeId(currentNodeId: string): string {
return `${prefix}${String(Number(digits) + 1).padStart(digits.length, "0")}`;
}
-function replyBeats(request: TurnRequest): PresentationBeat[] {
+function demoPhase(request: TurnRequest, currentView: PlayerView): DemoPhase {
+ if (
+ request.input.includes("天亮前") &&
+ request.input.includes("回来") &&
+ !currentView.promises.some((promise) => promise.status === "accepted")
+ ) {
+ return "promise";
+ }
+ if (currentView.history.some((node) => node.label === "进入封锁隧道")) return "return";
+ if (currentView.knowledge.some((record) => record.id.startsWith("knowledge_maintenance_door"))) {
+ return "enter";
+ }
+ if (currentView.promises.some((promise) => promise.status === "accepted")) return "investigate";
+ return "regular";
+}
+
+function replyBeats(request: TurnRequest, phase: DemoPhase): PresentationBeat[] {
+ if (phase === "promise") {
+ return [
+ {
+ id: `${request.actionId}_player`,
+ kind: "action",
+ speaker: "你",
+ text: request.input,
+ visual: null
+ },
+ {
+ id: `${request.actionId}_nana`,
+ kind: "dialogue",
+ speaker: "娜娜",
+ text: "娜娜看了你一会儿,终于松开攥紧外套的手。“好。我等你到天亮。”",
+ visual: {
+ character: "nana",
+ expression: "relieved",
+ pose: "holding_coat",
+ scene: null
+ }
+ }
+ ];
+ }
+
+ if (phase === "investigate") {
+ return [
+ {
+ id: `${request.actionId}_search`,
+ kind: "narration",
+ speaker: null,
+ text: "你打开旧手电,沿着站台边缘寻找。斜光扫过积水,一道检修门和半张旧车票显了出来。",
+ visual: {
+ character: null,
+ expression: null,
+ pose: null,
+ scene: "station_maintenance_door"
+ }
+ },
+ {
+ id: `${request.actionId}_nana`,
+ kind: "dialogue",
+ speaker: "娜娜",
+ text: "“这是我妹妹的字。门后通向封锁隧道。”",
+ visual: {
+ character: "nana",
+ expression: "startled",
+ pose: "reaching_out",
+ scene: null
+ }
+ }
+ ];
+ }
+
+ if (phase === "enter") {
+ return [
+ {
+ id: `${request.actionId}_door`,
+ kind: "narration",
+ speaker: null,
+ text: "检修门在肩膀的撞击下松开。手电光照见没过鞋面的水和向深处延伸的脚印。",
+ visual: {
+ character: null,
+ expression: null,
+ pose: null,
+ scene: "sealed_tunnel"
+ }
+ },
+ {
+ id: `${request.actionId}_nana`,
+ kind: "dialogue",
+ speaker: "娜娜",
+ text: "“我留在这里。你答应过会回来,所以我等。”",
+ visual: {
+ character: "nana",
+ expression: "determined",
+ pose: "at_door",
+ scene: null
+ }
+ }
+ ];
+ }
+
+ if (phase === "return") {
+ return [
+ {
+ id: `${request.actionId}_return`,
+ kind: "narration",
+ speaker: null,
+ text: "天色发白前,你重新推开检修门。娜娜仍坐在原处。",
+ visual: {
+ character: "nana",
+ expression: "disbelieving",
+ pose: "waiting",
+ scene: "station_before_dawn"
+ }
+ },
+ {
+ id: `${request.actionId}_nana`,
+ kind: "dialogue",
+ speaker: "娜娜",
+ text: "“你回来了。那我也会把剩下的事告诉你。”",
+ visual: {
+ character: "nana",
+ expression: "relieved",
+ pose: "lowered_guard",
+ scene: null
+ }
+ }
+ ];
+ }
+
if (request.intent === "continue") {
return [
{
@@ -78,8 +206,58 @@ export async function submitDemoTurn(
const committedNodeId = nextNodeId(currentView.nodeId);
const previousHistory = currentView.history.map((node) => ({ ...node, isCurrent: false }));
- const beats = replyBeats(request);
+ const phase = demoPhase(request, currentView);
+ const beats = replyBeats(request, phase);
const finalVisual = [...beats].reverse().find((beat) => beat.visual)?.visual;
+ const historyLabel = {
+ promise: "天亮前的许诺",
+ investigate: "检修门的线索",
+ enter: "进入封锁隧道",
+ return: "天亮前归来",
+ regular: request.intent === "continue" ? "雨声中的停顿" : "回应娜娜"
+ }[phase];
+ const promises =
+ phase === "promise"
+ ? [
+ ...currentView.promises,
+ {
+ id: `promise_return_before_dawn_${request.actionId}`,
+ content: "天亮前一定回来",
+ status: "accepted" as const,
+ weight: "major" as const
+ }
+ ]
+ : phase === "return"
+ ? currentView.promises.map((promise) =>
+ promise.status === "accepted" ? { ...promise, status: "fulfilled" as const } : promise
+ )
+ : currentView.promises;
+ const knowledge =
+ phase === "investigate"
+ ? [
+ ...currentView.knowledge,
+ {
+ id: `knowledge_maintenance_door_${request.actionId}`,
+ title: "封锁隧道的检修门",
+ summary: "旧站台下方的检修门通向封锁隧道,妹妹留下的车票指向四点十七分。",
+ certainty: "confirmed" as const
+ }
+ ]
+ : currentView.knowledge;
+ const inventory =
+ phase === "investigate"
+ ? [
+ ...currentView.inventory,
+ {
+ instanceId: `item_half_ticket_${request.actionId}`,
+ name: "半张旧车票",
+ description: "受潮的车票背面写着“四点十七分,检修线”。",
+ quantity: 1,
+ placement: "bag" as const,
+ condition: "damp"
+ }
+ ]
+ : currentView.inventory;
return {
committedNodeId,
@@ -89,13 +267,18 @@ export async function submitDemoTurn(
characterExpression: finalVisual?.expression ?? currentView.characterExpression,
characterPose: finalVisual?.pose ?? currentView.characterPose,
beats: [...currentView.beats, ...beats].slice(-8),
+ promises,
+ knowledge,
+ inventory,
+ suggestions: phase === "return" ? [] : currentView.suggestions,
+ canContinue: phase !== "return",
history: [
...previousHistory,
{
id: committedNodeId,
parentId: currentView.nodeId,
branchId: currentView.branchId,
- label: request.intent === "continue" ? "雨声中的停顿" : "回应娜娜",
+ label: historyLabel,
isCurrent: true
}
]
diff --git a/src/app/useDemo.ts b/src/app/useDemo.ts
index 5b39dae..915dc26 100644
--- a/src/app/useDemo.ts
+++ b/src/app/useDemo.ts
@@ -1,12 +1,25 @@
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 {
forkBranch as forkRuntimeBranch,
getAppInfo,
+ getBranchList,
getDemoPackSummary,
getDemoPlayerView,
+ getLappSettings,
+ renameBranch as renameRuntimeBranch,
+ switchBranch as switchRuntimeBranch,
+ updateLappSettings,
submitTurn as submitRuntimeTurn
} from "./bridge";
@@ -27,19 +40,26 @@ export function useDemo() {
const appInfo = ref(null);
const pack = ref(null);
const playerView = ref(null);
+ const branchList = ref(null);
+ const lappSettings = ref(null);
const loading = ref(true);
const error = ref(null);
const busy = ref(false);
const turnError = ref(null);
const lastSubmittedIntent = ref(null);
+ const branchViews = new Map();
onMounted(async () => {
try {
- [appInfo.value, pack.value, playerView.value] = await Promise.all([
+ [appInfo.value, pack.value, playerView.value, branchList.value, lappSettings.value] =
+ await Promise.all([
getAppInfo(),
getDemoPackSummary(),
- getDemoPlayerView()
+ getDemoPlayerView(),
+ getBranchList(),
+ getLappSettings()
]);
+ if (playerView.value) branchViews.set(playerView.value.branchId, playerView.value);
} catch (reason) {
error.value = errorMessage(reason);
} finally {
@@ -71,6 +91,8 @@ export function useDemo() {
try {
const result = await submitRuntimeTurn(request, currentView);
playerView.value = result.playerView;
+ branchViews.set(result.playerView.branchId, result.playerView);
+ updateBranchHead(result.playerView);
} catch (reason) {
turnError.value = errorMessage(reason);
} finally {
@@ -98,6 +120,27 @@ export function useDemo() {
currentView
);
playerView.value = result.playerView;
+ branchViews.set(currentView.branchId, currentView);
+ branchViews.set(result.playerView.branchId, result.playerView);
+ const branches = branchList.value;
+ if (branches) {
+ const nextOrdinal = branches.branches.length + 1;
+ branchList.value = {
+ storyId: branches.storyId,
+ activeBranchId: result.branchId,
+ branches: [
+ ...branches.branches.map((branch) => ({ ...branch, isActive: false })),
+ {
+ branchId: result.branchId,
+ name: `线路 ${nextOrdinal}`,
+ headNodeId: result.playerView.nodeId,
+ headLabel: currentHistoryLabel(result.playerView),
+ sourceNodeId,
+ isActive: true
+ }
+ ]
+ };
+ }
} catch (reason) {
turnError.value = errorMessage(reason);
} finally {
@@ -105,16 +148,112 @@ export function useDemo() {
}
}
+ async function switchBranch(branchId: string): Promise {
+ 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 {
+ 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 {
+ 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 {
appInfo,
+ branchList,
busy,
error,
lastSubmittedIntent,
loading,
+ lappSettings,
pack,
playerView,
forkBranch,
+ renameBranch,
+ selectLappModel,
submitTurn,
+ switchBranch,
turnError
};
}
diff --git a/src/styles/main.css b/src/styles/main.css
index 98e00fa..26551ad 100644
--- a/src/styles/main.css
+++ b/src/styles/main.css
@@ -22,6 +22,8 @@ body,
}
button,
+input,
+select,
textarea {
font: inherit;
}
@@ -103,6 +105,8 @@ button {
}
button:focus-visible,
+input:focus-visible,
+select:focus-visible,
textarea:focus-visible {
outline: 2px solid #f0c1ae;
outline-offset: 3px;
@@ -647,6 +651,138 @@ textarea:focus-visible {
margin: 20px 0 0;
}
+.branch-section {
+ padding-bottom: 24px;
+ margin-bottom: 24px;
+ border-bottom: 1px solid rgb(255 255 255 / 8%);
+}
+
+.branch-section h3 {
+ margin: 0 0 12px;
+ color: rgb(245 241 234 / 54%);
+ font-size: 12px;
+ font-weight: 600;
+ letter-spacing: 0.12em;
+}
+
+.branch-list {
+ display: grid;
+ gap: 10px;
+ padding: 0;
+ margin: 0;
+ list-style: none;
+}
+
+.branch-list li {
+ padding: 14px;
+ background: rgb(255 255 255 / 3%);
+ border: 1px solid rgb(255 255 255 / 8%);
+ border-radius: 10px;
+}
+
+.branch-list li.active {
+ background: rgb(231 184 164 / 7%);
+ border-color: rgb(231 184 164 / 32%);
+}
+
+.branch-heading,
+.branch-actions,
+.settings-status {
+ 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 {
position: relative;
}