This commit is contained in:
+587
-34
@@ -1,27 +1,41 @@
|
||||
use std::{fs, path::Path, sync::Mutex};
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use nana_domain::{
|
||||
AcquisitionMode, ActionSuggestion, AppInfo, BeatKind, BranchList, BranchSummary,
|
||||
CheckDifficulty, CheckRecord, CheckResult, DOMAIN_SCHEMA_VERSION, DemoPackSummary,
|
||||
ForkBranchRequest, ForkBranchResult, HistoryNodeView, ItemAcquisition, ItemInstance,
|
||||
ItemPlacement, KnowledgeCertainty, KnowledgeRecord, LappMode, LappModelOption, LappSettings,
|
||||
PlayerView, PresentationBeat, PresentationCharacter, PresentationScene, PresentationSnapshot,
|
||||
Promise, PromiseStatus, PromiseWeight, RelationshipAdjustment, RelationshipDimension,
|
||||
RenameBranchRequest, ResourceBundle, ResourceId, RuntimeState, StateDelta, StateOp, StoryNode,
|
||||
SwitchBranchRequest, SwitchBranchResult, TurnFailure, TurnFailureCode, TurnIntent, TurnRequest,
|
||||
TurnResult, UpdateLappSettingsRequest, ValidationIssue, VisualDirective, stable_json_hash,
|
||||
validate_bundle,
|
||||
ItemPlacement, KnowledgeCertainty, KnowledgeRecord, LappConnectionTestResult, LappMode,
|
||||
LappModelOption, LappSettings, PlayerView, PresentationBeat, PresentationCharacter,
|
||||
PresentationScene, PresentationSnapshot, Promise, PromiseStatus, PromiseWeight,
|
||||
RelationshipAdjustment, RelationshipDimension, RenameBranchRequest, ResourceBundle, ResourceId,
|
||||
RuntimeState, StateDelta, StateOp, StoryNode, SwitchBranchRequest, SwitchBranchResult,
|
||||
TurnFailure, TurnFailureCode, TurnIntent, TurnRequest, TurnResult, UpdateLappSettingsRequest,
|
||||
ValidationIssue, VisualDirective, stable_json_hash, validate_bundle,
|
||||
};
|
||||
use nana_engine::{
|
||||
SceneMetadata, StoryNodePlayerViewProjectionContext, project_story_node_player_view,
|
||||
};
|
||||
use nana_runtime::{
|
||||
AdjudicatingTurnPlanProvider, AdjudicationCatalog, LappAdjudicationModel, OpenLappChatExecutor,
|
||||
ProviderError, TurnEngine, TurnPlan, TurnPlanProvider, TurnProjector,
|
||||
load_default_lapp_profile,
|
||||
AdjudicatingTurnPlanProvider, AdjudicationCatalog, LappAdjudicationModel, LappNativeCallGate,
|
||||
LappNativeCallPermit, OpenLappChatExecutor, ProviderError, TurnControl, TurnEngine, TurnPlan,
|
||||
TurnPlanProvider, TurnProjector, load_default_lapp_profile,
|
||||
};
|
||||
use nana_store::{ForkError, SqliteStoryStore, StoreError, StoredBranch, StoryStore};
|
||||
use openlapp::{connection::ListModelsOptions, list_models};
|
||||
use openlapp::{
|
||||
ModelSelector,
|
||||
client::{Client, TestConnectionResult},
|
||||
connection::ListModelsOptions,
|
||||
credential::{CredentialResolver, DefaultCredentialResolver},
|
||||
list_models,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use tauri::Manager;
|
||||
|
||||
@@ -34,6 +48,32 @@ const PLAYER_ID: &str = "player";
|
||||
const CHARACTER_ID: &str = "nana";
|
||||
const LAPP_PROVIDER_SETTING: &str = "lapp.provider_id";
|
||||
const LAPP_MODEL_SETTING: &str = "lapp.model_id";
|
||||
const TURN_TIMEOUT: Duration = Duration::from_secs(90);
|
||||
const CONNECTION_TEST_TIMEOUT: Duration = Duration::from_secs(35);
|
||||
|
||||
fn story_data_dir(
|
||||
default_path: PathBuf,
|
||||
override_path: Option<PathBuf>,
|
||||
) -> Result<PathBuf, std::io::Error> {
|
||||
let Some(path) = override_path else {
|
||||
return Ok(default_path);
|
||||
};
|
||||
if !path.is_absolute() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"NANA_STORY_SMOKE_DATA_DIR must be an absolute path",
|
||||
));
|
||||
}
|
||||
let resolved = fs::canonicalize(path)?;
|
||||
let temporary_root = fs::canonicalize(std::env::temp_dir())?;
|
||||
if resolved == temporary_root || !resolved.starts_with(&temporary_root) {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"NANA_STORY_SMOKE_DATA_DIR must be a child of the temporary directory",
|
||||
));
|
||||
}
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -99,6 +139,51 @@ impl CommandError {
|
||||
}
|
||||
}
|
||||
|
||||
fn turn_in_progress() -> Self {
|
||||
Self {
|
||||
code: "turn_in_progress".to_owned(),
|
||||
message: "已有一轮故事正在生成。".to_owned(),
|
||||
retryable: true,
|
||||
issues: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn background_task() -> Self {
|
||||
Self {
|
||||
code: "internal".to_owned(),
|
||||
message: "故事回合未能完成。".to_owned(),
|
||||
retryable: true,
|
||||
issues: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn connection_test_in_progress() -> Self {
|
||||
Self {
|
||||
code: "connection_test_in_progress".to_owned(),
|
||||
message: "已有一项模型连接测试正在进行。".to_owned(),
|
||||
retryable: true,
|
||||
issues: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn connection_test_timed_out() -> Self {
|
||||
Self {
|
||||
code: "timed_out".to_owned(),
|
||||
message: "模型连接测试等待超时。".to_owned(),
|
||||
retryable: true,
|
||||
issues: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn connection_background_task() -> Self {
|
||||
Self {
|
||||
code: "internal".to_owned(),
|
||||
message: "模型连接测试未能完成。".to_owned(),
|
||||
retryable: true,
|
||||
issues: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn fork(error: &ForkError) -> Self {
|
||||
match error {
|
||||
ForkError::BranchAlreadyExists { .. } => Self::stale_branch(),
|
||||
@@ -121,6 +206,50 @@ struct DemoAppState {
|
||||
bundle: ResourceBundle,
|
||||
operation_lock: Mutex<()>,
|
||||
provider: Mutex<RuntimePlanProvider>,
|
||||
in_flight_turn: Arc<Mutex<Option<InFlightTurn>>>,
|
||||
connection_test_in_flight: Arc<AtomicBool>,
|
||||
lapp_native_call_gate: LappNativeCallGate,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct InFlightTurn {
|
||||
action_id: String,
|
||||
control: TurnControl,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct InFlightTurnGuard {
|
||||
slot: Arc<Mutex<Option<InFlightTurn>>>,
|
||||
action_id: String,
|
||||
}
|
||||
|
||||
impl Drop for InFlightTurnGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Ok(mut slot) = self.slot.lock()
|
||||
&& slot
|
||||
.as_ref()
|
||||
.is_some_and(|turn| turn.action_id == self.action_id)
|
||||
{
|
||||
*slot = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ConnectionTestGuard {
|
||||
in_flight: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl Drop for ConnectionTestGuard {
|
||||
fn drop(&mut self) {
|
||||
self.in_flight.store(false, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
struct AppliedLappTarget {
|
||||
profile: openlapp::Profile,
|
||||
provider_id: String,
|
||||
model_id: String,
|
||||
}
|
||||
|
||||
type LappRuntimeProvider =
|
||||
@@ -130,10 +259,26 @@ enum RuntimePlanProvider {
|
||||
Demo(DemoPlanProvider),
|
||||
Lapp(Box<LappRuntimeProvider>),
|
||||
Unavailable,
|
||||
#[cfg(test)]
|
||||
RetiredTest,
|
||||
}
|
||||
|
||||
impl RuntimePlanProvider {
|
||||
fn configured(bundle: &ResourceBundle, store: &SqliteStoryStore) -> Self {
|
||||
#[cfg(test)]
|
||||
fn configured(
|
||||
_bundle: &ResourceBundle,
|
||||
_store: &SqliteStoryStore,
|
||||
_native_call_gate: LappNativeCallGate,
|
||||
) -> Self {
|
||||
Self::Demo(DemoPlanProvider)
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
fn configured(
|
||||
bundle: &ResourceBundle,
|
||||
store: &SqliteStoryStore,
|
||||
native_call_gate: LappNativeCallGate,
|
||||
) -> Self {
|
||||
if demo_provider_requested() {
|
||||
return Self::Demo(DemoPlanProvider);
|
||||
}
|
||||
@@ -158,17 +303,27 @@ impl RuntimePlanProvider {
|
||||
{
|
||||
return Self::Unavailable;
|
||||
}
|
||||
let model = LappAdjudicationModel::from_profile_and_model(
|
||||
let model = LappAdjudicationModel::from_profile_and_model_with_gate(
|
||||
&profile,
|
||||
&provider_id,
|
||||
&model_id,
|
||||
bundle.clone(),
|
||||
native_call_gate,
|
||||
);
|
||||
let Ok(model) = model else {
|
||||
return Self::Unavailable;
|
||||
};
|
||||
Self::Lapp(Box::new(AdjudicatingTurnPlanProvider::new(model, catalog)))
|
||||
}
|
||||
|
||||
fn is_retired(&self) -> bool {
|
||||
match self {
|
||||
Self::Lapp(provider) => provider.model().executor().is_retired(),
|
||||
#[cfg(test)]
|
||||
Self::RetiredTest => true,
|
||||
Self::Demo(_) | Self::Unavailable => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn demo_provider_requested() -> bool {
|
||||
@@ -185,6 +340,23 @@ impl TurnPlanProvider for RuntimePlanProvider {
|
||||
Self::Demo(provider) => provider.plan_turn(request, state),
|
||||
Self::Lapp(provider) => provider.plan_turn(request, state),
|
||||
Self::Unavailable => Err(ProviderError::Configuration { code: None }),
|
||||
#[cfg(test)]
|
||||
Self::RetiredTest => Err(ProviderError::Cancelled),
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_turn_with_control(
|
||||
&mut self,
|
||||
request: &TurnRequest,
|
||||
state: &RuntimeState,
|
||||
control: &TurnControl,
|
||||
) -> Result<TurnPlan, ProviderError> {
|
||||
match self {
|
||||
Self::Demo(provider) => provider.plan_turn_with_control(request, state, control),
|
||||
Self::Lapp(provider) => provider.plan_turn_with_control(request, state, control),
|
||||
Self::Unavailable => Err(ProviderError::Configuration { code: None }),
|
||||
#[cfg(test)]
|
||||
Self::RetiredTest => Err(ProviderError::Cancelled),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -225,10 +397,11 @@ impl DemoAppState {
|
||||
Err(error) => return Err(CommandError::storage(&error)),
|
||||
}
|
||||
|
||||
let lapp_native_call_gate = LappNativeCallGate::new();
|
||||
let provider = if cfg!(test) {
|
||||
RuntimePlanProvider::Demo(DemoPlanProvider)
|
||||
} else {
|
||||
RuntimePlanProvider::configured(&bundle, &store)
|
||||
RuntimePlanProvider::configured(&bundle, &store, lapp_native_call_gate.clone())
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
@@ -236,6 +409,9 @@ impl DemoAppState {
|
||||
bundle,
|
||||
operation_lock: Mutex::new(()),
|
||||
provider: Mutex::new(provider),
|
||||
in_flight_turn: Arc::new(Mutex::new(None)),
|
||||
connection_test_in_flight: Arc::new(AtomicBool::new(false)),
|
||||
lapp_native_call_gate,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -271,7 +447,18 @@ impl DemoAppState {
|
||||
Ok(self.project_view_with_lineage(&state, &node, &lineage))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn submit_turn(&self, request: &TurnRequest) -> Result<TurnResult, CommandError> {
|
||||
let control = TurnControl::with_timeout(TURN_TIMEOUT);
|
||||
let _in_flight = self.register_turn(&request.action_id, &control)?;
|
||||
self.submit_turn_with_control(request, &control)
|
||||
}
|
||||
|
||||
fn submit_turn_with_control(
|
||||
&self,
|
||||
request: &TurnRequest,
|
||||
control: &TurnControl,
|
||||
) -> Result<TurnResult, CommandError> {
|
||||
let _operation = self
|
||||
.operation_lock
|
||||
.lock()
|
||||
@@ -301,9 +488,55 @@ impl DemoAppState {
|
||||
.provider
|
||||
.lock()
|
||||
.map_err(|_| CommandError::operation_lock())?;
|
||||
let projector = DemoProjector { app: self };
|
||||
let mut engine = TurnEngine::new(&self.store, &mut *provider, projector);
|
||||
engine.submit_turn(request).map_err(CommandError::turn)
|
||||
let result = {
|
||||
let projector = DemoProjector { app: self };
|
||||
let mut engine = TurnEngine::new(&self.store, &mut *provider, projector);
|
||||
engine.submit_turn_with_control(request, control)
|
||||
};
|
||||
if provider.is_retired() {
|
||||
// An interrupted LAPP request may leave one isolated native Vault
|
||||
// call running. Replace the retired executor before the UI can
|
||||
// offer its single safe retry.
|
||||
*provider = RuntimePlanProvider::configured(
|
||||
&self.bundle,
|
||||
&self.store,
|
||||
self.lapp_native_call_gate.clone(),
|
||||
);
|
||||
}
|
||||
result.map_err(CommandError::turn)
|
||||
}
|
||||
|
||||
fn register_turn(
|
||||
&self,
|
||||
action_id: &str,
|
||||
control: &TurnControl,
|
||||
) -> Result<InFlightTurnGuard, CommandError> {
|
||||
let mut slot = self
|
||||
.in_flight_turn
|
||||
.lock()
|
||||
.map_err(|_| CommandError::operation_lock())?;
|
||||
if slot.is_some() {
|
||||
return Err(CommandError::turn_in_progress());
|
||||
}
|
||||
*slot = Some(InFlightTurn {
|
||||
action_id: action_id.to_owned(),
|
||||
control: control.clone(),
|
||||
});
|
||||
Ok(InFlightTurnGuard {
|
||||
slot: Arc::clone(&self.in_flight_turn),
|
||||
action_id: action_id.to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
fn cancel_turn(&self, action_id: &str) -> Result<bool, CommandError> {
|
||||
let slot = self
|
||||
.in_flight_turn
|
||||
.lock()
|
||||
.map_err(|_| CommandError::operation_lock())?;
|
||||
Ok(slot
|
||||
.as_ref()
|
||||
.filter(|turn| turn.action_id == action_id)
|
||||
.is_some_and(|turn| turn.control.cancel()))
|
||||
}
|
||||
|
||||
fn fork_branch(&self, request: &ForkBranchRequest) -> Result<ForkBranchResult, CommandError> {
|
||||
@@ -496,7 +729,7 @@ impl DemoAppState {
|
||||
let (mode, status_message) = if selected_is_available {
|
||||
(
|
||||
LappMode::Lapp,
|
||||
"已读取 LAPP profile;凭据只会在生成时由系统 Vault 解析。".to_owned(),
|
||||
"已读取 LAPP profile;凭据仅在生成或你主动测试连接时由系统 Vault 解析。".to_owned(),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
@@ -543,11 +776,12 @@ impl DemoAppState {
|
||||
retryable: false,
|
||||
issues: Vec::new(),
|
||||
})?;
|
||||
let model = LappAdjudicationModel::from_profile_and_model(
|
||||
let model = LappAdjudicationModel::from_profile_and_model_with_gate(
|
||||
&profile,
|
||||
&request.provider_id,
|
||||
&request.model_id,
|
||||
self.bundle.clone(),
|
||||
self.lapp_native_call_gate.clone(),
|
||||
)
|
||||
.map_err(|_| CommandError {
|
||||
code: "provider_unavailable".to_owned(),
|
||||
@@ -577,6 +811,60 @@ impl DemoAppState {
|
||||
Ok(self.lapp_settings())
|
||||
}
|
||||
|
||||
fn applied_lapp_target(&self) -> Result<AppliedLappTarget, CommandError> {
|
||||
if demo_provider_requested() {
|
||||
return Err(CommandError::invalid_input(
|
||||
"确定性演示模式不连接外部模型。",
|
||||
));
|
||||
}
|
||||
let _operation = self
|
||||
.operation_lock
|
||||
.try_lock()
|
||||
.map_err(|_| CommandError::connection_test_in_progress())?;
|
||||
let profile = load_default_lapp_profile().map_err(|_| CommandError {
|
||||
code: "provider_unavailable".to_owned(),
|
||||
message: "未找到可用的 LAPP profile。".to_owned(),
|
||||
retryable: true,
|
||||
issues: Vec::new(),
|
||||
})?;
|
||||
let (provider_id, model_id) = selected_lapp_model(
|
||||
&profile,
|
||||
self.store
|
||||
.get_app_setting(LAPP_PROVIDER_SETTING)
|
||||
.map_err(|error| CommandError::storage(&error))?,
|
||||
self.store
|
||||
.get_app_setting(LAPP_MODEL_SETTING)
|
||||
.map_err(|error| CommandError::storage(&error))?,
|
||||
);
|
||||
let Some((provider_id, model_id)) = provider_id.zip(model_id) else {
|
||||
return Err(CommandError::invalid_input(
|
||||
"请先应用一个支持聊天与工具调用的模型。",
|
||||
));
|
||||
};
|
||||
if !lapp_model_options(&profile)
|
||||
.iter()
|
||||
.any(|model| model.provider_id == provider_id && model.model_id == model_id)
|
||||
{
|
||||
return Err(CommandError::invalid_input(
|
||||
"当前应用的模型不可用,或未声明聊天与工具调用能力。",
|
||||
));
|
||||
}
|
||||
Ok(AppliedLappTarget {
|
||||
profile,
|
||||
provider_id,
|
||||
model_id,
|
||||
})
|
||||
}
|
||||
|
||||
fn register_connection_test(&self) -> Result<ConnectionTestGuard, CommandError> {
|
||||
self.connection_test_in_flight
|
||||
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||
.map_err(|_| CommandError::connection_test_in_progress())?;
|
||||
Ok(ConnectionTestGuard {
|
||||
in_flight: Arc::clone(&self.connection_test_in_flight),
|
||||
})
|
||||
}
|
||||
|
||||
fn project_view(&self, state: &RuntimeState, node: &StoryNode) -> PlayerView {
|
||||
let lineage = self
|
||||
.load_lineage(node)
|
||||
@@ -1414,6 +1702,10 @@ const fn turn_failure_code(code: &TurnFailureCode) -> &'static str {
|
||||
TurnFailureCode::StaleNode => "stale_node",
|
||||
TurnFailureCode::InvalidInput => "invalid_input",
|
||||
TurnFailureCode::InvalidModelOutput => "invalid_model_output",
|
||||
TurnFailureCode::ProviderConfiguration => "provider_configuration",
|
||||
TurnFailureCode::ProviderCredentials => "provider_credentials",
|
||||
TurnFailureCode::ProviderRateLimited => "provider_rate_limited",
|
||||
TurnFailureCode::ProviderRejected => "provider_rejected",
|
||||
TurnFailureCode::ProviderUnavailable => "provider_unavailable",
|
||||
TurnFailureCode::Cancelled => "cancelled",
|
||||
TurnFailureCode::TimedOut => "timed_out",
|
||||
@@ -1421,6 +1713,46 @@ const fn turn_failure_code(code: &TurnFailureCode) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn project_connection_test(result: TestConnectionResult) -> LappConnectionTestResult {
|
||||
let message = if result.ok {
|
||||
"连接成功,模型能够响应最小请求。".to_owned()
|
||||
} else {
|
||||
"连接失败,请检查 LAPP 配置与网络。".to_owned()
|
||||
};
|
||||
LappConnectionTestResult {
|
||||
ok: result.ok,
|
||||
provider_id: result.provider_id,
|
||||
model_id: result.model_id,
|
||||
message,
|
||||
diagnostic_code: result.code.map(|code| code.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_lapp_connection_test(
|
||||
target: AppliedLappTarget,
|
||||
native_call_permit: LappNativeCallPermit,
|
||||
) -> Result<LappConnectionTestResult, CommandError> {
|
||||
let _native_call_permit = native_call_permit;
|
||||
let selector = ModelSelector::Explicit {
|
||||
provider_id: target.provider_id,
|
||||
model: target.model_id,
|
||||
};
|
||||
let resolver: Arc<dyn CredentialResolver> = Arc::new(DefaultCredentialResolver::system());
|
||||
let client = Client::new(&target.profile, &selector, resolver).map_err(|_| CommandError {
|
||||
code: "provider_configuration".to_owned(),
|
||||
message: "当前应用的 LAPP 模型无法初始化。".to_owned(),
|
||||
retryable: false,
|
||||
issues: Vec::new(),
|
||||
})?;
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|_| CommandError::connection_background_task())?;
|
||||
Ok(project_connection_test(
|
||||
runtime.block_on(client.test_connection()),
|
||||
))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_app_info() -> AppInfo {
|
||||
AppInfo {
|
||||
@@ -1432,26 +1764,28 @@ fn get_app_info() -> AppInfo {
|
||||
|
||||
#[tauri::command]
|
||||
#[allow(clippy::needless_pass_by_value)] // Tauri extracts managed state through this owned wrapper.
|
||||
fn get_demo_player_view(state: tauri::State<'_, DemoAppState>) -> Result<PlayerView, CommandError> {
|
||||
fn get_demo_player_view(
|
||||
state: tauri::State<'_, Arc<DemoAppState>>,
|
||||
) -> Result<PlayerView, CommandError> {
|
||||
state.current_player_view()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[allow(clippy::needless_pass_by_value)] // Tauri extracts managed state through this owned wrapper.
|
||||
fn get_demo_pack_summary(state: tauri::State<'_, DemoAppState>) -> DemoPackSummary {
|
||||
fn get_demo_pack_summary(state: tauri::State<'_, Arc<DemoAppState>>) -> DemoPackSummary {
|
||||
state.pack_summary()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[allow(clippy::needless_pass_by_value)] // Tauri extracts managed state through this owned wrapper.
|
||||
fn get_branch_list(state: tauri::State<'_, DemoAppState>) -> Result<BranchList, CommandError> {
|
||||
fn get_branch_list(state: tauri::State<'_, Arc<DemoAppState>>) -> Result<BranchList, CommandError> {
|
||||
state.branch_list()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[allow(clippy::needless_pass_by_value)] // Tauri deserializes command arguments into owned values.
|
||||
fn switch_branch(
|
||||
state: tauri::State<'_, DemoAppState>,
|
||||
state: tauri::State<'_, Arc<DemoAppState>>,
|
||||
request: SwitchBranchRequest,
|
||||
) -> Result<SwitchBranchResult, CommandError> {
|
||||
state.switch_branch(&request)
|
||||
@@ -1460,7 +1794,7 @@ fn switch_branch(
|
||||
#[tauri::command]
|
||||
#[allow(clippy::needless_pass_by_value)] // Tauri deserializes command arguments into owned values.
|
||||
fn rename_branch(
|
||||
state: tauri::State<'_, DemoAppState>,
|
||||
state: tauri::State<'_, Arc<DemoAppState>>,
|
||||
request: RenameBranchRequest,
|
||||
) -> Result<BranchList, CommandError> {
|
||||
state.rename_branch(&request)
|
||||
@@ -1468,32 +1802,77 @@ fn rename_branch(
|
||||
|
||||
#[tauri::command]
|
||||
#[allow(clippy::needless_pass_by_value)] // Tauri extracts managed state through this owned wrapper.
|
||||
fn get_lapp_settings(state: tauri::State<'_, DemoAppState>) -> LappSettings {
|
||||
fn get_lapp_settings(state: tauri::State<'_, Arc<DemoAppState>>) -> LappSettings {
|
||||
state.lapp_settings()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[allow(clippy::needless_pass_by_value)] // Tauri deserializes command arguments into owned values.
|
||||
fn update_lapp_settings(
|
||||
state: tauri::State<'_, DemoAppState>,
|
||||
state: tauri::State<'_, Arc<DemoAppState>>,
|
||||
request: UpdateLappSettingsRequest,
|
||||
) -> Result<LappSettings, CommandError> {
|
||||
state.update_lapp_settings(&request)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[allow(clippy::needless_pass_by_value)] // Tauri extracts managed state through this owned wrapper.
|
||||
async fn test_lapp_connection(
|
||||
state: tauri::State<'_, Arc<DemoAppState>>,
|
||||
) -> Result<LappConnectionTestResult, CommandError> {
|
||||
let state = Arc::clone(state.inner());
|
||||
let target = state.applied_lapp_target()?;
|
||||
let in_flight = state.register_connection_test()?;
|
||||
let native_call_permit = state
|
||||
.lapp_native_call_gate
|
||||
.try_acquire()
|
||||
.ok_or_else(CommandError::connection_test_in_progress)?;
|
||||
let (sender, receiver) = tokio::sync::oneshot::channel();
|
||||
std::thread::Builder::new()
|
||||
.name("nana-lapp-connection-test".into())
|
||||
.spawn(move || {
|
||||
let _in_flight = in_flight;
|
||||
let _ = sender.send(run_lapp_connection_test(target, native_call_permit));
|
||||
})
|
||||
.map_err(|_| CommandError::connection_background_task())?;
|
||||
|
||||
match tokio::time::timeout(CONNECTION_TEST_TIMEOUT, receiver).await {
|
||||
Ok(Ok(result)) => result,
|
||||
Ok(Err(_)) => Err(CommandError::connection_background_task()),
|
||||
Err(_) => Err(CommandError::connection_test_timed_out()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[allow(clippy::needless_pass_by_value)] // Tauri deserializes command arguments into owned values.
|
||||
fn submit_turn(
|
||||
state: tauri::State<'_, DemoAppState>,
|
||||
async fn submit_turn(
|
||||
state: tauri::State<'_, Arc<DemoAppState>>,
|
||||
request: TurnRequest,
|
||||
) -> Result<TurnResult, CommandError> {
|
||||
state.submit_turn(&request)
|
||||
let state = Arc::clone(state.inner());
|
||||
let control = TurnControl::with_timeout(TURN_TIMEOUT);
|
||||
let in_flight = state.register_turn(&request.action_id, &control)?;
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let _in_flight = in_flight;
|
||||
state.submit_turn_with_control(&request, &control)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| CommandError::background_task())?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[allow(clippy::needless_pass_by_value)] // Tauri extracts managed state and owned command data.
|
||||
fn cancel_turn(
|
||||
state: tauri::State<'_, Arc<DemoAppState>>,
|
||||
action_id: String,
|
||||
) -> Result<bool, CommandError> {
|
||||
state.cancel_turn(&action_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[allow(clippy::needless_pass_by_value)] // Tauri deserializes command arguments into owned values.
|
||||
fn fork_branch(
|
||||
state: tauri::State<'_, DemoAppState>,
|
||||
state: tauri::State<'_, Arc<DemoAppState>>,
|
||||
request: ForkBranchRequest,
|
||||
) -> Result<ForkBranchResult, CommandError> {
|
||||
state.fork_branch(&request)
|
||||
@@ -1508,11 +1887,14 @@ fn fork_branch(
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.setup(|app| {
|
||||
let app_data = app.path().app_data_dir()?;
|
||||
let app_data = story_data_dir(
|
||||
app.path().app_data_dir()?,
|
||||
std::env::var_os("NANA_STORY_SMOKE_DATA_DIR").map(PathBuf::from),
|
||||
)?;
|
||||
fs::create_dir_all(&app_data)?;
|
||||
let demo = DemoAppState::open(app_data.join("nana-story.sqlite3"))
|
||||
.map_err(|error| std::io::Error::other(error.message))?;
|
||||
app.manage(demo);
|
||||
app.manage(Arc::new(demo));
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
@@ -1524,7 +1906,9 @@ pub fn run() {
|
||||
rename_branch,
|
||||
get_lapp_settings,
|
||||
update_lapp_settings,
|
||||
test_lapp_connection,
|
||||
submit_turn,
|
||||
cancel_turn,
|
||||
fork_branch
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
@@ -1536,6 +1920,8 @@ mod tests {
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
thread,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
@@ -1544,10 +1930,13 @@ mod tests {
|
||||
RelationshipAdjustment, RelationshipBand, RelationshipDimension, RenameBranchRequest,
|
||||
StateDelta, StateOp, StoryNode, SwitchBranchRequest, TurnIntent, TurnRequest,
|
||||
};
|
||||
use nana_runtime::TurnControl;
|
||||
use nana_store::StoryStore;
|
||||
use openlapp::{ErrorCode, client::TestConnectionResult};
|
||||
|
||||
use super::{
|
||||
DEMO_BRANCH_ID, DEMO_STORY_ID, DemoAppState, history_label, last_player_relationship_update,
|
||||
DEMO_BRANCH_ID, DEMO_STORY_ID, DemoAppState, RuntimePlanProvider, history_label,
|
||||
last_player_relationship_update, project_connection_test, story_data_dir,
|
||||
};
|
||||
|
||||
struct TemporaryDatabase {
|
||||
@@ -1581,6 +1970,170 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lapp_connection_result_keeps_only_safe_diagnostics() {
|
||||
let projected = project_connection_test(TestConnectionResult {
|
||||
ok: false,
|
||||
provider_id: "provider.safe".to_owned(),
|
||||
model_id: "model.safe".to_owned(),
|
||||
protocol: "openai-responses".to_owned(),
|
||||
code: Some(ErrorCode::HttpStatus),
|
||||
message: Some("api_key=must-not-cross-app-boundary".to_owned()),
|
||||
});
|
||||
|
||||
assert!(!projected.ok);
|
||||
assert_eq!(projected.provider_id, "provider.safe");
|
||||
assert_eq!(projected.model_id, "model.safe");
|
||||
assert_eq!(projected.diagnostic_code.as_deref(), Some("HTTP_STATUS"));
|
||||
assert_eq!(projected.message, "连接失败,请检查 LAPP 配置与网络。");
|
||||
assert!(!projected.message.contains("must-not-cross"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smoke_data_override_requires_an_existing_absolute_temporary_child() {
|
||||
let default = PathBuf::from("default-data");
|
||||
assert_eq!(
|
||||
story_data_dir(default.clone(), None).expect("default path"),
|
||||
default
|
||||
);
|
||||
assert!(story_data_dir(PathBuf::from("default"), Some(std::env::temp_dir())).is_err());
|
||||
|
||||
let nonce = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos();
|
||||
let smoke = std::env::temp_dir().join(format!(
|
||||
"nana-story-smoke-dir-{}-{nonce}",
|
||||
std::process::id()
|
||||
));
|
||||
fs::create_dir_all(&smoke).expect("create smoke directory");
|
||||
|
||||
let resolved = story_data_dir(PathBuf::from("unused"), Some(smoke.clone()))
|
||||
.expect("temporary override");
|
||||
assert_eq!(resolved, fs::canonicalize(&smoke).expect("canonical smoke"));
|
||||
assert!(story_data_dir(PathBuf::from("default"), Some(PathBuf::from("relative"))).is_err());
|
||||
|
||||
fs::remove_dir(&smoke).expect("remove smoke directory");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn smoke_data_override_rejects_a_symlink_escape() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let nonce = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos();
|
||||
let outside = std::env::current_dir()
|
||||
.expect("current directory")
|
||||
.join(format!("nana-story-smoke-outside-{nonce}"));
|
||||
fs::create_dir_all(&outside).expect("create outside target");
|
||||
let temporary_root = fs::canonicalize(std::env::temp_dir()).expect("temporary root");
|
||||
let outside_resolved = fs::canonicalize(&outside).expect("outside target");
|
||||
if !outside_resolved.starts_with(&temporary_root) {
|
||||
let link = std::env::temp_dir().join(format!(
|
||||
"nana-story-smoke-link-{}-{nonce}",
|
||||
std::process::id()
|
||||
));
|
||||
symlink(&outside, &link).expect("create escape symlink");
|
||||
assert!(story_data_dir(PathBuf::from("default"), Some(link.clone())).is_err());
|
||||
fs::remove_file(link).expect("remove escape symlink");
|
||||
}
|
||||
fs::remove_dir(outside).expect("remove outside target");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_flight_turn_registry_accepts_cancel_and_rejects_overlap() {
|
||||
let app = DemoAppState::open_in_memory().expect("app");
|
||||
let control = TurnControl::new();
|
||||
let guard = app
|
||||
.register_turn("action_in_flight", &control)
|
||||
.expect("register turn");
|
||||
|
||||
let overlapping = app
|
||||
.register_turn("action_overlap", &TurnControl::new())
|
||||
.expect_err("only one turn may run");
|
||||
assert_eq!(overlapping.code, "turn_in_progress");
|
||||
assert!(app.cancel_turn("action_in_flight").expect("cancel"));
|
||||
assert!(control.is_cancelled());
|
||||
assert!(!app.cancel_turn("action_other").expect("wrong action"));
|
||||
|
||||
drop(guard);
|
||||
assert!(
|
||||
!app.cancel_turn("action_in_flight")
|
||||
.expect("cleared registry")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_test_registry_allows_only_one_worker() {
|
||||
let app = DemoAppState::open_in_memory().expect("app");
|
||||
let guard = app
|
||||
.register_connection_test()
|
||||
.expect("first connection test");
|
||||
let overlapping = app
|
||||
.register_connection_test()
|
||||
.expect_err("connection tests must not overlap");
|
||||
assert_eq!(overlapping.code, "connection_test_in_progress");
|
||||
|
||||
drop(guard);
|
||||
app.register_connection_test()
|
||||
.expect("connection slot should be released");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_before_worker_start_does_not_move_the_branch_head() {
|
||||
let app = Arc::new(DemoAppState::open_in_memory().expect("app"));
|
||||
let before = app.current_player_view().expect("initial view");
|
||||
let control = TurnControl::new();
|
||||
let request = promise_request(&before.node_id);
|
||||
let in_flight = app
|
||||
.register_turn(&request.action_id, &control)
|
||||
.expect("register before scheduling");
|
||||
assert!(app.cancel_turn(&request.action_id).expect("cancel pending"));
|
||||
|
||||
let worker_app = Arc::clone(&app);
|
||||
let failure = thread::spawn(move || {
|
||||
let _in_flight = in_flight;
|
||||
worker_app.submit_turn_with_control(&request, &control)
|
||||
})
|
||||
.join()
|
||||
.expect("worker")
|
||||
.expect_err("cancelled turn");
|
||||
|
||||
assert_eq!(failure.code, "cancelled");
|
||||
assert!(failure.retryable);
|
||||
assert_eq!(
|
||||
app.current_player_view().expect("unchanged view").node_id,
|
||||
before.node_id
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retired_lapp_provider_is_rebuilt_before_same_action_retry() {
|
||||
let app = DemoAppState::open_in_memory().expect("app");
|
||||
assert!(!RuntimePlanProvider::Unavailable.is_retired());
|
||||
let before = app.current_player_view().expect("initial view");
|
||||
let request = promise_request(&before.node_id);
|
||||
*app.provider.lock().expect("provider") = RuntimePlanProvider::RetiredTest;
|
||||
|
||||
let failure = app
|
||||
.submit_turn(&request)
|
||||
.expect_err("retired provider should surface its interruption");
|
||||
assert_eq!(failure.code, "cancelled");
|
||||
assert_eq!(
|
||||
app.current_player_view().expect("unchanged view").node_id,
|
||||
before.node_id
|
||||
);
|
||||
|
||||
let retried = app
|
||||
.submit_turn(&request)
|
||||
.expect("same action should reach rebuilt provider");
|
||||
assert_ne!(retried.committed_node_id, before.node_id);
|
||||
assert_eq!(retried.player_view.node_id, retried.committed_node_id);
|
||||
}
|
||||
|
||||
fn promise_request(expected_node_id: &str) -> TurnRequest {
|
||||
TurnRequest {
|
||||
story_id: DEMO_STORY_ID.to_owned(),
|
||||
|
||||
Reference in New Issue
Block a user