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:
@@ -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:验证更改");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
¬ification_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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(¶ms).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(¶ms).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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
+6
-6
@@ -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)
|
||||
}
|
||||
+16
-16
@@ -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
-1
@@ -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(
|
||||
+7
-7
@@ -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
-1
@@ -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
-1
@@ -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,
|
||||
+2
-2
@@ -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());
|
||||
+5
-5
@@ -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
-1
@@ -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};
|
||||
+7
-7
@@ -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,
|
||||
};
|
||||
+2
-2
@@ -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> {
|
||||
+29
-33
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 触发 reveal:lazy 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)
|
||||
|
||||
@@ -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");
|
||||
|
||||
+431
-211
File diff suppressed because it is too large
Load Diff
@@ -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 executor;Hermes 路径只作为 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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 产品面:facade(Pi 入口桥接)+ 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 runtime;tree 仍委托 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 AI:Pi 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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user