2870 lines
102 KiB
Rust
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(¶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)?;
|
|
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, ¶ms, false)?;
|
|
let content = fs::read_to_string(&target).map_err(|error| {
|
|
WebError::bad_request_code(
|
|
"page_ai_pi_lab_file_read_failed",
|
|
format!("读取文件失败: {error}"),
|
|
)
|
|
})?;
|
|
Ok(json!({
|
|
"rootUri": root_uri,
|
|
"relativePath": relative_path,
|
|
"path": target,
|
|
"content": content,
|
|
"contentLength": content.len(),
|
|
"fileVersion": file_version(&target),
|
|
}))
|
|
}
|
|
|
|
fn local_file_patch(&self, params: Value) -> Result<Value, WebError> {
|
|
let (target, root_uri, relative_path) =
|
|
resolve_file_path(&self.state, &self.context, ¶ms, true)?;
|
|
let before_version = file_version(&target);
|
|
let current = fs::read_to_string(&target).unwrap_or_default();
|
|
let next = if let Some(content) = params.get("content").and_then(Value::as_str) {
|
|
content.to_string()
|
|
} else {
|
|
let operations = params.get("operations").ok_or_else(|| {
|
|
WebError::bad_request_code(
|
|
"page_ai_pi_lab_operations_required",
|
|
"patch 需要 content 或 operations",
|
|
)
|
|
})?;
|
|
apply_text_operations(¤t, operations)?
|
|
};
|
|
if let Some(parent) = target.parent() {
|
|
fs::create_dir_all(parent).map_err(|error| {
|
|
WebError::bad_request_code(
|
|
"page_ai_pi_lab_patch_parent_failed",
|
|
format!("创建父目录失败: {error}"),
|
|
)
|
|
})?;
|
|
}
|
|
fs::write(&target, next.as_bytes()).map_err(|error| {
|
|
WebError::bad_request_code(
|
|
"page_ai_pi_lab_patch_write_failed",
|
|
format!("写入文件失败: {error}"),
|
|
)
|
|
})?;
|
|
let after_version = file_version(&target);
|
|
let diff_summary = if current == next {
|
|
"no_changes".to_string()
|
|
} else {
|
|
format!("bytes_delta={}", next.len() as i64 - current.len() as i64)
|
|
};
|
|
Ok(json!({
|
|
"rootUri": root_uri,
|
|
"relativePath": relative_path,
|
|
"path": target,
|
|
"beforeFileVersion": before_version,
|
|
"afterFileVersion": after_version,
|
|
"oldSize": current.len(),
|
|
"newSize": next.len(),
|
|
"diffSummary": diff_summary,
|
|
"refresh": "mnote local-folder watcher / document-session external refresh",
|
|
"polling": false,
|
|
}))
|
|
}
|
|
|
|
async fn knowledge_rag_query(&self, params: Value) -> Result<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(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 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, ¤t)?;
|
|
persist_append_event(
|
|
&state,
|
|
¤t,
|
|
"user_prompt",
|
|
&json!({"message": request.message}),
|
|
)?;
|
|
Ok(Json(json!({
|
|
"ok": true,
|
|
|
|
"schema": "mnote.page_ai_pi.send.v1",
|
|
"sessionId": request.session_id,
|
|
"providerSessionId": session.provider_session_id,
|
|
"accepted": true,
|
|
"eventStream": "/api/page-ai/pi/events",
|
|
})))
|
|
}
|
|
|
|
pub async fn abort(
|
|
State(state): State<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, ¤t) {
|
|
// abort 已执行,DB 写失败仅记录日志,不影响 abort 返回
|
|
publish_event(
|
|
&request.session_id,
|
|
"runtime_persistence_error",
|
|
json!({
|
|
"error": e.message(),
|
|
"context": "abort_persist",
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
Ok(Json(json!({
|
|
"ok": true,
|
|
"schema": "mnote.page_ai_pi.abort.v1",
|
|
"sessionId": request.session_id,
|
|
"providerSessionId": session.provider_session_id,
|
|
"aborted": true,
|
|
})))
|
|
}
|
|
|
|
pub async fn events(
|
|
State(state): State<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");
|
|
}
|
|
}
|