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);