1.0 mvp
This commit is contained in:
@@ -14,11 +14,11 @@ pub fn manifest() -> Value {
|
||||
},
|
||||
"tools": [
|
||||
page_get_tool(),
|
||||
planned_tool("mnote.page.save", ["page.write"]),
|
||||
planned_tool("mnote.page.update_title", ["page.write"]),
|
||||
planned_tool("mnote.page.update_options", ["page.write"]),
|
||||
planned_tool("mnote.artifact.create_summary", ["artifact.write"]),
|
||||
planned_tool("mnote.artifact.create_ai_note", ["artifact.write"])
|
||||
page_save_tool(),
|
||||
available_tool("mnote.page.update_title", "更新当前页面标题", ["page.write"]),
|
||||
available_tool("mnote.page.update_options", "更新当前页面设置", ["page.write"]),
|
||||
available_tool("mnote.artifact.create_summary", "为当前页面创建或更新 AI Summary", ["artifact.write"]),
|
||||
available_tool("mnote.artifact.create_ai_note", "基于当前页面创建新的 AI Note", ["artifact.write"])
|
||||
]
|
||||
})
|
||||
}
|
||||
@@ -47,12 +47,49 @@ fn page_get_tool() -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn planned_tool(name: &str, scope: impl IntoIterator<Item = &'static str>) -> Value {
|
||||
fn page_save_tool() -> Value {
|
||||
json!({
|
||||
"name": name,
|
||||
"description": "已冻结合同,按 7-4 后续 task 接入 Rust runtime / kernel",
|
||||
"name": "mnote.page.save",
|
||||
"description": "保存当前页面正文;replace 覆盖正文,append/prepend 会先读取当前 Page Aggregate 后合成完整正文再保存",
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": ["page.write"],
|
||||
"status": "available",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["workspaceId", "documentId", "sessionId", "runId", "toolCallId", "traceId", "content", "dryRun", "idempotencyKey"],
|
||||
"properties": {
|
||||
"workspaceId": { "type": "string" },
|
||||
"documentId": { "type": "string" },
|
||||
"sessionId": { "type": "string" },
|
||||
"runId": { "type": "string" },
|
||||
"toolCallId": { "type": "string" },
|
||||
"traceId": { "type": "string" },
|
||||
"content": {
|
||||
"description": "要写入的正文块数组、{blocks:[...]}、TipTap content 数组或纯文本",
|
||||
"type": ["array", "object", "string"]
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["replace", "append", "prepend"],
|
||||
"default": "replace"
|
||||
},
|
||||
"dryRun": { "type": "boolean" },
|
||||
"idempotencyKey": { "type": "string" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn available_tool(
|
||||
name: &str,
|
||||
description: &str,
|
||||
scope: impl IntoIterator<Item = &'static str>,
|
||||
) -> Value {
|
||||
json!({
|
||||
"name": name,
|
||||
"description": description,
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": scope.into_iter().collect::<Vec<_>>(),
|
||||
"status": "planned"
|
||||
"status": "available"
|
||||
})
|
||||
}
|
||||
|
||||
@@ -91,14 +91,20 @@ pub async fn page_save(
|
||||
WebError::bad_request_code("mnote_tool_bad_request", "mnote.page.save 缺少 content")
|
||||
.with_context(context)
|
||||
})?;
|
||||
let mode = input
|
||||
.arg_string("mode")
|
||||
.unwrap_or_else(|| "replace".into())
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
let save_content = resolve_page_save_content(state, context, input, content, &mode).await?;
|
||||
page_command(
|
||||
state,
|
||||
context,
|
||||
input,
|
||||
"page.body.save",
|
||||
json!({
|
||||
"content": content,
|
||||
"mode": input.arg_string("mode").unwrap_or_else(|| "replace".into())
|
||||
"content": save_content,
|
||||
"mode": mode
|
||||
}),
|
||||
None,
|
||||
)
|
||||
@@ -261,6 +267,98 @@ fn merge_page_payload(document_id: &str, workspace_id: Option<&str>, patch: Valu
|
||||
payload
|
||||
}
|
||||
|
||||
async fn resolve_page_save_content(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
content: Value,
|
||||
mode: &str,
|
||||
) -> Result<Value, WebError> {
|
||||
match mode {
|
||||
"replace" | "" => Ok(content),
|
||||
"append" | "prepend" => {
|
||||
let document_id = input.effective_document_id().ok_or_else(|| {
|
||||
WebError::bad_request_code("mnote_tool_bad_request", "页面写工具缺少 documentId")
|
||||
.with_context(context)
|
||||
})?;
|
||||
let workspace_id = input.effective_workspace_id();
|
||||
let aggregate = build_page_aggregate_snapshot(
|
||||
state,
|
||||
context,
|
||||
&document_id,
|
||||
workspace_id.as_deref(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
let aggregate_value = serde_json::to_value(&aggregate)
|
||||
.map_err(|error| WebError::internal(error.to_string()))?;
|
||||
let current = aggregate_value
|
||||
.pointer("/body/content")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!([]));
|
||||
Ok(merge_page_content_for_mode(current, content, mode))
|
||||
}
|
||||
other => Err(WebError::bad_request_code(
|
||||
"mnote_tool_bad_request",
|
||||
format!("mnote.page.save 不支持 mode={other}"),
|
||||
)
|
||||
.with_context(context)),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_page_save_blocks(value: Value) -> Vec<Value> {
|
||||
match value {
|
||||
Value::Array(items) => items.into_iter().map(normalize_page_save_block).collect(),
|
||||
Value::Object(mut map) => {
|
||||
if let Some(Value::Array(items)) = map.remove("blocks") {
|
||||
return items.into_iter().map(normalize_page_save_block).collect();
|
||||
}
|
||||
if let Some(Value::Array(items)) = map.get("content").cloned() {
|
||||
return items.into_iter().map(normalize_page_save_block).collect();
|
||||
}
|
||||
vec![normalize_page_save_block(Value::Object(map))]
|
||||
}
|
||||
Value::String(text) => vec![json!({
|
||||
"type": "paragraph",
|
||||
"content": text
|
||||
})],
|
||||
other => vec![other],
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_page_save_block(value: Value) -> Value {
|
||||
let Value::Object(mut map) = value else {
|
||||
return value;
|
||||
};
|
||||
if !map.contains_key("type") {
|
||||
map.insert("type".into(), Value::String("paragraph".into()));
|
||||
}
|
||||
if !map.contains_key("content") {
|
||||
if let Some(text) = map
|
||||
.get("text")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|text| !text.is_empty())
|
||||
{
|
||||
map.insert("content".into(), Value::String(text.to_string()));
|
||||
}
|
||||
}
|
||||
Value::Object(map)
|
||||
}
|
||||
|
||||
fn merge_page_content_for_mode(current: Value, next: Value, mode: &str) -> Value {
|
||||
let mut current_blocks = normalize_page_save_blocks(current);
|
||||
let mut next_blocks = normalize_page_save_blocks(next);
|
||||
if mode == "prepend" {
|
||||
next_blocks.extend(current_blocks);
|
||||
Value::Array(next_blocks)
|
||||
} else {
|
||||
current_blocks.extend(next_blocks);
|
||||
Value::Array(current_blocks)
|
||||
}
|
||||
}
|
||||
|
||||
fn filter_wired_page_options(options: Value) -> (Value, Vec<String>, Vec<Value>) {
|
||||
let allowed = ["wideLayout", "smallText", "showToc", "protectEditing"];
|
||||
let mut out = serde_json::Map::new();
|
||||
@@ -339,3 +437,55 @@ fn collect_text(value: &Value) -> String {
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn page_save_append_merges_current_and_next_blocks() {
|
||||
let merged = merge_page_content_for_mode(
|
||||
json!([
|
||||
{"id": "block_1", "type": "paragraph", "content": "原正文"}
|
||||
]),
|
||||
json!([
|
||||
{"type": "paragraph", "content": [{"type": "text", "text": "AI 追加"}]}
|
||||
]),
|
||||
"append",
|
||||
);
|
||||
|
||||
assert_eq!(merged.as_array().map(Vec::len), Some(2));
|
||||
assert_eq!(merged[0]["id"], "block_1");
|
||||
assert_eq!(collect_text(&merged[1]), "AI 追加");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_save_prepend_accepts_plain_text() {
|
||||
let merged = merge_page_content_for_mode(
|
||||
json!([
|
||||
{"id": "block_1", "type": "paragraph", "content": "原正文"}
|
||||
]),
|
||||
json!("AI 前置"),
|
||||
"prepend",
|
||||
);
|
||||
|
||||
assert_eq!(merged.as_array().map(Vec::len), Some(2));
|
||||
assert_eq!(collect_text(&merged[0]), "AI 前置");
|
||||
assert_eq!(merged[1]["id"], "block_1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_save_accepts_text_field_blocks_from_ai() {
|
||||
let merged = merge_page_content_for_mode(
|
||||
json!([]),
|
||||
json!([
|
||||
{"type": "paragraph", "text": "AI 使用 text 字段"}
|
||||
]),
|
||||
"append",
|
||||
);
|
||||
|
||||
assert_eq!(merged.as_array().map(Vec::len), Some(1));
|
||||
assert_eq!(merged[0]["content"], "AI 使用 text 字段");
|
||||
assert_eq!(collect_text(&merged[0]), "AI 使用 text 字段");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::transport::convex::execute_convex_query_by_name;
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Extension, Path, Query};
|
||||
use axum::extract::{Extension, Path, Query, State};
|
||||
use axum::http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||
use axum::response::Response;
|
||||
use axum::Json;
|
||||
@@ -56,6 +58,8 @@ struct HermesQueuedRun {
|
||||
profile: String,
|
||||
document_id: String,
|
||||
trace_id: String,
|
||||
actor_id: String,
|
||||
actor_type: String,
|
||||
input: String,
|
||||
context_summary: Value,
|
||||
queued_at: u128,
|
||||
@@ -450,10 +454,13 @@ async fn load_session_from_hermes_cli(session_id: &str) -> Option<Value> {
|
||||
}
|
||||
|
||||
pub async fn create_run(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(payload): Json<Value>,
|
||||
Json(mut payload): Json<Value>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
ensure_authenticated(&context)?;
|
||||
let (actor_id, actor_type) = resolve_run_actor(&state, &context).await;
|
||||
stamp_run_actor(&mut payload, &actor_id, &actor_type);
|
||||
let registration = run_registration_from_payload(&context, &payload);
|
||||
if session_has_active_run(®istration.session_id) {
|
||||
let queued = enqueue_run(&context, ®istration, &payload)?;
|
||||
@@ -1553,14 +1560,14 @@ fn build_run_upstream_body(context: &RequestContext, payload: Value) -> Result<V
|
||||
"workspaceId": workspace_id,
|
||||
"documentId": document_id,
|
||||
"profile": profile.unwrap_or("default"),
|
||||
"actorId": context.auth.actor_id,
|
||||
"actorType": context.auth.actor_type,
|
||||
"actorId": payload.get("actorId").and_then(Value::as_str).unwrap_or(&context.auth.actor_id),
|
||||
"actorType": payload.get("actorType").and_then(Value::as_str).unwrap_or(&context.auth.actor_type),
|
||||
"sessionId": session_id,
|
||||
"traceId": trace_id,
|
||||
"toolGuidance": "调用 mnote_page_get / mnote_page_save / mnote.page.* 对应工具时,必须透传 workspaceId、documentId、actorId、sessionId 和 traceId;读取页面标题或页面设置时调用 mnote_page_get,并传 includeBody=false、includeOptions=true、includeBlocks=false;需要正文摘要时才传 includeBody=true。不要只依据 pageContext 猜测。",
|
||||
"toolGuidance": "调用 mnote_page_get / mnote_page_save / mnote.page.* 对应工具时,必须透传 workspaceId、documentId、actorId、sessionId 和 traceId;读取页面标题或页面设置时调用 mnote_page_get,并传 includeBody=false、includeOptions=true、includeBlocks=false;需要正文摘要时才传 includeBody=true。写入正文时如需追加使用 mnote_page_save mode=append,覆盖全文才使用 mode=replace。不要只依据 pageContext 猜测。",
|
||||
"selectedBlockId": payload.get("selectedBlockId").cloned().unwrap_or(Value::Null),
|
||||
"selectedText": payload.get("selectedText").cloned().unwrap_or(Value::Null),
|
||||
"pageContext": page_context
|
||||
"pageContext": sanitize_run_page_context(page_context)
|
||||
})
|
||||
.to_string();
|
||||
|
||||
@@ -1582,6 +1589,87 @@ fn build_run_upstream_body(context: &RequestContext, payload: Value) -> Result<V
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
fn effective_run_actor(state: &AppState, context: &RequestContext) -> (String, String) {
|
||||
let actor_id = context.auth.actor_id.trim();
|
||||
if !actor_id.is_empty() && actor_id != "anonymous" {
|
||||
return (actor_id.to_string(), context.auth.actor_type.clone());
|
||||
}
|
||||
if context.auth.authorization.is_some() || context.auth.cookie_header.is_some() {
|
||||
return (state.config().dev_user_id.clone(), "devFallback".into());
|
||||
}
|
||||
("anonymous".into(), "anonymous".into())
|
||||
}
|
||||
|
||||
async fn resolve_run_actor(state: &AppState, context: &RequestContext) -> (String, String) {
|
||||
let actor_id = context.auth.actor_id.trim();
|
||||
if !actor_id.is_empty() && actor_id != "anonymous" {
|
||||
return (actor_id.to_string(), context.auth.actor_type.clone());
|
||||
}
|
||||
if context.auth.authorization.is_none() && context.auth.cookie_header.is_none() {
|
||||
return ("anonymous".into(), "anonymous".into());
|
||||
}
|
||||
if let Ok(Some(user_id)) = resolve_current_convex_user_id(state, context).await {
|
||||
return (user_id, "user".into());
|
||||
}
|
||||
effective_run_actor(state, context)
|
||||
}
|
||||
|
||||
async fn resolve_current_convex_user_id(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
) -> Result<Option<String>, WebError> {
|
||||
let payload = execute_convex_query_by_name(
|
||||
state.config(),
|
||||
context,
|
||||
"users:currentUser",
|
||||
json!({}),
|
||||
None,
|
||||
"hermes_run_current_user",
|
||||
)
|
||||
.await?;
|
||||
Ok(payload
|
||||
.get("_id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned))
|
||||
}
|
||||
|
||||
fn stamp_run_actor(payload: &mut Value, actor_id: &str, actor_type: &str) {
|
||||
let Value::Object(map) = payload else {
|
||||
return;
|
||||
};
|
||||
map.insert("actorId".into(), Value::String(actor_id.to_string()));
|
||||
map.insert("actorType".into(), Value::String(actor_type.to_string()));
|
||||
}
|
||||
|
||||
fn sanitize_run_page_context(page_context: Value) -> Value {
|
||||
let Some(source) = page_context.as_object() else {
|
||||
return Value::Null;
|
||||
};
|
||||
let mut sanitized = serde_json::Map::new();
|
||||
for key in [
|
||||
"contextScope",
|
||||
"node",
|
||||
"pageSubtreeSource",
|
||||
"evidence",
|
||||
"pageOptions",
|
||||
"contentAccess",
|
||||
] {
|
||||
if let Some(value) = source.get(key) {
|
||||
sanitized.insert(key.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
sanitized.insert(
|
||||
"contentAccess".to_string(),
|
||||
sanitized
|
||||
.get("contentAccess")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| Value::String("mnote.page.get".into())),
|
||||
);
|
||||
Value::Object(sanitized)
|
||||
}
|
||||
|
||||
fn now_ms() -> u128 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
@@ -2029,6 +2117,16 @@ fn enqueue_run(
|
||||
profile: registration.profile.clone(),
|
||||
document_id: registration.document_id.clone(),
|
||||
trace_id: registration.trace_id.clone(),
|
||||
actor_id: payload
|
||||
.get("actorId")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("anonymous")
|
||||
.to_string(),
|
||||
actor_type: payload
|
||||
.get("actorType")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("anonymous")
|
||||
.to_string(),
|
||||
input,
|
||||
context_summary: queue_context_summary(payload),
|
||||
queued_at: now_ms(),
|
||||
@@ -2092,6 +2190,8 @@ fn queued_run_payload(queued: &HermesQueuedRun) -> Value {
|
||||
"profile": queued.profile,
|
||||
"message": queued.input,
|
||||
"traceId": queued.trace_id,
|
||||
"actorId": queued.actor_id,
|
||||
"actorType": queued.actor_type,
|
||||
"contextScope": queued.context_summary.get("contextScope").cloned().unwrap_or(Value::Null)
|
||||
})
|
||||
}
|
||||
@@ -2330,6 +2430,28 @@ mod tests {
|
||||
}))
|
||||
}
|
||||
|
||||
fn test_state() -> AppState {
|
||||
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,
|
||||
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 hermes_client_gateway_health_reports_unconfigured_profile_settings() {
|
||||
let _env_guard = env_lock().lock().expect("env lock");
|
||||
@@ -3048,7 +3170,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hermes_client_run_body_carries_page_context_into_run_input() {
|
||||
fn hermes_client_run_body_carries_minimal_page_context_into_run_input() {
|
||||
let context = RequestContext::from_http_parts(
|
||||
&axum::http::Method::POST,
|
||||
&"/api/hermes/client/runs".parse().expect("uri"),
|
||||
@@ -3061,7 +3183,14 @@ mod tests {
|
||||
"documentId": "doc_1",
|
||||
"sessionId": "sess_1",
|
||||
"message": "概括当前页面",
|
||||
"pageContext": {"title": "页面标题"},
|
||||
"pageContext": {
|
||||
"contextScope": "page",
|
||||
"node": {"documentId": "doc_1", "title": "页面标题"},
|
||||
"documentBlocks": [{"type": "paragraph", "text": "不应进入 Hermes instructions"}],
|
||||
"subtree": {"children": [{"title": "不应进入 Hermes instructions"}]},
|
||||
"outline": [{"title": "不应进入 Hermes instructions"}],
|
||||
"contentAccess": "mnote.page.get"
|
||||
},
|
||||
"selectedBlockId": "block_1",
|
||||
"selectedText": "选中文本",
|
||||
"traceId": "trace_1"
|
||||
@@ -3075,6 +3204,33 @@ mod tests {
|
||||
assert!(instructions.contains("\"documentId\":\"doc_1\""));
|
||||
assert!(instructions.contains("\"title\":\"页面标题\""));
|
||||
assert!(instructions.contains("\"selectedBlockId\":\"block_1\""));
|
||||
assert!(instructions.contains("\"contentAccess\":\"mnote.page.get\""));
|
||||
assert!(!instructions.contains("不应进入 Hermes instructions"));
|
||||
assert!(!instructions.contains("\"documentBlocks\""));
|
||||
assert!(!instructions.contains("\"subtree\""));
|
||||
assert!(!instructions.contains("\"outline\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hermes_client_run_actor_falls_back_to_dev_user_for_cookie_auth() {
|
||||
let state = test_state();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("cookie", "mnote_web_convex_token=token_1".parse().unwrap());
|
||||
let context = RequestContext::from_http_parts(
|
||||
&axum::http::Method::POST,
|
||||
&"/api/hermes/client/runs".parse().expect("uri"),
|
||||
&headers,
|
||||
);
|
||||
let (actor_id, actor_type) = effective_run_actor(&state, &context);
|
||||
assert_eq!(actor_id, "dev-user");
|
||||
assert_eq!(actor_type, "devFallback");
|
||||
let mut payload = json!({"message": "读取当前页面"});
|
||||
stamp_run_actor(&mut payload, &actor_id, &actor_type);
|
||||
let body = build_run_upstream_body(&context, payload).expect("body");
|
||||
let instructions = body["instructions"].as_str().expect("instructions");
|
||||
assert!(instructions.contains("\"actorId\":\"dev-user\""));
|
||||
assert!(instructions.contains("\"actorType\":\"devFallback\""));
|
||||
assert!(!instructions.contains("\"actorId\":\"anonymous\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -65,7 +65,7 @@ pub async fn mnote_call(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(input): Json<ToolCallInput>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
ensure_authenticated(&context)?;
|
||||
let context = authenticated_tool_context(&context, &input)?;
|
||||
let trace_id = input
|
||||
.effective_trace_id(&context.trace.trace_id)
|
||||
.to_string();
|
||||
@@ -373,6 +373,71 @@ fn ensure_authenticated(context: &RequestContext) -> Result<(), WebError> {
|
||||
.with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools"))
|
||||
}
|
||||
|
||||
fn authenticated_tool_context(
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<RequestContext, WebError> {
|
||||
if ensure_authenticated(context).is_ok() {
|
||||
return Ok(context.clone());
|
||||
}
|
||||
let actor_id = input
|
||||
.actor_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty() && *value != "anonymous")
|
||||
.ok_or_else(|| {
|
||||
WebError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"mnote_tool_unauthorized",
|
||||
"mnote Hermes tool 需要登录后访问",
|
||||
)
|
||||
.with_context(context)
|
||||
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools")
|
||||
})?;
|
||||
let has_run_identity = input
|
||||
.session_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.is_some()
|
||||
&& input
|
||||
.run_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.is_some()
|
||||
&& input
|
||||
.tool_call_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.is_some()
|
||||
&& input
|
||||
.trace_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.is_some();
|
||||
if !has_run_identity {
|
||||
return Err(WebError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"mnote_tool_unauthorized",
|
||||
"mnote Hermes tool 委托调用缺少 sessionId/runId/toolCallId/traceId",
|
||||
)
|
||||
.with_context(context)
|
||||
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools"));
|
||||
}
|
||||
let mut next = context.clone();
|
||||
next.auth.actor_id = actor_id.to_string();
|
||||
next.auth.actor_type = "user".into();
|
||||
if next.auth.session_id.is_none() {
|
||||
next.auth.session_id = input.session_id.clone();
|
||||
}
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
fn ensure_workspace_context(
|
||||
context: &RequestContext,
|
||||
input_workspace_id: Option<&str>,
|
||||
@@ -493,6 +558,11 @@ mod tests {
|
||||
let tools = payload["manifest"]["tools"].as_array().expect("tools");
|
||||
assert!(tools.iter().any(|tool| tool["name"] == "mnote.page.get"));
|
||||
assert!(tools.iter().any(|tool| tool["name"] == "mnote.page.save"));
|
||||
let page_save = tools
|
||||
.iter()
|
||||
.find(|tool| tool["name"] == "mnote.page.save")
|
||||
.expect("page save tool");
|
||||
assert_eq!(page_save["status"], "available");
|
||||
assert_eq!(
|
||||
payload["manifest"]["schemaVersion"],
|
||||
"mnote.hermes_tool_manifest.v1"
|
||||
@@ -517,6 +587,41 @@ mod tests {
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_page_get_accepts_delegated_actor_from_hermes_payload() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/tools/mnote/call")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"toolName": "mnote.page.get",
|
||||
"workspaceId": "ws_demo",
|
||||
"documentId": "doc_1",
|
||||
"actorId": "user_hermes",
|
||||
"sessionId": "sess_1",
|
||||
"runId": "run_1",
|
||||
"toolCallId": "call_1",
|
||||
"traceId": "trace_1",
|
||||
"capabilityScope": ["page.read"]
|
||||
})
|
||||
.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["audit"]["actorId"], "user_hermes");
|
||||
assert_eq!(payload["result"]["title"], "服务端页面");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_write_tools_require_auth() {
|
||||
let response = app()
|
||||
|
||||
@@ -1607,12 +1607,15 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
const nextBody = nextAggregate?.body || {};
|
||||
const nextPermissions = nextAggregate?.head?.permissions || {};
|
||||
const nextConflictKey = conflictDetectionKeyFromBody(nextBody);
|
||||
const nextTiptapDocument = toTiptapDocument(nextBody.content);
|
||||
const nextSerialized = JSON.stringify(nextTiptapDocument);
|
||||
const contentChanged = nextSerialized !== session.currentSerialized;
|
||||
session.externalChangePending = false;
|
||||
if (!nextConflictKey || !session.lastExternalConflictDetectionKey) {
|
||||
session.lastExternalConflictDetectionKey = nextConflictKey || session.lastExternalConflictDetectionKey;
|
||||
return;
|
||||
if (!contentChanged) return;
|
||||
}
|
||||
if (nextConflictKey === session.lastExternalConflictDetectionKey) return;
|
||||
if (nextConflictKey === session.lastExternalConflictDetectionKey && !contentChanged) return;
|
||||
if (session.hasExternalConflict || sessionHasRecentLocalInput(session) || session.dirty || session.saveTimer || session.saving) {
|
||||
markSessionExternalConflict(session, externalConflictMessage);
|
||||
return;
|
||||
@@ -1620,8 +1623,8 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
const nextRevision = Number.isInteger(nextBody.revision) ? nextBody.revision : revisionFromConflictKey(nextConflictKey);
|
||||
session.latestAggregate = nextAggregate;
|
||||
session.title = nextAggregate?.head?.title || session.title;
|
||||
session.currentTiptapDocument = toTiptapDocument(nextBody.content);
|
||||
session.currentSerialized = JSON.stringify(session.currentTiptapDocument);
|
||||
session.currentTiptapDocument = nextTiptapDocument;
|
||||
session.currentSerialized = nextSerialized;
|
||||
session.lastPersistedSerialized = session.currentSerialized;
|
||||
session.revision = nextRevision;
|
||||
session.conflictDetectionKey = nextConflictKey;
|
||||
@@ -1840,8 +1843,38 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
});
|
||||
};
|
||||
|
||||
const sessionMatchesDocumentWorkspace = (session, documentId, workspaceId) => {
|
||||
if (!session) return false;
|
||||
const doc = String(documentId || '').trim();
|
||||
const workspace = String(workspaceId || '').trim();
|
||||
if (doc && session.documentId !== doc) return false;
|
||||
if (workspace && session.workspaceId && session.workspaceId !== workspace) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const refreshDocumentSessionsFromExternalWrite = (detail, source) => {
|
||||
const documentId = String(detail?.documentId || '').trim();
|
||||
const workspaceId = String(detail?.workspaceId || '').trim();
|
||||
let scheduled = 0;
|
||||
Array.from(documentSessionRegistry.values()).forEach((session) => {
|
||||
if (!sessionMatchesDocumentWorkspace(session, documentId, workspaceId)) return;
|
||||
session.lastExternalChangeSignalAt = Date.now();
|
||||
session.externalChangePending = true;
|
||||
if (session.hasExternalConflict || sessionHasRecentLocalInput(session) || session.dirty || session.saveTimer || session.saving) {
|
||||
markSessionExternalConflict(session, treeExternalConflictMessage);
|
||||
return;
|
||||
}
|
||||
scheduled += 1;
|
||||
scheduleSessionExternalRefresh(session, source || 'mnote-web-external-write');
|
||||
});
|
||||
return scheduled;
|
||||
};
|
||||
|
||||
window.addEventListener('tree:delta', handleTreeExternalChange);
|
||||
window.addEventListener('tree:resync', handleTreeExternalChange);
|
||||
window.addEventListener('mnote:page-ai-tool-write-completed', (event) => {
|
||||
refreshDocumentSessionsFromExternalWrite(event?.detail || {}, 'mnote-hermes-tool');
|
||||
});
|
||||
|
||||
const createDocumentSession = (runtimeDescriptor) => {
|
||||
const pageBody = runtimeDescriptor.aggregate.body || {};
|
||||
@@ -2407,6 +2440,23 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
closeSecondaryPane(url instanceof URL ? url : currentUrl());
|
||||
return true;
|
||||
},
|
||||
refreshDocument: async ({ documentId, workspaceId, source } = {}) => {
|
||||
const targets = Array.from(documentSessionRegistry.values()).filter((session) => (
|
||||
sessionMatchesDocumentWorkspace(session, documentId, workspaceId)
|
||||
));
|
||||
await Promise.all(targets.map((session) => refreshSessionFromExternalChange(
|
||||
session,
|
||||
source || 'mnote-web-programmatic-refresh',
|
||||
)));
|
||||
return targets.length;
|
||||
},
|
||||
refreshPrimaryDocument: async ({ documentId, workspaceId, source } = {}) => {
|
||||
const primary = paneViewRegistry.get('primary');
|
||||
if (!primary?.session) return 0;
|
||||
if (!sessionMatchesDocumentWorkspace(primary.session, documentId, workspaceId)) return 0;
|
||||
await refreshSessionFromExternalChange(primary.session, source || 'mnote-web-programmatic-refresh');
|
||||
return 1;
|
||||
},
|
||||
};
|
||||
|
||||
window.addEventListener('pagehide', () => {
|
||||
|
||||
@@ -267,16 +267,17 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return {
|
||||
pageContext: {
|
||||
contextScope: scope,
|
||||
documentBlocks: scope === 'page' ? (body.content || null) : null,
|
||||
documentBlocks: null,
|
||||
node: {
|
||||
documentId: currentDocumentId(),
|
||||
title: title
|
||||
},
|
||||
subtree: scope === 'page' ? subtree : null,
|
||||
outline: scope === 'page' ? outline : null,
|
||||
subtree: null,
|
||||
outline: null,
|
||||
pageSubtreeSource: scope === 'page' ? (contextSnapshot.pageSubtreeSource || 'none') : 'scope:' + scope,
|
||||
evidence: selectedText ? [{ kind: 'selection', text: selectedText }] : null,
|
||||
pageOptions: scope === 'options' || scope === 'page' ? currentPageOptions() : null
|
||||
pageOptions: scope === 'options' || scope === 'page' ? currentPageOptions() : null,
|
||||
contentAccess: 'mnote.page.get'
|
||||
},
|
||||
selectedText: selectedText,
|
||||
selectedBlockId: null
|
||||
@@ -503,6 +504,29 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var value = (root.getAttribute('data-workspace-id') || '').trim();
|
||||
if (value) return value;
|
||||
}
|
||||
var documentId = currentDocumentId();
|
||||
if (documentId) {
|
||||
var shell = document.querySelector('.document-shell[data-document-id="' + cssEscape(documentId) + '"][data-workspace-id]');
|
||||
if (shell instanceof HTMLElement) {
|
||||
var shellWorkspaceId = (shell.getAttribute('data-workspace-id') || '').trim();
|
||||
if (shellWorkspaceId) return shellWorkspaceId;
|
||||
}
|
||||
}
|
||||
var activePane = document.querySelector('[data-pane-visible="true"][data-pane-workspace-id]');
|
||||
if (activePane instanceof HTMLElement) {
|
||||
var paneWorkspaceId = (activePane.getAttribute('data-pane-workspace-id') || '').trim();
|
||||
if (paneWorkspaceId) return paneWorkspaceId;
|
||||
}
|
||||
var anyDocumentShell = document.querySelector('.document-shell[data-workspace-id]');
|
||||
if (anyDocumentShell instanceof HTMLElement) {
|
||||
var documentWorkspaceId = (anyDocumentShell.getAttribute('data-workspace-id') || '').trim();
|
||||
if (documentWorkspaceId) return documentWorkspaceId;
|
||||
}
|
||||
var fromDom = document.querySelector('[data-workspace-id]');
|
||||
if (fromDom instanceof HTMLElement) {
|
||||
var domWorkspaceId = (fromDom.getAttribute('data-workspace-id') || '').trim();
|
||||
if (domWorkspaceId) return domWorkspaceId;
|
||||
}
|
||||
return (new URLSearchParams(window.location.search).get('workspaceId') || 'default').trim() || 'default';
|
||||
}
|
||||
|
||||
@@ -4124,6 +4148,69 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}
|
||||
}
|
||||
|
||||
function pageAiNormalizeToolName(name) {
|
||||
return String(name || '').trim().replace(/_/g, '.');
|
||||
}
|
||||
|
||||
function pageAiToolEventDeepFindString(value, keys, depth) {
|
||||
if (!value || typeof value !== 'object' || depth > 5) return '';
|
||||
for (var index = 0; index < keys.length; index += 1) {
|
||||
var key = keys[index];
|
||||
if (Object.prototype.hasOwnProperty.call(value, key) && typeof value[key] === 'string' && value[key].trim()) {
|
||||
return value[key].trim();
|
||||
}
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (var arrayIndex = 0; arrayIndex < value.length; arrayIndex += 1) {
|
||||
var fromArray = pageAiToolEventDeepFindString(value[arrayIndex], keys, depth + 1);
|
||||
if (fromArray) return fromArray;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
var preferred = ['audit', 'args', 'arguments', 'input', 'result', 'summary', 'output', 'upstream'];
|
||||
for (var prefIndex = 0; prefIndex < preferred.length; prefIndex += 1) {
|
||||
var child = value[preferred[prefIndex]];
|
||||
var fromPreferred = pageAiToolEventDeepFindString(child, keys, depth + 1);
|
||||
if (fromPreferred) return fromPreferred;
|
||||
}
|
||||
var objectKeys = Object.keys(value);
|
||||
for (var objectIndex = 0; objectIndex < objectKeys.length; objectIndex += 1) {
|
||||
var fromObject = pageAiToolEventDeepFindString(value[objectKeys[objectIndex]], keys, depth + 1);
|
||||
if (fromObject) return fromObject;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function pageAiNotifyToolWriteCompleted(toolName, toolEvent, runId, runTraceId) {
|
||||
var normalizedTool = pageAiNormalizeToolName(toolName);
|
||||
var writesCurrentPage = [
|
||||
'mnote.page.save',
|
||||
'mnote.page.update.title',
|
||||
'mnote.page.update.options'
|
||||
].indexOf(normalizedTool) >= 0 || [
|
||||
'mnote.page.update_title',
|
||||
'mnote.page.update_options'
|
||||
].indexOf(String(toolName || '').trim()) >= 0;
|
||||
if (!writesCurrentPage) return;
|
||||
var documentId = pageAiToolEventDeepFindString(toolEvent, ['documentId', 'document_id'], 0) || currentDocumentId();
|
||||
var workspaceId = pageAiToolEventDeepFindString(toolEvent, ['workspaceId', 'workspace_id'], 0) || resolveWorkspaceId(document.body);
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('mnote:page-ai-tool-write-completed', {
|
||||
detail: {
|
||||
toolName: toolName,
|
||||
normalizedToolName: normalizedTool,
|
||||
documentId: documentId,
|
||||
workspaceId: workspaceId,
|
||||
runId: String(toolEvent && (toolEvent.run_id || toolEvent.runId) || runId || ''),
|
||||
traceId: String(toolEvent && (toolEvent.trace_id || toolEvent.traceId) || runTraceId || ''),
|
||||
toolCallId: String(toolEvent && (toolEvent.tool_call_id || toolEvent.toolCallId || toolEvent.id) || '')
|
||||
}
|
||||
}));
|
||||
} catch (error) {
|
||||
console.warn('mnote 页面 AI 写入刷新事件派发失败', error);
|
||||
}
|
||||
}
|
||||
|
||||
function pageAiApplyToolEvent(eventName, payloadText, runId, runTraceId) {
|
||||
var toolEvent = null;
|
||||
try {
|
||||
@@ -4173,6 +4260,9 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
existing.auditId = auditId || existing.auditId || '';
|
||||
if (argsSummary) existing.argsSummary = argsSummary;
|
||||
if (resultSummary) existing.resultSummary = resultSummary;
|
||||
if (status === 'completed') {
|
||||
pageAiNotifyToolWriteCompleted(toolName, toolEvent, runId, runTraceId);
|
||||
}
|
||||
}
|
||||
|
||||
async function pageAiCancelQueuedRun(queueId) {
|
||||
|
||||
@@ -111,25 +111,60 @@ fn build_authorization(config: &AppConfig, context: &RequestContext) -> Result<S
|
||||
.with_header("x-upstream-service", "convex")
|
||||
})?;
|
||||
|
||||
let dev_user_id = config.dev_user_id.trim();
|
||||
if dev_user_id.is_empty() {
|
||||
let Some(identity_user) = fallback_acting_identity_user(config, context) else {
|
||||
return Ok(format!("Convex {admin_key}"));
|
||||
}
|
||||
};
|
||||
|
||||
// 说明:Rust Web 直接调用 Convex HTTP API 时没有 Next/Convex Auth cookie。
|
||||
// 自托管开发态用 admin auth 携带 acting identity,让 @convex-dev/auth 的
|
||||
// getAuthUserId(ctx) 能得到 DEV_USER_ID,从而和 Next 开发态免登录语义一致。
|
||||
// 自托管开发态和 Hermes 委托 tool 调用都用 admin auth 携带 acting identity,
|
||||
// 让 @convex-dev/auth 的 getAuthUserId(ctx) 得到实际执行用户。
|
||||
let identity = json!({
|
||||
"subject": format!("{}|mnote-web-dev-session", dev_user_id),
|
||||
"issuer": "mnote-web-dev",
|
||||
"name": config.dev_user_name,
|
||||
"email": config.dev_user_email,
|
||||
"subject": format!("{}|{}", identity_user.user_id, identity_user.session_suffix),
|
||||
"issuer": identity_user.issuer,
|
||||
"name": identity_user.name,
|
||||
"email": identity_user.email,
|
||||
});
|
||||
let identity_encoded =
|
||||
base64::engine::general_purpose::STANDARD.encode(identity.to_string().as_bytes());
|
||||
Ok(format!("Convex {admin_key}:{identity_encoded}"))
|
||||
}
|
||||
|
||||
struct ActingIdentityUser {
|
||||
user_id: String,
|
||||
session_suffix: &'static str,
|
||||
issuer: &'static str,
|
||||
name: String,
|
||||
email: String,
|
||||
}
|
||||
|
||||
fn fallback_acting_identity_user(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
) -> Option<ActingIdentityUser> {
|
||||
let actor_id = context.auth.actor_id.trim();
|
||||
if !actor_id.is_empty() && actor_id != "anonymous" {
|
||||
return Some(ActingIdentityUser {
|
||||
user_id: actor_id.to_string(),
|
||||
session_suffix: "mnote-web-delegated-session",
|
||||
issuer: "mnote-web-delegated",
|
||||
name: actor_id.to_string(),
|
||||
email: String::new(),
|
||||
});
|
||||
}
|
||||
|
||||
let dev_user_id = config.dev_user_id.trim();
|
||||
if dev_user_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(ActingIdentityUser {
|
||||
user_id: dev_user_id.to_string(),
|
||||
session_suffix: "mnote-web-dev-session",
|
||||
issuer: "mnote-web-dev",
|
||||
name: config.dev_user_name.clone(),
|
||||
email: config.dev_user_email.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_cookie_value(context: &RequestContext, name: &str) -> Option<String> {
|
||||
context
|
||||
.auth
|
||||
@@ -1175,6 +1210,31 @@ mod tests {
|
||||
assert_eq!(identity["email"], "dev@mnote.local");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_authorization_uses_delegated_actor_for_admin_identity() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-mnote-actor-id", HeaderValue::from_static("user_real_1"));
|
||||
headers.insert("x-mnote-actor-type", HeaderValue::from_static("user"));
|
||||
|
||||
let authorization =
|
||||
build_authorization(&config(), &request_context(headers)).expect("authorization");
|
||||
|
||||
assert!(authorization.starts_with("Convex admin-demo:"));
|
||||
let encoded = authorization
|
||||
.trim_start_matches("Convex admin-demo:")
|
||||
.trim();
|
||||
let decoded = base64::engine::general_purpose::STANDARD
|
||||
.decode(encoded)
|
||||
.expect("identity base64");
|
||||
let identity: serde_json::Value = serde_json::from_slice(&decoded).expect("identity json");
|
||||
assert_eq!(
|
||||
identity["subject"],
|
||||
"user_real_1|mnote-web-delegated-session"
|
||||
);
|
||||
assert_eq!(identity["issuer"], "mnote-web-delegated");
|
||||
assert_eq!(identity["name"], "user_real_1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_authorization_reads_convex_token_from_cookie() {
|
||||
let mut headers = HeaderMap::new();
|
||||
|
||||
Reference in New Issue
Block a user