diff --git a/README.md b/README.md index afd418ea0a8..56aebe02f14 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,21 @@ If you come across any of these in search results, please report them as Supports a broad range of printers: Bambu Lab, Prusa, Creality, Voron, and more. - Additional features can be found in the [change notes](https://github.com/OrcaSlicer/OrcaSlicer/releases/). +# AI Slice Assistant (Experimental) + +AI Slice Assistant can suggest slicer setting changes from the current project context. +It does **not** provide general-purpose chat, direct printer control, or guaranteed print outcomes. + +- Quick start: [docs/ai/QUICKSTART.md](docs/ai/QUICKSTART.md) +- Safety model: [docs/ai/SAFETY.md](docs/ai/SAFETY.md) +- Debug bundle guide: [docs/ai/DEBUG_BUNDLE.md](docs/ai/DEBUG_BUNDLE.md) + +## Screenshots (placeholders) + +- `docs/ai/assets/ai_panel_placeholder.png` (AI panel) +- `docs/ai/assets/ai_preferences_placeholder.png` (AI provider settings) +- `docs/ai/assets/ai_diff_placeholder.png` (recommended changes and apply/undo) + # Wiki The [wiki](https://www.orcaslicer.com/wiki) aims to provide a detailed explanation of the slicer settings, including how to maximize their use and how to calibrate and set up your printer. diff --git a/docs/ai/DEBUG_BUNDLE.md b/docs/ai/DEBUG_BUNDLE.md new file mode 100644 index 00000000000..b8adcd2f022 --- /dev/null +++ b/docs/ai/DEBUG_BUNDLE.md @@ -0,0 +1,42 @@ +# AI Debug Bundle + +## Purpose + +The **Export Debug Bundle** button creates a JSON file to help diagnose AI Slice Assistant issues. + +## How To Export + +1. Open **AI Slice Assistant** panel. +2. Reproduce the issue (or run the scenario you want to report). +3. Click **Export Debug Bundle**. +4. Choose a file location in the save dialog. + +## What Is Included + +The exported JSON includes: + +- `last_context_snapshot_json` +- `last_geometry_insights_json` +- `last_ai_response_json` +- `validation_errors` +- `app` metadata (`name`, `version`, `platform`) + +## How To Share For Bug Reports + +1. Open an issue in the OrcaSlicer repository. +2. Describe: +- What you expected. +- What happened. +- Exact steps to reproduce. +3. Attach the exported debug bundle JSON. +4. Include whether you used `Fake` or `OpenAI-compatible` provider. + +## Privacy And Safety Notes + +- Review the JSON before sharing. +- Remove any sensitive information you do not want to publish. +- API keys should not be required for debugging and should never be shared in issue comments. + +## Disclaimer + +AI suggestions are not guaranteed. Always verify recommendations before applying them to real prints. diff --git a/docs/ai/QUICKSTART.md b/docs/ai/QUICKSTART.md new file mode 100644 index 00000000000..e7af0086314 --- /dev/null +++ b/docs/ai/QUICKSTART.md @@ -0,0 +1,56 @@ +# AI Slice Assistant Quickstart + +## What It Does + +AI Slice Assistant helps you review proposed **slicer/print setting** changes for the current project. +It builds a context snapshot, sends it to the selected provider, validates the JSON response, and shows a diff you can apply or undo. + +## What It Does Not Do + +- No general-purpose chat assistant. +- No direct printer control or machine actions. +- No guarantee that suggestions are correct for your printer/material/part. + +## Enable The Panel + +1. Open OrcaSlicer. +2. Use the **View** menu and enable **AI Slice Assistant**. + +## Configure Provider + +1. Open **Preferences**. +2. Go to **Online > AI**. +3. Select provider: +- `Fake` for deterministic offline testing. +- `OpenAI-compatible` for HTTP provider testing. +4. If using OpenAI-compatible, set: +- Base URL +- API key +- Model +- Timeout / Max tokens / Temperature +5. Click **Test** to verify connection. + +## First Test (Recommended) + +1. In the AI panel, click **Copy Context**. +2. Enter a focused prompt, for example: +- `Improve bridging quality while keeping print time reasonable.` +3. Click **Send**. +4. Check status in chat: +- `Valid`, `Repaired`, or `Rejected`. +5. Review `recommended_changes` and details. +6. Optionally click **Apply Selected**. +7. If needed, click **Undo Last Apply**. + +## Safe Mode + +Safe Mode is enabled by default in **Preferences > Online > AI**. +When enabled, it limits risk by: + +- Capping recommendations to 10. +- Blocking high-risk keys. +- Requiring user confirmation for temperature/flow/speed-related changes. + +## Important Disclaimer + +AI suggestions are not guaranteed. Always verify recommendations before applying them to real prints. diff --git a/docs/ai/RELEASING_MAC_BETA.md b/docs/ai/RELEASING_MAC_BETA.md new file mode 100644 index 00000000000..6753fc237b2 --- /dev/null +++ b/docs/ai/RELEASING_MAC_BETA.md @@ -0,0 +1,62 @@ +# Releasing macOS Beta (AI Branch) + +Ce guide explique comment construire et empaqueter une beta macOS de OrcaSlicer-AI. + +## Prerequis + +- macOS avec Xcode/Command Line Tools installes. +- Dependances de build deja configurees pour OrcaSlicer. +- Depot a jour et propre (`git status`). + +## Build + Package (script unique) + +Depuis la racine du repo: + +```bash +./scripts/release/macos_beta_package.sh +``` + +Le script effectue: + +1. `./build_release_macos.sh` +2. Detection de `OrcaSlicer.app` genere +3. Generation de `BUILD_INFO.txt` (hash git, date, macOS, architecture) +4. Creation d'une archive zip versionnee: + - Exemple: `OrcaSlicer-AI-beta-arm64-YYYYMMDD-.zip` + +Sortie attendue: + +- `build/release/OrcaSlicer-AI-beta-*.zip` + +## Validation rapide avant publication + +- Ouvrir l'app locale et verifier l'ecran principal. +- Verifier que le panneau AI est present. +- Verifier que `BUILD_INFO.txt` est bien inclus dans le zip. + +## Publication beta (exemple) + +1. Uploader le zip comme asset d'une pre-release GitHub. +2. Ajouter les points de test attendus dans les release notes. +3. Inclure commit hash et date de build. + +## Optionnel: Code signing / Notarization (placeholders) + +Selon votre process de release, vous pouvez ajouter: + +- Signature de `OrcaSlicer.app` avec certificat Developer ID. +- Notarization Apple du paquet. +- Stapling du ticket notarization. + +Placeholder commandes: + +```bash +# codesign placeholder +codesign --deep --force --verify --verbose --sign "Developer ID Application: " build/.../OrcaSlicer.app + +# notarization placeholder +xcrun notarytool submit --keychain-profile "" --wait + +# staple placeholder +xcrun stapler staple build/.../OrcaSlicer.app +``` diff --git a/docs/ai/SAFETY.md b/docs/ai/SAFETY.md new file mode 100644 index 00000000000..e961503de73 --- /dev/null +++ b/docs/ai/SAFETY.md @@ -0,0 +1,51 @@ +# AI Slice Assistant Safety + +## Scope And Guardrails + +AI Slice Assistant is designed for **slicer settings assistance only**. +It is not a general chatbot and should not be used for non-slicing topics. + +Core safety boundaries: + +- JSON contract responses only. +- Contract/schema validation before UI use. +- Business-rule checks (version, limits, value ranges). +- Allowlist-based setting filtering and value validation. +- Apply pipeline is controlled and reversible with undo. + +## Safe Mode (Default ON) + +Safe Mode is enabled by default and adds stricter runtime limits: + +- Maximum of 10 recommended changes shown/applied. +- High-risk keys are blocked. +- Temperature/flow/speed related changes are marked as requiring user confirmation. + +## Validation And Repair + +Before recommendations are accepted: + +1. JSON parsing must succeed. +2. Schema validation must pass. +3. Business rules must pass. + +If validation fails, a repair request is sent with validation errors, and retries are limited. +If still invalid, output is rejected. + +## Apply Safety + +When applying recommendations: + +- Only allowlisted keys are eligible. +- Values must match expected type and bounds. +- Apply is atomic (all-or-nothing). +- Original values are captured for undo. + +## Operator Responsibility + +AI suggestions can be wrong or suboptimal for your setup. +You are responsible for reviewing and validating each proposed change before printing. + +## Disclaimer + +AI suggestions are not guaranteed. Always verify recommendations before applying them to real prints. diff --git a/scripts/release/macos_beta_package.sh b/scripts/release/macos_beta_package.sh new file mode 100755 index 00000000000..c36bfa49761 --- /dev/null +++ b/scripts/release/macos_beta_package.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "Erreur: ce script doit etre execute sur macOS." >&2 + exit 1 +fi + +cd "${REPO_ROOT}" + +echo "[1/4] Build macOS..." +./build_release_macos.sh + +echo "[2/4] Recherche de OrcaSlicer.app..." +app_path="" +for candidate in \ + "${REPO_ROOT}/build/arm64/src/Release/OrcaSlicer.app" \ + "${REPO_ROOT}/build/x86_64/src/Release/OrcaSlicer.app"; do + if [[ -d "${candidate}" ]]; then + app_path="${candidate}" + break + fi +done + +if [[ -z "${app_path}" ]]; then + app_path="$(find "${REPO_ROOT}/build" -type d -path '*/src/Release/OrcaSlicer.app' | head -n 1 || true)" +fi + +if [[ -z "${app_path}" || ! -d "${app_path}" ]]; then + echo "Erreur: OrcaSlicer.app introuvable apres build." >&2 + exit 1 +fi + +arch="$(uname -m)" +if [[ "${app_path}" == *"/build/arm64/"* ]]; then + arch="arm64" +elif [[ "${app_path}" == *"/build/x86_64/"* ]]; then + arch="x86_64" +fi + +git_hash="$(git rev-parse HEAD)" +build_date_utc="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" +date_tag="$(date -u '+%Y%m%d')" +macos_version="$(sw_vers -productVersion)" + +artifacts_dir="${REPO_ROOT}/build/release" +mkdir -p "${artifacts_dir}" + +stage_dir="$(mktemp -d "${TMPDIR:-/tmp}/orcaslicer-beta.XXXXXX")" +cleanup() { + rm -rf "${stage_dir}" +} +trap cleanup EXIT + +echo "[3/4] Preparation du package..." +cp -R "${app_path}" "${stage_dir}/OrcaSlicer.app" + +cat > "${stage_dir}/BUILD_INFO.txt" < +#include +#include +#include +#include +#include + +namespace Slic3r { +namespace AI { +namespace Apply { + +namespace { + +std::vector derive_tags_for_key(const std::string& key) +{ + std::vector tags; + auto add_tag = [&tags](const std::string& tag) { + if (std::find(tags.begin(), tags.end(), tag) == tags.end()) + tags.push_back(tag); + }; + + if (key.find("temperature") != std::string::npos) { + add_tag("temperature"); + add_tag("high-risk"); + } + + if (key.find("speed") != std::string::npos) + add_tag("speed"); + + if (key.find("flow") != std::string::npos) + add_tag("flow"); + + static const std::array high_risk_flow_keys = { + "flow_ratio", + "pressure_advance", + "volumetric_flow_limit", + "max_volumetric_speed", + "bridge_flow_ratio" + }; + if (std::any_of(high_risk_flow_keys.begin(), high_risk_flow_keys.end(), [&key](const char* item) { return key == item; })) { + add_tag("flow"); + add_tag("high-risk"); + } + + return tags; +} + +AllowlistEntry bool_entry(std::string key, std::string label, std::string notes) +{ + AllowlistEntry entry; + entry.key = std::move(key); + entry.type = AllowlistValueType::Bool; + entry.label = std::move(label); + entry.safety_notes = std::move(notes); + entry.tags = derive_tags_for_key(entry.key); + entry.enum_values = {"false", "true"}; + return entry; +} + +AllowlistEntry int_entry(std::string key, int min_value, int max_value, std::string label, std::string notes) +{ + AllowlistEntry entry; + entry.key = std::move(key); + entry.type = AllowlistValueType::Int; + entry.label = std::move(label); + entry.safety_notes = std::move(notes); + entry.tags = derive_tags_for_key(entry.key); + entry.has_numeric_bounds = true; + entry.min_value = static_cast(min_value); + entry.max_value = static_cast(max_value); + return entry; +} + +AllowlistEntry float_entry(std::string key, double min_value, double max_value, std::string label, std::string notes) +{ + AllowlistEntry entry; + entry.key = std::move(key); + entry.type = AllowlistValueType::Float; + entry.label = std::move(label); + entry.safety_notes = std::move(notes); + entry.tags = derive_tags_for_key(entry.key); + entry.has_numeric_bounds = true; + entry.min_value = min_value; + entry.max_value = max_value; + return entry; +} + +AllowlistEntry string_entry(std::string key, size_t min_length, size_t max_length, std::string label, std::string notes) +{ + AllowlistEntry entry; + entry.key = std::move(key); + entry.type = AllowlistValueType::String; + entry.label = std::move(label); + entry.safety_notes = std::move(notes); + entry.tags = derive_tags_for_key(entry.key); + entry.has_length_bounds = true; + entry.min_length = min_length; + entry.max_length = max_length; + return entry; +} + +AllowlistEntry enum_entry(std::string key, std::vector enum_values, std::string label, std::string notes) +{ + AllowlistEntry entry; + entry.key = std::move(key); + entry.type = AllowlistValueType::Enum; + entry.label = std::move(label); + entry.safety_notes = std::move(notes); + entry.tags = derive_tags_for_key(entry.key); + entry.enum_values = std::move(enum_values); + return entry; +} + +const std::unordered_map& registry() +{ + static const std::unordered_map entries = [] { + const std::vector list = { + float_entry("layer_height", 0.04, 0.40, "Layer Height", "Lower values improve quality but may increase print time sharply."), + float_entry("first_layer_height", 0.04, 0.50, "First Layer Height", "Too high may reduce bed adhesion consistency."), + float_entry("line_width", 0.20, 1.20, "Line Width", "Extreme widths can break extrusion assumptions."), + float_entry("first_layer_line_width", 0.20, 1.50, "First Layer Line Width", "High values may cause elephant foot and over-extrusion."), + int_entry("wall_loops", 1, 12, "Wall Loops", "More walls improve strength but can trap heat and increase time."), + int_entry("top_shell_layers", 0, 20, "Top Shell Layers", "Too few layers can expose infill on top surfaces."), + int_entry("bottom_shell_layers", 0, 20, "Bottom Shell Layers", "Too few layers can weaken base integrity."), + float_entry("top_shell_thickness", 0.0, 5.0, "Top Shell Thickness", "Large values increase time and material use."), + float_entry("bottom_shell_thickness", 0.0, 5.0, "Bottom Shell Thickness", "Large values increase weight and duration."), + enum_entry("seam_position", {"nearest", "aligned", "random", "rear"}, "Seam Position", "Seam placement affects surface aesthetics."), + bool_entry("external_perimeters_first", "External Perimeters First", "May improve finish but can reduce support from inner walls."), + bool_entry("detect_thin_wall", "Detect Thin Wall", "May introduce narrow toolpaths that are harder to print reliably."), + bool_entry("ensure_vertical_shell_thickness", "Ensure Vertical Shell Thickness", "Can change local wall strategy and print time."), + enum_entry("ironing_type", {"no_ironing", "top_surfaces", "all_top_surfaces"}, "Ironing Type", "Ironing can overheat top surfaces on some materials."), + float_entry("ironing_spacing", 0.05, 1.00, "Ironing Spacing", "Very tight spacing can overcook top layers."), + float_entry("ironing_flow", 1.0, 100.0, "Ironing Flow", "Too much flow causes blobs and gloss artifacts."), + float_entry("ironing_speed", 1.0, 300.0, "Ironing Speed", "Very slow ironing can increase heat accumulation."), + enum_entry("fuzzy_skin", {"none", "external", "all"}, "Fuzzy Skin", "Useful for texture but may break dimensional accuracy."), + float_entry("fuzzy_skin_thickness", 0.0, 0.5, "Fuzzy Skin Thickness", "Large offsets can cause self-overlap."), + float_entry("fuzzy_skin_point_dist", 0.05, 1.0, "Fuzzy Skin Point Distance", "Very low distance can bloat path complexity."), + bool_entry("arc_fitting", "Arc Fitting", "May reduce file size but can alter motion on unsupported firmware."), + float_entry("resolution", 0.005, 0.2, "Resolution", "Very low values increase toolpath complexity and file size."), + float_entry("sparse_infill_density", 0.0, 100.0, "Sparse Infill Density", "High density can dramatically increase material/time."), + enum_entry("sparse_infill_pattern", {"grid", "gyroid", "cubic", "line", "triangles"}, "Sparse Infill Pattern", "Pattern changes affect strength and resonance."), + enum_entry("internal_solid_infill_pattern", {"rectilinear", "grid", "concentric"}, "Internal Solid Infill Pattern", "Pattern choice affects top-layer support quality."), + bool_entry("infill_combination", "Infill Combination", "Can reduce path count but alter local infill response."), + bool_entry("infill_anchor", "Infill Anchor", "Anchors may improve bonding at cost of extra travel."), + float_entry("infill_anchor_max", 0.0, 100.0, "Infill Anchor Max", "Very large anchors can over-constrain path planning."), + int_entry("solid_infill_every_layers", 0, 20, "Solid Infill Every N Layers", "High frequency increases rigidity and print time."), + int_entry("fill_angle", 0, 359, "Fill Angle", "Angle changes can impact anisotropic strength."), + int_entry("bridge_angle", 0, 359, "Bridge Angle", "Wrong angle can worsen unsupported spans."), + bool_entry("detect_overhang_wall", "Detect Overhang Wall", "May alter perimeters for steep regions."), + float_entry("bridge_flow_ratio", 0.5, 2.0, "Bridge Flow Ratio", "Excessive flow can sag and fuse strands."), + float_entry("bridge_speed", 5.0, 300.0, "Bridge Speed", "Bridge speed has direct impact on sagging risk."), + float_entry("bridge_internal_speed", 5.0, 300.0, "Bridge Internal Speed", "Internal bridge speed can alter support for top layers."), + bool_entry("support_enable", "Enable Support", "Global support changes can massively affect material use."), + enum_entry("support_style", {"normal", "snug", "tree"}, "Support Style", "Tree supports may conflict with tight clearances."), + enum_entry("support_type", {"normal", "tree"}, "Support Type", "Type influences contact and removal behavior."), + int_entry("support_threshold_angle", 0, 90, "Support Threshold Angle", "Lower thresholds generate more supports."), + bool_entry("support_on_build_plate_only", "Support On Build Plate Only", "May leave upper overhangs unsupported."), + bool_entry("support_critical_regions_only", "Support Critical Regions Only", "Can miss secondary overhangs."), + enum_entry("support_base_pattern", {"rectilinear", "grid", "honeycomb"}, "Support Base Pattern", "Pattern changes removal force and stability."), + enum_entry("support_interface_pattern", {"rectilinear", "concentric"}, "Support Interface Pattern", "Interface affects underside finish."), + int_entry("support_interface_layers", 0, 10, "Support Interface Layers", "Too many layers can fuse to model."), + float_entry("support_interface_spacing", 0.0, 1.0, "Support Interface Spacing", "Spacing controls density and removability."), + float_entry("support_object_xy_distance", 0.0, 5.0, "Support XY Distance", "Too small can weld support to part."), + float_entry("support_top_z_distance", 0.0, 2.0, "Support Top Z Distance", "Too large can degrade overhang quality."), + float_entry("support_bottom_z_distance", 0.0, 2.0, "Support Bottom Z Distance", "Too small can reduce removability."), + int_entry("support_angle", 0, 359, "Support Angle", "Angle affects support anisotropy."), + float_entry("support_line_width", 0.20, 1.20, "Support Line Width", "Wide lines can overbuild support mass."), + bool_entry("support_material_synchronize_layers", "Synchronize Support Layers", "Disabling may create timing mismatches in layer strategy."), + int_entry("raft_layers", 0, 10, "Raft Layers", "Rafts increase material use and can warp dimensions."), + enum_entry("brim_type", {"no_brim", "outer", "inner", "outer_and_inner"}, "Brim Type", "Brim can improve adhesion but affect removal."), + float_entry("brim_width", 0.0, 20.0, "Brim Width", "Large brims consume area and can fuse to part base."), + float_entry("brim_object_gap", 0.0, 5.0, "Brim Object Gap", "Small gaps may make brim hard to remove."), + int_entry("skirt_loops", 0, 20, "Skirt Loops", "High loop count wastes time/material."), + float_entry("skirt_distance", 0.0, 20.0, "Skirt Distance", "Distance impacts priming effectiveness."), + enum_entry("draft_shield", {"disabled", "enabled", "full"}, "Draft Shield", "Draft shields can trap heat around the print."), + bool_entry("ooze_prevention", "Ooze Prevention", "May increase travel and idle moves."), + float_entry("outer_wall_speed", 1.0, 500.0, "Outer Wall Speed", "High speed can reduce surface quality."), + float_entry("inner_wall_speed", 1.0, 500.0, "Inner Wall Speed", "High speed can reduce bonding consistency."), + float_entry("sparse_infill_speed", 1.0, 500.0, "Sparse Infill Speed", "Extreme speed can under-extrude sparse regions."), + float_entry("internal_solid_infill_speed", 1.0, 500.0, "Internal Solid Infill Speed", "Over-speeding can degrade top support."), + float_entry("top_surface_speed", 1.0, 300.0, "Top Surface Speed", "Top surfaces are sensitive to speed artifacts."), + float_entry("initial_layer_speed", 1.0, 200.0, "Initial Layer Speed", "Too high can hurt bed adhesion."), + float_entry("initial_layer_infill_speed", 1.0, 200.0, "Initial Layer Infill Speed", "Initial infill speed influences first-layer bonding."), + float_entry("travel_speed", 1.0, 1000.0, "Travel Speed", "Very high travel can induce skipped steps."), + bool_entry("slow_down_for_layer_cooling", "Slow Down For Cooling", "Disabling may overheat small layers."), + float_entry("min_print_speed", 1.0, 100.0, "Minimum Print Speed", "Too high minimum speed can defeat cooling safeguards."), + float_entry("max_volumetric_speed", 0.5, 80.0, "Max Volumetric Speed", "Over-limit values can exceed melt capacity."), + bool_entry("acceleration_enabled", "Enable Acceleration Control", "Acceleration overrides may conflict with firmware tuning."), + int_entry("default_acceleration", 100, 30000, "Default Acceleration", "High acceleration increases ringing risk."), + int_entry("outer_wall_acceleration", 100, 30000, "Outer Wall Acceleration", "Too high harms cosmetic surfaces."), + int_entry("inner_wall_acceleration", 100, 30000, "Inner Wall Acceleration", "High values can destabilize tall parts."), + int_entry("travel_acceleration", 100, 30000, "Travel Acceleration", "Aggressive travel acceleration can shift the print."), + int_entry("bridge_acceleration", 100, 30000, "Bridge Acceleration", "Bridge quality is sensitive to jerk/accel."), + bool_entry("jerk_enabled", "Enable Jerk Control", "Jerk overrides can cause vibration on weak frames."), + float_entry("jerk_print", 1.0, 50.0, "Print Jerk", "High print jerk can scar walls."), + float_entry("jerk_travel", 1.0, 50.0, "Travel Jerk", "High travel jerk can shake the model."), + float_entry("retraction_length", 0.0, 20.0, "Retraction Length", "Excessive retraction can grind filament."), + float_entry("retraction_speed", 1.0, 200.0, "Retraction Speed", "Too high speed can strip filament."), + float_entry("deretraction_speed", 1.0, 200.0, "Deretraction Speed", "Too high speed can cause pressure spikes."), + float_entry("retract_before_travel", 0.0, 20.0, "Retract Before Travel", "Low thresholds can increase stringing."), + float_entry("retract_lift_z", 0.0, 5.0, "Z-Hop Height", "Large Z-hop values add print time and artifacts."), + float_entry("retract_lift_above", 0.0, 400.0, "Z-Hop Lift Above", "Incorrect threshold may disable intended hops."), + float_entry("retract_lift_below", 0.0, 400.0, "Z-Hop Lift Below", "Incorrect threshold may over-apply hops."), + float_entry("retract_restart_extra", -2.0, 2.0, "Retraction Restart Extra", "Offsets can over/under-extrude seam starts."), + bool_entry("wipe", "Wipe While Retracting", "Wipe can reduce ooze but adds path complexity."), + float_entry("wipe_distance", 0.0, 20.0, "Wipe Distance", "Long wipe distances increase non-productive motion."), + bool_entry("avoid_crossing_wall", "Avoid Crossing Wall", "Can significantly alter travel planning."), + bool_entry("avoid_crossing_curled_overhangs", "Avoid Curled Overhangs", "Safety travel mode may increase route length."), + enum_entry("z_hop_types", {"normal", "spiral", "smart"}, "Z-Hop Type", "Advanced modes may be firmware-sensitive."), + float_entry("seam_gap", 0.0, 1.0, "Seam Gap", "Large seam gap can weaken perimeter continuity."), + int_entry("nozzle_temperature", 150, 320, "Nozzle Temperature", "Out-of-range temperature can damage material or hotend."), + int_entry("nozzle_temperature_initial_layer", 150, 340, "Initial Layer Nozzle Temperature", "Excessive first-layer heat can cause elephant foot."), + int_entry("bed_temperature", 0, 130, "Bed Temperature", "Too high bed temperatures can deform parts."), + int_entry("bed_temperature_initial_layer", 0, 130, "Initial Layer Bed Temperature", "Initial bed heat strongly impacts adhesion."), + bool_entry("fan_always_on", "Fan Always On", "Always-on fan can weaken layer bonding on some materials."), + bool_entry("cooling", "Enable Cooling", "Disabling cooling can overheat bridges and details."), + int_entry("min_fan_speed", 0, 100, "Minimum Fan Speed", "Very high minimum fan may hurt interlayer strength."), + int_entry("max_fan_speed", 0, 100, "Maximum Fan Speed", "Excessive fan can reduce adhesion."), + int_entry("bridge_fan_speed", 0, 100, "Bridge Fan Speed", "Bridge cooling is material-dependent."), + int_entry("disable_fan_first_layers", 0, 20, "Disable Fan For First Layers", "Too many layers without fan can soften geometry."), + int_entry("overhang_fan_speed", 0, 100, "Overhang Fan Speed", "Aggressive cooling can induce warping."), + int_entry("chamber_temperature", 0, 90, "Chamber Temperature", "High chamber setpoints can exceed material limits."), + enum_entry("filament_type", {"pla", "petg", "abs", "asa", "pc", "pa", "tpu", "support"}, "Filament Type", "Type mismatch can invalidate thermal assumptions."), + float_entry("filament_diameter", 1.0, 3.0, "Filament Diameter", "Incorrect diameter causes systematic flow errors."), + float_entry("flow_ratio", 0.5, 1.5, "Flow Ratio", "Large deviations can cause weak or overfilled parts."), + float_entry("pressure_advance", 0.0, 1.0, "Pressure Advance", "Requires firmware support and careful tuning."), + float_entry("volumetric_flow_limit", 0.0, 100.0, "Volumetric Flow Limit", "Unrealistic limits can cause severe under/over extrusion."), + enum_entry("gcode_flavor", {"marlin", "klipper", "reprap", "smoothie"}, "G-code Flavor", "Must match printer firmware for safe motion."), + bool_entry("spiral_vase", "Spiral Vase", "Single-wall continuous mode is not suitable for all parts."), + bool_entry("complete_objects", "Complete Objects", "Sequential printing risks collisions on tall models."), + enum_entry("print_sequence", {"by_layer", "by_object"}, "Print Sequence", "By-object mode needs strict clearance checks."), + bool_entry("only_retract_when_crossing_perimeters", "Only Retract When Crossing Perimeters", "Can reduce retractions but increase stringing inside infill."), + string_entry("printer_notes", 0, 256, "Printer Notes", "Free text only; avoid injecting control sequences."), + string_entry("filament_notes", 0, 256, "Filament Notes", "Free text metadata, not for executable content.") + }; + + std::unordered_map map; + map.reserve(list.size()); + for (const AllowlistEntry& entry : list) + map.emplace(entry.key, entry); + return map; + }(); + + return entries; +} + +} // namespace + +bool AllowlistRegistry::is_allowed(const std::string& key) +{ + return registry().find(key) != registry().end(); +} + +ValidationResult AllowlistRegistry::validate_value(const std::string& key, const nlohmann::json& json_value) +{ + ValidationResult result; + + const auto it = registry().find(key); + if (it == registry().end()) { + result.error_message = "key is not allowlisted"; + return result; + } + + const AllowlistEntry& entry = it->second; + switch (entry.type) { + case AllowlistValueType::Bool: + if (!json_value.is_boolean()) { + result.error_message = "expected bool"; + return result; + } + break; + case AllowlistValueType::Int: { + if (!json_value.is_number_integer()) { + result.error_message = "expected int"; + return result; + } + if (!entry.has_numeric_bounds) + break; + const int value = json_value.get(); + if (static_cast(value) < entry.min_value || static_cast(value) > entry.max_value) { + result.error_message = "int out of bounds"; + return result; + } + break; + } + case AllowlistValueType::Float: { + if (!json_value.is_number()) { + result.error_message = "expected float"; + return result; + } + if (!entry.has_numeric_bounds) + break; + const double value = json_value.get(); + if (value < entry.min_value || value > entry.max_value) { + result.error_message = "float out of bounds"; + return result; + } + break; + } + case AllowlistValueType::String: { + if (!json_value.is_string()) { + result.error_message = "expected string"; + return result; + } + if (!entry.has_length_bounds) + break; + const auto& value = json_value.get_ref(); + if (value.size() < entry.min_length || value.size() > entry.max_length) { + result.error_message = "string length out of bounds"; + return result; + } + break; + } + case AllowlistValueType::Enum: { + if (!json_value.is_string()) { + result.error_message = "expected enum(string)"; + return result; + } + const auto& value = json_value.get_ref(); + if (std::find(entry.enum_values.begin(), entry.enum_values.end(), value) == entry.enum_values.end()) { + result.error_message = "enum value not allowed"; + return result; + } + break; + } + } + + result.valid = true; + return result; +} + +std::string AllowlistRegistry::label_for(const std::string& key) +{ + const auto it = registry().find(key); + return it == registry().end() ? std::string() : it->second.label; +} + +std::string AllowlistRegistry::safety_notes_for(const std::string& key) +{ + const auto it = registry().find(key); + return it == registry().end() ? std::string() : it->second.safety_notes; +} + +bool AllowlistRegistry::has_tag(const std::string& key, const std::string& tag) +{ + const auto it = registry().find(key); + if (it == registry().end()) + return false; + const auto& tags = it->second.tags; + return std::find(tags.begin(), tags.end(), tag) != tags.end(); +} + +bool AllowlistRegistry::has_any_tag(const std::string& key, std::initializer_list tags) +{ + const auto it = registry().find(key); + if (it == registry().end()) + return false; + for (const std::string& tag : tags) { + if (std::find(it->second.tags.begin(), it->second.tags.end(), tag) != it->second.tags.end()) + return true; + } + return false; +} + +size_t AllowlistRegistry::key_count() +{ + return registry().size(); +} + +} // namespace Apply +} // namespace AI +} // namespace Slic3r diff --git a/src/ai/apply/allowlist_registry.h b/src/ai/apply/allowlist_registry.h new file mode 100644 index 00000000000..72eb4bf0782 --- /dev/null +++ b/src/ai/apply/allowlist_registry.h @@ -0,0 +1,62 @@ +#ifndef slic3r_ai_apply_allowlist_registry_h_ +#define slic3r_ai_apply_allowlist_registry_h_ + +#include +#include +#include +#include + +#include "nlohmann/json.hpp" + +namespace Slic3r { +namespace AI { +namespace Apply { + +enum class AllowlistValueType +{ + Bool, + Int, + Float, + String, + Enum +}; + +struct AllowlistEntry +{ + std::string key; + AllowlistValueType type { AllowlistValueType::String }; + std::string label; + std::string safety_notes; + std::vector tags; + bool has_numeric_bounds { false }; + double min_value { 0.0 }; + double max_value { 0.0 }; + bool has_length_bounds { false }; + size_t min_length { 0 }; + size_t max_length { 0 }; + std::vector enum_values; +}; + +struct ValidationResult +{ + bool valid { false }; + std::string error_message; +}; + +class AllowlistRegistry +{ +public: + static bool is_allowed(const std::string& key); + static ValidationResult validate_value(const std::string& key, const nlohmann::json& json_value); + static std::string label_for(const std::string& key); + static std::string safety_notes_for(const std::string& key); + static bool has_tag(const std::string& key, const std::string& tag); + static bool has_any_tag(const std::string& key, std::initializer_list tags); + static size_t key_count(); +}; + +} // namespace Apply +} // namespace AI +} // namespace Slic3r + +#endif // slic3r_ai_apply_allowlist_registry_h_ diff --git a/src/ai/context_snapshot.cpp b/src/ai/context_snapshot.cpp new file mode 100644 index 00000000000..5af35cf6b9e --- /dev/null +++ b/src/ai/context_snapshot.cpp @@ -0,0 +1,382 @@ +#include "context_snapshot.h" + +#include +#include +#include +#include +#include + +#include "nlohmann/json.hpp" + +#include "libslic3r/Config.hpp" +#include "libslic3r/PresetBundle.hpp" +#include "libslic3r/libslic3r.h" +#include "slic3r/GUI/GUI_App.hpp" +#include "slic3r/GUI/Plater.hpp" + +namespace Slic3r { +namespace AI { + +namespace { + +using nlohmann::json; + +std::string platform_name() +{ +#ifdef __APPLE__ + return "macOS"; +#elif defined(_WIN32) + return "Windows"; +#elif defined(__linux__) + return "Linux"; +#else + return "Unknown"; +#endif +} + +json vec2_to_json(const Vec2d& v) +{ + return json{{"x", v.x()}, {"y", v.y()}}; +} + +json vec3_to_json(const Vec3d& v) +{ + return json{{"x", v.x()}, {"y", v.y()}, {"z", v.z()}}; +} + +std::string enum_value_to_string(const t_config_enum_values* keys_map, int value) +{ + if (keys_map == nullptr) + return {}; + for (const auto& kv : *keys_map) { + if (kv.second == value) + return kv.first; + } + return {}; +} + +json float_or_percent_to_json(const FloatOrPercent& value) +{ + return json{{"value", value.value}, {"percent", value.percent}}; +} + +json serialize_floats(const ConfigOption* option) +{ + json arr = json::array(); + if (const auto* values = dynamic_cast(option)) { + for (size_t i = 0; i < values->values.size(); ++i) + arr.push_back(values->is_nil(i) ? json(nullptr) : json(values->values[i])); + return arr; + } + if (const auto* values = dynamic_cast(option)) { + for (double v : values->values) + arr.push_back(v); + return arr; + } + return option->serialize(); +} + +json serialize_ints(const ConfigOption* option) +{ + json arr = json::array(); + if (const auto* values = dynamic_cast(option)) { + for (size_t i = 0; i < values->values.size(); ++i) + arr.push_back(values->is_nil(i) ? json(nullptr) : json(values->values[i])); + return arr; + } + if (const auto* values = dynamic_cast(option)) { + for (int v : values->values) + arr.push_back(v); + return arr; + } + return option->serialize(); +} + +json serialize_bools(const ConfigOption* option) +{ + json arr = json::array(); + if (const auto* values = dynamic_cast(option)) { + for (size_t i = 0; i < values->values.size(); ++i) + arr.push_back(values->is_nil(i) ? json(nullptr) : json(values->get_at(i))); + return arr; + } + if (const auto* values = dynamic_cast(option)) { + for (size_t i = 0; i < values->values.size(); ++i) + arr.push_back(values->get_at(i)); + return arr; + } + return option->serialize(); +} + +json serialize_enum_ints(const ConfigOption* option) +{ + json arr = json::array(); + if (const auto* values = dynamic_cast(option)) { + for (size_t i = 0; i < values->values.size(); ++i) { + if (values->is_nil(i)) { + arr.push_back(nullptr); + continue; + } + const std::string enum_name = enum_value_to_string(values->keys_map, values->values[i]); + arr.push_back(enum_name.empty() ? json(values->values[i]) : json(enum_name)); + } + return arr; + } + if (const auto* values = dynamic_cast(option)) { + for (int v : values->values) { + const std::string enum_name = enum_value_to_string(values->keys_map, v); + arr.push_back(enum_name.empty() ? json(v) : json(enum_name)); + } + return arr; + } + return option->serialize(); +} + +json serialize_floats_or_percents(const ConfigOption* option) +{ + json arr = json::array(); + if (const auto* values = dynamic_cast(option)) { + for (size_t i = 0; i < values->values.size(); ++i) + arr.push_back(values->is_nil(i) ? json(nullptr) : float_or_percent_to_json(values->values[i])); + return arr; + } + if (const auto* values = dynamic_cast(option)) { + for (const auto& v : values->values) + arr.push_back(float_or_percent_to_json(v)); + return arr; + } + return option->serialize(); +} + +json config_option_to_json(const ConfigOption& option) +{ + switch (option.type()) { + case coFloat: + case coPercent: + return dynamic_cast(option).value; + case coInt: + return dynamic_cast(option).value; + case coBool: + return dynamic_cast(option).value; + case coString: + return dynamic_cast(option).value; + case coEnum: { + if (const auto* enum_opt = dynamic_cast(&option)) { + const std::string enum_name = enum_value_to_string(enum_opt->keys_map, enum_opt->value); + return enum_name.empty() ? json(enum_opt->value) : json(enum_name); + } + return option.getInt(); + } + case coFloatOrPercent: { + const auto& fp = dynamic_cast(option); + return json{{"value", fp.value}, {"percent", fp.percent}}; + } + case coPoint: + return vec2_to_json(dynamic_cast(option).value); + case coPoint3: + return vec3_to_json(dynamic_cast(option).value); + case coFloats: + case coPercents: + return serialize_floats(&option); + case coInts: + return serialize_ints(&option); + case coBools: + return serialize_bools(&option); + case coStrings: { + json arr = json::array(); + const auto& values = dynamic_cast(option).values; + for (const auto& v : values) + arr.push_back(v); + return arr; + } + case coEnums: + return serialize_enum_ints(&option); + case coFloatsOrPercents: + return serialize_floats_or_percents(&option); + case coPoints: { + json arr = json::array(); + const auto& values = dynamic_cast(option).values; + for (const auto& pt : values) + arr.push_back(vec2_to_json(pt)); + return arr; + } + case coPointsGroups: { + json groups = json::array(); + const auto& values = dynamic_cast(option).values; + for (const auto& group : values) { + json one_group = json::array(); + for (const auto& pt : group) + one_group.push_back(vec2_to_json(pt)); + groups.push_back(std::move(one_group)); + } + return groups; + } + case coIntsGroups: { + json groups = json::array(); + const auto& values = dynamic_cast(option).values; + for (const auto& group : values) { + json one_group = json::array(); + for (int v : group) + one_group.push_back(v); + groups.push_back(std::move(one_group)); + } + return groups; + } + default: + return option.serialize(); + } +} + +void append_settings(const DynamicPrintConfig& config, json& settings, const std::string& prefix) +{ + t_config_option_keys keys = config.keys(); + std::sort(keys.begin(), keys.end()); + for (const auto& key : keys) { + const ConfigOption* option = config.option(key); + if (option == nullptr) + continue; + settings[prefix + key] = config_option_to_json(*option); + } +} + +void append_settings_until_limit(const DynamicPrintConfig& config, json& settings, const std::string& prefix, size_t min_count) +{ + if (settings.size() >= min_count) + return; + + t_config_option_keys keys = config.keys(); + std::sort(keys.begin(), keys.end()); + for (const auto& key : keys) { + if (settings.size() >= min_count) + break; + + const ConfigOption* option = config.option(key); + if (option == nullptr) + continue; + settings[prefix + key] = config_option_to_json(*option); + } +} + +void add_optional_config_value(const DynamicPrintConfig& config, const std::string& key, json& dst, const std::string& out_key) +{ + const ConfigOption* option = config.option(key); + if (option != nullptr) + dst[out_key] = config_option_to_json(*option); +} + +void append_bed_size(const DynamicPrintConfig& config, json& printer_json) +{ + const auto* printable_area = config.option("printable_area"); + if (printable_area == nullptr || printable_area->values.empty()) + return; + + double min_x = std::numeric_limits::max(); + double max_x = std::numeric_limits::lowest(); + double min_y = std::numeric_limits::max(); + double max_y = std::numeric_limits::lowest(); + + for (const auto& point : printable_area->values) { + min_x = std::min(min_x, point.x()); + max_x = std::max(max_x, point.x()); + min_y = std::min(min_y, point.y()); + max_y = std::max(max_y, point.y()); + } + + if (min_x <= max_x && min_y <= max_y) { + printer_json["bed_size"] = json{ + {"width", max_x - min_x}, + {"depth", max_y - min_y} + }; + } +} + +} // namespace + +std::string build_context_snapshot_json(GUI::Plater& plater) +{ + json root = json::object(); + root["contract_version"] = "0.1.0"; + root["app"] = json{ + {"name", SLIC3R_APP_FULL_NAME}, + {"version", SLIC3R_VERSION}, + {"platform", platform_name()} + }; + + auto* bundle = GUI::wxGetApp().preset_bundle; + if (bundle == nullptr) + return root.dump(2); + + const bool is_fff = plater.printer_technology() == ptFFF; + + std::string filament_profile_name; + if (is_fff) { + filament_profile_name = bundle->filament_presets.empty() ? bundle->filaments.get_selected_preset_name() : bundle->filament_presets.front(); + } else { + filament_profile_name = bundle->sla_materials.get_selected_preset_name(); + } + + root["profiles"] = json{ + {"printer_profile_name", bundle->printers.get_selected_preset_name()}, + {"filament_profile_name", filament_profile_name}, + {"print_profile_name", is_fff ? bundle->prints.get_selected_preset_name() : bundle->sla_prints.get_selected_preset_name()} + }; + + const DynamicPrintConfig& printer_config = bundle->printers.get_edited_preset().config; + const DynamicPrintConfig& filament_config = is_fff ? bundle->filaments.get_edited_preset().config : bundle->sla_materials.get_edited_preset().config; + const DynamicPrintConfig full_config = bundle->full_config(); + + json printer_json = json::object(); + add_optional_config_value(printer_config, "nozzle_diameter", printer_json, "nozzle_diameter"); + append_bed_size(printer_config, printer_json); + if (const auto* model = printer_config.option("printer_model"); model != nullptr && !model->value.empty()) + printer_json["model"] = model->value; + root["printer"] = std::move(printer_json); + + json filament_json = json::object(); + if (const auto* type = filament_config.option("filament_type"); type != nullptr && !type->value.empty()) + filament_json["type"] = type->value; + add_optional_config_value(filament_config, "filament_diameter", filament_json, "diameter"); + + json temps = json::object(); + add_optional_config_value(full_config, "nozzle_temperature", temps, "nozzle"); + add_optional_config_value(full_config, "nozzle_temperature_initial_layer", temps, "nozzle_initial_layer"); + add_optional_config_value(full_config, "bed_temperature", temps, "bed"); + add_optional_config_value(full_config, "bed_temperature_initial_layer", temps, "bed_initial_layer"); + if (!temps.empty()) + filament_json["temps"] = std::move(temps); + root["filament"] = std::move(filament_json); + + json project_json = json{ + {"plate_count", plater.get_partplate_list().get_plate_count()}, + {"object_count", static_cast(plater.model().objects.size())} + }; + const int selected_object_idx = plater.get_selected_object_idx(); + if (selected_object_idx >= 0 && selected_object_idx < static_cast(plater.model().objects.size())) { + const ModelObject* object = plater.model().objects[static_cast(selected_object_idx)]; + if (object != nullptr) { + project_json["selected_object"] = json{ + {"index", selected_object_idx}, + {"name", object->name} + }; + } + } + root["project"] = std::move(project_json); + + json settings = json::object(); + append_settings(full_config, settings, ""); + + // Keep a safety fallback in case a constrained profile has too few keys. + if (settings.size() < 100) { + const DynamicPrintConfig& print_config = is_fff ? bundle->prints.get_edited_preset().config : bundle->sla_prints.get_edited_preset().config; + append_settings_until_limit(printer_config, settings, "printer.", 100); + append_settings_until_limit(filament_config, settings, "filament.", 100); + append_settings_until_limit(bundle->project_config, settings, "project.", 100); + append_settings_until_limit(print_config, settings, "print.", 100); + } + root["settings"] = std::move(settings); + + return root.dump(2); +} + +} // namespace AI +} // namespace Slic3r diff --git a/src/ai/context_snapshot.h b/src/ai/context_snapshot.h new file mode 100644 index 00000000000..5cb21216b56 --- /dev/null +++ b/src/ai/context_snapshot.h @@ -0,0 +1,19 @@ +#ifndef slic3r_ai_context_snapshot_h_ +#define slic3r_ai_context_snapshot_h_ + +#include + +namespace Slic3r { +namespace GUI { +class Plater; +} + +namespace AI { + +// Builds the read-only AI context snapshot JSON for the current plater state. +std::string build_context_snapshot_json(GUI::Plater& plater); + +} // namespace AI +} // namespace Slic3r + +#endif // slic3r_ai_context_snapshot_h_ diff --git a/src/ai/contracts/response_schema_v0_1_0.json b/src/ai/contracts/response_schema_v0_1_0.json new file mode 100644 index 00000000000..ddfc2b84cb1 --- /dev/null +++ b/src/ai/contracts/response_schema_v0_1_0.json @@ -0,0 +1,85 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AI Slice Assistant Response v0.1.0", + "type": "object", + "additionalProperties": false, + "required": [ + "contract_version", + "summary", + "recommended_changes", + "questions", + "refusals", + "warnings" + ], + "properties": { + "contract_version": { + "type": "string", + "const": "0.1.0" + }, + "summary": { + "type": "string" + }, + "recommended_changes": { + "type": "array", + "maxItems": 20, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "key", + "value", + "value_type", + "reason", + "impact", + "confidence", + "applies_to", + "tags", + "requires_user_confirmation" + ], + "properties": { + "id": { "type": "string" }, + "key": { "type": "string" }, + "value": { "type": ["string", "number", "boolean"] }, + "value_type": { + "type": "string", + "enum": ["bool", "int", "float", "string", "enum"] + }, + "reason": { "type": "string" }, + "impact": { + "type": "object", + "additionalProperties": false, + "required": ["quality", "time", "risk"], + "properties": { + "quality": { "type": "integer", "minimum": 1, "maximum": 10 }, + "time": { "type": "integer", "minimum": 1, "maximum": 10 }, + "risk": { "type": "integer", "minimum": 1, "maximum": 10 } + } + }, + "confidence": { "type": "number", "minimum": 0, "maximum": 1 }, + "applies_to": { + "type": "string", + "enum": ["global", "profile", "object"] + }, + "tags": { + "type": "array", + "items": { "type": "string" } + }, + "requires_user_confirmation": { "type": "boolean" } + } + } + }, + "questions": { + "type": "array", + "items": { "type": "string" } + }, + "refusals": { + "type": "array", + "items": { "type": "string" } + }, + "warnings": { + "type": "array", + "items": { "type": "string" } + } + } +} diff --git a/src/ai/providers/fake_provider.cpp b/src/ai/providers/fake_provider.cpp new file mode 100644 index 00000000000..dbf780f31b3 --- /dev/null +++ b/src/ai/providers/fake_provider.cpp @@ -0,0 +1,173 @@ +#include "fake_provider.h" + +#include +#include +#include + +#include "nlohmann/json.hpp" + +namespace Slic3r { +namespace AI { +namespace Providers { + +namespace { + +using nlohmann::json; + +std::string to_lower(std::string value) +{ + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); + return value; +} + +} // namespace + +std::string FakeProvider::run(const ProviderRequest& request) const +{ + const bool has_context = !request.context_snapshot_json.empty(); + const bool has_geometry = !request.geometry_insights_json.empty(); + const std::string message_lc = to_lower(request.user_message); + const bool is_repair_request = + message_lc.find("repair_request") != std::string::npos || + message_lc.find("output corrected json only") != std::string::npos; + + bool prefers_quality = message_lc.find("quality") != std::string::npos; + bool prefers_speed = message_lc.find("speed") != std::string::npos || message_lc.find("faster") != std::string::npos; + if (is_repair_request) { + prefers_quality = false; + prefers_speed = false; + } + + json response = json::object(); + response["contract_version"] = "0.1.0"; + + if (is_repair_request) { + response["summary"] = "Corrected contract v0.1.0 response generated after validation errors."; + } else if (prefers_quality) { + response["summary"] = "Prioriser la qualite avec une couche plus fine et un remplissage plus regulier."; + } else if (prefers_speed) { + response["summary"] = "Prioriser le temps d'impression avec une couche plus haute et un remplissage simplifie."; + } else { + response["summary"] = "Ajustements slicer proposes de maniere prudente pour un compromis qualite/temps."; + } + + json changes = json::array(); + if (prefers_speed) { + changes.push_back(json{ + {"id", "chg-layer-height-speed"}, + {"key", "layer_height"}, + {"value", 0.28}, + {"value_type", "float"}, + {"reason", "Augmenter la hauteur de couche reduit le nombre de couches et accelere l'impression."}, + {"impact", json{{"quality", 5}, {"time", 8}, {"risk", 4}}}, + {"confidence", has_context ? 0.85 : 0.62}, + {"applies_to", "profile"}, + {"tags", json::array({"speed", "layering"})}, + {"requires_user_confirmation", true} + }); + changes.push_back(json{ + {"id", "chg-sparse-infill-density-speed"}, + {"key", "sparse_infill_density"}, + {"value", 12}, + {"value_type", "int"}, + {"reason", "Diminuer legerement le remplissage accelere l'impression et economise de la matiere."}, + {"impact", json{{"quality", 5}, {"time", 8}, {"risk", 5}}}, + {"confidence", has_context ? 0.82 : 0.60}, + {"applies_to", "profile"}, + {"tags", json::array({"speed", "infill"})}, + {"requires_user_confirmation", true} + }); + } else { + changes.push_back(json{ + {"id", "chg-layer-height-quality"}, + {"key", "layer_height"}, + {"value", 0.16}, + {"value_type", "float"}, + {"reason", "Reduire la hauteur de couche ameliore le rendu de surface."}, + {"impact", json{{"quality", 8}, {"time", 4}, {"risk", 3}}}, + {"confidence", has_context ? 0.87 : 0.64}, + {"applies_to", "profile"}, + {"tags", json::array({"quality", "surface"})}, + {"requires_user_confirmation", true} + }); + changes.push_back(json{ + {"id", "chg-sparse-infill-density-quality"}, + {"key", "sparse_infill_density"}, + {"value", 18}, + {"value_type", "int"}, + {"reason", "Augmenter moderement le remplissage peut ameliorer la rigidite de la piece."}, + {"impact", json{{"quality", 7}, {"time", 5}, {"risk", 3}}}, + {"confidence", has_context ? 0.84 : 0.61}, + {"applies_to", "profile"}, + {"tags", json::array({"quality", "strength"})}, + {"requires_user_confirmation", true} + }); + } + + changes.push_back(json{ + {"id", "chg-detect-thin-walls"}, + {"key", "detect_thin_wall"}, + {"value", true}, + {"value_type", "bool"}, + {"reason", "Aider a conserver les details fins selon la geometrie du modele."}, + {"impact", json{{"quality", 7}, {"time", 6}, {"risk", 3}}}, + {"confidence", has_geometry ? 0.86 : 0.55}, + {"applies_to", "global"}, + {"tags", json::array({"geometry", "detail"})}, + {"requires_user_confirmation", true} + }); + changes.push_back(json{ + {"id", "chg-ironing-type"}, + {"key", "ironing_type"}, + {"value", prefers_speed ? "no_ironing" : "top_surfaces"}, + {"value_type", "enum"}, + {"reason", "Lissage cible pour controler le compromis finition/temps."}, + {"impact", json{{"quality", prefers_speed ? 4 : 7}, {"time", prefers_speed ? 8 : 5}, {"risk", 3}}}, + {"confidence", has_context ? 0.80 : 0.58}, + {"applies_to", "profile"}, + {"tags", json::array({"surface", "postprocessing"})}, + {"requires_user_confirmation", true} + }); + changes.push_back(json{ + {"id", "chg-support_enable"}, + {"key", "support_enable"}, + {"value", has_geometry}, + {"value_type", "bool"}, + {"reason", "Activer les supports seulement quand des surplombs sont probables."}, + {"impact", json{{"quality", has_geometry ? 7 : 5}, {"time", has_geometry ? 4 : 7}, {"risk", 5}}}, + {"confidence", has_geometry ? 0.83 : 0.50}, + {"applies_to", "profile"}, + {"tags", json::array({"supports", "overhang"})}, + {"requires_user_confirmation", true} + }); + changes.push_back(json{ + {"id", "chg-print_sequence"}, + {"key", "print_sequence"}, + {"value", "by_layer"}, + {"value_type", "enum"}, + {"reason", "Conserver une sequence stable pour limiter les collisions pendant les trajets."}, + {"impact", json{{"quality", 6}, {"time", 6}, {"risk", 2}}}, + {"confidence", has_context ? 0.81 : 0.59}, + {"applies_to", "global"}, + {"tags", json::array({"stability", "travel"})}, + {"requires_user_confirmation", false} + }); + + response["recommended_changes"] = std::move(changes); + response["questions"] = json::array(); + response["questions"].push_back("Souhaitez-vous prioriser la qualite visuelle ou le temps d'impression ?"); + response["refusals"] = json::array(); + + json warnings = json::array(); + if (!has_context) + warnings.push_back("context_snapshot_missing"); + if (!has_geometry) + warnings.push_back("geometry_insights_missing"); + response["warnings"] = std::move(warnings); + + return response.dump(2); +} + +} // namespace Providers +} // namespace AI +} // namespace Slic3r diff --git a/src/ai/providers/fake_provider.h b/src/ai/providers/fake_provider.h new file mode 100644 index 00000000000..0d088bbcbc0 --- /dev/null +++ b/src/ai/providers/fake_provider.h @@ -0,0 +1,20 @@ +#ifndef slic3r_ai_providers_fake_provider_h_ +#define slic3r_ai_providers_fake_provider_h_ + +#include "provider.h" + +namespace Slic3r { +namespace AI { +namespace Providers { + +class FakeProvider final : public Provider +{ +public: + std::string run(const ProviderRequest& request) const override; +}; + +} // namespace Providers +} // namespace AI +} // namespace Slic3r + +#endif // slic3r_ai_providers_fake_provider_h_ diff --git a/src/ai/providers/openai_compat/openai_compat_provider.cpp b/src/ai/providers/openai_compat/openai_compat_provider.cpp new file mode 100644 index 00000000000..9ae465b79e6 --- /dev/null +++ b/src/ai/providers/openai_compat/openai_compat_provider.cpp @@ -0,0 +1,468 @@ +#include "openai_compat_provider.h" + +#include + +#include +#include +#include "nlohmann/json.hpp" +#include "slic3r/Utils/Http.hpp" + +#include +#include +#include +#include +#include + +#include + +namespace Slic3r { +namespace AI { +namespace Providers { + +namespace { + +using nlohmann::json; + +constexpr const char* k_secret_store_service = "orcaslicer.ai.openai_compat"; +constexpr const char* k_secret_store_user = "api_key"; +constexpr const char* k_contract_schema_filename = "response_schema_v0_1_0.json"; + +std::filesystem::path contract_schema_path() +{ + return std::filesystem::path(__FILE__).parent_path().parent_path().parent_path() / "contracts" / k_contract_schema_filename; +} + +bool load_contract_schema_once(json& out_schema, std::string& out_error) +{ + static bool initialized = false; + static json cached_schema; + static std::string cached_error; + + if (!initialized) { + initialized = true; + const std::filesystem::path schema_path = contract_schema_path(); + std::ifstream schema_file(schema_path, std::ios::in | std::ios::binary); + if (!schema_file.is_open()) { + cached_error = "cannot open schema file: " + schema_path.string(); + } else { + try { + schema_file >> cached_schema; + } catch (const std::exception& ex) { + cached_error = std::string("failed to parse schema file: ") + ex.what(); + } + } + } + + if (!cached_error.empty()) { + out_error = cached_error; + return false; + } + + out_schema = cached_schema; + out_error.clear(); + return true; +} + +std::string trim_copy(const std::string& value) +{ + size_t start = 0; + while (start < value.size() && std::isspace(static_cast(value[start]))) + ++start; + + size_t end = value.size(); + while (end > start && std::isspace(static_cast(value[end - 1]))) + --end; + + return value.substr(start, end - start); +} + +std::string strip_code_fence(std::string text) +{ + text = trim_copy(text); + if (text.rfind("```", 0) != 0) + return text; + + const size_t first_newline = text.find('\n'); + if (first_newline == std::string::npos) + return text; + + const size_t closing_fence = text.rfind("```"); + if (closing_fence == std::string::npos || closing_fence <= first_newline) + return text; + + return trim_copy(text.substr(first_newline + 1, closing_fence - first_newline - 1)); +} + +std::string normalize_endpoint(const std::string& base_url) +{ + std::string normalized = trim_copy(base_url); + if (normalized.empty()) + normalized = "https://api.openai.com/v1"; + + while (!normalized.empty() && normalized.back() == '/') + normalized.pop_back(); + + if (normalized.size() >= 17 && normalized.compare(normalized.size() - 17, 17, "/chat/completions") == 0) + return normalized; + + return normalized + "/chat/completions"; +} + +std::string build_error_contract(const std::string& summary, const std::string& refusal, const std::string& warning) +{ + json output = json::object(); + output["contract_version"] = "0.1.0"; + output["summary"] = summary; + output["recommended_changes"] = json::array(); + output["questions"] = json::array(); + output["refusals"] = json::array({refusal}); + output["warnings"] = json::array({warning}); + return output.dump(2); +} + +std::string extract_content(const json& response) +{ + if (response.contains("choices") && response.at("choices").is_array() && !response.at("choices").empty()) { + const json& first = response.at("choices").at(0); + + if (first.contains("message") && first.at("message").is_object()) { + const json& message = first.at("message"); + + if (message.contains("content") && message.at("content").is_string()) + return message.at("content").get(); + + if (message.contains("content") && message.at("content").is_array()) { + std::string merged; + for (const json& item : message.at("content")) { + if (item.is_object() && item.contains("text") && item.at("text").is_string()) { + if (!merged.empty()) + merged.push_back('\n'); + merged += item.at("text").get(); + } + } + if (!merged.empty()) + return merged; + } + } + + if (first.contains("text") && first.at("text").is_string()) + return first.at("text").get(); + } + + if (response.contains("output_text") && response.at("output_text").is_string()) + return response.at("output_text").get(); + + return {}; +} + +std::string extract_first_json_object(std::string text) +{ + text = trim_copy(text); + const size_t start = text.find('{'); + const size_t end = text.rfind('}'); + if (start == std::string::npos || end == std::string::npos || end <= start) + return {}; + + return text.substr(start, end - start + 1); +} + +json build_messages(const ProviderRequest& request) +{ + static const std::string system_prompt = + "You are the OrcaSlicer AI Slice Assistant.\\n" + "Scope: slicer and print settings only; refuse all non-slicing topics.\\n" + "You MUST output JSON only and no markdown or code fences.\\n" + "Use contract version 0.1.0 with required fields: contract_version, summary, recommended_changes, questions, refusals, warnings.\\n" + "recommended_changes length must be <= 20.\\n" + "Each recommended_changes item must include: id,key,value,value_type,reason,impact,confidence,applies_to,tags,requires_user_confirmation.\\n" + "impact values are integers 1..10, confidence is 0..1.\\n" + "When a repair request is provided, output corrected JSON only, preserving intent and fixing validation errors."; + + std::ostringstream user_payload; + user_payload << "user_intent:\n" << request.user_message << "\n\n"; + user_payload << "context_snapshot_json:\n"; + user_payload << (request.context_snapshot_json.empty() ? "{}" : request.context_snapshot_json) << "\n\n"; + user_payload << "geometry_insights_json:\n"; + user_payload << (request.geometry_insights_json.empty() ? "{}" : request.geometry_insights_json) << "\n"; + + json messages = json::array(); + messages.push_back(json{{"role", "system"}, {"content", system_prompt}}); + messages.push_back(json{{"role", "user"}, {"content", user_payload.str()}}); + return messages; +} + +bool parse_json_only_response(const std::string& raw_content, std::string& normalized_json, std::string& error_message) +{ + normalized_json.clear(); + error_message.clear(); + + std::string candidate = strip_code_fence(raw_content); + if (candidate.empty()) { + error_message = "empty model response"; + return false; + } + + try { + json parsed = json::parse(candidate); + normalized_json = parsed.dump(2); + return true; + } catch (...) { + // Continue with object extraction fallback below. + } + + candidate = extract_first_json_object(candidate); + if (candidate.empty()) { + error_message = "response is not valid JSON"; + return false; + } + + try { + json parsed = json::parse(candidate); + normalized_json = parsed.dump(2); + return true; + } catch (const std::exception& ex) { + error_message = ex.what(); + return false; + } +} + +bool perform_chat_completion(const OpenAICompatConfig& config, + const ProviderRequest& request, + std::string& out_body, + std::string& out_error, + unsigned& out_status) +{ + out_body.clear(); + out_error.clear(); + out_status = 0; + + if (trim_copy(config.api_key).empty()) { + out_error = "missing api_key"; + return false; + } + if (trim_copy(config.model).empty()) { + out_error = "missing model"; + return false; + } + + const std::string endpoint = normalize_endpoint(config.base_url); + + json payload = json::object(); + payload["model"] = config.model; + payload["messages"] = build_messages(request); + payload["temperature"] = config.temperature; + payload["max_tokens"] = config.max_tokens; + if (config.use_json_schema_response_format) { + json schema_json; + std::string schema_error; + if (load_contract_schema_once(schema_json, schema_error)) { + payload["response_format"] = json::object({ + {"type", "json_schema"}, + {"json_schema", json::object({ + {"name", "ai_slice_contract_v0_1_0"}, + {"schema", schema_json}, + {"strict", true} + })} + }); + } else { + BOOST_LOG_TRIVIAL(error) + << "AI OpenAI-compatible provider: failed to load " << k_contract_schema_filename + << " for json_schema mode; falling back to json_object. reason=" << schema_error; + payload["response_format"] = json::object({{"type", "json_object"}}); + } + } else { + payload["response_format"] = json::object({{"type", "json_object"}}); + } + + bool success = false; + try { + Slic3r::Http::post(endpoint) + .header("Authorization", "Bearer " + config.api_key) + .header("Content-Type", "application/json") + .set_post_body(payload.dump()) + .timeout_max(config.timeout_seconds > 0 ? config.timeout_seconds : 30) + .on_complete([&](std::string body, unsigned status) { + success = (status >= 200 && status < 300); + out_body = std::move(body); + out_status = status; + }) + .on_error([&](std::string body, std::string error, unsigned status) { + success = false; + out_body = std::move(body); + out_error = std::move(error); + out_status = status; + }) + .perform_sync(); + } catch (const std::exception& ex) { + out_error = ex.what(); + success = false; + } + + return success; +} + +} // namespace + +OpenAICompatProvider::OpenAICompatProvider(OpenAICompatConfig config) + : m_config(std::move(config)) +{} + +std::string OpenAICompatProvider::run(const ProviderRequest& request) const +{ + std::string body; + std::string error; + unsigned status = 0; + + if (!perform_chat_completion(m_config, request, body, error, status)) { + std::ostringstream warning; + warning << "http_error status=" << status; + if (!error.empty()) + warning << " msg=" << error; + return build_error_contract("Le provider OpenAI-compatible n'a pas repondu correctement.", + "provider_request_failed", + warning.str()); + } + + json parsed_response; + try { + parsed_response = json::parse(body); + } catch (const std::exception& ex) { + return build_error_contract("Reponse HTTP recue mais format JSON invalide.", + "provider_response_not_json", + ex.what()); + } + + std::string content = extract_content(parsed_response); + if (content.empty()) { + if (parsed_response.is_object() && parsed_response.contains("contract_version")) { + return parsed_response.dump(2); + } + return build_error_contract("Reponse provider sans contenu exploitable.", + "provider_response_missing_content", + "empty_message_content"); + } + + std::string normalized_json; + std::string parse_error; + if (!parse_json_only_response(content, normalized_json, parse_error)) { + return build_error_contract("Le provider n'a pas renvoye un JSON valide.", + "provider_response_invalid_json", + parse_error.empty() ? "json_parse_failed" : parse_error); + } + + return normalized_json; +} + +bool OpenAICompatProvider::test_connection(std::string& error_message) const +{ + error_message.clear(); + + ProviderRequest req; + req.user_message = "Connection test. Return JSON only: {\"ok\":true}."; + + std::string body; + std::string error; + unsigned status = 0; + + if (!perform_chat_completion(m_config, req, body, error, status)) { + std::ostringstream oss; + oss << "status=" << status; + if (!error.empty()) + oss << " error=" << error; + if (!body.empty()) + oss << " body=" << trim_copy(body).substr(0, 160); + error_message = oss.str(); + return false; + } + + if (body.empty()) { + error_message = "empty response body"; + return false; + } + + return true; +} + +bool OpenAICompatProvider::save_api_key_securely(const std::string& api_key, std::string& error_message) +{ + error_message.clear(); + +#ifdef __APPLE__ + wxSecretStore store = wxSecretStore::GetDefault(); + if (!store.IsOk()) { + error_message = "system keychain not available"; + return false; + } + + wxSecretValue secret(wxString::FromUTF8(api_key.c_str())); + if (!store.Save(k_secret_store_service, k_secret_store_user, secret)) { + error_message = "failed to save api key to system keychain"; + return false; + } + + return true; +#else + (void) api_key; + error_message = "secure key storage is unavailable on this platform"; + return false; +#endif +} + +bool OpenAICompatProvider::load_api_key_securely(std::string& api_key, std::string& error_message) +{ + api_key.clear(); + error_message.clear(); + +#ifdef __APPLE__ + wxSecretStore store = wxSecretStore::GetDefault(); + if (!store.IsOk()) { + error_message = "system keychain not available"; + return false; + } + + wxString username; + wxSecretValue secret; + if (!store.Load(k_secret_store_service, username, secret) || !secret.IsOk()) { + error_message = "api key not found in system keychain"; + return false; + } + + api_key.assign(static_cast(secret.GetData()), secret.GetSize()); + if (api_key.empty()) { + error_message = "empty api key in system keychain"; + return false; + } + + return true; +#else + error_message = "secure key storage is unavailable on this platform"; + return false; +#endif +} + +bool OpenAICompatProvider::clear_api_key_securely(std::string& error_message) +{ + error_message.clear(); + +#ifdef __APPLE__ + wxSecretStore store = wxSecretStore::GetDefault(); + if (!store.IsOk()) { + error_message = "system keychain not available"; + return false; + } + + if (!store.Delete(k_secret_store_service)) { + error_message = "failed to clear api key from system keychain"; + return false; + } + + return true; +#else + error_message = "secure key storage is unavailable on this platform"; + return false; +#endif +} + +} // namespace Providers +} // namespace AI +} // namespace Slic3r diff --git a/src/ai/providers/openai_compat/openai_compat_provider.h b/src/ai/providers/openai_compat/openai_compat_provider.h new file mode 100644 index 00000000000..4512451e929 --- /dev/null +++ b/src/ai/providers/openai_compat/openai_compat_provider.h @@ -0,0 +1,44 @@ +#ifndef slic3r_ai_providers_openai_compat_provider_h_ +#define slic3r_ai_providers_openai_compat_provider_h_ + +#include + +#include "../provider.h" + +namespace Slic3r { +namespace AI { +namespace Providers { + +struct OpenAICompatConfig +{ + std::string provider_type; + std::string base_url; + std::string api_key; + std::string model; + long timeout_seconds { 30 }; + int max_tokens { 600 }; + double temperature { 0.2 }; + bool use_json_schema_response_format { true }; +}; + +class OpenAICompatProvider final : public Provider +{ +public: + explicit OpenAICompatProvider(OpenAICompatConfig config); + + std::string run(const ProviderRequest& request) const override; + bool test_connection(std::string& error_message) const; + + static bool save_api_key_securely(const std::string& api_key, std::string& error_message); + static bool load_api_key_securely(std::string& api_key, std::string& error_message); + static bool clear_api_key_securely(std::string& error_message); + +private: + OpenAICompatConfig m_config; +}; + +} // namespace Providers +} // namespace AI +} // namespace Slic3r + +#endif // slic3r_ai_providers_openai_compat_provider_h_ diff --git a/src/ai/providers/provider.h b/src/ai/providers/provider.h new file mode 100644 index 00000000000..95109a97ebf --- /dev/null +++ b/src/ai/providers/provider.h @@ -0,0 +1,28 @@ +#ifndef slic3r_ai_providers_provider_h_ +#define slic3r_ai_providers_provider_h_ + +#include + +namespace Slic3r { +namespace AI { +namespace Providers { + +struct ProviderRequest +{ + std::string user_message; + std::string context_snapshot_json; + std::string geometry_insights_json; +}; + +class Provider +{ +public: + virtual ~Provider() = default; + virtual std::string run(const ProviderRequest& request) const = 0; +}; + +} // namespace Providers +} // namespace AI +} // namespace Slic3r + +#endif // slic3r_ai_providers_provider_h_ diff --git a/src/ai/validation/response_validator.cpp b/src/ai/validation/response_validator.cpp new file mode 100644 index 00000000000..0e2fe00ab1d --- /dev/null +++ b/src/ai/validation/response_validator.cpp @@ -0,0 +1,329 @@ +#include "response_validator.h" + +#include +#include +#include +#include +#include + +#include "nlohmann/json.hpp" + +namespace Slic3r { +namespace AI { +namespace Validation { + +namespace { + +using nlohmann::json; + +constexpr const char* k_geometry_conversion_warning = "geometry_modification_not_supported_converted_to_slicing"; + +std::filesystem::path default_schema_path() +{ + return std::filesystem::path(__FILE__).parent_path().parent_path() / "contracts" / "response_schema_v0_1_0.json"; +} + +bool load_json_file(const std::string& path, json& out, std::string& error) +{ + std::ifstream file(path, std::ios::in | std::ios::binary); + if (!file.is_open()) { + error = "Cannot open schema file: " + path; + return false; + } + + try { + file >> out; + return true; + } catch (const std::exception& ex) { + error = std::string("Failed to parse schema JSON: ") + ex.what(); + return false; + } +} + +bool matches_type_name(const json& value, const std::string& type_name) +{ + if (type_name == "object") return value.is_object(); + if (type_name == "array") return value.is_array(); + if (type_name == "string") return value.is_string(); + if (type_name == "boolean") return value.is_boolean(); + if (type_name == "number") return value.is_number(); + if (type_name == "integer") return value.is_number_integer(); + if (type_name == "null") return value.is_null(); + return false; +} + +bool matches_type(const json& value, const json& schema_type) +{ + if (schema_type.is_string()) + return matches_type_name(value, schema_type.get()); + if (schema_type.is_array()) { + for (const auto& type_name : schema_type) { + if (type_name.is_string() && matches_type_name(value, type_name.get())) + return true; + } + } + return false; +} + +void validate_against_schema(const json& value, const json& schema, const std::string& path, std::vector& errors) +{ + if (schema.contains("const") && value != schema.at("const")) + errors.push_back(path + ": value does not match const."); + + if (schema.contains("type") && !matches_type(value, schema.at("type"))) { + errors.push_back(path + ": type mismatch."); + return; + } + + if (schema.contains("enum") && schema.at("enum").is_array()) { + bool found = false; + for (const auto& allowed : schema.at("enum")) { + if (value == allowed) { + found = true; + break; + } + } + if (!found) + errors.push_back(path + ": value is not in enum."); + } + + if (value.is_number()) { + if (schema.contains("minimum") && value.get() < schema.at("minimum").get()) + errors.push_back(path + ": value is below minimum."); + if (schema.contains("maximum") && value.get() > schema.at("maximum").get()) + errors.push_back(path + ": value is above maximum."); + } + + if (value.is_array()) { + if (schema.contains("minItems") && value.size() < schema.at("minItems").get()) + errors.push_back(path + ": array has too few items."); + if (schema.contains("maxItems") && value.size() > schema.at("maxItems").get()) + errors.push_back(path + ": array has too many items."); + if (schema.contains("items") && schema.at("items").is_object()) { + for (size_t i = 0; i < value.size(); ++i) + validate_against_schema(value[i], schema.at("items"), path + "[" + std::to_string(i) + "]", errors); + } + return; + } + + if (!value.is_object()) + return; + + if (schema.contains("required") && schema.at("required").is_array()) { + for (const auto& required_key : schema.at("required")) { + if (!required_key.is_string()) + continue; + const std::string key = required_key.get(); + if (!value.contains(key)) + errors.push_back(path + ": missing required field '" + key + "'."); + } + } + + const bool additional_allowed = !schema.contains("additionalProperties") || schema.at("additionalProperties").get(); + if (!additional_allowed && schema.contains("properties") && schema.at("properties").is_object()) { + for (auto it = value.begin(); it != value.end(); ++it) { + if (!schema.at("properties").contains(it.key())) + errors.push_back(path + ": unexpected field '" + it.key() + "'."); + } + } + + if (schema.contains("properties") && schema.at("properties").is_object()) { + for (auto prop_it = schema.at("properties").begin(); prop_it != schema.at("properties").end(); ++prop_it) { + if (!value.contains(prop_it.key())) + continue; + if (!prop_it.value().is_object()) + continue; + validate_against_schema(value.at(prop_it.key()), prop_it.value(), path + "." + prop_it.key(), errors); + } + } +} + +json make_reinforcement_change(const std::string& id, const std::string& key, int value, const std::string& reason) +{ + return json::object({ + {"id", id}, + {"key", key}, + {"value", value}, + {"value_type", "int"}, + {"reason", reason}, + {"impact", json::object({{"quality", 8}, {"time", 6}, {"risk", 3}})}, + {"confidence", 0.72}, + {"applies_to", "profile"}, + {"tags", json::array({"auto_converted", "slicing_reinforcement"})}, + {"requires_user_confirmation", true} + }); +} + +bool has_change_key(const json& changes, const std::string& key) +{ + if (!changes.is_array()) + return false; + + for (const auto& item : changes) { + if (!item.is_object() || !item.contains("key") || !item.at("key").is_string()) + continue; + if (item.at("key").get() == key) + return true; + } + return false; +} + +void ensure_warning(json& response, const std::string& warning) +{ + if (!response.contains("warnings") || !response.at("warnings").is_array()) + response["warnings"] = json::array(); + + for (const auto& item : response.at("warnings")) { + if (item.is_string() && item.get() == warning) + return; + } + response["warnings"].push_back(warning); +} + +void normalize_geometry_model_recommendations(json& response) +{ + if (!response.is_object() || !response.contains("recommended_changes") || !response.at("recommended_changes").is_array()) + return; + + json kept_changes = json::array(); + bool had_geometry_recommendation = false; + + for (const auto& item : response.at("recommended_changes")) { + if (!item.is_object()) { + kept_changes.push_back(item); + continue; + } + + const std::string applies_to = item.value("applies_to", ""); + if (applies_to == "model" || applies_to == "geometry") { + had_geometry_recommendation = true; + continue; + } + + kept_changes.push_back(item); + } + + if (!had_geometry_recommendation) { + response["recommended_changes"] = kept_changes; + return; + } + + const std::string reason = "Converted from unsupported geometry/model modification request."; + if (!has_change_key(kept_changes, "wall_count")) + kept_changes.push_back(make_reinforcement_change("auto_convert_wall_count", "wall_count", 3, reason)); + if (!has_change_key(kept_changes, "top_layers")) + kept_changes.push_back(make_reinforcement_change("auto_convert_top_layers", "top_layers", 5, reason)); + if (!has_change_key(kept_changes, "bottom_layers")) + kept_changes.push_back(make_reinforcement_change("auto_convert_bottom_layers", "bottom_layers", 5, reason)); + if (!has_change_key(kept_changes, "infill_density")) + kept_changes.push_back(make_reinforcement_change("auto_convert_infill_density", "infill_density", 20, reason)); + + if (kept_changes.size() > 20) + kept_changes.erase(kept_changes.begin() + 20, kept_changes.end()); + + response["recommended_changes"] = kept_changes; + ensure_warning(response, k_geometry_conversion_warning); +} + +void validate_business_rules(const json& response, std::vector& errors) +{ + if (!response.contains("contract_version") || !response.at("contract_version").is_string() || + response.at("contract_version").get() != "0.1.0") { + errors.push_back("business: contract_version must be '0.1.0'."); + } + + if (!response.contains("recommended_changes") || !response.at("recommended_changes").is_array()) { + errors.push_back("business: recommended_changes must be an array."); + return; + } + + const auto& changes = response.at("recommended_changes"); + if (changes.size() > 20) + errors.push_back("business: recommended_changes must be <= 20."); + + for (size_t i = 0; i < changes.size(); ++i) { + const auto& change = changes[i]; + if (!change.is_object()) + continue; + + if (change.contains("impact") && change.at("impact").is_object()) { + const auto& impact = change.at("impact"); + for (const char* key : {"quality", "time", "risk"}) { + if (!impact.contains(key) || !impact.at(key).is_number_integer()) { + errors.push_back("business: recommended_changes[" + std::to_string(i) + "].impact." + key + " must be integer."); + continue; + } + const int value = impact.at(key).get(); + if (value < 1 || value > 10) { + errors.push_back("business: recommended_changes[" + std::to_string(i) + "].impact." + key + " must be in [1,10]."); + } + } + } else { + errors.push_back("business: recommended_changes[" + std::to_string(i) + "].impact must be object."); + } + + if (!change.contains("confidence") || !change.at("confidence").is_number()) { + errors.push_back("business: recommended_changes[" + std::to_string(i) + "].confidence must be number."); + continue; + } + const double confidence = change.at("confidence").get(); + if (confidence < 0.0 || confidence > 1.0) + errors.push_back("business: recommended_changes[" + std::to_string(i) + "].confidence must be in [0,1]."); + } +} + +} // namespace + +ResponseValidator::ResponseValidator() +{ + m_schema_path = default_schema_path().string(); + + json schema_json; + std::string schema_error; + if (!load_json_file(m_schema_path, schema_json, schema_error)) { + m_schema_loaded = false; + m_schema_text = schema_error; + return; + } + + m_schema_loaded = true; + m_schema_text = schema_json.dump(); +} + +ValidationResult ResponseValidator::validate(std::string& raw_response_json) const +{ + ValidationResult result; + + json response; + try { + response = json::parse(raw_response_json); + } catch (const std::exception& ex) { + result.errors.push_back(std::string("parse: invalid JSON: ") + ex.what()); + return result; + } + + normalize_geometry_model_recommendations(response); + raw_response_json = response.dump(); + + if (!m_schema_loaded) { + result.errors.push_back("schema: " + m_schema_text); + return result; + } + + json schema; + try { + schema = json::parse(m_schema_text); + } catch (const std::exception& ex) { + result.errors.push_back(std::string("schema: cannot parse loaded schema: ") + ex.what()); + return result; + } + + validate_against_schema(response, schema, "$", result.errors); + validate_business_rules(response, result.errors); + result.valid = result.errors.empty(); + return result; +} + +} // namespace Validation +} // namespace AI +} // namespace Slic3r diff --git a/src/ai/validation/response_validator.h b/src/ai/validation/response_validator.h new file mode 100644 index 00000000000..a673718f4d6 --- /dev/null +++ b/src/ai/validation/response_validator.h @@ -0,0 +1,33 @@ +#ifndef slic3r_ai_validation_response_validator_h_ +#define slic3r_ai_validation_response_validator_h_ + +#include +#include + +namespace Slic3r { +namespace AI { +namespace Validation { + +struct ValidationResult +{ + bool valid { false }; + std::vector errors; +}; + +class ResponseValidator +{ +public: + ResponseValidator(); + ValidationResult validate(std::string& raw_response_json) const; + +private: + std::string m_schema_path; + std::string m_schema_text; + bool m_schema_loaded { false }; +}; + +} // namespace Validation +} // namespace AI +} // namespace Slic3r + +#endif // slic3r_ai_validation_response_validator_h_ diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index 52ba10ab03f..8fbd5a6a5cf 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -30,6 +30,8 @@ set(SLIC3R_GUI_SOURCES GUI/AMSSetting.hpp GUI/AmsWidgets.cpp GUI/AmsWidgets.hpp + GUI/AI/AISliceAssistantPanel.cpp + GUI/AI/AISliceAssistantPanel.hpp GUI/Auxiliary.cpp GUI/AuxiliaryDataViewModel.cpp GUI/AuxiliaryDataViewModel.hpp @@ -719,6 +721,19 @@ else () add_library(libslic3r_gui STATIC ${SLIC3R_GUI_SOURCES}) endif () target_include_directories(libslic3r_gui PRIVATE Utils ${CMAKE_CURRENT_BINARY_DIR}) +target_sources(libslic3r_gui PRIVATE + ${CMAKE_SOURCE_DIR}/src/ai/apply/allowlist_registry.cpp + ${CMAKE_SOURCE_DIR}/src/ai/apply/allowlist_registry.h + ${CMAKE_SOURCE_DIR}/src/ai/context_snapshot.cpp + ${CMAKE_SOURCE_DIR}/src/ai/context_snapshot.h + ${CMAKE_SOURCE_DIR}/src/ai/validation/response_validator.cpp + ${CMAKE_SOURCE_DIR}/src/ai/validation/response_validator.h + ${CMAKE_SOURCE_DIR}/src/ai/providers/provider.h + ${CMAKE_SOURCE_DIR}/src/ai/providers/fake_provider.cpp + ${CMAKE_SOURCE_DIR}/src/ai/providers/fake_provider.h + ${CMAKE_SOURCE_DIR}/src/ai/providers/openai_compat/openai_compat_provider.cpp + ${CMAKE_SOURCE_DIR}/src/ai/providers/openai_compat/openai_compat_provider.h +) if (WIN32) target_include_directories(libslic3r_gui SYSTEM PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../../deps/WebView2/include) diff --git a/src/slic3r/GUI/AI/AISliceAssistantPanel.cpp b/src/slic3r/GUI/AI/AISliceAssistantPanel.cpp new file mode 100644 index 00000000000..f40ae1e2ca4 --- /dev/null +++ b/src/slic3r/GUI/AI/AISliceAssistantPanel.cpp @@ -0,0 +1,1284 @@ +#include "AISliceAssistantPanel.hpp" + +#include "../../../ai/context_snapshot.h" +#include "../../../ai/providers/openai_compat/openai_compat_provider.h" +#include "../GUI_App.hpp" +#include "../MainFrame.hpp" +#include "../ParamsPanel.hpp" +#include "../Plater.hpp" +#include "../Tab.hpp" + +#include "libslic3r/AppConfig.hpp" +#include "libslic3r/Config.hpp" +#include "libslic3r/Model.hpp" +#include "libslic3r/PresetBundle.hpp" +#include "libslic3r_version.h" +#include "nlohmann/json.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace Slic3r { +namespace GUI { + +namespace { + +using nlohmann::json; + +enum : int +{ + ID_AI_MORE_COPY_CONTEXT = wxID_HIGHEST + 1201, + ID_AI_MORE_COPY_LAST_JSON, + ID_AI_MORE_EXPORT_DEBUG, + ID_AI_MORE_SAFE_MODE +}; + +struct PreparedOperation +{ + AISliceAssistantPanel::ApplyScope scope { AISliceAssistantPanel::ApplyScope::Global }; + int object_idx { -1 }; + std::string key; + std::string serialized_new_value; + ConfigBase* target_config { nullptr }; +}; + +bool scope_from_string(const std::string& scope, AISliceAssistantPanel::ApplyScope& out_scope) +{ + if (scope == "global") { + out_scope = AISliceAssistantPanel::ApplyScope::Global; + return true; + } + if (scope == "profile") { + out_scope = AISliceAssistantPanel::ApplyScope::Profile; + return true; + } + if (scope == "object") { + out_scope = AISliceAssistantPanel::ApplyScope::Object; + return true; + } + return false; +} + +std::string trim_copy(const std::string& value) +{ + size_t begin = 0; + while (begin < value.size() && std::isspace(static_cast(value[begin]))) + ++begin; + + size_t end = value.size(); + while (end > begin && std::isspace(static_cast(value[end - 1]))) + --end; + + return value.substr(begin, end - begin); +} + +bool is_missing_preset_name(const std::string& value) +{ + const std::string trimmed = trim_copy(value); + if (trimmed.empty()) + return true; + + std::string lower = trimmed; + std::transform(lower.begin(), lower.end(), lower.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); + return lower == "unknown" || lower == "n/a" || lower == "none"; +} + +void open_preset_tab(Slic3r::Preset::Type type) +{ + if (wxGetApp().mainframe != nullptr) + wxGetApp().mainframe->select_tab(size_t(MainFrame::tp3DEditor)); + + if (wxGetApp().params_panel() != nullptr) + wxGetApp().params_panel()->switch_to_global(); + + if (Tab* tab = wxGetApp().get_tab(type)) + tab->restore_last_select_item(); +} + +std::string serialize_number(double value) +{ + std::ostringstream oss; + oss << std::setprecision(12) << value; + return oss.str(); +} + +bool serialize_value_for_setting(const json& value, const std::string& value_type, std::string& out_serialized) +{ + if (value_type == "bool") { + if (!value.is_boolean()) + return false; + out_serialized = value.get() ? "1" : "0"; + return true; + } + if (value_type == "int") { + if (!value.is_number_integer()) + return false; + out_serialized = std::to_string(value.get()); + return true; + } + if (value_type == "float") { + if (!value.is_number()) + return false; + out_serialized = serialize_number(value.get()); + return true; + } + if (value_type == "string") { + if (!value.is_string()) + return false; + out_serialized = escape_string_cstyle(value.get()); + return true; + } + if (value_type == "enum") { + if (!value.is_string()) + return false; + out_serialized = value.get(); + return true; + } + + return false; +} + +std::string build_repair_request(const std::string& original_user_message, + const std::string& invalid_output_json, + const std::vector& errors) +{ + std::ostringstream oss; + oss << "repair_request\\n"; + oss << "instruction: output corrected JSON only\\n"; + oss << "target_contract_version: 0.1.0\\n"; + oss << "original_user_message:\\n" << original_user_message << "\\n"; + oss << "invalid_output:\\n" << invalid_output_json << "\\n"; + oss << "validation_errors:\\n"; + for (const std::string& err : errors) + oss << "- " << err << "\\n"; + return oss.str(); +} + +std::string compact_json_value(const json& value) +{ + std::string dumped = value.dump(); + constexpr size_t max_size = 56; + if (dumped.size() <= max_size) + return dumped; + return dumped.substr(0, max_size - 3) + "..."; +} + +std::string compact_status_reason(const std::string& reason) +{ + constexpr size_t max_size = 40; + if (reason.size() <= max_size) + return reason; + return reason.substr(0, max_size - 3) + "..."; +} + +bool is_safe_mode_enabled() +{ + const Slic3r::AppConfig* app_config = wxGetApp().app_config; + if (app_config == nullptr) + return true; + + const std::string configured = app_config->get("ai_safe_mode"); + if (configured.empty()) + return true; + return app_config->get_bool("ai_safe_mode"); +} + +json parse_json_or_string(const std::string& raw_json) +{ + if (raw_json.empty()) + return json(); + try { + return json::parse(raw_json); + } catch (...) { + return raw_json; + } +} + +long parse_long_with_fallback(const std::string& value, long fallback, long min_value, long max_value) +{ + if (value.empty()) + return fallback; + try { + long parsed = std::stol(value); + if (parsed < min_value) + return min_value; + if (parsed > max_value) + return max_value; + return parsed; + } catch (...) { + return fallback; + } +} + +int parse_int_with_fallback(const std::string& value, int fallback, int min_value, int max_value) +{ + if (value.empty()) + return fallback; + try { + int parsed = std::stoi(value); + if (parsed < min_value) + return min_value; + if (parsed > max_value) + return max_value; + return parsed; + } catch (...) { + return fallback; + } +} + +double parse_double_with_fallback(const std::string& value, double fallback, double min_value, double max_value) +{ + if (value.empty()) + return fallback; + try { + double parsed = std::stod(value); + if (parsed < min_value) + return min_value; + if (parsed > max_value) + return max_value; + return parsed; + } catch (...) { + return fallback; + } +} + +std::string load_ai_provider_api_key(const Slic3r::AppConfig* app_config) +{ + if (app_config == nullptr) + return {}; + + std::string api_key = app_config->get("ai_provider_api_key"); + if (!api_key.empty()) + return api_key; + + if (app_config->get("ai_provider_api_key_storage") == "keychain") { + std::string secure_error; + if (Slic3r::AI::Providers::OpenAICompatProvider::load_api_key_securely(api_key, secure_error)) + return api_key; + } + + return {}; +} + +std::string run_selected_provider(const Slic3r::AI::Providers::ProviderRequest& request, + Slic3r::AI::Providers::FakeProvider& fake_provider, + wxString& provider_name) +{ + provider_name = "Fake"; + Slic3r::AppConfig* app_config = wxGetApp().app_config; + if (app_config == nullptr) + return fake_provider.run(request); + + const std::string provider_type = app_config->get("ai_provider_type"); + if (provider_type != "openai_compat") + return fake_provider.run(request); + + Slic3r::AI::Providers::OpenAICompatConfig openai_config; + openai_config.provider_type = provider_type; + openai_config.base_url = app_config->get("ai_provider_base_url"); + openai_config.model = app_config->get("ai_provider_model"); + openai_config.timeout_seconds = parse_long_with_fallback(app_config->get("ai_provider_timeout_seconds"), 30, 5, 300); + openai_config.max_tokens = parse_int_with_fallback(app_config->get("ai_provider_max_tokens"), 600, 1, 4096); + openai_config.temperature = parse_double_with_fallback(app_config->get("ai_provider_temperature"), 0.2, 0.0, 2.0); + openai_config.api_key = load_ai_provider_api_key(app_config); + openai_config.use_json_schema_response_format = + app_config->get("ai_provider_use_json_schema").empty() || app_config->get_bool("ai_provider_use_json_schema"); + + provider_name = "OpenAI-compatible"; + Slic3r::AI::Providers::OpenAICompatProvider openai_provider(std::move(openai_config)); + return openai_provider.run(request); +} + +} // namespace + +AISliceAssistantPanel::AISliceAssistantPanel(wxWindow* parent) + : wxPanel(parent, wxID_ANY) +{ + m_plater = dynamic_cast(parent); + + auto* root_sizer = new wxBoxSizer(wxVERTICAL); + + m_context_card = new wxPanel(this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxBORDER_SIMPLE); + auto* context_card_sizer = new wxBoxSizer(wxVERTICAL); + auto* context_title = new wxStaticText(m_context_card, wxID_ANY, "Context"); + m_context_summary = new wxStaticText(m_context_card, wxID_ANY, ""); + m_context_warning = new wxStaticText(m_context_card, wxID_ANY, ""); + m_change_printer = new wxButton(m_context_card, wxID_ANY, "Change printer"); + m_change_filament = new wxButton(m_context_card, wxID_ANY, "Change filament"); + m_start_over = new wxButton(m_context_card, wxID_ANY, "Start over"); + + auto* context_actions = new wxBoxSizer(wxHORIZONTAL); + context_actions->Add(m_change_printer, 0, wxRIGHT, FromDIP(6)); + context_actions->Add(m_change_filament, 0, wxRIGHT, FromDIP(6)); + context_actions->AddStretchSpacer(1); + context_actions->Add(m_start_over, 0); + + context_card_sizer->Add(context_title, 0, wxLEFT | wxRIGHT | wxTOP, FromDIP(8)); + context_card_sizer->Add(m_context_summary, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, FromDIP(8)); + context_card_sizer->Add(m_context_warning, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, FromDIP(8)); + context_card_sizer->Add(context_actions, 0, wxEXPAND | wxALL, FromDIP(8)); + m_context_card->SetSizer(context_card_sizer); + + m_conversation_card = new wxPanel(this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxBORDER_SIMPLE); + auto* conversation_card_sizer = new wxBoxSizer(wxVERTICAL); + auto* conversation_title = new wxStaticText(m_conversation_card, wxID_ANY, "Conversation"); + m_history = new wxTextCtrl(m_conversation_card, wxID_ANY, "", wxDefaultPosition, wxDefaultSize, wxTE_MULTILINE | wxTE_READONLY); + m_input = new wxTextCtrl(m_conversation_card, wxID_ANY, "", wxDefaultPosition, wxDefaultSize, wxTE_MULTILINE | wxTE_PROCESS_ENTER); + m_send = new wxButton(m_conversation_card, wxID_ANY, "Send"); + m_input->SetMinSize(wxSize(-1, FromDIP(80))); + m_input->Enable(true); + m_input->SetEditable(true); + m_input->Raise(); + + auto* input_row = new wxBoxSizer(wxHORIZONTAL); + input_row->Add(m_input, 1, wxEXPAND | wxRIGHT, FromDIP(6)); + input_row->Add(m_send, 0, wxALIGN_BOTTOM); + + conversation_card_sizer->Add(conversation_title, 0, wxLEFT | wxRIGHT | wxTOP, FromDIP(8)); + conversation_card_sizer->Add(m_history, 1, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, FromDIP(8)); + conversation_card_sizer->Add(input_row, 0, wxEXPAND | wxALL, FromDIP(8)); + m_conversation_card->SetSizer(conversation_card_sizer); + + m_recommendations_card = new wxPanel(this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxBORDER_SIMPLE); + auto* recommendations_card_sizer = new wxBoxSizer(wxVERTICAL); + auto* recommendations_header = new wxBoxSizer(wxHORIZONTAL); + auto* recommendations_label = new wxStaticText(m_recommendations_card, wxID_ANY, "Recommendations"); + m_recommendations_recap = new wxStaticText(m_recommendations_card, wxID_ANY, "Selected: 0 changes"); + recommendations_header->Add(recommendations_label, 0, wxRIGHT, FromDIP(8)); + recommendations_header->AddStretchSpacer(1); + recommendations_header->Add(m_recommendations_recap, 0, wxALIGN_CENTER_VERTICAL); + + m_recommended_changes_list = new wxDataViewListCtrl(m_recommendations_card, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxDV_ROW_LINES | wxDV_VERT_RULES); + m_recommended_changes_list->AppendToggleColumn("Enabled", wxDATAVIEW_CELL_ACTIVATABLE, FromDIP(76), wxALIGN_CENTER, wxDATAVIEW_COL_RESIZABLE); + m_recommended_changes_list->AppendTextColumn("Setting", wxDATAVIEW_CELL_INERT, FromDIP(240), wxALIGN_LEFT, wxDATAVIEW_COL_RESIZABLE); + m_recommended_changes_list->AppendTextColumn("Value", wxDATAVIEW_CELL_INERT, FromDIP(160), wxALIGN_LEFT, wxDATAVIEW_COL_RESIZABLE); + m_recommended_changes_list->AppendTextColumn("Status", wxDATAVIEW_CELL_INERT, FromDIP(200), wxALIGN_LEFT, wxDATAVIEW_COL_RESIZABLE); + m_recommended_changes_list->SetMinSize(wxSize(-1, FromDIP(130))); + + m_change_details = new wxTextCtrl(m_recommendations_card, wxID_ANY, "", wxDefaultPosition, wxDefaultSize, wxTE_MULTILINE | wxTE_READONLY); + m_change_details->SetMinSize(wxSize(-1, FromDIP(95))); + + m_apply = new wxButton(m_recommendations_card, wxID_ANY, "Apply Selected"); + m_undo = new wxButton(m_recommendations_card, wxID_ANY, "Undo Last Apply"); + m_more_options = new wxButton(m_recommendations_card, wxID_ANY, "More options"); + + const wxSize button_min_size = wxSize(FromDIP(120), -1); + m_send->SetMinSize(button_min_size); + m_apply->SetMinSize(button_min_size); + m_undo->SetMinSize(button_min_size); + m_more_options->SetMinSize(button_min_size); + m_change_printer->SetMinSize(button_min_size); + m_change_filament->SetMinSize(button_min_size); + m_start_over->SetMinSize(button_min_size); + + auto* recommendations_actions = new wxBoxSizer(wxHORIZONTAL); + recommendations_actions->Add(m_apply, 0, wxRIGHT, FromDIP(6)); + recommendations_actions->Add(m_undo, 0, wxRIGHT, FromDIP(6)); + recommendations_actions->AddStretchSpacer(1); + recommendations_actions->Add(m_more_options, 0); + + recommendations_card_sizer->Add(recommendations_header, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, FromDIP(8)); + recommendations_card_sizer->Add(m_recommended_changes_list, 1, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, FromDIP(8)); + recommendations_card_sizer->Add(m_change_details, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, FromDIP(8)); + recommendations_card_sizer->Add(recommendations_actions, 0, wxEXPAND | wxALL, FromDIP(8)); + m_recommendations_card->SetSizer(recommendations_card_sizer); + + root_sizer->Add(m_context_card, 0, wxEXPAND | wxALL, FromDIP(8)); + root_sizer->Add(m_conversation_card, 1, wxEXPAND | wxLEFT | wxRIGHT | wxBOTTOM, FromDIP(8)); + root_sizer->Add(m_recommendations_card, 1, wxEXPAND | wxLEFT | wxRIGHT | wxBOTTOM, FromDIP(8)); + SetSizer(root_sizer); + Layout(); + + m_send->Bind(wxEVT_BUTTON, &AISliceAssistantPanel::on_send, this); + m_input->Bind(wxEVT_TEXT_ENTER, &AISliceAssistantPanel::on_send, this); + m_input->Bind(wxEVT_CHAR_HOOK, &AISliceAssistantPanel::on_input_char_hook, this); + m_more_options->Bind(wxEVT_BUTTON, &AISliceAssistantPanel::on_more_options, this); + m_change_printer->Bind(wxEVT_BUTTON, &AISliceAssistantPanel::on_change_printer, this); + m_change_filament->Bind(wxEVT_BUTTON, &AISliceAssistantPanel::on_change_filament, this); + m_start_over->Bind(wxEVT_BUTTON, &AISliceAssistantPanel::on_start_over, this); + m_apply->Bind(wxEVT_BUTTON, &AISliceAssistantPanel::on_apply, this); + m_undo->Bind(wxEVT_BUTTON, &AISliceAssistantPanel::on_undo, this); + m_recommended_changes_list->Bind(wxEVT_DATAVIEW_SELECTION_CHANGED, &AISliceAssistantPanel::on_change_list_event, this); + m_recommended_changes_list->Bind(wxEVT_DATAVIEW_ITEM_VALUE_CHANGED, &AISliceAssistantPanel::on_change_list_event, this); + Bind(wxEVT_SHOW, &AISliceAssistantPanel::on_panel_show, this); + Bind(wxEVT_SIZE, &AISliceAssistantPanel::on_panel_size, this); + + CallAfter([this]() { + refresh_context_card(); + update_recommendations_recap(); + if (m_input != nullptr) { + m_input->Enable(true); + m_input->SetEditable(true); + Layout(); + m_input->SetFocus(); + } + }); +} + +void AISliceAssistantPanel::on_send(wxCommandEvent& event) +{ + wxUnusedVar(event); + + if (m_input == nullptr || m_history == nullptr) + return; + + const wxString message = m_input->GetValue().Trim().Trim(false); + if (message.empty()) + return; + + append_history_line("You: " + message); + + const Slic3r::AI::Providers::ProviderRequest request{ + message.ToStdString(), + m_last_context_snapshot_json, + m_last_geometry_insights_json + }; + wxString provider_name; + std::string provider_output = run_selected_provider(request, m_fake_provider, provider_name); + Slic3r::AI::Validation::ValidationResult validation = m_response_validator.validate(provider_output); + m_last_validation_errors.clear(); + if (!validation.valid) + m_last_validation_errors.insert(m_last_validation_errors.end(), validation.errors.begin(), validation.errors.end()); + + bool repaired = false; + int repairs_attempted = 0; + while (!validation.valid && repairs_attempted < 2) { + ++repairs_attempted; + const Slic3r::AI::Providers::ProviderRequest repair_request{ + build_repair_request(message.ToStdString(), provider_output, validation.errors), + m_last_context_snapshot_json, + m_last_geometry_insights_json + }; + provider_output = run_selected_provider(repair_request, m_fake_provider, provider_name); + validation = m_response_validator.validate(provider_output); + if (!validation.valid) + m_last_validation_errors.insert(m_last_validation_errors.end(), validation.errors.begin(), validation.errors.end()); + } + repaired = validation.valid && repairs_attempted > 0; + + m_last_ai_response_json = provider_output; + if (validation.valid) { + try { + const nlohmann::json parsed = nlohmann::json::parse(m_last_ai_response_json); + const std::string summary = parsed.value("summary", "Reponse provider recue."); + append_history_line("Assistant (" + provider_name + "): " + wxString::FromUTF8(summary.c_str())); + populate_recommendations_from_response(parsed); + } catch (...) { + clear_recommendations(); + append_history_line("Assistant: reponse provider invalide."); + } + append_history_line(repaired ? "Repaired" : "Valid"); + } else { + clear_recommendations(); + append_history_line("Rejected"); + for (const std::string& error : validation.errors) + append_history_line("Validation error: " + wxString::FromUTF8(error.c_str())); + } + + m_input->Clear(); + refresh_context_card(); +} + +void AISliceAssistantPanel::on_input_char_hook(wxKeyEvent& event) +{ + if (m_input == nullptr) { + event.Skip(); + return; + } + + const int key_code = event.GetKeyCode(); + const bool is_enter = key_code == WXK_RETURN || key_code == WXK_NUMPAD_ENTER; + if (!is_enter) { + event.Skip(); + return; + } + + const bool is_multiline = (m_input->GetWindowStyleFlag() & wxTE_MULTILINE) != 0; + const bool shift_down = event.ShiftDown(); +#ifdef __WXOSX__ + const bool cmd_or_ctrl_down = event.CmdDown(); +#else + const bool cmd_or_ctrl_down = event.ControlDown(); +#endif + + if (cmd_or_ctrl_down || !is_multiline || !shift_down) { + wxCommandEvent send_event(wxEVT_BUTTON, m_send ? m_send->GetId() : wxID_ANY); + send_event.SetEventObject(m_send); + on_send(send_event); + return; + } + + event.Skip(); +} + +void AISliceAssistantPanel::on_panel_show(wxShowEvent& event) +{ + refresh_context_card(); + if (event.IsShown() && m_input != nullptr) { + m_input->Enable(true); + m_input->SetEditable(true); + m_input->Raise(); + Layout(); + CallAfter([this]() { + if (m_input != nullptr) { + Layout(); + m_input->SetFocus(); + } + }); + } + event.Skip(); +} + +void AISliceAssistantPanel::on_panel_size(wxSizeEvent& event) +{ + Layout(); + event.Skip(); +} + +void AISliceAssistantPanel::on_copy_context(wxCommandEvent& event) +{ + wxUnusedVar(event); + + if (m_plater == nullptr) { + append_history_line("System: unavailable context (plater not found)."); + return; + } + + m_last_context_snapshot_json = Slic3r::AI::build_context_snapshot_json(*m_plater); + if (m_last_context_snapshot_json.empty()) { + append_history_line("System: failed to generate context snapshot."); + return; + } + + if (wxTheClipboard->Open()) { + wxTheClipboard->SetData(new wxTextDataObject(wxString::FromUTF8(m_last_context_snapshot_json.c_str()))); + wxTheClipboard->Close(); + append_history_line("System: context snapshot copied to clipboard."); + } else { + append_history_line("System: unable to access clipboard."); + } + refresh_context_card(); +} + +void AISliceAssistantPanel::on_copy_last_json(wxCommandEvent& event) +{ + wxUnusedVar(event); + + if (m_last_ai_response_json.empty()) { + append_history_line("System: no provider JSON available yet."); + return; + } + + if (wxTheClipboard->Open()) { + wxTheClipboard->SetData(new wxTextDataObject(wxString::FromUTF8(m_last_ai_response_json.c_str()))); + wxTheClipboard->Close(); + append_history_line("System: last provider JSON copied to clipboard."); + } else { + append_history_line("System: unable to access clipboard."); + } + refresh_context_card(); +} + +void AISliceAssistantPanel::on_export_debug_bundle(wxCommandEvent& event) +{ + wxUnusedVar(event); + + wxFileDialog save_dialog( + this, + "Export AI Debug Bundle", + "", + "ai_debug_bundle.json", + "JSON files (*.json)|*.json|All files (*.*)|*.*", + wxFD_SAVE | wxFD_OVERWRITE_PROMPT); + if (save_dialog.ShowModal() != wxID_OK) + return; + + nlohmann::json bundle = nlohmann::json::object(); + bundle["last_context_snapshot_json"] = parse_json_or_string(m_last_context_snapshot_json); + bundle["last_geometry_insights_json"] = parse_json_or_string(m_last_geometry_insights_json); + bundle["last_ai_response_json"] = parse_json_or_string(m_last_ai_response_json); + bundle["validation_errors"] = m_last_validation_errors; + bundle["app"] = nlohmann::json::object({ + {"name", SLIC3R_APP_NAME}, + {"version", SLIC3R_VERSION}, + {"platform", wxGetOsDescription().ToStdString()} + }); + + const wxString path = save_dialog.GetPath(); + std::ofstream out(path.ToStdString(), std::ios::out | std::ios::trunc); + if (!out.good()) { + append_history_line("System: failed to write debug bundle file."); + return; + } + out << bundle.dump(2); + out.close(); + append_history_line("System: debug bundle exported to " + path); + refresh_context_card(); +} + +void AISliceAssistantPanel::on_change_printer(wxCommandEvent& event) +{ + wxUnusedVar(event); + open_preset_tab(Preset::TYPE_PRINTER); +} + +void AISliceAssistantPanel::on_change_filament(wxCommandEvent& event) +{ + wxUnusedVar(event); + open_preset_tab(Preset::TYPE_FILAMENT); +} + +void AISliceAssistantPanel::on_start_over(wxCommandEvent& event) +{ + wxUnusedVar(event); + + m_last_context_snapshot_json.clear(); + m_last_geometry_insights_json.clear(); + m_last_ai_response_json.clear(); + m_last_validation_errors.clear(); + + clear_recommendations(); + if (m_history != nullptr) + m_history->Clear(); + if (m_input != nullptr) { + m_input->Clear(); + m_input->SetFocus(); + } + + refresh_context_card(); +} + +void AISliceAssistantPanel::on_apply(wxCommandEvent& event) +{ + wxUnusedVar(event); + + size_t applied_count = 0; + std::vector errors; + if (apply_selected_changes_atomically(applied_count, errors)) { + append_history_line(wxString::Format("Applied %u changes", static_cast(applied_count))); + } else { + append_history_line("Error list:"); + for (const std::string& err : errors) + append_history_line("- " + wxString::FromUTF8(err.c_str())); + } + refresh_context_card(); +} + +void AISliceAssistantPanel::on_undo(wxCommandEvent& event) +{ + wxUnusedVar(event); + + std::string error; + if (undo_last_apply_atomically(error)) { + append_history_line("Undo successful"); + } else { + append_history_line("Error list:"); + append_history_line("- " + wxString::FromUTF8(error.c_str())); + } + refresh_context_card(); +} + +void AISliceAssistantPanel::on_more_options(wxCommandEvent& event) +{ + wxUnusedVar(event); + + wxMenu menu; + menu.Append(ID_AI_MORE_COPY_CONTEXT, "Copy Context"); + menu.Append(ID_AI_MORE_COPY_LAST_JSON, "Copy Last JSON"); + menu.Append(ID_AI_MORE_EXPORT_DEBUG, "Export Debug Bundle"); + menu.AppendSeparator(); + menu.AppendCheckItem(ID_AI_MORE_SAFE_MODE, "Safe Mode"); + menu.Check(ID_AI_MORE_SAFE_MODE, is_safe_mode_enabled()); + + const int selected = GetPopupMenuSelectionFromUser(menu); + if (selected == wxID_NONE) + return; + + wxCommandEvent forwarded_event; + switch (selected) { + case ID_AI_MORE_COPY_CONTEXT: + on_copy_context(forwarded_event); + break; + case ID_AI_MORE_COPY_LAST_JSON: + on_copy_last_json(forwarded_event); + break; + case ID_AI_MORE_EXPORT_DEBUG: + on_export_debug_bundle(forwarded_event); + break; + case ID_AI_MORE_SAFE_MODE: + if (Slic3r::AppConfig* app_config = wxGetApp().app_config) { + const bool new_value = !is_safe_mode_enabled(); + app_config->set_bool("ai_safe_mode", new_value); + app_config->save(); + append_history_line(wxString::Format("System: Safe Mode %s.", new_value ? "enabled" : "disabled")); + if (!m_last_ai_response_json.empty()) { + try { + populate_recommendations_from_response(nlohmann::json::parse(m_last_ai_response_json)); + } catch (...) { + // Keep existing state if current payload cannot be parsed. + } + } + } + break; + default: + break; + } +} + +void AISliceAssistantPanel::on_change_list_event(wxDataViewEvent& event) +{ + int index = wxNOT_FOUND; + if (event.GetItem().IsOk()) + index = m_recommended_changes_list->ItemToRow(event.GetItem()); + if (index == wxNOT_FOUND) + index = m_recommended_changes_list->GetSelectedRow(); + update_change_details(index); + update_recommendations_recap(); + event.Skip(); +} + +void AISliceAssistantPanel::append_history_line(const wxString& line) +{ + if (m_history == nullptr) + return; + + if (!m_history->IsEmpty()) + m_history->AppendText("\n"); + m_history->AppendText(line); +} + +void AISliceAssistantPanel::refresh_context_card() +{ + if (m_context_summary == nullptr || m_context_warning == nullptr || m_context_card == nullptr) + return; + + std::string printer_name = "Unknown"; + std::string filament_name = "Unknown"; + std::string process_name = "Unknown"; + + bool printer_missing = true; + bool filament_missing = true; + + const auto* bundle = wxGetApp().preset_bundle; + if (bundle != nullptr) { + printer_name = bundle->printers.get_edited_preset().name; + if (is_missing_preset_name(printer_name)) + printer_name = "Unknown"; + printer_missing = (printer_name == "Unknown"); + + for (const std::string& candidate : bundle->filament_presets) { + if (!is_missing_preset_name(candidate)) { + filament_name = candidate; + break; + } + } + if (is_missing_preset_name(filament_name)) + filament_name = "Unknown"; + filament_missing = (filament_name == "Unknown"); + + process_name = bundle->prints.get_edited_preset().name; + if (is_missing_preset_name(process_name)) + process_name = bundle->sla_prints.get_edited_preset().name; + if (is_missing_preset_name(process_name)) + process_name = "Unknown"; + } + + std::ostringstream summary; + summary << "Printer: " << printer_name << "\n"; + summary << "Filament: " << filament_name << "\n"; + summary << "Preset: " << process_name; + m_context_summary->SetLabel(wxString::FromUTF8(summary.str().c_str())); + + std::vector warnings; + bool no_models_on_plate = false; + if (!m_last_context_snapshot_json.empty()) { + try { + const nlohmann::json context_json = nlohmann::json::parse(m_last_context_snapshot_json); + if (context_json.contains("project") && context_json.at("project").is_object()) { + const nlohmann::json& project = context_json.at("project"); + if (project.contains("object_count") && project.at("object_count").is_number_integer()) + no_models_on_plate = project.at("object_count").get() <= 0; + } + } catch (...) { + // Keep context card resilient to malformed cached JSON. + } + } + if (no_models_on_plate) + warnings.emplace_back("No models on the plate. Add a model to see the magic."); + if (printer_missing) + warnings.emplace_back("Printer preset is missing."); + if (filament_missing) + warnings.emplace_back("Filament preset is missing."); + + if (warnings.empty()) { + m_context_warning->Show(false); + m_context_warning->SetLabel(""); + } else { + std::ostringstream warning_lines; + for (size_t i = 0; i < warnings.size(); ++i) { + if (i > 0) + warning_lines << "\n"; + warning_lines << warnings[i]; + } + m_context_warning->SetLabel(wxString::FromUTF8(warning_lines.str().c_str())); + m_context_warning->Show(true); + } + + const bool has_session_state = !m_last_context_snapshot_json.empty() || + !m_last_geometry_insights_json.empty() || + !m_last_ai_response_json.empty() || + !m_last_validation_errors.empty() || + !m_recommended_changes.empty() || + (m_history != nullptr && !m_history->IsEmpty()); + if (m_change_printer != nullptr) + m_change_printer->Show(printer_missing); + if (m_change_filament != nullptr) + m_change_filament->Show(filament_missing); + if (m_start_over != nullptr) + m_start_over->Show(has_session_state); + + m_context_card->Layout(); + Layout(); +} + +void AISliceAssistantPanel::clear_recommendations() +{ + m_recommended_changes.clear(); + if (m_recommended_changes_list) + m_recommended_changes_list->DeleteAllItems(); + if (m_change_details) + m_change_details->Clear(); + update_recommendations_recap(); + refresh_context_card(); +} + +void AISliceAssistantPanel::populate_recommendations_from_response(const nlohmann::json& response_json) +{ + clear_recommendations(); + + if (!response_json.contains("recommended_changes") || !response_json.at("recommended_changes").is_array()) + return; + + const bool safe_mode = is_safe_mode_enabled(); + + for (const auto& item : response_json.at("recommended_changes")) { + if (safe_mode && m_recommended_changes.size() >= 10) + break; + if (!item.is_object()) + continue; + + RecommendedChange change; + change.id = item.value("id", ""); + change.key = item.value("key", ""); + if (item.contains("value")) + change.value = item.at("value"); + change.value_type = item.value("value_type", ""); + change.reason = item.value("reason", ""); + change.confidence = item.value("confidence", 0.0); + change.applies_to = item.value("applies_to", ""); + change.requires_user_confirmation = item.value("requires_user_confirmation", false); + if (item.contains("impact") && item.at("impact").is_object()) { + change.quality = item.at("impact").value("quality", 0); + change.time = item.at("impact").value("time", 0); + change.risk = item.at("impact").value("risk", 0); + } + if (item.contains("tags") && item.at("tags").is_array()) { + for (const auto& tag : item.at("tags")) { + if (tag.is_string()) + change.tags.push_back(tag.get()); + } + } + if (change.key.empty()) + continue; + + if (!Slic3r::AI::Apply::AllowlistRegistry::is_allowed(change.key)) { + change.blocked = true; + change.blocked_reason = "not allowlisted"; + } else { + if (safe_mode && Slic3r::AI::Apply::AllowlistRegistry::has_tag(change.key, "high-risk")) { + change.blocked = true; + change.blocked_reason = "blocked by safe mode (high-risk key)"; + } + + if (safe_mode && Slic3r::AI::Apply::AllowlistRegistry::has_any_tag(change.key, {"temperature", "flow", "speed"})) + change.requires_user_confirmation = true; + + const auto value_validation = Slic3r::AI::Apply::AllowlistRegistry::validate_value(change.key, change.value); + if (!value_validation.valid) { + change.blocked = true; + change.blocked_reason = value_validation.error_message.empty() ? "invalid value" : value_validation.error_message; + } + } + + m_recommended_changes.push_back(std::move(change)); + } + + for (size_t i = 0; i < m_recommended_changes.size(); ++i) { + const auto& change = m_recommended_changes[i]; + const std::string label = Slic3r::AI::Apply::AllowlistRegistry::label_for(change.key); + const std::string setting = label.empty() ? change.key : (label + " [" + change.key + "]"); + const std::string value = compact_json_value(change.value); + std::string status = "OK"; + if (change.blocked) { + status = "BLOCKED"; + if (!change.blocked_reason.empty()) + status += ": " + compact_status_reason(change.blocked_reason); + } + + wxVector row; + row.push_back(wxVariant(!change.blocked)); + row.push_back(wxVariant(wxString::FromUTF8(setting.c_str()))); + row.push_back(wxVariant(wxString::FromUTF8(value.c_str()))); + row.push_back(wxVariant(wxString::FromUTF8(status.c_str()))); + m_recommended_changes_list->AppendItem(row); + } + + if (!m_recommended_changes.empty()) { + m_recommended_changes_list->SelectRow(0); + update_change_details(0); + } + update_recommendations_recap(); +} + +void AISliceAssistantPanel::update_recommendations_recap() +{ + if (m_recommendations_recap == nullptr) return; + + unsigned int selected_count = 0; + unsigned int safe_count = 0; + if (m_recommended_changes_list != nullptr) { + const unsigned int row_count = static_cast(m_recommended_changes_list->GetItemCount()); + for (unsigned int i = 0; i < row_count; ++i) { + wxVariant selected_variant; + m_recommended_changes_list->GetValue(selected_variant, i, 0); + if (!selected_variant.GetBool()) + continue; + + ++selected_count; + const size_t idx = static_cast(i); + if (idx < m_recommended_changes.size()) { + const RecommendedChange& change = m_recommended_changes[idx]; + if (!change.blocked && !change.requires_user_confirmation) + ++safe_count; + } + } + } + + wxString recap = wxString::Format("Selected: %u changes", selected_count); + if (is_safe_mode_enabled()) + recap += wxString::Format(" | Safe: %u changes", safe_count); + m_recommendations_recap->SetLabel(recap); +} + +void AISliceAssistantPanel::update_change_details(int index) +{ + if (m_change_details == nullptr) + return; + + if (index < 0 || index >= static_cast(m_recommended_changes.size())) { + m_change_details->Clear(); + return; + } + + const RecommendedChange& change = m_recommended_changes[static_cast(index)]; + const std::string label = Slic3r::AI::Apply::AllowlistRegistry::label_for(change.key); + const std::string safety = Slic3r::AI::Apply::AllowlistRegistry::safety_notes_for(change.key); + + std::ostringstream details; + details << "Key: " << change.key << "\\n"; + if (!label.empty()) + details << "Label: " << label << "\\n"; + details << "Scope: " << change.applies_to << "\\n"; + details << "Type: " << change.value_type << "\\n"; + details << "Value: " << change.value.dump() << "\\n"; + details << "Reason: " << change.reason << "\\n"; + details << "Impact (Q/T/R): " << change.quality << "/" << change.time << "/" << change.risk << "\\n"; + details << "Confidence: " << std::setprecision(3) << change.confidence << "\\n"; + details << "User confirmation: " << (change.requires_user_confirmation ? "yes" : "no") << "\\n"; + details << "Blocked: " << (change.blocked ? "yes" : "no") << "\\n"; + if (change.blocked && !change.blocked_reason.empty()) + details << "Blocked reason: " << change.blocked_reason << "\\n"; + if (!change.tags.empty()) { + details << "Tags: "; + for (size_t i = 0; i < change.tags.size(); ++i) { + if (i > 0) + details << ", "; + details << change.tags[i]; + } + details << "\\n"; + } + if (!safety.empty()) + details << "Safety: " << safety << "\\n"; + + m_change_details->SetValue(wxString::FromUTF8(details.str().c_str())); +} + +bool AISliceAssistantPanel::apply_selected_changes_atomically(size_t& applied_count, std::vector& error_list) +{ + applied_count = 0; + error_list.clear(); + + if (m_plater == nullptr) { + error_list.emplace_back("plater unavailable"); + return false; + } + + if (m_recommended_changes.empty()) { + error_list.emplace_back("no recommendations to apply"); + return false; + } + + std::vector prepared; + prepared.reserve(m_recommended_changes.size()); + + auto* bundle = wxGetApp().preset_bundle; + if (bundle == nullptr) { + error_list.emplace_back("preset bundle unavailable"); + return false; + } + + const int selected_object_idx = m_plater->get_selected_object_idx(); + bool has_checked_changes = false; + + for (size_t i = 0; i < m_recommended_changes.size(); ++i) { + wxVariant enabled_variant; + m_recommended_changes_list->GetValue(enabled_variant, static_cast(i), 0); + if (!enabled_variant.GetBool()) + continue; + has_checked_changes = true; + + const RecommendedChange& change = m_recommended_changes[i]; + + if (!Slic3r::AI::Apply::AllowlistRegistry::is_allowed(change.key)) { + error_list.emplace_back("key not allowed: " + change.key); + continue; + } + const auto validation = Slic3r::AI::Apply::AllowlistRegistry::validate_value(change.key, change.value); + if (!validation.valid) { + std::string msg = "invalid value for key: " + change.key; + if (!validation.error_message.empty()) + msg += " (" + validation.error_message + ")"; + error_list.push_back(std::move(msg)); + continue; + } + + ApplyScope scope; + if (!scope_from_string(change.applies_to, scope)) { + error_list.emplace_back("unknown scope: " + change.applies_to + " for key: " + change.key); + continue; + } + + ConfigBase* target_config = nullptr; + int object_idx = -1; + if (scope == ApplyScope::Global) { + target_config = &bundle->project_config; + } else if (scope == ApplyScope::Profile) { + target_config = (m_plater->printer_technology() == ptFFF) + ? static_cast(&bundle->prints.get_edited_preset().config) + : static_cast(&bundle->sla_prints.get_edited_preset().config); + } else { + object_idx = selected_object_idx; + if (object_idx < 0 || object_idx >= static_cast(m_plater->model().objects.size())) { + error_list.emplace_back("object scope requested but no object is selected for key: " + change.key); + continue; + } + ModelObject* object = m_plater->model().objects[static_cast(object_idx)]; + if (object == nullptr) { + error_list.emplace_back("selected object not available for key: " + change.key); + continue; + } + target_config = static_cast(const_cast(&object->config.get())); + } + + if (target_config == nullptr || !target_config->has(change.key)) { + error_list.emplace_back("target does not support key: " + change.key); + continue; + } + + std::string serialized_new; + if (!serialize_value_for_setting(change.value, change.value_type, serialized_new)) { + error_list.emplace_back("cannot serialize value for key: " + change.key); + continue; + } + + bool merged = false; + for (PreparedOperation& existing : prepared) { + if (existing.scope == scope && existing.object_idx == object_idx && existing.key == change.key) { + existing.serialized_new_value = serialized_new; + merged = true; + break; + } + } + if (!merged) { + prepared.push_back(PreparedOperation{scope, object_idx, change.key, serialized_new, target_config}); + } + } + + if (!has_checked_changes) { + error_list.emplace_back("no checked changes"); + return false; + } + if (!error_list.empty()) + return false; + if (prepared.empty()) { + error_list.emplace_back("no valid changes to apply"); + return false; + } + + std::vector backups; + backups.reserve(prepared.size()); + for (const PreparedOperation& op : prepared) { + const ConfigOption* original = op.target_config->option(op.key); + if (original == nullptr) { + error_list.emplace_back("cannot snapshot key: " + op.key); + return false; + } + backups.push_back(AppliedValueBackup{op.scope, op.object_idx, op.key, original->serialize()}); + } + + for (const PreparedOperation& op : prepared) { + try { + op.target_config->set_deserialize_strict(op.key, op.serialized_new_value); + } catch (const std::exception& ex) { + for (const AppliedValueBackup& backup : backups) { + ConfigBase* rollback_config = nullptr; + if (backup.scope == ApplyScope::Global) { + rollback_config = &bundle->project_config; + } else if (backup.scope == ApplyScope::Profile) { + rollback_config = (m_plater->printer_technology() == ptFFF) + ? static_cast(&bundle->prints.get_edited_preset().config) + : static_cast(&bundle->sla_prints.get_edited_preset().config); + } else if (backup.object_idx >= 0 && backup.object_idx < static_cast(m_plater->model().objects.size())) { + ModelObject* object = m_plater->model().objects[static_cast(backup.object_idx)]; + if (object != nullptr) + rollback_config = static_cast(const_cast(&object->config.get())); + } + if (rollback_config != nullptr) { + try { + rollback_config->set_deserialize_strict(backup.key, backup.serialized_value); + } catch (...) {} + } + } + error_list.push_back(std::string("apply failed on key '") + op.key + "': " + ex.what()); + return false; + } + } + + m_last_apply_backups = std::move(backups); + m_last_apply_touched_global_or_profile = false; + m_last_apply_touched_object = false; + m_last_apply_object_idx = -1; + + for (const PreparedOperation& op : prepared) { + if (op.scope == ApplyScope::Object) { + m_last_apply_touched_object = true; + m_last_apply_object_idx = op.object_idx; + } else { + m_last_apply_touched_global_or_profile = true; + } + } + + refresh_plater_after_changes(m_last_apply_touched_global_or_profile, m_last_apply_touched_object, m_last_apply_object_idx); + applied_count = prepared.size(); + return true; +} + +bool AISliceAssistantPanel::undo_last_apply_atomically(std::string& error_message) +{ + if (m_plater == nullptr) { + error_message = "plater unavailable"; + return false; + } + if (m_last_apply_backups.empty()) { + error_message = "no applied snapshot to undo"; + return false; + } + + auto* bundle = wxGetApp().preset_bundle; + if (bundle == nullptr) { + error_message = "preset bundle unavailable"; + return false; + } + + std::vector> targets; + targets.reserve(m_last_apply_backups.size()); + + for (const AppliedValueBackup& backup : m_last_apply_backups) { + ConfigBase* target_config = nullptr; + if (backup.scope == ApplyScope::Global) { + target_config = &bundle->project_config; + } else if (backup.scope == ApplyScope::Profile) { + target_config = (m_plater->printer_technology() == ptFFF) + ? static_cast(&bundle->prints.get_edited_preset().config) + : static_cast(&bundle->sla_prints.get_edited_preset().config); + } else { + if (backup.object_idx < 0 || backup.object_idx >= static_cast(m_plater->model().objects.size())) { + error_message = "object snapshot no longer valid"; + return false; + } + ModelObject* object = m_plater->model().objects[static_cast(backup.object_idx)]; + if (object == nullptr) { + error_message = "object snapshot missing"; + return false; + } + target_config = static_cast(const_cast(&object->config.get())); + } + + if (target_config == nullptr || !target_config->has(backup.key)) { + error_message = "cannot restore key: " + backup.key; + return false; + } + + targets.emplace_back(target_config, backup); + } + + for (const auto& pair : targets) { + try { + pair.first->set_deserialize_strict(pair.second.key, pair.second.serialized_value); + } catch (const std::exception& ex) { + error_message = std::string("restore failed on key '") + pair.second.key + "': " + ex.what(); + return false; + } + } + + refresh_plater_after_changes(m_last_apply_touched_global_or_profile, m_last_apply_touched_object, m_last_apply_object_idx); + m_last_apply_backups.clear(); + m_last_apply_touched_global_or_profile = false; + m_last_apply_touched_object = false; + m_last_apply_object_idx = -1; + return true; +} + +void AISliceAssistantPanel::refresh_plater_after_changes(bool touched_global_or_profile, bool touched_object, int object_idx) +{ + if (m_plater == nullptr) + return; + + if (touched_global_or_profile && wxGetApp().preset_bundle != nullptr) + m_plater->on_config_change(wxGetApp().preset_bundle->full_config()); + + if (touched_object) + m_plater->changed_object(object_idx); +} + +} // namespace GUI +} // namespace Slic3r diff --git a/src/slic3r/GUI/AI/AISliceAssistantPanel.hpp b/src/slic3r/GUI/AI/AISliceAssistantPanel.hpp new file mode 100644 index 00000000000..e07aa24f99e --- /dev/null +++ b/src/slic3r/GUI/AI/AISliceAssistantPanel.hpp @@ -0,0 +1,131 @@ +#ifndef slic3r_GUI_AISliceAssistantPanel_hpp_ +#define slic3r_GUI_AISliceAssistantPanel_hpp_ + +#include +#include + +#include "../../../ai/apply/allowlist_registry.h" +#include "../../../ai/providers/fake_provider.h" +#include "../../../ai/validation/response_validator.h" +#include "nlohmann/json.hpp" +#include +#include + +class wxButton; +class wxDataViewEvent; +class wxDataViewListCtrl; +class wxPanel; +class wxStaticText; +class wxTextCtrl; +class wxCommandEvent; +class wxKeyEvent; +class wxShowEvent; +class wxSizeEvent; +class wxString; + +namespace Slic3r { +namespace GUI { + +class Plater; + +class AISliceAssistantPanel : public wxPanel +{ +public: + explicit AISliceAssistantPanel(wxWindow* parent); + +private: + struct RecommendedChange + { + std::string id; + std::string key; + nlohmann::json value; + std::string value_type; + std::string reason; + int quality { 0 }; + int time { 0 }; + int risk { 0 }; + double confidence { 0.0 }; + std::string applies_to; + std::vector tags; + bool requires_user_confirmation { false }; + bool blocked { false }; + std::string blocked_reason; + }; + +public: + enum class ApplyScope + { + Global, + Profile, + Object + }; + + struct AppliedValueBackup + { + ApplyScope scope { ApplyScope::Global }; + int object_idx { -1 }; + std::string key; + std::string serialized_value; + }; + +private: + void on_send(wxCommandEvent& event); + void on_input_char_hook(wxKeyEvent& event); + void on_panel_show(wxShowEvent& event); + void on_panel_size(wxSizeEvent& event); + void on_copy_context(wxCommandEvent& event); + void on_copy_last_json(wxCommandEvent& event); + void on_export_debug_bundle(wxCommandEvent& event); + void on_change_printer(wxCommandEvent& event); + void on_change_filament(wxCommandEvent& event); + void on_start_over(wxCommandEvent& event); + void on_apply(wxCommandEvent& event); + void on_undo(wxCommandEvent& event); + void on_more_options(wxCommandEvent& event); + void on_change_list_event(wxDataViewEvent& event); + + void append_history_line(const wxString& line); + void clear_recommendations(); + void populate_recommendations_from_response(const nlohmann::json& response_json); + void update_change_details(int index); + bool apply_selected_changes_atomically(size_t& applied_count, std::vector& error_list); + bool undo_last_apply_atomically(std::string& error_message); + void refresh_plater_after_changes(bool touched_global_or_profile, bool touched_object, int object_idx); + void refresh_context_card(); + void update_recommendations_recap(); + + Plater* m_plater { nullptr }; + wxPanel* m_context_card { nullptr }; + wxPanel* m_conversation_card { nullptr }; + wxPanel* m_recommendations_card { nullptr }; + wxStaticText* m_context_summary { nullptr }; + wxStaticText* m_context_warning { nullptr }; + wxStaticText* m_recommendations_recap { nullptr }; + wxButton* m_change_printer { nullptr }; + wxButton* m_change_filament { nullptr }; + wxButton* m_start_over { nullptr }; + wxTextCtrl* m_history { nullptr }; + wxDataViewListCtrl* m_recommended_changes_list { nullptr }; + wxTextCtrl* m_change_details { nullptr }; + wxTextCtrl* m_input { nullptr }; + wxButton* m_send { nullptr }; + wxButton* m_more_options { nullptr }; + wxButton* m_apply { nullptr }; + wxButton* m_undo { nullptr }; + std::string m_last_context_snapshot_json; + std::string m_last_geometry_insights_json; + std::string m_last_ai_response_json; + std::vector m_last_validation_errors; + std::vector m_recommended_changes; + std::vector m_last_apply_backups; + bool m_last_apply_touched_global_or_profile { false }; + bool m_last_apply_touched_object { false }; + int m_last_apply_object_idx { -1 }; + Slic3r::AI::Providers::FakeProvider m_fake_provider; + Slic3r::AI::Validation::ResponseValidator m_response_validator; +}; + +} // namespace GUI +} // namespace Slic3r + +#endif // slic3r_GUI_AISliceAssistantPanel_hpp_ diff --git a/src/slic3r/GUI/MainFrame.cpp b/src/slic3r/GUI/MainFrame.cpp index 8e74eda9268..9ca48468b3f 100644 --- a/src/slic3r/GUI/MainFrame.cpp +++ b/src/slic3r/GUI/MainFrame.cpp @@ -2825,6 +2825,15 @@ void MainFrame::init_menubar_as_editor() this, [this]() { return m_tabpanel->GetSelection() == TabPosition::tp3DEditor || m_tabpanel->GetSelection() == TabPosition::tpPreview; }, [this]() { return wxGetApp().show_3d_navigator(); }, this); + append_menu_check_item( + viewMenu, wxID_ANY, _L("AI Slice Assistant"), _L("Show AI Slice Assistant panel."), + [this](wxCommandEvent&) { + if (m_plater) + m_plater->toggle_ai_slice_assistant_panel(); + }, + this, [this]() { return m_plater != nullptr; }, + [this]() { return m_plater != nullptr && m_plater->is_ai_slice_assistant_panel_shown(); }, this); + append_menu_item( viewMenu, wxID_ANY, _L("Reset Window Layout"), _L("Reset to default window layout"), [this](wxCommandEvent&) { m_plater->reset_window_layout(); }, "", this, diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 590f842aa4f..aa96de54c77 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -162,6 +162,7 @@ #include "StepMeshDialog.hpp" #include "FilamentMapDialog.hpp" #include "CloneDialog.hpp" +#include "AI/AISliceAssistantPanel.hpp" #include "DeviceCore/DevFilaSystem.h" #include "DeviceCore/DevManager.h" @@ -4181,6 +4182,7 @@ struct Plater::priv // PIMPL back pointer ("Q-Pointer") Plater *q; Sidebar * sidebar; + AISliceAssistantPanel* ai_slice_assistant_panel { nullptr }; MainFrame *main_frame; MenuFactory menus; @@ -4352,6 +4354,8 @@ struct Plater::priv void enable_sidebar(bool enabled); void collapse_sidebar(bool collapse); void update_sidebar(bool force_update = false); + void show_ai_slice_assistant_panel(bool show); + bool is_ai_slice_assistant_panel_shown(); void reset_window_layout(); Sidebar::DockingState get_sidebar_docking_state(); @@ -4876,6 +4880,8 @@ Plater::priv::priv(Plater *q, MainFrame *main_frame) update(); + ai_slice_assistant_panel = new AISliceAssistantPanel(q); + // Orca: Make sidebar dockable m_aui_mgr.AddPane(sidebar, wxAuiPaneInfo() .Name("sidebar") @@ -4885,6 +4891,16 @@ Plater::priv::priv(Plater *q, MainFrame *main_frame) .BottomDockable(false) .Floatable(true) .BestSize(wxSize(42 * wxGetApp().em_unit(), 90 * wxGetApp().em_unit()))); + m_aui_mgr.AddPane(ai_slice_assistant_panel, wxAuiPaneInfo() + .Name("ai_slice_assistant") + .Caption(_L("AI Slice Assistant")) + .Right() + .CloseButton(true) + .TopDockable(false) + .BottomDockable(false) + .Floatable(true) + .BestSize(wxSize(30 * wxGetApp().em_unit(), 36 * wxGetApp().em_unit())) + .Hide()); auto* panel_sizer = new wxBoxSizer(wxHORIZONTAL); panel_sizer->Add(view3D, 1, wxEXPAND | wxALL, 0); @@ -5549,6 +5565,22 @@ void Plater::priv::update_sidebar(bool force_update) { } } +void Plater::priv::show_ai_slice_assistant_panel(bool show) +{ + auto& pane = m_aui_mgr.GetPane(ai_slice_assistant_panel); + if (!pane.IsOk()) + return; + + pane.Show(show); + m_aui_mgr.Update(); +} + +bool Plater::priv::is_ai_slice_assistant_panel_shown() +{ + auto& pane = m_aui_mgr.GetPane(ai_slice_assistant_panel); + return pane.IsOk() && pane.IsShown(); +} + void Plater::priv::reset_window_layout() { m_aui_mgr.LoadPerspective(m_default_window_layout, false); @@ -14082,6 +14114,9 @@ void Plater::enable_sidebar(bool enabled) { p->enable_sidebar(enabled); } bool Plater::is_sidebar_collapsed() const { return p->sidebar_layout.is_collapsed; } void Plater::collapse_sidebar(bool collapse) { p->collapse_sidebar(collapse); } Sidebar::DockingState Plater::get_sidebar_docking_state() const { return p->get_sidebar_docking_state(); } +bool Plater::is_ai_slice_assistant_panel_shown() { return p->is_ai_slice_assistant_panel_shown(); } +void Plater::show_ai_slice_assistant_panel(bool show) { p->show_ai_slice_assistant_panel(show); } +void Plater::toggle_ai_slice_assistant_panel() { p->show_ai_slice_assistant_panel(!p->is_ai_slice_assistant_panel_shown()); } void Plater::reset_window_layout() { p->reset_window_layout(); } diff --git a/src/slic3r/GUI/Plater.hpp b/src/slic3r/GUI/Plater.hpp index 6dccc59b2be..a8c679668c1 100644 --- a/src/slic3r/GUI/Plater.hpp +++ b/src/slic3r/GUI/Plater.hpp @@ -438,6 +438,9 @@ class Plater: public wxPanel bool is_sidebar_collapsed() const; void collapse_sidebar(bool collapse); Sidebar::DockingState get_sidebar_docking_state() const; + bool is_ai_slice_assistant_panel_shown(); + void show_ai_slice_assistant_panel(bool show); + void toggle_ai_slice_assistant_panel(); void reset_window_layout(); @@ -973,4 +976,4 @@ wxArrayString get_all_camera_view_type(); } // namespace GUI } // namespace Slic3r -#endif \ No newline at end of file +#endif diff --git a/src/slic3r/GUI/Preferences.cpp b/src/slic3r/GUI/Preferences.cpp index 28868d3ac53..d8af6987414 100644 --- a/src/slic3r/GUI/Preferences.cpp +++ b/src/slic3r/GUI/Preferences.cpp @@ -5,9 +5,11 @@ #include "Plater.hpp" #include "MsgDialog.hpp" #include "I18N.hpp" +#include "../../ai/providers/openai_compat/openai_compat_provider.h" #include "libslic3r/AppConfig.hpp" #include "libslic3r/Format/DRC.hpp" #include +#include #include "OG_CustomCtrl.hpp" #include "wx/graphics.h" #include @@ -19,6 +21,9 @@ #include "slic3r/Utils/NetworkAgent.hpp" #include "DownloadProgressDialog.hpp" +#include +#include + #ifdef __WINDOWS__ #ifdef _MSW_DARK_MODE #include "dark_mode.hpp" @@ -1492,6 +1497,355 @@ void PreferencesDialog::create_items() SETTING_USE_ENCRYPTED_TOKEN_FILE); g_sizer->Add(item_token_storage); + //// ONLINE > AI + g_sizer->Add(create_item_title(_L("AI")), 1, wxEXPAND); + + auto ensure_app_default = [this](const std::string& key, const std::string& value) { + if (app_config->get(key).empty()) + app_config->set(key, value); + }; + + ensure_app_default("ai_provider_type", "fake"); + ensure_app_default("ai_provider_base_url", "https://api.openai.com/v1"); + ensure_app_default("ai_provider_model", "gpt-4.1-mini"); + ensure_app_default("ai_provider_timeout_seconds", "30"); + ensure_app_default("ai_provider_max_tokens", "600"); + ensure_app_default("ai_provider_temperature", "0.2"); + ensure_app_default("ai_provider_use_json_schema", "true"); + ensure_app_default("ai_provider_api_key_storage", "plain"); + ensure_app_default("ai_safe_mode", "true"); + ensure_app_default("ai_provider_preset", "openai_default"); + + auto item_ai_provider_type = create_item_combobox( + _L("AI provider"), + _L("Select which provider is used by AI Slice Assistant."), + "ai_provider_type", + {_L("Fake"), _L("OpenAI-compatible")}, + {"fake", "openai_compat"}); + g_sizer->Add(item_ai_provider_type); + + auto item_ai_safe_mode = create_item_checkbox( + _L("Safe Mode (recommended)"), + _L("Limits risky recommendations and enforces confirmations for temperature, flow, and speed changes."), + "ai_safe_mode"); + g_sizer->Add(item_ai_safe_mode); + + auto item_ai_json_schema_mode = create_item_checkbox( + _L("Strict JSON schema mode"), + _L("Use response_format json_schema when supported by the provider. Fallback is json_object."), + "ai_provider_use_json_schema"); + g_sizer->Add(item_ai_json_schema_mode); + + auto add_ai_text_row = + [this, g_sizer](const wxString& title, + const wxString& tooltip, + const wxString& initial_value, + long style, + const std::function& on_commit) -> wxTextCtrl* { + auto* row = new wxBoxSizer(wxHORIZONTAL); + row->AddSpacer(FromDIP(DESIGN_LEFT_MARGIN)); + + auto* label = new wxStaticText(m_parent, wxID_ANY, title, wxDefaultPosition, DESIGN_TITLE_SIZE, wxST_NO_AUTORESIZE); + label->SetForegroundColour(DESIGN_GRAY900_COLOR); + label->SetFont(::Label::Body_14); + label->SetToolTip(tooltip); + label->Wrap(DESIGN_TITLE_SIZE.x); + + auto* input = new wxTextCtrl(m_parent, wxID_ANY, initial_value, wxDefaultPosition, wxSize(FromDIP(260), -1), style); + input->SetToolTip(tooltip); + + row->Add(label, 0, wxALIGN_CENTER_VERTICAL); + row->Add(input, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(5)); + g_sizer->Add(row); + + input->Bind(wxEVT_TEXT_ENTER, [on_commit, input](wxCommandEvent& e) { + on_commit(input->GetValue()); + e.Skip(); + }); + input->Bind(wxEVT_KILL_FOCUS, [on_commit, input](wxFocusEvent& e) { + on_commit(input->GetValue()); + e.Skip(); + }); + + return input; + }; + + auto store_app_value = [this](const std::string& key, const wxString& value) { + app_config->set(key, value.ToStdString()); + app_config->save(); + }; + + wxTextCtrl* ai_base_url_ctrl = add_ai_text_row( + _L("Base URL"), + _L("OpenAI-compatible base URL, for example https://api.openai.com/v1"), + wxString::FromUTF8(app_config->get("ai_provider_base_url").c_str()), + wxTE_PROCESS_ENTER, + [store_app_value](const wxString& value) { + store_app_value("ai_provider_base_url", value); + }); + + wxTextCtrl* ai_model_ctrl = add_ai_text_row( + _L("Model"), + _L("Model name used for chat completions."), + wxString::FromUTF8(app_config->get("ai_provider_model").c_str()), + wxTE_PROCESS_ENTER, + [store_app_value](const wxString& value) { + store_app_value("ai_provider_model", value); + }); + + wxTextCtrl* ai_timeout_ctrl = add_ai_text_row( + _L("Timeout (s)"), + _L("HTTP timeout in seconds (5 to 300)."), + wxString::FromUTF8(app_config->get("ai_provider_timeout_seconds").c_str()), + wxTE_PROCESS_ENTER, + [this, store_app_value](const wxString& value) { + long parsed = 30; + if (!value.ToLong(&parsed)) + parsed = 30; + parsed = std::max(5L, std::min(300L, parsed)); + store_app_value("ai_provider_timeout_seconds", wxString::Format("%ld", parsed)); + }); + + wxTextCtrl* ai_max_tokens_ctrl = add_ai_text_row( + _L("Max tokens"), + _L("Maximum tokens for provider response (1 to 4096)."), + wxString::FromUTF8(app_config->get("ai_provider_max_tokens").c_str()), + wxTE_PROCESS_ENTER, + [this, store_app_value](const wxString& value) { + long parsed = 600; + if (!value.ToLong(&parsed)) + parsed = 600; + parsed = std::max(1L, std::min(4096L, parsed)); + store_app_value("ai_provider_max_tokens", wxString::Format("%ld", parsed)); + }); + + wxTextCtrl* ai_temperature_ctrl = add_ai_text_row( + _L("Temperature"), + _L("Sampling temperature from 0.0 to 2.0."), + wxString::FromUTF8(app_config->get("ai_provider_temperature").c_str()), + wxTE_PROCESS_ENTER, + [this, store_app_value](const wxString& value) { + double parsed = 0.2; + if (!value.ToDouble(&parsed)) + parsed = 0.2; + parsed = std::max(0.0, std::min(2.0, parsed)); + store_app_value("ai_provider_temperature", wxString::Format("%.2f", parsed)); + }); + + const std::vector ai_preset_values = {"openai_default", "openai_fast", "local_server"}; + unsigned int ai_preset_index = 0; + const std::string current_ai_preset = app_config->get("ai_provider_preset"); + for (size_t i = 0; i < ai_preset_values.size(); ++i) { + if (ai_preset_values[i] == current_ai_preset) { + ai_preset_index = static_cast(i); + break; + } + } + + auto [item_ai_preset, ai_preset_combo] = create_item_combobox_base( + _L("AI preset"), + _L("Apply provider defaults for endpoint and decoding parameters."), + "ai_provider_preset", + {_L("OpenAI (default)"), _L("OpenAI (fast)"), _L("Local server")}, + ai_preset_index); + g_sizer->Add(item_ai_preset); + + auto apply_ai_preset = [this, ai_base_url_ctrl, ai_model_ctrl, ai_temperature_ctrl, ai_max_tokens_ctrl](const std::string& preset_value) { + std::string base_url = "https://api.openai.com/v1"; + std::string model = "gpt-4.1-mini"; + std::string temperature = "0.20"; + std::string max_tokens = "600"; + std::string use_json_schema = "true"; + + if (preset_value == "openai_fast") { + model = "gpt-4.1-nano"; + temperature = "0.10"; + max_tokens = "400"; + } else if (preset_value == "local_server") { + base_url = "http://127.0.0.1:1234/v1"; + model = "local-model"; + temperature = "0.10"; + max_tokens = "400"; + use_json_schema = "false"; + } + + ai_base_url_ctrl->SetValue(wxString::FromUTF8(base_url.c_str())); + ai_model_ctrl->SetValue(wxString::FromUTF8(model.c_str())); + ai_temperature_ctrl->SetValue(wxString::FromUTF8(temperature.c_str())); + ai_max_tokens_ctrl->SetValue(wxString::FromUTF8(max_tokens.c_str())); + + app_config->set("ai_provider_preset", preset_value); + app_config->set("ai_provider_base_url", base_url); + app_config->set("ai_provider_model", model); + app_config->set("ai_provider_temperature", temperature); + app_config->set("ai_provider_max_tokens", max_tokens); + app_config->set("ai_provider_use_json_schema", use_json_schema); + app_config->save(); + }; + + ai_preset_combo->GetDropDown().Bind(wxEVT_COMBOBOX, [apply_ai_preset, ai_preset_values](wxCommandEvent& e) { + const int selection = e.GetSelection(); + if (selection >= 0 && static_cast(selection) < ai_preset_values.size()) + apply_ai_preset(ai_preset_values[static_cast(selection)]); + e.Skip(); + }); + + std::string initial_api_key = app_config->get("ai_provider_api_key"); +#ifdef __APPLE__ + if (initial_api_key.empty() && app_config->get("ai_provider_api_key_storage") == "keychain") { + std::string secure_error; + Slic3r::AI::Providers::OpenAICompatProvider::load_api_key_securely(initial_api_key, secure_error); + } +#endif + + wxTextCtrl* ai_api_key_ctrl = add_ai_text_row( + _L("API key"), + _L("Stored securely on macOS keychain when available."), + wxString::FromUTF8(initial_api_key.c_str()), + wxTE_PROCESS_ENTER | wxTE_PASSWORD, + [this](const wxString& value) { + const std::string api_key = value.ToStdString(); + if (api_key.empty()) { + app_config->set("ai_provider_api_key", ""); + app_config->set("ai_provider_api_key_storage", "plain"); +#ifdef __APPLE__ + std::string clear_error; + Slic3r::AI::Providers::OpenAICompatProvider::clear_api_key_securely(clear_error); +#endif + app_config->save(); + return; + } + +#ifdef __APPLE__ + std::string secure_error; + if (Slic3r::AI::Providers::OpenAICompatProvider::save_api_key_securely(api_key, secure_error)) { + app_config->set("ai_provider_api_key", ""); + app_config->set("ai_provider_api_key_storage", "keychain"); + app_config->save(); + return; + } + + app_config->set("ai_provider_api_key", api_key); + app_config->set("ai_provider_api_key_storage", "plain"); + app_config->save(); + MessageDialog dlg(this, + _L("System keychain unavailable. API key is stored in plain app config."), + _L("AI key storage warning"), + wxOK | wxICON_WARNING); + dlg.ShowModal(); +#else + app_config->set("ai_provider_api_key", api_key); + app_config->set("ai_provider_api_key_storage", "plain"); + app_config->save(); + MessageDialog dlg(this, + _L("Secure key storage is unavailable on this platform. API key is stored in plain app config."), + _L("AI key storage warning"), + wxOK | wxICON_WARNING); + dlg.ShowModal(); +#endif + }); + + auto item_ai_test = create_item_button( + _L("AI provider connection"), + _L("Test") + " " + dots, + "", + _L("Test AI provider connectivity"), + [this, ai_base_url_ctrl, ai_model_ctrl, ai_timeout_ctrl, ai_max_tokens_ctrl, ai_temperature_ctrl, ai_api_key_ctrl]() { + auto normalize_numeric = [this](wxTextCtrl* ctrl, const std::string& key, long default_value, long min_value, long max_value) -> long { + long parsed = default_value; + if (!ctrl->GetValue().ToLong(&parsed)) + parsed = default_value; + parsed = std::max(min_value, std::min(max_value, parsed)); + app_config->set(key, std::to_string(parsed)); + return parsed; + }; + + auto normalize_temperature = [this](wxTextCtrl* ctrl) -> double { + double parsed = 0.2; + if (!ctrl->GetValue().ToDouble(&parsed)) + parsed = 0.2; + parsed = std::max(0.0, std::min(2.0, parsed)); + app_config->set("ai_provider_temperature", wxString::Format("%.2f", parsed).ToStdString()); + return parsed; + }; + + const std::string provider_type = app_config->get("ai_provider_type"); + const std::string base_url = ai_base_url_ctrl->GetValue().ToStdString(); + const std::string model = ai_model_ctrl->GetValue().ToStdString(); + const long timeout_seconds = normalize_numeric(ai_timeout_ctrl, "ai_provider_timeout_seconds", 30, 5, 300); + const int max_tokens = static_cast(normalize_numeric(ai_max_tokens_ctrl, "ai_provider_max_tokens", 600, 1, 4096)); + const double temperature = normalize_temperature(ai_temperature_ctrl); + const bool use_json_schema_response_format = + app_config->get("ai_provider_use_json_schema").empty() || app_config->get_bool("ai_provider_use_json_schema"); + + // Persist typed values so Send and future sessions use the same provider config. + app_config->set("ai_provider_base_url", base_url); + app_config->set("ai_provider_model", model); + + const std::string typed_api_key = ai_api_key_ctrl->GetValue().ToStdString(); + if (!typed_api_key.empty()) { +#ifdef __APPLE__ + std::string secure_error; + if (Slic3r::AI::Providers::OpenAICompatProvider::save_api_key_securely(typed_api_key, secure_error)) { + app_config->set("ai_provider_api_key", ""); + app_config->set("ai_provider_api_key_storage", "keychain"); + } else { + app_config->set("ai_provider_api_key", typed_api_key); + app_config->set("ai_provider_api_key_storage", "plain"); + } +#else + app_config->set("ai_provider_api_key", typed_api_key); + app_config->set("ai_provider_api_key_storage", "plain"); +#endif + } + app_config->save(); + + std::string api_key = app_config->get("ai_provider_api_key"); + if (api_key.empty()) { + std::string secure_error; + Slic3r::AI::Providers::OpenAICompatProvider::load_api_key_securely(api_key, secure_error); + } + + std::thread([provider_type, base_url, model, timeout_seconds, max_tokens, temperature, use_json_schema_response_format, api_key]() { + bool ok = false; + std::string error; + + if (provider_type == "openai_compat") { + Slic3r::AI::Providers::OpenAICompatConfig cfg; + cfg.provider_type = provider_type; + cfg.base_url = base_url; + cfg.api_key = api_key; + cfg.model = model; + cfg.timeout_seconds = timeout_seconds; + cfg.max_tokens = max_tokens; + cfg.temperature = temperature; + cfg.use_json_schema_response_format = use_json_schema_response_format; + + Slic3r::AI::Providers::OpenAICompatProvider provider(std::move(cfg)); + ok = provider.test_connection(error); + } else { + ok = true; + } + + wxGetApp().CallAfter([ok, error]() { + if (ok) { + MessageDialog dlg(wxGetApp().mainframe, + _L("AI provider connection succeeded."), + _L("AI connection test"), + wxOK | wxICON_INFORMATION); + dlg.ShowModal(); + } else { + MessageDialog dlg(wxGetApp().mainframe, + wxString::Format(_L("AI provider connection failed: %s"), wxString::FromUTF8(error.c_str())), + _L("AI connection test"), + wxOK | wxICON_ERROR); + dlg.ShowModal(); + } + }); + }).detach(); + }); + g_sizer->Add(item_ai_test); + //// ONLINE > Network plugin g_sizer->Add(create_item_title(_L("Network plugin")), 1, wxEXPAND);