diff --git a/bugs/07-ai/done/7-63-page-ai-pi-rust-official-p1-alignment-checklist-v1.md b/bugs/07-ai/done/7-63-page-ai-pi-rust-official-p1-alignment-checklist-v1.md
new file mode 100644
index 00000000..083f4d30
--- /dev/null
+++ b/bugs/07-ai/done/7-63-page-ai-pi-rust-official-p1-alignment-checklist-v1.md
@@ -0,0 +1,30 @@
+# 7-63 Page AI Pi Rust Official P1 Alignment Checklist
+
+## 背景
+
+Pi Rust built-in tools 已恢复为一等工具面,但 MNote Pi Lab 仍有一组官方能力只接了外壳、被 MNote 自造路径替代,或没有 UI/API 入口。该缺陷以 Pi Rust 官方 RPC/CLI 语义为准,不再把 MNote 当成 Pi 能力替代层;MNote 只负责展示、授权目录、URL/引用与宿主上下文。
+
+## Checklist
+
+- [x] 官方 RPC 管理面:接入受控 `rpc-command` endpoint,覆盖 `get_messages`、`get_available_models`、`new_session`、`switch_session`、`set_session_name`、`export_html`、`abort_bash`。
+- [x] 中途发送:`steer` / `follow_up` 使用官方 RPC command,普通 prompt 保留为默认 `prompt`。
+- [x] Abort 语义:普通 abort 只发送官方 `abort`,不直接 kill runtime;新建会话/切换模式等需要结束旧进程的路径另走明确 stop/kill。
+- [x] Bash 中止:full access 下长 bash 暴露 `abort_bash` UI/API。
+- [x] Prompt templates:移除默认 `--no-prompt-templates`,无配置时允许官方 `.pi/prompts` / `~/.pi/agent/prompts` 发现。
+- [x] `@file` / context files:移除默认 `--no-context-files`,让 Pi Rust 官方 `@file` 与资源扩展处理真实文件地址;MNote hidden context 仅作为页面/selection/allowed roots 附加上下文。
+- [x] 图片/附件:前端接入文件选择并按 Pi RPC `images` base64 payload 传给 `prompt`/`steer`/`follow_up`。
+- [x] 测试:补静态 smoke 覆盖启动参数、RPC command allowlist、图片 payload、abort 不 kill;补浏览器 smoke 覆盖附件入口、RPC command wrapper 和官方 built-in tools 不回退。
+- [x] 验证:`cargo test -p mnote-web page_ai_pi`、相关 `scripts/task-pi-lab-*.js`、真实 Chromium smoke。
+- [x] 完成后移动到 `bugs/07-ai/done/` 并记录提交与验证证据。
+
+## 验证证据
+
+- `cargo test -p mnote-web page_ai_pi -- --nocapture`:30 passed。
+- `node scripts/task-pi-lab-static-smoke.js`:253 checks passed。
+- `MNOTE_UI_BASE_URL=http://127.0.0.1:3000 PLAYWRIGHT_CHROMIUM_EXECUTABLE=/usr/bin/chromium-browser node scripts/task-pi-lab-full-access-builtin-delete-smoke.js`:通过,结果 `/tmp/mnote-pi-full-access-builtin-tools-1783775113182/result.json`。
+
+## 非目标
+
+- 不在本轮开放任意外部 Pi extension/package install。
+- 不改变 MNote allowed roots / permission mode 的宿主授权边界。
+- 不把 OpenHub/LightRAG/MCP 的产品策略混入官方 Pi Rust RPC 对齐。
diff --git a/rust/crates/mnote-web/browser/sidebar-page-ai-pi-lab-runtime.js b/rust/crates/mnote-web/browser/sidebar-page-ai-pi-lab-runtime.js
index 4247bc5a..028da99f 100644
--- a/rust/crates/mnote-web/browser/sidebar-page-ai-pi-lab-runtime.js
+++ b/rust/crates/mnote-web/browser/sidebar-page-ai-pi-lab-runtime.js
@@ -28,6 +28,7 @@
START: '/api/page-ai/pi/start',
SEND: '/api/page-ai/pi/send',
ABORT: '/api/page-ai/pi/abort',
+ RPC_COMMAND: '/api/page-ai/pi/rpc-command',
UI_RESPONSE: '/api/page-ai/pi/ui-response',
EVENTS: '/api/page-ai/pi/events',
BOOTSTRAP: '/api/page-ai/pi/bootstrap',
@@ -97,6 +98,7 @@
modelOptions: [],
history: [],
pendingQueue: { steering: [], followUp: [] },
+ pendingImages: [],
viewingHistorySessionId: null,
contextSelection: {
currentPage: false,
@@ -1647,6 +1649,10 @@
window.location.assign('/user/ai#ai-admin-access');
return;
}
+ if (action === 'attach-file' || action === 'camera') {
+ pickPiPromptImages(action === 'camera');
+ return;
+ }
if (action === 'send-steer') {
sendCurrentInput({ streamingBehavior: 'steer' });
return;
@@ -1655,6 +1661,14 @@
sendCurrentInput({ streamingBehavior: 'followUp' });
return;
}
+ if (action === 'abort-bash') {
+ callPiRpcCommand('abort_bash', {}, 5000).then(function () {
+ showPiToast('已请求中止 bash', 'info');
+ }).catch(function (error) {
+ showPiToast(error && error.message ? error.message : 'bash 中止失败', 'warning');
+ });
+ return;
+ }
if (action === 'plan-mode') {
var input = piLabPanelEl && piLabPanelEl.querySelector('[data-page-ai-pi-lab-input]');
var current = input ? input.value.trim() : '';
@@ -1943,12 +1957,13 @@
'
' +
'' +
'' +
- '' +
- '' +
+ '' +
+ '' +
'' +
'' +
'' +
'' +
+ '' +
'' +
'' +
'' +
@@ -3386,6 +3401,71 @@
}).catch(function () {});
}
+ function callPiRpcCommand(type, params, timeoutMs) {
+ if (!piLabState.sessionId) return Promise.reject(new Error('Pi 会话尚未就绪'));
+ return fetch(API.RPC_COMMAND, {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ sessionId: piLabState.sessionId,
+ type: type,
+ params: params || {},
+ timeoutMs: timeoutMs || 10000,
+ }),
+ }).then(function (r) {
+ return responseJsonOrError(r, 'Pi RPC command failed');
+ });
+ }
+
+ function readFileAsPiImage(file) {
+ return new Promise(function (resolve, reject) {
+ if (!file || !/^image\//i.test(file.type || '')) {
+ reject(new Error('只支持图片附件;普通文件请在输入中使用 @文件路径'));
+ return;
+ }
+ var reader = new FileReader();
+ reader.onload = function () {
+ var dataUrl = String(reader.result || '');
+ var comma = dataUrl.indexOf(',');
+ if (comma < 0) {
+ reject(new Error('图片读取失败'));
+ return;
+ }
+ resolve({
+ type: 'image',
+ source: {
+ type: 'base64',
+ mediaType: file.type || 'image/png',
+ data: dataUrl.slice(comma + 1),
+ },
+ });
+ };
+ reader.onerror = function () { reject(new Error('图片读取失败')); };
+ reader.readAsDataURL(file);
+ });
+ }
+
+ function pickPiPromptImages(useCamera) {
+ var input = document.createElement('input');
+ input.type = 'file';
+ input.accept = 'image/*';
+ input.multiple = !useCamera;
+ if (useCamera) input.setAttribute('capture', 'environment');
+ input.addEventListener('change', function () {
+ var files = Array.prototype.slice.call(input.files || []);
+ if (!files.length) return;
+ Promise.all(files.map(readFileAsPiImage)).then(function (images) {
+ piLabState.pendingImages = (piLabState.pendingImages || []).concat(images);
+ showPiToast('已添加 ' + images.length + ' 张图片', 'success');
+ updateButtons();
+ }).catch(function (error) {
+ showPiToast(error && error.message ? error.message : '附件读取失败', 'warning');
+ });
+ });
+ input.click();
+ }
+
function responseJsonOrError(response, fallbackMessage) {
return response.json().catch(function () { return {}; }).then(function (payload) {
@@ -3947,6 +4027,10 @@
selectedContext: contextSelection,
};
if (streamingBehavior) body.streamingBehavior = streamingBehavior;
+ if (piLabState.pendingImages && piLabState.pendingImages.length) {
+ body.images = piLabState.pendingImages;
+ piLabState.pendingImages = [];
+ }
return fetch(API.SEND, {
method: 'POST',
credentials: 'same-origin',
diff --git a/rust/crates/mnote-web/src/routes/ai_settings.rs b/rust/crates/mnote-web/src/routes/ai_settings.rs
index f594d814..342079bc 100644
--- a/rust/crates/mnote-web/src/routes/ai_settings.rs
+++ b/rust/crates/mnote-web/src/routes/ai_settings.rs
@@ -1905,7 +1905,10 @@ pub(crate) fn load_effective_ai_runtime_policy(
.into_iter()
.filter_map(|entry| normalize_model_ref(Some(&default_provider), &entry.id))
.collect::
>();
- if !allowed_models.iter().any(|model| model == FREEFIRST_PI_MODEL) {
+ if !allowed_models
+ .iter()
+ .any(|model| model == FREEFIRST_PI_MODEL)
+ {
allowed_models.push(FREEFIRST_PI_MODEL.to_string());
}
if !allowed_models.iter().any(|model| model == DEFAULT_PI_MODEL) {
diff --git a/rust/crates/mnote-web/src/routes/mod.rs b/rust/crates/mnote-web/src/routes/mod.rs
index 7e508527..6bdc7d3c 100644
--- a/rust/crates/mnote-web/src/routes/mod.rs
+++ b/rust/crates/mnote-web/src/routes/mod.rs
@@ -604,6 +604,7 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/page-ai/pi/configure", post(page_ai_pi::configure))
.route("/api/page-ai/pi/state", post(page_ai_pi::state))
.route("/api/page-ai/pi/compact", post(page_ai_pi::compact))
+ .route("/api/page-ai/pi/rpc-command", post(page_ai_pi::rpc_command))
.route(
"/api/page-ai/pi/queue-config",
post(page_ai_pi::queue_config),
diff --git a/rust/crates/mnote-web/src/routes/page_ai_pi.rs b/rust/crates/mnote-web/src/routes/page_ai_pi.rs
index 297d747c..7f1b517f 100644
--- a/rust/crates/mnote-web/src/routes/page_ai_pi.rs
+++ b/rust/crates/mnote-web/src/routes/page_ai_pi.rs
@@ -43,6 +43,7 @@ const PI_LAB_SCHEMA_EVENT: &str = "mnote.page_ai_pi.event.v1";
const PI_LAB_SCHEMA_STATE: &str = "mnote.page_ai_pi.state.v1";
const PI_LAB_SCHEMA_COMPACT: &str = "mnote.page_ai_pi.compact.v1";
const PI_LAB_SCHEMA_QUEUE_CONFIG: &str = "mnote.page_ai_pi.queue_config.v1";
+const PI_LAB_SCHEMA_RPC_COMMAND: &str = "mnote.page_ai_pi.rpc_command.v1";
const PI_LAB_DEFAULT_MODEL_PROVIDER: &str = "omniroute";
const PI_LAB_SCHEMA_SESSION_TREE: &str = "mnote.page_ai_pi.session_tree.v1";
const PI_LAB_SCHEMA_FORK: &str = "mnote.page_ai_pi.fork.v1";
@@ -263,6 +264,7 @@ pub struct PiLabSendRequest {
pub session_id: String,
pub message: String,
pub streaming_behavior: Option,
+ pub images: Option>,
pub root_uri: Option,
pub workspace_id: Option,
pub page_path: Option,
@@ -278,6 +280,17 @@ pub struct PiLabAbortRequest {
pub session_id: String,
}
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct PiLabRpcCommandRequest {
+ pub session_id: String,
+ #[serde(rename = "type", alias = "command")]
+ pub command_type: String,
+ #[serde(default)]
+ pub params: Value,
+ pub timeout_ms: Option,
+}
+
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PiLabRenameSessionRequest {
@@ -723,10 +736,7 @@ fn resolve_file_path(
.filter(|value| !value.is_empty());
let relative_path = if let Some(folder_path) = folder_path {
let normalized_path = path.replace('\\', "/").trim_start_matches('/').to_string();
- let normalized_folder = folder_path
- .replace('\\', "/")
- .trim_matches('/')
- .to_string();
+ let normalized_folder = folder_path.replace('\\', "/").trim_matches('/').to_string();
if normalized_folder.is_empty()
|| Path::new(&normalized_path).is_absolute()
|| normalized_path == normalized_folder
@@ -2568,7 +2578,10 @@ fn stage_extension_file(
Ok(target_path.to_string_lossy().to_string())
}
-fn bind_mnote_bridge_context_path(extension_path: &str, context_path: &Path) -> Result<(), WebError> {
+fn bind_mnote_bridge_context_path(
+ extension_path: &str,
+ context_path: &Path,
+) -> Result<(), WebError> {
let target = Path::new(extension_path);
let source = fs::read_to_string(target).map_err(|error| {
WebError::internal(format!(
@@ -2579,18 +2592,23 @@ fn bind_mnote_bridge_context_path(extension_path: &str, context_path: &Path) ->
let canonical_context_path = context_path
.canonicalize()
.unwrap_or_else(|_| context_path.to_path_buf());
- let context_literal = serde_json::to_string(&canonical_context_path.to_string_lossy().to_string())
- .map_err(|error| WebError::internal(format!("编码 Pi Rust MNote context 路径失败: {error}")))?;
- let context_payload: Value = serde_json::from_slice(&fs::read(context_path).map_err(|error| {
- WebError::internal(format!(
- "读取 Pi Rust MNote context 失败: {}: {error}",
- context_path.display()
- ))
- })?)
- .map_err(|error| WebError::internal(format!("解析 Pi Rust MNote context 失败: {error}")))?;
- let context_snapshot_literal = serde_json::to_string(&context_payload)
- .map_err(|error| WebError::internal(format!("编码 Pi Rust MNote context 快照失败: {error}")))?;
- let context_file_needle = r#"const DEFAULT_CONTEXT_FILE = path.join(EXTENSION_DIR, "mnote-context.json");"#;
+ let context_literal = serde_json::to_string(
+ &canonical_context_path.to_string_lossy().to_string(),
+ )
+ .map_err(|error| WebError::internal(format!("编码 Pi Rust MNote context 路径失败: {error}")))?;
+ let context_payload: Value =
+ serde_json::from_slice(&fs::read(context_path).map_err(|error| {
+ WebError::internal(format!(
+ "读取 Pi Rust MNote context 失败: {}: {error}",
+ context_path.display()
+ ))
+ })?)
+ .map_err(|error| WebError::internal(format!("解析 Pi Rust MNote context 失败: {error}")))?;
+ let context_snapshot_literal = serde_json::to_string(&context_payload).map_err(|error| {
+ WebError::internal(format!("编码 Pi Rust MNote context 快照失败: {error}"))
+ })?;
+ let context_file_needle =
+ r#"const DEFAULT_CONTEXT_FILE = path.join(EXTENSION_DIR, "mnote-context.json");"#;
let context_snapshot_needle =
r#"const EMBEDDED_CONTEXT: Record | undefined = undefined;"#;
if !source.contains(context_file_needle) || !source.contains(context_snapshot_needle) {
@@ -3218,8 +3236,6 @@ async fn start_runtime_for_session(
.arg(&mnote_pi_extension_path)
.arg("--tools")
.arg(pi_tool_names.join(","))
- .arg("--no-prompt-templates")
- .arg("--no-context-files")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
@@ -3675,6 +3691,66 @@ fn rpc_response_error_message(response: Option<&Value>, fallback: &str) -> Strin
.to_string()
}
+fn normalize_pi_rpc_command_type(value: &str) -> String {
+ match value.trim() {
+ "follow-up" | "followUp" | "queue-follow-up" | "queueFollowUp" => "follow_up",
+ "get-state" | "getState" => "get_state",
+ "get-messages" | "getMessages" => "get_messages",
+ "get-available-models" | "getAvailableModels" => "get_available_models",
+ "set-model" | "setModel" => "set_model",
+ "set-thinking-level" | "setThinkingLevel" => "set_thinking_level",
+ "set-steering-mode" | "setSteeringMode" => "set_steering_mode",
+ "set-follow-up-mode" | "setFollowUpMode" => "set_follow_up_mode",
+ "set-auto-compaction" | "setAutoCompaction" => "set_auto_compaction",
+ "new-session" | "newSession" => "new_session",
+ "switch-session" | "switchSession" => "switch_session",
+ "set-session-name" | "setSessionName" => "set_session_name",
+ "export-html" | "exportHtml" => "export_html",
+ "abort-bash" | "abortBash" => "abort_bash",
+ other => other,
+ }
+ .to_string()
+}
+
+fn pi_lab_allowed_rpc_commands() -> HashSet<&'static str> {
+ [
+ "get_state",
+ "get_messages",
+ "get_available_models",
+ "new_session",
+ "switch_session",
+ "set_session_name",
+ "export_html",
+ "abort_bash",
+ "cycle_model",
+ ]
+ .into_iter()
+ .collect()
+}
+
+fn build_pi_rpc_command(command_type: &str, params: Value) -> Result {
+ let command_type = normalize_pi_rpc_command_type(command_type);
+ if !pi_lab_allowed_rpc_commands().contains(command_type.as_str()) {
+ return Err(WebError::bad_request_code(
+ "page_ai_pi_lab_rpc_command_not_allowed",
+ format!("Pi RPC command 不允许通过 MNote wrapper 调用: {command_type}"),
+ ));
+ }
+ let mut command = match params {
+ Value::Object(map) => Value::Object(map),
+ Value::Null => json!({}),
+ _ => {
+ return Err(WebError::bad_request_code(
+ "page_ai_pi_lab_rpc_command_params_invalid",
+ "Pi RPC command params 必须是 object",
+ ));
+ }
+ };
+ command["id"] = json!(generate_id("pi_rpc_command"));
+ command["type"] = json!(command_type);
+ Ok(command)
+}
+
fn apply_text_operations(current: &str, operations: &Value) -> Result {
match operations {
Value::Array(ops) => {
@@ -4149,8 +4225,13 @@ impl PiLabToolFacade {
}
fn local_file_read(&self, params: Value) -> Result {
- let (target, root_uri, relative_path) =
- resolve_file_path(&self.state, &self.context, ¶ms, self.session.as_ref(), false)?;
+ let (target, root_uri, relative_path) = resolve_file_path(
+ &self.state,
+ &self.context,
+ ¶ms,
+ self.session.as_ref(),
+ false,
+ )?;
let content = fs::read_to_string(&target).map_err(|error| {
WebError::bad_request_code(
"page_ai_pi_lab_file_read_failed",
@@ -4168,8 +4249,13 @@ impl PiLabToolFacade {
}
fn local_file_patch(&self, params: Value) -> Result {
- let (target, root_uri, relative_path) =
- resolve_file_path(&self.state, &self.context, ¶ms, self.session.as_ref(), true)?;
+ let (target, root_uri, relative_path) = resolve_file_path(
+ &self.state,
+ &self.context,
+ ¶ms,
+ self.session.as_ref(),
+ true,
+ )?;
let before_version = file_version(&target);
let current = fs::read_to_string(&target).unwrap_or_default();
let next = if let Some(content) = params.get("content").and_then(Value::as_str) {
@@ -5045,6 +5131,7 @@ pub async fn bootstrap(
.to_string(),
message: prompt.to_string(),
streaming_behavior: None,
+ images: None,
root_uri: start_request.root_uri.clone(),
workspace_id: start_request.workspace_id.clone(),
page_path: start_request.page_path.clone(),
@@ -5315,6 +5402,22 @@ pub async fn send(
request.selected_context.as_ref(),
request.context_refs.as_deref(),
)?;
+ let command_type = match request
+ .streaming_behavior
+ .as_deref()
+ .map(str::trim)
+ .filter(|value| !value.is_empty())
+ {
+ Some("steer") => "steer",
+ Some("follow-up") | Some("followUp") | Some("follow_up") => "follow_up",
+ Some(value) => {
+ return Err(WebError::bad_request_code(
+ "page_ai_pi_lab_invalid_streaming_behavior",
+ format!("streamingBehavior 只能是 steer/follow-up,当前为 {value}"),
+ ));
+ }
+ None => "prompt",
+ };
let command_message = pi_lab_command_message_for_session(
&command_session,
&request.message,
@@ -5323,7 +5426,7 @@ pub async fn send(
let plan_mode_prompt_applied = session_permission_mode(&command_session) == Some("plan");
let mut command = json!({
"id": generate_id("pi_rpc"),
- "type": "prompt",
+ "type": command_type,
"message": command_message,
"displayMessage": request.message,
"context": {
@@ -5336,13 +5439,8 @@ pub async fn send(
"selectedContext": request.selected_context,
},
});
- if let Some(streaming_behavior) = request
- .streaming_behavior
- .as_deref()
- .map(str::trim)
- .filter(|value| !value.is_empty())
- {
- command["streamingBehavior"] = json!(streaming_behavior);
+ if let Some(images) = request.images.clone().filter(|images| !images.is_empty()) {
+ command["images"] = json!(images);
}
if command_session.runtime_mode == "mock" {
publish_event(
@@ -5418,7 +5516,6 @@ pub async fn abort(
let session = get_session_for_context(&state, &context, &request.session_id)?;
if session.runtime_mode != "mock" {
let _ = send_rpc_command(&request.session_id, json!({"type": "abort"})).await;
- let _ = kill_session_process(&request.session_id).await;
}
update_session(&request.session_id, |session| {
session.status = PiLabSessionStatus::Aborted;
@@ -5462,6 +5559,60 @@ pub async fn abort(
})))
}
+pub async fn rpc_command(
+ State(state): State,
+ Extension(context): Extension,
+ Json(request): Json,
+) -> Result, WebError> {
+ ensure_enabled(&state)?;
+ cleanup_expired_sessions();
+ ensure_authenticated(&state, &context)?;
+ let session = get_session_for_context(&state, &context, &request.session_id)?;
+ if !session_runtime_is_usable(&session) {
+ return Err(WebError::bad_request_code(
+ "page_ai_pi_lab_runtime_not_started",
+ "Pi runtime 未启动;请先调用 /api/page-ai/pi/start",
+ ));
+ }
+ let command = build_pi_rpc_command(&request.command_type, request.params)?;
+ let command_type = command
+ .get("type")
+ .and_then(Value::as_str)
+ .unwrap_or("unknown")
+ .to_string();
+ if session.runtime_mode == "mock" {
+ return Ok(Json(json!({
+ "ok": true,
+ "schema": PI_LAB_SCHEMA_RPC_COMMAND,
+ "sessionId": request.session_id,
+ "providerSessionId": session.provider_session_id,
+ "command": command_type,
+ "stateSource": "mock_runtime_snapshot",
+ "rpcResponsePending": false,
+ "response": {
+ "type": "response",
+ "command": command_type,
+ "success": true,
+ "data": Value::Null,
+ },
+ })));
+ }
+ let timeout = Duration::from_millis(request.timeout_ms.unwrap_or(10_000).clamp(1_000, 120_000));
+ let response = send_rpc_command_wait(&request.session_id, command, timeout).await?;
+ Ok(Json(json!({
+ "ok": response.as_ref().is_some_and(|value| {
+ value.get("success").and_then(Value::as_bool).unwrap_or(false)
+ }),
+ "schema": PI_LAB_SCHEMA_RPC_COMMAND,
+ "sessionId": request.session_id,
+ "providerSessionId": session.provider_session_id,
+ "command": command_type,
+ "stateSource": "pi_rpc_command_response",
+ "rpcResponsePending": response.is_none(),
+ "response": response,
+ })))
+}
+
/// POST /api/page-ai/pi/state
/// 封装官方 Pi RPC `get_state`。
/// mock 返回完整假数据;real 发送 get_state RPC 并等待响应,超时降级。
@@ -6616,9 +6767,7 @@ fn pi_lab_entry_parent_id(entry: &Value) -> Option<&str> {
}
fn pi_lab_entry_message(entry: &Value) -> Option<&Value> {
- entry
- .get("message")
- .filter(|message| message.is_object())
+ entry.get("message").filter(|message| message.is_object())
}
fn pi_lab_entry_role(entry: &Value) -> String {
@@ -7893,11 +8042,30 @@ pub async fn rename_session(
update_session(&path.session_id, |session| {
session.page_title = Some(title.to_string());
});
+ let mut pi_rpc_response = Value::Null;
+ if let Some(session) = get_session(&path.session_id) {
+ if session.runtime_mode != "mock" && session_runtime_is_usable(&session) {
+ if let Some(response) = send_rpc_command_wait(
+ &path.session_id,
+ json!({
+ "id": generate_id("pi_rpc_set_session_name"),
+ "type": "set_session_name",
+ "name": title,
+ }),
+ Duration::from_secs(5),
+ )
+ .await?
+ {
+ pi_rpc_response = response;
+ }
+ }
+ }
Ok(Json(json!({
"ok": true,
"schema": "mnote.page_ai_pi.rename_session.v1",
"sessionId": path.session_id,
"title": title,
+ "piRpcResponse": pi_rpc_response,
"updatedRuns": renamed.len(),
})))
}
@@ -8331,10 +8499,16 @@ mod tests {
assert_eq!(replay[0]["role"], "user");
assert_eq!(replay[0]["text"], "请查看当前页");
assert_eq!(replay[1]["role"], "assistant");
- assert_eq!(replay[1]["toolCalls"][0]["toolName"], "mnote_current_page_read");
+ assert_eq!(
+ replay[1]["toolCalls"][0]["toolName"],
+ "mnote_current_page_read"
+ );
assert_eq!(replay[2]["role"], "assistant");
assert_eq!(replay[2]["toolCalls"][0]["status"], "done");
- assert_eq!(replay[2]["toolCalls"][0]["result"]["content"][0]["text"], "当前页内容");
+ assert_eq!(
+ replay[2]["toolCalls"][0]["result"]["content"][0]["text"],
+ "当前页内容"
+ );
assert_eq!(replay[3]["text"], "FINAL_TAIL_REPLY");
}
@@ -8743,6 +8917,30 @@ mod tests {
);
}
+ #[test]
+ fn pi_rpc_command_wrapper_allows_only_official_control_commands() {
+ let command = build_pi_rpc_command(
+ "getAvailableModels",
+ json!({
+ "ignoredClientField": true
+ }),
+ )
+ .expect("allowed command");
+ assert_eq!(command["type"], "get_available_models");
+ assert!(command["id"]
+ .as_str()
+ .unwrap_or("")
+ .starts_with("pi_rpc_command"));
+ assert!(build_pi_rpc_command("bash", json!({"command": "pwd"})).is_err());
+ }
+
+ #[test]
+ fn pi_rust_start_keeps_official_prompt_templates_and_context_files_enabled() {
+ let source = include_str!("page_ai_pi.rs");
+ assert!(!source.contains(".arg(\"--no-prompt-templates\")"));
+ assert!(!source.contains(".arg(\"--no-context-files\")"));
+ }
+
#[tokio::test]
async fn list_sessions_filters_by_current_user_and_pi_profile() {
let app = test_app();
@@ -8893,7 +9091,11 @@ mod tests {
assert_eq!(policy["defaultModel"], "omniroute/pi-fast");
assert_eq!(
policy["allowedModels"],
- json!(["omniroute/freefirst", "omniroute/gpt-5.4-mini", "omniroute/pi-fast"])
+ json!([
+ "omniroute/freefirst",
+ "omniroute/gpt-5.4-mini",
+ "omniroute/pi-fast"
+ ])
);
assert!(policy["enabledSkills"]
.as_array()
diff --git a/scripts/task-pi-lab-full-access-builtin-delete-smoke.js b/scripts/task-pi-lab-full-access-builtin-delete-smoke.js
index d5a652a0..1893946d 100644
--- a/scripts/task-pi-lab-full-access-builtin-delete-smoke.js
+++ b/scripts/task-pi-lab-full-access-builtin-delete-smoke.js
@@ -272,6 +272,20 @@ async function main() {
);
await openPiUi(page);
+ result.checks.attachmentButtonsEnabled = await page.locator('[data-page-ai-pi-lab-menu-action="attach-file"]:not([disabled])').count() === 1
+ && await page.locator('[data-page-ai-pi-lab-menu-action="camera"]:not([disabled])').count() === 1;
+ const rpcState = await requestJson(page, "/api/page-ai/pi/rpc-command", {
+ method: "POST",
+ data: {
+ sessionId: session.sessionId,
+ type: "get_state",
+ params: {},
+ timeoutMs: 10000,
+ },
+ });
+ result.checks.rpcCommandGetStateOk = rpcState.ok === true && rpcState.command === "get_state";
+ assert.equal(result.checks.attachmentButtonsEnabled, true, "Pi RPC 图片附件入口应该可用");
+ assert.equal(result.checks.rpcCommandGetStateOk, true, "受控 Pi RPC command wrapper 应能调用官方 get_state");
await page.screenshot({ path: path.join(OUT, "01-full-access-started.png"), fullPage: false });
result.screenshots.started = path.join(OUT, "01-full-access-started.png");
diff --git a/scripts/task-pi-lab-static-smoke.js b/scripts/task-pi-lab-static-smoke.js
index 6ae01eef..ce4cc781 100644
--- a/scripts/task-pi-lab-static-smoke.js
+++ b/scripts/task-pi-lab-static-smoke.js
@@ -204,6 +204,11 @@ const checks = [
['MNote Pi bridge allows native tools when context file proves Pi Rust', mnotePiExtension.includes('isPiRustNativeRuntime') && mnotePiExtension.includes('Boolean(CONTEXT_FILE)')],
['route starts Pi with explicit MNote extension bridge', route.includes('--extension') && route.includes('mnotePiExtension')],
['route starts Pi with MNote tool allowlist', route.includes('--tools') && route.includes('mnoteToolNames') && route.includes('pi_lab_extension_tool_names')],
+ ['route keeps Pi Rust official prompt templates and @file context enabled', !route.includes('.arg("--no-prompt-templates")') && !route.includes('.arg("--no-context-files")')],
+ ['route exposes controlled Pi Rust RPC command wrapper', route.includes('PI_LAB_SCHEMA_RPC_COMMAND') && route.includes('build_pi_rpc_command') && route.includes('abort_bash') && route.includes('get_available_models')],
+ ['route maps mid-stream send to official steer/follow_up commands', route.includes('Some("steer") => "steer"') && route.includes('Some("follow-up") | Some("followUp") | Some("follow_up") => "follow_up"')],
+ ['route sends Pi RPC images payload without MNote file substitution', route.includes('pub images: Option>') && route.includes('command["images"] = json!(images)')],
+ ['route abort uses official RPC without killing runtime process', route.includes('json!({"type": "abort"})') && !route.includes('let _ = kill_session_process(&request.session_id).await;')],
['route passes MNote tool manifest to extension env', route.includes('MNOTE_PI_BRIDGE_TOOLS') && route.includes('PI_MNOTE_BRIDGE_TOOLS') && route.includes('mnote_pi_tool_manifest')],
['route writes dynamic Pi Rust MNote context file', route.includes('mnote.pi.context.v1') && route.includes('PI_MNOTE_CONTEXT_FILE') && route.includes('write_pi_mnote_context_snapshot')],
['route binds staged MNote bridge to absolute context path and private embedded snapshot', route.includes('bind_mnote_bridge_context_path') && route.includes('DEFAULT_CONTEXT_FILE') && route.includes('EMBEDDED_CONTEXT')],
@@ -217,6 +222,8 @@ const checks = [
['dev:hot warmup defaults to no real model prompt', devHot.includes('MNOTE_PAGE_AI_PI_WARMUP_SEND') && devHot.includes('?? "0"')],
['runtime opens Pi drawer by prestarting current page session', showPiLabBody.includes('startRuntime().then') && showPiLabBody.includes('syncRuntimeState()')],
['runtime send path auto-starts Pi Rust instead of dead-end not-ready toast', runtime.includes('Pi Rust 正在启动,启动后自动发送') && runtime.includes('piLabStartPromise') && !runtime.includes('Pi 会话尚未就绪,请重新打开 Pi 面板或稍后重试')],
+ ['runtime enables Pi RPC image attachments', runtime.includes('pendingImages') && runtime.includes('readFileAsPiImage') && runtime.includes('body.images = piLabState.pendingImages') && !runtime.includes('Pi RPC 附件上下文尚未接入')],
+ ['runtime exposes official abort_bash command', runtime.includes("RPC_COMMAND: '/api/page-ai/pi/rpc-command'") && runtime.includes("callPiRpcCommand('abort_bash'")],
['runtime keeps selected model before start/configure', runtime.includes('ensurePiToolCapableModel') && !runtime.includes('isPiToolUnsupportedModel') && runtime.includes('gpt-5.4-mini') && runtime.includes('freefirst')],
['route applies TTL and rate limits', route.includes('PI_LAB_SESSION_TTL_MS') && route.includes('PI_LAB_RATE_LIMITS') && route.includes('check_rate_limit')],
['route cleans expired session dirs and rate buckets', route.includes('fs::remove_dir_all') && route.includes('buckets.retain')],