feat(ai): switch page ai to hermes panel
This commit is contained in:
@@ -7376,10 +7376,10 @@ fn build_sidebar_kernel_nodes(
|
||||
}
|
||||
|
||||
fn build_sidebar_kernel_edges(nodes: &[KernelNode]) -> Vec<KernelEdge> {
|
||||
nodes
|
||||
.iter()
|
||||
.filter_map(|node| {
|
||||
node.parent_id.as_ref().map(|parent_id| KernelEdge {
|
||||
let mut edges = Vec::new();
|
||||
for node in nodes {
|
||||
if let Some(parent_id) = node.parent_id.as_ref() {
|
||||
edges.push(KernelEdge {
|
||||
id: format!("edge_parent_of_{}_{}", parent_id, node.id),
|
||||
edge_type: KernelEdgeType::ParentOf,
|
||||
workspace_id: node.workspace_id.clone(),
|
||||
@@ -7387,9 +7387,58 @@ fn build_sidebar_kernel_edges(nodes: &[KernelNode]) -> Vec<KernelEdge> {
|
||||
to_node_id: node.id.clone(),
|
||||
metadata: BTreeMap::new(),
|
||||
audit: KernelAuditStamp::default(),
|
||||
})
|
||||
});
|
||||
if let Some(artifact_type) = ai_artifact_type_for_node(node) {
|
||||
edges.push(KernelEdge {
|
||||
id: format!("edge_ai_artifact_reference_{}_{}", parent_id, node.id),
|
||||
edge_type: KernelEdgeType::SourceOf,
|
||||
workspace_id: node.workspace_id.clone(),
|
||||
from_node_id: parent_id.clone(),
|
||||
to_node_id: node.id.clone(),
|
||||
metadata: BTreeMap::from([
|
||||
("kind".into(), json!("ai_artifact_reference")),
|
||||
("artifactType".into(), json!(artifact_type)),
|
||||
("projectionOnlyGroup".into(), json!("AI Artifacts")),
|
||||
]),
|
||||
audit: KernelAuditStamp::default(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
edges
|
||||
}
|
||||
|
||||
fn ai_artifact_type_for_node(node: &KernelNode) -> Option<&'static str> {
|
||||
let parent_id = node.parent_id.as_ref()?;
|
||||
if node.id == format!("summary_{parent_id}") {
|
||||
return Some("summary");
|
||||
}
|
||||
if node.id.starts_with(&format!("ai_note_{parent_id}_")) {
|
||||
return Some("ai_note");
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn ai_artifacts_projection_group_meta(edges: &[KernelEdge]) -> Value {
|
||||
let artifact_document_ids = edges
|
||||
.iter()
|
||||
.filter(|edge| {
|
||||
edge.metadata
|
||||
.get("kind")
|
||||
.and_then(Value::as_str)
|
||||
.map(|kind| kind == "ai_artifact_reference")
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect()
|
||||
.map(|edge| edge.to_node_id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
json!({
|
||||
"title": "AI Artifacts",
|
||||
"projectionOnly": true,
|
||||
"source": "kernel.project_view.synthetic_group",
|
||||
"kernelNodeId": Value::Null,
|
||||
"edgeKind": "ai_artifact_reference",
|
||||
"artifactDocumentIds": artifact_document_ids,
|
||||
})
|
||||
}
|
||||
|
||||
fn kernel_node_sort_order(node: &KernelNode) -> i64 {
|
||||
@@ -7979,6 +8028,36 @@ fn build_file_tree_projection_result(
|
||||
let visible_rows = items.len();
|
||||
let visible_edges = edges.len();
|
||||
|
||||
let mut meta = BTreeMap::from([(
|
||||
"search".into(),
|
||||
json!({
|
||||
"query": requested_query.clone(),
|
||||
"maxResults": requested_max_results,
|
||||
"maxResultsRule": "matches_only_before_ancestor_completion",
|
||||
"ancestorCompletion": "include_all_ancestors_after_match_truncation",
|
||||
"ordering": "kernel_file_tree_preorder",
|
||||
"indexingVisibility": {
|
||||
"schema": "mnote.file_tree.indexing_visibility",
|
||||
"schemaVersion": 1,
|
||||
"source": "kernel.project_view",
|
||||
"status": "visible",
|
||||
"requestKey": requested_query
|
||||
.as_ref()
|
||||
.map(|query| format!("{}:{query}", root_node_id.unwrap_or("root"))),
|
||||
"indexedResourceKinds": ["document", "index", "asset", "asset_folder", "mindmap", "book", "pdf"],
|
||||
"visibleResourceKinds": ["document", "index", "asset", "asset_folder", "mindmap", "book", "pdf"],
|
||||
"metrics": {
|
||||
"visibleRows": visible_rows,
|
||||
"visibleEdges": visible_edges,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)]);
|
||||
meta.insert(
|
||||
"aiArtifacts".into(),
|
||||
ai_artifacts_projection_group_meta(&edges),
|
||||
);
|
||||
|
||||
KernelProjectionResult {
|
||||
projection_id: format!(
|
||||
"kernel_projection:file_tree:{}",
|
||||
@@ -7988,31 +8067,7 @@ fn build_file_tree_projection_result(
|
||||
root_node_id: root_node_id.map(ToOwned::to_owned),
|
||||
items,
|
||||
edges,
|
||||
meta: BTreeMap::from([(
|
||||
"search".into(),
|
||||
json!({
|
||||
"query": requested_query.clone(),
|
||||
"maxResults": requested_max_results,
|
||||
"maxResultsRule": "matches_only_before_ancestor_completion",
|
||||
"ancestorCompletion": "include_all_ancestors_after_match_truncation",
|
||||
"ordering": "kernel_file_tree_preorder",
|
||||
"indexingVisibility": {
|
||||
"schema": "mnote.file_tree.indexing_visibility",
|
||||
"schemaVersion": 1,
|
||||
"source": "kernel.project_view",
|
||||
"status": "visible",
|
||||
"requestKey": requested_query
|
||||
.as_ref()
|
||||
.map(|query| format!("{}:{query}", root_node_id.unwrap_or("root"))),
|
||||
"indexedResourceKinds": ["document", "index", "asset", "asset_folder", "mindmap", "book", "pdf"],
|
||||
"visibleResourceKinds": ["document", "index", "asset", "asset_folder", "mindmap", "book", "pdf"],
|
||||
"metrics": {
|
||||
"visibleRows": visible_rows,
|
||||
"visibleEdges": visible_edges,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)]),
|
||||
meta,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17443,6 +17498,138 @@ mod tests {
|
||||
assert_eq!(result["edges"].as_array().map(Vec::len), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kernel_edges_list_exposes_ai_artifact_reference_edges() {
|
||||
let result = execute_runtime_query(RuntimeInput::Query {
|
||||
context: demo_context(),
|
||||
query: RuntimeQueryEnvelopeWire {
|
||||
name: "kernel.edges.list".into(),
|
||||
payload: json!({
|
||||
"workspaceId": "ws_1",
|
||||
"nodeId": "page_root",
|
||||
"direction": "both",
|
||||
}),
|
||||
},
|
||||
data: Some(json!({
|
||||
"documents": [
|
||||
{
|
||||
"id": "page_root",
|
||||
"workspace_id": "ws_1",
|
||||
"title": "根页面",
|
||||
"parent_id": null,
|
||||
"sort_order": 0,
|
||||
"is_starred": true,
|
||||
"is_template": false,
|
||||
"created_at": "2026-04-16T00:00:00Z",
|
||||
"updated_at": "2026-04-16T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "summary_page_root",
|
||||
"workspace_id": "ws_1",
|
||||
"title": "AI Summary",
|
||||
"parent_id": "page_root",
|
||||
"sort_order": 1,
|
||||
"is_starred": false,
|
||||
"is_template": false,
|
||||
"created_at": "2026-04-16T00:00:00Z",
|
||||
"updated_at": "2026-04-16T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "ai_note_page_root_req_1",
|
||||
"workspace_id": "ws_1",
|
||||
"title": "AI Note",
|
||||
"parent_id": "page_root",
|
||||
"sort_order": 2,
|
||||
"is_starred": false,
|
||||
"is_template": false,
|
||||
"created_at": "2026-04-16T00:00:00Z",
|
||||
"updated_at": "2026-04-16T00:00:00Z"
|
||||
}
|
||||
]
|
||||
})),
|
||||
})
|
||||
.expect("kernel edge list should build");
|
||||
|
||||
let edges = result["edges"].as_array().expect("edges should be array");
|
||||
let artifact_edges = edges
|
||||
.iter()
|
||||
.filter(|edge| edge["metadata"]["kind"] == json!("ai_artifact_reference"))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(artifact_edges.len(), 2);
|
||||
assert_eq!(artifact_edges[0]["fromNodeId"], json!("page_root"));
|
||||
assert_eq!(artifact_edges[0]["toNodeId"], json!("summary_page_root"));
|
||||
assert_eq!(artifact_edges[0]["edgeType"], json!("source_of"));
|
||||
assert_eq!(artifact_edges[0]["metadata"]["artifactType"], json!("summary"));
|
||||
assert_eq!(artifact_edges[1]["toNodeId"], json!("ai_note_page_root_req_1"));
|
||||
assert_eq!(artifact_edges[1]["metadata"]["artifactType"], json!("ai_note"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_tree_projection_marks_ai_artifacts_group_as_projection_only() {
|
||||
let result = execute_runtime_query(RuntimeInput::Query {
|
||||
context: demo_context(),
|
||||
query: RuntimeQueryEnvelopeWire {
|
||||
name: "kernel.project_view".into(),
|
||||
payload: json!({
|
||||
"projection": "file_tree",
|
||||
"workspaceId": "ws_1",
|
||||
"rootNodeId": "page_root",
|
||||
"depth": 2,
|
||||
"includeEdges": true,
|
||||
"nodeTypes": ["page"],
|
||||
}),
|
||||
},
|
||||
data: Some(json!({
|
||||
"documents": [
|
||||
{
|
||||
"id": "page_root",
|
||||
"workspace_id": "ws_1",
|
||||
"title": "根页面",
|
||||
"parent_id": null,
|
||||
"sort_order": 0,
|
||||
"is_starred": true,
|
||||
"is_template": false,
|
||||
"created_at": "2026-04-16T00:00:00Z",
|
||||
"updated_at": "2026-04-16T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "summary_page_root",
|
||||
"workspace_id": "ws_1",
|
||||
"title": "AI Summary",
|
||||
"parent_id": "page_root",
|
||||
"sort_order": 1,
|
||||
"is_starred": false,
|
||||
"is_template": false,
|
||||
"created_at": "2026-04-16T00:00:00Z",
|
||||
"updated_at": "2026-04-16T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"media_assets": [],
|
||||
"mindmap_assets": [],
|
||||
"table_assets": [],
|
||||
"mindmap_asset_children": {}
|
||||
})),
|
||||
})
|
||||
.expect("file tree projection should build");
|
||||
|
||||
let items = result["items"].as_array().expect("items should be array");
|
||||
assert!(!items
|
||||
.iter()
|
||||
.any(|item| item["nodeId"] == json!("AI Artifacts")));
|
||||
assert_eq!(result["meta"]["aiArtifacts"]["title"], json!("AI Artifacts"));
|
||||
assert_eq!(result["meta"]["aiArtifacts"]["projectionOnly"], json!(true));
|
||||
assert_eq!(
|
||||
result["meta"]["aiArtifacts"]["source"],
|
||||
json!("kernel.project_view.synthetic_group")
|
||||
);
|
||||
assert_eq!(result["meta"]["aiArtifacts"]["kernelNodeId"], Value::Null);
|
||||
assert_eq!(
|
||||
result["meta"]["aiArtifacts"]["artifactDocumentIds"],
|
||||
json!(["summary_page_root"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_rebuild_tool_executes_in_rust_runtime() {
|
||||
let result = execute_runtime_query(RuntimeInput::Tool {
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::hermes_tools::ToolCallInput;
|
||||
use crate::routes::command_support::execute_runtime_command_via_convex_with_artifacts;
|
||||
use bridge_runtime::{
|
||||
RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub async fn create_summary(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<Value, WebError> {
|
||||
create_artifact_node(
|
||||
state,
|
||||
context,
|
||||
input,
|
||||
"summary",
|
||||
"mnote.artifact.create_summary",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create_ai_note(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<Value, WebError> {
|
||||
create_artifact_node(
|
||||
state,
|
||||
context,
|
||||
input,
|
||||
"ai_note",
|
||||
"mnote.artifact.create_ai_note",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn create_artifact_node(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
node_type: &str,
|
||||
tool_name: &str,
|
||||
) -> Result<Value, WebError> {
|
||||
ensure_write_contract(context, input)?;
|
||||
let document_id = input.effective_document_id().ok_or_else(|| {
|
||||
WebError::bad_request_code("mnote_tool_bad_request", "artifact 工具缺少 documentId")
|
||||
.with_context(context)
|
||||
})?;
|
||||
let workspace_id = input.effective_workspace_id();
|
||||
let content = input
|
||||
.arg_string("summary")
|
||||
.or_else(|| input.arg_string("content"))
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code("mnote_tool_bad_request", "artifact 工具缺少内容")
|
||||
.with_context(context)
|
||||
})?;
|
||||
let idempotency_key =
|
||||
input.idempotency_key_or_default(&format!("{tool_name}_{}", context.trace.request_id));
|
||||
let command_id = format!(
|
||||
"{}_{}",
|
||||
tool_name.replace('.', "_"),
|
||||
context.trace.request_id
|
||||
);
|
||||
let artifact_document_id = if node_type == "summary" {
|
||||
format!("summary_{}", document_id)
|
||||
} else {
|
||||
format!("ai_note_{}_{}", document_id, context.trace.request_id)
|
||||
};
|
||||
|
||||
if input.dry_run.unwrap_or(false) {
|
||||
return Ok(json!({
|
||||
"dryRun": true,
|
||||
"commandName": "tree.node.create",
|
||||
"commandId": command_id,
|
||||
"artifactType": node_type,
|
||||
"artifactDocumentId": artifact_document_id,
|
||||
"documentId": document_id,
|
||||
"workspaceId": workspace_id,
|
||||
"diff": [{"op": "create_artifact", "artifactType": node_type}]
|
||||
}));
|
||||
}
|
||||
|
||||
let command = RuntimeCommandEnvelopeWire {
|
||||
name: "tree.node.create".into(),
|
||||
command_id: command_id.clone(),
|
||||
idempotency_key: Some(idempotency_key),
|
||||
actor: RuntimeActorWire {
|
||||
actor_type: context.auth.actor_type.clone(),
|
||||
actor_id: context.auth.actor_id.clone(),
|
||||
session_id: input
|
||||
.session_id
|
||||
.clone()
|
||||
.or_else(|| context.auth.session_id.clone()),
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "hermes".into(),
|
||||
client: "mnote-hermes-plugin".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: workspace_id.clone(),
|
||||
capabilities: input.capability_scope.clone().unwrap_or_default(),
|
||||
},
|
||||
target: Some(RuntimeTargetWire {
|
||||
workspace_id: workspace_id.clone(),
|
||||
page_id: Some(document_id.clone()),
|
||||
block_id: None,
|
||||
}),
|
||||
payload: json!({
|
||||
"workspaceId": workspace_id,
|
||||
"parentId": document_id,
|
||||
"documentId": artifact_document_id,
|
||||
"accessScope": "private",
|
||||
"nodeType": node_type,
|
||||
"title": if node_type == "summary" { "AI Summary" } else { "AI Note" },
|
||||
"content": [
|
||||
{
|
||||
"id": format!("{}_body", node_type),
|
||||
"type": "paragraph",
|
||||
"content": [{"type": "text", "text": content}]
|
||||
}
|
||||
],
|
||||
"artifact": {
|
||||
"kind": node_type,
|
||||
"sourceDocumentId": document_id,
|
||||
"source": "hermes",
|
||||
"sessionId": input.session_id,
|
||||
"runId": input.run_id,
|
||||
"toolCallId": input.tool_call_id,
|
||||
"traceId": input.effective_trace_id(&context.trace.trace_id)
|
||||
},
|
||||
"referenceEdge": {
|
||||
"from": document_id,
|
||||
"kind": "ai_artifact_reference"
|
||||
}
|
||||
}),
|
||||
preflight_data: None,
|
||||
reason: Some(tool_name.into()),
|
||||
refs: vec![tool_name.into(), "hermes-tool-call".into()],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
};
|
||||
let execution = execute_runtime_command_via_convex_with_artifacts(
|
||||
state.config(),
|
||||
context,
|
||||
workspace_id.as_deref(),
|
||||
command,
|
||||
)
|
||||
.await?;
|
||||
Ok(json!({
|
||||
"commandName": "tree.node.create",
|
||||
"commandId": command_id,
|
||||
"artifactType": node_type,
|
||||
"artifactDocumentId": artifact_document_id,
|
||||
"referenceEdge": {
|
||||
"from": document_id,
|
||||
"to": artifact_document_id,
|
||||
"kind": "ai_artifact_reference"
|
||||
},
|
||||
"result": execution.result,
|
||||
"artifacts": execution.artifacts,
|
||||
"artifactError": execution.artifact_error
|
||||
}))
|
||||
}
|
||||
|
||||
fn ensure_write_contract(context: &RequestContext, input: &ToolCallInput) -> Result<(), WebError> {
|
||||
if !input.has_idempotency_key() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_tool_idempotency_required",
|
||||
"写入型 mnote Hermes tool 必须携带 idempotencyKey",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
if input.dry_run.is_none() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_tool_dry_run_required",
|
||||
"写入型 mnote Hermes tool 必须显式携带 dryRun",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub const MANIFEST_SCHEMA_VERSION: &str = "mnote.hermes_tool_manifest.v1";
|
||||
pub const TOOL_SCHEMA_VERSION: &str = "mnote.hermes_tool.v1";
|
||||
|
||||
pub fn manifest() -> Value {
|
||||
json!({
|
||||
"schemaVersion": MANIFEST_SCHEMA_VERSION,
|
||||
"plugin": {
|
||||
"name": "mnote",
|
||||
"description": "mnote 页面、树、artifact 与 edge 工具",
|
||||
"runtimeOwner": "mnote-web",
|
||||
"writeOwner": "rust-runtime-kernel"
|
||||
},
|
||||
"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"])
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
fn page_get_tool() -> Value {
|
||||
json!({
|
||||
"name": "mnote.page.get",
|
||||
"description": "读取当前页面 Page Aggregate 摘要",
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": ["page.read"],
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["workspaceId", "documentId", "sessionId", "runId", "toolCallId", "traceId"],
|
||||
"properties": {
|
||||
"workspaceId": { "type": "string" },
|
||||
"documentId": { "type": "string" },
|
||||
"sessionId": { "type": "string" },
|
||||
"runId": { "type": "string" },
|
||||
"toolCallId": { "type": "string" },
|
||||
"traceId": { "type": "string" },
|
||||
"includeBody": { "type": "boolean", "default": true },
|
||||
"includeOptions": { "type": "boolean", "default": true },
|
||||
"includeBlocks": { "type": "boolean", "default": true }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn planned_tool(name: &str, scope: impl IntoIterator<Item = &'static str>) -> Value {
|
||||
json!({
|
||||
"name": name,
|
||||
"description": "已冻结合同,按 7-4 后续 task 接入 Rust runtime / kernel",
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": scope.into_iter().collect::<Vec<_>>(),
|
||||
"status": "planned"
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
pub mod artifact;
|
||||
pub mod manifest;
|
||||
pub mod page;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ToolCallInput {
|
||||
pub tool_name: String,
|
||||
pub workspace_id: Option<String>,
|
||||
pub document_id: Option<String>,
|
||||
pub actor_id: Option<String>,
|
||||
pub session_id: Option<String>,
|
||||
pub run_id: Option<String>,
|
||||
pub tool_call_id: Option<String>,
|
||||
pub trace_id: Option<String>,
|
||||
pub idempotency_key: Option<String>,
|
||||
pub dry_run: Option<bool>,
|
||||
pub capability_scope: Option<Vec<String>>,
|
||||
pub args: Option<Value>,
|
||||
}
|
||||
|
||||
impl ToolCallInput {
|
||||
pub fn arg_string(&self, key: &str) -> Option<String> {
|
||||
self.args
|
||||
.as_ref()
|
||||
.and_then(|args| args.get(key))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
pub fn arg_value(&self, key: &str) -> Option<Value> {
|
||||
self.args.as_ref().and_then(|args| args.get(key)).cloned()
|
||||
}
|
||||
|
||||
pub fn effective_workspace_id(&self) -> Option<String> {
|
||||
self.workspace_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| self.arg_string("workspaceId"))
|
||||
}
|
||||
|
||||
pub fn effective_document_id(&self) -> Option<String> {
|
||||
self.document_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| self.arg_string("documentId"))
|
||||
}
|
||||
|
||||
pub fn effective_tool_call_id(&self) -> String {
|
||||
self.tool_call_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("tool_call_missing")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub fn effective_trace_id<'a>(&'a self, fallback: &'a str) -> &'a str {
|
||||
self.trace_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(fallback)
|
||||
}
|
||||
|
||||
pub fn idempotency_key_or_default(&self, fallback: &str) -> String {
|
||||
self.idempotency_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(fallback)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub fn has_idempotency_key(&self) -> bool {
|
||||
self.idempotency_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.is_some()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::hermes_tools::ToolCallInput;
|
||||
use crate::routes::command_support::execute_runtime_command_via_convex_with_artifacts;
|
||||
use crate::routes::web_shell::build_page_aggregate_snapshot;
|
||||
use bridge_runtime::{
|
||||
RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub async fn page_get(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<Value, WebError> {
|
||||
let document_id = input.effective_document_id().ok_or_else(|| {
|
||||
WebError::bad_request_code("mnote_tool_bad_request", "mnote.page.get 缺少 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 title = aggregate_value
|
||||
.pointer("/head/title")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("无标题");
|
||||
let content = aggregate_value
|
||||
.pointer("/body/content")
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null);
|
||||
let page_options = aggregate_value
|
||||
.pointer("/layout/pageOptions")
|
||||
.or_else(|| aggregate_value.pointer("/layout/page_options"))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!({}));
|
||||
let blocks = summarize_blocks(&content);
|
||||
let body_summary = blocks
|
||||
.iter()
|
||||
.filter_map(|block| block.get("text").and_then(Value::as_str))
|
||||
.filter(|text| !text.trim().is_empty())
|
||||
.take(8)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
Ok(json!({
|
||||
"documentId": document_id,
|
||||
"workspaceId": workspace_id,
|
||||
"title": title,
|
||||
"bodySummary": body_summary,
|
||||
"pageOptions": page_options,
|
||||
"blocks": blocks,
|
||||
"aggregateSchema": aggregate_value.get("schema").cloned().unwrap_or(Value::Null),
|
||||
"aggregateSource": aggregate_value.get("source").cloned().unwrap_or(Value::Null)
|
||||
}))
|
||||
}
|
||||
|
||||
fn ensure_write_contract(context: &RequestContext, input: &ToolCallInput) -> Result<(), WebError> {
|
||||
if !input.has_idempotency_key() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_tool_idempotency_required",
|
||||
"写入型 mnote Hermes tool 必须携带 idempotencyKey",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
if input.dry_run.is_none() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_tool_dry_run_required",
|
||||
"写入型 mnote Hermes tool 必须显式携带 dryRun",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn page_save(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<Value, WebError> {
|
||||
let content = input.arg_value("content").ok_or_else(|| {
|
||||
WebError::bad_request_code("mnote_tool_bad_request", "mnote.page.save 缺少 content")
|
||||
.with_context(context)
|
||||
})?;
|
||||
page_command(
|
||||
state,
|
||||
context,
|
||||
input,
|
||||
"page.body.save",
|
||||
json!({
|
||||
"content": content,
|
||||
"mode": input.arg_string("mode").unwrap_or_else(|| "replace".into())
|
||||
}),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn update_title(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<Value, WebError> {
|
||||
let title = input.arg_string("title").ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_tool_bad_request",
|
||||
"mnote.page.update_title 缺少 title",
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
page_command(
|
||||
state,
|
||||
context,
|
||||
input,
|
||||
"page.head.updateTitle",
|
||||
json!({ "title": title }),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn update_options(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<Value, WebError> {
|
||||
let options = input.arg_value("options").ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_tool_bad_request",
|
||||
"mnote.page.update_options 缺少 options",
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
let (wired_options, ignored_options, warnings) = filter_wired_page_options(options);
|
||||
page_command(
|
||||
state,
|
||||
context,
|
||||
input,
|
||||
"page.layout.updateOptions",
|
||||
json!({ "options": wired_options }),
|
||||
Some(json!({
|
||||
"ignoredOptions": ignored_options,
|
||||
"warnings": warnings
|
||||
})),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn page_command(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
command_name: &str,
|
||||
payload_patch: Value,
|
||||
result_extra: Option<Value>,
|
||||
) -> Result<Value, WebError> {
|
||||
ensure_write_contract(context, input)?;
|
||||
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 command_id = format!(
|
||||
"{}_{}",
|
||||
command_name.replace('.', "_"),
|
||||
context.trace.request_id
|
||||
);
|
||||
let idempotency_key = input.idempotency_key_or_default(&command_id);
|
||||
let payload = merge_page_payload(&document_id, workspace_id.as_deref(), payload_patch);
|
||||
|
||||
if input.dry_run.unwrap_or(false) {
|
||||
let mut result = json!({
|
||||
"dryRun": true,
|
||||
"commandName": command_name,
|
||||
"commandId": command_id,
|
||||
"documentId": document_id,
|
||||
"workspaceId": workspace_id,
|
||||
"diff": [{"op": command_name, "payload": payload}]
|
||||
});
|
||||
merge_result_extra(&mut result, result_extra);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
let command = RuntimeCommandEnvelopeWire {
|
||||
name: command_name.into(),
|
||||
command_id: command_id.clone(),
|
||||
idempotency_key: Some(idempotency_key),
|
||||
actor: RuntimeActorWire {
|
||||
actor_type: context.auth.actor_type.clone(),
|
||||
actor_id: context.auth.actor_id.clone(),
|
||||
session_id: input
|
||||
.session_id
|
||||
.clone()
|
||||
.or_else(|| context.auth.session_id.clone()),
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "hermes".into(),
|
||||
client: "mnote-hermes-plugin".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: workspace_id.clone(),
|
||||
capabilities: input.capability_scope.clone().unwrap_or_default(),
|
||||
},
|
||||
target: Some(RuntimeTargetWire {
|
||||
workspace_id: workspace_id.clone(),
|
||||
page_id: Some(document_id.clone()),
|
||||
block_id: None,
|
||||
}),
|
||||
payload,
|
||||
preflight_data: None,
|
||||
reason: Some(format!("Hermes tool {command_name}")),
|
||||
refs: vec![command_name.into(), "hermes-tool-call".into()],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
};
|
||||
let execution = execute_runtime_command_via_convex_with_artifacts(
|
||||
state.config(),
|
||||
context,
|
||||
workspace_id.as_deref(),
|
||||
command,
|
||||
)
|
||||
.await?;
|
||||
let mut result = json!({
|
||||
"commandName": command_name,
|
||||
"commandId": command_id,
|
||||
"result": execution.result,
|
||||
"artifacts": execution.artifacts,
|
||||
"artifactError": execution.artifact_error
|
||||
});
|
||||
merge_result_extra(&mut result, result_extra);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn merge_result_extra(result: &mut Value, extra: Option<Value>) {
|
||||
if let (Value::Object(result_map), Some(Value::Object(extra_map))) = (result, extra) {
|
||||
for (key, value) in extra_map {
|
||||
result_map.insert(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_page_payload(document_id: &str, workspace_id: Option<&str>, patch: Value) -> Value {
|
||||
let mut payload = json!({
|
||||
"documentId": document_id,
|
||||
"workspaceId": workspace_id
|
||||
});
|
||||
if let (Value::Object(base), Value::Object(extra)) = (&mut payload, patch) {
|
||||
for (key, value) in extra {
|
||||
base.insert(key, value);
|
||||
}
|
||||
}
|
||||
payload
|
||||
}
|
||||
|
||||
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();
|
||||
let mut ignored = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
if let Value::Object(map) = options {
|
||||
for (key, value) in map {
|
||||
if allowed.contains(&key.as_str()) {
|
||||
out.insert(key, value);
|
||||
} else {
|
||||
warnings.push(json!({
|
||||
"code": "page_option_not_wired",
|
||||
"field": key.clone(),
|
||||
"message": "页面设置字段尚未接入 Page Aggregate command,已忽略"
|
||||
}));
|
||||
ignored.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
(Value::Object(out), ignored, warnings)
|
||||
}
|
||||
|
||||
fn summarize_blocks(content: &Value) -> Vec<Value> {
|
||||
let mut out = Vec::new();
|
||||
collect_blocks(content, &mut out);
|
||||
out.into_iter().take(40).collect()
|
||||
}
|
||||
|
||||
fn collect_blocks(value: &Value, out: &mut Vec<Value>) {
|
||||
match value {
|
||||
Value::Array(items) => {
|
||||
for item in items {
|
||||
collect_blocks(item, out);
|
||||
}
|
||||
}
|
||||
Value::Object(map) => {
|
||||
let block_id = map
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let block_type = map
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
let text = collect_text(value);
|
||||
if !block_id.is_empty() || !text.is_empty() {
|
||||
out.push(json!({
|
||||
"id": block_id,
|
||||
"type": block_type,
|
||||
"text": text
|
||||
}));
|
||||
}
|
||||
if let Some(children) = map.get("children") {
|
||||
collect_blocks(children, out);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_text(value: &Value) -> String {
|
||||
match value {
|
||||
Value::String(text) => text.clone(),
|
||||
Value::Array(items) => items.iter().map(collect_text).collect::<Vec<_>>().join(""),
|
||||
Value::Object(map) => {
|
||||
if let Some(text) = map.get("text").and_then(Value::as_str) {
|
||||
return text.to_string();
|
||||
}
|
||||
if let Some(content) = map.get("content") {
|
||||
return collect_text(content);
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod app;
|
||||
pub mod context;
|
||||
pub mod error;
|
||||
pub mod hermes_tools;
|
||||
pub mod local_folder_watcher_registry;
|
||||
pub mod middleware;
|
||||
pub mod page_aggregate;
|
||||
|
||||
@@ -3,16 +3,12 @@ use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Extension, State};
|
||||
use axum::http::{header, HeaderMap, HeaderName, HeaderValue, Request, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use serde_json::{json, Value};
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use axum::response::Response;
|
||||
use serde_json::Value;
|
||||
|
||||
pub async fn next_ai_agent_run(
|
||||
State(state): State<AppState>,
|
||||
State(_state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
request: Request<Body>,
|
||||
) -> Result<Response, WebError> {
|
||||
@@ -29,19 +25,17 @@ pub async fn next_ai_agent_run(
|
||||
.with_header("x-mnote-web-owner", "mnote-web")
|
||||
})?;
|
||||
|
||||
if let Some(provider) = explicit_agent_provider(&payload) {
|
||||
return Err(WebError::bad_gateway_code(
|
||||
"ai_provider_bridge_unavailable",
|
||||
format!(
|
||||
"{provider} provider 直连链路已退场;当前仅保留 mnote-cli host 主路径,不再静默降级。"
|
||||
),
|
||||
)
|
||||
.with_context(&context)
|
||||
.with_header("x-mnote-web-owner", "mnote-web")
|
||||
.with_header("x-mnote-ai-execution-owner", "provider-bridge-unavailable"));
|
||||
}
|
||||
|
||||
run_local_mnote_cli_ai_host(&state, &context, &payload).await
|
||||
let provider = explicit_agent_provider(&payload).unwrap_or("legacy");
|
||||
Err(WebError::new(
|
||||
StatusCode::GONE,
|
||||
"legacy_ai_agent_run_retired",
|
||||
format!(
|
||||
"旧 /api/ai-agent/run 页面 AI 主链已退场,provider={provider} 不再静默降级;请使用 Hermes client proxy 与 mnote Hermes plugin。"
|
||||
),
|
||||
)
|
||||
.with_context(&context)
|
||||
.with_header("x-mnote-web-owner", "mnote-web")
|
||||
.with_header("x-mnote-ai-execution-owner", "legacy-ai-agent-run-retired"))
|
||||
}
|
||||
|
||||
fn explicit_agent_provider(payload: &Value) -> Option<&'static str> {
|
||||
@@ -60,205 +54,6 @@ fn explicit_agent_provider(payload: &Value) -> Option<&'static str> {
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_repo_root() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../..")
|
||||
}
|
||||
|
||||
fn build_mnote_cli_args(context: &RequestContext, payload: &Value) -> Vec<String> {
|
||||
let ai = payload
|
||||
.get("options")
|
||||
.and_then(|value| value.get("ai"))
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null);
|
||||
let runtime_context = payload.get("context").cloned().unwrap_or(Value::Null);
|
||||
let document_id = runtime_context
|
||||
.get("documentId")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("current");
|
||||
let workspace_id = runtime_context
|
||||
.get("workspaceId")
|
||||
.and_then(Value::as_str)
|
||||
.or(context.workspace.workspace_id.as_deref());
|
||||
let session_id = ai
|
||||
.get("sessionId")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| format!("ai-{}", context.trace.request_id));
|
||||
let args_json = json!({
|
||||
"pageId": document_id,
|
||||
"documentId": document_id,
|
||||
"workspaceId": workspace_id,
|
||||
"provider": ai.get("provider").cloned().unwrap_or(Value::Null),
|
||||
"modelKey": ai.get("modelKey").cloned().unwrap_or(Value::Null),
|
||||
"profileId": ai.get("profileId").cloned().unwrap_or(Value::Null),
|
||||
"selectedUids": runtime_context.get("selectedUids").cloned().unwrap_or(Value::Null),
|
||||
"pageOptions": runtime_context.get("pageOptions").cloned().unwrap_or(Value::Null),
|
||||
})
|
||||
.to_string();
|
||||
|
||||
vec![
|
||||
"run".into(),
|
||||
"--quiet".into(),
|
||||
"--manifest-path".into(),
|
||||
resolve_repo_root()
|
||||
.join("rust")
|
||||
.join("Cargo.toml")
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
"-p".into(),
|
||||
"mnote-cli".into(),
|
||||
"--".into(),
|
||||
"--json".into(),
|
||||
"--validate-only".into(),
|
||||
"--dry-run".into(),
|
||||
"--actor-id".into(),
|
||||
context.auth.actor_id.clone(),
|
||||
"--actor-type".into(),
|
||||
context.auth.actor_type.clone(),
|
||||
"--session-id".into(),
|
||||
session_id,
|
||||
"--reason".into(),
|
||||
"ai-agent-run:mnote-web-rust-host".into(),
|
||||
"tool".into(),
|
||||
"run".into(),
|
||||
"--tool-name".into(),
|
||||
"doc_get".into(),
|
||||
"--kind".into(),
|
||||
"query".into(),
|
||||
"--mode".into(),
|
||||
"explain-plan".into(),
|
||||
"--args-json".into(),
|
||||
args_json,
|
||||
]
|
||||
}
|
||||
|
||||
async fn run_local_mnote_cli_ai_host(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
payload: &Value,
|
||||
) -> Result<Response, WebError> {
|
||||
let stream = payload
|
||||
.get("stream")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(true);
|
||||
let args = build_mnote_cli_args(context, payload);
|
||||
let repo_root = resolve_repo_root();
|
||||
let actor_id =
|
||||
if context.auth.actor_id.trim().is_empty() || context.auth.actor_id == "anonymous" {
|
||||
state.config().dev_user_id.clone()
|
||||
} else {
|
||||
context.auth.actor_id.clone()
|
||||
};
|
||||
let actor_type =
|
||||
if context.auth.actor_type.trim().is_empty() || context.auth.actor_type == "anonymous" {
|
||||
"user".to_string()
|
||||
} else {
|
||||
context.auth.actor_type.clone()
|
||||
};
|
||||
let dev_email = state.config().dev_user_email.clone();
|
||||
let dev_name = state.config().dev_user_name.clone();
|
||||
|
||||
let output = tokio::task::spawn_blocking(move || {
|
||||
Command::new("cargo")
|
||||
.args(args)
|
||||
.current_dir(repo_root)
|
||||
.env("CARGO_TERM_COLOR", "never")
|
||||
.env(
|
||||
"RUSTUP_TOOLCHAIN",
|
||||
env::var("RUSTUP_TOOLCHAIN")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| "1.89.0".into()),
|
||||
)
|
||||
.env("DEV_USER_ID", actor_id)
|
||||
.env("DEV_USER_EMAIL", dev_email)
|
||||
.env("DEV_USER_NAME", dev_name)
|
||||
.env("MNOTE_CLI_ALLOW_CREATE_PAGE", "1")
|
||||
.env("MNOTE_CLI_ALLOW_EDIT", "1")
|
||||
.env("MNOTE_ACTOR_TYPE", actor_type)
|
||||
.output()
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
WebError::bad_gateway_code(
|
||||
"mnote_cli_host_join_error",
|
||||
format!("mnote-cli host join 失败: {error}"),
|
||||
)
|
||||
.with_context(context)
|
||||
.with_header("x-mnote-web-owner", "mnote-web")
|
||||
})?
|
||||
.map_err(|error| {
|
||||
WebError::bad_gateway_code(
|
||||
"mnote_cli_host_spawn_error",
|
||||
format!("mnote-cli host 启动失败: {error}"),
|
||||
)
|
||||
.with_context(context)
|
||||
.with_header("x-mnote-web-owner", "mnote-web")
|
||||
})?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
if !stream {
|
||||
if !output.status.success() {
|
||||
return Err(WebError::bad_gateway_code(
|
||||
"mnote_cli_host_failed",
|
||||
if stderr.is_empty() {
|
||||
"mnote-cli 执行失败".into()
|
||||
} else {
|
||||
stderr
|
||||
},
|
||||
)
|
||||
.with_context(context)
|
||||
.with_header("x-mnote-web-owner", "mnote-web"));
|
||||
}
|
||||
let mut response = Json(json!({
|
||||
"ok": true,
|
||||
"bridgeOwner": "mnote-cli",
|
||||
"text": if stdout.is_empty() { "mnote-cli 无输出" } else { &stdout },
|
||||
}))
|
||||
.into_response();
|
||||
stamp_owner_header(response.headers_mut());
|
||||
response.headers_mut().insert(
|
||||
HeaderName::from_static("x-mnote-ai-execution-owner"),
|
||||
HeaderValue::from_static("mnote-cli"),
|
||||
);
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
let body = if output.status.success() {
|
||||
format!(
|
||||
"event: ready\ndata: {}\n\nevent: assistant_message\ndata: {}\n\nevent: completion\ndata: {}\n\n",
|
||||
json!({"ok": true, "bridgeOwner": "mnote-cli"}).to_string(),
|
||||
json!({"text": if stdout.is_empty() { "mnote-cli 无输出" } else { &stdout }}).to_string(),
|
||||
json!({"ok": true, "text": if stdout.is_empty() { "mnote-cli 无输出" } else { &stdout }, "steps": 1}).to_string(),
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"event: ready\ndata: {}\n\nevent: error\ndata: {}\n\n",
|
||||
json!({"ok": true, "bridgeOwner": "mnote-cli"}).to_string(),
|
||||
json!({"ok": false, "message": if stderr.is_empty() { "mnote-cli 执行失败" } else { &stderr }}).to_string(),
|
||||
)
|
||||
};
|
||||
let mut response = Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "text/event-stream; charset=utf-8")
|
||||
.header(header::CACHE_CONTROL, "no-cache, no-transform")
|
||||
.header(header::CONNECTION, "keep-alive")
|
||||
.header("x-accel-buffering", "no")
|
||||
.header("x-mnote-ai-execution-owner", "mnote-cli")
|
||||
.body(Body::from(body))
|
||||
.map_err(|error| WebError::internal(format!("mnote-cli SSE 响应构造失败: {error}")))?;
|
||||
stamp_owner_header(response.headers_mut());
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn stamp_owner_header(headers: &mut HeaderMap) {
|
||||
if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-web-owner") {
|
||||
headers.insert(name, HeaderValue::from_static("mnote-web"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
@@ -306,7 +101,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_ai_agent_run_is_owned_by_rust_web_when_next_compat_disabled() {
|
||||
async fn direct_ai_agent_run_returns_legacy_retired_guard() {
|
||||
let response = build_app(AppState::new(AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
@@ -337,7 +132,7 @@ mod tests {
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_ne!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
assert_eq!(response.status(), StatusCode::GONE);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
@@ -345,15 +140,23 @@ mod tests {
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("mnote-web")
|
||||
);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-mnote-ai-execution-owner")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("legacy-ai-agent-run-retired")
|
||||
);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let text = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(!text.contains("legacy_next_compat_disabled"));
|
||||
assert!(text.contains("legacy_ai_agent_run_retired"));
|
||||
assert!(text.contains("Hermes client proxy"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicit_agent_provider_does_not_fallback_to_mnote_cli_when_next_unavailable() {
|
||||
async fn explicit_agent_provider_returns_legacy_retired_guard() {
|
||||
let response = build_app(AppState::new(AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
@@ -384,20 +187,20 @@ mod tests {
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
|
||||
assert_eq!(response.status(), StatusCode::GONE);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-mnote-ai-execution-owner")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("provider-bridge-unavailable")
|
||||
Some("legacy-ai-agent-run-retired")
|
||||
);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let text = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(text.contains("codex"));
|
||||
assert!(text.contains("provider 直连链路已退场"));
|
||||
assert!(text.contains("legacy_ai_agent_run_retired"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -451,13 +254,13 @@ mod tests {
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
|
||||
assert_eq!(response.status(), StatusCode::GONE);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-mnote-ai-execution-owner")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("provider-bridge-unavailable")
|
||||
Some("legacy-ai-agent-run-retired")
|
||||
);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
|
||||
@@ -0,0 +1,696 @@
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Extension, Path, Query};
|
||||
use axum::http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||
use axum::response::Response;
|
||||
use axum::Json;
|
||||
use futures_util::TryStreamExt;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
||||
const HEADER_HERMES_CLIENT_OWNER: &str = "x-mnote-hermes-client-owner";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateSessionRequest {
|
||||
workspace_id: Option<String>,
|
||||
document_id: Option<String>,
|
||||
trace_id: Option<String>,
|
||||
title: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn list_sessions(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Query(query): Query<HashMap<String, String>>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
ensure_authenticated(&context)?;
|
||||
let Some(upstream) = configured_upstream() else {
|
||||
return hermes_unconfigured(&context);
|
||||
};
|
||||
let mut path = "/api/hermes/sessions".to_string();
|
||||
if !query.is_empty() {
|
||||
let params = query
|
||||
.iter()
|
||||
.map(|(key, value)| format!("{}={}", url_escape(key), url_escape(value)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&");
|
||||
path.push('?');
|
||||
path.push_str(¶ms);
|
||||
}
|
||||
proxy_json(&context, reqwest::Method::GET, &upstream, &path, None).await
|
||||
}
|
||||
|
||||
pub async fn create_session(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(payload): Json<CreateSessionRequest>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
ensure_authenticated(&context)?;
|
||||
let trace_id = payload
|
||||
.trace_id
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| context.trace.trace_id.clone());
|
||||
let document_id = payload
|
||||
.document_id
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or("current");
|
||||
let session_id = stable_session_id(document_id, &trace_id);
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
stamp_client_headers(),
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"sessionId": session_id,
|
||||
"workspaceId": payload.workspace_id,
|
||||
"documentId": payload.document_id,
|
||||
"title": payload.title.unwrap_or_else(|| "当前页问答".into()),
|
||||
"traceId": trace_id,
|
||||
"persistence": "hermes_on_first_run"
|
||||
})),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_session(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Path(session_id): Path<String>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
ensure_authenticated(&context)?;
|
||||
if let Some(session) = load_session_from_hermes_cli(&session_id).await {
|
||||
return Ok((
|
||||
StatusCode::OK,
|
||||
stamp_client_headers(),
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"traceId": context.trace.trace_id,
|
||||
"sessionId": session_id,
|
||||
"session": session
|
||||
})),
|
||||
));
|
||||
}
|
||||
let Some(upstream) = configured_upstream() else {
|
||||
return hermes_unconfigured(&context);
|
||||
};
|
||||
proxy_json(
|
||||
&context,
|
||||
reqwest::Method::GET,
|
||||
&upstream,
|
||||
&format!("/api/hermes/sessions/{}", url_escape(&session_id)),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn load_session_from_hermes_cli(session_id: &str) -> Option<Value> {
|
||||
let session_id = session_id.to_string();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let hermes_bin = std::env::var("MNOTE_WEB_HERMES_BIN")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| "hermes".into());
|
||||
let output = Command::new(hermes_bin)
|
||||
.args(["sessions", "export", "--session-id", &session_id, "-"])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let stdout = String::from_utf8(output.stdout).ok()?;
|
||||
stdout
|
||||
.lines()
|
||||
.find_map(|line| serde_json::from_str::<Value>(line).ok())
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
pub async fn create_run(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(payload): Json<Value>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
ensure_authenticated(&context)?;
|
||||
let Some(upstream) = configured_upstream() else {
|
||||
return hermes_unconfigured(&context);
|
||||
};
|
||||
let upstream_body = build_run_upstream_body(&context, payload)?;
|
||||
proxy_json(
|
||||
&context,
|
||||
reqwest::Method::POST,
|
||||
&upstream,
|
||||
"/v1/runs",
|
||||
Some(upstream_body),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn stream_events(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Path(run_id): Path<String>,
|
||||
) -> Result<Response, WebError> {
|
||||
ensure_authenticated(&context)?;
|
||||
let Some(upstream) = configured_upstream() else {
|
||||
return Err(hermes_unconfigured_error(&context));
|
||||
};
|
||||
let url = upstream_url(
|
||||
&upstream,
|
||||
&format!("/v1/runs/{}/events", url_escape(&run_id)),
|
||||
)?;
|
||||
let mut request = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(1800))
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
WebError::internal(format!("Hermes client 构造失败: {error}")).with_context(&context)
|
||||
})?
|
||||
.get(url);
|
||||
if let Some(api_key) = configured_api_key() {
|
||||
request = request.bearer_auth(api_key);
|
||||
}
|
||||
let upstream_response = request.send().await.map_err(|error| {
|
||||
WebError::bad_gateway_code(
|
||||
"hermes_client_upstream_unavailable",
|
||||
format!("Hermes events upstream 连接失败: {error}"),
|
||||
)
|
||||
.with_context(&context)
|
||||
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")
|
||||
})?;
|
||||
if !upstream_response.status().is_success() {
|
||||
let status = upstream_response.status();
|
||||
let text = upstream_response.text().await.unwrap_or_default();
|
||||
return Err(upstream_error(&context, status, text));
|
||||
}
|
||||
|
||||
let stream = upstream_response.bytes_stream().map_err(|error| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
format!("Hermes events stream 读取失败: {error}"),
|
||||
)
|
||||
});
|
||||
let mut response = Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "text/event-stream; charset=utf-8")
|
||||
.header(header::CACHE_CONTROL, "no-cache, no-transform")
|
||||
.header("x-accel-buffering", "no")
|
||||
.body(Body::from_stream(stream))
|
||||
.map_err(|error| WebError::internal(format!("Hermes events 响应构造失败: {error}")))?;
|
||||
stamp_client_headers_into(response.headers_mut());
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn abort_run(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Path(run_id): Path<String>,
|
||||
Json(payload): Json<Value>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
ensure_authenticated(&context)?;
|
||||
let Some(upstream) = configured_upstream() else {
|
||||
return hermes_unconfigured(&context);
|
||||
};
|
||||
proxy_json(
|
||||
&context,
|
||||
reqwest::Method::POST,
|
||||
&upstream,
|
||||
&format!("/v1/runs/{}/stop", url_escape(&run_id)),
|
||||
Some(payload),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_models(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
ensure_authenticated(&context)?;
|
||||
let Some(upstream) = configured_upstream() else {
|
||||
return hermes_unconfigured(&context);
|
||||
};
|
||||
proxy_json(
|
||||
&context,
|
||||
reqwest::Method::GET,
|
||||
&upstream,
|
||||
"/v1/models",
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_tools(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
ensure_authenticated(&context)?;
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
stamp_client_headers(),
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"traceId": context.trace.trace_id,
|
||||
"tools": [
|
||||
{
|
||||
"name": "mnote.page.get",
|
||||
"scope": "page.read",
|
||||
"schemaVersion": "mnote.hermes_tool.v1",
|
||||
"status": "planned_by_task_e"
|
||||
}
|
||||
]
|
||||
})),
|
||||
))
|
||||
}
|
||||
|
||||
fn ensure_authenticated(context: &RequestContext) -> Result<(), WebError> {
|
||||
let has_actor = context.auth.actor_id.trim() != "anonymous";
|
||||
if has_actor || context.auth.authorization.is_some() || context.auth.cookie_header.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
Err(WebError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"hermes_client_unauthorized",
|
||||
"页面 AI Hermes client 需要登录后访问",
|
||||
)
|
||||
.with_context(context)
|
||||
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client"))
|
||||
}
|
||||
|
||||
fn configured_upstream() -> Option<String> {
|
||||
std::env::var("MNOTE_WEB_HERMES_UPSTREAM_URL")
|
||||
.ok()
|
||||
.or_else(|| std::env::var("MNOTE_HERMES_UPSTREAM_URL").ok())
|
||||
.map(|value| value.trim().trim_end_matches('/').to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn configured_api_key() -> Option<String> {
|
||||
std::env::var("MNOTE_WEB_HERMES_API_KEY")
|
||||
.ok()
|
||||
.or_else(|| std::env::var("HERMES_API_SERVER_KEY").ok())
|
||||
.or_else(|| std::env::var("API_SERVER_KEY").ok())
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn build_run_upstream_body(context: &RequestContext, payload: Value) -> Result<Value, WebError> {
|
||||
let message = payload
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| {
|
||||
payload
|
||||
.get("messages")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|messages| messages.last())
|
||||
.and_then(|message| message.get("content"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code("hermes_client_bad_request", "缺少 message")
|
||||
.with_context(context)
|
||||
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")
|
||||
})?;
|
||||
let document_id = payload
|
||||
.get("documentId")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or("current");
|
||||
let trace_id = payload
|
||||
.get("traceId")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or(&context.trace.trace_id);
|
||||
let session_id = payload
|
||||
.get("sessionId")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| stable_session_id(document_id, trace_id));
|
||||
|
||||
let page_context = payload.get("pageContext").cloned().unwrap_or(Value::Null);
|
||||
let workspace_id = payload.get("workspaceId").cloned().unwrap_or(Value::Null);
|
||||
let instructions = json!({
|
||||
"role": "mnote_page_ai_context",
|
||||
"workspaceId": workspace_id,
|
||||
"documentId": document_id,
|
||||
"actorId": context.auth.actor_id,
|
||||
"actorType": 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 猜测。",
|
||||
"selectedBlockId": payload.get("selectedBlockId").cloned().unwrap_or(Value::Null),
|
||||
"selectedText": payload.get("selectedText").cloned().unwrap_or(Value::Null),
|
||||
"pageContext": page_context
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let mut body = json!({
|
||||
"input": message,
|
||||
"session_id": session_id,
|
||||
"instructions": instructions
|
||||
});
|
||||
if let Some(model) = payload
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
body["model"] = Value::String(model.to_string());
|
||||
}
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
async fn proxy_json(
|
||||
context: &RequestContext,
|
||||
method: reqwest::Method,
|
||||
upstream: &str,
|
||||
path: &str,
|
||||
body: Option<Value>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
let url = upstream_url(upstream, path)?;
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(1800))
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
WebError::internal(format!("Hermes client 构造失败: {error}")).with_context(context)
|
||||
})?;
|
||||
let mut request = client.request(method, url);
|
||||
if let Some(api_key) = configured_api_key() {
|
||||
request = request.bearer_auth(api_key);
|
||||
}
|
||||
if let Some(body) = body {
|
||||
request = request.json(&body);
|
||||
}
|
||||
let response = request.send().await.map_err(|error| {
|
||||
WebError::bad_gateway_code(
|
||||
"hermes_client_upstream_unavailable",
|
||||
format!("Hermes upstream 连接失败: {error}"),
|
||||
)
|
||||
.with_context(context)
|
||||
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")
|
||||
})?;
|
||||
let status = response.status();
|
||||
let text = response.text().await.unwrap_or_default();
|
||||
if !status.is_success() {
|
||||
return Err(upstream_error(context, status, text));
|
||||
}
|
||||
let payload = serde_json::from_str::<Value>(&text).unwrap_or_else(|_| {
|
||||
json!({
|
||||
"ok": true,
|
||||
"traceId": context.trace.trace_id,
|
||||
"raw": text
|
||||
})
|
||||
});
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
stamp_client_headers(),
|
||||
Json(normalize_success_payload(context, payload)),
|
||||
))
|
||||
}
|
||||
|
||||
fn normalize_success_payload(context: &RequestContext, payload: Value) -> Value {
|
||||
if payload.get("ok").is_some() {
|
||||
payload
|
||||
} else {
|
||||
json!({
|
||||
"ok": true,
|
||||
"traceId": context.trace.trace_id,
|
||||
"upstream": payload
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn upstream_error(context: &RequestContext, status: reqwest::StatusCode, text: String) -> WebError {
|
||||
let (response_status, code) = match status {
|
||||
reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN => (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"hermes_client_upstream_unauthorized",
|
||||
),
|
||||
reqwest::StatusCode::TOO_MANY_REQUESTS => (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"hermes_client_upstream_rate_limited",
|
||||
),
|
||||
status if status.is_server_error() => (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"hermes_client_upstream_unavailable",
|
||||
),
|
||||
_ => (StatusCode::BAD_GATEWAY, "hermes_client_upstream_error"),
|
||||
};
|
||||
WebError::new(
|
||||
response_status,
|
||||
code,
|
||||
format!(
|
||||
"Hermes upstream 返回 HTTP {}: {}",
|
||||
status.as_u16(),
|
||||
text.chars().take(600).collect::<String>()
|
||||
),
|
||||
)
|
||||
.with_context(context)
|
||||
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")
|
||||
}
|
||||
|
||||
fn hermes_unconfigured(
|
||||
context: &RequestContext,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
Err(hermes_unconfigured_error(context))
|
||||
}
|
||||
|
||||
fn hermes_unconfigured_error(context: &RequestContext) -> WebError {
|
||||
WebError::service_unavailable_code(
|
||||
"hermes_client_unconfigured",
|
||||
"Hermes client proxy 未配置 MNOTE_WEB_HERMES_UPSTREAM_URL",
|
||||
)
|
||||
.with_context(context)
|
||||
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")
|
||||
}
|
||||
|
||||
fn upstream_url(upstream: &str, path: &str) -> Result<String, WebError> {
|
||||
let url = format!(
|
||||
"{}/{}",
|
||||
upstream.trim_end_matches('/'),
|
||||
path.trim_start_matches('/')
|
||||
);
|
||||
reqwest::Url::parse(&url)
|
||||
.map(|url| url.to_string())
|
||||
.map_err(|error| WebError::internal(format!("Hermes upstream URL 无效: {error}")))
|
||||
}
|
||||
|
||||
fn stable_session_id(document_id: &str, trace_id: &str) -> String {
|
||||
format!(
|
||||
"mnote_{}_{}",
|
||||
sanitize_id_part(document_id),
|
||||
sanitize_id_part(trace_id)
|
||||
)
|
||||
}
|
||||
|
||||
fn sanitize_id_part(value: &str) -> String {
|
||||
let sanitized = value
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
|
||||
ch
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect::<String>();
|
||||
if sanitized.is_empty() {
|
||||
"current".into()
|
||||
} else {
|
||||
sanitized
|
||||
}
|
||||
}
|
||||
|
||||
fn url_escape(value: &str) -> String {
|
||||
value
|
||||
.bytes()
|
||||
.flat_map(|byte| match byte {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
vec![byte as char]
|
||||
}
|
||||
_ => format!("%{byte:02X}").chars().collect(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn stamp_client_headers() -> HeaderMap {
|
||||
let mut headers = HeaderMap::new();
|
||||
stamp_client_headers_into(&mut headers);
|
||||
headers
|
||||
}
|
||||
|
||||
fn stamp_client_headers_into(headers: &mut HeaderMap) {
|
||||
if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) {
|
||||
headers.insert(name, HeaderValue::from_static("mnote-web"));
|
||||
}
|
||||
if let Ok(name) = HeaderName::from_lowercase(HEADER_HERMES_CLIENT_OWNER.as_bytes()) {
|
||||
headers.insert(name, HeaderValue::from_static("mnote-web-hermes-client"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::Request;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
fn env_lock() -> &'static Mutex<()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
|
||||
fn app() -> axum::Router {
|
||||
build_app(AppState::new(AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
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_unauthenticated_requests_return_401() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/client/sessions")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(json!({"documentId":"doc_1"}).to_string()))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-error-code")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("hermes_client_unauthorized")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_client_unconfigured_run_returns_stable_error() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
std::env::remove_var("MNOTE_WEB_HERMES_UPSTREAM_URL");
|
||||
std::env::remove_var("MNOTE_HERMES_UPSTREAM_URL");
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/client/runs")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"workspaceId": "ws_1",
|
||||
"documentId": "doc_1",
|
||||
"sessionId": "sess_1",
|
||||
"message": "ping",
|
||||
"traceId": "trace_1"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-error-code")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("hermes_client_unconfigured")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_client_session_create_does_not_require_upstream_or_store_chat() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/client/sessions")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"workspaceId": "ws_1",
|
||||
"documentId": "doc_1",
|
||||
"traceId": "trace_1",
|
||||
"title": "当前页问答"
|
||||
})
|
||||
.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["ok"], true);
|
||||
assert_eq!(payload["sessionId"], "mnote_doc_1_trace_1");
|
||||
assert_eq!(payload["persistence"], "hermes_on_first_run");
|
||||
assert!(payload.get("messages").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hermes_client_run_body_carries_page_context_into_run_input() {
|
||||
let context = RequestContext::from_http_parts(
|
||||
&axum::http::Method::POST,
|
||||
&"/api/hermes/client/runs".parse().expect("uri"),
|
||||
&HeaderMap::new(),
|
||||
);
|
||||
let body = build_run_upstream_body(
|
||||
&context,
|
||||
json!({
|
||||
"workspaceId": "ws_1",
|
||||
"documentId": "doc_1",
|
||||
"sessionId": "sess_1",
|
||||
"message": "概括当前页面",
|
||||
"pageContext": {"title": "页面标题"},
|
||||
"selectedBlockId": "block_1",
|
||||
"selectedText": "选中文本",
|
||||
"traceId": "trace_1"
|
||||
}),
|
||||
)
|
||||
.expect("body");
|
||||
assert_eq!(body["input"], "概括当前页面");
|
||||
assert_eq!(body["session_id"], "sess_1");
|
||||
let instructions = body["instructions"].as_str().expect("instructions");
|
||||
assert!(instructions.contains("\"workspaceId\":\"ws_1\""));
|
||||
assert!(instructions.contains("\"documentId\":\"doc_1\""));
|
||||
assert!(instructions.contains("\"title\":\"页面标题\""));
|
||||
assert!(instructions.contains("\"selectedBlockId\":\"block_1\""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,787 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::hermes_tools::{artifact, manifest, page, ToolCallInput};
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||
use axum::Json;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use tracing::{info, warn};
|
||||
|
||||
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
||||
const HEADER_HERMES_TOOL_OWNER: &str = "x-mnote-hermes-tool-owner";
|
||||
|
||||
pub async fn mnote_audit(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Query(query): Query<HashMap<String, String>>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
ensure_authenticated(&context)?;
|
||||
let trace_id = query.get("traceId").map(String::as_str);
|
||||
let tool_call_id = query.get("toolCallId").map(String::as_str);
|
||||
let persisted_only = query
|
||||
.get("persistedOnly")
|
||||
.map(|value| value == "true" || value == "1")
|
||||
.unwrap_or(false);
|
||||
let events = if persisted_only {
|
||||
audit_persisted_events(trace_id, tool_call_id)
|
||||
} else {
|
||||
audit_events(trace_id, tool_call_id)
|
||||
};
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
stamp_tool_headers(),
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"traceId": context.trace.trace_id,
|
||||
"auditStore": if persisted_only { "jsonl" } else { "memory" },
|
||||
"events": events
|
||||
})),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn mnote_manifest(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
ensure_authenticated(&context)?;
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
stamp_tool_headers(),
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"traceId": context.trace.trace_id,
|
||||
"manifest": manifest::manifest()
|
||||
})),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn mnote_call(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(input): Json<ToolCallInput>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
ensure_authenticated(&context)?;
|
||||
let trace_id = input
|
||||
.effective_trace_id(&context.trace.trace_id)
|
||||
.to_string();
|
||||
let tool_call_id = input.effective_tool_call_id();
|
||||
let workspace_id = input.effective_workspace_id();
|
||||
let document_id = input.effective_document_id();
|
||||
let dry_run = input.dry_run.unwrap_or(false);
|
||||
let effect = if dry_run {
|
||||
"dry_run"
|
||||
} else if input.tool_name == "mnote.page.get" {
|
||||
"read"
|
||||
} else {
|
||||
"write"
|
||||
};
|
||||
let idempotency_key = idempotency_cache_key(
|
||||
&input,
|
||||
workspace_id.as_deref(),
|
||||
document_id.as_deref(),
|
||||
dry_run,
|
||||
);
|
||||
info!(
|
||||
trace_id = %trace_id,
|
||||
session_id = input.session_id.as_deref().unwrap_or(""),
|
||||
run_id = input.run_id.as_deref().unwrap_or(""),
|
||||
tool_call_id = %tool_call_id,
|
||||
tool_name = %input.tool_name,
|
||||
workspace_id = workspace_id.as_deref().unwrap_or(""),
|
||||
document_id = document_id.as_deref().unwrap_or(""),
|
||||
actor_id = input.actor_id.as_deref().unwrap_or(""),
|
||||
dry_run,
|
||||
"mnote Hermes tool call started"
|
||||
);
|
||||
audit_push(json!({
|
||||
"phase": "started",
|
||||
"traceId": trace_id,
|
||||
"sessionId": input.session_id,
|
||||
"runId": input.run_id,
|
||||
"toolCallId": tool_call_id,
|
||||
"toolName": input.tool_name,
|
||||
"workspaceId": workspace_id,
|
||||
"documentId": document_id,
|
||||
"actorId": input.actor_id,
|
||||
"dryRun": dry_run
|
||||
}));
|
||||
if let Err(error) = ensure_workspace_context(&context, workspace_id.as_deref()) {
|
||||
audit_push(json!({
|
||||
"phase": "failed",
|
||||
"traceId": trace_id,
|
||||
"sessionId": input.session_id,
|
||||
"runId": input.run_id,
|
||||
"toolCallId": tool_call_id,
|
||||
"toolName": input.tool_name,
|
||||
"workspaceId": workspace_id,
|
||||
"documentId": document_id,
|
||||
"actorId": input.actor_id,
|
||||
"status": error.status().as_u16(),
|
||||
"message": error.message()
|
||||
}));
|
||||
return Err(error);
|
||||
}
|
||||
if let Some(cached) = idempotency_key.as_deref().and_then(idempotency_cache_get) {
|
||||
info!(
|
||||
trace_id = %trace_id,
|
||||
session_id = input.session_id.as_deref().unwrap_or(""),
|
||||
run_id = input.run_id.as_deref().unwrap_or(""),
|
||||
tool_call_id = %tool_call_id,
|
||||
tool_name = %input.tool_name,
|
||||
workspace_id = workspace_id.as_deref().unwrap_or(""),
|
||||
document_id = document_id.as_deref().unwrap_or(""),
|
||||
actor_id = input.actor_id.as_deref().unwrap_or(""),
|
||||
"mnote Hermes tool call idempotency replay"
|
||||
);
|
||||
audit_push(json!({
|
||||
"phase": "idempotency_replay",
|
||||
"traceId": trace_id,
|
||||
"sessionId": cached.get("sessionId").cloned().unwrap_or(Value::Null),
|
||||
"runId": cached.get("runId").cloned().unwrap_or(Value::Null),
|
||||
"toolCallId": cached.get("toolCallId").cloned().unwrap_or(Value::Null),
|
||||
"toolName": cached.get("toolName").cloned().unwrap_or(Value::Null),
|
||||
"audit": cached.get("audit").cloned().unwrap_or(Value::Null)
|
||||
}));
|
||||
return Ok((StatusCode::OK, stamp_tool_headers(), Json(cached)));
|
||||
}
|
||||
let result = match input.tool_name.as_str() {
|
||||
"mnote.page.get" => page::page_get(&state, &context, &input).await,
|
||||
"mnote.page.save" => page::page_save(&state, &context, &input).await,
|
||||
"mnote.page.update_title" => page::update_title(&state, &context, &input).await,
|
||||
"mnote.page.update_options" => page::update_options(&state, &context, &input).await,
|
||||
"mnote.artifact.create_summary" => artifact::create_summary(&state, &context, &input).await,
|
||||
"mnote.artifact.create_ai_note" => artifact::create_ai_note(&state, &context, &input).await,
|
||||
_ => Err(
|
||||
WebError::bad_request_code("mnote_tool_unknown", "未知 mnote Hermes tool")
|
||||
.with_context(&context)
|
||||
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools"),
|
||||
),
|
||||
};
|
||||
if let Err(error) = &result {
|
||||
warn!(
|
||||
trace_id = %trace_id,
|
||||
session_id = input.session_id.as_deref().unwrap_or(""),
|
||||
run_id = input.run_id.as_deref().unwrap_or(""),
|
||||
tool_call_id = %tool_call_id,
|
||||
tool_name = %input.tool_name,
|
||||
workspace_id = workspace_id.as_deref().unwrap_or(""),
|
||||
document_id = document_id.as_deref().unwrap_or(""),
|
||||
actor_id = input.actor_id.as_deref().unwrap_or(""),
|
||||
status = %error.status(),
|
||||
message = %error.message(),
|
||||
"mnote Hermes tool call failed"
|
||||
);
|
||||
audit_push(json!({
|
||||
"phase": "failed",
|
||||
"traceId": trace_id,
|
||||
"sessionId": input.session_id,
|
||||
"runId": input.run_id,
|
||||
"toolCallId": tool_call_id,
|
||||
"toolName": input.tool_name,
|
||||
"workspaceId": workspace_id,
|
||||
"documentId": document_id,
|
||||
"actorId": input.actor_id,
|
||||
"status": error.status().as_u16(),
|
||||
"message": error.message()
|
||||
}));
|
||||
}
|
||||
let result = result?;
|
||||
let command_id = result.get("commandId").cloned().unwrap_or(Value::Null);
|
||||
info!(
|
||||
trace_id = %trace_id,
|
||||
session_id = input.session_id.as_deref().unwrap_or(""),
|
||||
run_id = input.run_id.as_deref().unwrap_or(""),
|
||||
tool_call_id = %tool_call_id,
|
||||
tool_name = %input.tool_name,
|
||||
workspace_id = workspace_id.as_deref().unwrap_or(""),
|
||||
document_id = document_id.as_deref().unwrap_or(""),
|
||||
actor_id = input.actor_id.as_deref().unwrap_or(""),
|
||||
effect,
|
||||
"mnote Hermes tool call completed"
|
||||
);
|
||||
let response_body = json!({
|
||||
"ok": true,
|
||||
"toolName": input.tool_name,
|
||||
"toolCallId": tool_call_id,
|
||||
"traceId": trace_id,
|
||||
"sessionId": input.session_id,
|
||||
"runId": input.run_id,
|
||||
"result": result,
|
||||
"audit": {
|
||||
"effect": effect,
|
||||
"commandId": command_id,
|
||||
"workspaceId": workspace_id,
|
||||
"documentId": document_id,
|
||||
"actorId": input.actor_id,
|
||||
"dryRun": dry_run,
|
||||
"idempotencyKey": input.idempotency_key,
|
||||
"capabilityScope": input.capability_scope
|
||||
},
|
||||
"error": null
|
||||
});
|
||||
if let Some(key) = idempotency_key {
|
||||
idempotency_cache_put(key, response_body.clone());
|
||||
}
|
||||
audit_push(json!({
|
||||
"phase": "completed",
|
||||
"traceId": response_body.get("traceId").cloned().unwrap_or(Value::Null),
|
||||
"sessionId": response_body.get("sessionId").cloned().unwrap_or(Value::Null),
|
||||
"runId": response_body.get("runId").cloned().unwrap_or(Value::Null),
|
||||
"toolCallId": response_body.get("toolCallId").cloned().unwrap_or(Value::Null),
|
||||
"toolName": response_body.get("toolName").cloned().unwrap_or(Value::Null),
|
||||
"audit": response_body.get("audit").cloned().unwrap_or(Value::Null)
|
||||
}));
|
||||
Ok((StatusCode::OK, stamp_tool_headers(), Json(response_body)))
|
||||
}
|
||||
|
||||
fn audit_log() -> &'static Mutex<Vec<Value>> {
|
||||
static LOG: OnceLock<Mutex<Vec<Value>>> = OnceLock::new();
|
||||
LOG.get_or_init(|| Mutex::new(Vec::new()))
|
||||
}
|
||||
|
||||
fn audit_push(event: Value) {
|
||||
if let Ok(mut log) = audit_log().lock() {
|
||||
log.push(event.clone());
|
||||
let overflow = log.len().saturating_sub(500);
|
||||
if overflow > 0 {
|
||||
log.drain(0..overflow);
|
||||
}
|
||||
}
|
||||
if let Err(error) = audit_append_persistent(&event) {
|
||||
warn!(message = %error, "mnote Hermes tool audit 持久化失败");
|
||||
}
|
||||
}
|
||||
|
||||
fn audit_events(trace_id: Option<&str>, tool_call_id: Option<&str>) -> Vec<Value> {
|
||||
let Ok(log) = audit_log().lock() else {
|
||||
return Vec::new();
|
||||
};
|
||||
log.iter()
|
||||
.filter(|event| {
|
||||
trace_id
|
||||
.map(|expected| event.get("traceId").and_then(Value::as_str) == Some(expected))
|
||||
.unwrap_or(true)
|
||||
})
|
||||
.filter(|event| {
|
||||
tool_call_id
|
||||
.map(|expected| event.get("toolCallId").and_then(Value::as_str) == Some(expected))
|
||||
.unwrap_or(true)
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn audit_log_path() -> PathBuf {
|
||||
env::var("MNOTE_HERMES_TOOL_AUDIT_LOG")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("tmp").join("mnote-hermes-tool-audit.jsonl"))
|
||||
}
|
||||
|
||||
fn audit_append_persistent(event: &Value) -> Result<(), String> {
|
||||
let path = audit_log_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|error| error.to_string())?;
|
||||
}
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let line = serde_json::to_string(event).map_err(|error| error.to_string())?;
|
||||
writeln!(file, "{line}").map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn audit_persisted_events(trace_id: Option<&str>, tool_call_id: Option<&str>) -> Vec<Value> {
|
||||
let path = audit_log_path();
|
||||
let Ok(file) = File::open(path) else {
|
||||
return Vec::new();
|
||||
};
|
||||
BufReader::new(file)
|
||||
.lines()
|
||||
.map_while(Result::ok)
|
||||
.filter_map(|line| serde_json::from_str::<Value>(&line).ok())
|
||||
.filter(|event| {
|
||||
trace_id
|
||||
.map(|expected| event.get("traceId").and_then(Value::as_str) == Some(expected))
|
||||
.unwrap_or(true)
|
||||
})
|
||||
.filter(|event| {
|
||||
tool_call_id
|
||||
.map(|expected| event.get("toolCallId").and_then(Value::as_str) == Some(expected))
|
||||
.unwrap_or(true)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn idempotency_cache() -> &'static Mutex<HashMap<String, Value>> {
|
||||
static CACHE: OnceLock<Mutex<HashMap<String, Value>>> = OnceLock::new();
|
||||
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
fn idempotency_cache_key(
|
||||
input: &ToolCallInput,
|
||||
workspace_id: Option<&str>,
|
||||
document_id: Option<&str>,
|
||||
dry_run: bool,
|
||||
) -> Option<String> {
|
||||
if dry_run || input.tool_name == "mnote.page.get" {
|
||||
return None;
|
||||
}
|
||||
let idempotency_key = input.idempotency_key.as_deref()?.trim();
|
||||
if idempotency_key.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(format!(
|
||||
"{}|{}|{}|{}",
|
||||
input.tool_name,
|
||||
workspace_id.unwrap_or(""),
|
||||
document_id.unwrap_or(""),
|
||||
idempotency_key
|
||||
))
|
||||
}
|
||||
|
||||
fn idempotency_cache_get(key: &str) -> Option<Value> {
|
||||
idempotency_cache().lock().ok()?.get(key).cloned()
|
||||
}
|
||||
|
||||
fn idempotency_cache_put(key: String, response: Value) {
|
||||
if let Ok(mut cache) = idempotency_cache().lock() {
|
||||
cache.insert(key, response);
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_authenticated(context: &RequestContext) -> Result<(), WebError> {
|
||||
let has_actor = context.auth.actor_id.trim() != "anonymous";
|
||||
if has_actor || context.auth.authorization.is_some() || context.auth.cookie_header.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
Err(WebError::new(
|
||||
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"))
|
||||
}
|
||||
|
||||
fn ensure_workspace_context(
|
||||
context: &RequestContext,
|
||||
input_workspace_id: Option<&str>,
|
||||
) -> Result<(), WebError> {
|
||||
let Some(input_workspace_id) = input_workspace_id
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(header_workspace_id) = context
|
||||
.workspace
|
||||
.workspace_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
if input_workspace_id == header_workspace_id {
|
||||
return Ok(());
|
||||
}
|
||||
Err(WebError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"workspace_context_conflict",
|
||||
"mnote Hermes tool 请求的 workspaceId 与请求上下文不一致",
|
||||
)
|
||||
.with_context(context)
|
||||
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools"))
|
||||
}
|
||||
|
||||
fn stamp_tool_headers() -> HeaderMap {
|
||||
let mut headers = HeaderMap::new();
|
||||
if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) {
|
||||
headers.insert(name, HeaderValue::from_static("mnote-web"));
|
||||
}
|
||||
if let Ok(name) = HeaderName::from_lowercase(HEADER_HERMES_TOOL_OWNER.as_bytes()) {
|
||||
headers.insert(name, HeaderValue::from_static("mnote-web-hermes-tools"));
|
||||
}
|
||||
headers
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use serde_json::{json, Value};
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
fn app() -> axum::Router {
|
||||
build_app(AppState::new(AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
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: Some(
|
||||
r#"{
|
||||
"documents:getMeta": {
|
||||
"id": "doc_1",
|
||||
"workspace_id": "ws_demo",
|
||||
"title": "服务端页面",
|
||||
"can_edit": true,
|
||||
"wide_layout": false,
|
||||
"use_small_text": false,
|
||||
"show_toc": true,
|
||||
"block_count": 1
|
||||
},
|
||||
"documents:getContent": {
|
||||
"title": "服务端页面",
|
||||
"content": [
|
||||
{
|
||||
"id": "heading_1",
|
||||
"type": "heading",
|
||||
"props": { "level": 1 },
|
||||
"content": [{ "type": "text", "text": "章节一" }]
|
||||
}
|
||||
],
|
||||
"revision": 7,
|
||||
"conflict_detection_key": "doc_1:7"
|
||||
}
|
||||
}"#
|
||||
.into(),
|
||||
),
|
||||
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_tools_manifest_returns_first_batch_tools() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/hermes/tools/mnote/manifest")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.body(Body::empty())
|
||||
.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");
|
||||
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"));
|
||||
assert_eq!(
|
||||
payload["manifest"]["schemaVersion"],
|
||||
"mnote.hermes_tool_manifest.v1"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_page_get_requires_auth() {
|
||||
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","documentId":"doc_1"}).to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_write_tools_require_auth() {
|
||||
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.save",
|
||||
"workspaceId": "ws_demo",
|
||||
"documentId": "doc_1",
|
||||
"sessionId": "sess_1",
|
||||
"runId": "run_1",
|
||||
"toolCallId": "call_1",
|
||||
"traceId": "trace_1",
|
||||
"idempotencyKey": "idem_1",
|
||||
"dryRun": false,
|
||||
"args": {"content": []}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_page_get_returns_page_aggregate_summary() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/tools/mnote/call")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"toolName": "mnote.page.get",
|
||||
"workspaceId": "ws_demo",
|
||||
"documentId": "doc_1",
|
||||
"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["toolName"], "mnote.page.get");
|
||||
assert_eq!(payload["toolCallId"], "call_1");
|
||||
assert_eq!(payload["result"]["title"], "服务端页面");
|
||||
assert!(payload["result"]["bodySummary"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("章节一"));
|
||||
assert_eq!(payload["audit"]["effect"], "read");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_reject_workspace_context_conflict_without_content_leak() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/tools/mnote/call")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.header("x-mnote-workspace-id", "ws_other")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"toolName": "mnote.page.get",
|
||||
"workspaceId": "ws_demo",
|
||||
"documentId": "doc_1",
|
||||
"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::FORBIDDEN);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-error-code")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("workspace_context_conflict")
|
||||
);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let text = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(!text.contains("服务端页面"));
|
||||
assert!(!text.contains("章节一"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_write_tools_require_idempotency_and_dry_run_flag() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/tools/mnote/call")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"toolName": "mnote.page.save",
|
||||
"workspaceId": "ws_demo",
|
||||
"documentId": "doc_1",
|
||||
"sessionId": "sess_1",
|
||||
"runId": "run_1",
|
||||
"toolCallId": "call_1",
|
||||
"traceId": "trace_1",
|
||||
"args": {"content": []}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-error-code")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("mnote_tool_idempotency_required")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_page_save_dry_run_returns_diff_without_write() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/tools/mnote/call")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"toolName": "mnote.page.save",
|
||||
"workspaceId": "ws_demo",
|
||||
"documentId": "doc_1",
|
||||
"sessionId": "sess_1",
|
||||
"runId": "run_1",
|
||||
"toolCallId": "call_1",
|
||||
"traceId": "trace_1",
|
||||
"idempotencyKey": "idem_save_1",
|
||||
"dryRun": true,
|
||||
"args": {"content": [{"type":"paragraph","content":[{"type":"text","text":"AI 写入"}]}]}
|
||||
})
|
||||
.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"]["effect"], "dry_run");
|
||||
assert_eq!(payload["result"]["dryRun"], true);
|
||||
assert_eq!(payload["result"]["commandName"], "page.body.save");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_update_options_dry_run_filters_unwired_fields() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/tools/mnote/call")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"toolName": "mnote.page.update_options",
|
||||
"workspaceId": "ws_demo",
|
||||
"documentId": "doc_1",
|
||||
"sessionId": "sess_1",
|
||||
"runId": "run_1",
|
||||
"toolCallId": "call_1",
|
||||
"traceId": "trace_1",
|
||||
"idempotencyKey": "idem_options_1",
|
||||
"dryRun": true,
|
||||
"args": {"options": {"wideLayout": true, "pageFont": "serif"}}
|
||||
})
|
||||
.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");
|
||||
let options = &payload["result"]["diff"][0]["payload"]["options"];
|
||||
assert_eq!(options["wideLayout"], true);
|
||||
assert!(options.get("pageFont").is_none());
|
||||
assert_eq!(payload["result"]["ignoredOptions"][0], "pageFont");
|
||||
assert_eq!(
|
||||
payload["result"]["warnings"][0]["code"],
|
||||
"page_option_not_wired"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_artifact_dry_run_returns_artifact_plan() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/tools/mnote/call")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"toolName": "mnote.artifact.create_summary",
|
||||
"workspaceId": "ws_demo",
|
||||
"documentId": "doc_1",
|
||||
"sessionId": "sess_1",
|
||||
"runId": "run_1",
|
||||
"toolCallId": "call_1",
|
||||
"traceId": "trace_1",
|
||||
"idempotencyKey": "idem_summary_1",
|
||||
"dryRun": true,
|
||||
"args": {"summary": "摘要内容"}
|
||||
})
|
||||
.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["result"]["dryRun"], true);
|
||||
assert_eq!(payload["result"]["artifactType"], "summary");
|
||||
}
|
||||
}
|
||||
@@ -364,9 +364,7 @@ mod tests {
|
||||
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(html.contains("mnote.mindmap_shell.v1"));
|
||||
assert!(html.contains("data-mnote-object-editor=\"mindmap\""));
|
||||
assert!(html.contains(
|
||||
"data-mnote-object-identity=\"resource:mindmap:doc_1:mind_1\""
|
||||
));
|
||||
assert!(html.contains("data-mnote-object-identity=\"resource:mindmap:doc_1:mind_1\""));
|
||||
assert!(html.contains("data-leptos-mindmap-island=\"standalone\""));
|
||||
assert!(html.contains("mindmap.simple_mind_map_scene.get"));
|
||||
assert!(html.contains("mindmap.command.apply"));
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
mod bridge;
|
||||
mod command_support;
|
||||
pub(crate) mod command_support;
|
||||
mod compat;
|
||||
mod documents;
|
||||
mod editor;
|
||||
mod gateway;
|
||||
mod health;
|
||||
mod hermes;
|
||||
mod hermes_client;
|
||||
mod hermes_tools;
|
||||
mod kernel;
|
||||
mod local_folder_events;
|
||||
mod local_folder_source;
|
||||
@@ -21,7 +23,7 @@ mod snapshot_support;
|
||||
mod sse;
|
||||
mod stream_support;
|
||||
mod tree;
|
||||
mod web_shell;
|
||||
pub(crate) mod web_shell;
|
||||
mod ws;
|
||||
|
||||
use crate::app::AppState;
|
||||
@@ -134,7 +136,30 @@ pub fn build_router(state: AppState) -> Router {
|
||||
&hermes_base_path,
|
||||
Router::new()
|
||||
.route("/health", get(hermes::health))
|
||||
.route("/bridge", post(hermes::bridge_runtime)),
|
||||
.route("/bridge", post(hermes::bridge_runtime))
|
||||
.route(
|
||||
"/client/sessions",
|
||||
get(hermes_client::list_sessions).post(hermes_client::create_session),
|
||||
)
|
||||
.route(
|
||||
"/client/sessions/{session_id}",
|
||||
get(hermes_client::get_session),
|
||||
)
|
||||
.route("/client/runs", post(hermes_client::create_run))
|
||||
.route("/client/events/{run_id}", get(hermes_client::stream_events))
|
||||
.route(
|
||||
"/client/runs/{run_id}/abort",
|
||||
post(hermes_client::abort_run),
|
||||
)
|
||||
.route("/client/models", get(hermes_client::list_models))
|
||||
.route("/client/tools", get(hermes_client::list_tools)),
|
||||
)
|
||||
.nest(
|
||||
"/api/hermes/tools",
|
||||
Router::new()
|
||||
.route("/mnote/manifest", get(hermes_tools::mnote_manifest))
|
||||
.route("/mnote/call", post(hermes_tools::mnote_call))
|
||||
.route("/mnote/audit", get(hermes_tools::mnote_audit)),
|
||||
);
|
||||
|
||||
if enable_debug_shell_routes {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::app::AppState;
|
||||
use crate::app::AppConfig;
|
||||
use crate::app::AppState;
|
||||
use crate::error::WebError;
|
||||
use adapter_onlyoffice::{prepare_proxy_request, sign_config, OnlyOfficeProxyPreparationInput};
|
||||
use axum::body::{Body, Bytes};
|
||||
@@ -796,7 +796,9 @@ pub async fn forcesave(
|
||||
return WebError::new(
|
||||
axum::http::StatusCode::NOT_IMPLEMENTED,
|
||||
"onlyoffice_legacy_writeback_unavailable",
|
||||
format!("OnlyOffice forcesave 未配置 legacy Next 写回链: assetId={asset_id}, key={key}"),
|
||||
format!(
|
||||
"OnlyOffice forcesave 未配置 legacy Next 写回链: assetId={asset_id}, key={key}"
|
||||
),
|
||||
);
|
||||
}
|
||||
error
|
||||
@@ -831,7 +833,9 @@ async fn proxy_legacy_onlyoffice_json(
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
WebError::internal(format!("OnlyOffice legacy proxy HTTP 客户端创建失败: {error}"))
|
||||
WebError::internal(format!(
|
||||
"OnlyOffice legacy proxy HTTP 客户端创建失败: {error}"
|
||||
))
|
||||
})?;
|
||||
let mut request = client
|
||||
.post(target)
|
||||
@@ -861,7 +865,9 @@ async fn proxy_legacy_onlyoffice_json(
|
||||
})?;
|
||||
let mut response = Response::new(Body::from(bytes));
|
||||
*response.status_mut() = status;
|
||||
response.headers_mut().insert(header::CONTENT_TYPE, content_type);
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::CONTENT_TYPE, content_type);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
@@ -1142,9 +1148,7 @@ mod tests {
|
||||
async fn spawn_legacy_json_server(
|
||||
response_body: &'static str,
|
||||
) -> (String, oneshot::Receiver<String>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind");
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
|
||||
let addr = listener.local_addr().expect("addr");
|
||||
let (tx, rx) = oneshot::channel();
|
||||
tokio::spawn(async move {
|
||||
@@ -1216,9 +1220,8 @@ mod tests {
|
||||
let request = captured.await.expect("captured");
|
||||
|
||||
assert_eq!(payload["error"], 0);
|
||||
assert!(request.starts_with(
|
||||
"POST /api/onlyoffice/callback?assetId=asset_1&userId=user_1 HTTP/1.1"
|
||||
));
|
||||
assert!(request
|
||||
.starts_with("POST /api/onlyoffice/callback?assetId=asset_1&userId=user_1 HTTP/1.1"));
|
||||
assert!(request.contains(r#""status":2"#));
|
||||
assert!(request.contains(r#""key":"doc_key""#));
|
||||
}
|
||||
@@ -1252,8 +1255,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn onlyoffice_forcesave_proxies_to_legacy_next_writeback() {
|
||||
let (base_url, captured) =
|
||||
spawn_legacy_json_server(r#"{"ok":true,"via":"forcesave","result":{"error":0}}"#)
|
||||
.await;
|
||||
spawn_legacy_json_server(r#"{"ok":true,"via":"forcesave","result":{"error":0}}"#).await;
|
||||
let response = forcesave(
|
||||
State(test_state(Some(base_url))),
|
||||
"/api/onlyoffice/forcesave?assetId=asset_1&key=doc_key"
|
||||
@@ -1274,8 +1276,7 @@ mod tests {
|
||||
|
||||
assert_eq!(payload["ok"], true);
|
||||
assert_eq!(payload["via"], "forcesave");
|
||||
assert!(request.starts_with(
|
||||
"POST /api/onlyoffice/forcesave?assetId=asset_1&key=doc_key HTTP/1.1"
|
||||
));
|
||||
assert!(request
|
||||
.starts_with("POST /api/onlyoffice/forcesave?assetId=asset_1&key=doc_key HTTP/1.1"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4439,13 +4439,6 @@ fn build_tree_shell_html(
|
||||
target: { documentId },
|
||||
payload: { documentId },
|
||||
});
|
||||
if (documentId) {
|
||||
postToHost("tree.navigate", {
|
||||
documentId,
|
||||
target: { documentId },
|
||||
payload: { documentId },
|
||||
});
|
||||
}
|
||||
if (sourceKind === "convex_workspace" && applyCreatedDocumentLocally(result, parentId, title)) {
|
||||
if (mode === "filetree") {
|
||||
beginInlineRename("filetree", `doc:${documentId}`);
|
||||
@@ -4454,6 +4447,13 @@ fn build_tree_shell_html(
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (documentId) {
|
||||
postToHost("tree.navigate", {
|
||||
documentId,
|
||||
target: { documentId },
|
||||
payload: { documentId },
|
||||
});
|
||||
}
|
||||
scheduleRefresh({
|
||||
renameRowId: localCreatedRowIdFromCommandResult(result, "markdown"),
|
||||
});
|
||||
@@ -6810,7 +6810,9 @@ mod tests {
|
||||
assert!(html.contains("applyCreatedDocumentLocally"));
|
||||
assert!(html.contains("applyRemovedDocumentLocally"));
|
||||
assert!(html.contains("sourceKind === \"convex_workspace\" && applyCreatedDocumentLocally"));
|
||||
assert!(html.contains("if (!deletableItems.every((item) => !fileTreeRowById.has(item.rowId)))"));
|
||||
assert!(
|
||||
html.contains("if (!deletableItems.every((item) => !fileTreeRowById.has(item.rowId)))")
|
||||
);
|
||||
assert!(html.contains("application/x-mnote-page-tree-node"));
|
||||
assert!(html.contains("页面已拖放到"));
|
||||
assert!(html.contains("setAttribute(\"role\", \"treeitem\")"));
|
||||
|
||||
@@ -3014,7 +3014,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}
|
||||
|
||||
function pageAiStorageKey() {
|
||||
return 'doc_ai_sessions:' + currentDocumentId();
|
||||
return 'hermes_page_ai_session:' + currentDocumentId();
|
||||
}
|
||||
|
||||
function pageAiNewSession(title) {
|
||||
@@ -3046,46 +3046,90 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}
|
||||
|
||||
function pageAiLoadSessions() {
|
||||
if (pageUiState.pageAiSessions.length && pageUiState.pageAiActiveSessionId) return;
|
||||
try {
|
||||
var raw = window.localStorage.getItem(pageAiStorageKey());
|
||||
if (!raw) {
|
||||
var fresh = pageAiNewSession();
|
||||
pageUiState.pageAiSessions = [fresh];
|
||||
pageUiState.pageAiActiveSessionId = fresh.id;
|
||||
pageUiState.pageAiMessages = [];
|
||||
return;
|
||||
}
|
||||
var parsed = JSON.parse(raw);
|
||||
var sessions = pageAiNormalizeSessions(parsed && parsed.sessions ? parsed.sessions : []);
|
||||
if (!sessions.length) {
|
||||
var fallback = pageAiNewSession();
|
||||
pageUiState.pageAiSessions = [fallback];
|
||||
pageUiState.pageAiActiveSessionId = fallback.id;
|
||||
pageUiState.pageAiMessages = [];
|
||||
return;
|
||||
}
|
||||
pageUiState.pageAiSessions = sessions;
|
||||
var parsed = raw ? JSON.parse(raw) : null;
|
||||
var activeId = String(parsed && parsed.activeSessionId || '').trim();
|
||||
var active = sessions.find(function(session) { return session.id === activeId; }) || sessions[0];
|
||||
pageUiState.pageAiActiveSessionId = active.id;
|
||||
pageUiState.pageAiMessages = Array.isArray(active.messages) ? active.messages.slice() : [];
|
||||
} catch (_) {
|
||||
var reset = pageAiNewSession();
|
||||
pageUiState.pageAiSessions = [reset];
|
||||
pageUiState.pageAiActiveSessionId = reset.id;
|
||||
pageUiState.pageAiMessages = [];
|
||||
}
|
||||
if (activeId) {
|
||||
pageUiState.pageAiSessions = [pageAiNewSession('当前页问答')];
|
||||
pageUiState.pageAiSessions[0].id = activeId;
|
||||
pageUiState.pageAiActiveSessionId = activeId;
|
||||
pageUiState.pageAiMessages = [];
|
||||
return;
|
||||
}
|
||||
} catch (_) {}
|
||||
var fresh = pageAiNewSession();
|
||||
pageUiState.pageAiSessions = [fresh];
|
||||
pageUiState.pageAiActiveSessionId = fresh.id;
|
||||
pageUiState.pageAiMessages = [];
|
||||
}
|
||||
|
||||
function pageAiPersistSessions() {
|
||||
document.documentElement.setAttribute('data-mnote-page-ai-session-owner', 'hermes');
|
||||
document.documentElement.setAttribute('data-mnote-page-ai-session-key', pageAiStorageKey());
|
||||
try {
|
||||
window.localStorage.setItem(pageAiStorageKey(), JSON.stringify({
|
||||
activeSessionId: pageUiState.pageAiActiveSessionId,
|
||||
sessions: pageAiNormalizeSessions(pageUiState.pageAiSessions)
|
||||
activeSessionId: pageUiState.pageAiActiveSessionId
|
||||
}));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async function pageAiEnsureHermesSession() {
|
||||
pageAiLoadSessions();
|
||||
var current = pageAiCurrentSession();
|
||||
if (current && String(current.id || '').startsWith('mnote_')) return current;
|
||||
var response = await fetch('/api/hermes/client/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
workspaceId: resolveWorkspaceId(document.body),
|
||||
documentId: currentDocumentId(),
|
||||
traceId: 'page-ai-' + Date.now().toString(36),
|
||||
title: current && current.title ? current.title : '当前页问答'
|
||||
})
|
||||
});
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
if (!response.ok || !payload || payload.ok !== true) {
|
||||
var code = payload && payload.code ? payload.code : 'hermes_session_failed_' + response.status;
|
||||
throw new Error(code);
|
||||
}
|
||||
var session = {
|
||||
id: String(payload.sessionId || '').trim(),
|
||||
title: String(payload.title || '当前页问答'),
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
messages: pageUiState.pageAiMessages.slice()
|
||||
};
|
||||
pageUiState.pageAiSessions = pageAiNormalizeSessions([session]);
|
||||
pageUiState.pageAiActiveSessionId = session.id;
|
||||
pageAiPersistSessions();
|
||||
renderPageAiConversation();
|
||||
return session;
|
||||
}
|
||||
|
||||
async function pageAiRestoreHermesSession() {
|
||||
var current = pageAiCurrentSession();
|
||||
if (!current || !String(current.id || '').startsWith('mnote_')) return;
|
||||
var response = await fetch('/api/hermes/client/sessions/' + encodeURIComponent(current.id), {
|
||||
headers: { 'accept': 'application/json' }
|
||||
});
|
||||
if (!response.ok) return;
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
var session = payload && (payload.session || (payload.upstream && payload.upstream.session) || payload);
|
||||
var messages = session && Array.isArray(session.messages) ? session.messages : [];
|
||||
if (!messages.length) return;
|
||||
pageUiState.pageAiMessages = messages.slice(-40).map(function(message) {
|
||||
return {
|
||||
role: message.role === 'user' ? 'user' : (message.role === 'tool' ? 'tool' : 'assistant'),
|
||||
content: String(message.content || '')
|
||||
};
|
||||
});
|
||||
current.messages = pageUiState.pageAiMessages.slice();
|
||||
current.updatedAt = Date.now();
|
||||
renderPageAiConversation();
|
||||
}
|
||||
|
||||
function pageAiCurrentSession() {
|
||||
return pageUiState.pageAiSessions.find(function(session) {
|
||||
return session.id === pageUiState.pageAiActiveSessionId;
|
||||
@@ -3146,7 +3190,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var args = normalized && normalized.args ? normalized.args : {};
|
||||
var documentId = searchText(args.documentId || args.pageId || currentDocumentId());
|
||||
var toolName = searchText(operation.tool_name || normalized.toolName || 'doc_get');
|
||||
return providerLabel + ' 已收到你的问题“' + searchText(promptText) + '”。当前已通过 mnote-cli 建立页面级工具调用,目标页面是 ' + (documentId || '当前页面') + ',本次选择的工具是 ' + toolName + '。如果你继续追问,我会沿当前页面上下文继续回复。';
|
||||
return providerLabel + ' 已收到你的问题“' + searchText(promptText) + '”。当前已通过 Hermes 调用 mnote plugin 工具,目标页面是 ' + (documentId || '当前页面') + ',本次选择的工具是 ' + toolName + '。如果你继续追问,我会沿当前页面上下文继续回复。';
|
||||
} catch (_) {
|
||||
return providerLabel + ' 已返回结果,但当前结果是结构化文本,已为你保留原始内容。';
|
||||
}
|
||||
@@ -3175,7 +3219,11 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
'<div class="wolai-page-ai-suggestions">' +
|
||||
'<div class="wolai-page-ai-suggestions-header">' +
|
||||
'<span>推荐问题</span>' +
|
||||
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-action="rotate">换一换</button>' +
|
||||
'<div class="wolai-page-ai-intents">' +
|
||||
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-intent="create-summary">创建 Summary</button>' +
|
||||
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-intent="create-ai-note">创建 AI Note</button>' +
|
||||
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-action="rotate">换一换</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="wolai-page-ai-suggestion-list" data-page-ai-suggestion-list></div>' +
|
||||
'</div>' +
|
||||
@@ -3187,8 +3235,6 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
'<button type="button" class="wolai-page-ai-tab" data-page-ai-action="history">历史会话</button>' +
|
||||
'<div class="wolai-page-ai-provider-group">' +
|
||||
'<button type="button" class="wolai-page-ai-model-chip is-active" data-page-ai-provider="hermes" aria-pressed="true">Hermes</button>' +
|
||||
'<button type="button" class="wolai-page-ai-model-chip" data-page-ai-provider="codex" aria-pressed="false">Codex</button>' +
|
||||
'<button type="button" class="wolai-page-ai-model-chip" data-page-ai-provider="claudecode" aria-pressed="false">ClaudeCode</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="wolai-page-ai-input-row">' +
|
||||
@@ -3240,9 +3286,10 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return;
|
||||
}
|
||||
conversation.innerHTML = pageUiState.pageAiMessages.map(function(item) {
|
||||
var roleLabel = item.role === 'user' ? '你' : (item.role === 'tool' ? '工具' : 'AI');
|
||||
return '' +
|
||||
'<div class="wolai-page-ai-message wolai-page-ai-message--' + escapeHtml(item.role || 'assistant') + '">' +
|
||||
'<div class="wolai-page-ai-message-role">' + escapeHtml(item.role === 'user' ? '你' : 'AI') + '</div>' +
|
||||
'<div class="wolai-page-ai-message-role">' + escapeHtml(roleLabel) + '</div>' +
|
||||
'<div class="wolai-page-ai-message-text">' + escapeHtml(item.content || '') + '</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
@@ -3258,6 +3305,15 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
drawer.hidden = false;
|
||||
pageUiState.pageAiOpen = true;
|
||||
updatePageAiTriggerState();
|
||||
pageAiEnsureHermesSession().then(function() {
|
||||
return pageAiRestoreHermesSession();
|
||||
}).catch(function(error) {
|
||||
pageUiState.pageAiMessages.push({
|
||||
role: 'assistant',
|
||||
content: 'Hermes 当前不可用:' + (error instanceof Error ? error.message : String(error))
|
||||
});
|
||||
renderPageAiConversation();
|
||||
});
|
||||
}
|
||||
|
||||
function closePageAiDrawer() {
|
||||
@@ -3290,7 +3346,14 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
if (line.startsWith('event:')) eventName = line.slice(6).trim();
|
||||
if (line.startsWith('data:')) dataLines.push(line.slice(5).trim());
|
||||
});
|
||||
if (eventName) onEvent(eventName, dataLines.join('\n'));
|
||||
var payloadText = dataLines.join('\n');
|
||||
if (!eventName && payloadText) {
|
||||
try {
|
||||
var parsed = JSON.parse(payloadText);
|
||||
eventName = parsed && parsed.event ? String(parsed.event) : '';
|
||||
} catch (_) {}
|
||||
}
|
||||
if (eventName) onEvent(eventName, payloadText);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3299,39 +3362,35 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
if (pageUiState.pageAiBusy) return;
|
||||
var prompt = searchText(text);
|
||||
if (!prompt) return;
|
||||
pageAiLoadSessions();
|
||||
var contextSnapshot = currentPageAiContextSnapshot();
|
||||
var aggregate = contextSnapshot.aggregate || {};
|
||||
var body = contextSnapshot.body || {};
|
||||
var subtree = contextSnapshot.subtree || null;
|
||||
var outline = subtree && subtree.outline ? subtree.outline : null;
|
||||
pageUiState.pageAiBusy = true;
|
||||
pageUiState.pageAiMessages.push({ role: 'user', content: prompt });
|
||||
var currentSession = pageAiCurrentSession();
|
||||
if (currentSession) {
|
||||
if (currentSession.title === '新会话') {
|
||||
currentSession.title = prompt.length > 18 ? prompt.slice(0, 18) + '…' : prompt;
|
||||
}
|
||||
currentSession.messages = pageUiState.pageAiMessages.slice();
|
||||
currentSession.updatedAt = Date.now();
|
||||
}
|
||||
pageAiPersistSessions();
|
||||
renderPageAiConversation();
|
||||
var currentSession = null;
|
||||
try {
|
||||
var response = await fetch('/api/ai-agent/run', {
|
||||
await pageAiEnsureHermesSession();
|
||||
var contextSnapshot = currentPageAiContextSnapshot();
|
||||
var aggregate = contextSnapshot.aggregate || {};
|
||||
var body = contextSnapshot.body || {};
|
||||
var subtree = contextSnapshot.subtree || null;
|
||||
var outline = subtree && subtree.outline ? subtree.outline : null;
|
||||
pageUiState.pageAiMessages.push({ role: 'user', content: prompt });
|
||||
currentSession = pageAiCurrentSession();
|
||||
if (currentSession) {
|
||||
if (currentSession.title === '新会话') {
|
||||
currentSession.title = prompt.length > 18 ? prompt.slice(0, 18) + '…' : prompt;
|
||||
}
|
||||
currentSession.messages = pageUiState.pageAiMessages.slice();
|
||||
currentSession.updatedAt = Date.now();
|
||||
}
|
||||
renderPageAiConversation();
|
||||
var response = await fetch('/api/hermes/client/runs', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
stream: true,
|
||||
maxSteps: 8,
|
||||
scope: 'document',
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
toolChoice: {
|
||||
mode: 'auto',
|
||||
toolSets: ['toolset.readonly', 'toolset.rag_read', 'toolset.docs_read', 'toolset.media_read', 'toolset.doc_read', 'toolset.doc_write', 'toolset.slash_write']
|
||||
},
|
||||
context: {
|
||||
documentId: currentDocumentId(),
|
||||
workspaceId: resolveWorkspaceId(document.body),
|
||||
documentId: currentDocumentId(),
|
||||
sessionId: pageUiState.pageAiActiveSessionId,
|
||||
message: prompt,
|
||||
model: 'hermes-agent',
|
||||
pageContext: {
|
||||
documentBlocks: body.content || null,
|
||||
node: {
|
||||
documentId: currentDocumentId(),
|
||||
@@ -3343,23 +3402,54 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
evidence: null,
|
||||
pageOptions: currentPageOptions()
|
||||
},
|
||||
options: {
|
||||
searxng: true,
|
||||
ai: { provider: pageUiState.pageAiProvider }
|
||||
}
|
||||
selectedBlockId: null,
|
||||
selectedText: null,
|
||||
traceId: 'page-ai-run-' + Date.now().toString(36)
|
||||
})
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('page_ai_failed_' + response.status);
|
||||
var errorPayload = await response.json().catch(function(){ return null; });
|
||||
throw new Error(errorPayload && errorPayload.code ? errorPayload.code : 'page_ai_failed_' + response.status);
|
||||
}
|
||||
var runPayload = await response.json().catch(function(){ return null; });
|
||||
var upstream = runPayload && runPayload.upstream ? runPayload.upstream : runPayload;
|
||||
var runId = upstream && (upstream.run_id || upstream.runId);
|
||||
if (!runId) throw new Error('hermes_run_missing_run_id');
|
||||
var eventResponse = await fetch('/api/hermes/client/events/' + encodeURIComponent(runId), {
|
||||
headers: { 'accept': 'text/event-stream' }
|
||||
});
|
||||
if (!eventResponse.ok) {
|
||||
var eventError = await eventResponse.json().catch(function(){ return null; });
|
||||
throw new Error(eventError && eventError.code ? eventError.code : 'hermes_events_failed_' + eventResponse.status);
|
||||
}
|
||||
var assistantText = '';
|
||||
await streamPageAiResponse(response, function(eventName, payloadText) {
|
||||
if (eventName === 'assistant_message') {
|
||||
await streamPageAiResponse(eventResponse, function(eventName, payloadText) {
|
||||
if (eventName === 'assistant_message' || eventName === 'message.delta') {
|
||||
try {
|
||||
var payload = JSON.parse(payloadText || 'null');
|
||||
assistantText = searchText(payload && payload.text);
|
||||
assistantText += searchText((payload && (payload.text || payload.delta)) || '');
|
||||
} catch (_) {
|
||||
assistantText += searchText(payloadText);
|
||||
}
|
||||
}
|
||||
if (eventName === 'run.completed') {
|
||||
try {
|
||||
var completed = JSON.parse(payloadText || 'null');
|
||||
if (completed && completed.output) assistantText = searchText(completed.output);
|
||||
} catch (_) {}
|
||||
}
|
||||
if (eventName === 'tool.started' || eventName === 'tool.completed' || eventName === 'tool.failed') {
|
||||
try {
|
||||
var toolEvent = JSON.parse(payloadText || 'null');
|
||||
pageUiState.pageAiMessages.push({
|
||||
role: 'tool',
|
||||
content: String(toolEvent && (toolEvent.name || toolEvent.tool || eventName) || eventName)
|
||||
});
|
||||
} catch (_) {
|
||||
pageUiState.pageAiMessages.push({ role: 'tool', content: eventName });
|
||||
}
|
||||
renderPageAiConversation();
|
||||
}
|
||||
});
|
||||
pageUiState.pageAiMessages.push({
|
||||
role: 'assistant',
|
||||
@@ -3370,18 +3460,16 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
currentSession.messages = pageUiState.pageAiMessages.slice();
|
||||
currentSession.updatedAt = Date.now();
|
||||
}
|
||||
pageAiPersistSessions();
|
||||
} catch (error) {
|
||||
pageUiState.pageAiMessages.push({
|
||||
role: 'assistant',
|
||||
content: pageAiProviderLabel(pageUiState.pageAiProvider) + ' 当前请求失败:' + (error instanceof Error ? error.message : String(error))
|
||||
content: 'Hermes 当前请求失败:' + (error instanceof Error ? error.message : String(error))
|
||||
});
|
||||
currentSession = pageAiCurrentSession();
|
||||
if (currentSession) {
|
||||
currentSession.messages = pageUiState.pageAiMessages.slice();
|
||||
currentSession.updatedAt = Date.now();
|
||||
}
|
||||
pageAiPersistSessions();
|
||||
} finally {
|
||||
pageUiState.pageAiBusy = false;
|
||||
renderPageAiConversation();
|
||||
@@ -3937,6 +4025,20 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiIntent = closestAction(e.target, '[data-page-ai-intent]');
|
||||
if (pageAiIntent) {
|
||||
e.preventDefault();
|
||||
var intentName = pageAiIntent.getAttribute('data-page-ai-intent') || '';
|
||||
if (intentName === 'create-summary') {
|
||||
void sendPageAiMessage('请通过 Hermes 调用 mnote.artifact.create_summary,为当前页面创建或更新 AI Summary。');
|
||||
return;
|
||||
}
|
||||
if (intentName === 'create-ai-note') {
|
||||
void sendPageAiMessage('请通过 Hermes 调用 mnote.artifact.create_ai_note,基于当前页面创建一篇新的 AI Note。');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var pageAiProvider = closestAction(e.target, '[data-page-ai-provider]');
|
||||
if (pageAiProvider) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -2690,6 +2690,13 @@ body {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-intents {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-suggestion-list,
|
||||
.wolai-page-ai-toolbar {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user