- expose Pi Rust built-in tools by permission mode instead of replacing them with MNote file tools - update Pi Lab smoke coverage for native ls/read/bash usage - document the overreplacement regression and verification evidence
9525 lines
350 KiB
Rust
9525 lines
350 KiB
Rust
//! 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<StdMutex<HashMap<String, PiLabSession>>> =
|
||
LazyLock::new(|| StdMutex::new(HashMap::new()));
|
||
static PI_LAB_PROCESSES: LazyLock<StdMutex<HashMap<String, PiLabProcessHandle>>> =
|
||
LazyLock::new(|| StdMutex::new(HashMap::new()));
|
||
static PI_LAB_RECEIPT_STORE: LazyLock<StdMutex<Vec<PiLabToolReceipt>>> =
|
||
LazyLock::new(|| StdMutex::new(Vec::new()));
|
||
static PI_LAB_RATE_LIMITS: LazyLock<StdMutex<HashMap<String, Vec<u128>>>> =
|
||
LazyLock::new(|| StdMutex::new(HashMap::new()));
|
||
static PI_LAB_APPROVALS: LazyLock<StdMutex<HashMap<String, PiLabPendingApproval>>> =
|
||
LazyLock::new(|| StdMutex::new(HashMap::new()));
|
||
static PI_LAB_UI_RESPONSES: LazyLock<StdMutex<HashMap<String, PiLabUiPendingResponse>>> =
|
||
LazyLock::new(|| StdMutex::new(HashMap::new()));
|
||
static PI_LAB_EVENT_TX: LazyLock<broadcast::Sender<Value>> = LazyLock::new(|| {
|
||
let (tx, _) = broadcast::channel(1024);
|
||
tx
|
||
});
|
||
static PI_LAB_PENDING_RPC_RESPONSES: LazyLock<AsyncMutex<HashMap<String, oneshot::Sender<Value>>>> =
|
||
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<String>,
|
||
pub root_uri: Option<String>,
|
||
pub workspace_id: Option<String>,
|
||
pub page_path: Option<String>,
|
||
pub page_title: Option<String>,
|
||
pub model_provider: Option<String>,
|
||
pub model_id: Option<String>,
|
||
#[serde(default)]
|
||
pub thinking_level: Option<String>,
|
||
pub allowed_roots_snapshot: Option<Value>,
|
||
#[serde(default)]
|
||
pub runtime_policy_snapshot: Option<Value>,
|
||
pub runtime_pid: Option<u32>,
|
||
pub runtime_mode: String,
|
||
pub runtime_error: Option<String>,
|
||
pub created_at_ms: u128,
|
||
pub updated_at_ms: u128,
|
||
pub message_count: u64,
|
||
}
|
||
|
||
#[derive(Clone)]
|
||
struct PiLabProcessHandle {
|
||
stdin: Arc<AsyncMutex<ChildStdin>>,
|
||
child: Arc<AsyncMutex<Child>>,
|
||
_pid: Option<u32>,
|
||
}
|
||
|
||
#[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<Value>,
|
||
confirmed: Option<bool>,
|
||
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<String>,
|
||
pub root_uri: Option<String>,
|
||
pub page_path: Option<String>,
|
||
pub session_id: String,
|
||
pub tool_name: String,
|
||
pub normalized_file_path: Option<String>,
|
||
pub allowed: bool,
|
||
pub deny_reason: Option<String>,
|
||
pub diff_summary: Option<String>,
|
||
pub before_file_version: Option<String>,
|
||
pub after_file_version: Option<String>,
|
||
pub provider: &'static str,
|
||
pub provider_session_id: Option<String>,
|
||
pub model_provider: Option<String>,
|
||
pub model_id: Option<String>,
|
||
pub allowed_roots_snapshot: Option<Value>,
|
||
pub storage: String,
|
||
pub created_at_ms: u128,
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
struct AllowedRoot {
|
||
root_uri: Option<String>,
|
||
root_path: PathBuf,
|
||
workspace_id: Option<String>,
|
||
permission: String,
|
||
source: String,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct PiLabBootstrapRequest {
|
||
pub prompt: Option<String>,
|
||
pub root_uri: Option<String>,
|
||
pub workspace_id: Option<String>,
|
||
pub page_path: Option<String>,
|
||
pub page_title: Option<String>,
|
||
pub model_provider: Option<String>,
|
||
pub model_id: Option<String>,
|
||
pub thinking_level: Option<String>,
|
||
pub permission_mode: Option<String>,
|
||
}
|
||
|
||
#[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<String>,
|
||
pub reserve_tokens: Option<u64>,
|
||
pub keep_recent_tokens: Option<u64>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct PiLabQueueConfigRequest {
|
||
pub session_id: String,
|
||
pub steering_mode: Option<String>,
|
||
pub follow_up_mode: Option<String>,
|
||
pub auto_compaction: Option<bool>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct PiLabStartRequest {
|
||
pub session_id: Option<String>,
|
||
pub root_uri: Option<String>,
|
||
pub workspace_id: Option<String>,
|
||
pub page_path: Option<String>,
|
||
pub page_title: Option<String>,
|
||
pub model_provider: Option<String>,
|
||
pub model_id: Option<String>,
|
||
pub thinking_level: Option<String>,
|
||
pub permission_mode: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct PiLabSendRequest {
|
||
pub session_id: String,
|
||
pub message: String,
|
||
pub streaming_behavior: Option<String>,
|
||
pub root_uri: Option<String>,
|
||
pub workspace_id: Option<String>,
|
||
pub page_path: Option<String>,
|
||
pub page_title: Option<String>,
|
||
pub folder_path: Option<String>,
|
||
pub context_refs: Option<Vec<String>>,
|
||
pub selected_context: Option<Value>,
|
||
}
|
||
|
||
#[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<String>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct PiLabUiResponseRequest {
|
||
pub session_id: String,
|
||
#[serde(alias = "requestId")]
|
||
pub id: String,
|
||
pub method: Option<String>,
|
||
pub value: Option<Value>,
|
||
pub confirmed: Option<bool>,
|
||
pub cancelled: Option<bool>,
|
||
#[serde(default)]
|
||
pub mnote_approval: Option<Value>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct PiLabUiRequestBridgeRequest {
|
||
pub session_id: String,
|
||
pub id: String,
|
||
pub method: String,
|
||
pub title: Option<String>,
|
||
pub message: Option<String>,
|
||
pub timeout_ms: Option<u64>,
|
||
#[serde(default)]
|
||
pub mnote_approval: Option<Value>,
|
||
#[serde(flatten)]
|
||
pub extra: HashMap<String, Value>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct PiLabMcpBridgeRequest {
|
||
pub session_id: String,
|
||
pub server: String,
|
||
pub mode: String,
|
||
pub tool: Option<String>,
|
||
pub arguments: Option<Value>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct PiLabEventsQuery {
|
||
pub session_id: Option<String>,
|
||
pub limit: Option<usize>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct PiLabToolCallRequest {
|
||
pub session_id: Option<String>,
|
||
pub tool_name: String,
|
||
#[serde(default)]
|
||
pub params: Value,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct PiLabListSessionsQuery {
|
||
pub workspace_id: Option<String>,
|
||
pub limit: Option<usize>,
|
||
}
|
||
|
||
#[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::<Vec<_>>()
|
||
.join(",");
|
||
format!("[{body}]")
|
||
}
|
||
Value::Object(map) => {
|
||
let mut keys = map.keys().collect::<Vec<_>>();
|
||
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::<Vec<_>>()
|
||
.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<String> {
|
||
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<String, WebError> {
|
||
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<PathBuf> {
|
||
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<Vec<AllowedRoot>, WebError> {
|
||
let actor_id = ensure_authenticated(state, context)?;
|
||
let mut roots: Vec<AllowedRoot> = 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<AllowedRoot> {
|
||
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::<Vec<String>>(&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<AllowedRoot> {
|
||
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<PathBuf, WebError> {
|
||
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<String>, Option<String>), 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<PathBuf, WebError> {
|
||
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<PiLabSession, WebError> {
|
||
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<String> {
|
||
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<String> {
|
||
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<PathBuf> {
|
||
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<String> {
|
||
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<String>) {
|
||
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<String> {
|
||
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<String> {
|
||
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> {
|
||
string_array_from_policy(session, "enabledPiExtensions")
|
||
}
|
||
|
||
fn pi_lab_configured_extension_sources(session: &PiLabSession) -> Vec<String> {
|
||
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<String> {
|
||
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::<Vec<_>>();
|
||
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<String> {
|
||
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<String>) -> Vec<String> {
|
||
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<String> {
|
||
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::<Vec<_>>();
|
||
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<String> {
|
||
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<String> {
|
||
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<String> {
|
||
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<String> {
|
||
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<String, String> {
|
||
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<Option<String>, 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<String, WebError> {
|
||
let raw = value.trim();
|
||
let normalized = match raw
|
||
.chars()
|
||
.filter(|ch| !matches!(ch, '-' | '_' | ' '))
|
||
.collect::<String>()
|
||
.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<String> {
|
||
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<String> {
|
||
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<String> {
|
||
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<bool> {
|
||
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<bool, WebError> {
|
||
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::<Value>().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。
|
||
/// - 写入 `<pi_session_dir>/config/models.json`
|
||
/// - API key 仅以环境变量名引用(OPENAI_API_KEY),不写入明文
|
||
/// - 调用方应将 `PI_CODING_AGENT_DIR` 设为返回的 config 目录
|
||
fn ensure_session_models_config(
|
||
session: &PiLabSession,
|
||
supports_tools: bool,
|
||
) -> Result<PathBuf, WebError> {
|
||
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::<Vec<_>>();
|
||
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<Option<PathBuf>, 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<PathBuf> {
|
||
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<Option<PathBuf>, 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<PathBuf> {
|
||
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<PathBuf> {
|
||
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<Option<PathBuf>, 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<PiLabToolDefinition> {
|
||
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<PiLabToolDefinition> {
|
||
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::<std::collections::HashSet<_>>();
|
||
pi_lab_tool_definitions()
|
||
.into_iter()
|
||
.filter(|tool| allowed.contains(tool.mnote_name))
|
||
.collect()
|
||
}
|
||
|
||
fn pi_lab_extension_tool_names(session: &PiLabSession) -> Vec<String> {
|
||
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<String> {
|
||
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<String> {
|
||
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<String, Value>> {
|
||
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<Value>,
|
||
confirmed: Option<bool>,
|
||
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<PiLabUiPendingResponse> {
|
||
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<PathBuf> {
|
||
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::<String>();
|
||
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<String> {
|
||
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::<String>()
|
||
.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<String, WebError> {
|
||
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<string, unknown> | 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<string, unknown> | 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<PathBuf, WebError> {
|
||
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<String, WebError> {
|
||
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<String, WebError> {
|
||
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<String, WebError> {
|
||
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<Vec<String>, 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<PathBuf>,
|
||
) -> 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<F>(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<PiLabSession> {
|
||
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::<Vec<_>>();
|
||
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::<Vec<_>>();
|
||
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<PiLabSession, WebError> {
|
||
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<PiLabSession, WebError> {
|
||
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<PiLabSession, WebError> {
|
||
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::<Value>(&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<Option<Value>, 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<String, WebError> {
|
||
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<String, WebError> {
|
||
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<String> {
|
||
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<String>,
|
||
allowed: bool,
|
||
deny_reason: Option<String>,
|
||
diff_summary: Option<String>,
|
||
before_file_version: Option<String>,
|
||
after_file_version: Option<String>,
|
||
) -> 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<PiLabSession>,
|
||
}
|
||
|
||
fn string_param(params: &Value, key: &str) -> Option<String> {
|
||
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<u64> {
|
||
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::<u64>().ok())
|
||
})
|
||
})
|
||
}
|
||
|
||
fn bool_param(params: &Value, key: &str) -> Option<bool> {
|
||
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::<String>();
|
||
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<PathBuf> {
|
||
[
|
||
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<String> {
|
||
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<String> {
|
||
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<Value, WebError> {
|
||
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<Value, WebError> {
|
||
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<Value, WebError> {
|
||
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::<Vec<_>>();
|
||
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::<Vec<_>>(),
|
||
"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<Value, WebError> {
|
||
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<Value, WebError> {
|
||
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<Value, WebError> {
|
||
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::<Vec<_>>()
|
||
})
|
||
}),
|
||
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<Value, WebError> {
|
||
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<Value, WebError> {
|
||
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<Value, WebError> {
|
||
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<Value, WebError> {
|
||
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<String>,
|
||
tool_name: String,
|
||
params: Value,
|
||
allow_bridge_approval: bool,
|
||
) -> Result<Json<Value>, 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<Value, WebError> = 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<AppState>,
|
||
Extension(context): Extension<RequestContext>,
|
||
) -> Result<Response, WebError> {
|
||
ensure_enabled(&state)?;
|
||
ensure_authenticated(&state, &context)?;
|
||
let html = r#"<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>MNote Pi Lab</title>
|
||
<style>
|
||
html,body{margin:0;height:100%;background:#f7f7f5;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
|
||
#pi-lab-root{height:100%}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="pi-lab-root" data-page-ai-sidebar-content></div>
|
||
<script src="/api/mnote-browser-runtime/sidebar-page-ai-pi-lab-runtime.js"></script>
|
||
<script>
|
||
window.createSidebarPageAiPiLabRuntime && window.createSidebarPageAiPiLabRuntime({ standalone: true });
|
||
window.postMessage({ source: 'mnote-sidebar', type: 'mnote:pi-lab-show' }, location.origin);
|
||
</script>
|
||
</body>
|
||
</html>"#;
|
||
Ok(Html(html).into_response())
|
||
}
|
||
|
||
pub async fn status(
|
||
State(state): State<AppState>,
|
||
Extension(context): Extension<RequestContext>,
|
||
) -> Result<Json<Value>, 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::<Vec<_>>()
|
||
})
|
||
.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::<Vec<_>>()
|
||
})
|
||
.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": "<workspace>/.mnote/ai/pi-sessions/<actor>/<session>",
|
||
"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<AppState>,
|
||
Extension(context): Extension<RequestContext>,
|
||
Json(request): Json<PiLabBootstrapRequest>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
Extension(context): Extension<RequestContext>,
|
||
Json(request): Json<PiLabStartRequest>,
|
||
) -> Result<Json<Value>, 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<String>,
|
||
pub model_id: Option<String>,
|
||
pub thinking_level: Option<String>,
|
||
pub permission_mode: Option<String>,
|
||
}
|
||
|
||
/// POST /api/page-ai/pi/configure
|
||
/// 配置 Pi Lab 会话的参数(model、thinking level、permission mode)。
|
||
/// 只在 mock 模式下本地生效;real 模式会转发对应 RPC 命令。
|
||
pub async fn configure(
|
||
State(state): State<AppState>,
|
||
Extension(context): Extension<RequestContext>,
|
||
Json(request): Json<PiLabConfigureRequest>,
|
||
) -> Result<Json<Value>, 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<String> = 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<AppState>,
|
||
Extension(context): Extension<RequestContext>,
|
||
Json(request): Json<PiLabSendRequest>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
Extension(context): Extension<RequestContext>,
|
||
Json(request): Json<PiLabAbortRequest>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
Extension(context): Extension<RequestContext>,
|
||
Json(request): Json<PiLabStateRequest>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
Extension(context): Extension<RequestContext>,
|
||
Json(request): Json<PiLabCompactRequest>,
|
||
) -> Result<Json<Value>, 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<Value> = 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<AppState>,
|
||
Extension(context): Extension<RequestContext>,
|
||
Json(request): Json<PiLabQueueConfigRequest>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
Extension(context): Extension<RequestContext>,
|
||
Json(request): Json<PiLabUiResponseRequest>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
Extension(context): Extension<RequestContext>,
|
||
headers: HeaderMap,
|
||
Json(request): Json<PiLabUiRequestBridgeRequest>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
Extension(context): Extension<RequestContext>,
|
||
Query(query): Query<PiLabEventsQuery>,
|
||
) -> Result<Sse<impl futures_util::Stream<Item = Result<SseEvent, Infallible>>>, 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::<Vec<Result<SseEvent, Infallible>>>()
|
||
} 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<AppState>,
|
||
Extension(context): Extension<RequestContext>,
|
||
Json(request): Json<PiLabToolCallRequest>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
Extension(context): Extension<RequestContext>,
|
||
headers: HeaderMap,
|
||
Json(request): Json<PiLabToolCallRequest>,
|
||
) -> Result<Json<Value>, 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<Value, WebError> {
|
||
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::<String>()
|
||
),
|
||
));
|
||
}
|
||
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<AppState>,
|
||
headers: HeaderMap,
|
||
Json(request): Json<PiLabMcpBridgeRequest>,
|
||
) -> Result<Json<Value>, 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<Option<Value>, 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::<Vec<_>>(),
|
||
"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<Vec<Value>, 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<Value> = 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::<Value>(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<String> {
|
||
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::<Vec<_>>()
|
||
.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<Value> {
|
||
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<Value> {
|
||
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<String, usize> = 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<Value> {
|
||
let mut messages: Vec<Value> = 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<Value> = Vec::new();
|
||
let mut compaction_summaries: Vec<Value> = Vec::new();
|
||
let mut branch_summaries: Vec<Value> = Vec::new();
|
||
let mut _active_leaf_id: Option<String> = 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<String> {
|
||
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<PathBuf> = 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<usize>,
|
||
}
|
||
|
||
pub async fn session_tree(
|
||
State(state): State<AppState>,
|
||
Extension(context): Extension<RequestContext>,
|
||
axum::extract::Path(path): axum::extract::Path<PiLabSessionPathParam>,
|
||
Query(query): Query<PiLabSessionTreeQuery>,
|
||
) -> Result<Json<Value>, 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<Value> = 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<Value> = 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<Value> = 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<String>,
|
||
}
|
||
|
||
pub async fn fork_pi_session(
|
||
State(state): State<AppState>,
|
||
Extension(context): Extension<RequestContext>,
|
||
Json(request): Json<PiLabForkRequest>,
|
||
) -> Result<Json<Value>, 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<String>;
|
||
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<String>,
|
||
}
|
||
|
||
pub async fn artifact_diff(
|
||
State(state): State<AppState>,
|
||
Extension(context): Extension<RequestContext>,
|
||
axum::extract::Path(tool_event_id): axum::extract::Path<String>,
|
||
Query(query): Query<PiLabArtifactDiffQuery>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
Extension(context): Extension<RequestContext>,
|
||
Query(query): Query<PiLabListSessionsQuery>,
|
||
) -> Result<Json<Value>, 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<Value> = 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<AppState>,
|
||
Extension(context): Extension<RequestContext>,
|
||
axum::extract::Path(path): axum::extract::Path<PiLabSessionPathParam>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
Extension(context): Extension<RequestContext>,
|
||
axum::extract::Path(path): axum::extract::Path<PiLabSessionPathParam>,
|
||
Query(query): Query<PiLabListSessionsQuery>,
|
||
) -> Result<Json<Value>, 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<Value> = 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<AppState>,
|
||
Extension(context): Extension<RequestContext>,
|
||
axum::extract::Path(path): axum::extract::Path<PiLabSessionPathParam>,
|
||
Query(query): Query<PiLabListSessionsQuery>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
Extension(context): Extension<RequestContext>,
|
||
Query(query): Query<PiLabListSessionsQuery>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
Extension(context): Extension<RequestContext>,
|
||
axum::extract::Path(path): axum::extract::Path<PiLabSessionPathParam>,
|
||
Json(request): Json<PiLabRenameSessionRequest>,
|
||
) -> Result<Json<Value>, 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");
|
||
}
|
||
}
|