Files
mnote/rust/crates/mnote-web/src/routes/page_ai_pi.rs
T

2870 lines
102 KiB
Rust

//! Pi-first Page AI Lab — MNote 托管的后端垂直切片。
//!
//! 该模块默认启用独立 Pi Lab 后端;OpenHub / LightRAG / Turso 默认主线不受影响。
//! Pi 进程通过 RPC subprocess 托管,文件读写只经 MNote-owned tool facade。
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::{knowledge_rag, local_folder_source};
use axum::extract::{Extension, Query, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
use axum::response::{Html, IntoResponse, Response};
use axum::Json;
use control_plane::{
AppendAiFilePatchInput, AppendAiRuntimeEventInput, AppendAiToolEventInput, ControlPlaneStore,
UpsertAiRuntimeRunInput,
};
use futures_util::stream::{self, StreamExt};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::convert::Infallible;
use std::fs::{self, OpenOptions};
use std::io::{Read as _, Write as _};
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::{Arc, LazyLock, Mutex as StdMutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::{Child, ChildStdin, Command};
use tokio::sync::{broadcast, Mutex as AsyncMutex};
use tokio_stream::wrappers::BroadcastStream;
const PI_LAB_VERSION: &str = "0.1.0-pi-lab-spike";
const PI_LAB_PROVIDER: &str = "pi";
const PI_LAB_SCHEMA_STATUS: &str = "mnote.page_ai_pi.status.v1";
const PI_LAB_SCHEMA_RECEIPT: &str = "mnote.page_ai_pi.tool_receipt.v1";
const PI_LAB_SCHEMA_EVENT: &str = "mnote.page_ai_pi.event.v1";
const PI_LAB_DEFAULT_MODEL_PROVIDER: &str = "omniroute";
const PI_LAB_DEFAULT_MODEL_ID: &str = "freefirst";
const PI_LAB_DEFAULT_OMNIROUTE_BASE_URL: &str = "http://127.0.0.1:20128/v1";
const HEADER_PI_LAB_BRIDGE_TOKEN: &str = "x-mnote-pi-lab-bridge-token";
const PI_LAB_SESSION_TTL_MS: u128 = 30 * 60 * 1000;
const PI_LAB_MAX_SESSIONS: usize = 16;
const PI_LAB_RATE_WINDOW_MS: u128 = 10_000;
const PI_LAB_MAX_STARTS_PER_WINDOW: usize = 4;
const PI_LAB_MAX_SENDS_PER_WINDOW: usize = 12;
const PI_LAB_MAX_TOOLS_PER_WINDOW: usize = 40;
pub const PI_LAB_PROFILE: &str = "pi_lab";
pub const PI_LAB_ACP_RUNTIME: &str = "pi";
static PI_LAB_SESSIONS: LazyLock<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_EVENT_TX: LazyLock<broadcast::Sender<Value>> = LazyLock::new(|| {
let (tx, _) = broadcast::channel(1024);
tx
});
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum PiLabSessionStatus {
Idle,
RuntimeRunning,
TurnRunning,
Aborted,
Error,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PiLabSession {
pub session_id: String,
pub mnote_user_id: String,
#[serde(skip_serializing, skip_deserializing)]
pub bridge_token: String,
pub status: PiLabSessionStatus,
pub provider_session_id: String,
pub pi_session_dir: String,
pub pi_session_file: Option<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 allowed_roots_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, 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>,
}
#[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>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PiLabSendRequest {
pub session_id: String,
pub message: String,
pub streaming_behavior: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PiLabAbortRequest {
pub session_id: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PiLabEventsQuery {
pub session_id: Option<String>,
}
#[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 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| {
root.source == "MNOTE_PI_LAB_ALLOWED_ROOTS"
&& path_is_inside(requested_root_path, &root.root_path)
})
});
let Some(root) = root else {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"page_ai_pi_lab_root_not_allowed",
"请求 rootUri 不在当前 allowed roots 内",
)
.with_context(context));
};
let base_root_path = if matched.is_some() {
canonical_or_parent(&root.root_path)
} else {
requested_root_path.unwrap_or_else(|| canonical_or_parent(&root.root_path))
};
if require_write && !root_can_write(root) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"page_ai_pi_lab_root_readonly",
"请求 rootUri 只有只读权限,不能执行 patch",
)
.with_context(context));
}
let target = canonical_or_parent(&base_root_path.join(relative_path));
if !path_is_inside(&target, &root.root_path) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"page_ai_pi_lab_path_escape",
"文件路径不能越过 allowed root",
)
.with_context(context));
}
if require_write {
if root.source != "MNOTE_PI_LAB_ALLOWED_ROOTS" {
local_folder_source::ensure_local_workspace_write_access_with_state(
state, context, root_uri,
)
.map_err(|error| error.with_context(context))?;
}
Ok(target)
} else if root.source == "MNOTE_PI_LAB_ALLOWED_ROOTS" {
Ok(target)
} else {
local_folder_source::ensure_local_path_read_access(context, root_uri, relative_path)
.map_err(|error| error.with_context(context))
}
}
fn resolve_file_path(
state: &AppState,
context: &RequestContext,
params: &Value,
require_write: bool,
) -> Result<(PathBuf, Option<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());
let path = params
.get("path")
.or_else(|| params.get("relativePath"))
.or_else(|| params.get("relative_path"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| WebError::bad_request_code("page_ai_pi_lab_path_required", "缺少 path"))?;
if let Some(root_uri) = root_uri {
let target = resolve_root_relative_path(state, context, root_uri, path, require_write)?;
return Ok((target, Some(root_uri.to_string()), Some(path.to_string())));
}
let requested = PathBuf::from(path);
if !requested.is_absolute() {
return Err(WebError::bad_request_code(
"page_ai_pi_lab_absolute_path_required",
"未提供 rootUri 时 path 必须是绝对路径",
)
.with_context(context));
}
let allowed = active_allowed_roots(state, context)?;
let target = canonical_or_parent(&requested);
let allowed_root = allowed
.iter()
.find(|root| path_is_inside(&target, &root.root_path))
.ok_or_else(|| {
WebError::new(
StatusCode::FORBIDDEN,
"page_ai_pi_lab_path_not_allowed",
"文件路径不在当前 allowed roots 内",
)
.with_context(context)
})?;
if require_write && !root_can_write(allowed_root) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"page_ai_pi_lab_path_readonly",
"目标文件所在 allowed root 只有只读权限",
)
.with_context(context));
}
let relative = target
.strip_prefix(canonical_or_parent(&allowed_root.root_path))
.ok()
.map(|value| value.to_string_lossy().to_string());
Ok((target, allowed_root.root_uri.clone(), relative))
}
fn managed_session_dir(
context: &RequestContext,
root_uri: Option<&str>,
session_id: &str,
) -> Result<PathBuf, WebError> {
let actor = context.auth.actor_id.trim().replace(['/', '\\', ':'], "_");
if let Some(root_path) = root_uri.and_then(file_path_from_root_uri) {
return Ok(root_path
.join(".mnote")
.join("ai")
.join("pi-sessions")
.join(if actor.is_empty() {
"anonymous"
} else {
&actor
})
.join(session_id));
}
Ok(std::env::temp_dir()
.join("mnote-web")
.join("pi-lab")
.join(if actor.is_empty() {
"anonymous"
} else {
&actor
})
.join(session_id))
}
fn session_from_request(
state: &AppState,
context: &RequestContext,
request: &PiLabStartRequest,
) -> Result<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 now = now_ms();
Ok(PiLabSession {
session_id: session_id.clone(),
mnote_user_id,
bridge_token: generate_bridge_token(),
status: PiLabSessionStatus::Idle,
provider_session_id: generate_id("pi_provider"),
pi_session_dir: session_dir.to_string_lossy().to_string(),
pi_session_file: None,
root_uri: request.root_uri.clone(),
workspace_id: request.workspace_id.clone(),
page_path: request.page_path.clone(),
page_title: request.page_title.clone(),
model_provider: request
.model_provider
.clone()
.filter(|v| !v.trim().is_empty())
.or_else(|| Some(default_model_provider())),
model_id: request
.model_id
.clone()
.filter(|v| !v.trim().is_empty())
.or_else(|| Some(default_model_id())),
runtime_pid: None,
runtime_mode: runtime_mode(),
runtime_error: None,
allowed_roots_snapshot: None,
created_at_ms: now,
updated_at_ms: now,
message_count: 0,
})
}
fn runtime_mode() -> String {
std::env::var("MNOTE_PAGE_AI_PI_LAB_RUNTIME")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "rpc".into())
}
fn env_trimmed(name: &str) -> Option<String> {
std::env::var(name)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn pi_binary() -> String {
env_trimmed("MNOTE_PAGE_AI_PI_BIN").unwrap_or_else(|| "pi".into())
}
fn default_model_provider() -> String {
env_trimmed("MNOTE_PAGE_AI_PI_DEFAULT_MODEL_PROVIDER")
.unwrap_or_else(|| PI_LAB_DEFAULT_MODEL_PROVIDER.into())
}
fn default_model_id() -> String {
env_trimmed("MNOTE_PAGE_AI_PI_DEFAULT_MODEL")
.or_else(|| env_trimmed("MNOTE_PAGE_AI_PI_DEFAULT_MODEL_ID"))
.unwrap_or_else(|| PI_LAB_DEFAULT_MODEL_ID.into())
}
fn omniroute_base_url() -> String {
env_trimmed("MNOTE_PAGE_AI_PI_OMNIROUTE_BASE_URL")
.or_else(|| env_trimmed("OMNIROUTE_BASE_URL"))
.unwrap_or_else(|| PI_LAB_DEFAULT_OMNIROUTE_BASE_URL.into())
}
fn omniroute_api_key() -> Option<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"))
}
/// 为 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) -> 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 models = match model_provider {
"omniroute" => json!({
"providers": {
"omniroute": {
"baseUrl": omniroute_base_url(),
"api": "openai-completions",
"apiKey": "$OPENAI_API_KEY",
"authHeader": true,
"compat": {
"supportsDeveloperRole": false,
"supportsReasoningEffort": false
},
"models": [
{
"id": model_id,
"name": format!("OmniRoute {}", model_id),
"input": ["text"],
"reasoning": false,
"contextWindow": 128000,
"maxTokens": 65536,
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
}
}
]
}
}
}),
_ => json!({
"providers": {
model_provider: {
"models": [
{
"id": model_id,
"name": format!("{} {}", model_provider, model_id),
"input": ["text"],
"reasoning": false,
"contextWindow": 128000,
"maxTokens": 65536
}
]
}
}
}),
};
let pretty = serde_json::to_string_pretty(&models)
.map_err(|error| WebError::internal(format!("序列化 Pi Lab models.json 失败: {error}")))?;
fs::write(&models_path, pretty.as_bytes())
.map_err(|error| WebError::internal(format!("写入 Pi Lab models.json 失败: {error}")))?;
Ok(config_dir)
}
fn pi_lab_public_base_url() -> String {
std::env::var("MNOTE_WEB_PUBLIC_BIND")
.or_else(|_| std::env::var("MNOTE_WEB_BIND"))
.ok()
.map(|value| value.trim().trim_end_matches('/').to_string())
.filter(|value| !value.is_empty())
.map(|value| {
if value.starts_with("http://") || value.starts_with("https://") {
value
} else {
format!("http://{value}")
}
})
.unwrap_or_else(|| "http://127.0.0.1:3000".into())
}
fn pi_lab_extension_tool_names() -> Vec<&'static str> {
vec![
"mnote_current_page_read",
"mnote_selection_read",
"mnote_allowed_roots_describe",
"mnote_local_file_read",
"mnote_local_file_patch",
"mnote_knowledge_rag_query",
"mnote_reference_open",
"mnote_tool_receipt_write",
]
}
fn ensure_session_tool_bridge_extension(session: &PiLabSession) -> Result<PathBuf, WebError> {
let extension_dir = PathBuf::from(&session.pi_session_dir).join("extensions");
fs::create_dir_all(&extension_dir).map_err(|error| {
WebError::internal(format!(
"创建 Pi Lab tool bridge extension 目录失败: {error}"
))
})?;
let extension_path = extension_dir.join("mnote-tool-bridge.ts");
let base_url =
serde_json::to_string(&pi_lab_public_base_url()).unwrap_or_else(|_| "\"\"".into());
let session_id = serde_json::to_string(&session.session_id).unwrap_or_else(|_| "\"\"".into());
let source = format!(
r#"import type {{ ExtensionAPI }} from "@earendil-works/pi-coding-agent";
import {{ Type }} from "typebox";
const BASE_URL = {base_url};
const SESSION_ID = {session_id};
const BRIDGE_TOKEN = process.env.MNOTE_PI_LAB_BRIDGE_TOKEN || "";
async function callMnote(toolName: string, params: unknown) {{
const response = await fetch(`${{BASE_URL}}/api/page-ai/pi/tool-call-bridge`, {{
method: "POST",
headers: {{
"content-type": "application/json",
"x-mnote-pi-lab-bridge-token": BRIDGE_TOKEN,
}},
body: JSON.stringify({{ sessionId: SESSION_ID, toolName, params: params || {{}} }}),
}});
const payload = await response.json().catch(() => ({{ ok: false, code: "bad_json" }}));
const text = JSON.stringify(payload.result || payload, null, 2);
return {{
content: [{{ type: "text", text }}],
details: payload,
}};
}}
function register(pi: ExtensionAPI, name: string, label: string, description: string, toolName: string) {{
pi.registerTool({{
name,
label,
description,
promptSnippet: `${{label}}: ${{description}}`,
parameters: Type.Object({{}}, {{ additionalProperties: true }}),
async execute(_toolCallId, params) {{
return callMnote(toolName, params);
}},
}});
}}
export default function mnoteToolBridge(pi: ExtensionAPI) {{
register(pi, "mnote_current_page_read", "MNote current page read", "Read the current MNote page through MNote access scope.", "mnote.current_page.read");
register(pi, "mnote_selection_read", "MNote selection read", "Read the current MNote editor selection snapshot supplied by MNote.", "mnote.selection.read");
register(pi, "mnote_allowed_roots_describe", "MNote allowed roots describe", "Describe MNote allowed roots and disabled raw tools.", "mnote.allowed_roots.describe");
register(pi, "mnote_local_file_read", "MNote local file read", "Read a file only through MNote allowed roots.", "mnote.local_file.read");
register(pi, "mnote_local_file_patch", "MNote local file patch", "Patch a file only through MNote allowed roots and watcher refresh.", "mnote.local_file.patch");
register(pi, "mnote_knowledge_rag_query", "MNote LightRAG query", "Query LightRAG only through the MNote knowledge facade.", "mnote.knowledge_rag.query");
register(pi, "mnote_reference_open", "MNote reference open", "Open a citation/reference through MNote mapping.", "mnote.reference.open");
register(pi, "mnote_tool_receipt_write", "MNote tool receipt write", "Write a provider-neutral MNote tool receipt.", "mnote.tool_receipt.write");
}}
"#
);
fs::write(&extension_path, source.as_bytes()).map_err(|error| {
WebError::internal(format!("写入 Pi Lab tool bridge extension 失败: {error}"))
})?;
Ok(extension_path)
}
fn publish_event(session_id: &str, kind: &str, payload: Value) {
let event = json!({
"schema": PI_LAB_SCHEMA_EVENT,
"sessionId": session_id,
"kind": kind,
"createdAtMs": now_ms(),
"payload": payload,
});
let _ = PI_LAB_EVENT_TX.send(event);
}
fn upsert_session(session: PiLabSession) {
if let Ok(mut sessions) = PI_LAB_SESSIONS.lock() {
sessions.insert(session.session_id.clone(), session);
}
}
fn update_session<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 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() {
if let Ok(mut processes) = PI_LAB_PROCESSES.lock() {
for (session_id, _) in &expired {
processes.remove(session_id);
}
}
for (_, session_dir) in expired {
let path = PathBuf::from(session_dir);
if path
.components()
.any(|component| component.as_os_str() == "pi-sessions")
{
let _ = fs::remove_dir_all(path);
}
}
}
}
fn check_rate_limit(actor_id: &str, action: &str, limit: usize) -> Result<(), WebError> {
let now = now_ms();
let key = format!("{actor_id}:{action}");
if let Ok(mut buckets) = PI_LAB_RATE_LIMITS.lock() {
let bucket = buckets.entry(key).or_default();
bucket.retain(|timestamp| now.saturating_sub(*timestamp) <= PI_LAB_RATE_WINDOW_MS);
if bucket.len() >= limit {
return Err(WebError::new(
StatusCode::TOO_MANY_REQUESTS,
"page_ai_pi_lab_rate_limited",
format!("Pi Lab {action} 请求过于频繁,请稍后再试"),
));
}
bucket.push(now);
buckets.retain(|_, timestamps| !timestamps.is_empty());
}
Ok(())
}
fn get_session_for_context(
state: &AppState,
context: &RequestContext,
session_id: &str,
) -> Result<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 pi_config_dir = ensure_session_models_config(&session)?;
let bridge_extension_path = ensure_session_tool_bridge_extension(&session)?;
let mnote_tool_names = pi_lab_extension_tool_names();
if session.runtime_mode == "mock" {
session.status = PiLabSessionStatus::RuntimeRunning;
session.runtime_pid = None;
upsert_session(session.clone());
persist_upsert_run(state, &session)?;
persist_append_event(
state,
&session,
"runtime_started",
&json!({
"mode": "mock",
"providerSessionId": session.provider_session_id,
"modelProvider": session.model_provider,
"modelId": session.model_id,
}),
)?;
publish_event(
&session.session_id,
"runtime_started",
json!({
"mode": "mock",
"providerSessionId": session.provider_session_id,
"modelProvider": session.model_provider,
"modelId": session.model_id,
"piCodingAgentDir": pi_config_dir,
"mnoteToolBridgeExtension": bridge_extension_path,
"mnoteToolNames": mnote_tool_names,
}),
);
return Ok(session);
}
let mut command = Command::new(pi_binary());
command
.arg("--mode")
.arg("rpc")
.arg("--session-dir")
.arg(&session.pi_session_dir)
.arg("--no-approve")
.arg("--no-builtin-tools")
.arg("--no-extensions")
.arg("--extension")
.arg(&bridge_extension_path)
.arg("--tools")
.arg(mnote_tool_names.join(","))
.arg("--no-skills")
.arg("--no-prompt-templates")
.arg("--no-context-files")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(provider) = session
.model_provider
.as_deref()
.filter(|value| !value.is_empty())
{
command.arg("--provider").arg(provider);
}
if let Some(model) = session
.model_id
.as_deref()
.filter(|value| !value.is_empty())
{
command.arg("--model").arg(model);
}
command
.arg("--name")
.arg(format!("MNote Pi Lab {}", session.session_id));
// 生成每 session 受控的 models.json,设置 PI_CODING_AGENT_DIR
// API key 仅从环境读取,不写入日志或 command line
command.env(
"PI_CODING_AGENT_DIR",
pi_config_dir.to_string_lossy().to_string(),
);
command.env("MNOTE_PI_LAB_BRIDGE_TOKEN", &session.bridge_token);
command.env("OPENAI_BASE_URL", omniroute_base_url());
if let Some(key) = omniroute_api_key() {
command.env("OPENAI_API_KEY", key);
}
let mut child = command.spawn().map_err(|error| {
WebError::bad_gateway_code(
"page_ai_pi_lab_runtime_spawn_failed",
format!("启动 pi --mode rpc 失败: {error}"),
)
})?;
let pid = child.id();
let stdin = child.stdin.take().ok_or_else(|| {
WebError::bad_gateway_code("page_ai_pi_lab_stdin_missing", "Pi RPC stdin 不可用")
})?;
let stdout = child.stdout.take().ok_or_else(|| {
WebError::bad_gateway_code("page_ai_pi_lab_stdout_missing", "Pi RPC stdout 不可用")
})?;
let handle = PiLabProcessHandle {
stdin: Arc::new(AsyncMutex::new(stdin)),
child: Arc::new(AsyncMutex::new(child)),
_pid: pid,
};
if let Ok(mut processes) = PI_LAB_PROCESSES.lock() {
processes.insert(session.session_id.clone(), handle);
}
session.status = PiLabSessionStatus::RuntimeRunning;
session.runtime_pid = pid;
upsert_session(session.clone());
publish_event(
&session.session_id,
"runtime_started",
json!({
"mode": "rpc",
"pid": pid,
"sessionDir": session.pi_session_dir,
"providerSessionId": session.provider_session_id,
"modelProvider": session.model_provider,
"modelId": session.model_id,
"omnirouteBaseUrl": omniroute_base_url(),
"piCodingAgentDir": pi_config_dir,
"mnoteToolBridgeExtension": bridge_extension_path,
"mnoteToolNames": mnote_tool_names,
"disabledBuiltinTools": ["bash", "read", "write", "edit"],
}),
);
let session_id = session.session_id.clone();
let cloned_state = state.clone();
let session_user_id = session.mnote_user_id.clone();
let session_workspace_id = session.workspace_id.clone();
let session_page_path = session.page_path.clone();
tokio::spawn(async move {
let cp: &dyn ControlPlaneStore = cloned_state.control_plane();
let run_id = pi_run_id(&session_id);
let persist_event = |event_type: &str, event_payload: &Value| {
let input = AppendAiRuntimeEventInput {
id: None,
user_id: session_user_id.clone(),
workspace_id: session_workspace_id.clone(),
document_id: session_page_path.clone(),
session_id: session_id.clone(),
run_id: run_id.clone(),
profile: PI_LAB_PROFILE.to_string(),
acp_runtime: PI_LAB_ACP_RUNTIME.to_string(),
event_type: event_type.to_string(),
payload_json: serde_json::to_string(event_payload).unwrap_or_else(|_| "{}".into()),
};
if let Err(e) = cp.append_ai_runtime_event(input) {
publish_event(
&session_id,
"runtime_persistence_error",
json!({
"error": e.to_string(),
"eventType": event_type,
}),
);
}
};
let mut lines = BufReader::new(stdout).lines();
loop {
match lines.next_line().await {
Ok(Some(line)) => {
let payload = serde_json::from_str::<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 = PiLabSessionStatus::RuntimeRunning;
});
}
persist_event("pi_rpc_event", &payload);
publish_event(&session_id, "pi_rpc_event", payload);
}
Ok(None) => {
update_session(&session_id, |session| {
session.status = PiLabSessionStatus::Idle;
});
persist_event("runtime_stdout_closed", &json!({}));
publish_event(&session_id, "runtime_stdout_closed", json!({}));
break;
}
Err(error) => {
update_session(&session_id, |session| {
session.status = PiLabSessionStatus::Error;
session.runtime_error = Some(error.to_string());
});
publish_event(
&session_id,
"runtime_stdout_error",
json!({"error": error.to_string()}),
);
persist_event("runtime_stdout_error", &json!({"error": error.to_string()}));
break;
}
}
}
});
persist_upsert_run(state, &session)?;
persist_append_event(
state,
&session,
"runtime_started",
&json!({
"mode": "rpc",
"pid": session.runtime_pid,
"providerSessionId": session.provider_session_id,
"modelProvider": session.model_provider,
"modelId": session.model_id,
}),
)?;
Ok(session)
}
async fn send_rpc_command(session_id: &str, command: Value) -> Result<(), WebError> {
let handle = PI_LAB_PROCESSES
.lock()
.ok()
.and_then(|processes| processes.get(session_id).cloned());
let Some(handle) = handle else {
return Err(WebError::bad_request_code(
"page_ai_pi_lab_runtime_not_started",
"Pi runtime 未启动;请先调用 /api/page-ai/pi/start",
));
};
let mut stdin = handle.stdin.lock().await;
let line = serde_json::to_string(&command)
.map_err(|error| WebError::internal(format!("序列化 Pi RPC command 失败: {error}")))?;
stdin.write_all(line.as_bytes()).await.map_err(|error| {
WebError::bad_gateway_code("page_ai_pi_lab_rpc_write_failed", error.to_string())
})?;
stdin.write_all(b"\n").await.map_err(|error| {
WebError::bad_gateway_code("page_ai_pi_lab_rpc_write_failed", error.to_string())
})?;
stdin.flush().await.map_err(|error| {
WebError::bad_gateway_code("page_ai_pi_lab_rpc_flush_failed", error.to_string())
})?;
Ok(())
}
fn apply_text_operations(current: &str, operations: &Value) -> Result<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: 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>,
}
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(&params).ok_or_else(|| {
WebError::bad_request_code("page_ai_pi_lab_root_uri_required", "读取当前页缺少 rootUri")
})?;
let page_path = self.session_page_path(&params).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(&params),
"pagePath": self.session_page_path(&params),
"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)?;
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": ["bash", "read", "write", "edit"],
"mnoteTools": [
"mnote.current_page.read",
"mnote.selection.read",
"mnote.allowed_roots.describe",
"mnote.local_file.read",
"mnote.local_file.patch",
"mnote.knowledge_rag.query",
"mnote.reference.open",
"mnote.tool_receipt.write"
],
}))
}
fn local_file_read(&self, params: Value) -> Result<Value, WebError> {
let (target, root_uri, relative_path) =
resolve_file_path(&self.state, &self.context, &params, 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, &params, 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(&current, 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(&params).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(payload)
}
async fn reference_open(&self, params: Value) -> Result<Value, WebError> {
let root_uri = self.session_root_uri(&params).ok_or_else(|| {
WebError::bad_request_code("page_ai_pi_lab_root_uri_required", "打开引用缺少 rootUri")
})?;
let body = knowledge_rag::KnowledgeRagOpenReferenceRequest {
workspace_id: self
.session
.as_ref()
.and_then(|session| session.workspace_id.clone())
.or_else(|| {
params
.get("workspaceId")
.and_then(Value::as_str)
.map(str::to_string)
}),
root_uri,
reference: params.get("reference").cloned(),
reference_id: params
.get("referenceId")
.or_else(|| params.get("reference_id"))
.and_then(Value::as_str)
.map(str::to_string),
file_path: params
.get("filePath")
.or_else(|| params.get("resourcePath"))
.or_else(|| params.get("path"))
.and_then(Value::as_str)
.map(str::to_string),
chunk_id: params
.get("chunkId")
.or_else(|| params.get("chunk_id"))
.and_then(Value::as_str)
.map(str::to_string),
};
let Json(payload) = knowledge_rag::open_reference(
State(self.state.clone()),
Extension(self.context.clone()),
Json(body),
)
.await?;
Ok(payload)
}
}
async fn execute_tool(
state: AppState,
context: RequestContext,
session_id: Option<String>,
tool_name: String,
params: Value,
) -> 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
};
let facade = PiLabToolFacade {
state,
context: context.clone(),
session: session.clone(),
};
let started = now_ms();
let result: Result<Value, WebError> = match tool_name.as_str() {
"mnote.current_page.read" => facade.current_page_read(params.clone()),
"mnote.selection.read" => facade.selection_read(params.clone()),
"mnote.allowed_roots.describe" => facade.allowed_roots_describe(),
"mnote.local_file.read" => facade.local_file_read(params.clone()),
"mnote.local_file.patch" => facade.local_file_patch(params.clone()),
"mnote.knowledge_rag.query" => facade.knowledge_rag_query(params.clone()).await,
"mnote.reference.open" => facade.reference_open(params.clone()).await,
"mnote.tool_receipt.write" => Ok(json!({
"requestedReceipt": params,
"storage": "control_plane_turso_libsql_v1",
"note": "Pi Lab 由 execute_tool 统一写入 control-plane receipt journal",
})),
_ => Err(WebError::bad_request_code(
"page_ai_pi_lab_unknown_tool",
format!("未知 Pi Lab tool: {tool_name}"),
)),
};
let normalized_file_path = params
.get("path")
.and_then(Value::as_str)
.map(str::to_string);
let (allowed, payload, deny_reason, diff_summary, before_file_version, after_file_version) =
match result {
Ok(payload) => (
true,
payload.clone(),
None,
payload
.get("diffSummary")
.and_then(Value::as_str)
.map(str::to_string),
payload
.get("beforeFileVersion")
.and_then(Value::as_str)
.map(str::to_string),
payload
.get("afterFileVersion")
.and_then(Value::as_str)
.map(str::to_string),
),
Err(error) => (
false,
json!({
"ok": false,
"code": error.code(),
"message": error.message(),
}),
Some(format!("{}: {}", error.code(), error.message())),
None,
None,
None,
),
};
let citation_count = payload
.get("citations")
.and_then(Value::as_array)
.map(|items| items.len())
.or_else(|| {
payload
.get("sources")
.and_then(Value::as_array)
.map(|items| items.len())
})
.or_else(|| {
payload
.get("references")
.and_then(Value::as_array)
.map(|items| items.len())
})
.unwrap_or(0);
let receipt = receipt_for(
&context,
session.as_ref(),
&tool_name,
normalized_file_path.clone(),
allowed,
deny_reason.clone(),
diff_summary.clone(),
before_file_version.clone(),
after_file_version.clone(),
);
let receipt_payload = write_receipt(&facade.state, receipt, &payload, citation_count);
let elapsed_ms = now_ms().saturating_sub(started) as u64;
if let Some(session) = session.as_ref() {
publish_event(
&session.session_id,
"tool_call",
json!({
"toolName": tool_name,
"allowed": allowed,
"denyReason": deny_reason,
"normalizedFilePath": normalized_file_path,
"rootUri": payload.get("rootUri").cloned().unwrap_or(Value::Null),
"relativePath": payload.get("relativePath").cloned().unwrap_or(Value::Null),
"diffSummary": diff_summary,
"beforeFileVersion": before_file_version,
"afterFileVersion": after_file_version,
"citationCount": citation_count,
"receipt": receipt_payload,
"elapsedMs": elapsed_ms,
}),
);
}
Ok(Json(json!({
"ok": allowed,
"toolName": tool_name,
"result": payload,
"receipt": receipt_payload,
"elapsedMs": elapsed_ms,
})))
}
pub async fn shell(
State(state): State<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)
.max_by_key(|session| session.updated_at_ms)
.cloned()
});
let active_session_count = sessions
.as_ref()
.map(|sessions| {
sessions
.values()
.filter(|session| session.mnote_user_id == actor_id)
.count()
})
.unwrap_or(0);
let owned_session_ids = sessions
.as_ref()
.map(|sessions| {
sessions
.values()
.filter(|session| session.mnote_user_id == actor_id)
.map(|session| session.session_id.clone())
.collect::<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);
Ok(Json(json!({
"ok": true,
"schema": PI_LAB_SCHEMA_STATUS,
"enabled": true,
"running": process_count > 0 || current_session.as_ref().is_some_and(|session| session.status == PiLabSessionStatus::RuntimeRunning || session.status == PiLabSessionStatus::TurnRunning),
"version": PI_LAB_VERSION,
"provider": PI_LAB_PROVIDER,
"runtimeMode": runtime_mode(),
"defaultModelProvider": default_model_provider(),
"defaultModelId": default_model_id(),
"omnirouteBaseUrl": omniroute_base_url(),
"pid": current_session.as_ref().and_then(|session| session.runtime_pid),
"sessionId": current_session.as_ref().map(|session| session.session_id.clone()),
"providerSessionId": current_session.as_ref().map(|session| session.provider_session_id.clone()),
"session": current_session,
"activeSessionCount": active_session_count,
"processCount": process_count,
"managedPiSessionDirPolicy": "<workspace>/.mnote/ai/pi-sessions/<actor>/<session>",
"disabledPiBuiltinTools": ["bash", "read", "write", "edit"],
"receiptStorage": "provider_neutral_jsonl_adapter_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_MAX_STARTS_PER_WINDOW)?;
let start_request = PiLabStartRequest {
session_id: None,
root_uri: request.root_uri,
workspace_id: request.workspace_id,
page_path: request.page_path,
page_title: request.page_title,
model_provider: request.model_provider,
model_id: request.model_id,
};
let requested = session_from_request(&state, &context, &start_request)?;
let mut session = requested;
session.allowed_roots_snapshot = snapshot_allowed_roots(&state, &context)?;
let session = start_runtime_for_session(&state, session).await?;
let mut response = json!({
"ok": true,
"schema": "mnote.page_ai_pi.bootstrap.v1",
"sessionId": session.session_id,
"providerSessionId": session.provider_session_id,
"runtimeMode": session.runtime_mode,
"pid": session.runtime_pid,
"sessionDir": session.pi_session_dir,
"toolCalls": [],
"citations": [],
"diffSummary": null,
"uiMode": "independent_mnote_native_drawer",
});
if let Some(prompt) = request
.prompt
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
let send_response = send(
State(state),
Extension(context),
Json(PiLabSendRequest {
session_id: response["sessionId"]
.as_str()
.unwrap_or_default()
.to_string(),
message: prompt.to_string(),
streaming_behavior: None,
}),
)
.await?
.0;
response["send"] = send_response;
response["text"] =
json!("Pi Lab 已接收 prompt;真实输出请从 /api/page-ai/pi/events 读取。");
}
Ok(Json(response))
}
pub async fn start(
State(state): State<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_MAX_STARTS_PER_WINDOW)?;
let requested_session = session_from_request(&state, &context, &request)?;
let mut requested_session = requested_session;
requested_session.allowed_roots_snapshot = snapshot_allowed_roots(&state, &context)?;
let session = start_runtime_for_session(&state, requested_session).await?;
Ok(Json(json!({
"ok": true,
"schema": "mnote.page_ai_pi.start.v1",
"session": session,
"disabledPiBuiltinTools": ["bash", "read", "write", "edit"],
"mnoteToolOnly": true,
})))
}
pub async fn send(
State(state): State<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| {
session.status = PiLabSessionStatus::TurnRunning;
session.message_count += 1;
});
let command = json!({
"id": generate_id("pi_rpc"),
"type": "prompt",
"message": request.message,
"streamingBehavior": request.streaming_behavior,
});
if session.runtime_mode == "mock" {
publish_event(
&session.session_id,
"pi_rpc_event",
json!({
"type": "message_update",
"assistantMessageEvent": {
"type": "text_delta",
"delta": "[Pi Lab mock] prompt accepted"
}
}),
);
publish_event(
&session.session_id,
"pi_rpc_event",
json!({
"type": "citation",
"source": "lightrag-mock",
"title": "LightRAG mock citation",
"url": "#lightrag-mock-citation",
}),
);
publish_event(
&session.session_id,
"pi_rpc_event",
json!({
"type": "diff",
"files": [session.page_path.clone().unwrap_or_else(|| "page.md".into())],
}),
);
update_session(&request.session_id, |session| {
session.status = PiLabSessionStatus::RuntimeRunning;
});
} else {
if let Err(error) = send_rpc_command(&request.session_id, command).await {
update_session(&request.session_id, |session| {
session.status = PiLabSessionStatus::Error;
session.runtime_error = Some(error.message().to_string());
});
return Err(error);
}
}
let current = get_session(&request.session_id).unwrap_or(session.clone());
persist_upsert_run(&state, &current)?;
persist_append_event(
&state,
&current,
"user_prompt",
&json!({"message": request.message}),
)?;
Ok(Json(json!({
"ok": true,
"schema": "mnote.page_ai_pi.send.v1",
"sessionId": request.session_id,
"providerSessionId": session.provider_session_id,
"accepted": true,
"eventStream": "/api/page-ai/pi/events",
})))
}
pub async fn abort(
State(state): State<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 handle = PI_LAB_PROCESSES
.lock()
.ok()
.and_then(|processes| processes.get(&request.session_id).cloned());
if let Some(handle) = handle {
let _ = handle.child.lock().await.kill().await;
if let Ok(mut processes) = PI_LAB_PROCESSES.lock() {
processes.remove(&request.session_id);
}
}
}
update_session(&request.session_id, |session| {
session.status = PiLabSessionStatus::Aborted;
});
publish_event(&request.session_id, "runtime_aborted", json!({}));
// 持久化 abort 状态到 DB
if let Some(current) = get_session(&request.session_id) {
if let Err(e) = persist_upsert_run(&state, &current) {
// abort 已执行,DB 写失败仅记录日志,不影响 abort 返回
publish_event(
&request.session_id,
"runtime_persistence_error",
json!({
"error": e.message(),
"context": "abort_persist",
}),
);
}
}
Ok(Json(json!({
"ok": true,
"schema": "mnote.page_ai_pi.abort.v1",
"sessionId": request.session_id,
"providerSessionId": session.provider_session_id,
"aborted": true,
})))
}
pub async fn events(
State(state): State<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();
ensure_authenticated(&state, &context)?;
let session_filter = if let Some(session_id) = query.session_id {
let session = get_session_for_context(&state, &context, &session_id)?;
Some(session.session_id)
} else {
None
};
let rx = PI_LAB_EVENT_TX.subscribe();
let hello = stream::once(async {
Ok(SseEvent::default().event("connected").data(
json!({
"schema": PI_LAB_SCHEMA_EVENT,
"kind": "connected",
"version": PI_LAB_VERSION,
})
.to_string(),
))
});
let stream = BroadcastStream::new(rx).filter_map(move |event| {
let session_filter = session_filter.clone();
async move {
let Ok(value) = event else {
return None;
};
if let Some(filter) = session_filter.as_deref() {
if value.get("sessionId").and_then(Value::as_str) != Some(filter) {
return None;
}
}
let event_name = value
.get("kind")
.and_then(Value::as_str)
.unwrap_or("message");
Some(Ok(SseEvent::default()
.event(event_name)
.data(value.to_string())))
}
});
Ok(Sse::new(hello.chain(stream)).keep_alive(
KeepAlive::new()
.interval(Duration::from_secs(30))
.text("keepalive"),
))
}
pub async fn tool_call(
State(state): State<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,
)
.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,
)
.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",
}
}
fn build_run_runtime_json(session: &PiLabSession) -> String {
serde_json::to_string(&json!({
"providerSessionId": session.provider_session_id,
"piSessionDir": session.pi_session_dir,
"runtimeMode": session.runtime_mode,
"modelProvider": session.model_provider,
"modelId": session.model_id,
"messageCount": session.message_count,
"pagePath": session.page_path,
"pageTitle": session.page_title,
"rootUri": session.root_uri,
"workspaceId": session.workspace_id,
"allowedRootsSnapshot": session.allowed_roots_snapshot,
}))
.unwrap_or_default()
}
fn build_upsert_run_input(session: &PiLabSession) -> UpsertAiRuntimeRunInput {
UpsertAiRuntimeRunInput {
id: None,
user_id: session.mnote_user_id.clone(),
workspace_id: session.workspace_id.clone(),
document_id: session.page_path.clone(),
session_id: session.session_id.clone(),
run_id: pi_run_id(&session.session_id),
title: session.page_title.clone(),
profile: PI_LAB_PROFILE.to_string(),
acp_runtime: PI_LAB_ACP_RUNTIME.to_string(),
trace_id: None,
status: session_status_to_string(&session.status).to_string(),
runtime_json: build_run_runtime_json(session),
payload_json: "{}".to_string(),
}
}
fn persist_upsert_run(state: &AppState, session: &PiLabSession) -> Result<(), WebError> {
let input = build_upsert_run_input(session);
state
.control_plane()
.upsert_ai_runtime_run(input)
.map_err(|e| WebError::internal(format!("持久化 Pi Lab run 失败: {e}")))?;
Ok(())
}
fn build_append_event_input(
session: &PiLabSession,
event_type: &str,
payload_json: &str,
) -> AppendAiRuntimeEventInput {
AppendAiRuntimeEventInput {
id: None,
user_id: session.mnote_user_id.clone(),
workspace_id: session.workspace_id.clone(),
document_id: session.page_path.clone(),
session_id: session.session_id.clone(),
run_id: pi_run_id(&session.session_id),
profile: PI_LAB_PROFILE.to_string(),
acp_runtime: PI_LAB_ACP_RUNTIME.to_string(),
event_type: event_type.to_string(),
payload_json: payload_json.to_string(),
}
}
fn persist_append_event(
state: &AppState,
session: &PiLabSession,
event_type: &str,
payload: &Value,
) -> Result<(), WebError> {
let payload_json = serde_json::to_string(payload).unwrap_or_else(|_| "{}".into());
let input = build_append_event_input(session, event_type, &payload_json);
state
.control_plane()
.append_ai_runtime_event(input)
.map_err(|e| WebError::internal(format!("持久化 Pi Lab event 失败: {e}")))?;
Ok(())
}
// ---------------------------------------------------------------------------
// Session history handlers
// ---------------------------------------------------------------------------
pub async fn list_sessions(
State(state): State<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)
.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(),
})))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::{build_app, AppConfig, AppState};
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use tower::util::ServiceExt;
fn test_app() -> axum::Router {
build_app(AppState::new(AppConfig {
service_name: "mnote-web".into(),
service_version: "test".into(),
bind_addr: "127.0.0.1:0".into(),
public_bind_addr: "127.0.0.1:3000".into(),
legacy_next_base_url: None,
enable_legacy_next_compat: false,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: true,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
allow_dev_fixtures: true,
query_fixtures_json: None,
mutation_fixtures_json: None,
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
}))
}
#[test]
fn pi_run_id_format() {
let rid = pi_run_id("pi_lab_abc123");
assert_eq!(rid, "pi_run_pi_lab_abc123");
}
#[test]
fn session_status_to_string_maps_all_variants() {
assert_eq!(session_status_to_string(&PiLabSessionStatus::Idle), "idle");
assert_eq!(
session_status_to_string(&PiLabSessionStatus::RuntimeRunning),
"runtime_running"
);
assert_eq!(
session_status_to_string(&PiLabSessionStatus::TurnRunning),
"turn_running"
);
assert_eq!(
session_status_to_string(&PiLabSessionStatus::Aborted),
"aborted"
);
assert_eq!(
session_status_to_string(&PiLabSessionStatus::Error),
"error"
);
}
#[test]
fn build_upsert_run_input_creates_correct_input() {
let session = PiLabSession {
session_id: "pi_lab_test123".into(),
mnote_user_id: "user_test".into(),
bridge_token: "bridge".into(),
status: PiLabSessionStatus::RuntimeRunning,
provider_session_id: "prov_123".into(),
pi_session_dir: "/tmp/pi-lab/test".into(),
pi_session_file: None,
root_uri: Some("file:///workspace".into()),
workspace_id: Some("ws_test".into()),
page_path: Some("doc.md".into()),
page_title: Some("Test Page".into()),
model_provider: Some("omniroute".into()),
model_id: Some("freefirst".into()),
allowed_roots_snapshot: None,
runtime_pid: Some(12345),
runtime_mode: "mock".into(),
runtime_error: None,
created_at_ms: 1000,
updated_at_ms: 2000,
message_count: 5,
};
let input = build_upsert_run_input(&session);
assert_eq!(input.user_id, "user_test");
assert_eq!(input.run_id, "pi_run_pi_lab_test123");
assert_eq!(input.session_id, "pi_lab_test123");
assert_eq!(input.profile, "pi_lab");
assert_eq!(input.acp_runtime, "pi");
assert_eq!(input.status, "runtime_running");
assert_eq!(input.title.as_deref(), Some("Test Page"));
assert_eq!(input.workspace_id.as_deref(), Some("ws_test"));
assert_eq!(input.document_id.as_deref(), Some("doc.md"));
let runtime: Value = serde_json::from_str(&input.runtime_json).unwrap();
assert_eq!(runtime["messageCount"], 5);
assert_eq!(runtime["modelProvider"], "omniroute");
}
#[test]
fn build_append_event_input_creates_correct_input() {
let session = PiLabSession {
session_id: "pi_lab_test456".into(),
mnote_user_id: "user_test".into(),
bridge_token: "bridge".into(),
status: PiLabSessionStatus::RuntimeRunning,
provider_session_id: "prov_456".into(),
pi_session_dir: "/tmp/pi-lab/test".into(),
pi_session_file: None,
root_uri: None,
workspace_id: None,
page_path: None,
page_title: None,
model_provider: None,
model_id: None,
allowed_roots_snapshot: None,
runtime_pid: None,
runtime_mode: "mock".into(),
runtime_error: None,
created_at_ms: 1000,
updated_at_ms: 2000,
message_count: 0,
};
let payload = json!({"type": "text_delta", "delta": "hello"});
let input = build_append_event_input(
&session,
"pi_rpc_event",
&serde_json::to_string(&payload).unwrap(),
);
assert_eq!(input.user_id, "user_test");
assert_eq!(input.session_id, "pi_lab_test456");
assert_eq!(input.run_id, "pi_run_pi_lab_test456");
assert_eq!(input.event_type, "pi_rpc_event");
let parsed: Value = serde_json::from_str(&input.payload_json).unwrap();
assert_eq!(parsed["delta"], "hello");
}
#[tokio::test]
async fn list_sessions_filters_by_current_user_and_pi_profile() {
let app = test_app();
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/page-ai/pi/sessions")
.header("x-mnote-actor-id", "user_test_list")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["ok"], true);
assert_eq!(payload["schema"], "mnote.page_ai_pi.list_sessions.v1");
// Should be an empty list since no Pi sessions exist for this user
assert!(payload["sessions"].is_array());
assert_eq!(payload["count"].as_u64().unwrap_or(0), 0);
}
#[tokio::test]
async fn get_session_returns_not_found_for_nonexistent_session() {
let app = test_app();
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/page-ai/pi/sessions/nonexistent_session_id")
.header("x-mnote-actor-id", "user_test_get")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["code"], "page_ai_pi_lab_session_not_found");
}
#[tokio::test]
async fn get_session_events_returns_not_found_for_nonexistent_session() {
let app = test_app();
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/page-ai/pi/sessions/nonexistent_session_id/events")
.header("x-mnote-actor-id", "user_test_events")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["code"], "page_ai_pi_lab_session_not_found");
}
}