//! Pi-first Page AI Lab — MNote 托管的后端垂直切片。 //! //! 该模块默认启用独立 Pi Lab 后端;OpenHub / LightRAG / Turso 默认主线不受影响。 //! Pi 进程通过 RPC subprocess 托管,文件读写只经 MNote-owned tool facade。 use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; use crate::routes::{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; use std::convert::Infallible; use std::fs::{self, OpenOptions}; use std::io::{Read as _, Write as _}; 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, 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_DEFAULT_MODEL_PROVIDER: &str = "omniroute"; const PI_LAB_DEFAULT_MODEL_ID: &str = "freefirst"; 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; 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_EVENT_TX: LazyLock> = LazyLock::new(|| { let (tx, _) = broadcast::channel(1024); tx }); #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum PiLabSessionStatus { Idle, RuntimeRunning, TurnRunning, Aborted, Error, } #[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, pub allowed_roots_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, 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, } #[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, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PiLabSendRequest { pub session_id: String, pub message: String, pub streaming_behavior: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PiLabAbortRequest { pub session_id: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PiLabEventsQuery { pub session_id: 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 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| { root.source == "MNOTE_PI_LAB_ALLOWED_ROOTS" && 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)); } if require_write { if root.source != "MNOTE_PI_LAB_ALLOWED_ROOTS" { local_folder_source::ensure_local_workspace_write_access_with_state( state, context, root_uri, ) .map_err(|error| error.with_context(context))?; } Ok(target) } else if root.source == "MNOTE_PI_LAB_ALLOWED_ROOTS" { Ok(target) } else { local_folder_source::ensure_local_path_read_access(context, root_uri, relative_path) .map_err(|error| error.with_context(context)) } } fn resolve_file_path( state: &AppState, context: &RequestContext, params: &Value, 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()); 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 { let target = resolve_root_relative_path(state, context, root_uri, path, require_write)?; return Ok((target, Some(root_uri.to_string()), Some(path.to_string()))); } 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 = context.auth.actor_id.trim().replace(['/', '\\', ':'], "_"); 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(if actor.is_empty() { "anonymous" } else { &actor }) .join(session_id)); } Ok(std::env::temp_dir() .join("mnote-web") .join("pi-lab") .join(if actor.is_empty() { "anonymous" } else { &actor }) .join(session_id)) } 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 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: request .model_provider .clone() .filter(|v| !v.trim().is_empty()) .or_else(|| Some(default_model_provider())), model_id: request .model_id .clone() .filter(|v| !v.trim().is_empty()) .or_else(|| Some(default_model_id())), runtime_pid: None, runtime_mode: runtime_mode(), runtime_error: None, allowed_roots_snapshot: None, 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 { env_trimmed("MNOTE_PAGE_AI_PI_BIN").unwrap_or_else(|| "pi".into()) } 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 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_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")) } /// 为 Pi 子进程生成每 session 受控的 models.json。 /// - 写入 `/config/models.json` /// - API key 仅以环境变量引用($OPENAI_API_KEY),不写入明文 /// - 调用方应将 `PI_CODING_AGENT_DIR` 设为返回的 config 目录 fn ensure_session_models_config(session: &PiLabSession) -> 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 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 }, "models": [ { "id": model_id, "name": format!("OmniRoute {}", model_id), "input": ["text"], "reasoning": false, "contextWindow": 128000, "maxTokens": 65536, "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 } } ] } } }), _ => 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 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()) } fn pi_lab_extension_tool_names() -> Vec<&'static str> { vec![ "mnote_current_page_read", "mnote_selection_read", "mnote_allowed_roots_describe", "mnote_local_file_read", "mnote_local_file_patch", "mnote_knowledge_rag_query", "mnote_reference_open", "mnote_tool_receipt_write", ] } fn ensure_session_tool_bridge_extension(session: &PiLabSession) -> Result { let extension_dir = PathBuf::from(&session.pi_session_dir).join("extensions"); fs::create_dir_all(&extension_dir).map_err(|error| { WebError::internal(format!( "创建 Pi Lab tool bridge extension 目录失败: {error}" )) })?; let extension_path = extension_dir.join("mnote-tool-bridge.ts"); let base_url = serde_json::to_string(&pi_lab_public_base_url()).unwrap_or_else(|_| "\"\"".into()); let session_id = serde_json::to_string(&session.session_id).unwrap_or_else(|_| "\"\"".into()); let source = format!( r#"import type {{ ExtensionAPI }} from "@earendil-works/pi-coding-agent"; import {{ Type }} from "typebox"; const BASE_URL = {base_url}; const SESSION_ID = {session_id}; const BRIDGE_TOKEN = process.env.MNOTE_PI_LAB_BRIDGE_TOKEN || ""; async function callMnote(toolName: string, params: unknown) {{ const response = await fetch(`${{BASE_URL}}/api/page-ai/pi/tool-call-bridge`, {{ method: "POST", headers: {{ "content-type": "application/json", "x-mnote-pi-lab-bridge-token": BRIDGE_TOKEN, }}, body: JSON.stringify({{ sessionId: SESSION_ID, toolName, params: params || {{}} }}), }}); const payload = await response.json().catch(() => ({{ ok: false, code: "bad_json" }})); const text = JSON.stringify(payload.result || payload, null, 2); return {{ content: [{{ type: "text", text }}], details: payload, }}; }} function register(pi: ExtensionAPI, name: string, label: string, description: string, toolName: string) {{ pi.registerTool({{ name, label, description, promptSnippet: `${{label}}: ${{description}}`, parameters: Type.Object({{}}, {{ additionalProperties: true }}), async execute(_toolCallId, params) {{ return callMnote(toolName, params); }}, }}); }} export default function mnoteToolBridge(pi: ExtensionAPI) {{ register(pi, "mnote_current_page_read", "MNote current page read", "Read the current MNote page through MNote access scope.", "mnote.current_page.read"); register(pi, "mnote_selection_read", "MNote selection read", "Read the current MNote editor selection snapshot supplied by MNote.", "mnote.selection.read"); register(pi, "mnote_allowed_roots_describe", "MNote allowed roots describe", "Describe MNote allowed roots and disabled raw tools.", "mnote.allowed_roots.describe"); register(pi, "mnote_local_file_read", "MNote local file read", "Read a file only through MNote allowed roots.", "mnote.local_file.read"); register(pi, "mnote_local_file_patch", "MNote local file patch", "Patch a file only through MNote allowed roots and watcher refresh.", "mnote.local_file.patch"); register(pi, "mnote_knowledge_rag_query", "MNote LightRAG query", "Query LightRAG only through the MNote knowledge facade.", "mnote.knowledge_rag.query"); register(pi, "mnote_reference_open", "MNote reference open", "Open a citation/reference through MNote mapping.", "mnote.reference.open"); register(pi, "mnote_tool_receipt_write", "MNote tool receipt write", "Write a provider-neutral MNote tool receipt.", "mnote.tool_receipt.write"); }} "# ); fs::write(&extension_path, source.as_bytes()).map_err(|error| { WebError::internal(format!("写入 Pi Lab tool bridge extension 失败: {error}")) })?; Ok(extension_path) } 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 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() { if let Ok(mut processes) = PI_LAB_PROCESSES.lock() { for (session_id, _) in &expired { processes.remove(session_id); } } 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 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 pi_config_dir = ensure_session_models_config(&session)?; let bridge_extension_path = ensure_session_tool_bridge_extension(&session)?; let mnote_tool_names = pi_lab_extension_tool_names(); if session.runtime_mode == "mock" { session.status = PiLabSessionStatus::RuntimeRunning; session.runtime_pid = None; upsert_session(session.clone()); 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, }), )?; publish_event( &session.session_id, "runtime_started", json!({ "mode": "mock", "providerSessionId": session.provider_session_id, "modelProvider": session.model_provider, "modelId": session.model_id, "piCodingAgentDir": pi_config_dir, "mnoteToolBridgeExtension": bridge_extension_path, "mnoteToolNames": mnote_tool_names, }), ); return Ok(session); } let mut command = Command::new(pi_binary()); command .arg("--mode") .arg("rpc") .arg("--session-dir") .arg(&session.pi_session_dir) .arg("--no-approve") .arg("--no-builtin-tools") .arg("--no-extensions") .arg("--extension") .arg(&bridge_extension_path) .arg("--tools") .arg(mnote_tool_names.join(",")) .arg("--no-skills") .arg("--no-prompt-templates") .arg("--no-context-files") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); if let Some(provider) = session .model_provider .as_deref() .filter(|value| !value.is_empty()) { command.arg("--provider").arg(provider); } if let Some(model) = session .model_id .as_deref() .filter(|value| !value.is_empty()) { command.arg("--model").arg(model); } 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(), ); command.env("MNOTE_PI_LAB_BRIDGE_TOKEN", &session.bridge_token); command.env("OPENAI_BASE_URL", omniroute_base_url()); if let Some(key) = omniroute_api_key() { command.env("OPENAI_API_KEY", key); } let mut child = command.spawn().map_err(|error| { WebError::bad_gateway_code( "page_ai_pi_lab_runtime_spawn_failed", format!("启动 pi --mode rpc 失败: {error}"), ) })?; 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 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); } session.status = PiLabSessionStatus::RuntimeRunning; session.runtime_pid = pid; upsert_session(session.clone()); publish_event( &session.session_id, "runtime_started", json!({ "mode": "rpc", "pid": pid, "sessionDir": session.pi_session_dir, "providerSessionId": session.provider_session_id, "modelProvider": session.model_provider, "modelId": session.model_id, "omnirouteBaseUrl": omniroute_base_url(), "piCodingAgentDir": pi_config_dir, "mnoteToolBridgeExtension": bridge_extension_path, "mnoteToolNames": mnote_tool_names, "disabledBuiltinTools": ["bash", "read", "write", "edit"], }), ); 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 = PiLabSessionStatus::RuntimeRunning; }); } persist_event("pi_rpc_event", &payload); publish_event(&session_id, "pi_rpc_event", payload); } Ok(None) => { update_session(&session_id, |session| { session.status = PiLabSessionStatus::Idle; }); persist_event("runtime_stdout_closed", &json!({})); publish_event(&session_id, "runtime_stdout_closed", json!({})); 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; } } } }); persist_upsert_run(state, &session)?; persist_append_event( state, &session, "runtime_started", &json!({ "mode": "rpc", "pid": session.runtime_pid, "providerSessionId": session.provider_session_id, "modelProvider": session.model_provider, "modelId": session.model_id, }), )?; 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(()) } 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: 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, } 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)?; 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": ["bash", "read", "write", "edit"], "mnoteTools": [ "mnote.current_page.read", "mnote.selection.read", "mnote.allowed_roots.describe", "mnote.local_file.read", "mnote.local_file.patch", "mnote.knowledge_rag.query", "mnote.reference.open", "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, 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, 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(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 execute_tool( state: AppState, context: RequestContext, session_id: Option, tool_name: String, params: Value, ) -> 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 }; let facade = PiLabToolFacade { state, context: context.clone(), session: session.clone(), }; let started = now_ms(); let result: Result = 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.query" => facade.knowledge_rag_query(params.clone()).await, "mnote.reference.open" => facade.reference_open(params.clone()).await, "mnote.tool_receipt.write" => Ok(json!({ "requestedReceipt": params, "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() { 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, }), ); } Ok(Json(json!({ "ok": allowed, "toolName": tool_name, "result": payload, "receipt": receipt_payload, "elapsedMs": elapsed_ms, }))) } 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) .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) .count() }) .unwrap_or(0); let owned_session_ids = sessions .as_ref() .map(|sessions| { sessions .values() .filter(|session| session.mnote_user_id == actor_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); 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(), "defaultModelProvider": default_model_provider(), "defaultModelId": default_model_id(), "omnirouteBaseUrl": omniroute_base_url(), "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, "managedPiSessionDirPolicy": "/.mnote/ai/pi-sessions//", "disabledPiBuiltinTools": ["bash", "read", "write", "edit"], "receiptStorage": "provider_neutral_jsonl_adapter_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_MAX_STARTS_PER_WINDOW)?; 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, }; 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, }), ) .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_MAX_STARTS_PER_WINDOW)?; 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)?; let session = start_runtime_for_session(&state, requested_session).await?; Ok(Json(json!({ "ok": true, "schema": "mnote.page_ai_pi.start.v1", "session": session, "disabledPiBuiltinTools": ["bash", "read", "write", "edit"], "mnoteToolOnly": true, }))) } 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| { session.status = PiLabSessionStatus::TurnRunning; session.message_count += 1; }); let command = json!({ "id": generate_id("pi_rpc"), "type": "prompt", "message": request.message, "streamingBehavior": request.streaming_behavior, }); if session.runtime_mode == "mock" { publish_event( &session.session_id, "pi_rpc_event", json!({ "type": "message_update", "assistantMessageEvent": { "type": "text_delta", "delta": "[Pi Lab mock] prompt accepted" } }), ); publish_event( &session.session_id, "pi_rpc_event", json!({ "type": "citation", "source": "lightrag-mock", "title": "LightRAG mock citation", "url": "#lightrag-mock-citation", }), ); publish_event( &session.session_id, "pi_rpc_event", json!({ "type": "diff", "files": [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(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": session.provider_session_id, "accepted": true, "eventStream": "/api/page-ai/pi/events", }))) } 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 handle = PI_LAB_PROCESSES .lock() .ok() .and_then(|processes| processes.get(&request.session_id).cloned()); if let Some(handle) = handle { let _ = handle.child.lock().await.kill().await; if let Ok(mut processes) = PI_LAB_PROCESSES.lock() { processes.remove(&request.session_id); } } } update_session(&request.session_id, |session| { session.status = PiLabSessionStatus::Aborted; }); publish_event(&request.session_id, "runtime_aborted", json!({})); // 持久化 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, }))) } pub async fn events( State(state): State, Extension(context): Extension, Query(query): Query, ) -> Result>>, WebError> { ensure_enabled(&state)?; cleanup_expired_sessions(); 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 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 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(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, ) .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, ) .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", } } fn build_run_runtime_json(session: &PiLabSession) -> String { serde_json::to_string(&json!({ "providerSessionId": session.provider_session_id, "piSessionDir": session.pi_session_dir, "runtimeMode": session.runtime_mode, "modelProvider": session.model_provider, "modelId": session.model_id, "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, })) .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(()) } // --------------------------------------------------------------------------- // 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) .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(), }))) } #[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 tower::util::ServiceExt; fn test_app() -> axum::Router { build_app(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(), })) } #[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 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()), allowed_roots_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, allowed_roots_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"); } #[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 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"); } }