feat(mcp): import_html tool inserting op-html parsed subtrees
This commit is contained in:
parent
97f1cf6b44
commit
21d90a2f25
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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"}}}}"#,
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
134
crates/op-mcp/src/import_html_tool.rs
Normal file
134
crates/op-mcp/src/import_html_tool.rs
Normal 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
|
||||
}
|
||||
31
crates/op-mcp/src/import_html_tool_tests.rs
Normal file
31
crates/op-mcp/src/import_html_tool_tests.rs
Normal 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(..)));
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue