//! Pi-first Page AI Lab — MNote 托管的后端垂直切片。 //! //! 该模块默认启用 MNote 托管的 Pi Rust Page AI 后端;OpenHub 仅保留为迁移期兼容 / admin 边界。 //! Pi 进程通过 RPC subprocess 托管,MNote bridge tools 在 Rust 后端按 allowed roots 执行权限校验。 use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; use crate::hermes_tools::knowledge_rag as knowledge_rag_agent_output; use crate::routes::{ai_settings, knowledge_rag, local_folder_source}; use axum::extract::{Extension, Query, State}; use axum::http::{HeaderMap, StatusCode}; use axum::response::sse::{Event as SseEvent, KeepAlive, Sse}; use axum::response::{Html, IntoResponse, Response}; use axum::Json; use control_plane::{ AppendAiFilePatchInput, AppendAiRuntimeEventInput, AppendAiToolEventInput, ControlPlaneStore, UpsertAiRuntimeRunInput, }; use futures_util::stream::{self, StreamExt}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::{HashMap, HashSet}; use std::convert::Infallible; use std::fs::{self, OpenOptions}; use std::io::{Read as _, Write as _}; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::sync::{Arc, LazyLock, Mutex as StdMutex}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::process::{Child, ChildStdin, Command}; use tokio::sync::{broadcast, oneshot, Mutex as AsyncMutex}; use tokio_stream::wrappers::BroadcastStream; const PI_LAB_VERSION: &str = "0.1.0-pi-lab-spike"; const PI_LAB_PROVIDER: &str = "pi"; const PI_LAB_SCHEMA_STATUS: &str = "mnote.page_ai_pi.status.v1"; const PI_LAB_SCHEMA_RECEIPT: &str = "mnote.page_ai_pi.tool_receipt.v1"; const PI_LAB_SCHEMA_EVENT: &str = "mnote.page_ai_pi.event.v1"; const PI_LAB_SCHEMA_STATE: &str = "mnote.page_ai_pi.state.v1"; const PI_LAB_SCHEMA_COMPACT: &str = "mnote.page_ai_pi.compact.v1"; const PI_LAB_SCHEMA_QUEUE_CONFIG: &str = "mnote.page_ai_pi.queue_config.v1"; const PI_LAB_DEFAULT_MODEL_PROVIDER: &str = "omniroute"; const PI_LAB_SCHEMA_SESSION_TREE: &str = "mnote.page_ai_pi.session_tree.v1"; const PI_LAB_SCHEMA_FORK: &str = "mnote.page_ai_pi.fork.v1"; const PI_LAB_SCHEMA_ARTIFACT_DIFF: &str = "mnote.page_ai_pi.artifact_diff.v1"; const PI_LAB_DEFAULT_MODEL_ID: &str = "gpt-5.4-mini"; const PI_LAB_DEFAULT_OMNIROUTE_BASE_URL: &str = "http://127.0.0.1:20128/v1"; const HEADER_PI_LAB_BRIDGE_TOKEN: &str = "x-mnote-pi-lab-bridge-token"; const PI_LAB_SESSION_TTL_MS: u128 = 30 * 60 * 1000; const PI_LAB_MAX_SESSIONS: usize = 16; const PI_LAB_RATE_WINDOW_MS: u128 = 10_000; const PI_LAB_MAX_STARTS_PER_WINDOW: usize = 4; const PI_LAB_MAX_SENDS_PER_WINDOW: usize = 12; const PI_LAB_MAX_TOOLS_PER_WINDOW: usize = 40; const PI_LAB_RUNTIME_IMPL_RUST: &str = "pi-rust"; const PI_LAB_RUNTIME_IMPL_TS: &str = "pi-ts"; const PI_LAB_MANAGED_BUILTIN_TOOLS: [&str; 8] = [ "read", "write", "edit", "bash", "grep", "find", "ls", "hashline_edit", ]; const PI_LAB_MCP_CACHE_FILE: &str = "mcp-cache.json"; const PI_LAB_MCP_BRIDGE_TIMEOUT_MS: u64 = 45_000; const PI_LAB_MCP_BRIDGE_MAX_OUTPUT_BYTES: usize = 2 * 1024 * 1024; pub const PI_LAB_PROFILE: &str = "pi_lab"; pub const PI_LAB_ACP_RUNTIME: &str = "pi"; static PI_LAB_SESSIONS: LazyLock>> = LazyLock::new(|| StdMutex::new(HashMap::new())); static PI_LAB_PROCESSES: LazyLock>> = LazyLock::new(|| StdMutex::new(HashMap::new())); static PI_LAB_RECEIPT_STORE: LazyLock>> = LazyLock::new(|| StdMutex::new(Vec::new())); static PI_LAB_RATE_LIMITS: LazyLock>>> = LazyLock::new(|| StdMutex::new(HashMap::new())); static PI_LAB_APPROVALS: LazyLock>> = LazyLock::new(|| StdMutex::new(HashMap::new())); static PI_LAB_UI_RESPONSES: LazyLock>> = LazyLock::new(|| StdMutex::new(HashMap::new())); static PI_LAB_EVENT_TX: LazyLock> = LazyLock::new(|| { let (tx, _) = broadcast::channel(1024); tx }); static PI_LAB_PENDING_RPC_RESPONSES: LazyLock>>> = LazyLock::new(|| AsyncMutex::new(HashMap::new())); #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum PiLabSessionStatus { Idle, RuntimeRunning, TurnRunning, Aborted, Error, } fn status_after_agent_end(status: &PiLabSessionStatus) -> PiLabSessionStatus { match status { PiLabSessionStatus::Aborted => PiLabSessionStatus::Aborted, PiLabSessionStatus::Error => PiLabSessionStatus::Error, PiLabSessionStatus::Idle | PiLabSessionStatus::RuntimeRunning | PiLabSessionStatus::TurnRunning => PiLabSessionStatus::RuntimeRunning, } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PiLabSession { pub session_id: String, pub mnote_user_id: String, #[serde(skip_serializing, skip_deserializing)] pub bridge_token: String, pub status: PiLabSessionStatus, pub provider_session_id: String, pub pi_session_dir: String, pub pi_session_file: Option, pub root_uri: Option, pub workspace_id: Option, pub page_path: Option, pub page_title: Option, pub model_provider: Option, pub model_id: Option, #[serde(default)] pub thinking_level: Option, pub allowed_roots_snapshot: Option, #[serde(default)] pub runtime_policy_snapshot: Option, pub runtime_pid: Option, pub runtime_mode: String, pub runtime_error: Option, pub created_at_ms: u128, pub updated_at_ms: u128, pub message_count: u64, } #[derive(Clone)] struct PiLabProcessHandle { stdin: Arc>, child: Arc>, _pid: Option, } #[derive(Debug, Clone)] struct PiLabPendingApproval { session_id: String, approval_id: String, tool_name: String, params_hash: String, confirmed: bool, cancelled: bool, expires_at_ms: u128, } #[derive(Debug, Clone)] struct PiLabUiPendingResponse { value: Option, confirmed: Option, cancelled: bool, expires_at_ms: u128, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct PiLabToolReceipt { pub schema: &'static str, pub receipt_id: String, pub mnote_user_id: String, pub workspace_id: Option, pub root_uri: Option, pub page_path: Option, pub session_id: String, pub tool_name: String, pub normalized_file_path: Option, pub allowed: bool, pub deny_reason: Option, pub diff_summary: Option, pub before_file_version: Option, pub after_file_version: Option, pub provider: &'static str, pub provider_session_id: Option, pub model_provider: Option, pub model_id: Option, pub allowed_roots_snapshot: Option, pub storage: String, pub created_at_ms: u128, } #[derive(Debug, Clone)] struct AllowedRoot { root_uri: Option, root_path: PathBuf, workspace_id: Option, permission: String, source: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PiLabBootstrapRequest { pub prompt: Option, pub root_uri: Option, pub workspace_id: Option, pub page_path: Option, pub page_title: Option, pub model_provider: Option, pub model_id: Option, pub thinking_level: Option, pub permission_mode: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PiLabStateRequest { pub session_id: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PiLabCompactRequest { pub session_id: String, pub custom_instructions: Option, pub reserve_tokens: Option, pub keep_recent_tokens: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PiLabQueueConfigRequest { pub session_id: String, pub steering_mode: Option, pub follow_up_mode: Option, pub auto_compaction: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PiLabStartRequest { pub session_id: Option, pub root_uri: Option, pub workspace_id: Option, pub page_path: Option, pub page_title: Option, pub model_provider: Option, pub model_id: Option, pub thinking_level: Option, pub permission_mode: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PiLabSendRequest { pub session_id: String, pub message: String, pub streaming_behavior: Option, pub root_uri: Option, pub workspace_id: Option, pub page_path: Option, pub page_title: Option, pub folder_path: Option, pub context_refs: Option>, pub selected_context: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PiLabAbortRequest { pub session_id: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PiLabRenameSessionRequest { pub title: String, pub workspace_id: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PiLabUiResponseRequest { pub session_id: String, #[serde(alias = "requestId")] pub id: String, pub method: Option, pub value: Option, pub confirmed: Option, pub cancelled: Option, #[serde(default)] pub mnote_approval: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PiLabUiRequestBridgeRequest { pub session_id: String, pub id: String, pub method: String, pub title: Option, pub message: Option, pub timeout_ms: Option, #[serde(default)] pub mnote_approval: Option, #[serde(flatten)] pub extra: HashMap, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PiLabMcpBridgeRequest { pub session_id: String, pub server: String, pub mode: String, pub tool: Option, pub arguments: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PiLabEventsQuery { pub session_id: Option, pub limit: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PiLabToolCallRequest { pub session_id: Option, pub tool_name: String, #[serde(default)] pub params: Value, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PiLabListSessionsQuery { pub workspace_id: Option, pub limit: Option, } #[derive(Debug, Deserialize)] pub struct PiLabSessionPathParam { pub session_id: String, } fn now_ms() -> u128 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|duration| duration.as_millis()) .unwrap_or(0) } fn generate_id(prefix: &str) -> String { format!("{prefix}_{:x}_{:x}", now_ms(), random_suffix()) } fn generate_bridge_token() -> String { let mut bytes = [0_u8; 24]; if let Ok(mut random) = fs::File::open("/dev/urandom") { if random.read_exact(&mut bytes).is_ok() { let mut encoded = String::with_capacity(bytes.len() * 2); for byte in bytes { encoded.push_str(&format!("{byte:02x}")); } return format!("pi_bridge_{encoded}"); } } generate_id("pi_bridge_fallback") } fn random_suffix() -> u64 { use std::hash::{Hash, Hasher}; let mut hasher = std::collections::hash_map::DefaultHasher::new(); SystemTime::now().hash(&mut hasher); std::thread::current().id().hash(&mut hasher); hasher.finish() } fn canonical_json_string(value: &Value) -> String { match value { Value::Null => "null".into(), Value::Bool(value) => value.to_string(), Value::Number(value) => value.to_string(), Value::String(value) => serde_json::to_string(value).unwrap_or_else(|_| "\"\"".into()), Value::Array(items) => { let body = items .iter() .map(canonical_json_string) .collect::>() .join(","); format!("[{body}]") } Value::Object(map) => { let mut keys = map.keys().collect::>(); keys.sort(); let body = keys .into_iter() .map(|key| { let encoded_key = serde_json::to_string(key).unwrap_or_else(|_| "\"\"".into()); let encoded_value = canonical_json_string(&map[key]); format!("{encoded_key}:{encoded_value}") }) .collect::>() .join(","); format!("{{{body}}}") } } } fn pi_lab_tool_params_hash(params: &Value) -> String { let mut normalized = params.clone(); if let Value::Object(map) = &mut normalized { map.remove("mnoteApproval"); map.remove("mnote_approval"); } format!("{:x}", stable_hash(&canonical_json_string(&normalized))) } fn stable_hash(value: &str) -> u64 { let mut hash = 5381_u32; for unit in value.encode_utf16() { hash = ((hash << 5).wrapping_add(hash)).wrapping_add(u32::from(unit)); } u64::from(hash) } fn approval_key(session_id: &str, approval_id: &str) -> String { format!("{session_id}:{approval_id}") } fn enabled(state: &AppState) -> bool { state.config().enable_page_ai_pi_lab } fn ensure_enabled(state: &AppState) -> Result<(), WebError> { if enabled(state) { return Ok(()); } Err(WebError::new( StatusCode::NOT_FOUND, "page_ai_pi_lab_disabled", "Pi Lab 已被 MNOTE_PAGE_AI_PI_LAB=0 强制关闭", )) } fn hash_auth_identity(value: &str) -> String { use std::hash::{Hash, Hasher}; let mut hasher = std::collections::hash_map::DefaultHasher::new(); value.hash(&mut hasher); format!("auth:{:016x}", hasher.finish()) } fn pi_actor_id(state: &AppState, context: &RequestContext) -> Option { crate::routes::gateway::current_actor_id(state, context).or_else(|| { context .auth .authorization .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(hash_auth_identity) }) } fn ensure_authenticated(state: &AppState, context: &RequestContext) -> Result { if let Some(actor_id) = pi_actor_id(state, context) { return Ok(actor_id); } if let Some(raw_token) = context .auth .cookie_header .as_deref() .and_then(|_| crate::routes::gateway::current_actor_id(state, context)) { return Ok(raw_token); } if context.auth.cookie_header.is_some() { return Err(WebError::new( StatusCode::UNAUTHORIZED, "page_ai_pi_lab_invalid_session_cookie", "Pi Lab 需要有效 MNote session cookie,不能用任意 Cookie 头访问", ) .with_context(context)); } Err(WebError::new( StatusCode::UNAUTHORIZED, "page_ai_pi_lab_unauthorized", "Pi Lab 需要 MNote 登录态后访问", ) .with_context(context)) } fn ensure_session_owner( state: &AppState, context: &RequestContext, session: &PiLabSession, ) -> Result<(), WebError> { let actor_id = ensure_authenticated(state, context)?; if session.mnote_user_id == actor_id { return Ok(()); } Err(WebError::new( StatusCode::FORBIDDEN, "page_ai_pi_lab_session_owner_mismatch", "Pi Lab session 不属于当前登录主体", ) .with_context(context)) } fn file_path_from_root_uri(root_uri: &str) -> Option { let value = root_uri.trim(); let path = value.strip_prefix("file://")?; (!path.is_empty()).then(|| PathBuf::from(path)) } fn active_allowed_roots( state: &AppState, context: &RequestContext, ) -> Result, WebError> { let actor_id = ensure_authenticated(state, context)?; let mut roots: Vec = state .control_plane() .list_directory_grants_for_actor(actor_id.trim()) .unwrap_or_default() .into_iter() .filter(|grant| grant.status.trim() == "active") .filter_map(|grant| { let root_path = if let Some(path) = file_path_from_root_uri(&grant.root_uri) { path } else if !grant.root_path.trim().is_empty() { PathBuf::from(grant.root_path.trim()) } else { return None; }; Some(AllowedRoot { root_uri: (!grant.root_uri.trim().is_empty()).then(|| grant.root_uri.clone()), root_path, workspace_id: grant .workspace_id .as_ref() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()), permission: grant.permission, source: grant.source, }) }) .collect(); if roots.is_empty() { roots.extend(dev_allowed_roots_from_env()); } if roots.is_empty() { return Err(WebError::new( StatusCode::FORBIDDEN, "page_ai_pi_lab_allowed_roots_empty", "当前用户没有可用于 Pi Lab 的 allowed roots;请先通过 Turso/libSQL control-plane 授权目录,或仅在 dev 下设置 MNOTE_PI_LAB_ALLOWED_ROOTS", ) .with_context(context)); } Ok(roots) } fn dev_allowed_roots_from_env() -> Vec { let Some(raw) = std::env::var("MNOTE_PI_LAB_ALLOWED_ROOTS") .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) else { return Vec::new(); }; if let Ok(values) = serde_json::from_str::>(&raw) { return values .into_iter() .filter_map(|value| dev_allowed_root_from_string(&value)) .collect(); } let delimiter = if raw.contains(';') { ';' } else { ':' }; raw.split(delimiter) .filter_map(dev_allowed_root_from_string) .collect() } fn dev_allowed_root_from_string(value: &str) -> Option { let trimmed = value.trim(); if trimmed.is_empty() { return None; } let (root_uri, root_path) = if trimmed.starts_with("file://") { (Some(trimmed.to_string()), file_path_from_root_uri(trimmed)?) } else { let root_path = PathBuf::from(trimmed); let normalized = canonical_or_parent(&root_path); ( Some(format!("file://{}", normalized.to_string_lossy())), root_path, ) }; Some(AllowedRoot { root_uri, root_path, workspace_id: None, permission: "write".into(), source: "MNOTE_PI_LAB_ALLOWED_ROOTS".into(), }) } fn root_can_write(root: &AllowedRoot) -> bool { let permission = root.permission.to_ascii_lowercase(); permission.contains("write") || permission == "owner" } fn canonical_or_parent(path: &Path) -> PathBuf { if let Ok(canonical) = path.canonicalize() { return canonical; } if let Some(parent) = path.parent() { if let Ok(canonical_parent) = parent.canonicalize() { return canonical_parent.join(path.file_name().unwrap_or_default()); } } path.to_path_buf() } fn path_is_inside(path: &Path, root: &Path) -> bool { let canonical_path = canonical_or_parent(path); let canonical_root = canonical_or_parent(root); canonical_path.starts_with(canonical_root) } fn resolve_root_relative_path( state: &AppState, context: &RequestContext, root_uri: &str, relative_path: &str, require_write: bool, ) -> Result { let allowed = active_allowed_roots(state, context)?; let requested_root_path = file_path_from_root_uri(root_uri).map(|path| canonical_or_parent(&path)); let matched = allowed.iter().find(|root| { root.root_uri .as_deref() .is_some_and(|candidate| candidate == root_uri) }); let root = matched.or_else(|| { let requested_root_path = requested_root_path.as_ref()?; allowed .iter() .find(|root| path_is_inside(requested_root_path, &root.root_path)) }); let Some(root) = root else { return Err(WebError::new( StatusCode::FORBIDDEN, "page_ai_pi_lab_root_not_allowed", "请求 rootUri 不在当前 allowed roots 内", ) .with_context(context)); }; let base_root_path = if matched.is_some() { canonical_or_parent(&root.root_path) } else { requested_root_path.unwrap_or_else(|| canonical_or_parent(&root.root_path)) }; if require_write && !root_can_write(root) { return Err(WebError::new( StatusCode::FORBIDDEN, "page_ai_pi_lab_root_readonly", "请求 rootUri 只有只读权限,不能执行 patch", ) .with_context(context)); } let target = canonical_or_parent(&base_root_path.join(relative_path)); if !path_is_inside(&target, &root.root_path) { return Err(WebError::new( StatusCode::FORBIDDEN, "page_ai_pi_lab_path_escape", "文件路径不能越过 allowed root", ) .with_context(context)); } Ok(target) } fn resolve_file_path( state: &AppState, context: &RequestContext, params: &Value, session: Option<&PiLabSession>, require_write: bool, ) -> Result<(PathBuf, Option, Option), WebError> { let root_uri = params .get("rootUri") .or_else(|| params.get("root_uri")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string) .or_else(|| session.and_then(|session| session.root_uri.clone())); let path = params .get("path") .or_else(|| params.get("relativePath")) .or_else(|| params.get("relative_path")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| WebError::bad_request_code("page_ai_pi_lab_path_required", "缺少 path"))?; if let Some(root_uri) = root_uri.as_deref() { let folder_path = params .get("folderPath") .or_else(|| params.get("folder_path")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()); let relative_path = if let Some(folder_path) = folder_path { let normalized_path = path.replace('\\', "/").trim_start_matches('/').to_string(); let normalized_folder = folder_path .replace('\\', "/") .trim_matches('/') .to_string(); if normalized_folder.is_empty() || Path::new(&normalized_path).is_absolute() || normalized_path == normalized_folder || normalized_path.starts_with(&format!("{normalized_folder}/")) { normalized_path } else { format!("{normalized_folder}/{normalized_path}") } } else { path.to_string() }; let target = resolve_root_relative_path(state, context, root_uri, &relative_path, require_write)?; return Ok((target, Some(root_uri.to_string()), Some(relative_path))); } let requested = PathBuf::from(path); if !requested.is_absolute() { return Err(WebError::bad_request_code( "page_ai_pi_lab_absolute_path_required", "未提供 rootUri 时 path 必须是绝对路径", ) .with_context(context)); } let allowed = active_allowed_roots(state, context)?; let target = canonical_or_parent(&requested); let allowed_root = allowed .iter() .find(|root| path_is_inside(&target, &root.root_path)) .ok_or_else(|| { WebError::new( StatusCode::FORBIDDEN, "page_ai_pi_lab_path_not_allowed", "文件路径不在当前 allowed roots 内", ) .with_context(context) })?; if require_write && !root_can_write(allowed_root) { return Err(WebError::new( StatusCode::FORBIDDEN, "page_ai_pi_lab_path_readonly", "目标文件所在 allowed root 只有只读权限", ) .with_context(context)); } let relative = target .strip_prefix(canonical_or_parent(&allowed_root.root_path)) .ok() .map(|value| value.to_string_lossy().to_string()); Ok((target, allowed_root.root_uri.clone(), relative)) } fn managed_session_dir( context: &RequestContext, root_uri: Option<&str>, session_id: &str, ) -> Result { let actor = pi_lab_actor_segment(&context.auth.actor_id); if let Some(root_path) = root_uri.and_then(file_path_from_root_uri) { return Ok(root_path .join(".mnote") .join("ai") .join("pi-sessions") .join(actor) .join(session_id)); } Ok(std::env::temp_dir() .join("mnote-web") .join("pi-lab") .join(actor) .join(session_id)) } fn pi_lab_actor_segment(actor_id: &str) -> String { let actor = actor_id.trim().replace(['/', '\\', ':'], "_"); if actor.is_empty() { "anonymous".into() } else { actor } } fn is_pi_lab_warmup_session_id(session_id: &str) -> bool { let value = session_id.trim(); value.starts_with("pi_lab_dev_warm_") || value.starts_with("pi_lab_warm_") } fn session_from_request( state: &AppState, context: &RequestContext, request: &PiLabStartRequest, ) -> Result { let mnote_user_id = ensure_authenticated(state, context)?; let session_id = request .session_id .clone() .unwrap_or_else(|| generate_id("pi_lab")); let session_dir = managed_session_dir(context, request.root_uri.as_deref(), &session_id)?; let runtime_policy = ai_settings::load_effective_ai_runtime_policy( state, &mnote_user_id, request.workspace_id.as_deref(), ); let resolved_model = runtime_policy .resolve_requested_model( request.model_provider.as_deref(), request.model_id.as_deref(), ) .map_err(|message| { WebError::new( StatusCode::FORBIDDEN, "page_ai_pi_lab_model_not_allowed", message, ) .with_context(context) })?; let default_thinking = default_thinking_level(); let thinking_level = normalize_thinking_level( request .thinking_level .as_deref() .or(Some(default_thinking.as_str())), ) .map_err(|message| { WebError::new( StatusCode::BAD_REQUEST, "page_ai_pi_lab_invalid_thinking_level", message, ) .with_context(context) })?; let permission_mode = normalize_permission_mode(request.permission_mode.as_deref()).map_err(|message| { WebError::new( StatusCode::BAD_REQUEST, "page_ai_pi_lab_invalid_permission_mode", message, ) .with_context(context) })?; let mut runtime_policy_snapshot = serde_json::to_value(&runtime_policy).unwrap_or_else(|_| json!({})); if let Some(permission_mode) = permission_mode.as_deref() { runtime_policy_snapshot = refresh_runtime_policy_permission_mode_value(runtime_policy_snapshot, permission_mode); } let now = now_ms(); Ok(PiLabSession { session_id: session_id.clone(), mnote_user_id, bridge_token: generate_bridge_token(), status: PiLabSessionStatus::Idle, provider_session_id: generate_id("pi_provider"), pi_session_dir: session_dir.to_string_lossy().to_string(), pi_session_file: None, root_uri: request.root_uri.clone(), workspace_id: request.workspace_id.clone(), page_path: request.page_path.clone(), page_title: request.page_title.clone(), model_provider: Some(resolved_model.provider), model_id: Some(resolved_model.model_id), thinking_level: Some(thinking_level), runtime_pid: None, runtime_mode: runtime_mode(), runtime_error: None, allowed_roots_snapshot: None, runtime_policy_snapshot: Some(runtime_policy_snapshot), created_at_ms: now, updated_at_ms: now, message_count: 0, }) } fn runtime_mode() -> String { std::env::var("MNOTE_PAGE_AI_PI_LAB_RUNTIME") .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) .unwrap_or_else(|| "rpc".into()) } fn env_trimmed(name: &str) -> Option { std::env::var(name) .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) } fn pi_binary() -> String { if let Some(explicit) = env_trimmed("MNOTE_PAGE_AI_PI_BIN") { return explicit; } if pi_runtime_impl() == PI_LAB_RUNTIME_IMPL_TS { return env_trimmed("MNOTE_PAGE_AI_PI_TS_BIN").unwrap_or_else(|| "pi".into()); } env_trimmed("MNOTE_PAGE_AI_PI_RUST_BIN") .or_else(first_existing_pi_rust_binary) .unwrap_or_else(|| "pi-rust".into()) } fn pi_runtime_impl() -> String { let raw = env_trimmed("MNOTE_PAGE_AI_PI_IMPL") .or_else(|| env_trimmed("MNOTE_PAGE_AI_PI_RUNTIME_IMPL")) .unwrap_or_else(|| PI_LAB_RUNTIME_IMPL_RUST.into()); match raw.trim().to_ascii_lowercase().as_str() { "ts" | "typescript" | "pi-ts" | "legacy-ts" => PI_LAB_RUNTIME_IMPL_TS.into(), "rust" | "rs" | "pi-rust" | "pi_agent_rust" | "pi-agent-rust" => { PI_LAB_RUNTIME_IMPL_RUST.into() } _ => PI_LAB_RUNTIME_IMPL_RUST.into(), } } fn first_existing_pi_rust_binary() -> Option { let mut candidates = Vec::new(); if let Some(home) = env_trimmed("HOME") { candidates.push(PathBuf::from(&home).join(".local/share/mnote/pi-rust/bin/pi")); candidates.push(PathBuf::from(&home).join(".cargo/bin/pi-rust")); candidates.push(PathBuf::from(&home).join(".cargo/bin/pi_agent_rust")); } candidates.extend(path_candidates("pi-rust")); candidates.extend(path_candidates("pi_agent_rust")); candidates .into_iter() .find(|path| path.is_file()) .map(|path| path.to_string_lossy().to_string()) } fn path_candidates(binary: &str) -> Vec { std::env::var_os("PATH") .map(|path| { std::env::split_paths(&path) .map(|dir| dir.join(binary)) .collect() }) .unwrap_or_default() } fn pi_runtime_binary_available(binary: &str) -> bool { let path = Path::new(binary); if path.is_absolute() || path.components().count() > 1 { return path.is_file(); } path_candidates(binary) .into_iter() .any(|candidate| candidate.is_file()) } fn pi_runtime_install_hint(runtime_impl: &str, binary: &str) -> Option { if runtime_mode() == "mock" || pi_runtime_binary_available(binary) { return None; } if runtime_impl == PI_LAB_RUNTIME_IMPL_TS { return Some(format!( "未找到 Pi TS runtime: {binary}。请设置 MNOTE_PAGE_AI_PI_TS_BIN 或 MNOTE_PAGE_AI_PI_BIN。" )); } Some(format!( "未找到 Pi Rust runtime: {binary}。请安装到 ~/.local/share/mnote/pi-rust/bin/pi,或设置 MNOTE_PAGE_AI_PI_RUST_BIN / MNOTE_PAGE_AI_PI_BIN。" )) } fn pi_runtime_status_snapshot() -> (String, String, bool, Option) { let runtime_impl = pi_runtime_impl(); let binary = pi_binary(); let available = runtime_mode() == "mock" || pi_runtime_binary_available(&binary); let install_hint = if available { None } else { pi_runtime_install_hint(&runtime_impl, &binary) }; (runtime_impl, binary, available, install_hint) } fn pi_lab_start_response(session: &PiLabSession, reused: bool) -> Value { let (runtime_impl, runtime_binary, runtime_available, runtime_install_hint) = pi_runtime_status_snapshot(); let pi_extension_sources = pi_lab_runtime_extension_sources(session); let pi_extension_tool_names = if pi_extension_sources.is_empty() { Vec::new() } else { pi_lab_pi_extension_tool_names(session) }; json!({ "ok": true, "schema": "mnote.page_ai_pi.start.v1", "session": session, "managedPiBuiltinTools": pi_lab_enabled_builtin_tools(session), "mnoteToolOnly": false, "configuredPiExtensionSources": pi_lab_configured_extension_sources(session), "piExtensionSources": pi_extension_sources, "piExtensionToolNames": pi_extension_tool_names, "thinkingLevel": session.thinking_level, "permissionMode": session_permission_mode(session), "runtimeImplementation": runtime_impl, "runtimeBinary": runtime_binary, "runtimeAvailable": runtime_available, "runtimeInstallHint": runtime_install_hint, "runtimeReused": reused, }) } fn pi_mcp_extension_source() -> Option { let value = env_trimmed("MNOTE_PAGE_AI_PI_MCP_EXTENSION")?; let normalized = value.to_ascii_lowercase(); if matches!( normalized.as_str(), "0" | "false" | "off" | "disabled" | "none" | "1" | "true" | "yes" | "on" | "builtin" | "mnote" ) { return None; } Some(value) } fn string_array_from_policy(session: &PiLabSession, key: &str) -> Vec { session .runtime_policy_snapshot .as_ref() .and_then(|value| value.get(key)) .and_then(Value::as_array) .into_iter() .flatten() .filter_map(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string) .collect() } fn pi_lab_enabled_extension_ids(session: &PiLabSession) -> Vec { string_array_from_policy(session, "enabledPiExtensions") } fn pi_lab_configured_extension_sources(session: &PiLabSession) -> Vec { let mut sources = string_array_from_policy(session, "enabledPiExtensionSources"); if pi_lab_mcp_enabled(session) { sources.push(pi_mcp_extension_source().unwrap_or_else(|| "mnote:mcp".into())); } sources.sort(); sources.dedup(); sources } fn pi_lab_external_configured_extension_sources(session: &PiLabSession) -> Vec { let mcp_source = pi_mcp_extension_source(); let mut sources = pi_lab_configured_extension_sources(session) .into_iter() .filter_map(|source| { if source.starts_with("mnote:") { return None; } if pi_lab_resolve_bundled_pi_rust_official_source(&source).is_some() { return None; } if Some(source.as_str()) == mcp_source.as_deref() { if pi_lab_mcp_enabled(session) { return mcp_source.clone(); } return None; } Some(source) }) .collect::>(); sources.sort(); sources.dedup(); sources } fn pi_lab_external_extensions_enabled(session: &PiLabSession) -> bool { if env_trimmed("MNOTE_PAGE_AI_PI_ALLOW_EXTERNAL_EXTENSIONS") .as_deref() .is_some_and(|value| matches!(value, "1" | "true" | "yes" | "on")) { return true; } session .runtime_policy_snapshot .as_ref() .and_then(|policy| { policy .get("allowExternalPiExtensions") .or_else(|| policy.get("externalPiExtensionsEnabled")) }) .and_then(Value::as_bool) .unwrap_or(false) } fn pi_lab_external_extension_allowlist() -> Vec { env_trimmed("MNOTE_PAGE_AI_PI_EXTENSION_ALLOWLIST") .or_else(|| env_trimmed("MNOTE_PAGE_AI_PI_EXTENSIONS_ALLOWLIST")) .unwrap_or_default() .split(',') .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string) .collect() } fn pi_lab_deconflict_configured_extension_sources(mut sources: Vec) -> Vec { sources.sort(); sources.dedup(); // 官方 plan-mode 与官方 todo 都会注册 /todos。Pi Rust 当前按重复注册直接失败, // 所以在进入 runtime 前做能力去重;默认 todo 可用,只有显式启用 plan-mode 时才让 plan-mode 接管。 let has_plan_mode = sources .iter() .any(|source| source == "pi-rust-official:plan-mode"); if has_plan_mode { sources.retain(|source| source != "pi-rust-official:todo"); } sources } fn pi_lab_official_extension_tool_name_map() -> [(&'static str, &'static [&'static str]); 4] { [ ("pi-rust-official:question", &["question"]), ("pi-rust-official:questionnaire", &["questionnaire"]), ("pi-rust-official:subagent", &["subagent"]), ("pi-rust-official:todo", &["todo"]), ] } fn pi_lab_runtime_extension_sources(session: &PiLabSession) -> Vec { let mut configured_sources = pi_lab_deconflict_configured_extension_sources( pi_lab_configured_extension_sources(session), ); let mut sources = configured_sources .drain(..) .into_iter() .filter_map(|source| pi_lab_resolve_bundled_pi_rust_official_source(&source)) .collect::>(); if !pi_lab_external_extensions_enabled(session) { sources.sort(); sources.dedup(); return sources; } let configured = pi_lab_external_configured_extension_sources(session); let allowlist = pi_lab_external_extension_allowlist(); sources.extend(configured.into_iter().filter(|source| { allowlist.is_empty() || allowlist.iter().any(|allowed| allowed == source) })); sources.sort(); sources.dedup(); sources } fn pi_lab_pi_extension_tool_names(session: &PiLabSession) -> Vec { let mut names = string_array_from_policy(session, "piExtensionToolNames"); if pi_lab_mcp_enabled(session) { names.push("mcp".into()); } else { names.retain(|name| name != "mcp"); } names.sort(); names.dedup(); names } fn pi_lab_runtime_pi_extension_tool_names(session: &PiLabSession) -> Vec { let mut names = if pi_lab_runtime_extension_sources(session).is_empty() { Vec::new() } else { pi_lab_pi_extension_tool_names(session) }; let active_sources = pi_lab_deconflict_configured_extension_sources( pi_lab_configured_extension_sources(session), ); for (source, tool_names) in pi_lab_official_extension_tool_name_map() { if !active_sources.iter().any(|active| active == source) { names.retain(|name| !tool_names.iter().any(|tool_name| tool_name == name)); } } names.sort(); names.dedup(); names } fn pi_lab_managed_builtin_tools() -> Vec { PI_LAB_MANAGED_BUILTIN_TOOLS .iter() .map(|tool| (*tool).to_string()) .collect() } fn pi_lab_permission_system_enabled(session: &PiLabSession) -> bool { if !pi_lab_external_extensions_enabled(session) { return false; } pi_lab_enabled_extension_ids(session) .iter() .any(|id| id == "pi-permission-system") || pi_lab_runtime_extension_sources(session) .iter() .any(|source| source == "npm:@gotgenes/pi-permission-system") } fn pi_lab_official_permission_gate_enabled(session: &PiLabSession) -> bool { pi_lab_enabled_extension_ids(session) .iter() .any(|id| id == "pi-rust-official-permission-gate") || pi_lab_runtime_extension_sources(session) .iter() .any(|source| { source.ends_with("packages/pi-mnote/extensions/pi-rust-official/permission-gate.ts") }) } fn pi_lab_enabled_builtin_tools(session: &PiLabSession) -> Vec { match session_permission_mode(session) { Some("full_access") => pi_lab_managed_builtin_tools(), Some("auto_edit") => [ "read", "write", "edit", "grep", "find", "ls", "hashline_edit", ] .iter() .map(|tool| (*tool).to_string()) .collect(), Some("plan") => ["read", "grep", "find", "ls"] .iter() .map(|tool| (*tool).to_string()) .collect(), _ if pi_lab_permission_system_enabled(session) => pi_lab_managed_builtin_tools(), _ => Vec::new(), } } fn default_model_provider() -> String { env_trimmed("MNOTE_PAGE_AI_PI_DEFAULT_MODEL_PROVIDER") .unwrap_or_else(|| PI_LAB_DEFAULT_MODEL_PROVIDER.into()) } fn default_model_id() -> String { env_trimmed("MNOTE_PAGE_AI_PI_DEFAULT_MODEL") .or_else(|| env_trimmed("MNOTE_PAGE_AI_PI_DEFAULT_MODEL_ID")) .unwrap_or_else(|| PI_LAB_DEFAULT_MODEL_ID.into()) } fn default_thinking_level() -> String { env_trimmed("MNOTE_PAGE_AI_PI_THINKING").unwrap_or_else(|| "medium".into()) } fn normalize_thinking_level(value: Option<&str>) -> Result { let raw = value .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("medium") .to_ascii_lowercase(); match raw.as_str() { "off" | "minimal" | "low" | "medium" | "high" | "xhigh" => Ok(raw), _ => Err(format!( "Pi thinking level 只能是 off/minimal/low/medium/high/xhigh,当前为 {raw}" )), } } fn normalize_permission_mode(value: Option<&str>) -> Result, String> { let Some(raw) = value .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_ascii_lowercase) else { return Ok(None); }; match raw.as_str() { "confirm" | "ask" | "auto_edit" | "plan" | "full_access" => Ok(Some(raw)), _ => Err(format!( "Pi permission mode 只能是 confirm/ask/auto_edit/plan/full_access,当前为 {raw}" )), } } fn normalize_queue_mode(value: &str, field: &str) -> Result { let raw = value.trim(); let normalized = match raw .chars() .filter(|ch| !matches!(ch, '-' | '_' | ' ')) .collect::() .to_ascii_lowercase() .as_str() { "all" => "all".to_string(), "oneatatime" => "one-at-a-time".to_string(), _ => { return Err(WebError::bad_request_code( "page_ai_pi_lab_invalid_queue_mode", format!("{field} 的值无效: \"{value}\"。支持的值: \"all\" 或 \"one-at-a-time\"",), )); } }; Ok(normalized) } fn permission_mode_tool_policy(mode: Option<&str>, tool_name: &str, base_policy: &str) -> String { match mode { Some("plan") => match tool_name { "mnote.current_page.read" | "mnote.selection.read" | "mnote.allowed_roots.describe" | "mnote.local_file.read" | "mnote.knowledge_rag.status" | "mnote.knowledge_rag.query" | "mnote.knowledge_rag.section_context" | "mnote.knowledge_rag.open_reference" | "mnote.reference.open" | "mnote.tool_receipt.write" => "allow".into(), _ => "deny".into(), }, Some("auto_edit") => match tool_name { "mnote.local_file.read" | "mnote.local_file.patch" => "allow".into(), "mnote.codex_rescue.request" => "ask".into(), _ => base_policy.into(), }, Some("full_access") => { if tool_name == "mnote.codex_rescue.request" { "ask".into() } else { "allow".into() } } Some("confirm") | Some("ask") => match tool_name { "mnote.local_file.read" | "mnote.local_file.patch" | "mnote.codex_rescue.request" => { "ask".into() } _ => base_policy.into(), }, _ => base_policy.into(), } } fn session_permission_mode(session: &PiLabSession) -> Option<&str> { session .runtime_policy_snapshot .as_ref() .and_then(|value| value.get("permissionMode")) .or_else(|| { session .runtime_policy_snapshot .as_ref() .and_then(|value| value.get("permission_mode")) }) .and_then(Value::as_str) } fn refresh_runtime_policy_permission_mode_value(mut policy: Value, mode: &str) -> Value { policy["permissionMode"] = json!(mode); let tool_names = policy .get("mnoteToolNames") .and_then(Value::as_array) .cloned() .unwrap_or_default(); let mut policies = serde_json::Map::new(); for tool_name in tool_names.iter().filter_map(Value::as_str) { policies.insert( tool_name.to_string(), json!(permission_mode_tool_policy(Some(mode), tool_name, "allow")), ); } if !policies.is_empty() { policy["mnoteToolPolicies"] = Value::Object(policies); } policy } fn refresh_runtime_policy_permission_mode(session: &PiLabSession, mode: &str) -> Value { refresh_runtime_policy_permission_mode_value( session .runtime_policy_snapshot .clone() .unwrap_or_else(|| json!({})), mode, ) } fn pi_lab_effective_prompt_for_session(session: &PiLabSession, message: &str) -> String { if session_permission_mode(session) != Some("plan") { return message.to_string(); } format!( "当前是 MNote Pi 计划模式。\n\ 要求:先输出可执行计划和风险点;不要实际写入、删除、移动、重命名文件;不要执行 bash/write/edit/rm/mv/cp/mkdir 或 mnote.local_file.patch 等会改变环境的工具。\n\ 允许:只读分析、读取当前页、读取授权目录、查询 LightRAG/MCP、列出需要用户确认的下一步。\n\ 如果必须修改文件,请明确说明需要用户切换到“自动编辑”或“完全访问”后再执行。\n\n\ 用户原始请求:\n{message}" ) } fn pi_lab_command_message_for_session( session: &PiLabSession, message: &str, input_context_prefix: &str, ) -> String { if !message.starts_with("/skill:") { let contextual_message = format!("{input_context_prefix}{message}"); return pi_lab_effective_prompt_for_session(session, &contextual_message); } let command_end = message.find(char::is_whitespace).unwrap_or(message.len()); let command = &message[..command_end]; let skill_args = message[command_end..].trim_start_matches(char::is_whitespace); let contextual_args = format!("{input_context_prefix}{skill_args}"); let effective_args = pi_lab_effective_prompt_for_session(session, &contextual_args); format!("{command}\n{effective_args}") } fn omniroute_base_url() -> String { env_trimmed("MNOTE_PAGE_AI_PI_OMNIROUTE_BASE_URL") .or_else(|| env_trimmed("OMNIROUTE_BASE_URL")) .unwrap_or_else(|| PI_LAB_DEFAULT_OMNIROUTE_BASE_URL.into()) } fn omniroute_base_url_is_local() -> bool { let value = omniroute_base_url().to_ascii_lowercase(); value.starts_with("http://127.0.0.1:") || value.starts_with("http://localhost:") || value.starts_with("http://0.0.0.0:") } fn configured_omniroute_api_key() -> Option { env_trimmed("MNOTE_PAGE_AI_PI_OMNIROUTE_API_KEY") .or_else(|| env_trimmed("MNOTE_PAGE_AI_PI_OPENAI_API_KEY")) .or_else(|| env_trimmed("OPENAI_API_KEY")) } fn omniroute_api_key() -> Option { configured_omniroute_api_key().or_else(|| { if omniroute_base_url_is_local() { Some("mnote-local-omniroute".into()) } else { None } }) } fn omniroute_models_url() -> String { format!("{}/models", omniroute_base_url().trim_end_matches('/')) } fn omniroute_models_api_key() -> Option { env_trimmed("MNOTE_PAGE_AI_PI_OMNIROUTE_API_KEY").or_else(|| { (!omniroute_base_url_is_local()) .then(|| { env_trimmed("MNOTE_PAGE_AI_PI_OPENAI_API_KEY") .or_else(|| env_trimmed("OPENAI_API_KEY")) }) .flatten() }) } fn omniroute_model_tool_calling_capability(catalog: &Value, model_id: &str) -> Option { catalog .get("data") .and_then(Value::as_array) .into_iter() .flatten() .find(|model| model.get("id").and_then(Value::as_str) == Some(model_id)) .map(|model| { model .get("capabilities") .and_then(|capabilities| capabilities.get("tool_calling")) .and_then(Value::as_bool) .unwrap_or(false) }) } async fn ensure_session_model_supports_tools(session: &PiLabSession) -> Result { let provider = session .model_provider .as_deref() .unwrap_or(PI_LAB_DEFAULT_MODEL_PROVIDER); if provider != "omniroute" || session.runtime_mode == "mock" { return Ok(true); } let model_id = session .model_id .as_deref() .filter(|value| !value.trim().is_empty()) .unwrap_or(PI_LAB_DEFAULT_MODEL_ID); let models_url = omniroute_models_url(); let client = reqwest::Client::builder() .timeout(Duration::from_secs(5)) .build() .map_err(|error| { WebError::bad_gateway_code( "page_ai_pi_model_capabilities_unavailable", format!("创建 OmniRoute 模型能力检查客户端失败: {error}"), ) })?; let mut request = client.get(&models_url); if let Some(api_key) = omniroute_models_api_key() { request = request.bearer_auth(api_key); } let response = request.send().await.map_err(|error| { WebError::bad_gateway_code( "page_ai_pi_model_capabilities_unavailable", format!("无法读取 OmniRoute 模型能力: {error}"), ) .with_details(json!({ "provider": provider, "modelId": model_id, "modelsUrl": models_url, })) })?; let status = response.status(); if !status.is_success() { return Err(WebError::bad_gateway_code( "page_ai_pi_model_capabilities_unavailable", format!("OmniRoute 模型能力接口返回 {status}"), ) .with_details(json!({ "provider": provider, "modelId": model_id, "modelsUrl": models_url, }))); } let catalog = response.json::().await.map_err(|error| { WebError::bad_gateway_code( "page_ai_pi_model_capabilities_unavailable", format!("解析 OmniRoute 模型能力失败: {error}"), ) .with_details(json!({ "provider": provider, "modelId": model_id, "modelsUrl": models_url, })) })?; match omniroute_model_tool_calling_capability(&catalog, model_id) { Some(true) => Ok(true), Some(false) => Err(WebError::new( StatusCode::BAD_REQUEST, "page_ai_pi_model_tools_unsupported", format!( "模型 omniroute/{model_id} 不支持工具调用;Page AI 已启用 skill、扩展、MCP 与文件工具,请选择支持 tool_calling 的模型" ), ) .with_details(json!({ "provider": provider, "modelId": model_id, "requiredCapability": "tool_calling", "recommendedModel": format!("{PI_LAB_DEFAULT_MODEL_PROVIDER}/{PI_LAB_DEFAULT_MODEL_ID}"), }))), None => Err(WebError::new( StatusCode::BAD_REQUEST, "page_ai_pi_model_tools_unsupported", format!("OmniRoute 模型目录中未找到模型 {model_id},无法确认工具调用能力"), ) .with_details(json!({ "provider": provider, "modelId": model_id, "requiredCapability": "tool_calling", "modelsUrl": models_url, }))), } } /// 为 Pi 子进程生成每 session 受控的 models.json。 /// - 写入 `/config/models.json` /// - API key 仅以环境变量名引用(OPENAI_API_KEY),不写入明文 /// - 调用方应将 `PI_CODING_AGENT_DIR` 设为返回的 config 目录 fn ensure_session_models_config( session: &PiLabSession, supports_tools: bool, ) -> Result { let config_dir = PathBuf::from(&session.pi_session_dir).join("config"); fs::create_dir_all(&config_dir).map_err(|error| { WebError::internal(format!("创建 Pi Lab session config 目录失败: {error}")) })?; let models_path = config_dir.join("models.json"); let model_provider = session .model_provider .as_deref() .unwrap_or(PI_LAB_DEFAULT_MODEL_PROVIDER); let model_id = session .model_id .as_deref() .filter(|value| !value.trim().is_empty()) .unwrap_or(PI_LAB_DEFAULT_MODEL_ID); let mut omniroute_model_ids = vec![model_id.to_string()]; for candidate in ["freefirst", PI_LAB_DEFAULT_MODEL_ID] { if !omniroute_model_ids.iter().any(|id| id == candidate) { omniroute_model_ids.push(candidate.to_string()); } } let omniroute_models = omniroute_model_ids .iter() .map(|id| { json!({ "id": id, "name": format!("OmniRoute {}", id), "input": ["text"], "reasoning": false, "contextWindow": 128000, "maxTokens": 65536, "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 } }) }) .collect::>(); let models = match model_provider { "omniroute" => json!({ "providers": { "omniroute": { "baseUrl": omniroute_base_url(), "api": "openai-completions", "apiKey": "OPENAI_API_KEY", "authHeader": true, "compat": { "supportsDeveloperRole": false, "supportsReasoningEffort": false, "supportsTools": supports_tools, "supportsUsageInStreaming": true }, "models": omniroute_models } } }), _ => json!({ "providers": { model_provider: { "models": [ { "id": model_id, "name": format!("{} {}", model_provider, model_id), "input": ["text"], "reasoning": false, "contextWindow": 128000, "maxTokens": 65536 } ] } } }), }; let pretty = serde_json::to_string_pretty(&models) .map_err(|error| WebError::internal(format!("序列化 Pi Lab models.json 失败: {error}")))?; fs::write(&models_path, pretty.as_bytes()) .map_err(|error| WebError::internal(format!("写入 Pi Lab models.json 失败: {error}")))?; Ok(config_dir) } fn ensure_session_mcp_config( session: &PiLabSession, config_dir: &Path, ) -> Result, WebError> { if !pi_lab_mcp_enabled(session) { return Ok(None); } let Some(servers) = session .runtime_policy_snapshot .as_ref() .and_then(|value| value.get("mcpServers")) .and_then(Value::as_object) .filter(|servers| !servers.is_empty()) else { return Ok(None); }; fs::create_dir_all(config_dir) .map_err(|error| WebError::internal(format!("创建 Pi Lab MCP config 目录失败: {error}")))?; let mcp_path = config_dir.join("mcp.json"); let payload = json!({ "settings": { "toolPrefix": "server", "directTools": false, "outputGuard": true, "requestTimeoutMs": 30000 }, "mcpServers": servers, }); let pretty = serde_json::to_vec_pretty(&payload) .map_err(|error| WebError::internal(format!("序列化 Pi Lab MCP config 失败: {error}")))?; fs::write(&mcp_path, pretty) .map_err(|error| WebError::internal(format!("写入 Pi Lab MCP config 失败: {error}")))?; Ok(Some(mcp_path)) } fn pi_lab_mcp_strict_lazy_enabled() -> bool { env_trimmed("MNOTE_PAGE_AI_PI_MCP_STRICT_LAZY") .map(|value| { !matches!( value.to_ascii_lowercase().as_str(), "0" | "false" | "off" | "disabled" | "no" ) }) .unwrap_or(true) } fn shared_mcp_cache_path(session: &PiLabSession) -> Option { if !pi_lab_mcp_enabled(session) { return None; } let actor = pi_lab_actor_segment(&session.mnote_user_id); let base = session .root_uri .as_deref() .and_then(file_path_from_root_uri) .map(|root| root.join(".mnote").join("ai").join("pi-mcp-cache")) .unwrap_or_else(|| { std::env::temp_dir() .join("mnote-web") .join("pi-lab") .join("mcp-cache") }); Some(base.join(actor).join(PI_LAB_MCP_CACHE_FILE)) } fn hydrate_session_mcp_cache( session: &PiLabSession, config_dir: &Path, ) -> Result, WebError> { let Some(shared_cache_path) = shared_mcp_cache_path(session) else { return Ok(None); }; let session_cache_path = config_dir.join(PI_LAB_MCP_CACHE_FILE); if session_cache_path.exists() { return Ok(Some(shared_cache_path)); } if shared_cache_path.exists() { fs::copy(&shared_cache_path, &session_cache_path).map_err(|error| { WebError::internal(format!( "复制 Pi Lab MCP metadata cache 失败: {} -> {}: {error}", shared_cache_path.display(), session_cache_path.display() )) })?; return Ok(Some(shared_cache_path)); } if pi_lab_mcp_strict_lazy_enabled() { fs::write(&session_cache_path, br#"{"version":1,"servers":{}}"#).map_err(|error| { WebError::internal(format!( "初始化 Pi Lab MCP metadata cache 失败: {}: {error}", session_cache_path.display() )) })?; } Ok(Some(shared_cache_path)) } fn schedule_mcp_cache_sync(session_id: String, config_dir: PathBuf, shared_cache_path: PathBuf) { tokio::spawn(async move { let session_cache_path = config_dir.join(PI_LAB_MCP_CACHE_FILE); let mut last_hash = 0_u64; for _ in 0..360 { tokio::time::sleep(Duration::from_secs(5)).await; if let Ok(bytes) = tokio::fs::read(&session_cache_path).await { let hash = stable_hash(&String::from_utf8_lossy(&bytes)); if hash != last_hash { if let Some(parent) = shared_cache_path.parent() { let _ = tokio::fs::create_dir_all(parent).await; } let tmp_path = shared_cache_path .with_extension(format!("json.{}.tmp", std::process::id())); if tokio::fs::write(&tmp_path, &bytes).await.is_ok() && tokio::fs::rename(&tmp_path, &shared_cache_path) .await .is_ok() { last_hash = hash; } else { let _ = tokio::fs::remove_file(&tmp_path).await; } } } let running = PI_LAB_PROCESSES .lock() .map(|processes| processes.contains_key(&session_id)) .unwrap_or(false); if !running { break; } } }); } fn shell_glob_escape_path(path: &str) -> String { path.replace('\\', "\\\\") .replace('[', "\\[") .replace(']', "\\]") .replace('{', "\\{") .replace('}', "\\}") } fn allowed_root_paths(session: &PiLabSession) -> Vec { session .allowed_roots_snapshot .as_ref() .and_then(|value| value.get("roots")) .and_then(Value::as_array) .into_iter() .flatten() .filter_map(|root| { root.get("rootPath") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(PathBuf::from) }) .collect() } fn primary_allowed_root_path(session: &PiLabSession) -> Option { let allowed_paths = allowed_root_paths(session); let requested = session .root_uri .as_deref() .and_then(file_path_from_root_uri); if let Some(requested) = requested { let requested = canonical_or_parent(&requested); if allowed_paths.iter().any(|root| { let root = canonical_or_parent(root); path_is_inside(&requested, &root) || requested == root }) { return Some(requested); } } allowed_paths.into_iter().find(|path| path.exists()) } fn ensure_session_pi_permission_config( session: &PiLabSession, config_dir: &Path, ) -> Result, WebError> { if !pi_lab_permission_system_enabled(session) { return Ok(None); } let permission_dir = config_dir.join("extensions").join("pi-permission-system"); fs::create_dir_all(&permission_dir).map_err(|error| { WebError::internal(format!( "创建 Pi permission-system config 目录失败: {error}" )) })?; let mode = session_permission_mode(session).unwrap_or("confirm"); let ( default_path_rule, allowed_root_rule, default_mcp_rule, read_rule, write_rule, edit_rule, bash_rule, ) = match mode { "plan" => ("deny", "allow", "ask", "allow", "deny", "deny", "deny"), "auto_edit" => ("ask", "allow", "allow", "allow", "allow", "allow", "ask"), "full_access" => ( "allow", "allow", "allow", "allow", "allow", "allow", "allow", ), _ => ("ask", "ask", "ask", "allow", "ask", "ask", "ask"), }; let mut path_rules = serde_json::Map::new(); path_rules.insert("*".into(), json!(default_path_rule)); path_rules.insert("*.env".into(), json!("deny")); path_rules.insert("*.env.*".into(), json!("deny")); path_rules.insert("*.env.example".into(), json!(default_path_rule)); let mut external_rules = serde_json::Map::new(); external_rules.insert("*".into(), json!(default_path_rule)); if let Some(roots) = session .allowed_roots_snapshot .as_ref() .and_then(|value| value.get("roots")) .and_then(Value::as_array) { for root in roots { if let Some(root_path) = root .get("rootPath") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) { let pattern = format!( "{}/*", shell_glob_escape_path(root_path.trim_end_matches('/')) ); path_rules.insert( shell_glob_escape_path(root_path.trim_end_matches('/')), json!(allowed_root_rule), ); path_rules.insert(pattern.clone(), json!(allowed_root_rule)); external_rules.insert( shell_glob_escape_path(root_path.trim_end_matches('/')), json!(allowed_root_rule), ); external_rules.insert(pattern, json!(allowed_root_rule)); } } } let mut permission_rules = serde_json::Map::new(); permission_rules.insert("*".into(), json!("allow")); permission_rules.insert("path".into(), Value::Object(path_rules)); permission_rules.insert("external_directory".into(), Value::Object(external_rules)); permission_rules.insert("read".into(), json!(read_rule)); permission_rules.insert("write".into(), json!(write_rule)); permission_rules.insert("edit".into(), json!(edit_rule)); permission_rules.insert("hashline_edit".into(), json!(edit_rule)); permission_rules.insert( "grep".into(), json!({ "*": read_rule }), ); permission_rules.insert( "find".into(), json!({ "*": read_rule }), ); permission_rules.insert( "ls".into(), json!({ "*": read_rule }), ); permission_rules.insert( "bash".into(), json!({ "*": bash_rule, "cat *": read_rule, "head *": read_rule, "tail *": read_rule, "sed -n *": read_rule, "rg *": read_rule, "grep *": read_rule, "ls *": read_rule, "pwd": "allow", "git status": read_rule, "git diff *": read_rule, "git ls-files *": read_rule, "rm *": bash_rule, "rm -r *": bash_rule, "rm -rf *": bash_rule, "mv *": bash_rule, "cp *": bash_rule, "mkdir *": bash_rule }), ); permission_rules.insert( "mcp".into(), json!({ "*": default_mcp_rule }), ); for tool in pi_lab_tool_definitions_for_session(session) { permission_rules.insert( tool.pi_name.to_string(), json!(pi_lab_session_tool_policy(session, tool.mnote_name)), ); } let config = json!({ "$schema": "https://raw.githubusercontent.com/gotgenes/pi-packages/main/packages/pi-permission-system/schemas/permissions.schema.json", "debugLog": false, "permissionReviewLog": true, "permission": Value::Object(permission_rules) }); let config_path = permission_dir.join("config.json"); let pretty = serde_json::to_vec_pretty(&config).map_err(|error| { WebError::internal(format!("序列化 Pi permission-system config 失败: {error}")) })?; fs::write(&config_path, pretty).map_err(|error| { WebError::internal(format!("写入 Pi permission-system config 失败: {error}")) })?; Ok(Some(config_path)) } fn pi_lab_mcp_enabled(session: &PiLabSession) -> bool { let has_mcp_servers = session .runtime_policy_snapshot .as_ref() .and_then(|value| value.get("mcpServers")) .and_then(Value::as_object) .is_some_and(|servers| !servers.is_empty()); if !has_mcp_servers { return false; } let bridge_enabled_by_policy = session .runtime_policy_snapshot .as_ref() .and_then(|value| value.get("mcpBridge")) .and_then(Value::as_str) .is_some_and(|value| { matches!( value, "pi-rust-sync-client" | "mnote-backend" | "pi-extension" ) }); bridge_enabled_by_policy || pi_mcp_extension_source().is_some() } fn pi_lab_public_base_url() -> String { std::env::var("MNOTE_WEB_PUBLIC_BIND") .or_else(|_| std::env::var("MNOTE_WEB_BIND")) .ok() .map(|value| value.trim().trim_end_matches('/').to_string()) .filter(|value| !value.is_empty()) .map(|value| { if value.starts_with("http://") || value.starts_with("https://") { value } else { format!("http://{value}") } }) .unwrap_or_else(|| "http://127.0.0.1:3000".into()) } #[derive(Debug, Clone)] struct PiLabToolDefinition { pi_name: &'static str, mnote_name: &'static str, label: &'static str, description: &'static str, } fn pi_lab_tool_definitions() -> Vec { vec![ PiLabToolDefinition { pi_name: "mnote_current_page_read", mnote_name: "mnote.current_page.read", label: "MNote current page read", description: "Read the current MNote page through MNote access scope.", }, PiLabToolDefinition { pi_name: "mnote_selection_read", mnote_name: "mnote.selection.read", label: "MNote selection read", description: "Read the current MNote editor selection snapshot supplied by MNote.", }, PiLabToolDefinition { pi_name: "mnote_allowed_roots_describe", mnote_name: "mnote.allowed_roots.describe", label: "MNote allowed roots describe", description: "Describe MNote allowed roots and disabled raw tools.", }, PiLabToolDefinition { pi_name: "mnote_local_file_read", mnote_name: "mnote.local_file.read", label: "MNote local file read", description: "Read a file only through MNote allowed roots.", }, PiLabToolDefinition { pi_name: "mnote_local_file_patch", mnote_name: "mnote.local_file.patch", label: "MNote local file patch", description: "Patch a file only through MNote allowed roots and watcher refresh.", }, PiLabToolDefinition { pi_name: "mnote_knowledge_rag_status", mnote_name: "mnote.knowledge_rag.status", label: "MNote LightRAG status", description: "Check the MNote LightRAG knowledge provider status, indexed source registry, dashboard URL, and sync state. Use before knowledge-base questions when availability is uncertain. Params: optional workspaceId/rootUri; MNote fills the current Pi session context when omitted.", }, PiLabToolDefinition { pi_name: "mnote_knowledge_rag_query", mnote_name: "mnote.knowledge_rag.query", label: "MNote LightRAG query", description: "Ask the MNote LightRAG knowledge library across indexed books, papers, Office files, PDFs, images, and attachments. Use this for knowledge-base questions and answers that require sources, citations, or evidence. For book or long-document questions, pass query, mode='naive' or 'mix', topK, chunkTopK, includeChunkContent=true, and includeDocumentStructureIndex=true. Use returned references/citations quotes as evidence; do not invent page numbers or hand-write /documents/mnote:// links.", }, PiLabToolDefinition { pi_name: "mnote_knowledge_rag_section_context", mnote_name: "mnote.knowledge_rag.section_context", label: "MNote LightRAG section context", description: "Read bounded section blocks/chunks from a LightRAG sidecar using documentStructureIndex ranges returned by mnote_knowledge_rag_query. Use this for second-pass reading of large books or long documents when query references are not enough. Params include sourcePath/sourceId/lightRagDocId/filePath/sectionId, block or paragraph ordinal range, contextBefore/contextAfter, maxBlocks, maxChars.", }, PiLabToolDefinition { pi_name: "mnote_knowledge_rag_open_reference", mnote_name: "mnote.knowledge_rag.open_reference", label: "MNote LightRAG open reference", description: "Convert a LightRAG reference, filePath, or chunkId returned by mnote_knowledge_rag_query into a MNote clickable local resource locator. Use when the user asks to open or verify a cited source.", }, PiLabToolDefinition { pi_name: "mnote_reference_open", mnote_name: "mnote.reference.open", label: "MNote reference open", description: "Legacy alias for opening a citation/reference through MNote mapping. Prefer mnote_knowledge_rag_open_reference for LightRAG references.", }, PiLabToolDefinition { pi_name: "mnote_codex_rescue_request", mnote_name: "mnote.codex_rescue.request", label: "MNote Codex rescue", description: "Ask local Codex to rescue hard MNote/Pi problems before escalating to the user. Use when tools, skills, MCP, LightRAG, environment, or local repo behavior looks broken and normal Pi troubleshooting is insufficient. This tool is admin-gated, approval-gated by default, runs codex exec with workspace-write sandbox and a timeout, and returns Codex's final answer plus stdout/stderr snippets. Provide issue, evidence/logs, attempted steps, and desired outcome. Call at most once per unresolved incident; if Codex cannot fix it, summarize the blocker to the user.", }, PiLabToolDefinition { pi_name: "mnote_tool_receipt_write", mnote_name: "mnote.tool_receipt.write", label: "MNote tool receipt write", description: "Write a provider-neutral MNote tool receipt.", }, ] } fn pi_lab_tool_definitions_for_session(session: &PiLabSession) -> Vec { let Some(policy_tools) = session .runtime_policy_snapshot .as_ref() .and_then(|value| value.get("mnoteToolNames")) .and_then(Value::as_array) else { return pi_lab_tool_definitions(); }; let allowed = policy_tools .iter() .filter_map(Value::as_str) .collect::>(); pi_lab_tool_definitions() .into_iter() .filter(|tool| allowed.contains(tool.mnote_name)) .collect() } fn pi_lab_extension_tool_names(session: &PiLabSession) -> Vec { pi_lab_tool_definitions_for_session(session) .into_iter() .map(|tool| tool.pi_name.to_string()) .collect() } fn pi_lab_enabled_skill_sources(session: &PiLabSession) -> Vec { session .runtime_policy_snapshot .as_ref() .and_then(|value| value.get("enabledSkillSources")) .and_then(Value::as_array) .into_iter() .flatten() .filter_map(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string) .collect() } fn pi_lab_skill_cli_args(session: &PiLabSession) -> Vec { let skill_sources = pi_lab_enabled_skill_sources(session); if skill_sources.is_empty() { return vec!["--no-skills".into()]; } skill_sources .into_iter() .flat_map(|source| ["--skill".to_string(), source]) .collect() } fn pi_lab_session_allows_mnote_tool(session: &PiLabSession, tool_name: &str) -> bool { let Some(policy_tools) = session .runtime_policy_snapshot .as_ref() .and_then(|value| value.get("mnoteToolNames")) .and_then(Value::as_array) else { return true; }; policy_tools .iter() .filter_map(Value::as_str) .any(|name| name == tool_name) } fn pi_lab_session_tool_policy(session: &PiLabSession, tool_name: &str) -> String { let base_policy = session .runtime_policy_snapshot .as_ref() .and_then(|value| value.get("mnoteToolPolicies")) .and_then(Value::as_object) .and_then(|policies| policies.get(tool_name)) .and_then(Value::as_str) .map(str::to_string) .unwrap_or_else(|| "allow".into()); permission_mode_tool_policy(session_permission_mode(session), tool_name, &base_policy) } fn pi_lab_extract_approval(params: &Value) -> Option<&serde_json::Map> { params .get("mnoteApproval") .or_else(|| params.get("mnote_approval")) .and_then(Value::as_object) } fn cleanup_expired_approvals() { let now = now_ms(); if let Ok(mut approvals) = PI_LAB_APPROVALS.lock() { approvals.retain(|_, approval| approval.expires_at_ms >= now); } if let Ok(mut responses) = PI_LAB_UI_RESPONSES.lock() { responses.retain(|_, response| response.expires_at_ms >= now); } } fn ui_response_key(session_id: &str, request_id: &str) -> String { format!("{session_id}:{request_id}") } fn rpc_response_key(session_id: &str, rpc_id: &str) -> String { format!("{session_id}:{rpc_id}") } fn record_pending_approval_from_event(session_id: &str, payload: &Value) { if payload.get("type").and_then(Value::as_str) != Some("extension_ui_request") || payload.get("method").and_then(Value::as_str) != Some("confirm") { return; } let Some(approval) = payload.get("mnoteApproval").and_then(Value::as_object) else { return; }; let Some(approval_id) = approval .get("approvalId") .or_else(|| approval.get("approval_id")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) else { return; }; let Some(tool_name) = approval .get("toolName") .or_else(|| approval.get("tool_name")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) else { return; }; let Some(params_hash) = approval .get("paramsHash") .or_else(|| approval.get("params_hash")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) else { return; }; cleanup_expired_approvals(); if let Ok(mut approvals) = PI_LAB_APPROVALS.lock() { approvals.insert( approval_key(session_id, approval_id), PiLabPendingApproval { session_id: session_id.into(), approval_id: approval_id.into(), tool_name: tool_name.into(), params_hash: params_hash.into(), confirmed: false, cancelled: false, expires_at_ms: now_ms().saturating_add(5 * 60 * 1000), }, ); } } fn update_pending_approval_response( session_id: &str, approval: Option<&Value>, confirmed: bool, cancelled: bool, ) { let Some(approval) = approval.and_then(Value::as_object) else { return; }; let Some(approval_id) = approval .get("approvalId") .or_else(|| approval.get("approval_id")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) else { return; }; cleanup_expired_approvals(); if let Ok(mut approvals) = PI_LAB_APPROVALS.lock() { if let Some(pending) = approvals.get_mut(&approval_key(session_id, approval_id)) { pending.confirmed = confirmed; pending.cancelled = cancelled; } } } fn confirm_pending_approval(session_id: &str, approval: Option<&Value>) { update_pending_approval_response(session_id, approval, true, false); } fn cancel_pending_approval(session_id: &str, approval: Option<&Value>) { update_pending_approval_response(session_id, approval, false, true); } fn pending_approval_response(session_id: &str, approval_id: &str) -> Option<(bool, bool)> { cleanup_expired_approvals(); PI_LAB_APPROVALS .lock() .ok() .and_then(|approvals| { approvals .get(&approval_key(session_id, approval_id)) .cloned() }) .map(|pending| (pending.confirmed, pending.cancelled)) } fn pi_lab_tool_approval_confirmed( session: Option<&PiLabSession>, params: &Value, tool_name: &str, allow_bridge_approval: bool, ) -> bool { let Some(session) = session else { return false; }; if !allow_bridge_approval { return false; } let Some(approval) = pi_lab_extract_approval(params) else { return false; }; let confirmed = approval .get("confirmed") .or_else(|| approval.get("approved")) .and_then(Value::as_bool) .unwrap_or(false); if !confirmed { return false; } if !approval .get("toolName") .or_else(|| approval.get("tool_name")) .and_then(Value::as_str) .map(|name| name == tool_name) .unwrap_or(true) { return false; } let Some(approval_id) = approval .get("approvalId") .or_else(|| approval.get("approval_id")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) else { return false; }; let expected_params_hash = pi_lab_tool_params_hash(params); cleanup_expired_approvals(); PI_LAB_APPROVALS .lock() .ok() .and_then(|approvals| { approvals .get(&approval_key(&session.session_id, approval_id)) .cloned() }) .is_some_and(|pending| { pending.session_id == session.session_id && pending.approval_id == approval_id && pending.tool_name == tool_name && pending.params_hash == expected_params_hash && pending.confirmed && pending.expires_at_ms >= now_ms() }) } fn store_pending_ui_response( session_id: &str, request_id: &str, value: Option, confirmed: Option, cancelled: bool, ) { let key = ui_response_key(session_id, request_id); if let Ok(mut responses) = PI_LAB_UI_RESPONSES.lock() { responses.insert( key, PiLabUiPendingResponse { value, confirmed, cancelled, expires_at_ms: now_ms().saturating_add(120_000), }, ); } } fn take_pending_ui_response(session_id: &str, request_id: &str) -> Option { let key = ui_response_key(session_id, request_id); PI_LAB_UI_RESPONSES .lock() .ok() .and_then(|mut responses| responses.remove(&key)) } fn mnote_pi_extension_path() -> PathBuf { if let Some(path) = env_trimmed("MNOTE_PAGE_AI_PI_MNOTE_EXTENSION") { return PathBuf::from(path); } PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../../../packages/pi-mnote/extensions/mnote-bridge.ts") } fn mnote_pi_mcp_extension_path() -> PathBuf { if let Some(path) = env_trimmed("MNOTE_PAGE_AI_PI_MCP_BUILTIN_EXTENSION") { return PathBuf::from(path); } PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../../../packages/pi-mnote/extensions/mnote-mcp/index.ts") } fn mnote_pi_mcp_client_path() -> PathBuf { if let Some(path) = env_trimmed("MNOTE_PAGE_AI_PI_MCP_CLIENT") { return PathBuf::from(path); } mnote_pi_mcp_extension_path() .parent() .map(|path| path.join("client.mjs")) .unwrap_or_else(|| PathBuf::from("client.mjs")) } fn pi_rust_official_extension_path(name: &str) -> Option { let file_name = match name { "question" => "question.ts", "questionnaire" => "questionnaire.ts", "todo" => "todo.ts", "permission-gate" => "permission-gate.ts", "plan-mode" => "plan-mode/index.ts", "subagent" => "subagent/index.ts", _ => return None, }; let env_slug = name .chars() .map(|ch| { if ch.is_ascii_alphanumeric() { ch.to_ascii_uppercase() } else { '_' } }) .collect::(); let env_name = format!("MNOTE_PAGE_AI_PI_OFFICIAL_{env_slug}_EXTENSION"); if let Some(path) = env_trimmed(&env_name) { return Some(PathBuf::from(path)); } Some( PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../../../packages/pi-mnote/extensions/pi-rust-official") .join(file_name), ) } fn pi_lab_resolve_bundled_pi_rust_official_source(source: &str) -> Option { let name = source.strip_prefix("pi-rust-official:")?.trim(); pi_rust_official_extension_path(name).map(|path| path.to_string_lossy().to_string()) } fn pi_lab_official_extension_slug_from_source(source: &str) -> String { source .chars() .map(|ch| { if ch.is_ascii_alphanumeric() { ch.to_ascii_lowercase() } else { '-' } }) .collect::() .trim_matches('-') .to_string() } fn copy_dir_recursive(source_dir: &Path, target_dir: &Path) -> Result<(), WebError> { fs::create_dir_all(target_dir).map_err(|error| { WebError::internal(format!( "创建 Pi 扩展 staging 目录失败: {}: {error}", target_dir.display() )) })?; for entry in fs::read_dir(source_dir).map_err(|error| { WebError::internal(format!( "读取 Pi 扩展目录失败: {}: {error}", source_dir.display() )) })? { let entry = entry .map_err(|error| WebError::internal(format!("读取 Pi 扩展目录项失败: {error}")))?; let source_path = entry.path(); let target_path = target_dir.join(entry.file_name()); let file_type = entry.file_type().map_err(|error| { WebError::internal(format!( "读取 Pi 扩展目录项类型失败: {}: {error}", source_path.display() )) })?; if file_type.is_dir() { copy_dir_recursive(&source_path, &target_path)?; } else if file_type.is_file() { fs::copy(&source_path, &target_path).map_err(|error| { WebError::internal(format!( "复制 Pi 扩展文件失败: {} -> {}: {error}", source_path.display(), target_path.display() )) })?; } } Ok(()) } fn stage_extension_file( source_path: &Path, stage_root: &Path, slug: &str, ) -> Result { let file_name = source_path.file_name().ok_or_else(|| { WebError::internal(format!("Pi 扩展路径缺少文件名: {}", source_path.display())) })?; let target_dir = stage_root.join(slug); fs::create_dir_all(&target_dir).map_err(|error| { WebError::internal(format!( "创建 Pi 扩展 staging 目录失败: {}: {error}", target_dir.display() )) })?; let target_path = target_dir.join(file_name); fs::copy(source_path, &target_path).map_err(|error| { WebError::internal(format!( "复制 Pi 扩展文件失败: {} -> {}: {error}", source_path.display(), target_path.display() )) })?; Ok(target_path.to_string_lossy().to_string()) } fn bind_mnote_bridge_context_path(extension_path: &str, context_path: &Path) -> Result<(), WebError> { let target = Path::new(extension_path); let source = fs::read_to_string(target).map_err(|error| { WebError::internal(format!( "读取 staged MNote Pi bridge 扩展失败: {}: {error}", target.display() )) })?; let canonical_context_path = context_path .canonicalize() .unwrap_or_else(|_| context_path.to_path_buf()); let context_literal = serde_json::to_string(&canonical_context_path.to_string_lossy().to_string()) .map_err(|error| WebError::internal(format!("编码 Pi Rust MNote context 路径失败: {error}")))?; let context_payload: Value = serde_json::from_slice(&fs::read(context_path).map_err(|error| { WebError::internal(format!( "读取 Pi Rust MNote context 失败: {}: {error}", context_path.display() )) })?) .map_err(|error| WebError::internal(format!("解析 Pi Rust MNote context 失败: {error}")))?; let context_snapshot_literal = serde_json::to_string(&context_payload) .map_err(|error| WebError::internal(format!("编码 Pi Rust MNote context 快照失败: {error}")))?; let context_file_needle = r#"const DEFAULT_CONTEXT_FILE = path.join(EXTENSION_DIR, "mnote-context.json");"#; let context_snapshot_needle = r#"const EMBEDDED_CONTEXT: Record | undefined = undefined;"#; if !source.contains(context_file_needle) || !source.contains(context_snapshot_needle) { return Err(WebError::internal( "staged MNote Pi bridge 扩展缺少 context 绑定点", )); } let next = source.replace( context_file_needle, &format!("const DEFAULT_CONTEXT_FILE = {context_literal};"), ) .replace( context_snapshot_needle, &format!( "const EMBEDDED_CONTEXT: Record | undefined = {context_snapshot_literal};" ), ); fs::write(target, next).map_err(|error| { WebError::internal(format!( "写入 staged MNote Pi bridge 扩展失败: {}: {error}", target.display() )) })?; Ok(()) } fn pi_mnote_context_path(session: &PiLabSession) -> PathBuf { PathBuf::from(&session.pi_session_dir) .join("config") .join("runtime-extensions") .join("mnote-bridge") .join("mnote-context.json") } fn pi_mnote_context_payload( session: &PiLabSession, selected_context: Option<&Value>, context_refs: Option<&[String]>, ) -> Value { json!({ "schema": "mnote.pi.context.v1", "runtimeImplementation": pi_runtime_impl(), "sessionId": session.session_id, "bridgeBaseUrl": pi_lab_public_base_url(), "rootUri": session.root_uri, "workspaceId": session.workspace_id, "pagePath": session.page_path, "pageTitle": session.page_title, "modelProvider": session.model_provider, "modelId": session.model_id, "thinkingLevel": session.thinking_level, "primaryRootPath": primary_allowed_root_path(session), "allowedRoots": session.allowed_roots_snapshot, "toolPolicies": mnote_pi_tool_policies(session), "selectedContext": selected_context.cloned().unwrap_or(Value::Null), "contextRefs": context_refs.unwrap_or(&[]), "updatedAtMs": now_ms(), }) } fn write_pi_mnote_context_snapshot( session: &PiLabSession, selected_context: Option<&Value>, context_refs: Option<&[String]>, ) -> Result { let context_path = pi_mnote_context_path(session); if let Some(parent) = context_path.parent() { fs::create_dir_all(parent).map_err(|error| { WebError::internal(format!("创建 Pi Rust MNote context 目录失败: {error}")) })?; } let mut payload = pi_mnote_context_payload(session, selected_context, context_refs); if let Some(object) = payload.as_object_mut() { object.insert( "bridgeToken".to_string(), Value::String(session.bridge_token.clone()), ); } let bytes = serde_json::to_vec_pretty(&payload).map_err(|error| { WebError::internal(format!("序列化 Pi Rust MNote context 失败: {error}")) })?; let temp_path = context_path.with_extension("json.tmp"); fs::write(&temp_path, bytes) .map_err(|error| WebError::internal(format!("写入 Pi Rust MNote context 失败: {error}")))?; #[cfg(unix)] { let mut permissions = fs::metadata(&temp_path) .map_err(|error| { WebError::internal(format!("读取 Pi Rust MNote context 权限失败: {error}")) })? .permissions(); permissions.set_mode(0o600); fs::set_permissions(&temp_path, permissions).map_err(|error| { WebError::internal(format!("收紧 Pi Rust MNote context 权限失败: {error}")) })?; } fs::rename(&temp_path, &context_path) .map_err(|error| WebError::internal(format!("提交 Pi Rust MNote context 失败: {error}")))?; Ok(context_path) } fn pi_mnote_input_context_prefix( session: &PiLabSession, selected_context: Option<&Value>, context_refs: Option<&[String]>, ) -> Result { let payload = pi_mnote_context_payload(session, selected_context, context_refs); let bytes = serde_json::to_vec(&payload).map_err(|error| { WebError::internal(format!("序列化 Pi Rust input context 失败: {error}")) })?; let mut encoded = String::with_capacity(bytes.len() * 2); for byte in bytes { use std::fmt::Write as _; write!(&mut encoded, "{byte:02x}").map_err(|error| { WebError::internal(format!("编码 Pi Rust input context 失败: {error}")) })?; } Ok(format!("[[MNOTE_PI_CONTEXT_V1:{encoded}]]\n")) } fn stage_extension_tree( entry_path: &Path, stage_root: &Path, slug: &str, ) -> Result { let source_dir = entry_path.parent().ok_or_else(|| { WebError::internal(format!("Pi 扩展入口缺少父目录: {}", entry_path.display())) })?; let file_name = entry_path.file_name().ok_or_else(|| { WebError::internal(format!("Pi 扩展入口缺少文件名: {}", entry_path.display())) })?; let target_dir = stage_root.join(slug); copy_dir_recursive(source_dir, &target_dir)?; Ok(target_dir.join(file_name).to_string_lossy().to_string()) } fn stage_pi_extension_source(source: &str, stage_root: &Path) -> Result { let source_path = PathBuf::from(source); if !source_path.exists() { return Ok(source.to_string()); } let slug = pi_lab_official_extension_slug_from_source(source); if source_path .file_name() .and_then(|name| name.to_str()) .is_some_and(|name| name == "index.ts" || name == "index.js") { return stage_extension_tree(&source_path, stage_root, &slug); } stage_extension_file(&source_path, stage_root, &slug) } fn stage_pi_lab_runtime_extension_sources( session: &PiLabSession, stage_root: &Path, ) -> Result, WebError> { let mut staged = Vec::new(); for source in pi_lab_runtime_extension_sources(session) { staged.push(stage_pi_extension_source(&source, stage_root)?); } staged.sort(); staged.dedup(); Ok(staged) } fn stage_project_mcp_config_for_extension( extension_source: Option<&str>, mcp_config_path: &Option, ) -> Result<(), WebError> { let (Some(extension_source), Some(mcp_config_path)) = (extension_source, mcp_config_path) else { return Ok(()); }; let extension_dir = Path::new(extension_source).parent().ok_or_else(|| { WebError::internal(format!("Pi MCP 扩展入口缺少父目录: {extension_source}")) })?; let project_mcp_dir = extension_dir.join(".pi"); fs::create_dir_all(&project_mcp_dir).map_err(|error| { WebError::internal(format!( "创建 Pi MCP project config 目录失败: {}: {error}", project_mcp_dir.display() )) })?; fs::copy(mcp_config_path, project_mcp_dir.join("mcp.json")).map_err(|error| { WebError::internal(format!( "写入 Pi MCP project config 失败: {}: {error}", project_mcp_dir.display() )) })?; Ok(()) } fn mnote_pi_tool_manifest(session: &PiLabSession) -> Value { Value::Array( pi_lab_tool_definitions_for_session(session) .into_iter() .map(|tool| { json!({ "piName": tool.pi_name, "mnoteName": tool.mnote_name, "label": tool.label, "description": tool.description, }) }) .collect(), ) } fn mnote_pi_tool_policies(session: &PiLabSession) -> Value { let mut policies = serde_json::Map::new(); for tool in pi_lab_tool_definitions_for_session(session) { policies.insert( tool.mnote_name.to_string(), json!(pi_lab_session_tool_policy(session, tool.mnote_name)), ); } Value::Object(policies) } fn publish_event(session_id: &str, kind: &str, payload: Value) { let event = json!({ "schema": PI_LAB_SCHEMA_EVENT, "sessionId": session_id, "kind": kind, "createdAtMs": now_ms(), "payload": payload, }); let _ = PI_LAB_EVENT_TX.send(event); } fn upsert_session(session: PiLabSession) { if let Ok(mut sessions) = PI_LAB_SESSIONS.lock() { sessions.insert(session.session_id.clone(), session); } } fn update_session(session_id: &str, f: F) where F: FnOnce(&mut PiLabSession), { if let Ok(mut sessions) = PI_LAB_SESSIONS.lock() { if let Some(session) = sessions.get_mut(session_id) { f(session); session.updated_at_ms = now_ms(); } } } fn get_session(session_id: &str) -> Option { PI_LAB_SESSIONS .lock() .ok() .and_then(|sessions| sessions.get(session_id).cloned()) } fn session_has_process(session_id: &str) -> bool { PI_LAB_PROCESSES .lock() .map(|processes| processes.contains_key(session_id)) .unwrap_or(false) } fn session_runtime_is_usable(session: &PiLabSession) -> bool { matches!( session.status, PiLabSessionStatus::RuntimeRunning | PiLabSessionStatus::TurnRunning ) && (session.runtime_mode == "mock" || session_has_process(&session.session_id)) } fn session_can_auto_resume(session: &PiLabSession) -> bool { session.runtime_error.is_none() && session_runtime_is_usable(session) } fn session_runtime_config_matches(existing: &PiLabSession, requested: &PiLabSession) -> bool { existing.model_provider == requested.model_provider && existing.model_id == requested.model_id && existing.thinking_level == requested.thinking_level && session_permission_mode(existing) == session_permission_mode(requested) } fn refresh_runtime_session_context(existing: &mut PiLabSession, requested: &PiLabSession) { existing.root_uri = requested.root_uri.clone(); existing.workspace_id = requested.workspace_id.clone(); existing.page_path = requested.page_path.clone(); existing.page_title = requested.page_title.clone(); existing.allowed_roots_snapshot = requested.allowed_roots_snapshot.clone(); existing.runtime_policy_snapshot = requested.runtime_policy_snapshot.clone(); existing.runtime_mode = requested.runtime_mode.clone(); existing.runtime_error = None; } fn remove_session(session_id: &str) -> bool { PI_LAB_SESSIONS .lock() .map(|mut sessions| sessions.remove(session_id).is_some()) .unwrap_or(false) } async fn kill_process_handle(handle: PiLabProcessHandle) { let mut child = handle.child.lock().await; let _ = child.kill().await; let _ = child.wait().await; } async fn kill_session_process(session_id: &str) -> bool { let handle = PI_LAB_PROCESSES .lock() .ok() .and_then(|mut processes| processes.remove(session_id)); if let Some(handle) = handle { kill_process_handle(handle).await; true } else { false } } fn cleanup_expired_sessions() { let cutoff = now_ms().saturating_sub(PI_LAB_SESSION_TTL_MS); let expired = PI_LAB_SESSIONS .lock() .map(|mut sessions| { let mut expired = sessions .iter() .filter_map(|(session_id, session)| { (session.updated_at_ms < cutoff && !matches!( session.status, PiLabSessionStatus::RuntimeRunning | PiLabSessionStatus::TurnRunning )) .then(|| (session_id.clone(), session.pi_session_dir.clone())) }) .collect::>(); for (session_id, _) in &expired { sessions.remove(session_id); } if sessions.len() > PI_LAB_MAX_SESSIONS { let mut removable = sessions .values() .filter(|session| { !matches!( session.status, PiLabSessionStatus::RuntimeRunning | PiLabSessionStatus::TurnRunning ) }) .map(|session| { ( session.updated_at_ms, session.session_id.clone(), session.pi_session_dir.clone(), ) }) .collect::>(); removable.sort_by_key(|(updated_at, _, _)| *updated_at); for (_, session_id, session_dir) in removable .into_iter() .take(sessions.len().saturating_sub(PI_LAB_MAX_SESSIONS)) { sessions.remove(&session_id); expired.push((session_id, session_dir)); } } expired }) .unwrap_or_default(); if !expired.is_empty() { let mut handles = Vec::new(); if let Ok(mut processes) = PI_LAB_PROCESSES.lock() { for (session_id, _) in &expired { if let Some(handle) = processes.remove(session_id) { handles.push(handle); } } } for handle in handles { tokio::spawn(kill_process_handle(handle)); } for (_, session_dir) in expired { let path = PathBuf::from(session_dir); if path .components() .any(|component| component.as_os_str() == "pi-sessions") { let _ = fs::remove_dir_all(path); } } } } fn check_rate_limit(actor_id: &str, action: &str, limit: usize) -> Result<(), WebError> { let now = now_ms(); let key = format!("{actor_id}:{action}"); if let Ok(mut buckets) = PI_LAB_RATE_LIMITS.lock() { let bucket = buckets.entry(key).or_default(); bucket.retain(|timestamp| now.saturating_sub(*timestamp) <= PI_LAB_RATE_WINDOW_MS); if bucket.len() >= limit { return Err(WebError::new( StatusCode::TOO_MANY_REQUESTS, "page_ai_pi_lab_rate_limited", format!("Pi Lab {action} 请求过于频繁,请稍后再试"), )); } bucket.push(now); buckets.retain(|_, timestamps| !timestamps.is_empty()); } Ok(()) } fn pi_lab_start_rate_limit(state: &AppState) -> usize { if state.config().allow_dev_fixtures { 24 } else { PI_LAB_MAX_STARTS_PER_WINDOW } } fn get_session_for_context( state: &AppState, context: &RequestContext, session_id: &str, ) -> Result { let session = get_session(session_id).ok_or_else(|| { WebError::bad_request_code("page_ai_pi_lab_session_not_found", "Pi Lab session 不存在") })?; ensure_session_owner(state, context, &session)?; Ok(session) } fn bridge_token_from_headers(headers: &HeaderMap) -> Option<&str> { headers .get(HEADER_PI_LAB_BRIDGE_TOKEN) .and_then(|value| value.to_str().ok()) .map(str::trim) .filter(|value| !value.is_empty()) } fn get_session_for_bridge(headers: &HeaderMap, session_id: &str) -> Result { let session = get_session(session_id).ok_or_else(|| { WebError::bad_request_code("page_ai_pi_lab_session_not_found", "Pi Lab session 不存在") })?; if !matches!( session.status, PiLabSessionStatus::RuntimeRunning | PiLabSessionStatus::TurnRunning ) { return Err(WebError::new( StatusCode::FORBIDDEN, "page_ai_pi_lab_bridge_session_not_running", "Pi Lab internal tool bridge 只允许运行中的 session 调用", )); } if bridge_token_from_headers(headers) == Some(session.bridge_token.as_str()) { return Ok(session); } Err(WebError::new( StatusCode::UNAUTHORIZED, "page_ai_pi_lab_bridge_token_invalid", "Pi Lab internal tool bridge token 无效", )) } fn context_for_session(mut context: RequestContext, session: &PiLabSession) -> RequestContext { context.auth.actor_id = session.mnote_user_id.clone(); context.auth.actor_type = "user".into(); context } async fn start_runtime_for_session( state: &AppState, mut session: PiLabSession, ) -> Result { fs::create_dir_all(&session.pi_session_dir) .map_err(|error| WebError::internal(format!("创建 Pi Lab sessionDir 失败: {error}")))?; let model_supports_tools = ensure_session_model_supports_tools(&session).await?; let pi_config_dir = ensure_session_models_config(&session, model_supports_tools)?; let mcp_config_path = ensure_session_mcp_config(&session, &pi_config_dir)?; let shared_mcp_cache_path = hydrate_session_mcp_cache(&session, &pi_config_dir)?; let permission_config_path = ensure_session_pi_permission_config(&session, &pi_config_dir)?; let mnote_pi_extension_source_path = mnote_pi_extension_path(); if !mnote_pi_extension_source_path.exists() { return Err(WebError::internal(format!( "MNote Pi package extension 不存在: {}", mnote_pi_extension_source_path.display() ))); } let extension_stage_dir = pi_config_dir.join("runtime-extensions"); let mnote_pi_extension_path = stage_extension_file( &mnote_pi_extension_source_path, &extension_stage_dir, "mnote-bridge", )?; let mnote_context_path = write_pi_mnote_context_snapshot(&session, None, None)?; bind_mnote_bridge_context_path(&mnote_pi_extension_path, &mnote_context_path)?; let enabled_builtin_tools = pi_lab_enabled_builtin_tools(&session); let configured_extension_sources = pi_lab_configured_extension_sources(&session); let mut pi_extension_sources = stage_pi_lab_runtime_extension_sources(&session, &extension_stage_dir)?; let mut mcp_extension_runtime_path = None; if pi_lab_mcp_enabled(&session) { let mcp_extension_source = if let Some(custom_source) = pi_mcp_extension_source() { let allowlist = pi_lab_external_extension_allowlist(); if !pi_lab_external_extensions_enabled(&session) || !allowlist.iter().any(|allowed| allowed == &custom_source) { return Err(WebError::new( StatusCode::FORBIDDEN, "page_ai_pi_lab_mcp_extension_not_allowed", "自定义 Pi MCP 扩展必须显式启用外部扩展,并加入 MNOTE_PAGE_AI_PI_EXTENSION_ALLOWLIST。", )); } PathBuf::from(custom_source) } else { mnote_pi_mcp_extension_path() }; if !mcp_extension_source.exists() { return Err(WebError::internal(format!( "MNote Pi Rust MCP extension 不存在: {}", mcp_extension_source.display() ))); } let staged_mcp_extension = stage_pi_extension_source( &mcp_extension_source.to_string_lossy(), &extension_stage_dir, )?; pi_extension_sources.push(staged_mcp_extension.clone()); mcp_extension_runtime_path = Some(staged_mcp_extension); pi_extension_sources.sort(); pi_extension_sources.dedup(); } stage_project_mcp_config_for_extension( mcp_extension_runtime_path.as_deref(), &mcp_config_path, )?; let bridge_base_url = pi_lab_public_base_url(); let pi_extension_tool_names = pi_lab_runtime_pi_extension_tool_names(&session); let mut pi_tool_names = pi_lab_extension_tool_names(&session); pi_tool_names.extend(pi_extension_tool_names.clone()); pi_tool_names.extend(enabled_builtin_tools.clone()); pi_tool_names.sort(); pi_tool_names.dedup(); if session.runtime_mode == "mock" { session.status = PiLabSessionStatus::RuntimeRunning; session.runtime_pid = None; upsert_session(session.clone()); persist_upsert_run(state, &session)?; // 扫描 pi_session_file if session.pi_session_file.is_none() { if let Some(pi_file) = resolve_pi_session_file(&session) { session.pi_session_file = Some(pi_file); upsert_session(session.clone()); let _ = persist_upsert_run(state, &session); } } persist_append_event( state, &session, "runtime_started", &json!({ "mode": "mock", "providerSessionId": session.provider_session_id, "modelProvider": session.model_provider, "modelId": session.model_id, "runtimePolicy": session.runtime_policy_snapshot, "mcpConfigPath": mcp_config_path.clone(), "sharedMcpCachePath": shared_mcp_cache_path.clone(), "permissionConfigPath": permission_config_path.clone(), "configuredPiExtensionSources": configured_extension_sources.clone(), "piExtensionSources": pi_extension_sources.clone(), "piExtensionToolNames": pi_extension_tool_names.clone(), "mnotePiToolManifest": mnote_pi_tool_manifest(&session), "piToolNames": pi_tool_names.clone(), "managedBuiltinTools": enabled_builtin_tools.clone(), }), )?; publish_event( &session.session_id, "runtime_started", json!({ "mode": "mock", "providerSessionId": session.provider_session_id, "modelProvider": session.model_provider, "modelId": session.model_id, "runtimePolicy": session.runtime_policy_snapshot, "piCodingAgentDir": pi_config_dir, "mnotePiExtension": mnote_pi_extension_path, "mcpConfigPath": mcp_config_path, "sharedMcpCachePath": shared_mcp_cache_path, "permissionConfigPath": permission_config_path, "configuredPiExtensionSources": configured_extension_sources, "piExtensionSources": pi_extension_sources, "piExtensionToolNames": pi_extension_tool_names, "mnotePiToolManifest": mnote_pi_tool_manifest(&session), "piToolNames": pi_tool_names, "managedBuiltinTools": enabled_builtin_tools, }), ); return Ok(session); } let runtime_impl = pi_runtime_impl(); let binary = pi_binary(); if !pi_runtime_binary_available(&binary) { let message = pi_runtime_install_hint(&runtime_impl, &binary) .unwrap_or_else(|| format!("Pi runtime 不可用: {binary}")); session.status = PiLabSessionStatus::Error; session.runtime_error = Some(message.clone()); upsert_session(session.clone()); let _ = persist_upsert_run(state, &session); return Err( WebError::bad_gateway_code("page_ai_pi_lab_runtime_missing", message).with_details( json!({ "runtimeImplementation": runtime_impl, "runtimeBinary": binary, }), ), ); } let mut command = Command::new(&binary); command .arg("--mode") .arg("rpc") .arg("--session-dir") .arg(&session.pi_session_dir) .arg("--no-approve") .arg("--no-extensions") .arg("--extension") .arg(&mnote_pi_extension_path) .arg("--tools") .arg(pi_tool_names.join(",")) .arg("--no-prompt-templates") .arg("--no-context-files") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); if let Some(cwd) = primary_allowed_root_path(&session) { command.current_dir(cwd); } for source in &pi_extension_sources { command.arg("--extension").arg(source); } for arg in pi_lab_skill_cli_args(&session) { command.arg(arg); } if let Some(provider) = session .model_provider .as_deref() .filter(|value| !value.is_empty()) { command .arg("--provider") .arg(provider) .env("MNOTE_PI_MODEL_PROVIDER", provider); } if let Some(model) = session .model_id .as_deref() .filter(|value| !value.is_empty()) { command .arg("--model") .arg(model) .env("MNOTE_PI_MODEL_ID", model); } if let Some(thinking) = session .thinking_level .as_deref() .filter(|value| !value.is_empty()) { command.arg("--thinking").arg(thinking); } command .arg("--name") .arg(format!("MNote Pi Lab {}", session.session_id)); // 生成每 session 受控的 models.json,设置 PI_CODING_AGENT_DIR // API key 仅从环境读取,不写入日志或 command line command.env( "PI_CODING_AGENT_DIR", pi_config_dir.to_string_lossy().to_string(), ); let bridge_tools = serde_json::to_string(&mnote_pi_tool_manifest(&session)).unwrap_or_else(|_| "[]".into()); let bridge_tool_policies = serde_json::to_string(&mnote_pi_tool_policies(&session)).unwrap_or_else(|_| "{}".into()); command.env("PI_MNOTE_RUNTIME_IMPL", &runtime_impl); command.env("PI_MNOTE_CONTEXT_FILE", &mnote_context_path); command.env("PI_MNOTE_HTTP_BRIDGE_AVAILABLE", "0"); command.env("PI_MNOTE_BRIDGE_BASE_URL", &bridge_base_url); command.env("PI_MNOTE_BRIDGE_SESSION_ID", &session.session_id); command.env("PI_MNOTE_BRIDGE_TOOLS", &bridge_tools); command.env("PI_MNOTE_BRIDGE_TOOL_POLICIES", &bridge_tool_policies); command.env("PI_MNOTE_BRIDGE_TOKEN", &session.bridge_token); command.env("MNOTE_PI_BRIDGE_BASE_URL", &bridge_base_url); command.env("MNOTE_PI_BRIDGE_SESSION_ID", &session.session_id); command.env("MNOTE_PI_BRIDGE_TOOLS", &bridge_tools); command.env("MNOTE_PI_BRIDGE_TOOL_POLICIES", &bridge_tool_policies); command.env("MNOTE_PI_BRIDGE_TOKEN", &session.bridge_token); command.env("MNOTE_PI_LAB_BRIDGE_TOKEN", &session.bridge_token); if let Some(permission_mode) = session_permission_mode(&session) { command.env("MNOTE_PI_PERMISSION_MODE", permission_mode); } command.env("MNOTE_PI_RUNTIME_BINARY", &binary); command.env("PI_HTTP_ALLOW_LOOPBACK", "1"); if pi_lab_mcp_enabled(&session) { // Pi Rust 的同步 child_process API 还有独立的默认关闭开关。 // 仅受控 MCP 扩展启用时开放,后续仍经过扩展 exec capability/mediation。 command.env("PIJS_ALLOW_UNSAFE_SYNC_EXEC", "1"); } command.env("OPENAI_BASE_URL", omniroute_base_url()); if let Some(key) = omniroute_api_key() { command.env("OPENAI_API_KEY", key); } let mut child = match command.spawn() { Ok(child) => child, Err(error) => { let message = format!("启动 {binary} --mode rpc 失败: {error}"); session.status = PiLabSessionStatus::Error; session.runtime_error = Some(message.clone()); upsert_session(session.clone()); let _ = persist_upsert_run(state, &session); return Err( WebError::bad_gateway_code("page_ai_pi_lab_runtime_spawn_failed", message) .with_details(json!({ "runtimeImplementation": runtime_impl, "runtimeBinary": binary, })), ); } }; let pid = child.id(); let stdin = child.stdin.take().ok_or_else(|| { WebError::bad_gateway_code("page_ai_pi_lab_stdin_missing", "Pi RPC stdin 不可用") })?; let stdout = child.stdout.take().ok_or_else(|| { WebError::bad_gateway_code("page_ai_pi_lab_stdout_missing", "Pi RPC stdout 不可用") })?; let stderr = child.stderr.take().ok_or_else(|| { WebError::bad_gateway_code("page_ai_pi_lab_stderr_missing", "Pi RPC stderr 不可用") })?; let handle = PiLabProcessHandle { stdin: Arc::new(AsyncMutex::new(stdin)), child: Arc::new(AsyncMutex::new(child)), _pid: pid, }; if let Ok(mut processes) = PI_LAB_PROCESSES.lock() { processes.insert(session.session_id.clone(), handle); } if let Some(shared_mcp_cache_path) = shared_mcp_cache_path.clone() { schedule_mcp_cache_sync( session.session_id.clone(), pi_config_dir.clone(), shared_mcp_cache_path, ); } session.status = PiLabSessionStatus::RuntimeRunning; session.runtime_pid = pid; session.runtime_error = None; upsert_session(session.clone()); // 扫描 pi_session_file if session.pi_session_file.is_none() { if let Some(pi_file) = resolve_pi_session_file(&session) { session.pi_session_file = Some(pi_file); upsert_session(session.clone()); let _ = persist_upsert_run(state, &session); } } publish_event( &session.session_id, "runtime_started", json!({ "mode": "rpc", "runtimeImplementation": runtime_impl, "pid": pid, "sessionDir": session.pi_session_dir, "providerSessionId": session.provider_session_id, "modelProvider": session.model_provider, "modelId": session.model_id, "thinkingLevel": session.thinking_level, "omnirouteBaseUrl": omniroute_base_url(), "piCodingAgentDir": pi_config_dir, "mnotePiExtension": mnote_pi_extension_path, "mcpConfigPath": mcp_config_path, "permissionConfigPath": permission_config_path, "configuredPiExtensionSources": configured_extension_sources, "piExtensionSources": pi_extension_sources, "piExtensionToolNames": pi_extension_tool_names, "mnotePiToolManifest": mnote_pi_tool_manifest(&session), "piToolNames": pi_tool_names, "managedBuiltinTools": enabled_builtin_tools, }), ); let session_id = session.session_id.clone(); let cloned_state = state.clone(); let session_user_id = session.mnote_user_id.clone(); let session_workspace_id = session.workspace_id.clone(); let session_page_path = session.page_path.clone(); tokio::spawn(async move { let cp: &dyn ControlPlaneStore = cloned_state.control_plane(); let run_id = pi_run_id(&session_id); let persist_event = |event_type: &str, event_payload: &Value| { let input = AppendAiRuntimeEventInput { id: None, user_id: session_user_id.clone(), workspace_id: session_workspace_id.clone(), document_id: session_page_path.clone(), session_id: session_id.clone(), run_id: run_id.clone(), profile: PI_LAB_PROFILE.to_string(), acp_runtime: PI_LAB_ACP_RUNTIME.to_string(), event_type: event_type.to_string(), payload_json: serde_json::to_string(event_payload).unwrap_or_else(|_| "{}".into()), }; if let Err(e) = cp.append_ai_runtime_event(input) { publish_event( &session_id, "runtime_persistence_error", json!({ "error": e.to_string(), "eventType": event_type, }), ); } }; let mut lines = BufReader::new(stdout).lines(); loop { match lines.next_line().await { Ok(Some(line)) => { let payload = serde_json::from_str::(&line) .unwrap_or_else(|_| json!({"raw": line})); if payload.get("type").and_then(Value::as_str) == Some("agent_end") { update_session(&session_id, |session| { session.status = status_after_agent_end(&session.status); }); } record_pending_approval_from_event(&session_id, &payload); // Resolve pending RPC response if this line is a Pi RPC response if payload.get("type").and_then(Value::as_str) == Some("response") { if let Some(resp_id) = payload.get("id").and_then(Value::as_str) { let resp_key = rpc_response_key(&session_id, resp_id); let mut pending = PI_LAB_PENDING_RPC_RESPONSES.lock().await; if let Some(tx) = pending.remove(&resp_key) { let _ = tx.send(payload.clone()); } } } persist_event("pi_rpc_event", &payload); publish_event(&session_id, "pi_rpc_event", payload); } Ok(None) => { let closed_during_turn = get_session(&session_id) .map(|session| session.status == PiLabSessionStatus::TurnRunning) .unwrap_or(false); let close_payload = json!({ "pid": pid, "duringTurn": closed_during_turn, "message": if closed_during_turn { "Pi Rust runtime exited before returning an assistant response" } else { "Pi Rust runtime stdout closed" }, }); update_session(&session_id, |session| { session.runtime_pid = None; match session.status { PiLabSessionStatus::TurnRunning => { session.status = PiLabSessionStatus::Error; session.runtime_error = Some( "Pi Rust runtime exited before returning an assistant response" .into(), ); } PiLabSessionStatus::Aborted | PiLabSessionStatus::Error => {} PiLabSessionStatus::Idle | PiLabSessionStatus::RuntimeRunning => { session.status = PiLabSessionStatus::Idle; } } }); if let Ok(mut processes) = PI_LAB_PROCESSES.lock() { if processes .get(&session_id) .is_some_and(|handle| handle._pid == pid) { processes.remove(&session_id); } } persist_event("runtime_stdout_closed", &close_payload); publish_event(&session_id, "runtime_stdout_closed", close_payload); break; } Err(error) => { update_session(&session_id, |session| { session.status = PiLabSessionStatus::Error; session.runtime_error = Some(error.to_string()); }); publish_event( &session_id, "runtime_stdout_error", json!({"error": error.to_string()}), ); persist_event("runtime_stdout_error", &json!({"error": error.to_string()})); break; } } } }); let stderr_state = state.clone(); let stderr_session = session.clone(); let stderr_session_id = stderr_session.session_id.clone(); tokio::spawn(async move { let mut lines = BufReader::new(stderr).lines(); loop { match lines.next_line().await { Ok(Some(line)) => { let payload = json!({"line": line}); if let Err(error) = persist_append_event( &stderr_state, &stderr_session, "runtime_stderr_line", &payload, ) { publish_event( &stderr_session_id, "runtime_persistence_error", json!({ "error": error.message(), "eventType": "runtime_stderr_line", }), ); } publish_event(&stderr_session_id, "runtime_stderr_line", payload); } Ok(None) => break, Err(error) => { let payload = json!({"error": error.to_string()}); if let Err(error) = persist_append_event( &stderr_state, &stderr_session, "runtime_stderr_error", &payload, ) { publish_event( &stderr_session_id, "runtime_persistence_error", json!({ "error": error.message(), "eventType": "runtime_stderr_error", }), ); } publish_event(&stderr_session_id, "runtime_stderr_error", payload); break; } } } }); persist_upsert_run(state, &session)?; // 扫描 pi_session_file if session.pi_session_file.is_none() { if let Some(pi_file) = resolve_pi_session_file(&session) { session.pi_session_file = Some(pi_file); upsert_session(session.clone()); let _ = persist_upsert_run(state, &session); } } persist_append_event( state, &session, "runtime_started", &json!({ "mode": "rpc", "runtimeImplementation": pi_runtime_impl(), "pid": session.runtime_pid, "providerSessionId": session.provider_session_id, "modelProvider": session.model_provider, "modelId": session.model_id, "thinkingLevel": session.thinking_level, "omnirouteBaseUrl": omniroute_base_url(), "sessionDir": session.pi_session_dir, "mnotePiExtension": mnote_pi_extension_path, "mcpConfigPath": mcp_config_path, "permissionConfigPath": permission_config_path, "configuredPiExtensionSources": pi_lab_configured_extension_sources(&session), "piExtensionSources": pi_extension_sources, "piExtensionToolNames": pi_extension_tool_names, "mnotePiToolManifest": mnote_pi_tool_manifest(&session), "piToolNames": pi_tool_names, "managedBuiltinTools": pi_lab_enabled_builtin_tools(&session), }), )?; Ok(session) } async fn send_rpc_command(session_id: &str, command: Value) -> Result<(), WebError> { let handle = PI_LAB_PROCESSES .lock() .ok() .and_then(|processes| processes.get(session_id).cloned()); let Some(handle) = handle else { return Err(WebError::bad_request_code( "page_ai_pi_lab_runtime_not_started", "Pi runtime 未启动;请先调用 /api/page-ai/pi/start", )); }; let mut stdin = handle.stdin.lock().await; let line = serde_json::to_string(&command) .map_err(|error| WebError::internal(format!("序列化 Pi RPC command 失败: {error}")))?; stdin.write_all(line.as_bytes()).await.map_err(|error| { WebError::bad_gateway_code("page_ai_pi_lab_rpc_write_failed", error.to_string()) })?; stdin.write_all(b"\n").await.map_err(|error| { WebError::bad_gateway_code("page_ai_pi_lab_rpc_write_failed", error.to_string()) })?; stdin.flush().await.map_err(|error| { WebError::bad_gateway_code("page_ai_pi_lab_rpc_flush_failed", error.to_string()) })?; Ok(()) } async fn send_rpc_command_wait( session_id: &str, command: Value, timeout: Duration, ) -> Result, WebError> { let rpc_id = command .get("id") .and_then(Value::as_str) .unwrap_or("") .to_string(); if rpc_id.is_empty() { return Err(WebError::bad_request_code( "page_ai_pi_lab_missing_rpc_id", "Pi RPC command 缺少 id 字段", )); } let key = rpc_response_key(session_id, &rpc_id); let (tx, rx) = oneshot::channel(); // Register pending response before sending { let mut pending = PI_LAB_PENDING_RPC_RESPONSES.lock().await; pending.insert(key.clone(), tx); } // Send command send_rpc_command(session_id, command).await?; // Wait for response with timeout let result = tokio::time::timeout(timeout, rx).await; // Clean up registry { let mut pending = PI_LAB_PENDING_RPC_RESPONSES.lock().await; pending.remove(&key); } match result { Ok(Ok(response)) => Ok(Some(response)), Ok(Err(_)) | Err(_) => Ok(None), } } fn rpc_response_success(response: Option<&Value>) -> bool { response .and_then(|value| value.get("success")) .and_then(Value::as_bool) .unwrap_or(false) } fn rpc_response_error_message(response: Option<&Value>, fallback: &str) -> String { response .and_then(|value| value.get("error")) .and_then(Value::as_str) .filter(|value| !value.trim().is_empty()) .unwrap_or(fallback) .to_string() } fn apply_text_operations(current: &str, operations: &Value) -> Result { match operations { Value::Array(ops) => { let mut result = current.to_string(); for op in ops { result = apply_single_operation(&result, op)?; } Ok(result) } Value::Object(_) => apply_single_operation(current, operations), _ => Err(WebError::bad_request_code( "page_ai_pi_lab_invalid_operations", "operations 必须是对象或数组", )), } } fn apply_single_operation(current: &str, op: &Value) -> Result { let op_type = op.get("op").and_then(Value::as_str).ok_or_else(|| { WebError::bad_request_code("page_ai_pi_lab_missing_op", "operation 缺少 op 字段") })?; match op_type { "replace" => { let old = op.get("old").and_then(Value::as_str).ok_or_else(|| { WebError::bad_request_code("page_ai_pi_lab_missing_old", "replace 缺少 old") })?; let new = op.get("new").and_then(Value::as_str).unwrap_or(""); if !current.contains(old) { return Err(WebError::bad_request_code( "page_ai_pi_lab_replace_not_found", "replace 未找到匹配文本", )); } Ok(current.replacen(old, new, 1)) } "append" => { let content = op.get("content").and_then(Value::as_str).ok_or_else(|| { WebError::bad_request_code("page_ai_pi_lab_missing_content", "append 缺少 content") })?; Ok(format!("{current}{content}")) } "prepend" => { let content = op.get("content").and_then(Value::as_str).ok_or_else(|| { WebError::bad_request_code("page_ai_pi_lab_missing_content", "prepend 缺少 content") })?; Ok(format!("{content}{current}")) } "delete" => { let target = op.get("target").and_then(Value::as_str).ok_or_else(|| { WebError::bad_request_code("page_ai_pi_lab_missing_target", "delete 缺少 target") })?; if !current.contains(target) { return Err(WebError::bad_request_code( "page_ai_pi_lab_delete_not_found", "delete 未找到匹配文本", )); } Ok(current.replacen(target, "", 1)) } _ => Err(WebError::bad_request_code( "page_ai_pi_lab_unknown_op", format!("不支持的 operation: {op_type}"), )), } } fn file_version(path: &Path) -> Option { let bytes = fs::read(path).ok()?; use std::hash::{Hash, Hasher}; let mut hasher = std::collections::hash_map::DefaultHasher::new(); bytes.hash(&mut hasher); Some(format!("{:016x}", hasher.finish())) } fn write_receipt( state: &AppState, receipt: PiLabToolReceipt, result_payload: &Value, citation_count: usize, ) -> Value { if let Ok(mut store) = PI_LAB_RECEIPT_STORE.lock() { store.push(receipt.clone()); if store.len() > 1000 { store.remove(0); } } let receipt_payload = serde_json::to_string(&json!({ "receipt": receipt, "result": result_payload, })) .unwrap_or_else(|_| "{}".into()); let event_result = state .control_plane() .append_ai_tool_event(AppendAiToolEventInput { id: Some(receipt.receipt_id.clone()), user_id: receipt.mnote_user_id.clone(), workspace_id: receipt.workspace_id.clone(), session_id: receipt.session_id.clone(), run_id: (receipt.session_id != "standalone_tool_call") .then(|| pi_run_id(&receipt.session_id)), provider: receipt.provider.to_string(), provider_session_id: receipt.provider_session_id.clone(), tool_name: receipt.tool_name.clone(), allowed: receipt.allowed, deny_reason: receipt.deny_reason.clone(), root_uri: receipt.root_uri.clone().unwrap_or_default(), page_path: receipt.page_path.clone(), normalized_file_path: receipt.normalized_file_path.clone(), diff_summary: receipt.diff_summary.clone(), citation_count: citation_count as i64, before_file_version: receipt.before_file_version.clone(), after_file_version: receipt.after_file_version.clone(), payload_json: receipt_payload, }); if let Ok(event) = event_result { let patch_persisted = if receipt.tool_name == "mnote.local_file.patch" && receipt.allowed { let relative_path = result_payload .get("relativePath") .and_then(Value::as_str) .unwrap_or_default() .to_string(); let root_uri = result_payload .get("rootUri") .and_then(Value::as_str) .map(str::to_string) .or_else(|| receipt.root_uri.clone()) .unwrap_or_default(); state .control_plane() .append_ai_file_patch(AppendAiFilePatchInput { id: None, user_id: receipt.mnote_user_id.clone(), workspace_id: receipt.workspace_id.clone(), session_id: receipt.session_id.clone(), run_id: (receipt.session_id != "standalone_tool_call") .then(|| pi_run_id(&receipt.session_id)), tool_event_id: event.id, root_uri, relative_path, before_file_version: receipt.before_file_version.clone(), after_file_version: receipt.after_file_version.clone(), patch_summary_json: serde_json::to_string(&json!({ "diffSummary": receipt.diff_summary, "oldSize": result_payload.get("oldSize"), "newSize": result_payload.get("newSize"), })) .unwrap_or_else(|_| "{}".into()), }) .is_ok() } else { false }; return json!({ "receiptId": receipt.receipt_id, "storage": "control_plane_turso_libsql_v1", "persisted": true, "patchPersisted": patch_persisted, }); } let mut adapter_path = std::env::temp_dir() .join("mnote-web") .join("pi-lab") .join("tool-receipts.jsonl"); if let Some(root_path) = receipt .root_uri .as_deref() .and_then(file_path_from_root_uri) { adapter_path = root_path .join(".mnote") .join("ai") .join("pi-lab") .join("tool-receipts.jsonl"); } let persisted = (|| -> Result<(), std::io::Error> { if let Some(parent) = adapter_path.parent() { fs::create_dir_all(parent)?; } let mut file = OpenOptions::new() .create(true) .append(true) .open(&adapter_path)?; let line = serde_json::to_string(&receipt).unwrap_or_else(|_| "{}".into()); writeln!(file, "{line}")?; Ok(()) })() .is_ok(); json!({ "receiptId": receipt.receipt_id, "storage": "provider_neutral_jsonl_debug_fallback_v1", "persisted": persisted, "path": adapter_path, "fallbackReason": "control-plane ai_tool_events write failed", }) } fn receipt_for( context: &RequestContext, session: Option<&PiLabSession>, tool_name: &str, normalized_file_path: Option, allowed: bool, deny_reason: Option, diff_summary: Option, before_file_version: Option, after_file_version: Option, ) -> PiLabToolReceipt { PiLabToolReceipt { schema: PI_LAB_SCHEMA_RECEIPT, receipt_id: generate_id("pi_receipt"), mnote_user_id: session .map(|session| session.mnote_user_id.clone()) .unwrap_or_else(|| context.auth.actor_id.clone()), workspace_id: session.and_then(|session| session.workspace_id.clone()), root_uri: session.and_then(|session| session.root_uri.clone()), page_path: session.and_then(|session| session.page_path.clone()), session_id: session .map(|session| session.session_id.clone()) .unwrap_or_else(|| "standalone_tool_call".into()), tool_name: tool_name.to_string(), normalized_file_path, allowed, deny_reason, diff_summary, before_file_version, after_file_version, provider: PI_LAB_PROVIDER, provider_session_id: session.map(|session| session.provider_session_id.clone()), model_provider: session.and_then(|session| session.model_provider.clone()), model_id: session.and_then(|session| session.model_id.clone()), allowed_roots_snapshot: session.and_then(|session| session.allowed_roots_snapshot.clone()), storage: "control_plane_turso_libsql_v1".into(), created_at_ms: now_ms(), } } pub struct PiLabToolFacade { state: AppState, context: RequestContext, session: Option, } fn string_param(params: &Value, key: &str) -> Option { params .get(key) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string) } fn u64_param(params: &Value, key: &str) -> Option { params.get(key).and_then(|value| { value .as_u64() .or_else(|| value.as_i64().and_then(|n| (n >= 0).then_some(n as u64))) .or_else(|| { value .as_str() .and_then(|text| text.trim().parse::().ok()) }) }) } fn bool_param(params: &Value, key: &str) -> Option { params.get(key).and_then(|value| { value.as_bool().or_else(|| { value .as_str() .and_then(|text| match text.trim().to_ascii_lowercase().as_str() { "1" | "true" | "yes" | "on" => Some(true), "0" | "false" | "no" | "off" => Some(false), _ => None, }) }) }) } fn truncate_text(value: &str, max_chars: usize) -> String { if value.chars().count() <= max_chars { return value.to_string(); } let mut text = value.chars().take(max_chars).collect::(); text.push_str("\n...[truncated]"); text } fn mnote_repo_root() -> PathBuf { if let Ok(root) = std::env::var("MNOTE_REPO_ROOT") { let root = PathBuf::from(root); if root.exists() { return root; } } let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); for ancestor in manifest_dir.ancestors() { if ancestor.join("AGENTS.md").exists() && ancestor.join("rust").exists() { return ancestor.to_path_buf(); } } std::env::current_dir().unwrap_or(manifest_dir) } fn pi_codex_extra_writable_dirs(repo_root: &Path) -> Vec { [ repo_root.join(".codex").join("skills"), PathBuf::from("/home/lix/.codex/skills"), PathBuf::from("/home/lix/.agents/skills"), ] .into_iter() .filter(|path| path.exists()) .collect() } impl PiLabToolFacade { fn session_root_uri(&self, params: &Value) -> Option { params .get("rootUri") .or_else(|| params.get("root_uri")) .and_then(Value::as_str) .map(str::to_string) .or_else(|| { self.session .as_ref() .and_then(|session| session.root_uri.clone()) }) } fn session_page_path(&self, params: &Value) -> Option { params .get("pagePath") .or_else(|| params.get("page_path")) .or_else(|| params.get("path")) .and_then(Value::as_str) .map(str::to_string) .or_else(|| { self.session .as_ref() .and_then(|session| session.page_path.clone()) }) } fn current_page_read(&self, params: Value) -> Result { let root_uri = self.session_root_uri(¶ms).ok_or_else(|| { WebError::bad_request_code("page_ai_pi_lab_root_uri_required", "读取当前页缺少 rootUri") })?; let page_path = self.session_page_path(¶ms).ok_or_else(|| { WebError::bad_request_code( "page_ai_pi_lab_page_path_required", "读取当前页缺少 pagePath", ) })?; let target = resolve_root_relative_path(&self.state, &self.context, &root_uri, &page_path, false)?; let content = fs::read_to_string(&target).map_err(|error| { WebError::bad_request_code( "page_ai_pi_lab_current_page_read_failed", format!("读取当前页失败: {error}"), ) })?; Ok(json!({ "rootUri": root_uri, "pagePath": page_path, "path": target, "content": content, "contentLength": content.len(), "format": "markdown", "fileVersion": file_version(&target), })) } fn selection_read(&self, params: Value) -> Result { let source = params .get("selectionSource") .or_else(|| params.get("selection_source")) .and_then(Value::as_str) .unwrap_or_default(); if source != "mnote_sidebar_host" { return Err(WebError::bad_request_code( "page_ai_pi_lab_selection_snapshot_required", "selection 必须由 MNote sidebar host 注入,不能由 provider 直接伪造", )); } let selection = params.get("selection").cloned().unwrap_or(Value::Null); Ok(json!({ "selection": selection, "selectionSource": "mnote_sidebar_host_snapshot", "rootUri": self.session_root_uri(¶ms), "pagePath": self.session_page_path(¶ms), "note": "Pi Lab 只接受 MNote sidebar host 注入的 tiptap live selection snapshot", })) } fn allowed_roots_describe(&self) -> Result { let roots = active_allowed_roots(&self.state, &self.context)?; let permission_mode = self .session .as_ref() .and_then(|session| session_permission_mode(session)); let managed_builtin_tools = self .session .as_ref() .map(pi_lab_enabled_builtin_tools) .unwrap_or_default(); let denied_builtin_tools = pi_lab_managed_builtin_tools() .into_iter() .filter(|tool| !managed_builtin_tools.iter().any(|managed| managed == tool)) .collect::>(); let permission_provider = if permission_mode == Some("full_access") { "pi-rust-full-access-builtins" } else if permission_mode == Some("auto_edit") { "pi-rust-auto-edit-builtins" } else if permission_mode == Some("plan") { "pi-rust-readonly-builtins" } else if managed_builtin_tools.is_empty() { "mnote-bridge-rust-policy" } else if self .session .as_ref() .is_some_and(pi_lab_official_permission_gate_enabled) { "pi-rust-official:permission-gate" } else { "@gotgenes/pi-permission-system" }; let permission_note = if permission_mode == Some("full_access") { "Full access exposes Pi Rust built-in read/write/edit/hashline_edit/bash/grep/find/ls as first-class tools. MNote bridge tools only add current page, allowed roots, URL/reference, and knowledge context." } else if permission_mode == Some("auto_edit") { "Auto edit exposes Pi Rust built-in read/write/edit/hashline_edit/grep/find/ls. Bash remains reserved for full_access; MNote bridge tools only add current page, allowed roots, URL/reference, and knowledge context." } else if permission_mode == Some("plan") { "Plan mode exposes only Pi Rust read/grep/find/ls built-ins. Write/edit/hashline_edit/bash remain disabled until the user switches to auto_edit or full_access." } else if managed_builtin_tools.is_empty() { "Pi built-in read/write/edit/hashline_edit/bash/grep/find/ls are disabled by default for Pi Rust; MNote bridge tools enforce allowed roots in Rust." } else if self .session .as_ref() .is_some_and(pi_lab_official_permission_gate_enabled) { "Pi Rust official permission-gate only confirms dangerous bash commands; MNote keeps built-in file tools disabled unless a separate explicit permission-system bridge is enabled." } else { "Pi built-in read/write/edit/hashline_edit/bash/grep/find/ls are exposed through opt-in pi-permission-system path gates." }; Ok(json!({ "allowedRoots": roots.iter().map(|root| json!({ "rootUri": root.root_uri, "rootPath": root.root_path, "workspaceId": root.workspace_id, "permission": root.permission, "source": root.source, })).collect::>(), "deniedPiBuiltinTools": denied_builtin_tools, "managedPiBuiltinTools": managed_builtin_tools, "permissionProvider": permission_provider, "note": permission_note, "mnoteTools": [ "mnote.current_page.read", "mnote.selection.read", "mnote.allowed_roots.describe", "mnote.local_file.read", "mnote.local_file.patch", "mnote.knowledge_rag.status", "mnote.knowledge_rag.query", "mnote.knowledge_rag.section_context", "mnote.knowledge_rag.open_reference", "mnote.reference.open", "mnote.codex_rescue.request", "mnote.tool_receipt.write" ], })) } fn local_file_read(&self, params: Value) -> Result { let (target, root_uri, relative_path) = resolve_file_path(&self.state, &self.context, ¶ms, self.session.as_ref(), false)?; let content = fs::read_to_string(&target).map_err(|error| { WebError::bad_request_code( "page_ai_pi_lab_file_read_failed", format!("读取文件失败: {error}"), ) })?; Ok(json!({ "rootUri": root_uri, "relativePath": relative_path, "path": target, "content": content, "contentLength": content.len(), "fileVersion": file_version(&target), })) } fn local_file_patch(&self, params: Value) -> Result { let (target, root_uri, relative_path) = resolve_file_path(&self.state, &self.context, ¶ms, self.session.as_ref(), true)?; let before_version = file_version(&target); let current = fs::read_to_string(&target).unwrap_or_default(); let next = if let Some(content) = params.get("content").and_then(Value::as_str) { content.to_string() } else { let operations = params.get("operations").ok_or_else(|| { WebError::bad_request_code( "page_ai_pi_lab_operations_required", "patch 需要 content 或 operations", ) })?; apply_text_operations(¤t, operations)? }; if let Some(parent) = target.parent() { fs::create_dir_all(parent).map_err(|error| { WebError::bad_request_code( "page_ai_pi_lab_patch_parent_failed", format!("创建父目录失败: {error}"), ) })?; } fs::write(&target, next.as_bytes()).map_err(|error| { WebError::bad_request_code( "page_ai_pi_lab_patch_write_failed", format!("写入文件失败: {error}"), ) })?; let after_version = file_version(&target); let diff_summary = if current == next { "no_changes".to_string() } else { format!("bytes_delta={}", next.len() as i64 - current.len() as i64) }; Ok(json!({ "rootUri": root_uri, "relativePath": relative_path, "path": target, "beforeFileVersion": before_version, "afterFileVersion": after_version, "oldSize": current.len(), "newSize": next.len(), "diffSummary": diff_summary, "refresh": "mnote local-folder watcher / document-session external refresh", "polling": false, })) } async fn knowledge_rag_query(&self, params: Value) -> Result { let query = params .get("query") .or_else(|| params.get("question")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { WebError::bad_request_code("page_ai_pi_lab_rag_query_required", "缺少 query") })?; let root_uri = self.session_root_uri(¶ms).ok_or_else(|| { WebError::bad_request_code("page_ai_pi_lab_root_uri_required", "RAG 查询缺少 rootUri") })?; let body = knowledge_rag::KnowledgeRagQueryRequest { workspace_id: self .session .as_ref() .and_then(|session| session.workspace_id.clone()) .or_else(|| { params .get("workspaceId") .and_then(Value::as_str) .map(str::to_string) }), root_uri, question: Some(query.to_string()), query: None, mode: params .get("mode") .and_then(Value::as_str) .map(str::to_string), top_k: params .get("topK") .and_then(Value::as_u64) .map(|value| value as u32), chunk_top_k: params .get("chunkTopK") .and_then(Value::as_u64) .map(|value| value as u32), include_chunk_content: params.get("includeChunkContent").and_then(Value::as_bool), source_paths: params.get("sourcePaths").and_then(|value| { value.as_array().map(|items| { items .iter() .filter_map(Value::as_str) .map(str::to_string) .collect::>() }) }), include_document_structure_index: params .get("includeDocumentStructureIndex") .and_then(Value::as_bool), knowledge_base_id: params .get("knowledgeBaseId") .and_then(Value::as_str) .map(str::to_string), provider_knowledge_base_id: params .get("providerKnowledgeBaseId") .and_then(Value::as_str) .map(str::to_string), }; let Json(payload) = knowledge_rag::query_rag( State(self.state.clone()), Extension(self.context.clone()), Json(body), ) .await?; Ok(knowledge_rag_agent_output::compact_query_result_for_agent( payload, )) } async fn knowledge_rag_status(&self, params: Value) -> Result { let query = knowledge_rag::KnowledgeRagStatusQuery { workspace_id: self .session .as_ref() .and_then(|session| session.workspace_id.clone()) .or_else(|| { params .get("workspaceId") .and_then(Value::as_str) .map(str::to_string) }), root_uri: self.session_root_uri(¶ms), }; let Json(payload) = knowledge_rag::status( State(self.state.clone()), Extension(self.context.clone()), Query(query), ) .await?; Ok(knowledge_rag_agent_output::compact_status_result_for_agent( payload, )) } async fn knowledge_rag_section_context(&self, params: Value) -> Result { let root_uri = self.session_root_uri(¶ms).ok_or_else(|| { WebError::bad_request_code( "page_ai_pi_lab_root_uri_required", "读取 RAG section context 缺少 rootUri", ) })?; let body = knowledge_rag::KnowledgeRagSectionContextRequest { workspace_id: self .session .as_ref() .and_then(|session| session.workspace_id.clone()) .or_else(|| { params .get("workspaceId") .and_then(Value::as_str) .map(str::to_string) }), root_uri, source_path: string_param(¶ms, "sourcePath") .or_else(|| string_param(¶ms, "source_path")), source_id: string_param(¶ms, "sourceId") .or_else(|| string_param(¶ms, "source_id")), light_rag_doc_id: string_param(¶ms, "lightRagDocId") .or_else(|| string_param(¶ms, "light_rag_doc_id")), provider_knowledge_id: string_param(¶ms, "providerKnowledgeId") .or_else(|| string_param(¶ms, "provider_knowledge_id")), provider_knowledge_base_id: string_param(¶ms, "providerKnowledgeBaseId") .or_else(|| string_param(¶ms, "provider_knowledge_base_id")), chunk_id: string_param(¶ms, "chunkId") .or_else(|| string_param(¶ms, "chunk_id")), provider_chunk_id: string_param(¶ms, "providerChunkId") .or_else(|| string_param(¶ms, "provider_chunk_id")), file_path: string_param(¶ms, "filePath") .or_else(|| string_param(¶ms, "file_path")), section_id: string_param(¶ms, "sectionId") .or_else(|| string_param(¶ms, "section_id")), start_block_ordinal: u64_param(¶ms, "startBlockOrdinal") .or_else(|| u64_param(¶ms, "start_block_ordinal")), end_block_ordinal: u64_param(¶ms, "endBlockOrdinal") .or_else(|| u64_param(¶ms, "end_block_ordinal")), start_paragraph_ordinal: u64_param(¶ms, "startParagraphOrdinal") .or_else(|| u64_param(¶ms, "start_paragraph_ordinal")) .map(|value| value as u32), end_paragraph_ordinal: u64_param(¶ms, "endParagraphOrdinal") .or_else(|| u64_param(¶ms, "end_paragraph_ordinal")) .map(|value| value as u32), context_before: u64_param(¶ms, "contextBefore") .or_else(|| u64_param(¶ms, "context_before")), context_after: u64_param(¶ms, "contextAfter") .or_else(|| u64_param(¶ms, "context_after")), max_blocks: u64_param(¶ms, "maxBlocks") .or_else(|| u64_param(¶ms, "max_blocks")) .map(|value| value as usize), max_chars: u64_param(¶ms, "maxChars") .or_else(|| u64_param(¶ms, "max_chars")) .map(|value| value as usize), }; let Json(payload) = knowledge_rag::section_context( State(self.state.clone()), Extension(self.context.clone()), Json(body), ) .await?; Ok(knowledge_rag_agent_output::compact_section_context_for_agent(payload)) } async fn reference_open(&self, params: Value) -> Result { let root_uri = self.session_root_uri(¶ms).ok_or_else(|| { WebError::bad_request_code("page_ai_pi_lab_root_uri_required", "打开引用缺少 rootUri") })?; let body = knowledge_rag::KnowledgeRagOpenReferenceRequest { workspace_id: self .session .as_ref() .and_then(|session| session.workspace_id.clone()) .or_else(|| { params .get("workspaceId") .and_then(Value::as_str) .map(str::to_string) }), root_uri, reference: params.get("reference").cloned(), reference_id: params .get("referenceId") .or_else(|| params.get("reference_id")) .and_then(Value::as_str) .map(str::to_string), file_path: params .get("filePath") .or_else(|| params.get("resourcePath")) .or_else(|| params.get("path")) .and_then(Value::as_str) .map(str::to_string), chunk_id: params .get("chunkId") .or_else(|| params.get("chunk_id")) .and_then(Value::as_str) .map(str::to_string), }; let Json(payload) = knowledge_rag::open_reference( State(self.state.clone()), Extension(self.context.clone()), Json(body), ) .await?; Ok(payload) } async fn codex_rescue_request(&self, params: Value) -> Result { if !local_folder_source::is_local_access_policy_admin_context(&self.context) { return Err(WebError::new( StatusCode::FORBIDDEN, "page_ai_pi_lab_codex_rescue_admin_required", "Codex 自救只能由管理员授权的 Pi 会话调用", ) .with_context(&self.context)); } let issue = string_param(¶ms, "issue") .or_else(|| string_param(¶ms, "problem")) .or_else(|| string_param(¶ms, "question")) .ok_or_else(|| { WebError::bad_request_code( "page_ai_pi_lab_codex_rescue_issue_required", "Codex 自救需要提供 issue/problem/question", ) })?; let evidence = string_param(¶ms, "evidence") .or_else(|| string_param(¶ms, "logs")) .or_else(|| string_param(¶ms, "context")) .unwrap_or_default(); let attempted = string_param(¶ms, "attempted") .or_else(|| string_param(¶ms, "attemptedSteps")) .unwrap_or_default(); let desired = string_param(¶ms, "desiredOutcome") .or_else(|| string_param(¶ms, "goal")) .unwrap_or_else(|| "修复根因;如果不能安全修复,说明阻塞原因和下一步".into()); let allow_writes = bool_param(¶ms, "allowWrites").unwrap_or(true); let timeout_secs = u64_param(¶ms, "timeoutSeconds") .unwrap_or(300) .clamp(60, 900); let rescue_id = generate_id("codex_rescue"); let repo_root = mnote_repo_root(); let output_dir = self .session .as_ref() .map(|session| PathBuf::from(&session.pi_session_dir)) .unwrap_or_else(std::env::temp_dir) .join("codex-rescue"); fs::create_dir_all(&output_dir) .map_err(|error| WebError::internal(format!("创建 Codex 自救输出目录失败: {error}")))?; let final_message_path = output_dir.join(format!("{rescue_id}-final.md")); let sandbox = if allow_writes { "workspace-write" } else { "read-only" }; let root_uri = self .session .as_ref() .and_then(|session| session.root_uri.clone()); let page_path = self .session .as_ref() .and_then(|session| session.page_path.clone()); let prompt = format!( r#"你是被 MNote Pi 调用的本机 Codex 自救代理。目标是在用户看到失败前,先尝试定位和修复 MNote/Pi 的疑难问题。 边界: - 使用简体中文沟通和总结。 - 遵守仓库 AGENTS.md;不要删除、回滚或覆盖用户已有改动。 - 只做与本问题直接相关的最小修复;能验证就运行最相关验证。 - 当前运行在 codex exec,sandbox={sandbox},approval=never;如果需要 sudo、系统级权限、外部凭证或超出 sandbox 的写入,不要强行绕过,明确说明阻塞。 - 如果你完成了修复,最后说明修改文件、验证命令和结果。 - 如果不能修复,最后给出明确原因、用户需要介入的动作,以及 Pi 下一步应如何降级汇报。 MNote Pi 会话: - actorId: {actor_id} - sessionId: {session_id} - workspaceId: {workspace_id} - rootUri: {root_uri} - pagePath: {page_path} 问题: {issue} 期望结果: {desired} Pi 已尝试: {attempted} 证据/日志/上下文: {evidence} "#, sandbox = sandbox, actor_id = self.context.auth.actor_id, session_id = self .session .as_ref() .map(|session| session.session_id.as_str()) .unwrap_or("standalone"), workspace_id = self .session .as_ref() .and_then(|session| session.workspace_id.as_deref()) .unwrap_or(""), root_uri = root_uri.as_deref().unwrap_or(""), page_path = page_path.as_deref().unwrap_or(""), issue = truncate_text(&issue, 12_000), desired = truncate_text(&desired, 4_000), attempted = truncate_text(&attempted, 8_000), evidence = truncate_text(&evidence, 20_000), ); let codex_bin = std::env::var("MNOTE_CODEX_BIN").unwrap_or_else(|_| "codex".into()); let mut command = Command::new("timeout"); command .arg("--kill-after=10s") .arg(format!("{timeout_secs}s")) .arg(&codex_bin) .arg("--ask-for-approval") .arg("never") .arg("exec") .arg("--sandbox") .arg(sandbox) .arg("--cd") .arg(&repo_root) .arg("--output-last-message") .arg(&final_message_path) .arg("-"); for extra_dir in pi_codex_extra_writable_dirs(&repo_root) { if allow_writes { command.arg("--add-dir").arg(extra_dir); } } command .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .env("MNOTE_PI_CODEX_RESCUE", "1") .env("NO_COLOR", "1"); let mut child = command .spawn() .map_err(|error| WebError::internal(format!("启动 Codex 自救失败: {error}")))?; if let Some(mut stdin) = child.stdin.take() { stdin.write_all(prompt.as_bytes()).await.map_err(|error| { WebError::internal(format!("写入 Codex 自救 prompt 失败: {error}")) })?; } let output = child .wait_with_output() .await .map_err(|error| WebError::internal(format!("等待 Codex 自救失败: {error}")))?; let exit_code = output.status.code(); let stdout = String::from_utf8_lossy(&output.stdout).to_string(); let stderr = String::from_utf8_lossy(&output.stderr).to_string(); let final_message = fs::read_to_string(&final_message_path).unwrap_or_default(); let timed_out = exit_code == Some(124) || exit_code == Some(137); let ok = output.status.success() && !final_message.trim().is_empty(); Ok(json!({ "schema": "mnote.page_ai_pi.codex_rescue_result.v1", "ok": ok, "rescueId": rescue_id, "status": if ok { "completed" } else if timed_out { "timed_out" } else { "failed" }, "exitCode": exit_code, "timedOut": timed_out, "sandbox": sandbox, "approvalPolicy": "never", "repoRoot": repo_root, "finalMessage": truncate_text(&final_message, 12_000), "stdout": truncate_text(&stdout, 8_000), "stderr": truncate_text(&stderr, 8_000), "finalMessagePath": final_message_path, "nextAction": if ok { "Pi 应读取 finalMessage,总结 Codex 已修复内容、验证结果和仍需用户确认的事项。" } else { "Pi 应停止重复自救,向用户汇报 Codex 未能解决的原因、stdout/stderr 摘要和需要人工介入的动作。" }, })) } } async fn execute_tool( state: AppState, context: RequestContext, session_id: Option, tool_name: String, params: Value, allow_bridge_approval: bool, ) -> Result, WebError> { let actor_id = ensure_authenticated(&state, &context)?; check_rate_limit(&actor_id, "tool", PI_LAB_MAX_TOOLS_PER_WINDOW)?; let session = if let Some(session_id) = session_id.as_deref() { Some(get_session_for_context(&state, &context, session_id)?) } else { None }; if let Some(session) = session.as_ref() { if !pi_lab_session_allows_mnote_tool(session, &tool_name) { return Err(WebError::new( StatusCode::FORBIDDEN, "page_ai_pi_lab_tool_disabled", format!("当前 AI 设置未启用 Pi 工具 {tool_name}"), ) .with_context(&context)); } } let facade = PiLabToolFacade { state, context: context.clone(), session: session.clone(), }; let started = now_ms(); let tool_policy = session .as_ref() .map(|session| pi_lab_session_tool_policy(session, &tool_name)) .unwrap_or_else(|| "allow".into()); if tool_policy == "deny" { return Err(WebError::new( StatusCode::FORBIDDEN, "page_ai_pi_lab_tool_denied_by_permission_mode", format!("当前 Pi 模式禁止调用工具 {tool_name}"), ) .with_context(&context)); } let approval_required = tool_policy == "ask"; let approval_confirmed = !approval_required || pi_lab_tool_approval_confirmed( session.as_ref(), ¶ms, &tool_name, allow_bridge_approval, ); let result: Result = if approval_required && !approval_confirmed { Err(WebError::new( StatusCode::FORBIDDEN, "page_ai_pi_lab_tool_approval_required", format!("Pi 工具 {tool_name} 需要用户审批"), )) } else { match tool_name.as_str() { "mnote.current_page.read" => facade.current_page_read(params.clone()), "mnote.selection.read" => facade.selection_read(params.clone()), "mnote.allowed_roots.describe" => facade.allowed_roots_describe(), "mnote.local_file.read" => facade.local_file_read(params.clone()), "mnote.local_file.patch" => facade.local_file_patch(params.clone()), "mnote.knowledge_rag.status" => facade.knowledge_rag_status(params.clone()).await, "mnote.knowledge_rag.query" => facade.knowledge_rag_query(params.clone()).await, "mnote.knowledge_rag.section_context" => { facade.knowledge_rag_section_context(params.clone()).await } "mnote.knowledge_rag.open_reference" => facade.reference_open(params.clone()).await, "mnote.reference.open" => facade.reference_open(params.clone()).await, "mnote.codex_rescue.request" => facade.codex_rescue_request(params.clone()).await, "mnote.tool_receipt.write" => Ok(json!({ "requestedReceipt": params, "diffSummary": params.get("diffSummary").cloned().unwrap_or(Value::Null), "citations": params.get("citations").cloned().unwrap_or_else(|| json!([])), "sources": params.get("sources").cloned().unwrap_or_else(|| json!([])), "references": params.get("references").cloned().unwrap_or_else(|| json!([])), "storage": "control_plane_turso_libsql_v1", "note": "Pi Lab 由 execute_tool 统一写入 control-plane receipt journal", })), _ => Err(WebError::bad_request_code( "page_ai_pi_lab_unknown_tool", format!("未知 Pi Lab tool: {tool_name}"), )), } }; let normalized_file_path = params .get("path") .and_then(Value::as_str) .map(str::to_string); let (allowed, payload, deny_reason, diff_summary, before_file_version, after_file_version) = match result { Ok(payload) => ( true, payload.clone(), None, payload .get("diffSummary") .and_then(Value::as_str) .map(str::to_string), payload .get("beforeFileVersion") .and_then(Value::as_str) .map(str::to_string), payload .get("afterFileVersion") .and_then(Value::as_str) .map(str::to_string), ), Err(error) => ( false, json!({ "ok": false, "code": error.code(), "message": error.message(), }), Some(format!("{}: {}", error.code(), error.message())), None, None, None, ), }; let citation_count = payload .get("citations") .and_then(Value::as_array) .map(|items| items.len()) .or_else(|| { payload .get("sources") .and_then(Value::as_array) .map(|items| items.len()) }) .or_else(|| { payload .get("references") .and_then(Value::as_array) .map(|items| items.len()) }) .unwrap_or(0); let receipt = receipt_for( &context, session.as_ref(), &tool_name, normalized_file_path.clone(), allowed, deny_reason.clone(), diff_summary.clone(), before_file_version.clone(), after_file_version.clone(), ); let receipt_payload = write_receipt(&facade.state, receipt, &payload, citation_count); let elapsed_ms = now_ms().saturating_sub(started) as u64; if let Some(session) = session.as_ref() { let tool_event_id = receipt_payload .get("toolEventId") .or_else(|| receipt_payload.get("tool_event_id")) .and_then(Value::as_str) .map(str::to_string); if allowed && tool_name == "mnote.local_file.patch" { let artifact = json!({ "schema": "mnote.page_ai_pi.artifact.file_patch.v1", "sessionId": session.session_id, "providerSessionId": session.provider_session_id, "toolEventId": tool_event_id, "rootUri": payload.get("rootUri").cloned().unwrap_or(Value::Null), "relativePath": payload.get("relativePath").cloned().unwrap_or_else(|| { normalized_file_path .as_deref() .map(|path| json!(path)) .unwrap_or(Value::Null) }), "beforeFileVersion": before_file_version, "afterFileVersion": after_file_version, "diffSummary": diff_summary, "receipt": receipt_payload, }); publish_event(&session.session_id, "artifact_file_patch", artifact); } publish_event( &session.session_id, "tool_call", json!({ "toolName": tool_name, "allowed": allowed, "denyReason": deny_reason, "normalizedFilePath": normalized_file_path, "rootUri": payload.get("rootUri").cloned().unwrap_or(Value::Null), "relativePath": payload.get("relativePath").cloned().unwrap_or(Value::Null), "diffSummary": diff_summary, "beforeFileVersion": before_file_version, "afterFileVersion": after_file_version, "citationCount": citation_count, "receipt": receipt_payload, "elapsedMs": elapsed_ms, "approvalRequired": approval_required, "approvalConfirmed": approval_confirmed, "toolPolicy": tool_policy, }), ); } Ok(Json(json!({ "ok": allowed, "toolName": tool_name, "result": payload, "receipt": receipt_payload, "elapsedMs": elapsed_ms, "approvalRequired": approval_required, "approvalConfirmed": approval_confirmed, "toolPolicy": tool_policy, }))) } pub async fn shell( State(state): State, Extension(context): Extension, ) -> Result { ensure_enabled(&state)?; ensure_authenticated(&state, &context)?; let html = r#" MNote Pi Lab
"#; Ok(Html(html).into_response()) } pub async fn status( State(state): State, Extension(context): Extension, ) -> Result, WebError> { if !enabled(&state) { return Ok(Json(json!({ "ok": true, "schema": PI_LAB_SCHEMA_STATUS, "enabled": false, "running": false, "uiMode": "independent_mnote_native_drawer", "reason": "Pi Lab disabled by config", }))); } cleanup_expired_sessions(); let actor_id = ensure_authenticated(&state, &context)?; let sessions = PI_LAB_SESSIONS.lock().ok(); let current_session = sessions.as_ref().and_then(|sessions| { sessions .values() .filter(|session| session.mnote_user_id == actor_id) .filter(|session| !is_pi_lab_warmup_session_id(&session.session_id)) .filter(|session| session_can_auto_resume(session)) .max_by_key(|session| session.updated_at_ms) .cloned() }); let warmup_session = sessions.as_ref().and_then(|sessions| { sessions .values() .filter(|session| session.mnote_user_id == actor_id) .filter(|session| is_pi_lab_warmup_session_id(&session.session_id)) .filter(|session| session_can_auto_resume(session)) .max_by_key(|session| session.updated_at_ms) .cloned() }); let active_session_count = sessions .as_ref() .map(|sessions| { sessions .values() .filter(|session| session.mnote_user_id == actor_id) .filter(|session| !is_pi_lab_warmup_session_id(&session.session_id)) .count() }) .unwrap_or(0); let owned_session_ids = sessions .as_ref() .map(|sessions| { sessions .values() .filter(|session| session.mnote_user_id == actor_id) .filter(|session| !is_pi_lab_warmup_session_id(&session.session_id)) .map(|session| session.session_id.clone()) .collect::>() }) .unwrap_or_default(); let owned_warmup_session_ids = sessions .as_ref() .map(|sessions| { sessions .values() .filter(|session| session.mnote_user_id == actor_id) .filter(|session| is_pi_lab_warmup_session_id(&session.session_id)) .map(|session| session.session_id.clone()) .collect::>() }) .unwrap_or_default(); let process_count = PI_LAB_PROCESSES .lock() .map(|processes| { owned_session_ids .iter() .filter(|session_id| processes.contains_key(*session_id)) .count() }) .unwrap_or(0); let warmup_process_count = PI_LAB_PROCESSES .lock() .map(|processes| { owned_warmup_session_ids .iter() .filter(|session_id| processes.contains_key(*session_id)) .count() }) .unwrap_or(0); let (runtime_impl, runtime_binary, runtime_available, runtime_install_hint) = pi_runtime_status_snapshot(); let runtime_error = current_session .as_ref() .and_then(|session| session.runtime_error.clone()); Ok(Json(json!({ "ok": true, "schema": PI_LAB_SCHEMA_STATUS, "enabled": true, "running": process_count > 0 || current_session.as_ref().is_some_and(|session| session.status == PiLabSessionStatus::RuntimeRunning || session.status == PiLabSessionStatus::TurnRunning), "version": PI_LAB_VERSION, "provider": PI_LAB_PROVIDER, "runtimeMode": runtime_mode(), "runtimeImplementation": runtime_impl, "runtimeBinary": runtime_binary, "runtimeAvailable": runtime_available, "runtimeInstallHint": runtime_install_hint, "runtimeError": runtime_error, "defaultModelProvider": default_model_provider(), "defaultModelId": default_model_id(), "defaultThinkingLevel": default_thinking_level(), "permissionMode": current_session.as_ref().and_then(|session| session_permission_mode(session)), "omnirouteBaseUrl": omniroute_base_url(), "piExtensions": current_session .as_ref() .and_then(|session| session.runtime_policy_snapshot.as_ref()) .and_then(|policy| policy.get("piExtensions").cloned()) .unwrap_or_else(|| json!({})), "enabledPiExtensions": current_session .as_ref() .map(pi_lab_enabled_extension_ids) .unwrap_or_default(), "piExtensionToolNames": current_session .as_ref() .map(pi_lab_runtime_pi_extension_tool_names) .unwrap_or_default(), "configuredPiExtensionSources": current_session .as_ref() .map(pi_lab_configured_extension_sources) .unwrap_or_default(), "piExtensionSources": current_session .as_ref() .map(pi_lab_runtime_extension_sources) .unwrap_or_default(), "pid": current_session.as_ref().and_then(|session| session.runtime_pid), "sessionId": current_session.as_ref().map(|session| session.session_id.clone()), "providerSessionId": current_session.as_ref().map(|session| session.provider_session_id.clone()), "session": current_session, "activeSessionCount": active_session_count, "processCount": process_count, "warmupRunning": warmup_process_count > 0 || warmup_session.as_ref().is_some_and(|session| session.status == PiLabSessionStatus::RuntimeRunning || session.status == PiLabSessionStatus::TurnRunning), "warmupSessionId": warmup_session.as_ref().map(|session| session.session_id.clone()), "warmupStatus": warmup_session.as_ref().map(|session| session_status_to_string(&session.status)), "warmupSessionCount": owned_warmup_session_ids.len(), "warmupProcessCount": warmup_process_count, "managedPiSessionDirPolicy": "/.mnote/ai/pi-sessions//", "managedPiBuiltinTools": current_session .as_ref() .map(pi_lab_enabled_builtin_tools) .unwrap_or_default(), "receiptStorage": "control_plane_turso_libsql_v1", "receiptFallbackStorage": "provider_neutral_jsonl_debug_fallback_v1", "uiMode": "independent_mnote_native_drawer", }))) } pub async fn bootstrap( State(state): State, Extension(context): Extension, Json(request): Json, ) -> Result, WebError> { ensure_enabled(&state)?; cleanup_expired_sessions(); let actor_id = ensure_authenticated(&state, &context)?; check_rate_limit(&actor_id, "start", pi_lab_start_rate_limit(&state))?; let start_request = PiLabStartRequest { session_id: None, root_uri: request.root_uri, workspace_id: request.workspace_id, page_path: request.page_path, page_title: request.page_title, model_provider: request.model_provider, model_id: request.model_id, thinking_level: request.thinking_level, permission_mode: request.permission_mode, }; let requested = session_from_request(&state, &context, &start_request)?; let mut session = requested; session.allowed_roots_snapshot = snapshot_allowed_roots(&state, &context)?; let session = start_runtime_for_session(&state, session).await?; let mut response = json!({ "ok": true, "schema": "mnote.page_ai_pi.bootstrap.v1", "sessionId": session.session_id, "providerSessionId": session.provider_session_id, "runtimeMode": session.runtime_mode, "pid": session.runtime_pid, "sessionDir": session.pi_session_dir, "toolCalls": [], "citations": [], "diffSummary": null, "uiMode": "independent_mnote_native_drawer", }); if let Some(prompt) = request .prompt .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) { let send_response = send( State(state), Extension(context), Json(PiLabSendRequest { session_id: response["sessionId"] .as_str() .unwrap_or_default() .to_string(), message: prompt.to_string(), streaming_behavior: None, root_uri: start_request.root_uri.clone(), workspace_id: start_request.workspace_id.clone(), page_path: start_request.page_path.clone(), page_title: start_request.page_title.clone(), folder_path: None, context_refs: None, selected_context: None, }), ) .await? .0; response["send"] = send_response; response["text"] = json!("Pi Lab 已接收 prompt;真实输出请从 /api/page-ai/pi/events 读取。"); } Ok(Json(response)) } pub async fn start( State(state): State, Extension(context): Extension, Json(request): Json, ) -> Result, WebError> { ensure_enabled(&state)?; cleanup_expired_sessions(); let actor_id = ensure_authenticated(&state, &context)?; check_rate_limit(&actor_id, "start", pi_lab_start_rate_limit(&state))?; let requested_session = session_from_request(&state, &context, &request)?; let mut requested_session = requested_session; requested_session.allowed_roots_snapshot = snapshot_allowed_roots(&state, &context)?; if let Some(existing_session_id) = request.session_id.as_deref() { if let Some(mut existing_session) = get_session(existing_session_id) { ensure_session_owner(&state, &context, &existing_session)?; if session_runtime_is_usable(&existing_session) && session_runtime_config_matches(&existing_session, &requested_session) { refresh_runtime_session_context(&mut existing_session, &requested_session); upsert_session(existing_session.clone()); return Ok(Json(pi_lab_start_response(&existing_session, true))); } } } if kill_session_process(&requested_session.session_id).await { update_session(&requested_session.session_id, |session| { session.status = PiLabSessionStatus::Aborted; session.runtime_pid = None; }); } let session = start_runtime_for_session(&state, requested_session).await?; Ok(Json(pi_lab_start_response(&session, false))) } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PiLabConfigureRequest { pub session_id: String, pub model_provider: Option, pub model_id: Option, pub thinking_level: Option, pub permission_mode: Option, } /// POST /api/page-ai/pi/configure /// 配置 Pi Lab 会话的参数(model、thinking level、permission mode)。 /// 只在 mock 模式下本地生效;real 模式会转发对应 RPC 命令。 pub async fn configure( State(state): State, Extension(context): Extension, Json(request): Json, ) -> Result, WebError> { ensure_enabled(&state)?; cleanup_expired_sessions(); ensure_authenticated(&state, &context)?; let session = get_session_for_context(&state, &context, &request.session_id)?; let mut applied = json!({}); let mut rpc_response_pending = false; let mut rpc_failures: Vec = Vec::new(); if request.model_provider.is_some() || request.model_id.is_some() { let mut requested_session = session.clone(); requested_session.model_provider = request .model_provider .clone() .or_else(|| session.model_provider.clone()); requested_session.model_id = request .model_id .clone() .or_else(|| session.model_id.clone()); ensure_session_model_supports_tools(&requested_session).await?; } if session.runtime_mode != "mock" && session_runtime_is_usable(&session) { if request.model_provider.is_some() || request.model_id.is_some() { let provider = request .model_provider .clone() .or_else(|| session.model_provider.clone()) .unwrap_or_else(|| PI_LAB_DEFAULT_MODEL_PROVIDER.to_string()); let model_id = request .model_id .clone() .or_else(|| session.model_id.clone()) .unwrap_or_else(|| PI_LAB_DEFAULT_MODEL_ID.to_string()); let response = send_rpc_command_wait( &request.session_id, json!({ "id": generate_id("pi_rpc_set_model"), "type": "set_model", "provider": provider, "modelId": model_id, }), Duration::from_secs(5), ) .await?; if rpc_response_success(response.as_ref()) { applied["model"] = json!({ "provider": provider, "modelId": model_id, }); } else { rpc_response_pending = true; rpc_failures.push(rpc_response_error_message( response.as_ref(), "set_model timeout or no response", )); } } if let Some(level) = &request.thinking_level { let normalized = normalize_thinking_level(Some(level)).map_err(|msg| { WebError::bad_request_code("page_ai_pi_lab_invalid_thinking_level", &msg) })?; let response = send_rpc_command_wait( &request.session_id, json!({ "id": generate_id("pi_rpc_set_thinking"), "type": "set_thinking_level", "level": normalized, }), Duration::from_secs(5), ) .await?; if rpc_response_success(response.as_ref()) { applied["thinkingLevel"] = json!(normalized); } else { rpc_response_pending = true; rpc_failures.push(rpc_response_error_message( response.as_ref(), "set_thinking_level timeout or no response", )); } } } if let Some(provider) = &request.model_provider { update_session(&request.session_id, |session| { session.model_provider = Some(provider.clone()); }); } if let Some(model_id) = &request.model_id { update_session(&request.session_id, |session| { session.model_id = Some(model_id.clone()); }); } if let Some(level) = &request.thinking_level { let normalized = normalize_thinking_level(Some(level)).map_err(|msg| { WebError::bad_request_code("page_ai_pi_lab_invalid_thinking_level", &msg) })?; update_session(&request.session_id, |session| { session.thinking_level = Some(normalized); }); } if let Some(mode) = &request.permission_mode { let normalized = normalize_permission_mode(Some(mode)).map_err(|msg| { WebError::bad_request_code("page_ai_pi_lab_invalid_permission_mode", &msg) })?; if let Some(normalized) = normalized { let runtime_policy = refresh_runtime_policy_permission_mode(&session, &normalized); applied["permissionMode"] = json!(normalized); update_session(&request.session_id, |session| { session.runtime_policy_snapshot = Some(runtime_policy); }); } } persist_append_event( &state, &session, "runtime_configured", &json!({ "modelProvider": request.model_provider, "modelId": request.model_id, "thinkingLevel": request.thinking_level, "permissionMode": request.permission_mode, }), )?; let current = get_session(&request.session_id).unwrap_or(session.clone()); Ok(Json(json!({ "ok": true, "schema": "mnote.page_ai_pi.configure.v1", "sessionId": request.session_id, "providerSessionId": current.provider_session_id, "runtimeMode": current.runtime_mode, "stateSource": if current.runtime_mode == "mock" { "mock_runtime_snapshot" } else { "pi_rpc_with_response_tracking" }, "rpcResponsePending": rpc_response_pending, "applied": applied, "failures": rpc_failures, "session": current, }))) } pub async fn send( State(state): State, Extension(context): Extension, Json(request): Json, ) -> Result, WebError> { ensure_enabled(&state)?; cleanup_expired_sessions(); ensure_authenticated(&state, &context)?; let session = get_session_for_context(&state, &context, &request.session_id)?; check_rate_limit(&session.mnote_user_id, "send", PI_LAB_MAX_SENDS_PER_WINDOW)?; if request.message.trim().is_empty() { return Err(WebError::bad_request_code( "page_ai_pi_lab_prompt_required", "prompt 不能为空", )); } update_session(&request.session_id, |session| { if request .root_uri .as_deref() .is_some_and(|value| !value.trim().is_empty()) { session.root_uri = request.root_uri.clone(); } if request .workspace_id .as_deref() .is_some_and(|value| !value.trim().is_empty()) { session.workspace_id = request.workspace_id.clone(); } if request .page_path .as_deref() .is_some_and(|value| !value.trim().is_empty()) { session.page_path = request.page_path.clone(); } if request .page_title .as_deref() .is_some_and(|value| !value.trim().is_empty()) { session.page_title = request.page_title.clone(); } session.status = PiLabSessionStatus::TurnRunning; session.message_count += 1; }); let command_session = get_session(&request.session_id).unwrap_or(session.clone()); write_pi_mnote_context_snapshot( &command_session, request.selected_context.as_ref(), request.context_refs.as_deref(), )?; let input_context_prefix = pi_mnote_input_context_prefix( &command_session, request.selected_context.as_ref(), request.context_refs.as_deref(), )?; let command_message = pi_lab_command_message_for_session( &command_session, &request.message, &input_context_prefix, ); let plan_mode_prompt_applied = session_permission_mode(&command_session) == Some("plan"); let mut command = json!({ "id": generate_id("pi_rpc"), "type": "prompt", "message": command_message, "displayMessage": request.message, "context": { "rootUri": request.root_uri, "workspaceId": request.workspace_id, "pagePath": request.page_path, "pageTitle": request.page_title, "folderPath": request.folder_path, "contextRefs": request.context_refs, "selectedContext": request.selected_context, }, }); if let Some(streaming_behavior) = request .streaming_behavior .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) { command["streamingBehavior"] = json!(streaming_behavior); } if command_session.runtime_mode == "mock" { publish_event( &command_session.session_id, "pi_rpc_event", json!({ "type": "message_update", "assistantMessageEvent": { "type": "text_delta", "delta": "[Pi Lab mock] prompt accepted" } }), ); publish_event( &command_session.session_id, "pi_rpc_event", json!({ "type": "citation", "source": "lightrag-mock", "title": "LightRAG mock citation", "url": "#lightrag-mock-citation", }), ); publish_event( &command_session.session_id, "pi_rpc_event", json!({ "type": "diff", "files": [command_session.page_path.clone().unwrap_or_else(|| "page.md".into())], }), ); update_session(&request.session_id, |session| { session.status = PiLabSessionStatus::RuntimeRunning; }); } else { if let Err(error) = send_rpc_command(&request.session_id, command).await { update_session(&request.session_id, |session| { session.status = PiLabSessionStatus::Error; session.runtime_error = Some(error.message().to_string()); }); return Err(error); } } let current = get_session(&request.session_id).unwrap_or(command_session.clone()); persist_upsert_run(&state, ¤t)?; persist_append_event( &state, ¤t, "user_prompt", &json!({"message": request.message}), )?; Ok(Json(json!({ "ok": true, "schema": "mnote.page_ai_pi.send.v1", "sessionId": request.session_id, "providerSessionId": current.provider_session_id, "accepted": true, "eventStream": "/api/page-ai/pi/events", "permissionMode": session_permission_mode(¤t), "planModePromptApplied": plan_mode_prompt_applied, }))) } pub async fn abort( State(state): State, Extension(context): Extension, Json(request): Json, ) -> Result, WebError> { ensure_enabled(&state)?; cleanup_expired_sessions(); ensure_authenticated(&state, &context)?; let session = get_session_for_context(&state, &context, &request.session_id)?; if session.runtime_mode != "mock" { let _ = send_rpc_command(&request.session_id, json!({"type": "abort"})).await; let _ = kill_session_process(&request.session_id).await; } update_session(&request.session_id, |session| { session.status = PiLabSessionStatus::Aborted; }); let abort_payload = json!({ "schema": "mnote.page_ai_pi.abort.v1", "sessionId": request.session_id, "providerSessionId": session.provider_session_id, "aborted": true, "stopReason": "aborted", "command": "abort", }); publish_event( &request.session_id, "runtime_aborted", abort_payload.clone(), ); // 持久化 abort 状态到 DB if let Some(current) = get_session(&request.session_id) { if let Err(e) = persist_upsert_run(&state, ¤t) { // abort 已执行,DB 写失败仅记录日志,不影响 abort 返回 publish_event( &request.session_id, "runtime_persistence_error", json!({ "error": e.message(), "context": "abort_persist", }), ); } } Ok(Json(json!({ "ok": true, "schema": "mnote.page_ai_pi.abort.v1", "sessionId": request.session_id, "providerSessionId": session.provider_session_id, "aborted": true, "stopReason": "aborted", "command": "abort", }))) } /// POST /api/page-ai/pi/state /// 封装官方 Pi RPC `get_state`。 /// mock 返回完整假数据;real 发送 get_state RPC 并等待响应,超时降级。 pub async fn state( State(state): State, Extension(context): Extension, Json(request): Json, ) -> Result, WebError> { ensure_enabled(&state)?; cleanup_expired_sessions(); ensure_authenticated(&state, &context)?; let session = get_session_for_context(&state, &context, &request.session_id)?; if !session_runtime_is_usable(&session) { return Err(WebError::bad_request_code( "page_ai_pi_lab_runtime_not_started", "Pi runtime 未启动;请先调用 /api/page-ai/pi/start", )); } let is_running = matches!( session.status, PiLabSessionStatus::RuntimeRunning | PiLabSessionStatus::TurnRunning ); let is_streaming = session.status == PiLabSessionStatus::TurnRunning; if session.runtime_mode == "mock" { return Ok(Json(json!({ "ok": true, "schema": PI_LAB_SCHEMA_STATE, "sessionId": session.session_id, "providerSessionId": session.provider_session_id, "stateSource": "mock_runtime_snapshot", "rpcResponsePending": false, "status": session.status, "running": true, "isStreaming": false, "isCompacting": false, "modelProvider": session.model_provider.clone().unwrap_or_else(|| PI_LAB_DEFAULT_MODEL_PROVIDER.to_string()), "modelId": session.model_id.clone().unwrap_or_else(|| PI_LAB_DEFAULT_MODEL_ID.to_string()), "thinkingLevel": session.thinking_level.clone().unwrap_or_else(default_thinking_level), "contextUsage": { "used": 0, "limit": 0, "ratio": 0.0, }, "pendingMessageCount": 0, "queuedMessages": [], "autoCompactionEnabled": false, "autoRetryEnabled": false, }))); } // Real runtime: send get_state RPC command and wait for response let state_response = send_rpc_command_wait( &request.session_id, json!({ "id": generate_id("pi_rpc_state"), "type": "get_state", }), Duration::from_secs(5), ) .await?; if let Some(response) = state_response { if response .get("success") .and_then(Value::as_bool) .unwrap_or(false) { if let Some(data) = response.get("data") { let model_provider = data .get("model") .and_then(|m| m.get("provider")) .and_then(Value::as_str) .map(String::from); let model_id = data .get("model") .and_then(|m| m.get("id")) .and_then(Value::as_str) .map(String::from); let thinking_level = data .get("thinkingLevel") .and_then(Value::as_str) .map(String::from); let effective_thinking_level = session .thinking_level .clone() .or(thinking_level) .unwrap_or_else(default_thinking_level); let steering_mode = data .get("steeringMode") .and_then(Value::as_str) .map(String::from); let follow_up_mode = data .get("followUpMode") .and_then(Value::as_str) .map(String::from); let auto_compaction_enabled = data .get("autoCompactionEnabled") .and_then(Value::as_bool) .unwrap_or(false); let auto_retry_enabled = data .get("autoRetryEnabled") .and_then(Value::as_bool) .unwrap_or(false); let pending_message_count = data .get("pendingMessageCount") .and_then(Value::as_u64) .unwrap_or(0); let rpc_is_streaming = data .get("isStreaming") .and_then(Value::as_bool) .unwrap_or(false); let rpc_is_compacting = data .get("isCompacting") .and_then(Value::as_bool) .unwrap_or(false); return Ok(Json(json!({ "ok": true, "schema": PI_LAB_SCHEMA_STATE, "sessionId": session.session_id, "providerSessionId": session.provider_session_id, "stateSource": "pi_rpc_get_state_response", "rpcResponsePending": false, "status": session.status, "running": is_running, "isStreaming": rpc_is_streaming, "isCompacting": rpc_is_compacting, "modelProvider": model_provider.unwrap_or_else(|| session.model_provider.clone().unwrap_or_else(|| PI_LAB_DEFAULT_MODEL_PROVIDER.to_string())), "modelId": model_id.unwrap_or_else(|| session.model_id.clone().unwrap_or_else(|| PI_LAB_DEFAULT_MODEL_ID.to_string())), "thinkingLevel": effective_thinking_level, "steeringMode": steering_mode, "followUpMode": follow_up_mode, "contextUsage": { "used": 0, "limit": 0, "ratio": 0.0, }, "pendingMessageCount": pending_message_count, "queuedMessages": [], "autoCompactionEnabled": auto_compaction_enabled, "autoRetryEnabled": auto_retry_enabled, }))); } } } // Fallback: degraded response when RPC times out or returns error Ok(Json(json!({ "ok": true, "schema": PI_LAB_SCHEMA_STATE, "sessionId": session.session_id, "providerSessionId": session.provider_session_id, "stateSource": "mnote_session_snapshot_with_rpc_request_pending", "rpcResponsePending": true, "degradedReason": "Pi RPC get_state response not received within timeout or returned error", "status": session.status, "running": is_running, "isStreaming": is_streaming, "isCompacting": false, "modelProvider": session.model_provider.clone().unwrap_or_else(|| PI_LAB_DEFAULT_MODEL_PROVIDER.to_string()), "modelId": session.model_id.clone().unwrap_or_else(|| PI_LAB_DEFAULT_MODEL_ID.to_string()), "thinkingLevel": session.thinking_level.clone().unwrap_or_else(default_thinking_level), "contextUsage": { "used": 0, "limit": 0, "ratio": 0.0, }, "pendingMessageCount": if session.status == PiLabSessionStatus::TurnRunning { session.message_count as u64 } else { 0u64 }, "queuedMessages": [], "autoCompactionEnabled": false, "autoRetryEnabled": false, }))) } /// POST /api/page-ai/pi/compact /// 封装官方 Pi RPC `compact`。 /// mock 返回稳定 compact summary;real 发送 compact RPC 并等待响应,超时降级。 pub async fn compact( State(state): State, Extension(context): Extension, Json(request): Json, ) -> Result, WebError> { ensure_enabled(&state)?; cleanup_expired_sessions(); ensure_authenticated(&state, &context)?; let session = get_session_for_context(&state, &context, &request.session_id)?; if !session_runtime_is_usable(&session) { return Err(WebError::bad_request_code( "page_ai_pi_lab_runtime_not_started", "Pi runtime 未启动;请先调用 /api/page-ai/pi/start", )); } let compact_response = if session.runtime_mode != "mock" { let mut command = json!({ "id": generate_id("pi_rpc_compact"), "type": "compact", }); if let Some(instructions) = &request.custom_instructions { command["customInstructions"] = json!(instructions); } if let Some(tokens) = request.reserve_tokens { command["reserveTokens"] = json!(tokens); } if let Some(tokens) = request.keep_recent_tokens { command["keepRecentTokens"] = json!(tokens); } send_rpc_command_wait(&request.session_id, command, Duration::from_secs(60)).await? } else { None }; let mock_summary = format!( "Mock compact: consolidated {} previous messages into a compacted summary", session.message_count.max(1) ); let mock_first_kept = generate_id("compact_entry"); // Try to extract real response data let response_data: Option = compact_response.as_ref().and_then(|resp| { if resp .get("success") .and_then(Value::as_bool) .unwrap_or(false) { resp.get("data").cloned() } else { None } }); let (summary, first_kept_entry_id, tokens_before, compact_details, rpc_pending, state_source) = if session.runtime_mode == "mock" { ( mock_summary, Some(mock_first_kept), Some(session.message_count.max(1) * 100), json!({ "customInstructions": request.custom_instructions, "reserveTokens": request.reserve_tokens, "keepRecentTokens": request.keep_recent_tokens, }), false, "mock_runtime_snapshot", ) } else if let Some(data) = response_data { let s = data .get("summary") .and_then(Value::as_str) .unwrap_or("Compaction completed") .to_string(); let f = data .get("firstKeptEntryId") .and_then(Value::as_str) .map(String::from); let t = data.get("tokensBefore").and_then(Value::as_u64); let d = data.get("details").cloned().unwrap_or(json!(null)); (s, f, t, d, false, "pi_rpc_compact_response") } else { ( "Compaction requested via Pi RPC".to_string(), None, None, json!({ "customInstructions": request.custom_instructions, "reserveTokens": request.reserve_tokens, "keepRecentTokens": request.keep_recent_tokens, }), true, "pi_rpc_request_pending", ) }; persist_append_event( &state, &session, "runtime_compacted", &json!({ "summary": &summary, "details": &compact_details, }), )?; publish_event( &request.session_id, "runtime_compacted", json!({ "summary": &summary, "details": &compact_details, }), ); Ok(Json(json!({ "ok": true, "schema": PI_LAB_SCHEMA_COMPACT, "sessionId": session.session_id, "providerSessionId": session.provider_session_id, "stateSource": state_source, "rpcResponsePending": rpc_pending, "summary": summary, "firstKeptEntryId": first_kept_entry_id, "tokensBefore": tokens_before, "details": compact_details, }))) } /// POST /api/page-ai/pi/queue-config /// 封装官方 Pi RPC `set_steering_mode`/`set_follow_up_mode`/`set_auto_compaction`。 /// 只发送请求中出现的字段,不发送未提供的字段。 /// 每个 setter 等待对应 RPC response,失败则标记 failed。 pub async fn queue_config( State(state): State, Extension(context): Extension, Json(request): Json, ) -> Result, WebError> { ensure_enabled(&state)?; cleanup_expired_sessions(); ensure_authenticated(&state, &context)?; let session = get_session_for_context(&state, &context, &request.session_id)?; if !session_runtime_is_usable(&session) { return Err(WebError::bad_request_code( "page_ai_pi_lab_runtime_not_started", "Pi runtime 未启动;请先调用 /api/page-ai/pi/start", )); } let mut applied = json!({}); let mut payload_fields = json!({}); let mut has_failed = false; if let Some(mode) = &request.steering_mode { let normalized = normalize_queue_mode(mode, "steeringMode")?; if session.runtime_mode != "mock" { let resp = send_rpc_command_wait( &request.session_id, json!({ "id": generate_id("pi_rpc_steer_mode"), "type": "set_steering_mode", "mode": &normalized, }), Duration::from_secs(5), ) .await?; let ok = resp .as_ref() .and_then(|r| r.get("success")) .and_then(Value::as_bool) .unwrap_or(false); if ok { applied["steeringMode"] = json!(&normalized); } else { applied["steeringMode"] = json!({ "value": &normalized, "failed": true, "error": resp.as_ref() .and_then(|r| r.get("error")) .and_then(Value::as_str) .unwrap_or("timeout or no response"), }); has_failed = true; } } else { applied["steeringMode"] = json!(&normalized); } payload_fields["steeringMode"] = json!(&normalized); } if let Some(mode) = &request.follow_up_mode { let normalized = normalize_queue_mode(mode, "followUpMode")?; if session.runtime_mode != "mock" { let resp = send_rpc_command_wait( &request.session_id, json!({ "id": generate_id("pi_rpc_followup_mode"), "type": "set_follow_up_mode", "mode": &normalized, }), Duration::from_secs(5), ) .await?; let ok = resp .as_ref() .and_then(|r| r.get("success")) .and_then(Value::as_bool) .unwrap_or(false); if ok { applied["followUpMode"] = json!(&normalized); } else { applied["followUpMode"] = json!({ "value": &normalized, "failed": true, "error": resp.as_ref() .and_then(|r| r.get("error")) .and_then(Value::as_str) .unwrap_or("timeout or no response"), }); has_failed = true; } } else { applied["followUpMode"] = json!(&normalized); } payload_fields["followUpMode"] = json!(&normalized); } if let Some(enabled) = request.auto_compaction { if session.runtime_mode != "mock" { let resp = send_rpc_command_wait( &request.session_id, json!({ "id": generate_id("pi_rpc_auto_compact"), "type": "set_auto_compaction", "enabled": enabled, }), Duration::from_secs(5), ) .await?; let ok = resp .as_ref() .and_then(|r| r.get("success")) .and_then(Value::as_bool) .unwrap_or(false); if ok { applied["autoCompaction"] = json!(enabled); } else { applied["autoCompaction"] = json!({ "value": enabled, "failed": true, "error": resp.as_ref() .and_then(|r| r.get("error")) .and_then(Value::as_str) .unwrap_or("timeout or no response"), }); has_failed = true; } } else { applied["autoCompaction"] = json!(enabled); } payload_fields["autoCompaction"] = json!(enabled); } persist_append_event( &state, &session, "runtime_queue_config_applied", &payload_fields, )?; publish_event( &request.session_id, "runtime_queue_config_applied", payload_fields, ); Ok(Json(json!({ "ok": true, "schema": PI_LAB_SCHEMA_QUEUE_CONFIG, "sessionId": session.session_id, "providerSessionId": session.provider_session_id, "stateSource": if session.runtime_mode == "mock" { "mock_runtime_snapshot" } else { "pi_rpc_with_response_tracking" }, "rpcResponsePending": session.runtime_mode != "mock" && has_failed, "applied": applied, }))) } pub async fn ui_response( State(state): State, Extension(context): Extension, Json(request): Json, ) -> Result, WebError> { ensure_enabled(&state)?; cleanup_expired_sessions(); ensure_authenticated(&state, &context)?; let session = get_session_for_context(&state, &context, &request.session_id)?; if request.id.trim().is_empty() { return Err(WebError::bad_request_code( "page_ai_pi_lab_ui_request_id_required", "extension UI request id 不能为空", )); } let mut command = json!({ "type": "extension_ui_response", "id": request.id, }); if let Some(value) = request.value.clone() { command["value"] = value; } if let Some(confirmed) = request.confirmed { command["confirmed"] = json!(confirmed); } if request.cancelled.unwrap_or(false) { command["cancelled"] = json!(true); } if let Some(approval) = request.mnote_approval.clone() { command["mnoteApproval"] = approval; } if session.runtime_mode != "mock" { send_rpc_command(&request.session_id, command.clone()).await?; } if request.confirmed == Some(true) && !request.cancelled.unwrap_or(false) { confirm_pending_approval(&request.session_id, request.mnote_approval.as_ref()); } else if request.cancelled.unwrap_or(false) || request.confirmed == Some(false) { cancel_pending_approval(&request.session_id, request.mnote_approval.as_ref()); } store_pending_ui_response( &request.session_id, &request.id, request.value.clone(), request.confirmed, request.cancelled.unwrap_or(false), ); persist_append_event( &state, &session, "extension_ui_response", &json!({ "id": command.get("id").cloned().unwrap_or(Value::Null), "method": request.method, "value": command.get("value").cloned().unwrap_or(Value::Null), "confirmed": command.get("confirmed").cloned().unwrap_or(Value::Null), "cancelled": command.get("cancelled").cloned().unwrap_or(Value::Null), "mnoteApproval": command.get("mnoteApproval").cloned().unwrap_or(Value::Null), }), )?; Ok(Json(json!({ "ok": true, "schema": "mnote.page_ai_pi.ui_response.v1", "sessionId": request.session_id, "providerSessionId": session.provider_session_id, "requestId": command.get("id").cloned().unwrap_or(Value::Null), }))) } pub async fn ui_request_bridge( State(state): State, Extension(context): Extension, headers: HeaderMap, Json(request): Json, ) -> Result, WebError> { ensure_enabled(&state)?; cleanup_expired_sessions(); let session = get_session_for_bridge(&headers, &request.session_id)?; let context = context_for_session(context, &session); ensure_authenticated(&state, &context)?; if request.id.trim().is_empty() { return Err(WebError::bad_request_code( "page_ai_pi_lab_ui_request_id_required", "extension UI request id 不能为空", )); } let mut payload = json!({ "type": "extension_ui_request", "id": request.id.clone(), "method": request.method.clone(), "title": request.title.clone(), "message": request.message.clone(), }); for (key, value) in request.extra.iter() { if !matches!( key.as_str(), "sessionId" | "session_id" | "id" | "method" | "title" | "message" | "timeoutMs" | "timeout_ms" | "mnoteApproval" | "mnote_approval" ) { payload[key] = value.clone(); } } if let Some(approval) = request.mnote_approval.clone() { payload["mnoteApproval"] = approval; } record_pending_approval_from_event(&session.session_id, &payload); persist_append_event(&state, &session, "pi_rpc_event", &payload)?; publish_event(&session.session_id, "pi_rpc_event", payload); let approval_id = request .mnote_approval .as_ref() .and_then(Value::as_object) .and_then(|approval| { approval .get("approvalId") .or_else(|| approval.get("approval_id")) .and_then(Value::as_str) }) .map(str::to_string) .unwrap_or_else(|| request.id.clone()); let timeout_ms = request.timeout_ms.unwrap_or(60_000).clamp(1_000, 120_000); let started = now_ms(); while now_ms().saturating_sub(started) < u128::from(timeout_ms) { if let Some(response) = take_pending_ui_response(&session.session_id, &request.id) { return Ok(Json(json!({ "ok": true, "confirmed": response.confirmed.unwrap_or(false), "cancelled": response.cancelled, "value": response.value.unwrap_or(Value::Null), "requestId": request.id, }))); } if let Some((confirmed, cancelled)) = pending_approval_response(&session.session_id, &approval_id) { if confirmed { return Ok(Json(json!({ "ok": true, "confirmed": true, "requestId": request.id, }))); } if cancelled { return Ok(Json(json!({ "ok": true, "cancelled": true, "requestId": request.id, }))); } } tokio::time::sleep(Duration::from_millis(120)).await; } Ok(Json(json!({ "ok": false, "cancelled": true, "code": "page_ai_pi_lab_ui_request_timeout", "message": "等待 MNote 审批超时", "requestId": request.id, }))) } pub async fn events( State(state): State, Extension(context): Extension, Query(query): Query, ) -> Result>>, WebError> { ensure_enabled(&state)?; cleanup_expired_sessions(); let user_id = ensure_authenticated(&state, &context)?; let session_filter = if let Some(session_id) = query.session_id { let session = get_session_for_context(&state, &context, &session_id)?; Some(session.session_id) } else { None }; let backlog = if let Some(session_id) = session_filter.as_deref() { let run_id = pi_run_id(session_id); let limit = query.limit.unwrap_or(200).min(1000); state .control_plane() .list_ai_runtime_events(&user_id, &run_id, limit) .map_err(|e| WebError::internal(format!("查询 Pi Lab SSE backlog 失败: {e}")))? .into_iter() .map(|event| { let payload: Value = serde_json::from_str(&event.payload_json).unwrap_or(json!({})); let event_name = event.event_type.clone(); let data = json!({ "schema": PI_LAB_SCHEMA_EVENT, "sessionId": session_id, "kind": event_name.clone(), "createdAt": event.created_at, "payload": payload, "replayed": true, }); Ok(SseEvent::default() .event(event_name) .id(event.id) .data(data.to_string())) }) .collect::>>() } else { Vec::new() }; let rx = PI_LAB_EVENT_TX.subscribe(); let hello = stream::once(async { Ok(SseEvent::default().event("connected").data( json!({ "schema": PI_LAB_SCHEMA_EVENT, "kind": "connected", "version": PI_LAB_VERSION, }) .to_string(), )) }); let replay = stream::iter(backlog); let stream = BroadcastStream::new(rx).filter_map(move |event| { let session_filter = session_filter.clone(); async move { let Ok(value) = event else { return None; }; if let Some(filter) = session_filter.as_deref() { if value.get("sessionId").and_then(Value::as_str) != Some(filter) { return None; } } let event_name = value .get("kind") .and_then(Value::as_str) .unwrap_or("message"); Some(Ok(SseEvent::default() .event(event_name) .data(value.to_string()))) } }); Ok(Sse::new(hello.chain(replay).chain(stream)).keep_alive( KeepAlive::new() .interval(Duration::from_secs(30)) .text("keepalive"), )) } pub async fn tool_call( State(state): State, Extension(context): Extension, Json(request): Json, ) -> Result, WebError> { ensure_enabled(&state)?; cleanup_expired_sessions(); execute_tool( state, context, request.session_id, request.tool_name, request.params, false, ) .await } pub async fn tool_call_bridge( State(state): State, Extension(context): Extension, headers: HeaderMap, Json(request): Json, ) -> Result, WebError> { ensure_enabled(&state)?; cleanup_expired_sessions(); let session_id = request.session_id.clone().ok_or_else(|| { WebError::bad_request_code( "page_ai_pi_lab_bridge_session_required", "Pi Lab tool bridge 缺少 sessionId", ) })?; let session = get_session_for_bridge(&headers, &session_id)?; let context = context_for_session(context, &session); execute_tool( state, context, Some(session_id), request.tool_name, request.params, true, ) .await } async fn execute_mcp_bridge_request( session: &PiLabSession, request: &PiLabMcpBridgeRequest, ) -> Result { if !pi_lab_mcp_enabled(session) { return Err(WebError::new( StatusCode::FORBIDDEN, "page_ai_pi_lab_mcp_disabled", "当前 Pi session 未启用 MCP bridge", )); } let server = request.server.trim(); if server.is_empty() { return Err(WebError::bad_request_code( "page_ai_pi_lab_mcp_server_required", "MCP bridge 缺少 server", )); } let mode = request.mode.trim().to_ascii_lowercase(); if !matches!(mode.as_str(), "list" | "status" | "call") { return Err(WebError::bad_request_code( "page_ai_pi_lab_mcp_mode_invalid", "MCP bridge mode 只支持 list/status/call", )); } let tool = request .tool .as_deref() .map(str::trim) .filter(|value| !value.is_empty()); if mode == "call" && tool.is_none() { return Err(WebError::bad_request_code( "page_ai_pi_lab_mcp_tool_required", "MCP bridge mode=call 时必须提供 tool", )); } if request .arguments .as_ref() .is_some_and(|value| !value.is_object()) { return Err(WebError::bad_request_code( "page_ai_pi_lab_mcp_arguments_invalid", "MCP bridge arguments 必须是对象", )); } let server_config = session .runtime_policy_snapshot .as_ref() .and_then(|value| value.get("mcpServers")) .and_then(Value::as_object) .and_then(|servers| servers.get(server)) .ok_or_else(|| { WebError::bad_request_code( "page_ai_pi_lab_mcp_server_not_configured", format!("MCP server 未配置: {server}"), ) })?; if server_config .get("disabled") .and_then(Value::as_bool) .unwrap_or(false) { return Err(WebError::new( StatusCode::FORBIDDEN, "page_ai_pi_lab_mcp_server_disabled", format!("MCP server 已禁用: {server}"), )); } let config_path = PathBuf::from(&session.pi_session_dir) .join("config") .join("mcp.json"); if !config_path.is_file() { return Err(WebError::internal(format!( "Pi session MCP config 不存在: {}", config_path.display() ))); } let client_path = mnote_pi_mcp_client_path(); if !client_path.is_file() { return Err(WebError::internal(format!( "MNote MCP client 不存在: {}", client_path.display() ))); } let payload = serde_json::to_vec(&json!({ "server": server, "mode": mode, "tool": tool, "arguments": request.arguments.clone().unwrap_or_else(|| json!({})), })) .map_err(|error| WebError::internal(format!("序列化 MCP bridge 请求失败: {error}")))?; let node_binary = env_trimmed("MNOTE_PAGE_AI_PI_MCP_NODE_BIN").unwrap_or_else(|| "node".into()); let mut command = Command::new(node_binary); command .arg(&client_path) .arg("-") .env("MNOTE_MCP_CONFIG_PATH", &config_path) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .kill_on_drop(true); if let Some(parent) = client_path.parent() { command.current_dir(parent); } let mut child = command .spawn() .map_err(|error| WebError::internal(format!("启动 MNote MCP client 失败: {error}")))?; let mut stdin = child .stdin .take() .ok_or_else(|| WebError::internal("MNote MCP client stdin 不可用".to_string()))?; stdin .write_all(&payload) .await .map_err(|error| WebError::internal(format!("写入 MCP bridge 请求失败: {error}")))?; stdin .shutdown() .await .map_err(|error| WebError::internal(format!("关闭 MCP bridge stdin 失败: {error}")))?; drop(stdin); let output = tokio::time::timeout( Duration::from_millis(PI_LAB_MCP_BRIDGE_TIMEOUT_MS), child.wait_with_output(), ) .await .map_err(|_| { WebError::new( StatusCode::GATEWAY_TIMEOUT, "page_ai_pi_lab_mcp_timeout", format!("MCP bridge 超时: {server}/{mode}"), ) })? .map_err(|error| WebError::internal(format!("等待 MNote MCP client 失败: {error}")))?; if output.stdout.len() > PI_LAB_MCP_BRIDGE_MAX_OUTPUT_BYTES { return Err(WebError::new( StatusCode::BAD_GATEWAY, "page_ai_pi_lab_mcp_output_too_large", "MCP bridge 输出超过 2 MiB 限制", )); } if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(WebError::new( StatusCode::BAD_GATEWAY, "page_ai_pi_lab_mcp_client_failed", format!( "MNote MCP client 执行失败: {}", stderr.trim().chars().take(2000).collect::() ), )); } let stdout = String::from_utf8(output.stdout) .map_err(|error| WebError::internal(format!("MCP bridge 输出不是 UTF-8: {error}")))?; serde_json::from_str(stdout.trim()) .map_err(|error| WebError::internal(format!("解析 MCP bridge 输出失败: {error}"))) } pub async fn mcp_call_bridge( State(state): State, headers: HeaderMap, Json(request): Json, ) -> Result, WebError> { ensure_enabled(&state)?; cleanup_expired_sessions(); let session = get_session_for_bridge(&headers, &request.session_id)?; check_rate_limit(&session.mnote_user_id, "mcp", PI_LAB_MAX_TOOLS_PER_WINDOW)?; Ok(Json(execute_mcp_bridge_request(&session, &request).await?)) } fn snapshot_allowed_roots( state: &AppState, context: &RequestContext, ) -> Result, WebError> { match active_allowed_roots(state, context) { Ok(roots) => Ok(Some(json!({ "roots": roots.iter().map(|root| json!({ "rootUri": root.root_uri, "rootPath": root.root_path, "workspaceId": root.workspace_id, "permission": root.permission, "source": root.source, })).collect::>(), "capturedAtMs": now_ms(), }))), Err(_) => Ok(None), } } // --------------------------------------------------------------------------- // DB persistence helpers // --------------------------------------------------------------------------- fn pi_run_id(session_id: &str) -> String { format!("pi_run_{session_id}") } fn session_status_to_string(status: &PiLabSessionStatus) -> &'static str { match status { PiLabSessionStatus::Idle => "idle", PiLabSessionStatus::RuntimeRunning => "runtime_running", PiLabSessionStatus::TurnRunning => "turn_running", PiLabSessionStatus::Aborted => "aborted", PiLabSessionStatus::Error => "error", } } // ── JSONL reader limits ────────────────────────────────────────── const PI_LAB_JSONL_MAX_FILE_BYTES: u64 = 10 * 1024 * 1024; // 10 MiB const PI_LAB_JSONL_MAX_LINE_BYTES: u64 = 100 * 1024; // 100 KiB const PI_LAB_JSONL_MAX_ENTRIES: usize = 5000; const PI_LAB_JSONL_WINDOW_ENTRIES: usize = 2000; fn build_run_runtime_json(session: &PiLabSession) -> String { serde_json::to_string(&json!({ "providerSessionId": session.provider_session_id, "piSessionDir": session.pi_session_dir, "piSessionFile": session.pi_session_file, "runtimeMode": session.runtime_mode, "modelProvider": session.model_provider, "modelId": session.model_id, "thinkingLevel": session.thinking_level, "messageCount": session.message_count, "pagePath": session.page_path, "pageTitle": session.page_title, "rootUri": session.root_uri, "workspaceId": session.workspace_id, "allowedRootsSnapshot": session.allowed_roots_snapshot, "runtimePolicy": session.runtime_policy_snapshot, })) .unwrap_or_default() } fn build_upsert_run_input(session: &PiLabSession) -> UpsertAiRuntimeRunInput { UpsertAiRuntimeRunInput { id: None, user_id: session.mnote_user_id.clone(), workspace_id: session.workspace_id.clone(), document_id: session.page_path.clone(), session_id: session.session_id.clone(), run_id: pi_run_id(&session.session_id), title: session.page_title.clone(), profile: PI_LAB_PROFILE.to_string(), acp_runtime: PI_LAB_ACP_RUNTIME.to_string(), trace_id: None, status: session_status_to_string(&session.status).to_string(), runtime_json: build_run_runtime_json(session), payload_json: "{}".to_string(), } } fn persist_upsert_run(state: &AppState, session: &PiLabSession) -> Result<(), WebError> { let input = build_upsert_run_input(session); state .control_plane() .upsert_ai_runtime_run(input) .map_err(|e| WebError::internal(format!("持久化 Pi Lab run 失败: {e}")))?; Ok(()) } fn build_append_event_input( session: &PiLabSession, event_type: &str, payload_json: &str, ) -> AppendAiRuntimeEventInput { AppendAiRuntimeEventInput { id: None, user_id: session.mnote_user_id.clone(), workspace_id: session.workspace_id.clone(), document_id: session.page_path.clone(), session_id: session.session_id.clone(), run_id: pi_run_id(&session.session_id), profile: PI_LAB_PROFILE.to_string(), acp_runtime: PI_LAB_ACP_RUNTIME.to_string(), event_type: event_type.to_string(), payload_json: payload_json.to_string(), } } fn persist_append_event( state: &AppState, session: &PiLabSession, event_type: &str, payload: &Value, ) -> Result<(), WebError> { let payload_json = serde_json::to_string(payload).unwrap_or_else(|_| "{}".into()); let input = build_append_event_input(session, event_type, &payload_json); state .control_plane() .append_ai_runtime_event(input) .map_err(|e| WebError::internal(format!("持久化 Pi Lab event 失败: {e}")))?; Ok(()) } // ── B2: 受控 Pi JSONL 读取 helper ────────────────────────────────── /// 仅读取当前 session 自己的 pi_session_file。 /// 单行上限 PI_LAB_JSONL_MAX_LINE_BYTES,总大小上限 PI_LAB_JSONL_MAX_FILE_BYTES,entry 上限 PI_LAB_JSONL_MAX_ENTRIES。 /// 返回解析后的 entry 列表。文件不存在或超出限制返回明确错误。 fn read_pi_session_jsonl(session: &PiLabSession) -> Result, WebError> { let pi_session_file = session.pi_session_file.as_deref().ok_or_else(|| { WebError::new( StatusCode::NOT_FOUND, "page_ai_pi_lab_no_pi_session_file", "当前 Pi session 还没有 pi_session_file 记录;请先启动 runtime 后再查询", ) })?; let path = Path::new(pi_session_file); // 安全验证:路径必须在 session pi_session_dir 内 let session_dir = Path::new(&session.pi_session_dir); let canonical_path = canonical_or_parent(path); let canonical_dir = canonical_or_parent(session_dir); if !canonical_path.starts_with(&canonical_dir) { return Err(WebError::new( StatusCode::FORBIDDEN, "page_ai_pi_lab_jsonl_path_escape", "JSONL 文件必须在 session 目录内", )); } if !path.exists() { return Ok(Vec::new()); } let metadata = fs::metadata(path) .map_err(|e| WebError::internal(format!("读取 Pi JSONL metadata 失败: {e}")))?; if metadata.len() > PI_LAB_JSONL_MAX_FILE_BYTES as u64 { return Err(WebError::new( StatusCode::PAYLOAD_TOO_LARGE, "page_ai_pi_lab_jsonl_too_large", format!( "Pi JSONL 文件过大 ({} bytes, 上限 {} bytes)", metadata.len(), PI_LAB_JSONL_MAX_FILE_BYTES ), )); } let raw = fs::read_to_string(path) .map_err(|e| WebError::internal(format!("读取 Pi JSONL 失败: {e}")))?; let mut entries: Vec = Vec::new(); for line in raw.lines() { if line.trim().is_empty() { continue; } if line.len() as u64 > PI_LAB_JSONL_MAX_LINE_BYTES { return Err(WebError::new( StatusCode::PAYLOAD_TOO_LARGE, "page_ai_pi_lab_jsonl_line_too_long", format!( "Pi JSONL 单行过长 ({} bytes, 上限 {} bytes)", line.len(), PI_LAB_JSONL_MAX_LINE_BYTES ), )); } match serde_json::from_str::(line) { Ok(entry) => entries.push(entry), Err(e) => { // 跳过无法解析的行,但记录日志 entries.push(json!({ "parseError": format!("无法解析 JSONL 行: {e}"), "rawPreview": truncate_text(line, 200), })); } } if entries.len() > PI_LAB_JSONL_MAX_ENTRIES { return Err(WebError::new( StatusCode::PAYLOAD_TOO_LARGE, "page_ai_pi_lab_jsonl_too_many_entries", format!( "Pi JSONL entry 过多 (>{}, 上限 {})", PI_LAB_JSONL_MAX_ENTRIES, PI_LAB_JSONL_MAX_ENTRIES ), )); } } Ok(entries) } fn pi_lab_entry_parent_id(entry: &Value) -> Option<&str> { entry .get("parent_id") .or_else(|| entry.get("parentId")) .or_else(|| entry.get("parentEntryId")) .and_then(Value::as_str) } fn pi_lab_entry_message(entry: &Value) -> Option<&Value> { entry .get("message") .filter(|message| message.is_object()) } fn pi_lab_entry_role(entry: &Value) -> String { entry .get("role") .and_then(Value::as_str) .or_else(|| { pi_lab_entry_message(entry) .and_then(|message| message.get("role")) .and_then(Value::as_str) }) .unwrap_or("") .to_string() } fn pi_lab_content_block_text(block: &Value) -> Option { block .get("text") .and_then(Value::as_str) .or_else(|| block.get("content").and_then(Value::as_str)) .or_else(|| block.get("message").and_then(Value::as_str)) .map(str::to_string) } fn pi_lab_content_blocks_text(content: &Value, include_tool_result: bool) -> String { if let Some(text) = content.as_str() { return text.to_string(); } let Some(items) = content.as_array() else { return String::new(); }; items .iter() .filter_map(|item| { let kind = item.get("type").and_then(Value::as_str).unwrap_or(""); if kind == "text" || kind == "input_text" || (include_tool_result && matches!(kind, "tool_result" | "output_text" | "text_delta")) { pi_lab_content_block_text(item) } else { None } }) .collect::>() .join("\n") } fn pi_lab_entry_text(entry: &Value) -> String { if let Some(text) = entry .get("text") .or_else(|| entry.get("content")) .and_then(Value::as_str) { return text.to_string(); } let Some(message) = pi_lab_entry_message(entry) else { return entry .get("message") .and_then(Value::as_str) .unwrap_or_default() .to_string(); }; let role = message.get("role").and_then(Value::as_str).unwrap_or(""); let include_tool_result = role == "toolResult" || role == "tool_result"; message .get("content") .map(|content| pi_lab_content_blocks_text(content, include_tool_result)) .unwrap_or_default() } fn pi_lab_message_content_text(message: &Value, include_tool_result: bool) -> String { message .get("content") .map(|content| pi_lab_content_blocks_text(content, include_tool_result)) .unwrap_or_default() } fn pi_lab_entry_tool_calls(entry: &Value) -> Vec { let Some(message) = pi_lab_entry_message(entry) else { return Vec::new(); }; let Some(items) = message.get("content").and_then(Value::as_array) else { return Vec::new(); }; items .iter() .filter(|item| item.get("type").and_then(Value::as_str) == Some("toolCall")) .map(|item| { json!({ "id": item.get("id"), "name": item.get("name"), "arguments": item.get("arguments"), }) }) .collect() } fn pi_lab_message_tool_calls(message: &Value) -> Vec { let Some(items) = message.get("content").and_then(Value::as_array) else { return Vec::new(); }; items .iter() .filter(|item| item.get("type").and_then(Value::as_str) == Some("toolCall")) .map(|item| { json!({ "id": item.get("id"), "toolCallId": item.get("toolCallId").or_else(|| item.get("tool_call_id")).or_else(|| item.get("id")), "toolName": item.get("name").or_else(|| item.get("toolName")).or_else(|| item.get("tool_name")), "name": item.get("name").or_else(|| item.get("toolName")).or_else(|| item.get("tool_name")), "args": item.get("args").or_else(|| item.get("arguments")).cloned().unwrap_or_else(|| json!({})), "status": "running", }) }) .collect() } fn pi_lab_tool_result_call_id(message: &Value) -> String { message .get("toolCallId") .or_else(|| message.get("tool_call_id")) .and_then(Value::as_str) .unwrap_or_default() .to_string() } fn pi_lab_tool_result_name(message: &Value) -> String { message .get("toolName") .or_else(|| message.get("tool_name")) .and_then(Value::as_str) .unwrap_or("tool") .to_string() } fn pi_lab_entry_id(entry: &Value) -> String { entry .get("id") .and_then(Value::as_str) .unwrap_or_default() .to_string() } fn pi_lab_entry_seq(entry: &Value) -> i64 { entry .get("seq") .or_else(|| entry.get("entry_seq")) .and_then(Value::as_i64) .unwrap_or(0) } fn pi_lab_entry_created_at(entry: &Value) -> Value { entry .get("created_at") .or_else(|| entry.get("timestamp")) .or_else(|| entry.get("createdAt")) .cloned() .unwrap_or(Value::Null) } fn pi_lab_active_path_entries(entries: &[Value]) -> Vec<&Value> { if entries.is_empty() { return Vec::new(); } let mut by_id: std::collections::HashMap = std::collections::HashMap::new(); for (index, entry) in entries.iter().enumerate() { let id = pi_lab_entry_id(entry); if !id.is_empty() { by_id.insert(id, index); } } let Some(leaf_id) = entries .iter() .max_by_key(|entry| pi_lab_entry_seq(entry)) .map(pi_lab_entry_id) .filter(|id| !id.is_empty()) else { return entries.iter().collect(); }; let mut path = Vec::new(); let mut visited = std::collections::HashSet::new(); let mut current = Some(leaf_id); while let Some(id) = current { if !visited.insert(id.clone()) { break; } let Some(index) = by_id.get(&id).copied() else { break; }; let entry = &entries[index]; path.push(entry); current = pi_lab_entry_parent_id(entry).map(str::to_string); } path.reverse(); if path.is_empty() || (path.len() <= 1 && entries.len() > 1) { entries.iter().collect() } else { path } } fn build_pi_replay_messages(entries: &[Value]) -> Vec { let mut messages: Vec = Vec::new(); for entry in pi_lab_active_path_entries(entries) { let Some(message) = pi_lab_entry_message(entry) else { continue; }; let role = message.get("role").and_then(Value::as_str).unwrap_or(""); let entry_id = pi_lab_entry_id(entry); let base = json!({ "entryId": entry_id, "parentId": pi_lab_entry_parent_id(entry), "seq": pi_lab_entry_seq(entry), "createdAt": pi_lab_entry_created_at(entry), }); match role { "user" => { let text = pi_lab_message_content_text(message, false); if !text.trim().is_empty() { messages.push(json!({ "entryId": base["entryId"], "parentId": base["parentId"], "seq": base["seq"], "createdAt": base["createdAt"], "role": "user", "text": text, "meta": "", })); } } "assistant" => { let text = pi_lab_message_content_text(message, false); let tool_calls = pi_lab_message_tool_calls(message); if !text.trim().is_empty() || !tool_calls.is_empty() { messages.push(json!({ "entryId": base["entryId"], "parentId": base["parentId"], "seq": base["seq"], "createdAt": base["createdAt"], "role": "assistant", "text": text, "meta": pi_lab_entry_meta(entry), "toolCalls": tool_calls, })); } } "toolResult" | "tool_result" => { let text = pi_lab_message_content_text(message, true); let tool_name = pi_lab_tool_result_name(message); let tool_call_id = pi_lab_tool_result_call_id(message); let is_error = message .get("isError") .or_else(|| message.get("is_error")) .and_then(Value::as_bool) .unwrap_or(false); let tool_call = json!({ "id": tool_call_id, "toolCallId": tool_call_id, "toolName": tool_name, "name": tool_name, "status": if is_error { "error" } else { "done" }, "result": { "content": message.get("content").cloned().unwrap_or_else(|| json!([])), "details": message.get("details").cloned().unwrap_or(Value::Null), }, "isError": is_error, }); messages.push(json!({ "entryId": base["entryId"], "parentId": base["parentId"], "seq": base["seq"], "createdAt": base["createdAt"], "role": "assistant", "text": "", "meta": if text.trim().is_empty() { "" } else { "tool result" }, "toolCalls": [tool_call], })); } "custom" => { let display = message .get("display") .and_then(Value::as_bool) .unwrap_or(false); let text = message .get("content") .and_then(Value::as_str) .unwrap_or_default(); if display && !text.trim().is_empty() { messages.push(json!({ "entryId": base["entryId"], "parentId": base["parentId"], "seq": base["seq"], "createdAt": base["createdAt"], "role": "system", "text": text, "meta": message.get("customType").or_else(|| message.get("custom_type")).and_then(Value::as_str).unwrap_or("custom"), })); } } "bashExecution" | "bash_execution" => { messages.push(json!({ "entryId": base["entryId"], "parentId": base["parentId"], "seq": base["seq"], "createdAt": base["createdAt"], "role": "assistant", "text": "", "meta": "bash execution", "toolCalls": [{ "id": base["entryId"], "toolCallId": base["entryId"], "toolName": "bash", "name": "bash", "status": if message.get("exitCode").or_else(|| message.get("exit_code")).and_then(Value::as_i64).unwrap_or(0) == 0 { "done" } else { "error" }, "args": {"command": message.get("command").cloned().unwrap_or(Value::Null)}, "result": {"content": [{"type": "text", "text": message.get("output").and_then(Value::as_str).unwrap_or_default()}]}, }], })); } _ => {} } } messages } fn pi_lab_entry_meta(entry: &Value) -> String { pi_lab_entry_message(entry) .and_then(|message| message.get("stopReason")) .and_then(Value::as_str) .map(|reason| format!("stopReason={reason}")) .unwrap_or_default() } /// 从解析后的 JSONL entry 列表中提取 entry tree、active leaf、compaction/branch 摘要。 fn build_pi_entry_tree(entries: &[Value]) -> Value { let mut nodes = json!({}); let mut edges: Vec = Vec::new(); let mut compaction_summaries: Vec = Vec::new(); let mut branch_summaries: Vec = Vec::new(); let mut _active_leaf_id: Option = None; let mut max_seq: i64 = -1; for entry in entries { let entry_id = entry.get("id").and_then(Value::as_str).unwrap_or(""); let entry_type = entry .get("type") .and_then(Value::as_str) .unwrap_or("unknown"); let parent_id = pi_lab_entry_parent_id(entry); let role = pi_lab_entry_role(entry); let seq = entry .get("seq") .or_else(|| entry.get("entry_seq")) .and_then(Value::as_i64) .unwrap_or(0); if seq > max_seq { max_seq = seq; _active_leaf_id = (!entry_id.is_empty()).then(|| entry_id.to_string()); } let preview = truncate_text(&pi_lab_entry_text(entry), 200); nodes[entry_id] = json!({ "id": entry_id, "type": entry_type, "role": role, "preview": preview, "seq": seq, "parentId": parent_id, }); if let Some(pid) = parent_id { if !pid.is_empty() { edges.push(json!({ "from": pid, "to": entry_id, })); } } if entry_type == "compaction" { let summary = entry .get("summary") .or_else(|| entry.get("details")) .and_then(Value::as_str) .map(str::to_string) .unwrap_or_default(); compaction_summaries.push(json!({ "entryId": entry_id, "summary": summary, "tokensBefore": entry.get("tokensBefore").or_else(|| entry.get("tokens_before")), "firstKeptEntryId": entry.get("firstKeptEntryId").or_else(|| entry.get("first_kept_entry_id")), })); } if entry_type == "branch_summary" { branch_summaries.push(json!({ "entryId": entry_id, "summary": entry.get("summary").and_then(Value::as_str).unwrap_or(""), "branchPointEntryId": entry.get("branchPointEntryId").or_else(|| entry.get("branch_point_entry_id")), })); } } json!({ "nodes": nodes, "edges": edges, }) } // ── B1: 扫描并记录 pi_session_file ────────────────────────────────── /// 从 session dir 中扫描 Pi JSONL session 文件并设置 pi_session_file。 /// Pi 的命名模式:YYYY-MM-DDTHH-MM-SS.sssZ_id.jsonl fn resolve_pi_session_file(session: &PiLabSession) -> Option { let session_dir = Path::new(&session.pi_session_dir); if !session_dir.exists() { return None; } // Pi 创建 session 时可能在 session_dir 下创建子目录,或直接在 session_dir 中创建 .jsonl // 先在 session_dir 中找 .jsonl 文件 let mut candidates: Vec = Vec::new(); if let Ok(entries) = fs::read_dir(session_dir) { for entry in entries.flatten() { let path = entry.path(); if path.extension().and_then(|e| e.to_str()) == Some("jsonl") { // 检查命名模式 if let Some(name) = path.file_stem().and_then(|n| n.to_str()) { // Pi pattern: YYYY-MM-DDTHH-MM-SS.sssZ_id 或包含 timestamp if name.contains('T') || name.len() > 20 { candidates.push(path); } } } } } // 也检查 session_dir 的子目录(Pi 可能创建 session-index.sqlite 等,但 .jsonl 通常在 session_dir 下) // 按修改时间排序,取最新的 candidates.sort_by_key(|p| fs::metadata(p).ok().and_then(|m| m.modified().ok())); candidates .last() .and_then(|p| p.to_str().map(str::to_string)) } // ── B3: GET /api/page-ai/pi/sessions/{sessionId}/tree ────────────── #[derive(Debug, Deserialize)] pub struct PiLabSessionTreeQuery { pub window_entries: Option, } pub async fn session_tree( State(state): State, Extension(context): Extension, axum::extract::Path(path): axum::extract::Path, Query(query): Query, ) -> Result, WebError> { ensure_enabled(&state)?; cleanup_expired_sessions(); let user_id = ensure_authenticated(&state, &context)?; // 先检查内存 session let session = get_session(&path.session_id) .or_else(|| { // fallback: 从 control-plane 恢复 session 元数据 state .control_plane() .find_ai_runtime_run(&user_id, &pi_run_id(&path.session_id)) .ok() .flatten() .map(|run| { let runtime: Value = serde_json::from_str(&run.runtime_json).unwrap_or(json!({})); PiLabSession { session_id: run.session_id, mnote_user_id: run.user_id, bridge_token: String::new(), status: PiLabSessionStatus::Idle, provider_session_id: runtime .get("providerSessionId") .and_then(Value::as_str) .unwrap_or("") .to_string(), pi_session_dir: runtime .get("piSessionDir") .and_then(Value::as_str) .unwrap_or("") .to_string(), pi_session_file: runtime .get("piSessionFile") .and_then(Value::as_str) .map(str::to_string), root_uri: runtime .get("rootUri") .and_then(Value::as_str) .map(str::to_string), workspace_id: run.workspace_id, page_path: run.document_id, page_title: run.title, model_provider: runtime .get("modelProvider") .and_then(Value::as_str) .map(str::to_string), model_id: runtime .get("modelId") .and_then(Value::as_str) .map(str::to_string), thinking_level: runtime .get("thinkingLevel") .and_then(Value::as_str) .map(str::to_string), allowed_roots_snapshot: runtime.get("allowedRootsSnapshot").cloned(), runtime_policy_snapshot: runtime.get("runtimePolicy").cloned(), runtime_pid: None, runtime_mode: runtime .get("runtimeMode") .and_then(Value::as_str) .unwrap_or("mock") .to_string(), runtime_error: None, created_at_ms: 0, updated_at_ms: 0, message_count: 0, } }) }) .ok_or_else(|| { WebError::new( StatusCode::NOT_FOUND, "page_ai_pi_lab_session_not_found", "Pi Lab session 不存在或不属于当前用户", ) })?; // Verify ownership if session.mnote_user_id != user_id { return Err(WebError::new( StatusCode::FORBIDDEN, "page_ai_pi_lab_session_owner_mismatch", "Pi Lab session 不属于当前登录主体", )); } // B2: Read JSONL let entries = match read_pi_session_jsonl(&session) { Ok(entries) => entries, Err(e) => { return Ok(Json(json!({ "ok": true, "schema": PI_LAB_SCHEMA_SESSION_TREE, "sessionId": session.session_id, "providerSessionId": session.provider_session_id, "piSessionFile": session.pi_session_file, "status": session.status, "entries": [], "entryTree": json!({}), "activeLeafId": Value::Null, "compactionSummaries": [], "branchSummaries": [], "totalEntries": 0, "degradedReason": format!("Failed to read Pi JSONL: {}", e.message()), }))); } }; let total_entries = entries.len(); let replay_messages = build_pi_replay_messages(&entries); let window_size = query .window_entries .unwrap_or(PI_LAB_JSONL_WINDOW_ENTRIES) .min(total_entries); // Window entries (last N for preview, for memory safety) let window_start = if window_size >= total_entries { 0 } else { total_entries - window_size }; let window_entries: Vec = entries[window_start..] .iter() .map(|entry| { json!({ "id": entry.get("id"), "parentId": pi_lab_entry_parent_id(entry).map(|parent| Value::String(parent.to_string())), "type": entry.get("type").unwrap_or(&json!("unknown")), "role": pi_lab_entry_role(entry), "text": pi_lab_entry_text(entry), "preview": truncate_text(&pi_lab_entry_text(entry), 200), "meta": pi_lab_entry_meta(entry), "toolCalls": pi_lab_entry_tool_calls(entry), "seq": entry.get("seq").or_else(|| entry.get("entry_seq")), "createdAt": entry.get("created_at").or_else(|| entry.get("timestamp")).or_else(|| entry.get("createdAt")), }) }) .collect(); // Build tree from full entries let entry_tree = build_pi_entry_tree(&entries); // Extract summaries let compaction_summaries: Vec = entries.iter() .filter(|e| e.get("type").and_then(Value::as_str) == Some("compaction")) .map(|e| json!({ "entryId": e.get("id"), "summary": e.get("summary").or_else(|| e.get("details")).and_then(Value::as_str).unwrap_or(""), "tokensBefore": e.get("tokensBefore").or_else(|| e.get("tokens_before")), "firstKeptEntryId": e.get("firstKeptEntryId").or_else(|| e.get("first_kept_entry_id")), })) .collect(); let branch_summaries: Vec = entries.iter() .filter(|e| e.get("type").and_then(Value::as_str) == Some("branch_summary")) .map(|e| json!({ "entryId": e.get("id"), "summary": e.get("summary").and_then(Value::as_str).unwrap_or(""), "branchPointEntryId": e.get("branchPointEntryId").or_else(|| e.get("branch_point_entry_id")), })) .collect(); // Determine active leaf (last entry with max seq or last entry in window) let active_leaf_id = entries .iter() .max_by_key(|e| { e.get("seq") .or_else(|| e.get("entry_seq")) .and_then(Value::as_i64) .unwrap_or(0) }) .and_then(|e| e.get("id").and_then(Value::as_str).map(str::to_string)); Ok(Json(json!({ "ok": true, "schema": PI_LAB_SCHEMA_SESSION_TREE, "sessionId": session.session_id, "providerSessionId": session.provider_session_id, "piSessionFile": session.pi_session_file, "status": session.status, "entries": window_entries, "messages": replay_messages, "entryTree": entry_tree, "activeLeafId": active_leaf_id, "compactionSummaries": compaction_summaries, "branchSummaries": branch_summaries, "totalEntries": total_entries, "windowStart": window_start, "windowSize": window_size, "degradedReason": Value::Null, }))) } // ── B5: POST /api/page-ai/pi/fork ────────────────────────────────── #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PiLabForkRequest { pub session_id: String, pub entry_id: Option, } pub async fn fork_pi_session( State(state): State, Extension(context): Extension, Json(request): Json, ) -> Result, WebError> { ensure_enabled(&state)?; cleanup_expired_sessions(); ensure_authenticated(&state, &context)?; let source_session = get_session_for_context(&state, &context, &request.session_id)?; let rpc_response_pending: bool; let degraded_reason: Option; let new_session: PiLabSession; if source_session.runtime_mode == "mock" { // Mock fork: generate a stable mock fork session let entry_id = request .entry_id .clone() .unwrap_or_else(|| "mock_root_entry".to_string()); new_session = PiLabSession { session_id: generate_id("pi_fork"), mnote_user_id: source_session.mnote_user_id.clone(), bridge_token: generate_bridge_token(), status: PiLabSessionStatus::RuntimeRunning, provider_session_id: generate_id("pi_provider_fork"), pi_session_dir: source_session.pi_session_dir.clone(), pi_session_file: source_session.pi_session_file.clone(), root_uri: source_session.root_uri.clone(), workspace_id: source_session.workspace_id.clone(), page_path: source_session.page_path.clone(), page_title: Some(format!( "{} (fork)", source_session.page_title.as_deref().unwrap_or("Pi Session") )), model_provider: source_session.model_provider.clone(), model_id: source_session.model_id.clone(), thinking_level: source_session.thinking_level.clone(), allowed_roots_snapshot: source_session.allowed_roots_snapshot.clone(), runtime_policy_snapshot: source_session.runtime_policy_snapshot.clone(), runtime_pid: None, runtime_mode: "mock".to_string(), runtime_error: None, created_at_ms: now_ms(), updated_at_ms: now_ms(), message_count: 0, }; upsert_session(new_session.clone()); let _ = persist_upsert_run(&state, &new_session); rpc_response_pending = false; degraded_reason = None; publish_event( &new_session.session_id, "runtime_started", json!({ "mode": "mock_fork", "sourceSessionId": source_session.session_id, "forkEntryId": entry_id, "providerSessionId": new_session.provider_session_id, "sessionId": new_session.session_id, }), ); } else if session_runtime_is_usable(&source_session) { // Real runtime: send fork RPC command let fork_response = send_rpc_command_wait( &request.session_id, json!({ "id": generate_id("pi_rpc_fork"), "type": "fork", "entryId": request.entry_id, }), Duration::from_secs(10), ) .await?; if let Some(ref response) = fork_response { if response .get("success") .and_then(Value::as_bool) .unwrap_or(false) { if let Some(data) = response.get("data") { let forked_session_id = data.get("sessionId").and_then(Value::as_str).unwrap_or(""); new_session = PiLabSession { session_id: generate_id("pi_fork"), mnote_user_id: source_session.mnote_user_id.clone(), bridge_token: generate_bridge_token(), status: PiLabSessionStatus::RuntimeRunning, provider_session_id: data .get("providerSessionId") .and_then(Value::as_str) .unwrap_or("") .to_string(), pi_session_dir: data .get("sessionDir") .and_then(Value::as_str) .map(str::to_string) .unwrap_or_else(|| source_session.pi_session_dir.clone()), pi_session_file: None, root_uri: source_session.root_uri.clone(), workspace_id: source_session.workspace_id.clone(), page_path: source_session.page_path.clone(), page_title: Some(format!( "{} (fork)", source_session.page_title.as_deref().unwrap_or("Pi Session") )), model_provider: source_session.model_provider.clone(), model_id: source_session.model_id.clone(), thinking_level: source_session.thinking_level.clone(), allowed_roots_snapshot: source_session.allowed_roots_snapshot.clone(), runtime_policy_snapshot: source_session.runtime_policy_snapshot.clone(), runtime_pid: None, runtime_mode: source_session.runtime_mode.clone(), runtime_error: None, created_at_ms: now_ms(), updated_at_ms: now_ms(), message_count: 0, }; upsert_session(new_session.clone()); let _ = persist_upsert_run(&state, &new_session); rpc_response_pending = false; degraded_reason = None; publish_event( &new_session.session_id, "runtime_started", json!({ "mode": "fork", "sourceSessionId": source_session.session_id, "forkedSessionId": forked_session_id, "providerSessionId": new_session.provider_session_id, "sessionId": new_session.session_id, }), ); } else { return Err(WebError::bad_gateway_code( "page_ai_pi_lab_fork_response_no_data", "Pi fork RPC 返回成功但没有 data", )); } } else { rpc_response_pending = true; degraded_reason = Some(rpc_response_error_message( fork_response.as_ref(), "Pi fork RPC 返回错误", )); // Return degraded response with source session info return Ok(Json(json!({ "ok": true, "schema": PI_LAB_SCHEMA_FORK, "sourceSessionId": source_session.session_id, "sessionId": Value::Null, "session": Value::Null, "rpcResponsePending": rpc_response_pending, "degradedReason": degraded_reason, }))); } } else { rpc_response_pending = true; degraded_reason = Some("Pi fork RPC 超时或未收到响应".to_string()); return Ok(Json(json!({ "ok": true, "schema": PI_LAB_SCHEMA_FORK, "sourceSessionId": source_session.session_id, "sessionId": Value::Null, "session": Value::Null, "rpcResponsePending": rpc_response_pending, "degradedReason": degraded_reason, }))); } } else { return Err(WebError::bad_request_code( "page_ai_pi_lab_runtime_not_started", "源 session Pi runtime 未启动;请先调用 /api/page-ai/pi/start", )); } Ok(Json(json!({ "ok": true, "schema": PI_LAB_SCHEMA_FORK, "sourceSessionId": source_session.session_id, "sessionId": new_session.session_id, "session": new_session, "rpcResponsePending": rpc_response_pending, "degradedReason": degraded_reason, }))) } // ── D2: GET /api/page-ai/pi/artifacts/{toolEventId}/diff ────────── #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PiLabArtifactDiffQuery { pub session_id: Option, } pub async fn artifact_diff( State(state): State, Extension(context): Extension, axum::extract::Path(tool_event_id): axum::extract::Path, Query(query): Query, ) -> Result, WebError> { ensure_enabled(&state)?; cleanup_expired_sessions(); let user_id = ensure_authenticated(&state, &context)?; let session_id = query.session_id.as_deref().ok_or_else(|| { WebError::bad_request_code( "page_ai_pi_lab_session_id_required", "artifact diff 需要 sessionId 查询参数", ) })?; // Verify session ownership let run = state .control_plane() .find_ai_runtime_run(user_id.trim(), &pi_run_id(session_id)) .map_err(|e| WebError::internal(format!("查询 session 详情失败: {e}")))? .ok_or_else(|| { WebError::new( StatusCode::NOT_FOUND, "page_ai_pi_lab_session_not_found", "Pi Lab session 不存在或不属于当前用户", ) })?; if run.profile != PI_LAB_PROFILE || run.acp_runtime != PI_LAB_ACP_RUNTIME { return Err(WebError::new( StatusCode::NOT_FOUND, "page_ai_pi_lab_session_not_found", "Pi Lab session 不存在或不属于当前用户", )); } // Query control-plane ai_file_patches by session_id, filter in-memory by tool_event_id let patches = state .control_plane() .list_ai_file_patches(user_id.trim(), Some(session_id), 100) .map_err(|e| WebError::internal(format!("查询 file patches 失败: {e}")))?; let matching = patches .into_iter() .find(|p| p.tool_event_id == tool_event_id); match matching { Some(patch) => { let patch_summary: Value = serde_json::from_str(&patch.patch_summary_json).unwrap_or(json!({})); Ok(Json(json!({ "ok": true, "schema": PI_LAB_SCHEMA_ARTIFACT_DIFF, "toolEventId": tool_event_id, "sessionId": session_id, "diffSummary": patch_summary.get("diffSummary").and_then(Value::as_str).unwrap_or(""), "patchSummary": patch_summary, "rootUri": patch.root_uri, "relativePath": patch.relative_path, "beforeFileVersion": patch.before_file_version, "afterFileVersion": patch.after_file_version, "raw": Value::Null, }))) } None => { // 404: no matching patch found Err(WebError::new( StatusCode::NOT_FOUND, "page_ai_pi_lab_artifact_not_found", format!("toolEventId {tool_event_id} 没有对应的 file patch 记录"), )) } } } // --------------------------------------------------------------------------- // Session history handlers // --------------------------------------------------------------------------- pub async fn list_sessions( State(state): State, Extension(context): Extension, Query(query): Query, ) -> Result, WebError> { if !enabled(&state) { return Ok(Json(json!({"ok": true, "sessions": []}))); } let user_id = ensure_authenticated(&state, &context)?; let limit = query.limit.unwrap_or(20).min(100); let runs = state .control_plane() .list_ai_runtime_runs(&user_id, query.workspace_id.as_deref(), None, None, limit) .map_err(|e| WebError::internal(format!("查询 session 历史失败: {e}")))?; // Filter to Pi Lab runs in-memory (the store list method doesn't support profile/acp_runtime filter) let pi_runs: Vec = runs .into_iter() .filter(|r| r.profile == PI_LAB_PROFILE && r.acp_runtime == PI_LAB_ACP_RUNTIME) .filter(|r| !is_pi_lab_warmup_session_id(&r.session_id)) .map(|r| { let runtime: Value = serde_json::from_str(&r.runtime_json).unwrap_or(json!({})); let preview = runtime .get("pageTitle") .and_then(Value::as_str) .map(str::to_string) .or_else(|| r.title.clone()) .unwrap_or_default(); json!({ "sessionId": r.session_id, "runId": r.run_id, "profile": r.profile, "acpRuntime": r.acp_runtime, "status": r.status, "title": r.title, "preview": preview, "messageCount": runtime.get("messageCount").unwrap_or(&json!(0)), "pagePath": runtime.get("pagePath"), "pageTitle": runtime.get("pageTitle"), "modelProvider": runtime.get("modelProvider"), "modelId": runtime.get("modelId"), "createdAt": r.created_at, "updatedAt": r.updated_at, }) }) .collect(); Ok(Json(json!({ "ok": true, "schema": "mnote.page_ai_pi.list_sessions.v1", "sessions": pi_runs, "count": pi_runs.len(), }))) } pub async fn get_session_history( State(state): State, Extension(context): Extension, axum::extract::Path(path): axum::extract::Path, ) -> Result, WebError> { if !enabled(&state) { return Err(WebError::new( StatusCode::NOT_FOUND, "page_ai_pi_lab_disabled", "Pi Lab 已被 MNOTE_PAGE_AI_PI_LAB=0 强制关闭", )); } let user_id = ensure_authenticated(&state, &context)?; let run_id = pi_run_id(&path.session_id); let run = state .control_plane() .find_ai_runtime_run(&user_id, &run_id) .map_err(|e| WebError::internal(format!("查询 session 详情失败: {e}")))? .ok_or_else(|| { WebError::new( StatusCode::NOT_FOUND, "page_ai_pi_lab_session_not_found", "Pi Lab session 不存在或不属于当前用户", ) })?; if run.profile != PI_LAB_PROFILE || run.acp_runtime != PI_LAB_ACP_RUNTIME { return Err(WebError::new( StatusCode::NOT_FOUND, "page_ai_pi_lab_session_not_found", "Pi Lab session 不存在或不属于当前用户", )); } let runtime: Value = serde_json::from_str(&run.runtime_json).unwrap_or(json!({})); let payload: Value = serde_json::from_str(&run.payload_json).unwrap_or(json!({})); Ok(Json(json!({ "ok": true, "schema": "mnote.page_ai_pi.get_session.v1", "session": { "id": run.id, "sessionId": run.session_id, "runId": run.run_id, "userId": run.user_id, "workspaceId": run.workspace_id, "documentId": run.document_id, "title": run.title, "profile": run.profile, "acpRuntime": run.acp_runtime, "status": run.status, "traceId": run.trace_id, "runtime": runtime, "payload": payload, "createdAt": run.created_at, "updatedAt": run.updated_at, } }))) } pub async fn get_session_events( State(state): State, Extension(context): Extension, axum::extract::Path(path): axum::extract::Path, Query(query): Query, ) -> Result, WebError> { if !enabled(&state) { return Err(WebError::new( StatusCode::NOT_FOUND, "page_ai_pi_lab_disabled", "Pi Lab 已被 MNOTE_PAGE_AI_PI_LAB=0 强制关闭", )); } let user_id = ensure_authenticated(&state, &context)?; let run_id = pi_run_id(&path.session_id); let limit = query.limit.unwrap_or(200).min(1000); // Verify the run exists and belongs to the user let run = state .control_plane() .find_ai_runtime_run(&user_id, &run_id) .map_err(|e| WebError::internal(format!("查询 session 详情失败: {e}")))? .ok_or_else(|| { WebError::new( StatusCode::NOT_FOUND, "page_ai_pi_lab_session_not_found", "Pi Lab session 不存在或不属于当前用户", ) })?; if run.profile != PI_LAB_PROFILE || run.acp_runtime != PI_LAB_ACP_RUNTIME { return Err(WebError::new( StatusCode::NOT_FOUND, "page_ai_pi_lab_session_not_found", "Pi Lab session 不存在或不属于当前用户", )); } let events = state .control_plane() .list_ai_runtime_events(&user_id, &run_id, limit) .map_err(|e| WebError::internal(format!("查询 session events 失败: {e}")))?; let event_values: Vec = events .into_iter() .map(|e| { let payload: Value = serde_json::from_str(&e.payload_json).unwrap_or(json!({})); json!({ "id": e.id, "eventType": e.event_type, "profile": e.profile, "acpRuntime": e.acp_runtime, "payload": payload, "createdAt": e.created_at, }) }) .collect(); Ok(Json(json!({ "ok": true, "schema": "mnote.page_ai_pi.get_session_events.v1", "sessionId": path.session_id, "runId": run_id, "events": event_values, "count": event_values.len(), }))) } pub async fn delete_session_history( State(state): State, Extension(context): Extension, axum::extract::Path(path): axum::extract::Path, Query(query): Query, ) -> Result, WebError> { if !enabled(&state) { return Err(WebError::new( StatusCode::NOT_FOUND, "page_ai_pi_lab_disabled", "Pi Lab 已被 MNOTE_PAGE_AI_PI_LAB=0 强制关闭", )); } let user_id = ensure_authenticated(&state, &context)?; let run_id = pi_run_id(&path.session_id); let run = state .control_plane() .find_ai_runtime_run(&user_id, &run_id) .map_err(|e| WebError::internal(format!("查询 Pi Lab session 失败: {e}")))? .ok_or_else(|| { WebError::new( StatusCode::NOT_FOUND, "page_ai_pi_lab_session_not_found", "Pi Lab session 不存在或不属于当前用户", ) })?; if run.profile != PI_LAB_PROFILE || run.acp_runtime != PI_LAB_ACP_RUNTIME { return Err(WebError::new( StatusCode::NOT_FOUND, "page_ai_pi_lab_session_not_found", "Pi Lab session 不存在或不属于当前用户", )); } let deleted = state .control_plane() .delete_ai_runtime_session(&user_id, &path.session_id, query.workspace_id.as_deref()) .map_err(|e| WebError::internal(format!("删除 Pi Lab session 失败: {e}")))?; let killed = kill_session_process(&path.session_id).await; let removed = remove_session(&path.session_id); Ok(Json(json!({ "ok": true, "schema": "mnote.page_ai_pi.delete_session.v1", "sessionId": path.session_id, "deletedRuns": deleted, "runtimeKilled": killed, "memorySessionRemoved": removed, }))) } pub async fn clear_sessions( State(state): State, Extension(context): Extension, Query(query): Query, ) -> Result, WebError> { if !enabled(&state) { return Ok(Json(json!({ "ok": true, "schema": "mnote.page_ai_pi.clear_sessions.v1", "deletedRuns": 0, "sessionIds": [], }))); } let user_id = ensure_authenticated(&state, &context)?; let limit = query.limit.unwrap_or(500).min(1000); let runs = state .control_plane() .list_ai_runtime_runs(&user_id, query.workspace_id.as_deref(), None, None, limit) .map_err(|e| WebError::internal(format!("查询 Pi Lab session 历史失败: {e}")))?; let mut seen = HashSet::new(); let mut session_ids = Vec::new(); for run in runs { if run.profile == PI_LAB_PROFILE && run.acp_runtime == PI_LAB_ACP_RUNTIME && seen.insert(run.session_id.clone()) { session_ids.push(run.session_id); } } let mut deleted_runs = 0usize; let mut killed_sessions = 0usize; let mut memory_sessions_removed = 0usize; for session_id in &session_ids { deleted_runs += state .control_plane() .delete_ai_runtime_session(&user_id, session_id, query.workspace_id.as_deref()) .map_err(|e| WebError::internal(format!("清空 Pi Lab session 失败: {e}")))?; if kill_session_process(session_id).await { killed_sessions += 1; } if remove_session(session_id) { memory_sessions_removed += 1; } } Ok(Json(json!({ "ok": true, "schema": "mnote.page_ai_pi.clear_sessions.v1", "deletedRuns": deleted_runs, "sessionIds": session_ids, "killedSessions": killed_sessions, "memorySessionsRemoved": memory_sessions_removed, }))) } pub async fn rename_session( State(state): State, Extension(context): Extension, axum::extract::Path(path): axum::extract::Path, Json(request): Json, ) -> Result, WebError> { if !enabled(&state) { return Err(WebError::new( StatusCode::NOT_FOUND, "page_ai_pi_lab_disabled", "Pi Lab 已被 MNOTE_PAGE_AI_PI_LAB=0 强制关闭", )); } let user_id = ensure_authenticated(&state, &context)?; let title = request.title.trim(); if title.is_empty() { return Err(WebError::bad_request_code( "page_ai_pi_lab_session_title_required", "session 标题不能为空", )); } let renamed = state .control_plane() .rename_ai_runtime_session( &user_id, &path.session_id, request.workspace_id.as_deref(), title, ) .map_err(|e| WebError::internal(format!("重命名 Pi Lab session 失败: {e}")))?; if renamed .iter() .all(|run| run.profile != PI_LAB_PROFILE || run.acp_runtime != PI_LAB_ACP_RUNTIME) { return Err(WebError::new( StatusCode::NOT_FOUND, "page_ai_pi_lab_session_not_found", "Pi Lab session 不存在或不属于当前用户", )); } update_session(&path.session_id, |session| { session.page_title = Some(title.to_string()); }); Ok(Json(json!({ "ok": true, "schema": "mnote.page_ai_pi.rename_session.v1", "sessionId": path.session_id, "title": title, "updatedRuns": renamed.len(), }))) } #[cfg(test)] mod tests { use super::*; use crate::app::{build_app, AppConfig, AppState}; use axum::body::{to_bytes, Body}; use axum::http::{Request, StatusCode}; use control_plane::{DirectoryGrantInput, UpsertAiPolicyInput, UpsertUserInput}; use tower::util::ServiceExt; fn test_state() -> AppState { AppState::new(AppConfig { service_name: "mnote-web".into(), service_version: "test".into(), bind_addr: "127.0.0.1:0".into(), public_bind_addr: "127.0.0.1:3000".into(), legacy_next_base_url: None, enable_legacy_next_compat: false, enable_debug_shell_routes: false, enable_editor_actor: true, enable_page_ai_pi_lab: true, hermes_base_path: "/api/hermes".into(), compat_next_base_path: "/api/compat/next".into(), convex_url: None, convex_admin_key: None, allow_dev_fixtures: true, query_fixtures_json: None, mutation_fixtures_json: None, dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), }) } fn test_app() -> axum::Router { build_app(test_state()) } fn temp_root(name: &str) -> PathBuf { let root = std::env::temp_dir().join(format!("{name}-{}", now_ms())); fs::create_dir_all(&root).expect("temp root"); root.canonicalize().expect("canonical temp root") } fn file_uri(path: &Path) -> String { format!("file://{}", path.to_string_lossy()) } fn grant_directory(state: &AppState, user_id: &str, root: &Path, permission: &str) -> String { let root_uri = file_uri(root); state .control_plane() .upsert_user(UpsertUserInput { id: Some(user_id.into()), email: Some(format!("{user_id}@example.com")), username: user_id.into(), display_name: user_id.into(), role: None, password_hash: None, }) .expect("upsert user"); state .control_plane() .upsert_user(UpsertUserInput { id: Some("test_admin".into()), email: Some("test-admin@example.com".into()), username: "test_admin".into(), display_name: "test_admin".into(), role: Some("admin".into()), password_hash: None, }) .expect("upsert admin"); state .control_plane() .grant_directory_access(DirectoryGrantInput { user_id: user_id.into(), workspace_id: None, root_uri: root_uri.clone(), root_path: root.to_string_lossy().to_string(), permission: permission.into(), recursive: true, capabilities: vec!["ai".into()], source: "test".into(), created_by: Some("test_admin".into()), }) .expect("grant directory"); root_uri } fn upsert_ai_policy(state: &AppState, user_id: &str, model_policy: Value) { state .control_plane() .upsert_ai_policy(UpsertAiPolicyInput { id: None, user_id: Some(user_id.into()), workspace_id: None, allowed_roots_json: "[]".into(), model_policy_json: model_policy.to_string(), quota_json: "{}".into(), }) .expect("upsert ai policy"); } async fn request_json( app: axum::Router, uri: &str, actor_id: &str, body: Value, ) -> (StatusCode, Value) { let response = app .oneshot( Request::builder() .method("POST") .uri(uri) .header("content-type", "application/json") .header("x-mnote-actor-id", actor_id) .header("x-mnote-actor-type", "user") .body(Body::from(body.to_string())) .expect("request"), ) .await .expect("response"); let status = response.status(); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); (status, payload) } async fn bridge_request_json( app: axum::Router, actor_id: &str, bridge_token: &str, body: Value, ) -> (StatusCode, Value) { let response = app .oneshot( Request::builder() .method("POST") .uri("/api/page-ai/pi/tool-call-bridge") .header("content-type", "application/json") .header("x-mnote-actor-id", actor_id) .header("x-mnote-actor-type", "user") .header(HEADER_PI_LAB_BRIDGE_TOKEN, bridge_token) .body(Body::from(body.to_string())) .expect("request"), ) .await .expect("response"); let status = response.status(); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); (status, payload) } fn confirm_test_approval(session_id: &str, approval: &Value) { record_pending_approval_from_event( session_id, &json!({ "type": "extension_ui_request", "method": "confirm", "mnoteApproval": approval, }), ); confirm_pending_approval(session_id, Some(approval)); } fn permission_mode_test_session(root: &Path, mode: &str) -> PiLabSession { PiLabSession { session_id: format!("pi_lab_permission_{mode}"), mnote_user_id: "pi_permission_user".into(), bridge_token: "bridge".into(), status: PiLabSessionStatus::RuntimeRunning, provider_session_id: format!("prov_permission_{mode}"), pi_session_dir: root .join(format!("session-{mode}")) .to_string_lossy() .to_string(), pi_session_file: None, root_uri: Some(file_uri(root)), workspace_id: None, page_path: Some("note.md".into()), page_title: Some("Permission mode test".into()), model_provider: Some("omniroute".into()), model_id: Some("pi-fast".into()), thinking_level: Some("medium".into()), allowed_roots_snapshot: Some(json!({ "roots": [{ "rootUri": file_uri(root), "rootPath": root.to_string_lossy(), "permission": "write" }] })), runtime_policy_snapshot: Some(json!({ "permissionMode": mode, "allowExternalPiExtensions": true, "enabledPiExtensions": ["pi-permission-system"], "enabledPiExtensionSources": ["npm:@gotgenes/pi-permission-system"], "mnoteToolNames": [ "mnote.current_page.read", "mnote.selection.read", "mnote.allowed_roots.describe", "mnote.local_file.read", "mnote.local_file.patch", "mnote.knowledge_rag.status", "mnote.knowledge_rag.query", "mnote.knowledge_rag.section_context", "mnote.knowledge_rag.open_reference", "mnote.reference.open", "mnote.codex_rescue.request", "mnote.tool_receipt.write" ], "mnoteToolPolicies": { "mnote.current_page.read": "allow", "mnote.selection.read": "allow", "mnote.allowed_roots.describe": "allow", "mnote.local_file.read": "ask", "mnote.local_file.patch": "ask", "mnote.knowledge_rag.status": "allow", "mnote.knowledge_rag.query": "allow", "mnote.knowledge_rag.section_context": "allow", "mnote.knowledge_rag.open_reference": "allow", "mnote.reference.open": "allow", "mnote.codex_rescue.request": "ask", "mnote.tool_receipt.write": "allow" } })), runtime_pid: None, runtime_mode: "mock".into(), runtime_error: None, created_at_ms: 1000, updated_at_ms: 2000, message_count: 0, } } fn permission_config_for_mode(root: &Path, mode: &str) -> Value { let session = permission_mode_test_session(root, mode); let config_dir = root.join(format!("config-{mode}")); let path = ensure_session_pi_permission_config(&session, &config_dir) .expect("permission config") .expect("permission-system enabled"); serde_json::from_slice(&fs::read(path).expect("permission config file")).unwrap() } #[test] fn permission_modes_compile_distinct_runtime_policies() { let root = temp_root("mnote-pi-permission-modes"); let root_rule = shell_glob_escape_path(root.to_string_lossy().trim_end_matches('/')); let root_wildcard_rule = format!("{root_rule}/*"); let confirm = permission_config_for_mode(&root, "confirm"); assert_eq!(confirm["permission"]["path"]["*"], "ask"); assert_eq!(confirm["permission"]["path"][&root_rule], "ask"); assert_eq!(confirm["permission"]["read"], "allow"); assert_eq!(confirm["permission"]["write"], "ask"); assert_eq!(confirm["permission"]["edit"], "ask"); assert_eq!(confirm["permission"]["bash"]["*"], "ask"); assert_eq!(confirm["permission"]["bash"]["rm -rf *"], "ask"); assert_eq!(confirm["permission"]["mnote_local_file_read"], "ask"); assert_eq!(confirm["permission"]["mnote_local_file_patch"], "ask"); let auto_edit = permission_config_for_mode(&root, "auto_edit"); assert_eq!(auto_edit["permission"]["path"]["*"], "ask"); assert_eq!(auto_edit["permission"]["path"][&root_rule], "allow"); assert_eq!( auto_edit["permission"]["path"][&root_wildcard_rule], "allow" ); assert_eq!(auto_edit["permission"]["read"], "allow"); assert_eq!(auto_edit["permission"]["write"], "allow"); assert_eq!(auto_edit["permission"]["edit"], "allow"); assert_eq!(auto_edit["permission"]["bash"]["*"], "ask"); assert_eq!(auto_edit["permission"]["bash"]["rm -rf *"], "ask"); assert_eq!(auto_edit["permission"]["mnote_local_file_read"], "allow"); assert_eq!(auto_edit["permission"]["mnote_local_file_patch"], "allow"); let plan = permission_config_for_mode(&root, "plan"); assert_eq!(plan["permission"]["path"]["*"], "deny"); assert_eq!(plan["permission"]["path"][&root_rule], "allow"); assert_eq!(plan["permission"]["path"][&root_wildcard_rule], "allow"); assert_eq!(plan["permission"]["read"], "allow"); assert_eq!(plan["permission"]["write"], "deny"); assert_eq!(plan["permission"]["edit"], "deny"); assert_eq!(plan["permission"]["bash"]["*"], "deny"); assert_eq!(plan["permission"]["bash"]["rm -rf *"], "deny"); assert_eq!(plan["permission"]["mnote_local_file_read"], "allow"); assert_eq!(plan["permission"]["mnote_local_file_patch"], "deny"); assert_eq!(plan["permission"]["mnote_codex_rescue_request"], "deny"); let full_access = permission_config_for_mode(&root, "full_access"); assert_eq!(full_access["permission"]["path"]["*"], "allow"); assert_eq!(full_access["permission"]["path"][&root_rule], "allow"); assert_eq!(full_access["permission"]["read"], "allow"); assert_eq!(full_access["permission"]["write"], "allow"); assert_eq!(full_access["permission"]["edit"], "allow"); assert_eq!(full_access["permission"]["bash"]["*"], "allow"); assert_eq!(full_access["permission"]["bash"]["rm -rf *"], "allow"); assert_eq!(full_access["permission"]["mnote_local_file_read"], "allow"); assert_eq!(full_access["permission"]["mnote_local_file_patch"], "allow"); assert_eq!( full_access["permission"]["mnote_codex_rescue_request"], "ask" ); } #[test] fn plan_mode_wraps_prompt_with_readonly_instructions() { let root = temp_root("mnote-pi-plan-mode-prompt"); let plan_session = permission_mode_test_session(&root, "plan"); let prompt = pi_lab_effective_prompt_for_session(&plan_session, "请删除这个目录并修改 note.md"); assert!(prompt.contains("当前是 MNote Pi 计划模式")); assert!(prompt.contains("不要实际写入、删除、移动、重命名文件")); assert!(prompt.contains("不要执行 bash/write/edit/rm/mv/cp/mkdir")); assert!(prompt.contains("用户原始请求")); assert!(prompt.contains("请删除这个目录并修改 note.md")); let full_access_session = permission_mode_test_session(&root, "full_access"); assert_eq!( pi_lab_effective_prompt_for_session(&full_access_session, "请删除这个目录"), "请删除这个目录" ); } #[test] fn command_prompt_routes_hidden_context_through_extension_input_hook() { let root = temp_root("mnote-pi-skill-prompt-context"); let full_access_session = permission_mode_test_session(&root, "full_access"); let context = "[[MNOTE_PI_CONTEXT_V1:7b7d]]\n"; let normal_prompt = pi_lab_command_message_for_session(&full_access_session, "请只回复 ok", context); assert_eq!(normal_prompt, format!("{context}请只回复 ok")); assert!(!normal_prompt.contains("bridgeToken")); let prompt = pi_lab_command_message_for_session( &full_access_session, "/skill:vpn 请返回 skill 中定义的端口", context, ); assert!(prompt.starts_with("/skill:vpn\n")); assert!(prompt.contains(context)); assert!(!prompt.contains("bridgeToken")); assert!(prompt.ends_with("请返回 skill 中定义的端口")); let plan_session = permission_mode_test_session(&root, "plan"); let plan_prompt = pi_lab_command_message_for_session(&plan_session, "/skill:vpn 检查代理配置", context); assert!(plan_prompt.starts_with("/skill:vpn\n")); assert!(plan_prompt.contains(context)); assert!(!plan_prompt.contains("bridgeToken")); assert!(plan_prompt.contains("当前是 MNote Pi 计划模式")); assert!(plan_prompt.contains("用户原始请求")); assert!(plan_prompt.ends_with("检查代理配置")); } #[test] fn pi_rust_jsonl_message_entries_preserve_tail_text_and_tools() { let entries = vec![ json!({ "id": "u1", "type": "message", "seq": 1, "message": { "role": "user", "content": "请查看当前页" } }), json!({ "id": "a1", "type": "message", "seq": 2, "parentId": "u1", "message": { "role": "assistant", "content": [{ "type": "toolCall", "id": "tool_1", "name": "mnote_current_page_read", "arguments": {"path": "note.md"} }] } }), json!({ "id": "t1", "type": "message", "seq": 3, "parentId": "a1", "message": { "role": "toolResult", "toolCallId": "tool_1", "content": [{"type": "text", "text": "当前页内容"}] } }), json!({ "id": "a2", "type": "message", "seq": 4, "parentId": "t1", "message": { "role": "assistant", "content": [{"type": "text", "text": "FINAL_TAIL_REPLY"}], "stopReason": "stop" } }), ]; assert_eq!(pi_lab_entry_text(&entries[0]), "请查看当前页"); assert_eq!(pi_lab_entry_parent_id(&entries[1]), Some("u1")); assert_eq!(pi_lab_entry_role(&entries[1]), "assistant"); let tool_calls = pi_lab_entry_tool_calls(&entries[1]); assert_eq!(tool_calls.len(), 1); assert_eq!(tool_calls[0]["name"], "mnote_current_page_read"); assert_eq!(pi_lab_entry_text(&entries[2]), "当前页内容"); assert_eq!(pi_lab_entry_text(&entries[3]), "FINAL_TAIL_REPLY"); assert_eq!(pi_lab_entry_meta(&entries[3]), "stopReason=stop"); let tree = build_pi_entry_tree(&entries); assert_eq!(tree["nodes"]["a2"]["preview"], "FINAL_TAIL_REPLY"); assert_eq!(tree["edges"][2]["from"], "t1"); assert_eq!(tree["edges"][2]["to"], "a2"); let replay = build_pi_replay_messages(&entries); assert_eq!(replay.len(), 4); assert_eq!(replay[0]["role"], "user"); assert_eq!(replay[0]["text"], "请查看当前页"); assert_eq!(replay[1]["role"], "assistant"); assert_eq!(replay[1]["toolCalls"][0]["toolName"], "mnote_current_page_read"); assert_eq!(replay[2]["role"], "assistant"); assert_eq!(replay[2]["toolCalls"][0]["status"], "done"); assert_eq!(replay[2]["toolCalls"][0]["result"]["content"][0]["text"], "当前页内容"); assert_eq!(replay[3]["text"], "FINAL_TAIL_REPLY"); } #[test] fn pi_run_id_format() { let rid = pi_run_id("pi_lab_abc123"); assert_eq!(rid, "pi_run_pi_lab_abc123"); } #[test] fn session_status_to_string_maps_all_variants() { assert_eq!(session_status_to_string(&PiLabSessionStatus::Idle), "idle"); assert_eq!( session_status_to_string(&PiLabSessionStatus::RuntimeRunning), "runtime_running" ); assert_eq!( session_status_to_string(&PiLabSessionStatus::TurnRunning), "turn_running" ); assert_eq!( session_status_to_string(&PiLabSessionStatus::Aborted), "aborted" ); assert_eq!( session_status_to_string(&PiLabSessionStatus::Error), "error" ); } #[test] fn agent_end_does_not_revive_terminal_session_status() { assert_eq!( status_after_agent_end(&PiLabSessionStatus::Aborted), PiLabSessionStatus::Aborted ); assert_eq!( status_after_agent_end(&PiLabSessionStatus::Error), PiLabSessionStatus::Error ); assert_eq!( status_after_agent_end(&PiLabSessionStatus::TurnRunning), PiLabSessionStatus::RuntimeRunning ); } #[test] fn mnote_context_snapshot_keeps_bridge_token_out_of_prompt_payload() { let root = temp_root("mnote-pi-context-bridge-token"); let session = permission_mode_test_session(&root, "full_access"); let prompt_payload = pi_mnote_context_payload(&session, None, None); assert!(prompt_payload.get("bridgeToken").is_none()); assert_eq!( prompt_payload["modelProvider"], session .model_provider .clone() .map(Value::String) .unwrap_or(Value::Null) ); assert_eq!( prompt_payload["modelId"], session .model_id .clone() .map(Value::String) .unwrap_or(Value::Null) ); let context_path = write_pi_mnote_context_snapshot(&session, None, None).expect("write context snapshot"); let persisted: Value = serde_json::from_slice(&fs::read(&context_path).expect("read context snapshot")) .expect("parse context snapshot"); assert_eq!( persisted["bridgeToken"], Value::String(session.bridge_token.clone()) ); assert_eq!( persisted["sessionId"], Value::String(session.session_id.clone()) ); assert_eq!( persisted["bridgeBaseUrl"], Value::String(pi_lab_public_base_url()) ); #[cfg(unix)] { let mode = fs::metadata(context_path) .expect("context metadata") .permissions() .mode() & 0o777; assert_eq!(mode, 0o600); } } #[test] fn permission_modes_expose_pi_builtins_by_mode() { let root = temp_root("mnote-pi-full-access-builtins"); let mut session = permission_mode_test_session(&root, "full_access"); session.runtime_policy_snapshot = Some(json!({ "permissionMode": "full_access", "allowExternalPiExtensions": false, "enabledPiExtensions": ["pi-rust-official-permission-gate"], "enabledPiExtensionSources": ["pi-rust-official:permission-gate"], })); assert!(!pi_lab_permission_system_enabled(&session)); assert_eq!( pi_lab_enabled_builtin_tools(&session), pi_lab_managed_builtin_tools() ); session.runtime_policy_snapshot = Some(json!({ "permissionMode": "auto_edit", "allowExternalPiExtensions": false, "enabledPiExtensions": ["pi-rust-official-permission-gate"], "enabledPiExtensionSources": ["pi-rust-official:permission-gate"], })); assert_eq!( pi_lab_enabled_builtin_tools(&session), vec![ "read".to_string(), "write".to_string(), "edit".to_string(), "grep".to_string(), "find".to_string(), "ls".to_string(), "hashline_edit".to_string() ] ); session.runtime_policy_snapshot = Some(json!({ "permissionMode": "plan", "allowExternalPiExtensions": false, "enabledPiExtensions": ["pi-rust-official-permission-gate"], "enabledPiExtensionSources": ["pi-rust-official:permission-gate"], })); assert_eq!( pi_lab_enabled_builtin_tools(&session), vec![ "read".to_string(), "grep".to_string(), "find".to_string(), "ls".to_string() ] ); } #[test] fn omniroute_models_config_preserves_tools_and_stream_usage() { let root = temp_root("mnote-pi-omniroute-models-config"); let session = PiLabSession { session_id: "pi_lab_models_config".into(), mnote_user_id: "user_test".into(), bridge_token: "bridge".into(), status: PiLabSessionStatus::Idle, provider_session_id: "prov_models_config".into(), pi_session_dir: root.join("session").to_string_lossy().to_string(), pi_session_file: None, root_uri: Some(file_uri(&root)), workspace_id: Some("ws_test".into()), page_path: Some("doc.md".into()), page_title: Some("Test Page".into()), model_provider: Some("omniroute".into()), model_id: Some("freefirst".into()), thinking_level: Some("off".into()), allowed_roots_snapshot: None, runtime_policy_snapshot: None, runtime_pid: None, runtime_mode: "rpc".into(), runtime_error: None, created_at_ms: 1000, updated_at_ms: 2000, message_count: 0, }; let config_dir = ensure_session_models_config(&session, true).expect("models config"); let models: Value = serde_json::from_slice(&fs::read(config_dir.join("models.json")).expect("models file")) .expect("models json"); let compat = &models["providers"]["omniroute"]["compat"]; assert_eq!(compat["supportsDeveloperRole"], false); assert_eq!(compat["supportsReasoningEffort"], false); assert_eq!(compat["supportsTools"], true); assert_eq!(compat["supportsUsageInStreaming"], true); assert_eq!( models["providers"]["omniroute"]["apiKey"], "OPENAI_API_KEY", "Pi Rust resolves bare *_API_KEY env var names; shell-style $OPENAI_API_KEY is sent literally" ); assert_eq!( models["providers"]["omniroute"]["baseUrl"], omniroute_base_url() ); } #[test] fn omniroute_model_capability_requires_explicit_tool_calling() { let catalog = json!({ "data": [ { "id": "gpt-5.4-mini", "capabilities": { "tool_calling": true, "reasoning": true } }, { "id": "freefirst", "capabilities": { "reasoning": true } } ] }); assert_eq!( omniroute_model_tool_calling_capability(&catalog, "gpt-5.4-mini"), Some(true) ); assert_eq!( omniroute_model_tool_calling_capability(&catalog, "freefirst"), Some(false) ); assert_eq!( omniroute_model_tool_calling_capability(&catalog, "missing"), None ); } #[test] fn build_upsert_run_input_creates_correct_input() { let session = PiLabSession { session_id: "pi_lab_test123".into(), mnote_user_id: "user_test".into(), bridge_token: "bridge".into(), status: PiLabSessionStatus::RuntimeRunning, provider_session_id: "prov_123".into(), pi_session_dir: "/tmp/pi-lab/test".into(), pi_session_file: None, root_uri: Some("file:///workspace".into()), workspace_id: Some("ws_test".into()), page_path: Some("doc.md".into()), page_title: Some("Test Page".into()), model_provider: Some("omniroute".into()), model_id: Some("freefirst".into()), thinking_level: Some("medium".into()), allowed_roots_snapshot: None, runtime_policy_snapshot: None, runtime_pid: Some(12345), runtime_mode: "mock".into(), runtime_error: None, created_at_ms: 1000, updated_at_ms: 2000, message_count: 5, }; let input = build_upsert_run_input(&session); assert_eq!(input.user_id, "user_test"); assert_eq!(input.run_id, "pi_run_pi_lab_test123"); assert_eq!(input.session_id, "pi_lab_test123"); assert_eq!(input.profile, "pi_lab"); assert_eq!(input.acp_runtime, "pi"); assert_eq!(input.status, "runtime_running"); assert_eq!(input.title.as_deref(), Some("Test Page")); assert_eq!(input.workspace_id.as_deref(), Some("ws_test")); assert_eq!(input.document_id.as_deref(), Some("doc.md")); let runtime: Value = serde_json::from_str(&input.runtime_json).unwrap(); assert_eq!(runtime["messageCount"], 5); assert_eq!(runtime["modelProvider"], "omniroute"); } #[test] fn build_append_event_input_creates_correct_input() { let session = PiLabSession { session_id: "pi_lab_test456".into(), mnote_user_id: "user_test".into(), bridge_token: "bridge".into(), status: PiLabSessionStatus::RuntimeRunning, provider_session_id: "prov_456".into(), pi_session_dir: "/tmp/pi-lab/test".into(), pi_session_file: None, root_uri: None, workspace_id: None, page_path: None, page_title: None, model_provider: None, model_id: None, thinking_level: Some("medium".into()), allowed_roots_snapshot: None, runtime_policy_snapshot: None, runtime_pid: None, runtime_mode: "mock".into(), runtime_error: None, created_at_ms: 1000, updated_at_ms: 2000, message_count: 0, }; let payload = json!({"type": "text_delta", "delta": "hello"}); let input = build_append_event_input( &session, "pi_rpc_event", &serde_json::to_string(&payload).unwrap(), ); assert_eq!(input.user_id, "user_test"); assert_eq!(input.session_id, "pi_lab_test456"); assert_eq!(input.run_id, "pi_run_pi_lab_test456"); assert_eq!(input.event_type, "pi_rpc_event"); let parsed: Value = serde_json::from_str(&input.payload_json).unwrap(); assert_eq!(parsed["delta"], "hello"); } #[test] fn tool_bridge_extension_does_not_duplicate_native_skill_loading() { let root = temp_root("mnote-pi-bridge-no-skill-dup"); let skill_path = root.join("context7").join("SKILL.md"); fs::create_dir_all(skill_path.parent().unwrap()).expect("skill dir"); fs::write( &skill_path, "---\nname: context7\n---\n\n# Context7\n\nUse Context7 docs.\n", ) .expect("skill file"); let session = PiLabSession { session_id: "pi_lab_skill_no_dup".into(), mnote_user_id: "user_test".into(), bridge_token: "bridge".into(), status: PiLabSessionStatus::RuntimeRunning, provider_session_id: "prov_skill".into(), pi_session_dir: root.join("session").to_string_lossy().to_string(), pi_session_file: None, root_uri: None, workspace_id: None, page_path: None, page_title: None, model_provider: None, model_id: None, thinking_level: Some("medium".into()), allowed_roots_snapshot: None, runtime_policy_snapshot: Some(json!({ "enabledSkillSources": [skill_path.to_string_lossy()] })), runtime_pid: None, runtime_mode: "mock".into(), runtime_error: None, created_at_ms: 1000, updated_at_ms: 2000, message_count: 0, }; let manifest = mnote_pi_tool_manifest(&session); assert!(manifest .as_array() .unwrap() .iter() .any(|tool| tool["piName"] == "mnote_current_page_read")); let extension_path = mnote_pi_extension_path(); let source = fs::read_to_string(extension_path).expect("extension source"); assert!(source.contains("pi.registerTool")); assert!(!source.contains("SKILL_CONTEXTS")); assert!(!source.contains("before_agent_start")); assert!(!source.contains("MNote Pi Lab enabled skill context follows")); assert!(!source.contains("Use Context7 docs.")); } #[test] fn skill_cli_args_use_native_skill_without_no_skills() { let session = PiLabSession { session_id: "pi_lab_skill_args".into(), mnote_user_id: "user_test".into(), bridge_token: "bridge".into(), status: PiLabSessionStatus::RuntimeRunning, provider_session_id: "prov_skill".into(), pi_session_dir: "/tmp/pi-lab/skill-args".into(), pi_session_file: None, root_uri: None, workspace_id: None, page_path: None, page_title: None, model_provider: None, model_id: None, thinking_level: Some("medium".into()), allowed_roots_snapshot: None, runtime_policy_snapshot: Some(json!({ "enabledSkillSources": ["/tmp/context7/SKILL.md", "/tmp/vpn/SKILL.md"] })), runtime_pid: None, runtime_mode: "mock".into(), runtime_error: None, created_at_ms: 1000, updated_at_ms: 2000, message_count: 0, }; assert_eq!( pi_lab_skill_cli_args(&session), vec![ "--skill", "/tmp/context7/SKILL.md", "--skill", "/tmp/vpn/SKILL.md" ] ); let mut no_skill_session = session; no_skill_session.runtime_policy_snapshot = None; assert_eq!( pi_lab_skill_cli_args(&no_skill_session), vec!["--no-skills"] ); } #[tokio::test] async fn list_sessions_filters_by_current_user_and_pi_profile() { let app = test_app(); let response = app .oneshot( Request::builder() .method("GET") .uri("/api/page-ai/pi/sessions") .header("x-mnote-actor-id", "user_test_list") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["ok"], true); assert_eq!(payload["schema"], "mnote.page_ai_pi.list_sessions.v1"); // Should be an empty list since no Pi sessions exist for this user assert!(payload["sessions"].is_array()); assert_eq!(payload["count"].as_u64().unwrap_or(0), 0); } #[tokio::test] async fn start_uses_ai_settings_for_model_skills_mcp_and_tools() { std::env::set_var("MNOTE_PAGE_AI_PI_LAB_RUNTIME", "mock"); std::env::set_var("MNOTE_PAGE_AI_PI_MCP_STRICT_LAZY", "1"); let state = test_state(); let actor_id = "pi_policy_user"; let root = temp_root("mnote-pi-policy-root"); let root_uri = grant_directory(&state, actor_id, &root, "write"); upsert_ai_policy( &state, actor_id, json!({ "defaultModel": "omniroute/pi-fast", "allowedModels": ["omniroute/pi-fast"], "tools": { "mnote.local_file.patch": "deny" }, "skills": { "context7": { "name": "Context7", "enabled": false, "description": "disabled in test", "source": "/tmp/context7-disabled/SKILL.md", "riskLevel": "medium", "requiredScopes": [] }, "vpn": { "name": "VPN", "enabled": true, "description": "enabled in test", "source": "/tmp/vpn-enabled/SKILL.md", "riskLevel": "high", "requiredScopes": [] } }, "mcpServers": { "context7": { "name": "Context7", "enabled": false, "transport": "streamable-http", "url": "https://mcp.context7.com/mcp", "command": "", "networkPolicy": "allow-all", "secretRefs": [], "facadeOnly": true, "sandbox": true, "description": "disabled in test", "riskLevel": "medium", "requiredScopes": [] }, "codegraph": { "name": "CodeGraph", "enabled": true, "transport": "stdio", "url": "", "command": "codegraph serve --mcp", "networkPolicy": "deny-all", "secretRefs": [], "facadeOnly": true, "sandbox": true, "description": "enabled in test", "riskLevel": "medium", "requiredScopes": [] } } }), ); let app = build_app(state); let (status, payload) = request_json( app.clone(), "/api/page-ai/pi/start", actor_id, json!({ "sessionId": "pi_lab_policy_test", "rootUri": root_uri, "modelProvider": "omniroute", "modelId": "pi-fast", "thinkingLevel": "high", "permissionMode": "full_access" }), ) .await; assert_eq!(status, StatusCode::OK); assert_eq!(payload["mnoteToolOnly"], false); assert_eq!( payload["managedPiBuiltinTools"], json!(pi_lab_managed_builtin_tools()) ); assert!(payload["configuredPiExtensionSources"] .as_array() .unwrap() .contains(&json!("mnote:core"))); assert!(payload["piExtensionSources"] .as_array() .unwrap() .iter() .any(|source| { source.as_str().is_some_and(|value| { value.ends_with("packages/pi-mnote/extensions/pi-rust-official/question.ts") }) })); assert!(payload["piExtensionSources"] .as_array() .unwrap() .iter() .any(|source| { source.as_str().is_some_and(|value| { value .ends_with("packages/pi-mnote/extensions/pi-rust-official/questionnaire.ts") }) })); assert_eq!(payload["session"]["modelProvider"], "omniroute"); assert_eq!(payload["session"]["modelId"], "pi-fast"); assert_eq!(payload["session"]["thinkingLevel"], "high"); assert_eq!( payload["session"]["runtimePolicySnapshot"]["permissionMode"], "full_access" ); let policy = &payload["session"]["runtimePolicySnapshot"]; assert_eq!(policy["defaultModel"], "omniroute/pi-fast"); assert_eq!( policy["allowedModels"], json!(["omniroute/freefirst", "omniroute/gpt-5.4-mini", "omniroute/pi-fast"]) ); assert!(policy["enabledSkills"] .as_array() .unwrap() .contains(&json!("vpn"))); assert!(!policy["enabledSkills"] .as_array() .unwrap() .contains(&json!("context7"))); assert!(policy["enabledMcpServers"] .as_array() .unwrap() .contains(&json!("codegraph"))); assert!(!policy["enabledMcpServers"] .as_array() .unwrap() .contains(&json!("context7"))); assert_eq!(policy["nativeMcpSupported"], false); assert_eq!(policy["mcpBridge"], "pi-rust-sync-client"); assert!(policy["enabledPiExtensions"] .as_array() .unwrap() .contains(&json!("mnote-pi"))); assert!(policy["enabledPiExtensionSources"] .as_array() .unwrap() .contains(&json!("mnote:core"))); assert_eq!( policy["mcpServers"]["codegraph"]["command"], json!("codegraph") ); assert_eq!( policy["mcpServers"]["codegraph"]["args"], json!(["serve", "--mcp"]) ); assert!(policy["mcpServers"].get("context7").is_none()); assert!(!policy["mnoteToolNames"] .as_array() .unwrap() .contains(&json!("mnote.local_file.patch"))); let mcp_config_path = PathBuf::from( payload["session"]["piSessionDir"] .as_str() .expect("session dir"), ) .join("config") .join("mcp.json"); assert!( mcp_config_path.exists(), "built-in Pi Rust MCP extension should receive session mcp.json" ); let mcp_config: Value = serde_json::from_slice(&fs::read(&mcp_config_path).expect("read mcp config")) .expect("parse mcp config"); assert_eq!( mcp_config["mcpServers"]["codegraph"]["command"], json!("codegraph") ); let mcp_cache_path = PathBuf::from( payload["session"]["piSessionDir"] .as_str() .expect("session dir"), ) .join("config") .join(PI_LAB_MCP_CACHE_FILE); assert!( mcp_cache_path.exists(), "MCP-enabled sessions should initialize a session metadata cache" ); let full_access_session = get_session("pi_lab_policy_test").expect("full access session"); assert!(shared_mcp_cache_path(&full_access_session).is_some()); let permission_config_path = PathBuf::from( payload["session"]["piSessionDir"] .as_str() .expect("session dir"), ) .join("config") .join("extensions") .join("pi-permission-system") .join("config.json"); assert!( !permission_config_path.exists(), "permission-system config should not be generated unless external Pi extensions are explicitly enabled" ); let full_access_tool_policies = mnote_pi_tool_policies(&full_access_session); assert_eq!( full_access_tool_policies["mnote.current_page.read"], "allow" ); assert_eq!( full_access_tool_policies["mnote.codex_rescue.request"], "ask" ); let (tool_status, tool_payload) = request_json( app.clone(), "/api/page-ai/pi/tool-call", actor_id, json!({ "sessionId": "pi_lab_policy_test", "toolName": "mnote.local_file.patch", "params": {"rootUri": root_uri, "path": "note.md", "operations": []} }), ) .await; assert_eq!(tool_status, StatusCode::FORBIDDEN); assert_eq!(tool_payload["code"], "page_ai_pi_lab_tool_disabled"); let (denied_status, denied_payload) = request_json( app, "/api/page-ai/pi/start", actor_id, json!({ "sessionId": "pi_lab_policy_denied_model", "modelProvider": "omniroute", "modelId": "not-allowed" }), ) .await; assert_eq!(denied_status, StatusCode::FORBIDDEN); assert_eq!(denied_payload["code"], "page_ai_pi_lab_model_not_allowed"); } #[test] fn hydrate_session_mcp_cache_copies_shared_cache() { let root = temp_root("mnote-pi-shared-mcp-cache-root"); let config_dir = root.join("session-config"); fs::create_dir_all(&config_dir).expect("config dir"); let session = PiLabSession { session_id: "pi_lab_cache_copy".into(), mnote_user_id: "user/cache".into(), bridge_token: "bridge".into(), status: PiLabSessionStatus::RuntimeRunning, provider_session_id: "prov_cache".into(), pi_session_dir: root.join("session").to_string_lossy().to_string(), pi_session_file: None, root_uri: Some(file_uri(&root)), workspace_id: None, page_path: None, page_title: None, model_provider: None, model_id: None, thinking_level: Some("medium".into()), allowed_roots_snapshot: None, runtime_policy_snapshot: Some(json!({ "mcpBridge": "pi-rust-sync-client", "enabledMcpServers": ["codegraph"], "mcpServers": { "codegraph": { "transport": "stdio", "command": "codegraph", "args": ["serve", "--mcp"], "lifecycle": "lazy" } } })), runtime_pid: None, runtime_mode: "mock".into(), runtime_error: None, created_at_ms: 1000, updated_at_ms: 2000, message_count: 0, }; let shared = shared_mcp_cache_path(&session).expect("shared cache path"); fs::create_dir_all(shared.parent().expect("shared parent")).expect("shared parent"); fs::write( &shared, br#"{"version":1,"servers":{"codegraph":{"configHash":"hash","tools":[],"resources":[],"cachedAt":1}}}"#, ) .expect("shared cache write"); let hydrated = hydrate_session_mcp_cache(&session, &config_dir) .expect("hydrate") .expect("shared path"); assert_eq!(hydrated, shared); let session_cache = fs::read_to_string(config_dir.join(PI_LAB_MCP_CACHE_FILE)).expect("session cache"); assert!(session_cache.contains("\"codegraph\"")); } #[tokio::test] async fn tool_facade_enforces_directory_grant_read_and_write_permissions() { std::env::set_var("MNOTE_PAGE_AI_PI_LAB_RUNTIME", "mock"); let state = test_state(); let reader_id = "pi_reader_user"; let writer_id = "pi_writer_user"; let read_root = temp_root("mnote-pi-read-root"); let write_root = temp_root("mnote-pi-write-root"); let read_root_uri = grant_directory(&state, reader_id, &read_root, "read"); let write_root_uri = grant_directory(&state, writer_id, &write_root, "write"); fs::write(read_root.join("note.md"), "reader").expect("read file"); fs::write(write_root.join("note.md"), "writer").expect("write file"); let app = build_app(state); let (reader_start_status, _) = request_json( app.clone(), "/api/page-ai/pi/start", reader_id, json!({"sessionId": "pi_lab_reader", "rootUri": read_root_uri}), ) .await; assert_eq!(reader_start_status, StatusCode::OK); let (unapproved_read_status, unapproved_read_payload) = request_json( app.clone(), "/api/page-ai/pi/tool-call", reader_id, json!({ "sessionId": "pi_lab_reader", "toolName": "mnote.local_file.read", "params": {"rootUri": read_root_uri, "path": "note.md"} }), ) .await; assert_eq!(unapproved_read_status, StatusCode::OK); assert_eq!(unapproved_read_payload["ok"], false); assert_eq!( unapproved_read_payload["result"]["code"], "page_ai_pi_lab_tool_approval_required" ); assert_eq!(unapproved_read_payload["approvalRequired"], true); assert_eq!(unapproved_read_payload["approvalConfirmed"], false); let (forged_read_status, forged_read_payload) = request_json( app.clone(), "/api/page-ai/pi/tool-call", reader_id, json!({ "sessionId": "pi_lab_reader", "toolName": "mnote.local_file.read", "params": { "rootUri": read_root_uri, "path": "note.md", "mnoteApproval": {"confirmed": true, "toolName": "mnote.local_file.read"} } }), ) .await; assert_eq!(forged_read_status, StatusCode::OK); assert_eq!(forged_read_payload["ok"], false); assert_eq!( forged_read_payload["result"]["code"], "page_ai_pi_lab_tool_approval_required" ); let read_params = json!({"rootUri": read_root_uri, "path": "note.md"}); let read_approval = json!({ "approvalId": "approval_read_note", "toolName": "mnote.local_file.read", "paramsHash": pi_lab_tool_params_hash(&read_params), "confirmed": true }); confirm_test_approval("pi_lab_reader", &read_approval); let reader_bridge_token = get_session("pi_lab_reader") .expect("reader session") .bridge_token; let (read_status, read_payload) = bridge_request_json( app.clone(), reader_id, &reader_bridge_token, json!({ "sessionId": "pi_lab_reader", "toolName": "mnote.local_file.read", "params": { "rootUri": read_root_uri, "path": "note.md", "mnoteApproval": read_approval } }), ) .await; assert_eq!(read_status, StatusCode::OK); assert_eq!(read_payload["ok"], true); assert_eq!(read_payload["result"]["content"], "reader"); let current_page_params = json!({"pagePath": "note.md"}); let current_page_approval = json!({ "approvalId": "approval_current_page_note", "toolName": "mnote.current_page.read", "paramsHash": pi_lab_tool_params_hash(¤t_page_params), "confirmed": true }); confirm_test_approval("pi_lab_reader", ¤t_page_approval); let (current_page_status, current_page_payload) = bridge_request_json( app.clone(), reader_id, &reader_bridge_token, json!({ "sessionId": "pi_lab_reader", "toolName": "mnote.current_page.read", "params": { "pagePath": "note.md", "mnoteApproval": current_page_approval } }), ) .await; assert_eq!(current_page_status, StatusCode::OK); assert_eq!(current_page_payload["ok"], true); assert_eq!(current_page_payload["result"]["rootUri"], read_root_uri); assert_eq!(current_page_payload["result"]["content"], "reader"); let nested_read_root = read_root.join("nested-current-page-root"); fs::create_dir_all(&nested_read_root).expect("nested root"); fs::write(nested_read_root.join("child.md"), "nested reader").expect("nested reader file"); let nested_read_root_uri = format!("file://{}", nested_read_root.display()); let nested_current_page_params = json!({"rootUri": nested_read_root_uri, "pagePath": "child.md"}); let nested_current_page_approval = json!({ "approvalId": "approval_nested_current_page_child", "toolName": "mnote.current_page.read", "paramsHash": pi_lab_tool_params_hash(&nested_current_page_params), "confirmed": true }); confirm_test_approval("pi_lab_reader", &nested_current_page_approval); let (nested_current_page_status, nested_current_page_payload) = bridge_request_json( app.clone(), reader_id, &reader_bridge_token, json!({ "sessionId": "pi_lab_reader", "toolName": "mnote.current_page.read", "params": { "rootUri": nested_read_root_uri, "pagePath": "child.md", "mnoteApproval": nested_current_page_approval } }), ) .await; assert_eq!(nested_current_page_status, StatusCode::OK); assert_eq!(nested_current_page_payload["ok"], true); assert_eq!( nested_current_page_payload["result"]["rootUri"], nested_read_root_uri ); assert_eq!( nested_current_page_payload["result"]["content"], "nested reader" ); let readonly_patch_params = json!({ "rootUri": read_root_uri, "path": "note.md", "operations": [{"op": "append", "content": " updated"}] }); let readonly_patch_approval = json!({ "approvalId": "approval_readonly_patch", "toolName": "mnote.local_file.patch", "paramsHash": pi_lab_tool_params_hash(&readonly_patch_params), "confirmed": true }); confirm_test_approval("pi_lab_reader", &readonly_patch_approval); let (readonly_patch_status, readonly_patch_payload) = bridge_request_json( app.clone(), reader_id, &reader_bridge_token, json!({ "sessionId": "pi_lab_reader", "toolName": "mnote.local_file.patch", "params": { "rootUri": read_root_uri, "path": "note.md", "operations": [{"op": "append", "content": " updated"}], "mnoteApproval": readonly_patch_approval } }), ) .await; assert_eq!(readonly_patch_status, StatusCode::OK); assert_eq!(readonly_patch_payload["ok"], false); assert_eq!( readonly_patch_payload["result"]["code"], "page_ai_pi_lab_root_readonly" ); let (writer_start_status, _) = request_json( app.clone(), "/api/page-ai/pi/start", writer_id, json!({"sessionId": "pi_lab_writer", "rootUri": write_root_uri}), ) .await; assert_eq!(writer_start_status, StatusCode::OK); let write_params = json!({ "rootUri": write_root_uri, "path": "note.md", "operations": [{"op": "append", "content": " updated"}] }); let write_approval = json!({ "approvalId": "approval_write_note", "toolName": "mnote.local_file.patch", "paramsHash": pi_lab_tool_params_hash(&write_params), "confirmed": true }); confirm_test_approval("pi_lab_writer", &write_approval); let writer_bridge_token = get_session("pi_lab_writer") .expect("writer session") .bridge_token; let (write_status, write_payload) = bridge_request_json( app, writer_id, &writer_bridge_token, json!({ "sessionId": "pi_lab_writer", "toolName": "mnote.local_file.patch", "params": { "rootUri": write_root_uri, "path": "note.md", "operations": [{"op": "append", "content": " updated"}], "mnoteApproval": write_approval } }), ) .await; assert_eq!(write_status, StatusCode::OK); assert_eq!(write_payload["ok"], true); assert_eq!( fs::read_to_string(write_root.join("note.md")).expect("patched file"), "writer updated" ); } #[tokio::test] async fn configure_permission_mode_refreshes_mnote_tool_policy() { std::env::set_var("MNOTE_PAGE_AI_PI_LAB_RUNTIME", "mock"); let state = test_state(); let actor_id = "pi_configure_permission_user"; let root = temp_root("mnote-pi-configure-permission-root"); let root_uri = grant_directory(&state, actor_id, &root, "write"); fs::write(root.join("note.md"), "before").expect("write file"); let app = build_app(state); let (start_status, _) = request_json( app.clone(), "/api/page-ai/pi/start", actor_id, json!({ "sessionId": "pi_lab_configure_permission", "rootUri": root_uri, "permissionMode": "confirm" }), ) .await; assert_eq!(start_status, StatusCode::OK); let (configure_status, configure_payload) = request_json( app.clone(), "/api/page-ai/pi/configure", actor_id, json!({ "sessionId": "pi_lab_configure_permission", "permissionMode": "auto_edit" }), ) .await; assert_eq!(configure_status, StatusCode::OK); assert_eq!(configure_payload["applied"]["permissionMode"], "auto_edit"); assert_eq!( configure_payload["session"]["runtimePolicySnapshot"]["permissionMode"], "auto_edit" ); let (tool_status, tool_payload) = request_json( app, "/api/page-ai/pi/tool-call", actor_id, json!({ "sessionId": "pi_lab_configure_permission", "toolName": "mnote.local_file.patch", "params": { "rootUri": root_uri, "path": "note.md", "operations": [{"op": "replace", "old": "before", "new": "after"}] } }), ) .await; assert_eq!(tool_status, StatusCode::OK); assert_eq!(tool_payload["ok"], true); assert_eq!( fs::read_to_string(root.join("note.md")).expect("read file"), "after" ); } #[tokio::test] async fn get_session_returns_not_found_for_nonexistent_session() { let app = test_app(); let response = app .oneshot( Request::builder() .method("GET") .uri("/api/page-ai/pi/sessions/nonexistent_session_id") .header("x-mnote-actor-id", "user_test_get") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::NOT_FOUND); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["code"], "page_ai_pi_lab_session_not_found"); } #[tokio::test] async fn get_session_events_returns_not_found_for_nonexistent_session() { let app = test_app(); let response = app .oneshot( Request::builder() .method("GET") .uri("/api/page-ai/pi/sessions/nonexistent_session_id/events") .header("x-mnote-actor-id", "user_test_events") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::NOT_FOUND); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["code"], "page_ai_pi_lab_session_not_found"); } // ── A7: Pi RPC request-response correlation ────────────────────── #[test] fn rpc_response_key_joins_session_id_and_rpc_id() { let key = rpc_response_key("session_abc", "pi_rpc_state_123"); assert_eq!(key, "session_abc:pi_rpc_state_123"); } #[test] fn rpc_response_key_handles_empty_ids() { let key = rpc_response_key("", ""); assert_eq!(key, ":"); } #[test] fn queue_mode_normalization_matches_pi_rpc_contract() { assert_eq!(normalize_queue_mode("all", "steeringMode").unwrap(), "all"); assert_eq!( normalize_queue_mode("one-at-a-time", "followUpMode").unwrap(), "one-at-a-time" ); assert_eq!( normalize_queue_mode("oneAtATime", "steeringMode").unwrap(), "one-at-a-time" ); assert_eq!( normalize_queue_mode("one_at_a_time", "followUpMode").unwrap(), "one-at-a-time" ); assert!(normalize_queue_mode("skip", "steeringMode").is_err()); assert!(normalize_queue_mode("queue", "followUpMode").is_err()); assert!(normalize_queue_mode("parallel", "steeringMode").is_err()); assert!(normalize_queue_mode("single", "followUpMode").is_err()); } #[tokio::test] async fn pending_rpc_registry_insert_remove_and_resolve() { let (tx, rx) = oneshot::channel(); let key = "test_sess:test_id".to_string(); // Insert { let mut pending = PI_LAB_PENDING_RPC_RESPONSES.lock().await; pending.insert(key.clone(), tx); assert!(pending.contains_key(&key)); } // Resolve via registry lookup let response = json!({"type": "response", "success": true, "data": {"key": "val"}}); { let mut pending = PI_LAB_PENDING_RPC_RESPONSES.lock().await; if let Some(sender) = pending.remove(&key) { let _ = sender.send(response.clone()); } assert!(!pending.contains_key(&key)); } // Verify receiver got the message let received = rx.await.expect("should receive via oneshot"); assert_eq!(received["type"], "response"); assert_eq!(received["success"], true); assert_eq!(received["data"]["key"], "val"); } #[tokio::test] async fn pending_rpc_registry_cleanup_on_remove() { let (tx, _rx) = oneshot::channel(); let key = "cleanup_test:id".to_string(); // Insert { let mut pending = PI_LAB_PENDING_RPC_RESPONSES.lock().await; pending.insert(key.clone(), tx); assert!(pending.contains_key(&key)); } // Remove without sending { let mut pending = PI_LAB_PENDING_RPC_RESPONSES.lock().await; pending.remove(&key); assert!(!pending.contains_key(&key)); } } #[tokio::test] async fn mock_state_handler_unchanged() { let app = test_app(); let (status, body) = request_json( app, "/api/page-ai/pi/state", "user_a7_mock_state", json!({ "sessionId": "nonexistent_mock_state", }), ) .await; assert_eq!(status, 400); assert_eq!(body["code"], "page_ai_pi_lab_session_not_found"); } }