- 归档 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 .
1131 lines
36 KiB
Rust
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)
|
|
}
|