diff --git a/crates/op-host-desktop/src/codegen_input.rs b/crates/op-host-desktop/src/codegen_input.rs new file mode 100644 index 000000000..4af02db53 --- /dev/null +++ b/crates/op-host-desktop/src/codegen_input.rs @@ -0,0 +1,229 @@ +//! Build a codegen-pipeline `CodegenInput` from the editor's current +//! selection. +//! +//! The AI codegen pipeline runs against the SELECTED node subtrees, not +//! the whole document. This module turns `EditorState`'s selection into a +//! `CodegenInput`: the selected subtrees serialized to canonical JSON, the +//! active framework, and the document's design variables (when present). +//! +//! Asset sanitization (replacing embedded data-URLs) happens later, inside +//! the pipeline — so the RAW selected-nodes JSON returned here is also kept +//! for the export bundle. At this stage the raw JSON equals `nodes_json`. + +use jian_ops_schema::node::PenNode; +use op_ai::chat_provider::{EffortLevel, ThinkingMode}; +use op_codegen::ai::types::CodegenInput; +use op_editor_core::state::EditorState; +use op_editor_core::walkers::find_node; + +/// Default per-request token cap for the pipeline input. The per-phase +/// prompt builders override this, so it is only a fallback default. +// Consumed by the host codegen session in a later P3 task. +#[allow(dead_code)] +const DEFAULT_MAX_OUTPUT_TOKENS: u32 = 4096; + +/// Build pipeline input from the current selection. Returns the input plus +/// the RAW selected-nodes JSON (before asset sanitization) for the bundle. +/// `None` when nothing is selected. +// Wired into the codegen session launch in a later P3 task. +#[allow(dead_code)] +pub fn build_codegen_input(state: &EditorState) -> Option<(CodegenInput, String)> { + if state.selection.is_empty() { + return None; + } + + // Resolve each selected id to its full subtree. The forest is the page + // children when the document is multi-page, else the root `children`. + // `find_node` recurses, so a selected nested node resolves too. + let mut selected: Vec<&PenNode> = Vec::with_capacity(state.selection.set.len()); + if let Some(pages) = state.doc.pages.as_ref() { + for id in &state.selection.set { + if let Some(node) = pages.iter().find_map(|page| find_node(&page.children, id)) { + selected.push(node); + } + } + } else { + for id in &state.selection.set { + if let Some(node) = find_node(&state.doc.children, id) { + selected.push(node); + } + } + } + + // A selection of ids that resolve to no live node yields no input. + if selected.is_empty() { + return None; + } + + Some(input_from_nodes( + &selected, + state.codegen.framework, + state.doc.variables.as_ref(), + )) +} + +/// Pure core: build a `CodegenInput` from already-resolved nodes, the target +/// framework and the document's optional variables. Separated from selection +/// resolution so it can be unit-tested with hand-built nodes. The second +/// tuple element is the RAW nodes JSON (identical to `input.nodes_json` here; +/// asset sanitization is a later, in-pipeline step). +// Reached only through `build_codegen_input`, itself wired in a later task. +#[allow(dead_code)] +fn input_from_nodes( + nodes: &[&PenNode], + framework: op_editor_core::codegen::Framework, + variables: Option< + &std::collections::BTreeMap, + >, +) -> (CodegenInput, String) { + let nodes_json = serde_json::to_string(nodes).unwrap_or_else(|_| "[]".to_string()); + let variables_json = variables + .filter(|vars| !vars.is_empty()) + .map(|vars| serde_json::to_string(vars).unwrap_or_else(|_| "{}".to_string())); + + let input = CodegenInput { + nodes_json: nodes_json.clone(), + framework, + variables_json, + max_output_tokens: DEFAULT_MAX_OUTPUT_TOKENS, + thinking: ThinkingMode::Adaptive, + effort: EffortLevel::Low, + }; + (input, nodes_json) +} + +#[cfg(test)] +mod tests { + use super::*; + use jian_ops_schema::node::{ContainerProps, FrameNode, PenNode, PenNodeBase, RectangleNode}; + use jian_ops_schema::sizing::SizingBehavior; + use jian_ops_schema::variable::{ + VariableDefinition, VariableKind, VariableScalar, VariableValue, + }; + use op_editor_core::EditorState; + use op_editor_core::NodeId; + + fn frame(id: &str, children: Vec) -> PenNode { + PenNode::Frame(FrameNode { + base: PenNodeBase { + id: id.to_string(), + name: Some(id.to_string()), + x: Some(0.0), + y: Some(0.0), + ..Default::default() + }, + container: ContainerProps { + width: Some(SizingBehavior::Number(100.0)), + height: Some(SizingBehavior::Number(100.0)), + ..Default::default() + }, + children: Some(children), + image_search_query: None, + reusable: None, + slot: None, + state: None, + bindings: None, + events: None, + lifecycle: None, + semantics: None, + gestures: None, + route: None, + }) + } + + fn rect(id: &str) -> PenNode { + PenNode::Rectangle(RectangleNode { + base: PenNodeBase { + id: id.to_string(), + name: Some(id.to_string()), + x: Some(10.0), + y: Some(10.0), + ..Default::default() + }, + container: ContainerProps { + width: Some(SizingBehavior::Number(40.0)), + height: Some(SizingBehavior::Number(40.0)), + ..Default::default() + }, + children: None, + state: None, + bindings: None, + events: None, + lifecycle: None, + semantics: None, + gestures: None, + route: None, + }) + } + + #[test] + fn build_from_single_selected_frame() { + let mut state = EditorState::new(); + state.doc.children = vec![frame("n1", vec![rect("n2")])]; + state.set_single_selection(NodeId::new("n1")); + + let (input, raw) = build_codegen_input(&state).expect("some"); + // The selected subtree (and its child) is serialized into the JSON. + assert!(input.nodes_json.contains("n1")); + assert!(input.nodes_json.contains("n2")); + assert!(raw.contains("n1")); + assert_eq!(input.framework, state.codegen.framework); + // Defaults. + assert_eq!(input.max_output_tokens, DEFAULT_MAX_OUTPUT_TOKENS); + assert_eq!(input.thinking, ThinkingMode::Adaptive); + assert_eq!(input.effort, EffortLevel::Low); + // No variables on this doc. + assert!(input.variables_json.is_none()); + // Raw equals the serialized nodes JSON at this stage. + assert_eq!(raw, input.nodes_json); + } + + #[test] + fn empty_selection_is_none() { + let state = EditorState::new(); + assert!(build_codegen_input(&state).is_none()); + } + + #[test] + fn unresolvable_selection_is_none() { + let mut state = EditorState::new(); + state.doc.children = vec![frame("n1", vec![])]; + // Select an id that no longer exists in the tree. + state.set_single_selection(NodeId::new("ghost")); + assert!(build_codegen_input(&state).is_none()); + } + + #[test] + fn variables_are_serialized_when_present() { + let mut state = EditorState::new(); + state.doc.children = vec![frame("n1", vec![])]; + state + .doc + .variables + .get_or_insert_with(Default::default) + .insert( + "color-1".to_string(), + VariableDefinition { + kind: VariableKind::Color, + value: VariableValue::Scalar(VariableScalar::Str("#ff0000".to_string())), + }, + ); + state.set_single_selection(NodeId::new("n1")); + + let (input, _raw) = build_codegen_input(&state).expect("some"); + let vars_json = input.variables_json.expect("variables"); + assert!(vars_json.contains("color-1")); + } + + #[test] + fn multi_selection_serializes_each_subtree() { + let mut state = EditorState::new(); + state.doc.children = vec![frame("n1", vec![]), frame("n3", vec![])]; + state.selection.set = vec![NodeId::new("n1"), NodeId::new("n3")]; + state.selection.anchor = NodeId::new("n3"); + + let (input, _raw) = build_codegen_input(&state).expect("some"); + assert!(input.nodes_json.contains("n1")); + assert!(input.nodes_json.contains("n3")); + } +} diff --git a/crates/op-host-desktop/src/main.rs b/crates/op-host-desktop/src/main.rs index 2a28c8d07..a0f42310b 100644 --- a/crates/op-host-desktop/src/main.rs +++ b/crates/op-host-desktop/src/main.rs @@ -15,6 +15,7 @@ mod chat_runtime; mod chat_session; mod chat_subprocess; mod clipboard; +mod codegen_input; mod codegen_session; mod commit_diff_host; mod commit_diff_semantic;