Files
mnote/rust/crates/mnote-web/src/hermes_tools/onlyoffice_live.rs
T
lix-2026 1882db7681 收口 MNote P0 P1 P2 审查尾项
- 归档 OnlyOffice live bridge、Page AI、mindmap、design governance 与相关 bug 条目
- 补齐 MinerU OCR 后端 runtime 合同与 smoke/test 基线
- 收口 ChatOnly/Doubao、ObjectIdentity、Page Aggregate compat 与 runtime owner 文档口径

验证:
- cargo test --manifest-path rust/Cargo.toml -p mnote-web local_ocr -- --test-threads=1
- cargo test --manifest-path rust/Cargo.toml -p mnote-web onlyoffice_bridge -- --test-threads=1
- git diff --check
- git diff --cached --check
- codegraph index . --force && codegraph status .
- codegraph sync . && codegraph status .
2026-06-01 09:29:12 +08:00

1131 lines
36 KiB
Rust

use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::{ensure_write_authorized, ToolCallInput};
use crate::routes::onlyoffice_bridge::{self, BridgeResultWire, BridgeRunError};
use axum::http::StatusCode;
use serde_json::{json, Value};
use std::collections::HashSet;
const DEFAULT_TIMEOUT_MS: u64 = 25_000;
const MAX_SHEET_BATCH_CELLS: u64 = 5_000;
const MAX_DOCUMENT_TABLE_CELLS: u64 = 1_000;
const MAX_PRESENTATION_TABLE_CELLS: u64 = 200;
pub async fn session_current(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let session = current_session(context, input)?;
Ok(json!({
"schema": "mnote.onlyoffice.session.v1",
"session": session
}))
}
pub async fn capabilities(
_context: &RequestContext,
_input: &ToolCallInput,
) -> Result<Value, WebError> {
Ok(onlyoffice_bridge::capability_payload())
}
pub async fn selection_get(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
run_read_action(
context,
input,
"selection.get",
json!({
"options": input.arg_value("options").unwrap_or_else(|| json!({}))
}),
)
.await
}
pub async fn sheet_get_range(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
run_read_action(
context,
input,
"sheet.get_range",
json!({
"address": input.arg_string("address").unwrap_or_default()
}),
)
.await
}
pub async fn document_export(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let format = input
.arg_string("format")
.unwrap_or_else(|| "markdown".into())
.to_ascii_lowercase();
if !matches!(format.as_str(), "markdown" | "html") {
return Err(WebError::bad_request_code(
"mnote_onlyoffice_export_format_invalid",
"format 仅支持 markdown 或 html",
)
.with_context(context));
}
run_read_action(
context,
input,
"document.export",
json!({ "format": format }),
)
.await
}
pub async fn document_search_replace(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let search = required_string(context, input, "search")?;
if search.is_empty() {
return Err(
WebError::bad_request_code("mnote_onlyoffice_search_empty", "search 不能为空")
.with_context(context),
);
}
run_write_action(
context,
input,
"document.search_replace",
json!({
"search": search,
"replace": input.arg_string("replace").unwrap_or_default(),
"matchCase": arg_bool(input, "matchCase").unwrap_or(false)
}),
)
.await
}
pub async fn document_insert_table(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let data = input.arg_value("data").unwrap_or_else(|| json!([]));
let rows_from_data = data.as_array().map(|rows| rows.len() as u64).unwrap_or(0);
let cols_from_data = data
.as_array()
.and_then(|rows| {
rows.iter()
.map(|row| row.as_array().map(|cells| cells.len() as u64).unwrap_or(1))
.max()
})
.unwrap_or(0);
let rows = arg_u64(input, "rows").unwrap_or(rows_from_data).max(1);
let cols = arg_u64(input, "cols")
.or_else(|| arg_u64(input, "columns"))
.unwrap_or(cols_from_data)
.max(1);
if rows.saturating_mul(cols) > MAX_DOCUMENT_TABLE_CELLS {
return Err(WebError::bad_request_code(
"mnote_onlyoffice_table_too_large",
format!("单次 Word 表格最多支持 {MAX_DOCUMENT_TABLE_CELLS} 个单元格"),
)
.with_context(context));
}
run_write_action(
context,
input,
"document.insert_table",
json!({
"rows": rows,
"cols": cols,
"data": data
}),
)
.await
}
pub async fn document_get_comments(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
run_read_action(context, input, "document.get_comments", json!({})).await
}
pub async fn document_add_comment(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let text = required_string(context, input, "text")?;
run_write_action(
context,
input,
"document.add_comment",
json!({
"text": text,
"author": input.arg_string("author").unwrap_or_else(|| "MNote AI".into()),
"target": input.arg_string("target").unwrap_or_else(|| "selection".into())
}),
)
.await
}
pub async fn sheet_get_sheets(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
run_read_action(context, input, "sheet.get_sheets", json!({})).await
}
pub async fn sheet_add_sheet(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let name = required_string(context, input, "name")?;
run_write_action(context, input, "sheet.add_sheet", json!({ "name": name })).await
}
pub async fn sheet_rename_sheet(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let name = required_string(context, input, "name")?;
run_write_action(
context,
input,
"sheet.rename_sheet",
json!({
"name": name,
"sheetIndex": arg_u64(input, "sheetIndex"),
"sheetName": input.arg_string("sheetName").unwrap_or_default()
}),
)
.await
}
pub async fn sheet_get_range_values(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let address = input
.arg_string("address")
.or_else(|| input.arg_string("range"))
.ok_or_else(|| {
WebError::bad_request_code("mnote_onlyoffice_range_required", "缺少 address")
.with_context(context)
})?;
let range = parse_a1_range(context, &address)?;
ensure_sheet_cell_limit(context, range.row_count, range.col_count)?;
run_read_action(
context,
input,
"sheet.get_range_values",
json!({ "address": address }),
)
.await
}
pub async fn sheet_get_values(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let start_row = arg_u64(input, "startRow").unwrap_or(0);
let start_col = arg_u64(input, "startCol").unwrap_or(0);
let row_count = required_u64(context, input, "rowCount")?;
let col_count = required_u64(context, input, "colCount")?;
ensure_sheet_cell_limit(context, row_count, col_count)?;
run_read_action(
context,
input,
"sheet.get_values",
json!({
"startRow": start_row,
"startCol": start_col,
"rowCount": row_count,
"colCount": col_count
}),
)
.await
}
pub async fn presentation_get_slides(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
run_read_action(context, input, "presentation.get_slides", json!({})).await
}
pub async fn presentation_get_slide_texts(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let mut payload = json!({});
if let Some(slide_index) = arg_u64(input, "slideIndex") {
payload["slideIndex"] = json!(slide_index);
}
run_read_action(context, input, "presentation.get_slide_texts", payload).await
}
pub async fn presentation_get_shapes(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let mut payload = json!({});
if let Some(slide_index) = arg_u64(input, "slideIndex") {
payload["slideIndex"] = json!(slide_index);
}
run_read_action(context, input, "presentation.get_shapes", payload).await
}
pub async fn document_insert_text(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let text = required_string(context, input, "text")?;
run_write_action(
context,
input,
"document.insert_text",
json!({ "text": text }),
)
.await
}
pub async fn document_replace_selection(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let text = required_string(context, input, "text")?;
run_write_action(
context,
input,
"document.replace_selection",
json!({ "text": text }),
)
.await
}
pub async fn document_insert_html(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let html = required_string(context, input, "html")?;
run_write_action(
context,
input,
"document.insert_html",
json!({ "html": html }),
)
.await
}
pub async fn sheet_set_value(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let value = input.arg_value("value").ok_or_else(|| {
WebError::bad_request_code("mnote_onlyoffice_value_required", "缺少 value")
.with_context(context)
})?;
run_write_action(
context,
input,
"sheet.set_value",
json!({
"address": input.arg_string("address").unwrap_or_default(),
"value": value
}),
)
.await
}
pub async fn sheet_set_formula(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let formula = required_string(context, input, "formula")?;
run_write_action(
context,
input,
"sheet.set_formula",
json!({
"address": input.arg_string("address").unwrap_or_default(),
"formula": formula
}),
)
.await
}
pub async fn sheet_batch_set_values(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let values = input.arg_value("values").ok_or_else(|| {
WebError::bad_request_code("mnote_onlyoffice_values_required", "缺少 values")
.with_context(context)
})?;
let rows = values.as_array().ok_or_else(|| {
WebError::bad_request_code("mnote_onlyoffice_values_invalid", "values 必须是二维数组")
.with_context(context)
})?;
let row_count = rows.len() as u64;
let col_count = rows
.iter()
.map(|row| row.as_array().map(|cells| cells.len()).unwrap_or(1))
.max()
.unwrap_or(0) as u64;
if row_count == 0 || col_count == 0 {
return Err(
WebError::bad_request_code("mnote_onlyoffice_values_empty", "values 不能为空")
.with_context(context),
);
}
ensure_sheet_cell_limit(context, row_count, col_count)?;
run_write_action(
context,
input,
"sheet.batch_set_values",
json!({
"startRow": arg_u64(input, "startRow").unwrap_or(0),
"startCol": arg_u64(input, "startCol").unwrap_or(0),
"values": values
}),
)
.await
}
pub async fn sheet_set_range_values(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let address = input
.arg_string("address")
.or_else(|| input.arg_string("range"))
.ok_or_else(|| {
WebError::bad_request_code("mnote_onlyoffice_range_required", "缺少 address")
.with_context(context)
})?;
let range = parse_a1_range(context, &address)?;
ensure_sheet_cell_limit(context, range.row_count, range.col_count)?;
let values = input.arg_value("values").ok_or_else(|| {
WebError::bad_request_code("mnote_onlyoffice_values_required", "缺少 values")
.with_context(context)
})?;
let rows = values.as_array().ok_or_else(|| {
WebError::bad_request_code("mnote_onlyoffice_values_invalid", "values 必须是二维数组")
.with_context(context)
})?;
let row_count = rows.len() as u64;
let col_count = rows
.iter()
.map(|row| row.as_array().map(|cells| cells.len()).unwrap_or(1))
.max()
.unwrap_or(0) as u64;
if row_count == 0 || col_count == 0 {
return Err(
WebError::bad_request_code("mnote_onlyoffice_values_empty", "values 不能为空")
.with_context(context),
);
}
ensure_sheet_cell_limit(context, row_count, col_count)?;
run_write_action(
context,
input,
"sheet.set_range_values",
json!({
"address": address,
"values": values
}),
)
.await
}
pub async fn sheet_format_range(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let address = input
.arg_string("address")
.or_else(|| input.arg_string("range"))
.ok_or_else(|| {
WebError::bad_request_code("mnote_onlyoffice_range_required", "缺少 address")
.with_context(context)
})?;
let range = parse_a1_range(context, &address)?;
ensure_sheet_cell_limit(context, range.row_count, range.col_count)?;
run_write_action(
context,
input,
"sheet.format_range",
json!({
"address": address,
"bold": arg_bool(input, "bold"),
"italic": arg_bool(input, "italic"),
"underline": arg_bool(input, "underline"),
"fillColor": input.arg_string("fillColor").or_else(|| input.arg_string("fill_color")).unwrap_or_default(),
"fontColor": input.arg_string("fontColor").or_else(|| input.arg_string("font_color")).unwrap_or_default(),
"fontSize": arg_f64(input, "fontSize"),
"fontName": input.arg_string("fontName").or_else(|| input.arg_string("font_name")).unwrap_or_default(),
"horizontalAlign": input.arg_string("horizontalAlign").or_else(|| input.arg_string("horizontal_align")).unwrap_or_default(),
"verticalAlign": input.arg_string("verticalAlign").or_else(|| input.arg_string("vertical_align")).unwrap_or_default(),
"numberFormat": input.arg_string("numberFormat").or_else(|| input.arg_string("number_format")).unwrap_or_default()
}),
)
.await
}
pub async fn sheet_set_dimensions(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let column_index = arg_u64(input, "columnIndex");
let column_width = arg_f64(input, "columnWidth");
let row_index = arg_u64(input, "rowIndex");
let row_height = arg_f64(input, "rowHeight");
let has_column = column_index.is_some() && column_width.is_some();
let has_row = row_index.is_some() && row_height.is_some();
if !has_column && !has_row {
return Err(WebError::bad_request_code(
"mnote_onlyoffice_dimensions_required",
"缺少 columnIndex/columnWidth 或 rowIndex/rowHeight",
)
.with_context(context));
}
run_write_action(
context,
input,
"sheet.set_dimensions",
json!({
"columnIndex": column_index,
"columnWidth": column_width,
"rowIndex": row_index,
"rowHeight": row_height
}),
)
.await
}
pub async fn sheet_sort_range(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let address = input
.arg_string("address")
.or_else(|| input.arg_string("range"))
.ok_or_else(|| {
WebError::bad_request_code("mnote_onlyoffice_range_required", "缺少 address")
.with_context(context)
})?;
let range = parse_a1_range(context, &address)?;
ensure_sheet_cell_limit(context, range.row_count, range.col_count)?;
let key_column = arg_u64(input, "keyColumn").unwrap_or(1).max(1);
if key_column > range.col_count {
return Err(WebError::bad_request_code(
"mnote_onlyoffice_sort_key_out_of_range",
"keyColumn 必须落在排序 range 内,且从 1 开始",
)
.with_context(context));
}
let order = input
.arg_string("order")
.unwrap_or_else(|| "ascending".into())
.to_ascii_lowercase();
if !matches!(
order.as_str(),
"ascending" | "descending" | "asc" | "desc" | "xlascending" | "xldescending"
) {
return Err(WebError::bad_request_code(
"mnote_onlyoffice_sort_order_invalid",
"order 仅支持 ascending/descending",
)
.with_context(context));
}
run_write_action(
context,
input,
"sheet.sort_range",
json!({
"address": address,
"keyColumn": key_column,
"order": order,
"header": arg_bool(input, "header").unwrap_or(true)
}),
)
.await
}
pub async fn sheet_add_chart(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let address = input
.arg_string("address")
.or_else(|| input.arg_string("range"))
.ok_or_else(|| {
WebError::bad_request_code("mnote_onlyoffice_range_required", "缺少 address")
.with_context(context)
})?;
let range = parse_a1_range(context, &address)?;
ensure_sheet_cell_limit(context, range.row_count, range.col_count)?;
let chart_type = input
.arg_string("chartType")
.or_else(|| input.arg_string("chart_type"))
.unwrap_or_else(|| "bar".into());
run_write_action(
context,
input,
"sheet.add_chart",
json!({
"address": address,
"chartType": chart_type,
"inColumns": arg_bool(input, "inColumns").unwrap_or(true),
"style": arg_u64(input, "style").unwrap_or(2),
"widthMm": arg_f64(input, "widthMm").unwrap_or(120.0),
"heightMm": arg_f64(input, "heightMm").unwrap_or(80.0),
"fromCol": arg_u64(input, "fromCol").unwrap_or(4),
"xOffsetMm": arg_f64(input, "xOffsetMm").unwrap_or(0.0),
"fromRow": arg_u64(input, "fromRow").unwrap_or(1),
"yOffsetMm": arg_f64(input, "yOffsetMm").unwrap_or(0.0)
}),
)
.await
}
pub async fn presentation_add_text_slide(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
run_write_action(
context,
input,
"presentation.add_text_slide",
json!({
"title": input.arg_string("title").unwrap_or_default(),
"body": input.arg_string("body").unwrap_or_default()
}),
)
.await
}
pub async fn presentation_add_table(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let data = input.arg_value("data").unwrap_or_else(|| json!([]));
let rows_from_data = data.as_array().map(|rows| rows.len() as u64).unwrap_or(0);
let cols_from_data = data
.as_array()
.and_then(|rows| {
rows.iter()
.map(|row| row.as_array().map(|cells| cells.len() as u64).unwrap_or(1))
.max()
})
.unwrap_or(0);
let rows = arg_u64(input, "rows").unwrap_or(rows_from_data).max(1);
let cols = arg_u64(input, "cols")
.or_else(|| arg_u64(input, "columns"))
.unwrap_or(cols_from_data)
.max(1);
if rows.saturating_mul(cols) > MAX_PRESENTATION_TABLE_CELLS {
return Err(WebError::bad_request_code(
"mnote_onlyoffice_presentation_table_too_large",
format!("单次 PPT 表格最多支持 {MAX_PRESENTATION_TABLE_CELLS} 个单元格"),
)
.with_context(context));
}
let mut payload = json!({
"rows": rows,
"cols": cols,
"data": data,
"widthMm": arg_f64(input, "widthMm").unwrap_or(190.0),
"heightMm": arg_f64(input, "heightMm").unwrap_or(90.0)
});
if let Some(slide_index) = arg_u64(input, "slideIndex") {
payload["slideIndex"] = json!(slide_index);
}
if let Some(x_mm) = arg_f64(input, "xMm") {
payload["xMm"] = json!(x_mm);
}
if let Some(y_mm) = arg_f64(input, "yMm") {
payload["yMm"] = json!(y_mm);
}
run_write_action(context, input, "presentation.add_table", payload).await
}
pub async fn presentation_clear_slide(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let slide_index = required_u64(context, input, "slideIndex")?;
run_write_action(
context,
input,
"presentation.clear_slide",
json!({ "slideIndex": slide_index }),
)
.await
}
pub async fn presentation_add_shape(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let shape_type = input
.arg_string("shapeType")
.or_else(|| input.arg_string("shape_type"))
.unwrap_or_else(|| "rect".into());
let allowed_shapes = [
"rect",
"roundRect",
"ellipse",
"triangle",
"diamond",
"cube",
"cloud",
"flowChartMagneticTape",
];
if !allowed_shapes.contains(&shape_type.as_str()) {
return Err(WebError::bad_request_code(
"mnote_onlyoffice_shape_type_invalid",
"shapeType 不在允许列表中",
)
.with_context(context));
}
let mut payload = json!({
"shapeType": shape_type,
"text": input.arg_string("text").unwrap_or_default(),
"fillColor": input.arg_string("fillColor").or_else(|| input.arg_string("fill_color")).unwrap_or_else(|| "#4F81BD".into()),
"strokeColor": input.arg_string("strokeColor").or_else(|| input.arg_string("stroke_color")).unwrap_or_default(),
"strokeWidthMm": arg_f64(input, "strokeWidthMm").unwrap_or(0.0),
"xMm": arg_f64(input, "xMm").unwrap_or(20.0),
"yMm": arg_f64(input, "yMm").unwrap_or(35.0),
"widthMm": arg_f64(input, "widthMm").unwrap_or(160.0),
"heightMm": arg_f64(input, "heightMm").unwrap_or(70.0)
});
if let Some(slide_index) = arg_u64(input, "slideIndex") {
payload["slideIndex"] = json!(slide_index);
}
run_write_action(context, input, "presentation.add_shape", payload).await
}
pub async fn presentation_replace_text(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let search = required_string(context, input, "search")?;
if search.is_empty() {
return Err(
WebError::bad_request_code("mnote_onlyoffice_search_empty", "search 不能为空")
.with_context(context),
);
}
let mut payload = json!({
"search": search,
"replace": input.arg_string("replace").unwrap_or_default(),
"matchCase": arg_bool(input, "matchCase").unwrap_or(false),
"replaceAll": arg_bool(input, "replaceAll").unwrap_or(true)
});
if let Some(slide_index) = arg_u64(input, "slideIndex") {
payload["slideIndex"] = json!(slide_index);
}
run_write_action(context, input, "presentation.replace_text", payload).await
}
pub async fn presentation_set_shape_text(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let slide_index = required_u64(context, input, "slideIndex")?;
let text = required_string(context, input, "text")?;
let shape_index = arg_u64(input, "shapeIndex");
let shape_id = input.arg_string("shapeId");
if shape_index.is_none() && shape_id.as_deref().unwrap_or_default().trim().is_empty() {
return Err(WebError::bad_request_code(
"mnote_onlyoffice_shape_locator_required",
"缺少 shapeIndex 或 shapeId",
)
.with_context(context));
}
run_write_action(
context,
input,
"presentation.set_shape_text",
json!({
"slideIndex": slide_index,
"shapeIndex": shape_index,
"shapeId": shape_id.unwrap_or_default(),
"text": text
}),
)
.await
}
pub async fn presentation_delete_slide(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let slide_index = required_u64(context, input, "slideIndex")?;
run_write_action(
context,
input,
"presentation.delete_slide",
json!({ "slideIndex": slide_index }),
)
.await
}
async fn run_read_action(
context: &RequestContext,
input: &ToolCallInput,
action: &'static str,
payload: Value,
) -> Result<Value, WebError> {
let session_id = resolve_explicit_session_id(context, input)?;
ensure_onlyoffice_resource_scope_allowed(context, input, &session_id)?;
let result = run_bridge(context, input, &session_id, action, payload).await?;
Ok(action_result(action, &session_id, result))
}
async fn run_write_action(
context: &RequestContext,
input: &ToolCallInput,
action: &'static str,
payload: Value,
) -> Result<Value, WebError> {
ensure_write_authorized(context, input)?;
let session_id = resolve_explicit_session_id(context, input)?;
ensure_onlyoffice_resource_scope_allowed(context, input, &session_id)?;
if input.dry_run.unwrap_or(false) {
return Ok(json!({
"schema": "mnote.onlyoffice.action_plan.v1",
"sessionId": session_id,
"action": action,
"payload": payload,
"dryRun": true,
"wouldWrite": true
}));
}
let result = run_bridge(context, input, &session_id, action, payload).await?;
Ok(action_result(action, &session_id, result))
}
async fn run_bridge(
context: &RequestContext,
input: &ToolCallInput,
session_id: &str,
action: &'static str,
payload: Value,
) -> Result<BridgeResultWire, WebError> {
let timeout_ms = input
.arg_value("timeoutMs")
.or_else(|| input.arg_value("timeout_ms"))
.and_then(|value| value.as_u64())
.unwrap_or(DEFAULT_TIMEOUT_MS);
let result = onlyoffice_bridge::run_bridge_command(session_id, action, payload, timeout_ms)
.await
.map_err(|error| bridge_run_error(context, error))?;
if !result.ok {
return Err(WebError::bad_request_code(
"mnote_onlyoffice_bridge_failed",
result
.error
.clone()
.unwrap_or_else(|| "ONLYOFFICE bridge 命令执行失败".into()),
)
.with_context(context));
}
Ok(result)
}
fn action_result(action: &str, session_id: &str, result: BridgeResultWire) -> Value {
json!({
"schema": "mnote.onlyoffice.action_result.v1",
"sessionId": session_id,
"action": action,
"commandId": result.id,
"result": result.result
})
}
fn resolve_explicit_session_id(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<String, WebError> {
input
.arg_string("onlyofficeSessionId")
.or_else(|| input.arg_string("bridgeSessionId"))
.ok_or_else(|| {
WebError::bad_request_code(
"mnote_onlyoffice_session_explicit_required",
"ONLYOFFICE 工具必须显式携带 onlyofficeSessionId",
)
.with_context(context)
})
}
fn ensure_onlyoffice_resource_scope_allowed(
context: &RequestContext,
input: &ToolCallInput,
session_id: &str,
) -> Result<(), WebError> {
let Some(scope) = input.arg_value("aiAccessScope") else {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"mnote_onlyoffice_resource_scope_required",
"ONLYOFFICE 工具必须携带 aiAccessScope.allowedResourceIds",
)
.with_context(context));
};
let allowed = scope
.get("allowedResourceIds")
.or_else(|| scope.get("allowed_resource_ids"))
.and_then(Value::as_array)
.map(|values| {
values
.iter()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.collect::<HashSet<_>>()
})
.unwrap_or_default();
if allowed.is_empty() {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"mnote_onlyoffice_resource_scope_required",
"ONLYOFFICE 工具必须携带非空 aiAccessScope.allowedResourceIds",
)
.with_context(context));
}
let Some(session) = onlyoffice_bridge::session_info(session_id) else {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"mnote_onlyoffice_resource_scope_forbidden",
"ONLYOFFICE session 未注册,无法校验资源授权",
)
.with_context(context));
};
let mut candidates = HashSet::new();
candidates.insert(session.session_id);
let document_id = session.document_id;
let asset_id = session.asset_id;
if let Some(document_id) = document_id.as_deref() {
candidates.insert(document_id.to_string());
}
if let Some(asset_id) = asset_id.as_deref() {
candidates.insert(asset_id.to_string());
}
if let (Some(document_id), Some(asset_id)) = (document_id.as_deref(), asset_id.as_deref()) {
candidates.insert(format!("resource:office:{document_id}:{asset_id}"));
candidates.insert(format!("resource:onlyoffice:{document_id}:{asset_id}"));
}
if candidates
.iter()
.any(|candidate| allowed.contains(candidate))
{
return Ok(());
}
Err(WebError::new(
StatusCode::FORBIDDEN,
"mnote_onlyoffice_resource_scope_forbidden",
"ONLYOFFICE session 不在当前 AI resource 授权范围内",
)
.with_context(context))
}
fn current_session(context: &RequestContext, input: &ToolCallInput) -> Result<Value, WebError> {
let session_id = resolve_explicit_session_id(context, input)?;
ensure_onlyoffice_resource_scope_allowed(context, input, &session_id)?;
let session = onlyoffice_bridge::session_info(&session_id).ok_or_else(|| {
WebError::new(
StatusCode::NOT_FOUND,
"mnote_onlyoffice_session_not_found",
"指定的 ONLYOFFICE bridge session 不存在,请先打开 ONLYOFFICE 文档",
)
.with_context(context)
})?;
Ok(json!({
"sessionId": session.session_id,
"editorType": session.editor_type.unwrap_or_default(),
"documentId": session.document_id.unwrap_or_default(),
"assetId": session.asset_id.unwrap_or_default(),
"fileType": session.file_type.unwrap_or_default(),
"lastSeenMillis": session.last_seen_millis,
"pendingCommands": session.pending_commands,
"pendingResults": session.pending_results
}))
}
fn required_string(
context: &RequestContext,
input: &ToolCallInput,
key: &'static str,
) -> Result<String, WebError> {
input.arg_string(key).ok_or_else(|| {
WebError::bad_request_code("mnote_onlyoffice_argument_required", format!("缺少 {key}"))
.with_context(context)
})
}
fn required_u64(
context: &RequestContext,
input: &ToolCallInput,
key: &'static str,
) -> Result<u64, WebError> {
arg_u64(input, key).ok_or_else(|| {
WebError::bad_request_code("mnote_onlyoffice_argument_required", format!("缺少 {key}"))
.with_context(context)
})
}
fn arg_u64(input: &ToolCallInput, key: &'static str) -> Option<u64> {
input
.arg_value(key)
.or_else(|| match key {
"startRow" => input.arg_value("start_row"),
"startCol" => input.arg_value("start_col"),
"rowCount" => input.arg_value("row_count"),
"colCount" => input.arg_value("col_count"),
"slideIndex" => input.arg_value("slide_index"),
"sheetIndex" => input.arg_value("sheet_index"),
"shapeIndex" => input.arg_value("shape_index"),
"columnIndex" => input.arg_value("column_index"),
"rowIndex" => input.arg_value("row_index"),
"keyColumn" => input.arg_value("key_column"),
"fromCol" => input.arg_value("from_col"),
"fromRow" => input.arg_value("from_row"),
"cols" => input.arg_value("columns"),
_ => None,
})
.and_then(|value| value.as_u64())
}
fn arg_f64(input: &ToolCallInput, key: &'static str) -> Option<f64> {
input
.arg_value(key)
.or_else(|| match key {
"columnWidth" => input.arg_value("column_width"),
"rowHeight" => input.arg_value("row_height"),
"fontSize" => input.arg_value("font_size"),
"widthMm" => input.arg_value("width_mm"),
"heightMm" => input.arg_value("height_mm"),
"xOffsetMm" => input.arg_value("x_offset_mm"),
"yOffsetMm" => input.arg_value("y_offset_mm"),
"xMm" => input.arg_value("x_mm"),
"yMm" => input.arg_value("y_mm"),
"strokeWidthMm" => input.arg_value("stroke_width_mm"),
_ => None,
})
.and_then(|value| value.as_f64())
.filter(|value| value.is_finite() && *value >= 0.0)
}
fn arg_bool(input: &ToolCallInput, key: &'static str) -> Option<bool> {
input
.arg_value(key)
.or_else(|| match key {
"matchCase" => input.arg_value("match_case"),
"replaceAll" => input.arg_value("replace_all"),
"inColumns" => input.arg_value("in_columns"),
_ => None,
})
.and_then(|value| value.as_bool())
}
fn ensure_sheet_cell_limit(
context: &RequestContext,
row_count: u64,
col_count: u64,
) -> Result<(), WebError> {
if row_count == 0 || col_count == 0 {
return Err(WebError::bad_request_code(
"mnote_onlyoffice_sheet_range_empty",
"rowCount 和 colCount 必须大于 0",
)
.with_context(context));
}
if row_count.saturating_mul(col_count) > MAX_SHEET_BATCH_CELLS {
return Err(WebError::bad_request_code(
"mnote_onlyoffice_sheet_range_too_large",
format!("单次表格批量操作最多支持 {MAX_SHEET_BATCH_CELLS} 个单元格"),
)
.with_context(context));
}
Ok(())
}
#[derive(Debug)]
struct A1Range {
row_count: u64,
col_count: u64,
}
fn parse_a1_range(context: &RequestContext, address: &str) -> Result<A1Range, WebError> {
let value = address.trim();
let mut parts = value.split(':');
let start = parts.next().unwrap_or_default();
let end = parts.next().unwrap_or(start);
if parts.next().is_some() {
return Err(invalid_a1_range(context));
}
let (start_row, start_col) = parse_a1_cell(start).ok_or_else(|| invalid_a1_range(context))?;
let (end_row, end_col) = parse_a1_cell(end).ok_or_else(|| invalid_a1_range(context))?;
Ok(A1Range {
row_count: start_row.abs_diff(end_row) + 1,
col_count: start_col.abs_diff(end_col) + 1,
})
}
fn parse_a1_cell(value: &str) -> Option<(u64, u64)> {
let value = value.trim();
let split_at = value
.char_indices()
.find_map(|(index, ch)| ch.is_ascii_digit().then_some(index))?;
let (letters, digits) = value.split_at(split_at);
if letters.is_empty()
|| digits.is_empty()
|| !letters.chars().all(|ch| ch.is_ascii_alphabetic())
|| !digits.chars().all(|ch| ch.is_ascii_digit())
{
return None;
}
let mut col = 0_u64;
for ch in letters.chars() {
col = col
.saturating_mul(26)
.saturating_add((ch.to_ascii_uppercase() as u8 - b'A' + 1) as u64);
}
let row = digits.parse::<u64>().ok()?;
if row == 0 || col == 0 {
return None;
}
Some((row - 1, col - 1))
}
fn invalid_a1_range(context: &RequestContext) -> WebError {
WebError::bad_request_code(
"mnote_onlyoffice_range_invalid",
"address 必须是 A1 或 A1:B2",
)
.with_context(context)
}
fn bridge_run_error(context: &RequestContext, error: BridgeRunError) -> WebError {
match error {
BridgeRunError::BadRequest(message) => {
WebError::bad_request_code("mnote_onlyoffice_bridge_bad_request", message)
}
BridgeRunError::Timeout {
session_id,
command_id,
} => WebError::new(
StatusCode::GATEWAY_TIMEOUT,
"mnote_onlyoffice_bridge_timeout",
format!("ONLYOFFICE bridge 命令超时,sessionId={session_id}, commandId={command_id}"),
),
}
.with_context(context)
}