1.0 mvp
This commit is contained in:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user