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, 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( mut connection: Connection, database_kind: DatabaseKind, @@ -451,6 +641,7 @@ impl StoryStore for SqliteStoryStore { DO UPDATE SET head_node_id = excluded.head_node_id", params![node.story_id, node.branch_id, node.id], )?; + ensure_branch_session(&transaction, node)?; transaction.commit()?; Ok(()) } @@ -523,6 +714,31 @@ impl StoryStore for SqliteStoryStore { VALUES (?1, ?2, ?3)", 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()?; Ok(restored) } @@ -599,6 +815,130 @@ impl StoryStore for SqliteStoryStore { validate_loaded_node(&node, story_id, node_id, &stored.0, stored.1.as_deref())?; Ok(node) } + + fn list_branches(&self, story_id: &str) -> Result, 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::, _>>() + .map_err(StoreError::from) + } + + fn active_branch(&self, story_id: &str) -> Result { + 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 { + 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> { @@ -641,11 +981,23 @@ fn configure_journal( fn initialize_schema(connection: &mut Connection) -> Result<(), StoreError> { match schema_version(connection)? { SCHEMA_VERSION => validate_schema(connection, SCHEMA_VERSION), + LEGACY_SCHEMA_VERSION => migrate_legacy_schema(connection), 0 => initialize_unversioned_schema(connection), 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> { let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; let version = schema_version(&transaction)?; @@ -657,15 +1009,29 @@ fn initialize_unversioned_schema(connection: &mut Connection) -> Result<(), Stor transaction.commit()?; 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 { return Err(unsupported_schema_version(version)); } 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 // required table, column, foreign key, and index before adopting them; // a partial legacy database must never be repaired with IF NOT EXISTS. - validate_schema(&transaction, 0)?; + validate_legacy_schema(&transaction, 0)?; } else { transaction.execute_batch( "CREATE TABLE nodes ( @@ -704,14 +1070,82 @@ fn initialize_unversioned_schema(connection: &mut Connection) -> Result<(), Stor 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.commit()?; 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 { connection .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> { + 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_materialized_states_schema(connection, version)?; validate_branch_heads_schema(connection, version)?; @@ -773,6 +1215,140 @@ fn validate_schema(connection: &Connection, version: i64) -> Result<(), StoreErr 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> { validate_table( connection, @@ -1086,6 +1662,71 @@ fn validate_new_branch_id(branch_id: &str) -> Result<(), ForkError> { Ok(()) } +fn default_branch_name(ordinal: u32) -> String { + if ordinal == 1 { + "主线路".to_owned() + } else { + format!("线路 {ordinal}") + } +} + +fn validate_branch_name(name: &str) -> Result { + 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 { + 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>(2)?, + row.get::<_, String>(3)?, + row.get::<_, Option>(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> { if node.story_id != state.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] fn memory_appends_and_loads_the_branch_head() { let store = InMemoryStoryStore::new(); @@ -1602,6 +2289,18 @@ mod tests { 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] fn memory_rejects_invalid_duplicate_and_unknown_forks_atomically() { 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] fn sqlite_rejects_an_unknown_future_schema_version_without_changing_it() { let database = TemporaryDatabase::new(); @@ -2152,7 +2921,9 @@ mod tests { assert!(matches!( SqliteStoryStore::open(database.path()), 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"); diff --git a/docs/status/2026-07-28-m2-wave5.md b/docs/status/2026-07-28-m2-wave5.md new file mode 100644 index 0000000..6c1ef3e --- /dev/null +++ b/docs/status/2026-07-28-m2-wave5.md @@ -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。 diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index fd555c8..23f8738 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -18,6 +18,7 @@ nana-domain.workspace = true nana-engine.workspace = true nana-runtime.workspace = true nana-store.workspace = true +openlapp.workspace = true serde.workspace = true serde_json.workspace = true tauri = { version = "2", features = [] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1a8f6bd..92f4395 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,12 +1,16 @@ use std::{fs, path::Path, sync::Mutex}; use nana_domain::{ - ActionSuggestion, AppInfo, BeatKind, DOMAIN_SCHEMA_VERSION, DemoPackSummary, ForkBranchRequest, - ForkBranchResult, HistoryNodeView, PlayerView, PresentationBeat, PresentationCharacter, - PresentationScene, PresentationSnapshot, Promise, PromiseStatus, PromiseWeight, - RelationshipAdjustment, RelationshipDimension, ResourceBundle, RuntimeState, StateDelta, - StateOp, StoryNode, TurnFailure, TurnFailureCode, TurnIntent, TurnRequest, TurnResult, - ValidationIssue, VisualDirective, stable_json_hash, validate_bundle, + AcquisitionMode, ActionSuggestion, AppInfo, BeatKind, BranchList, BranchSummary, + CheckDifficulty, CheckRecord, CheckResult, DOMAIN_SCHEMA_VERSION, DemoPackSummary, + ForkBranchRequest, ForkBranchResult, HistoryNodeView, ItemAcquisition, ItemInstance, + ItemPlacement, KnowledgeCertainty, KnowledgeRecord, LappMode, LappModelOption, LappSettings, + PlayerView, PresentationBeat, PresentationCharacter, PresentationScene, PresentationSnapshot, + Promise, PromiseStatus, PromiseWeight, RelationshipAdjustment, RelationshipDimension, + RenameBranchRequest, ResourceBundle, ResourceId, RuntimeState, StateDelta, StateOp, StoryNode, + SwitchBranchRequest, SwitchBranchResult, TurnFailure, TurnFailureCode, TurnIntent, TurnRequest, + TurnResult, UpdateLappSettingsRequest, ValidationIssue, VisualDirective, stable_json_hash, + validate_bundle, }; use nana_engine::{ SceneMetadata, StoryNodePlayerViewProjectionContext, project_story_node_player_view, @@ -14,8 +18,10 @@ use nana_engine::{ use nana_runtime::{ AdjudicatingTurnPlanProvider, AdjudicationCatalog, LappAdjudicationModel, OpenLappChatExecutor, ProviderError, TurnEngine, TurnPlan, TurnPlanProvider, TurnProjector, + load_default_lapp_profile, }; -use nana_store::{ForkError, SqliteStoryStore, StoreError, StoryStore}; +use nana_store::{ForkError, SqliteStoryStore, StoreError, StoredBranch, StoryStore}; +use openlapp::{connection::ListModelsOptions, list_models}; use serde::Serialize; use tauri::Manager; @@ -26,6 +32,8 @@ const DEMO_STORY_ID: &str = "story_nana_demo"; const DEMO_BRANCH_ID: &str = "branch_main"; const PLAYER_ID: &str = "player"; const CHARACTER_ID: &str = "nana"; +const LAPP_PROVIDER_SETTING: &str = "lapp.provider_id"; +const LAPP_MODEL_SETTING: &str = "lapp.model_id"; #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] @@ -125,23 +133,48 @@ enum RuntimePlanProvider { } impl RuntimePlanProvider { - fn configured(bundle: &ResourceBundle) -> Self { - if std::env::var("NANA_STORY_PROVIDER") - .is_ok_and(|provider| provider.eq_ignore_ascii_case("demo")) - { + fn configured(bundle: &ResourceBundle, store: &SqliteStoryStore) -> Self { + if demo_provider_requested() { return Self::Demo(DemoPlanProvider); } let Ok(catalog) = AdjudicationCatalog::from_bundle(bundle) else { return Self::Unavailable; }; - let Ok(model) = LappAdjudicationModel::from_default_profile(bundle.clone()) else { + let Ok(profile) = load_default_lapp_profile() else { + return Self::Unavailable; + }; + let selected = selected_lapp_model( + &profile, + store.get_app_setting(LAPP_PROVIDER_SETTING).ok().flatten(), + store.get_app_setting(LAPP_MODEL_SETTING).ok().flatten(), + ); + let Some((provider_id, model_id)) = selected.0.zip(selected.1) else { + return Self::Unavailable; + }; + if !lapp_model_options(&profile) + .iter() + .any(|model| model.provider_id == provider_id && model.model_id == model_id) + { + return Self::Unavailable; + } + let model = LappAdjudicationModel::from_profile_and_model( + &profile, + &provider_id, + &model_id, + bundle.clone(), + ); + let Ok(model) = model else { return Self::Unavailable; }; Self::Lapp(Box::new(AdjudicatingTurnPlanProvider::new(model, catalog))) } } +fn demo_provider_requested() -> bool { + std::env::var("NANA_STORY_PROVIDER").is_ok_and(|provider| provider.eq_ignore_ascii_case("demo")) +} + impl TurnPlanProvider for RuntimePlanProvider { fn plan_turn( &mut self, @@ -195,7 +228,7 @@ impl DemoAppState { let provider = if cfg!(test) { RuntimePlanProvider::Demo(DemoPlanProvider) } else { - RuntimePlanProvider::configured(&bundle) + RuntimePlanProvider::configured(&bundle, &store) }; Ok(Self { @@ -220,9 +253,13 @@ impl DemoAppState { } fn current_player_view(&self) -> Result { + let active_branch = self + .store + .active_branch(DEMO_STORY_ID) + .map_err(|error| CommandError::storage(&error))?; let state = self .store - .load_state(DEMO_STORY_ID, DEMO_BRANCH_ID) + .load_state(DEMO_STORY_ID, &active_branch) .map_err(|error| CommandError::storage(&error))?; let node = self .store @@ -239,6 +276,27 @@ impl DemoAppState { .operation_lock .lock() .map_err(|_| CommandError::operation_lock())?; + let active_branch = self + .store + .active_branch(&request.story_id) + .map_err(|error| CommandError::storage(&error))?; + if active_branch != request.branch_id { + return Err(CommandError::stale_branch()); + } + let current_state = self + .store + .load_state(&request.story_id, &request.branch_id) + .map_err(|error| CommandError::storage(&error))?; + if current_state + .world_flags + .get("nana.ending.returned_before_dawn") + .copied() + .unwrap_or(false) + { + return Err(CommandError::invalid_input( + "这一夜的故事已经结束;可从回溯中选择另一条线路。", + )); + } let mut provider = self .provider .lock() @@ -259,6 +317,13 @@ impl DemoAppState { .store .load_state(&request.story_id, &request.current_branch_id) .map_err(|error| CommandError::storage(&error))?; + let active_branch = self + .store + .active_branch(&request.story_id) + .map_err(|error| CommandError::storage(&error))?; + if active_branch != request.current_branch_id { + return Err(CommandError::stale_branch()); + } if current.current_node != request.expected_current_node_id { return Err(CommandError::stale_branch()); } @@ -299,6 +364,219 @@ impl DemoAppState { }) } + fn branch_list(&self) -> Result { + let active_branch_id = self + .store + .active_branch(DEMO_STORY_ID) + .map_err(|error| CommandError::storage(&error))?; + let branches = self + .store + .list_branches(DEMO_STORY_ID) + .map_err(|error| CommandError::storage(&error))? + .into_iter() + .map(|branch| self.branch_summary(branch, &active_branch_id)) + .collect::, _>>()?; + Ok(BranchList { + story_id: DEMO_STORY_ID.to_owned(), + active_branch_id, + branches, + }) + } + + fn branch_summary( + &self, + branch: StoredBranch, + active_branch_id: &str, + ) -> Result { + let head = self + .store + .load_node(DEMO_STORY_ID, &branch.head_node_id) + .map_err(|error| CommandError::storage(&error))?; + Ok(BranchSummary { + is_active: branch.branch_id == active_branch_id, + branch_id: branch.branch_id, + name: branch.name, + head_node_id: branch.head_node_id, + head_label: history_label(&head), + source_node_id: branch.source_node_id, + }) + } + + fn switch_branch( + &self, + request: &SwitchBranchRequest, + ) -> Result { + validate_switch_branch_request(request)?; + let _operation = self + .operation_lock + .lock() + .map_err(|_| CommandError::operation_lock())?; + let state = self + .store + .switch_active_branch( + &request.story_id, + &request.expected_active_branch_id, + &request.branch_id, + ) + .map_err(|error| match error { + StoreError::StaleBranchHead { .. } => CommandError::stale_branch(), + _ => CommandError::storage(&error), + })?; + let node = self + .store + .load_node(&state.story_id, &state.current_node) + .map_err(|error| CommandError::storage(&error))?; + let lineage = self + .load_lineage(&node) + .map_err(|error| CommandError::storage(&error))?; + Ok(SwitchBranchResult { + branch_id: request.branch_id.clone(), + player_view: self.project_view_with_lineage(&state, &node, &lineage), + }) + } + + fn rename_branch(&self, request: &RenameBranchRequest) -> Result { + validate_rename_branch_request(request)?; + let _operation = self + .operation_lock + .lock() + .map_err(|_| CommandError::operation_lock())?; + self.store + .rename_branch(&request.story_id, &request.branch_id, &request.name) + .map_err(|error| match error { + StoreError::StateMismatch("invalid branch name") => { + CommandError::invalid_input("线路名称需为 1–40 个可见字符。") + } + _ => CommandError::storage(&error), + })?; + self.branch_list() + } + + fn lapp_settings(&self) -> LappSettings { + if demo_provider_requested() { + return LappSettings { + mode: LappMode::Demo, + selected_provider_id: None, + selected_model_id: None, + available_models: Vec::new(), + status_message: "当前由 NANA_STORY_PROVIDER=demo 使用确定性纵切。".to_owned(), + }; + } + let Ok(profile) = load_default_lapp_profile() else { + return LappSettings { + mode: LappMode::Unavailable, + selected_provider_id: None, + selected_model_id: None, + available_models: Vec::new(), + status_message: "未找到可用的 LAPP profile。请先在系统 LAPP 中配置供应商与凭据。" + .to_owned(), + }; + }; + let available_models = lapp_model_options(&profile); + let saved_provider = self + .store + .get_app_setting(LAPP_PROVIDER_SETTING) + .ok() + .flatten(); + let saved_model = self + .store + .get_app_setting(LAPP_MODEL_SETTING) + .ok() + .flatten(); + let (selected_provider_id, selected_model_id) = + selected_lapp_model(&profile, saved_provider, saved_model); + let selected_is_available = selected_provider_id + .as_ref() + .zip(selected_model_id.as_ref()) + .is_some_and(|(provider_id, model_id)| { + available_models + .iter() + .any(|model| &model.provider_id == provider_id && &model.model_id == model_id) + }); + let (mode, status_message) = if selected_is_available { + ( + LappMode::Lapp, + "已读取 LAPP profile;凭据只会在生成时由系统 Vault 解析。".to_owned(), + ) + } else { + ( + LappMode::Unavailable, + "LAPP 中没有可用于工具调用的已启用聊天模型。".to_owned(), + ) + }; + LappSettings { + mode, + selected_provider_id, + selected_model_id, + available_models, + status_message, + } + } + + fn update_lapp_settings( + &self, + request: &UpdateLappSettingsRequest, + ) -> Result { + if demo_provider_requested() { + return Err(CommandError::invalid_input( + "确定性演示模式由环境变量锁定,无法在应用内切换模型。", + )); + } + validate_lapp_settings_request(request)?; + let profile = load_default_lapp_profile().map_err(|_| CommandError { + code: "provider_unavailable".to_owned(), + message: "未找到可用的 LAPP profile。".to_owned(), + retryable: true, + issues: Vec::new(), + })?; + let available = lapp_model_options(&profile); + if !available.iter().any(|model| { + model.provider_id == request.provider_id && model.model_id == request.model_id + }) { + return Err(CommandError::invalid_input( + "所选模型不可用,或未声明聊天与工具调用能力。", + )); + } + let catalog = AdjudicationCatalog::from_bundle(&self.bundle).map_err(|_| CommandError { + code: "internal".to_owned(), + message: "内容包无法建立可信裁决目录。".to_owned(), + retryable: false, + issues: Vec::new(), + })?; + let model = LappAdjudicationModel::from_profile_and_model( + &profile, + &request.provider_id, + &request.model_id, + self.bundle.clone(), + ) + .map_err(|_| CommandError { + code: "provider_unavailable".to_owned(), + message: "所选 LAPP 模型无法初始化。".to_owned(), + retryable: true, + issues: Vec::new(), + })?; + let replacement = + RuntimePlanProvider::Lapp(Box::new(AdjudicatingTurnPlanProvider::new(model, catalog))); + + let _operation = self + .operation_lock + .lock() + .map_err(|_| CommandError::operation_lock())?; + let mut provider = self + .provider + .lock() + .map_err(|_| CommandError::operation_lock())?; + self.store + .set_app_settings(&[ + (LAPP_PROVIDER_SETTING, &request.provider_id), + (LAPP_MODEL_SETTING, &request.model_id), + ]) + .map_err(|error| CommandError::storage(&error))?; + *provider = replacement; + drop(provider); + Ok(self.lapp_settings()) + } + fn project_view(&self, state: &RuntimeState, node: &StoryNode) -> PlayerView { let lineage = self .load_lineage(node) @@ -377,17 +655,43 @@ impl TurnPlanProvider for DemoPlanProvider { ) }); - let (beats, delta) = if accepted_promise { - promise_turn(request, &committed_node_id) + let (beats, delta, suggestions, can_continue) = if accepted_promise { + let (beats, delta) = promise_turn(request, &committed_node_id); + (beats, delta, investigation_suggestions(), true) + } else if state + .world_flags + .get("nana.tunnel.entered") + .copied() + .unwrap_or(false) + { + let (beats, delta) = return_before_dawn_turn(request, state, &committed_node_id); + (beats, delta, Vec::new(), false) + } else if state + .world_flags + .get("nana.clue.maintenance_door") + .copied() + .unwrap_or(false) + { + let (beats, delta) = enter_tunnel_turn(request); + (beats, delta, return_suggestions(), true) + } else if state.promises.iter().any(|promise| { + promise.promiser == PLAYER_ID + && promise.promisee == CHARACTER_ID + && promise.status == PromiseStatus::Accepted + }) { + let (beats, delta) = investigate_platform_turn(request, &committed_node_id); + (beats, delta, tunnel_suggestions(), true) } else if request.intent == TurnIntent::Continue { - continue_turn(request, state) + let (beats, delta) = continue_turn(request, state); + (beats, delta, default_suggestions(), true) } else { - regular_turn(request) + let (beats, delta) = regular_turn(request); + (beats, delta, default_suggestions(), true) }; Ok(TurnPlan { committed_node_id, - presentation: demo_presentation(beats), + presentation: demo_presentation(beats, suggestions, can_continue), delta, }) } @@ -458,6 +762,10 @@ fn promise_turn(request: &TurnRequest, node_id: &str) -> (Vec, ]; let delta = StateDelta { ops: vec![ + StateOp::SetWorldFlag { + key: "nana.promise.return_before_dawn".to_owned(), + value: true, + }, StateOp::CreatePromise { promise: Promise { id: format!("promise_return_before_dawn_{}", request.action_id), @@ -486,6 +794,238 @@ fn promise_turn(request: &TurnRequest, node_id: &str) -> (Vec, (beats, delta) } +fn investigate_platform_turn( + request: &TurnRequest, + node_id: &str, +) -> (Vec, StateDelta) { + let beats = vec![ + player_action_beat(request), + PresentationBeat { + id: format!("{}_search", request.action_id), + kind: BeatKind::Narration, + speaker: None, + text: + "你打开旧手电,沿着站台边缘寻找。斜光扫过积水,一道被锈迹遮住的检修门轮廓显了出来。" + .to_owned(), + visual: Some(VisualDirective { + character: None, + expression: None, + pose: None, + scene: Some("station_maintenance_door".to_owned()), + }), + }, + PresentationBeat { + id: format!("{}_ticket", request.action_id), + kind: BeatKind::Narration, + speaker: None, + text: "门缝里卡着半张受潮的旧车票,背面写着:四点十七分,检修线。".to_owned(), + visual: None, + }, + PresentationBeat { + id: format!("{}_nana", request.action_id), + kind: BeatKind::Dialogue, + speaker: Some("娜娜".to_owned()), + text: "娜娜接过车票看了一眼,又立刻还给你。“这是我妹妹的字。门后通向封锁隧道。”" + .to_owned(), + visual: Some(VisualDirective { + character: Some(CHARACTER_ID.to_owned()), + expression: Some("startled".to_owned()), + pose: Some("reaching_out".to_owned()), + scene: None, + }), + }, + ]; + let delta = StateDelta { + ops: vec![ + StateOp::RecordCheck { + check: CheckRecord { + id: format!("check_find_door_{}", request.action_id), + action_id: request.action_id.clone(), + actor: PLAYER_ID.to_owned(), + skill: "spot_hidden".to_owned(), + target: 56, + difficulty: CheckDifficulty::Regular, + bonus_dice: 0, + roll: 42, + result: CheckResult::Success, + pushed_from: None, + node_id: node_id.to_owned(), + }, + }, + StateOp::AddKnowledge { + record: KnowledgeRecord { + id: format!("knowledge_maintenance_door_{}", request.action_id), + observer: PLAYER_ID.to_owned(), + subject: Some("封锁隧道的检修门".to_owned()), + fact: "旧站台下方的检修门通向封锁隧道,妹妹留下的车票指向四点十七分。" + .to_owned(), + certainty: KnowledgeCertainty::Confirmed, + source: "玩家用手电检查站台边缘".to_owned(), + learned_at: node_id.to_owned(), + last_verified_at: Some(node_id.to_owned()), + }, + }, + StateOp::AddItem { + item: ItemInstance { + id: format!("item_half_ticket_{}", request.action_id), + spec_ref: ResourceId("nana.item.half_ticket".to_owned()), + owner: PLAYER_ID.to_owned(), + holder: PLAYER_ID.to_owned(), + placement: ItemPlacement::Bag, + quantity: 1, + condition: "damp".to_owned(), + state_tags: vec!["sister_clue".to_owned()], + acquisition: ItemAcquisition { + mode: AcquisitionMode::Found, + from: Some("old_station_platform".to_owned()), + at_node: node_id.to_owned(), + }, + }, + }, + StateOp::SetWorldFlag { + key: "nana.clue.maintenance_door".to_owned(), + value: true, + }, + StateOp::AdvanceClock { + clock_id: "clock_dawn".to_owned(), + delta: 2, + }, + ], + }; + (beats, delta) +} + +fn enter_tunnel_turn(request: &TurnRequest) -> (Vec, StateDelta) { + let beats = vec![ + player_action_beat(request), + PresentationBeat { + id: format!("{}_door", request.action_id), + kind: BeatKind::Narration, + speaker: None, + text: "检修门在肩膀的撞击下松开。手电光伸进隧道,只照见没过鞋面的水和一串向深处延伸的脚印。" + .to_owned(), + visual: Some(VisualDirective { + character: None, + expression: None, + pose: None, + scene: Some("sealed_tunnel".to_owned()), + }), + }, + PresentationBeat { + id: format!("{}_nana", request.action_id), + kind: BeatKind::Dialogue, + speaker: Some("娜娜".to_owned()), + text: "“我留在这里。”娜娜把手从门框上收回来,“你答应过会回来,所以我等。”".to_owned(), + visual: Some(VisualDirective { + character: Some(CHARACTER_ID.to_owned()), + expression: Some("determined".to_owned()), + pose: Some("at_door".to_owned()), + scene: None, + }), + }, + ]; + let delta = StateDelta { + ops: vec![ + StateOp::SetWorldFlag { + key: "nana.tunnel.entered".to_owned(), + value: true, + }, + StateOp::AdjustRelationship { + from: CHARACTER_ID.to_owned(), + to: PLAYER_ID.to_owned(), + adjustment: RelationshipAdjustment { + dimension: RelationshipDimension::Respect, + delta: 3, + cause: "玩家为兑现许诺进入封锁隧道".to_owned(), + judgment_rule: Some("nana.promises.accepted_risk".to_owned()), + }, + }, + StateOp::AdvanceClock { + clock_id: "clock_dawn".to_owned(), + delta: 2, + }, + ], + }; + (beats, delta) +} + +fn return_before_dawn_turn( + request: &TurnRequest, + state: &RuntimeState, + node_id: &str, +) -> (Vec, StateDelta) { + let promise_id = state + .promises + .iter() + .find(|promise| { + promise.promiser == PLAYER_ID + && promise.promisee == CHARACTER_ID + && promise.status == PromiseStatus::Accepted + }) + .map_or_else( + || "promise_return_before_dawn".to_owned(), + |promise| promise.id.clone(), + ); + let beats = vec![ + PresentationBeat { + id: format!("{}_return", request.action_id), + kind: BeatKind::Narration, + speaker: None, + text: + "天色发白前,你重新推开检修门。雨已经小了,娜娜仍坐在原处,怀里抱着那件湿透的外套。" + .to_owned(), + visual: Some(VisualDirective { + character: Some(CHARACTER_ID.to_owned()), + expression: Some("disbelieving".to_owned()), + pose: Some("waiting".to_owned()), + scene: Some("station_before_dawn".to_owned()), + }), + }, + PresentationBeat { + id: format!("{}_nana", request.action_id), + kind: BeatKind::Dialogue, + speaker: Some("娜娜".to_owned()), + text: + "她确认你真的站在面前,才很轻地呼出一口气。“你回来了。那我也会把剩下的事告诉你。”" + .to_owned(), + visual: Some(VisualDirective { + character: Some(CHARACTER_ID.to_owned()), + expression: Some("relieved".to_owned()), + pose: Some("lowered_guard".to_owned()), + scene: None, + }), + }, + ]; + let delta = StateDelta { + ops: vec![ + StateOp::UpdatePromise { + promise_id, + status: PromiseStatus::Fulfilled, + resolved_at: Some(node_id.to_owned()), + }, + StateOp::SetWorldFlag { + key: "nana.ending.returned_before_dawn".to_owned(), + value: true, + }, + StateOp::AdjustRelationship { + from: CHARACTER_ID.to_owned(), + to: PLAYER_ID.to_owned(), + adjustment: RelationshipAdjustment { + dimension: RelationshipDimension::Trust, + delta: 7, + cause: "玩家在天亮前履行了归来的许诺".to_owned(), + judgment_rule: Some("nana.promises.proven_by_action".to_owned()), + }, + }, + StateOp::AdvanceClock { + clock_id: "clock_dawn".to_owned(), + delta: 1, + }, + ], + }; + (beats, delta) +} + fn continue_turn( request: &TurnRequest, state: &RuntimeState, @@ -555,7 +1095,11 @@ fn player_action_beat(request: &TurnRequest) -> PresentationBeat { } } -fn demo_presentation(beats: Vec) -> PresentationSnapshot { +fn demo_presentation( + beats: Vec, + suggestions: Vec, + can_continue: bool, +) -> PresentationSnapshot { let scene_id = beats .iter() .filter_map(|beat| beat.visual.as_ref()) @@ -586,8 +1130,8 @@ fn demo_presentation(beats: Vec) -> PresentationSnapshot { pose, }, beats, - suggestions: default_suggestions(), - can_continue: true, + suggestions, + can_continue, } } @@ -611,6 +1155,44 @@ fn default_suggestions() -> Vec { ] } +fn investigation_suggestions() -> Vec { + vec![ + ActionSuggestion { + id: "suggestion_search".to_owned(), + label: "检查站台边缘".to_owned(), + draft: "我打开旧手电,沿着站台边缘寻找她妹妹留下的线索。".to_owned(), + }, + ActionSuggestion { + id: "suggestion_ticket".to_owned(), + label: "查看检票口".to_owned(), + draft: "我去看看废弃检票口附近有没有被忽略的东西。".to_owned(), + }, + ] +} + +fn tunnel_suggestions() -> Vec { + vec![ + ActionSuggestion { + id: "suggestion_enter".to_owned(), + label: "打开检修门".to_owned(), + draft: "我带上手电,打开检修门进入封锁隧道。".to_owned(), + }, + ActionSuggestion { + id: "suggestion_wait".to_owned(), + label: "让娜娜留下".to_owned(), + draft: "你留在这里等我,我会按约定回来。".to_owned(), + }, + ] +} + +fn return_suggestions() -> Vec { + vec![ActionSuggestion { + id: "suggestion_return".to_owned(), + label: "带着线索返回".to_owned(), + draft: "我赶在天亮前离开隧道,回到娜娜等待的站台。".to_owned(), + }] +} + fn node_id_for_action(request: &TurnRequest) -> String { let identity = format!( "{}\0{}\0{}", @@ -653,6 +1235,87 @@ fn validate_fork_request(request: &ForkBranchRequest) -> Result<(), CommandError Ok(()) } +fn validate_switch_branch_request(request: &SwitchBranchRequest) -> Result<(), CommandError> { + for (field, value) in [ + ("story_id", request.story_id.as_str()), + ("branch_id", request.branch_id.as_str()), + ( + "expected_active_branch_id", + request.expected_active_branch_id.as_str(), + ), + ] { + if value.trim().is_empty() { + return Err(CommandError::invalid_input(format!( + "{field} must not be empty" + ))); + } + } + Ok(()) +} + +fn validate_rename_branch_request(request: &RenameBranchRequest) -> Result<(), CommandError> { + if request.story_id.trim().is_empty() || request.branch_id.trim().is_empty() { + return Err(CommandError::invalid_input( + "story_id and branch_id must not be empty", + )); + } + if request.name.trim().is_empty() { + return Err(CommandError::invalid_input("线路名称不能为空。")); + } + Ok(()) +} + +fn validate_lapp_settings_request(request: &UpdateLappSettingsRequest) -> Result<(), CommandError> { + if request.provider_id.trim().is_empty() || request.model_id.trim().is_empty() { + return Err(CommandError::invalid_input( + "provider_id and model_id must not be empty", + )); + } + Ok(()) +} + +fn lapp_model_options(profile: &openlapp::Profile) -> Vec { + let mut models = list_models(profile, &ListModelsOptions::default()) + .into_iter() + .filter(|model| { + model.capabilities.as_ref().is_some_and(|capabilities| { + capabilities.iter().any(|value| value == "chat") + && capabilities.iter().any(|value| value == "tool-call") + }) + }) + .map(|model| LappModelOption { + provider_id: model.provider_id, + provider_name: model.provider_name, + model_id: model.model_id, + model_name: model.model_name, + }) + .collect::>(); + models.sort_by(|left, right| { + (&left.provider_id, &left.model_id).cmp(&(&right.provider_id, &right.model_id)) + }); + models +} + +fn selected_lapp_model( + profile: &openlapp::Profile, + saved_provider: Option, + saved_model: Option, +) -> (Option, Option) { + match (saved_provider, saved_model) { + (Some(provider), Some(model)) => (Some(provider), Some(model)), + _ => profile + .global + .as_ref() + .and_then(|global| global.defaults.get("chat")) + .map_or((None, None), |selected| { + ( + Some(selected.provider_id.clone()), + Some(selected.model_id.clone()), + ) + }), + } +} + fn is_return_promise(input: &str) -> bool { input.contains("天亮前") && input.contains("回来") } @@ -670,6 +1333,34 @@ fn history_label(node: &StoryNode) -> String { }) { return "天亮前的许诺".to_owned(); } + if node + .delta + .ops + .iter() + .any(|op| matches!(op, StateOp::AddKnowledge { .. })) + { + return "检修门的线索".to_owned(); + } + if node.delta.ops.iter().any(|op| { + matches!( + op, + StateOp::SetWorldFlag { key, value } + if key == "nana.tunnel.entered" && *value + ) + }) { + return "进入封锁隧道".to_owned(); + } + if node.delta.ops.iter().any(|op| { + matches!( + op, + StateOp::UpdatePromise { + status: PromiseStatus::Fulfilled, + .. + } + ) + }) { + return "天亮前归来".to_owned(); + } if node.user_input.is_empty() { return "雨声中的停顿".to_owned(); } @@ -751,6 +1442,45 @@ fn get_demo_pack_summary(state: tauri::State<'_, DemoAppState>) -> DemoPackSumma state.pack_summary() } +#[tauri::command] +#[allow(clippy::needless_pass_by_value)] // Tauri extracts managed state through this owned wrapper. +fn get_branch_list(state: tauri::State<'_, DemoAppState>) -> Result { + state.branch_list() +} + +#[tauri::command] +#[allow(clippy::needless_pass_by_value)] // Tauri deserializes command arguments into owned values. +fn switch_branch( + state: tauri::State<'_, DemoAppState>, + request: SwitchBranchRequest, +) -> Result { + state.switch_branch(&request) +} + +#[tauri::command] +#[allow(clippy::needless_pass_by_value)] // Tauri deserializes command arguments into owned values. +fn rename_branch( + state: tauri::State<'_, DemoAppState>, + request: RenameBranchRequest, +) -> Result { + state.rename_branch(&request) +} + +#[tauri::command] +#[allow(clippy::needless_pass_by_value)] // Tauri extracts managed state through this owned wrapper. +fn get_lapp_settings(state: tauri::State<'_, DemoAppState>) -> LappSettings { + state.lapp_settings() +} + +#[tauri::command] +#[allow(clippy::needless_pass_by_value)] // Tauri deserializes command arguments into owned values. +fn update_lapp_settings( + state: tauri::State<'_, DemoAppState>, + request: UpdateLappSettingsRequest, +) -> Result { + state.update_lapp_settings(&request) +} + #[tauri::command] #[allow(clippy::needless_pass_by_value)] // Tauri deserializes command arguments into owned values. fn submit_turn( @@ -789,6 +1519,11 @@ pub fn run() { get_app_info, get_demo_pack_summary, get_demo_player_view, + get_branch_list, + switch_branch, + rename_branch, + get_lapp_settings, + update_lapp_settings, submit_turn, fork_branch ]) @@ -806,8 +1541,8 @@ mod tests { use nana_domain::{ ForkBranchRequest, PresentationSnapshot, Promise, PromiseStatus, PromiseWeight, - RelationshipAdjustment, RelationshipBand, RelationshipDimension, StateDelta, StateOp, - StoryNode, TurnIntent, TurnRequest, + RelationshipAdjustment, RelationshipBand, RelationshipDimension, RenameBranchRequest, + StateDelta, StateOp, StoryNode, SwitchBranchRequest, TurnIntent, TurnRequest, }; use nana_store::StoryStore; @@ -867,6 +1602,17 @@ mod tests { } } + fn continue_request(branch_id: &str, expected_node_id: &str, action_id: &str) -> TurnRequest { + TurnRequest { + story_id: DEMO_STORY_ID.to_owned(), + branch_id: branch_id.to_owned(), + expected_node_id: expected_node_id.to_owned(), + action_id: action_id.to_owned(), + intent: TurnIntent::Continue, + input: String::new(), + } + } + fn node(id: &str, parent_id: Option<&str>, ops: Vec) -> StoryNode { StoryNode { id: id.to_owned(), @@ -924,6 +1670,90 @@ mod tests { assert_eq!(restored.character_pose, result.player_view.character_pose); } + #[test] + fn complete_before_dawn_slice_records_check_item_promise_and_ending_once() { + let app = DemoAppState::open_in_memory().expect("valid demo"); + let promise = app + .submit_turn(&promise_request("node_001")) + .expect("promise turn"); + let clue = app + .submit_turn(&continue_request( + DEMO_BRANCH_ID, + &promise.committed_node_id, + "action_find_clue", + )) + .expect("hidden search turn"); + assert_eq!(clue.player_view.inventory.len(), 2); + assert_eq!(clue.player_view.inventory[1].name, "半张旧车票"); + assert_eq!( + clue.player_view + .knowledge + .last() + .map(|record| record.title.as_str()), + Some("封锁隧道的检修门") + ); + + let tunnel = app + .submit_turn(&continue_request( + DEMO_BRANCH_ID, + &clue.committed_node_id, + "action_enter_tunnel", + )) + .expect("enter tunnel turn"); + let returned = app + .submit_turn(&continue_request( + DEMO_BRANCH_ID, + &tunnel.committed_node_id, + "action_return_before_dawn", + )) + .expect("return turn"); + + assert!(!returned.player_view.can_continue); + assert_eq!( + returned.player_view.promises[0].status, + PromiseStatus::Fulfilled + ); + assert_eq!( + returned + .player_view + .history + .last() + .map(|node| node.label.as_str()), + Some("天亮前归来") + ); + let persisted = app + .store + .load_state(DEMO_STORY_ID, DEMO_BRANCH_ID) + .expect("completed state"); + assert_eq!(persisted.checks.len(), 1); + assert_eq!(persisted.items.len(), 2); + assert_eq!(persisted.clocks[0].value, 5); + assert_eq!(persisted.promises[0].status, PromiseStatus::Fulfilled); + assert_eq!(persisted.relationships["nana->player"].trust, 41); + assert_eq!(persisted.relationships["nana->player"].respect, 48); + assert_eq!( + persisted + .world_flags + .get("nana.ending.returned_before_dawn"), + Some(&true) + ); + let rejected = app + .submit_turn(&continue_request( + DEMO_BRANCH_ID, + &returned.committed_node_id, + "action_after_ending", + )) + .expect_err("ending is terminal"); + assert_eq!(rejected.code, "invalid_input"); + assert_eq!( + app.store + .load_state(DEMO_STORY_ID, DEMO_BRANCH_ID) + .expect("unchanged ending") + .current_node, + returned.committed_node_id + ); + } + #[test] fn file_database_reopens_at_the_committed_player_view() { let database = TemporaryDatabase::new(); @@ -1039,6 +1869,134 @@ mod tests { assert!(fork.promises.is_empty()); } + #[test] + fn branch_list_rename_switch_and_active_reopen_restore_the_selected_route() { + let database = TemporaryDatabase::new(); + let (main_node, fork_branch) = { + let app = DemoAppState::open(database.path()).expect("new file demo"); + let main = app + .submit_turn(&promise_request("node_001")) + .expect("main promise"); + let forked = app + .fork_branch(&fork_request(&main.committed_node_id, "node_001")) + .expect("fork root"); + let list = app.branch_list().expect("forked branch list"); + assert_eq!(list.branches.len(), 2); + assert_eq!(list.active_branch_id, forked.branch_id); + assert!(list.branches[1].is_active); + + let renamed = app + .rename_branch(&RenameBranchRequest { + story_id: DEMO_STORY_ID.to_owned(), + branch_id: forked.branch_id.clone(), + name: "先听完雨".to_owned(), + }) + .expect("renamed branch"); + assert_eq!(renamed.branches[1].name, "先听完雨"); + + let switched = app + .switch_branch(&SwitchBranchRequest { + story_id: DEMO_STORY_ID.to_owned(), + branch_id: DEMO_BRANCH_ID.to_owned(), + expected_active_branch_id: forked.branch_id.clone(), + }) + .expect("switch to main"); + assert_eq!(switched.player_view.node_id, main.committed_node_id); + (main.committed_node_id, forked.branch_id) + }; + + let reopened = DemoAppState::open(database.path()).expect("reopened selected route"); + let view = reopened.current_player_view().expect("active PlayerView"); + assert_eq!(view.branch_id, DEMO_BRANCH_ID); + assert_eq!(view.node_id, main_node); + let list = reopened.branch_list().expect("persisted branch list"); + assert_eq!(list.active_branch_id, DEMO_BRANCH_ID); + assert_eq!( + list.branches + .iter() + .find(|branch| branch.branch_id == fork_branch) + .map(|branch| branch.name.as_str()), + Some("先听完雨") + ); + } + + #[test] + fn full_file_vertical_slice_reopens_rewinds_and_keeps_completed_main_isolated() { + let database = TemporaryDatabase::new(); + let completed_main_node = { + let app = DemoAppState::open(database.path()).expect("new playable demo"); + let promise = app + .submit_turn(&promise_request("node_001")) + .expect("promise"); + let clue = app + .submit_turn(&continue_request( + DEMO_BRANCH_ID, + &promise.committed_node_id, + "vertical_find_clue", + )) + .expect("hidden check and item"); + let tunnel = app + .submit_turn(&continue_request( + DEMO_BRANCH_ID, + &clue.committed_node_id, + "vertical_enter_tunnel", + )) + .expect("enter tunnel"); + app.submit_turn(&continue_request( + DEMO_BRANCH_ID, + &tunnel.committed_node_id, + "vertical_return", + )) + .expect("fulfilled return") + .committed_node_id + }; + + let fork_branch = { + let app = DemoAppState::open(database.path()).expect("reopen completed story"); + let completed = app.current_player_view().expect("completed main view"); + assert_eq!(completed.node_id, completed_main_node); + assert!(!completed.can_continue); + assert_eq!(completed.promises[0].status, PromiseStatus::Fulfilled); + + let forked = app + .fork_branch(&fork_request(&completed_main_node, "node_001")) + .expect("rewind completed route"); + let advanced = app + .submit_turn(&continue_request( + &forked.branch_id, + "node_001", + "vertical_alternate_route", + )) + .expect("advance alternate route"); + assert!(advanced.player_view.promises.is_empty()); + forked.branch_id + }; + + let reopened = DemoAppState::open(database.path()).expect("reopen alternate route"); + let active = reopened + .current_player_view() + .expect("restored alternate view"); + assert_eq!(active.branch_id, fork_branch); + assert!(active.promises.is_empty()); + let main = reopened + .store + .load_state(DEMO_STORY_ID, DEMO_BRANCH_ID) + .expect("completed main remains intact"); + assert_eq!(main.current_node, completed_main_node); + assert_eq!(main.promises[0].status, PromiseStatus::Fulfilled); + assert_eq!(main.checks.len(), 1); + let alternate = reopened + .store + .load_state(DEMO_STORY_ID, &fork_branch) + .expect("alternate remains isolated"); + assert!(alternate.promises.is_empty()); + assert!(alternate.checks.is_empty()); + assert_eq!( + reopened.branch_list().expect("two branches").branches.len(), + 2 + ); + } + #[test] fn player_view_does_not_serialize_hidden_item_facts_or_checks() { let app = DemoAppState::open_in_memory().expect("valid demo"); diff --git a/src/App.spec.ts b/src/App.spec.ts index 510fa3b..b604ed1 100644 --- a/src/App.spec.ts +++ b/src/App.spec.ts @@ -52,7 +52,7 @@ describe("App", () => { await vi.waitFor(() => { expect(wrapper.text()).toContain("我答应你,天亮前一定回来。"); expect(wrapper.get(".statusline span").text()).toBe("node_002"); - expect(wrapper.get(".character").attributes("data-expression")).toBe("uneasy"); + expect(wrapper.get(".character").attributes("data-expression")).toBe("relieved"); expect(wrapper.get(".character").attributes("data-pose")).toBe("holding_coat"); }); expect(wrapper.get(".send-button").text()).toBe("发送"); @@ -73,6 +73,30 @@ describe("App", () => { wrapper.unmount(); }); + it("finishes the playable before-dawn slice and exposes its settled records", async () => { + const wrapper = await mountLoadedApp(); + const composer = wrapper.get('textarea[aria-label="自由输入"]'); + await composer.setValue("我答应你,天亮前一定回来。"); + await wrapper.get("form.composer").trigger("submit"); + await vi.waitFor(() => expect(wrapper.get(".statusline span").text()).toBe("node_002")); + + for (const nodeId of ["node_003", "node_004", "node_005"]) { + await wrapper.get(".continue-button").trigger("click"); + await vi.waitFor(() => expect(wrapper.get(".statusline span").text()).toBe(nodeId)); + } + + expect(wrapper.text()).toContain("你回来了"); + expect(wrapper.text()).toContain("第一幕终 · 天亮之前"); + expect(composer.attributes("disabled")).toBeDefined(); + expect(wrapper.get(".send-button").attributes("disabled")).toBeDefined(); + await wrapper.findAll(".top-actions button")[0].trigger("click"); + const records = wrapper.get("#records-panel").text(); + expect(records).toContain("半张旧车票"); + expect(records).toContain("封锁隧道的检修门"); + expect(records).toContain("已履行"); + wrapper.unmount(); + }); + it("opens mutually exclusive, PlayerView-only record and relationship panels", async () => { const wrapper = await mountLoadedApp(); const [recordsButton, relationshipButton] = wrapper.findAll(".top-actions button"); @@ -125,6 +149,32 @@ describe("App", () => { expect(status[1]?.text()).toContain("branch_fork_"); }); expect(wrapper.find("#history-panel").exists()).toBe(false); + + await historyButton.trigger("click"); + expect(wrapper.findAll(".branch-list li")).toHaveLength(2); + expect(wrapper.get(".branch-list").text()).toContain("线路 2"); + await wrapper + .findAll(".branch-list li")[0]! + .findAll(".branch-actions button") + .at(-1)! + .trigger("click"); + await vi.waitFor(() => { + const status = wrapper.findAll(".statusline span"); + expect(status[0]?.text()).toBe("node_002"); + expect(status[1]?.text()).toBe("branch_main"); + }); + wrapper.unmount(); + }); + + it("keeps provider credentials outside the unobtrusive model settings panel", async () => { + const wrapper = await mountLoadedApp(); + await wrapper.get('button[aria-label="模型设置"]').trigger("click"); + + const panel = wrapper.get("#settings-panel"); + expect(panel.text()).toContain("确定性演示"); + expect(panel.text()).toContain("API Key 与供应商凭据不会进入本应用"); + expect(panel.text()).not.toContain("secret"); + expect(panel.get("select").attributes("disabled")).toBeDefined(); wrapper.unmount(); }); diff --git a/src/App.vue b/src/App.vue index 2b96e67..92c9949 100644 --- a/src/App.vue +++ b/src/App.vue @@ -3,24 +3,31 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue"; import { useDemo } from "./app/useDemo"; -type PanelName = "records" | "relationship" | "history"; +type PanelName = "records" | "relationship" | "history" | "settings"; const { appInfo, + branchList, busy, error, lastSubmittedIntent, loading, + lappSettings, pack, playerView, forkBranch, + renameBranch, + selectLappModel, submitTurn, + switchBranch, turnError } = useDemo(); const draft = ref(""); const activePanel = ref(null); const selectedHistoryNode = ref(null); const closeButton = ref(null); +const branchNameDrafts = ref>({}); +const lappSelection = ref(""); const sceneLabel = computed(() => { 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) { if (event.key === "Escape" && activePanel.value) { closePanel(); @@ -150,6 +198,16 @@ onBeforeUnmount(() => window.removeEventListener("keydown", handleEscape)); > 回溯 + @@ -177,7 +235,9 @@ onBeforeUnmount(() => window.removeEventListener("keydown", handleEscape));
-
第一幕 · 雨夜车站
+
+ {{ playerView.canContinue ? "第一幕 · 雨夜车站" : "第一幕终 · 天亮之前" }} +
    @@ -206,7 +266,7 @@ onBeforeUnmount(() => window.removeEventListener("keydown", handleEscape)); aria-label="自由输入" placeholder="说些什么,或者描述你的行动……" rows="2" - :disabled="busy" + :disabled="busy || !playerView.canContinue" /> - @@ -249,7 +313,9 @@ onBeforeUnmount(() => window.removeEventListener("keydown", handleEscape)); ? "随身记录" : activePanel === "relationship" ? `与${playerView.characterName}的关系` - : "故事回溯" + : activePanel === "history" + ? "故事线路" + : "模型设置" }} @@ -326,7 +392,48 @@ onBeforeUnmount(() => window.removeEventListener("keydown", handleEscape));

    -
    +
    +
    +

    已有线路

    +
      +
    • +
      +
      + {{ branch.name }} + {{ branch.headLabel }} +
      + 当前 +
      +
      + + + +
      +
    • +
    +

    从旧节点继续会创建一条新线路,当前线路仍会完整保留。

    1. @@ -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 }}

    + + + +
    +
    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; }