fix: align pi rust p1 official features

This commit is contained in:
Agent Board
2026-07-11 21:07:32 +08:00
parent d16eccfe10
commit 1418f1b1e0
7 changed files with 382 additions and 41 deletions
@@ -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 @@
'<div class="wolai-page-ai-pi-lab-menu-sep"></div>' +
'<div class="wolai-page-ai-pi-lab-menu-section">权限与附件</div>' +
'<button type="button" class="wolai-page-ai-pi-lab-menu-item" data-page-ai-pi-lab-menu-action="directory-permission">' + piIcon('folder') + '<span>目录权限</span></button>' +
'<button type="button" class="wolai-page-ai-pi-lab-menu-item" data-page-ai-pi-lab-menu-action="attach-file" disabled title="Pi RPC 附件上下文尚未接入">' + piIcon('image') + '<span>添加图片和文件</span></button>' +
'<button type="button" class="wolai-page-ai-pi-lab-menu-item" data-page-ai-pi-lab-menu-action="camera" disabled title="Pi RPC 附件上下文尚未接入">' + piIcon('camera') + '<span>拍照</span></button>' +
'<button type="button" class="wolai-page-ai-pi-lab-menu-item" data-page-ai-pi-lab-menu-action="attach-file">' + piIcon('image') + '<span>添加图片和文件</span></button>' +
'<button type="button" class="wolai-page-ai-pi-lab-menu-item" data-page-ai-pi-lab-menu-action="camera">' + piIcon('camera') + '<span>拍照</span></button>' +
'<div class="wolai-page-ai-pi-lab-menu-sep"></div>' +
'<div class="wolai-page-ai-pi-lab-menu-section">执行中发送</div>' +
'<button type="button" class="wolai-page-ai-pi-lab-menu-item" data-page-ai-pi-lab-menu-action="send-steer">' + piIcon('zap') + '<span>作为引导发送</span></button>' +
'<button type="button" class="wolai-page-ai-pi-lab-menu-item" data-page-ai-pi-lab-menu-action="send-followup">' + piIcon('queue') + '<span>作为排队追问发送</span></button>' +
'<button type="button" class="wolai-page-ai-pi-lab-menu-item" data-page-ai-pi-lab-menu-action="abort-bash">' + piIcon('stop') + '<span>中止 bash</span></button>' +
'<button type="button" class="wolai-page-ai-pi-lab-menu-item" data-page-ai-pi-lab-menu-action="plan-mode" title="通过 Pi Rust 官方 plan-mode 扩展进入计划评审">' + piIcon('settings') + '<span>计划评审</span></button>' +
'</div>' +
'<div class="wolai-page-ai-pi-lab-modebar" data-page-ai-pi-lab-modebar>' +
@@ -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',
@@ -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::<Vec<_>>();
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) {
+1
View File
@@ -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),
+240 -38
View File
@@ -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<String>,
pub images: Option<Vec<Value>>,
pub root_uri: Option<String>,
pub workspace_id: Option<String>,
pub page_path: Option<String>,
@@ -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<u64>,
}
#[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<string, unknown> | 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<Value, WebError> {
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<String, WebError> {
match operations {
Value::Array(ops) => {
@@ -4149,8 +4225,13 @@ impl PiLabToolFacade {
}
fn local_file_read(&self, params: Value) -> Result<Value, WebError> {
let (target, root_uri, relative_path) =
resolve_file_path(&self.state, &self.context, &params, self.session.as_ref(), false)?;
let (target, root_uri, relative_path) = resolve_file_path(
&self.state,
&self.context,
&params,
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<Value, WebError> {
let (target, root_uri, relative_path) =
resolve_file_path(&self.state, &self.context, &params, self.session.as_ref(), true)?;
let (target, root_uri, relative_path) = resolve_file_path(
&self.state,
&self.context,
&params,
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<AppState>,
Extension(context): Extension<RequestContext>,
Json(request): Json<PiLabRpcCommandRequest>,
) -> Result<Json<Value>, WebError> {
ensure_enabled(&state)?;
cleanup_expired_sessions();
ensure_authenticated(&state, &context)?;
let session = get_session_for_context(&state, &context, &request.session_id)?;
if !session_runtime_is_usable(&session) {
return Err(WebError::bad_request_code(
"page_ai_pi_lab_runtime_not_started",
"Pi runtime 未启动;请先调用 /api/page-ai/pi/start",
));
}
let 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()