Compare commits

7 Commits
Author SHA1 Message Date
klarkxy 1ebc3b28af feat(runtime): compact long branch context safely
verify / verify (push) Has been cancelled
2026-07-29 10:14:21 +08:00
klarkxy b6600c1f06 feat(runtime): prepare safe context checkpoints 2026-07-29 08:46:23 +08:00
klarkxy d0612b399c feat(runtime): preserve current branch context 2026-07-28 22:52:06 +08:00
klarkxy 37b3397e4a chore: establish wave 7 local baseline 2026-07-28 22:07:49 +08:00
Codex 23672e857b feat(app): harden cancellable LAPP turns
verify / verify (push) Has been cancelled
2026-07-28 08:44:53 -04:00
Codex 4831db1763 feat(app): add cancellable turn lifecycle 2026-07-28 08:01:56 -04:00
Codex 1f935a3206 feat(app): complete playable branch sessions
verify / verify (push) Has been cancelled
2026-07-28 04:42:40 -04:00
100 changed files with 15966 additions and 361 deletions
+7
View File
@@ -0,0 +1,7 @@
* text=auto eol=lf
*.ico binary
*.png binary
*.webp binary
*.wav binary
*.mp3 binary
Generated
+2
View File
@@ -1928,10 +1928,12 @@ dependencies = [
"nana-engine",
"nana-runtime",
"nana-store",
"openlapp",
"serde",
"serde_json",
"tauri",
"tauri-build",
"tokio",
]
[[package]]
+1 -1
View File
@@ -29,7 +29,7 @@ serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.145"
sha2 = "0.10.9"
thiserror = "2.0.17"
tokio = { version = "1.44.2", features = ["rt", "time"] }
tokio = { version = "1.44.2", features = ["rt", "sync", "time"] }
ts-rs = { version = "11.1.0", features = ["serde-compat"] }
[workspace.lints.rust]
+36 -6
View File
@@ -4,7 +4,9 @@
## 当前阶段
M0 契约基线已建立;M1 状态、投影与持久化主链已经接通M2 正在进入真实模型循环。
M0 契约基线已建立;M1 状态、投影与持久化主链已经接通M2 已具备可取消的真实模型
回合,以及面向长分支的上下文检查点与滚动压缩主链。Windows 原生构建基线已经建立,
真实在线 LAPP 与视觉验收仍待关闭。
目前包括:
@@ -16,26 +18,54 @@ M0 契约基线已建立;M1 状态、投影与持久化主链已经接通,M2
- 最小娜娜内容包;
- 原子化状态 reducer,覆盖关系、许诺、知识、持有物、时钟与判定;
- 保持同一语义的内存与 SQLite StoryStore,支持事务追加、恢复和回溯分叉;
- SQLite schema v1、旧库完整性校验、WAL、busy timeout 与未来版本拒绝;
- SQLite schema v3,以及从 schema v1 / v2 的无损迁移、旧库完整性校验、WAL、
busy timeout 与未来版本拒绝;
- 写入与恢复时的状态哈希校验;
- `RuntimeState → PlayerView` 脱敏投影与隐藏信息 canary 测试;
- `TurnPlan → reducer → SQLite → PlayerView` 单轮提交链;
- 随故事节点持久化并恢复 scene、expression、pose、beats 与 suggestions
- 确定性的场景上下文编译器,按固定顺序筛选角色卡、Persona、世界书、剧情事件与可见记忆;
- 缓存友好的 prompt schema v3:稳定资源前缀、当前分支检查点 / 原文尾部和动态本轮
内容各自分区;模型上下文窗口、输出上限、工具续调用与安全余量统一进入预算;
- 长分支超过预算时,由当前选中的同一个 LAPP 模型通过严格的摘要工具滚动压缩;摘要
只接受当前分支的玩家安全叙事投影,始终是可删除、可重建的非权威缓存;
- 检查点通过稳定前缀与叙事来源 SHA-256 校验,只能沿当前祖先链继承;损坏、过期或
来自兄弟分支的缓存不会参与提示词;
- 新检查点先作为待提交结果保留,最终与普通节点或重生成节点在同一存储事务中提交;
取消、超时、陈旧线路或无效输出不会留下半轮节点或孤立检查点;
- 可继续的模型回复会在提交前按同一模型预算验证其“下一轮最新原文”占用;即使输出
符合静态 Schema,只要会让后续回合永久无法编排,就不会写入线路;
- 内存与 SQLite 后端均覆盖 500 节点祖先链读取和检查点查找,不再沿用界面层的
200 节点深度限制;
- 引擎掌控的类型化隐藏检定循环,模型只收到定性结果,不能指定或读取骰点、难度与精确数值;
- 基于 `lapp-rs` 原生工具消息的 LAPP TurnPlan provider 与严格输出校验;
- 从任意当前线路历史节点创建真实持久化分支,旧线路与新线路保持隔离;
- 列出、重命名、切换故事线路,并在关闭应用后恢复最后活动线路;
- 应用内选择 LAPP profile 中声明了聊天与工具调用能力的模型,凭据仍只由 LAPP
Vault 即时解析;
- 应用内测试当前已应用模型的最小连接,不接收任意模型目标,也不返回供应商正文;
- 回合支持停止生成与 90 秒应用截止时间;取消、超时或无效输出都不能产生半轮节点;
- 缺凭据、配置错误、限流、供应商拒绝与网络不可用使用脱敏错误分类,并只允许一次
明确的安全重试;
- 可完整游玩的“天亮之前”纵切:接受许诺、隐藏搜索、获得车票、进入隧道、天亮前
归来并结算许诺;
- Turn 请求/结果校验、Fake Provider 与确定性世界书触发;
- 可交互的主演出屏、持有物/线索/许诺、关系与回溯面板。
浏览器模式继续使用确定性的本地 Turn adapter,方便无桌面壳开发;Tauri
模式已经通过 `submit_turn` 使用 SQLite 自动保存和恢复,并通过 `fork_branch`
创建持久化分支。桌面端默认使用真实 LAPP provider;只有显式设置
创建持久化分支。线路选择和名称也保存在同一 SQLite 存档中。桌面端默认使用真实
LAPP provider;只有显式设置
`NANA_STORY_PROVIDER=demo` 时才启用确定性的娜娜纵切实现。
Rust 1.96 下的核心测试、Clippy、契约生成检查、Tauri 全 target 类型检查与后端
单元测试已经通过。当前 Linux Work 环境缺少 WebKitGTK 等桌面开发库,因此真实
桌面窗口启动与 Windows 打包仍需在具备原生依赖的环境补跑。
应用内“设置”只选择 LAPP profile 已有模型,不读取、保存或回显 API Key。若 profile
缺失,或没有声明 `chat``tool-call` 能力的启用模型,界面会明确显示不可用。
Rust 1.96 下的 workspace 共 215 项测试通过:Contracts 1、Domain 5、Engine 21、
Runtime 106、Store 65、Tauri 17;全 workspace Clippy、25 份契约 Schema 检查、
TypeScript 检查、29 项 Web 测试与生产构建均已通过。Windows 原生构建基线与复现
步骤见 [`docs/windows-developer-smoke.md`](docs/windows-developer-smoke.md),本轮
不据此宣称真实在线 LAPP 或窗口视觉验收已经完成。
## 开发
+20
View File
@@ -189,6 +189,26 @@
"check_modifier": 1
},
"hidden_facts": ["电池仓里刻着青川站工作人员的编号"]
},
{
"header": {
"id": "nana.item.half_ticket",
"kind": "item_spec",
"schema_version": 1,
"revision": "0.1.0",
"content_hash": "sha256:6666666666666666666666666666666666666666666666666666666666666666",
"dependencies": []
},
"name": "半张旧车票",
"description": "从检修门缝里找到的受潮车票,背面写着“四点十七分,检修线”。",
"tags": ["clue", "ticket", "sister"],
"lore_refs": ["station_clock"],
"mechanics": {
"usable": false,
"grants_tags": ["has_sister_clue"],
"check_modifier": null
},
"hidden_facts": ["车票纤维中残留着封锁隧道深处的红色矿尘"]
}
]
}
+1 -1
View File
@@ -1 +1 @@
93d800013b9bec3087490ae629039724076cf26d9bb9a5f8834c6aa7473b1ca7
9862200858d46d8e15e10d56972039a85b69853ca35f4e79f19abaab784d91fc
+59
View File
@@ -0,0 +1,59 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "BranchList",
"type": "object",
"properties": {
"activeBranchId": {
"type": "string"
},
"branches": {
"type": "array",
"items": {
"$ref": "#/$defs/BranchSummary"
}
},
"storyId": {
"type": "string"
}
},
"required": [
"storyId",
"activeBranchId",
"branches"
],
"$defs": {
"BranchSummary": {
"type": "object",
"properties": {
"branchId": {
"type": "string"
},
"headLabel": {
"type": "string"
},
"headNodeId": {
"type": "string"
},
"isActive": {
"type": "boolean"
},
"name": {
"type": "string"
},
"sourceNodeId": {
"type": [
"string",
"null"
]
}
},
"required": [
"branchId",
"name",
"headNodeId",
"headLabel",
"isActive"
]
}
}
}
@@ -0,0 +1,31 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "LappConnectionTestResult",
"type": "object",
"properties": {
"diagnosticCode": {
"type": [
"string",
"null"
]
},
"message": {
"type": "string"
},
"modelId": {
"type": "string"
},
"ok": {
"type": "boolean"
},
"providerId": {
"type": "string"
}
},
"required": [
"ok",
"providerId",
"modelId",
"message"
]
}
@@ -0,0 +1,73 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "LappSettings",
"type": "object",
"properties": {
"availableModels": {
"type": "array",
"default": [],
"items": {
"$ref": "#/$defs/LappModelOption"
}
},
"mode": {
"$ref": "#/$defs/LappMode"
},
"selectedModelId": {
"type": [
"string",
"null"
]
},
"selectedProviderId": {
"type": [
"string",
"null"
]
},
"statusMessage": {
"type": "string"
}
},
"required": [
"mode",
"statusMessage"
],
"$defs": {
"LappMode": {
"type": "string",
"enum": [
"lapp",
"demo",
"unavailable"
]
},
"LappModelOption": {
"type": "object",
"properties": {
"modelId": {
"type": "string"
},
"modelName": {
"type": [
"string",
"null"
]
},
"providerId": {
"type": "string"
},
"providerName": {
"type": [
"string",
"null"
]
}
},
"required": [
"providerId",
"modelId"
]
}
}
}
@@ -0,0 +1,21 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "RenameBranchRequest",
"type": "object",
"properties": {
"branchId": {
"type": "string"
},
"name": {
"type": "string"
},
"storyId": {
"type": "string"
}
},
"required": [
"storyId",
"branchId",
"name"
]
}
@@ -0,0 +1,21 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SwitchBranchRequest",
"type": "object",
"properties": {
"branchId": {
"type": "string"
},
"expectedActiveBranchId": {
"type": "string"
},
"storyId": {
"type": "string"
}
},
"required": [
"storyId",
"branchId",
"expectedActiveBranchId"
]
}
@@ -0,0 +1,397 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SwitchBranchResult",
"type": "object",
"properties": {
"branchId": {
"type": "string"
},
"playerView": {
"$ref": "#/$defs/PlayerView"
}
},
"required": [
"branchId",
"playerView"
],
"$defs": {
"ActionSuggestion": {
"type": "object",
"properties": {
"draft": {
"type": "string"
},
"id": {
"type": "string"
},
"label": {
"type": "string"
}
},
"required": [
"id",
"label",
"draft"
]
},
"BeatKind": {
"type": "string",
"enum": [
"narration",
"dialogue",
"action",
"system"
]
},
"HistoryNodeView": {
"type": "object",
"properties": {
"branchId": {
"type": "string"
},
"id": {
"type": "string"
},
"isCurrent": {
"type": "boolean"
},
"label": {
"type": "string"
},
"parentId": {
"type": [
"string",
"null"
]
}
},
"required": [
"id",
"branchId",
"label",
"isCurrent"
]
},
"ItemPlacement": {
"type": "string",
"enum": [
"bag",
"worn",
"hand",
"scene",
"hidden"
]
},
"KnowledgeCertainty": {
"type": "string",
"enum": [
"suspected",
"reported",
"confirmed"
]
},
"PlayerItemView": {
"type": "object",
"properties": {
"condition": {
"type": "string"
},
"description": {
"type": "string"
},
"instanceId": {
"type": "string"
},
"name": {
"type": "string"
},
"placement": {
"$ref": "#/$defs/ItemPlacement"
},
"quantity": {
"type": "integer",
"format": "uint32",
"minimum": 0
}
},
"required": [
"instanceId",
"name",
"description",
"quantity",
"placement",
"condition"
]
},
"PlayerKnowledgeView": {
"type": "object",
"properties": {
"certainty": {
"$ref": "#/$defs/KnowledgeCertainty"
},
"id": {
"type": "string"
},
"summary": {
"type": "string"
},
"title": {
"type": "string"
}
},
"required": [
"id",
"title",
"summary",
"certainty"
]
},
"PlayerPromiseView": {
"type": "object",
"properties": {
"content": {
"type": "string"
},
"id": {
"type": "string"
},
"status": {
"$ref": "#/$defs/PromiseStatus"
},
"weight": {
"$ref": "#/$defs/PromiseWeight"
}
},
"required": [
"id",
"content",
"status",
"weight"
]
},
"PlayerView": {
"type": "object",
"properties": {
"beats": {
"type": "array",
"items": {
"$ref": "#/$defs/PresentationBeat"
}
},
"branchId": {
"type": "string"
},
"canContinue": {
"type": "boolean"
},
"characterExpression": {
"type": [
"string",
"null"
],
"default": null
},
"characterName": {
"type": "string"
},
"characterPose": {
"type": [
"string",
"null"
],
"default": null
},
"history": {
"type": "array",
"default": [],
"items": {
"$ref": "#/$defs/HistoryNodeView"
}
},
"inventory": {
"type": "array",
"default": [],
"items": {
"$ref": "#/$defs/PlayerItemView"
}
},
"knowledge": {
"type": "array",
"default": [],
"items": {
"$ref": "#/$defs/PlayerKnowledgeView"
}
},
"nodeId": {
"type": "string"
},
"promises": {
"type": "array",
"default": [],
"items": {
"$ref": "#/$defs/PlayerPromiseView"
}
},
"relationship": {
"$ref": "#/$defs/RelationshipView"
},
"sceneId": {
"type": "string"
},
"sceneTitle": {
"type": "string"
},
"storyId": {
"type": "string"
},
"suggestions": {
"type": "array",
"default": [],
"items": {
"$ref": "#/$defs/ActionSuggestion"
}
}
},
"required": [
"storyId",
"nodeId",
"branchId",
"sceneId",
"sceneTitle",
"characterName",
"beats",
"relationship",
"canContinue"
]
},
"PresentationBeat": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"kind": {
"$ref": "#/$defs/BeatKind"
},
"speaker": {
"type": [
"string",
"null"
]
},
"text": {
"type": "string"
},
"visual": {
"anyOf": [
{
"$ref": "#/$defs/VisualDirective"
},
{
"type": "null"
}
]
}
},
"required": [
"id",
"kind",
"text"
]
},
"PromiseStatus": {
"type": "string",
"enum": [
"proposed",
"accepted",
"fulfilled",
"broken",
"released",
"impossible"
]
},
"PromiseWeight": {
"type": "string",
"enum": [
"minor",
"major"
]
},
"RelationshipBand": {
"type": "string",
"enum": [
"distant",
"guarded",
"warming",
"close",
"bonded"
]
},
"RelationshipView": {
"type": "object",
"properties": {
"affinity": {
"$ref": "#/$defs/RelationshipBand"
},
"attachment": {
"$ref": "#/$defs/RelationshipBand"
},
"hope": {
"$ref": "#/$defs/RelationshipBand"
},
"intimacy": {
"$ref": "#/$defs/RelationshipBand"
},
"respect": {
"$ref": "#/$defs/RelationshipBand"
},
"trust": {
"$ref": "#/$defs/RelationshipBand"
},
"updatedAtNode": {
"type": [
"string",
"null"
]
}
},
"required": [
"affinity",
"trust",
"hope",
"respect",
"intimacy",
"attachment"
]
},
"VisualDirective": {
"type": "object",
"properties": {
"character": {
"type": [
"string",
"null"
]
},
"expression": {
"type": [
"string",
"null"
]
},
"pose": {
"type": [
"string",
"null"
]
},
"scene": {
"type": [
"string",
"null"
]
}
}
}
}
}
@@ -25,6 +25,10 @@
"stale_node",
"invalid_input",
"invalid_model_output",
"provider_configuration",
"provider_credentials",
"provider_rate_limited",
"provider_rejected",
"provider_unavailable",
"cancelled",
"timed_out",
@@ -0,0 +1,17 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "UpdateLappSettingsRequest",
"type": "object",
"properties": {
"modelId": {
"type": "string"
},
"providerId": {
"type": "string"
}
},
"required": [
"providerId",
"modelId"
]
}
+21 -1
View File
@@ -120,7 +120,27 @@ export type ForkBranchRequest = { storyId: string, currentBranchId: string, expe
export type ForkBranchResult = { branchId: string, playerView: PlayerView, };
export type TurnFailureCode = "stale_node" | "invalid_input" | "invalid_model_output" | "provider_unavailable" | "cancelled" | "timed_out" | "internal";
export type BranchSummary = { branchId: string, name: string, headNodeId: string, headLabel: string, sourceNodeId: string | null, isActive: boolean, };
export type BranchList = { storyId: string, activeBranchId: string, branches: Array<BranchSummary>, };
export type SwitchBranchRequest = { storyId: string, branchId: string, expectedActiveBranchId: string, };
export type SwitchBranchResult = { branchId: string, playerView: PlayerView, };
export type RenameBranchRequest = { storyId: string, branchId: string, name: string, };
export type LappModelOption = { providerId: string, providerName: string | null, modelId: string, modelName: string | null, };
export type LappMode = "lapp" | "demo" | "unavailable";
export type LappSettings = { mode: LappMode, selectedProviderId: string | null, selectedModelId: string | null, availableModels: Array<LappModelOption>, statusMessage: string, };
export type UpdateLappSettingsRequest = { providerId: string, modelId: string, };
export type LappConnectionTestResult = { ok: boolean, providerId: string, modelId: string, message: string, diagnosticCode: string | null, };
export type TurnFailureCode = "stale_node" | "invalid_input" | "invalid_model_output" | "provider_configuration" | "provider_credentials" | "provider_rate_limited" | "provider_rejected" | "provider_unavailable" | "cancelled" | "timed_out" | "internal";
export type TurnFailure = { code: TurnFailureCode, message: string, retryable: boolean, };
+80 -13
View File
@@ -4,17 +4,19 @@ use std::{
};
use nana_domain::{
AcquisitionMode, ActionSuggestion, AppInfo, BeatKind, CharacterCard, CharacterJudgmentRule,
CharacterStyle, CheckDifficulty, CheckRecord, CheckResult, ClockState, DemoPackSummary,
ForkBranchRequest, ForkBranchResult, HistoryNodeView, ItemAcquisition, ItemInstance,
ItemMechanics, ItemPlacement, ItemSpec, KnowledgeCertainty, KnowledgeRecord, Persona,
PlayerItemView, PlayerKnowledgeView, PlayerPromiseView, PlayerView, PlotEvent, PlotModule,
PlotOutcome, PlotPressure, PresentationBeat, PresentationCharacter, PresentationScene,
PresentationSnapshot, Promise, PromiseStatus, PromiseWeight, RelationshipAdjustment,
RelationshipAxes, RelationshipBand, RelationshipDimension, RelationshipState, RelationshipView,
ResourceBundle, ResourceHeader, ResourceId, ResourceKind, ResourceRef, RuntimeState,
SkillValue, StateDelta, StateOp, Story, StoryBinding, StoryNode, TurnFailure, TurnFailureCode,
TurnIntent, TurnRequest, TurnResult, ValidationCode, ValidationIssue, ValidationReport,
AcquisitionMode, ActionSuggestion, AppInfo, BeatKind, BranchList, BranchSummary, CharacterCard,
CharacterJudgmentRule, CharacterStyle, CheckDifficulty, CheckRecord, CheckResult, ClockState,
DemoPackSummary, ForkBranchRequest, ForkBranchResult, HistoryNodeView, ItemAcquisition,
ItemInstance, ItemMechanics, ItemPlacement, ItemSpec, KnowledgeCertainty, KnowledgeRecord,
LappConnectionTestResult, LappMode, LappModelOption, LappSettings, Persona, PlayerItemView,
PlayerKnowledgeView, PlayerPromiseView, PlayerView, PlotEvent, PlotModule, PlotOutcome,
PlotPressure, PresentationBeat, PresentationCharacter, PresentationScene, PresentationSnapshot,
Promise, PromiseStatus, PromiseWeight, RelationshipAdjustment, RelationshipAxes,
RelationshipBand, RelationshipDimension, RelationshipState, RelationshipView,
RenameBranchRequest, ResourceBundle, ResourceHeader, ResourceId, ResourceKind, ResourceRef,
RuntimeState, SkillValue, StateDelta, StateOp, Story, StoryBinding, StoryNode,
SwitchBranchRequest, SwitchBranchResult, TurnFailure, TurnFailureCode, TurnIntent, TurnRequest,
TurnResult, UpdateLappSettingsRequest, ValidationCode, ValidationIssue, ValidationReport,
VisualDirective, WorldBook, WorldBookEntry,
};
use schemars::{JsonSchema, schema_for};
@@ -33,7 +35,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if check {
let mut stale = Vec::new();
for (path, expected) in outputs {
if fs::read(&path).ok().as_deref() != Some(expected.as_slice()) {
let matches = fs::read(&path).ok().is_some_and(|actual| {
normalize_line_endings(&actual) == normalize_line_endings(&expected)
});
if !matches {
stale.push(path);
}
}
@@ -55,6 +60,23 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
fn normalize_line_endings(bytes: &[u8]) -> Vec<u8> {
let mut normalized = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'\r' && bytes.get(index + 1) == Some(&b'\n') {
normalized.push(b'\n');
index += 2;
} else {
normalized.push(bytes[index]);
index += 1;
}
}
normalized
}
fn generated_outputs(root: &Path) -> Result<GeneratedOutputs, Box<dyn std::error::Error>> {
let mut outputs = Vec::new();
let schema_dir = root.join("contracts/schema");
@@ -74,6 +96,21 @@ fn generated_outputs(root: &Path) -> Result<GeneratedOutputs, Box<dyn std::error
add_schema::<TurnResult>(&mut outputs, &schema_dir, "turn-result")?;
add_schema::<ForkBranchRequest>(&mut outputs, &schema_dir, "fork-branch-request")?;
add_schema::<ForkBranchResult>(&mut outputs, &schema_dir, "fork-branch-result")?;
add_schema::<BranchList>(&mut outputs, &schema_dir, "branch-list")?;
add_schema::<SwitchBranchRequest>(&mut outputs, &schema_dir, "switch-branch-request")?;
add_schema::<SwitchBranchResult>(&mut outputs, &schema_dir, "switch-branch-result")?;
add_schema::<RenameBranchRequest>(&mut outputs, &schema_dir, "rename-branch-request")?;
add_schema::<LappSettings>(&mut outputs, &schema_dir, "lapp-settings")?;
add_schema::<UpdateLappSettingsRequest>(
&mut outputs,
&schema_dir,
"update-lapp-settings-request",
)?;
add_schema::<LappConnectionTestResult>(
&mut outputs,
&schema_dir,
"lapp-connection-test-result",
)?;
add_schema::<TurnFailure>(&mut outputs, &schema_dir, "turn-failure")?;
add_schema::<AppInfo>(&mut outputs, &schema_dir, "app-info")?;
add_schema::<DemoPackSummary>(&mut outputs, &schema_dir, "demo-pack-summary")?;
@@ -83,7 +120,10 @@ fn generated_outputs(root: &Path) -> Result<GeneratedOutputs, Box<dyn std::error
outputs.push((root.join("contracts/ts/index.ts"), ts.into_bytes()));
let domain_source = fs::read(root.join("crates/nana-domain/src/lib.rs"))?;
let source_hash = format!("{:x}\n", Sha256::digest(domain_source));
let source_hash = format!(
"{:x}\n",
Sha256::digest(normalize_line_endings(&domain_source))
);
outputs.push((
root.join("contracts/.source.sha256"),
source_hash.into_bytes(),
@@ -154,6 +194,16 @@ fn generated_declarations() -> String {
TurnResult::decl(),
ForkBranchRequest::decl(),
ForkBranchResult::decl(),
BranchSummary::decl(),
BranchList::decl(),
SwitchBranchRequest::decl(),
SwitchBranchResult::decl(),
RenameBranchRequest::decl(),
LappModelOption::decl(),
LappMode::decl(),
LappSettings::decl(),
UpdateLappSettingsRequest::decl(),
LappConnectionTestResult::decl(),
TurnFailureCode::decl(),
TurnFailure::decl(),
AppInfo::decl(),
@@ -178,3 +228,20 @@ fn add_schema<T: JsonSchema + Serialize>(
outputs.push((schema_dir.join(format!("{name}.schema.json")), bytes));
Ok(())
}
#[cfg(test)]
mod tests {
use super::normalize_line_endings;
#[test]
fn source_hash_input_is_independent_of_checkout_line_endings() {
assert_eq!(
normalize_line_endings(b"first\r\nsecond\nthird\r"),
b"first\nsecond\nthird\r"
);
assert_eq!(
normalize_line_endings(b"first\nsecond\nthird\r"),
b"first\nsecond\nthird\r"
);
}
}
+100
View File
@@ -745,6 +745,102 @@ pub struct ForkBranchResult {
pub player_view: PlayerView,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
pub struct BranchSummary {
pub branch_id: String,
pub name: String,
pub head_node_id: String,
pub head_label: String,
pub source_node_id: Option<String>,
pub is_active: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
pub struct BranchList {
pub story_id: String,
pub active_branch_id: String,
pub branches: Vec<BranchSummary>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
pub struct SwitchBranchRequest {
pub story_id: String,
pub branch_id: String,
pub expected_active_branch_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
pub struct SwitchBranchResult {
pub branch_id: String,
pub player_view: PlayerView,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
pub struct RenameBranchRequest {
pub story_id: String,
pub branch_id: String,
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
pub struct LappModelOption {
pub provider_id: String,
pub provider_name: Option<String>,
pub model_id: String,
pub model_name: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
pub enum LappMode {
Lapp,
Demo,
Unavailable,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
pub struct LappSettings {
pub mode: LappMode,
pub selected_provider_id: Option<String>,
pub selected_model_id: Option<String>,
#[serde(default)]
pub available_models: Vec<LappModelOption>,
pub status_message: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
pub struct UpdateLappSettingsRequest {
pub provider_id: String,
pub model_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
pub struct LappConnectionTestResult {
pub ok: bool,
pub provider_id: String,
pub model_id: String,
pub message: String,
pub diagnostic_code: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
@@ -759,6 +855,10 @@ pub enum TurnFailureCode {
StaleNode,
InvalidInput,
InvalidModelOutput,
ProviderConfiguration,
ProviderCredentials,
ProviderRateLimited,
ProviderRejected,
ProviderUnavailable,
Cancelled,
TimedOut,
+268 -23
View File
@@ -2,15 +2,23 @@ use std::collections::{BTreeMap, BTreeSet};
use nana_domain::{
CharacterCard, CheckDifficulty, CheckRecord, CheckResult, ItemPlacement, ItemSpec, Persona,
ResourceBundle, RuntimeState, StateOp, TurnIntent, TurnRequest, stable_json_hash,
ResourceBundle, RuntimeState, StateOp, StoryNode, TurnIntent, TurnRequest, stable_json_hash,
};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::{InvalidModelOutputKind, ProviderError, TurnPlan, TurnPlanProvider};
use crate::{
BranchHistoryProjection, InvalidModelOutputKind, NarrativeCheckpoint, ProviderError,
TurnContextPreparation, TurnControl, TurnPlan, TurnPlanProvider, provider_interruption,
};
pub const HIDDEN_CHECK_TOOL_NAME: &str = "request_hidden_check";
pub const DEFAULT_MAX_ADJUDICATION_STEPS: usize = 4;
/// V1 permits one authoritative check before the final turn plan.
///
/// Keeping this limit explicit makes the continuation budget bounded and
/// keeps one player action from silently turning into several unrelated rolls.
pub const MAX_HIDDEN_CHECKS_PER_TURN: usize = 1;
pub const DEFAULT_MAX_ADJUDICATION_STEPS: usize = MAX_HIDDEN_CHECKS_PER_TURN + 1;
/// A typed hidden-check request proposed by the narrative model.
///
@@ -105,6 +113,91 @@ pub trait AdjudicationModel {
&mut self,
input: AdjudicationModelInput<'_>,
) -> Result<AdjudicationModelResponse, ProviderError>;
/// Respond with caller-selected safe branch history.
///
/// Existing models remain compatible and may ignore it. Narrative adapters
/// should override this instead of loading branch nodes themselves.
fn respond_with_history(
&mut self,
input: AdjudicationModelInput<'_>,
branch_history: &BranchHistoryProjection,
) -> Result<AdjudicationModelResponse, ProviderError> {
let _ = branch_history;
self.respond(input)
}
/// Respond while observing the outer turn lifecycle.
///
/// Existing deterministic models remain source-compatible. Network-backed
/// adapters should override this to interrupt their in-flight operation.
fn respond_with_control(
&mut self,
input: AdjudicationModelInput<'_>,
control: &TurnControl,
) -> Result<AdjudicationModelResponse, ProviderError> {
if let Some(interruption) = control.interruption() {
return Err(provider_interruption(interruption));
}
let result = self.respond(input);
if let Some(interruption) = control.interruption() {
Err(provider_interruption(interruption))
} else {
result
}
}
/// History-aware controlled response. The compatibility default keeps a
/// model's existing cancellation behavior and ignores history.
fn respond_with_history_and_control(
&mut self,
input: AdjudicationModelInput<'_>,
branch_history: &BranchHistoryProjection,
control: &TurnControl,
) -> Result<AdjudicationModelResponse, ProviderError> {
let _ = branch_history;
self.respond_with_control(input, control)
}
#[must_use]
fn uses_context_checkpoints(&self) -> bool {
false
}
fn prepare_turn_context_with_control(
&mut self,
request: &TurnRequest,
state: &RuntimeState,
source_nodes: &[StoryNode],
expected_history_head_node_id: &str,
checkpoint: Option<&NarrativeCheckpoint>,
control: &TurnControl,
) -> Result<TurnContextPreparation, ProviderError> {
let _ = (
request,
state,
source_nodes,
expected_history_head_node_id,
checkpoint,
);
if let Some(interruption) = control.interruption() {
return Err(provider_interruption(interruption));
}
Ok(TurnContextPreparation::Unmanaged)
}
fn validate_prospective_context_with_control(
&mut self,
state: &RuntimeState,
node: &StoryNode,
control: &TurnControl,
) -> Result<(), ProviderError> {
let _ = (state, node);
if let Some(interruption) = control.interruption() {
return Err(provider_interruption(interruption));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
@@ -145,8 +238,12 @@ pub enum AdjudicationError {
DuplicateItem(String),
#[error("check id was already used: {0}")]
DuplicateCheckId(String),
#[error("one player action may request at most one hidden check")]
TooManyHiddenChecks,
#[error("model supplied a RecordCheck state operation")]
ModelSuppliedRecordCheck,
#[error("regeneration requested a new hidden check")]
RegenerationRequestedCheck,
#[error("adjudication exceeded its tool-step budget")]
StepBudgetExceeded,
#[error("pushed check did not match the player-authorized failed check")]
@@ -376,6 +473,11 @@ impl<Model> AdjudicatingTurnPlanProvider<Model> {
&self.model
}
#[must_use]
pub const fn model_mut(&mut self) -> &mut Model {
&mut self.model
}
#[must_use]
pub fn into_model(self) -> Model {
self.model
@@ -387,6 +489,50 @@ impl<Model: AdjudicationModel> AdjudicatingTurnPlanProvider<Model> {
&mut self,
request: &TurnRequest,
state: &RuntimeState,
) -> Result<TurnPlan, AdjudicationRunError> {
self.plan_adjudicated_turn_with_history_and_control(
request,
state,
&BranchHistoryProjection::default(),
&TurnControl::new(),
)
}
pub fn plan_adjudicated_turn_with_history(
&mut self,
request: &TurnRequest,
state: &RuntimeState,
branch_history: &BranchHistoryProjection,
) -> Result<TurnPlan, AdjudicationRunError> {
self.plan_adjudicated_turn_with_history_and_control(
request,
state,
branch_history,
&TurnControl::new(),
)
}
/// Run the model/check/model loop without persisting any partial result.
pub fn plan_adjudicated_turn_with_control(
&mut self,
request: &TurnRequest,
state: &RuntimeState,
control: &TurnControl,
) -> Result<TurnPlan, AdjudicationRunError> {
self.plan_adjudicated_turn_with_history_and_control(
request,
state,
&BranchHistoryProjection::default(),
control,
)
}
pub fn plan_adjudicated_turn_with_history_and_control(
&mut self,
request: &TurnRequest,
state: &RuntimeState,
branch_history: &BranchHistoryProjection,
control: &TurnControl,
) -> Result<TurnPlan, AdjudicationRunError> {
let mut records = Vec::new();
let mut last_outcome = None;
@@ -401,13 +547,21 @@ impl<Model: AdjudicationModel> AdjudicatingTurnPlanProvider<Model> {
AdjudicationModelInput::BeginTurn { request, state },
AdjudicationModelInput::CheckResolved,
);
let response = self.model.respond(input)?;
let response =
self.model
.respond_with_history_and_control(input, branch_history, control)?;
let tool_call = exactly_one_tool(response)?;
match tool_call {
AdjudicationToolCall::RequestHiddenCheck(proposed) => {
if matches!(request.intent, TurnIntent::Regenerate) {
return Err(AdjudicationError::RegenerationRequestedCheck.into());
}
if !check_ids.insert(proposed.check_id.clone()) {
return Err(AdjudicationError::DuplicateCheckId(proposed.check_id).into());
}
if records.len() >= MAX_HIDDEN_CHECKS_PER_TURN {
return Err(AdjudicationError::TooManyHiddenChecks.into());
}
if matches!(request.intent, TurnIntent::PushCheck) && !records.is_empty() {
return Err(AdjudicationError::PushedCheckMismatch.into());
}
@@ -464,6 +618,85 @@ impl<Model: AdjudicationModel> TurnPlanProvider for AdjudicatingTurnPlanProvider
},
})
}
fn plan_turn_with_control(
&mut self,
request: &TurnRequest,
state: &RuntimeState,
control: &TurnControl,
) -> Result<TurnPlan, ProviderError> {
self.plan_adjudicated_turn_with_control(request, state, control)
.map_err(|error| match error {
AdjudicationRunError::Provider(error) => error,
AdjudicationRunError::Rejected(_) => ProviderError::InvalidModelOutput {
kind: InvalidModelOutputKind::InvalidPlan,
},
})
}
fn plan_turn_with_history(
&mut self,
request: &TurnRequest,
state: &RuntimeState,
branch_history: &BranchHistoryProjection,
) -> Result<TurnPlan, ProviderError> {
self.plan_adjudicated_turn_with_history(request, state, branch_history)
.map_err(|error| match error {
AdjudicationRunError::Provider(error) => error,
AdjudicationRunError::Rejected(_) => ProviderError::InvalidModelOutput {
kind: InvalidModelOutputKind::InvalidPlan,
},
})
}
fn plan_turn_with_history_and_control(
&mut self,
request: &TurnRequest,
state: &RuntimeState,
branch_history: &BranchHistoryProjection,
control: &TurnControl,
) -> Result<TurnPlan, ProviderError> {
self.plan_adjudicated_turn_with_history_and_control(request, state, branch_history, control)
.map_err(|error| match error {
AdjudicationRunError::Provider(error) => error,
AdjudicationRunError::Rejected(_) => ProviderError::InvalidModelOutput {
kind: InvalidModelOutputKind::InvalidPlan,
},
})
}
fn uses_context_checkpoints(&self) -> bool {
self.model.uses_context_checkpoints()
}
fn prepare_turn_context_with_control(
&mut self,
request: &TurnRequest,
state: &RuntimeState,
source_nodes: &[StoryNode],
expected_history_head_node_id: &str,
checkpoint: Option<&NarrativeCheckpoint>,
control: &TurnControl,
) -> Result<TurnContextPreparation, ProviderError> {
self.model.prepare_turn_context_with_control(
request,
state,
source_nodes,
expected_history_head_node_id,
checkpoint,
control,
)
}
fn validate_prospective_context_with_control(
&mut self,
state: &RuntimeState,
node: &StoryNode,
control: &TurnControl,
) -> Result<(), ProviderError> {
self.model
.validate_prospective_context_with_control(state, node, control)
}
}
fn select_bound_actor<'a, T>(
@@ -1071,7 +1304,22 @@ mod tests {
}
#[test]
fn multiple_distinct_checks_are_buffered_until_one_final_plan() {
fn regeneration_cannot_request_a_new_hidden_check() {
let model = ScriptedModel::new([tool(AdjudicationToolCall::RequestHiddenCheck(
hidden_check("replacement_check"),
))]);
let mut provider = AdjudicatingTurnPlanProvider::new(model, catalog());
assert!(matches!(
provider.plan_adjudicated_turn(&request(TurnIntent::Regenerate), &state()),
Err(AdjudicationRunError::Rejected(
AdjudicationError::RegenerationRequestedCheck
))
));
}
#[test]
fn a_second_distinct_check_is_rejected_for_one_player_action() {
let mut second = hidden_check("check_2");
second.actor_id = "nana".into();
second.skill = "Listen".into();
@@ -1085,19 +1333,12 @@ mod tests {
]);
let mut provider = AdjudicatingTurnPlanProvider::new(model, catalog());
let planned = provider
.plan_adjudicated_turn(&request(TurnIntent::SpeakOrAct), &state())
.expect("two checks then one plan");
let ids = planned
.delta
.ops
.iter()
.filter_map(|op| match op {
StateOp::RecordCheck { check } => Some(check.id.as_str()),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(ids, ["check_1", "check_2"]);
assert!(matches!(
provider.plan_adjudicated_turn(&request(TurnIntent::SpeakOrAct), &state()),
Err(AdjudicationRunError::Rejected(
AdjudicationError::TooManyHiddenChecks
))
));
}
#[test]
@@ -1371,7 +1612,7 @@ mod tests {
}
#[test]
fn loop_stops_at_the_configured_step_budget() {
fn loop_stops_before_a_second_hidden_check_even_with_a_larger_step_budget() {
let mut provider = AdjudicatingTurnPlanProvider::with_max_steps(
ScriptedModel::new([
tool(AdjudicationToolCall::RequestHiddenCheck(hidden_check(
@@ -1382,12 +1623,12 @@ mod tests {
))),
]),
catalog(),
2,
3,
);
assert!(matches!(
provider.plan_adjudicated_turn(&request(TurnIntent::SpeakOrAct), &state()),
Err(AdjudicationRunError::Rejected(
AdjudicationError::StepBudgetExceeded
AdjudicationError::TooManyHiddenChecks
))
));
}
@@ -1492,13 +1733,17 @@ mod tests {
#[test]
fn model_failures_are_forwarded_and_rejections_are_redacted_by_provider_trait() {
let mut unavailable = AdjudicatingTurnPlanProvider::new(
ScriptedModel::new([Err(ProviderError::Upstream { code: None })]),
ScriptedModel::new([Err(ProviderError::Upstream {
code: None,
status: None,
})]),
catalog(),
);
assert!(matches!(
unavailable.plan_adjudicated_turn(&request(TurnIntent::SpeakOrAct), &state()),
Err(AdjudicationRunError::Provider(ProviderError::Upstream {
code: None
code: None,
status: None
}))
));
+258
View File
@@ -0,0 +1,258 @@
use std::str::FromStr;
use nana_store::{
CanonicalSha256, ContextCheckpointError, ContextCheckpointInput, StoredContextCheckpoint,
};
use thiserror::Error;
use crate::context::{
MAX_NARRATIVE_CHECKPOINT_SUMMARY_BYTES, NarrativeCheckpoint, NarrativeCheckpointSourceHash,
NarrativeCheckpointSummary, StablePrefixHash, SummaryClassification,
};
/// A strict runtime-to-storage mapping failure.
///
/// Loading is deliberately different: a malformed disposable cache row is a
/// cache miss, not a story/storage failure. Saving a freshly produced runtime
/// checkpoint must instead explain why it could not cross the storage
/// boundary.
#[derive(Debug, Error, PartialEq, Eq)]
pub enum CheckpointStoreMappingError {
#[error("context checkpoint summary has the wrong classification")]
InvalidSummaryClassification,
#[error("context checkpoint summary is blank")]
BlankSummary,
#[error("context checkpoint summary exceeds the runtime byte limit")]
SummaryTooLarge,
#[error("context checkpoint summary could not be serialized")]
SummarySerialization,
#[error("context checkpoint stable-prefix hash is not canonical")]
InvalidStablePrefixHash,
#[error("context checkpoint source hash is not canonical")]
InvalidSourceHash,
#[error("context checkpoint could not be represented by the store: {0}")]
InvalidStoredCheckpoint(ContextCheckpointError),
}
/// Converts a structurally loaded storage record into a runtime checkpoint.
///
/// Checkpoints are only a disposable optimization. Any malformed JSON,
/// classification, summary, or typed hash is therefore treated as a cache
/// miss. In particular, this function never turns corrupt cache contents into
/// a [`nana_store::StoreError`].
#[must_use]
pub fn runtime_checkpoint_from_stored(
stored: &StoredContextCheckpoint,
) -> Option<NarrativeCheckpoint> {
runtime_checkpoint_from_parts(StoredCheckpointParts {
story_id: stored.story_id(),
at_node_id: stored.at_node_id(),
covered_through_node_id: stored.covered_through_node_id(),
retained_from_node_id: stored.retained_from_node_id(),
checkpoint_schema_version: stored.checkpoint_schema_version(),
prompt_schema_version: stored.prompt_schema_version(),
stable_prefix_hash: stored.stable_prefix_hash().as_str(),
summary_json: stored.summary_json(),
source_hash: stored.source_hash().as_str(),
})
}
/// Converts a trusted runtime checkpoint into the store's checked cache type.
///
/// Only [`NarrativeCheckpointSummary`] is serialized into `summary_json`; range
/// metadata and hashes stay in their dedicated store columns.
pub fn stored_checkpoint_from_runtime(
checkpoint: &NarrativeCheckpoint,
) -> Result<StoredContextCheckpoint, CheckpointStoreMappingError> {
validate_summary(&checkpoint.summary)?;
let summary_json = serde_json::to_string(&checkpoint.summary)
.map_err(|_| CheckpointStoreMappingError::SummarySerialization)?;
let stable_prefix_hash = CanonicalSha256::from_str(checkpoint.stable_prefix_hash.as_str())
.map_err(|_| CheckpointStoreMappingError::InvalidStablePrefixHash)?;
let source_hash = CanonicalSha256::from_str(checkpoint.source_hash.as_str())
.map_err(|_| CheckpointStoreMappingError::InvalidSourceHash)?;
StoredContextCheckpoint::new(ContextCheckpointInput {
story_id: checkpoint.story_id.clone(),
at_node_id: checkpoint.at_node_id.clone(),
covered_through_node_id: checkpoint.covered_through_node_id.clone(),
retained_from_node_id: checkpoint.retained_from_node_id.clone(),
checkpoint_schema_version: checkpoint.checkpoint_schema_version,
prompt_schema_version: checkpoint.prompt_schema_version,
stable_prefix_hash,
summary_json,
source_hash,
})
.map_err(CheckpointStoreMappingError::InvalidStoredCheckpoint)
}
#[derive(Debug, Clone, Copy)]
struct StoredCheckpointParts<'a> {
story_id: &'a str,
at_node_id: &'a str,
covered_through_node_id: &'a str,
retained_from_node_id: Option<&'a str>,
checkpoint_schema_version: u32,
prompt_schema_version: u32,
stable_prefix_hash: &'a str,
summary_json: &'a str,
source_hash: &'a str,
}
fn runtime_checkpoint_from_parts(parts: StoredCheckpointParts<'_>) -> Option<NarrativeCheckpoint> {
let summary = serde_json::from_str::<NarrativeCheckpointSummary>(parts.summary_json).ok()?;
validate_summary(&summary).ok()?;
let stable_prefix_hash =
StablePrefixHash::try_from(parts.stable_prefix_hash.to_owned()).ok()?;
let source_hash = NarrativeCheckpointSourceHash::try_from(parts.source_hash.to_owned()).ok()?;
Some(NarrativeCheckpoint {
story_id: parts.story_id.to_owned(),
at_node_id: parts.at_node_id.to_owned(),
covered_through_node_id: parts.covered_through_node_id.to_owned(),
retained_from_node_id: parts.retained_from_node_id.map(str::to_owned),
checkpoint_schema_version: parts.checkpoint_schema_version,
prompt_schema_version: parts.prompt_schema_version,
stable_prefix_hash,
summary,
source_hash,
})
}
fn validate_summary(
summary: &NarrativeCheckpointSummary,
) -> Result<(), CheckpointStoreMappingError> {
if summary.classification != SummaryClassification::NonAuthoritativeNarrative {
return Err(CheckpointStoreMappingError::InvalidSummaryClassification);
}
if summary.text.trim().is_empty() {
return Err(CheckpointStoreMappingError::BlankSummary);
}
if summary.text.len() > MAX_NARRATIVE_CHECKPOINT_SUMMARY_BYTES {
return Err(CheckpointStoreMappingError::SummaryTooLarge);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{
CheckpointStoreMappingError, StoredCheckpointParts, runtime_checkpoint_from_parts,
runtime_checkpoint_from_stored, stored_checkpoint_from_runtime,
};
use crate::context::{
MAX_NARRATIVE_CHECKPOINT_SUMMARY_BYTES, NARRATIVE_CHECKPOINT_SCHEMA_VERSION,
NarrativeCheckpoint, NarrativeCheckpointSourceHash, NarrativeCheckpointSummary,
SCENE_PROMPT_SCHEMA_VERSION, StablePrefixHash, SummaryClassification,
};
const HASH_A: &str = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const HASH_B: &str = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
fn runtime_checkpoint() -> NarrativeCheckpoint {
NarrativeCheckpoint {
story_id: "story_demo".to_owned(),
at_node_id: "node_004".to_owned(),
covered_through_node_id: "node_002".to_owned(),
retained_from_node_id: Some("node_003".to_owned()),
checkpoint_schema_version: NARRATIVE_CHECKPOINT_SCHEMA_VERSION,
prompt_schema_version: SCENE_PROMPT_SCHEMA_VERSION,
stable_prefix_hash: StablePrefixHash::try_from(HASH_A.to_owned())
.expect("valid stable-prefix hash"),
summary: NarrativeCheckpointSummary {
classification: SummaryClassification::NonAuthoritativeNarrative,
text: "娜娜记得玩家答应在天亮前回来。".to_owned(),
},
source_hash: NarrativeCheckpointSourceHash::try_from(HASH_B.to_owned())
.expect("valid source hash"),
}
}
fn valid_parts(summary_json: &str) -> StoredCheckpointParts<'_> {
StoredCheckpointParts {
story_id: "story_demo",
at_node_id: "node_004",
covered_through_node_id: "node_002",
retained_from_node_id: Some("node_003"),
checkpoint_schema_version: NARRATIVE_CHECKPOINT_SCHEMA_VERSION,
prompt_schema_version: SCENE_PROMPT_SCHEMA_VERSION,
stable_prefix_hash: HASH_A,
summary_json,
source_hash: HASH_B,
}
}
#[test]
fn round_trip_serializes_only_the_summary_payload() {
let runtime = runtime_checkpoint();
let stored =
stored_checkpoint_from_runtime(&runtime).expect("runtime checkpoint should map");
let expected_summary =
serde_json::to_string(&runtime.summary).expect("summary should serialize");
assert_eq!(stored.summary_json(), expected_summary);
assert!(!stored.summary_json().contains("story_demo"));
assert!(!stored.summary_json().contains("node_004"));
assert_eq!(runtime_checkpoint_from_stored(&stored), Some(runtime));
}
#[test]
fn malformed_summary_json_is_a_cache_miss() {
assert_eq!(
runtime_checkpoint_from_parts(valid_parts("{not-json")),
None
);
}
#[test]
fn wrong_summary_classification_is_a_cache_miss() {
let summary = r#"{"classification":"authoritative_fact","text":"should miss"}"#;
assert_eq!(runtime_checkpoint_from_parts(valid_parts(summary)), None);
}
#[test]
fn blank_or_oversized_summary_is_a_cache_miss() {
let blank = r#"{"classification":"non_authoritative_narrative","text":" \n\t "}"#;
assert_eq!(runtime_checkpoint_from_parts(valid_parts(blank)), None);
let oversized_text = "x".repeat(MAX_NARRATIVE_CHECKPOINT_SUMMARY_BYTES + 1);
let oversized = serde_json::to_string(&NarrativeCheckpointSummary {
classification: SummaryClassification::NonAuthoritativeNarrative,
text: oversized_text,
})
.expect("summary should serialize");
assert_eq!(runtime_checkpoint_from_parts(valid_parts(&oversized)), None);
}
#[test]
fn invalid_typed_hashes_are_cache_misses() {
let summary =
serde_json::to_string(&runtime_checkpoint().summary).expect("summary should serialize");
let mut invalid_stable = valid_parts(&summary);
invalid_stable.stable_prefix_hash =
"sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
assert_eq!(runtime_checkpoint_from_parts(invalid_stable), None);
let mut invalid_source = valid_parts(&summary);
invalid_source.source_hash = "sha256:not-a-hash";
assert_eq!(runtime_checkpoint_from_parts(invalid_source), None);
}
#[test]
fn runtime_to_store_rejects_bad_summary_before_persistence() {
let mut checkpoint = runtime_checkpoint();
checkpoint.summary.text = " \t ".to_owned();
assert_eq!(
stored_checkpoint_from_runtime(&checkpoint),
Err(CheckpointStoreMappingError::BlankSummary)
);
checkpoint.summary.text = "x".repeat(MAX_NARRATIVE_CHECKPOINT_SUMMARY_BYTES + 1);
assert_eq!(
stored_checkpoint_from_runtime(&checkpoint),
Err(CheckpointStoreMappingError::SummaryTooLarge)
);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+298
View File
@@ -0,0 +1,298 @@
use std::sync::{
Arc,
atomic::{AtomicU8, Ordering},
};
use std::time::{Duration, Instant};
const READY: u8 = 0;
const RUNNING: u8 = 1;
const CANCELLED: u8 = 2;
const TIMED_OUT: u8 = 3;
const COMMITTING: u8 = 4;
const FINISHED: u8 = 5;
/// Why a turn stopped before reaching its commit boundary.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TurnInterruption {
Cancelled,
TimedOut,
}
/// One-shot cancellation and deadline control for a single turn attempt.
///
/// Clones refer to the same attempt. Cancellation is accepted until the turn
/// atomically claims its commit boundary; after that boundary, the append is
/// allowed to finish and [`Self::cancel`] returns `false`. Deadlines use the
/// same boundary. This makes interruption and persistence race in one
/// well-defined place instead of checking a best-effort flag immediately
/// before an append.
#[derive(Debug, Clone)]
pub struct TurnControl {
state: Arc<AtomicU8>,
deadline: Option<Instant>,
}
impl TurnControl {
/// Create a turn control without an application deadline.
#[must_use]
pub fn new() -> Self {
Self {
state: Arc::new(AtomicU8::new(READY)),
deadline: None,
}
}
/// Create a turn control whose deadline starts now.
#[must_use]
pub fn with_timeout(timeout: Duration) -> Self {
Self {
state: Arc::new(AtomicU8::new(READY)),
deadline: Instant::now().checked_add(timeout),
}
}
/// Request cancellation.
///
/// Returns `true` when cancellation already owns or wins the boundary.
/// Returns `false` when timeout won, commit started, or the attempt ended.
#[must_use]
pub fn cancel(&self) -> bool {
loop {
let state = self.state.load(Ordering::Acquire);
match state {
READY | RUNNING => {
if self
.state
.compare_exchange(state, CANCELLED, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
{
return true;
}
}
CANCELLED => return true,
TIMED_OUT | COMMITTING | FINISHED => return false,
_ => unreachable!("invalid turn cancellation state"),
}
}
}
/// Whether cancellation owns the turn boundary.
#[must_use]
pub fn is_cancelled(&self) -> bool {
self.state.load(Ordering::Acquire) == CANCELLED
}
/// Whether the application deadline owns the turn boundary.
#[must_use]
pub fn is_timed_out(&self) -> bool {
let _ = self.interruption();
self.state.load(Ordering::Acquire) == TIMED_OUT
}
/// Return and, when due, atomically claim the current interruption.
#[must_use]
pub fn interruption(&self) -> Option<TurnInterruption> {
loop {
let state = self.state.load(Ordering::Acquire);
match state {
CANCELLED => return Some(TurnInterruption::Cancelled),
TIMED_OUT => return Some(TurnInterruption::TimedOut),
READY | RUNNING
if self
.deadline
.is_some_and(|deadline| Instant::now() >= deadline) =>
{
if self
.state
.compare_exchange(state, TIMED_OUT, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
{
return Some(TurnInterruption::TimedOut);
}
}
READY | RUNNING | COMMITTING | FINISHED => return None,
_ => unreachable!("invalid turn cancellation state"),
}
}
}
/// Whether the attempt can no longer be started or cancelled.
#[must_use]
pub fn is_terminal(&self) -> bool {
let _ = self.interruption();
matches!(
self.state.load(Ordering::Acquire),
CANCELLED | TIMED_OUT | FINISHED
)
}
pub(crate) fn begin_attempt(&self) -> Result<TurnAttempt<'_>, BeginAttemptError> {
if let Some(interruption) = self.interruption() {
return Err(interruption.into());
}
match self
.state
.compare_exchange(READY, RUNNING, Ordering::AcqRel, Ordering::Acquire)
{
Ok(_) => Ok(TurnAttempt { control: self }),
Err(CANCELLED) => Err(BeginAttemptError::Cancelled),
Err(TIMED_OUT) => Err(BeginAttemptError::TimedOut),
Err(_) => Err(BeginAttemptError::AlreadyUsed),
}
}
pub(crate) fn begin_commit(&self) -> Result<(), BeginCommitError> {
if let Some(interruption) = self.interruption() {
return Err(interruption.into());
}
match self
.state
.compare_exchange(RUNNING, COMMITTING, Ordering::AcqRel, Ordering::Acquire)
{
Ok(_) => Ok(()),
Err(CANCELLED) => Err(BeginCommitError::Cancelled),
Err(TIMED_OUT) => Err(BeginCommitError::TimedOut),
Err(_) => Err(BeginCommitError::InvalidState),
}
}
fn finish(&self) {
let state = self.state.load(Ordering::Acquire);
if matches!(state, RUNNING | COMMITTING) {
let _ =
self.state
.compare_exchange(state, FINISHED, Ordering::AcqRel, Ordering::Acquire);
}
}
}
impl Default for TurnControl {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum BeginAttemptError {
Cancelled,
TimedOut,
AlreadyUsed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum BeginCommitError {
Cancelled,
TimedOut,
InvalidState,
}
pub(crate) struct TurnAttempt<'a> {
control: &'a TurnControl,
}
impl Drop for TurnAttempt<'_> {
fn drop(&mut self) {
self.control.finish();
}
}
impl From<TurnInterruption> for BeginAttemptError {
fn from(value: TurnInterruption) -> Self {
match value {
TurnInterruption::Cancelled => Self::Cancelled,
TurnInterruption::TimedOut => Self::TimedOut,
}
}
}
impl From<TurnInterruption> for BeginCommitError {
fn from(value: TurnInterruption) -> Self {
match value {
TurnInterruption::Cancelled => Self::Cancelled,
TurnInterruption::TimedOut => Self::TimedOut,
}
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::{BeginAttemptError, BeginCommitError, TurnControl};
#[test]
fn cancellation_is_idempotent_before_commit() {
let cancellation = TurnControl::new();
let peer = cancellation.clone();
assert!(peer.cancel());
assert!(cancellation.cancel());
assert!(cancellation.is_cancelled());
assert!(cancellation.is_terminal());
assert!(matches!(
cancellation.begin_attempt(),
Err(BeginAttemptError::Cancelled)
));
}
#[test]
fn commit_claim_has_a_single_race_winner() {
let cancellation = TurnControl::new();
let attempt = cancellation.begin_attempt().expect("start attempt");
cancellation.begin_commit().expect("claim commit");
assert!(!cancellation.cancel());
assert!(!cancellation.is_cancelled());
assert!(!cancellation.is_terminal());
drop(attempt);
assert!(cancellation.is_terminal());
assert!(!cancellation.cancel());
}
#[test]
fn cancellation_blocks_commit_and_handles_are_one_shot() {
let cancellation = TurnControl::new();
let attempt = cancellation.begin_attempt().expect("start attempt");
assert!(cancellation.cancel());
assert_eq!(
cancellation.begin_commit(),
Err(BeginCommitError::Cancelled)
);
drop(attempt);
assert!(cancellation.is_terminal());
assert!(matches!(
cancellation.begin_attempt(),
Err(BeginAttemptError::Cancelled)
));
let finished = TurnControl::new();
drop(finished.begin_attempt().expect("start finished attempt"));
assert!(finished.is_terminal());
assert!(!finished.cancel());
assert!(matches!(
finished.begin_attempt(),
Err(BeginAttemptError::AlreadyUsed)
));
}
#[test]
fn deadline_uses_the_same_pre_commit_boundary() {
let timed_out = TurnControl::with_timeout(Duration::ZERO);
assert!(timed_out.is_timed_out());
assert!(timed_out.is_terminal());
assert!(!timed_out.cancel());
assert!(matches!(
timed_out.begin_attempt(),
Err(BeginAttemptError::TimedOut)
));
let committing = TurnControl::with_timeout(Duration::from_secs(30));
let attempt = committing.begin_attempt().expect("start attempt");
committing.begin_commit().expect("claim commit");
assert!(!committing.is_timed_out());
drop(attempt);
}
}
+310
View File
@@ -0,0 +1,310 @@
use std::collections::BTreeSet;
use thiserror::Error;
use crate::{
NarrativeCheckpointSourceEntry, NarrativeCheckpointSummary, ProviderError,
SummaryClassification, TurnControl,
};
pub const MAX_CONTEXT_SUMMARY_BYTES: usize = 64 * 1024;
pub const MAX_CONTEXT_SUMMARY_SOURCE_ENTRIES: usize = 512;
/// Player-safe, contiguous narrative material selected by the context planner.
///
/// It has no representation for runtime state, state deltas, hidden checks,
/// private inventory, credentials, or provider responses. Source identities
/// are retained for host validation and are not delegated to the model.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SummaryRequest {
prior_summary: Option<NarrativeCheckpointSummary>,
prior_covered_through_node_id: Option<String>,
entries: Vec<NarrativeCheckpointSourceEntry>,
max_summary_bytes: usize,
}
impl SummaryRequest {
pub fn new(
prior_summary: Option<NarrativeCheckpointSummary>,
prior_covered_through_node_id: Option<String>,
entries: Vec<NarrativeCheckpointSourceEntry>,
max_summary_bytes: usize,
) -> Result<Self, SummaryRequestError> {
if max_summary_bytes == 0 || max_summary_bytes > MAX_CONTEXT_SUMMARY_BYTES {
return Err(SummaryRequestError::InvalidSummaryLimit);
}
if entries.is_empty() {
return Err(SummaryRequestError::EmptyEntries);
}
if entries.len() > MAX_CONTEXT_SUMMARY_SOURCE_ENTRIES {
return Err(SummaryRequestError::TooManyEntries);
}
if prior_summary.is_some() != prior_covered_through_node_id.is_some() {
return Err(SummaryRequestError::IncompletePriorSummary);
}
if prior_summary.as_ref().is_some_and(|summary| {
summary.classification != SummaryClassification::NonAuthoritativeNarrative
}) {
return Err(SummaryRequestError::InvalidPriorClassification);
}
if prior_summary
.as_ref()
.is_some_and(|summary| summary.text.trim().is_empty())
{
return Err(SummaryRequestError::EmptyPriorSummary);
}
if prior_summary
.as_ref()
.is_some_and(|summary| summary.text.len() > MAX_CONTEXT_SUMMARY_BYTES)
{
return Err(SummaryRequestError::PriorSummaryTooLarge);
}
let mut node_ids = BTreeSet::new();
for node_id in entries.iter().map(|entry| &entry.node_id) {
if node_id.trim().is_empty() {
return Err(SummaryRequestError::EmptyNodeId);
}
if !node_ids.insert(node_id.clone()) {
return Err(SummaryRequestError::DuplicateNodeId(node_id.clone()));
}
}
if let Some(prior_covered) = prior_covered_through_node_id.as_deref() {
if prior_covered.trim().is_empty() {
return Err(SummaryRequestError::EmptyNodeId);
}
if entries[0].parent_id.as_deref() != Some(prior_covered) {
return Err(SummaryRequestError::NonContiguousEntries);
}
} else if entries[0].parent_id.is_some() {
return Err(SummaryRequestError::NonContiguousEntries);
}
if entries
.windows(2)
.any(|pair| pair[1].parent_id.as_deref() != Some(pair[0].node_id.as_str()))
{
return Err(SummaryRequestError::NonContiguousEntries);
}
Ok(Self {
prior_summary,
prior_covered_through_node_id,
entries,
max_summary_bytes,
})
}
#[must_use]
pub const fn prior_summary(&self) -> Option<&NarrativeCheckpointSummary> {
self.prior_summary.as_ref()
}
#[must_use]
pub fn prior_covered_through_node_id(&self) -> Option<&str> {
self.prior_covered_through_node_id.as_deref()
}
#[must_use]
pub fn entries(&self) -> &[NarrativeCheckpointSourceEntry] {
&self.entries
}
#[must_use]
pub const fn max_summary_bytes(&self) -> usize {
self.max_summary_bytes
}
#[must_use]
pub fn covered_through_node_id(&self) -> &str {
self.entries
.last()
.map_or("", |entry| entry.node_id.as_str())
}
}
#[derive(Debug, Error, Clone, PartialEq, Eq)]
pub enum SummaryRequestError {
#[error("a summary request requires at least one complete narrative entry")]
EmptyEntries,
#[error("a summary request contains too many narrative entries")]
TooManyEntries,
#[error("a summary request contains an empty source node id")]
EmptyNodeId,
#[error("a summary request repeats source node id `{0}`")]
DuplicateNodeId(String),
#[error("a summary request must provide both prior summary and covered node, or neither")]
IncompletePriorSummary,
#[error("a prior summary has an unsupported classification")]
InvalidPriorClassification,
#[error("summary source entries are not one contiguous path")]
NonContiguousEntries,
#[error("a prior narrative summary cannot be empty")]
EmptyPriorSummary,
#[error("a prior narrative summary exceeds the summary size limit")]
PriorSummaryTooLarge,
#[error("the requested narrative summary size limit is invalid")]
InvalidSummaryLimit,
}
/// The only model-authored value accepted from a summary call.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SummaryResult {
text: String,
}
impl SummaryResult {
pub fn new(text: String, max_summary_bytes: usize) -> Result<Self, SummaryResultError> {
if text.trim().is_empty() {
return Err(SummaryResultError::Empty);
}
if max_summary_bytes == 0
|| max_summary_bytes > MAX_CONTEXT_SUMMARY_BYTES
|| text.len() > max_summary_bytes
{
return Err(SummaryResultError::TooLarge);
}
Ok(Self { text })
}
#[must_use]
pub fn text(&self) -> &str {
&self.text
}
#[must_use]
pub fn into_checkpoint_summary(self) -> NarrativeCheckpointSummary {
NarrativeCheckpointSummary {
classification: SummaryClassification::NonAuthoritativeNarrative,
text: self.text,
}
}
}
#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
pub enum SummaryResultError {
#[error("a narrative summary cannot be empty")]
Empty,
#[error("a narrative summary exceeds the summary size limit")]
TooLarge,
}
/// Provider-neutral seam for one same-model narrative compaction call.
///
/// Implementations must use only [`SummaryRequest`], observe the existing turn
/// control, and return no persistence metadata. The runtime stamps hashes,
/// schema versions, and covered ranges after validating the result.
pub trait ContextSummaryModel {
fn summarize_with_control(
&mut self,
request: &SummaryRequest,
control: &TurnControl,
) -> Result<SummaryResult, ProviderError>;
}
#[cfg(test)]
mod tests {
use nana_domain::{BeatKind, PresentationBeat, VisualDirective};
use super::{
MAX_CONTEXT_SUMMARY_BYTES, SummaryRequest, SummaryRequestError, SummaryResult,
SummaryResultError,
};
use crate::{
BranchHistoryCharacter, BranchHistoryScene, NarrativeCheckpointSourceEntry,
NarrativeCheckpointSummary, SummaryClassification,
};
fn entry(node_id: &str, parent_id: Option<&str>) -> NarrativeCheckpointSourceEntry {
NarrativeCheckpointSourceEntry {
node_id: node_id.into(),
parent_id: parent_id.map(str::to_owned),
user_input: "Wait here.".into(),
scene: BranchHistoryScene {
id: "station".into(),
title: "Station".into(),
},
character: BranchHistoryCharacter {
id: "nana".into(),
name: "Nana".into(),
expression: Some("guarded".into()),
pose: None,
},
beats: vec![PresentationBeat {
id: format!("beat_{node_id}"),
kind: BeatKind::Dialogue,
speaker: Some("Nana".into()),
text: "I will wait.".into(),
visual: Some(VisualDirective {
character: Some("nana".into()),
expression: Some("guarded".into()),
pose: None,
scene: None,
}),
}],
}
}
#[test]
fn summary_request_owns_an_exact_non_repeating_source_sequence() {
let request = SummaryRequest::new(
Some(NarrativeCheckpointSummary {
classification: SummaryClassification::NonAuthoritativeNarrative,
text: "Earlier events.".into(),
}),
Some("node_root".into()),
vec![
entry("node_1", Some("node_root")),
entry("node_2", Some("node_1")),
],
1_024,
)
.expect("valid request");
assert_eq!(request.covered_through_node_id(), "node_2");
let result = SummaryResult::new("Self-contained recap.".into(), 1_024).expect("summary");
let summary = result.into_checkpoint_summary();
assert_eq!(
summary.classification,
SummaryClassification::NonAuthoritativeNarrative
);
}
#[test]
fn summary_request_rejects_empty_duplicate_and_oversized_values() {
assert_eq!(
SummaryRequest::new(None, None, Vec::new(), 1_024),
Err(SummaryRequestError::EmptyEntries)
);
assert!(matches!(
SummaryRequest::new(
Some(NarrativeCheckpointSummary {
classification: SummaryClassification::NonAuthoritativeNarrative,
text: "Earlier.".into(),
}),
Some("node_1".into()),
vec![entry("node_2", Some("node_other"))],
1_024,
),
Err(SummaryRequestError::NonContiguousEntries)
));
assert_eq!(
SummaryResult::new(String::new(), 1_024),
Err(SummaryResultError::Empty)
);
assert_eq!(
SummaryResult::new(
"x".repeat(MAX_CONTEXT_SUMMARY_BYTES + 1),
MAX_CONTEXT_SUMMARY_BYTES,
),
Err(SummaryResultError::TooLarge)
);
assert_eq!(
SummaryRequest::new(None, None, vec![entry("node_1", None)], 0),
Err(SummaryRequestError::InvalidSummaryLimit)
);
assert_eq!(
SummaryResult::new("12345".into(), 4),
Err(SummaryResultError::TooLarge)
);
}
}
File diff suppressed because it is too large Load Diff
+189
View File
@@ -0,0 +1,189 @@
# Current-Branch Context Checkpoints
Status: design frozen for the Wave 7 implementation slice.
## Purpose
Long stories must stay inside the selected LAPP model's real context window without silently
dropping the current input, triggered world-book entries, runtime facts, or recent committed
scenes. A checkpoint is a disposable narrative cache. It is never an authoritative source for
flags, relationships, promises, inventory, knowledge, checks, or branch structure.
## Prerequisites
Checkpoint persistence starts only after these contracts are represented in code:
1. The selected LAPP model's `context_window` and `max_output_tokens` reach the Runtime provider.
2. The stable prompt prefix has a deterministic resource fingerprint.
3. The narrative source path has a canonical ordered hash that covers committed public story
content, not only `RuntimeState`.
4. `Regenerate` excludes the replaced node from narrative history, constrains the replacement with
the target node's committed post-state, creates a sibling on a new branch, and reuses existing
hidden-check outcomes.
## Prompt budget
The provider computes one explicit budget before every model request:
```text
hard input budget =
model context window
- requested output tokens
- system prompt and tool schema
- message framing overhead
- reserved hidden-check continuation overhead
- safety margin
```
- Requested output is `min(model.max_output_tokens ?? 4096, 4096)`.
- A missing context window uses a conservative 16,384-token V1 fallback, but the resulting
`BudgetSource::Assumed` diagnostic must remain visible to the connection/settings layer. The
fallback must not be silent.
- Cross-provider V1 estimation treats every serialized UTF-8 byte as at most one token. This may
compact early but must not optimistically overfill a model window.
- Compression starts at 70% of the hard input budget and compacts back below a lower watermark.
- The complete dynamic tail is budgeted first. If it does not fit by itself, the request fails with
`DynamicTailTooLarge`.
- At least one recent committed node remains verbatim. A single oversized node fails with
`HistoryEntryTooLarge`; beat text is never truncated.
- Every initial and hidden-check continuation `ChatInput` must remain within budget.
## Prompt layout
Prompt schema v3 will replace the current v2 history array with one production checkpoint-aware
encoder:
```text
prompt_schema_version
stable_prefix
branch_context
checkpoint?
raw_tail[]
dynamic_tail
```
`stable_prefix` remains byte-identical while the bound resources do not change. `dynamic_tail`
always contains the current input and the complete safe state projection. A checkpoint replaces
only a continuous oldest prefix of `raw_tail`.
## Checkpoint record
SQLite schema v3 adds a cache table keyed by the immutable host node, not by branch:
```sql
CREATE TABLE context_checkpoints (
story_id TEXT NOT NULL,
at_node_id TEXT NOT NULL,
covered_through_node_id TEXT NOT NULL,
retained_from_node_id TEXT,
checkpoint_schema_version INTEGER NOT NULL,
prompt_schema_version INTEGER NOT NULL,
stable_prefix_hash TEXT NOT NULL,
summary_json TEXT NOT NULL,
source_hash TEXT NOT NULL,
PRIMARY KEY (story_id, at_node_id),
FOREIGN KEY (story_id, at_node_id)
REFERENCES nodes (story_id, node_id) ON DELETE CASCADE,
FOREIGN KEY (story_id, covered_through_node_id)
REFERENCES nodes (story_id, node_id) ON DELETE CASCADE,
FOREIGN KEY (story_id, retained_from_node_id)
REFERENCES nodes (story_id, node_id) ON DELETE CASCADE
);
```
- The nearest checkpoint is found only by walking the current node's `parent_id` chain.
- Shared ancestors naturally share a checkpoint; sibling-only descendants are unreachable.
- The summary is self-contained. A new checkpoint replaces the old summary instead of nesting a
chain of summaries in the prompt.
- Successful checkpoint generation may be persisted independently on the existing host node.
Failure of the later story-generation call may leave this harmless cache in place.
- Deleting every checkpoint must leave nodes, branch heads, materialized state, and `PlayerView`
byte-identical.
## Source and resource hashes
The model never supplies either hash.
`stable_prefix_hash` covers the exact canonical stable-prefix encoding, including character,
Persona, resource provenance, and prompt safety/version fields.
`source_hash` covers:
```text
checkpoint source schema version
prompt schema version
stable_prefix_hash
story id
ordered root-to-covered sequence of:
node id
parent id
player input
public scene
public character visual state
committed beats
```
Unselected suggestions, state delta, exact relationship values, checks, NPC private inventory,
untriggered world-book content, provider responses, credentials, and chain-of-thought never enter
the source manifest or compression request.
## Regenerate boundary
`Regenerate` is a full alternative rendering of one committed player choice:
- the replaced node is not included in the new prompt;
- model history ends at the replaced node's parent, while the target node's authoritative
post-state supplies only the already-committed facts and fixed qualitative check outcomes;
- the replacement creates a sibling on a new runtime-generated branch;
- hidden checks from the original action are supplied as qualitative fixed outcomes and are not
rolled again;
- a model cannot submit forged `RecordCheck` operations;
- old nodes, descendants, and checkpoints remain immutable and become naturally unreachable from
the new branch unless they are shared ancestors.
The sibling keeps the original player action identity, authoritative delta, terminal state, and
exact hidden-check records. Those immutable records intentionally retain their original source-node
provenance; the new branch is an alternative presentation of that same committed action, not a new
roll or a new state transition.
## Summary safety
The summary may retain public causal order, actual player choices, public NPC actions, revealed
facts with their certainty, unresolved conflicts, shared goals, and observable emotional residue.
It may not become a trigger or rules input.
The summary must not contain:
- exact dice mechanics or relationship numbers;
- private NPC knowledge, private inventory, or unrevealed item provenance;
- hidden flags, clocks, event conditions, or untriggered world-book entries;
- unselected suggestions, cancelled output, sibling-branch content, or inferred player thoughts;
- credentials, request headers, raw provider bodies, or chain-of-thought.
The current player inventory projection must not be fed to the summarizer until ownership and
acquisition fields have their own player-knowledge visibility boundary.
## Failure semantics
- No valid checkpoint and history above the high watermark returns `NeedsCompaction`; history is
never silently shortened.
- Invalid schema, source hash, stable-prefix hash, range, or summary size makes a checkpoint
unusable and rebuildable.
- Broken authoritative ancestry or state remains a hard store error and is not downgraded to a
cache miss.
- Cancelled, timed-out, rate-limited, or malformed compression produces no story node and moves no
branch head.
- A first load of a very long legacy branch uses bounded rolling chunks and keeps only the final
self-contained summary.
## Required gates
- Exact window boundary, one-token overflow, unknown-model-limit fallback, and smaller-model switch.
- Every normal and hidden-check request stays within the computed budget.
- 500-node first compaction and incremental compaction from an existing checkpoint.
- v2-to-v3 migration, rollback, restart recovery, cache deletion, and corruption rejection.
- Root, shared-ancestor, pre/post-checkpoint fork, regenerate, and sibling-canary isolation.
- Stable-prefix cache identity and one cache break only when a checkpoint rotates.
- Hidden canaries for checks, exact relationships, NPC facts/items, untriggered resources,
unselected suggestions, credentials, and provider bodies.
+68
View File
@@ -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 IDAPI 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。
+82
View File
@@ -0,0 +1,82 @@
# M2 第六波状态
日期:2026-07-28
## 基线
本轮从已推送的 `integration/v1@1f935a3` 开始,目标是把真实模型回合从“能调用”
收敛为“可停止、可超时、失败不产生半轮、玩家能安全恢复”,并准备一套可在真实
Windows 开发机重复执行的桌面冒烟流程。
## 已完成
### 可取消的原子回合
- 新增一次性 `TurnControl`,取消、90 秒截止时间与 SQLite 提交竞争同一个原子边界。
- Tauri 在调度后台回合前同步登记 `actionId`,消除“提交后立刻停止”找不到回合的窗口。
- 停止命令不等待故事操作锁;提交已经赢得边界时,原提交结果仍是唯一权威,界面会明确
告知完整回合已安全写入。
- LAPP 请求运行在隔离的原生请求线程;同步 Vault 解析卡住时,取消或超时仍能及时返回,
reducer 与 SQLite 不会收到半轮结果。
- 中断后的 LAPP executor 会先发布退休状态,Tauri 在向界面返回前重建 provider;同一
`TurnRequest``actionId` 可进行一次安全重试。
- 故事回合与连接测试共享原生调用单飞闸门。旧 Vault/HTTP 调用真正退出前不会再启动
新的原生模型调用,避免连续取消累积后台线程。
### 错误恢复与连接测试
- 后端只根据 LAPP 稳定错误码和 HTTP 状态分类配置错误、凭据不可用、限流、供应商拒绝、
网络不可用、超时与无效响应;供应商正文不会进入 `TurnFailure` 或玩家界面。
- 界面只对后端标记为可重试的失败显示一次“重试本轮”,并复用原请求和动作 ID。
- 新增“测试当前连接”。Tauri 命令不接受 provider/model 参数,只读取当前已应用设置,
因而不能被前端用于探测任意模型。
- 连接测试在独立线程执行,受 35 秒外层超时与单飞保护;返回值只包含 provider/model
标识、成功状态和稳定诊断码,不包含凭据或供应商响应正文。
- 连接成功只证明最小聊天请求可用;隐藏判定工具调用仍需在真实故事回合中验证。
### Windows 开发机冒烟
- 新增 `scripts/windows-smoke.ps1`,检查干净且已跟踪远端的 `integration/v1`、Rust
1.96 MSVC host、Node 24、精确 pnpm、锁文件一致性,以及相邻 `lapp-rs` 的来源、
固定提交与干净工作树。
- 脚本依次执行全量 `pnpm verify``pnpm tauri build --no-bundle`;只有全部通过后
才可选启动桌面窗口。
- `-Launch` 强制使用 Windows 临时目录下的显式隔离存档路径。应用会 canonicalize
路径并拒绝相对路径、临时目录根和链接逃逸;脚本不会删除该目录,便于重启恢复验证。
- `-Demo` 先验证窗口、SQLite 恢复、终局与双线路隔离;默认 LAPP 模式再验证 profile、
Vault、最小连接和一轮真实工具调用。
完整操作见 [`docs/windows-developer-smoke.md`](../windows-developer-smoke.md)。
## 验证结果
- Rust 1.96 `cargo fmt --check` 通过。
- 119 项核心 Rust 测试通过:Domain 5、Engine 21、Runtime 61、Store 32。
- Tauri 后端 **18** 项测试通过,包括取消前置竞态、provider 退休后同动作重试、
连接单飞、隔离存档路径和既有文件数据库纵切。
- 核心与 Tauri Clippy `-D warnings` 通过,Tauri 全 target 类型检查通过。
- 25 份契约 Schema、TypeScript DTO 与 Rust 源哈希一致,Schema 清单本身也受检查。
- TypeScript 严格检查、29 项 Web 测试和 Vite 生产构建通过。
Tauri 检查和后端测试在当前 Linux Work 环境继续使用空的 GUI 链接占位库;它们证明
Rust/Tauri 代码、宏和测试能够编译执行,不等同于真实 WebView2 窗口或 Windows 二进制。
PowerShell 脚本也尚未在本环境解析执行。
## 明确限制
- 固定版 `lapp-rs@5ba3c659…` 的流事件只有文本增量、结束与用量,不包含工具调用片段,
且流式 API 不返回最终 `ChatResponse`。因此本轮实现的是可取消的非流式可信回合;
在 LAPP 增加可验证的工具调用流与最终响应前,不能安全地把隐藏检定/TurnPlan 改成
真正流式提交。
- Rust 无法强杀正在执行的 Windows 原生 Vault 调用。单飞闸门会阻止新的调用,应用与
SQLite 仍能及时结束本轮;若原生调用永久不返回,后续模型调用需等待或重启应用。
- 某些供应商可能已在取消生效前接收请求,因此“未写入故事”不等于绝对不会产生一次
供应商计费。消除这一点需要 `lapp-rs` 提供可取消、分阶段的凭据解析与请求 API。
## 尚待真实 PC 关闭
1. 在 Windows 上实际执行脚本,确认完整验证与 `tauri build --no-bundle`
2. 使用隔离存档启动 Demo 窗口,完成两次重启、终局与双线路隔离。
3. 使用真实 LAPP profile/Vault 运行最小连接测试及至少一轮隐藏判定工具调用。
4. 记录 Windows 版本、架构、被测提交和首个失败点;不得回传凭据、profile、数据库或
完整供应商响应。
+59
View File
@@ -0,0 +1,59 @@
# M2 第七波本地开工状态
日期:2026-07-28
## 本地基线
- 从私有 Gitea 安全恢复 `integration/v1@23672e857b`,并通过 `git fsck` 检查对象完整性。
- 下载时使用的临时仓库只读 Token 已在 Gitea 撤销;临时配置、bundle 与内存中的凭据均
已清理。
- `origin` 已恢复为 Gitea SSH 地址,分支仍跟踪 `origin/integration/v1`。当前执行环境的
网络代理会关闭 SSH 2222 端口,因此本轮只提交到本地,不把“已配置公钥”误报为“已成功
推送”。
- 相邻 `lapp-rs` 已固定在 `5ba3c659e1536ec4bee16340faca603940a5cb17`,未修改其源码。
- 本地基线提交为 `37b3397 chore: establish wave 7 local baseline`
## 已完成
### Windows 可重复基线
- 新增 `.gitattributes`,固定文本 LF 与图片、音频二进制属性。
- Node 与 Rust 契约源码哈希在计算前统一 LF,避免 Windows CRLF checkout 产生假漂移。
- 增加 LF / CRLF 哈希等价测试。
- 安装并锁定 Node 24、pnpm 10.29.2、Rust 1.96.0、rustfmt 与 clippy。
- 使用锁文件安装 JavaScript 依赖。
### 当前分支连续上下文
- Store 新增按 `(story_id, node_id)``parent_id` 读取根到当前节点祖先链的接口。
- Memory 与 SQLite 后端均检测缺失父节点和父链循环;读取不依赖 `branch_id`,允许分叉
复用共享祖先,同时不会遍历兄弟分支。
- Runtime 在生成本轮前自动投影当前祖先链,并把已提交的玩家输入、演出节拍、场景和角色
视觉状态传给 Provider;未选择的行动建议不会被误当成历史事实。
- LAPP 普通回合与隐藏判定回合共用同一套缓存友好上下文协议:
`stable_prefix → branch_history → dynamic_tail`
- 动态状态只包含可用于角色扮演的安全投影,包括关系阶段与玩家可见持有物的归属、取得
方式;精确关系数值、隐藏骰点、NPC 私物、未触发世界书和状态 delta 仍不能进入提示。
- Tauri 的线路恢复复用 Store 祖先链接口,不再保留旧的 200 节点读取上限。
## 验证
- `node scripts/verify-contracts.mjs`25 份契约无漂移。
- `pnpm verify:web`TypeScript 检查、5 个测试文件 / 29 项测试及生产构建通过。
- `cargo metadata --no-deps`:通过,相邻 `lapp-rs` 路径依赖可解析。
- `cargo fmt --all -- --check`:通过。
- `git diff --check`:通过。
完整 Rust 测试、Clippy、Tauri 后端测试和 Windows 桌面编译尚未关闭,原因不是项目依赖
解析,而是本机缺少 Microsoft C++ Build Tools 与 Windows SDK`rustc` 当前找不到
`link.exe`
## 下一步
1. 安装 Microsoft C++ Build Tools 与 Windows SDK 后立即运行 `pnpm verify:rust`
`pnpm tauri build --no-bundle`
2. 为长分支实现非权威上下文检查点、来源哈希和 SQLite v3 迁移;历史不得静默截断。
3. 在连续上下文门禁关闭后,实现“风险预检 → 玩家确认 → 隐藏判定失败 → 推骰 / 重新
生成”纵向切片。
4. 最后在隔离存档上启动 Demo 窗口,验证重启恢复、终局、回溯和双线路隔离,再运行真实
LAPP 最小连接与隐藏判定工具调用。
@@ -0,0 +1,113 @@
# M2 第七波:当前分支上下文检查点
日期:2026-07-29
## 本轮结果
本轮完成了长分支上下文主链:运行时按实际 LAPP 模型预算编排 prompt schema v3
超出预算时使用当前选中的同一个模型生成玩家安全的滚动摘要;摘要与来源指纹组成
非权威检查点,并在最终回合成功时与新故事节点原子提交。历史不会为了适配窗口而被
静默截断。
这套机制适用于普通继续与重生成。它不引入导演 Agent、独立摘要模型、向量记忆库或
跨分支记忆。
## SQLite schema v3
- 新增 `context_checkpoints` 缓存表,以不可变的故事节点作为 host,保存覆盖范围、
prompt / checkpoint schema 版本、稳定前缀哈希、摘要和叙事来源哈希。
- schema v1 与 v2 数据库都能迁移至 v3;迁移不会修改故事节点、线路头、物化状态或
活动线路。
- Memory 与 SQLite Store 使用同一检查点语义:只沿目标节点的 `parent_id` 祖先链
查找最近的可达检查点,兄弟分支和其他故事不可见。
- 缓存行的类型、版本、大小、规范 SHA-256、host 和覆盖范围都会被校验。损坏或未知
版本的行被当作可重建的 cache miss,并继续查找更早的有效祖先;权威祖先链损坏仍是
硬错误。
- 删除全部检查点不会改变节点、线路、状态哈希或 `PlayerView`
- 内存和 SQLite 后端均覆盖 500 节点祖先链读取与检查点查找,长线路不再受界面层
200 节点展示限制。
## 缓存友好的 prompt schema v3
模型输入固定分成三段:
```text
stable_prefix
branch_context
checkpoint?
raw_tail[]
dynamic_tail
```
- `stable_prefix` 只包含角色、Persona、剧情模块、绑定世界书的版本来源及稳定安全
规则;资源未变化时保持字节一致,便于供应商前缀缓存命中。
- `branch_context` 使用一个已验证的祖先检查点加最近原文;至少保留最新的已提交节点,
节拍文本不会被截断。
- `dynamic_tail` 保留本轮输入、实际触发的世界书条目、剧情事件、可见状态与重生成的
固定定性结果,不允许被摘要替代。
- 输入预算来自当前 LAPP 模型的 `context_window``max_output_tokens`;缺失元数据
使用显式标记的保守回退。输出、system、工具 schema、消息 framing、一次隐藏检定
续调用与安全余量都会先从窗口中扣除。
- 完整请求在发给 provider 前再次预检。动态尾部、固定部分或单个历史节点本身无法
容纳时会失败关闭,而不是删字、截断或越过模型窗口。
- V1 每次玩家行动最多进行一次隐藏检定;这使为工具调用及其续写保留的预算存在明确
上界。初始请求会明确提示该限制,检定结果返回后以及重生成回合只向模型暴露
`submit_turn_plan`,不会再提供第二次检定工具。
- 可继续的候选节点会在 `begin_commit` 前,以提交后的状态、同一资源编译器和同一
模型预算验证一次最小“下一轮继续”上下文。即使回复满足静态字段上限,只要它作为
强制保留的最新原文会让下一轮无法编排,本轮就整体失败且不移动线路头。
## 同模型滚动摘要
- 压缩复用当前 TurnPlan provider 的同一 LAPP executor、模型、调用闸门和
`TurnControl`,不会暗中切换模型或绕过供应商配置。
- 每次摘要请求都是新的单用途会话,只开放一个严格的 `submit_context_summary`
工具;正常文本、额外工具调用、未知字段、空白或超大摘要都会被拒绝。
- 首次加载超长分支时按有界块滚动摘要;后续压缩把上一个自包含摘要与新增的连续节点
一起压成新的自包含摘要,不在 prompt 中嵌套摘要链。
- 摘要来源仅包含当前根到覆盖节点路径上的实际玩家输入、公开场景、角色视觉状态和
已提交演出节拍。
- 精确关系值、骰点和难度、状态 delta、NPC 私物和私密知识、未触发世界书、未选择
建议、兄弟分支、凭据、原始供应商响应与推理过程都不进入摘要请求。
- 摘要分类由运行时固定为 `non_authoritative_narrative`;模型只能提供文本,摘要不能
作为规则触发、状态恢复或判定事实来源。
## 原子提交与中断边界
- 压缩发生在模型生成正式 TurnPlan 之前。生成的检查点先停留在内存中,不会立即写库。
- 普通回合使用 `append_node_with_checkpoint`,重生成使用
`append_regenerated_node_with_checkpoint`;节点、物化状态、线路头与待提交检查点
在同一 Store 原子操作中落库。
- 检查点必须 host 在提交前的历史头上,并与该祖先链的覆盖范围一致。陈旧线路、
错误故事、无效范围或状态不一致都会使整次写入失败,节点与检查点均不落库。
- 取消或超时可发生在摘要、正式模型调用或最终提交边界之前。只要中断先于
`begin_commit`,待提交检查点与故事节点都会被丢弃,线路头保持不变。
- 一旦 `begin_commit` 成功,取消不再与写入竞争;调用方接收该原子提交的确定结果。
- 重生成继续复用原玩家行动、权威 delta、隐藏检定与终局状态;检查点只覆盖待替换
节点的父路径,旧节点及旧分支保持不可变。
## 验证
- Rust workspace**215** 项测试通过。
- Contracts1。
- Domain5。
- Engine21。
- Runtime106。
- Store65。
- Tauri 后端:17。
- `cargo clippy --workspace --all-targets -- -D warnings`:通过。
- Web5 个测试文件 / **29** 项测试、TypeScript 严格检查与生产构建通过。
- 契约:**25** 份 JSON Schema 与 TypeScript DTO 无漂移。
测试覆盖包括预算边界、prompt schema v3 编排、同模型严格摘要工具、滚动与增量摘要、
检查点哈希和失效、兄弟分支隔离、schema v1 / v2 迁移、缓存删除、500 节点祖先链、
节点与检查点原子提交、压缩后取消不移动线路头、隐藏检定工具单次暴露,以及合法但
会阻断后续上下文的超长回复在提交前被拒绝。
## 尚未关闭
- 尚未使用用户实际 LAPP profile、Vault 凭据和在线模型完成端到端调用;本报告不宣称
在线 LAPP 冒烟通过。
- 本轮没有完成窗口截图或人工视觉检查;本报告不宣称视觉验收通过。
- Gitea SSH 推送在当前执行环境中仍未验证;本轮成果当前只能视为本地工作树状态,
不能宣称已经同步到远端。
@@ -0,0 +1,72 @@
# M2 第七波 Windows 基线与上下文前置状态
日期:2026-07-29
## 本机 Windows 基线
- 已安装 Visual Studio 2022 Build Tools 17.14、MSVC x64 工具链与 Windows 11 SDK
10.0.26100`cl.exe``link.exe``rc.exe` 均可用。
- `scripts/windows-smoke.ps1` 现在会通过 `vswhere` 选择具备 C++ 工具链的 Visual
Studio,并自动载入 x64 开发环境。普通 PowerShell 不再需要先手工运行
`VsDevCmd.bat`
- 使用现有应用图标生成 Tauri 的 Windows ICO、macOS ICNS、Linux PNG 及后续移动端
图标集合,关闭了 Windows 资源编译缺少 `icon.ico` 的阻塞。
- release 模式的 Windows 桌面程序已构建:
`target/release/nana-story-app.exe`
## Task 5 前置契约
### 模型预算
- `OpenLappChatExecutor` 从实际选中的 LAPP 模型读取 `context_window`
`max_output_tokens`
- 缺少或无效模型元数据时显式标为 `Assumed`,使用 16,384 / 4,096 的保守 V1 回退;
被应用上限或窗口边界收紧时标为 `Capped` 并保留原始来源。
- 预算值只能通过校验构造,始终保证输出预算大于零且小于上下文窗口;上层可读取来源、
fallback 与 cap 诊断。
- 普通回合、隐藏判定初始调用和所有工具续调用使用同一个模型预算;单回合输出上限为
4,096 tokens。
### 检查点来源
- 增加稳定前缀与叙事来源的强类型 SHA-256 指纹。
- 稳定前缀显式覆盖角色、Persona、剧情模块与绑定世界书的版本来源;持久化读取只接受
规范化的 `sha256:` 小写十六进制值。
- 叙事来源只接受连续的根到目标节点路径,覆盖节点 ID、父节点、玩家实际输入、公开场景、
角色视觉状态和完整演出节拍。
- 状态 delta、精确关系、隐藏判定、NPC 私物、未选择建议和兄弟分支均不能进入来源投影。
- [上下文检查点设计](../context-checkpoint-design.md) 已冻结预算、提示布局、SQLite v3、
失效规则和安全边界。
### 重新生成
- `Regenerate` 的叙事历史截止到待替换节点的父节点;模型使用目标节点的安全 post-state
与原判定定性结果生成兄弟节点,不再接收旧演出或骰点细节。
- 原玩家行动标识、authoritative delta、隐藏判定、终局状态与物化状态原样复用;模型
只能替换演出,也不能让成功 / 失败结果反转。
- 终局节点允许重生成演出,但普通继续行动仍会被终局保护拦截。
- 新版本使用独立分支并切为活动线路,旧节点、旧线路和后代保持不可变。
- Memory 与 SQLite 均在单次原子操作中校验来源线路、节点、delta 和物化状态。
## 验证
- Rust workspace171 项测试通过。
- Runtime83 项。
- Store44 项。
- Tauri 后端:17 项。
- Domain / Engine / Contracts27 项。
- `cargo clippy --workspace --all-targets -- -D warnings`:通过。
- Rust 契约生成器 `--check`:通过。
- Web:5 个测试文件 / 29 项测试、TypeScript 检查与生产构建通过。
- 契约:25 份 Schema 与 TypeScript DTO 无漂移。
- `pnpm tauri build --no-bundle`:通过,生成 Windows release 可执行文件。
- 隔离 Demo 已启动,窗口枚举标题为《听娜娜讲故事》,进程保持响应并创建独立 SQLite
存档。为重建 release 文件现已关闭该进程;隔离存档仍保留。自动截图组件不支持该
Tauri 窗口,因此本报告不宣称视觉验收完成。
## 尚未关闭
- 当前执行环境仍会关闭 Gitea SSH 2222 连接,本地提交暂时不能推送。
- SQLite v3 检查点表、同模型摘要工具、500 节点滚动压缩与实际预算编排仍属于 Task 5
主体。
- Demo 重启恢复、终局 / 双线路人工操作和真实 LAPP 在线调用仍待后续冒烟。
+137
View File
@@ -0,0 +1,137 @@
# Windows 开发机冒烟
这份流程用于在真实 Windows 桌面环境关闭三道门:仓库全量验证、Tauri 桌面编译,以及
窗口/SQLite/LAPP 的人工冒烟。`scripts/windows-smoke.ps1` 不负责克隆、Git 认证、
工具链安装或 LAPP 凭据配置;它没有 API Key、Token 或密码参数,也不会读取、打印或
保存这些值。
## 1. 在脚本之外准备仓库
先用 SSH Key 或 Git Credential Manager 完成 Gitea 认证,再在单独的终端克隆仓库。
不要把 Token 或密码写进 Git URL、命令参数、脚本、仓库文件或回传日志。
目录必须保持相邻:
```text
workspace\
├─ lapp-rs\
└─ nana-story\
```
示例命令中的地址不含凭据:
```powershell
git clone --branch integration/v1 https://git.klarkxy.xyz/klarkxy/nana-story.git
git clone https://github.com/openlapp/lapp-rs.git
git -C .\lapp-rs checkout 5ba3c659e1536ec4bee16340faca603940a5cb17
```
还需预先安装 Windows 的 Tauri 2 原生开发依赖、Microsoft C++ Build Tools、WebView2、
Git、rustup、Rust 1.96.0 MSVC host(含 `rustfmt``clippy`)、Node.js 24+,以及
`package.json` 指定版本的 pnpm。脚本会通过 `vswhere` 自动载入 x64 C++ 开发环境并
检查 `cl.exe``link.exe``rc.exe`,但不会自动安装或升级工具链。下文使用 PowerShell
7 的 `pwsh`;脚本也只使用 Windows PowerShell 5.1 支持的语法,可将 `pwsh` 换成
`powershell.exe`
## 2. 跑机械门禁
第一次安装 JavaScript 依赖时,必须显式给出开关:
```powershell
pwsh -NoProfile -File .\scripts\windows-smoke.ps1 -InstallDependencies
```
后续运行不会修改依赖:
```powershell
pwsh -NoProfile -File .\scripts\windows-smoke.ps1
```
脚本依次执行:
1. 检查 Git、Rust 1.96.0 MSVC host、`rustfmt``clippy`、Node.js 24+ 和精确 pnpm 版本;
2. 从 `origin` 获取最新 `integration/v1`,检查本地分支干净、正确跟踪该远端且与
最新远端提交一致,并打印被测提交;Git 认证或网络失败会直接停止;
3. 检查相邻 `lapp-rs` 的 origin、提交和工作树都与 `lapp-rs.lock` 固定来源一致;
4. 检查现有 `node_modules` 的 pnpm 版本及内置锁文件与仓库 `pnpm-lock.yaml` 一致;
5. 执行非交互式 `pnpm verify`
6. 执行 `pnpm tauri build --no-bundle`,验证真实 Windows 桌面目标。
任一步失败都会停止,且不会继续启动应用。
## 3. 启动桌面窗口
先用不需要 LAPP 的确定性模式验证窗口和存档:
```powershell
$smokeData = Join-Path ([System.IO.Path]::GetTempPath()) "nana-story-wave6-demo"
pwsh -NoProfile -File .\scripts\windows-smoke.ps1 -Launch -Demo -SmokeDataPath $smokeData
```
`-Launch` 只会在所有机械门禁通过后执行 `pnpm tauri dev`。关闭窗口会结束命令。
`-Demo` 只影响这次进程,不会写入系统配置。`-SmokeDataPath` 必须指向 Windows 临时
目录的子目录;应用只在这里创建冒烟数据库,不会碰正常的开发存档。复用同一路径用于
重启恢复测试;想从全新数据库开始时换一个新的目录名,脚本本身不会删除任何目录。
脚本在创建前后都会拒绝路径链中的 junction、符号链接和其他 reparse point,应用还会
再次 canonicalize 并验证最终路径。
在线冒烟前,应在系统 LAPP 中另行准备默认 profile
- 至少一个已启用模型同时声明 `chat``tool-call` 能力;
- 供应商凭据能由系统 Vault 解析;
- 凭据不进入 `nana-story` 设置、仓库、命令行或回传材料。
准备好后,不带 `-Demo` 启动:
```powershell
$lappSmokeData = Join-Path ([System.IO.Path]::GetTempPath()) "nana-story-wave6-lapp"
pwsh -NoProfile -File .\scripts\windows-smoke.ps1 -Launch -SmokeDataPath $lappSmokeData
```
打开设置页,确认显示“LAPP 已就绪”并能选择预期模型。“测试当前连接”只验证最小聊天
请求;通过后仍需提交一轮包含隐藏判定的行动,才能验证真实工具调用。只记录成功/失败
和界面错误码;不要复制 profile、Vault 内容、请求头或供应商原始响应。
在线模式还需验证一次取消恢复:
1. 提交一条会触发模型生成、但尚未改变故事节点的行动;
2. 在生成完成前点击“停止生成”,确认界面显示本轮未写入、节点与存档状态不变;
3. 点击唯一一次“重试本轮”,确认同一行动能完整完成;
4. 若停止请求输给了提交边界,界面应明确显示完整回合已安全写入,不能同时显示“未写入”;
5. 再提交一条会触发隐藏判定的行动,确认工具调用结束后只产生一个新故事节点。
若 Windows Vault 自身永久卡住,应用会结束当前回合并阻止新的原生模型调用;此时关闭
应用、修复 Vault 后再启动。Rust 无法强杀已进入系统原生凭据调用的线程。
## 4. SQLite 与重启冒烟
确定性模式中按界面建议完成以下检查:
1. 接受“天亮前回来”的许诺,继续调查并取得半张旧车票;
2. 关闭窗口,再次使用同一个 `$smokeData` 执行
`-Launch -Demo -SmokeDataPath $smokeData`
3. 确认场景、时钟、许诺、物品和当前线路恢复到关闭前状态;
4. 完成进入隧道和天亮前归来的终局;
5. 从历史节点创建新线路,重命名并切换到新线路;
6. 再次关闭和启动,确认活动线路仍是新线路;
7. 切回原线路,确认终局、许诺、物品和节点没有被新线路污染。
冒烟数据库文件名是 `nana-story.sqlite3`,位于本轮显式传入的 `$smokeData`,正常的
`$env:APPDATA\dev.nanastory.app\` 不会被读写。不要编辑或上传数据库;本轮只确认文件
存在、重启可恢复且线路互相隔离。
## 5. 回传结果
请回传以下信息:
- Windows 版本与 CPU 架构;
- 脚本的全部 `[ok]`/`[run]` 结果及最终退出码;
- `pnpm verify``pnpm tauri build --no-bundle` 是通过还是首个失败阶段;
- 窗口是否启动,关闭后命令是否正常退出;
- Demo 的两次重启恢复、终局与双线路隔离是否通过;
- 在线 LAPP 的 profile 就绪状态、所选 provider/model 标识,以及一轮工具调用是否成功;
- 在线回合的停止、节点不变、一次重试和取消/提交竞态提示是否符合上述语义;
- 若失败,附首个错误的文本或截图和复现步骤。
发送前删除用户名、绝对路径和其他个人信息。不要发送 API Key、Token、密码、Git
凭据、LAPP profile/Vault 文件、数据库文件、完整供应商响应或完整环境变量列表。
+14 -5
View File
@@ -9,7 +9,8 @@ const schemaDirectory = resolve(root, "contracts/schema");
const source = await readFile(sourcePath);
const expectedHash = (await readFile(hashPath, "utf8")).trim();
const actualHash = createHash("sha256").update(source).digest("hex");
const normalizedSource = source.toString("utf8").replaceAll("\r\n", "\n");
const actualHash = createHash("sha256").update(normalizedSource).digest("hex");
if (actualHash !== expectedHash) {
throw new Error(
@@ -23,28 +24,36 @@ const schemaFiles = (await readdir(schemaDirectory))
const required = [
"app-info.schema.json",
"branch-list.schema.json",
"character-card.schema.json",
"demo-pack-summary.schema.json",
"fork-branch-request.schema.json",
"fork-branch-result.schema.json",
"item-spec.schema.json",
"lapp-connection-test-result.schema.json",
"lapp-settings.schema.json",
"persona.schema.json",
"player-view.schema.json",
"plot-module.schema.json",
"presentation-snapshot.schema.json",
"rename-branch-request.schema.json",
"resource-bundle.schema.json",
"resource-header.schema.json",
"runtime-state.schema.json",
"story-node.schema.json",
"switch-branch-request.schema.json",
"switch-branch-result.schema.json",
"turn-failure.schema.json",
"turn-request.schema.json",
"turn-result.schema.json",
"update-lapp-settings-request.schema.json",
"world-book.schema.json"
];
for (const name of required) {
if (!schemaFiles.includes(name)) {
throw new Error(`Missing generated schema: contracts/schema/${name}`);
}
if (JSON.stringify(schemaFiles) !== JSON.stringify(required)) {
throw new Error(
`Generated schema manifest mismatch.\nExpected: ${required.join(", ")}\nActual: ${schemaFiles.join(", ")}`
);
}
for (const name of schemaFiles) {
+568
View File
@@ -0,0 +1,568 @@
<#
.SYNOPSIS
Runs the Windows developer smoke gates for nana-story.
.DESCRIPTION
Checks the pinned developer toolchain and adjacent lapp-rs checkout, optionally
installs JavaScript dependencies, then runs the repository verification and a
non-bundled Tauri production build. With -Launch, it starts `pnpm tauri dev`
after all gates pass.
This script deliberately has no credential parameters. Git authentication and
LAPP profile/Vault setup must be completed outside this process.
.PARAMETER InstallDependencies
Runs `pnpm install --frozen-lockfile`. Without this switch, the script never
installs dependencies and requires an existing pnpm node_modules layout.
.PARAMETER Launch
Starts the desktop application with `pnpm tauri dev` after verification.
.PARAMETER Demo
Uses the deterministic demo provider for the optional desktop launch. This
switch is only valid together with -Launch.
.PARAMETER SmokeDataPath
Absolute child path below the Windows temporary directory used as isolated app
data for -Launch. Reuse the same path to test restart recovery; choose a new
path to start from a fresh database. The script never deletes this directory.
#>
[CmdletBinding(PositionalBinding = $false)]
param(
[switch]$InstallDependencies,
[switch]$Launch,
[switch]$Demo,
[string]$SmokeDataPath
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
function Find-NativeCommand {
param(
[Parameter(Mandatory = $true)]
[string[]]$Names,
[Parameter(Mandatory = $true)]
[string]$DisplayName
)
foreach ($name in $Names) {
$command = Get-Command -Name $name -CommandType Application -ErrorAction SilentlyContinue |
Select-Object -First 1
if ($null -ne $command) {
return $command.Source
}
}
throw "$DisplayName was not found on PATH. Install it outside this script and retry."
}
function Invoke-NativeCapture {
param(
[Parameter(Mandatory = $true)]
[string]$FilePath,
[string[]]$ArgumentList = @(),
[Parameter(Mandatory = $true)]
[string]$Label
)
$output = @(& $FilePath @ArgumentList 2>&1)
$exitCode = $LASTEXITCODE
$text = ($output | ForEach-Object { $_.ToString() }) -join [Environment]::NewLine
if ($exitCode -ne 0) {
throw "$Label failed with exit code $exitCode. $text"
}
return $text.Trim()
}
function Invoke-NativeChecked {
param(
[Parameter(Mandatory = $true)]
[string]$FilePath,
[string[]]$ArgumentList = @(),
[Parameter(Mandatory = $true)]
[string]$Label
)
Write-Host ("[run] {0}" -f $Label)
& $FilePath @ArgumentList
$exitCode = $LASTEXITCODE
if ($exitCode -ne 0) {
throw "$Label failed with exit code $exitCode."
}
}
function Normalize-GitRemote {
param(
[Parameter(Mandatory = $true)]
[string]$Url
)
$normalized = $Url.Trim()
if ($normalized -match "^git@([^:]+):(.+)$") {
$normalized = "https://$($Matches[1])/$($Matches[2])"
}
elseif ($normalized -match "^ssh://git@([^/]+)/(.+)$") {
$normalized = "https://$($Matches[1])/$($Matches[2])"
}
$normalized = $normalized.TrimEnd("/")
if ($normalized.EndsWith(".git", [System.StringComparison]::OrdinalIgnoreCase)) {
$normalized = $normalized.Substring(0, $normalized.Length - 4)
}
return $normalized.ToLowerInvariant()
}
function Assert-TemporaryChildWithoutReparsePoint {
param(
[Parameter(Mandatory = $true)]
[string]$TemporaryRoot,
[Parameter(Mandatory = $true)]
[string]$TargetPath
)
$root = [System.IO.Path]::GetFullPath($TemporaryRoot).TrimEnd(
[System.IO.Path]::DirectorySeparatorChar,
[System.IO.Path]::AltDirectorySeparatorChar
)
$target = [System.IO.Path]::GetFullPath($TargetPath)
$rootPrefix = $root + [System.IO.Path]::DirectorySeparatorChar
if (-not $target.StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
throw "-SmokeDataPath must be a child of the Windows temporary directory."
}
if (
(Test-Path -LiteralPath $target) -and
-not (Test-Path -LiteralPath $target -PathType Container)
) {
throw "-SmokeDataPath exists but is not a directory."
}
$existingAncestor = $target
while (-not (Test-Path -LiteralPath $existingAncestor -PathType Container)) {
$parent = [System.IO.Directory]::GetParent($existingAncestor)
if ($null -eq $parent) {
throw "Could not resolve an existing ancestor for -SmokeDataPath."
}
$existingAncestor = $parent.FullName
}
if (
-not [string]::Equals(
$existingAncestor,
$root,
[System.StringComparison]::OrdinalIgnoreCase
) -and
-not $existingAncestor.StartsWith(
$rootPrefix,
[System.StringComparison]::OrdinalIgnoreCase
)
) {
throw "-SmokeDataPath resolves through an ancestor outside the temporary directory."
}
if ($existingAncestor.Length -gt $rootPrefix.Length) {
$relativeAncestor = $existingAncestor.Substring($rootPrefix.Length)
$cursor = $root
foreach ($part in ($relativeAncestor -split "[\\/]")) {
if ([string]::IsNullOrWhiteSpace($part)) {
continue
}
$cursor = Join-Path -Path $cursor -ChildPath $part
$attributes = (Get-Item -LiteralPath $cursor -Force).Attributes
if (($attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) {
throw "-SmokeDataPath must not traverse a junction, symlink, or other reparse point."
}
}
}
return $target
}
function Import-VisualStudioBuildEnvironment {
$originalPath = $env:Path
$programFilesX86 = [Environment]::GetFolderPath(
[Environment+SpecialFolder]::ProgramFilesX86
)
$vswherePath = Join-Path `
-Path $programFilesX86 `
-ChildPath "Microsoft Visual Studio\Installer\vswhere.exe"
if (-not (Test-Path -LiteralPath $vswherePath -PathType Leaf)) {
throw "Visual Studio Installer's vswhere.exe was not found. Install Microsoft C++ Build Tools."
}
$installationPath = (
& $vswherePath `
-latest `
-products "*" `
-requires "Microsoft.VisualStudio.Component.VC.Tools.x86.x64" `
-property installationPath
).Trim()
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($installationPath)) {
throw "Microsoft C++ Build Tools were not found."
}
$devCommandPath = Join-Path `
-Path $installationPath `
-ChildPath "Common7\Tools\VsDevCmd.bat"
if (-not (Test-Path -LiteralPath $devCommandPath -PathType Leaf)) {
throw "VsDevCmd.bat was not found in the selected Visual Studio installation."
}
$commandLine = 'call "' + $devCommandPath + '" -arch=x64 -host_arch=x64 >nul && set'
$environmentLines = @(& $env:ComSpec /d /c $commandLine)
if ($LASTEXITCODE -ne 0) {
throw "Visual Studio x64 developer environment initialization failed."
}
$developerPath = $null
foreach ($line in $environmentLines) {
$separator = $line.IndexOf("=")
if ($separator -le 0) {
continue
}
if ($line.StartsWith("PATH=", [System.StringComparison]::Ordinal)) {
$developerPath = $line.Substring($separator + 1)
continue
}
[Environment]::SetEnvironmentVariable(
$line.Substring(0, $separator),
$line.Substring($separator + 1),
"Process"
)
}
if ([string]::IsNullOrWhiteSpace($developerPath)) {
throw "Visual Studio did not publish a developer PATH."
}
$pathSegments = @($developerPath)
$userProfilePath = $env:USERPROFILE
if ([string]::IsNullOrWhiteSpace($userProfilePath)) {
$userProfilePath = [Environment]::GetFolderPath(
[Environment+SpecialFolder]::UserProfile
)
}
$cargoBinPath = Join-Path `
-Path $userProfilePath `
-ChildPath ".cargo\bin"
if (Test-Path -LiteralPath (Join-Path -Path $cargoBinPath -ChildPath "rustup.exe") -PathType Leaf) {
$pathSegments += $cargoBinPath
}
$pathSegments += $originalPath
$env:Path = $pathSegments -join [System.IO.Path]::PathSeparator
foreach ($requiredTool in @("cl.exe", "link.exe", "rc.exe")) {
$tool = Get-Command `
-Name $requiredTool `
-CommandType Application `
-ErrorAction SilentlyContinue |
Select-Object -First 1
if ($null -eq $tool) {
throw "$requiredTool was not found after loading Visual Studio Build Tools and the Windows SDK."
}
}
Write-Host ("[ok] Visual Studio x64 C++ Build Tools and Windows SDK from {0}" -f $installationPath)
}
if ($Demo -and -not $Launch) {
throw "-Demo is only valid together with -Launch."
}
if ($Launch -and [string]::IsNullOrWhiteSpace($SmokeDataPath)) {
throw "-Launch requires -SmokeDataPath so smoke runs cannot touch the normal app database."
}
if (-not $Launch -and -not [string]::IsNullOrWhiteSpace($SmokeDataPath)) {
throw "-SmokeDataPath is only valid together with -Launch."
}
Import-VisualStudioBuildEnvironment
$projectRoot = [System.IO.Path]::GetFullPath((Join-Path -Path $PSScriptRoot -ChildPath ".."))
$packageJsonPath = Join-Path -Path $projectRoot -ChildPath "package.json"
$lappLockPath = Join-Path -Path $projectRoot -ChildPath "lapp-rs.lock"
$modulesManifestPath = Join-Path -Path $projectRoot -ChildPath "node_modules\.modules.yaml"
$installedLockPath = Join-Path -Path $projectRoot -ChildPath "node_modules\.pnpm\lock.yaml"
$workspaceLockPath = Join-Path -Path $projectRoot -ChildPath "pnpm-lock.yaml"
if (-not (Test-Path -LiteralPath $packageJsonPath -PathType Leaf)) {
throw "package.json was not found next to the scripts directory."
}
if (-not (Test-Path -LiteralPath $lappLockPath -PathType Leaf)) {
throw "lapp-rs.lock was not found in the project root."
}
$gitPath = Find-NativeCommand -Names @("git.exe", "git") -DisplayName "Git"
$rustupPath = Find-NativeCommand -Names @("rustup.exe", "rustup") -DisplayName "rustup"
$nodePath = Find-NativeCommand -Names @("node.exe", "node") -DisplayName "Node.js"
$pnpmPath = Find-NativeCommand -Names @("pnpm.cmd", "pnpm.exe", "pnpm") -DisplayName "pnpm"
$gitVersion = Invoke-NativeCapture -FilePath $gitPath -ArgumentList @("--version") -Label "Git version check"
Write-Host ("[ok] {0}" -f $gitVersion)
$installedToolchains = Invoke-NativeCapture `
-FilePath $rustupPath `
-ArgumentList @("toolchain", "list") `
-Label "rustup toolchain check"
if ($installedToolchains -notmatch "(?m)^1\.96\.0(?:-|\s|$)") {
throw "Rust 1.96.0 is not installed. Install that toolchain outside this script and retry."
}
$rustVersion = Invoke-NativeCapture `
-FilePath $rustupPath `
-ArgumentList @("run", "1.96.0", "rustc", "--version") `
-Label "Rust 1.96 version check"
if ($rustVersion -notmatch "^rustc 1\.96\.0(?:\s|$)") {
throw "Expected rustc 1.96.0, received: $rustVersion"
}
$rustVerboseVersion = Invoke-NativeCapture `
-FilePath $rustupPath `
-ArgumentList @("run", "1.96.0", "rustc", "-vV") `
-Label "Rust host check"
if ($rustVerboseVersion -notmatch "(?m)^host:\s+\S+-pc-windows-msvc\s*$") {
throw "Rust 1.96.0 must use a *-pc-windows-msvc host for this Windows smoke."
}
$installedComponents = Invoke-NativeCapture `
-FilePath $rustupPath `
-ArgumentList @("component", "list", "--toolchain", "1.96.0", "--installed") `
-Label "Rust component check"
if ($installedComponents -notmatch "(?m)^rustfmt-") {
throw "rustfmt is missing from Rust 1.96.0. Install it outside this script and retry."
}
if ($installedComponents -notmatch "(?m)^clippy-") {
throw "clippy is missing from Rust 1.96.0. Install it outside this script and retry."
}
Write-Host ("[ok] {0}; rustfmt and clippy are installed" -f $rustVersion)
$nodeVersion = Invoke-NativeCapture -FilePath $nodePath -ArgumentList @("--version") -Label "Node.js version check"
if ($nodeVersion -notmatch "^v(\d+)\.(\d+)\.(\d+)") {
throw "Could not parse the Node.js version: $nodeVersion"
}
$nodeMajor = [int]$Matches[1]
if ($nodeMajor -lt 24) {
throw "Node.js 24 or newer is required; received $nodeVersion."
}
Write-Host ("[ok] Node.js {0}" -f $nodeVersion)
$packageManifest = Get-Content -LiteralPath $packageJsonPath -Raw | ConvertFrom-Json
$packageManager = [string]$packageManifest.packageManager
if ($packageManager -notmatch "^pnpm@(.+)$") {
throw "package.json does not declare an exact pnpm packageManager version."
}
$expectedPnpmVersion = $Matches[1]
$pnpmVersion = Invoke-NativeCapture -FilePath $pnpmPath -ArgumentList @("--version") -Label "pnpm version check"
if ($pnpmVersion -ne $expectedPnpmVersion) {
throw "pnpm $expectedPnpmVersion is required; received $pnpmVersion."
}
Write-Host ("[ok] pnpm {0}" -f $pnpmVersion)
$insideWorkTree = Invoke-NativeCapture `
-FilePath $gitPath `
-ArgumentList @("-C", $projectRoot, "rev-parse", "--is-inside-work-tree") `
-Label "nana-story Git checkout check"
if ($insideWorkTree -ne "true") {
throw "The project directory is not a Git working tree."
}
$projectBranch = Invoke-NativeCapture `
-FilePath $gitPath `
-ArgumentList @("-C", $projectRoot, "branch", "--show-current") `
-Label "nana-story branch check"
if ($projectBranch -ne "integration/v1") {
throw "nana-story must be checked out on integration/v1; received '$projectBranch'."
}
$projectCommit = Invoke-NativeCapture `
-FilePath $gitPath `
-ArgumentList @("-C", $projectRoot, "rev-parse", "HEAD") `
-Label "nana-story commit check"
if ($projectCommit -notmatch "^[0-9a-fA-F]{40}$") {
throw "nana-story HEAD is not a valid Git commit."
}
$projectWorkTreeStatus = Invoke-NativeCapture `
-FilePath $gitPath `
-ArgumentList @("-C", $projectRoot, "status", "--porcelain=v1", "--untracked-files=all") `
-Label "nana-story clean-worktree check"
if (-not [string]::IsNullOrWhiteSpace($projectWorkTreeStatus)) {
throw "nana-story has tracked, staged, or untracked changes; smoke evidence would be ambiguous."
}
$projectUpstream = Invoke-NativeCapture `
-FilePath $gitPath `
-ArgumentList @("-C", $projectRoot, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}") `
-Label "nana-story upstream check"
if ($projectUpstream -ne "origin/integration/v1") {
throw "integration/v1 must track origin/integration/v1; received '$projectUpstream'."
}
Invoke-NativeChecked `
-FilePath $gitPath `
-ArgumentList @(
"-C",
$projectRoot,
"fetch",
"--prune",
"origin",
"+refs/heads/integration/v1:refs/remotes/origin/integration/v1"
) `
-Label "fetch current origin/integration/v1"
$projectUpstreamCommit = Invoke-NativeCapture `
-FilePath $gitPath `
-ArgumentList @("-C", $projectRoot, "rev-parse", $projectUpstream) `
-Label "nana-story upstream commit check"
if ($projectUpstreamCommit -ne $projectCommit) {
throw "nana-story is ahead of or behind its local origin/integration/v1 reference. Pull the published branch and retry."
}
Write-Host (
"[ok] nana-story integration/v1 at {0}" -f $projectCommit.Substring(0, 12)
)
$lappLock = Get-Content -LiteralPath $lappLockPath -Raw
if ($lappLock -notmatch '(?m)^\s*commit\s*=\s*"([0-9a-fA-F]{40})"\s*$') {
throw "lapp-rs.lock does not contain a valid pinned commit."
}
$expectedLappCommit = $Matches[1].ToLowerInvariant()
if ($lappLock -notmatch '(?m)^\s*repository\s*=\s*"([^"]+)"\s*$') {
throw "lapp-rs.lock does not contain a repository."
}
$expectedLappRepository = Normalize-GitRemote -Url $Matches[1]
if ($lappLock -notmatch '(?m)^\s*development_path\s*=\s*"([^"]+)"\s*$') {
throw "lapp-rs.lock does not contain a development_path."
}
$lappDevelopmentPath = $Matches[1]
$lappPath = [System.IO.Path]::GetFullPath(
(Join-Path -Path $projectRoot -ChildPath $lappDevelopmentPath)
)
if (-not (Test-Path -LiteralPath (Join-Path -Path $lappPath -ChildPath "Cargo.toml") -PathType Leaf)) {
throw "The adjacent lapp-rs checkout required by lapp-rs.lock was not found."
}
$actualLappCommit = Invoke-NativeCapture `
-FilePath $gitPath `
-ArgumentList @("-C", $lappPath, "rev-parse", "HEAD") `
-Label "lapp-rs commit check"
$actualLappCommit = $actualLappCommit.ToLowerInvariant()
if ($actualLappCommit -ne $expectedLappCommit) {
throw "lapp-rs is not at the commit pinned by lapp-rs.lock."
}
$actualLappRepository = Invoke-NativeCapture `
-FilePath $gitPath `
-ArgumentList @("-C", $lappPath, "remote", "get-url", "origin") `
-Label "lapp-rs origin check"
if ((Normalize-GitRemote -Url $actualLappRepository) -ne $expectedLappRepository) {
throw "lapp-rs origin does not match the repository pinned by lapp-rs.lock."
}
$lappWorkTreeStatus = Invoke-NativeCapture `
-FilePath $gitPath `
-ArgumentList @("-C", $lappPath, "status", "--porcelain=v1", "--untracked-files=all") `
-Label "lapp-rs clean-worktree check"
if (-not [string]::IsNullOrWhiteSpace($lappWorkTreeStatus)) {
throw "lapp-rs has tracked, staged, or untracked changes; Cargo would not be building the exact pin."
}
Write-Host ("[ok] lapp-rs matches {0}" -f $expectedLappCommit.Substring(0, 12))
# Keep all automated gates deterministic and noninteractive. Visual Studio's
# developer shell is imported into this process; environment values are never
# printed or persisted by this script.
$env:CI = "true"
$env:NO_COLOR = "1"
$env:CARGO_TERM_COLOR = "never"
$env:RUSTUP_TOOLCHAIN = "1.96.0"
$env:COREPACK_ENABLE_DOWNLOAD_PROMPT = "0"
$previousStoryDataDir = [Environment]::GetEnvironmentVariable(
"NANA_STORY_SMOKE_DATA_DIR",
"Process"
)
$previousStoryProvider = [Environment]::GetEnvironmentVariable("NANA_STORY_PROVIDER", "Process")
$resolvedSmokeDataPath = $null
if ($Launch) {
$temporaryRoot = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath())
$resolvedSmokeDataPath = Assert-TemporaryChildWithoutReparsePoint `
-TemporaryRoot $temporaryRoot `
-TargetPath $SmokeDataPath
}
$locationPushed = $false
try {
Push-Location -LiteralPath $projectRoot
$locationPushed = $true
if ($InstallDependencies) {
Invoke-NativeChecked `
-FilePath $pnpmPath `
-ArgumentList @("--reporter=append-only", "install", "--frozen-lockfile") `
-Label "pnpm install --frozen-lockfile"
}
if (
-not (Test-Path -LiteralPath $modulesManifestPath -PathType Leaf) -or
-not (Test-Path -LiteralPath $installedLockPath -PathType Leaf)
) {
throw "Dependencies are missing. Re-run with -InstallDependencies to install them explicitly."
}
$modulesManifest = Get-Content -LiteralPath $modulesManifestPath -Raw | ConvertFrom-Json
if (
-not ($modulesManifest.PSObject.Properties.Name -contains "packageManager") -or
[string]$modulesManifest.packageManager -ne "pnpm@$expectedPnpmVersion"
) {
throw "node_modules was installed by a different pnpm version. Re-run with -InstallDependencies."
}
$workspaceLockHash = (Get-FileHash -LiteralPath $workspaceLockPath -Algorithm SHA256).Hash
$installedLockHash = (Get-FileHash -LiteralPath $installedLockPath -Algorithm SHA256).Hash
if ($workspaceLockHash -ne $installedLockHash) {
throw "node_modules does not match pnpm-lock.yaml. Re-run with -InstallDependencies."
}
Write-Host "[ok] node_modules matches the pinned pnpm version and lockfile."
Invoke-NativeChecked `
-FilePath $pnpmPath `
-ArgumentList @("--reporter=append-only", "verify") `
-Label "pnpm verify"
Invoke-NativeChecked `
-FilePath $pnpmPath `
-ArgumentList @("--reporter=append-only", "tauri", "build", "--no-bundle") `
-Label "pnpm tauri build --no-bundle"
Write-Host "[ok] Windows developer verification and non-bundled desktop build passed."
if ($Launch) {
New-Item -ItemType Directory -Path $resolvedSmokeDataPath -Force | Out-Null
$null = Assert-TemporaryChildWithoutReparsePoint `
-TemporaryRoot $temporaryRoot `
-TargetPath $resolvedSmokeDataPath
$env:NANA_STORY_SMOKE_DATA_DIR = $resolvedSmokeDataPath
if ($Demo) {
$env:NANA_STORY_PROVIDER = "demo"
Write-Host "[run] pnpm tauri dev (deterministic demo provider)"
}
else {
Remove-Item -Path Env:NANA_STORY_PROVIDER -ErrorAction SilentlyContinue
Write-Host "[run] pnpm tauri dev (default LAPP provider)"
}
Invoke-NativeChecked `
-FilePath $pnpmPath `
-ArgumentList @("tauri", "dev") `
-Label "pnpm tauri dev"
}
else {
Write-Host "[done] Re-run with -Launch, optionally with -Demo, to open the desktop app."
}
}
finally {
if ($locationPushed) {
Pop-Location
}
if ($null -eq $previousStoryDataDir) {
Remove-Item -Path Env:NANA_STORY_SMOKE_DATA_DIR -ErrorAction SilentlyContinue
}
else {
$env:NANA_STORY_SMOKE_DATA_DIR = $previousStoryDataDir
}
if ($null -eq $previousStoryProvider) {
Remove-Item -Path Env:NANA_STORY_PROVIDER -ErrorAction SilentlyContinue
}
else {
$env:NANA_STORY_PROVIDER = $previousStoryProvider
}
}
+2
View File
@@ -18,9 +18,11 @@ nana-domain.workspace = true
nana-engine.workspace = true
nana-runtime.workspace = true
nana-store.workspace = true
openlapp.workspace = true
serde.workspace = true
serde_json.workspace = true
tauri = { version = "2", features = [] }
tokio.workspace = true
[lints]
workspace = true
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+1669 -53
View File
File diff suppressed because it is too large Load Diff
+137 -2
View File
@@ -1,8 +1,12 @@
import { flushPromises, mount, type VueWrapper } from "@vue/test-utils";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import App from "./App.vue";
afterEach(() => {
delete window.__TAURI_INTERNALS__;
});
async function mountLoadedApp(): Promise<VueWrapper> {
const wrapper = mount(App, { attachTo: document.body });
await flushPromises();
@@ -52,7 +56,7 @@ describe("App", () => {
await vi.waitFor(() => {
expect(wrapper.text()).toContain("我答应你,天亮前一定回来。");
expect(wrapper.get(".statusline span").text()).toBe("node_002");
expect(wrapper.get(".character").attributes("data-expression")).toBe("uneasy");
expect(wrapper.get(".character").attributes("data-expression")).toBe("relieved");
expect(wrapper.get(".character").attributes("data-pose")).toBe("holding_coat");
});
expect(wrapper.get(".send-button").text()).toBe("发送");
@@ -73,6 +77,99 @@ describe("App", () => {
wrapper.unmount();
});
it("cancels an in-flight turn without committing and explicitly retries it", async () => {
const wrapper = await mountLoadedApp();
const composer = wrapper.get<HTMLTextAreaElement>('textarea[aria-label="自由输入"]');
const startingNode = wrapper.get(".statusline span").text();
await composer.setValue("我先听听雨声。");
await wrapper.get("form.composer").trigger("submit");
expect(wrapper.get(".cancel-turn-button").text()).toBe("停止生成");
expect(wrapper.get(".turn-status").text()).toContain("只有完整回应通过校验后");
await wrapper.get(".cancel-turn-button").trigger("click");
expect(wrapper.get(".cancel-turn-button").text()).toBe("停止中…");
await vi.waitFor(() => {
expect(wrapper.get(".turn-status").text()).toContain("已停止生成");
expect(wrapper.get(".turn-status").text()).toContain("本轮没有写入故事");
});
expect(wrapper.get(".statusline span").text()).toBe(startingNode);
expect(wrapper.find(".beat--action").exists()).toBe(false);
expect(wrapper.get(".retry-turn-button").text()).toBe("重试本轮");
await wrapper.get(".retry-turn-button").trigger("click");
await vi.waitFor(() => {
expect(wrapper.get(".statusline span").text()).toBe("node_002");
expect(wrapper.text()).toContain("我先听听雨声。");
});
expect(wrapper.find(".retry-turn-button").exists()).toBe(false);
wrapper.unmount();
});
it("allows only one safe retry for the same paid model action", async () => {
const wrapper = await mountLoadedApp();
const composer = wrapper.get<HTMLTextAreaElement>('textarea[aria-label="自由输入"]');
await composer.setValue("我再确认一次。");
await wrapper.get("form.composer").trigger("submit");
await wrapper.get(".cancel-turn-button").trigger("click");
await vi.waitFor(() => expect(wrapper.find(".retry-turn-button").exists()).toBe(true));
await wrapper.get(".retry-turn-button").trigger("click");
await wrapper.get(".cancel-turn-button").trigger("click");
await vi.waitFor(() => {
expect(wrapper.get(".turn-status").text()).toContain("已经使用过一次安全重试");
});
expect(wrapper.find(".retry-turn-button").exists()).toBe(false);
wrapper.unmount();
});
it("reports a complete commit when cancellation loses the atomic boundary", async () => {
const wrapper = await mountLoadedApp();
const composer = wrapper.get<HTMLTextAreaElement>('textarea[aria-label="自由输入"]');
await composer.setValue("我先确认这条线路。");
await wrapper.get("form.composer").trigger("submit");
window.__TAURI_INTERNALS__ = {
invoke: vi.fn().mockResolvedValue(false)
};
await wrapper.get(".cancel-turn-button").trigger("click");
await vi.waitFor(() => {
expect(wrapper.get(".statusline span").text()).toBe("node_002");
expect(wrapper.get(".turn-status").text()).toContain("已经完整完成");
expect(wrapper.get(".turn-status").text()).toContain("安全写入故事");
});
delete window.__TAURI_INTERNALS__;
wrapper.unmount();
});
it("finishes the playable before-dawn slice and exposes its settled records", async () => {
const wrapper = await mountLoadedApp();
const composer = wrapper.get<HTMLTextAreaElement>('textarea[aria-label="自由输入"]');
await composer.setValue("我答应你,天亮前一定回来。");
await wrapper.get("form.composer").trigger("submit");
await vi.waitFor(() => expect(wrapper.get(".statusline span").text()).toBe("node_002"));
for (const nodeId of ["node_003", "node_004", "node_005"]) {
await wrapper.get(".continue-button").trigger("click");
await vi.waitFor(() => expect(wrapper.get(".statusline span").text()).toBe(nodeId));
}
expect(wrapper.text()).toContain("你回来了");
expect(wrapper.text()).toContain("第一幕终 · 天亮之前");
expect(composer.attributes("disabled")).toBeDefined();
expect(wrapper.get(".send-button").attributes("disabled")).toBeDefined();
await wrapper.findAll(".top-actions button")[0].trigger("click");
const records = wrapper.get("#records-panel").text();
expect(records).toContain("半张旧车票");
expect(records).toContain("封锁隧道的检修门");
expect(records).toContain("已履行");
wrapper.unmount();
});
it("opens mutually exclusive, PlayerView-only record and relationship panels", async () => {
const wrapper = await mountLoadedApp();
const [recordsButton, relationshipButton] = wrapper.findAll(".top-actions button");
@@ -125,6 +222,44 @@ describe("App", () => {
expect(status[1]?.text()).toContain("branch_fork_");
});
expect(wrapper.find("#history-panel").exists()).toBe(false);
await historyButton.trigger("click");
expect(wrapper.findAll(".branch-list li")).toHaveLength(2);
expect(wrapper.get(".branch-list").text()).toContain("线路 2");
await wrapper
.findAll(".branch-list li")[0]!
.findAll(".branch-actions button")
.at(-1)!
.trigger("click");
await vi.waitFor(() => {
const status = wrapper.findAll(".statusline span");
expect(status[0]?.text()).toBe("node_002");
expect(status[1]?.text()).toBe("branch_main");
});
wrapper.unmount();
});
it("keeps provider credentials outside the unobtrusive model settings panel", async () => {
const wrapper = await mountLoadedApp();
await wrapper.get('button[aria-label="模型设置"]').trigger("click");
const panel = wrapper.get("#settings-panel");
expect(panel.text()).toContain("确定性演示");
expect(panel.text()).toContain("API Key 与供应商凭据不会进入本应用");
expect(panel.text()).not.toContain("secret");
expect(panel.get("select").attributes("disabled")).toBeDefined();
wrapper.unmount();
});
it("does not advertise a real LAPP connection check in deterministic preview", async () => {
const wrapper = await mountLoadedApp();
await wrapper.get('button[aria-label="模型设置"]').trigger("click");
const testButton = wrapper.get(".connection-test-button");
expect(testButton.attributes("disabled")).toBeDefined();
expect(wrapper.get("#settings-panel").text()).toContain("确定性演示");
expect(wrapper.find(".connection-result").exists()).toBe(false);
expect(wrapper.get("#settings-panel").text()).not.toMatch(/api[_-]?key\s*[:=]/i);
wrapper.unmount();
});
+257 -12
View File
@@ -3,24 +3,42 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
import { useDemo } from "./app/useDemo";
type PanelName = "records" | "relationship" | "history";
type PanelName = "records" | "relationship" | "history" | "settings";
const {
appInfo,
branchList,
busy,
canRetryTurn,
cancellingTurn,
error,
lastSubmittedIntent,
loading,
lappSettings,
lappTestStatus,
pack,
playerView,
testingLappConnection,
turnFailure,
turnInFlight,
turnNotice,
cancelActiveTurn,
clearLappTestStatus,
forkBranch,
renameBranch,
retryTurn,
selectLappModel,
submitTurn,
switchBranch,
testLappConnection,
turnError
} = useDemo();
const draft = ref("");
const activePanel = ref<PanelName | null>(null);
const selectedHistoryNode = ref<string | null>(null);
const closeButton = ref<HTMLButtonElement | null>(null);
const branchNameDrafts = ref<Record<string, string>>({});
const lappSelection = ref("");
const sceneLabel = computed(() => {
if (!playerView.value) return "载入场景";
@@ -107,6 +125,64 @@ async function continueFromSelectedNode() {
}
}
async function switchToBranch(branchId: string) {
if (busy.value || branchId === playerView.value?.branchId) return;
await switchBranch(branchId);
}
function setBranchNameDraft(branchId: string, event: Event) {
const target = event.target;
if (target instanceof HTMLInputElement) {
branchNameDrafts.value[branchId] = target.value;
}
}
async function saveBranchName(branchId: string, currentName: string) {
const name = branchNameDrafts.value[branchId] ?? currentName;
await renameBranch(branchId, name);
}
function modelOptionValue(providerId: string, modelId: string) {
return `${encodeURIComponent(providerId)}|${encodeURIComponent(modelId)}`;
}
function setLappSelection(event: Event) {
const target = event.target;
if (target instanceof HTMLSelectElement) {
lappSelection.value = target.value;
clearLappTestStatus();
}
}
const selectedLappValue = computed(() => {
if (lappSelection.value) return lappSelection.value;
const providerId = lappSettings.value?.selectedProviderId;
const modelId = lappSettings.value?.selectedModelId;
return providerId && modelId ? modelOptionValue(providerId, modelId) : "";
});
const appliedLappValue = computed(() => {
const providerId = lappSettings.value?.selectedProviderId;
const modelId = lappSettings.value?.selectedModelId;
return providerId && modelId ? modelOptionValue(providerId, modelId) : "";
});
async function saveLappSelection() {
const [providerId, modelId] = selectedLappValue.value.split("|").map(decodeURIComponent);
if (!providerId || !modelId) return;
await selectLappModel(providerId, modelId);
}
const canTestLappConnection = computed(() => {
const settings = lappSettings.value;
if (!settings) return false;
return (
settings.mode === "lapp" &&
Boolean(settings.selectedProviderId && settings.selectedModelId) &&
selectedLappValue.value === appliedLappValue.value
);
});
function handleEscape(event: KeyboardEvent) {
if (event.key === "Escape" && activePanel.value) {
closePanel();
@@ -150,6 +226,16 @@ onBeforeUnmount(() => window.removeEventListener("keydown", handleEscape));
>
回溯
</button>
<button
class="settings-button"
type="button"
aria-label="模型设置"
aria-controls="settings-panel"
:aria-expanded="activePanel === 'settings'"
@click="openPanel('settings')"
>
设置
</button>
</nav>
</header>
@@ -177,7 +263,9 @@ onBeforeUnmount(() => window.removeEventListener("keydown", handleEscape));
<div class="character-coat" />
</div>
<div class="chapter-chip">第一幕 · 雨夜车站</div>
<div class="chapter-chip">
{{ playerView.canContinue ? "第一幕 · 雨夜车站" : "第一幕终 · 天亮之前" }}
</div>
<article class="dialogue-panel" aria-label="最近演出">
<ol class="beat-list">
@@ -206,7 +294,7 @@ onBeforeUnmount(() => window.removeEventListener("keydown", handleEscape));
aria-label="自由输入"
placeholder="说些什么,或者描述你的行动……"
rows="2"
:disabled="busy"
:disabled="busy || !playerView.canContinue"
/>
<button
class="continue-button"
@@ -214,17 +302,60 @@ onBeforeUnmount(() => window.removeEventListener("keydown", handleEscape));
:disabled="busy || !playerView.canContinue"
@click="continueStory"
>
{{ busy && lastSubmittedIntent === "continue" ? "继续中…" : "继续" }}
{{ turnInFlight && lastSubmittedIntent === "continue" ? "继续中…" : "继续" }}
</button>
<button class="send-button" type="submit" :disabled="busy || draft.trim().length === 0">
{{ busy && lastSubmittedIntent === "speak_or_act" ? "发送中…" : "发送" }}
<button
class="send-button"
type="submit"
:disabled="busy || !playerView.canContinue || draft.trim().length === 0"
>
{{ turnInFlight && lastSubmittedIntent === "speak_or_act" ? "发送中…" : "发送" }}
</button>
</form>
<p class="turn-status" aria-live="polite">
<span v-if="busy">故事正在回应请稍候</span>
<span v-else-if="turnError">请求未完成{{ turnError }}</span>
</p>
<section class="turn-status" aria-live="polite" aria-label="回合状态">
<div v-if="turnInFlight" class="turn-status-card turn-status-card--progress">
<span>
<strong>{{ cancellingTurn ? "正在停止本轮" : "故事正在回应" }}</strong>
<small>
{{
cancellingTurn
? "等待引擎确认;未完成的回合不会写入故事。"
: turnNotice ?? "只有完整回应通过校验后,才会写入当前线路。"
}}
</small>
</span>
<button
class="cancel-turn-button"
type="button"
:disabled="cancellingTurn"
@click="cancelActiveTurn"
>
{{ cancellingTurn ? "停止中…" : "停止生成" }}
</button>
</div>
<div v-else-if="turnFailure" class="turn-status-card turn-status-card--failure">
<span>
<strong>{{ turnFailure.title }}</strong>
<small>{{ turnFailure.message }} {{ turnFailure.noCommitMessage }}</small>
</span>
<button
v-if="turnFailure.retryable"
class="retry-turn-button"
type="button"
:disabled="!canRetryTurn"
@click="retryTurn"
>
重试本轮
</button>
</div>
<div v-else-if="turnNotice" class="turn-status-card turn-status-card--notice">
<span>{{ turnNotice }}</span>
</div>
<div v-else-if="turnError" class="turn-status-card turn-status-card--failure">
<span>操作未完成{{ turnError }}</span>
</div>
</section>
<footer class="statusline">
<span>{{ playerView.nodeId }}</span>
@@ -249,7 +380,9 @@ onBeforeUnmount(() => window.removeEventListener("keydown", handleEscape));
? "随身记录"
: activePanel === "relationship"
? `${playerView.characterName}的关系`
: "故事回溯"
: activePanel === "history"
? "故事线路"
: "模型设置"
}}
</h2>
</div>
@@ -326,7 +459,48 @@ onBeforeUnmount(() => window.removeEventListener("keydown", handleEscape));
</p>
</div>
<div v-else class="panel-content history-panel">
<div v-else-if="activePanel === 'history'" class="panel-content history-panel">
<section class="branch-section" aria-labelledby="branch-list-title">
<h3 id="branch-list-title">已有线路</h3>
<ul v-if="branchList" class="branch-list">
<li
v-for="branch in branchList.branches"
:key="branch.branchId"
:class="{ active: branch.isActive }"
>
<div class="branch-heading">
<div>
<strong>{{ branch.name }}</strong>
<small>{{ branch.headLabel }}</small>
</div>
<span v-if="branch.isActive">当前</span>
</div>
<div class="branch-actions">
<input
:value="branchNameDrafts[branch.branchId] ?? branch.name"
:aria-label="`重命名${branch.name}`"
maxlength="40"
:disabled="busy"
@input="setBranchNameDraft(branch.branchId, $event)"
/>
<button
type="button"
:disabled="busy"
@click="saveBranchName(branch.branchId, branch.name)"
>
保存名称
</button>
<button
type="button"
:disabled="busy || branch.isActive"
@click="switchToBranch(branch.branchId)"
>
{{ branch.isActive ? "正在游玩" : "切换到这里" }}
</button>
</div>
</li>
</ul>
</section>
<p class="panel-intro">从旧节点继续会创建一条新线路当前线路仍会完整保留</p>
<ol class="history-list">
<li v-for="node in playerView.history" :key="node.id">
@@ -359,6 +533,77 @@ onBeforeUnmount(() => window.removeEventListener("keydown", handleEscape));
</small>
</div>
</div>
<div v-else class="panel-content settings-panel">
<p class="panel-intro">
这里只选择系统 LAPP profile 中已有的模型API Key 与供应商凭据不会进入本应用
</p>
<div v-if="lappSettings" class="settings-card">
<div class="settings-status">
<strong>
{{
lappSettings.mode === "lapp"
? "LAPP 已就绪"
: lappSettings.mode === "demo"
? "确定性演示"
: "LAPP 不可用"
}}
</strong>
<span :data-mode="lappSettings.mode">{{ lappSettings.mode }}</span>
</div>
<p>{{ lappSettings.statusMessage }}</p>
<label for="lapp-model">叙事模型</label>
<select
id="lapp-model"
:value="selectedLappValue"
:disabled="busy || lappSettings.mode === 'demo' || !lappSettings.availableModels.length"
@change="setLappSelection"
>
<option value="" disabled>选择一个支持工具调用的聊天模型</option>
<option
v-for="model in lappSettings.availableModels"
:key="`${model.providerId}/${model.modelId}`"
:value="modelOptionValue(model.providerId, model.modelId)"
>
{{ model.providerName ?? model.providerId }} · {{ model.modelName ?? model.modelId }}
</option>
</select>
<div class="settings-actions">
<button
type="button"
:disabled="busy || !selectedLappValue || lappSettings.mode === 'demo'"
@click="saveLappSelection"
>
应用模型
</button>
<button
class="connection-test-button"
type="button"
:disabled="busy || !canTestLappConnection"
@click="testLappConnection"
>
{{ testingLappConnection ? "测试中…" : "测试当前连接" }}
</button>
</div>
<small class="connection-privacy">
测试只确认当前模型能否完成最小请求不读取展示或保存 API Key
</small>
<small
v-if="lappSettings.mode === 'lapp' && selectedLappValue !== appliedLappValue"
class="connection-privacy"
>
请先应用所选模型再测试这条连接
</small>
<p
v-if="lappTestStatus"
class="connection-result"
:data-tone="lappTestStatus.tone"
role="status"
>
{{ lappTestStatus.message }}
</p>
</div>
</div>
</aside>
</div>
</section>
+97 -2
View File
@@ -2,11 +2,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import initialView from "../../fixtures/player-view/initial.json";
import type {
BranchList,
ForkBranchRequest,
ForkBranchResult,
LappSettings,
PlayerView,
SwitchBranchRequest,
SwitchBranchResult,
TurnRequest,
TurnResult
TurnResult,
UpdateLappSettingsRequest
} from "@contracts";
const invokeMock = vi.hoisted(() => vi.fn());
@@ -15,7 +20,16 @@ vi.mock("@tauri-apps/api/core", () => ({
invoke: invokeMock
}));
import { forkBranch, submitTurn } from "./bridge";
import {
cancelTurn,
forkBranch,
getBranchList,
getLappSettings,
submitTurn,
switchBranch,
testLappConnection,
updateLappSettings
} from "./bridge";
const request: TurnRequest = {
storyId: initialView.storyId,
@@ -34,6 +48,7 @@ describe("Tauri bridge", () => {
afterEach(() => {
delete window.__TAURI_INTERNALS__;
vi.useRealTimers();
});
it("sends only TurnRequest to the submit_turn command", async () => {
@@ -50,6 +65,44 @@ describe("Tauri bridge", () => {
expect(invokeMock).toHaveBeenCalledWith("submit_turn", { request });
});
it("uses narrow commands for cancellation and a credential-free connection test", async () => {
const connectionResult = {
ok: true,
providerId: "provider",
modelId: "model",
message: "connected",
diagnosticCode: null
};
invokeMock.mockResolvedValueOnce(true).mockResolvedValueOnce(connectionResult);
await expect(cancelTurn(request.actionId)).resolves.toBe(true);
await expect(testLappConnection()).resolves.toEqual(connectionResult);
expect(invokeMock.mock.calls).toEqual([
["cancel_turn", { actionId: request.actionId }],
["test_lapp_connection"]
]);
});
it("deterministically discards a cancelled browser-preview result", async () => {
delete window.__TAURI_INTERNALS__;
vi.useFakeTimers();
const pending = submitTurn(request, initialView as PlayerView);
const rejection = expect(pending).rejects.toMatchObject({
code: "cancelled",
retryable: true
});
await expect(cancelTurn(request.actionId)).resolves.toBe(true);
await vi.runAllTimersAsync();
await rejection;
await expect(
testLappConnection()
).resolves.toMatchObject({
ok: true,
diagnosticCode: "browser_preview"
});
});
it("sends only the typed fork request to the fork_branch command", async () => {
const forkRequest: ForkBranchRequest = {
storyId: initialView.storyId,
@@ -70,4 +123,46 @@ describe("Tauri bridge", () => {
await expect(forkBranch(forkRequest, initialView as PlayerView)).resolves.toEqual(expected);
expect(invokeMock).toHaveBeenCalledWith("fork_branch", { request: forkRequest });
});
it("uses typed commands for branch sessions and LAPP model selection", async () => {
const branches: BranchList = {
storyId: initialView.storyId,
activeBranchId: initialView.branchId,
branches: []
};
const switchRequest: SwitchBranchRequest = {
storyId: initialView.storyId,
branchId: "branch_second",
expectedActiveBranchId: initialView.branchId
};
const switched: SwitchBranchResult = {
branchId: "branch_second",
playerView: { ...(initialView as PlayerView), branchId: "branch_second" }
};
const settings: LappSettings = {
mode: "lapp",
selectedProviderId: "provider",
selectedModelId: "model",
availableModels: [],
statusMessage: "ready"
};
const update: UpdateLappSettingsRequest = { providerId: "provider", modelId: "model" };
invokeMock
.mockResolvedValueOnce(branches)
.mockResolvedValueOnce(switched)
.mockResolvedValueOnce(settings)
.mockResolvedValueOnce(settings);
await expect(getBranchList()).resolves.toEqual(branches);
await expect(switchBranch(switchRequest)).resolves.toEqual(switched);
await expect(getLappSettings()).resolves.toEqual(settings);
await expect(updateLappSettings(update)).resolves.toEqual(settings);
expect(invokeMock.mock.calls).toEqual([
["get_branch_list"],
["switch_branch", { request: switchRequest }],
["get_lapp_settings"],
["update_lapp_settings", { request: update }]
]);
});
});
+135 -3
View File
@@ -3,16 +3,26 @@ import { invoke } from "@tauri-apps/api/core";
import initialView from "../../fixtures/player-view/initial.json";
import type {
AppInfo,
BranchList,
DemoPackSummary,
ForkBranchRequest,
ForkBranchResult,
LappConnectionTestResult,
LappSettings,
PlayerView,
RenameBranchRequest,
SwitchBranchRequest,
SwitchBranchResult,
TurnRequest,
TurnResult
TurnResult,
UpdateLappSettingsRequest
} from "@contracts";
import { forkDemoBranch, submitDemoTurn } from "./turnAdapter";
const browserActiveTurns = new Set<string>();
const browserCancelledTurns = new Set<string>();
function isTauri(): boolean {
return typeof window !== "undefined" && window.__TAURI_INTERNALS__ !== undefined;
}
@@ -45,22 +55,144 @@ export async function getDemoPackSummary(): Promise<DemoPackSummary> {
worldBooks: 1,
personas: 1,
plotModules: 1,
itemSpecs: 1
itemSpecs: 2
};
}
return invoke<DemoPackSummary>("get_demo_pack_summary");
}
export async function getBranchList(): Promise<BranchList> {
if (!isTauri()) {
return {
storyId: initialView.storyId,
activeBranchId: initialView.branchId,
branches: [
{
branchId: initialView.branchId,
name: "主线路",
headNodeId: initialView.nodeId,
headLabel: "雨夜车站",
sourceNodeId: null,
isActive: true
}
]
};
}
return invoke<BranchList>("get_branch_list");
}
export async function switchBranch(
request: SwitchBranchRequest,
cachedView?: PlayerView
): Promise<SwitchBranchResult> {
if (!isTauri()) {
if (!cachedView || cachedView.branchId !== request.branchId) {
throw new Error("browser preview does not have that branch snapshot");
}
return { branchId: request.branchId, playerView: cachedView };
}
return invoke<SwitchBranchResult>("switch_branch", { request });
}
export async function renameBranch(
request: RenameBranchRequest,
current: BranchList
): Promise<BranchList> {
if (!isTauri()) {
return {
...current,
branches: current.branches.map((branch) =>
branch.branchId === request.branchId ? { ...branch, name: request.name.trim() } : branch
)
};
}
return invoke<BranchList>("rename_branch", { request });
}
export async function getLappSettings(): Promise<LappSettings> {
if (!isTauri()) {
return {
mode: "demo",
selectedProviderId: null,
selectedModelId: null,
availableModels: [],
statusMessage: "浏览器预览使用确定性纵切;桌面端读取系统 LAPP profile。"
};
}
return invoke<LappSettings>("get_lapp_settings");
}
export async function updateLappSettings(
request: UpdateLappSettingsRequest
): Promise<LappSettings> {
if (!isTauri()) {
throw new Error("浏览器预览不能修改 LAPP 模型。");
}
return invoke<LappSettings>("update_lapp_settings", { request });
}
/**
* Tests only the currently applied LAPP model. The desktop command owns profile
* and credential access; no secret or provider response crosses this boundary.
*/
export async function testLappConnection(
): Promise<LappConnectionTestResult> {
if (!isTauri()) {
await Promise.resolve();
return {
ok: true,
providerId: "browser-preview",
modelId: "deterministic-demo",
message: "browser preview is deterministic",
diagnosticCode: "browser_preview"
};
}
return invoke<LappConnectionTestResult>("test_lapp_connection");
}
export async function submitTurn(
request: TurnRequest,
currentView: PlayerView
): Promise<TurnResult> {
if (!isTauri()) {
return submitDemoTurn(request, currentView);
browserActiveTurns.add(request.actionId);
try {
const result = await submitDemoTurn(request, currentView);
if (browserCancelledTurns.has(request.actionId)) {
throw {
code: "cancelled",
message: "browser preview turn was cancelled",
retryable: true
};
}
return result;
} finally {
browserActiveTurns.delete(request.actionId);
browserCancelledTurns.delete(request.actionId);
}
}
return invoke<TurnResult>("submit_turn", { request });
}
/**
* Requests cancellation of an uncommitted turn. The original submit_turn
* promise remains authoritative: it must settle as either a complete commit or
* a structured `cancelled` failure.
*/
export async function cancelTurn(actionId: string): Promise<boolean> {
if (!isTauri()) {
if (browserActiveTurns.has(actionId)) {
browserCancelledTurns.add(actionId);
await Promise.resolve();
return true;
}
await Promise.resolve();
return false;
}
return invoke<boolean>("cancel_turn", { actionId });
}
export async function forkBranch(
request: ForkBranchRequest,
currentView: PlayerView
+81
View File
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import {
describeConnectionDiagnostic,
describeConnectionFailure,
describeOperationFailure,
describeTurnFailure
} from "./errors";
describe("player-safe failure copy", () => {
it("offers an immediate retry only for retryable turn failures", () => {
const retryable = describeTurnFailure({
code: "provider_unavailable",
message: "upstream secret diagnostic",
retryable: true
});
const invalid = describeTurnFailure({
code: "invalid_input",
message: "private validation detail",
retryable: false
});
expect(retryable.retryable).toBe(true);
expect(retryable.noCommitMessage).toContain("没有写入故事");
expect(invalid.retryable).toBe(false);
});
it("does not echo provider or hidden-check diagnostics", () => {
const privateDetail = "roll=97 target=42 api_key=do-not-show";
const turn = describeTurnFailure({
code: "invalid_model_output",
message: privateDetail,
retryable: true
});
expect(JSON.stringify(turn)).not.toContain(privateDetail);
expect(describeOperationFailure({ message: privateDetail })).not.toContain(privateDetail);
expect(describeConnectionFailure({ message: privateDetail })).not.toContain(privateDetail);
expect(describeConnectionDiagnostic("VAULT_CREDENTIAL_NOT_FOUND")).not.toContain(privateDetail);
});
it("does not suggest blindly retrying a stale branch", () => {
const failure = describeTurnFailure({
code: "stale_node",
retryable: true
});
expect(failure.retryable).toBe(false);
expect(failure.message).toContain("重新打开");
});
it("distinguishes safe provider recovery actions", () => {
const credentials = describeTurnFailure({
code: "provider_credentials",
retryable: true,
message: "api_key=must-not-cross-boundary"
});
const rateLimited = describeTurnFailure({
code: "provider_rate_limited",
retryable: true
});
const rejected = describeTurnFailure({
code: "provider_rejected",
retryable: true
});
expect(credentials.retryable).toBe(false);
expect(credentials.message).toContain("LAPP Vault");
expect(JSON.stringify(credentials)).not.toContain("must-not-cross-boundary");
expect(rateLimited.retryable).toBe(true);
expect(rejected.retryable).toBe(false);
});
it("uses connection diagnostic codes without displaying provider text", () => {
expect(describeConnectionDiagnostic("WAIT_TIMEOUT")).toContain("超时");
expect(describeConnectionDiagnostic("VAULT_CREDENTIAL_NOT_FOUND")).toContain("LAPP Vault");
expect(describeConnectionFailure({ code: "connection_test_in_progress" })).toContain(
"正在进行"
);
});
});
+196
View File
@@ -0,0 +1,196 @@
export interface PlayerTurnFailure {
code: string;
title: string;
message: string;
retryable: boolean;
noCommitMessage: string;
}
interface CommandFailureShape {
code?: unknown;
retryable?: unknown;
}
interface FailureCopy {
title: string;
message: string;
retryableByDefault: boolean;
supportsImmediateRetry: boolean;
}
const NO_COMMIT_MESSAGE = "本轮没有写入故事,当前节点保持不变。";
const TURN_FAILURE_COPY: Record<string, FailureCopy> = {
cancelled: {
title: "已停止生成",
message: "这次回应已被完整丢弃。",
retryableByDefault: true,
supportsImmediateRetry: true
},
timed_out: {
title: "等待模型超时",
message: "模型没有及时完成回应,可以重试本轮。",
retryableByDefault: true,
supportsImmediateRetry: true
},
provider_unavailable: {
title: "模型暂时不可用",
message: "请检查模型设置或网络连接,也可以稍后重试。",
retryableByDefault: true,
supportsImmediateRetry: true
},
provider_configuration: {
title: "模型配置不可用",
message: "请在设置中选择一个支持聊天与工具调用的 LAPP 模型。",
retryableByDefault: false,
supportsImmediateRetry: false
},
provider_credentials: {
title: "模型凭据不可用",
message: "请在 LAPP Vault 中检查当前模型引用的凭据。",
retryableByDefault: false,
supportsImmediateRetry: false
},
provider_rate_limited: {
title: "模型请求过于频繁",
message: "供应商暂时限流,请稍等片刻后重试本轮。",
retryableByDefault: true,
supportsImmediateRetry: true
},
provider_rejected: {
title: "模型拒绝了本轮请求",
message: "当前请求无法由所选模型处理,请检查模型兼容性或切换模型。",
retryableByDefault: false,
supportsImmediateRetry: false
},
invalid_model_output: {
title: "模型回应无法使用",
message: "回应未通过完整性校验,可以重新生成本轮。",
retryableByDefault: true,
supportsImmediateRetry: true
},
storage_unavailable: {
title: "存档暂时不可用",
message: "故事未能安全保存,请稍后重试。",
retryableByDefault: true,
supportsImmediateRetry: true
},
internal: {
title: "本轮未能完成",
message: "应用遇到临时问题,可以重试本轮。",
retryableByDefault: true,
supportsImmediateRetry: true
},
turn_in_progress: {
title: "已有一轮正在生成",
message: "请等待正在进行的回合结束,再重试本轮。",
retryableByDefault: true,
supportsImmediateRetry: true
},
stale_node: {
title: "故事线路已经变化",
message: "当前画面不是最新节点,请重新打开对应线路后再行动。",
retryableByDefault: false,
supportsImmediateRetry: false
},
invalid_input: {
title: "这次行动无法提交",
message: "请调整输入后再试。",
retryableByDefault: false,
supportsImmediateRetry: false
}
};
function commandFailure(reason: unknown): CommandFailureShape | null {
if (typeof reason !== "object" || reason === null) return null;
return reason as CommandFailureShape;
}
function normalizedCode(reason: unknown): string {
const code = commandFailure(reason)?.code;
return typeof code === "string" && code.trim() ? code.trim().toLowerCase() : "unknown";
}
/**
* Converts a backend failure into player-safe copy.
*
* The backend message is intentionally not echoed: provider diagnostics may
* contain implementation details that do not belong in PlayerView-facing UI.
*/
export function describeTurnFailure(reason: unknown): PlayerTurnFailure {
const failure = commandFailure(reason);
const code = normalizedCode(reason);
const copy = TURN_FAILURE_COPY[code] ?? {
title: "请求没有完成",
message: "本轮没有产生可用的回应。",
retryableByDefault: false,
supportsImmediateRetry: true
};
const retryable =
copy.supportsImmediateRetry &&
(typeof failure?.retryable === "boolean" ? failure.retryable : copy.retryableByDefault);
return {
code,
title: copy.title,
message: copy.message,
retryable,
noCommitMessage: NO_COMMIT_MESSAGE
};
}
export function describeOperationFailure(reason: unknown): string {
switch (normalizedCode(reason)) {
case "timed_out":
return "操作等待超时,请稍后重试。";
case "provider_unavailable":
return "当前模型不可用,请检查模型设置。";
case "storage_unavailable":
return "故事存档暂时不可用,请稍后重试。";
case "stale_node":
return "故事线路已经变化,请重新打开对应线路。";
case "invalid_input":
return "提交的内容无法使用,请检查后重试。";
default:
return "操作没有完成,请稍后重试。";
}
}
export function describeConnectionFailure(reason: unknown): string {
switch (normalizedCode(reason)) {
case "timed_out":
return "连接测试超时,请检查网络后重试。";
case "connection_test_in_progress":
return "已有一项连接测试或故事回合正在进行,请等待它结束。";
case "provider_configuration":
return "当前模型无法初始化,请重新应用一个兼容的 LAPP 模型。";
case "provider_credentials":
return "当前模型凭据不可用,请在 LAPP Vault 中检查配置。";
case "provider_unavailable":
return "当前模型无法连接,请检查 LAPP profile 与网络。";
case "invalid_input":
return "当前模型配置不完整,请先应用可用模型。";
default:
return "连接测试没有完成;没有读取或显示任何凭据。";
}
}
export function describeConnectionDiagnostic(code: string | null): string {
switch (code?.toUpperCase()) {
case "ENV_SECRET_MISSING":
case "VAULT_BACKEND_UNAVAILABLE":
case "VAULT_CREDENTIAL_NOT_FOUND":
case "VAULT_BINDING_MISMATCH":
case "VAULT_ACCESS_DENIED":
return "LAPP profile 引用的凭据当前不可用,请在 LAPP Vault 中检查配置。";
case "WAIT_TIMEOUT":
return "最小请求等待超时,请检查网络后重试。";
case "HTTP_REQUEST_FAILED":
case "HTTP_STATUS":
return "供应商连接失败,请检查网络与模型服务状态。";
case "INVALID_RESPONSE":
return "模型返回了无法识别的响应,请确认所选协议与模型兼容。";
default:
return "当前模型没有完成最小请求,请检查 LAPP profile 与网络。";
}
}
+47
View File
@@ -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("你回来了");
});
});
+186 -3
View File
@@ -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
}
]
+346 -26
View File
@@ -1,47 +1,91 @@
import { onMounted, ref } from "vue";
import { computed, onMounted, ref } from "vue";
import type { AppInfo, DemoPackSummary, PlayerView, TurnIntent, TurnRequest } from "@contracts";
import type {
AppInfo,
BranchList,
DemoPackSummary,
LappSettings,
PlayerView,
TurnIntent,
TurnRequest
} from "@contracts";
import {
cancelTurn as cancelRuntimeTurn,
forkBranch as forkRuntimeBranch,
getAppInfo,
getBranchList,
getDemoPackSummary,
getDemoPlayerView,
submitTurn as submitRuntimeTurn
getLappSettings,
renameBranch as renameRuntimeBranch,
submitTurn as submitRuntimeTurn,
switchBranch as switchRuntimeBranch,
testLappConnection as testRuntimeLappConnection,
updateLappSettings
} from "./bridge";
import {
describeConnectionDiagnostic,
describeConnectionFailure,
describeOperationFailure,
describeTurnFailure,
type PlayerTurnFailure
} from "./errors";
function errorMessage(reason: unknown): string {
if (reason instanceof Error) return reason.message;
if (
typeof reason === "object" &&
reason !== null &&
"message" in reason &&
typeof reason.message === "string"
) {
return reason.message;
}
return String(reason);
type TurnPhase = "idle" | "submitting" | "cancelling";
interface TurnAttempt {
request: TurnRequest;
sourceView: PlayerView;
intent: TurnIntent;
retryCount: number;
cancellationRequested: boolean;
}
export interface LappTestStatus {
tone: "success" | "error" | "preview";
message: string;
}
export function useDemo() {
const appInfo = ref<AppInfo | null>(null);
const pack = ref<DemoPackSummary | null>(null);
const playerView = ref<PlayerView | null>(null);
const branchList = ref<BranchList | null>(null);
const lappSettings = ref<LappSettings | null>(null);
const loading = ref(true);
const error = ref<string | null>(null);
const busy = ref(false);
const turnError = ref<string | null>(null);
const turnFailure = ref<PlayerTurnFailure | null>(null);
const turnNotice = ref<string | null>(null);
const turnPhase = ref<TurnPhase>("idle");
const lastSubmittedIntent = ref<TurnIntent | null>(null);
const testingLappConnection = ref(false);
const lappTestStatus = ref<LappTestStatus | null>(null);
const branchViews = new Map<string, PlayerView>();
let activeTurn: TurnAttempt | null = null;
let retryableTurn: TurnAttempt | null = null;
const turnInFlight = computed(() => turnPhase.value !== "idle");
const cancellingTurn = computed(() => turnPhase.value === "cancelling");
const canRetryTurn = computed(
() => !busy.value && Boolean(retryableTurn && turnFailure.value?.retryable)
);
onMounted(async () => {
try {
[appInfo.value, pack.value, playerView.value] = await Promise.all([
getAppInfo(),
getDemoPackSummary(),
getDemoPlayerView()
]);
[appInfo.value, pack.value, playerView.value, branchList.value, lappSettings.value] =
await Promise.all([
getAppInfo(),
getDemoPackSummary(),
getDemoPlayerView(),
getBranchList(),
getLappSettings()
]);
if (playerView.value) branchViews.set(playerView.value.branchId, playerView.value);
} catch (reason) {
error.value = errorMessage(reason);
error.value = describeOperationFailure(reason);
} finally {
loading.value = false;
}
@@ -64,26 +108,110 @@ export function useDemo() {
input: intent === "continue" ? "" : normalizedInput
};
await runTurn({
request,
sourceView: currentView,
intent,
retryCount: 0,
cancellationRequested: false
});
}
async function runTurn(attempt: TurnAttempt): Promise<void> {
if (busy.value) return;
busy.value = true;
turnError.value = null;
lastSubmittedIntent.value = intent;
turnFailure.value = null;
turnNotice.value = null;
retryableTurn = null;
turnPhase.value = "submitting";
lastSubmittedIntent.value = attempt.intent;
activeTurn = attempt;
try {
const result = await submitRuntimeTurn(request, currentView);
const result = await submitRuntimeTurn(attempt.request, attempt.sourceView);
const cancellationWasRequested = attempt.cancellationRequested;
playerView.value = result.playerView;
branchViews.set(result.playerView.branchId, result.playerView);
updateBranchHead(result.playerView);
turnNotice.value = cancellationWasRequested
? "本轮在停止请求生效前已经完整完成,并已安全写入故事。"
: null;
} catch (reason) {
turnError.value = errorMessage(reason);
const failure = describeTurnFailure(reason);
const canRetry = failure.retryable && attempt.retryCount === 0;
turnFailure.value = canRetry
? failure
: {
...failure,
retryable: false,
message:
attempt.retryCount > 0
? `${failure.message} 本轮已经使用过一次安全重试。`
: failure.message
};
retryableTurn = canRetry ? attempt : null;
} finally {
if (activeTurn === attempt) activeTurn = null;
turnPhase.value = "idle";
busy.value = false;
}
}
async function cancelActiveTurn(): Promise<void> {
const attempt = activeTurn;
if (!attempt || turnPhase.value !== "submitting") return;
attempt.cancellationRequested = true;
turnPhase.value = "cancelling";
turnNotice.value = null;
try {
const accepted = await cancelRuntimeTurn(attempt.request.actionId);
if (!accepted && activeTurn === attempt) {
turnPhase.value = "submitting";
turnNotice.value = "停止请求未被接受;正在等待本轮的最终结果。";
}
} catch (reason) {
if (activeTurn === attempt) {
turnPhase.value = "submitting";
turnNotice.value = `${describeOperationFailure(reason)} 当前回合仍在等待完成。`;
}
}
}
async function retryTurn(): Promise<void> {
const attempt = retryableTurn;
const currentView = playerView.value;
if (!attempt || !currentView || busy.value) return;
if (
currentView.storyId !== attempt.request.storyId ||
currentView.branchId !== attempt.request.branchId ||
currentView.nodeId !== attempt.request.expectedNodeId
) {
retryableTurn = null;
turnFailure.value = describeTurnFailure({
code: "stale_node",
retryable: false
});
return;
}
// Keep the original action id: desktop retries can therefore be idempotent.
await runTurn({
...attempt,
retryCount: attempt.retryCount + 1,
cancellationRequested: false
});
}
async function forkBranch(sourceNodeId: string): Promise<void> {
const currentView = playerView.value;
if (!currentView || busy.value || sourceNodeId === currentView.nodeId) return;
busy.value = true;
turnError.value = null;
clearTurnFeedback();
lastSubmittedIntent.value = null;
try {
@@ -98,23 +226,215 @@ export function useDemo() {
currentView
);
playerView.value = result.playerView;
branchViews.set(currentView.branchId, currentView);
branchViews.set(result.playerView.branchId, result.playerView);
const branches = branchList.value;
if (branches) {
const nextOrdinal = branches.branches.length + 1;
branchList.value = {
storyId: branches.storyId,
activeBranchId: result.branchId,
branches: [
...branches.branches.map((branch) => ({ ...branch, isActive: false })),
{
branchId: result.branchId,
name: `线路 ${nextOrdinal}`,
headNodeId: result.playerView.nodeId,
headLabel: currentHistoryLabel(result.playerView),
sourceNodeId,
isActive: true
}
]
};
}
} catch (reason) {
turnError.value = errorMessage(reason);
turnError.value = describeOperationFailure(reason);
} finally {
busy.value = false;
}
}
async function switchBranch(branchId: string): Promise<void> {
const currentView = playerView.value;
const branches = branchList.value;
if (!currentView || !branches || busy.value || branchId === currentView.branchId) return;
busy.value = true;
clearTurnFeedback();
lastSubmittedIntent.value = null;
branchViews.set(currentView.branchId, currentView);
try {
const result = await switchRuntimeBranch(
{
storyId: currentView.storyId,
branchId,
expectedActiveBranchId: branches.activeBranchId
},
branchViews.get(branchId)
);
playerView.value = result.playerView;
branchViews.set(result.branchId, result.playerView);
branchList.value = {
...branches,
activeBranchId: result.branchId,
branches: branches.branches.map((branch) => ({
...branch,
isActive: branch.branchId === result.branchId
}))
};
} catch (reason) {
turnError.value = describeOperationFailure(reason);
} finally {
busy.value = false;
}
}
async function renameBranch(branchId: string, name: string): Promise<void> {
const branches = branchList.value;
const normalized = name.trim();
if (!branches || busy.value || !normalized) return;
busy.value = true;
clearTurnFeedback();
try {
branchList.value = await renameRuntimeBranch(
{ storyId: branches.storyId, branchId, name: normalized },
branches
);
} catch (reason) {
turnError.value = describeOperationFailure(reason);
} finally {
busy.value = false;
}
}
async function selectLappModel(providerId: string, modelId: string): Promise<void> {
if (busy.value) return;
busy.value = true;
lappTestStatus.value = null;
try {
lappSettings.value = await updateLappSettings({ providerId, modelId });
} catch (reason) {
lappTestStatus.value = {
tone: "error",
message: describeOperationFailure(reason)
};
} finally {
busy.value = false;
}
}
async function testLappConnection(): Promise<void> {
const settings = lappSettings.value;
if (!settings || busy.value) return;
if (
settings.mode !== "lapp" ||
!settings.selectedProviderId ||
!settings.selectedModelId
) {
lappTestStatus.value = {
tone: "error",
message:
settings.mode === "demo"
? "确定性演示模式不连接外部模型。"
: "请先应用一个支持聊天与工具调用的模型。"
};
return;
}
busy.value = true;
testingLappConnection.value = true;
lappTestStatus.value = null;
try {
const result = await testRuntimeLappConnection();
if (result.diagnosticCode === "browser_preview") {
lappTestStatus.value = {
tone: "preview",
message: "浏览器演示路径正常;真实 LAPP 连接需在桌面端测试。"
};
} else if (result.ok) {
lappTestStatus.value = {
tone: "success",
message: "最小聊天请求成功;工具调用仍需在实际故事回合中验证。"
};
} else {
lappTestStatus.value = {
tone: "error",
message: describeConnectionDiagnostic(result.diagnosticCode)
};
}
} catch (reason) {
lappTestStatus.value = {
tone: "error",
message: describeConnectionFailure(reason)
};
} finally {
testingLappConnection.value = false;
busy.value = false;
}
}
function clearLappTestStatus(): void {
lappTestStatus.value = null;
}
function clearTurnFeedback(): void {
turnError.value = null;
turnFailure.value = null;
turnNotice.value = null;
retryableTurn = null;
}
function currentHistoryLabel(view: PlayerView): string {
return view.history.find((node) => node.isCurrent)?.label ?? view.sceneTitle;
}
function updateBranchHead(view: PlayerView): void {
const branches = branchList.value;
if (!branches) return;
branchList.value = {
...branches,
activeBranchId: view.branchId,
branches: branches.branches.map((branch) =>
branch.branchId === view.branchId
? {
...branch,
headNodeId: view.nodeId,
headLabel: currentHistoryLabel(view),
isActive: true
}
: { ...branch, isActive: false }
)
};
}
return {
appInfo,
branchList,
busy,
canRetryTurn,
cancellingTurn,
error,
lastSubmittedIntent,
lappSettings,
lappTestStatus,
loading,
pack,
playerView,
testingLappConnection,
turnError,
turnFailure,
turnInFlight,
turnNotice,
cancelActiveTurn,
clearLappTestStatus,
forkBranch,
renameBranch,
retryTurn,
selectLappModel,
submitTurn,
turnError
switchBranch,
testLappConnection
};
}
+253 -5
View File
@@ -22,6 +22,8 @@ body,
}
button,
input,
select,
textarea {
font: inherit;
}
@@ -103,6 +105,8 @@ button {
}
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: 2px solid #f0c1ae;
outline-offset: 3px;
@@ -427,15 +431,86 @@ textarea:focus-visible {
.turn-status {
position: absolute;
right: 18%;
bottom: 18px;
z-index: 14;
right: 7%;
bottom: 108px;
z-index: 16;
width: min(620px, 72%);
margin: 0;
color: rgb(241 196 178 / 72%);
font-size: 11px;
pointer-events: none;
}
.turn-status-card {
display: flex;
min-height: 54px;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 10px 12px 10px 16px;
color: rgb(245 241 234 / 78%);
font-size: 12px;
background: rgb(11 14 23 / 94%);
border: 1px solid rgb(255 255 255 / 12%);
border-radius: 11px;
box-shadow: 0 12px 34px rgb(0 0 0 / 32%);
backdrop-filter: blur(16px);
pointer-events: auto;
}
.turn-status-card > span {
display: grid;
gap: 3px;
line-height: 1.45;
}
.turn-status-card strong {
color: rgb(247 243 237 / 88%);
font-weight: 600;
}
.turn-status-card small {
color: rgb(245 241 234 / 48%);
font-size: 11px;
}
.turn-status-card--progress {
border-color: rgb(184 205 222 / 24%);
}
.turn-status-card--failure {
border-color: rgb(231 167 167 / 32%);
}
.turn-status-card--notice {
min-height: 40px;
color: rgb(211 224 231 / 72%);
}
.turn-status-card button {
flex: 0 0 auto;
padding: 8px 12px;
cursor: pointer;
color: rgb(245 241 234 / 78%);
background: rgb(255 255 255 / 5%);
border: 1px solid rgb(255 255 255 / 14%);
border-radius: 8px;
}
.turn-status-card button:hover:not(:disabled) {
color: #f4c8b5;
border-color: rgb(231 184 164 / 38%);
}
.turn-status-card button:disabled {
cursor: default;
opacity: 0.46;
}
.turn-status-card .retry-turn-button {
color: #241b1a;
background: #e7b8a4;
border-color: transparent;
}
.statusline {
position: absolute;
right: 8%;
@@ -647,6 +722,179 @@ textarea:focus-visible {
margin: 20px 0 0;
}
.branch-section {
padding-bottom: 24px;
margin-bottom: 24px;
border-bottom: 1px solid rgb(255 255 255 / 8%);
}
.branch-section h3 {
margin: 0 0 12px;
color: rgb(245 241 234 / 54%);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.12em;
}
.branch-list {
display: grid;
gap: 10px;
padding: 0;
margin: 0;
list-style: none;
}
.branch-list li {
padding: 14px;
background: rgb(255 255 255 / 3%);
border: 1px solid rgb(255 255 255 / 8%);
border-radius: 10px;
}
.branch-list li.active {
background: rgb(231 184 164 / 7%);
border-color: rgb(231 184 164 / 32%);
}
.branch-heading,
.branch-actions,
.settings-status,
.settings-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.branch-heading div {
display: grid;
gap: 4px;
}
.branch-heading small {
color: rgb(245 241 234 / 38%);
}
.branch-heading > span,
.settings-status span {
padding: 3px 8px;
color: #eac0ae;
font-size: 10px;
letter-spacing: 0.08em;
background: rgb(231 184 164 / 10%);
border: 1px solid rgb(231 184 164 / 28%);
border-radius: 999px;
}
.branch-actions {
margin-top: 12px;
}
.branch-actions input,
.settings-card select {
min-width: 0;
color: rgb(245 241 234 / 82%);
background: rgb(8 10 17 / 55%);
border: 1px solid rgb(255 255 255 / 12%);
border-radius: 8px;
}
.branch-actions input {
flex: 1;
padding: 8px 10px;
}
.branch-actions button,
.settings-actions button {
padding: 8px 10px;
cursor: pointer;
color: rgb(245 241 234 / 72%);
background: rgb(255 255 255 / 4%);
border: 1px solid rgb(255 255 255 / 12%);
border-radius: 8px;
}
.branch-actions button:disabled,
.settings-actions button:disabled {
cursor: default;
opacity: 0.42;
}
.settings-card {
display: grid;
gap: 14px;
padding: 18px;
background: rgb(7 9 15 / 36%);
border: 1px solid rgb(255 255 255 / 8%);
border-radius: 10px;
}
.settings-card p {
margin: 0;
color: rgb(245 241 234 / 48%);
line-height: 1.65;
}
.settings-card label {
color: rgb(245 241 234 / 58%);
font-size: 12px;
}
.settings-card select {
width: 100%;
padding: 10px 12px;
}
.settings-card option {
color: #161923;
background: #f3f0eb;
}
.settings-actions {
align-items: stretch;
}
.settings-actions button {
flex: 1;
}
.settings-actions .connection-test-button {
color: #eac0ae;
border-color: rgb(231 184 164 / 24%);
}
.settings-card .connection-privacy {
color: rgb(245 241 234 / 34%);
font-size: 10px;
line-height: 1.6;
}
.settings-card .connection-result {
padding: 10px 12px;
color: rgb(211 224 231 / 72%);
font-size: 12px;
background: rgb(123 164 190 / 8%);
border: 1px solid rgb(123 164 190 / 18%);
border-radius: 8px;
}
.settings-card .connection-result[data-tone="success"] {
color: #b9dac6;
background: rgb(118 184 143 / 8%);
border-color: rgb(118 184 143 / 20%);
}
.settings-card .connection-result[data-tone="error"] {
color: #e7b0aa;
background: rgb(205 113 113 / 8%);
border-color: rgb(205 113 113 / 20%);
}
.settings-status span[data-mode="unavailable"] {
color: #e7a7a7;
border-color: rgb(231 167 167 / 28%);
}
.history-list {
position: relative;
}
+286
View File
@@ -0,0 +1,286 @@
# Implementation Plan: Wave 7 本地 Windows 闭环
## Overview
`integration/v1@23672e857bfa854930b0c8ab8aca316a95ba5d09` 继续开发。先关闭真实
Windows 基线暴露出的可重复性问题,再补齐真实模型的当前分支连续上下文与超预算检查点,
随后实现“风险预检 → 玩家确认 → 隐藏检定失败 → 可推骰 / 可重新生成”的纵向切片。
每个阶段都必须保持回合原子提交、分支隔离和 `PlayerView` 脱敏边界。
## Architecture Decisions
- 继续使用 `integration/v1`,不在本地另起一套重建工程。
- `lapp-rs` 保持相邻 path dependency,并固定到 `5ba3c659…`
- 契约源码哈希统一按 LF 规范化;同时用 `.gitattributes` 固定文本文件换行,避免
Windows `core.autocrlf=true` 产生假漂移。
- 模型上下文固定为“系统约束 → 稳定资源 → 当前分支检查点 / 原始历史 → 本轮动态尾部”,
提升前缀缓存命中;兄弟分支内容不得进入上下文。
- 压缩检查点是可丢弃缓存,不是事实来源;删除检查点不得改变 `RuntimeState`
- 风险提示是“不落节点的预检结果”,只有玩家确认后才进入现有隐藏判定与原子提交链。
- `Regenerate` 复用原行动的判定记录;`PushCheck` 是带明确后果的新行动和新节点。
- 在重新生成 / 推骰语义闭环前,不扩大到内容包导入、TTS、生图或第二完整角色。
## Dependency Graph
```text
Windows 可重复契约门禁
└─ 精确 Node/pnpm + Rust/MSVC 工具链
└─ Wave 6 全量基线
└─ 当前分支连续上下文
└─ 超预算检查点压缩
└─ 风险预检契约
├─ Runtime / Store 语义
└─ Tauri 窄命令
└─ Vue 确认、推骰、重新生成界面
└─ Windows Demo + LAPP 冒烟
```
## Task 1: 修复跨平台契约哈希
**Description:** 消除 Windows CRLF checkout 对契约源码哈希的影响,同时固定未来 checkout
的文本换行。
**Acceptance criteria:**
- [x] 干净 Windows checkout 上 `node scripts/verify-contracts.mjs` 通过。
- [x] Rust 生成器对 LF 与 CRLF 输入计算相同源码哈希。
- [x] Linux 既有 `.source.sha256` 不发生无意义变化。
**Verification:**
- [x] `node scripts/verify-contracts.mjs`
- [x] `cargo test -p nana-contracts`
- [x] `cargo run -p nana-contracts -- --check`
- [x] `git diff --check`
**Dependencies:** None
**Files likely touched:**
- `.gitattributes`
- `scripts/verify-contracts.mjs`
- `crates/nana-contracts/src/main.rs`
**Estimated scope:** Medium
## Task 2: 恢复锁定的本地工具链
**Description:** 让 Windows 开发机满足仓库已声明的精确依赖,不修改项目依赖版本。
**Acceptance criteria:**
- [x] Node.js 24+ 与 pnpm 10.29.2 可用。
- [x] Rust 1.96.0、rustfmt、clippy、MSVC Build Tools、Windows SDK 可用。
- [x] 相邻 `lapp-rs` 位于固定提交且工作树干净。
**Verification:**
- [x] `node --version`
- [x] `pnpm --version`
- [x] `rustc --version`
- [x] `cargo clippy --version`
- [x] `git -C ..\lapp-rs rev-parse HEAD`
**Dependencies:** Task 1 可并行
**Files likely touched:** None(仅本机工具链与依赖目录)
**Estimated scope:** Medium
## Task 3: 关闭 Wave 6 Windows 机械门禁
**Description:** 在真实 Windows 目标上重新验证既有 119 项核心 Rust、18 项 Tauri、
29 项 Web 测试和无 bundle 桌面编译。
**Acceptance criteria:**
- [x] 依赖安装使用锁文件且不修改锁文件。
- [x] `pnpm verify` 等价的 Web / Rust / 契约门禁全绿。
- [x] `pnpm tauri build --no-bundle` 成功。
**Verification:**
- [ ] `powershell.exe -NoProfile -File .\scripts\windows-smoke.ps1 -InstallDependencies`
**Dependencies:** Tasks 1-2
**Files likely touched:** None
**Estimated scope:** Small
## Checkpoint: Windows 基线
- [x] 工作树只包含已审阅的 Wave 7 变更。
- [x] 契约、Web、Rust、Tauri 门禁均可重复。
- [x] 记录首个无法自动关闭的环境阻塞。
## Task 4: 恢复当前分支连续上下文
**Description:** Store 提供按 `parent_id` 追溯的祖先链;Runtime 将稳定资源、当前分支
历史和本轮输入按固定顺序编译给 LAPP,解决真实模型第二轮起失忆的问题。
**Acceptance criteria:**
- [x] 第三轮模型输入包含根到当前节点的前两轮玩家输入和演出节拍。
- [x] 分叉后只包含本分支祖先,兄弟分支文本和隐藏状态不泄漏。
- [x] 系统约束、角色卡和 Persona 在连续回合中保持逐字节稳定,本轮输入位于尾部。
- [x] 精确骰点、目标值、状态 delta、NPC 隐藏物品和未触发世界书不进入上下文。
**Verification:**
- [x] Store 祖先链测试覆盖分叉共享祖先。
- [x] Runtime 捕获模型输入的三轮与兄弟分支隔离测试。
- [x] 既有 PlayerView / 上下文泄密 canary 通过。
**Dependencies:** Checkpoint: Windows 基线
**Files likely touched:**
- `crates/nana-store/src/lib.rs`
- `crates/nana-runtime/src/context.rs`
- `crates/nana-runtime/src/lib.rs`
- `crates/nana-runtime/src/lapp_provider.rs`
- `src-tauri/src/lib.rs`
**Estimated scope:** Medium
## Task 5: 增加超预算分支检查点
**Description:** 根据模型上下文窗口预算保留“最近祖先检查点 + 检查点后原始节点 + 本轮
输入”;超预算时使用同一 LAPP 模型压缩旧段,并把检查点作为当前节点的非权威缓存。
**Acceptance criteria:**
- [ ] 500 节点分支恢复不扫描无关分支并保持在输入预算内。
- [ ] 检查点只继承自当前祖先链,来源哈希不符时拒绝使用。
- [ ] 删除检查点后 `RuntimeState`、分支头和 `PlayerView` 不变。
- [ ] 压缩取消、超时或畸形输出不创建节点、不移动分支头。
**Verification:**
- [ ] SQLite v2 → v3 无损迁移测试。
- [ ] 预算边界、检查点继承、来源哈希和删除重建测试。
- [ ] 压缩调用复用既有取消控制与原生单飞闸门。
**Dependencies:** Task 4
**Files likely touched:**
- `crates/nana-domain/src/lib.rs`
- `crates/nana-store/src/lib.rs`
- `crates/nana-runtime/src/context.rs`
- `crates/nana-runtime/src/lapp_provider.rs`
- `src-tauri/src/lib.rs`
**Estimated scope:** MediumCore 与 Runtime 可在内部契约冻结后并行)
## Checkpoint: 连续上下文
- [ ] 三轮真实上下文连续。
- [ ] 兄弟分支隔离。
- [ ] 稳定前缀可缓存。
- [ ] 500 节点与超预算检查点测试通过。
## Task 6: 增加不落节点的风险预检契约
**Description:** 高风险玩家行动先返回脱敏风险描述和确认令牌,不调用隐藏判定、不推进
状态、不创建故事节点。
**Acceptance criteria:**
- [ ] 普通行动仍沿用现有单次提交路径。
- [ ] 高风险行动首次提交只返回玩家可理解的风险提示。
- [ ] 预检结果不包含骰点、目标值、NPC 隐藏信息或精确关系数值。
**Verification:**
- [ ] Domain / Runtime 契约测试。
- [ ] Tauri 命令测试证明预检不写 SQLite。
- [ ] PlayerView 泄密 canary 继续通过。
**Dependencies:** Checkpoint: 连续上下文
**Files likely touched:**
- `crates/nana-domain/src/lib.rs`
- `crates/nana-runtime/src/`
- `src-tauri/src/lib.rs`
- `contracts/`
**Estimated scope:** Medium
## Task 7: 完成确认、推骰与重新生成纵切
**Description:** 把现有 `Regenerate` / `PushCheck` 领域语义接到 Tauri 与 Vue,让玩家在失败
后选择承担结果、推骰或重新生成叙述。
**Acceptance criteria:**
- [ ] 确认风险后只产生一个完整节点。
- [ ] `Regenerate` 复用原判定,不允许刷骰。
- [ ] `PushCheck` 明示后果并创建新行动、新判定和新节点。
- [ ] 回溯后两条线路的判定、物品、许诺和关系互不污染。
**Verification:**
- [ ] Runtime / Store 端到端测试。
- [ ] Tauri 命令测试。
- [ ] Vue 交互测试覆盖确认、取消、推骰、重新生成。
- [ ] 完整 `pnpm verify`
**Dependencies:** Task 6
**Files likely touched:**
- `crates/nana-runtime/src/adjudication.rs`
- `crates/nana-store/src/lib.rs`
- `src-tauri/src/lib.rs`
- `src/app/`
- `src/App.vue`
**Estimated scope:** Medium(按契约、后端、前端三个小提交交付)
## Checkpoint: 可玩纵切
- [ ] 风险提示先于隐藏检定。
- [ ] 失败、推骰、重新生成的语义可被玩家区分。
- [ ] 取消、超时和畸形响应仍不产生半轮。
- [ ] Demo 与真实 LAPP 各完成一次隐藏判定。
## Task 8: Windows 桌面冒烟与交付
**Description:** 启动隔离存档的 Demo 窗口,验证两次重启、终局、回溯与双线路隔离;随后
执行真实 LAPP 最小连接及隐藏判定。
**Acceptance criteria:**
- [ ] Demo 窗口启动并正常退出。
- [ ] 两次重启恢复、终局和双线路隔离通过。
- [ ] 真实 LAPP 连接测试及一轮工具调用通过,日志无凭据。
**Verification:**
- [ ] `scripts/windows-smoke.ps1 -Launch -Demo`
- [ ] `scripts/windows-smoke.ps1 -Launch`
**Dependencies:** Task 7
**Files likely touched:** 仅状态报告;若冒烟发现缺陷则另建小任务。
**Estimated scope:** Medium
## Risks and Mitigations
| Risk | Impact | Mitigation |
|---|---|---|
| Gitea SSH 2222 被本机代理拦截 | 无法自动 fetch/push | 本地开发不依赖 fetch;提交前使用用户终端 SSH 或短期 HTTPS 凭据 |
| Rust/MSVC 未安装 | 无法关闭 Tauri 门禁 | 先完成纯文本跨平台修复;工具链作为显式环境任务 |
| 精确 pnpm 下载不稳定 | Web 验证延迟 | 保留锁文件;优先使用 Corepack 官方包并重试,不升级项目版本 |
| 祖先链错误按 branch_id 过滤 | 分叉后丢失共享历史 | 只按 parent_id 追溯并做兄弟分支隔离测试 |
| 压缩摘要被误当事实 | 删除缓存后剧情状态变化 | 检查点保持非权威,状态仍只由根状态 + delta 重建 |
| 风险预检变成第二套状态机 | 原子性回归 | 预检只产生短期确认数据,唯一 reducer / SQLite 提交路径不变 |
| 重新生成可刷骰 | 破坏公平性 | 判定绑定原 action/check;只有 PushCheck 创建新判定 |
## Open Questions
- 无产品阻塞问题。Rust/MSVC 安装若需要管理员交互,将在 Task 2 单独请求用户操作。
+62
View File
@@ -0,0 +1,62 @@
# Wave 7 Checklist
## 本地恢复
- [x] 从私有 Gitea 恢复 `integration/v1@23672e857b`
- [x] 确认 Git 对象完整且工作树干净。
- [x] 临时只读 Token 已撤销,认证临时文件与 bundle 已删除。
- [x] 克隆相邻 `lapp-rs` 并固定到 `5ba3c659…`
- [ ] 恢复可 fetch/push 的远端认证路径。
## Task 1:跨平台契约门禁
- [x] 添加文本换行约束。
- [x] Node 契约检查按 LF 规范化源码。
- [x] Rust 契约生成器按 LF 规范化源码。
- [x] 增加 LF / CRLF 等价测试。
- [x] Windows 上运行 Node 契约检查。
- [x] Rust 生成器检查与测试。
## Task 2:本机工具链
- [x] Node.js 24+。
- [x] WebView2。
- [x] pnpm 10.29.2。
- [x] Rust 1.96.0、rustfmt、clippy。
- [x] Microsoft C++ Build Tools 与 Windows SDK。
- [x] 安装锁定的 JavaScript 依赖。
## Task 3Wave 6 门禁
- [x] `pnpm verify:web`(25 份契约、29 项 Web 测试及生产构建通过)
- [x] `pnpm verify:rust` 等价门禁(171 项 Rust 测试与严格 Clippy 通过)
- [x] `pnpm tauri build --no-bundle`
## Task 4-5:连续上下文与检查点
- [x] Store 按 parent_id 读取当前祖先链。
- [x] 第三轮包含前两轮原始剧情。
- [x] 兄弟分支上下文隔离。
- [x] 稳定前缀与动态尾部固定编排。
- [x] LAPP 模型预算与缺省来源贯通。
- [x] 稳定前缀与叙事来源哈希。
- [x] `Regenerate` 从父节点创建兄弟分支并复用原判定 / 状态。
- [ ] 超预算检查点与来源哈希。
- [ ] SQLite v3 迁移与 500 节点测试。
## Task 6-7:风险 / 判定纵切
- [ ] 风险预检不落节点。
- [ ] 玩家确认后进入隐藏判定。
- [ ] 失败后提供推骰入口。
- [x] Runtime / Store 重新生成复用原判定。
- [ ] 推骰创建新行动与新判定。
- [ ] Vue / Tauri / Runtime / Store 测试全绿。
## Task 8:桌面冒烟
- [x] 隔离 Demo 窗口启动并创建存档。
- [ ] 重启恢复。
- [ ] 终局与双线路隔离。
- [ ] 真实 LAPP 连接。
- [ ] 真实隐藏判定工具调用。