feat(mcp): import_html tool inserting op-html parsed subtrees

This commit is contained in:
Kayshen-X 2026-07-18 14:16:43 +08:00
parent 97f1cf6b44
commit 21d90a2f25
7 changed files with 176 additions and 1 deletions

View file

@ -15,6 +15,7 @@ use std::io::{BufRead, BufReader, BufWriter, Write};
use std::path::{Path, PathBuf};
use op_editor_core::{EditorCommand, EditorState};
use op_mcp::import_html_tool::import_html_snapshot;
use op_mcp::{
add_node_effect_snapshot, add_page_snapshot, align_selected_snapshot,
apply_design_system_snapshot, batch_design_snapshot, batch_get_snapshot,
@ -587,6 +588,7 @@ fn rebuild_registry(doc: &EditorState, requested_tool: Option<&str>) -> ToolRegi
register_tool!("set_active_axis_value", set_active_axis_value_snapshot(doc));
register_tool!("insert_node", insert_node_snapshot());
register_tool!("import_svg", import_svg_snapshot());
register_tool!("import_html", import_html_snapshot());
register_tool!("update_node", update_node_snapshot());
register_tool!("delete_node", delete_node_snapshot());
register_tool!("move_node", move_node_snapshot());

View file

@ -120,6 +120,7 @@ pub const TOOL_SCHEMAS: &[&str] = &[
r#"{"name":"insert_node","description":"Create a new leaf node on the active or requested page. Accepts Rust flat fields or TS-style data object.","inputSchema":{"type":"object","properties":{"filePath":{"type":"string","description":"Optional target .op file path; omit to use the server document"},"kind":{"type":"string","enum":["frame","group","rect","ellipse","polygon","line","text","path"]},"name":{"type":"string"},"x":{"type":"string"},"y":{"type":"string"},"width":{"type":"string"},"height":{"type":"string"},"fill_hex":{"type":"string"},"parent":{"type":"string","description":"optional parent node id; empty/0/null/root omitted = page root"},"data":{"type":"object","description":"TS-style PenNode data; rich fields are preserved as a subtree"},"postProcess":{"type":"boolean","description":"Accepted for TS compatibility; Rust leaf insert path ignores post-processing"},"canvasWidth":{"type":"number","description":"Accepted for TS compatibility; Rust leaf insert path ignores post-processing width"},"pageId":{"type":"string","description":"optional target page id or legacy page index; omitted = active page"}}}}"#,
r#"{"name":"update_node","description":"Patch fields on an existing node. Accepts Rust flat fields or TS-style nodeId + data object.","inputSchema":{"type":"object","properties":{"filePath":{"type":"string","description":"Optional target .op file path; omit to use the server document"},"node_id":{"type":"string"},"nodeId":{"type":"string"},"x":{"type":"string"},"y":{"type":"string"},"width":{"type":"string"},"height":{"type":"string"},"name":{"type":"string"},"fill_hex":{"type":"string"},"data":{"type":"object","description":"TS-style shallow patch data; rich fields such as content/fontSize are preserved"},"postProcess":{"type":"boolean","description":"Accepted for TS compatibility; Rust patch path ignores post-processing"},"canvasWidth":{"type":"number","description":"Accepted for TS compatibility; Rust patch path ignores post-processing width"},"pageId":{"type":"string","description":"optional target page id or legacy page index; omitted = active page"}}}}"#,
r#"{"name":"import_svg","description":"Parse an SVG document and insert the resulting nodes on the active or requested page. Accepts inline svg or TS-style svgPath. Supports rect/circle/ellipse/line/polyline/polygon and path (M/L/H/V/C/S/Q/T/Z).","inputSchema":{"type":"object","properties":{"filePath":{"type":"string","description":"Optional target .op file path; omit to use the server document"},"svg":{"type":"string","description":"SVG document text"},"svgPath":{"type":"string","description":"local SVG file path"},"x":{"type":"string","description":"i32 doc-px x offset (default 0)"},"y":{"type":"string","description":"i32 doc-px y offset (default 0)"},"parent":{"type":"string","description":"optional parent node id; empty/0/root omitted = page root"},"maxDim":{"type":"number","description":"Accepted for TS compatibility; Rust SVG path currently preserves parsed dimensions"},"postProcess":{"type":"boolean","description":"Accepted for TS compatibility; Rust SVG path ignores post-processing"},"canvasWidth":{"type":"number","description":"Accepted for TS compatibility; Rust SVG path ignores post-processing width"},"pageId":{"type":"string","description":"optional target page id or legacy page index; omitted = active page"}}}}"#,
r#"{"name":"import_html","description":"Parse an HTML document or snippet (CSS-subset cascade: inline style, <style> blocks, tag/class/id descendant selectors) and insert the resulting editable auto-layout nodes on the active or requested page. Accepts inline html or htmlPath.","inputSchema":{"type":"object","properties":{"filePath":{"type":"string","description":"Optional target .op file path; omit to use the server document"},"html":{"type":"string","description":"HTML document or fragment text"},"htmlPath":{"type":"string","description":"local HTML file path"},"x":{"type":"string","description":"i32 doc-px x offset (default 0)"},"y":{"type":"string","description":"i32 doc-px y offset (default 0)"},"parent":{"type":"string","description":"optional parent node id; empty/0/root omitted = page root"},"pageId":{"type":"string","description":"optional target page id or legacy page index; omitted = active page"}}}}"#,
r#"{"name":"delete_node","description":"Remove a node + descendants from its parent. Accepts Rust node_id or TS nodeId, plus optional pageId.","inputSchema":{"type":"object","properties":{"filePath":{"type":"string","description":"Optional target .op file path; omit to use the server document"},"node_id":{"type":"string"},"nodeId":{"type":"string"},"pageId":{"type":"string","description":"optional target page id or legacy page index; omitted = active page"}}}}"#,
r#"{"name":"move_node","description":"Reparent a node. Accepts Rust node_id/target_parent_id or TS nodeId/parent, plus optional index and pageId.","inputSchema":{"type":"object","properties":{"filePath":{"type":"string","description":"Optional target .op file path; omit to use the server document"},"node_id":{"type":"string"},"nodeId":{"type":"string"},"target_parent_id":{"type":"string"},"parent":{"type":"string","description":"target parent node id; empty/0/null/root omitted = page root"},"index":{"type":"string","description":"optional insertion index within target parent/root"},"pageId":{"type":"string","description":"optional target page id or legacy page index; omitted = active page"}}}}"#,
r#"{"name":"copy_node","description":"Deep-clone a subtree with fresh ids under a new parent. Accepts Rust node_id or TS sourceId/nodeId, plus optional parent/overrides/pageId.","inputSchema":{"type":"object","properties":{"filePath":{"type":"string","description":"Optional target .op file path; omit to use the server document"},"node_id":{"type":"string"},"sourceId":{"type":"string"},"nodeId":{"type":"string"},"target_parent_id":{"type":"string"},"parent":{"type":"string","description":"target parent node id; empty/0/null/root omitted = page root"},"overrides":{"type":"object","description":"TS-style shallow properties to apply to the cloned root; id is ignored"},"pageId":{"type":"string","description":"optional target page id or legacy page index; omitted = active page"}}}}"#,

View file

@ -23,6 +23,7 @@ jian-scene = { path = "../../vendor/jian/crates/jian-scene" }
op-ai-skills = { path = "../op-ai-skills" }
op-design-lint = { path = "../op-design-lint" }
op-editor-core = { path = "../op-editor-core" }
op-html = { path = "../op-html" }
op-pen-loader = { path = "../op-pen-loader" }
regex = "1"
rquickjs = { version = "0.12", optional = true }

View file

@ -0,0 +1,134 @@
use std::{collections::BTreeMap, fs};
use jian_ops_schema::node::PenNode;
use op_editor_core::NodeId;
use op_html::{import_html, HtmlImportOptions};
use super::write_tools::{parse_opt_i32, root_or_node_id};
use super::{EditorCommand, McpTool, ToolErrorCode, ToolOutcome};
pub struct ImportHtml;
impl McpTool for ImportHtml {
fn name(&self) -> &str {
"import_html"
}
fn call(&self, args: &BTreeMap<String, String>) -> ToolOutcome {
let html = match args.get("html") {
Some(html) => html.clone(),
None => {
let Some(path) = args.get("htmlPath").or_else(|| args.get("html_path")) else {
return ToolOutcome::Err(
ToolErrorCode::MissingArgument,
"html or htmlPath is required".into(),
);
};
match fs::read_to_string(path) {
Ok(html) => html,
Err(error) => {
return ToolOutcome::Err(
ToolErrorCode::ToolFailed,
format!("failed to read htmlPath {path:?}: {error}"),
);
}
}
}
};
if html.trim().is_empty() {
return ToolOutcome::Err(
ToolErrorCode::InvalidArgument,
"html must not be empty".into(),
);
}
let x = match parse_opt_i32(args, "x") {
Ok(value) => value.unwrap_or(0),
Err(error) => {
return ToolOutcome::Err(ToolErrorCode::InvalidArgument, format!("x: {error}"));
}
};
let y = match parse_opt_i32(args, "y") {
Ok(value) => value.unwrap_or(0),
Err(error) => {
return ToolOutcome::Err(ToolErrorCode::InvalidArgument, format!("y: {error}"));
}
};
let target_parent = args
.get("parent")
.or_else(|| args.get("parent_id"))
.or_else(|| args.get("target_parent_id"))
.map(|value| root_or_node_id(value))
.unwrap_or(NodeId::NONE);
let page_id = args
.get("pageId")
.or_else(|| args.get("page_id"))
.or_else(|| args.get("page"))
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.map(str::to_string);
let result = import_html(&html, &HtmlImportOptions::default());
if result.nodes.is_empty() {
let detail = result
.warnings
.first()
.map(String::as_str)
.unwrap_or("input produced no nodes");
return ToolOutcome::Err(
ToolErrorCode::InvalidArgument,
format!("no importable content: {detail}"),
);
}
let mut nodes = result.nodes;
if x != 0 || y != 0 {
if let PenNode::Frame(frame) = &mut nodes[0] {
frame.base.x = Some(x as f64);
frame.base.y = Some(y as f64);
}
}
let mut output = BTreeMap::new();
output.insert("wrote".into(), "true".into());
output.insert("nodeCount".into(), count_nodes(&nodes).to_string());
if !result.warnings.is_empty() {
output.insert("warnings".into(), result.warnings.join("\n"));
}
ToolOutcome::OkWithCommand(
output,
EditorCommand::InsertSubtree {
nodes,
parent_id: target_parent,
page_id,
},
)
}
}
fn count_nodes(nodes: &[PenNode]) -> usize {
nodes
.iter()
.map(|node| {
1 + match node {
PenNode::Frame(node) => node
.children
.as_deref()
.map(count_nodes)
.unwrap_or_default(),
PenNode::Group(node) => node
.children
.as_deref()
.map(count_nodes)
.unwrap_or_default(),
PenNode::Rectangle(node) => node
.children
.as_deref()
.map(count_nodes)
.unwrap_or_default(),
_ => 0,
}
})
.sum()
}
pub fn import_html_snapshot() -> ImportHtml {
ImportHtml
}

View file

@ -0,0 +1,31 @@
use std::collections::BTreeMap;
use super::import_html_tool::{import_html_snapshot, ImportHtml};
use super::{EditorCommand, McpTool, ToolOutcome};
#[test]
fn import_html_returns_insert_subtree_command() {
let tool: ImportHtml = import_html_snapshot();
let mut args = BTreeMap::new();
args.insert(
"html".to_string(),
"<div style=\"display:flex\"><p>hi</p></div>".to_string(),
);
args.insert("x".to_string(), "100".to_string());
let outcome = tool.call(&args);
let ToolOutcome::OkWithCommand(result, EditorCommand::InsertSubtree { nodes, page_id, .. }) =
outcome
else {
panic!("expected OkWithCommand(InsertSubtree)")
};
assert_eq!(result.get("wrote").map(String::as_str), Some("true"));
assert_eq!(nodes.len(), 1);
assert!(page_id.is_none());
}
#[test]
fn missing_html_is_typed_error() {
let tool = import_html_snapshot();
let outcome = tool.call(&BTreeMap::new());
assert!(matches!(outcome, ToolOutcome::Err(..)));
}

View file

@ -74,6 +74,9 @@ pub mod extra_read_tools;
#[cfg(test)]
mod extra_read_tools_tests;
pub mod guidelines_tools;
pub mod import_html_tool;
#[cfg(test)]
mod import_html_tool_tests;
mod insert_node_args;
mod insert_node_data;
pub mod json_serializer;

View file

@ -498,7 +498,10 @@ fn json_scalar_to_string(value: Option<&Value>) -> Option<String> {
/// Parse an optional i32 arg. `Ok(None)` when absent, `Ok(Some)` on a
/// successful parse, `Err` on present-but-malformed input.
fn parse_opt_i32(args: &BTreeMap<String, String>, key: &str) -> Result<Option<i32>, String> {
pub(crate) fn parse_opt_i32(
args: &BTreeMap<String, String>,
key: &str,
) -> Result<Option<i32>, String> {
match args.get(key) {
None => Ok(None),
Some(s) => s