This commit is contained in:
lix-2026
2026-05-16 12:34:48 +08:00
parent f9336257a5
commit d8bfaea306
17 changed files with 2817 additions and 543 deletions
@@ -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"
})
}
+152 -2
View File
@@ -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(&registration.session_id) {
let queued = enqueue_run(&context, &registration, &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()
+54 -4
View File
@@ -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', () => {
+94 -4
View File
@@ -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) {
+69 -9
View File
@@ -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();
@@ -33,12 +33,11 @@ import { register_text_style } from './snippets/leptos-tiptap-c355e6ec24c3df4c/s
import { register_toc_node } from './snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_toc_node.js';
import { register_underline } from './snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_underline.js';
import { register_youtube } from './snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_youtube.js';
import { find_mnote_block_anchor, post_mnote_document_save, write_mnote_text_to_clipboard } from './snippets/mnote-leptos-tiptap-spike-fdb5779a1690afd2/inline0.js';
import { find_mnote_block_anchor, write_mnote_text_to_clipboard } from './snippets/mnote-leptos-tiptap-spike-fdb5779a1690afd2/inline0.js';
import * as import1 from "./snippets/mnote-leptos-tiptap-spike-fdb5779a1690afd2/inline0.js"
import * as import2 from "./snippets/mnote-leptos-tiptap-spike-fdb5779a1690afd2/inline0.js"
import * as import3 from "./snippets/mnote-leptos-tiptap-spike-fdb5779a1690afd2/inline0.js"
import * as import3 from "./snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/bridge_runtime.js"
import * as import4 from "./snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/bridge_runtime.js"
import * as import5 from "./snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/bridge_runtime.js"
export class IntoUnderlyingByteSource {
@@ -370,6 +369,9 @@ function __wbg_get_imports() {
const ret = arg0.children;
return ret;
},
__wbg_click_bc40376705b1e04d: function(arg0) {
arg0.click();
},
__wbg_clientHeight_01b31bebacb195f0: function(arg0) {
const ret = arg0.clientHeight;
return ret;
@@ -561,13 +563,6 @@ function __wbg_get_imports() {
const ret = arg0[arg1 >>> 0];
return ret;
},
__wbg_get_aa7ea1c497b45090: function() { return handleError(function (arg0, arg1, arg2, arg3) {
const ret = arg1.get(getStringFromWasm0(arg2, arg3));
var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
var len1 = WASM_VECTOR_LEN;
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
}, arguments); },
__wbg_get_unchecked_17f53dad852b9588: function(arg0, arg1) {
const ret = arg0[arg1 >>> 0];
return ret;
@@ -583,10 +578,6 @@ function __wbg_get_imports() {
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
}, arguments); },
__wbg_headers_6022deb4e576fb8e: function(arg0) {
const ret = arg0.headers;
return ret;
},
__wbg_height_cc0f4b9ec7073c11: function(arg0) {
const ret = arg0.height;
return ret;
@@ -786,10 +777,6 @@ function __wbg_get_imports() {
const ret = arg0.metaKey;
return ret;
},
__wbg_new_0_4d657201ced14de3: function() {
const ret = new Date();
return ret;
},
__wbg_new_0c7403db6e782f19: function(arg0) {
const ret = new Uint8Array(arg0);
return ret;
@@ -878,18 +865,6 @@ function __wbg_get_imports() {
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
}, arguments); },
__wbg_post_mnote_document_save_94a0f8ecea85d168: function() { return handleError(function (arg0, arg1) {
let deferred0_0;
let deferred0_1;
try {
deferred0_0 = arg0;
deferred0_1 = arg1;
const ret = post_mnote_document_save(getStringFromWasm0(arg0, arg1));
return ret;
} finally {
wasm.__wbindgen_free(deferred0_0, deferred0_1, 1);
}
}, arguments); },
__wbg_preventDefault_f55c01cb5fd2bcc0: function(arg0) {
arg0.preventDefault();
},
@@ -1214,10 +1189,6 @@ function __wbg_get_imports() {
const ret = arg0.target;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
},
__wbg_text_595ef75535aa25c1: function() { return handleError(function (arg0) {
const ret = arg0.text();
return ret;
}, arguments); },
__wbg_then_792e0c862b060889: function(arg0, arg1, arg2) {
const ret = arg0.then(arg1, arg2);
return ret;
@@ -1226,14 +1197,6 @@ function __wbg_get_imports() {
const ret = arg0.then(arg1);
return ret;
},
__wbg_toISOString_07c00b3614e865a1: function(arg0) {
const ret = arg0.toISOString();
return ret;
},
__wbg_toString_306ed0b9f320c1ca: function(arg0) {
const ret = arg0.toString();
return ret;
},
__wbg_top_158f7c4dd1427771: function(arg0) {
const ret = arg0.top;
return ret;
@@ -1276,42 +1239,42 @@ function __wbg_get_imports() {
}
}, arguments); },
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1889, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1878, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hdfdf165eabd6279b);
return ret;
},
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 2135, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 2117, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2593b18d2d6f8a75);
return ret;
},
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 2227, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 2209, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h36e788bd1cd1f0f7);
return ret;
},
__wbindgen_cast_0000000000000004: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 2050, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 2040, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h03deb55668e1377f);
return ret;
},
__wbindgen_cast_0000000000000005: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 2137, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 2119, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h837fba73fce77300);
return ret;
},
__wbindgen_cast_0000000000000006: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 2049, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 2039, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h96c1a94f3b510398);
return ret;
},
__wbindgen_cast_0000000000000007: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 2071, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 2053, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hd17f30facfdd737f);
return ret;
},
__wbindgen_cast_0000000000000008: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 2136, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 2118, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hf726e22cfd1ee0a9);
return ret;
},
@@ -1350,9 +1313,8 @@ function __wbg_get_imports() {
"./mnote-leptos-tiptap-spike-island_bg.js": import0,
"./snippets/mnote-leptos-tiptap-spike-fdb5779a1690afd2/inline0.js": import1,
"./snippets/mnote-leptos-tiptap-spike-fdb5779a1690afd2/inline0.js": import2,
"./snippets/mnote-leptos-tiptap-spike-fdb5779a1690afd2/inline0.js": import3,
"./snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/bridge_runtime.js": import3,
"./snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/bridge_runtime.js": import4,
"./snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/bridge_runtime.js": import5,
};
}
+26 -453
View File
@@ -433,51 +433,6 @@ const SPIKE_STYLE: &str = r#"
overflow: hidden;
}
.ai-bridge-status {
position: absolute;
right: 18px;
top: 14px;
z-index: 8;
display: inline-flex;
align-items: center;
gap: 6px;
max-width: min(320px, 42vw);
padding: 5px 8px;
border: 1px solid rgba(17, 24, 39, 0.12);
border-radius: 6px;
background: rgba(255, 255, 255, 0.94);
color: #374151;
font-size: 12px;
line-height: 16px;
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.08);
}
.ai-bridge-status[data-state="pending"] {
border-color: rgba(37, 99, 235, 0.28);
color: #1d4ed8;
}
.ai-bridge-status[data-state="ready"] {
border-color: rgba(22, 163, 74, 0.28);
color: #15803d;
}
.ai-bridge-status[data-state="error"] {
border-color: rgba(220, 38, 38, 0.28);
color: #b91c1c;
}
.ai-bridge-status span {
font-weight: 700;
}
.ai-bridge-status strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.editor-topbar {
display: flex;
justify-content: space-between;
@@ -7816,157 +7771,18 @@ fn apply_ok_feedback<E>(
apply_feedback_result(setter, result.map(|_| ok_message));
}
fn selected_text_from_document(editor: TiptapEditorHandle) -> String {
window()
.and_then(|win| win.get_selection().ok().flatten())
.map(|selection| String::from(selection.to_string()))
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.or_else(|| {
editor
.get_json()
.ok()
.map(|document| collect_plain_text(&document))
})
.unwrap_or_default()
}
fn ai_action_prompt(action: &str, selected_text: &str) -> String {
let instruction = match action {
"continue_writing" => "请基于当前块继续写作",
"summarize" => "请总结当前块内容",
"translate" => "请把当前块内容翻译为简体中文",
"improve" => "请改写并润色当前块内容",
_ => "请分析当前块并给出可用于编辑的建议",
fn open_hermes_page_ai_drawer() {
let Some(win) = window() else {
return;
};
if selected_text.trim().is_empty() {
instruction.to_string()
} else {
format!("{instruction}:\n\n{selected_text}")
}
}
fn build_ai_bridge_payload(
editor: TiptapEditorHandle,
action: &str,
document_id: Option<String>,
workspace_id: Option<String>,
selected_block_index: Option<usize>,
selected_block_id: Option<String>,
selection_state: TiptapSelectionState,
) -> Value {
let tiptap_document = editor.get_json().unwrap_or_else(
|err| json!({ "type": "error", "message": format!("读取 Tiptap JSON 失败:{err}") }),
);
let selection = serde_json::to_value(selection_state).unwrap_or(Value::Null);
let selected_text = selected_text_from_document(editor);
let selected_uids = selected_block_id
.as_ref()
.map(|block_id| vec![Value::String(block_id.clone())])
.unwrap_or_default();
json!({
"stream": true,
"scope": "document",
"messages": [{
"role": "user",
"content": ai_action_prompt(action, &selected_text),
}],
"maxSteps": 8,
"context": {
"source": "leptos-tiptap-island",
"action": action,
"documentId": document_id,
"workspaceId": workspace_id,
"selectedBlockIndex": selected_block_index,
"selectedBlockId": selected_block_id,
"selectedUids": selected_uids,
"selectedText": selected_text,
"selection": {
"currentBlockId": selected_block_id,
"state": selection,
},
"tiptapDocument": tiptap_document,
"documentBlocks": tiptap_document,
},
"options": {
"ai": {
"provider": "online",
},
},
})
}
async fn post_ai_bridge_request(payload: Value) -> Result<Value, String> {
let body_string = serde_json::to_string(&payload)
.map_err(|err| format!("序列化 AI bridge 请求失败:{err}"))?;
let request_init = RequestInit::new();
request_init.set_method("POST");
request_init.set_mode(RequestMode::SameOrigin);
request_init.set_body(&JsValue::from_str(&body_string));
let headers = js_sys::Object::new();
js_sys::Reflect::set(
&headers,
&JsValue::from_str("content-type"),
&JsValue::from_str("application/json"),
)
.map_err(|_| "设置 AI bridge 请求头失败".to_string())?;
js_sys::Reflect::set(
request_init.as_ref(),
&JsValue::from_str("headers"),
&headers,
)
.map_err(|_| "设置 AI bridge 请求头失败".to_string())?;
let win = window().ok_or_else(|| "当前浏览器窗口不可用".to_string())?;
let response_value =
JsFuture::from(win.fetch_with_str_and_init("/api/ai-agent/run", &request_init))
.await
.map_err(|err| format!("AI agent 请求失败:{err:?}"))?;
let response: Response = response_value
.dyn_into()
.map_err(|_| "AI agent 响应类型错误".to_string())?;
let status = response.status();
let content_type = response
.headers()
.get("content-type")
.ok()
.flatten()
.unwrap_or_default();
if content_type.contains("text/event-stream") {
let text_value = JsFuture::from(
response
.text()
.map_err(|_| "读取 AI agent 事件流失败".to_string())?,
)
.await
.map_err(|err| format!("解析 AI agent 事件流失败:{err:?}"))?;
let stream_text = text_value.as_string().unwrap_or_default();
if !response.ok() {
return Err(format!("AI agent 返回失败:HTTP {status}; {stream_text}"));
let Some(document) = win.document() else {
return;
};
if let Ok(Some(trigger)) = document.query_selector("[data-mnote-action=\"open-page-ai\"]") {
if let Some(button) = trigger.dyn_ref::<HtmlElement>() {
button.click();
}
return Ok(json!({
"ok": true,
"stream": stream_text,
"contract": {
"schema": "mnote.ai_agent.run.v1",
"structuredWriteOwner": "openai-agents-python",
},
}));
}
let json_value = JsFuture::from(
response
.json()
.map_err(|_| "读取 AI agent 响应失败".to_string())?,
)
.await
.map_err(|err| format!("解析 AI agent 响应失败:{err:?}"))?;
let parsed = serde_wasm_bindgen::from_value::<Value>(json_value).unwrap_or(Value::Null);
if !response.ok() {
return Err(format!("AI agent 返回失败:HTTP {status}; {parsed}"));
}
Ok(parsed)
}
fn request_ai_edit_bridge(
@@ -7986,261 +7802,26 @@ fn request_ai_edit_bridge(
title: ReadSignal<String>,
set_command_feedback: WriteSignal<String>,
) {
let persisted_identity = runtime_persisted_identity(document_id, workspace_id);
let payload = build_ai_bridge_payload(
let _ = (
editor,
action,
document_id.get_untracked(),
workspace_id.get_untracked(),
document_id,
workspace_id,
selected_block_index,
selected_block_id,
selection_state.get_untracked(),
selection_state,
set_dirty_count,
set_html_output,
set_document_json,
set_json_output,
title,
);
set_ai_bridge_state.set("pending".to_string());
set_ai_bridge_message.set("正在连接 mnote AI agent".to_string());
set_command_feedback.set("AI 请求已发送到 /api/ai-agent/run".to_string());
let _ = bump_mnote_e27_ai_bridge_request_count();
spawn_local(async move {
match post_ai_bridge_request(payload).await {
Ok(response) => {
let owner = response
.get("contract")
.and_then(|contract| contract.get("structuredWriteOwner"))
.and_then(Value::as_str)
.unwrap_or("rust-web-hermes");
match apply_ai_doc_write_response(
editor,
&response,
&persisted_identity,
set_dirty_count,
set_html_output,
set_document_json,
set_json_output,
title,
set_command_feedback,
)
.await
{
Ok(true) => {
set_ai_bridge_state.set("ready".to_string());
set_ai_bridge_message.set(format!("AI agent 已写入:{owner}"));
}
Ok(false) => {
set_ai_bridge_state.set("ready".to_string());
set_ai_bridge_message.set(format!("AI agent 主路径已就绪:{owner}"));
set_command_feedback
.set("AI agent 已返回,未收到可应用的 doc 写入结果".to_string());
}
Err(err) => {
set_ai_bridge_state.set("error".to_string());
set_ai_bridge_message.set(err.clone());
set_command_feedback.set(err);
}
}
}
Err(err) => {
set_ai_bridge_state.set("error".to_string());
set_ai_bridge_message.set(err.clone());
set_command_feedback.set(err);
}
}
});
}
fn ai_legacy_block_text(block: &Value) -> String {
match block.get("content") {
Some(Value::String(text)) => text.clone(),
Some(Value::Array(children)) => children
.iter()
.map(|child| {
child
.get("text")
.and_then(Value::as_str)
.map(str::to_string)
.unwrap_or_else(|| collect_plain_text(child))
})
.collect::<Vec<_>>()
.join(""),
Some(other) => collect_plain_text(other),
None => String::new(),
}
}
fn ai_text_content(text: &str) -> Vec<Value> {
if text.is_empty() {
Vec::new()
} else {
vec![json!({ "type": "text", "text": text })]
}
}
fn ai_block_attrs(block: &Value) -> Option<Map<String, Value>> {
let mut attrs = Map::new();
let block_id = block
.get("id")
.or_else(|| block.get("blockId"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
if let Some(block_id) = block_id {
attrs.insert("blockId".to_string(), Value::String(block_id.to_string()));
}
Some(attrs).filter(|attrs| !attrs.is_empty())
}
fn ai_heading_attrs(block: &Value) -> Option<Map<String, Value>> {
let mut attrs = ai_block_attrs(block).unwrap_or_default();
let level = block
.get("level")
.or_else(|| block.get("props").and_then(|props| props.get("level")))
.or_else(|| block.get("attrs").and_then(|attrs| attrs.get("level")))
.and_then(Value::as_u64)
.unwrap_or(1)
.clamp(1, 6);
attrs.insert("level".to_string(), json!(level));
Some(attrs)
}
fn ai_legacy_block_to_tiptap_node(block: &Value) -> Value {
let block_type = block
.get("type")
.and_then(Value::as_str)
.unwrap_or("paragraph");
let text = ai_legacy_block_text(block);
let inline = ai_text_content(&text);
match block_type {
"heading" | "header" => node_with_attrs("heading", ai_heading_attrs(block), inline),
"quote" | "blockquote" => node_with_attrs(
"blockquote",
ai_block_attrs(block),
vec![paragraph_node(inline)],
),
"code" | "codeBlock" => node_with_attrs("codeBlock", ai_block_attrs(block), inline),
"divider" | "horizontalRule" => {
node_with_attrs("horizontalRule", ai_block_attrs(block), Vec::new())
}
_ => node_with_attrs("paragraph", ai_block_attrs(block), inline),
}
}
fn ai_legacy_blocks_to_tiptap_document(blocks: &[Value]) -> Value {
json!({
"type": "doc",
"content": blocks.iter().map(ai_legacy_block_to_tiptap_node).collect::<Vec<_>>(),
})
}
fn ai_sse_tool_result_payloads(stream_text: &str) -> Vec<Value> {
let mut results = Vec::new();
let mut current_event = String::new();
let mut data_lines: Vec<String> = Vec::new();
let mut flush = |event: &mut String, lines: &mut Vec<String>| {
if event == "tool_result" && !lines.is_empty() {
let raw = lines.join("\n");
if let Ok(value) = serde_json::from_str::<Value>(&raw) {
results.push(value);
}
}
event.clear();
lines.clear();
};
for line in stream_text.lines() {
if line.trim().is_empty() {
flush(&mut current_event, &mut data_lines);
continue;
}
if let Some(event) = line.strip_prefix("event:") {
current_event = event.trim().to_string();
continue;
}
if let Some(data) = line.strip_prefix("data:") {
data_lines.push(data.trim_start().to_string());
}
}
flush(&mut current_event, &mut data_lines);
results
}
async fn persist_ai_doc_write_blocks(
persisted_identity: &PersistedDocumentIdentity,
blocks: &[Value],
tiptap_document: &Value,
) -> Result<(), String> {
let document_id = persisted_identity
.document_id
.clone()
.ok_or_else(|| "AI 写入缺少 documentId,无法保存".to_string())?;
let workspace_id = persisted_identity
.workspace_id
.clone()
.ok_or_else(|| "AI 写入缺少 workspaceId,无法保存".to_string())?;
let payload = json!({
"documentId": document_id,
"workspaceId": workspace_id,
"content": blocks,
"tiptapDocument": tiptap_document,
"blockCount": blocks.len(),
"snapshotCapturedAt": js_sys::Date::new_0().to_iso_string().as_string().unwrap_or_default(),
});
let body =
serde_json::to_string(&payload).map_err(|err| format!("序列化 AI 保存请求失败:{err}"))?;
post_mnote_document_save(body)
.await
.map_err(|err| format!("AI 写入保存失败:{err:?}"))?;
Ok(())
}
async fn apply_ai_doc_write_response(
editor: TiptapEditorHandle,
response: &Value,
persisted_identity: &PersistedDocumentIdentity,
set_dirty_count: WriteSignal<u32>,
set_html_output: WriteSignal<String>,
set_document_json: WriteSignal<Value>,
set_json_output: WriteSignal<String>,
title: ReadSignal<String>,
set_command_feedback: WriteSignal<String>,
) -> Result<bool, String> {
let Some(stream_text) = response.get("stream").and_then(Value::as_str) else {
return Ok(false);
};
for payload in ai_sse_tool_result_payloads(stream_text) {
let tool = payload
.get("tool")
.and_then(Value::as_str)
.unwrap_or_default();
let ok = payload.get("ok").and_then(Value::as_bool).unwrap_or(false);
if !ok || !(tool == "doc_insert_blocks" || tool == "doc_replace_range") {
continue;
}
let Some(blocks) = payload
.get("result")
.and_then(|result| result.get("data"))
.and_then(Value::as_array)
else {
continue;
};
let next_document = ai_legacy_blocks_to_tiptap_document(blocks);
persist_ai_doc_write_blocks(persisted_identity, blocks, &next_document).await?;
apply_document_update(
editor,
persisted_identity,
next_document,
set_dirty_count,
set_html_output,
set_document_json,
set_json_output,
title,
set_command_feedback,
"AI 已写入当前页面",
);
return Ok(true);
}
Ok(false)
let _ = (
set_ai_bridge_state,
set_ai_bridge_message,
set_command_feedback,
);
open_hermes_page_ai_drawer();
}
fn normalize_layout_density(value: Option<String>) -> String {
@@ -8366,8 +7947,8 @@ fn App(mount_options: MountOptions) -> impl IntoView {
let (block_menu_open, set_block_menu_open) = signal(false);
let (block_turn_into_open, set_block_turn_into_open) = signal(false);
let (block_folded_title_open, set_block_folded_title_open) = signal(false);
let (ai_bridge_state, set_ai_bridge_state) = signal("idle".to_string());
let (ai_bridge_message, set_ai_bridge_message) = signal("AI bridge 未连接".to_string());
let (_ai_bridge_state, set_ai_bridge_state) = signal("idle".to_string());
let (_ai_bridge_message, set_ai_bridge_message) = signal("".to_string());
let (pending_drag, set_pending_drag) = signal(None::<PendingDragState>);
let (dragging_block_index, set_dragging_block_index) = signal(None::<usize>);
let (dragging_block_anchor, set_dragging_block_anchor) = signal(None::<HoveredBlockState>);
@@ -9320,14 +8901,6 @@ fn App(mount_options: MountOptions) -> impl IntoView {
}}
<section class="editor-card">
<div
class="ai-bridge-status"
data-testid="mnote-leptos-tiptap-ai-status"
data-state=move || ai_bridge_state.get()
>
<span>"AI"</span>
<strong>{move || ai_bridge_message.get()}</strong>
</div>
{move || {
if is_embedded {
().into_any()