feat: purge legacy agent hosts and land vault Chrome extension path

Retire ACP/Hermes/OpenCode surfaces and rename hermes_tools to
mnote_agent_tools so Page AI stays on Pi Lab only. Add Chrome vault
extension + extension token route, pre-release purge design, and soft-retire
legacy smokes for the small-group production cut.
This commit is contained in:
Agent Board
2026-07-25 14:25:37 +08:00
parent bc6f8488ee
commit 262e66b02e
137 changed files with 9018 additions and 46049 deletions
-621
View File
@@ -1,621 +0,0 @@
/// ACP ↔ SSE bridge for Hermes route integration.
///
/// Transforms ACP session events into the SSE event format expected by the
/// frontend (HermesRunEvent), and manages the background prompt lifecycle.
///
/// Reference: `hermes-vscode-main/src/sessionManager.ts` handleUpdate()
use crate::acp_runtime::AcpRuntimeManager;
use crate::acp_session_manager::{AcpSessionEvent, AcpSessionManager};
use crate::acp_types::ContentBlock;
use axum::body::Body;
use axum::http::{header, StatusCode};
use axum::response::Response;
use serde_json::{json, Value};
use std::collections::HashSet;
use std::sync::Arc;
use tokio::sync::broadcast;
use tracing::{info, warn};
/// Errors from the ACP bridge.
#[derive(Debug)]
pub enum AcpBridgeError {
NoActiveRuntime,
SessionError(String),
StreamError(String),
}
impl std::fmt::Display for AcpBridgeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AcpBridgeError::NoActiveRuntime => write!(f, "no active ACP runtime"),
AcpBridgeError::SessionError(msg) => write!(f, "ACP session error: {msg}"),
AcpBridgeError::StreamError(msg) => write!(f, "ACP stream error: {msg}"),
}
}
}
/// SSE event types sent to the frontend.
/// Mirrors HermesRunEvent from bridge.ts.
#[derive(Debug, Clone)]
pub struct SseEvent {
pub event: String,
pub data: Value,
}
/// Bridge state for one run: holds the broadcast channel for SSE events.
pub struct AcpRunBridge {
session_id: String,
event_tx: broadcast::Sender<SseEvent>,
}
fn collect_citation_markdowns_from_value(value: &Value) -> Vec<Value> {
fn add_citation_value(value: &Value, seen: &mut HashSet<String>, out: &mut Vec<Value>) {
let Some(citation) = value.get("citationMarkdown").and_then(Value::as_str) else {
return;
};
let citation = citation.trim();
if citation.is_empty() || !seen.insert(citation.to_string()) {
return;
}
out.push(json!({
"schema": value.get("schema").cloned().unwrap_or_else(|| json!("mnote.knowledge_rag.citation.v1")),
"citationMarkdown": citation,
"citationId": value.get("citationId").cloned().unwrap_or(Value::Null),
"citationLabel": value.get("citationLabel").cloned().unwrap_or(Value::Null),
"sourceRootRelativePath": value.get("sourceRootRelativePath").cloned().unwrap_or(Value::Null),
"sourcePath": value.get("sourcePath").cloned().unwrap_or(Value::Null),
"filePath": value.get("filePath").or_else(|| value.get("lightRagFilePath")).cloned().unwrap_or(Value::Null),
"headingPath": value.get("headingPath").cloned().unwrap_or_else(|| json!([])),
"displayQuote": value.get("displayQuote").or_else(|| value.get("quote")).cloned().unwrap_or(Value::Null),
"locatorEvidenceText": value.get("locatorEvidenceText").cloned().unwrap_or(Value::Null),
"locatorPrecision": value.get("locatorPrecision").cloned().unwrap_or(Value::Null),
"locatorDegraded": value.get("locatorDegraded").cloned().unwrap_or(Value::Bool(true)),
"citationUrl": value.get("citationUrl").cloned().unwrap_or(Value::Null),
}));
}
fn add_reference_citations(
references: &[Value],
seen: &mut HashSet<String>,
out: &mut Vec<Value>,
) -> bool {
let has_precise = references.iter().any(|reference| {
reference
.get("citationMarkdown")
.and_then(Value::as_str)
.is_some_and(|value| !value.trim().is_empty())
&& reference.get("locatorDegraded").and_then(Value::as_bool) != Some(true)
});
let mut added = false;
for reference in references {
if out.len() >= 8 {
break;
}
let Some(_citation) = reference.get("citationMarkdown").and_then(Value::as_str) else {
continue;
};
if has_precise
&& reference.get("locatorDegraded").and_then(Value::as_bool) == Some(true)
{
continue;
}
let before = out.len();
add_citation_value(reference, seen, out);
added = added || out.len() > before;
}
added
}
fn visit(value: &Value, seen: &mut HashSet<String>, out: &mut Vec<Value>) {
if out.len() >= 8 {
return;
}
match value {
Value::String(text) => {
let trimmed = text.trim();
if (trimmed.starts_with('{') || trimmed.starts_with('['))
&& trimmed.contains("citationMarkdown")
{
if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
visit(&parsed, seen, out);
} else if let Some(first_line) = trimmed.lines().next() {
if let Ok(parsed) = serde_json::from_str::<Value>(first_line.trim()) {
visit(&parsed, seen, out);
}
}
}
}
Value::Array(items) => {
for item in items {
visit(item, seen, out);
if out.len() >= 8 {
break;
}
}
}
Value::Object(map) => {
let has_filtered_references = map
.get("references")
.and_then(Value::as_array)
.is_some_and(|references| add_reference_citations(references, seen, out));
if let Some(_citation) = map.get("citationMarkdown").and_then(Value::as_str) {
if !has_filtered_references {
add_citation_value(value, seen, out);
}
}
for (key, item) in map {
if has_filtered_references
&& matches!(key.as_str(), "references" | "citations" | "uiCitations")
{
continue;
}
visit(item, seen, out);
if out.len() >= 8 {
break;
}
}
}
_ => {}
}
}
let mut seen = HashSet::new();
let mut out = Vec::new();
visit(value, &mut seen, &mut out);
out
}
impl AcpRunBridge {
/// Create a new ACP run: create session + start prompt in background.
///
/// Returns a bridge with a broadcast receiver that the SSE endpoint can use.
pub async fn start(
runtime_mgr: &AcpRuntimeManager,
runtime_name: &str,
prompt_blocks: Vec<ContentBlock>,
) -> Result<Self, AcpBridgeError> {
// Get or activate the runtime
let client = if runtime_mgr.is_active().await {
runtime_mgr
.active_client()
.await
.ok_or(AcpBridgeError::NoActiveRuntime)?
} else {
runtime_mgr
.switch_to(runtime_name)
.await
.map_err(|e| AcpBridgeError::SessionError(e.to_string()))?
};
// Create session manager
let mgr = Arc::new(AcpSessionManager::new(client));
// Create event channel (256 buffered, enough for SSE streaming)
let (event_tx, _) = broadcast::channel(256);
let event_tx_clone = event_tx.clone();
// Set up event handler
mgr.on_event(move |event| {
if let Some(sse) = acp_event_to_sse(event) {
let _ = event_tx_clone.send(sse);
}
});
// Create session
let sid = mgr
.create_session(None, None)
.await
.map_err(|e| AcpBridgeError::SessionError(e.to_string()))?;
// Start prompt in background
let mgr_clone = mgr.clone();
let event_tx_prompt = event_tx.clone();
tokio::spawn(async move {
match mgr_clone.run_prompt(prompt_blocks).await {
Ok(result) => {
info!("ACP prompt completed: stop_reason={:?}", result.stop_reason);
let _ = event_tx_prompt.send(SseEvent {
event: "run.completed".into(),
data: json!({
"stopReason": format!("{:?}", result.stop_reason),
}),
});
}
Err(e) => {
warn!("ACP prompt failed: {e}");
let _ = event_tx_prompt.send(SseEvent {
event: "run.failed".into(),
data: json!({ "error": e.to_string() }),
});
}
}
});
info!("ACP run started: session={}", sid);
Ok(Self {
session_id: sid,
event_tx,
})
}
/// Cancel the current run.
pub async fn abort(&self) {
// Cancellation is sent via the session manager.
// For now, we just drop the bridge — the background task will detect this
// via the broadcast channel being closed.
info!("ACP run aborted: session={}", self.session_id);
}
/// Create an SSE response body from the event broadcast receiver.
pub fn into_sse_response(self) -> Response {
use tokio::sync::mpsc;
let (tx, rx) = mpsc::channel::<Result<axum::body::Bytes, std::convert::Infallible>>(256);
let mut broadcast_rx = self.event_tx.subscribe();
// Forward events from broadcast to mpsc
tokio::spawn(async move {
loop {
match broadcast_rx.recv().await {
Ok(event) => {
let json =
serde_json::to_string(&event.data).unwrap_or_else(|_| "{}".into());
let bytes = axum::body::Bytes::from(format!(
"event: {}\ndata: {}\n\n",
event.event, json
));
if tx.send(Ok(bytes)).await.is_err() {
break; // receiver dropped
}
}
Err(broadcast::error::RecvError::Lagged(n)) => {
warn!("ACP SSE lagged: {n} events dropped");
}
Err(broadcast::error::RecvError::Closed) => {
break; // stream ended
}
}
}
});
let stream = tokio_stream::wrappers::ReceiverStream::new(rx);
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/event-stream; charset=utf-8")
.header(header::CACHE_CONTROL, "no-cache, no-transform")
.header("x-accel-buffering", "no")
.body(Body::from_stream(stream))
.unwrap_or_else(|_| {
Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(Body::empty())
.unwrap()
})
}
}
/// Map an AcpSessionEvent to an SSE event for the frontend.
///
/// Reference: hermes-vscode-main protocol.ts extractTextContent/parseToolCall/parseToolCallUpdate
/// Reference: recycle/wolai-frontend bridge.ts HermesRunEvent type (historic)
pub fn acp_event_to_sse(event: AcpSessionEvent) -> Option<SseEvent> {
match event {
AcpSessionEvent::TextDelta { text } => Some(SseEvent {
event: "message.delta".into(),
data: json!({ "delta": text }),
}),
AcpSessionEvent::ThoughtDelta { text } => Some(SseEvent {
event: "thought.delta".into(),
data: json!({ "delta": text }),
}),
AcpSessionEvent::ToolCall {
tool_call_id,
title,
kind,
status,
raw_input,
locations,
} => Some(SseEvent {
event: "tool.started".into(),
data: json!({
"tool": title,
"toolCallId": tool_call_id,
"kind": kind,
"status": status,
"input": raw_input,
"locations": locations,
}),
}),
AcpSessionEvent::ToolCallUpdate {
tool_call_id,
status,
content,
} => {
let output = json!(content);
let citation_markdowns = collect_citation_markdowns_from_value(&output);
let error = status == crate::acp_types::ToolCallStatus::Failed;
let event = if error {
"tool.failed"
} else if status == crate::acp_types::ToolCallStatus::Completed {
"tool.completed"
} else {
"tool.started"
};
Some(SseEvent {
event: event.into(),
data: json!({
"toolCallId": tool_call_id,
"status": status,
"error": error,
"output": output,
"citationMarkdowns": citation_markdowns,
}),
})
}
AcpSessionEvent::UsageUpdate { used, size } => Some(SseEvent {
event: "usage.updated".into(),
data: json!({ "used": used, "size": size }),
}),
AcpSessionEvent::PermissionRequest {
permission_id,
tool_name,
params,
decision,
} => {
let event = match decision.as_str() {
"allowed" => "permission.allowed",
"requested" => "permission.requested",
_ => "permission.denied",
};
Some(SseEvent {
event: event.into(),
data: json!({
"permissionId": permission_id,
"toolName": tool_name,
"params": params,
"decision": decision,
}),
})
}
AcpSessionEvent::SessionInfoUpdate { title } => Some(SseEvent {
event: "session.info.updated".into(),
data: json!({ "title": title }),
}),
AcpSessionEvent::ProviderConversationBound {
provider,
remote_conversation_id,
remote_url,
acp_session_id,
} => Some(SseEvent {
event: "provider.conversation.bound".into(),
data: json!({
"provider": provider,
"remoteConversationId": remote_conversation_id,
"remoteUrl": remote_url,
"acpSessionId": acp_session_id,
}),
}),
AcpSessionEvent::PlanUpdate { entries } => Some(SseEvent {
event: "plan.updated".into(),
data: json!({ "entries": entries }),
}),
AcpSessionEvent::Disconnected { reason } if reason == "session closed" => None,
AcpSessionEvent::Disconnected { reason } => Some(SseEvent {
event: "run.failed".into(),
data: json!({ "error": reason }),
}),
}
}
/// Helper: get the runtime name from a profile.
/// For now, we use "hermes" or "reasonix" directly.
/// In Step 12, this will come from the profile config.
pub fn runtime_name_for_profile(profile: &str) -> &str {
match profile {
"reasonix" => "reasonix",
_ => "hermes",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn acp_normal_session_close_does_not_emit_failure() {
let event = AcpSessionEvent::Disconnected {
reason: "session closed".into(),
};
assert!(acp_event_to_sse(event).is_none());
}
#[test]
fn acp_unexpected_disconnect_emits_failure() {
let event = AcpSessionEvent::Disconnected {
reason: "transport lost".into(),
};
let sse = acp_event_to_sse(event).expect("unexpected disconnect should be forwarded");
assert_eq!(sse.event, "run.failed");
assert_eq!(sse.data["error"], "transport lost");
}
#[test]
fn acp_thought_delta_does_not_emit_message_delta() {
let event = AcpSessionEvent::ThoughtDelta {
text: "internal reasoning".into(),
};
let sse = acp_event_to_sse(event).expect("thought delta should be forwarded separately");
assert_eq!(sse.event, "thought.delta");
assert_eq!(sse.data["delta"], "internal reasoning");
}
#[test]
fn acp_permission_request_emits_frontend_decision_event() {
let event = AcpSessionEvent::PermissionRequest {
permission_id: "perm_1".into(),
tool_name: "mnote.page.save".into(),
params: json!({"documentId": "doc_1"}),
decision: "denied".into(),
};
let sse = acp_event_to_sse(event).expect("permission decision should be forwarded");
assert_eq!(sse.event, "permission.denied");
assert_eq!(sse.data["permissionId"], "perm_1");
assert_eq!(sse.data["toolName"], "mnote.page.save");
assert_eq!(sse.data["params"]["documentId"], "doc_1");
assert_eq!(sse.data["decision"], "denied");
}
#[test]
fn acp_permission_requested_emits_permission_requested_event() {
let event = AcpSessionEvent::PermissionRequest {
permission_id: "perm_2".into(),
tool_name: "mnote.page.get".into(),
params: json!({"documentId": "doc_2"}),
decision: "requested".into(),
};
let sse = acp_event_to_sse(event).expect("permission requested should be forwarded");
assert_eq!(sse.event, "permission.requested");
assert_eq!(sse.data["permissionId"], "perm_2");
assert_eq!(sse.data["toolName"], "mnote.page.get");
assert_eq!(sse.data["decision"], "requested");
}
#[test]
fn acp_permission_allowed_emits_permission_allowed_event() {
let event = AcpSessionEvent::PermissionRequest {
permission_id: "perm_3".into(),
tool_name: "mnote.page.save".into(),
params: json!({"documentId": "doc_1"}),
decision: "allowed".into(),
};
let sse = acp_event_to_sse(event).expect("permission allowed should be forwarded");
assert_eq!(sse.event, "permission.allowed");
assert_eq!(sse.data["permissionId"], "perm_3");
assert_eq!(sse.data["decision"], "allowed");
}
#[test]
fn acp_tool_events_keep_detail_for_collapsible_ui() {
let started = acp_event_to_sse(AcpSessionEvent::ToolCall {
tool_call_id: "tool_1".into(),
title: "mnote.page.get".into(),
kind: "read".into(),
status: crate::acp_types::ToolCallStatus::InProgress,
raw_input: Some(json!({"documentId": "doc_1", "includeBody": true})),
locations: vec!["/mnt/Data1T/mnote/src/main.rs".into()],
})
.expect("tool start");
assert_eq!(started.event, "tool.started");
assert_eq!(started.data["tool"], "mnote.page.get");
assert_eq!(started.data["status"], "in_progress");
assert_eq!(started.data["input"]["documentId"], "doc_1");
assert_eq!(
started.data["locations"][0],
"/mnt/Data1T/mnote/src/main.rs"
);
assert_eq!(started.data["locations"].as_array().unwrap().len(), 1);
let completed = acp_event_to_sse(AcpSessionEvent::ToolCallUpdate {
tool_call_id: "tool_1".into(),
status: crate::acp_types::ToolCallStatus::Completed,
content: Some(vec![crate::acp_types::ContentBlockWrapper {
wrapper_type: "content".into(),
content: crate::acp_types::TextContent {
content_type: "text".into(),
text: "读取完成".into(),
},
}]),
})
.expect("tool complete");
assert_eq!(completed.event, "tool.completed");
assert_eq!(completed.data["status"], "completed");
assert_eq!(completed.data["output"][0]["content"]["text"], "读取完成");
let running = acp_event_to_sse(AcpSessionEvent::ToolCallUpdate {
tool_call_id: "tool_1".into(),
status: crate::acp_types::ToolCallStatus::InProgress,
content: None,
})
.expect("tool running");
assert_eq!(running.event, "tool.started");
assert_eq!(running.data["status"], "in_progress");
}
#[test]
fn acp_tool_completed_extracts_precise_ui_citations_from_prefixed_text() {
let prefix = json!({
"schema": "mnote.acp.tool_result_ui_citations.v1",
"references": [{
"citationMarkdown": "[来源定位降级:a.md](/documents/a)",
"locatorDegraded": true
}, {
"citationMarkdown": "[b.md · p.2](/documents/b?page=2)",
"locatorDegraded": false
}],
"uiCitations": [{
"citationMarkdown": "[来源定位降级:a.md](/documents/a)"
}]
})
.to_string();
let completed = acp_event_to_sse(AcpSessionEvent::ToolCallUpdate {
tool_call_id: "tool_2".into(),
status: crate::acp_types::ToolCallStatus::Completed,
content: Some(vec![crate::acp_types::ContentBlockWrapper {
wrapper_type: "content".into(),
content: crate::acp_types::TextContent {
content_type: "text".into(),
text: format!("{prefix}\n工具正文"),
},
}]),
})
.expect("tool complete");
assert_eq!(
completed.data["citationMarkdowns"][0]["citationMarkdown"].as_str(),
Some("[b.md · p.2](/documents/b?page=2)")
);
assert_eq!(
completed.data["citationMarkdowns"]
.as_array()
.unwrap()
.len(),
1
);
}
#[test]
fn acp_session_info_update_emits_session_info_updated_sse() {
let sse = acp_event_to_sse(AcpSessionEvent::SessionInfoUpdate {
title: "我的新会话标题".into(),
})
.expect("session info update should be forwarded");
assert_eq!(sse.event, "session.info.updated");
assert_eq!(sse.data["title"], "我的新会话标题");
}
#[test]
fn acp_plan_update_emits_plan_updated_sse() {
let entries = vec![
"步骤 1:读取文件".into(),
"步骤 2:修改配置".into(),
"步骤 3:验证更改".into(),
];
let sse = acp_event_to_sse(AcpSessionEvent::PlanUpdate {
entries: entries.clone(),
})
.expect("plan update should be forwarded");
assert_eq!(sse.event, "plan.updated");
let sse_entries: Vec<String> =
serde_json::from_value(sse.data["entries"].clone()).unwrap_or_default();
assert_eq!(sse_entries.len(), 3);
assert_eq!(sse_entries[0], "步骤 1:读取文件");
assert_eq!(sse_entries[1], "步骤 2:修改配置");
assert_eq!(sse_entries[2], "步骤 3:验证更改");
}
}
-755
View File
@@ -1,755 +0,0 @@
/// ACP (Agent Client Protocol) JSON-RPC 2.0 client.
///
/// Walks an agent runtime subprocess (e.g. `hermes acp` or `node reasonix-acp-wrapper.mjs`)
/// over NDJSON stdio: one JSON object per line, newline-delimited.
///
/// Reference implementations:
/// - `reference-code/hermes-vscode-main/src/acpClient.ts` (primary reference)
/// - `reference-code/DeepSeek-Reasonix-main/src/acp/protocol.ts`
///
/// Wire format:
/// Request: { jsonrpc: "2.0", id: number, method: string, params?: object }
/// Response: { jsonrpc: "2.0", id: number, result?: any, error?: { code, message } }
/// Notification:{ jsonrpc: "2.0", method: string, params?: object } (no id)
/// Incoming: { jsonrpc: "2.0", id: number, method: string, params?: object } (from agent)
use serde::de::DeserializeOwned;
use serde::Serialize;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter};
use tokio::process::{Child, ChildStdin, ChildStdout, Command};
use tokio::sync::{oneshot, Mutex};
use tokio::time::{timeout, Duration};
use tracing::{debug, info, warn};
// ── Error types ──────────────────────────────────────
#[derive(Debug)]
pub enum AcpError {
Spawn(std::io::Error),
JsonParse(serde_json::Error),
JsonRpc { code: i64, message: String },
Timeout(u64),
Closed,
Internal(String),
}
impl std::fmt::Display for AcpError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AcpError::Spawn(e) => write!(f, "ACP spawn failed: {e}"),
AcpError::JsonParse(e) => write!(f, "ACP JSON parse error: {e}"),
AcpError::JsonRpc { code, message } => {
write!(f, "ACP JSON-RPC error [{code}]: {message}")
}
AcpError::Timeout(secs) => write!(f, "ACP request timed out after {secs}s"),
AcpError::Closed => write!(f, "ACP connection closed"),
AcpError::Internal(msg) => write!(f, "ACP internal: {msg}"),
}
}
}
impl std::error::Error for AcpError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
AcpError::Spawn(e) => Some(e),
AcpError::JsonParse(e) => Some(e),
_ => None,
}
}
}
impl From<std::io::Error> for AcpError {
fn from(e: std::io::Error) -> Self {
AcpError::Spawn(e)
}
}
impl From<serde_json::Error> for AcpError {
fn from(e: serde_json::Error) -> Self {
AcpError::JsonParse(e)
}
}
// ── Notification handler type ────────────────────────
type NotificationHandler = Box<dyn Fn(String, Value) + Send + 'static>;
/// Thread-safe mutex for notification handler (std mutex — lightweight, never held across awaits).
type NotificationHandlerMutex = std::sync::Mutex<Option<NotificationHandler>>;
// ── Incoming request handler type ────────────────────
///
/// agent 发送 JSON-RPC request(同时包含 `id` 与 `method`)时调用。
/// 返回 `true` 表示 handler 已负责稍后响应;返回 `false` 则由 dispatch_message
/// 直接回复 method-not-found。handler 应通过 [`AcpClient::respond_to_incoming`]
/// 或 [`AcpClient::respond_to_incoming_error`] 回写响应。
type IncomingRequestHandler = Box<dyn Fn(Value, String, Value) -> bool + Send + 'static>;
/// incoming request handler 的线程安全容器。
type IncomingRequestHandlerMutex = std::sync::Mutex<Option<IncomingRequestHandler>>;
// ── Pending request entry ────────────────────────────
type PendingEntry = oneshot::Sender<Result<Value, AcpError>>;
// ── AcpClient ────────────────────────────────────────
/// ACP JSON-RPC 2.0 client over stdio.
///
/// Create via [`AcpClient::spawn`], then use [`request`](Self::request) for RPC
/// calls and [`notification`](Self::notification) for fire-and-forget messages.
/// Register a handler with [`on_notification`](Self::on_notification) to receive
/// agent push events (e.g. `session/update`).
// Manual Debug impl: Child doesn't impl Debug, so we skip it
impl std::fmt::Debug for AcpClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AcpClient")
.field("next_id", &self.next_id)
.field("pending_count", &self.pending.blocking_lock().len())
.finish_non_exhaustive()
}
}
pub struct AcpClient {
child: Option<Child>,
writer: Arc<Mutex<BufWriter<ChildStdin>>>,
pending: Arc<Mutex<HashMap<u64, PendingEntry>>>,
next_id: AtomicU64,
notification_handler: Arc<NotificationHandlerMutex>,
/// agent 发来的 incoming JSON-RPC request handler(同时有 id 和 method)。
/// 未设置时会直接回复 method-not-found。
incoming_request_handler: Arc<IncomingRequestHandlerMutex>,
}
impl AcpClient {
/// Spawn an ACP subprocess and establish the JSON-RPC connection.
///
/// After spawn, sends an `initialize` handshake (as Hermes does in acpClient.ts
/// `start()` → `call('initialize', { protocolVersion: 1 })`).
/// Launches a background tokio task that reads NDJSON lines from the child's stdout.
///
/// Reference: `hermes-vscode-main/src/acpClient.ts` L50-80 (spawn + stdio setup)
pub async fn spawn(bin: &str, args: &[&str]) -> Result<Self, AcpError> {
Self::spawn_with_env(bin, args, None).await
}
/// Spawn an ACP subprocess with extra environment variables.
pub async fn spawn_with_env(
bin: &str,
args: &[&str],
env_overrides: Option<&HashMap<String, String>>,
) -> Result<Self, AcpError> {
let mut command = Command::new(bin);
command
.args(args)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::inherit())
.kill_on_drop(true);
if let Some(env) = env_overrides {
if let Some(workspace_root) = env.get("MNOTE_AI_WORKSPACE_ROOT") {
let workspace_root = std::path::Path::new(workspace_root);
if workspace_root.is_dir() {
// 本地 workspace run 以授权根目录作为进程工作目录,贴近 VSCode agent 行为。
command.current_dir(workspace_root);
}
}
command.envs(env);
}
let mut child = command.spawn().map_err(AcpError::Spawn)?;
let stdin = child
.stdin
.take()
.ok_or_else(|| AcpError::Internal("failed to take child stdin".into()))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| AcpError::Internal("failed to take child stdout".into()))?;
let writer = Arc::new(Mutex::new(BufWriter::new(stdin)));
let reader = BufReader::new(stdout);
let pending: Arc<Mutex<HashMap<u64, PendingEntry>>> = Arc::new(Mutex::new(HashMap::new()));
let notification_handler: Arc<NotificationHandlerMutex> =
Arc::new(std::sync::Mutex::new(None));
let incoming_request_handler: Arc<IncomingRequestHandlerMutex> =
Arc::new(std::sync::Mutex::new(None));
// Start background reader task
let pending_clone = pending.clone();
let handler_clone = notification_handler.clone();
let incoming_clone = incoming_request_handler.clone();
let writer_clone = writer.clone();
let child_pid = child.id().unwrap_or(0);
tokio::spawn(async move {
Self::reader_loop(
reader,
writer_clone,
pending_clone,
handler_clone,
incoming_clone,
)
.await;
info!("ACP reader loop ended (pid={})", child_pid);
});
let client = Self {
child: Some(child),
writer,
pending,
next_id: AtomicU64::new(1),
notification_handler,
incoming_request_handler,
};
// Handshake: initialize (reference: acpClient.ts L108 → call('initialize', {protocolVersion: 1}))
let init_result: Value = client
.request("initialize", json!({ "protocolVersion": 1 }))
.await?;
debug!(?init_result, "ACP initialize OK");
Ok(client)
}
/// Send a JSON-RPC request and await the response.
///
/// Returns `Result<R>` where `R` is the deserialized `result` field.
/// On JSON-RPC error, returns [`AcpError::JsonRpc`].
/// Default timeout: 300 seconds. Override with `MNOTE_ACP_REQUEST_TIMEOUT_SECS`.
///
/// Reference: `acpClient.ts` L95-110 (`call()` method)
pub async fn request<P: Serialize, R: DeserializeOwned>(
&self,
method: &str,
params: P,
) -> Result<R, AcpError> {
let timeout_secs = std::env::var("MNOTE_ACP_REQUEST_TIMEOUT_SECS")
.ok()
.and_then(|value| value.parse::<u64>().ok())
.filter(|value| *value >= 30)
.unwrap_or(300);
self.request_with_timeout(method, params, Duration::from_secs(timeout_secs))
.await
}
/// Same as [`request`] but with a configurable timeout.
pub async fn request_with_timeout<P: Serialize, R: DeserializeOwned>(
&self,
method: &str,
params: P,
dur: Duration,
) -> Result<R, AcpError> {
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let (tx, rx) = oneshot::channel();
self.pending.lock().await.insert(id, tx);
let req = json!({
"jsonrpc": "2.0",
"id": id,
"method": method,
"params": params,
});
let line = serde_json::to_string(&req)?;
debug!("ACP --> {} #{} ({} bytes)", method, id, line.len());
{
let mut writer = self.writer.lock().await;
writer.write_all(line.as_bytes()).await?;
writer.write_all(b"\n").await?;
writer.flush().await?;
}
match timeout(dur, rx).await {
Ok(Ok(Ok(value))) => {
let result: R = serde_json::from_value(value)?;
Ok(result)
}
Ok(Ok(Err(err))) => Err(err),
Ok(Err(_recv_err)) => Err(AcpError::Closed),
Err(_elapsed) => Err(AcpError::Timeout(dur.as_secs())),
}
}
/// Send a fire-and-forget notification (no id, no response expected).
///
/// Reference: `acpClient.ts` L115-118 (`notify()`)
pub async fn notification(&self, method: &str, params: Value) -> Result<(), AcpError> {
let msg = json!({
"jsonrpc": "2.0",
"method": method,
"params": params,
});
let line = serde_json::to_string(&msg)?;
debug!("ACP ~~> {} ({} bytes)", method, line.len());
{
let mut writer = self.writer.lock().await;
writer.write_all(line.as_bytes()).await?;
writer.write_all(b"\n").await?;
writer.flush().await?;
}
Ok(())
}
/// Register a handler for incoming notifications (messages with `method` but no `id`).
/// Only one handler at a time — subsequent calls replace the previous.
pub fn on_notification<F>(&self, handler: F)
where
F: Fn(String, Value) + Send + 'static,
{
let mut guard = self.notification_handler.lock().unwrap();
*guard = Some(Box::new(handler));
}
/// 注册 incoming JSON-RPC request handler(消息同时包含 `id` 与 `method`)。
/// handler 接收原始 request id、method 和 params,并应稍后通过
/// [`respond_to_incoming`] 或 [`respond_to_incoming_error`] 响应。
/// 同一时间只保留一个 handler,后续注册会覆盖前一个。
pub fn on_incoming_request<F>(&self, handler: F)
where
F: Fn(Value, String, Value) -> bool + Send + 'static,
{
let mut guard = self.incoming_request_handler.lock().unwrap();
*guard = Some(Box::new(handler));
}
/// 用 result 响应 agent 发来的 incoming JSON-RPC request。
///
/// 必须使用 incoming request handler 收到的原始 `id`,避免丢失字符串 id。
pub async fn respond_to_incoming(&self, id: Value, result: Value) -> Result<(), AcpError> {
let msg = json!({
"jsonrpc": "2.0",
"id": id.clone(),
"result": result,
});
debug!("ACP <-- respond to incoming #{}", id);
Self::write_jsonrpc_message(&self.writer, &msg).await
}
/// 用 error 响应 incoming JSON-RPC request。
pub async fn respond_to_incoming_error(
&self,
id: Value,
code: i64,
message: &str,
) -> Result<(), AcpError> {
let msg = json!({
"jsonrpc": "2.0",
"id": id.clone(),
"error": {
"code": code,
"message": message,
},
});
debug!(
"ACP <-- respond error to incoming #{}: [{}] {}",
id, code, message
);
Self::write_jsonrpc_message(&self.writer, &msg).await
}
/// Gracefully close the ACP connection and kill the subprocess.
pub async fn close(&mut self) {
if let Some(mut child) = self.child.take() {
let _ = child.start_kill();
let _ = child.wait().await;
}
// Resolve all pending with Closed error
let mut pending = self.pending.lock().await;
for (_, tx) in pending.drain() {
let _ = tx.send(Err(AcpError::Closed));
}
}
// ── Background reader ────────────────────────────
/// Background loop: reads NDJSON lines from the child's stdout,
/// routes responses to pending requests and notifications to the handler.
///
/// Reference: `acpClient.ts` L120-180 (onData + dispatch)
async fn reader_loop(
mut reader: BufReader<ChildStdout>,
writer: Arc<Mutex<BufWriter<ChildStdin>>>,
pending: Arc<Mutex<HashMap<u64, PendingEntry>>>,
notification_handler: Arc<NotificationHandlerMutex>,
incoming_request_handler: Arc<IncomingRequestHandlerMutex>,
) {
let mut line_buf = String::new();
loop {
line_buf.clear();
match reader.read_line(&mut line_buf).await {
Ok(0) => {
info!("ACP stdout closed (EOF)");
break;
}
Ok(_n) => {}
Err(e) => {
warn!("ACP read error: {e}");
break;
}
}
let trimmed = line_buf.trim();
if trimmed.is_empty() {
continue;
}
let msg: Value = match serde_json::from_str(trimmed) {
Ok(v) => v,
Err(e) => {
warn!(
"ACP parse error: {e} (line: {})",
&trimmed[..trimmed.len().min(80)]
);
continue;
}
};
Self::dispatch_message(
msg,
&writer,
&pending,
&notification_handler,
&incoming_request_handler,
)
.await;
}
// Process died or EOF — resolve all pending
let mut pending_guard = pending.lock().await;
for (_, tx) in pending_guard.drain() {
let _ = tx.send(Err(AcpError::Closed));
}
}
/// Route a single JSON message to pending request, notification handler, or incoming request.
///
/// Reference: `acpClient.ts` L160-200 (dispatch)
async fn dispatch_message(
msg: Value,
writer: &Arc<Mutex<BufWriter<ChildStdin>>>,
pending: &Arc<Mutex<HashMap<u64, PendingEntry>>>,
notification_handler: &Arc<NotificationHandlerMutex>,
incoming_request_handler: &Arc<IncomingRequestHandlerMutex>,
) {
let has_id = msg.get("id").is_some();
let has_method = msg
.get("method")
.and_then(|v| v.as_str())
.map(|s| !s.is_empty())
.unwrap_or(false);
if has_id && has_method {
// agent 发来的 incoming request,例如 session/request_permission。
let method = msg["method"].as_str().unwrap_or("unknown").to_string();
let params = msg.get("params").cloned().unwrap_or(Value::Null);
let id_val = msg.get("id").cloned().unwrap_or(Value::Null);
// 若已注册 handler,则由 handler 决定是否负责稍后响应。
let handled = {
let handler_guard = incoming_request_handler.lock().unwrap();
if let Some(ref handler) = *handler_guard {
handler(id_val.clone(), method.clone(), params.clone())
} else {
false
}
};
if handled {
debug!("ACP incoming request dispatched: {method} #{}", id_val);
} else {
// 没有 handler 时必须立即响应,避免 agent 一直等待。
warn!("ACP incoming request not handled (no handler registered): {method}");
let response = json!({
"jsonrpc": "2.0",
"id": id_val,
"error": {
"code": -32601,
"message": format!("ACP incoming request not supported: {method}")
}
});
if let Err(error) = Self::write_jsonrpc_message(writer, &response).await {
warn!("ACP incoming request response write failed: {error}");
}
}
} else if has_id {
// Response to one of our requests
if let Some(id) = msg["id"].as_u64() {
let mut pending_guard = pending.lock().await;
if let Some(tx) = pending_guard.remove(&id) {
if let Some(error) = msg.get("error") {
let code = error["code"].as_i64().unwrap_or(-1);
let message = error["message"]
.as_str()
.unwrap_or("unknown error")
.to_string();
let _ = tx.send(Err(AcpError::JsonRpc { code, message }));
} else if let Some(result) = msg.get("result") {
let _ = tx.send(Ok(result.clone()));
} else {
let _ = tx.send(Err(AcpError::Internal(
"response without result or error".into(),
)));
}
} else {
debug!("ACP response for unknown request id={id}");
}
}
} else if has_method {
// Notification (no id)
let method = msg["method"].as_str().unwrap_or("unknown").to_string();
let params = msg.get("params").cloned().unwrap_or(Value::Null);
let handler_guard = notification_handler.lock().unwrap();
if let Some(ref handler) = *handler_guard {
handler(method, params);
} else {
debug!("ACP notification unhandled: {method}");
}
}
}
async fn write_jsonrpc_message(
writer: &Arc<Mutex<BufWriter<ChildStdin>>>,
msg: &Value,
) -> Result<(), AcpError> {
let line = serde_json::to_string(msg)?;
let mut writer = writer.lock().await;
writer.write_all(line.as_bytes()).await?;
writer.write_all(b"\n").await?;
writer.flush().await?;
Ok(())
}
}
impl Drop for AcpClient {
fn drop(&mut self) {
if let Some(mut child) = self.child.take() {
let _ = child.start_kill();
}
}
}
// ── Tests ────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
/// Helper: create a mock subprocess that echoes back requests as responses.
/// Simulates a minimal ACP server for testing.
async fn spawn_mock_acp_server() -> AcpClient {
// We spawn a small node script that reads NDJSON and echoes back
let script = r#"
import * as readline from 'node:readline';
import { stdin as input, stdout as output } from 'node:process';
const rl = readline.createInterface({ input, output, terminal: false });
rl.on('line', (line) => {
const msg = JSON.parse(line);
if (msg.id !== undefined && msg.method) {
if (msg.method === 'initialize') {
process.stdout.write(JSON.stringify({
jsonrpc: '2.0',
id: msg.id,
result: { protocolVersion: 1, agentCapabilities: {}, agentInfo: { name: 'mock', version: '1.0' }, authMethods: [] }
}) + '\n');
} else {
process.stdout.write(JSON.stringify({
jsonrpc: '2.0',
id: msg.id,
result: { ok: true, echo: msg.params }
}) + '\n');
}
} else if (msg.method && msg.id === undefined) {
// Notification → ignore
}
});
"#;
// Write script to temp file
let dir = std::env::temp_dir();
let script_path = dir.join("acp_test_mock.mjs");
std::fs::write(&script_path, script).expect("write mock script");
AcpClient::spawn("node", &[script_path.to_str().unwrap()])
.await
.expect("spawn mock ACP")
}
async fn spawn_permission_request_mock_server() -> AcpClient {
let script = r#"
import * as readline from 'node:readline';
import { stdin as input, stdout as output } from 'node:process';
let permissionResponse = null;
const rl = readline.createInterface({ input, output, terminal: false });
function send(msg) { process.stdout.write(JSON.stringify(msg) + '\n'); }
rl.on('line', (line) => {
const msg = JSON.parse(line);
if (msg.id !== undefined && msg.method === 'initialize') {
send({
jsonrpc: '2.0',
id: msg.id,
result: { protocolVersion: 1, agentCapabilities: {}, agentInfo: { name: 'mock', version: '1.0' }, authMethods: [] }
});
setTimeout(() => send({
jsonrpc: '2.0',
id: 77,
method: 'session/request_permission',
params: { reason: 'test permission' }
}), 10);
} else if (msg.id === 77 && msg.method === undefined) {
permissionResponse = msg;
} else if (msg.id !== undefined && msg.method === 'get_permission_response') {
send({
jsonrpc: '2.0',
id: msg.id,
result: { permissionResponse }
});
}
});
"#;
let dir = std::env::temp_dir();
let script_path = dir.join("acp_permission_request_mock.mjs");
std::fs::write(&script_path, script).expect("write permission mock script");
AcpClient::spawn("node", &[script_path.to_str().unwrap()])
.await
.expect("spawn permission mock ACP")
}
async fn spawn_string_id_permission_request_mock_server() -> AcpClient {
let script = r#"
import * as readline from 'node:readline';
import { stdin as input, stdout as output } from 'node:process';
let permissionResponse = null;
const rl = readline.createInterface({ input, output, terminal: false });
function send(msg) { process.stdout.write(JSON.stringify(msg) + '\n'); }
rl.on('line', (line) => {
const msg = JSON.parse(line);
if (msg.id !== undefined && msg.method === 'initialize') {
send({
jsonrpc: '2.0',
id: msg.id,
result: { protocolVersion: 1, agentCapabilities: {}, agentInfo: { name: 'mock', version: '1.0' }, authMethods: [] }
});
setTimeout(() => send({
jsonrpc: '2.0',
id: 'perm-string-id',
method: 'session/request_permission',
params: { reason: 'test permission' }
}), 10);
} else if (msg.id === 'perm-string-id' && msg.method === undefined) {
permissionResponse = msg;
} else if (msg.id !== undefined && msg.method === 'get_permission_response') {
send({
jsonrpc: '2.0',
id: msg.id,
result: { permissionResponse }
});
}
});
"#;
let dir = std::env::temp_dir();
let script_path = dir.join("acp_permission_request_string_id_mock.mjs");
std::fs::write(&script_path, script).expect("write permission string id mock script");
AcpClient::spawn("node", &[script_path.to_str().unwrap()])
.await
.expect("spawn permission string id mock ACP")
}
#[tokio::test]
async fn test_request_response() {
let client = spawn_mock_acp_server().await;
let result: Value = client
.request("test_method", json!({ "hello": "world" }))
.await
.expect("request should succeed");
assert_eq!(result["ok"], true);
assert_eq!(result["echo"]["hello"], "world");
}
#[tokio::test]
async fn test_notification() {
let client = spawn_mock_acp_server().await;
// Notifications are fire-and-forget, no response expected
client
.notification("test_notify", json!({ "foo": "bar" }))
.await
.expect("notification should succeed");
}
#[tokio::test]
async fn test_on_notification_received() {
use std::sync::atomic::AtomicBool;
let client = spawn_mock_acp_server().await;
let received = Arc::new(AtomicBool::new(false));
let received_clone = received.clone();
client.on_notification(move |method, _params| {
if method == "test_push" {
received_clone.store(true, Ordering::SeqCst);
}
});
// Send a notification that the mock server will echo back as...
// Actually the mock doesn't send unsolicited notifications.
// This test just validates the handler registration doesn't crash.
client
.notification("test_push", json!({}))
.await
.expect("notification");
// Give background task time to process
tokio::time::sleep(Duration::from_millis(100)).await;
// In this mock, no notification will be received; that's OK
}
#[tokio::test]
async fn test_close() {
let mut client = spawn_mock_acp_server().await;
client.close().await;
// Second close should be no-op
client.close().await;
}
#[tokio::test]
async fn test_initialize_handshake() {
// spawn already calls initialize; if it fails, the test fails
let _client = spawn_mock_acp_server().await;
}
#[tokio::test]
async fn test_incoming_permission_request_gets_response() {
let client = spawn_permission_request_mock_server().await;
tokio::time::sleep(Duration::from_millis(100)).await;
let result: Value = client
.request("get_permission_response", json!({}))
.await
.expect("permission response probe");
let response = &result["permissionResponse"];
assert_eq!(response["jsonrpc"], "2.0");
assert_eq!(response["id"], 77);
assert!(response.get("result").is_some() || response.get("error").is_some());
}
#[tokio::test]
async fn test_incoming_permission_request_preserves_string_id() {
let client = spawn_string_id_permission_request_mock_server().await;
tokio::time::sleep(Duration::from_millis(100)).await;
let result: Value = client
.request("get_permission_response", json!({}))
.await
.expect("permission response probe");
let response = &result["permissionResponse"];
assert_eq!(response["jsonrpc"], "2.0");
assert_eq!(response["id"], "perm-string-id");
assert!(response.get("result").is_some() || response.get("error").is_some());
}
}
-391
View File
@@ -1,391 +0,0 @@
/// ACP Runtime Manager — manages agent runtime subprocess lifecycle.
///
/// Supports multiple runtimes (Hermes, Reasonix) and switching between them.
/// Each runtime is spawned as a subprocess communicating via the ACP JSON-RPC 2.0 protocol.
///
/// Configuration:
/// `MNOTE_WEB_ACP_DEFAULT_RUNTIME` — default runtime name ("hermes" | "reasonix")
/// `MNOTE_WEB_HERMES_BIN` — path to Hermes binary (default: "hermes")
/// `MNOTE_WEB_HERMES_ACP_PROFILE` — Hermes profile for ACP runtime (default: "default")
/// `MNOTE_WEB_REASONIX_WRAPPER` — path to Reasonix wrapper script (default: "scripts/reasonix-acp-wrapper.mjs")
///
/// Or via JSON env var:
/// `MNOTE_WEB_ACP_RUNTIMES` — JSON array of runtime configs
use crate::acp_client::{AcpClient, AcpError};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::env;
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::time::{timeout, Duration};
use tracing::{debug, info, warn};
// ── Config ───────────────────────────────────────────
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AcpRuntimeConfig {
/// Display name (e.g. "hermes", "reasonix").
pub name: String,
/// Binary path (e.g. "hermes", "node").
pub bin: String,
/// Command arguments (e.g. ["acp"], ["scripts/reasonix-acp-wrapper.mjs"]).
#[serde(default)]
pub args: Vec<String>,
/// Extra environment variables.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub env: Option<HashMap<String, String>>,
/// Human-readable title for the runtime selector.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
}
impl AcpRuntimeConfig {
/// Create a Hermes ACP runtime config.
pub fn hermes(bin: Option<&str>, profile: Option<&str>) -> Self {
let profile = profile.unwrap_or("default").trim();
let args = if profile.is_empty() {
vec!["acp".into()]
} else {
vec!["-p".into(), profile.to_string(), "acp".into()]
};
Self {
name: "hermes".into(),
bin: bin.unwrap_or("hermes").to_string(),
args,
env: None,
title: Some("Hermes".into()),
}
}
/// Create a Reasonix ACP runtime config.
/// `wrapper_path` is relative to the project root (where Cargo.toml's parent is).
/// Default: `design/05-editor-mainline/reference-code/DeepSeek-Reasonix-main/scripts/reasonix-acp-wrapper.mjs` (dev),
/// or in production, the absolute path is resolved via `CARGO_MANIFEST_DIR` (the `rust/` directory).
pub fn reasonix(wrapper_path: Option<&str>) -> Self {
// CARGO_MANIFEST_DIR is the directory containing this crate's Cargo.toml:
// /mnt/Data1T/mnote/rust/crates/mnote-web/
// We need the project root: /mnt/Data1T/mnote/
let project_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.nth(3) // up: mnote-web/ → crates/ → rust/ → mnote/ (project root)
.unwrap_or(std::path::Path::new("/mnt/Data1T/mnote"))
.to_string_lossy()
.to_string();
let default_path = format!("{project_root}/scripts/reasonix-acp-wrapper.mjs");
// Resolve wrapper_path: if it's relative, prepend project_root; absolute paths used as-is
let resolved_path = wrapper_path
.map(|p| {
if p.starts_with('/') {
p.to_string()
} else {
format!("{project_root}/{p}")
}
})
.unwrap_or(default_path);
Self {
name: "reasonix".into(),
bin: "node".into(),
args: vec![resolved_path],
env: None,
title: Some("Reasonix".into()),
}
}
}
// ── Runtime Manager ──────────────────────────────────
/// Manages lifecycle of multiple agent runtimes.
///
/// Each runtime is defined by a name and spawn configuration.
/// At most one runtime is "active" at a time, providing an [`AcpClient`].
#[derive(Debug)]
pub struct AcpRuntimeManager {
runtimes: HashMap<String, AcpRuntimeConfig>,
active: Mutex<Option<ActiveRuntime>>,
default_runtime: String,
}
#[derive(Debug)]
struct ActiveRuntime {
config: AcpRuntimeConfig,
client: Arc<AcpClient>,
}
impl AcpRuntimeManager {
/// Create a new runtime manager with built-in default configurations.
///
/// Reads environment variables to configure Hermes and Reasonix runtimes.
/// Default active runtime is set by `MNOTE_WEB_ACP_DEFAULT_RUNTIME` (default: "reasonix").
pub fn from_env() -> Self {
let mut runtimes: HashMap<String, AcpRuntimeConfig> = HashMap::new();
// Check for JSON-based configuration first
if let Ok(json) = env::var("MNOTE_WEB_ACP_RUNTIMES") {
if let Ok(custom_runtimes) = serde_json::from_str::<Vec<AcpRuntimeConfig>>(&json) {
for rt in custom_runtimes {
let name = rt.name.clone();
runtimes.insert(name, rt);
}
} else {
warn!("Failed to parse MNOTE_WEB_ACP_RUNTIMES JSON");
}
}
// Always add default Hermes if not already configured
if !runtimes.contains_key("hermes") {
let hermes_bin = env::var("MNOTE_WEB_HERMES_BIN").unwrap_or_else(|_| "hermes".into());
let hermes_profile =
env::var("MNOTE_WEB_HERMES_ACP_PROFILE").unwrap_or_else(|_| "default".into());
runtimes.insert(
"hermes".into(),
AcpRuntimeConfig::hermes(Some(&hermes_bin), Some(&hermes_profile)),
);
}
// Always add default Reasonix if not already configured
if !runtimes.contains_key("reasonix") {
let wrapper = env::var("MNOTE_WEB_REASONIX_WRAPPER")
.unwrap_or_else(|_| "scripts/reasonix-acp-wrapper.mjs".into());
runtimes.insert(
"reasonix".into(),
AcpRuntimeConfig::reasonix(Some(&wrapper)),
);
}
let default =
env::var("MNOTE_WEB_ACP_DEFAULT_RUNTIME").unwrap_or_else(|_| "reasonix".into());
Self {
runtimes,
active: Mutex::new(None),
default_runtime: default,
}
}
/// Get the list of available runtime names.
pub fn available_runtimes(&self) -> Vec<String> {
self.runtimes.keys().cloned().collect()
}
/// Get a runtime config by name.
pub fn get_config(&self, name: &str) -> Option<&AcpRuntimeConfig> {
self.runtimes.get(name)
}
/// Get the default runtime name.
pub fn default_runtime(&self) -> &str {
&self.default_runtime
}
/// Get the currently active runtime name, if any.
pub async fn active_runtime_name(&self) -> Option<String> {
self.active
.lock()
.await
.as_ref()
.map(|a| a.config.name.clone())
}
/// Get a reference to the currently active [`AcpClient`], if any.
pub async fn active_client(&self) -> Option<Arc<AcpClient>> {
self.active.lock().await.as_ref().map(|a| a.client.clone())
}
/// Check if a runtime is active and the client is available.
pub async fn is_active(&self) -> bool {
self.active.lock().await.is_some()
}
/// Activate a runtime by name, spawning a new subprocess if needed.
///
/// If another runtime is currently active, it will be shut down first.
/// After spawn, performs an `initialize` handshake to verify the runtime is healthy.
pub async fn switch_to(&self, name: &str) -> Result<Arc<AcpClient>, AcpError> {
let config = self
.runtimes
.get(name)
.cloned()
.ok_or_else(|| AcpError::Internal(format!("unknown runtime: {name}")))?;
self.switch_to_config(config).await
}
/// Activate a runtime from an explicit config.
///
/// This is used by Hermes ACP because the binary is the same runtime name,
/// but the selected Hermes profile changes the launch args.
pub async fn switch_to_config(
&self,
config: AcpRuntimeConfig,
) -> Result<Arc<AcpClient>, AcpError> {
let name = config.name.clone();
// Shutdown current active runtime
let mut active_guard = self.active.lock().await;
if let Some(ref current) = *active_guard {
if current.config == config {
// Already active — return existing client
return Ok(current.client.clone());
}
// Drop the old ActiveRuntime, which will kill the child process
// (via AcpClient's Drop impl)
}
info!(
"ACP runtime: switching to {name} (bin={}, args={:?})",
config.bin, config.args
);
// Convert Vec<String> to Vec<&str> for AcpClient::spawn
let args_refs: Vec<&str> = config.args.iter().map(|s| s.as_str()).collect();
let client =
AcpClient::spawn_with_env(&config.bin, &args_refs, config.env.as_ref()).await?;
let client = Arc::new(client);
*active_guard = Some(ActiveRuntime {
config,
client: client.clone(),
});
info!("ACP runtime: {name} active");
Ok(client)
}
/// Shut down the currently active runtime.
pub async fn shutdown_active(&self) {
let mut active_guard = self.active.lock().await;
if let Some(active) = active_guard.take() {
info!("ACP runtime: shutting down {}", active.config.name);
// AcpClient's Drop kills the process
}
}
/// Perform a health check on the active runtime.
///
/// Returns `true` if the runtime responds to an `initialize` handshake within 5 seconds.
pub async fn health_check(&self) -> bool {
let client = match self.active_client().await {
Some(c) => c,
None => return false,
};
// Use request_with_timeout with a short timeout
let result: Result<serde_json::Value, AcpError> = timeout(
Duration::from_secs(5),
client.request("initialize", serde_json::json!({ "protocolVersion": 1 })),
)
.await
.map_err(|_| AcpError::Timeout(5))
.and_then(|r| r);
match result {
Ok(val) => {
let ok = val.get("protocolVersion").and_then(|v| v.as_u64()) == Some(1);
if ok {
debug!("ACP health check OK");
} else {
warn!("ACP health check: unexpected response: {val:?}");
}
ok
}
Err(e) => {
warn!("ACP health check failed: {e}");
false
}
}
}
}
impl Drop for AcpRuntimeManager {
fn drop(&mut self) {
// The active runtime's AcpClient Drop will kill the process
}
}
// ── Tests ────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_runtime_config_hermes() {
let cfg = AcpRuntimeConfig::hermes(None, None);
assert_eq!(cfg.name, "hermes");
assert_eq!(cfg.bin, "hermes");
assert_eq!(cfg.args, vec!["-p", "default", "acp"]);
}
#[test]
fn test_runtime_config_hermes_profile_can_be_disabled() {
let cfg = AcpRuntimeConfig::hermes(None, Some(""));
assert_eq!(cfg.args, vec!["acp"]);
}
#[test]
fn test_runtime_config_reasonix() {
let cfg = AcpRuntimeConfig::reasonix(None);
assert_eq!(cfg.name, "reasonix");
assert_eq!(cfg.bin, "node");
assert_eq!(cfg.args.len(), 1);
assert!(cfg.args[0].ends_with("/scripts/reasonix-acp-wrapper.mjs"));
}
#[test]
fn test_runtime_config_custom() {
let cfg = AcpRuntimeConfig {
name: "custom".into(),
bin: "/usr/local/bin/my-agent".into(),
args: vec!["--acp".into(), "--debug".into()],
env: None,
title: Some("My Agent".into()),
};
let json = serde_json::to_value(&cfg).unwrap();
assert_eq!(json["name"], "custom");
assert_eq!(json["bin"], "/usr/local/bin/my-agent");
assert_eq!(json["title"], "My Agent");
}
#[test]
fn test_runtime_manager_from_env_defaults() {
// Without env overrides, should contain hermes and reasonix
let mgr = AcpRuntimeManager::from_env();
let runtimes = mgr.available_runtimes();
assert!(runtimes.contains(&"hermes".into()));
assert!(runtimes.contains(&"reasonix".into()));
}
#[tokio::test]
async fn test_switch_to_unknown_runtime() {
let mgr = AcpRuntimeManager::from_env();
let result = mgr.switch_to("nonexistent").await;
assert!(result.is_err());
let err_str = format!("{}", result.err().unwrap());
assert!(
err_str.contains("unknown runtime"),
"should return error for unknown runtime, got: {err_str}"
);
}
#[tokio::test]
async fn test_health_check_no_active() {
let mgr = AcpRuntimeManager::from_env();
assert!(!mgr.health_check().await, "no active runtime = unhealthy");
}
#[tokio::test]
async fn test_switch_to_hermes_requires_binary() {
let mgr = AcpRuntimeManager::from_env();
// This might fail if `hermes` binary is not in PATH — that's OK for this test
let result = mgr.switch_to("hermes").await;
// We just verify it doesn't panic; either succeeds or returns Spawn error
if let Err(e) = &result {
assert!(
matches!(e, AcpError::Spawn(_)),
"expected Spawn error if hermes not in PATH, got: {e}"
);
} else {
// Success — clean up
mgr.shutdown_active().await;
}
}
}
File diff suppressed because it is too large Load Diff
-802
View File
@@ -1,802 +0,0 @@
/// ACP (Agent Client Protocol) type definitions.
///
/// Strongly-typed Rust representations of the ACP JSON-RPC 2.0 messages.
/// Both Hermes (`hermes acp`) and Reasonix share this protocol shape.
///
/// Reference:
/// - `reference-code/DeepSeek-Reasonix-main/src/acp/protocol.ts`
/// - `reference-code/hermes-vscode-main/src/protocol.ts`
use serde::{de, Deserialize, Deserializer, Serialize};
use serde_json::Value;
// ── JSON-RPC 2.0 basics ──────────────────────────────
pub type JsonRpcId = serde_json::Value; // number or string
// ── Initialize ───────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InitializeParams {
pub protocol_version: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_capabilities: Option<ClientCapabilities>,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_info: Option<ClientInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClientCapabilities {
#[serde(skip_serializing_if = "Option::is_none")]
pub fs: Option<FsCapabilities>,
#[serde(skip_serializing_if = "Option::is_none")]
pub terminal: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FsCapabilities {
#[serde(skip_serializing_if = "Option::is_none")]
pub read_text_file: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub write_text_file: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClientInfo {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InitializeResult {
pub protocol_version: u64,
pub agent_capabilities: AgentCapabilities,
pub agent_info: AgentInfo,
pub auth_methods: Vec<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentCapabilities {
#[serde(skip_serializing_if = "Option::is_none")]
pub load_session: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_capabilities: Option<PromptCapabilities>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mcp_capabilities: Option<McpCapabilities>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PromptCapabilities {
#[serde(skip_serializing_if = "Option::is_none")]
pub image: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub audio: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub embedded_context: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpCapabilities {
#[serde(skip_serializing_if = "Option::is_none")]
pub http: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sse: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentInfo {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
pub version: String,
}
// ── Session ──────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionNewParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mcp_servers: Option<Vec<McpServerSpec>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpServerSpec {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub args: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub env: Option<std::collections::HashMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionNewResult {
pub session_id: String,
}
// ── Session load ─────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionLoadParams {
pub session_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mcp_servers: Option<Vec<McpServerSpec>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionLoadResult {
pub session_id: String,
}
// ── Content blocks ───────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum ContentBlock {
#[serde(rename = "text")]
Text { text: String },
#[serde(rename = "resource")]
Resource { resource: ResourceContent },
#[serde(rename = "image")]
Image { mime_type: String, data: String },
#[serde(rename = "audio")]
Audio { mime_type: String, data: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResourceContent {
pub uri: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub mime_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
}
// ── Session prompt ───────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionPromptParams {
pub session_id: String,
pub prompt: Vec<ContentBlock>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mnote_session_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub run_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub actor_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub trace_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub document_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mnote_capabilities: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionPromptResult {
pub stop_reason: StopReason,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StopReason {
EndTurn,
ToolUseComplete,
Cancelled,
Error,
}
// ── Session cancel (notification, no result) ─────────
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionCancelParams {
pub session_id: String,
}
// ── Session update (notification from agent to client) ──
/// The `session/update` notification payload.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionUpdateParams {
pub session_id: String,
pub update: SessionUpdate,
}
#[derive(Debug, Clone, Serialize)]
#[serde(untagged)]
pub enum SessionUpdate {
AgentMessageChunk {
#[serde(rename = "sessionUpdate")]
session_update: String, // "agent_message_chunk"
content: TextContent,
},
AgentThoughtChunk {
#[serde(rename = "sessionUpdate")]
session_update: String, // "agent_thought_chunk"
content: TextContent,
},
ToolCall {
#[serde(rename = "sessionUpdate")]
session_update: String, // "tool_call"
#[serde(rename = "toolCallId")]
tool_call_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
kind: Option<ToolCallKind>,
#[serde(skip_serializing_if = "Option::is_none")]
status: Option<ToolCallStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
raw_input: Option<Value>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
locations: Vec<ToolLocation>,
},
ToolCallUpdate {
#[serde(rename = "sessionUpdate")]
session_update: String, // "tool_call_update"
#[serde(rename = "toolCallId")]
tool_call_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
status: Option<ToolCallStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
content: Option<Vec<ContentBlockWrapper>>,
},
Plan {
#[serde(rename = "sessionUpdate")]
session_update: String, // "plan"
entries: Vec<PlanEntry>,
},
UsageUpdate {
#[serde(rename = "sessionUpdate")]
session_update: String, // "usage_update"
used: u64,
size: u64,
},
SessionInfoUpdate {
#[serde(rename = "sessionUpdate")]
session_update: String, // "session_info_update"
title: String,
},
/// Catch-all for any future/unknown session update variants.
Unknown {
#[serde(rename = "sessionUpdate")]
session_update: String,
#[serde(flatten)]
extra: std::collections::HashMap<String, Value>,
},
}
impl<'de> Deserialize<'de> for SessionUpdate {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = Value::deserialize(deserializer)?;
let kind = value
.get("sessionUpdate")
.and_then(Value::as_str)
.ok_or_else(|| de::Error::missing_field("sessionUpdate"))?
.to_string();
match kind.as_str() {
SessionUpdate::AGENT_MESSAGE_CHUNK => {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Raw {
#[serde(rename = "sessionUpdate")]
session_update: String,
content: TextContent,
}
let raw: Raw = serde_json::from_value(value).map_err(de::Error::custom)?;
Ok(SessionUpdate::AgentMessageChunk {
session_update: raw.session_update,
content: raw.content,
})
}
SessionUpdate::AGENT_THOUGHT_CHUNK => {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Raw {
#[serde(rename = "sessionUpdate")]
session_update: String,
content: TextContent,
}
let raw: Raw = serde_json::from_value(value).map_err(de::Error::custom)?;
Ok(SessionUpdate::AgentThoughtChunk {
session_update: raw.session_update,
content: raw.content,
})
}
SessionUpdate::TOOL_CALL => {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Raw {
#[serde(rename = "sessionUpdate")]
session_update: String,
#[serde(rename = "toolCallId")]
tool_call_id: String,
title: Option<String>,
kind: Option<ToolCallKind>,
status: Option<ToolCallStatus>,
raw_input: Option<Value>,
#[serde(default)]
locations: Vec<ToolLocation>,
}
let raw: Raw = serde_json::from_value(value).map_err(de::Error::custom)?;
Ok(SessionUpdate::ToolCall {
session_update: raw.session_update,
tool_call_id: raw.tool_call_id,
title: raw.title,
kind: raw.kind,
status: raw.status,
raw_input: raw.raw_input,
locations: raw.locations,
})
}
SessionUpdate::TOOL_CALL_UPDATE => {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Raw {
#[serde(rename = "sessionUpdate")]
session_update: String,
#[serde(rename = "toolCallId")]
tool_call_id: String,
status: Option<ToolCallStatus>,
content: Option<Vec<ContentBlockWrapper>>,
}
let raw: Raw = serde_json::from_value(value).map_err(de::Error::custom)?;
Ok(SessionUpdate::ToolCallUpdate {
session_update: raw.session_update,
tool_call_id: raw.tool_call_id,
status: raw.status,
content: raw.content,
})
}
SessionUpdate::PLAN => {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Raw {
#[serde(rename = "sessionUpdate")]
session_update: String,
entries: Vec<PlanEntry>,
}
let raw: Raw = serde_json::from_value(value).map_err(de::Error::custom)?;
Ok(SessionUpdate::Plan {
session_update: raw.session_update,
entries: raw.entries,
})
}
SessionUpdate::USAGE_UPDATE => {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Raw {
#[serde(rename = "sessionUpdate")]
session_update: String,
used: u64,
size: u64,
}
let raw: Raw = serde_json::from_value(value).map_err(de::Error::custom)?;
Ok(SessionUpdate::UsageUpdate {
session_update: raw.session_update,
used: raw.used,
size: raw.size,
})
}
SessionUpdate::SESSION_INFO_UPDATE => {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Raw {
#[serde(rename = "sessionUpdate")]
session_update: String,
title: String,
}
let raw: Raw = serde_json::from_value(value).map_err(de::Error::custom)?;
Ok(SessionUpdate::SessionInfoUpdate {
session_update: raw.session_update,
title: raw.title,
})
}
_ => {
let mut extra = match value {
Value::Object(map) => map.into_iter().collect(),
_ => std::collections::HashMap::new(),
};
extra.remove("sessionUpdate");
Ok(SessionUpdate::Unknown {
session_update: kind,
extra,
})
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TextContent {
#[serde(rename = "type")]
pub content_type: String, // "text"
pub text: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ContentBlockWrapper {
#[serde(rename = "type")]
pub wrapper_type: String, // "content"
pub content: TextContent,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolCallKind {
Read,
Edit,
Search,
Execute,
Other,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolCallStatus {
Pending,
InProgress,
Completed,
Failed,
}
/// A file path location referenced by a tool call.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ToolLocation {
pub path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlanEntry {
pub content: String,
pub priority: PlanPriority,
pub status: PlanEntryStatus,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PlanPriority {
High,
Medium,
Low,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PlanEntryStatus {
Pending,
InProgress,
Completed,
}
// ── Permission request (from agent to client) ────────
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionRequestParams {
pub session_id: String,
pub tool_call: PermissionToolCall,
pub options: Vec<PermissionOption>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionToolCall {
#[serde(rename = "toolCallId")]
pub tool_call_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub kind: Option<ToolCallKind>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<ToolCallStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
pub raw_input: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionOption {
pub option_id: String,
pub name: String,
pub kind: PermissionOptionKind,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PermissionOptionKind {
AllowOnce,
AllowAlways,
RejectOnce,
RejectAlways,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionRequestResult {
pub outcome: PermissionOutcome,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum PermissionOutcome {
Selected { outcome: String, option_id: String },
Cancelled { outcome: String },
}
// ── Error codes (JSON-RPC standard) ──────────────────
pub const ERR_PARSE: i64 = -32700;
pub const ERR_INVALID_REQUEST: i64 = -32600;
pub const ERR_METHOD_NOT_FOUND: i64 = -32601;
pub const ERR_INVALID_PARAMS: i64 = -32602;
pub const ERR_INTERNAL: i64 = -32603;
// ── Session update kind discriminants ────────────────
/// Constants for the `sessionUpdate` string field.
impl SessionUpdate {
pub const AGENT_MESSAGE_CHUNK: &'static str = "agent_message_chunk";
pub const AGENT_THOUGHT_CHUNK: &'static str = "agent_thought_chunk";
pub const TOOL_CALL: &'static str = "tool_call";
pub const TOOL_CALL_UPDATE: &'static str = "tool_call_update";
pub const PLAN: &'static str = "plan";
pub const USAGE_UPDATE: &'static str = "usage_update";
pub const SESSION_INFO_UPDATE: &'static str = "session_info_update";
}
/// Parse the `sessionUpdate` string field from a raw JSON value and return the discriminant.
pub fn session_update_kind<'a>(value: &'a serde_json::Value) -> Option<&'a str> {
value
.get("update")
.and_then(|u| u.get("sessionUpdate"))
.and_then(|v| v.as_str())
}
// ── Helper: extract text from agent_message_chunk / agent_thought_chunk ──
/// Extract the text content from a session update's content block.
/// Returns `None` for non-text updates or malformed content.
///
/// Reference: `hermes-vscode-main/src/protocol.ts` `extractTextContent()`
pub fn extract_text_from_update(update: &SessionUpdate) -> Option<&str> {
match update {
SessionUpdate::AgentMessageChunk { content, .. }
| SessionUpdate::AgentThoughtChunk { content, .. } => Some(&content.text),
_ => None,
}
}
// ── Tests ────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_initialize_params_roundtrip() {
let params = InitializeParams {
protocol_version: 1,
client_capabilities: None,
client_info: Some(ClientInfo {
name: "mnote-web".into(),
title: Some("MNote".into()),
version: Some("0.1.0".into()),
}),
};
let json = serde_json::to_value(&params).unwrap();
assert_eq!(json["protocolVersion"], 1);
assert_eq!(json["clientInfo"]["name"], "mnote-web");
let deserialized: InitializeParams = serde_json::from_value(json).unwrap();
assert_eq!(deserialized.protocol_version, 1);
}
#[test]
fn test_session_new_params() {
let params = SessionNewParams {
cwd: Some("/mnt/Data1T/mnote".into()),
mcp_servers: Some(Vec::new()),
};
let json = serde_json::to_value(&params).unwrap();
assert_eq!(json["cwd"], "/mnt/Data1T/mnote");
assert_eq!(json["mcpServers"], serde_json::json!([]));
}
#[test]
fn test_session_update_agent_message_chunk() {
let json = serde_json::json!({
"sessionId": "test_1",
"update": {
"sessionUpdate": "agent_message_chunk",
"content": { "type": "text", "text": "Hello" }
}
});
let parsed: SessionUpdateParams = serde_json::from_value(json).unwrap();
assert_eq!(parsed.session_id, "test_1");
match &parsed.update {
SessionUpdate::AgentMessageChunk { content, .. } => {
assert_eq!(content.text, "Hello");
}
_ => panic!("expected AgentMessageChunk"),
}
}
#[test]
fn test_session_update_agent_thought_chunk() {
let json = serde_json::json!({
"sessionId": "test_1",
"update": {
"sessionUpdate": "agent_thought_chunk",
"content": { "type": "text", "text": "thinking" }
}
});
let parsed: SessionUpdateParams = serde_json::from_value(json).unwrap();
assert_eq!(parsed.session_id, "test_1");
match &parsed.update {
SessionUpdate::AgentThoughtChunk { content, .. } => {
assert_eq!(content.text, "thinking");
}
_ => panic!("expected AgentThoughtChunk"),
}
}
#[test]
fn test_session_update_tool_call() {
let json = serde_json::json!({
"sessionId": "test_1",
"update": {
"sessionUpdate": "tool_call",
"toolCallId": "tc_1",
"title": "mnote.doc.fetch",
"kind": "read",
"status": "pending"
}
});
let parsed: SessionUpdateParams = serde_json::from_value(json).unwrap();
match &parsed.update {
SessionUpdate::ToolCall {
title,
kind,
locations,
..
} => {
assert_eq!(title.as_deref(), Some("mnote.doc.fetch"));
assert!(matches!(kind, Some(ToolCallKind::Read)));
assert!(locations.is_empty(), "no locations in this fixture");
}
_ => panic!("expected ToolCall"),
}
}
#[test]
fn test_session_update_tool_call_with_locations() {
let json = serde_json::json!({
"sessionId": "test_1",
"update": {
"sessionUpdate": "tool_call",
"toolCallId": "tc_2",
"title": "mnote.doc.read",
"kind": "read",
"status": "in_progress",
"locations": [
{ "path": "/mnt/Data1T/mnote/src/main.rs" },
{ "path": "/mnt/Data1T/mnote/src/lib.rs" }
]
}
});
let parsed: SessionUpdateParams = serde_json::from_value(json).unwrap();
match &parsed.update {
SessionUpdate::ToolCall {
tool_call_id,
locations,
..
} => {
assert_eq!(tool_call_id, "tc_2");
assert_eq!(locations.len(), 2);
assert_eq!(locations[0].path, "/mnt/Data1T/mnote/src/main.rs");
assert_eq!(locations[1].path, "/mnt/Data1T/mnote/src/lib.rs");
}
_ => panic!("expected ToolCall"),
}
}
#[test]
fn test_session_update_usage() {
let json = serde_json::json!({
"sessionId": "test_1",
"update": {
"sessionUpdate": "usage_update",
"used": 1500,
"size": 4000
}
});
let parsed: SessionUpdateParams = serde_json::from_value(json).unwrap();
match &parsed.update {
SessionUpdate::UsageUpdate { used, size, .. } => {
assert_eq!(*used, 1500);
assert_eq!(*size, 4000);
}
_ => panic!("expected UsageUpdate"),
}
}
#[test]
fn test_content_block_text() {
let block = ContentBlock::Text {
text: "hello".into(),
};
let json = serde_json::to_value(&block).unwrap();
assert_eq!(json["type"], "text");
assert_eq!(json["text"], "hello");
}
#[test]
fn test_flatten_prompt() {
let blocks = vec![
ContentBlock::Text {
text: "Hello".into(),
},
ContentBlock::Resource {
resource: ResourceContent {
uri: "file:///test.md".into(),
mime_type: None,
text: Some(" world".into()),
},
},
];
// flattenPrompt equivalent: concatenate text blocks + resource text
let text: Vec<String> = blocks
.iter()
.map(|b| match b {
ContentBlock::Text { text } => text.clone(),
ContentBlock::Resource { resource } => resource.text.clone().unwrap_or_default(),
_ => String::new(),
})
.collect();
assert_eq!(text.join(""), "Hello world");
}
}
-368
View File
@@ -1,368 +0,0 @@
use crate::acp_bridge::SseEvent;
use serde_json::{json, Value};
use std::env;
const DEFAULT_API_CHAT_BASE_URL: &str = "http://127.0.0.1:20128/v1";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ApiChatProfile {
pub profile_id: &'static str,
pub base_profile: &'static str,
pub isolated_profile: &'static str,
pub label: &'static str,
pub model: &'static str,
pub provider_kind: &'static str,
pub status: &'static str,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedApiChatProfile {
pub profile_id: String,
pub base_profile: String,
pub isolated_profile: String,
pub label: String,
pub model: String,
pub provider_kind: String,
pub status: String,
pub base_url: String,
pub api_key: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ApiChatError {
pub code: &'static str,
pub message: String,
}
impl ApiChatError {
pub fn new(code: &'static str, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
}
}
}
impl std::fmt::Display for ApiChatError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.code, self.message)
}
}
impl std::error::Error for ApiChatError {}
pub const API_CHAT_PROFILES: &[ApiChatProfile] = &[
ApiChatProfile {
profile_id: "shared_api_deepseek_flash_chat",
base_profile: "api-deepseek-flash-chat",
isolated_profile: "api-deepseek-flash-chat",
label: "DeepSeek Flash Chat",
model: "deepseek-v4-flash",
provider_kind: "api-chat",
status: "active",
},
ApiChatProfile {
profile_id: "shared_api_deepseek_pro_chat",
base_profile: "api-deepseek-pro-chat",
isolated_profile: "api-deepseek-pro-chat",
label: "DeepSeek Pro Chat",
model: "deepseek-v4-pro",
provider_kind: "api-chat",
status: "active",
},
ApiChatProfile {
profile_id: "shared_api_gpt_chat",
base_profile: "api-gpt-chat",
isolated_profile: "api-gpt-chat",
label: "GPT Chat",
model: "aisz-chat/gpt-5.5-extra-high-fast",
provider_kind: "api-chat",
status: "active",
},
ApiChatProfile {
profile_id: "shared_api_kimi_chat",
base_profile: "api-kimi-chat",
isolated_profile: "api-kimi-chat",
label: "Kimi Chat",
model: "aisz-chat/kimi-k2.5",
provider_kind: "api-chat",
status: "active",
},
ApiChatProfile {
profile_id: "shared_api_gemini_chat",
base_profile: "api-gemini-chat",
isolated_profile: "api-gemini-chat",
label: "Gemini API Chat",
model: "aisz-chat/gemini-3.1-pro",
provider_kind: "api-chat",
status: "active",
},
ApiChatProfile {
profile_id: "shared_api_grok_chat",
base_profile: "api-grok-chat",
isolated_profile: "api-grok-chat",
label: "Grok API Chat",
model: "aisz-chat/grok-4.3",
provider_kind: "api-chat",
status: "active",
},
];
pub fn api_chat_profiles() -> &'static [ApiChatProfile] {
API_CHAT_PROFILES
}
pub fn api_chat_profile_by_id(value: &str) -> Option<ApiChatProfile> {
let needle = value.trim();
if needle.is_empty() {
return None;
}
API_CHAT_PROFILES
.iter()
.copied()
.find(|profile| profile_matches(*profile, needle))
}
pub fn payload_uses_api_chat_profile(payload: &Value, registration_profile: &str) -> bool {
let agent_id = payload
.get("agentId")
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or_default();
if agent_id != "chat_only" {
return false;
}
let agent_profile = payload.get("agentProfileRef");
[
Some(registration_profile),
payload.get("profile").and_then(Value::as_str),
payload.get("profileId").and_then(Value::as_str),
payload.get("profile_id").and_then(Value::as_str),
agent_profile
.and_then(|value| value.get("baseProfile"))
.and_then(Value::as_str),
agent_profile
.and_then(|value| value.get("isolatedProfile"))
.and_then(Value::as_str),
agent_profile
.and_then(|value| value.get("profileId"))
.and_then(Value::as_str),
]
.into_iter()
.flatten()
.any(|candidate| api_chat_profile_by_id(candidate).is_some())
}
pub fn resolve_api_chat_profile(value: &str) -> Result<ResolvedApiChatProfile, ApiChatError> {
let profile = api_chat_profile_by_id(value).ok_or_else(|| {
ApiChatError::new(
"api_chat_profile_unknown",
format!("未知 API Chat profile: {value}"),
)
})?;
let env_prefix = profile_env_prefix(profile.profile_id);
let model = env_value(&format!("{env_prefix}_MODEL"))
.or_else(|| env_value("MNOTE_API_CHAT_MODEL"))
.unwrap_or_else(|| profile.model.to_string());
let base_url = env_value(&format!("{env_prefix}_BASE_URL"))
.or_else(|| env_value("MNOTE_API_CHAT_BASE_URL"))
.unwrap_or_else(|| DEFAULT_API_CHAT_BASE_URL.to_string());
let api_key = env_value(&format!("{env_prefix}_API_KEY"))
.or_else(|| env_value("MNOTE_API_CHAT_API_KEY"))
.or_else(|| env_value("OPENAI_API_KEY"));
Ok(ResolvedApiChatProfile {
profile_id: profile.profile_id.to_string(),
base_profile: profile.base_profile.to_string(),
isolated_profile: profile.isolated_profile.to_string(),
label: profile.label.to_string(),
model,
provider_kind: profile.provider_kind.to_string(),
status: profile.status.to_string(),
base_url: base_url.trim().trim_end_matches('/').to_string(),
api_key,
})
}
pub fn runtime_events_from_openai_sse_chunk(
run_id: &str,
chunk: &str,
) -> Result<Vec<SseEvent>, ApiChatError> {
let mut decoder = OpenAiSseDecoder::default();
decoder.push_chunk(run_id, chunk)
}
#[derive(Debug, Default)]
pub struct OpenAiSseDecoder {
buffer: String,
output: String,
completed: bool,
}
impl OpenAiSseDecoder {
pub fn push_chunk(&mut self, run_id: &str, chunk: &str) -> Result<Vec<SseEvent>, ApiChatError> {
self.buffer.push_str(chunk);
let mut frames = self
.buffer
.split("\n\n")
.map(str::to_string)
.collect::<Vec<_>>();
self.buffer = frames.pop().unwrap_or_default();
let mut events = Vec::new();
for frame in frames {
events.extend(self.parse_frame(run_id, &frame)?);
}
Ok(events)
}
pub fn finish(&mut self, run_id: &str) -> Result<Vec<SseEvent>, ApiChatError> {
let rest = std::mem::take(&mut self.buffer);
let mut events = if rest.trim().is_empty() {
Vec::new()
} else {
self.parse_frame(run_id, &rest)?
};
if !self.completed {
self.completed = true;
events.push(self.completed_event());
}
Ok(events)
}
fn parse_frame(&mut self, run_id: &str, frame: &str) -> Result<Vec<SseEvent>, ApiChatError> {
let mut events = Vec::new();
for data in sse_frame_data_lines(frame) {
if data == "[DONE]" {
if !self.completed {
self.completed = true;
events.push(self.completed_event());
}
continue;
}
let payload = serde_json::from_str::<Value>(&data).map_err(|error| {
ApiChatError::new(
"api_chat_stream_parse_error",
format!("OpenAI SSE chunk 解析失败: {error}"),
)
})?;
if let Some(error) = payload.get("error") {
self.completed = true;
events.push(SseEvent {
event: "run.failed".into(),
data: json!({
"runId": run_id,
"code": "api_chat_upstream_error",
"message": error
.get("message")
.and_then(Value::as_str)
.unwrap_or("API Chat upstream error"),
"error": error
}),
});
continue;
}
for delta in extract_delta_texts(&payload) {
self.output.push_str(&delta);
events.push(SseEvent {
event: "message.delta".into(),
data: json!({
"runId": run_id,
"delta": delta
}),
});
}
}
Ok(events)
}
fn completed_event(&self) -> SseEvent {
SseEvent {
event: "run.completed".into(),
data: json!({
"output": self.output
}),
}
}
}
fn profile_matches(profile: ApiChatProfile, value: &str) -> bool {
profile.profile_id == value
|| profile.base_profile == value
|| profile.isolated_profile == value
|| profile.label == value
}
fn profile_env_prefix(profile_id: &str) -> String {
let suffix = profile_id
.trim()
.strip_prefix("shared_api_")
.unwrap_or(profile_id)
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() {
ch.to_ascii_uppercase()
} else {
'_'
}
})
.collect::<String>();
format!("MNOTE_API_CHAT_{suffix}")
}
fn env_value(key: &str) -> Option<String> {
env::var(key)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn sse_frame_data_lines(frame: &str) -> Vec<String> {
let mut lines = Vec::new();
for line in frame.lines() {
let trimmed = line.trim();
if let Some(data) = trimmed.strip_prefix("data:") {
lines.push(data.trim().to_string());
}
}
lines
}
fn extract_delta_texts(payload: &Value) -> Vec<String> {
let mut texts = Vec::new();
if let Some(choices) = payload.get("choices").and_then(Value::as_array) {
for choice in choices {
for value in [
choice
.get("delta")
.and_then(|delta| delta.get("content"))
.and_then(Value::as_str),
choice
.get("message")
.and_then(|message| message.get("content"))
.and_then(Value::as_str),
choice.get("text").and_then(Value::as_str),
]
.into_iter()
.flatten()
{
if !value.is_empty() {
texts.push(value.to_string());
}
}
}
}
if texts.is_empty() {
for value in [
payload.get("delta").and_then(Value::as_str),
payload.get("text").and_then(Value::as_str),
payload.get("content").and_then(Value::as_str),
]
.into_iter()
.flatten()
{
if !value.is_empty() {
texts.push(value.to_string());
}
}
}
texts
}
-7
View File
@@ -1,4 +1,3 @@
use crate::acp_runtime::AcpRuntimeManager;
use crate::document_buffer_store::BufferStore;
use crate::editor_actor::EditorRuntimeActor;
use crate::local_folder_watcher_registry::LocalFolderWatcherRegistry;
@@ -33,7 +32,6 @@ pub struct AppConfig {
pub enable_debug_shell_routes: bool,
pub enable_editor_actor: bool,
pub enable_page_ai_pi_lab: bool,
pub hermes_base_path: String,
pub compat_next_base_path: String,
pub convex_url: Option<String>,
pub convex_admin_key: Option<String>,
@@ -67,8 +65,6 @@ impl AppConfig {
.unwrap_or(false),
enable_editor_actor: env_bool("MNOTE_WEB_ENABLE_EDITOR_ACTOR", true),
enable_page_ai_pi_lab: env_bool("MNOTE_PAGE_AI_PI_LAB", true),
hermes_base_path: env::var("MNOTE_WEB_HERMES_BASE_PATH")
.unwrap_or_else(|_| "/api/hermes".into()),
compat_next_base_path: env::var("MNOTE_WEB_COMPAT_NEXT_BASE_PATH")
.unwrap_or_else(|_| "/api/compat/next".into()),
convex_url: None,
@@ -163,7 +159,6 @@ pub struct AppState {
pub editor_actor: EditorRuntimeActor,
pub block_delta_tx: broadcast::Sender<serde_json::Value>,
pub stream_delta_tx: broadcast::Sender<serde_json::Value>,
pub acp_runtime: Arc<AcpRuntimeManager>,
pub buffer_store: BufferStore,
control_plane: Arc<dyn ControlPlaneStore>,
}
@@ -185,7 +180,6 @@ impl AppState {
editor_actor: actor,
block_delta_tx,
stream_delta_tx,
acp_runtime: Arc::new(AcpRuntimeManager::from_env()),
buffer_store,
control_plane,
}
@@ -389,7 +383,6 @@ mod tests {
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,
+1 -1
View File
@@ -192,7 +192,7 @@ mod tests {
let context = RequestContext::from_http_parts(
&Method::POST,
&"/api/hermes/bridge".parse::<Uri>().expect("uri"),
&"/api/mnote/tools".parse::<Uri>().expect("uri"),
&headers,
);
@@ -358,7 +358,7 @@ pub fn buffer_key_string(path: &ObjectWorkspacePath) -> String {
/// 从本地文件夹写入上下文的参数构建 ObjectWorkspacePath。
///
/// 在 save_local_markdown_page、watcher event 和 Hermes 写入链中统一使用此函数构造路径。
/// 在 save_local_markdown_page、watcher event 和 agent tool 写入链中统一使用此函数构造路径。
pub fn build_local_folder_workspace_path(
workspace_id: &str,
root_uri: &str,
+2 -8
View File
@@ -1,18 +1,12 @@
#![recursion_limit = "1024"]
pub mod acp_bridge;
pub mod acp_client;
pub mod acp_runtime;
pub mod acp_session_manager;
pub mod acp_types;
pub mod api_chat;
pub mod app;
pub mod context;
pub mod document_buffer_store;
pub mod editor_actor;
pub mod error;
pub mod evidence_parse;
pub mod hermes_tools;
pub mod mnote_agent_tools;
pub mod local_folder_watcher_registry;
pub mod middleware;
pub mod page_aggregate;
@@ -29,7 +23,7 @@ pub use app::{build_app, AppConfig, AppState};
pub(crate) mod test_support {
use std::sync::{Mutex, OnceLock};
pub(crate) fn hermes_env_lock() -> &'static Mutex<()> {
pub(crate) fn agent_env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
@@ -1,11 +1,30 @@
use crate::context::RequestContext;
use crate::routes::vault_extension_token::{bearer_mnext1, verify_extension_token};
use axum::extract::Request;
use axum::middleware::Next;
use axum::response::Response;
pub async fn inject_request_context(mut request: Request, next: Next) -> Response {
let context =
let mut context =
RequestContext::from_http_parts(request.method(), request.uri(), request.headers());
// 12-3 E2: Authorization Bearer mnext1.* → actor (when cookie/header actor is anonymous)
if context.auth.actor_id.trim() == "anonymous" || context.auth.actor_id.trim().is_empty() {
if let Some(token) = bearer_mnext1(context.auth.authorization.as_deref()) {
if let Ok(claims) = verify_extension_token(token) {
context.auth.actor_id = claims.actor;
if context.auth.actor_type.trim().is_empty()
|| context.auth.actor_type.trim() == "anonymous"
{
context.auth.actor_type = "user".into();
}
if context.auth.session_id.is_none() {
context.auth.session_id = Some(format!("ext:{}", claims.jti));
}
}
}
}
request.extensions_mut().insert(context.clone());
let mut response = next.run(request).await;
@@ -1,7 +1,7 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::ToolCallInput;
use crate::mnote_agent_tools::ToolCallInput;
use crate::routes::command_support::execute_runtime_command_via_legacy_cloud_with_artifacts;
use crate::routes::ensure_local_workspace_access;
use bridge_runtime::{
@@ -179,8 +179,8 @@ async fn create_artifact_node(
.or_else(|| context.auth.session_id.clone()),
},
source: RuntimeSourceWire {
channel: "hermes".into(),
client: "mnote-hermes-plugin".into(),
channel: "agent".into(),
client: "mnote-agent-plugin".into(),
source_kind: None,
root_uri: None,
workspace_id: workspace_id.clone(),
@@ -208,7 +208,7 @@ async fn create_artifact_node(
"artifact": {
"kind": node_type,
"sourceDocumentId": document_id,
"source": "hermes",
"source": "agent",
"sessionId": input.session_id,
"runId": input.run_id,
"toolCallId": input.tool_call_id,
@@ -221,7 +221,7 @@ async fn create_artifact_node(
}),
preflight_data: None,
reason: Some(tool_name.into()),
refs: vec![tool_name.into(), "hermes-tool-call".into()],
refs: vec![tool_name.into(), "agent-tool-call".into()],
dry_run: false,
validate_only: false,
};
@@ -276,5 +276,5 @@ fn sanitize_local_artifact_file_name(value: &str) -> String {
}
fn ensure_write_contract(context: &RequestContext, input: &ToolCallInput) -> Result<(), WebError> {
crate::hermes_tools::ensure_write_authorized(context, input)
crate::mnote_agent_tools::ensure_write_authorized(context, input)
}
@@ -1,11 +1,11 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::doc::{
use crate::mnote_agent_tools::doc::{
aggregate_value, block_id_of, block_not_found, block_projection_blocks, find_block,
required_arg,
};
use crate::hermes_tools::ToolCallInput;
use crate::mnote_agent_tools::ToolCallInput;
use crate::routes::command_support::execute_runtime_command_via_legacy_cloud_with_artifacts;
use bridge_runtime::{
apply_editor_command_to_legacy_content, RuntimeActorWire, RuntimeCommandEnvelopeWire,
@@ -693,7 +693,7 @@ pub(crate) fn ensure_write_contract(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<(), WebError> {
crate::hermes_tools::ensure_write_authorized(context, input)
crate::mnote_agent_tools::ensure_write_authorized(context, input)
}
fn ensure_leaf_block(
@@ -1128,8 +1128,8 @@ async fn execute_page_body_save(
.or_else(|| context.auth.session_id.clone()),
},
source: RuntimeSourceWire {
channel: "hermes".into(),
client: "mnote-hermes-plugin".into(),
channel: "agent".into(),
client: "mnote-agent-plugin".into(),
source_kind: None,
root_uri: None,
workspace_id: workspace_id.clone(),
@@ -1142,8 +1142,8 @@ async fn execute_page_body_save(
}),
payload,
preflight_data: None,
reason: Some("Hermes block tool page.body.save".into()),
refs: vec!["page.body.save".into(), "hermes-block-tool-call".into()],
reason: Some("agent block tool page.body.save".into()),
refs: vec!["page.body.save".into(), "agent-block-tool-call".into()],
dry_run: false,
validate_only: false,
};
@@ -1199,8 +1199,8 @@ pub(crate) async fn execute_page_body_save_from_aggregate(
.or_else(|| context.auth.session_id.clone()),
},
source: RuntimeSourceWire {
channel: "hermes".into(),
client: "mnote-hermes-plugin".into(),
channel: "agent".into(),
client: "mnote-agent-plugin".into(),
source_kind: None,
root_uri: None,
workspace_id: workspace_id.clone(),
@@ -1213,10 +1213,10 @@ pub(crate) async fn execute_page_body_save_from_aggregate(
}),
payload,
preflight_data: None,
reason: Some("Hermes batch block tool page.body.save".into()),
reason: Some("agent batch block tool page.body.save".into()),
refs: vec![
"page.body.save".into(),
"hermes-batch-block-tool-call".into(),
"agent-batch-block-tool-call".into(),
],
dry_run: false,
validate_only: false,
@@ -1480,7 +1480,7 @@ mod tests {
fn ensure_write_contract_rejects_read_only_ai_scope() {
let context = RequestContext::from_http_parts(
&axum::http::Method::POST,
&"/api/hermes/tools".parse().expect("uri"),
&"/api/mnote/tools".parse().expect("uri"),
&axum::http::HeaderMap::new(),
);
let input = ToolCallInput {
@@ -1521,13 +1521,13 @@ mod tests {
"attrs": {},
"payload": {
"marks": [],
"text": "Hermes 插件替换第二段",
"text": "agent 插件替换第二段",
"type": "text"
}
}
]);
assert_eq!(content_to_text(&value), "Hermes 插件替换第二段");
assert_eq!(content_to_text(&value), "agent 插件替换第二段");
}
#[test]
@@ -1540,7 +1540,7 @@ mod tests {
"attrs": {},
"payload": {
"marks": [],
"text": "Hermes 插件插入段",
"text": "agent 插件插入段",
"type": "text"
}
}
@@ -1548,6 +1548,6 @@ mod tests {
}
]);
assert_eq!(content_to_text(&value), "Hermes 插件插入段");
assert_eq!(content_to_text(&value), "agent 插件插入段");
}
}
@@ -1,7 +1,7 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::{doc, ToolCallInput};
use crate::mnote_agent_tools::{doc, ToolCallInput};
use serde_json::{json, Value};
pub async fn context_snapshot(
@@ -1,7 +1,7 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::ToolCallInput;
use crate::mnote_agent_tools::ToolCallInput;
use crate::routes::web_shell::build_page_aggregate_snapshot;
use axum::http::StatusCode;
use serde_json::{json, Value};
@@ -346,7 +346,7 @@ pub async fn plan_update(
if !input.has_idempotency_key() {
return Err(WebError::bad_request_code(
"mnote_tool_idempotency_required",
"写入计划型 mnote Hermes tool 必须携带 idempotencyKey",
"写入计划型 mnote agent tool 必须携带 idempotencyKey",
)
.with_context(context));
}
@@ -1492,7 +1492,7 @@ pub async fn doc_markdown_edit(
let is_local_file = document_id.starts_with('/') || document_id.starts_with("./");
let is_local_workspace =
source_kind.as_deref() == Some("local_folder") && root_uri.as_deref().is_some();
crate::hermes_tools::block::ensure_write_contract(context, input)?;
crate::mnote_agent_tools::block::ensure_write_contract(context, input)?;
// 1. 读取当前文档内容(markdown 形式)
let (current_md, source) = if is_local_file {
@@ -1650,7 +1650,7 @@ pub async fn doc_markdown_edit(
match aggregate_value(state, context, input).await {
Ok(agg) => {
let blocks = block_projection_blocks(&agg);
let original_content = crate::hermes_tools::block::current_body_content(&agg);
let original_content = crate::mnote_agent_tools::block::current_body_content(&agg);
(agg, blocks, original_content)
}
Err(_) if use_full_content.is_some() => {
@@ -1756,8 +1756,8 @@ pub async fn doc_markdown_edit(
.or_else(|| context.auth.session_id.clone()),
},
source: RuntimeSourceWire {
channel: "mnote-hermes".into(),
client: "mnote-hermes-plugin".into(),
channel: "mnote-agent".into(),
client: "mnote-agent-plugin".into(),
source_kind,
root_uri,
workspace_id: None,
@@ -1771,7 +1771,7 @@ pub async fn doc_markdown_edit(
payload,
preflight_data: None,
reason: Some("mnote.doc.markdown_edit (7-27)".into()),
refs: vec!["page.body.save".into(), "mnote-hermes-tool-call".into()],
refs: vec!["page.body.save".into(), "mnote-agent-tool-call".into()],
dry_run: false,
validate_only: false,
};
@@ -1,7 +1,7 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::{ensure_write_authorized, ToolCallInput};
use crate::mnote_agent_tools::{ensure_write_authorized, ToolCallInput};
use crate::routes;
use axum::http::StatusCode;
use serde::Deserialize;
@@ -1,7 +1,7 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::ToolCallInput;
use crate::mnote_agent_tools::ToolCallInput;
use crate::routes::knowledge_rag::{
KnowledgeRagOpenReferenceRequest, KnowledgeRagQueryRequest, KnowledgeRagSearchRequest,
KnowledgeRagSectionContextRequest, KnowledgeRagStatusQuery,
@@ -1,8 +1,8 @@
use super::skill;
use serde_json::{json, Value};
pub const MANIFEST_SCHEMA_VERSION: &str = "mnote.hermes_tool_manifest.v1";
pub const TOOL_SCHEMA_VERSION: &str = "mnote.hermes_tool.v1";
pub const MANIFEST_SCHEMA_VERSION: &str = "mnote.agent_tool_manifest.v1";
pub const TOOL_SCHEMA_VERSION: &str = "mnote.agent_tool.v1";
pub fn manifest() -> Value {
let tools = annotate_tools_with_capabilities(assemble_all_tools());
@@ -177,7 +177,7 @@ impl ToolCallInput {
}
}
/// CommandContext 桥接信息,用于将 `core-protocol` 的 command context 引入 hermes_tools 写入守卫。
/// CommandContext 桥接信息,用于将 `core-protocol` 的 command context 引入 agent tools 写入守卫。
///
/// 当此桥接可用时,`ensure_write_authorized` 除检查 `ToolCallInput` 自带的
/// `aiAccessScope.permissionLevel` 外,额外检查 `ai_can_write` 和 `workspace_readonly`。
@@ -191,7 +191,7 @@ pub struct CommandContextBridge {
pub ai_can_write: bool,
}
/// 统一的 hermes_tools 写入守卫。检查:
/// 统一的 agent tools 写入守卫。检查:
///
/// - `idempotencyKey` 必须存在
/// - `dryRun` 必须显式携带
@@ -207,14 +207,14 @@ pub fn ensure_write_authorized(
if !input.has_idempotency_key() {
return Err(WebError::bad_request_code(
"mnote_tool_idempotency_required",
"写入型 mnote Hermes tool 必须携带 idempotencyKey",
"写入型 mnote agent tool 必须携带 idempotencyKey",
)
.with_context(context));
}
if input.dry_run.is_none() {
return Err(WebError::bad_request_code(
"mnote_tool_dry_run_required",
"写入型 mnote Hermes tool 必须显式携带 dryRun",
"写入型 mnote agent tool 必须显式携带 dryRun",
)
.with_context(context));
}
@@ -256,7 +256,7 @@ mod tests {
fn context() -> RequestContext {
RequestContext::from_http_parts(
&Method::POST,
&"/api/hermes/tools".parse().expect("uri"),
&"/api/mnote/tools".parse().expect("uri"),
&HeaderMap::new(),
)
}
@@ -1,6 +1,6 @@
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::{ensure_write_authorized, ToolCallInput};
use crate::mnote_agent_tools::{ensure_write_authorized, ToolCallInput};
use crate::routes::onlyoffice_bridge::{self, BridgeResultWire, BridgeRunError};
use axum::http::StatusCode;
use serde_json::{json, Value};
@@ -1,7 +1,7 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::ToolCallInput;
use crate::mnote_agent_tools::ToolCallInput;
use crate::routes::command_support::execute_runtime_command_via_legacy_cloud_with_artifacts;
use crate::routes::web_shell::build_page_aggregate_snapshot;
use bridge_runtime::{
@@ -18,7 +18,7 @@ pub async fn page_get(
WebError::bad_request_code("mnote_tool_bad_request", "mnote.page.get 缺少 documentId")
.with_context(context)
})?;
crate::hermes_tools::doc::ensure_ai_scope_resource_allowed(context, input, &document_id)?;
crate::mnote_agent_tools::doc::ensure_ai_scope_resource_allowed(context, input, &document_id)?;
let workspace_id = input.effective_workspace_id();
let source_kind = input.effective_source_kind();
let root_uri = input.effective_root_uri();
@@ -72,7 +72,7 @@ pub async fn page_get(
}
fn ensure_write_contract(context: &RequestContext, input: &ToolCallInput) -> Result<(), WebError> {
crate::hermes_tools::ensure_write_authorized(context, input)
crate::mnote_agent_tools::ensure_write_authorized(context, input)
}
pub async fn page_save(
@@ -287,8 +287,8 @@ async fn page_command(
.or_else(|| context.auth.session_id.clone()),
},
source: RuntimeSourceWire {
channel: "hermes".into(),
client: "mnote-hermes-plugin".into(),
channel: "agent".into(),
client: "mnote-agent-plugin".into(),
source_kind: None,
root_uri: None,
workspace_id: workspace_id.clone(),
@@ -301,8 +301,8 @@ async fn page_command(
}),
payload,
preflight_data: None,
reason: Some(format!("Hermes tool {command_name}")),
refs: vec![command_name.into(), "hermes-tool-call".into()],
reason: Some(format!("agent tool {command_name}")),
refs: vec![command_name.into(), "agent-tool-call".into()],
dry_run: false,
validate_only: false,
};
@@ -1,6 +1,6 @@
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::ToolCallInput;
use crate::mnote_agent_tools::ToolCallInput;
use axum::http::StatusCode;
use serde_json::{json, Value};
use std::collections::HashSet;
@@ -468,7 +468,7 @@ fn ensure_resource_write_contract(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<(), WebError> {
crate::hermes_tools::ensure_write_authorized(context, input)
crate::mnote_agent_tools::ensure_write_authorized(context, input)
}
fn local_root_uri_for_resource(input: &ToolCallInput) -> Option<String> {
@@ -1,6 +1,6 @@
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::ToolCallInput;
use crate::mnote_agent_tools::ToolCallInput;
use axum::http::StatusCode;
use serde_json::{json, Value};
@@ -25,7 +25,7 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
title: "当前页读取",
description: "仅在任务需要当前 MNote Markdown 页面内容时读取当前页。",
category: "mnote",
agent_ids: &["hermes", "reasonix"],
agent_ids: &["pi"],
read_only: true,
requires_context_refs: &["current_page"],
tool_names: &[
@@ -40,7 +40,7 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
title: "知识库问答",
description: "通过 LightRAG 知识库检索多本书、论文、PDF 和附件,并返回可回跳来源;默认 provider 是 LightRAG。",
category: "knowledge",
agent_ids: &["hermes", "reasonix"],
agent_ids: &["pi"],
read_only: true,
requires_context_refs: &["folder"],
tool_names: &[
@@ -58,7 +58,7 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
title: "本地文件编辑",
description: "在 MNote 授权目录内读取和修改本地 Markdown 文件。",
category: "file",
agent_ids: &["hermes", "reasonix"],
agent_ids: &["pi"],
read_only: false,
requires_context_refs: &["current_page", "file", "folder"],
tool_names: &["mnote.context.snapshot", "mnote.context.resolve_target"],
@@ -69,7 +69,7 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
title: "ONLYOFFICE 实时编辑",
description: "操作当前已打开的 ONLYOFFICE Word、Excel、PPT 编辑会话。",
category: "office",
agent_ids: &["hermes", "reasonix"],
agent_ids: &["pi"],
read_only: false,
requires_context_refs: &["onlyoffice"],
tool_names: &[
@@ -116,7 +116,7 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
title: "思维导图",
description: "读取、更新、总结或创建 MNote 思维导图资源。",
category: "resource",
agent_ids: &["hermes", "reasonix"],
agent_ids: &["pi"],
read_only: false,
requires_context_refs: &["current_page", "file", "folder", "resource"],
tool_names: &[
@@ -133,7 +133,7 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
title: "密码箱 / AI 密码本",
description: "密码箱与 AI 密码本:读密/login/session 用 mnote-vault CLI 或 Pi mnote.vault.*token+core/UDS,不依赖 3000);禁止通用文件工具读 .mnote/vault。",
category: "security",
agent_ids: &["hermes", "reasonix"],
agent_ids: &["pi"],
read_only: true,
requires_context_refs: &["folder"],
tool_names: &[
@@ -153,7 +153,7 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
title: "纯聊天",
description: "只进行对话回复,不读取或写入 MNote 页面、文件上下文。",
category: "chat",
agent_ids: &["chat_only", "hermes", "reasonix"],
agent_ids: &["chat_only", "pi"],
read_only: true,
requires_context_refs: &[],
tool_names: &[],
@@ -306,18 +306,18 @@ mod tests {
#[test]
fn skill_lookup_rejects_unknown_or_agent_mismatch() {
assert!(find_skill("mnote-current-page", Some("reasonix")).is_some());
assert!(find_skill("mnote-current-page", Some("pi")).is_some());
assert!(find_skill("mnote-local-file", Some("chat_only")).is_none());
assert!(find_skill("missing", Some("reasonix")).is_none());
assert!(find_skill("missing", Some("pi")).is_none());
}
#[test]
fn skill_registry_exposes_onlyoffice_live_skill_to_agents() {
let reasonix_skills = skill_summaries_for_agent(Some("reasonix"));
let skill = reasonix_skills
let pi_agent_skills = skill_summaries_for_agent(Some("pi"));
let skill = pi_agent_skills
.iter()
.find(|skill| skill["id"] == "mnote-onlyoffice-live")
.expect("reasonix should see live ONLYOFFICE skill");
.expect("pi should see live ONLYOFFICE skill");
assert_eq!(skill["readOnly"], false);
assert_eq!(
skill["toolNames"]
@@ -363,11 +363,11 @@ mod tests {
#[test]
fn skill_registry_exposes_mindmap_skill_to_agents() {
let reasonix_skills = skill_summaries_for_agent(Some("reasonix"));
let skill = reasonix_skills
let pi_agent_skills = skill_summaries_for_agent(Some("pi"));
let skill = pi_agent_skills
.iter()
.find(|skill| skill["id"] == "mnote-mindmap")
.expect("reasonix should see mindmap skill");
.expect("pi should see mindmap skill");
assert_eq!(skill["readOnly"], false);
assert!(skill["requiresContextRefs"]
.as_array()
@@ -383,12 +383,11 @@ mod tests {
#[test]
fn skill_registry_exposes_vault_skill_to_agents() {
let reasonix_skills = skill_summaries_for_agent(Some("reasonix"));
let hermes_skills = skill_summaries_for_agent(Some("hermes"));
let skill = reasonix_skills
let pi_skills = skill_summaries_for_agent(Some("pi"));
let skill = pi_skills
.iter()
.find(|skill| skill["id"] == "mnote-vault")
.expect("reasonix should see vault skill");
.expect("pi should see vault skill");
assert_eq!(skill["readOnly"], true);
assert_eq!(skill["category"], "security");
assert!(skill["toolNames"]
@@ -396,12 +395,9 @@ mod tests {
.expect("tool names")
.iter()
.any(|name| name == "mnote.vault.resolve"));
assert!(hermes_skills
.iter()
.any(|skill| skill["id"] == "mnote-vault"));
assert!(find_skill("mnote-vault", Some("chat_only")).is_none());
let body = find_skill("mnote-vault", Some("hermes"))
.expect("hermes can read vault skill")
let body = find_skill("mnote-vault", Some("pi"))
.expect("pi can read vault skill")
.content;
assert!(body.contains(".mnote/vault"));
assert!(body.contains("共享到 AI") || body.contains("AI 密码本"));
@@ -409,28 +405,28 @@ mod tests {
#[test]
fn skill_registry_retired_local_index_in_favor_of_lightrag() {
let hermes_skills = skill_summaries_for_agent(Some("hermes"));
assert!(!hermes_skills
let pi_skills = skill_summaries_for_agent(Some("pi"));
assert!(!pi_skills
.iter()
.any(|skill| skill["id"] == "mnote-local-index"));
let skill = hermes_skills
let skill = pi_skills
.iter()
.find(|skill| skill["id"] == "mnote-knowledge-rag")
.expect("hermes should see LightRAG skill");
.expect("pi should see LightRAG skill");
assert_eq!(skill["readOnly"], true);
assert!(skill["toolNames"]
.as_array()
.expect("tool names")
.iter()
.any(|name| name == "mnote.knowledge_rag.query"));
assert!(!hermes_skills
assert!(!pi_skills
.iter()
.any(|skill| skill["id"] == "mnote-document-evidence"));
}
#[test]
fn skill_read_maps_document_evidence_alias_to_lightrag() {
let skill = find_skill("mnote-document-evidence", Some("reasonix"))
let skill = find_skill("mnote-document-evidence", Some("pi"))
.expect("compat alias should resolve");
assert_eq!(skill.id, "mnote-knowledge-rag");
}
@@ -439,14 +435,14 @@ mod tests {
async fn skill_read_returns_mindmap_skill_content() {
let context = RequestContext::from_http_parts(
&axum::http::Method::POST,
&"/api/hermes/tools/execute".parse().expect("uri"),
&"/api/mnote/tools/call".parse().expect("uri"),
&axum::http::HeaderMap::new(),
);
let input: ToolCallInput = serde_json::from_value(json!({
"toolName": "mnote.skill.read",
"args": {
"skillId": "mnote-mindmap",
"agentId": "reasonix"
"agentId": "pi"
}
}))
.expect("input");
@@ -1385,7 +1385,7 @@ fn default_skill_registry() -> HashMap<String, SkillConfig> {
skill_config(
"Global Search",
"聚合本机/网页搜索线索,适合研究型查询入口。",
"/home/lix/.hermes/profiles/lite/skills/global-search/SKILL.md",
"/home/lix/.mnote/agent-profiles/lite/skills/global-search/SKILL.md",
"medium",
&["network:search"],
),
@@ -3611,7 +3611,7 @@ mod tests {
#[test]
fn admin_user_display_role_includes_access_policy_admins() {
let _guard = crate::test_support::hermes_env_lock()
let _guard = crate::test_support::agent_env_lock()
.lock()
.expect("env lock");
let policy_root = std::env::temp_dir().join(format!(
@@ -168,7 +168,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
+3 -7
View File
@@ -30,7 +30,7 @@ pub async fn next_ai_agent_run(
StatusCode::GONE,
"legacy_ai_agent_run_retired",
format!(
"旧 /api/ai-agent/run 页面 AI 主链已退场,provider={provider} 不再静默降级;请使用 Hermes client proxy 与 mnote Hermes plugin"
"旧 /api/ai-agent/run 页面 AI 主链已退场,provider={provider} 不再静默降级;请使用 Pi Lab 与 /api/mnote/tools"
),
)
.with_context(&context)
@@ -74,7 +74,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -114,7 +113,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -156,7 +154,7 @@ mod tests {
.expect("body");
let text = String::from_utf8(body.to_vec()).expect("utf8");
assert!(text.contains("legacy_ai_agent_run_retired"));
assert!(text.contains("Hermes client proxy"));
assert!(text.contains("Pi Lab"));
}
#[tokio::test]
@@ -171,7 +169,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -210,7 +207,7 @@ mod tests {
}
#[tokio::test]
async fn explicit_hermes_provider_fails_instead_of_using_legacy_next_or_direct_bridge() {
async fn explicit_retired_hermes_provider_fails_instead_of_using_legacy_next_or_direct_bridge() {
let next_app = axum::Router::new().route(
"/api/ai-agent/run",
axum::routing::post(|| async move {
@@ -240,7 +237,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
+3 -4
View File
@@ -523,7 +523,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: false,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -601,7 +600,7 @@ mod tests {
"session_id": "test-session-1",
"run_id": "test-run-1",
"profile": "test-profile",
"acp_runtime": "reasonix",
"acp_runtime": "pi",
"status": "running",
"events": [{
"eventType": "message",
@@ -616,7 +615,7 @@ mod tests {
assert_eq!(payload["results"][0]["kind"], "seedAiRuntime");
assert_eq!(payload["results"][0]["run"]["run_id"], "test-run-1");
assert_eq!(payload["results"][0]["run"]["profile"], "test-profile");
assert_eq!(payload["results"][0]["run"]["acp_runtime"], "reasonix");
assert_eq!(payload["results"][0]["run"]["acp_runtime"], "pi");
assert_eq!(payload["results"][0]["run"]["status"], "running");
assert_eq!(payload["results"][0]["events"].as_array().unwrap().len(), 1);
@@ -636,7 +635,7 @@ mod tests {
assert!(payload["results"][0]["run"].is_object());
assert_eq!(payload["results"][0]["run"]["run_id"], "test-run-1");
assert_eq!(payload["results"][0]["run"]["profile"], "test-profile");
assert_eq!(payload["results"][0]["run"]["acp_runtime"], "reasonix");
assert_eq!(payload["results"][0]["run"]["acp_runtime"], "pi");
assert_eq!(payload["results"][0]["run"]["status"], "running");
}
@@ -1197,7 +1197,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -1780,7 +1780,6 @@ mod tests {
enable_debug_shell_routes: true,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -389,7 +389,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
+6 -8
View File
@@ -2998,7 +2998,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url,
convex_admin_key: None,
@@ -3418,7 +3417,7 @@ mod tests {
#[tokio::test]
async fn root_entry_renders_local_first_landing_without_convex() {
let _guard = crate::test_support::hermes_env_lock()
let _guard = crate::test_support::agent_env_lock()
.lock()
.expect("env lock");
let base = temp_root("mnote-root-local-first-landing");
@@ -3521,7 +3520,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -3589,7 +3587,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -3897,7 +3894,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -4039,11 +4035,13 @@ mod tests {
&root_uri,
)
.expect("init local workspace");
// pageId 触发 reveallazy PageTree 在 scope 内展开 active 文档父链。
let page_id = "local-md:design~2F05-editor-mainline~2FTarget.md";
let response = app_with_config("http://127.0.0.1:3100".into(), false)
.oneshot(
Request::builder()
.uri(format!(
"/?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree&fileTreeScope=design"
"/?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree&fileTreeScope=design&pageId={page_id}"
))
.header("x-mnote-actor-id", "user_real")
.header("x-mnote-actor-type", "user")
@@ -4181,7 +4179,7 @@ mod tests {
#[tokio::test]
async fn root_entry_initializes_default_local_workspace_page() {
let _guard = crate::test_support::hermes_env_lock()
let _guard = crate::test_support::agent_env_lock()
.lock()
.expect("env lock");
let base = temp_root("mnote-root-default-local-workspace");
@@ -4239,7 +4237,7 @@ mod tests {
#[tokio::test]
async fn bare_vault_entry_returns_friendly_200_without_convex_bootstrap() {
let _guard = crate::test_support::hermes_env_lock()
let _guard = crate::test_support::agent_env_lock()
.lock()
.expect("env lock");
let response = app_with_config("http://127.0.0.1:3100".into(), false)
-297
View File
@@ -1,297 +0,0 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use axum::extract::{Extension, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::Json;
use bridge_runtime::{
build_failure_response, build_success_response, execute_runtime_input, execute_runtime_query,
runtime_input_requests_result, RuntimeInput,
};
use serde::Serialize;
use serde_json::{json, Value};
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const HEADER_AI_BRIDGE_OWNER: &str = "x-mnote-ai-bridge-owner";
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HermesHealthResponse {
pub ok: bool,
pub service: String,
pub bridge: &'static str,
pub request_id: String,
pub trace_id: String,
}
pub async fn health(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
) -> Json<HermesHealthResponse> {
Json(HermesHealthResponse {
ok: true,
service: state.config().service_name.clone(),
bridge: "hermes",
request_id: context.trace.request_id,
trace_id: context.trace.trace_id,
})
}
pub async fn bridge_runtime(
Extension(context): Extension<RequestContext>,
Json(payload): Json<Value>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let session_id = format!("hermes_{}", context.trace.request_id);
let runtime_input = match serde_json::from_value::<RuntimeInput>(payload.clone()) {
Ok(runtime_input) => runtime_input,
Err(_) => {
return Ok((
StatusCode::OK,
stamp_ai_bridge_headers(),
Json(json!({
"ok": true,
"bridge": "hermes_session",
"sessionId": session_id,
"eventStreamEndpoint": format!("/api/hermes/events/{}", context.trace.request_id),
"canonicalRoute": "/api/hermes/bridge",
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
"contract": ai_bridge_contract(&session_id),
"structuredWrite": {
"owner": "rust-web-hermes",
"allowedCommands": [
"page.body.save",
"tree.node.create",
"kernel.edge.attach"
]
},
"compatPayload": payload,
})),
));
}
};
let payload = if runtime_input_requests_result(&runtime_input) {
match execute_runtime_query(runtime_input) {
Ok(result) => json!({
"ok": true,
"bridge": "hermes_runtime_result",
"sessionId": session_id,
"eventStreamEndpoint": format!("/api/hermes/events/{}", context.trace.request_id),
"canonicalRoute": "/api/hermes/bridge",
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
"contract": ai_bridge_contract(&session_id),
"result": result,
}),
Err(error) => {
let failure = build_failure_response(error);
return Ok((
StatusCode::BAD_REQUEST,
stamp_ai_bridge_headers(),
Json(serde_json::to_value(failure).expect("runtime failure 应可序列化")),
));
}
}
} else {
match execute_runtime_input(runtime_input) {
Ok(plan) => {
let success = build_success_response(plan);
json!({
"ok": success.ok,
"bridge": "hermes_runtime_plan",
"sessionId": session_id,
"eventStreamEndpoint": format!("/api/hermes/events/{}", context.trace.request_id),
"canonicalRoute": "/api/hermes/bridge",
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
"contract": ai_bridge_contract(&session_id),
"plan": success.plan,
})
}
Err(error) => {
let failure = build_failure_response(error);
return Ok((
StatusCode::BAD_REQUEST,
stamp_ai_bridge_headers(),
Json(serde_json::to_value(failure).expect("runtime failure 应可序列化")),
));
}
}
};
Ok((StatusCode::OK, stamp_ai_bridge_headers(), Json(payload)))
}
fn ai_bridge_contract(session_id: &str) -> Value {
json!({
"schema": "mnote.ai_bridge.v1",
"owner": "mnote-web",
"bridge": "hermes",
"sessionId": session_id,
"eventStreamEndpoint": format!("/api/hermes/events/{session_id}"),
"canonicalRoute": "/api/hermes/bridge",
"sessionOwner": "rust-web-hermes",
"toolEventOwner": "rust-web-hermes",
"clientActionOwner": "rust-web-hermes",
"structuredWriteOwner": "rust-web-hermes"
})
}
fn stamp_ai_bridge_headers() -> HeaderMap {
let mut headers = HeaderMap::new();
if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) {
headers.insert(name, HeaderValue::from_static("mnote-web"));
}
if let Ok(name) = HeaderName::from_lowercase(HEADER_AI_BRIDGE_OWNER.as_bytes()) {
headers.insert(name, HeaderValue::from_static("rust-web-hermes"));
}
headers
}
#[cfg(test)]
mod tests {
use crate::app::{build_app, AppConfig, AppState};
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use serde_json::{json, Value};
use tower::util::ServiceExt;
fn app() -> axum::Router {
build_app(AppState::new(AppConfig {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:0".into(),
public_bind_addr: "127.0.0.1:3000".into(),
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
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(),
}))
}
#[tokio::test]
async fn ai_bridge_route_returns_hermes_owner_contract() {
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/hermes/bridge")
.header("content-type", "application/json")
.body(Body::from(
json!({
"kind": "tool",
"context": {
"deploymentId": null,
"projectId": null,
"workspaceId": "ws_demo",
"requestId": "req_1",
"traceId": "trace_1",
"actor": {
"actorType": "user",
"actorId": "user_1",
"sessionId": null
},
"source": {
"channel": "rust-web",
"client": "mnote-web"
},
"tenantId": null,
"authToken": null,
"idempotencyKey": null,
"validateOnly": false,
"dryRun": false
},
"tool": {
"tool": "search_web",
"kind": "query",
"mode": "plan",
"argsJson": {"query": "Rust Web"},
"target": null,
"reason": "owner gate",
"refs": []
},
"data": null
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get("x-mnote-web-owner")
.and_then(|value| value.to_str().ok()),
Some("mnote-web")
);
assert_eq!(
response
.headers()
.get("x-mnote-ai-bridge-owner")
.and_then(|value| value.to_str().ok()),
Some("rust-web-hermes")
);
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["contract"]["schema"], "mnote.ai_bridge.v1");
assert_eq!(payload["contract"]["sessionOwner"], "rust-web-hermes");
assert_eq!(payload["contract"]["toolEventOwner"], "rust-web-hermes");
assert_eq!(payload["contract"]["clientActionOwner"], "rust-web-hermes");
assert_eq!(payload["canonicalRoute"], "/api/hermes/bridge");
assert!(payload["eventStreamEndpoint"]
.as_str()
.unwrap_or_default()
.contains("/api/hermes/events/"));
}
#[tokio::test]
async fn ai_bridge_accepts_legacy_intent_payload_as_hermes_session() {
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/hermes/bridge")
.header("content-type", "application/json")
.body(Body::from(
json!({
"stream": true,
"scope": "document",
"messages": [{"role": "user", "content": "生成摘要"}],
"context": {"documentId": "doc_1"}
})
.to_string(),
))
.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["bridge"], "hermes_session");
assert_eq!(payload["canonicalRoute"], "/api/hermes/bridge");
assert_eq!(
payload["contract"]["structuredWriteOwner"],
"rust-web-hermes"
);
}
}
File diff suppressed because it is too large Load Diff
@@ -299,7 +299,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -505,7 +505,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -144,12 +144,24 @@ fn local_folder_metadata_fingerprints(root: &Path) -> [u64; 4] {
}
fn invalidate_local_folder_metadata_cache(root: &Path) {
let key = root.to_string_lossy().to_string();
// 与 local_folder_watch_revision / metadata 写入侧一致:优先用 canonicalize 后的 key。
let key = root
.canonicalize()
.unwrap_or_else(|_| root.to_path_buf())
.to_string_lossy()
.to_string();
let raw_key = root.to_string_lossy().to_string();
if let Ok(mut cache) = local_folder_metadata_cache().lock() {
cache.remove(&key);
if raw_key != key {
cache.remove(&raw_key);
}
}
if let Ok(mut cache) = local_folder_watch_revision_cache().lock() {
cache.remove(&key);
if raw_key != key {
cache.remove(&raw_key);
}
}
}
@@ -10703,7 +10715,7 @@ mod tests {
use std::sync::Mutex;
fn env_lock() -> &'static Mutex<()> {
crate::test_support::hermes_env_lock()
crate::test_support::agent_env_lock()
}
fn temp_root(name: &str) -> std::path::PathBuf {
@@ -10735,7 +10747,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -10926,6 +10937,7 @@ mod tests {
.expect("write md");
let root_uri = format!("file://{}", root.display());
// 根快照 lazy:仅一层;根级 md 直接可见。
let first = load_local_folder_page_tree_snapshot(&root_uri).expect("first snapshot");
let first_json = first.projection.to_string();
assert!(first_json.contains("local-md:page.md"));
@@ -10934,9 +10946,14 @@ mod tests {
std::fs::create_dir_all(root.join("docs")).expect("create docs");
std::fs::rename(root.join("page.md"), root.join("docs").join("renamed.md"))
.expect("move md");
let second = load_local_folder_page_tree_snapshot(&root_uri).expect("second snapshot");
// 嵌套页需 scope 到父目录才能在 lazy PageTree 中看到。
let second =
load_local_folder_page_tree_scope_snapshot(&root_uri, "docs").expect("second snapshot");
let second_json = second.projection.to_string();
assert!(second_json.contains("local-md:docs~2Frenamed.md"));
assert!(
second_json.contains("local-md:docs~2Frenamed.md"),
"second_json missing expected id; got: {second_json}"
);
assert!(!second_json.contains("local-mdid:stable-frontmatter-id"));
assert!(second_json.contains("renamed.md"));
@@ -10957,9 +10974,14 @@ mod tests {
.expect("write page ids");
let root_uri = format!("file://{}", root.display());
let snapshot = load_local_folder_page_tree_snapshot(&root_uri).expect("snapshot");
// lazy:嵌套 page 在 parent scope 中按 path 派生 id,忽略 page-ids.json 稳定 id。
let snapshot =
load_local_folder_page_tree_scope_snapshot(&root_uri, "docs").expect("snapshot");
let html_json = snapshot.projection.to_string();
assert!(html_json.contains("local-md:docs~2Fpage.md"));
assert!(
html_json.contains("local-md:docs~2Fpage.md"),
"missing path id; got: {html_json}"
);
assert!(!html_json.contains("local-mdid:stable-from-page-ids"));
assert!(!html_json.contains("page-ids.json"));
@@ -15285,13 +15307,18 @@ fn main() {}
);
}
let page_tree = load_local_folder_page_tree_snapshot(&root_uri).expect("page tree");
// lazy 根快照只有 docs 文件夹;真实 page 在 docs scope 内。
let page_tree =
load_local_folder_page_tree_scope_snapshot(&root_uri, "docs").expect("page tree");
let page_items = page_tree.projection["items"]
.as_array()
.expect("page items");
assert!(page_items
.iter()
.any(|item| item["documentId"].as_str() == Some("local-md:docs~2FPage.md")));
assert!(
page_items
.iter()
.any(|item| item["documentId"].as_str() == Some("local-md:docs~2FPage.md")),
"Page.md 应出现在 docs scope PageTree"
);
assert!(!page_items.iter().any(|item| item["documentId"].as_str()
== Some("local-md:docs~2FPage.ocr~2Fphoto.png.ocr.md")));
@@ -15525,14 +15552,23 @@ fn main() {}
let first = local_folder_watch_revision(&root_uri).expect("first revision");
// Mutate tree immediately; TTL should still serve previous revision.
std::fs::write(root.join("docs").join("page.md"), "# Two\n").expect("update md");
// 改长度 + 增文件,避免仅改同长内容时 mtime 精度导致 hash 不变。
std::fs::write(
root.join("docs").join("page.md"),
"# Two — longer body to change len fingerprint\n",
)
.expect("update md");
std::fs::write(root.join("docs").join("extra.md"), "# Extra\n").expect("add md");
let cached = local_folder_watch_revision(&root_uri).expect("cached revision");
assert_eq!(first.revision, cached.revision);
// Explicit invalidation path (same as metadata writes) must force recompute.
invalidate_local_folder_metadata_cache(&root);
let after_invalidate = local_folder_watch_revision(&root_uri).expect("fresh revision");
assert_ne!(first.revision, after_invalidate.revision);
assert_ne!(
first.revision, after_invalidate.revision,
"invalidate 后应看到 entry_count/len 变化"
);
set_local_folder_watch_revision_cache_ttl_ms_for_test(2_000);
let _ = std::fs::remove_dir_all(&root);
@@ -5170,7 +5170,7 @@ mod tests {
let workspace_id = "local-ws-evidence-sqlite";
fs::write(
root.join("README.md"),
"# Home\nIntro body.\n## Evidence Section\nEvidenceToken root body.\nAsk @Reasonix for citation.\n",
"# Home\nIntro body.\n## Evidence Section\nEvidenceToken root body.\nAsk @AtlasNote for citation.\n",
)
.expect("write home");
fs::write(
@@ -5239,7 +5239,7 @@ mod tests {
assert!(section_count >= 3);
let mention_source_block: String = connection
.query_row(
"SELECT source_block_id FROM evidence_edge WHERE edge_type = 'mentions' AND to_id = 'entity:Reasonix'",
"SELECT source_block_id FROM evidence_edge WHERE edge_type = 'mentions' AND to_id = 'entity:AtlasNote'",
[],
|row| row.get(0),
)
@@ -5253,7 +5253,7 @@ mod tests {
)
.expect("mention source locator");
assert!(mention_locator.contains("mnote.evidence_locator.v1"));
let graph_results = query_evidence_graph_results(&root, "Reasonix", None, 10)
let graph_results = query_evidence_graph_results(&root, "AtlasNote", None, 10)
.expect("graph query")
.expect("sqlite exists");
let mention_result = graph_results
@@ -487,7 +487,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -353,7 +353,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -422,7 +421,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -508,7 +506,7 @@ mod tests {
#[test]
fn mindmap_standalone_bootstrap_propagates_dev_hot_to_island_runtime() {
let _guard = crate::test_support::hermes_env_lock()
let _guard = crate::test_support::agent_env_lock()
.lock()
.expect("env lock");
std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1");
+25 -333
View File
@@ -9,9 +9,7 @@ mod editor;
pub(crate) mod evidence;
mod gateway;
mod health;
mod hermes;
mod hermes_client;
mod hermes_tools;
mod mnote_tools;
mod kernel;
pub(crate) mod knowledge_rag;
mod local_folder_events;
@@ -26,8 +24,6 @@ mod mindmap_shell;
pub(crate) mod navigation_recent;
mod onlyoffice;
pub(crate) mod onlyoffice_bridge;
mod page_ai_board;
mod page_ai_opencode;
mod page_ai_pi;
mod page_ai_workflow;
mod query_support;
@@ -43,6 +39,7 @@ mod tree_view_state;
mod ui_debug;
pub(crate) mod ui_preferences;
mod vault;
pub(crate) mod vault_extension_token;
mod vault_path;
mod vault_store;
mod vault_transport;
@@ -72,7 +69,6 @@ use axum::routing::{any, delete, get, post, put};
use axum::Router;
pub fn build_router(state: AppState) -> Router {
let hermes_base_path = state.config().hermes_base_path.clone();
let enable_debug_shell_routes = state.config().enable_debug_shell_routes;
let mut router = Router::new()
@@ -248,42 +244,10 @@ pub fn build_router(state: AppState) -> Router {
"/api/mnote-browser-runtime/sidebar-attachment-open-runtime.js",
get(web_shell::sidebar_attachment_open_runtime_asset),
)
.route(
"/api/mnote-browser-runtime/agent-stream-event-router.js",
get(web_shell::agent_stream_event_router_asset),
)
.route(
"/api/mnote-browser-runtime/sidebar-page-ai-runtime.js",
get(web_shell::sidebar_page_ai_runtime_asset),
)
.route(
"/api/mnote-browser-runtime/sidebar-page-ai-markdown-runtime.js",
get(web_shell::sidebar_page_ai_markdown_runtime_asset),
)
.route(
"/api/mnote-browser-runtime/sidebar-page-ai-render-runtime.js",
get(web_shell::sidebar_page_ai_render_runtime_asset),
)
.route(
"/api/mnote-browser-runtime/sidebar-page-ai-permission-runtime.js",
get(web_shell::sidebar_page_ai_permission_runtime_asset),
)
.route(
"/api/mnote-browser-runtime/sidebar-page-ai-profile-runtime.js",
get(web_shell::sidebar_page_ai_profile_runtime_asset),
)
.route(
"/api/mnote-browser-runtime/sidebar-page-ai-session-runtime.js",
get(web_shell::sidebar_page_ai_session_runtime_asset),
)
.route(
"/api/mnote-browser-runtime/sidebar-page-ai-skill-runtime.js",
get(web_shell::sidebar_page_ai_skill_runtime_asset),
)
.route(
"/api/mnote-browser-runtime/sidebar-page-ai-target-runtime.js",
get(web_shell::sidebar_page_ai_target_runtime_asset),
)
.route(
"/api/mnote-browser-runtime/sidebar-page-ai-pi-lab-runtime.js",
get(web_shell::sidebar_page_ai_pi_lab_runtime_asset),
@@ -411,6 +375,18 @@ pub fn build_router(state: AppState) -> Router {
"/api/vault/items/{id}/unshare-from-ai",
post(vault::unshare_from_ai),
)
.route(
"/api/vault/items/{id}/session",
put(vault::put_item_session),
)
.route(
"/api/vault/extension/token",
post(vault::issue_extension_token),
)
.route(
"/api/vault/extension/token/revoke",
post(vault::revoke_extension_token),
)
.route("/api/vault/ai/list", get(vault::list_ai))
.route("/api/vault/ai/items/{id}", get(vault::get_ai_item))
.route(
@@ -463,140 +439,6 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/auth/whoami", get(session::session))
.route("/api/auth/mnote-web-token", get(session::session))
.route("/api/auth/session/refresh", post(session::refresh_session))
.route(
"/api/ai/agent-profiles",
get(hermes_client::list_agent_profiles),
)
.route(
"/api/page-ai/opencode/status",
get(page_ai_opencode::status),
)
.route(
"/api/page-ai/opencode/session",
post(page_ai_opencode::bind_session),
)
.route(
"/api/page-ai/opencode/sessions",
get(page_ai_opencode::sessions),
)
.route("/api/page-ai/opencode/abort", post(page_ai_opencode::abort))
.route("/api/page-ai/opencode/todo", get(page_ai_opencode::todo))
.route("/api/page-ai/opencode/diff", get(page_ai_opencode::diff))
.route(
"/api/page-ai/opencode/messages",
get(page_ai_opencode::messages),
)
.route(
"/api/page-ai/opencode/prompt",
post(page_ai_opencode::prompt),
)
.route(
"/api/page-ai/opencode/permissions",
get(page_ai_opencode::permissions),
)
.route(
"/api/page-ai/opencode/permission/reply",
post(page_ai_opencode::reply_permission),
)
.route(
"/api/page-ai/opencode/events",
get(page_ai_opencode::events),
)
.route("/page-ai/opencode", any(page_ai_opencode::proxy_root))
.route(
"/page-ai/opencode/assets/{*path}",
any(page_ai_opencode::proxy_assets),
)
.route(
"/page-ai/opencode/favicon-96x96-v3.png",
any(page_ai_opencode::proxy_assets),
)
.route(
"/page-ai/opencode/favicon-v3.svg",
any(page_ai_opencode::proxy_assets),
)
.route(
"/page-ai/opencode/favicon-v3.ico",
any(page_ai_opencode::proxy_assets),
)
.route(
"/page-ai/opencode/apple-touch-icon-v3.png",
any(page_ai_opencode::proxy_assets),
)
.route(
"/page-ai/opencode/site.webmanifest",
any(page_ai_opencode::proxy_assets),
)
.route(
"/page-ai/opencode/social-share.png",
any(page_ai_opencode::proxy_assets),
)
.route("/page-ai/opencode/{*path}", any(page_ai_opencode::proxy))
.route("/assets/{*path}", any(page_ai_opencode::proxy_assets))
.route("/global/{*path}", any(page_ai_opencode::proxy_assets))
.route(
"/favicon-96x96-v3.png",
any(page_ai_opencode::proxy_current_path),
)
.route("/favicon-v3.svg", any(page_ai_opencode::proxy_current_path))
.route("/favicon-v3.ico", any(page_ai_opencode::proxy_current_path))
.route(
"/apple-touch-icon-v3.png",
any(page_ai_opencode::proxy_current_path),
)
.route(
"/site.webmanifest",
any(page_ai_opencode::proxy_current_path),
)
.route(
"/social-share.png",
any(page_ai_opencode::proxy_current_path),
)
.route("/provider", any(page_ai_opencode::proxy_current_path))
.route("/path", any(page_ai_opencode::proxy_current_path))
.route("/project", any(page_ai_opencode::proxy_current_path))
.route(
"/project/{*path}",
any(page_ai_opencode::proxy_current_path),
)
.route("/lsp", any(page_ai_opencode::proxy_current_path))
.route("/command", any(page_ai_opencode::proxy_current_path))
.route("/mcp", any(page_ai_opencode::proxy_current_path))
.route("/agent", any(page_ai_opencode::proxy_current_path))
.route("/config", any(page_ai_opencode::proxy_current_path))
.route("/vcs", any(page_ai_opencode::proxy_current_path))
.route("/permission", any(page_ai_opencode::proxy_current_path))
.route("/question", any(page_ai_opencode::proxy_current_path))
.route("/event", any(page_ai_opencode::proxy_current_path))
.route("/session", any(page_ai_opencode::proxy_current_path))
.route(
"/session/{*path}",
any(page_ai_opencode::proxy_current_path),
)
.route("/new-session", any(page_ai_opencode::proxy_current_path))
.route(
"/{opencode_dir}/session",
any(page_ai_opencode::proxy_current_path),
)
.route(
"/{opencode_dir}/session/{*path}",
any(page_ai_opencode::proxy_current_path),
)
.route("/api/page-ai/board/status", get(page_ai_board::status))
.route("/api/page-ai/board/workers", get(page_ai_board::workers))
.route(
"/api/page-ai/board/workflows",
get(page_ai_board::workflows),
)
.route("/api/page-ai/board/runs", post(page_ai_board::create_run))
.route(
"/api/page-ai/board/runs/{run_id}",
get(page_ai_board::get_run),
)
.route(
"/api/page-ai/board/runs/{run_id}/cancel",
post(page_ai_board::cancel_run),
)
.route("/api/page-ai/pi/status", get(page_ai_pi::status))
.route("/api/page-ai/pi/bootstrap", post(page_ai_pi::bootstrap))
.route("/api/page-ai/pi/start", post(page_ai_pi::start))
@@ -820,63 +662,6 @@ pub fn build_router(state: AppState) -> Router {
"/api/onlyoffice/bridge/capabilities",
get(onlyoffice_bridge::capabilities),
)
.route(
"/api/page-ai/agents/descriptors",
get(hermes_client::list_agent_descriptors),
)
.route("/api/page-ai/runs", post(hermes_client::create_page_ai_run))
.route(
"/api/page-ai/runs/{host_run_id}",
get(hermes_client::get_page_ai_run),
)
.route(
"/api/page-ai/runs/{host_run_id}/events",
get(hermes_client::list_page_ai_run_events),
)
.route(
"/api/page-ai/sessions/{session_id}/active-run",
get(hermes_client::get_page_ai_session_active_run),
)
.route(
"/api/page-ai/runtime/status",
get(hermes_client::get_page_ai_runtime_status),
)
.route(
"/api/page-ai/runtime/reset",
post(hermes_client::reset_page_ai_runtime),
)
.route(
"/api/page-ai/sessions",
get(hermes_client::list_sessions).post(hermes_client::create_session),
)
.route(
"/api/page-ai/sessions/search",
get(hermes_client::search_sessions),
)
.route(
"/api/page-ai/sessions/{session_id}",
get(hermes_client::get_session).delete(hermes_client::delete_session),
)
.route(
"/api/page-ai/sessions/{session_id}/resume",
post(hermes_client::resume_session),
)
.route(
"/api/page-ai/sessions/{session_id}/rename",
post(hermes_client::rename_session),
)
.route(
"/api/page-ai/sessions/{session_id}/export",
get(hermes_client::export_session),
)
.route(
"/api/page-ai/sessions/{session_id}/auto-title",
post(hermes_client::auto_title_session),
)
.route(
"/api/page-ai/sessions/{session_id}/queue/{queue_id}",
delete(hermes_client::cancel_queued_run),
)
.route(
"/api/onlyoffice/bridge/commands",
post(onlyoffice_bridge::enqueue_command),
@@ -1018,94 +803,12 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/tree/events", get(sse::tree_events))
.route("/api/stream/events", get(sse::events))
.route("/api/realtime/ws", get(ws::socket))
.nest(
&hermes_base_path,
Router::new()
.route("/health", get(hermes::health))
.route("/bridge", post(hermes::bridge_runtime))
.route(
"/client/sessions",
get(hermes_client::list_sessions).post(hermes_client::create_session),
)
.route(
"/client/sessions/search",
get(hermes_client::search_sessions),
)
.route(
"/client/sessions/{session_id}",
get(hermes_client::get_session).delete(hermes_client::delete_session),
)
.route(
"/client/sessions/{session_id}/resume",
post(hermes_client::resume_session),
)
.route(
"/client/sessions/{session_id}/rename",
post(hermes_client::rename_session),
)
.route(
"/client/sessions/{session_id}/export",
get(hermes_client::export_session),
)
.route(
"/client/sessions/{session_id}/auto-title",
post(hermes_client::auto_title_session),
)
.route("/client/gateway/health", get(hermes_client::gateway_health))
.route("/client/profiles", get(hermes_client::list_profiles))
.route(
"/client/profiles/active",
put(hermes_client::switch_active_profile),
)
.route(
"/client/profiles/{profile_name}",
get(hermes_client::get_profile),
)
.route(
"/client/profile-memory",
get(hermes_client::get_profile_memory).post(hermes_client::save_profile_memory),
)
.route("/client/skills", get(hermes_client::list_skills))
.route("/client/skills/toggle", put(hermes_client::toggle_skill))
.route(
"/client/capabilities",
get(hermes_client::list_capabilities),
)
.route(
"/client/capabilities/toggle",
put(hermes_client::toggle_capability),
)
.route("/client/tools/toggle", put(hermes_client::toggle_tool))
.route("/client/runs", post(hermes_client::create_run))
.route(
"/client/sessions/{session_id}/queue/{queue_id}",
delete(hermes_client::cancel_queued_run),
)
.route("/client/events/{run_id}", get(hermes_client::stream_events))
.route(
"/client/runs/{run_id}/abort",
post(hermes_client::abort_run),
)
.route(
"/client/runs/{run_id}/resolve-permission",
post(hermes_client::resolve_permission),
)
.route("/client/models", get(hermes_client::list_models))
.route("/client/tools", get(hermes_client::list_tools)),
)
.nest(
"/api/hermes/tools",
Router::new()
.route("/mnote/manifest", get(hermes_tools::mnote_manifest))
.route("/mnote/call", post(hermes_tools::mnote_call))
.route("/mnote/audit", get(hermes_tools::mnote_audit)),
)
.nest(
"/api/mnote/tools",
Router::new()
.route("/manifest", get(hermes_tools::mnote_manifest))
.route("/call", post(hermes_tools::mnote_call))
.route("/audit", get(hermes_tools::mnote_audit)),
.route("/manifest", get(mnote_tools::mnote_manifest))
.route("/call", post(mnote_tools::mnote_call))
.route("/audit", get(mnote_tools::mnote_audit)),
);
if enable_debug_shell_routes {
@@ -1142,7 +845,6 @@ mod tests {
enable_debug_shell_routes,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -1253,15 +955,12 @@ mod tests {
}
#[tokio::test]
async fn mnote_tools_have_current_alias_and_legacy_hermes_mount() {
async fn mnote_tools_mount_is_current_only() {
for (method, path) in [
("GET", "/api/mnote/tools/manifest"),
("POST", "/api/mnote/tools/call"),
("GET", "/api/mnote/tools/audit"),
("GET", "/api/hermes/tools/mnote/manifest"),
("POST", "/api/hermes/tools/mnote/call"),
("GET", "/api/hermes/tools/mnote/audit"),
] {
] {
let response = app(false)
.oneshot(
Request::builder()
@@ -1275,7 +974,7 @@ mod tests {
assert_ne!(
response.status(),
StatusCode::NOT_FOUND,
"{method} {path} 应挂到 MNote tool executorHermes 路径只作为 legacy alias 保留",
"{method} {path} 应挂到 MNote tool executor历史 alias 已移除;仅挂 MNote tool executor",
);
}
}
@@ -1543,12 +1242,12 @@ mod tests {
"rootUri": root_uri,
"documentId": "local-md:ai.md",
"updates": {
"ai.common.default_agent_id": "reasonix",
"ai.common.default_agent_id": "pi",
"ai.common.context_refs.default_selected": {
"current_page": true,
"folder": true
},
"ai.agent.hermes.profile_id": "mnoteai",
"ai.agent.pi.profile_id": "mnoteai",
"localOcr.autoEnabled": true
}
})
@@ -1597,7 +1296,7 @@ mod tests {
let alice_payload: Value = serde_json::from_slice(&alice_body).expect("alice json");
assert_eq!(
alice_payload["result"]["aiPreferences"]["ai.common.default_agent_id"],
"reasonix"
"pi"
);
assert_eq!(
alice_payload["result"]["aiPreferences"]["ai.common.context_refs.default_selected"]
@@ -1605,7 +1304,7 @@ mod tests {
true
);
assert_eq!(
alice_payload["result"]["aiPreferences"]["ai.agent.hermes.profile_id"],
alice_payload["result"]["aiPreferences"]["ai.agent.pi.profile_id"],
"mnoteai"
);
assert_eq!(
@@ -1662,14 +1361,7 @@ mod tests {
"/api/mnote-browser-runtime/sidebar-filetree-upload-runtime.js",
"/api/mnote-browser-runtime/sidebar-attachment-open-runtime.js",
"/api/mnote-browser-runtime/sidebar-page-ai-runtime.js",
"/api/mnote-browser-runtime/agent-stream-event-router.js",
"/api/mnote-browser-runtime/sidebar-page-ai-markdown-runtime.js",
"/api/mnote-browser-runtime/sidebar-page-ai-render-runtime.js",
"/api/mnote-browser-runtime/sidebar-page-ai-permission-runtime.js",
"/api/mnote-browser-runtime/sidebar-page-ai-profile-runtime.js",
"/api/mnote-browser-runtime/sidebar-page-ai-session-runtime.js",
"/api/mnote-browser-runtime/sidebar-page-ai-skill-runtime.js",
"/api/mnote-browser-runtime/sidebar-page-ai-target-runtime.js",
"/api/mnote-browser-runtime/sidebar-page-ai-pi-lab-runtime.js",
"/api/mnote-browser-runtime/sidebar-page-settings-runtime.js",
"/api/mnote-browser-runtime/sidebar-tree-runtime.js",
"/api/mnote-browser-runtime/local-folder-event-bus-runtime.js",
@@ -370,7 +370,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -1999,7 +1999,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -1,334 +0,0 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use axum::extract::{Path, State};
use axum::{Extension, Json};
use reqwest::Method;
use serde_json::{json, Value};
use std::time::Duration;
const DEFAULT_BOARD_BASE_URL: &str = "http://127.0.0.1:3901/api";
const DEFAULT_BOARD_PROJECT_ID: &str = "51067826-50c7-4869-a8cd-5496f08ca8e6";
const DEFAULT_PAGE_AI_WORKFLOW_ID: &str = "builtin-mnote-page-ai-chat";
const DEFAULT_PAGE_AI_WORKER_PRESET_ID: &str = "mnote-page-ai-zcode";
const DEFAULT_PAGE_AI_MODEL_OVERRIDE: &str = "zcode-default";
fn page_ai_worker_options() -> Value {
json!([{
"id": DEFAULT_PAGE_AI_WORKER_PRESET_ID,
"workerPresetId": DEFAULT_PAGE_AI_WORKER_PRESET_ID,
"name": "MNote 页面 AI · ZCode",
"surface": "mnote-page-ai",
"role": "developer",
"agentType": "zcode",
"capabilities": ["text", "repo-edit", "terminal", "mnote-capability-envelope", "local-markdown-edit"],
"modelOptions": page_ai_model_options(),
}])
}
fn page_ai_workflow_options() -> Value {
json!([{
"id": DEFAULT_PAGE_AI_WORKFLOW_ID,
"workflowId": DEFAULT_PAGE_AI_WORKFLOW_ID,
"name": "MNote 页面 AI",
"surface": "mnote-page-ai",
"stages": ["answer"],
}])
}
fn page_ai_model_options() -> Value {
json!([
{ "id": "zcode-default", "label": "默认", "default": true },
{ "id": "zcode-fast", "label": "快速" },
{ "id": "zcode-strong", "label": "强力" },
])
}
fn validate_page_ai_route(
context: &RequestContext,
workflow_id: &str,
worker_preset_id: &str,
model_override: &str,
) -> Result<(), WebError> {
if workflow_id != DEFAULT_PAGE_AI_WORKFLOW_ID
|| worker_preset_id != DEFAULT_PAGE_AI_WORKER_PRESET_ID
{
return Err(WebError::bad_request_code(
"page_ai_board_route_not_allowed",
"Page AI 只能使用 mnote-page-ai 白名单 worker/workflow",
)
.with_context(context));
}
let allowed_models = ["zcode-default", "zcode-fast", "zcode-strong"];
if !allowed_models.contains(&model_override) {
return Err(WebError::bad_request_code(
"page_ai_board_model_not_allowed",
"Page AI 只能使用当前 MNote worker 允许的模型档位",
)
.with_context(context));
}
Ok(())
}
fn ensure_authenticated(context: &RequestContext) -> Result<(), WebError> {
let has_actor = context.auth.actor_id.trim() != "anonymous";
if has_actor || context.auth.authorization.is_some() || context.auth.cookie_header.is_some() {
return Ok(());
}
Err(WebError::new(
axum::http::StatusCode::UNAUTHORIZED,
"page_ai_board_unauthorized",
"页面 AI Agent Board bridge 需要登录后访问",
)
.with_context(context))
}
fn board_base_url() -> String {
std::env::var("MNOTE_AGENT_BOARD_API_BASE")
.or_else(|_| {
std::env::var("MNOTE_AGENT_BOARD_BASE_URL")
.map(|value| format!("{}/api", value.trim_end_matches('/')))
})
.unwrap_or_else(|_| DEFAULT_BOARD_BASE_URL.to_string())
.trim_end_matches('/')
.to_string()
}
fn default_project_id() -> String {
std::env::var("MNOTE_AGENT_BOARD_PROJECT_ID")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| DEFAULT_BOARD_PROJECT_ID.to_string())
}
async fn board_request(
context: &RequestContext,
method: Method,
path: &str,
body: Option<Value>,
) -> Result<Value, WebError> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.build()
.map_err(|error| {
WebError::internal(format!("Agent Board client 构造失败: {error}"))
.with_context(context)
})?;
let url = format!("{}{}", board_base_url(), path);
let mut request = client
.request(method, &url)
.header("accept", "application/json");
if let Some(body) = body {
request = request.json(&body);
}
let response = request.send().await.map_err(|error| {
WebError::bad_gateway_code(
"page_ai_board_unreachable",
format!("无法连接 Agent Board: {error}"),
)
.with_context(context)
})?;
let status = response.status();
let payload = response.json::<Value>().await.unwrap_or_else(|_| json!({}));
if !status.is_success() {
return Err(WebError::bad_gateway_code(
"page_ai_board_error",
format!("Agent Board 返回 HTTP {status}: {payload}"),
)
.with_context(context)
.with_details(payload));
}
Ok(payload)
}
pub async fn status(
Extension(context): Extension<RequestContext>,
) -> Result<Json<Value>, WebError> {
ensure_authenticated(&context)?;
let payload = board_request(&context, Method::GET, "/health", None).await?;
Ok(Json(json!({
"ok": true,
"schema": "mnote.page_ai_board_status.v1",
"baseUrl": board_base_url(),
"projectId": default_project_id(),
"board": payload,
})))
}
pub async fn workers(
Extension(context): Extension<RequestContext>,
) -> Result<Json<Value>, WebError> {
ensure_authenticated(&context)?;
let project_id = default_project_id();
let payload = board_request(
&context,
Method::GET,
&format!("/workers/catalog?projectId={project_id}"),
None,
)
.await
.unwrap_or_else(|_| json!(null));
Ok(Json(json!({
"ok": true,
"schema": "agent_board.page_ai_route.v2",
"surface": "mnote-page-ai",
"projectId": project_id,
"workerPresetId": DEFAULT_PAGE_AI_WORKER_PRESET_ID,
"workerName": "MNote 页面 AI · ZCode",
"allowedWorkerPresetIds": [DEFAULT_PAGE_AI_WORKER_PRESET_ID],
"modelOverride": DEFAULT_PAGE_AI_MODEL_OVERRIDE,
"modelOptions": page_ai_model_options(),
"workers": page_ai_worker_options(),
"boardCatalog": payload,
})))
}
pub async fn workflows(
Extension(context): Extension<RequestContext>,
) -> Result<Json<Value>, WebError> {
ensure_authenticated(&context)?;
let project_id = default_project_id();
let payload = board_request(
&context,
Method::GET,
&format!("/workflow-presets?projectId={project_id}"),
None,
)
.await
.unwrap_or_else(|_| json!(null));
Ok(Json(json!({
"ok": true,
"schema": "agent_board.page_ai_route.v2",
"surface": "mnote-page-ai",
"projectId": project_id,
"workflowId": DEFAULT_PAGE_AI_WORKFLOW_ID,
"workflowName": "MNote 页面 AI",
"allowedWorkflowIds": [DEFAULT_PAGE_AI_WORKFLOW_ID],
"requiresConfirmation": false,
"requires": {
"filesystem": true,
"write": false,
"browser": false,
"vision": false,
},
"stages": ["answer"],
"workflows": page_ai_workflow_options(),
"boardCatalog": payload,
})))
}
pub async fn create_run(
State(_state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(mut body): Json<Value>,
) -> Result<Json<Value>, WebError> {
ensure_authenticated(&context)?;
let project_id = body
.get("projectId")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.unwrap_or_else(default_project_id);
let envelope = body.get("envelope").cloned().unwrap_or_else(|| json!({}));
let user_message = body
.get("message")
.and_then(Value::as_str)
.unwrap_or("请处理当前页面任务")
.trim();
let workflow_id = body
.get("workflowId")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(DEFAULT_PAGE_AI_WORKFLOW_ID)
.to_string();
let worker_preset_id = body
.get("workerPresetId")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(DEFAULT_PAGE_AI_WORKER_PRESET_ID)
.to_string();
let model_override = body
.get("modelOverride")
.and_then(Value::as_str)
.or_else(|| envelope.get("modelOverride").and_then(Value::as_str))
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(DEFAULT_PAGE_AI_MODEL_OVERRIDE)
.to_string();
validate_page_ai_route(&context, &workflow_id, &worker_preset_id, &model_override)?;
let board_message = format!(
"你正在处理 MNote Page AI 发来的任务。你的最终回复会直接显示在页面 AI 对话里。\n\n用户请求:\n{user_message}\n\nMNote Page AI envelope\n```json\n{}\n```\n\n要求:\n1. 只读问答要像普通页面 AI 一样直接回答用户,不要输出 Board 任务报告。\n2. 如果任务要求编辑页面,只修改 envelope.primaryTarget 指向的真实文件,不要调用 MNote 内部页面写入接口。\n3. 你的源头最终回答必须是自然语言 final answer;同时在结构化 receipt.finalAnswer/changedFiles/verification/remaining 中写入运行记录。\n4. 为兼容旧运行器,<task-summary> 可以包含 ## FINAL_ANSWER 段,但不要把 Completed/Comments/Remaining 当作用户主回答。",
serde_json::to_string_pretty(&envelope).unwrap_or_else(|_| "{}".to_string())
);
body["projectId"] = Value::String(project_id.clone());
body["message"] = Value::String(board_message);
body["envelope"] = envelope;
body["surface"] = Value::String("mnote-page-ai".into());
body["workflowId"] = Value::String(workflow_id.clone());
body["workerPresetId"] = Value::String(worker_preset_id.clone());
body["modelOverride"] = Value::String(model_override.clone());
if body.get("autoRun").is_none() {
body["autoRun"] = Value::Bool(true);
}
let payload = board_request(&context, Method::POST, "/workflow-runs", Some(body)).await?;
Ok(Json(json!({
"ok": true,
"schema": "mnote.page_ai_board_run.v1",
"surface": "mnote-page-ai",
"projectId": project_id,
"workflowId": workflow_id,
"workerPresetId": worker_preset_id,
"modelOverride": model_override,
"board": payload,
})))
}
pub async fn get_run(
Extension(context): Extension<RequestContext>,
Path(run_id): Path<String>,
) -> Result<Json<Value>, WebError> {
ensure_authenticated(&context)?;
let details = board_request(
&context,
Method::GET,
&format!("/workflow-runs/{run_id}"),
None,
)
.await?;
let receipt = board_request(
&context,
Method::GET,
&format!("/workflow-runs/{run_id}/receipt"),
None,
)
.await
.unwrap_or_else(|_| json!(null));
Ok(Json(json!({
"ok": true,
"schema": "mnote.page_ai_board_run_status.v1",
"runId": run_id,
"board": details,
"receipt": receipt,
})))
}
pub async fn cancel_run(
Extension(context): Extension<RequestContext>,
Path(run_id): Path<String>,
) -> Result<Json<Value>, WebError> {
ensure_authenticated(&context)?;
let payload = board_request(
&context,
Method::POST,
&format!("/workflow-runs/{run_id}/cancel"),
Some(json!({})),
)
.await?;
Ok(Json(
json!({ "ok": true, "runId": run_id, "board": payload }),
))
}
File diff suppressed because it is too large Load Diff
@@ -7,7 +7,7 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::knowledge_rag as knowledge_rag_agent_output;
use crate::mnote_agent_tools::knowledge_rag as knowledge_rag_agent_output;
use crate::routes::{ai_settings, knowledge_rag, local_folder_source};
use axum::extract::{Extension, Query, State};
use axum::http::{HeaderMap, StatusCode};
@@ -8956,7 +8956,6 @@ mod tests {
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,
@@ -1,7 +1,7 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::ToolCallInput;
use crate::mnote_agent_tools::ToolCallInput;
use axum::extract::{Extension, State};
use axum::http::{HeaderMap, StatusCode};
use axum::Json;
@@ -132,7 +132,7 @@ pub async fn block_edit_workflow(
};
let apply_started = Instant::now();
let tool_response =
crate::routes::hermes_tools::execute_mnote_tool_call(&state, &context, edit_input).await?;
crate::routes::mnote_tools::execute_mnote_tool_call(&state, &context, edit_input).await?;
let apply_result = tool_response.get("result").cloned().unwrap_or(Value::Null);
let apply_ms = apply_started.elapsed().as_millis();
info!(
@@ -518,21 +518,35 @@ fn quoted_segments(value: &str) -> Vec<String> {
segments
}
fn hermes_home() -> PathBuf {
std::env::var("HERMES_HOME")
fn agent_profile_home() -> PathBuf {
std::env::var("MNOTE_AGENT_HOME")
.ok()
.filter(|value| !value.trim().is_empty())
.map(PathBuf::from)
.or_else(|| {
std::env::var("HOME")
std::env::var("HERMES_HOME")
.ok()
.map(|home| PathBuf::from(home).join(".hermes"))
.filter(|value| !value.trim().is_empty())
.map(PathBuf::from)
})
.unwrap_or_else(|| PathBuf::from(".hermes"))
.or_else(|| {
std::env::var("HOME").ok().and_then(|home| {
let preferred = PathBuf::from(&home).join(".mnote-agent");
if preferred.exists() {
return Some(preferred);
}
let legacy = PathBuf::from(&home).join(".hermes");
if legacy.exists() {
return Some(legacy);
}
Some(preferred)
})
})
.unwrap_or_else(|| PathBuf::from(".mnote-agent"))
}
fn profile_config_path(profile: &str) -> PathBuf {
let home = hermes_home();
let home = agent_profile_home();
let profile = profile.trim();
if profile.is_empty() || profile == "default" {
return home.join("config.yaml");
@@ -598,7 +612,7 @@ mod tests {
use tower::util::ServiceExt;
fn env_lock() -> &'static Mutex<()> {
crate::test_support::hermes_env_lock()
crate::test_support::agent_env_lock()
}
fn app() -> axum::Router {
@@ -612,7 +626,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -763,12 +776,12 @@ mod tests {
async fn block_edit_workflow_respects_disabled_markdown_edit_tool() {
let _guard = env_lock().lock().expect("env lock");
let base_url = spawn_mock_model_server().await;
let hermes_home = std::env::temp_dir().join(format!(
let agent_home = std::env::temp_dir().join(format!(
"mnote-page-ai-workflow-disabled-tool-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&hermes_home);
let profile_dir = hermes_home.join("profiles").join("mnoteai");
let _ = fs::remove_dir_all(&agent_home);
let profile_dir = agent_home.join("profiles").join("mnoteai");
fs::create_dir_all(&profile_dir).expect("profile dir");
fs::write(
profile_dir.join("config.yaml"),
@@ -777,7 +790,7 @@ mod tests {
),
)
.expect("profile config");
std::env::set_var("HERMES_HOME", &hermes_home);
std::env::set_var("MNOTE_AGENT_HOME", &agent_home);
let response = app()
.oneshot(
@@ -821,20 +834,20 @@ mod tests {
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["code"], "mnote_tool_disabled");
std::env::remove_var("HERMES_HOME");
let _ = fs::remove_dir_all(&hermes_home);
std::env::remove_var("MNOTE_AGENT_HOME");
let _ = fs::remove_dir_all(&agent_home);
}
#[tokio::test]
async fn block_edit_workflow_forwards_allowed_target_blocks_to_markdown_edit() {
let _guard = env_lock().lock().expect("env lock");
let base_url = spawn_out_of_scope_mock_model_server().await;
let hermes_home = std::env::temp_dir().join(format!(
let agent_home = std::env::temp_dir().join(format!(
"mnote-page-ai-workflow-selection-scope-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&hermes_home);
let profile_dir = hermes_home.join("profiles").join("mnoteai");
let _ = fs::remove_dir_all(&agent_home);
let profile_dir = agent_home.join("profiles").join("mnoteai");
fs::create_dir_all(&profile_dir).expect("profile dir");
fs::write(
profile_dir.join("config.yaml"),
@@ -843,7 +856,7 @@ mod tests {
),
)
.expect("profile config");
std::env::set_var("HERMES_HOME", &hermes_home);
std::env::set_var("MNOTE_AGENT_HOME", &agent_home);
let response = app()
.oneshot(
@@ -888,20 +901,20 @@ mod tests {
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["code"], "mnote_markdown_edit_target_out_of_scope");
std::env::remove_var("HERMES_HOME");
let _ = fs::remove_dir_all(&hermes_home);
std::env::remove_var("MNOTE_AGENT_HOME");
let _ = fs::remove_dir_all(&agent_home);
}
#[tokio::test]
async fn block_edit_workflow_surfaces_model_summary_for_read_and_edit_request() {
let _guard = env_lock().lock().expect("env lock");
let base_url = spawn_read_and_edit_mock_model_server().await;
let hermes_home = std::env::temp_dir().join(format!(
let agent_home = std::env::temp_dir().join(format!(
"mnote-page-ai-workflow-read-summary-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&hermes_home);
let profile_dir = hermes_home.join("profiles").join("mnoteai");
let _ = fs::remove_dir_all(&agent_home);
let profile_dir = agent_home.join("profiles").join("mnoteai");
fs::create_dir_all(&profile_dir).expect("profile dir");
fs::write(
profile_dir.join("config.yaml"),
@@ -910,7 +923,7 @@ mod tests {
),
)
.expect("profile config");
std::env::set_var("HERMES_HOME", &hermes_home);
std::env::set_var("MNOTE_AGENT_HOME", &agent_home);
let response = app()
.oneshot(
@@ -962,7 +975,7 @@ mod tests {
.unwrap_or_default()
.contains("测试123"));
std::env::remove_var("HERMES_HOME");
let _ = fs::remove_dir_all(&hermes_home);
std::env::remove_var("MNOTE_AGENT_HOME");
let _ = fs::remove_dir_all(&agent_home);
}
}
@@ -1164,7 +1164,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
+7 -10
View File
@@ -677,18 +677,18 @@ fn fallback_search_dataset(workspace_id: &str) -> Value {
"updatedAt": "2026-04-28T00:00:00Z"
},
{
"id": "doc_hermes",
"id": "doc_skill_graph",
"workspaceId": workspace_id,
"title": "Hermes",
"rawText": "Hermes 技能知识图谱开发 Wolai aline fixture",
"title": "SkillGraph",
"rawText": "SkillGraph 技能知识图谱开发 Wolai aline fixture",
"createdAt": "2026-04-30T00:00:00Z",
"updatedAt": "2026-04-30T00:00:00Z"
},
{
"id": "doc_hermes_skill",
"id": "doc_skill_path",
"workspaceId": workspace_id,
"title": "技能知识图谱开发",
"rawText": "Hermes 页面路径 个人 软件开发",
"rawText": "SkillGraph 页面路径 个人 软件开发",
"createdAt": "2026-04-30T00:00:00Z",
"updatedAt": "2026-04-30T00:00:00Z"
}
@@ -777,7 +777,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -908,7 +907,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -928,7 +926,7 @@ mod tests {
.body(Body::from(
json!({
"workspaceId": "ws_demo",
"query": "Hermes"
"query": "SkillGraph"
})
.to_string(),
))
@@ -944,7 +942,7 @@ mod tests {
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["results"], json!([]));
assert_eq!(payload["meta"]["degraded"], true);
assert!(!payload.to_string().contains("Hermes 技能知识图谱开发"));
assert!(!payload.to_string().contains("SkillGraph 技能知识图谱开发"));
}
#[tokio::test]
@@ -1520,7 +1518,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
+1 -5
View File
@@ -230,7 +230,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -333,7 +332,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -403,7 +401,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -460,7 +457,7 @@ mod tests {
#[tokio::test]
async fn session_returns_admin_for_local_access_policy_admin() {
let _guard = crate::test_support::hermes_env_lock()
let _guard = crate::test_support::agent_env_lock()
.lock()
.expect("env lock");
let policy_root = std::env::temp_dir().join(format!(
@@ -484,7 +481,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
-1
View File
@@ -359,7 +359,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
-3
View File
@@ -2726,7 +2726,6 @@ mod tests {
enable_debug_shell_routes: true,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -3072,7 +3071,6 @@ mod tests {
enable_debug_shell_routes: true,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -3818,7 +3816,6 @@ mod tests {
enable_debug_shell_routes: true,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
+267 -2
View File
@@ -10,9 +10,10 @@ use crate::routes::local_folder_source::{
self as local_folder_source, ensure_local_workspace_read_access_with_state,
ensure_local_workspace_write_access_with_state,
};
use crate::routes::vault_extension_token;
use crate::routes::vault_store::{
self, SecretPatch, VaultAccountSlot, VaultCreateInput, VaultItemStatus, VaultSecretSlot,
VaultUpdateInput,
self, SecretPatch, VaultAccountSlot, VaultCreateInput, VaultItemStatus, VaultLoginSession,
VaultSecretSlot, VaultSessionCookie, VaultUpdateInput,
};
use axum::extract::{Extension, Path, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
@@ -1312,6 +1313,270 @@ pub async fn login_ai_item(
Ok(ok_response(&context, result))
}
/// PUT /api/vault/items/{id}/session — human / chrome-extension session file write (12-3)
/// body: { rootUri, accountId?, cookieHeader?, cookies[]?, origin?, expiresAt?, source? }
pub async fn put_item_session(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Path(id): Path<String>,
Json(body): Json<Value>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
require_vault_enabled()?;
let actor = require_authenticated(&context)?;
let map = body.as_object().ok_or_else(|| {
WebError::bad_request_code("vault_body_invalid", "请求体必须是 JSON 对象")
})?;
let root_uri = require_root_uri(
map.get("rootUri")
.and_then(Value::as_str)
.or_else(|| map.get("root_uri").and_then(Value::as_str)),
)?;
let root = resolve_write_root(&state, &context, root_uri).await?;
let account_id = map
.get("accountId")
.or_else(|| map.get("account_id"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned);
let cookies = parse_session_cookies(map.get("cookies"));
let cookie_header = map
.get("cookieHeader")
.or_else(|| map.get("cookie_header"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned);
if cookie_header.is_none() && cookies.is_empty() {
return Err(WebError::bad_request_code(
"vault_session_cookie_required",
"cookieHeader 与 cookies[] 至少一个非空",
));
}
let source = map
.get("source")
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or("chrome_extension")
.to_string();
let session = VaultLoginSession {
cookie_header,
expires_at: map
.get("expiresAt")
.or_else(|| map.get("expires_at"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned),
last_login_at: map
.get("lastLoginAt")
.or_else(|| map.get("last_login_at"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned),
source: Some(source),
origin: map
.get("origin")
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned),
account_id: account_id.clone(),
cookies,
revision: None,
};
let record = vault_store::put_login_session_for_account(
&root,
&id,
account_id.as_deref(),
session,
)?;
let _ = vault_store::append_vault_audit(
&root,
"session_put",
&actor,
&id,
None,
Some(context.trace.request_id.as_str()),
true,
);
let acc = record
.login_session
.as_ref()
.and_then(|s| s.account_id.clone())
.or(account_id)
.unwrap_or_else(|| "primary".into());
let expires = record
.login_session
.as_ref()
.and_then(|s| s.expires_at.clone());
let rev = record
.login_session
.as_ref()
.and_then(|s| s.revision)
.unwrap_or(1);
Ok(ok_response(
&context,
json!({
"credentialId": id,
"accountId": acc,
"hasLoginSession": true,
"sessionExpiresAt": expires,
"revision": rev,
"item": vault_store::project_item_l0_with_cipher(&record, Some(&root)),
}),
))
}
fn parse_session_cookies(value: Option<&Value>) -> Vec<VaultSessionCookie> {
let Some(arr) = value.and_then(Value::as_array) else {
return Vec::new();
};
arr.iter()
.filter_map(|item| {
let obj = item.as_object()?;
let name = obj
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())?
.to_string();
let value = obj
.get("value")
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned);
Some(VaultSessionCookie {
name,
value,
domain: obj
.get("domain")
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned),
path: obj
.get("path")
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned),
secure: obj.get("secure").and_then(Value::as_bool),
http_only: obj
.get("httpOnly")
.or_else(|| obj.get("http_only"))
.and_then(Value::as_bool),
same_site: obj
.get("sameSite")
.or_else(|| obj.get("same_site"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned),
expiration_date: obj
.get("expirationDate")
.or_else(|| obj.get("expiration_date"))
.and_then(|v| v.as_f64().or_else(|| v.as_i64().map(|i| i as f64))),
})
})
.collect()
}
/// POST /api/vault/extension/token — issue E2 human token (mnext1.*) after session login.
pub async fn issue_extension_token(
Extension(context): Extension<RequestContext>,
Json(body): Json<Value>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
require_vault_enabled()?;
let actor = require_authenticated(&context)?;
let map = body.as_object();
let client_id = map
.and_then(|m| m.get("clientId").or_else(|| m.get("client_id")))
.and_then(Value::as_str);
let extension_id = map
.and_then(|m| m.get("extensionId").or_else(|| m.get("extension_id")))
.and_then(Value::as_str);
let ttl_hours = map
.and_then(|m| m.get("ttlHours").or_else(|| m.get("ttl_hours")))
.and_then(Value::as_u64);
let email = map
.and_then(|m| m.get("email"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty());
let issued = vault_extension_token::issue_extension_token(
&actor,
email,
client_id,
extension_id,
ttl_hours,
)?;
Ok(ok_response(
&context,
json!({
"token": issued.token,
"expiresAt": vault_extension_token::exp_to_rfc3339(issued.claims.exp),
"scope": issued.claims.scope,
"userId": issued.claims.actor,
"email": issued.claims.email,
"jti": issued.claims.jti,
"aud": issued.claims.aud,
}),
))
}
/// POST /api/vault/extension/token/revoke — revoke by jti or current Bearer.
pub async fn revoke_extension_token(
Extension(context): Extension<RequestContext>,
Json(body): Json<Value>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
require_vault_enabled()?;
let _actor = require_authenticated(&context)?;
let jti_from_body = body
.get("jti")
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned);
let jti = if let Some(j) = jti_from_body {
j
} else if let Some(token) =
vault_extension_token::bearer_mnext1(context.auth.authorization.as_deref())
{
let claims = vault_extension_token::verify_extension_token(token)?;
claims.jti
} else {
return Err(WebError::bad_request_code(
"vault_ext_jti_required",
"吊销需要 jti 或当前 Bearer mnext1 token",
));
};
vault_extension_token::revoke_jti(&jti)?;
Ok(ok_response(
&context,
json!({
"revoked": true,
"jti": jti,
}),
))
}
/// POST /api/vault/ai/items/{id}/session — human/browser cookie write-back
/// body: { cookieHeader, expiresAt?, source? }
pub async fn put_ai_session(
@@ -0,0 +1,466 @@
//! Chrome extension human token (12-3 E2).
//!
//! Format: `mnext1.<base64url(payload_json)>.<base64url(hmac_sha256)>`
//! Separate HMAC key / aud from agent `mnv1.*` tokens (12-2).
use crate::error::WebError;
use axum::http::StatusCode;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use uuid::Uuid;
type HmacSha256 = Hmac<Sha256>;
pub const TOKEN_PREFIX: &str = "mnext1";
pub const TOKEN_VERSION: u32 = 1;
pub const ISSUER: &str = "mnote-web";
pub const AUDIENCE: &str = "chrome-extension-vault";
pub const SCOPE_VIEW: &str = "vault.view";
pub const SCOPE_EDIT: &str = "vault.edit";
pub const DEFAULT_TTL_HOURS: u64 = 168;
pub const MAX_TTL_HOURS: u64 = 720; // 30d
const DEFAULT_HMAC_KEY_REL: &str = ".config/mnote/vault-extension-hmac.key";
const DEFAULT_REVOKE_REL: &str = ".config/mnote/vault-extension-revoked.jti";
static REVOKE_LOCK: Mutex<()> = Mutex::new(());
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ExtensionTokenClaims {
pub v: u32,
pub iss: String,
pub aud: String,
pub sub: String,
pub actor: String,
pub scope: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub extension_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
pub iat: u64,
/// 0 = no expiry (not used for extension tokens)
pub exp: u64,
pub jti: String,
}
#[derive(Debug, Clone)]
pub struct IssuedExtensionToken {
pub token: String,
pub claims: ExtensionTokenClaims,
}
fn dirs_path_home() -> PathBuf {
std::env::var_os("HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("USERPROFILE").map(PathBuf::from))
.unwrap_or_else(|| PathBuf::from("."))
}
pub fn default_hmac_key_path() -> PathBuf {
if let Ok(p) = std::env::var("MNOTE_VAULT_EXTENSION_HMAC_KEY") {
let p = p.trim();
if !p.is_empty() {
return PathBuf::from(p);
}
}
dirs_path_home().join(DEFAULT_HMAC_KEY_REL)
}
fn default_revoke_path() -> PathBuf {
if let Ok(p) = std::env::var("MNOTE_VAULT_EXTENSION_REVOKE_FILE") {
let p = p.trim();
if !p.is_empty() {
return PathBuf::from(p);
}
}
dirs_path_home().join(DEFAULT_REVOKE_REL)
}
fn now_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn getrandom_fill(buf: &mut [u8]) -> Result<(), WebError> {
use std::io::Read;
let mut f = fs::File::open("/dev/urandom").map_err(|e| {
WebError::internal(format!("open /dev/urandom: {e}"))
})?;
f.read_exact(buf)
.map_err(|e| WebError::internal(format!("read urandom: {e}")))?;
Ok(())
}
/// Load or create a random 32-byte HMAC secret (hex file, 0600 when possible).
pub fn load_or_create_hmac_key(path: &Path) -> Result<Vec<u8>, WebError> {
if path.exists() {
let raw = fs::read_to_string(path).map_err(|e| {
WebError::bad_request_code(
"vault_ext_hmac_key_read_failed",
format!("无法读取 extension HMAC key {}: {e}", path.display()),
)
})?;
let hex = raw.trim();
if hex.len() < 32 {
return Err(WebError::bad_request_code(
"vault_ext_hmac_key_invalid",
"extension HMAC key 过短",
));
}
return hex::decode(hex).map_err(|e| {
WebError::bad_request_code(
"vault_ext_hmac_key_invalid",
format!("extension HMAC key 非 hex: {e}"),
)
});
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| {
WebError::bad_request_code(
"vault_ext_hmac_key_write_failed",
format!("无法创建目录: {e}"),
)
})?;
}
let mut bytes = [0u8; 32];
getrandom_fill(&mut bytes)?;
let hex = hex::encode(bytes);
fs::write(path, format!("{hex}\n")).map_err(|e| {
WebError::bad_request_code(
"vault_ext_hmac_key_write_failed",
format!("无法写入 extension HMAC key: {e}"),
)
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
}
Ok(bytes.to_vec())
}
pub fn issue_extension_token(
actor_id: &str,
email: Option<&str>,
client_id: Option<&str>,
extension_id: Option<&str>,
ttl_hours: Option<u64>,
) -> Result<IssuedExtensionToken, WebError> {
let actor = actor_id.trim();
if actor.is_empty() || actor == "anonymous" {
return Err(WebError::new(
StatusCode::UNAUTHORIZED,
"vault_ext_auth_required",
"签发 extension token 需要已登录会话",
));
}
let hours = ttl_hours
.unwrap_or(DEFAULT_TTL_HOURS)
.clamp(1, MAX_TTL_HOURS);
let iat = now_unix();
let exp = iat.saturating_add(hours.saturating_mul(3600));
let claims = ExtensionTokenClaims {
v: TOKEN_VERSION,
iss: ISSUER.into(),
aud: AUDIENCE.into(),
sub: format!("user:{actor}"),
actor: actor.to_string(),
scope: vec![SCOPE_VIEW.into(), SCOPE_EDIT.into()],
client_id: client_id
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned),
extension_id: extension_id
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned),
email: email
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned),
iat,
exp,
jti: Uuid::new_v4().to_string(),
};
let key = load_or_create_hmac_key(&default_hmac_key_path())?;
let token = encode_token(&key, &claims)?;
Ok(IssuedExtensionToken { token, claims })
}
pub fn encode_token(hmac_key: &[u8], claims: &ExtensionTokenClaims) -> Result<String, WebError> {
let payload = serde_json::to_vec(claims).map_err(|e| {
WebError::internal(format!("extension token serialize: {e}"))
})?;
let payload_b64 = URL_SAFE_NO_PAD.encode(&payload);
let mut mac = HmacSha256::new_from_slice(hmac_key)
.map_err(|e| WebError::internal(format!("hmac init: {e}")))?;
mac.update(payload_b64.as_bytes());
let sig = mac.finalize().into_bytes();
let sig_b64 = URL_SAFE_NO_PAD.encode(sig);
Ok(format!("{TOKEN_PREFIX}.{payload_b64}.{sig_b64}"))
}
/// Verify Bearer raw token string (with or without "Bearer " prefix stripped by caller).
pub fn verify_extension_token(token: &str) -> Result<ExtensionTokenClaims, WebError> {
let token = token.trim();
let parts: Vec<&str> = token.split('.').collect();
if parts.len() != 3 || parts[0] != TOKEN_PREFIX {
return Err(WebError::new(
StatusCode::UNAUTHORIZED,
"vault_ext_token_invalid",
"extension token 格式无效(期望 mnext1.<payload>.<sig>",
));
}
let key = load_or_create_hmac_key(&default_hmac_key_path())?;
let payload_b64 = parts[1];
let sig_b64 = parts[2];
let mut mac = HmacSha256::new_from_slice(&key)
.map_err(|e| WebError::internal(format!("hmac init: {e}")))?;
mac.update(payload_b64.as_bytes());
let expected = mac.finalize().into_bytes();
let sig = URL_SAFE_NO_PAD.decode(sig_b64).map_err(|_| {
WebError::new(
StatusCode::UNAUTHORIZED,
"vault_ext_token_invalid",
"extension token 签名解码失败",
)
})?;
if sig.len() != expected.len()
|| sig
.iter()
.zip(expected.iter())
.fold(0u8, |acc, (a, b)| acc | (a ^ b))
!= 0
{
return Err(WebError::new(
StatusCode::UNAUTHORIZED,
"vault_ext_token_invalid",
"extension token 签名校验失败",
));
}
let payload = URL_SAFE_NO_PAD.decode(payload_b64).map_err(|_| {
WebError::new(
StatusCode::UNAUTHORIZED,
"vault_ext_token_invalid",
"extension token payload 解码失败",
)
})?;
let claims: ExtensionTokenClaims = serde_json::from_slice(&payload).map_err(|_| {
WebError::new(
StatusCode::UNAUTHORIZED,
"vault_ext_token_invalid",
"extension token payload JSON 无效",
)
})?;
if claims.v != TOKEN_VERSION {
return Err(WebError::new(
StatusCode::UNAUTHORIZED,
"vault_ext_token_invalid",
format!("不支持的 extension token 版本 {}", claims.v),
));
}
if claims.aud != AUDIENCE {
return Err(WebError::new(
StatusCode::UNAUTHORIZED,
"vault_ext_token_invalid",
"extension token aud 不匹配",
));
}
if claims.iss != ISSUER {
return Err(WebError::new(
StatusCode::UNAUTHORIZED,
"vault_ext_token_invalid",
"extension token iss 不匹配",
));
}
if claims.exp != 0 && now_unix() > claims.exp {
return Err(WebError::new(
StatusCode::UNAUTHORIZED,
"vault_ext_token_expired",
"extension token 已过期",
));
}
if is_jti_revoked(&claims.jti)? {
return Err(WebError::new(
StatusCode::UNAUTHORIZED,
"vault_ext_token_revoked",
"extension token 已吊销",
));
}
if claims.actor.trim().is_empty() || claims.actor.trim() == "anonymous" {
return Err(WebError::new(
StatusCode::UNAUTHORIZED,
"vault_ext_token_invalid",
"extension token actor 无效",
));
}
Ok(claims)
}
pub fn require_scope(claims: &ExtensionTokenClaims, need: &str) -> Result<(), WebError> {
let set: BTreeSet<&str> = claims.scope.iter().map(|s| s.as_str()).collect();
if set.contains(need) || set.contains("*") {
return Ok(());
}
Err(WebError::new(
StatusCode::FORBIDDEN,
"vault_ext_scope_denied",
format!("extension token 缺少 scope: {need}"),
))
}
/// Extract Bearer token if it looks like mnext1.*
pub fn bearer_mnext1(authorization: Option<&str>) -> Option<&str> {
let auth = authorization?.trim();
let token = auth
.strip_prefix("Bearer ")
.or_else(|| auth.strip_prefix("bearer "))
.map(str::trim)
.filter(|s| !s.is_empty())?;
if token.starts_with(TOKEN_PREFIX) {
Some(token)
} else {
None
}
}
fn load_revoked_jtis(path: &Path) -> Result<BTreeSet<String>, WebError> {
if !path.exists() {
return Ok(BTreeSet::new());
}
let raw = fs::read_to_string(path).map_err(|e| {
WebError::internal(format!("读取 extension revoke 文件失败: {e}"))
})?;
let mut set = BTreeSet::new();
for line in raw.lines() {
let j = line.trim();
if !j.is_empty() && !j.starts_with('#') {
set.insert(j.to_string());
}
}
Ok(set)
}
fn is_jti_revoked(jti: &str) -> Result<bool, WebError> {
let path = default_revoke_path();
let _guard = REVOKE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let set = load_revoked_jtis(&path)?;
Ok(set.contains(jti.trim()))
}
pub fn revoke_jti(jti: &str) -> Result<(), WebError> {
let jti = jti.trim();
if jti.is_empty() {
return Err(WebError::bad_request_code(
"vault_ext_jti_required",
"吊销需要 jti",
));
}
let path = default_revoke_path();
let _guard = REVOKE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let mut set = load_revoked_jtis(&path)?;
if !set.insert(jti.to_string()) {
return Ok(());
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| {
WebError::internal(format!("创建 revoke 目录失败: {e}"))
})?;
}
let mut lines: Vec<String> = set.into_iter().collect();
lines.sort();
let body = format!("{}\n", lines.join("\n"));
fs::write(&path, body).map_err(|e| {
WebError::internal(format!("写入 revoke 文件失败: {e}"))
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600));
}
Ok(())
}
pub fn exp_to_rfc3339(exp: u64) -> String {
use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime;
OffsetDateTime::from_unix_timestamp(exp as i64)
.ok()
.and_then(|t| t.format(&Rfc3339).ok())
.unwrap_or_else(|| exp.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static TEST_LOCK: Mutex<()> = Mutex::new(());
fn with_temp_key_env<F: FnOnce()>(f: F) {
let _g = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = std::env::temp_dir().join(format!(
"mnote-ext-token-test-{}",
Uuid::new_v4()
));
let _ = fs::create_dir_all(&dir);
let key = dir.join("hmac.key");
let rev = dir.join("revoked.jti");
std::env::set_var("MNOTE_VAULT_EXTENSION_HMAC_KEY", &key);
std::env::set_var("MNOTE_VAULT_EXTENSION_REVOKE_FILE", &rev);
f();
std::env::remove_var("MNOTE_VAULT_EXTENSION_HMAC_KEY");
std::env::remove_var("MNOTE_VAULT_EXTENSION_REVOKE_FILE");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn issue_verify_roundtrip() {
with_temp_key_env(|| {
let issued = issue_extension_token(
"user_demo",
Some("demo@example.com"),
Some("chrome-extension"),
Some("ext_id_1"),
Some(24),
)
.expect("issue");
assert!(issued.token.starts_with("mnext1."));
let claims = verify_extension_token(&issued.token).expect("verify");
assert_eq!(claims.actor, "user_demo");
assert!(claims.scope.contains(&SCOPE_VIEW.to_string()));
assert!(claims.scope.contains(&SCOPE_EDIT.to_string()));
require_scope(&claims, SCOPE_EDIT).expect("scope");
});
}
#[test]
fn revoke_blocks_verify() {
with_temp_key_env(|| {
let issued =
issue_extension_token("user_demo", None, None, None, Some(1)).expect("issue");
revoke_jti(&issued.claims.jti).expect("revoke");
let err = verify_extension_token(&issued.token).expect_err("revoked");
assert_eq!(err.code(), "vault_ext_token_revoked");
});
}
#[test]
fn agent_token_prefix_rejected() {
with_temp_key_env(|| {
let err = verify_extension_token("mnv1.abc.def").expect_err("reject");
assert_eq!(err.code(), "vault_ext_token_invalid");
});
}
}
File diff suppressed because it is too large Load Diff
+11 -177
View File
@@ -328,7 +328,6 @@ pub async fn document_page_shell(
vault_nav_href={vault_nav_href}
/>
});
let hermes_settings_config_script = render_hermes_settings_config_script();
let html = format!(
r#"<!doctype html>
<html lang="zh-CN">
@@ -349,7 +348,6 @@ pub async fn document_page_shell(
{}
{}
{}
{}
</body>
</html>"#,
escape_html(title),
@@ -364,7 +362,6 @@ pub async fn document_page_shell(
escape_script_json(&snapshot_json),
escape_script_json(&bootstrap_json),
escape_script_json(&panes_bootstrap_json),
hermes_settings_config_script,
secondary_snapshot_json
.as_ref()
.map(|value| format!(r#"<script id="__MNOTE_SECONDARY_PAGE_AGGREGATE__" type="application/json">{}</script>"#, escape_script_json(value)))
@@ -510,59 +507,6 @@ pub(crate) fn build_editor_bootstrap_json(
)
}
fn render_hermes_settings_config_script() -> String {
let Some(base_url) = [
"MNOTE_WEB_HERMES_UPSTREAM_URL",
"MNOTE_HERMES_UPSTREAM_URL",
"MNOTE_HERMES_API_BASE_URL",
]
.into_iter()
.find_map(env_or_dotenv)
.map(|value| value.trim().trim_end_matches('/').to_string())
.filter(|value| !value.is_empty()) else {
return String::new();
};
let settings_url = format!("{base_url}/hermes/settings");
let encoded = serde_json::to_string(&settings_url).unwrap_or_else(|_| "\"\"".to_string());
format!(
r#"<script>window.__mnoteHermesSettingsUrl = {};</script>"#,
escape_script_json(&encoded)
)
}
fn env_or_dotenv(key: &str) -> Option<String> {
if let Ok(value) = std::env::var(key) {
let trimmed = value.trim().trim_matches('"').to_string();
if !trimmed.is_empty() {
return Some(trimmed);
}
}
if cfg!(test) {
return None;
}
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../..")
.join(".env.all");
let content = fs::read_to_string(root).ok()?;
for line in content.lines() {
let line = line.trim_end_matches('\r');
if line.starts_with('#') || line.trim().is_empty() {
continue;
}
let Some((candidate_key, value)) = line.split_once('=') else {
continue;
};
if candidate_key.trim() != key {
continue;
}
let trimmed = value.trim().trim_matches('"').to_string();
if !trimmed.is_empty() {
return Some(trimmed);
}
}
None
}
pub(crate) fn build_editor_bootstrap_json_with_ids(
aggregate: &PageAggregate,
context: &RequestContext,
@@ -2511,15 +2455,6 @@ pub async fn sidebar_tree_runtime_asset() -> Response {
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn agent_stream_event_router_asset() -> Response {
const JS: &str = include_str!("../../browser/agent-stream-event-router.js");
Response::builder()
.header("content-type", "application/javascript; charset=utf-8")
.header("cache-control", "public, max-age=3600")
.body(Body::from(JS))
.expect("agent-stream-event-router.js")
}
pub async fn sidebar_page_ai_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-page-ai-runtime.js");
Response::builder()
@@ -2534,104 +2469,6 @@ pub async fn sidebar_page_ai_runtime_asset() -> Response {
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn sidebar_page_ai_markdown_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-page-ai-markdown-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(browser_runtime_js_body(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn sidebar_page_ai_render_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-page-ai-render-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(browser_runtime_js_body(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn sidebar_page_ai_permission_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-page-ai-permission-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(browser_runtime_js_body(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn sidebar_page_ai_profile_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-page-ai-profile-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(browser_runtime_js_body(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn sidebar_page_ai_session_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-page-ai-session-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(browser_runtime_js_body(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn sidebar_page_ai_skill_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-page-ai-skill-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(browser_runtime_js_body(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn sidebar_page_ai_target_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-page-ai-target-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(browser_runtime_js_body(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn sidebar_page_ai_pi_lab_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-page-ai-pi-lab-runtime.js");
Response::builder()
@@ -3749,7 +3586,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -3807,7 +3643,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -3929,7 +3764,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: Some("http://127.0.0.1:9".into()),
convex_admin_key: Some("test-admin-key".into()),
@@ -4072,7 +3906,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -4447,7 +4280,7 @@ mod tests {
#[tokio::test]
async fn mnote_browser_runtime_assets_are_not_cached_during_dev_hot() {
let _guard = crate::test_support::hermes_env_lock()
let _guard = crate::test_support::agent_env_lock()
.lock()
.expect("env lock");
std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1");
@@ -4476,7 +4309,7 @@ mod tests {
#[tokio::test]
async fn mnote_browser_runtime_module_imports_carry_dev_hot_buster() {
let _guard = crate::test_support::hermes_env_lock()
let _guard = crate::test_support::agent_env_lock()
.lock()
.expect("env lock");
std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1");
@@ -4508,7 +4341,7 @@ mod tests {
#[tokio::test]
async fn editor_runtime_preload_links_use_dev_hot_buster() {
let _guard = crate::test_support::hermes_env_lock()
let _guard = crate::test_support::agent_env_lock()
.lock()
.expect("env lock");
std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1");
@@ -4536,7 +4369,7 @@ mod tests {
#[tokio::test]
async fn leptos_tiptap_entry_imports_carry_dev_hot_buster() {
let _guard = crate::test_support::hermes_env_lock()
let _guard = crate::test_support::agent_env_lock()
.lock()
.expect("env lock");
std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1");
@@ -4570,7 +4403,7 @@ mod tests {
#[tokio::test]
async fn dev_hot_runtime_serves_page_block_pane_navigation_bridge() {
let _guard = crate::test_support::hermes_env_lock()
let _guard = crate::test_support::agent_env_lock()
.lock()
.expect("env lock");
std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1");
@@ -4841,7 +4674,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -4910,7 +4742,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -5069,7 +4900,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -5339,7 +5169,12 @@ mod tests {
assert!(html.contains("Local Shell"));
assert!(html.contains("data-page-aggregate-snapshot=\"mnote.page_aggregate.v1\""));
assert!(html.contains("\"saveEndpoint\":\"/api/page-body/write\""));
assert!(html.contains("Child Page"));
// lazy PageTree:只 reveal active 文档路径,不把 sibling「Child Page」扫进首屏。
assert!(
html.contains(r#"data-node-id="local-md:Local~20Shell~2FLocal~20Shell.md""#)
|| html.contains("Local Shell"),
"active 本地页应出现在 shell / PageTree 首屏"
);
assert!(html.contains("asset.png"));
assert!(html.contains("data-row-kind="));
assert!(html.contains("data-page-openable=\"false\""));
@@ -5620,7 +5455,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
+83 -205
View File
@@ -241,25 +241,12 @@ mod tests {
include_str!("../../../browser/sidebar-workspace-runtime.js");
const SIDEBAR_PAGE_TREE_RUNTIME_JS: &str =
include_str!("../../../browser/sidebar-page-tree-runtime.js");
// Browser Page AI runtime is served as a module; keep this include explicit so
// resume/journal contract checks inspect the actual shipped JS.
// Page AI 产品面:facadePi 入口桥接)+ Pi Lab runtime。Hermes/OpenCode 子模块已物理删除。
const SIDEBAR_PAGE_AI_RUNTIME_JS: &str =
include_str!("../../../browser/sidebar-page-ai-runtime.js");
const SIDEBAR_PAGE_AI_PI_LAB_RUNTIME_JS: &str =
include_str!("../../../browser/sidebar-page-ai-pi-lab-runtime.js");
const MNOTE_UI_RUNTIME_JS: &str = include_str!("../../../browser/mnote-ui-runtime.js");
const SIDEBAR_PAGE_AI_MARKDOWN_RUNTIME_JS: &str =
include_str!("../../../browser/sidebar-page-ai-markdown-runtime.js");
const SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS: &str =
include_str!("../../../browser/sidebar-page-ai-render-runtime.js");
const SIDEBAR_PAGE_AI_PERMISSION_RUNTIME_JS: &str =
include_str!("../../../browser/sidebar-page-ai-permission-runtime.js");
const SIDEBAR_PAGE_AI_PROFILE_RUNTIME_JS: &str =
include_str!("../../../browser/sidebar-page-ai-profile-runtime.js");
const SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS: &str =
include_str!("../../../browser/sidebar-page-ai-session-runtime.js");
const SIDEBAR_PAGE_AI_SKILL_RUNTIME_JS: &str =
include_str!("../../../browser/sidebar-page-ai-skill-runtime.js");
const SIDEBAR_PAGE_AI_TARGET_RUNTIME_JS: &str =
include_str!("../../../browser/sidebar-page-ai-target-runtime.js");
const SIDEBAR_PAGE_SETTINGS_RUNTIME_JS: &str =
include_str!("../../../browser/sidebar-page-settings-runtime.js");
const FILETREE_CONTEXT_MENU_RUNTIME_JS: &str =
@@ -341,7 +328,12 @@ mod tests {
assert!(SIDEBAR_PAGE_TREE_RUNTIME_JS.contains("application/x-mnote-page-tree-node"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("dragstart"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("drop"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("data-drop-feedback"));
// drop feedback 已外置到 filetree-dnd / page-tree runtimetree 仍委托 drop 入口
assert!(
SIDEBAR_TREE_RUNTIME_JS.contains("data-drop-feedback")
|| FILETREE_DND_RUNTIME_JS.contains("data-drop-feedback")
|| SIDEBAR_PAGE_TREE_RUNTIME_JS.contains("data-drop-feedback")
);
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("action: 'move'"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("sidebar-file-tree-root"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("tree.filetree.open"));
@@ -623,7 +615,7 @@ mod tests {
#[test]
fn page_layout_adds_dev_hot_cache_buster_to_browser_runtime_scripts() {
let _guard = crate::test_support::hermes_env_lock()
let _guard = crate::test_support::agent_env_lock()
.lock()
.expect("env lock");
std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1");
@@ -745,6 +737,8 @@ mod tests {
< html.find(r#"data-testid="wolai-floating-ai""#)
);
assert!(html.contains(r#"data-mnote-action="open-page-ai-pi-lab""#));
assert!(html.contains(r#"aria-label="Pi Lab""#));
assert!(html.contains(r#"title="打开 Pi Lab""#));
assert!(!html.contains(r#"data-mnote-action="open-page-ai""#));
assert!(!html.contains(r#"data-mnote-action="open-knowledge-rag-settings""#));
assert!(html.contains(r#"data-icon="travel_explore""#));
@@ -755,6 +749,33 @@ mod tests {
assert!(!html.contains(r#"data-mnote-action="toggle-ocr-tasks""#));
}
#[test]
fn page_ai_product_entry_is_pi_lab_only() {
// 产品入口:浮钮 + tree click + openPageAiDrawer 均只打开 Pi Lab。
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("产品唯一入口:Pi Lab"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("mnote:pi-lab-show"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function openPageAiDrawer"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function installPageAiDelegates"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function updatePageAiTriggerState"));
assert!(SIDEBAR_PAGE_AI_PI_LAB_RUNTIME_JS.contains("createSidebarPageAiPiLabRuntime"));
assert!(SIDEBAR_PAGE_AI_PI_LAB_RUNTIME_JS.contains("mnote:pi-lab-show"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("产品面唯一 Page AIPi Lab"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("mnote:pi-lab-show"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains(r#"data-mnote-action="open-page-ai-pi-lab""#));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("sidebarPageAi.installPageAiDelegates()"));
// Hermes/OpenCode drawer 与子模块已物理删除
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiOpencodeHostEnabled"));
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiSessionRuntime"));
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiRenderRuntime"));
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("/api/hermes/"));
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("切换到 Hermes"));
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("切换到 OpenCode"));
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("切换到 Hermes"));
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("切换到 OpenCode"));
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("pageAiOpenHermesSettings"));
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("pageAiSetHideHermesBuiltinSkills"));
}
#[test]
fn sidebar_settings_runtime_routes_index_and_ocr_to_knowledge_rag_settings() {
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("mnote-knowledge-rag-settings-popover"));
@@ -879,196 +900,43 @@ mod tests {
}
#[test]
fn page_ai_fast_path_is_not_local_first_main_path() {
assert!(
SIDEBAR_PAGE_AI_RUNTIME_JS
.contains("if (currentSourceKind() === 'local_folder') return false;"),
"local-first 页面 AI 不应继续加厚 page-ai fast-path;本地编辑应走受控文件工具"
);
fn page_ai_facade_is_pi_lab_bridge_only() {
// facade 只桥接 Pi Lab;不承载 Hermes session / ACP / OpenCode host。
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("export function createSidebarPageAiRuntime"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("window.createSidebarPageAiPiLabRuntime"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("mnote:pi-lab-show"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("mnote:pi-lab-hide"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("window.__mnoteSidebarPageAiRuntime"));
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiOpencode"));
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiAcpRuntime"));
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("reasonix"));
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("/api/page-ai/sessions"));
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("/api/hermes/"));
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiMarkdownRuntime"));
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiPermissionRuntime"));
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiProfileRuntime"));
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiRenderRuntime"));
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiSessionRuntime"));
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiSkillRuntime"));
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiTargetRuntime"));
}
#[test]
fn page_ai_local_source_passes_file_reference_fields_to_agent_run() {
fn page_ai_pi_lab_runtime_is_product_host() {
assert!(SIDEBAR_PAGE_AI_PI_LAB_RUNTIME_JS.contains("function createSidebarPageAiPiLabRuntime"));
assert!(SIDEBAR_PAGE_AI_PI_LAB_RUNTIME_JS.contains("mnote:pi-lab-show"));
assert!(SIDEBAR_PAGE_AI_PI_LAB_RUNTIME_JS.contains("mnote:pi-lab-hide"));
assert!(SIDEBAR_PAGE_AI_PI_LAB_RUNTIME_JS.contains("/api/page-ai/pi/"));
assert!(SIDEBAR_WORKSPACE_RUNTIME_JS.contains("function currentRootUri()"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("sourceKind: currentSourceKind()"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("rootUri: currentRootUri()"));
assert!(SIDEBAR_WORKSPACE_RUNTIME_JS
.contains("if (sourceKind && !payload.sourceKind) payload.sourceKind = sourceKind"));
assert!(SIDEBAR_WORKSPACE_RUNTIME_JS
.contains("if (rootUri && !payload.rootUri) payload.rootUri = rootUri"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageContext: scopedContext.pageContext"));
assert!(
SIDEBAR_PAGE_AI_RUNTIME_JS
.contains("if (currentSourceKind() === 'local_folder') return false;"),
"local source 不应进入 page-ai fast-path,后端会把 run 收敛为文件引用 scope"
);
assert!(
!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiEnrichOcrContextRefs"),
"Page AI 目标包不应保留已退役 OCR sidecar context enrichment"
);
assert!(
!SIDEBAR_PAGE_AI_TARGET_RUNTIME_JS.contains("ocrRootRelativePath"),
"Page AI target runtime 不应再注入旧 OCR sidecar 路径"
);
}
#[test]
fn page_ai_agent_target_picker_contract_is_visible_and_serialized() {
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiRenderRuntime"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS
.contains("export function createSidebarPageAiRenderRuntime"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-target-button"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-target-popover"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-target-option"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-target-chip"));
assert!(SIDEBAR_PAGE_AI_TARGET_RUNTIME_JS.contains("primaryTargetId"));
assert!(SIDEBAR_PAGE_AI_TARGET_RUNTIME_JS.contains("targets: ["));
assert!(SIDEBAR_PAGE_AI_TARGET_RUNTIME_JS.contains("policy: {"));
}
#[test]
fn page_ai_uses_backend_acp_session_runtime_store() {
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiSessionRuntime"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiSkillRuntime"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiTargetRuntime"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("function renderPageAiControls"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("function ensurePageAiDrawer"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("function renderPageAiConversation"));
assert!(SIDEBAR_PAGE_AI_SKILL_RUNTIME_JS
.contains("export function createSidebarPageAiSkillRuntime"));
assert!(SIDEBAR_PAGE_AI_TARGET_RUNTIME_JS
.contains("export function createSidebarPageAiTargetRuntime"));
assert!(
SIDEBAR_PAGE_AI_TARGET_RUNTIME_JS.contains("function currentPageAiOpenEditorsSnapshot")
);
assert!(SIDEBAR_PAGE_AI_TARGET_RUNTIME_JS.contains("mnote.agent_target_package.v1"));
assert!(SIDEBAR_PAGE_AI_SKILL_RUNTIME_JS.contains("function pageAiSkillSourceOptions"));
assert!(SIDEBAR_PAGE_AI_SKILL_RUNTIME_JS.contains("function pageAiSkillPreferenceTable"));
assert!(SIDEBAR_PAGE_AI_SKILL_RUNTIME_JS.contains("ai.agent.reasonix.memory_enabled"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("function pageAiLoadBackendSessions"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("/api/page-ai/sessions?"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("params.set('source', 'acp')"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains(
"var draftSessions = pageAiNormalizeArray(pageUiState.pageAiSessions).filter"
));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS
.contains("pageAiDedupeSessions(backendSessions.concat(draftSessions))"));
assert!(
SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("function pageAiLoadBackendSessionDetail")
);
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("function pageAiSearchBackendSessions"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS
.contains("function pageAiDeleteSelectedBackendSessions"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("function pageAiCheckActiveRun"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("/api/page-ai/sessions/"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("/active-run?"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-session-rename"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-session-delete"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-session-select"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-session-delete-selected"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-session-resume"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-session-search"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("function pageAiRenderThoughtGroup"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-thought-card"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-collapse-card"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiUsageSummary"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("usage.updated"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("thought.delta"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("permission.requested"));
assert!(SIDEBAR_PAGE_AI_PERMISSION_RUNTIME_JS
.contains("data-page-ai-permission-action=\"allow\""));
assert!(SIDEBAR_PAGE_AI_PERMISSION_RUNTIME_JS
.contains("data-page-ai-permission-action=\"deny\""));
assert!(
SIDEBAR_PAGE_AI_PERMISSION_RUNTIME_JS.contains("function pageAiHidePermissionDialog")
);
assert!(SIDEBAR_PAGE_AI_PERMISSION_RUNTIME_JS.contains("if (!message.resolved)"));
assert!(
!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("(item.resolved ? ' disabled' : '')"),
"已决 ACP permission 事件不能继续展示假审批按钮"
);
assert!(
SIDEBAR_PAGE_AI_RUNTIME_JS.contains("session.info.updated"),
"ACP SessionInfoUpdate 事件应通过 session.info.updated SSE 转发到前端"
);
assert!(
SIDEBAR_PAGE_AI_RUNTIME_JS.contains("plan.updated"),
"ACP PlanUpdate 事件应通过 plan.updated SSE 转发到前端"
);
assert!(
SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-plan"),
"plan 消息应渲染为 data-page-ai-plan 标记的轻量系统状态面板"
);
assert!(
SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("执行计划 · "),
"plan 面板标题应显示执行计划和步数"
);
}
#[test]
fn page_ai_session_ui_labels_local_shared_and_cloud_storage() {
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("function pageAiSessionStorageLabel"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("local_private"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("local_shared"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("sqlite_control_plane"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("convex_acp_runtime_store"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("本地私有"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("共享会话"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("账号会话"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("云端会话"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("sessionStorage:"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("permissionLevel:"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("shareId:"));
}
#[test]
fn page_ai_acp_runtime_legacy_selector_contract_is_explicit() {
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("var PAGE_AI_SESSION_STORAGE_VERSION = 4"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function ensurePageAiStateFacade"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiAcpRuntime: 'reasonix'"));
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("pageAiAcpRuntime: 'reasonix'"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiPermissionRuntime"));
assert!(SIDEBAR_PAGE_AI_PERMISSION_RUNTIME_JS.contains("function pageAiResolvePermission"));
assert!(SIDEBAR_PAGE_AI_PERMISSION_RUNTIME_JS.contains("resolve-permission"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiProfileRuntime"));
assert!(SIDEBAR_PAGE_AI_PROFILE_RUNTIME_JS.contains("function pageAiNormalizeAcpRuntimes"));
assert!(SIDEBAR_PAGE_AI_PROFILE_RUNTIME_JS.contains("return ['reasonix', 'hermes']"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiNormalizeAcpRuntimes(acpRuntimes)"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiAcpRuntimeSelect.value || 'reasonix'"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function handlePageAiClick"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function handlePageAiPointerDown"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("mnote.page_ai.drawer_width"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-resize-handle"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function pageAiOpenCitationUrl"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("a[data-page-ai-citation-link=\"true\"]"));
assert!(SIDEBAR_PAGE_AI_MARKDOWN_RUNTIME_JS.contains("data-page-ai-citation-link=\"true\""));
assert!(SIDEBAR_PAGE_AI_MARKDOWN_RUNTIME_JS.contains("renderPageAiMarkdownTable"));
assert!(SIDEBAR_PAGE_AI_MARKDOWN_RUNTIME_JS.contains("wolai-page-ai-markdown-table-wrap"));
assert!(SIDEBAR_PAGE_AI_MARKDOWN_RUNTIME_JS.contains("target=\"_blank\""));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function handlePageAiChange"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function installPageAiDelegates"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("document.addEventListener('visibilitychange'"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiCheckActiveRun(sessionId || undefined)"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function pageAiCheckAndResumeActiveRun"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function pageAiResumeActiveRunJournal"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function pageAiTrackStreamingRunEvent"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("void pageAiCheckAndResumeActiveRun().catch"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("mnote.page_ai_active_run_snapshot.v1"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("data-mnote-page-ai-active-run-last-seq"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("/api/page-ai/runs/"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("afterSeq="));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiCheckAndResumeActiveRun()"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("/api/page-ai/agents/descriptors"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function pageAiLoadAgentDescriptors"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("data-page-ai-descriptor-field"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("function pageAiRenderDescriptorField"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-agent-descriptor-card"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("sidebarPageAi.installPageAiDelegates()"));
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("sidebarPageAi.handlePageAi"));
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("closestAction(e.target, '[data-page-ai-action"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS
.contains("activeAcpRuntime: pageUiState.pageAiAcpRuntime || 'reasonix'"));
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("默认 (Hermes HTTP)"));
// 已退役:OCR sidecar / Hermes agent 切换 / ACP session store 前端
assert!(!SIDEBAR_PAGE_AI_PI_LAB_RUNTIME_JS.contains("pageAiEnrichOcrContextRefs"));
assert!(!SIDEBAR_PAGE_AI_PI_LAB_RUNTIME_JS.contains("ocrRootRelativePath"));
assert!(!SIDEBAR_PAGE_AI_PI_LAB_RUNTIME_JS.contains("切换到 Hermes"));
assert!(!SIDEBAR_PAGE_AI_PI_LAB_RUNTIME_JS.contains("切换到 OpenCode"));
}
#[test]
@@ -1158,8 +1026,10 @@ mod tests {
assert!(LOCAL_FOLDER_EVENT_BUS_RUNTIME_JS.contains("tree:local-folder-watch-batch"));
assert!(LOCAL_FOLDER_EVENT_BUS_RUNTIME_JS.contains("viaEventBus: true"));
assert!(LOCAL_FOLDER_EVENT_BUS_RUNTIME_JS.contains("emitSyntheticWatchBatch"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("__mnoteLocalFolderEventBus"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("synthetic_page_ai_receipt"));
// Page AI 写回本地文件夹:由 Pi Lab runtime 调 event bus,不再经 legacy drawer。
assert!(SIDEBAR_PAGE_AI_PI_LAB_RUNTIME_JS.contains("__mnoteLocalFolderEventBus"));
assert!(SIDEBAR_PAGE_AI_PI_LAB_RUNTIME_JS.contains("emitChangedFiles"));
assert!(SIDEBAR_PAGE_AI_PI_LAB_RUNTIME_JS.contains("pi_lab_tool_call"));
assert!(TREE_LIVE_CONTROLLER_JS.contains("__mnoteLocalFolderEventBus"));
assert!(TREE_LIVE_CONTROLLER_JS.contains("startLocalFolderWatcher"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
@@ -2032,9 +1902,17 @@ mod tests {
SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("recordFileTreeActionStatus('blocked'")
);
assert!(SIDEBAR_FILETREE_UPLOAD_RUNTIME_JS.contains("/api/tree/filetree/drop-preflight"));
assert!(SIDEBAR_FILETREE_COMMAND_RUNTIME_JS
.contains("moveSidebarFileTreeRows(runtimeState.sidebarFileTreeClipboard.rowIds"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("ensureFileTreeWritableTarget('drop'"));
assert!(
SIDEBAR_FILETREE_COMMAND_RUNTIME_JS
.contains("moveSidebarFileTreeRows(runtimeState.sidebarFileTreeClipboard.rowIds")
|| SIDEBAR_FILETREE_COMMAND_RUNTIME_JS
.contains("moveSidebarFileTreeRows(clipboard.rowIds")
);
assert!(
SIDEBAR_TREE_RUNTIME_JS.contains("ensureFileTreeWritableTarget('drop'")
|| FILETREE_DND_RUNTIME_JS.contains("ensureFileTreeWritableTarget('drop'")
|| SIDEBAR_FILETREE_UPLOAD_RUNTIME_JS.contains("ensureFileTreeWritableTarget")
);
}
#[test]
+2 -1
View File
@@ -220,7 +220,8 @@ mod tests {
assert!(MNOTE_CSS.len() > 2000);
// CSS 已拆分为模块化文件,通过 concat!(include_str!()) 组装;
// 当前包含主壳、Page AI、搜索、toast、debug 与 vault 样式,继续用上限防止意外重复打包。
assert!(MNOTE_CSS.len() < 220000);
// vault workbench 样式增长后合计约 234KB;上限放宽到 280KB。
assert!(MNOTE_CSS.len() < 280000);
}
#[test]
@@ -2002,37 +2002,6 @@ button.wolai-page-ai-history-main span {
border-color: rgba(27, 28, 28, 0.32);
}
.wolai-page-ai-reasonix-controls {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
padding: 0 8px 6px;
}
.wolai-page-ai-reasonix-controls[hidden] {
display: none !important;
}
.wolai-page-ai-reasonix-control {
display: grid;
gap: 2px;
min-width: 0;
color: #8B8782;
font-size: 11px;
}
.wolai-page-ai-reasonix-control select {
width: 100%;
min-width: 0;
height: 28px;
padding: 0 8px;
border: 1px solid rgba(27, 28, 28, 0.1);
border-radius: 7px;
background: #FFF;
color: #1B1C1C;
font-size: 12px;
}
.wolai-page-ai-composer-bar {
display: flex;
min-height: 36px;
@@ -2214,298 +2183,3 @@ button.wolai-page-ai-history-main span {
}
}
.wolai-page-ai-drawer[data-page-ai-opencode-host="true"] .wolai-page-ai-panel {
display: flex;
flex-direction: column;
gap: 0;
overflow: hidden;
}
.wolai-page-ai-opencode-header {
flex: 0 0 auto;
}
.wolai-page-ai-opencode-chrome {
display: flex;
flex: 0 0 auto;
flex-direction: column;
gap: 6px;
padding: 8px 12px 10px;
border-bottom: 1px solid rgba(27, 28, 28, 0.08);
background: rgba(247, 247, 245, 0.92);
}
.wolai-page-ai-opencode-row {
display: grid;
grid-template-columns: 44px minmax(0, 1fr);
gap: 6px;
align-items: baseline;
font-size: 12px;
color: rgba(27, 28, 28, 0.58);
}
.wolai-page-ai-opencode-row strong,
.wolai-page-ai-opencode-row code {
min-width: 0;
overflow: hidden;
color: rgba(27, 28, 28, 0.86);
text-overflow: ellipsis;
white-space: nowrap;
}
.wolai-page-ai-opencode-badges,
.wolai-page-ai-opencode-files {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.wolai-page-ai-opencode-badges span,
.wolai-page-ai-opencode-empty,
.wolai-page-ai-opencode-chip {
display: inline-flex;
align-items: center;
max-width: 100%;
min-height: 24px;
padding: 3px 8px;
border: 1px solid rgba(27, 28, 28, 0.08);
border-radius: 999px;
background: #fff;
color: rgba(27, 28, 28, 0.68);
font: inherit;
font-size: 12px;
}
.wolai-page-ai-opencode-chip {
cursor: pointer;
}
.wolai-page-ai-opencode-chip:hover {
border-color: rgba(35, 131, 226, 0.32);
color: var(--wolai-accent, #2383e2);
}
.wolai-page-ai-opencode-chip span {
margin-left: 6px;
color: rgba(27, 28, 28, 0.45);
}
.wolai-page-ai-opencode-frame-wrap {
position: relative;
display: flex;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
background: #fff;
}
.wolai-page-ai-opencode-iframe {
flex: 1 1 auto;
width: 100%;
min-height: 0;
border: 0;
background: #fff;
}
.wolai-page-ai-opencode-iframe[hidden] {
display: none;
}
.wolai-page-ai-opencode-iframe-fallback {
position: absolute;
inset: 0;
display: grid;
place-items: center;
padding: 24px;
color: rgba(27, 28, 28, 0.62);
text-align: center;
background: #fff;
}
.wolai-page-ai-opencode-iframe-fallback[hidden] {
display: none;
}
.wolai-page-ai-opencode-chat {
display: flex;
flex: 1 1 auto;
min-height: 0;
flex-direction: column;
background: #fff;
}
.wolai-page-ai-opencode-messages {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
padding: 12px;
}
.wolai-page-ai-opencode-message {
margin: 0 0 10px;
padding: 10px 12px;
border: 1px solid rgba(27, 28, 28, 0.08);
border-radius: 12px;
background: rgba(247, 247, 245, 0.9);
}
.wolai-page-ai-opencode-message[data-role="assistant"] {
background: #fff;
}
.wolai-page-ai-opencode-message-role {
margin-bottom: 4px;
color: rgba(27, 28, 28, 0.52);
font-size: 11px;
font-weight: 600;
}
.wolai-page-ai-opencode-message-body {
color: rgba(27, 28, 28, 0.88);
font-size: 13px;
line-height: 1.55;
}
.wolai-page-ai-opencode-composer {
display: flex;
gap: 8px;
padding: 10px 12px 12px;
border-top: 1px solid rgba(27, 28, 28, 0.08);
}
.wolai-page-ai-opencode-composer textarea {
flex: 1 1 auto;
min-height: 42px;
resize: vertical;
border: 1px solid rgba(27, 28, 28, 0.14);
border-radius: 10px;
padding: 8px 10px;
font: inherit;
}
.wolai-page-ai-opencode-send,
.wolai-page-ai-opencode-permission button {
border: 0;
border-radius: 10px;
padding: 0 12px;
background: #1f6feb;
color: #fff;
font-weight: 600;
}
.wolai-page-ai-opencode-permissions {
display: flex;
flex-direction: column;
gap: 6px;
padding: 0 12px;
}
.wolai-page-ai-opencode-permission {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 6px;
align-items: center;
padding: 8px;
border: 1px solid rgba(227, 115, 14, 0.24);
border-radius: 10px;
background: rgba(255, 247, 237, 0.92);
font-size: 12px;
}
.wolai-page-ai-opencode-permission span {
overflow: hidden;
color: rgba(27, 28, 28, 0.62);
text-overflow: ellipsis;
white-space: nowrap;
}
.wolai-page-ai-opencode-sessions-wrap {
font-size: 12px;
}
.wolai-page-ai-opencode-sessions {
display: grid;
gap: 4px;
margin-top: 6px;
}
.wolai-page-ai-opencode-session-row {
display: grid;
min-width: 0;
gap: 2px;
border: 1px solid rgba(27, 28, 28, 0.08);
border-radius: 8px;
padding: 6px 8px;
background: rgba(255, 255, 255, 0.72);
color: inherit;
text-align: left;
}
.wolai-page-ai-opencode-session-row[data-active="true"] {
border-color: rgba(31, 111, 235, 0.38);
background: rgba(31, 111, 235, 0.08);
}
.wolai-page-ai-opencode-session-row span,
.wolai-page-ai-opencode-message-role span {
overflow: hidden;
color: rgba(27, 28, 28, 0.52);
text-overflow: ellipsis;
white-space: nowrap;
}
.wolai-page-ai-opencode-message-role {
display: flex;
justify-content: space-between;
gap: 8px;
}
.wolai-page-ai-opencode-part {
margin-top: 8px;
border: 1px solid rgba(27, 28, 28, 0.08);
border-radius: 10px;
padding: 8px;
background: rgba(250, 250, 249, 0.88);
font-size: 12px;
}
.wolai-page-ai-opencode-part summary {
cursor: pointer;
font-weight: 650;
}
.wolai-page-ai-opencode-part pre,
.wolai-page-ai-opencode-error {
margin: 8px 0 0;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
}
.wolai-page-ai-opencode-tool summary {
display: flex;
justify-content: space-between;
gap: 8px;
}
.wolai-page-ai-opencode-patch {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.wolai-page-ai-opencode-patch button,
.wolai-page-ai-opencode-part button {
border: 1px solid rgba(27, 28, 28, 0.12);
border-radius: 999px;
padding: 3px 8px;
background: #fff;
}
.wolai-page-ai-opencode-todo {
margin: 0;
padding: 0 12px;
list-style-position: inside;
font-size: 12px;
}
@@ -456,6 +456,44 @@
border-top: 1px solid rgba(27, 28, 28, 0.06);
}
/* 账号下登录态(扩展 Cookie session)状态徽章 */
.mnote-vault-session-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 1px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
line-height: 18px;
letter-spacing: 0.01em;
}
.mnote-vault-session-badge.is-saved {
background: rgba(34, 160, 107, 0.12);
color: #1a7a4c;
border: 1px solid rgba(34, 160, 107, 0.28);
}
.mnote-vault-session-badge.is-absent {
background: rgba(109, 106, 101, 0.08);
color: #6d6a65;
border: 1px solid rgba(109, 106, 101, 0.18);
}
.mnote-vault-session-badge.is-compact {
font-size: 10px;
padding: 0 6px;
line-height: 16px;
margin-left: 6px;
}
.mnote-vault-session-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.mnote-vault-session-row > label {
min-width: 0;
}
.mnote-vault-field-row > label {
padding-top: 4px;
color: #6d6a65;
@@ -556,6 +594,114 @@
line-height: 18px;
}
/* Expand-style folder picker: top-level groups stay visible (no long select). */
.mnote-vault-folder-picker {
width: 100%;
box-sizing: border-box;
max-height: 220px;
overflow: auto;
padding: 6px;
border: 1px solid rgba(27, 28, 28, 0.12);
border-radius: 6px;
background: #fafaf9;
}
.mnote-vault-folder-picker-none {
display: block;
width: 100%;
margin: 0 0 4px;
padding: 5px 8px;
border: none;
border-radius: 4px;
background: transparent;
color: #6d6a65;
font: inherit;
font-size: 12px;
text-align: left;
cursor: pointer;
}
.mnote-vault-folder-picker-none:hover,
.mnote-vault-folder-picker-label:hover {
background: rgba(35, 131, 226, 0.08);
color: #37352f;
}
.mnote-vault-folder-picker-none.is-selected,
.mnote-vault-folder-picker-row.is-selected .mnote-vault-folder-picker-label {
background: rgba(35, 131, 226, 0.14);
color: #1b64c2;
font-weight: 600;
}
.mnote-vault-folder-picker-node {
min-width: 0;
}
.mnote-vault-folder-picker-row {
display: flex;
align-items: center;
gap: 2px;
min-height: 26px;
padding-left: calc(var(--vault-picker-depth, 0) * 12px);
border-radius: 4px;
}
.mnote-vault-folder-picker-chevron {
flex: 0 0 18px;
width: 18px;
height: 22px;
margin: 0;
padding: 0;
border: none;
background: transparent;
color: #9b9a97;
font: inherit;
font-size: 9px;
line-height: 22px;
text-align: center;
cursor: pointer;
}
.mnote-vault-folder-picker-chevron.is-leaf {
cursor: default;
visibility: hidden;
}
.mnote-vault-folder-picker-label {
flex: 1 1 auto;
min-width: 0;
margin: 0;
padding: 4px 6px;
border: none;
border-radius: 4px;
background: transparent;
color: #37352f;
font: inherit;
font-size: 13px;
line-height: 18px;
text-align: left;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mnote-vault-folder-picker-children {
min-width: 0;
}
.mnote-vault-folder-picker-empty {
padding: 6px 8px;
font-size: 12px;
}
.mnote-vault-folder-hint {
margin: 0;
font-size: 11px;
color: #9b9a97;
}
.mnote-vault-folder-controls input[type="text"] {
width: 100%;
box-sizing: border-box;