├── Fusion-GPT-Addin ├── commands │ ├── Browser │ │ ├── __init__.py │ │ ├── resources │ │ │ ├── 16x16.png │ │ │ ├── 32x32.png │ │ │ ├── 64x64.png │ │ │ └── html │ │ │ │ ├── static │ │ │ │ ├── sql_test.js │ │ │ │ └── style.css │ │ │ │ └── index.html │ │ └── entry.py │ └── __init__.py ├── f_interface │ ├── modules │ │ ├── __init__.py │ │ ├── transient_objects.py │ │ └── shared.py │ ├── fusion_interface.py │ └── gpt_client.py ├── lib │ └── fusion360utils │ │ ├── __init__.py │ │ ├── general_utils.py │ │ └── event_utils.py ├── GptAddin.manifest ├── GptAddin.py └── config.py ├── config.sample ├── .gitignore ├── requirements.txt ├── README.md ├── sample_prompts.txt └── oai_container ├── system_instructions ├── system_instructions.txt └── system_instructions_o3_mini.txt └── connection.py /Fusion-GPT-Addin/commands/Browser/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Fusion-GPT-Addin/f_interface/modules/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Fusion-GPT-Addin/lib/fusion360utils/__init__.py: -------------------------------------------------------------------------------- 1 | from .general_utils import * 2 | from .event_utils import * 3 | -------------------------------------------------------------------------------- /Fusion-GPT-Addin/commands/Browser/resources/16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/STS-3D/fusion360-gpt-addin/HEAD/Fusion-GPT-Addin/commands/Browser/resources/16x16.png -------------------------------------------------------------------------------- /Fusion-GPT-Addin/commands/Browser/resources/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/STS-3D/fusion360-gpt-addin/HEAD/Fusion-GPT-Addin/commands/Browser/resources/32x32.png -------------------------------------------------------------------------------- /Fusion-GPT-Addin/commands/Browser/resources/64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/STS-3D/fusion360-gpt-addin/HEAD/Fusion-GPT-Addin/commands/Browser/resources/64x64.png -------------------------------------------------------------------------------- /config.sample: -------------------------------------------------------------------------------- 1 | # rename this file to `config.env`, delete this line, and fill in values, no quotes 2 | [DEFAULT] 3 | OPEN_AI_API_KEY=paste_api_key_here 4 | ASSISTANT_ID=paste_open_ai_assistant_id_here 5 | LOCAL_CAD_PATH=path_to_local_directory_containing_step_files 6 | 7 | 8 | -------------------------------------------------------------------------------- /Fusion-GPT-Addin/GptAddin.manifest: -------------------------------------------------------------------------------- 1 | { 2 | "autodeskProduct": "Fusion360", 3 | "type": "addin", 4 | "id": "31d19906-baca-4d53-9e1c-2ab9e4519022", 5 | "author": "STS Innovations LLC", 6 | "description": { 7 | "": "" 8 | }, 9 | "version": "0.1.0", 10 | "runOnStartup": false, 11 | "supportedOS": "windows|mac", 12 | "editEnabled": true 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.env 2 | *.wav 3 | *.ini 4 | *.pyc 5 | *.vscode 6 | *.ipynb 7 | .DS_Store 8 | /old/ 9 | /fusion_env/ 10 | /no_commit/ 11 | *fusion_types.py 12 | *test_client.py 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | adsk==2.0.12157 2 | annotated-types==0.7.0 3 | anyio==4.7.0 4 | certifi==2024.12.14 5 | charset-normalizer==3.4.1 6 | distro==1.9.0 7 | filelock==3.17.0 8 | fsspec==2024.12.0 9 | h11==0.14.0 10 | httpcore==1.0.7 11 | httpx==0.28.1 12 | idna==3.10 13 | Jinja2==3.1.5 14 | jiter==0.8.2 15 | llvmlite==0.44.0 16 | MarkupSafe==3.0.2 17 | more-itertools==10.6.0 18 | mpmath==1.3.0 19 | networkx==3.4.2 20 | numba==0.61.0 21 | numpy==1.26.4 22 | openai==1.63.2 23 | openai-whisper==20240930 24 | PyAudio==0.2.14 25 | pydantic==2.10.4 26 | pydantic_core==2.27.2 27 | regex==2024.11.6 28 | requests==2.32.3 29 | six==1.17.0 30 | sniffio==1.3.1 31 | sympy==1.13.3 32 | tiktoken==0.8.0 33 | torch==2.2.2 34 | tqdm==4.67.1 35 | typing_extensions==4.12.2 36 | urllib3==2.3.0 37 | whisper==1.1.10 38 | -------------------------------------------------------------------------------- /Fusion-GPT-Addin/GptAddin.py: -------------------------------------------------------------------------------- 1 | 2 | from .lib import fusion360utils as futil 3 | import adsk.core 4 | import os 5 | import sys 6 | from . import config 7 | from . import commands 8 | 9 | def print(string): 10 | futil.log(str(string)) 11 | 12 | dirname = os.path.dirname(__file__) 13 | 14 | def run(context): 15 | try: 16 | # Display a message when the add-in is manually run. 17 | if not context['IsApplicationStartup']: 18 | app = adsk.core.Application.get() 19 | ui = app.userInterface 20 | # This will run the start function in each of your commands as defined in commands/__init__.py 21 | commands.start() 22 | 23 | except: 24 | futil.handle_error('run') 25 | 26 | 27 | def stop(context): 28 | try: 29 | # Remove all of the event handlers your app has created 30 | futil.clear_handlers() 31 | 32 | # This will run the start function in each of your commands as defined in commands/__init__.py 33 | commands.stop() 34 | 35 | except: 36 | futil.handle_error('stop') 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /Fusion-GPT-Addin/commands/__init__.py: -------------------------------------------------------------------------------- 1 | # Here you define the commands that will be added to your add-in 2 | # If you want to add an additional command, duplicate one of the existing directories and import it here. 3 | # You need to use aliases (import "entry" as "my_module") assuming you have the default module named "entry" 4 | import sys 5 | from .Browser import entry as browser 6 | # global config settings 7 | from .. import config 8 | from ..lib import fusion360utils as futil 9 | 10 | 11 | # This Template will automatically call the start() and stop() functions. 12 | # By default the order you add the commands to this list will be the order they appear in the UI 13 | commands = [ 14 | browser, 15 | ] 16 | 17 | # Assumes you defined a "start" function in each of your modules. 18 | # These functions will be run when the add-in is stopped. 19 | def start(): 20 | for command in commands: 21 | command.start() 22 | 23 | 24 | # Assumes you defined a "start" function in each of your modules. 25 | # These functions will be run when the add-in is stopped. 26 | def stop(): 27 | for command in commands: 28 | command.stop() 29 | -------------------------------------------------------------------------------- /Fusion-GPT-Addin/config.py: -------------------------------------------------------------------------------- 1 | 2 | # FUSION 360 config 3 | # config.py 4 | # Application Global Variables 5 | # Adding application wide global variables here is a convenient technique 6 | # It allows for access across multiple event handlers and modules 7 | 8 | # read openai/system specific config vars 9 | import configparser 10 | import os 11 | 12 | user_config = configparser.ConfigParser() 13 | # path to config file containing open ai API keys, Python env path 14 | config_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "config.env") 15 | 16 | user_config.read(config_path) 17 | 18 | default_config = user_config["DEFAULT"] 19 | 20 | LOCAL_CAD_PATH = default_config["LOCAL_CAD_PATH"] 21 | 22 | # Set to False to remove most log messages from text palette 23 | DEBUG = True 24 | 25 | ADDIN_NAME = os.path.basename(os.path.dirname(__file__)) 26 | 27 | COMPANY_NAME = "STS Innovations LLC" 28 | 29 | # FIXME explain 30 | design_workspace = "FusionSolidEnvironment" 31 | tools_tab_id = "ToolsTab" 32 | # Only used if creating a custom Tab 33 | my_tab_name = "gpt_addin" 34 | 35 | my_panel_id = f"{ADDIN_NAME}_panel_2" 36 | my_panel_name = ADDIN_NAME 37 | my_panel_after = '' 38 | 39 | palette_id = "gpt_addin" 40 | 41 | STATE_DATA = { } 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /Fusion-GPT-Addin/lib/fusion360utils/general_utils.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 by Autodesk, Inc. 2 | # Permission to use, copy, modify, and distribute this software in object code form 3 | # for any purpose and without fee is hereby granted, provided that the above copyright 4 | # notice appears in all copies and that both that copyright notice and the limited 5 | # warranty and restricted rights notice below appear in all supporting documentation. 6 | # 7 | # AUTODESK PROVIDES THIS PROGRAM "AS IS" AND WITH ALL FAULTS. AUTODESK SPECIFICALLY 8 | # DISCLAIMS ANY IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR A PARTICULAR USE. 9 | # AUTODESK, INC. DOES NOT WARRANT THAT THE OPERATION OF THE PROGRAM WILL BE 10 | # UNINTERRUPTED OR ERROR FREE. 11 | 12 | import traceback 13 | import adsk.core 14 | 15 | app = adsk.core.Application.get() 16 | ui = app.userInterface 17 | 18 | # Attempt to read DEBUG flag from parent config. 19 | try: 20 | from ... import config 21 | DEBUG = config.DEBUG 22 | except: 23 | DEBUG = False 24 | 25 | 26 | def log(message: str, level: adsk.core.LogLevels = adsk.core.LogLevels.InfoLogLevel, force_console: bool = False): 27 | """Utility function to easily handle logging in your app. 28 | 29 | Arguments: 30 | message -- The message to log. 31 | level -- The logging severity level. 32 | force_console -- Forces the message to be written to the Text Command window. 33 | """ 34 | # Always print to console, only seen through IDE. 35 | print(message) 36 | 37 | # Log all errors to Fusion log file. 38 | if level == adsk.core.LogLevels.ErrorLogLevel: 39 | log_type = adsk.core.LogTypes.FileLogType 40 | app.log(message, level, log_type) 41 | 42 | # If config.DEBUG is True write all log messages to the console. 43 | if DEBUG or force_console: 44 | log_type = adsk.core.LogTypes.ConsoleLogType 45 | app.log(message, level, log_type) 46 | 47 | 48 | def handle_error(name: str, show_message_box: bool = False): 49 | """Utility function to simplify error handling. 50 | 51 | Arguments: 52 | name -- A name used to label the error. 53 | show_message_box -- Indicates if the error should be shown in the message box. 54 | If False, it will only be shown in the Text Command window 55 | and logged to the log file. 56 | """ 57 | 58 | log('===== Error =====', adsk.core.LogLevels.ErrorLogLevel) 59 | log(f'{name}\n{traceback.format_exc()}', adsk.core.LogLevels.ErrorLogLevel) 60 | 61 | # If desired you could show an error as a message box. 62 | if show_message_box: 63 | ui.messageBox(f'{name}\n{traceback.format_exc()}') 64 | -------------------------------------------------------------------------------- /Fusion-GPT-Addin/lib/fusion360utils/event_utils.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 by Autodesk, Inc. 2 | # Permission to use, copy, modify, and distribute this software in object code form 3 | # for any purpose and without fee is hereby granted, provided that the above copyright 4 | # notice appears in all copies and that both that copyright notice and the limited 5 | # warranty and restricted rights notice below appear in all supporting documentation. 6 | # 7 | # AUTODESK PROVIDES THIS PROGRAM "AS IS" AND WITH ALL FAULTS. AUTODESK SPECIFICALLY 8 | # DISCLAIMS ANY IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR A PARTICULAR USE. 9 | # AUTODESK, INC. DOES NOT WARRANT THAT THE OPERATION OF THE PROGRAM WILL BE 10 | # UNINTERRUPTED OR ERROR FREE. 11 | 12 | import sys 13 | from typing import Callable 14 | 15 | import adsk.core 16 | from .general_utils import handle_error 17 | 18 | 19 | # Global Variable to hold Event Handlers 20 | _handlers = [] 21 | 22 | 23 | def add_handler( 24 | event: adsk.core.Event, 25 | callback: Callable, 26 | *, 27 | name: str = None, 28 | local_handlers: list = None 29 | ): 30 | """Adds an event handler to the specified event. 31 | 32 | Arguments: 33 | event -- The event object you want to connect a handler to. 34 | callback -- The function that will handle the event. 35 | name -- A name to use in logging errors associated with this event. 36 | Otherwise the name of the event object is used. This argument 37 | must be specified by its keyword. 38 | local_handlers -- A list of handlers you manage that is used to maintain 39 | a reference to the handlers so they aren't released. 40 | This argument must be specified by its keyword. If not 41 | specified the handler is added to a global list and can 42 | be cleared using the clear_handlers function. You may want 43 | to maintain your own handler list so it can be managed 44 | independently for each command. 45 | 46 | :returns: 47 | The event handler that was created. You don't often need this reference, but it can be useful in some cases. 48 | """ 49 | module = sys.modules[event.__module__] 50 | handler_type = module.__dict__[event.add.__annotations__['handler']] 51 | handler = _create_handler(handler_type, callback, event, name, local_handlers) 52 | event.add(handler) 53 | return handler 54 | 55 | 56 | def clear_handlers(): 57 | """Clears the global list of handlers. 58 | """ 59 | global _handlers 60 | _handlers = [] 61 | 62 | 63 | def _create_handler( 64 | handler_type, 65 | callback: Callable, 66 | event: adsk.core.Event, 67 | name: str = None, 68 | local_handlers: list = None 69 | ): 70 | handler = _define_handler(handler_type, callback, name)() 71 | (local_handlers if local_handlers is not None else _handlers).append(handler) 72 | return handler 73 | 74 | 75 | def _define_handler(handler_type, callback, name: str = None): 76 | name = name or handler_type.__name__ 77 | 78 | class Handler(handler_type): 79 | def __init__(self): 80 | super().__init__() 81 | 82 | def notify(self, args): 83 | try: 84 | callback(args) 85 | except: 86 | handle_error(name) 87 | 88 | return Handler 89 | -------------------------------------------------------------------------------- /Fusion-GPT-Addin/commands/Browser/resources/html/static/sql_test.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | const queryStrings = [ 6 | "SELECT name FROM BRepBody", 7 | 8 | "SELECT name, appearance, area, assemblyContext, boundingBox, isLightBulbOn, isSelectable, isSheetMetal, isSolid, isTransient, orientedMinimumBoundingBox, parentComponent, vertices FROM BRepBody\n\ 9 | WHERE name LIKE '%b%' LIMIT 10", 10 | 11 | "SELECT name,entityToken, isBodiesFolderLightBulbOn, isCanvasFolderLightBulbOn, isConstructionFolderLightBulbOn,isDecalFolderLightBulbOn,isJointsFolderLightBulbOn,isOriginFolderLightBulbOn,isSketchFolderLightBulbOn\n\ 12 | FROM Component WHERE name LIKE '%screw%' LIMIT 10", 13 | 14 | "UPDATE Component SET isJointsFolderLightBulbOn=true, isOriginFolderLightBulbOn=true, isConstructionFolderLightBulbOn=true, isSketchFolderLightBulbOn=true, isBodiesFolderLightBulbOn=true LIMIT 10", 15 | 16 | "UPDATE Component SET isJointsFolderLightBulbOn=false, isOriginFolderLightBulbOn=false, isConstructionFolderLightBulbOn=false, isSketchFolderLightBulbOn=false, isBodiesFolderLightBulbOn=false LIMIT 10", 17 | 18 | "SELECT name, assemblyContext.name, appearanceSourceType, appearance.name, assemblyContext.appearance.name FROM BRepBody LIMIT 100", 19 | 20 | "SELECT name, assemblyContext.name, appearanceSourceType, appearance.name, assemblyContext.appearance.name FROM BRepBody WHERE assemblyContext.appearance.name LIKE '%steel%' LIMIT 10", 21 | 22 | "SELECT name, assemblyContext.name, appearanceSourceType, appearance.name, assemblyContext.appearance.name FROM BRepBody WHERE assemblyContext.appearance.name LIKE '%steel%' LIMIT 10", 23 | 24 | "UPDATE BRepBody SET isLightBulbOn=true WHERE assemblyContext.appearance.name LIKE '%steel%' LIMIT 10", 25 | 26 | "UPDATE BRepBody SET isLightBulbOn=true, parentComponent.isBodiesFolderLightBulbOn=true, assemblyContext.isLightBulbOn=true WHERE appearance.name LIKE '%steel%' OR assemblyContext.appearance.name LIKE '%steel%' OR assemblyContext.appearance.name LIKE '%black%'", 27 | 28 | "UPDATE Joint SET isLightBulbOn=false WHERE jointMotion.objectType NOT LIKE '%rigid%' LIMIT 10", 29 | "UPDATE AsBuiltJoint SET isLightBulbOn=false WHERE jointMotion.objectType NOT LIKE '%rigid%' LIMIT 10", 30 | 31 | "UPDATE Component SET isJointsFolderLightBulbOn=true,isOriginFolderLightBulbOn=true LIMIT 20", 32 | "SELECT name, jointMotion.objectType, isFlipped, entityToken, isLightBulbOn FROM Joint WHERE jointMotion.objectType NOT LIKE '%rigid%' LIMIT 10", 33 | 34 | "SELECT length,endVertex,startVertex,faces,geometry\nFROM BRepEdge LIMIT 10", 35 | 36 | "SELECT length,endVertex,startVertex,faces,geometry\nFROM BRepEdge ORDER BY length DESC LIMIT 100", 37 | 38 | "SELECT length,body.parentComponent.name FROM BRepEdge ORDER BY length DESC LIMIT 100", 39 | 40 | "SELECT length,objectType, boundingBox.minPoint.x, boundingBox.minPoint.y, boundingBox.minPoint.z FROM BRepEdge ORDER BY length DESC LIMIT 10", 41 | 42 | "SELECT length,objectType, boundingBox.minPoint.x, boundingBox.minPoint.y, boundingBox.minPoint.z, boundingBox.maxPoint.x, boundingBox.maxPoint.y, boundingBox.maxPoint.z\n\ 43 | FROM BRepEdge\n\ 44 | ORDER BY boundingBox.maxPoint.z DESC LIMIT 10", 45 | 46 | "SELECT component.name, entityToken, createdBy.name, dependentParameters, expression, role\n\ 47 | FROM Parameter", 48 | 49 | "SELECT hasTexture,name,id\n\ 50 | FROM Appearance WHERE name LIKE '%oak%'", 51 | 52 | "UPDATE SketchCurve SET radius = 1.1 WHERE objectType LIKE '%circle%'", 53 | 54 | "SELECT entity.entityToken, index, errorOrWarningMessage, healthState, isRolledBack FROM TimelineObject", 55 | 56 | "SELECT name,entityToken, assemblyContext, assemblyContext.name, assemblyContext.entityToken FROM BRepBody WHERE assemblyContext.name LIKE '%J0-Top-Plate:1%'", 57 | 58 | "SELECT index, name, objectType, isGroup FROM TimelineObject", 59 | 60 | "SELECT name, physicalProperties.area, physicalProperties.volume, physicalProperties.mass, physicalProperties.centerOfMass FROM BRepBody ORDER BY physicalProperties.area DESC LIMIT 300" 61 | 62 | 63 | 64 | ]; 65 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | ### Fusion 360 - OpenaAI Assistants API Add-In. 3 | ### This Fusion 360 Python Add-In allows the OpenAI Assistant API to interact with the Fusion design workspace. 4 | 5 | ## WARNING 6 | - This add-in is in the beta stage, and under active development, it needs many improvements. 7 | - Currently, this Add-In is intended for users with Python / Fusion 360 API experience. 8 | - Use at your own risk, always save backups of your Fusion 360 files. 9 | - Be especially careful using this Add-In on designs with referenced/linked/imported components, changes to a component in one design can affect the original component. 10 | - I recommend you learn at least the basics of the Fusion 360 Python API, specifically the way objects are structured in a design. 11 | 12 | 13 | ## Bugs/ Issues 14 | - Due to the non-deterministic nature of LLMs, the same prompt will likely produce different results. 15 | - If you would like to report an issue/ bug, please include all details that produced the error. Please DO NOT say something like: "I asked it to make a functioning rocket ship and it didn't work" 16 | - Runtime Python errors/output are visible in the **Text Commands** window, View -> Show Text Commands on Mac. You should ways have this open 17 | - JavaScript errors/ output are visible by right clicking Add-In Window, click Dev Tools In Product, then STSI GPT Addin. This will display a Chrome Browser Tools window, here you can explore the HTML, CSS, and JavaScript. 18 | 19 | ## Key Points 20 | 1. Fusion 360 runs Python Add-Ins (e.g. "Fusion-GPT-Addin") in the built in Fusion 360 Python environment. It is relatively difficult and not recommended to modify (install third party packages) the built in Fusion 360 Python environment. 21 | 2. To overcome this limitation, we run a separate Python program with its own environment, on a separate process. This program is called **connection.py** located in the directory "oai_container". 22 | 3. The two Python programs communicate with each other via the Python "multiprocessing" package. 23 | 4. When running the Add-In, please open the Fusion 360 **Text Commands** window. This provides details on errors and other runtime messages. 24 | 25 | 26 | # Set Up 27 | ## setup Overview 28 | 1. "Fusion-GPT-Addin" is the actual Fusion 360 Add-In, must be loaded into Fusion 360 via the utilities tab 29 | 2. "oai_container" contains the code relating to the Assistants API. **connection.py** is run in a separate process than Fusion. The Fusion 360 Add-In connects during run time 30 | 3. "Browser" directory contains files for the HTML window displayed in Fusion 31 | 32 | ## Config / Environment Setup 33 | 1. Create an OpenAI Assistant at https://platform.openai.com/assistants/ Currently, you must have paid credits on the OpenAI Developer API. 34 | 2. Rename the "config.sample" to "config.env", add your OpenAI API key and AssistantID, which you will find in the OpenAI Assistant Dashboard. 35 | 3. I used Python 3.11 for this project. While it's not required, I highly recommend setting up a virtual environment. Create a virtual Python environment for this project. If you are unfamiliar with downloading Python / setting up a virtual environment, Google/ChatGPT "How to install Python and create a virtual environment" 36 | 4. Install required libraries from the **requirements.txt** file. 37 | 38 | ## Add-In setup 39 | 1. In Fusion 360 navigate to the utilities tab. 40 | 2. In the Add-Ins section, click on the green + icon and load the directory **Fusion-GPT-Addin**. 41 | 3. Click run, to run the Add-In. 42 | 4. Now in the utilities tab you should see a new icon called Fusion-GPT-Addin. 43 | 5. Click on the icon to open the prompt window. 44 | 4. Please open the Fusion 360 **Text Commands** window. If the Add-In is not working properly, you should check the output here first. 45 | 46 | 47 | # Usage 48 | ## Assistants API Connection 49 | 1. If you are using a virtual environment, activate it now. 50 | 2. Navigate to "/oai_container/" and run **connection.py** (python connection.py) 51 | 3. In the console you should see "WAITING FOR FUSION 360 TO CONNECT". 52 | 53 | ## Assistant Config 54 | 1. Click on the Add-Icon in the utilities tab, the prompt window should appear. 55 | 2. Click on the "Settings" checkbox to expand all settings. 56 | 3. We need to set up the Assistant's System Instructions, Model, and Tools (functions). 57 | 58 | ## System Instructions 59 | - You can set the system instructions by modifying/ adding txt files in oai_container/system_instructions. 60 | 61 | 62 | # Structure 63 | 64 | ``` 65 | ├── Fusion-GPT-Addin 66 | │   ├── GptAddin.manifest 67 | │   ├── GptAddin.py 68 | │   ├── commands 69 | │   │   └── Browser 70 | │   │   ├── entry.py 71 | │   │   └── resources 72 | │   │   ├── 16x16.png 73 | │   │   ├── 32x32.png 74 | │   │   ├── 64x64.png 75 | │   │   └── html 76 | │   │   ├── index.html 77 | │   │   └── static 78 | │   │   ├── palette.js 79 | │   │   ├── sql_test.js 80 | │   │   └── style.css 81 | │   ├── config.py 82 | │   └── f_interface 83 | │   ├── fusion_interface.py 84 | │   ├── gpt_client.py 85 | │   └── modules 86 | │   ├── cad_modeling.py 87 | │   ├── document_data.py 88 | │   ├── shared.py 89 | │   ├── transient_objects.py 90 | │   └── utilities.py 91 | ├── README.md 92 | ├── config.env 93 | ├── config.sample 94 | ├── oai_container 95 | │   ├── connection.py 96 | │   └── system_instructions 97 | │      ├── system_instructions.txt 98 | │      └── system_instructions_o3_mini.txt 99 | ├── requirements.txt 100 | └── sample_prompts.txt 101 | ``` 102 | 103 | 104 | 105 | 106 | -------------------------------------------------------------------------------- /Fusion-GPT-Addin/f_interface/fusion_interface.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | import adsk.core 7 | import adsk.fusion 8 | #import adsk.cam 9 | import traceback 10 | import sys 11 | import math 12 | import os 13 | import json 14 | import inspect 15 | import re 16 | 17 | #from multiprocessing.connection import Client 18 | from array import array 19 | import time 20 | 21 | import importlib 22 | import functools 23 | 24 | from .. import config 25 | from ..lib import fusion360utils as futil 26 | 27 | from . import modules 28 | from .modules import cad_modeling, shared, transient_objects, document_data, utilities 29 | from .modules.shared import ToolCollection 30 | 31 | #print(modules) 32 | 33 | #members = inspect.getmembers(modules) 34 | #print(members) 35 | #importlib.reload(modules) 36 | 37 | def print(string): 38 | """redefine print for fusion env""" 39 | futil.log(str(string)) 40 | 41 | 42 | print(f"RELOADED: {__name__.split("%2F")[-1]}") 43 | 44 | # send info to html palette 45 | PALETTE_ID = config.palette_id 46 | app = adsk.core.Application.get() 47 | ui = app.userInterface 48 | palette = ui.palettes.itemById(PALETTE_ID) 49 | 50 | ent_dict = {} 51 | 52 | 53 | class FusionInterface: 54 | """ 55 | interface between Fusion360 api and OpenAI Assistant 56 | methonds in this class are made avilable to the OpenAI Assistants API 57 | via the GptClient class 58 | """ 59 | 60 | def __init__(self, app, ui): 61 | self.app = app 62 | self.ui = ui 63 | self.design = adsk.fusion.Design.cast(self.app.activeProduct) 64 | 65 | ent_dict["design"] = self.design 66 | ent_dict["root"] = self.design.rootComponent 67 | 68 | # method collections 69 | self.submodules = [ 70 | document_data.SQL(ent_dict), 71 | document_data.GetStateData(ent_dict), 72 | document_data.SetStateData(ent_dict), 73 | transient_objects.TransientObjects(ent_dict), 74 | cad_modeling.CreateObjects(ent_dict), 75 | utilities.Utilities(ent_dict), 76 | utilities.ImportExport(ent_dict), 77 | utilities.Joints(ent_dict), 78 | cad_modeling.ModifyObjects(ent_dict), 79 | ] 80 | 81 | fusion_methods = {} 82 | for submod in self.submodules: 83 | for method_name, method in submod.methods.items(): 84 | # add method from container classes to main interface class 85 | setattr(self, method_name, method) 86 | 87 | # TODO do this without hard coading modules name 88 | def _reload_modules(self): 89 | importlib.reload(shared) 90 | importlib.reload(transient_objects) 91 | importlib.reload(document_data) 92 | importlib.reload(cad_modeling) 93 | importlib.reload(utilities) 94 | 95 | def update_settings(self, settings_dict ): 96 | ToolCollection.update(settings_dict) 97 | 98 | def get_tools(self): 99 | """ 100 | creates list fusion interface functions 101 | """ 102 | methods = {} 103 | 104 | # add modules and create methods 105 | for mod in self.submodules: 106 | class_name = mod.__class__.__name__ 107 | 108 | # class name used for display 109 | methods[class_name] = {} 110 | 111 | for attr_name in dir(mod): 112 | 113 | attr = getattr(mod, attr_name) 114 | wrapper = getattr(attr, "__wrapper__", None ) 115 | if wrapper != "tool_call": 116 | continue 117 | 118 | if str(attr.__class__) == "": 119 | # method signature 120 | sig = inspect.signature(attr) 121 | 122 | attr = inspect.unwrap(attr) 123 | 124 | default_vals = inspect.getfullargspec(attr).defaults 125 | 126 | if default_vals != None: 127 | n_default_vals = len(default_vals) 128 | else: 129 | n_default_vals = 0 130 | 131 | param_dict = {} 132 | for index, param_name in enumerate(sig.parameters): 133 | annotation = sig.parameters.get(param_name).annotation 134 | 135 | if index < n_default_vals: 136 | default_val = default_vals[index] 137 | else: 138 | default_val = None 139 | 140 | param_info_dict = { 141 | "type": str(annotation.__name__), 142 | "default_val": default_val 143 | } 144 | 145 | #param_info_dict 146 | param_dict[param_name] = param_info_dict 147 | 148 | methods[class_name][attr_name] = param_dict 149 | 150 | return methods 151 | 152 | def get_docstr(self): 153 | """ 154 | creates list fusion interface functions 155 | """ 156 | method_list = [] 157 | for attr_name in dir(self): 158 | 159 | attr = getattr(self, attr_name) 160 | 161 | if callable(attr) == False: 162 | continue 163 | 164 | if str(attr.__class__) == "": 165 | sig = inspect.signature(attr) 166 | 167 | wrapper = getattr(attr, "__wrapper__", None ) 168 | 169 | if wrapper != "tool_call": 170 | continue 171 | 172 | docstring = attr.__doc__ 173 | 174 | print(attr_name) 175 | json_method = json.loads(docstring) 176 | 177 | method_list.append(json_method) 178 | 179 | 180 | method_list = json.dumps(method_list) 181 | self.tools_json = method_list 182 | return method_list 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | -------------------------------------------------------------------------------- /sample_prompts.txt: -------------------------------------------------------------------------------- 1 | 2 | # hardware 3 | 4 | Hello, can you create a brass M8x45mm Hex Head screw, add a small fillet to all the edges, then add a joint origin to the top face? 5 | 6 | Can you create a similar component, but this time for an stainless steel M12x30mm Hex Head screw? 7 | 8 | Hello, can you create a brass M8x20mm Socket Head screw, with a small chamfer on all the edges, then add a joint origin on the top face? 9 | 10 | Hello, can you create an M8x40mm hex head bolt, with a .5mm chamfer on all edges? 11 | Hello, can you create a black oxide M8x35mm hex head bolt with a small chamfer on all the edges and a joint origin on the top. Then create the associated nut and washer for the bolt, both should also have joint origins. These should all be in a parent container called M8-Hardware. Use standard dimensions for all the components. 12 | 13 | 14 | # random cubes 15 | Hello, can you generate 30 rectangles with random sizes between 10 and 20mm. The rectangles should be quite close together so they are overlapping. Then extrude each profile by a random amount between 10 and 30mm. 16 | 17 | 18 | # keyboard 19 | 20 | I am designing a common TKL keyboard. It will have a main body, which should be draw as a rectangle, then extruded into a solid. On top of the body, you should create another sketch for the keys. Each key profile should be drawn as square or rectangle depending on the key. For example: number and letter keys are square, but space bar and shift keys are rectangular. Each key should be in the correct x,y position for a TKL QWERTY keyboard. You may want to create a separate sketch for each row or other logical grouping you think makes sense. Make sure to include all the keys. There should be a slight gap between each key. The keys should be extrude-joined into the body, protruding through the top, if the key sketches and the body sketch have the same z position, the keys need to be extruded at least as thick as the body. For body size, key size, key height, and key spacing, use common values for a typical TKL keyboard. 21 | 22 | 23 | 24 | 25 | # vase 26 | Can you create a vase. It should have a very detailed, highly contoured profile, and you should probably use the spline tool. Make sure the walls are sufficiently far from the Z axis. The walls should be a few mm thick. 27 | 28 | 29 | Hello, can you create a new component called shapes, then create a basic 4 point spline in the sketch? 30 | 31 | 32 | # shapes 33 | Cuboctahedron 34 | 35 | Hello, can you create Great dodecahedron. You should Create a new component and sketch. The create the Great dodecahedron vertices and edges used sketch lines. Remember sketch object can be 3d. 36 | 37 | # Rhombicuboctahedron 38 | Hello, Please create a Rhombicuboctahedron in the current design. Start by creating a component and sketch, they should have meaningful names. Then create all the faces by drawing the edges and vertices with sketchLines or any other type of sketch geometry you think is appropriate. Remember all sketch geometry can be 3d. The overall height of the Rhombicuboctahedron should be about 100mm, although the exact dimensions are not that important. 39 | 40 | 41 | # kind of worked, wrong geometry 42 | # Great triambic icosahedron 43 | Hello, Please create a Great triambic icosahedron in the current design. Start by querying the document structure, then create a component and sketch, they should have meaningful names. Create all the faces by drawing the edges and vertices with sketchLines. Remember all sketch geometry can be 3d. The overall height of the Great triambic icosahedron should be about 100mm, although the exact dimensions are not that important. 44 | 45 | 46 | # Great triambic icosahedron 47 | Hello, Please create a crossed triangular antiprism in the current design. Start by querying the document structure, then create a component and sketch, they should have meaningful names. Create all the faces by drawing the edges and vertices with sketchLines. Remember all sketch geometry can be 3d. The overall height should be about 100mm, although the exact dimensions are not that important. 48 | 49 | 50 | 51 | # fail Truncated octahedron 52 | Hello, Please create a Truncated octahedron in the current design. Create a component and sketch, then create the edges and vertices with sketch objects, such as sketchLines, sketchPoints, polygons, etc. he overall height of the polyhedron should be about 100mm, although the exact dimensions are not that important. After you have created the sketch lines, add a 5mm diameter pipe feature to each line. Please give the component and sketch meaning full names. 53 | 54 | 55 | 56 | # Hardware 57 | Create a standard M8x50mm Socket Head Screw. Add a small fillet to the vertical edges and a small chamfer to horizontal edges. You should put this component in another component called M8-Hardware. 58 | 59 | 60 | # screw copy 61 | Hello, I would like you to create an M8, M12 and M16 socket head screw based on the M6 screw in the current design. Start by creating 3 "new copies" of the M6 screw component. Each new component should be spaced 15mm from the previous. Then, rename and modify the parameters, so each of the new component's dimension are set to to the correct values for their respective screw types. 62 | 63 | 64 | # screw copy 65 | Hello, Create a M8, M10, M12, and M16 socket head screw based on the M6 screw in the current design. Start by creating 5 "new copies" of the M6 screw component. After creating the new components, arrange them in order of diameter, such that the each component does not overlap with the adjacent components. After moving the components, rename and modify the parameters so each of the new component's dimensions are set to to the correct values for their respective screw types. Make sure to adjust the parameter values in the sketches first, the body create features, and finally cut features. Also, the fillets, chamfers and shaft length extrude should be set proportionally to the screw size. Finally set the appearance on the screws to the following: The M10 should be some type of zinc. The M8, M16, should be some type of black oxide steel. The M6, M10, and M12 should be some type of stainless steel. 66 | 67 | 68 | # NEMA 17 69 | Hello, I would like to create a mounting plate for a NEMA 17 stepper motor. 70 | 71 | Isogrid 72 | Hello, I would like to create a section of Isogrid, that will be 3d printed. The section should be arranged as a hexagon with an approximate width of 100mm. It will contain 24 total triangular cutout sections and 7 total intersection points. After creating the component, you should start with a sketch on the xy plane. Think deeply about the best way for you to accomplish this. 73 | 74 | 75 | 76 | Please create a Gyroelongated triangular cupola. You should create each face by drawing the vertices with sketchLine objects. Remember all sketch geometry is 3d. The Gyroelongated triangular cupola should be about 100m tall. Begin by creating a component and sketch. 77 | 78 | 79 | # OLD hardware isolation 80 | Hello, Please hide every occurrence other than fastening hardware or their container occurrences? Fastening hardware includes all occurrences that contain words like M4, M5, M6, M8, screw, bolt, washer, nut, and hardware. Hardware containers usually have the phrase "hardware-container" in the name, and all bodies inside should be visible. Make sure you don't hide a hardware occurrence's ancestor occurrence, since they may be nested many levels deep. You should only hide the actual body in a occurrence, not the occurrence itself. All top level occurrences, and any occurrence that is an assembly or container should remain visible. Also, Hardware will never be made out of aluminum, and all plates should be hidden. 81 | 82 | 83 | 84 | # hardware isolation 85 | I would like to set all bodies relating to hardware visible, specifically: hardware, screws, bolts, nuts, washers, etc... Also components with the terms Thread, or M8 show be included. Can you first query the names of all the components. Then run a query that make all bodies visible whose parent component name includes relevant hardware related terms 86 | 87 | 88 | # Hello can you hid all bodies other than those that relate to fastening hardware, including screws, washers, nuts, bolts? Maybe query the names of all components first to figure out which keywords to use. 89 | 90 | # display based on location 91 | Hello, can you hide all bodies which are to the left of the YZ center plane 92 | 93 | 94 | # physical properties 95 | Can you hide the 10 largest bodies by volume 96 | 97 | 98 | 99 | # 100 | can you create a 80mm wide, 8 sided octagon at the origin point, then add a 20mm circle at the center point of each octagon edge 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | -------------------------------------------------------------------------------- /Fusion-GPT-Addin/commands/Browser/entry.py: -------------------------------------------------------------------------------- 1 | # browser 2 | # Copyright 2022 by Autodesk, Inc. 3 | # Permission to use, copy, modify, and distribute this software in object code form 4 | # for any purpose and without fee is hereby granted, provided that the above copyright 5 | # notice appears in all copies and that both that copyright notice and the limited 6 | # warranty and restricted rights notice below appear in all supporting documentation. 7 | # 8 | # AUTODESK PROVIDES THIS PROGRAM "AS IS" AND WITH ALL FAULTS. AUTODESK SPECIFICALLY 9 | # DISCLAIMS ANY IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR A PARTICULAR USE. 10 | # AUTODESK, INC. DOES NOT WARRANT THAT THE OPERATION OF THE PROGRAM WILL BE 11 | # UNINTERRUPTED OR ERROR FREE. 12 | 13 | from datetime import datetime 14 | import adsk.core 15 | from adsk.core import ValueInput 16 | from adsk.core import MessageBoxButtonTypes, ObjectCollection 17 | #from adsk.fusion import CombineFeature 18 | #from adsk.core import Camera 19 | import os 20 | import sys 21 | import json 22 | import math 23 | import time 24 | 25 | from ... import config 26 | from ...lib import fusion360utils as futil 27 | import importlib 28 | 29 | # custom modules 30 | #from ...lib.sutil import fusion_interface, gpt_client 31 | from ...f_interface import gpt_client 32 | 33 | app = adsk.core.Application.get() 34 | ui = app.userInterface 35 | 36 | PALETTE_NAME = 'STSi-Fusion-GPT' 37 | IS_PROMOTED = False 38 | 39 | # Using "global" variables by referencing values from /config.py 40 | PALETTE_ID = config.palette_id 41 | 42 | # Specify the full path to the local html. You can also use a web URL 43 | # such as 'https://www.autodesk.com/' 44 | PALETTE_URL = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'resources', 'html', 'index.html') 45 | 46 | # The path function builds a valid OS path. This fixes it to be a valid local URL. 47 | PALETTE_URL = PALETTE_URL.replace('\\', '/') 48 | 49 | # Set a default docking behavior for the palette 50 | PALETTE_DOCKING = adsk.core.PaletteDockingStates.PaletteDockStateFloating 51 | 52 | CMD_NAME = "Prompt Window" 53 | 54 | CMD_ID = f'{config.COMPANY_NAME}_{config.ADDIN_NAME}_{CMD_NAME}' 55 | CMD_Description = "Prompt Window" 56 | #IS_PROMOTED = False 57 | IS_PROMOTED = True 58 | 59 | # Global variables by referencing values from /config.py 60 | WORKSPACE_ID = config.design_workspace 61 | TAB_ID = config.tools_tab_id 62 | TAB_NAME = config.my_tab_name 63 | 64 | PANEL_ID = config.my_panel_id 65 | PANEL_NAME = config.my_panel_name 66 | PANEL_AFTER = config.my_panel_after 67 | 68 | # Resource location for command icons, here we assume a sub folder in this directory named "resources". 69 | ICON_FOLDER = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'resources', '') 70 | 71 | # Holds references to event handlers 72 | local_handlers = [] 73 | 74 | def print(string): 75 | """redefine print for fusion env""" 76 | futil.log(str(string)) 77 | 78 | 79 | # Executed when add-in is run. 80 | def start(): 81 | # ******************************** Create Command Definition ******************************** 82 | cmd_def = ui.commandDefinitions.addButtonDefinition(CMD_ID, CMD_NAME, CMD_Description, ICON_FOLDER) 83 | 84 | # Add command created handler. The function passed here will be executed when the command is executed. 85 | futil.add_handler(cmd_def.commandCreated, command_created) 86 | 87 | # ******************************** Create Command Control ******************************** 88 | # Get target workspace for the command. 89 | workspace = ui.workspaces.itemById(WORKSPACE_ID) 90 | 91 | # Get target toolbar tab for the command and create the tab if necessary. 92 | toolbar_tab = workspace.toolbarTabs.itemById(TAB_ID) 93 | if toolbar_tab is None: 94 | toolbar_tab = workspace.toolbarTabs.add(TAB_ID, TAB_NAME) 95 | 96 | # Get target panel for the command and and create the panel if necessary. 97 | panel = toolbar_tab.toolbarPanels.itemById(PANEL_ID) 98 | if panel is None: 99 | panel = toolbar_tab.toolbarPanels.add(PANEL_ID, PANEL_NAME, PANEL_AFTER, False) 100 | 101 | # Create the command control, i.e. a button in the UI. 102 | control = panel.controls.addCommand(cmd_def) 103 | 104 | # Now you can set various options on the control such as promoting it to always be shown. 105 | control.isPromoted = IS_PROMOTED 106 | 107 | 108 | # Executed when add-in is stopped. 109 | def stop(): 110 | # Get the various UI elements for this command 111 | workspace = ui.workspaces.itemById(WORKSPACE_ID) 112 | panel = workspace.toolbarPanels.itemById(PANEL_ID) 113 | command_control = panel.controls.itemById(CMD_ID) 114 | command_definition = ui.commandDefinitions.itemById(CMD_ID) 115 | palette = ui.palettes.itemById(PALETTE_ID) 116 | 117 | # Delete the button command control 118 | if command_control: 119 | command_control.deleteMe() 120 | 121 | # Delete the command definition 122 | if command_definition: 123 | command_definition.deleteMe() 124 | 125 | # Delete the Palette 126 | if palette: 127 | palette.deleteMe() 128 | 129 | 130 | # Function to be called when a user clicks the corresponding button in the UI. 131 | def command_created(args: adsk.core.CommandCreatedEventArgs): 132 | futil.log(f'{CMD_NAME} Command Created Event') 133 | 134 | # Connect to the events that are needed by this command. 135 | futil.add_handler(args.command.execute, command_execute, local_handlers=local_handlers) 136 | # futil.add_handler(args.command.inputChanged, command_input_changed, local_handlers=local_handlers) 137 | futil.add_handler(args.command.destroy, command_destroy, local_handlers=local_handlers) 138 | 139 | 140 | # connects to Assistant Interface running on external process 141 | # server interface 142 | server_itf = gpt_client.GptClient() 143 | 144 | def command_execute(args: adsk.core.CommandEventArgs): 145 | # General logging for debug. 146 | futil.log(f'{CMD_NAME}: Command execute event.') 147 | 148 | palettes = ui.palettes 149 | palette = palettes.itemById(PALETTE_ID) 150 | 151 | palette_width = 1000 152 | palette_height = 1000 153 | 154 | if palette is None: 155 | #palette = palettes.add2( 156 | palette = palettes.add2( 157 | id=PALETTE_ID, 158 | name=PALETTE_NAME, 159 | htmlFileURL=PALETTE_URL, 160 | isVisible=True, 161 | showCloseButton=True, 162 | isResizable=True, 163 | width=palette_width, 164 | height=palette_height 165 | ) 166 | 167 | palette.isVisible = True 168 | 169 | futil.add_handler(palette.closed, palette_closed) 170 | futil.add_handler(palette.navigatingURL, palette_navigating) 171 | # created 172 | futil.add_handler(palette.incomingFromHTML, palette_incoming) 173 | 174 | 175 | PALETTE_DOCKING = adsk.core.PaletteDockingStates.PaletteDockStateFloating 176 | palette.dockingState = PALETTE_DOCKING 177 | screen_width = app.activeViewport.width 178 | screen_height = app.activeViewport.height 179 | 180 | left = (screen_width - palette_width)-5 181 | # clear the tool row 182 | top = +120 183 | 184 | palette.left = left 185 | palette.top = top 186 | 187 | server_itf.reload_interface() 188 | 189 | 190 | 191 | # Use this to handle a user closing your palette. 192 | def palette_closed(args: adsk.core.UserInterfaceGeneralEventArgs): 193 | # General logging for debug. 194 | futil.log(f'{CMD_NAME}: Palette was closed.') 195 | 196 | 197 | # Use this to handle a user navigating to a new page in your palette. 198 | def palette_navigating(args: adsk.core.NavigationEventArgs): 199 | 200 | # Get the URL the user is navigating to: 201 | url = args.navigationURL 202 | 203 | futil.log(log_msg, adsk.core.LogLevels.InfoLogLevel) 204 | 205 | # Check if url is an external site and open in user's default browser. 206 | if url.startswith("http"): 207 | args.launchExternally = True 208 | 209 | 210 | 211 | def palette_incoming(html_args: adsk.core.HTMLEventArgs): 212 | """ 213 | handles events sent from Javascript in palette 214 | """ 215 | 216 | # read message sent from browser input javascript function 217 | message_data = json.loads(html_args.data) 218 | message_action = html_args.action 219 | 220 | if message_action == "error": 221 | print(message_data) 222 | 223 | # passes calls from js/html to the server_itf class 224 | # call server interface functions, initiated from js 225 | elif message_action == "function_call": 226 | # server_itf function name 227 | function_name = message_data.get("function_name") 228 | function_args = message_data.get("function_args", {}) 229 | return_data = {} 230 | 231 | if function_name is None: 232 | print(f"Error: Entry.py: No function name passed") 233 | 234 | # check if server_itf has function 235 | elif hasattr(server_itf, function_name) == True: 236 | 237 | # server_itf function to call 238 | function = getattr(server_itf, function_name) 239 | if callable(function) == True: 240 | print(f"Calling: {function_name}") 241 | return_data = function(**function_args) 242 | else: 243 | print(f"Error: {function_name} not callable") 244 | return_data = {} 245 | else: 246 | print(f"Error: server_itf has no attr: {function_name}") 247 | return_data = {} 248 | 249 | html_args.returnData = json.dumps(return_data) 250 | 251 | 252 | # TODO work on audio 253 | #elif message_action == "stop_record": 254 | # audio_text = server_itf.stop_record() 255 | # #audio_text = {"audio_text": audio_text["content"]} 256 | # html_args.returnData = json.dumps(audio_text) 257 | #elif message_action == "start_record": 258 | # server_itf.start_record() 259 | # html_args.returnData = "" 260 | 261 | elif message_action == "reset_all": 262 | server_itf.reload_modules() 263 | server_itf.reload_fusion_intf() 264 | html_args.returnData = "" 265 | 266 | elif message_action == "execute_tool_call": 267 | #server_itf.reload_interface() 268 | function_name = message_data["function_name"] 269 | function_args = message_data["function_args"] 270 | 271 | 272 | # tool call id should only be present if the user calls the 273 | # function from an existing thread call 274 | tool_call_id = message_data.get("tool_call_id") 275 | 276 | # convert to dict if passed as str when manually testing 277 | if isinstance(function_args, str) == False: 278 | function_args = json.dumps(function_args) 279 | 280 | # call function through the server interface class 281 | server_itf.call_function(function_name, function_args, tool_call_id) 282 | html_args.returnData = "" 283 | 284 | else: 285 | html_args.returnData = "" 286 | 287 | 288 | 289 | 290 | # This function will be called when the user completes the command. 291 | def command_destroy(args: adsk.core.CommandEventArgs): 292 | 293 | futil.log(f'{CMD_NAME} Command Destroy Event') 294 | global local_handlers 295 | local_handlers = [] 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | -------------------------------------------------------------------------------- /Fusion-GPT-Addin/commands/Browser/resources/html/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | STSI GPT Addin 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 |
21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 |
29 | 30 |
31 |
32 | 33 | 35 | 36 | 37 | 41 | 42 | 43 | 44 |
45 | 46 | 47 |
48 |
49 |
50 |
51 | 52 | 53 |
54 | 55 | 56 |
57 | 58 | 59 | 60 | 61 |
62 | 63 |
64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 |
72 |
Prompt input text size
73 | 74 | 75 |
76 | 77 | 78 |
79 | 80 | 81 |
82 | 83 | 84 | 85 | 88 | 89 | 90 | 91 |
92 | 93 |
94 | 95 | 96 | 97 | 98 | 99 |
100 | 101 | 102 | 103 | 104 | 105 | 106 |
107 | 108 |
109 |
Print function results in the Text Commands Window.
110 | 111 | 112 |
113 | 114 |
115 |
Sends the text prompt to the model when the enter key is pressed.
116 | 117 | 118 |
119 | 120 |
121 |
Shows "run" object response in console.
122 | 123 | 124 | 125 |
126 | 127 |
128 |
Shows "step" object response in console.
129 | 130 | 131 |
132 | 133 |
134 |
Shows "results" object response in console.
135 | 136 | 137 |
138 | 139 |
140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 |
149 | 150 | 151 |
152 | 153 |
Uploads current settings to the OpenAI Assistant. This includes the model type, system instructions, reasoning effort and all tool (function) definitions (see "Tools" Tab)
154 | 155 |
156 | 157 | 158 |
159 |
Select the assistant model. Button click queries Open AI API for available models, (non assistant models filtered out ). Common models included by default. Changes not applied until "Upload Model Settings" Clicked.
160 | 161 | 165 | 166 | 173 | 174 |
175 | 176 | 177 |
178 |
Lists available files in the /oai_container/system_instructions directory, you may want to use different base system instructions for different models. Changes not applied until "Upload Model Settings" Clicked.
179 | 180 | 184 | 185 | 188 | 189 |
190 | 191 | 192 |
193 |
Reasoning effort for the assistant model. Only valid for reasoning models (o1, o3), set to None for all other models. Changes not applied until "Upload Model Settings" Clicked.
194 | 197 | 198 | 205 | 206 |
207 | 208 | 209 | 210 |
211 | 212 | 213 | 214 |
215 | 219 | 220 | 221 |
222 |
Reloads the Fusion Sub module class, useful when debugging
223 | 224 |
225 | 226 |
227 |
Reloads the Fusion Interface class, useful when debugging
228 | 229 |
230 | 231 | 232 | 233 |
234 |
Reloads the style sheet (css file). Useful for debugging style/formatting on dynamically generated html
235 | 236 | 237 |
238 | 239 |
240 |
//TODO
241 | 242 |
243 | 244 | 245 |
246 | 247 | 248 | 249 | 250 |
251 | 252 |
253 |
Reloads the internal object reference dictionary.
254 | 255 |
256 | 257 | 258 |
259 |
Reloads the global object index every time its is referenced, this should be checked when during 3d modeling, sketch/body creation etc. On very large assembly, with hundreds of thousands of BRepBody edges/vertices sketch lines, this will slow down performance. It is only necessary when you are creating/deleting objects.
260 | 261 | 262 | 263 |
264 | 265 | 266 | 267 | 268 |
269 |
On start/reload, this program stores create a dictionary with references to document objects (Component, Occurrence, Sketch, BRepBody, Appearance, etc..) By default, in excludes Sketch child entities (SketchCurve, SketchPoint, etc..) Because even for simple sketch this may include hundreds or thousands of objects. In a complex assembly this will slow down performance, however modeling is rarely done in a top level assembly. Enable this when you are using the program to create and modify sketches. You must click the "Reload Index" button after changing the value
270 | 271 | 272 | 273 | 274 |
275 | 276 | 277 |
278 |
(See Index Sketch Child Objects help text) By default, in excludes BRepBody child entities (Vertex, Edge, Face, etc..) Because even for simple BRepBody this may include hundreds or thousands of objects. In a complex assembly this will slow down performance, however modeling is rarely done in a top level assembly. Enable this when you are using the program to create and modify BRepBody. You must click the "Reload Index" button after changing the value.
279 | 280 | 281 | 282 | 283 |
284 | 285 | 286 | 287 | 288 |
289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 |
299 | 300 |
301 |
When "record_playback" is selected, all Assistant messages are stored locally. When "playback" is clicked all messages will be rerun. This is useful when debugging, as you don't have waste tokens on actual API call. Note: for complex/ long runs object references may be lost, causing additional errors
302 | 303 |
304 | 305 | 306 |
307 |
Print response messages to console
308 | 309 |
310 | 311 | 312 |
313 |
Clears outputs in the "Prompt Output" tab
314 | 315 |
316 | 317 | 318 |
319 |
Record model messages for playback
320 | 321 | 322 | 323 |
324 | 325 | 326 | 327 | 328 | 329 |
330 | 331 | 332 | 333 | 334 | 335 |
336 | 337 | 338 | 339 | 340 |
341 | 342 | 343 |
344 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 |
354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | -------------------------------------------------------------------------------- /Fusion-GPT-Addin/commands/Browser/resources/html/static/style.css: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | /* Hover effect for the button */ 6 | .__input-container button:hover { 7 | background-color: rgb(249,70,249); /* vim magenta */ 8 | } 9 | 10 | /* Hover effect for the button */ 11 | .__input-container label { 12 | color: white; 13 | 14 | } 15 | 16 | 17 | 18 | /* 19 | * global font styles 20 | */ 21 | textarea, button, input, span, div, label, select{ 22 | font-family: 'SF Mono', Consolas, 'Roboto Mono', 'Source Code Pro', Monaco, monospace; 23 | 24 | 25 | } 26 | 27 | label{ 28 | color: white; 29 | 30 | } 31 | 32 | 33 | body { 34 | margin: 0px; 35 | padding: 0px; 36 | border: none; 37 | background-color: cyan; 38 | background-color: #262626; /* background gray*/ 39 | } 40 | 41 | div, span{ 42 | padding: 0px; 43 | margin 0px; 44 | 45 | } 46 | 47 | 48 | 49 | .help{ 50 | 51 | display: none; 52 | color: red; 53 | font-size: 14px; 54 | } 55 | 56 | #toggleHelp{ 57 | 58 | color: red; 59 | } 60 | 61 | 62 | /* 63 | * margin bottom must macth inputwrapper height 64 | * postion: fixed and bottom:0 keep text box at bottom 65 | * 66 | display: flex; 67 | */ 68 | #pageContainer{ 69 | height: 100vh; 70 | width: 100vw; 71 | padding: 0px; 72 | margin: 0px; 73 | border: none; 74 | background-color: #262626; /* background gray*/ 75 | background-color: pink; /* background gray*/ 76 | background-color: #262626; /* background gray*/ 77 | background-color: #1B1212; 78 | } 79 | 80 | 81 | 82 | /* 83 | * notes 84 | * 85 | * any input color should be light gray 86 | * 87 | */ 88 | 89 | /* 90 | * ============ Wrapper =========== 91 | * top-wrapper class is full width elemnt 92 | * the top levle in pageContainer 93 | * 94 | border: 1px solid white; 95 | */ 96 | 97 | .top-wrapper{ 98 | margin: 0px; 99 | padding: 0px; 100 | border-radius: 0px; 101 | box-sizing: border-box; /* includes padding/ border in content width*/ 102 | width: 100%; 103 | padding-left: 5px; 104 | padding-right: 5px; 105 | 106 | } 107 | 108 | #tabWrapper{ 109 | height: auto; 110 | padding: 0px; 111 | padding-bottom: 15px; 112 | } 113 | 114 | #pageWrapper{ 115 | height: auto; 116 | overflow: auto; 117 | } 118 | 119 | #consoleWrapper{ 120 | display:none; 121 | overflow: auto; 122 | height: 100px; 123 | } 124 | 125 | #inputWrapper { 126 | height: auto; 127 | padding-top: 10px; 128 | padding-bottom: 10px; 129 | } 130 | 131 | .content-section{ 132 | background-color: #1B1212; 133 | padding-left: 5px; 134 | padding-right: 5px; 135 | 136 | } 137 | 138 | 139 | 140 | /* 141 | * =========== tabs =========== 142 | */ 143 | 144 | .tab-button { 145 | 146 | color: white; 147 | background-color: #878787; 148 | 149 | box-shadow: none; 150 | border: none; 151 | text-decoration: none; 152 | font-size: 15px; 153 | 154 | padding-top: 2px; 155 | padding-bottom: 2px; 156 | padding-left: 0px; 157 | padding-right: 0px; 158 | margin: 0px; 159 | width: 200px; 160 | 161 | 162 | } 163 | 164 | #tabButton0{ 165 | background-color: #1B1212; /*background black*/ 166 | color: magenta; 167 | 168 | } 169 | 170 | 171 | 172 | 173 | 174 | /* 175 | * =========== response container =========== 176 | */ 177 | 178 | 179 | .response-container{ 180 | margin: 0px; 181 | padding: 0px; 182 | 183 | } 184 | 185 | 186 | /* 187 | * display console output 188 | */ 189 | 190 | 191 | #consoleOutput{ 192 | 193 | width: auto; 194 | height:100%; 195 | 196 | overflow: auto; 197 | 198 | border: 2px solid magenta; 199 | border-radius: 2px; 200 | padding: 0px; 201 | 202 | margin-top: 5px; 203 | margin-bottom: 5px; 204 | margin-left: 0px; 205 | margin-right: 0px; 206 | 207 | } 208 | 209 | 210 | 211 | /* 212 | * margin bottom must macth inputwrapper height 213 | * postion: fixed and bottom:0 keep text box at bottom 214 | * 215 | * 216 | */ 217 | 218 | 219 | 220 | /* 221 | * show hide methods 222 | * 223 | display: flex; 224 | flex-direction: column; 225 | */ 226 | #toolTestContainer{ 227 | box-sizing: border-box; /* includes padding/ border in content width*/ 228 | height: 100%; 229 | width: 100%; 230 | margin: 0px; 231 | padding: 0px; 232 | } 233 | 234 | 235 | 236 | /* 237 | * show hide methods 238 | * button 239 | */ 240 | .tool-call-class-title{ 241 | display: inline-block; 242 | margin: 0px; 243 | font-size: 16; 244 | padding-left: 10px; 245 | padding-right: 10px; 246 | padding-top: 5px; 247 | padding-bottom: 5px; 248 | border: 2px solid pink; 249 | border-radius: 5px; 250 | 251 | } 252 | 253 | .tool-row{ 254 | box-sizing: border-box; /* includes padding/ border in content width*/ 255 | vertical-align: top; 256 | 257 | margin-right: 0px; 258 | margin-left: 0px; 259 | margin-top: 5px; 260 | margin-bottom: 5px; 261 | padding: 5px; 262 | padding:0px; 263 | 264 | border-radius: 5px; 265 | 266 | border: 1px solid cyan; 267 | 268 | } 269 | 270 | /* call function method button */ 271 | .tool-call-button{ 272 | box-sizing: border-box; /* includes padding/ border in content width*/ 273 | display: inline-block; 274 | font-size:14px; 275 | vertical-align: top; 276 | text-align: left; 277 | 278 | padding-left: 5px; 279 | padding-right: 5px; 280 | padding-top: 3px; 281 | padding-bottom: 3px; 282 | 283 | margin: 0px; 284 | 285 | width: 250px; 286 | min-height: 30px; 287 | border-radius: 5px; 288 | white-space: normal; /* Allow text to wrap */ 289 | word-break: break-word; /* Break words if necessary */ 290 | 291 | } 292 | 293 | 294 | .function-input-container{ 295 | box-sizing: border-box; /* includes padding/ border in content width*/ 296 | display: inline-block; 297 | vertical-align: top; 298 | max-width: 100%; 299 | margin: 0px; 300 | 301 | margin-left: 5px; 302 | margin-right: 5px; 303 | 304 | min-height: 30px; 305 | min-width: 100px; 306 | 307 | border-radius: 5px; 308 | word-break: break-word; /* Break words if necessary */ 309 | } 310 | 311 | 312 | .function-input-container textarea{ 313 | box-sizing: border-box; /* includes padding/ border in content width*/ 314 | vertical-align: middle; 315 | text-align: left; 316 | font-size:18px; 317 | margin-left: 4px; 318 | margin-right: 4px; 319 | padding-left: 5px; 320 | height: auto; 321 | min-height: 30px; 322 | min-width: 50px; 323 | max-width: 100%; 324 | border-radius: 5px; 325 | } 326 | 327 | 328 | 329 | 330 | /* tool test title, buttons and input boxes*/ 331 | .toolClassContainer{ 332 | width: auto; 333 | font-size: 12px; 334 | color: white; 335 | min-width: 5ch; 336 | 337 | border: 1px solid magenta; 338 | border-radius: 3px; 339 | padding-right: 10px; 340 | padding-left: 10px; 341 | padding-top: 2px; 342 | padding-bottom: 2px; 343 | 344 | margin-left: 10px; 345 | margin-right: 10px; 346 | margin-bottom: 3px; 347 | margin-top: 3px; 348 | 349 | } 350 | 351 | .toolCallClassTitle{ 352 | width: auto; 353 | font-size: 14px; 354 | margin-left: 1px; 355 | margin-right: 1px; 356 | margin-bottom: 1px; 357 | padding-bottom: 1px; 358 | 359 | } 360 | 361 | 362 | 363 | /* tool test button and input boxes*/ 364 | .tool-row{ 365 | display: block; 366 | width: auto; 367 | 368 | font-size: 14px; 369 | color: white; 370 | margin-left: 10px; 371 | margin-right: 1px; 372 | margin-bottom: 1px; 373 | padding-bottom: 1px; 374 | 375 | } 376 | 377 | 378 | 379 | 380 | 381 | /* 382 | * ===== Input container ===== 383 | */ 384 | 385 | 386 | #textSizeInput{ 387 | width: 50px; 388 | } 389 | 390 | #promptTextInput{ 391 | box-sizing: border-box; 392 | padding: 10px; 393 | margin-right: 0px; 394 | margin-left: 0px; 395 | width: 100%; 396 | font-size: 20px; 397 | 398 | border: 2px solid white; 399 | background-color: #303030; 400 | color: white; 401 | border-radius: 5px; 402 | margin-top: 5px; 403 | margin-bottom: 15px; /* Space between the textarea and the button row */ 404 | resize: vertical; 405 | } 406 | 407 | 408 | 409 | 410 | #submitRow{ 411 | 412 | box-sizing: border-box; 413 | width: 100%; 414 | display: flex; 415 | justify-content: space-between; /* Pushes left and right groups apart */ 416 | 417 | padding-right: 20px; 418 | 419 | } 420 | 421 | 422 | #submitButton{ 423 | border: 3px solid magenta; 424 | padding-right: 10px; 425 | padding-left: 10px; 426 | } 427 | 428 | #submitRow button{ 429 | padding-top: 2px; 430 | padding-bottom: 2px; 431 | padding-right: 5px; 432 | padding-left: 5px; 433 | border-radius: 5px; 434 | font-size: 16px; 435 | cursor: pointer; 436 | transition: background-color 0.3s; 437 | } 438 | 439 | .left-group, .right-group { 440 | display: flex; 441 | gap: 10px; /* Adds spacing between input elements */ 442 | } 443 | 444 | .right-group { 445 | margin-left: auto; /* Moves right-group to the right */ 446 | margin-right: 10px; 447 | } 448 | 449 | .submit-button-row { 450 | display: flex; /* Use Flexbox to arrange buttons in a row */ 451 | justify-content: flex-start; /* Align buttons to the start of the container */ 452 | margin-bottom: 15px; /* Space between the textarea and the button row */ 453 | gap: 10px; /* Space between the buttons */ 454 | } 455 | 456 | 457 | 458 | .input-container{ 459 | display: flex; 460 | box-sizing: border-box; 461 | justify-content: flex-start; /* Align buttons to the start of the container */ 462 | min-width: 100px; 463 | max-width: 300px; 464 | max-height: 200px; 465 | min-height: 30px; 466 | overflow: auto; 467 | border: 0px solid magenta; 468 | place-items:center; 469 | } 470 | 471 | 472 | .test-content{ 473 | font-size: 100px 474 | 475 | } 476 | 477 | .setting-row { 478 | display: flex; /* Use Flexbox to arrange buttons in a row */ 479 | box-sizing: border-box; 480 | justify-content: flex-start; /* Align buttons to the start of the container */ 481 | margin-bottom: 5px; /* Space between the textarea and the button row */ 482 | gap: 10px; 483 | margin-right: 5px; 484 | margin-left: 5px; 485 | } 486 | 487 | 488 | .input-container button{ 489 | width: 100%; 490 | box-sizing: border-box; 491 | padding: 0px; 492 | margin: 0px; 493 | padding-right: 5px; 494 | padding-left: 5px; 495 | border-radius: 5px; 496 | font-size: 14px; 497 | cursor: pointer; 498 | transition: background-color 0.3s; 499 | border: 1px solid pink; 500 | min-height: 30px; 501 | 502 | } 503 | 504 | .input-container label{ 505 | margin: 0px; 506 | padding: 0px; 507 | text-align: center; 508 | } 509 | 510 | .input-container input[type="checkbox"]{ 511 | width:20px; 512 | height:20px; 513 | margin-right:20px; 514 | 515 | } 516 | 517 | .input-container input[type="number"]{ 518 | width: 30px; 519 | font-size: 14px; 520 | height:30px; 521 | margin: 0px; 522 | } 523 | 524 | .select-container{ 525 | width: auto; 526 | display: block; 527 | max-width: 350px; 528 | 529 | } 530 | 531 | .select-container select{ 532 | display: block; 533 | min-height: 30px; 534 | min-width: 130px; 535 | width: 100%; 536 | font-size: 14px; 537 | border-radius: 5px; 538 | 539 | } 540 | 541 | .select-container button{ 542 | width: 100%; 543 | margin: 0px; 544 | padding: 0px; 545 | } 546 | 547 | .dropdown-label{ 548 | display: flex; 549 | place-items:center; 550 | height: 30px; 551 | } 552 | 553 | .dropdown-label button{ 554 | min-height:20px; 555 | font-size: 12px; 556 | padding: 0px; 557 | margin: 0px; 558 | 559 | } 560 | 561 | 562 | 563 | 564 | 565 | 566 | /* top run container 567 | * 568 | *background-color: rgb(0,255,0); 569 | *background-color: rgb(136,249,168); 570 | * background-color: rgb(55,247,247); green 571 | * 572 | * 573 | * */ 574 | 575 | 576 | 577 | 578 | 579 | /* 580 | * =========== run output ===============t 581 | * 582 | */ 583 | 584 | .run-output { 585 | box-sizing: border-box; 586 | margin-top: 10px; 587 | border: 1px solid #ccc; 588 | border-radius: 5px; 589 | padding: 10px; 590 | padding-right: 0px; 591 | background-color: pink; 592 | 593 | } 594 | 595 | .run-container{ 596 | box-sizing: border-box; 597 | margin: 5px; 598 | padding: 5px; 599 | padding-right: 0px; 600 | border: 1px solid white; 601 | border-radius: 5px; 602 | background-color: darkgray; 603 | } 604 | 605 | .log-header{ 606 | background-color:none; 607 | } 608 | 609 | 610 | .log-response-header{ 611 | padding-left: 20px; 612 | } 613 | 614 | 615 | 616 | /* element type */ 617 | .span-info{ 618 | font-size: 10px; 619 | color: red; 620 | } 621 | 622 | .user-text { 623 | box-sizing: border-box; 624 | display: flex; 625 | justify-content: space-between; 626 | align-items: center; 627 | color: yellow; 628 | border: 1px solid white; 629 | background-color: purple; 630 | border-radius: 5px; 631 | padding: 5px; 632 | margin: 5px; 633 | } 634 | 635 | .log-button { 636 | margin: 2px; 637 | font-size: 14px; 638 | padding-top: 2px; 639 | padding-bottom: 2px; 640 | padding-left: 10px; 641 | padding-right: 10px; 642 | border-radius: 5px; 643 | } 644 | 645 | .function-button{ 646 | margin: 5px; 647 | color: #00ffff; /*cyan*/ 648 | 649 | } 650 | 651 | .function-name { 652 | margin: 5px; 653 | font-size: 14px; 654 | color: #00ffff; /*cyan*/ 655 | color: magenta; /*cyan*/ 656 | border: 1px solid black; 657 | background-color: white; 658 | border-radius: 5px; 659 | } 660 | 661 | .step-container { 662 | border: 1px solid black; 663 | border-radius: 5px; 664 | 665 | margin-left: 20px; 666 | margin-right: 5px; 667 | margin-top: 5px; 668 | margin-bottom: 10px; 669 | padding: 5px; 670 | background-color: #3a3a3a; 671 | color: white; 672 | } 673 | 674 | 675 | /*text area*/ 676 | .function-body-textarea , .functionBody{ 677 | box-sizing: border-box; 678 | color: #ff00ff; 679 | font-size: 16px; 680 | width: 100%; 681 | padding: 10px; 682 | border: 1px solid white; 683 | border-radius: 5px; 684 | resize: vertical; /*text area wont resize horizontal*/ 685 | background-color: #303030; 686 | } 687 | 688 | 689 | .function-results{ 690 | box-sizing: border-box; 691 | border: 1px solid white; 692 | border-radius: 5px; 693 | 694 | min-width: 90%; 695 | font-size: 12px; 696 | padding: 10px; 697 | margin-left: 20px; 698 | max-height: 400px; 699 | background-color: #303030; 700 | color: cyan; 701 | resize: vertical; /*text area wont resize horizontal*/ 702 | overflow: auto; 703 | 704 | 705 | } 706 | 707 | .display-setting{ 708 | color: orange; 709 | 710 | } 711 | 712 | .text-response { 713 | margin-left: 10px; 714 | color: white; 715 | } 716 | 717 | 718 | 719 | .streamOutput{ 720 | 721 | } 722 | 723 | 724 | 725 | 726 | 727 | #sqlContent{ 728 | width:100%; 729 | height:100%; 730 | padding: 5px; 731 | 732 | } 733 | 734 | 735 | .sql-row { 736 | border: 1px solid white; 737 | margin-top:5px; 738 | padding: 5px; 739 | border-radius: 5px; 740 | height: auto; 741 | 742 | } 743 | 744 | .sql-row textarea{ 745 | box-sizing: border-box; /* includes padding/ border in content width*/ 746 | vertical-align: left; 747 | width:100%; 748 | height: 60px; 749 | text-align: left; 750 | font-size:16px; 751 | padding-left: 5px; 752 | min-width: 50px; 753 | max-width: 100%; 754 | border-radius: 5px; 755 | } 756 | 757 | .sql-row button{ 758 | width: 300px; 759 | height: 30px; 760 | border-radius: 5px; 761 | 762 | } 763 | 764 | 765 | 766 | 767 | 768 | 769 | 770 | 771 | 772 | 773 | 774 | -------------------------------------------------------------------------------- /Fusion-GPT-Addin/f_interface/modules/transient_objects.py: -------------------------------------------------------------------------------- 1 | # transient object 2 | 3 | import adsk.core 4 | import adsk.fusion 5 | import adsk.cam 6 | import traceback 7 | import sys 8 | import math 9 | import os 10 | import json 11 | import inspect 12 | import re 13 | #from multiprocessing.connection import Client 14 | from array import array 15 | import time 16 | import functools 17 | import hashlib 18 | import base64 19 | import re 20 | import random 21 | from ... import config 22 | from ...lib import fusion360utils as futil 23 | 24 | from .shared import ToolCollection 25 | 26 | def print(string): 27 | """redefine print for fusion env""" 28 | futil.log(str(string)) 29 | 30 | print(f"RELOADED: {__name__.split("%2F")[-1]}") 31 | 32 | 33 | class TransientObjects(ToolCollection): 34 | 35 | @ToolCollection.tool_call 36 | def create_point3d_list(self, coords_list: list = [[.5, .5, 0], [1,2,0]]) -> str: 37 | """ 38 | { 39 | "name": "create_point3d_list", 40 | "description": "Creates a set of adsk.core.Point3D objects in memory from the specified list of [x, y, z] coordinates. Returns a JSON mapping each index to the newly created reference token (or name).", 41 | "parameters": { 42 | "type": "object", 43 | "properties": { 44 | "coords_list": { 45 | "type": "array", 46 | "description": "An array of [x, y, z] coordinate triples.", 47 | "items": { 48 | "type": "array", 49 | "items": { "type": "number" }, 50 | "minItems": 3, 51 | "maxItems": 3 52 | } 53 | } 54 | }, 55 | "required": ["coords_list"], 56 | "returns": { 57 | "type": "string", 58 | "description": "A JSON object mapping each index in coords_list to the reference token for the newly created Point3D." 59 | } 60 | } 61 | } 62 | """ 63 | 64 | try: 65 | if not coords_list or not isinstance(coords_list, list): 66 | return "Error: coords_list must be a non-empty list of [x, y, z] items." 67 | 68 | app = adsk.core.Application.get() 69 | if not app: 70 | return "Error: Fusion 360 is not running." 71 | 72 | product = app.activeProduct 73 | if not product or not isinstance(product, adsk.fusion.Design): 74 | return "Error: No active Fusion 360 design found." 75 | 76 | # If you don't already have a dict for storing references, create one. 77 | # We'll store the references as self._point_dict: Dict[str, adsk.core.Point3D] 78 | if not hasattr(self, "_point_dict"): 79 | self._point_dict = {} 80 | 81 | results = {} 82 | for i, coords in enumerate(coords_list): 83 | if not isinstance(coords, list) or len(coords) != 3: 84 | results[str(i)] = "Error: invalid [x, y, z] triple." 85 | continue 86 | 87 | x, y, z = coords 88 | # Create the Point3D object 89 | p3d = adsk.core.Point3D.create(x, y, z) 90 | 91 | #p3d_name = f"Point3D__{i}_{x}_{y}_{z}" 92 | p3d_entity_token = self.set_obj_hash(p3d) 93 | 94 | # Return the token for the user 95 | results[p3d_entity_token] = f"Success: Created new 'Point3D' with token '{p3d_entity_token}' at {coords}" 96 | 97 | return json.dumps(results) 98 | 99 | except: 100 | return "Error: An unexpected exception occurred:\n" + traceback.format_exc() 101 | 102 | @ToolCollection.tool_call 103 | def create_matrix3d_list(self, matrix_list: list = [[ 104 | 1.0, 0.0, 0.0, 0.0, 105 | 0.0, 1.0, 0.0, 0.0, 106 | 0.0, 0.0, 1.0, 0.0, 107 | 0.0, 0.0, 0.0, 1.0]]) -> str: 108 | """ 109 | { 110 | "name": "create_matrix3d_list", 111 | "description": "Creates a set of adsk.core.Matrix3D objects from an array of 16-float arrays (row-major). Returns a JSON mapping each new matrix's entity token to a success message.", 112 | "parameters": { 113 | "type": "object", 114 | "properties": { 115 | "matrix_list": { 116 | "type": "array", 117 | "description": "An array of 16-float arrays representing row-major 4x4 transforms. Example: [[1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1], [...]]", 118 | "items": { 119 | "type": "array", 120 | "items": { "type": "number" }, 121 | "minItems": 16, 122 | "maxItems": 16 123 | } 124 | } 125 | }, 126 | "required": ["matrix_list"], 127 | "returns": { 128 | "type": "string", 129 | "description": "A JSON object mapping each new Matrix3D's token to a success or error message." 130 | } 131 | } 132 | } 133 | """ 134 | 135 | try: 136 | if not matrix_list or not isinstance(matrix_list, list): 137 | return "Error: matrix_list must be a non-empty list of 16-float arrays." 138 | 139 | app = adsk.core.Application.get() 140 | if not app: 141 | return "Error: Fusion 360 is not running." 142 | 143 | product = app.activeProduct 144 | if not product or not isinstance(product, adsk.fusion.Design): 145 | return "Error: No active Fusion 360 design found." 146 | 147 | results = {} 148 | 149 | for i, mat_vals in enumerate(matrix_list): 150 | # Validate input shape 151 | if not isinstance(mat_vals, list) or len(mat_vals) != 16: 152 | results[f"Index_{i}"] = "Error: must provide exactly 16 floats for Matrix3D." 153 | continue 154 | 155 | try: 156 | m3d = adsk.core.Matrix3D.create() 157 | m3d.setWithArray(mat_vals) 158 | 159 | # Generate a name for referencing 160 | #mat_name = f"Matrix3D_{i}_{mat_vals}_{random.random()}" 161 | 162 | mat_token = self.set_obj_hash(m3d) 163 | 164 | results[mat_token] = f"Success: Created new 'Matrix3D' with token '{mat_token}' at {mat_vals}." 165 | except Exception as e: 166 | results[f"Index_{i}"] = f"Error: {str(e)}" 167 | 168 | return json.dumps(results) 169 | except: 170 | return "Error: An unexpected exception occurred:\n" + traceback.format_exc() 171 | 172 | @ToolCollection.tool_call 173 | def create_point2d_list(self, coords_list: list = None) -> str: 174 | """ 175 | { 176 | "name": "create_point2d_list", 177 | "description": "Creates adsk.core.Point2D objects from the given list of [x, y] pairs. Returns a JSON mapping tokens to success messages.", 178 | "parameters": { 179 | "type": "object", 180 | "properties": { 181 | "coords_list": { 182 | "type": "array", 183 | "description": "An array of [x, y] pairs for 2D points.", 184 | "items": { 185 | "type": "array", 186 | "items": { "type": "number" }, 187 | "minItems": 2, 188 | "maxItems": 2 189 | } 190 | } 191 | }, 192 | "required": ["coords_list"], 193 | "returns": { 194 | "type": "string", 195 | "description": "A JSON object mapping each new Point2D's token to a success or error message." 196 | } 197 | } 198 | } 199 | """ 200 | 201 | try: 202 | if not coords_list or not isinstance(coords_list, list): 203 | return "Error: coords_list must be a non-empty list of [x, y] pairs." 204 | 205 | app = adsk.core.Application.get() 206 | if not app: 207 | return "Error: Fusion 360 is not running." 208 | 209 | product = app.activeProduct 210 | if not product or not isinstance(product, adsk.fusion.Design): 211 | return "Error: No active Fusion 360 design found." 212 | 213 | results = {} 214 | 215 | for i, coords in enumerate(coords_list): 216 | if not isinstance(coords, list) or len(coords) != 2: 217 | results[f"Index_{i}"] = "Error: invalid [x, y] pair." 218 | continue 219 | 220 | x, y = coords 221 | try: 222 | p2d = adsk.core.Point2D.create(x, y) 223 | #p2d_name = f"Point2D_{i}_{x}_{y}_{random.random()}" 224 | p2d_token = self.set_obj_hash(p2d) 225 | results[p2d_token] = f"Success: Created new 'Point2D' with token '{p2d_token}' at {coords}" 226 | except Exception as e: 227 | results[f"Index_{i}"] = f"Error: {str(e)}" 228 | 229 | return json.dumps(results) 230 | 231 | except: 232 | return "Error: An unexpected exception occurred:\n" + traceback.format_exc() 233 | 234 | @ToolCollection.tool_call 235 | def create_matrix2d_list(self, matrix_list: list = None) -> str: 236 | """ 237 | { 238 | "name": "create_matrix2d_list", 239 | "description": "Creates a set of adsk.core.Matrix2D objects from an array of 9-float arrays (row-major). Returns a JSON mapping each new matrix token to success or error.", 240 | "parameters": { 241 | "type": "object", 242 | "properties": { 243 | "matrix_list": { 244 | "type": "array", 245 | "description": "An array of 9-float arrays in row-major format for 2D transforms. Example: [[1,0,0, 0,1,0, 0,0,1], [...]]", 246 | "items": { 247 | "type": "array", 248 | "items": { "type": "number" }, 249 | "minItems": 9, 250 | "maxItems": 9 251 | } 252 | } 253 | }, 254 | "required": ["matrix_list"], 255 | "returns": { 256 | "type": "string", 257 | "description": "A JSON object mapping each new Matrix2D's token to a success or error message." 258 | } 259 | } 260 | } 261 | """ 262 | try: 263 | if not matrix_list or not isinstance(matrix_list, list): 264 | return "Error: matrix_list must be a non-empty list of 9-float arrays." 265 | 266 | app = adsk.core.Application.get() 267 | if not app: 268 | return "Error: Fusion 360 is not running." 269 | 270 | product = app.activeProduct 271 | if not product or not isinstance(product, adsk.fusion.Design): 272 | return "Error: No active Fusion 360 design found." 273 | 274 | results = {} 275 | 276 | for i, mat_vals in enumerate(matrix_list): 277 | if not isinstance(mat_vals, list) or len(mat_vals) != 9: 278 | results[f"Index_{i}"] = "Error: must provide exactly 9 floats for Matrix2D." 279 | continue 280 | 281 | try: 282 | m2d = adsk.core.Matrix2D.create() 283 | m2d.setWithArray(mat_vals) 284 | #mat_name = f"Matrix2D_{i}_{mat_vals}_{random.random()}" 285 | mat_token = self.set_obj_hash(m2d) 286 | 287 | results[mat_token] = f"Success: Created new 'Matrix2D' with token '{mat_token}' at {mat_vals}." 288 | except Exception as e: 289 | results[f"Index_{i}"] = f"Error: {str(e)}" 290 | 291 | return json.dumps(results) 292 | 293 | except: 294 | return "Error: An unexpected exception occurred:\n" + traceback.format_exc() 295 | 296 | @ToolCollection.tool_call 297 | def create_vector3d_list(self, coords_list: list = [[1, 0, 0], [0, 1, 0]]) -> str: 298 | """ 299 | { 300 | "name": "create_vector3d_list", 301 | "description": "Creates a set of adsk.core.Vector3D objects from the specified list of [x, y, z] coordinates. Returns a JSON mapping each index to the newly created reference token (or name).", 302 | "parameters": { 303 | "type": "object", 304 | "properties": { 305 | "coords_list": { 306 | "type": "array", 307 | "description": "An array of [x, y, z] coordinate triples representing vector directions.", 308 | "items": { 309 | "type": "array", 310 | "items": { "type": "number" }, 311 | "minItems": 3, 312 | "maxItems": 3 313 | } 314 | } 315 | }, 316 | "required": ["coords_list"], 317 | "returns": { 318 | "type": "string", 319 | "description": "A JSON object mapping each index in coords_list to the reference token for the newly created Vector3D." 320 | } 321 | } 322 | } 323 | """ 324 | 325 | try: 326 | if not coords_list or not isinstance(coords_list, list): 327 | return "Error: coords_list must be a non-empty list of [x, y, z] items." 328 | 329 | app = adsk.core.Application.get() 330 | if not app: 331 | return "Error: Fusion 360 is not running." 332 | 333 | product = app.activeProduct 334 | if not product or not isinstance(product, adsk.fusion.Design): 335 | return "Error: No active Fusion 360 design found." 336 | 337 | # If you don't already have a dict for storing references, create one. 338 | # We'll store the references as self._vector_dict: Dict[str, adsk.core.Vector3D] 339 | if not hasattr(self, "_vector_dict"): 340 | self._vector_dict = {} 341 | 342 | results = {} 343 | for i, coords in enumerate(coords_list): 344 | if not isinstance(coords, list) or len(coords) != 3: 345 | results[str(i)] = "Error: invalid [x, y, z] triple." 346 | continue 347 | 348 | x, y, z = coords 349 | # Create the Vector3D object 350 | vec3d = adsk.core.Vector3D.create(x, y, z) 351 | #vec3d_name = f"Vector3D_{i}_{x}_{y}_{z}_{random.random()}" 352 | vec3d_token = self.set_obj_hash(vec3d) 353 | 354 | # Store a success message for the new reference token 355 | results[vec3d_token] = f"Success: Created new Vector3D with token '{vec3d_token}' at {coords}" 356 | 357 | return json.dumps(results) 358 | 359 | except: 360 | return "Error: An unexpected exception occurred:\n" + traceback.format_exc() 361 | 362 | @ToolCollection.tool_call 363 | def create_object_collection(self, entity_token_list: list = []) -> str: 364 | """ 365 | { 366 | "name": "create_object_collection", 367 | "description": "Creates an adsk.core.ObjectCollection from the given list of entity tokens. Each token should reference a valid Fusion 360 object. Returns a JSON object containing the final collection token and any error messages for invalid tokens.", 368 | "parameters": { 369 | "type": "object", 370 | "properties": { 371 | "entity_token_list": { 372 | "type": "array", 373 | "description": "A list of entity tokens representing Fusion 360 objects to be collected in an ObjectCollection.", 374 | "items": { 375 | "type": "string" 376 | } 377 | } 378 | }, 379 | "required": ["entity_token_list"], 380 | "returns": { 381 | "type": "string", 382 | "description": "A JSON object containing a 'collectionToken' for the new ObjectCollection, plus success or error messages per token." 383 | } 384 | } 385 | } 386 | """ 387 | 388 | try: 389 | if not entity_token_list or not isinstance(entity_token_list, list): 390 | return "Error: entity_token_list must be a non-empty list of strings." 391 | 392 | # Create an empty ObjectCollection 393 | obj_collection = adsk.core.ObjectCollection.create() 394 | 395 | # We'll store the final JSON results in this dictionary 396 | items = {} 397 | # Process each token 398 | for token in entity_token_list: 399 | # Attempt to get the associated object from your internal hash / store 400 | obj = self.get_hash_obj(token) 401 | if not obj: 402 | items[token] = f"Error: No object found for token '{token}'." 403 | continue 404 | 405 | # Add to the collection 406 | try: 407 | obj_collection.add(obj) 408 | object_type = obj.__class__.__name__ 409 | items[token] = f"Success: '{object_type}' '{token}' object added to collection." 410 | except Exception as e: 411 | items[token] = f"Error adding object token={token} to collection: {str(e)}" 412 | 413 | # Now we create a reference token for the entire ObjectCollection 414 | # Provide a unique name or ID as you prefer 415 | collection_name = f"ObjectCollection_{obj.__class__.__name__}_{len(entity_token_list)}_items_{random.random()}" 416 | 417 | collection_token = self.set_obj_hash(obj_collection, collection_name) 418 | 419 | results = { 420 | collection_token: f"Success: ObjectCollection created with entityToken {collection_token}", 421 | "items": items 422 | } 423 | 424 | return json.dumps(results) 425 | 426 | except: 427 | return "Error: An unexpected exception occurred:\n" + traceback.format_exc() 428 | 429 | -------------------------------------------------------------------------------- /Fusion-GPT-Addin/f_interface/modules/shared.py: -------------------------------------------------------------------------------- 1 | 2 | import adsk.core 3 | import adsk.fusion 4 | #import adsk.cam 5 | import traceback 6 | import sys 7 | import math 8 | import os 9 | import json 10 | import inspect 11 | import re 12 | #from multiprocessing.connection import Client 13 | from array import array 14 | import time 15 | import hashlib 16 | import random 17 | import base64 18 | import functools 19 | 20 | #from ... import config 21 | from ...lib import fusion360utils as futil 22 | 23 | 24 | def print(string): 25 | """redefine print for fusion env""" 26 | futil.log(str(string)) 27 | 28 | print(f"RELOADED: {__name__.split("%2F")[-1]}") 29 | 30 | 31 | class ToolCollection: 32 | """ 33 | methods colletion 34 | """ 35 | 36 | # store references to fusion object based on id 37 | log_results = True 38 | log_errors = True 39 | 40 | def tool_call(func): 41 | """ 42 | Wraps fusion interface calls 43 | """ 44 | # TODO probably a better way to select functions wrapped in this 45 | func.__wrapper__ = "tool_call" 46 | 47 | # for retrieving wrapped function kwarg names 48 | @functools.wraps(func) 49 | def wrapper(self, *args, **kwds): 50 | self.app = adsk.core.Application.get() 51 | 52 | print(func.__name__) 53 | 54 | results = func(self, *args, **kwds) 55 | 56 | if isinstance(results, str): 57 | try: 58 | json.loads(results) 59 | except Exception as e: 60 | results = json.dumps({"results": results}) 61 | 62 | #if isinstance(results, dict): 63 | # results = json.dumps(results) 64 | 65 | if getattr(ToolCollection, "log_results") == True: 66 | self.print_results(results) 67 | 68 | return results 69 | 70 | return wrapper 71 | 72 | 73 | def __init__(self, ent_dict): 74 | self.methods = self._get_methods() 75 | self.ent_dict = ent_dict 76 | 77 | def log_print(self, output): 78 | print(output) 79 | 80 | @classmethod 81 | def set_class_attr(cls, settings_dict): 82 | 83 | setting_name = settings_dict.get("setting_name") 84 | setting_val = settings_dict.get("setting_val") 85 | 86 | current_val = getattr(cls, setting_name, None) 87 | setattr(ToolCollection, setting_name, setting_val) 88 | print(f"fusion: {setting_name}: {current_val} => {setting_val}") 89 | 90 | def print_results(self, results): 91 | 92 | if isinstance(results, str): 93 | try: 94 | formatted_results = json.dumps(json.loads(results), indent=4) 95 | except: 96 | formatted_results = results 97 | else: 98 | formatted_results = results 99 | 100 | print(formatted_results) 101 | 102 | 103 | def _get_methods(self): 104 | """ 105 | creates list fusion interface functions 106 | """ 107 | methods = {} 108 | 109 | for attr_name in dir(self): 110 | 111 | # ignore any method with leading underscore "_", e.g __init__, 112 | # ignore functions that don't directly interface with fusion workspace 113 | if attr_name[0] == "_": 114 | continue 115 | 116 | attr = getattr(self, attr_name) 117 | 118 | if callable(attr) == False: 119 | continue 120 | 121 | if str(attr.__class__) == "": 122 | methods[attr_name] = attr 123 | 124 | return methods 125 | 126 | def hash_string_to_fixed_length(self, input_string: str, length: int = 10) -> str: 127 | """ 128 | Returns a stable, unique, alphanumeric hash string of the specified length 129 | for the given input_string. Uses SHA-256, then Base64, removing non-alphanumeric 130 | characters and truncating/padding as needed. 131 | 132 | :param input_string: The input string to hash. 133 | :param length: The desired length of the resulting hash (default=10). 134 | :return: A hash string of the given length (alphanumeric only). 135 | """ 136 | # 1) Compute SHA-256 hash 137 | input_string = str(input_string) 138 | sha_hash = hashlib.sha256(str(input_string).encode('utf-8')).digest() 139 | 140 | # 2) Encode as Base64 (returns a bytes object) 141 | b64_encoded = base64.b64encode(sha_hash) # e.g. b'abcd1234==' 142 | 143 | # Convert to ASCII string 144 | hash_str = b64_encoded.decode('ascii') # e.g. "abcd1234==" 145 | 146 | # 3) Remove non-alphanumeric characters (like '=', '+', '/') 147 | hash_str = re.sub(r'[^A-Za-z0-9]', '', hash_str) 148 | 149 | # 4) Truncate or pad to desired length 150 | # If it's shorter than 'length' after removing symbols (rare), we can pad with '0'. 151 | if len(hash_str) < length: 152 | hash_str += '0' * (length - len(hash_str)) 153 | else: 154 | hash_str = hash_str[:length] 155 | 156 | return hash_str 157 | 158 | def describe_object(self, obj) -> str: 159 | """ 160 | Accepts any Fusion 360 (or Python) object and returns a JSON-like string 161 | describing its non-private attributes (properties) and methods. This 162 | includes both Python-level and C++-backed method descriptors if found. 163 | 164 | :param obj: The object to introspect. 165 | :return: A JSON string with two arrays: 'properties' and 'methods'. 166 | """ 167 | try: 168 | # A list or set of known internal or private names to ignore 169 | exclude_list = {"cast", "classType", "__init__", "__del__", "this", "thisown", "attributes", "createForAssemblyContext", "convert", "objectType", "isTemporary", "revisionId", "baseFeature", "meshManager", "nativeObject"} 170 | 171 | # We'll gather results in a dictionary 172 | result_data = { 173 | # "objectType": str(obj.__class__.__name__), 174 | "attributes": [], 175 | "methods": [] 176 | } 177 | 178 | # Use inspect.getmembers(...) to get all members 179 | all_members = inspect.getmembers(obj.__class__) 180 | 181 | # For each (name, value) pair, decide if it's a property or method 182 | for name, value in all_members: 183 | # Skip private/dunder and anything in exclude_list 184 | if name.startswith("_") or name in exclude_list: 185 | continue 186 | 187 | if callable(value): 188 | # It's a method (function or method descriptor) 189 | result_data["methods"].append(name) 190 | else: 191 | # It's a property/attribute 192 | result_data["attributes"].append(name) 193 | 194 | # Convert to JSON 195 | return result_data 196 | 197 | except Exception as e: 198 | return json.dumps({"error": str(e)}) 199 | 200 | def set_sub_attr(self, entity: object, attr_path: str, new_val) -> tuple: 201 | """ 202 | accepts an entity and attribute path, returns the bottom level method 203 | """ 204 | 205 | # seperater between attribute levels 206 | delimeter = "." 207 | # if single attribute name passed 208 | if delimeter not in attr_path: 209 | attr_parts = [attr_path] 210 | else: 211 | attr_parts = attr_path.split(delimeter) 212 | # updateed each iteration 213 | target_entity = entity 214 | # return vals 215 | errors = None 216 | 217 | processed_path = f"{target_entity.__class__.__name__}" 218 | 219 | # work down through attrs 220 | n_attrs = len(attr_parts) 221 | for index, attr_str in enumerate(attr_parts): 222 | 223 | # alwas check has attr in cas attr exists but is None 224 | attr_exists = hasattr(target_entity, attr_str) 225 | 226 | if attr_exists == False: 227 | # successfully accessed attributes 228 | #processed_path += f".{attr_str}" 229 | errors = "" 230 | error_msg = f"Object '{processed_path}' of class '{target_entity.__class__.__name__}' has no attribute/method '{attr_str}'" 231 | avail_attrs = f"'{target_entity.__class__.__name__}' has the following attributes/methods: {self.describe_object(target_entity)}" 232 | 233 | entity_info = f"Object information: {target_entity.__doc__}" 234 | errors += f"Error: {error_msg}. {avail_attrs} {entity_info}".strip() 235 | attr = None 236 | break 237 | 238 | attr = getattr(target_entity, attr_str) 239 | if attr is None: 240 | break 241 | 242 | # successfully accessed attributes 243 | processed_path += f".{attr_str}" 244 | # set the target entity to the attr, assumes the attr is an object 245 | if index < n_attrs-1: 246 | # when not last iteration 247 | target_entity = attr 248 | 249 | attr_set = setattr(target_entity, attr_str, new_val) 250 | 251 | return attr_set, errors 252 | 253 | 254 | def get_sub_attr(self, entity: object, attr_path: str) -> tuple: 255 | """ 256 | accepts an entity and attribute path, returns the bottom level method 257 | """ 258 | 259 | # seperater between attribute levels 260 | delimeter = "." 261 | 262 | # if single attribute name passed 263 | if delimeter not in attr_path: 264 | attr_parts = [attr_path] 265 | else: 266 | attr_parts = attr_path.split(delimeter) 267 | 268 | # updated each iteration 269 | target_entity = entity 270 | 271 | # return vals 272 | errors = None 273 | 274 | processed_path = f"{target_entity.__class__.__name__}" 275 | #print(entity.name) 276 | 277 | 278 | # work down through attrs 279 | n_attrs = len(attr_parts) 280 | for index, attr_str in enumerate(attr_parts): 281 | #print(f"{index}, {target_entity} {target_entity.objectType} {attr_str}") 282 | 283 | try: 284 | # alwas check has attr in cas attr exists but is None 285 | attr_exists = hasattr(target_entity, attr_str) 286 | except Exception as e: 287 | print(entity.name) 288 | errors = f"Error: get_sub_attr: {e}" 289 | attr_exists = False 290 | attr = None 291 | break 292 | 293 | if attr_exists == False: 294 | # successfully accessed attributes 295 | #processed_path += f".{attr_str}" 296 | errors = "" 297 | error_msg = f"Object '{processed_path}' of class '{target_entity.__class__.__name__}' has no attribute/method '{attr_str}'" 298 | avail_attrs = f"'{target_entity.__class__.__name__}' has the following attributes/methods: {self.describe_object(target_entity)}" 299 | 300 | entity_info = f"Object information: {target_entity.__doc__}" 301 | errors += f"Error: {error_msg}. {avail_attrs} {entity_info}".strip() 302 | attr = None 303 | break 304 | 305 | attr = getattr(target_entity, attr_str) 306 | #if attr is None: 307 | # break 308 | 309 | # successfully accessed attributes 310 | processed_path += f".{attr_str}" 311 | # set the target entity to the attr, assumes the attr is an object 312 | if index < n_attrs-1: 313 | # when not last iteration 314 | target_entity = attr 315 | 316 | 317 | # if object is entity token, make sure we store object reference 318 | if attr_str == "entityToken" or attr_str == "id": 319 | attr = self.set_obj_hash(target_entity) 320 | 321 | 322 | return attr, errors 323 | 324 | 325 | # TODO handle all operation responses 326 | def object_creation_response(self, response_obj) -> str: 327 | """ 328 | converts fusion object to json 329 | """ 330 | 331 | attr_list = [ 332 | #"entityToken", 333 | "name", 334 | "area", 335 | "length", 336 | "xConstructionAxis", 337 | "yConstructionAxis", 338 | "zConstructionAxis", 339 | "xYConstructionPlane", 340 | "xZConstructionPlane", 341 | "yZConstructionPlane", 342 | #"timelineObject", 343 | 344 | ] 345 | 346 | sub_attr_list = [ 347 | "name", 348 | ] 349 | 350 | # some responses will be iterable 351 | response_object_list = [] 352 | 353 | if isinstance(response_obj, adsk.fusion.ExtrudeFeature): 354 | for ent in response_obj.bodies: 355 | response_object_list.append(ent) 356 | 357 | # object arrays 358 | elif hasattr(response_obj, "item") == True: 359 | #print(f"response_obj: {response_obj}") 360 | for ent in response_obj: 361 | # print(f" ent: {ent}") 362 | 363 | response_object_list.append(ent) 364 | else: 365 | response_object_list.append(response_obj) 366 | 367 | 368 | results = [] 369 | for obj in response_object_list: 370 | ent_dict = {} 371 | entity_token = self.set_obj_hash(obj) 372 | ent_dict["entityToken"] = entity_token 373 | ent_dict["objectType"] = obj.__class__.__name__ 374 | 375 | for attr in attr_list: 376 | val, errors = self.get_sub_attr(obj, attr) 377 | if val == None: 378 | continue 379 | 380 | # TODO find better way to check if fusion object 381 | elif any([ isinstance(val, attrType) for attrType in [str, int, float, bool]] ) == True: 382 | ent_dict[attr] = val 383 | 384 | else: 385 | 386 | val_dict = {} 387 | val_dict["entityToken"] = self.set_obj_hash(val) 388 | val_dict["objectType"] = obj.__class__.__name__ 389 | 390 | for sub_attr in sub_attr_list: 391 | sub_val, sub_errors = self.get_sub_attr(val, sub_attr) 392 | if sub_val: 393 | val_dict[sub_attr] = sub_val 394 | 395 | val = val_dict 396 | 397 | if val: 398 | ent_dict[attr] = val 399 | 400 | 401 | results.append(ent_dict) 402 | 403 | 404 | 405 | return results 406 | 407 | 408 | 409 | def get_comp_str(self, entity): 410 | # unique id str, not unique when copied 411 | id_str = getattr(entity, "id", None) 412 | ent_tok_str = getattr(entity, "entityToken", None) 413 | # parent cod/element name 414 | parent_name = entity.parentDesign.parentDocument.name 415 | token_str = f"{id_str}_{ent_tok_str}_{parent_name}" 416 | 417 | return token_str 418 | 419 | 420 | 421 | def set_obj_hash(self, entity: object, ref_occ: str= None, length=5): 422 | """ 423 | adds a Fusion 360 to the hash:object dict 424 | """ 425 | if isinstance(entity, str): 426 | raise Exception 427 | 428 | entity_attrs = dir(entity) 429 | 430 | token_str = None 431 | entity_type = entity.__class__.__name__ 432 | 433 | if isinstance(entity, adsk.fusion.Component): 434 | token_str = self.get_comp_str(entity) 435 | 436 | elif isinstance(entity, adsk.fusion.Occurrence): 437 | 438 | token_str = getattr(entity, "entityToken", None) 439 | 440 | elif isinstance(entity, adsk.fusion.BRepBodies): 441 | if ref_occ != None: 442 | comp_token_str = self.get_comp_str(ref_occ.component) 443 | token_str = f"{entity_type}_{comp_token_str}_{ref_occ.name}" 444 | 445 | elif entity.count != 0: 446 | body_0_parent_comp = entity.item(0).parentComponent 447 | parent_comp_token_str = self.get_comp_str(body_0_parent_comp) 448 | token_str = f"{entity_type}_{parent_comp_token_str}" 449 | else: 450 | token_str = f"{entity_type}_{id(entity)}" 451 | 452 | elif hasattr(entity, "entityToken") == True: 453 | token_str = getattr(entity, "entityToken", None) 454 | 455 | elif hasattr(entity, "id") == True: 456 | token_str = getattr(entity, "id", None) 457 | 458 | elif hasattr(entity, "name") == True: 459 | token_str = getattr(entity, "name", None) 460 | 461 | elif isinstance(entity, adsk.core.Point3D): 462 | token_str = f"{entity.objectType}_{entity.x}_{entity.y}_{entity.z}" 463 | 464 | else: 465 | token_str = f"{entity_type}_{id(entity)}" 466 | 467 | 468 | 469 | hash_val = self.hash_string_to_fixed_length(str(token_str), length) 470 | hash_val = f"{hash_val}" 471 | 472 | # check token exists 473 | existing_entity = self.ent_dict.get(hash_val) 474 | if existing_entity: 475 | 476 | # if token refers to different entities 477 | if existing_entity != entity: 478 | print("---") 479 | print_string = f"Token exists: {hash_val}, token_str: {token_str}" 480 | spacing = " " * 10 481 | 482 | e0_id = id(existing_entity) 483 | e1_id = id(entity) 484 | 485 | for n, v in {"prev": existing_entity, "new ": entity }.items(): 486 | 487 | class_name = v.__class__.__name__ 488 | 489 | ent_name = getattr(v, "name", None) 490 | 491 | print_string += f"\n {n}: {class_name}, {ent_name}" 492 | 493 | print(print_string) 494 | print(f" e0: {e0_id}, {existing_entity}") 495 | print(f" e1: {e1_id}, {entity}") 496 | 497 | 498 | 499 | self.ent_dict[hash_val] = entity 500 | 501 | return hash_val 502 | 503 | 504 | def get_hash_obj(self, hash_val): 505 | """ 506 | adds a fusion360 to the hash:object dict 507 | """ 508 | 509 | return self.ent_dict.get(hash_val) 510 | 511 | 512 | 513 | 514 | -------------------------------------------------------------------------------- /Fusion-GPT-Addin/f_interface/gpt_client.py: -------------------------------------------------------------------------------- 1 | 2 | import adsk.core 3 | import adsk.fusion 4 | import adsk.cam 5 | import traceback 6 | import sys 7 | import math 8 | import os 9 | import json 10 | import inspect 11 | import importlib 12 | from multiprocessing.connection import Client 13 | from array import array 14 | import time 15 | import functools 16 | 17 | from .. import config 18 | from ..lib import fusion360utils as futil 19 | 20 | from . import fusion_interface 21 | 22 | import time 23 | #import asyncio 24 | 25 | def print(string): 26 | """redefine print for fusion env""" 27 | futil.log(str(string)) 28 | 29 | print(f"RELOADED: {__name__.split("%2F")[-1]}") 30 | 31 | 32 | class MockServer: 33 | """ 34 | test Fusion side code without OpenAI API call 35 | 36 | """ 37 | def __init__(self): 38 | 39 | self.call_history = [] 40 | self.msg_index = 0 41 | 42 | def set_index(self, index): 43 | self.msg_index = index 44 | 45 | def send(self, message): 46 | time.sleep(.0001) 47 | return True; 48 | 49 | def recv(self): 50 | time.sleep(.0001) 51 | msg = self.call_history[self.msg_index] 52 | self.msg_index += 1 53 | return msg 54 | 55 | def add_call(self, call): 56 | self.call_history.append(call) 57 | 58 | 59 | def download_call_hostory(self): 60 | pass 61 | 62 | 63 | 64 | class GptClient: 65 | """ 66 | Instantiated in entry.py 67 | connects to command server running on separate process 68 | """ 69 | def __init__(self): 70 | """ 71 | fusion_interface : class inst whose methods call Fusion API 72 | """ 73 | 74 | # send info to html palette 75 | self.PALETTE_ID = config.palette_id 76 | self.app = adsk.core.Application.get() 77 | 78 | self.ui = self.app.userInterface 79 | self.palette = self.ui.palettes.itemById(self.PALETTE_ID) 80 | 81 | print(f"palette: {self.palette}:") 82 | #print(f"PALETTE_ID: {self.PALETTE_ID}:") 83 | 84 | # Fusion360 interface, methods available to OpenAI Assistant 85 | self.fusion_itf = fusion_interface.FusionInterface(self.app, self.ui) 86 | 87 | # must be defined here, 88 | self.app = adsk.core.Application.get() 89 | 90 | # current connection status 91 | self.connected = False 92 | 93 | self.has_initial_settings = False 94 | 95 | # store call history for mock playback 96 | self.use_mock_server = False 97 | self.record_calls = True 98 | 99 | self.mock_server = MockServer() 100 | 101 | # tool call history 102 | self.user_messages = [] 103 | 104 | 105 | 106 | # TODO sort setting type better 107 | def update_settings(self, settings_list: list): 108 | 109 | """ 110 | update state settings from js/html interface 111 | """ 112 | for settings_dict in settings_list: 113 | 114 | input_type = settings_dict["input_type"] 115 | setting_name = settings_dict["setting_name"] 116 | setting_val = settings_dict["setting_val"] 117 | setting_class = settings_dict["setting_class"].split(" ") 118 | 119 | # must match with html classes 120 | if "server-setting" in setting_class: 121 | current_val = getattr(self, setting_name, None) 122 | setattr(self, setting_name, setting_val) 123 | print(f"client: {setting_name}: {current_val} => {setting_val}") 124 | 125 | elif "fusion-setting" in setting_class: 126 | self.fusion_itf.set_class_attr({"setting_name": setting_name, "setting_val": setting_val}) 127 | 128 | elif "client-setting" in setting_class: 129 | current_val = getattr(self, setting_name, None) 130 | setattr(self, setting_name, setting_val) 131 | print(f"client: {setting_name}: {current_val} => {setting_val}") 132 | 133 | 134 | else: 135 | print(f"Error: Unlcassified setting : {settings_dict}") 136 | 137 | 138 | 139 | 140 | 141 | def get_tools(self): 142 | """ 143 | return tool to js from fusion interface 144 | """ 145 | return self.fusion_itf.get_tools() 146 | 147 | def print_response_messages(self): 148 | calls = self.mock_server.call_history = [] 149 | for call in call: 150 | print(call) 151 | 152 | def reload_modules(self): 153 | importlib.reload(fusion_interface) 154 | self.fusion_itf._reload_modules() 155 | self.fusion_itf = fusion_interface.FusionInterface(self.app, self.ui) 156 | # Get settings from js 157 | self.get_initial_settings() 158 | print("Modules Reloaded") 159 | 160 | def reload_object_dict(self): 161 | """reload fusion document objects""" 162 | return self.fusion_itf.reload_object_dict() 163 | 164 | def reload_fusion_intf(self): 165 | importlib.reload(fusion_interface) 166 | self.fusion_itf = fusion_interface.FusionInterface(self.app, self.ui) 167 | print("Fusion Interface Reloded") 168 | 169 | def reload_interface(self): 170 | self.connected = False 171 | self.palette = self.ui.palettes.itemById(self.PALETTE_ID) 172 | importlib.reload(fusion_interface) 173 | self.fusion_itf = fusion_interface.FusionInterface(self.app, self.ui) 174 | # Get settings from js 175 | self.get_initial_settings() 176 | 177 | print("fusion_interface reloded") 178 | 179 | # TODO 180 | def get_initial_settings(self): 181 | data = {"get_initial": "get_initial"} 182 | self.palette.sendInfoToHTML("get_initial", json.dumps(data)) 183 | 184 | def sendToBrowser(self, function_name, data): 185 | """send event data to js""" 186 | json_data = json.dumps(data) 187 | # create run output section in html 188 | self.palette.sendInfoToHTML(function_name, json_data) 189 | 190 | # TODO add 191 | def playback(self): 192 | """run recorded calls""" 193 | print(f"start playback") 194 | 195 | self.use_mock_server = True 196 | self.record_calls = False 197 | self.conn = self.mock_server 198 | self.mock_server.set_index(0) 199 | self.connected = True; 200 | 201 | for message in self.user_messages: 202 | self.send_message(message) 203 | self.use_mock_server = False 204 | self.record_calls = True 205 | 206 | 207 | # TODO 208 | def resize_palette(self): 209 | self.palette.setSize(900, 900) 210 | 211 | ### ====== server calls ====== ### 212 | 213 | def connect(self): 214 | """ 215 | connect to assistant manager class on seperate process 216 | """ 217 | 218 | if self.use_mock_server == True: 219 | self.conn = self.mock_server 220 | self.connected = True; 221 | return 222 | else: 223 | try: 224 | address = ('localhost', 6000) 225 | self.conn = Client(address, authkey=b'fusion260') 226 | except Exception as e: 227 | message = {"error": "connection_error"} 228 | self.palette.sendInfoToHTML("connection_error", json.dumps(message)) 229 | return None 230 | 231 | self.connected = True; 232 | print(f"RECONNECTED") 233 | return True 234 | 235 | 236 | def send_msg(self, message): 237 | 238 | if self.connected == False: 239 | self.connect() 240 | 241 | try: 242 | self.conn.send(message) 243 | except Exception as e: 244 | self.connect() 245 | self.conn.send(message) 246 | 247 | 248 | return True 249 | 250 | 251 | # TODO use regulare message format 252 | def start_record(self): 253 | message = { 254 | "message_type": "start_record", 255 | "content": None 256 | } 257 | message = json.dumps(message) 258 | 259 | message_confirmation = self.send_msg(message) 260 | print(f"START RECORD: message sent, waiting for result...") 261 | start_confirm = self.conn.recv() 262 | print(f"{start_confirm}") 263 | 264 | def stop_record(self): 265 | message = { 266 | "message_type": "stop_record", 267 | "content": None 268 | } 269 | 270 | message = json.dumps(message) 271 | 272 | # start message 273 | self.send_msg(message) 274 | print(f"END RECORD: waiting for result...") 275 | 276 | # audio transcription 277 | audio_text = self.conn.recv() 278 | audio_text = json.loads(audio_text) 279 | audio_text = {"audio_text": audio_text["content"]} 280 | 281 | return audio_text 282 | 283 | def upload_model_settings(self): 284 | """ 285 | upload tools to assistant 286 | """ 287 | model_name = self.model_name 288 | reasoning_effort = self.reasoning_effort 289 | tools = self.fusion_itf.get_docstr() 290 | instructions_path = os.path.join("system_instructions",self.instructions_name) 291 | 292 | model_settings = { 293 | "model_name": model_name, 294 | "tools": tools, 295 | "instructions_path": instructions_path, 296 | "reasoning_effort": reasoning_effort 297 | } 298 | 299 | message = { 300 | "message_type": "function_call", 301 | "function_name": "update_settings", 302 | "function_args": {"model_settings": model_settings} 303 | } 304 | 305 | message = json.dumps(message) 306 | #if self.connected == False: 307 | 308 | message_confirmation = self.send_msg(message) 309 | print(f"SETTINGS SENT, waiting for result...") 310 | 311 | settings_response = self.conn.recv() 312 | settings_response = json.loads(settings_response) 313 | print(settings_response) 314 | return settings_response 315 | 316 | 317 | def get_system_instructions(self): 318 | """ 319 | get available system_instructions 320 | """ 321 | 322 | message = { 323 | "message_type": "function_call", 324 | "function_name": "get_available_system_instructions" 325 | } 326 | message = json.dumps(message) 327 | message_confirmation = self.send_msg(message) 328 | 329 | print(f"REQUEST SENT, waiting for result...") 330 | instructions = self.conn.recv() 331 | instructions = json.loads(instructions) 332 | return instructions 333 | 334 | def get_models(self): 335 | """ 336 | get available models 337 | """ 338 | message = { 339 | "message_type": "function_call", 340 | "function_name": "get_available_models", 341 | } 342 | message = json.dumps(message) 343 | 344 | message_confirmation = self.send_msg(message) 345 | 346 | print(f"REQUEST SENT, waiting for result...") 347 | models = self.conn.recv() 348 | models = json.loads(models) 349 | 350 | filtered_models = [] 351 | exclude = [ 352 | "tts", 353 | "text-embedding", 354 | "babbage", 355 | "davinci", 356 | "dall-e", "audio", 357 | "omni-moderation", 358 | "whisper" 359 | ] 360 | 361 | for m in models: 362 | 363 | if any([t in m for t in exclude]): 364 | continue 365 | 366 | filtered_models.append(m) 367 | 368 | models = sorted(filtered_models) 369 | 370 | return models 371 | 372 | def send_message(self, message): 373 | """send message to process server""" 374 | 375 | if message == "": 376 | return 377 | 378 | if self.record_calls == True: 379 | self.user_messages.append(message) 380 | 381 | message = {"message_type": "thread_update", "content": message} 382 | message = json.dumps(message) 383 | 384 | message_confirmation = self.send_msg(message) 385 | print(f"MESSAGE SENT, waiting for result...") 386 | 387 | # continue to run as long thread is open 388 | run_complete = False 389 | while run_complete == False: 390 | 391 | # result from server 392 | api_result = self.conn.recv() 393 | 394 | if self.record_calls == True: 395 | self.mock_server.add_call(api_result) 396 | 397 | api_result = json.loads(api_result) 398 | 399 | response_type = api_result.get("response_type") 400 | event_type = api_result.get("event") 401 | run_status = api_result.get("run_status") 402 | 403 | content = api_result.get("content") 404 | 405 | # streaming call outputs 406 | if event_type == "thread.run.created": 407 | self.sendToBrowser("runCreated", content) 408 | 409 | # streaming call outputs 410 | elif event_type == "thread.run.step.created": 411 | self.sendToBrowser("stepCreated", content) 412 | 413 | # streaming call outputs 414 | elif event_type == "thread.message.created": 415 | self.sendToBrowser("messageCreated", content) 416 | 417 | # streaming call outputs 418 | elif event_type == "thread.message.delta": 419 | self.sendToBrowser("messageDelta", content) 420 | 421 | elif event_type in ["thread.run.step.delta"]: 422 | self.sendToBrowser("stepDelta", content) 423 | 424 | # TODO, use event type not response type 425 | elif response_type == "tool_call": 426 | 427 | tool_call_id = api_result["tool_call_id"] 428 | function_name = api_result["function_name"] 429 | function_args = api_result["function_args"] 430 | 431 | function_result = self.call_function(function_name, function_args, tool_call_id) 432 | adsk.doEvents() 433 | 434 | message = {"message_type": "thread_update", "content": function_result} 435 | message = json.dumps(message) 436 | 437 | self.send_msg(function_result) 438 | 439 | # thread complete break loop 440 | if run_status == "thread.run.completed": 441 | run_complete = True 442 | 443 | adsk.doEvents() 444 | 445 | return api_result 446 | 447 | 448 | def call_function(self, function_name: str, function_args: str, tool_call_id=None): 449 | """ 450 | called from Assistants API 451 | calls function passed from Assistants API 452 | """ 453 | 454 | if function_args != None: 455 | function_args = json.loads(validate_and_repair_json(function_args)) 456 | 457 | print(f"CALL FUNCTION: {function_name}, {function_args}, {tool_call_id}") 458 | 459 | # check of FusionInterface inst has requested method 460 | function = getattr(self.fusion_itf, function_name, None) 461 | 462 | if callable(function): 463 | if function_args == None: 464 | result = function() 465 | else: 466 | result = function(**function_args) 467 | else: 468 | result = json.dumps({"error": f"Function '{function_name}' not callable"}) 469 | 470 | # send function response to js/html 471 | if tool_call_id != None: 472 | 473 | message_data = { 474 | "tool_call_id": tool_call_id, 475 | "function_result": result 476 | } 477 | 478 | self.sendToBrowser("toolCallResponse", message_data) 479 | 480 | 481 | 482 | # return function result to Assistant API 483 | return result 484 | 485 | 486 | 487 | 488 | # TODO put json validation code somewhere else 489 | def validate_and_repair_json(json_str: str) -> str: 490 | """ 491 | Tries to load the given json_str as JSON. If it fails due to structural errors 492 | like extra/missing brackets/braces, applies simple heuristics to fix them. 493 | Returns a *string* containing valid JSON or raises ValueError if it can't fix. 494 | 495 | NOTE: This function is minimal and won't fix all possible JSON issues, but 496 | it demonstrates how to handle common bracket or brace mismatches. 497 | More advanced or specialized logic may be needed for complicated errors. 498 | """ 499 | 500 | # 1) First, try a direct json.loads 501 | try: 502 | parsed = json.loads(json_str) 503 | # If no exception, it was valid 504 | return json.dumps(parsed, indent=2) 505 | except json.JSONDecodeError: 506 | pass # We'll attempt repairs below 507 | 508 | # We'll store the original for fallback 509 | original_str = json_str 510 | 511 | # Heuristic approach: 512 | # a) Balanced brackets/braces: We'll try to count braces/brackets. 513 | # b) Fix trailing commas, if present. 514 | # c) Fix unquoted keys, if any. (This is optional or advanced.) 515 | 516 | # a) Attempt bracket/brace balancing 517 | repaired = basic_bracket_repair(json_str) 518 | 519 | # b) Attempt removing trailing commas 520 | repaired = remove_trailing_commas(repaired) 521 | 522 | # c) Possibly try other heuristics (like ensuring top-level braces if it looks like an object) 523 | repaired = ensure_top_level_braces_if_needed(repaired) 524 | 525 | # 2) Try again 526 | try: 527 | parsed = json.loads(repaired) 528 | return json.dumps(parsed, indent=2) 529 | except json.JSONDecodeError as e: 530 | # If we still fail, we can either raise or fallback 531 | raise ValueError(f"Could not repair JSON. Original error: {str(e)}\n" 532 | f"Original:\n{original_str}\n\nAttempted Repair:\n{repaired}") 533 | 534 | 535 | def basic_bracket_repair(s: str) -> str: 536 | """ 537 | Attempt to fix counts of square brackets [] or curly braces {} if they differ by 1. 538 | We'll do minimal attempts like: 539 | - If we have one more '{' than '}', we append '}' at the end. 540 | - If we have one more '}' than '{', we remove the last '}' or the first if found extra. 541 | Similarly for brackets. 542 | """ 543 | opens_curly = s.count('{') 544 | closes_curly = s.count('}') 545 | if opens_curly == closes_curly + 1: 546 | # We have one extra '{' => add a '}' at the end 547 | s += '}' 548 | elif closes_curly == opens_curly + 1: 549 | # We have one extra '}' => remove the last one 550 | # (this might cause issues if the extra is in the middle, but it's a guess) 551 | idx = s.rfind('}') 552 | if idx != -1: 553 | s = s[:idx] + s[idx+1:] 554 | 555 | opens_square = s.count('[') 556 | closes_square = s.count(']') 557 | if opens_square == closes_square + 1: 558 | s += ']' 559 | elif closes_square == opens_square + 1: 560 | idx = s.rfind(']') 561 | if idx != -1: 562 | s = s[:idx] + s[idx+1:] 563 | 564 | return s 565 | 566 | 567 | def remove_trailing_commas(s: str) -> str: 568 | """ 569 | Removes commas that appear right before a closing bracket or brace or end of string. 570 | E.g. "...,}" => "...}" or "...,]" => "...]". This helps fix some JSON errors. 571 | """ 572 | # Regex to find a comma followed by optional whitespace + a closing brace/bracket 573 | # or end of string 574 | pattern = re.compile(r",\s*(?=[}\]])") 575 | s = pattern.sub("", s) 576 | return s 577 | 578 | 579 | def ensure_top_level_braces_if_needed(s: str) -> str: 580 | """ 581 | If the string doesn't parse as JSON but looks like it's missing top-level 582 | braces for an object, we might wrap it. This is guesswork and optional. 583 | For example, if we see it starts with some key but no braces, we do { ... }. 584 | We'll do a naive check: if it doesn't start with [ or {, let's try wrapping with braces. 585 | """ 586 | st = s.strip() 587 | if not st.startswith("{") and not st.startswith("["): 588 | # Maybe we wrap it in braces 589 | # e.g. "key: val, ..." => we do "{ key: val, ... }" 590 | # But we'd need quotes for "key", so this is advanced. We'll keep it minimal. 591 | s = "{" + s + "}" 592 | return s 593 | 594 | 595 | def example_usage(): 596 | bad_json = """ 597 | { 598 | "name": "example", 599 | "values": [ 600 | 1, 601 | 2 602 | "flag": true, 603 | } 604 | """ # missing comma, bracket mismatch 605 | 606 | try: 607 | fixed = validate_and_repair_json(bad_json) 608 | print("Fixed JSON:") 609 | print(fixed) 610 | except ValueError as e: 611 | print("Could not fix JSON:", str(e)) 612 | 613 | -------------------------------------------------------------------------------- /oai_container/system_instructions/system_instructions.txt: -------------------------------------------------------------------------------- 1 | You are an expert 3D designer and engineer with a thorough understanding of the Autodesk Fusion 360 CAD program. 2 | 3 | You are responsible for creating, modifying, and organizing highly detailed components and designs. All future messages will relate to the Autodesk Fusion 360 Python API. The user will ask you to generate and modify objects and document settings, which you will accomplish by calling functions that are run in the user's local Fusion 360 environment. Some functions retrieve information about the state of the active Fusion 360 document, while other functions create and modify the objects in the document. 4 | 5 | OBJECT METADATA: 6 | You have two primary functions for retrieving Fusion 360 Class metadata: 'get_available_classes' and 'get_fusion_classes_detail'. 7 | 'get_available_classes' provides a high level overview of all available class names, with their attributes and methods. It includes the datatype and name for each attribute, and the datatype and name for each method argument. The data retuned by this function provides context for for calls to the functions 'run_sql_query' and 'call_entity_methods', so it is a good idea to call it before calling those methods. 8 | 'get_fusion_classes_detail' provides a highly detailed description of the Fusion 360 class name passed to it. This description includes all attributes, methods, method arguments, and docstring for the class. You should call this function if you are unsure about Class details, or you get a recurrent error when call related functions. 9 | DOCUMENT STRUCTURE: 10 | If you need to understand the hierarchy and structure of objects in the document use the function "list_document_structure" it provides a high level-overview of the document "shape", including entity tokens for most relevant objects. You may need to call this if you need to understand complex component nesting. This should not be called often since it is data intensive. It output only changes when objects are created or destroyed. 11 | 12 | ENTITY TOKENS: 13 | All relevant Fusion 360 objects have an associated 'entityToken' attribute, which acts as a unique identifier for the object. You will use an object's 'entityToken' reference during function calls. You can get an objects 'entityToken' by including the entityToken field in a call to 'run_sql_query', additionally enityTokens will be retuned when an object is created or modified. You can always reference the current Fusion 360 design object with the 'entityToken' "design", and the current 'RootComponent' object with the 'entityToken' "root". All other entity tokens are random strings. The 'Appearance' and 'Material' objects have the attribute 'id' instead of 'entityToken', for these object 'id' should be used as a proxy for 'entityToken' 14 | 15 | 16 | SQL QUERY INTERFACE: 17 | The function "run_sql_query" is the most important and primary tool to get and set data for objects in the Fusion 360 document. This function provides an SQL interface to the Fusion 360 document. It supports the following SQL clauses: [SELECT, UPDATE, SET, FROM, WHERE, LIKE, IN, AND, OR, ORDER BY, ASC, DESC, LIMIT, OFFSET]. 18 | You can SELECT FROM and UPDATE the following Fusion 360 Objects: [Occurrence, Component, BRepBody, Sketch, Parameter, Joint, JointOrigin, SketchCurve, Profile, Parameter, Appearance, Material, RigidGroup]. You can think of these like tables in traditional SQL. Some Fusion 360 Objects have attributes whose value is another Fusion 360 object, you set these by referencing the target objects 'entityToken' or 'id'. 19 | This SQL schema supports dot notation for all fields when accessing sub attributes eg: "SELECT component.name FROM component". 20 | The use of dot notation is important because it allows you to access detailed information about an object that may only be available by referencing an attributes attribute. For example, many objects include a 'boundingBox' attribute which provides data bout the object spacial location. If you wanted to get the minimum point of an object, your query expression may look like this: "SELECT boundingBox.minPoint.x, boundingBox.minPoint.y, boundingBox.minPoint.z FROM WHERE " 21 | 22 | Your query statements should follow standard SQL format, for example: 23 | "SELECT parentComponent.name,parentComponent.entityToken FROM BRepBody WHERE appearance.name LIKE 'Steel' LIMIT 5 OFFSET 10" Returns: the parent Component name and entity token for the first 5 BRepBody objects (starting at 10) whose appearance contains the string steel. 24 | 25 | APPEARANCE 26 | You should always include the 'name' attribute when querying 'Appearance' and 'Material', the name provides details about the appearance/material type. 27 | 28 | JOINT TYPE 29 | The user may refer to a joint by its motion type, you can access this through a Joints jointMotion.jointType attribute, wich return an integer representing: 6:Ball, 3:Cylindrical, 4:PinSlot, 5:Planar, 1:Revolute, 2:Slider. You may also want to call jointMotion.objectType,which will return the full joint type string name. 30 | 31 | COMPARISON OPERATORS: 32 | You have access to the following operators: [=, <, >, <=, >=, LIKE]. When referring to null values in SQL expressions, use 'None' or NOT = 'None' 33 | 34 | This function returns a JSON like array containing the selected object information. 35 | 36 | Here are more query expression examples: 37 | 38 | This sets the visibility to True for the first 40 BRepBody Objects (starting at 5) whose parent component name includes the strings 'hardware', 'screw' or 'bolt'. 39 | 40 | To list the name, and id (entityToken proxy) of all available Appearance objects sorted by name: 41 | 60 | If you receive an error during an operation, querying timeline data may provide insights into the cause of the error. 61 | 62 | PHYSICAL PROPERTIES: 63 | Sometime you will need physical property data (area, volumne, mass, centerOfMass) about an Component, Occurrence or BRepBody, you can use the 'physicalProperties' attribute, here is a sample query Expression: 64 | 47 | 48 | After querying the list of 'Appearance' objects, you can set the appearance attribute on a body with the following query: 49 | Where 'wWmLj' represents the id (entityToken) of an Appearance object. 50 | 51 | The following expression does several thing, it selects all BRepBody objects whose Appearance or it's parent component's Appearance includes the string 'steel', then sets the body, parent component and parent component folder to visible: 52 | 53 | 54 | The following expression update the value to 5 on all parameters whose name includes the string length. 55 | 56 | 57 | 58 | RESPONSE FORMAT 59 | Please only respond with one query per message, end of line semi-colon is not yet supported in our SQL parser. When you respond with a function call, make sure its valid JSON. 60 | 61 | 62 | TIMELINE: 63 | You can query the 'timelineObjects' with the following Query expression 64 | 70 | 71 | PROFILES 72 | When performing operations that involve sketch profiles, you should include the parameter 'face.area'. 73 | You can access the area of a Profile object through its 'face' attribute. 74 | 75 | 76 | Only use the LIMIT clause if you think it's necessary, or the user tells you to. 77 | 78 | The word "object" and "entity" refer to Fusion 360 Python API objects. 79 | 80 | METHODS: 81 | While we can use 'run_sql_query' to retrive data and set attributes, we use a different function to call methods. To call a method on an entity, use the function "call_entity_methods". This function accepts an array of dictionary like objects, each containing the entityToken, method_path, and an array of optional arguments. The items in the arguments array can be primitive data type such as (bool, string, float, integer) or an entityToken representing another object such as (Point3D, ConstructionPlane, construction_plane). For these arguments you may need to create the entity before passing it into the function. The method_path can either be full path from and entity: 'sketchCurves.sketchCircles.addByCenterRadius', or just the name of the method of the methods parent is passed in as the target entity: 'addByCenterRadius' if the entitytoken references a 'sketchCircles' object. 82 | You should try to use the lowest level object to the method if it's available. 83 | 84 | When you call a method that creates an object, a reference to that object will be returned. This is useful if a method requires Fusion 360 Object as arguments for example: 85 | You create a "ConstructionPlaneInput" object by calling the "constructionPlanes.createInput" method on a component object, which accepts an optional reference to an occurrence object: 86 | 87 | You create a "ConstructionAxisInput" object by calling the "constructionAxes.createInput" method on a component object, which accepts an optional reference to an occurrence object. 88 | 89 | You create a "JointOriginInput" object by calling the "jointOrigins.createInput" method on a component object, which accepts a reference to a "JointGeometry" object. 90 | 91 | When calling a method whose argument is another Fusion 360 object, you should used the entityToken for this object, this may be a component, occurrence, sketch, body, constructionPlane, constructionAxis, etc.. 92 | 93 | If you haven't called "call_entity_methods" for a specific objectType, you should first call "describe_fusion_classes", which provides detailed information about a specific object class's methods, and arguments for each method. 94 | Some methods such as 'deleteMe' are included in most objects, and should be used when the user requests to delete an object. 95 | 96 | TRANSIENT OBJECTS: 97 | You have functions available to create the following transient objects: "Point3D", "Point2D", "Vector3D", "Matrix3D", "Matrix2D", "create_object_collection". 98 | If the method argument requires an transient object, you should call that object specific function which will return the "entityToken" for the transient object. For example, many methods relating to sketch lines and profiles require Point3D object. You have access to a function called "create_point3d_list" which creates the Point3D objects and returns their entityTokens. You can then used the Point3D entityTokens as arguments when calling other methods. 99 | 100 | 101 | COMPONENT CREATION: 102 | You can create a new component by calling the "addNewComponent" method on an "Occurrences" object. The this method accepts 1 argument, which is an entityToken referencing a "Matrix3D" object. The "Occurrences" object can be accessed from a "Component" object including the root component, and sets the new components parent component. If you want to create a copy of an occurrence you can call the "addExistingComponent" method on an "Occurrences" object, which takes 2 arguments, the component to copy, and a "Matrix3D". If you want to create an entirely new component copy you can use the "addNewComponentCopy" method which is like "paste new". 103 | Remember, you cannot set the 'name' attribute of an occurrence directly, you must change the associated component 'name' instead. So if the user asks you name a component during creation, you must first create the component/occurrence, then set the name on the associated component. 104 | 105 | OCCURRENCE GROUNDING: 106 | Occurrence object have two types types grounding attributes: "isGroundToParent" and "isGrounded", If you experience an issue while moving or reorienting a occurrence, you may want to set both of thees values to False. 107 | 108 | OCCURRENCE POSITION: 109 | The user may ask you to move or reorient an occurrence. You should use the function "move_occurrence" to change its position and use the function "reorient_occurrence" to change its orientation. The user may use words like "flip" or "turn" you should to you best to interpret these into a translation, rotation, or combination movement. The user may sometime refer to an occurrence as a component, when there is only one occurrence, just assume the user mean occurrence. 110 | If you encounter an error while re-orienting a component, try expanding all timeline groups before reorienting. 111 | 112 | CAD OPERATIONS: 113 | When creating parts in the Fusion 360 design environment, you will often use the following workflow: 114 | 1. Create a new component or components relating to the current design, if they do not already exist. 115 | 2. Create one or more sketches inside a component, these should be given a logical name. 116 | 3. Draw profile geometry inside the sketches, this includes any combination of circles, squares, lines, polygons, etc.. 117 | 4. Generate solid bodies from the sketch profiles using tools such as extrude, revolve, mirror, sweep, loft, etc... 118 | 4a. When creating solids you will usually use the NewBodyFeatureOperation or JoinFeatureOperation. 119 | 5. Perform cut operations on the solid bodies using subtractive tools, such as extrude, revolve, mirror, etc... 120 | 5a. When performing cuts you will usually use the CutFeatureOperation 121 | 6. Steps 3, 4 and 5 may be repeated multiple times. The user may ask to create the solid body from a sketch profile, then ask to create another sketch which will be used to further modify the body or bodies. 122 | 123 | COMPONENT COPY: 124 | To create an entirely new component copy you should use the function "copy_component_as_new". If you need to copy an occurrence the still references the original component, use the function "copy_occurrence". 125 | 126 | PARAMETERS: 127 | Whenever you complete an operation that involves dimension, such as an extrusion, cut, sketch profile, etc.. Fusion 360 generates model parameters. In general if the user is asking you to modify an existing object, you should try to accomplish this be changing parameter values first. Parameters are also important if the user asks you to create a modified version of an existing component; you can create a new copy of the relevant component, the modify the new component's parameters. 128 | 129 | 130 | Design Creation Best Practices: 131 | 132 | SKETCHES: 133 | As a general rule, it is better to create a separate sketch for each logical operation, meaning there should be relatively few profiles in each sketch. This approach makes it easier to select the correct profile for a given operation. There will be exceptions to this rule, especially if you will be applying the exact same operation to all profiles, then it makes sense to put them in the same sketch. 134 | 135 | You should always try to use the most appropriate tool when creating sketch geometry. There are specific Fusion 360 Methods for creating circles, polygons, splines, curves, rectangles, and many more. For example if the user asks for a polygon you may want to use 'addScribedPolygon' sketchCurve method instead of drawing each edge with a line. 136 | 137 | 138 | When creating a new sketch you call the "add" method on a component's "sketches" object, the "add" method accepts an "entityToken" representing a planarEntity, usually its parent component's origin construction plane. 139 | 140 | 141 | When creating a "sketchPoint" you will call the "add" method on a "sketch" objects "sketchPoints" object. 142 | IMPORTANT: Even though a sketch has to be associated with a 2D plane, sketch geometry such as points and lines, require 3d input points. This mean you can create 3d design inside a sketch. 143 | When creating sketch geometry you will often use the following methods: 144 | 145 | { 146 | "sketchCurves": { 147 | "sketchArcs": ["addByCenterStartSweep", "addByThreePoints", "addFillet", ], 148 | "sketchCircles": [ 149 | "addByCenterRadius", "addByThreePoints", "addByThreeTangents", 150 | "addByTwoPoints", "addByTwoTangents" 151 | ], 152 | "sketchConicCurves": ["add"], 153 | "sketchControlPointSplines": ["add"], 154 | "sketchEllipses": ["add"], 155 | "sketchEllipticalArcs": ["addByAngle", "addByEndPoints"], 156 | "sketchFittedSplines": ["add"], 157 | "sketchFixedSplines": ["addByNurbsCurve"], 158 | "sketchLines": [ 159 | "addAngleChamfer", "addByTwoPoints", "addCenterPointRectangle", 160 | "addDistanceChamfer", "addEdgePolygon", "addScribedPolygon", "addThreePointRectangle", "addTwoPointRectangle", 161 | ] 162 | 163 | } 164 | 165 | 166 | When adding dimensions to sketch geometry you will use the following methods: 167 | 168 | { "sketchDimensions": [ 169 | "addAngularDimension", 170 | "addConcentricCircleDimension", 171 | "addDiameterDimension", 172 | "addDistanceBetweenLineAndPlanarSurfaceDimension", 173 | "addDistanceBetweenPointAndSurfaceDimension", 174 | "addDistanceDimension", 175 | "addEllipseMajorRadiusDimension", 176 | "addEllipseMinorRadiusDimension", 177 | "addLinearDiameterDimension", 178 | "addOffsetDimension", 179 | "addRadialDimension", 180 | "addTangentDistanceDimension" 181 | ] 182 | } 183 | 184 | You can move a "SketchCircle" object by calling its "centerSketchPoint.move" method, which accepts a vector as its only argument. 185 | 186 | BODY CREATION 187 | As a general rule, you should first perform all the body creation/join operations before performing cut operations. This is important because if you perform a body create operation after a cut operation, you may add volume to an area that was just created. It is important to remember you can extrude profile in both the positive and negative direction, keep this in mind when performing multiple extrusions on a body. 188 | 189 | You should use the "start_offset" option when extruding features that don't lay directly on the profile plane. 190 | You have two sketch specific data functions you should call during sketch operation: 191 | 192 | You should first query the relevant sketch lines before calling "thin_extrude_lines" and "create_pipe_from_lines" function. You should first query the relevant sketch Profile objects before calling the "extrude_profiles" or "revolve_profiles" functions. 193 | 194 | 195 | IMPORTING: 196 | The user may ask you to import components into the current design. Before attempting to import a component, you will call the "list_step_files_in_directory" function, this provides you with the name and file path of all components available for import. You should find the STEP file component that most closely matches the users request, and import it using the import_step_file_to_component function. After importing a component, you should expand all timeline groups. 197 | 198 | GENERAL WORKFLOW: 199 | The user will not always ask for a function call, sometimes the user will ask you for information, in this case you should respond with text. Fusion 360 uses centimeters as the default unit of length for all functions, however the user will often give dimensions in millimeters, in this case you just need to divide by 10. 200 | When creating multiple components, you should hide or move other components in the design prior to creating a new one. This is important because operations meant for one component may affect the bodies in a different component. The user may instead ask you to move a component out of the way after creation, this way only the component currently being modified is at the design origin point. 201 | 202 | Appearance: 203 | The user may ask you to set the Appearance on one or more components. The user will not always give you the exact name of a Appearance, you should do your best to select the closest Appearance to the user's description. If you are unsure of the exact Appearance name, you can query 'Appearance' objects with an optional filter on the name attribute. 204 | 205 | 206 | Joints: 207 | The user may ask you to add joints and joint origins to a component. You should first query the "list_joint_origin_references" function which will provide you with geometry information for bodies in that component. This information will include the reference id to attach the joint origin to, and location information in the form of a x,y,z point. Here is an example user joint origin request: "Add a joint origin to the top face of component1." In this case you would attempt to add a joint origin to the top center face of the first body in component1". 208 | 209 | Standard components: 210 | The user will often want to create components that have standard dimensions. These include fastening hardware (bolts, screws, nuts, washers, etc..), extruded aluminum profiles (20mm and 40mm T slot, etc..), electric motors (NEMA 17, NEMA 34, etc.). Try to consider standards organizations like ISO, which provide standard dimensions for many components, for example pneumatic cylinders, linear guide rails, etc.. 211 | 212 | CONCLUSION: 213 | If there is an additional function that would help you accomplish a specific task, PLEASE TELL THE USER!!! The user will be more than happy to generate the function for you. 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | -------------------------------------------------------------------------------- /oai_container/connection.py: -------------------------------------------------------------------------------- 1 | 2 | import configparser 3 | import os 4 | from multiprocessing.connection import Listener 5 | from array import array 6 | import traceback 7 | import math 8 | import os 9 | import json 10 | import subprocess 11 | import sys 12 | import time 13 | from openai import OpenAI 14 | import adsk 15 | 16 | 17 | import whisper 18 | import pyaudio 19 | import wave 20 | 21 | 22 | user_config = configparser.ConfigParser() 23 | # path to config file containing open ai API keys, Python env path 24 | parent_dir = os.path.dirname(os.getcwd()) 25 | config_path = os.path.join(parent_dir,"config.env") 26 | user_config.read(config_path) 27 | 28 | default_config = user_config["DEFAULT"] 29 | OPENAI_API_KEY = default_config["OPEN_AI_API_KEY"] 30 | os.environ['OPENAI_API_KEY'] = OPENAI_API_KEY 31 | 32 | #client = OpenAI(api_key=OPENAI_API_KEY) 33 | ASSISTANT_ID = default_config["ASSISTANT_ID"] 34 | 35 | client = OpenAI() 36 | 37 | print(f"RELOADED: {__name__.split('%2F')[-1]}") 38 | 39 | 40 | class Assistant: 41 | """ 42 | get assistant and create new thread 43 | base assistant class 44 | """ 45 | 46 | def __init__(self, assistant_id=None, initial_message=None): 47 | """get assistant and create new thread""" 48 | 49 | self.client = OpenAI() 50 | 51 | # assistant_id is defined in the OpenAI Assistant API website 52 | self.assistant_id = assistant_id 53 | print(f'assistant_id: {assistant_id}') 54 | 55 | #self.audio_interface = AudioInterface() 56 | 57 | # TODO eventualy, user should be able to restart thred from Fusion 58 | # start assistant thread (conversation) 59 | #self.start_thread() 60 | 61 | self.thread_started = False; 62 | 63 | # run local process server, how Fusion connects 64 | #self.start_server() 65 | #self.system_instructions_path = "system_instructions/system_instructions.txt" 66 | #self.selected_model = "gpt-4o" 67 | 68 | # store incomplete tool call ids, during an Exception in 69 | # the Fusion program, we can still respond to theese tool calls 70 | # and continue the thread 71 | self.pending_tool_calls = {} 72 | 73 | # whisper model size 74 | model_size = "base" 75 | #self.model = whisper.load_model(model_size) # Load the selected model 76 | 77 | def start_record(self, conn): 78 | """ 79 | Records audio from the default input device and saves it as a WAV file. 80 | :param filename: The name of the output WAV file. 81 | :param duration: Duration of the recording in seconds. 82 | :param sample_rate: Sample rate in Hz. 83 | :param chunk_size: Number of frames per buffer. 84 | :param channels: Number of audio channels (1 for mono, 2 for stereo). 85 | :param format: pyaudio format (default: pyaudio.paInt16). 86 | """ 87 | 88 | filename="output.wav" 89 | sample_rate=44100 90 | chunk_size=1024 91 | channels=1 92 | audio_format=pyaudio.paInt32 #pyaudio.paInt32 93 | audio = pyaudio.PyAudio() 94 | 95 | # Open stream 96 | stream = audio.open(format=audio_format, 97 | channels=channels, 98 | rate=sample_rate, 99 | input=True, 100 | frames_per_buffer=chunk_size) 101 | 102 | fusion_call = { 103 | "content": "recording_started" 104 | } 105 | conn.send(json.dumps(fusion_call)) 106 | 107 | frames = [] 108 | 109 | #self.record = True 110 | while True: 111 | data = stream.read(chunk_size) 112 | frames.append(data) 113 | 114 | if conn.poll(): 115 | # wait for message from user 116 | message_raw = conn.recv() 117 | message = json.loads(message_raw) 118 | print(f"conn.poll: {message}") 119 | 120 | break 121 | 122 | 123 | print("Recording finished.") 124 | 125 | # Stop and close the stream 126 | stream.stop_stream() 127 | stream.close() 128 | audio.terminate() 129 | 130 | # Save the recorded data as a WAV file 131 | with wave.open(filename, 'wb') as wf: 132 | wf.setnchannels(channels) 133 | wf.setsampwidth(audio.get_sample_size(audio_format)) 134 | wf.setframerate(sample_rate) 135 | wf.writeframes(b''.join(frames)) 136 | 137 | print(f"Audio recorded and saved to {filename}") 138 | return self.transcribe_audio() 139 | 140 | 141 | def transcribe_audio(self, filename="output.wav"): 142 | """ 143 | Transcribes an audio file using OpenAI's Whisper model. 144 | :param filename: The path to the audio file to transcribe. 145 | :param model_size: The size of the Whisper model to use (tiny, base, small, medium, large). 146 | :return: The transcribed text. 147 | """ 148 | print(f"Transcribing {filename}...") 149 | result = self.model.transcribe(filename, language='en', fp16=False) 150 | 151 | text = result["text"] 152 | print("Transcription completed:") 153 | print(text) 154 | return text 155 | 156 | 157 | def format_str(self, string, n_char): 158 | """uniform print spacing """ 159 | string = str(string) 160 | spacer_len = max(n_char - len(string), 0) 161 | spacer = " " *spacer_len 162 | return f"{string}{spacer}" 163 | 164 | def get_available_system_instructions(self): 165 | """ 166 | List available system instructions in the system 167 | """ 168 | instructions = os.listdir("./system_instructions") 169 | return instructions 170 | 171 | def get_available_models(self): 172 | """ 173 | List available Assistant models, 174 | This partially depends on on user payment tier 175 | """ 176 | models_resp = self.client.models.list() 177 | models = models_resp.data 178 | model_ids = [m.id for m in models] 179 | return model_ids 180 | 181 | 182 | def update_settings(self, model_settings): 183 | """ 184 | update assistant tools, and initial prompt instructions 185 | """ 186 | 187 | #print(f"model_settings: {model_settings}") 188 | #model_settings = json.loads(model_settings) 189 | 190 | model_name = model_settings["model_name"] 191 | instructions_path = model_settings["instructions_path"] 192 | tools = model_settings["tools"] 193 | reasoning_effort = model_settings["reasoning_effort"] 194 | 195 | # base assistant prompt 196 | with open(instructions_path) as f: 197 | instructions = f.read() 198 | instructions = instructions.strip() 199 | 200 | # functions 201 | tools = json.loads(tools) 202 | updated_tools = [] 203 | for index, tool in enumerate(tools): 204 | updated_tools.append({"type": "function", "function": tool}) 205 | print(f"{index}: {tool['name']}") 206 | try: 207 | updated_assistant = client.beta.assistants.update( 208 | self.assistant_id, 209 | model=model_name, 210 | instructions=instructions, 211 | tools=updated_tools, 212 | reasoning_effort=reasoning_effort, 213 | response_format="auto", 214 | ) 215 | 216 | return { 217 | "id": updated_assistant.id, 218 | "name": updated_assistant.name, 219 | "model": updated_assistant.model, 220 | "created_at": updated_assistant.created_at, 221 | } 222 | 223 | except Exception as e: 224 | for index, tool in enumerate(tools): 225 | print(f"{index}: {tool['name']}") 226 | #print(f"ERROR: {e}") 227 | return f"Error: {e}" 228 | 229 | def start_thread(self): 230 | """ 231 | start thread (conversation) with Assistant API 232 | """ 233 | # create new thread 234 | self.thread = self.client.beta.threads.create() 235 | 236 | self.thread_id = self.thread.id 237 | 238 | # last run step 239 | self.run_steps = None 240 | self.thread_started = True 241 | print(f"Thread created: {self.thread.id}") 242 | 243 | 244 | def run(self, conn): 245 | """ 246 | main server loop, called from "start server" accepts a multiprocess connection object 247 | """ 248 | 249 | user_message_index = 0 250 | while True: 251 | print(f"\n{user_message_index}: WAITING FOR USER COMMAND...") 252 | user_message_index +=1 253 | 254 | # wait for message from user 255 | message_raw = conn.recv() 256 | message = json.loads(message_raw) 257 | 258 | message_type = message["message_type"] 259 | 260 | print(f" MESSAGE RECEIVED:\n {message_raw}") 261 | 262 | # handle system update calls, Assistant meta data 263 | # check if method exists on our Assistant class 264 | if message_type == "function_call": 265 | 266 | function_name = message.get("function_name") 267 | function_args = message.get("function_args", {}) 268 | print(f"args: {function_args}") 269 | 270 | if not function_name: 271 | results = f"Error: function_name is '{function_name}'" 272 | print(results) 273 | elif not hasattr(self, function_name): 274 | results = f"Error: {self} has no function '{function_name}'" 275 | print(results) 276 | else: 277 | function = getattr(self, function_name) 278 | if not callable(function): 279 | results = f"Error: '{function_name}' is not callable" 280 | print(results) 281 | else: 282 | # call function 283 | results = function(**function_args) 284 | 285 | conn.send(json.dumps(results)) 286 | continue 287 | 288 | 289 | if message_type == "thread_update": 290 | message_text = message["content"] 291 | 292 | # start audio recording 293 | elif message_type == "start_record": 294 | # stop_record handled in self.start_record poll loop 295 | audio_text = self.start_record(conn) 296 | fusion_call = { "content": audio_text } 297 | conn.send(json.dumps(fusion_call)) 298 | continue 299 | 300 | # start assistant thread 301 | if self.thread_started == False: 302 | self.start_thread() 303 | 304 | # add message to thread 305 | self.add_message(message_text) 306 | 307 | # once message(s) are added, run 308 | self.stream = self.create_run() 309 | 310 | event_type = "" 311 | message_text = "" 312 | delta_count = 0 313 | thread_start = 0 314 | 315 | # TODO condense much of this 316 | while event_type != "thread.run.completed": 317 | print(f"THREAD START") 318 | thread_start +=1 319 | 320 | if thread_start > 20: 321 | return 322 | 323 | for event in self.stream: 324 | event_type = event.event 325 | print(event_type) 326 | thread_start = 0 327 | data = event.data 328 | 329 | fusion_call = None 330 | if event_type == "thread.run.created": 331 | # set run id for tool call result calls 332 | self.run = event.data 333 | self.run_id = event.data.id 334 | 335 | content = { 336 | "run_id": self.run_id, 337 | }; 338 | 339 | fusion_call = { 340 | "run_status": "in_progress", 341 | "event": event_type, 342 | "content": content 343 | } 344 | #conn.send(json.dumps(fusion_call)) 345 | 346 | elif event_type == "thread.message.created": 347 | content = { 348 | "message_id": data.id, 349 | "run_id": data.run_id, 350 | "event": event_type, 351 | }; 352 | 353 | fusion_call = { 354 | "run_status": "in_progress", 355 | "event": event_type, 356 | "content": content 357 | } 358 | 359 | #conn.send(json.dumps(fusion_call)) 360 | 361 | elif event_type == "thread.run.step.created": 362 | step_type = data.type 363 | 364 | step_details = data.step_details 365 | 366 | content = { 367 | "step_id": data.id, 368 | "run_id": data.run_id, 369 | "status": data.status, 370 | "step_type": step_type, 371 | "event": event_type, 372 | }; 373 | fusion_call = { 374 | "run_status": "in_progress", 375 | "event": event_type, 376 | "content": content 377 | } 378 | #conn.send(json.dumps(fusion_call)) 379 | 380 | 381 | elif event_type == "thread.run.step.in_progress": 382 | pass 383 | 384 | 385 | elif event_type == "thread.message.delta": 386 | delta_text = event.data.delta.content[0].text.value 387 | message_id = event.data.id 388 | 389 | content = { 390 | "message_id": message_id, 391 | "message": delta_text, 392 | "event": event_type, 393 | } 394 | 395 | fusion_call = { 396 | "run_status": "in_progress", 397 | "event": event_type, 398 | "content": content 399 | } 400 | #conn.send(json.dumps(fusion_call)) 401 | 402 | 403 | elif event_type == "thread.run.step.delta": 404 | 405 | try: 406 | function = event.data.delta.step_details.tool_calls[0].function 407 | 408 | # tool call is not None on first delta 409 | tool_call_id = event.data.delta.step_details.tool_calls[0].id 410 | 411 | tool_call_len = len(event.data.delta.step_details.tool_calls) 412 | 413 | # TODO 414 | if tool_call_len != 1: 415 | print( event.data.delta.step_details.tool_calls) 416 | print("CHECK TOOL CALL LEN\n\n\n\n\n") 417 | return 418 | 419 | except Exception as e: 420 | print(e) 421 | continue 422 | 423 | step_id = event.data.id 424 | 425 | content = { 426 | "step_id": step_id, 427 | "tool_call_id": tool_call_id, 428 | "function_name": function.name, 429 | "function_args": function.arguments, 430 | "function_output": function.output, 431 | "event": event_type, 432 | } 433 | 434 | fusion_call = { 435 | "run_status": "in_progress", 436 | "event": event_type, 437 | "content": content 438 | } 439 | 440 | delta_count +=1 441 | 442 | elif event_type == "thread.message.completed": 443 | content = event.data.content 444 | delta_count = 0 445 | 446 | elif event_type == "thread.run.requires_action": 447 | 448 | tool_calls = event.data.required_action.submit_tool_outputs.tool_calls 449 | 450 | # return data for all tool calls in a step 451 | tool_call_results = [] 452 | 453 | for tool_call in tool_calls: 454 | 455 | tool_call_id = tool_call.id 456 | function_name = tool_call.function.name 457 | function_args = tool_call.function.arguments 458 | 459 | if function_name == None: 460 | continue 461 | print(f" CALL TOOL: {function_name}, {function_args}") 462 | 463 | fusion_call = { 464 | "run_status": self.run.status, 465 | "response_type": "tool_call", 466 | "event": event_type, 467 | "tool_call_id": tool_call_id, 468 | "function_name": function_name, 469 | "function_args": function_args, 470 | } 471 | 472 | # set tool call status in case of Exception during tool call 473 | self.pending_tool_calls[tool_call_id] = "in_progress" 474 | 475 | conn.send(json.dumps(fusion_call)) 476 | # Fusion360 function results 477 | function_result = conn.recv() 478 | 479 | tool_call_results.append({ 480 | "tool_call_id" : tool_call.id, 481 | "output": function_result 482 | }) 483 | 484 | # remove tool_cal_id after successful completion 485 | self.pending_tool_calls.pop(tool_call_id) 486 | 487 | 488 | print(f" FUNC RESULTS: {function_result}") 489 | 490 | ## submit results for all tool calls in step 491 | self.stream = self.submit_tool_call(tool_call_results) 492 | print("TOOL CALL RESUTS FINISHED") 493 | continue 494 | 495 | elif event_type == "thread.run.step.completed": 496 | delta_count = 0 497 | 498 | step_details = event.data.step_details 499 | step_type = step_details.type 500 | 501 | # skip response for mesage completion 502 | if step_type == "message_creation": 503 | continue 504 | 505 | try: 506 | function = step_details.tool_calls[0].function 507 | except Exception as e: 508 | print(f"Error: thread.run.step.completed: {e}") 509 | continue 510 | step_id = event.data.id 511 | content = { 512 | "step_id": step_id, 513 | "function_name": function.name, 514 | "function_args": function.arguments, 515 | "function_output": function.output, 516 | "event": event_type, 517 | } 518 | 519 | fusion_call = { 520 | "run_status": "in_progress", 521 | "event": event_type, 522 | "content": content 523 | } 524 | 525 | elif event_type == "thread.run.completed": 526 | print("THREAD.RUN.COMPLETED") 527 | #print(event.data) 528 | 529 | fusion_call = { 530 | "run_status": "thread.run.completed", 531 | "response_type": "message", 532 | "event": event_type, 533 | "text": message_text 534 | } 535 | 536 | 537 | if fusion_call != None: 538 | conn.send(json.dumps(fusion_call)) 539 | 540 | 541 | def start_server(self): 542 | # start run on local host, Fusion client must connect to this address 543 | address = ('localhost', 6000) # family is deduced to be 'AF_INET' 544 | 545 | while True: 546 | try: 547 | # Multiprocess server 548 | with Listener(address, authkey=b'fusion260') as listener: 549 | print(f"WAITING FOR FUSION 360 TO CONNECT...") 550 | # Fusion 360 Add-In connect here 551 | with listener.accept() as conn: 552 | print("CONNECTION ACCEPTED FROM", listener.last_accepted) 553 | self.run(conn) 554 | 555 | except Exception as e: 556 | print(f"ERROR: {e} {traceback.format_exc()}") 557 | print(f"{traceback.format_exc()}") 558 | 559 | print(f"\nPENDING TOOL CALLS: {self.pending_tool_calls}") 560 | print(f"RETRYING CONNECTION...") 561 | time.sleep(1) 562 | 563 | 564 | def add_message(self, message_text: str): 565 | """ 566 | create new message and add it to thread 567 | """ 568 | message = self.client.beta.threads.messages.create( 569 | thread_id=self.thread_id, 570 | role="user", 571 | content=message_text 572 | ) 573 | 574 | self.message_id = message.id 575 | print(f' MESSAGE ADDED: {message.id}') 576 | 577 | def parse_stream(self, stream): 578 | 579 | for event in stream: 580 | event_type = event.event 581 | 582 | if event_type == "thread.message.completed": 583 | print("THREAD.MESSAGE.COMPLETED") 584 | print(event.data.content.text.value) 585 | 586 | elif event_type == "thread.run.requires_action": 587 | print("THREAD.RUN.REQUIRES_ACTION") 588 | #print(event.data) 589 | 590 | elif event_type == "thread.run.step.completed": 591 | print("THREAD.RUN.STEP.COMPLETED") 592 | 593 | elif event_type == "thread.run.completed": 594 | print("THREAD.RUN.COMPLETED") 595 | 596 | def create_run(self): 597 | """create initial run""" 598 | 599 | stream = self.client.beta.threads.runs.create( 600 | thread_id=self.thread_id, 601 | assistant_id=self.assistant_id, 602 | stream=True 603 | ) 604 | return stream 605 | 606 | def run_status(self): 607 | """get run status""" 608 | # get run status 609 | run = self.client.beta.threads.runs.retrieve( 610 | thread_id=self.thread_id, 611 | run_id=self.run_id, 612 | ) 613 | 614 | return run 615 | 616 | def submit_tool_call(self, response_list: list): 617 | """ 618 | send tool call responses 619 | response_list : list of dicts, each dict containg tool_call id and output 620 | """ 621 | 622 | # function reply 623 | stream = self.client.beta.threads.runs.submit_tool_outputs( 624 | thread_id=self.thread_id, 625 | run_id=self.run_id, 626 | tool_outputs=response_list, 627 | stream=True, 628 | ) 629 | 630 | return stream 631 | 632 | def send_func_response(self, response_list: list): 633 | """ 634 | send tool call responses 635 | response_list : list of dicts, each dict contains tool_call id and output 636 | """ 637 | 638 | # function reply 639 | run = self.client.beta.threads.runs.submit_tool_outputs( 640 | thread_id=self.thread_id, 641 | run_id=self.run_id, 642 | tool_outputs=response_list 643 | ) 644 | 645 | print(f"RESP RUN STATUS: run_id: {run.id}, status: {run.status}") 646 | 647 | 648 | def cancel_run(self): 649 | run = self.client.beta.threads.runs.cancel( 650 | thread_id=self.thread_id, 651 | run_id=self.run_id 652 | ) 653 | print("RUN CANCEL") 654 | 655 | 656 | 657 | 658 | if __name__ == "__main__": 659 | 660 | assistant = Assistant(assistant_id =ASSISTANT_ID) 661 | assistant.start_server() 662 | 663 | 664 | 665 | 666 | 667 | 668 | 669 | 670 | 671 | 672 | 673 | 674 | 675 | 676 | 677 | 678 | 679 | 680 | 681 | 682 | 683 | 684 | 685 | --------------------------------------------------------------------------------