1616
1717import argparse
1818import asyncio
19+ from datetime import datetime
20+ import json
1921import sys
2022import threading
23+ from pathlib import Path
24+ from typing import Optional
2125
2226import pyperclip # To paste the clipboard into the terminal
2327from textual import events , work
3337from vulcanai .console .utils import SpinnerHook , StreamToTextual , attach_ros_logger_to_console , common_prefix
3438from vulcanai .console .widget_custom_log_text_area import CustomLogTextArea
3539from vulcanai .console .widget_spinner import SpinnerStatus
36-
40+ from vulcanai . core . plan_types import GlobalPlan
3741
3842class TextualLogSink :
3943 """A default console that prints to standard output."""
@@ -169,6 +173,9 @@ def __init__(
169173 # Suggestion index for RadioListModal
170174 self .suggestion_index = - 1
171175 self .suggestion_index_changed = threading .Event ()
176+ # Log creation files
177+ now = datetime .now ()
178+ self .execution_time = now .strftime ('%Y-%m-%d-%H-%M' )
172179
173180 async def on_mouse_down (self , event : MouseEvent ) -> None :
174181 """
@@ -266,6 +273,8 @@ def worker() -> None:
266273 "/plan" : self .cmd_plan ,
267274 "/rerun" : self .cmd_rerun ,
268275 "/bb" : self .cmd_blackboard_state ,
276+ "/save_plans" : self .cmd_save_plans ,
277+ "/load_plan" : self .cmd_load_plan ,
269278 "/clear" : self .cmd_clear ,
270279 "/exit" : self .cmd_quit ,
271280 }
@@ -288,8 +297,8 @@ def worker() -> None:
288297 self .manager .register_tools_from_file (tool_file_path )
289298
290299 # Entry points tools
291- for ep in self .tools_from_entrypoints :
292- self .manager .register_tools_from_entry_points (ep )
300+ if self .tools_from_entrypoints != "" :
301+ self .manager .register_tools_from_entry_points (self . tools_from_entrypoints )
293302
294303 # Add user context
295304 self .manager .add_user_context (self .user_context )
@@ -367,6 +376,154 @@ def worker(user_input: str = "") -> None:
367376
368377 # region Utilities
369378
379+ def save_history_log (self , output_dir : Path | str = None ) -> Optional [Path ]:
380+ """
381+ Save the queries history to a '.log' file.
382+ """
383+ if output_dir is None :
384+ # Default path
385+ output_dir = Path ("./saved_plans/" )
386+ else :
387+ output_dir = Path (output_dir )
388+
389+ # Create the output directory if it doesn't exist
390+ output_dir .mkdir (parents = True , exist_ok = True )
391+
392+ try :
393+ # Create a filename with timestamp
394+ filename = f"{ self .execution_time } _history.log"
395+ filepath = output_dir / filename
396+
397+ # 186║
398+ # 187╗
399+ # 188╝
400+ # 200╚
401+ # 201╔
402+ # 202╩
403+ # 203╦
404+ # 204╠
405+ # 205═
406+
407+ # Log tittle
408+ log_lines = [
409+ "╔" + "═" * 78 + "╗" ,
410+ f"║ VulcanAI Console Session History" .ljust (79 ) + "║" ,
411+ f"║ Execution Time: { self .execution_time } " .ljust (79 ) + "║" ,
412+ "╚" + "═" * 78 + "╝" ,
413+ ""
414+ ]
415+
416+ # Add queries from manager history
417+ if self .manager and hasattr (self .manager , 'history' ) and self .manager .history :
418+ log_lines .append (f"Total Queries: { len (self .manager .history )} " )
419+ log_lines .append ("-" * 48 )
420+ log_lines .append ("" )
421+
422+ for i , (user_text , plan_summary ) in enumerate (self .manager .history , 1 ):
423+ log_lines .append (f"Query #{ i } " )
424+ log_lines .append (f" User Input: { user_text .split (chr (10 ))[- 1 ] if chr (10 ) in user_text else user_text } " )
425+ log_lines .append (f" Plan Summary: { plan_summary } " )
426+ log_lines .append ("" )
427+ else :
428+ log_lines .append ("No queries recorded." )
429+ log_lines .append ("" )
430+
431+ # Save to file
432+ filepath .write_text ("\n " .join (log_lines ), encoding = "utf-8" )
433+
434+ self .logger .log_console (f"History log saved to: { filepath } " )
435+ return filepath
436+
437+ except Exception as e :
438+ self .logger .log_console (f"Error saving history log: { e } " )
439+ return None
440+
441+ def save_plans_to_json (self , output_dir : Path | str = None ) -> list [Path ]:
442+ """
443+ Save all plans from 'self.plans_list' to JSON files.
444+ """
445+ if output_dir is None :
446+ # Default path
447+ output_dir = Path ("./saved_plans/" )
448+ else :
449+ output_dir = Path (output_dir )
450+
451+ # Create the output directory if it doesn't exist
452+ output_dir .mkdir (parents = True , exist_ok = True )
453+
454+ saved_files = []
455+
456+ for idx , plan in enumerate (self .plans_list ):
457+ if plan is None :
458+ self .logger .log_console (f"Skipping plan { idx } : plan is None" )
459+ continue
460+
461+ try :
462+ # Create a filename based on index and timestamp
463+ filename = f"{ self .execution_time } _plan_{ idx } .json"
464+ filepath = output_dir / filename
465+
466+ # Convert plan to JSON dictionary
467+ plan_dict = plan .model_dump ()
468+
469+ # Save to file
470+ filepath .write_text (
471+ json .dumps (plan_dict , indent = 2 ),
472+ encoding = "utf-8"
473+ )
474+
475+ saved_files .append (filepath )
476+ self .logger .log_console (f"Saved plan { idx } to: { filepath } " )
477+
478+ except Exception as e :
479+ self .logger .log_console (f"Error saving plan { idx } : { e } " )
480+
481+ if saved_files :
482+ self .logger .log_console (f"Successfully saved { len (saved_files )} plan(s)" )
483+ else :
484+ self .logger .log_console ("No plans were saved" )
485+
486+ return saved_files
487+
488+ def load_and_execute_plan (self , plan_file : Path | str ) -> Optional [dict ]:
489+ """
490+ Load a plan from a JSON file and execute it.
491+ """
492+ try :
493+ plan_file = Path (plan_file )
494+
495+ if not plan_file .exists ():
496+ self .logger .log_console (f"Error: Plan file not found: { plan_file } " )
497+ return None
498+
499+ # Load the plan from JSON
500+ plan_data = json .loads (plan_file .read_text (encoding = "utf-8" ))
501+
502+ # Convert JSON to GlobalPlan object
503+ plan = GlobalPlan .model_validate (plan_data )
504+
505+ self .logger .log_console (f"Loaded plan from: { plan_file } " )
506+ self .logger .log_console (f"Executing plan..." )
507+
508+ # Execute the plan using the manager's executor and blackboard
509+ result = self .manager .executor .run (plan , self .manager .bb )
510+
511+ # Update the last blackboard state
512+ self .last_bb = result .get ("blackboard" , None )
513+
514+ # Log the result
515+ bb_parsed = str (self .last_bb ).replace ("<" , "'" ).replace (">" , "'" )
516+ self .logger .log_console (f"Plan execution completed. Output: { bb_parsed } " )
517+
518+ return result
519+
520+ except json .JSONDecodeError as e :
521+ self .logger .log_console (f"Error: Invalid JSON in plan file: { e } " )
522+ return None
523+ except Exception as e :
524+ self .logger .log_console (f"Error executing plan: { e } " )
525+ return None
526+
370527 def _apply_history_to_input (self ) -> None :
371528 """
372529 Function used to apply the current history index to the input box.
@@ -482,20 +639,22 @@ def cmd_help(self, _) -> None:
482639 "___________________\n "
483640 "<bold>Available commands:</bold>\n "
484641 "‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾\n "
485- "/<bold>help</bold> - Show this help message\n "
486- "/<bold>tools</bold> - List available tools\n "
487- "/<bold>edit_tools</bold> - Edit the list of available tools\n "
488- "/<bold>change_k 'int'</bold> - Change the 'k' value for the top_k algorithm selection"
642+ "/<bold>help</bold> - Show this help message\n "
643+ "/<bold>tools</bold> - List available tools\n "
644+ "/<bold>edit_tools</bold> - Edit the list of available tools\n "
645+ "/<bold>change_k 'int'</bold> - Change the 'k' value for the top_k algorithm selection"
489646 " or show the current value if no 'int' is provided\n "
490- "/<bold>history 'int'</bold> - Change the history depth or show the current value if no"
647+ "/<bold>history 'int'</bold> - Change the history depth or show the current value if no"
491648 " 'int' is provided\n "
492- "/<bold>show_history</bold> - Show the current history\n "
493- "/<bold>clear_history</bold> - Clear the history\n "
494- "/<bold>plan</bold> - Show the last generated plan\n "
495- "/<bold>rerun</bold> - Rerun the last plan\n "
496- "/<bold>bb</bold> - Show the last blackboard state\n "
497- "/<bold>clear</bold> - Clears the console screen\n "
498- "/<bold>exit</bold> - Exit the console\n "
649+ "/<bold>show_history</bold> - Show the current history\n "
650+ "/<bold>clear_history</bold> - Clear the history\n "
651+ "/<bold>plan</bold> - Show the last generated plan\n "
652+ "/<bold>rerun</bold> - Rerun the last plan\n "
653+ "/<bold>save_plans 'dir'</bold> - Save all generated plans to JSON files (default: ./saved_plans/)\n "
654+ "/<bold>load_plan 'path'</bold> - Load and execute a plan from a JSON file\n "
655+ "/<bold>bb</bold> - Show the last blackboard state\n "
656+ "/<bold>clear</bold> - Clears the console screen\n "
657+ "/<bold>exit</bold> - Exit the console\n "
499658 "<bold>Query any other text</bold> to process it with the LLM and execute the plan generated.\n \n "
500659 "Add --image='path' to include images in the query. It can be used multiple times to add"
501660 " more images.\n "
@@ -664,6 +823,55 @@ def cmd_blackboard_state(self, _) -> None:
664823 else :
665824 self .logger .log_console ("No blackboard available." )
666825
826+ def cmd_save_plans (self , args ) -> None :
827+ """Save all generated plans to JSON files and history log."""
828+ output_dir = "./saved_plans/"
829+ if len (args ) > 0 :
830+ output_dir = args [0 ]
831+
832+ # Save history log first
833+ history_log_path = self .save_history_log (output_dir )
834+
835+ if not self .plans_list :
836+ self .logger .log_console ("No plans to save" )
837+ if history_log_path :
838+ self .logger .log_console (f"History log saved successfully to: { Path (output_dir ).absolute ()} " )
839+ return
840+
841+ saved_files = self .save_plans_to_json (output_dir )
842+ if saved_files :
843+ self .logger .log_console (f"All plans saved to: { Path (output_dir ).absolute ()} " )
844+ else :
845+ self .logger .log_console ("No plans were saved" )
846+
847+ def cmd_load_plan (self , args ) -> None :
848+ self ._load_plan_worker (args ) # start worker (dont await)
849+
850+ @work (thread = True )
851+ async def _load_plan_worker (self , args ) -> None :
852+ """
853+ Worker function used to run the command "rerun".
854+ It has to be a worker(thead=True) because the call 'self.manager.executor.run'
855+ might have a "call_from_thread" in the tool executed,
856+ and it is only valid in non Textual app Threads (separated Thread).
857+
858+ @work runs on the app's event loop (app thread) and is for async, non-blocking code.
859+ @work(thread=True) runs in a separate OS thread and is for blocking.
860+
861+ e.g.:
862+ 'move_turtle' tool contains a 'call_from_thread'
863+ 'ros2_topic' tool does not contains a 'call_from_thread'
864+ """
865+
866+ if len (args ) == 0 :
867+ self .logger .log_console ("Usage: /load_plan 'path/to/plan.json'" )
868+ return
869+
870+ plan_file = args [0 ]
871+ result = self .load_and_execute_plan (plan_file )
872+ if result is None :
873+ self .logger .log_console (f"Failed to load and execute plan from: { plan_file } " )
874+
667875 def cmd_clear (self , _ ) -> None :
668876 self .left_pannel .clear_console ()
669877
0 commit comments