diff --git a/src/vtk_prompt/controllers/conversation.py b/src/vtk_prompt/controllers/conversation.py index 43bc5bc..c16637b 100644 --- a/src/vtk_prompt/controllers/conversation.py +++ b/src/vtk_prompt/controllers/conversation.py @@ -186,11 +186,145 @@ def _process_conversation_pair(app: Any, pair_index: int | None = None) -> None: app.state.query_text = query_text -def _process_loaded_conversation(app: Any) -> None: +def _process_loaded_conversation( + app: Any, conversation_object: dict[str, Any] | None = None +) -> None: """Process loaded conversation file.""" - if not app.state.conversation: + # Use provided object or fall back to state + if conversation_object: + # Set state without triggering watcher infinite loop + app.state.conversation_object = conversation_object + app.state.conversation_file = conversation_object["name"] + elif not app.state.conversation: return # Build navigation pairs and process the latest one build_conversation_navigation(app) _process_conversation_pair(app) + + +def _process_multiple_conversations(app: Any, conversation_files: list[dict[str, Any]]) -> None: + """Process multiple conversation files together to merge them.""" + merged_conversation = [] + valid_files = [] + + for file_obj in conversation_files: + try: + # Validate file before processing + if ( + file_obj.get("type") == "application/json" + and Path(file_obj.get("name", "")).suffix == ".json" + and file_obj.get("content") + ): + loaded_conversation = json.loads(file_obj["content"]) + merged_conversation.extend(loaded_conversation) + valid_files.append(file_obj["name"]) + logger.info(f"Loaded conversation file: {file_obj['name']}") + else: + logger.warning(f"Invalid conversation file: {file_obj.get('name')}") + + except json.JSONDecodeError as e: + logger.error(f"Failed to parse JSON from {file_obj.get('name')}: {e}") + except Exception as e: + logger.error(f"Failed to process conversation file {file_obj.get('name')}: {e}") + + if merged_conversation and valid_files: + # Set the merged conversation + app.state.conversation = merged_conversation + app.state.conversation_file = ", ".join(valid_files) + app.prompt_client.update_conversation(merged_conversation, app.state.conversation_file) + + # Process the merged conversation + _process_loaded_conversation(app) + + logger.info(f"Successfully merged {len(valid_files)} conversation files") + else: + logger.warning("No valid conversation files to process") + + +def process_uploaded_files(app: Any, uploaded_files: list[dict[str, Any]]) -> None: + """Process multiple uploaded files with intelligent routing based on file extensions.""" + if not uploaded_files: + return + + try: + # Separate files by type for batch processing + conversation_files = [] + prompt_files = [] + + for file_obj in uploaded_files: + file_name = file_obj.get("name", "").lower() + + if file_name.endswith(".json"): + conversation_files.append(file_obj) + elif file_name.endswith((".yaml", ".yml")): + prompt_files.append(file_obj) + else: + logger.warning(f"Unsupported file type: {file_obj.get('name')}") + + # Process all conversation files together to merge them + if conversation_files: + _process_multiple_conversations(app, conversation_files) + + # Process prompt files (last one wins for configuration) + for prompt_file in prompt_files: + _process_loaded_prompt(app, prompt_file) + + except Exception as e: + logger.error(f"Failed to process uploaded files: {e}") + + +def _process_loaded_prompt(app: Any, prompt_object: dict[str, Any] | None = None) -> None: + """Process loaded prompt file using existing prompt loader functionality.""" + # Use provided object or fall back to state + prompt_obj = prompt_object or app.state.prompt_object + if not prompt_obj: + return + + try: + # Use the existing prompt loader functionality + import os + import tempfile + + from ..utils import prompt_loader + + # Use the parameter we already assigned above + + # Get content and ensure it's a string + content = prompt_obj["content"] + if isinstance(content, bytes): + content = content.decode("utf-8") + + # Write content to temp file for the loader + with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as tmp: + tmp.write(content) # Now guaranteed to be a string + temp_path = tmp.name + + try: + # Use existing custom prompt file logic + original_prompt_file = app.custom_prompt_file + app.custom_prompt_file = temp_path + app.custom_prompt_data = None # Reset + + # Load using existing prompt loader + prompt_loader.load_custom_prompt_file(app) + + app.state.prompt_file = prompt_obj["name"] + logger.info(f"Loaded custom prompt file: {prompt_obj['name']}") + + # Force UI to recognize state changes by triggering model selection update + # This is safe because we're not in a watcher context here + if hasattr(app.state, "provider"): + # Trigger available models update by re-setting provider + current_provider = getattr(app.state, "provider", None) + if current_provider: + app.state.provider = current_provider + + finally: + # Clean up temp file and restore original + os.unlink(temp_path) + app.custom_prompt_file = original_prompt_file + + except Exception as e: + logger.error(f"Failed to load prompt file: {e}") + app.state.prompt_file = None diff --git a/src/vtk_prompt/rag_chat_wrapper.py b/src/vtk_prompt/rag_chat_wrapper.py index 09804f9..4843864 100644 --- a/src/vtk_prompt/rag_chat_wrapper.py +++ b/src/vtk_prompt/rag_chat_wrapper.py @@ -24,7 +24,6 @@ from typing import Any import click -import query_db from llama_index.core.llms import ChatMessage from llama_index.llms.openai import OpenAI @@ -34,9 +33,6 @@ logger = get_logger(__name__) -# Add rag-components to path -sys.path.append(str(Path(__file__).resolve().parent.parent.parent / "rag-components")) - def check_rag_components_available() -> bool: """Check if RAG components are available and installed.""" @@ -46,9 +42,9 @@ def check_rag_components_available() -> bool: def setup_rag_path() -> str: - """Add rag-components directory to the Python path.""" + """Add rag-components src directory to the Python path.""" repo_root = Path(__file__).resolve().parent.parent.parent - rag_path = str(repo_root / "rag-components") + rag_path = str(repo_root / "rag-components" / "src") if rag_path not in sys.path: sys.path.append(rag_path) return rag_path @@ -63,7 +59,8 @@ def get_rag_snippets( """Get code snippets from the RAG database.""" setup_rag_path() try: - import query_db + # Lazy import after path setup + from rag_components import query_db # type: ignore[import-not-found] client = query_db.initialize_db(database_path) results = query_db.query_db(query, collection_name, top_k, client) @@ -118,6 +115,10 @@ def _init_components(self) -> None: except Exception as e: raise RuntimeError(f"Unsupported Model {self.model}: {e}") + # Lazy import after path setup + setup_rag_path() + from rag_components import query_db # type: ignore[import-not-found] + self.client = query_db.initialize_db(database_path=self.database) os.environ["TOKENIZERS_PARALLELISM"] = "false" @@ -143,6 +144,8 @@ def ask( self.history.append(ChatMessage(role="user", content=query)) # Query the RAG database for relevant documents + from rag_components import query_db # type: ignore[import-not-found] + results = query_db.query_db(query, collection_name, top_k, self.client) relevant_examples = [item["original_id"] for item in results["code_metadata"]] + [ item["code"] for item in results["text_metadata"] diff --git a/src/vtk_prompt/state/initializer.py b/src/vtk_prompt/state/initializer.py index 558cb00..66ce7a6 100644 --- a/src/vtk_prompt/state/initializer.py +++ b/src/vtk_prompt/state/initializer.py @@ -30,6 +30,11 @@ def initialize_state(app: Any) -> None: app.state.error_message = "" app.state.input_tokens = 0 app.state.output_tokens = 0 + app.state.advanced_settings_open = False + app.state.active_settings_tab = "files" + + # File upload state variables + app.state.uploaded_files = None # Conversation state variables app._conversation_loading = False @@ -42,6 +47,10 @@ def initialize_state(app: Any) -> None: app.state.can_navigate_right = False app.state.is_viewing_history = False + # Prompt file state variables + app.state.prompt_object = None + app.state.prompt_file = None + # Toast notification state app.state.toast_message = "" app.state.toast_visible = False diff --git a/src/vtk_prompt/ui/layout/__init__.py b/src/vtk_prompt/ui/layout/__init__.py index 5c1567f..ce7816a 100644 --- a/src/vtk_prompt/ui/layout/__init__.py +++ b/src/vtk_prompt/ui/layout/__init__.py @@ -6,11 +6,11 @@ """ from .content import build_content -from .drawer import build_drawer +from .settings_dialog import build_settings_dialog from .toolbar import build_toolbar __all__ = [ "build_toolbar", - "build_drawer", "build_content", + "build_settings_dialog", ] diff --git a/src/vtk_prompt/ui/layout/content.py b/src/vtk_prompt/ui/layout/content.py index fea811d..3130050 100644 --- a/src/vtk_prompt/ui/layout/content.py +++ b/src/vtk_prompt/ui/layout/content.py @@ -20,265 +20,224 @@ def build_content(layout: Any, app: Any) -> None: ): with vuetify.VRow(rows=12, classes="fill-height px-4 pt-1 pb-1"): # Left column - Generated code view - with vuetify.VCol(cols=7, classes="fill-height pa-0"): - with vuetify.VExpansionPanels( - v_model=("explanation_expanded", [0, 1]), - classes="fill-height pb-1 pr-1", - multiple=True, - ): - # Explanation panel - with vuetify.VExpansionPanel( - classes=("flex-grow-1 flex-shrink-0 d-flex flex-column pa-0 mt-0"), - style="max-height: 25%;", - ): - vuetify.VExpansionPanelTitle("Explanation", classes="text-h6") - with vuetify.VExpansionPanelText( - classes="fill-height flex-shrink-1", - style="overflow: hidden;", - ): - vuetify.VTextarea( - v_model=("generated_explanation", ""), - readonly=True, - solo=True, - hide_details=True, - no_resize=True, - classes="overflow-y-auto fill-height", - placeholder="Explanation will appear here...", - auto_grow=True, - density="compact", - style="overflow-y: auto;", + with vuetify.VCol(cols=6): + # Prompt input + with vuetify.VCard(classes="h-25"): + with vuetify.VCardText(classes="h-100"): + with html.Div(classes="d-flex"): + # Cloud models chip + vuetify.VChip( + "☁️ {{ provider }}/{{ model }}", + small=True, + color="blue", + text_color="white", + label=True, + classes="mb-2", + v_show="use_cloud_models", + ) + # Local models chip + vuetify.VChip( + ( + "🏠 " + "{{ local_base_url.replace('http://', '')" + ".replace('https://', '') }}/" + "{{ local_model }}" + ), + small=True, + color="green", + text_color="white", + label=True, + classes="mb-2", + v_show="!use_cloud_models", + ) + vuetify.VSpacer() + # API token warning chip + vuetify.VChip( + "API token is required for cloud models.", + small=True, + color="error", + text_color="white", + label=True, + classes="mb-2", + v_show="use_cloud_models && !api_token.trim()", + prepend_icon="mdi-alert", ) - # Generated code panel - with vuetify.VExpansionPanel( - classes=( - "fill-height flex-grow-2 flex-shrink-0" + " d-flex flex-column mt-1" - ), - readonly=True, - style=( - "explanation_expanded.length > 1 ? " - + "'max-height: 75%;' : 'max-height: 95%;'", - "box-sizing: border-box;", - ), - ): - vuetify.VExpansionPanelTitle( - "Generated Code", - collapse_icon=False, - classes="text-h6", - ) - with vuetify.VExpansionPanelText( - style="overflow: hidden; height: 90%;", - classes="flex-grow-1", - ): + with html.Div(classes="d-flex", style="height: calc(100% - 75px);"): + with vuetify.VBtn( + variant="tonal", + icon=True, + rounded="0", + disabled=("!can_navigate_left",), + classes="h-auto mr-1", + click=app.ctrl.navigate_conversation_left, + ): + vuetify.VIcon("mdi-arrow-left-circle") + # Query input vuetify.VTextarea( - v_model=("generated_code", ""), - readonly=True, - solo=True, + label="Describe VTK visualization", + v_model=("query_text", ""), + rows=4, + variant="outlined", + placeholder=("e.g., Create a red sphere with lighting"), hide_details=True, no_resize=True, - classes="overflow-y-auto fill-height", - style="font-family: monospace;", - placeholder="Generated VTK code will appear here...", + disabled=( + "is_viewing_history", + False, + ), ) + with vuetify.VBtn( + color=( + "conversation_index ===" + + " conversation_navigation.length - 1" + + " ? 'success' : 'default'", + "default", + ), + variant="tonal", + icon=True, + rounded="0", + disabled=("!can_navigate_right",), + click=app.ctrl.navigate_conversation_right, + ): + vuetify.VIcon( + "mdi-arrow-right-circle", + v_show="conversation_index <" + + " conversation_navigation.length - 1", + ) + vuetify.VIcon( + "mdi-message-plus", + v_show="conversation_index ===" + + " conversation_navigation.length - 1", + ) + + # Generate button + vuetify.VBtn( + "Generate Code", + color="primary", + block=True, + loading=("trame__busy", False), + click=app.ctrl.generate_code, + classes="my-2", + disabled="is_viewing_history || !query_text.trim()", + v_show="api_token.trim()", + ) + vuetify.VBtn( + "Set API Key", + color="error", + block=True, + click=( + "advanced_settings_open = true;" + + " active_settings_tab = 'model';" + ), + classes="mb-2", + v_show="use_cloud_models && !api_token.trim()", + ) + + # Generated code panel + with vuetify.VCard(readonly=True, classes="h-75 mt-2"): + vuetify.VCardTitle("Generated Code") + with vuetify.VCardText(style="height: calc(100% - 50px);"): + vuetify.VTextarea( + v_model=("generated_code", ""), + readonly=True, + solo=True, + hide_details=True, + no_resize=True, + classes="overflow-y-auto fill-height", + style="font-family: monospace;", + placeholder="Generated VTK code will appear here...", + ) # Right column - VTK viewer and prompt - with vuetify.VCol(cols=5, classes="fill-height pa-0"): + with vuetify.VCol(cols=6): with vuetify.VRow(no_gutters=True, classes="fill-height"): # Top: VTK render view - with vuetify.VCol( - cols=12, - classes="flex-grow-1 flex-shrink-0 pa-0", - style="min-height: calc(100% - 256px);", - ): - with vuetify.VCard(classes="fill-height"): - with vuetify.VCardTitle( - "VTK Visualization", classes="d-flex align-center" + with vuetify.VCard(classes="h-75 w-100"): + with vuetify.VCardTitle("VTK Visualization", classes="d-flex"): + vuetify.VSpacer() + # Token usage display + with vuetify.VChip( + small=True, + color="secondary", + text_color="white", + v_show="input_tokens > 0 || output_tokens > 0", + classes="mr-2", + density="compact", ): - vuetify.VSpacer() - # Token usage display - with vuetify.VChip( - small=True, - color="secondary", - text_color="white", - v_show="input_tokens > 0 || output_tokens > 0", - classes="mr-2", - density="compact", - ): - html.Span( - "Tokens: In: {{ input_tokens }} | " - "Out: {{ output_tokens }}" - ) - # VTK control buttons - with vuetify.VTooltip( - text="Clear Scene", - location="bottom", - ): - with vuetify.Template(v_slot_activator="{ props }"): - with vuetify.VBtn( - click=app.ctrl.clear_scene, - icon=True, - color="secondary", - v_bind="props", - classes="mr-2", - density="compact", - variant="text", - ): - vuetify.VIcon("mdi-reload") - with vuetify.VTooltip( - text="Reset Camera", - location="bottom", - ): - with vuetify.Template(v_slot_activator="{ props }"): - with vuetify.VBtn( - click=app.ctrl.reset_camera, - icon=True, - color="secondary", - v_bind="props", - classes="mr-2", - density="compact", - variant="text", - ): - vuetify.VIcon("mdi-camera-retake-outline") - with vuetify.VCardText(style="height: 90%;"): - # VTK render window - view = vtk_widgets.VtkRemoteView( - app.render_window, - ref="view", - classes="w-100 h-100", - interactor_settings=[ - ( - "SetInteractorStyle", - ["vtkInteractorStyleTrackballCamera"], - ), - ], + html.Span( + "Tokens: In: {{ input_tokens }} | Out: {{ output_tokens }}" ) - app.ctrl.view_update = view.update - app.ctrl.view_reset_camera = view.reset_camera - - # Register custom controller methods - app.ctrl.on_tab_change = app.on_tab_change - - # Ensure initial render - view.update() - - # Bottom: Prompt input - with vuetify.VCol( - cols=12, - classes="flex-grow-0 flex-shrink-0", - style="height: 256px;", - ): - with vuetify.VCard(classes="fill-height"): - with vuetify.VCardText( - classes="d-flex flex-column", - style="height: 100%;", + # VTK control buttons + with vuetify.VTooltip( + text="Clear Scene", + location="bottom", ): - with html.Div(classes="d-flex"): - # Cloud models chip - vuetify.VChip( - "☁️ {{ provider }}/{{ model }}", - small=True, - color="blue", - text_color="white", - label=True, - classes="mb-2", - v_show="use_cloud_models", - ) - # Local models chip - vuetify.VChip( - ( - "🏠 " - "{{ local_base_url.replace('http://', '')" - ".replace('https://', '') }}/" - "{{ local_model }}" - ), - small=True, - color="green", - text_color="white", - label=True, - classes="mb-2", - v_show="!use_cloud_models", - ) - vuetify.VSpacer() - # API token warning chip - vuetify.VChip( - "API token is required for cloud models.", - small=True, - color="error", - text_color="white", - label=True, - classes="mb-2", - v_show="use_cloud_models && !api_token.trim()", - prepend_icon="mdi-alert", - ) - - with html.Div( - classes="d-flex mb-2", - style="height: 100%;", - ): + with vuetify.Template(v_slot_activator="{ props }"): with vuetify.VBtn( - variant="tonal", + click=app.ctrl.clear_scene, icon=True, - rounded="0", - disabled=("!can_navigate_left",), - classes="h-auto mr-1", - click=app.ctrl.navigate_conversation_left, + color="secondary", + v_bind="props", + classes="mr-2", + density="compact", + variant="text", ): - vuetify.VIcon("mdi-arrow-left-circle") - # Query input - vuetify.VTextarea( - label="Describe VTK visualization", - v_model=("query_text", ""), - rows=4, - variant="outlined", - placeholder=("e.g., Create a red sphere with lighting"), - hide_details=True, - no_resize=True, - disabled=( - "is_viewing_history", - False, - ), - ) + vuetify.VIcon("mdi-reload") + with vuetify.VTooltip( + text="Reset Camera", + location="bottom", + ): + with vuetify.Template(v_slot_activator="{ props }"): with vuetify.VBtn( - color=( - "conversation_index ===" - + " conversation_navigation.length - 1" - + " ? 'success' : 'default'", - "default", - ), - variant="tonal", + click=app.ctrl.reset_camera, icon=True, - rounded="0", - disabled=("!can_navigate_right",), - classes="h-auto ml-1", - click=app.ctrl.navigate_conversation_right, + color="secondary", + v_bind="props", + classes="mr-2", + density="compact", + variant="text", ): - vuetify.VIcon( - "mdi-arrow-right-circle", - v_show="conversation_index <" - + " conversation_navigation.length - 1", - ) - vuetify.VIcon( - "mdi-message-plus", - v_show="conversation_index ===" - + " conversation_navigation.length - 1", - ) - - # Generate button - vuetify.VBtn( - "Generate Code", - color="primary", - block=True, - loading=("trame__busy", False), - click=app.ctrl.generate_code, - classes="mb-2", - disabled=( - "is_viewing_history ||" - + " !query_text.trim() ||" - + " (use_cloud_models && !api_token.trim())", + vuetify.VIcon("mdi-camera-retake-outline") + with vuetify.VCardText(style="height: calc(100% - 50px);"): + # VTK render window + view = vtk_widgets.VtkRemoteView( + app.render_window, + ref="view", + classes="w-100 h-100", + interactor_settings=[ + ( + "SetInteractorStyle", + ["vtkInteractorStyleTrackballCamera"], ), - ) + ], + ) + app.ctrl.view_update = view.update + app.ctrl.view_reset_camera = view.reset_camera + + # Register custom controller methods + app.ctrl.on_tab_change = app.on_tab_change + + # Ensure initial render + view.update() + + # Explanation panel + with vuetify.VCard(classes="h-25 w-100 mt-2"): + vuetify.VCardTitle("Explanation", classes="text-h6") + with vuetify.VCardText(style="height: calc(100% - 50px);"): + vuetify.VTextarea( + v_model=("generated_explanation", ""), + readonly=True, + solo=True, + hide_details=True, + no_resize=True, + classes="overflow-y-auto fill-height", + placeholder="Explanation will appear here...", + auto_grow=True, + density="compact", + style="overflow-y: auto;", + ) - # Error alert vuetify.VAlert( closable=True, v_show=("error_message", ""), diff --git a/src/vtk_prompt/ui/layout/drawer.py b/src/vtk_prompt/ui/layout/drawer.py deleted file mode 100644 index 804837a..0000000 --- a/src/vtk_prompt/ui/layout/drawer.py +++ /dev/null @@ -1,231 +0,0 @@ -""" -Drawer Layout Module. - -This module provides the settings drawer layout for the VTK Prompt UI. -The drawer contains model configuration, RAG settings, and file controls. -""" - -from typing import Any - -from trame.widgets import html -from trame.widgets import vuetify3 as vuetify - -from ...provider_utils import DEFAULT_MODEL, DEFAULT_PROVIDER - - -def build_drawer(layout: Any) -> None: - """Build the settings drawer with configuration options.""" - with layout.drawer as drawer: - drawer.width = 350 - with vuetify.VContainer(): - # Tab Navigation - Centered - with vuetify.VRow(justify="center"): - with vuetify.VCol(cols="auto"): - with vuetify.VTabs( - v_model=("tab_index", 0), - color="primary", - slider_color="primary", - centered=True, - grow=False, - ): - vuetify.VTab("☁️ Cloud") - vuetify.VTab("🏠Local") - - # Tab Content - with vuetify.VTabsWindow(v_model="tab_index"): - # Cloud Providers Tab Content - with vuetify.VTabsWindowItem(): - with vuetify.VCard(flat=True, style="mt-2"): - with vuetify.VCardText(): - # Provider selection - vuetify.VSelect( - label="Provider", - v_model=("provider", DEFAULT_PROVIDER), - items=("available_providers", []), - density="compact", - variant="outlined", - prepend_icon="mdi-cloud", - ) - # Model selection - vuetify.VSelect( - label="Model", - v_model=("model", DEFAULT_MODEL), - items=("available_models[provider] || []",), - density="compact", - variant="outlined", - prepend_icon="mdi-brain", - ) - # API Token - vuetify.VTextField( - label="API Token", - v_model=("api_token", ""), - placeholder="Enter your API token", - type="password", - density="compact", - variant="outlined", - prepend_icon="mdi-key", - hint="Required for cloud providers", - persistent_hint=True, - error=("!api_token", False), - ) - - # Local Models Tab Content - with vuetify.VTabsWindowItem(): - with vuetify.VCard(flat=True, style="mt-2"): - with vuetify.VCardText(): - vuetify.VTextField( - label="Base URL", - v_model=( - "local_base_url", - "http://localhost:11434/v1", - ), - placeholder="http://localhost:11434/v1", - density="compact", - variant="outlined", - prepend_icon="mdi-server", - hint="Ollama, LM Studio, etc.", - persistent_hint=True, - ) - vuetify.VTextField( - label="Model Name", - v_model=("local_model", "devstral"), - placeholder="devstral", - density="compact", - variant="outlined", - prepend_icon="mdi-brain", - hint="Model identifier", - persistent_hint=True, - ) - # Optional API Token for local - vuetify.VTextField( - label="API Token (Optional)", - v_model=("api_token", "ollama"), - placeholder="ollama", - type="password", - density="compact", - variant="outlined", - prepend_icon="mdi-key", - hint="Optional for local servers", - persistent_hint=True, - ) - - # RAG Settings Card - with vuetify.VCard(classes="mt-2"): - vuetify.VCardTitle("⚙️ RAG settings", classes="pb-0") - with vuetify.VCardText(): - vuetify.VCheckbox( - v_model=("use_rag", False), - label="RAG", - prepend_icon="mdi-bookshelf", - density="compact", - ) - vuetify.VTextField( - label="Top K", - v_model=("top_k", 5), - type="number", - min=1, - max=15, - density="compact", - disabled=("!use_rag",), - variant="outlined", - prepend_icon="mdi-chart-scatter-plot", - ) - - # Generation Settings Card - with vuetify.VCard(classes="mt-2"): - vuetify.VCardTitle("⚙️ Generation Settings", classes="pb-0") - with vuetify.VCardText(): - vuetify.VSlider( - label="Temperature", - v_model=("temperature", 0.1), - min=0.0, - max=1.0, - step=0.1, - thumb_label="always", - color="orange", - prepend_icon="mdi-thermometer", - classes="mt-2", - disabled=("!temperature_supported",), - ) - vuetify.VTextField( - label="Max Tokens", - v_model=("max_tokens", 1000), - type="number", - density="compact", - variant="outlined", - prepend_icon="mdi-format-text", - ) - vuetify.VTextField( - label="Retry Attempts", - v_model=("retry_attempts", 1), - type="number", - min=1, - max=5, - density="compact", - variant="outlined", - prepend_icon="mdi-repeat", - ) - - # Files Card - with vuetify.VCard(classes="mt-2"): - vuetify.VCardTitle("⚙️ Files", hide_details=True, density="compact") - with vuetify.VCardText(): - vuetify.VCheckbox( - label="Run new conversation files", - v_model=("auto_run_conversation_file", True), - prepend_icon="mdi-file-refresh-outline", - density="compact", - color="primary", - hide_details=True, - ) - with html.Div(classes="d-flex align-center justify-space-between"): - with vuetify.VTooltip( - text=("conversation_file", "No file loaded"), - location="top", - disabled=("!conversation_object",), - ): - with vuetify.Template(v_slot_activator="{ props }"): - vuetify.VFileInput( - label="Conversation File", - v_model=("conversation_object", None), - accept=".json", - density="compact", - variant="solo", - prepend_icon="mdi-forum-outline", - hide_details="auto", - classes="py-1 pr-1 mr-1 text-truncate", - open_on_focus=False, - clearable=False, - v_bind="props", - rules=["[utils.vtk_prompt.rules.json_file]"], - ) - with vuetify.VTooltip( - text="Download conversation file", - location="right", - ): - with vuetify.Template(v_slot_activator="{ props }"): - with vuetify.VBtn( - icon=True, - density="comfortable", - color="secondary", - rounded="lg", - v_bind="props", - disabled=("!conversation",), - click="utils.download(" - + "`vtk-prompt_${provider}_${model}.json`," - + "trigger('save_conversation')," - + "'application/json'" - + ")", - ): - vuetify.VIcon("mdi-file-download-outline") - vuetify.VBtn( - text="Download config file", - color="secondary", - rounded="lg", - click="utils.download(" - + "`vtk-prompt_config.yml`," - + "trigger('save_config')," - + "'application/x-yaml'" - + ")", - block=True, - ) diff --git a/src/vtk_prompt/ui/layout/settings_dialog.py b/src/vtk_prompt/ui/layout/settings_dialog.py new file mode 100644 index 0000000..917760a --- /dev/null +++ b/src/vtk_prompt/ui/layout/settings_dialog.py @@ -0,0 +1,238 @@ +""" +Settings Dialog Layout Module. + +This module provides the advanced settings dialog layout for the VTK Prompt UI. +The dialog contains model configuration, RAG settings, and file controls. +""" + +from typing import Any + +from trame.widgets import vuetify3 as vuetify + +from ...provider_utils import DEFAULT_MODEL, DEFAULT_PROVIDER + +vuetify.enable_lab() + + +def build_settings_dialog(layout: Any, app: Any) -> None: + """Build the advanced settings dialog with configuration options.""" + with layout.content: + with vuetify.VDialog(v_model=("advanced_settings_open", False), classes="w-33"): + with vuetify.VCard(): + with vuetify.VTabs( + v_model=("active_settings_tab", "files"), + color="primary", + classes="pa-1", + ): + vuetify.VTab("Files", value="files") + vuetify.VTab("Model", value="model") + vuetify.VTab("Advanced", value="advanced") + with vuetify.VTabsWindow(v_model=("active_settings_tab", "files")): + # Files Tab + with vuetify.VTabsWindowItem(value="files"): + with vuetify.VCard(): + with vuetify.VCardTitle("Uploads"): + vuetify.VCardSubtitle( + "Upload conversation files (.json) or prompt " + "config files (.yaml/.yml)" + ) + with vuetify.VCardText(): + with vuetify.VTooltip( + text="Upload conversation files (.json) or prompt " + "config files (.yaml/.yml)", + location="top", + ): + with vuetify.Template(v_slot_activator="{ props }"): + vuetify.VFileUpload( + label="Upload Files (.json, .yaml, .yml)", + v_model=("uploaded_files", None), + accept=".json,.yaml,.yml", + multiple=True, + hide_details="auto", + classes="py-3 pr-1 mr-1 w-100", + v_bind="props", + color="teal-lighten-5", + ) + with vuetify.VCard(): + with vuetify.VCardTitle("Settings"): + vuetify.VCardSubtitle("Configure default behavior") + with vuetify.VCardText(): + vuetify.VCheckbox( + label="Automatically run new conversation files", + v_model=("auto_run_conversation_file", True), + density="compact", + color="primary", + hide_details=True, + ) + with vuetify.VCard(): + with vuetify.VCardTitle("Downloads"): + vuetify.VCardSubtitle("Download conversation or prompt files") + with vuetify.VCardText(): + with vuetify.VRow(cols=12): + with vuetify.VCol(cols=6): + vuetify.VBtn( + "Download Conversation File", + color="secondary", + classes="mr-2 mt-2 w-100", + click="download_conversation_file", + append_icon="mdi-download", + ) + with vuetify.VCol(cols=6): + vuetify.VBtn( + "Download Prompt File", + color="secondary", + classes="mr-2 mt-2 w-100", + click="download_prompt_file", + append_icon="mdi-download", + ) + # Model Tab + with vuetify.VTabsWindowItem(value="model"): + # Tab Navigation - Centered + with vuetify.VRow(justify="center"): + with vuetify.VCol(cols="auto"): + with vuetify.VTabs( + v_model=("tab_index", 0), + color="primary", + slider_color="primary", + centered=True, + grow=False, + ): + vuetify.VTab("☁️ Cloud") + vuetify.VTab("🏠Local") + + # Tab Content + with vuetify.VTabsWindow(v_model="tab_index"): + # Cloud Providers Tab Content + with vuetify.VTabsWindowItem(): + with vuetify.VCard(flat=True, style="mt-2"): + with vuetify.VCardText(): + # Provider selection + vuetify.VSelect( + label="Provider", + v_model=("provider", DEFAULT_PROVIDER), + items=("available_providers", []), + density="compact", + variant="outlined", + prepend_icon="mdi-cloud", + ) + # Model selection + vuetify.VSelect( + label="Model", + v_model=("model", DEFAULT_MODEL), + items=("available_models[provider] || []",), + density="compact", + variant="outlined", + prepend_icon="mdi-brain", + ) + # API Token + vuetify.VTextField( + label="API Token", + v_model=("api_token", ""), + placeholder="Enter your API token", + type="password", + density="compact", + variant="outlined", + prepend_icon="mdi-key", + hint="Required for cloud providers", + persistent_hint=True, + error=("!api_token", False), + ) + + # Local Models Tab Content + with vuetify.VTabsWindowItem(): + with vuetify.VCard(flat=True, style="mt-2"): + with vuetify.VCardText(): + vuetify.VTextField( + label="Base URL", + v_model=( + "local_base_url", + "http://localhost:11434/v1", + ), + placeholder="http://localhost:11434/v1", + density="compact", + variant="outlined", + prepend_icon="mdi-server", + hint="Ollama, LM Studio, etc.", + persistent_hint=True, + ) + vuetify.VTextField( + label="Model Name", + v_model=("local_model", "devstral"), + placeholder="devstral", + density="compact", + variant="outlined", + prepend_icon="mdi-brain", + hint="Model identifier", + persistent_hint=True, + ) + # Optional API Token for local + vuetify.VTextField( + label="API Token (Optional)", + v_model=("api_token", "ollama"), + placeholder="ollama", + type="password", + density="compact", + variant="outlined", + prepend_icon="mdi-key", + hint="Optional for local servers", + persistent_hint=True, + ) + + # Advanced Settings Tab + with vuetify.VTabsWindowItem(value="advanced"): + # RAG Settings Card + with vuetify.VCard(classes="mt-2"): + vuetify.VCardTitle("⚙️ RAG settings", classes="pb-0") + with vuetify.VCardText(): + vuetify.VCheckbox( + v_model=("use_rag", False), + label="RAG", + prepend_icon="mdi-bookshelf", + density="compact", + ) + vuetify.VTextField( + label="Top K", + v_model=("top_k", 5), + type="number", + min=1, + max=15, + density="compact", + disabled=("!use_rag",), + variant="outlined", + prepend_icon="mdi-chart-scatter-plot", + ) + + # Generation Settings Card + with vuetify.VCard(classes="mt-2"): + vuetify.VCardTitle("⚙️ Generation Settings", classes="pb-0") + with vuetify.VCardText(): + vuetify.VSlider( + label="Temperature", + v_model=("temperature", 0.1), + min=0.0, + max=1.0, + step=0.1, + thumb_label="always", + color="orange", + prepend_icon="mdi-thermometer", + classes="mt-2", + disabled=("!temperature_supported",), + ) + vuetify.VTextField( + label="Max Tokens", + v_model=("max_tokens", 1000), + type="number", + density="compact", + variant="outlined", + prepend_icon="mdi-format-text", + ) + vuetify.VTextField( + label="Retry Attempts", + v_model=("retry_attempts", 1), + type="number", + min=1, + max=5, + density="compact", + variant="outlined", + prepend_icon="mdi-repeat", + ) diff --git a/src/vtk_prompt/ui/layout/toolbar.py b/src/vtk_prompt/ui/layout/toolbar.py index e85bc1a..031f505 100644 --- a/src/vtk_prompt/ui/layout/toolbar.py +++ b/src/vtk_prompt/ui/layout/toolbar.py @@ -10,108 +10,61 @@ from trame.widgets import vuetify3 as vuetify -def build_toolbar(layout: Any) -> None: +def build_toolbar(layout: Any, app: Any) -> None: """Build the toolbar layout with file controls and settings.""" - with layout.toolbar: - vuetify.VSpacer() + with layout.toolbar as toolbar: + drawer_icon = toolbar.children[0] + drawer_icon.hide() - # Conversation file input - with vuetify.VTooltip( - text=("conversation_file", "No file loaded"), - location="bottom", - disabled=("!conversation_object",), - ): - with vuetify.Template(v_slot_activator="{ props }"): - vuetify.VFileInput( - label="Conversation File", - v_model=("conversation_object", None), - accept=".json", - variant="solo", - density="compact", - prepend_icon="mdi-forum-outline", - hide_details="auto", - classes="py-1 pr-1 mr-2 text-truncate", - open_on_focus=False, - clearable=False, - v_bind="props", - rules=["[utils.vtk_prompt.rules.json_file]"], - color="primary", - style="max-width: 25%;", - ) + vuetify.VSpacer() - # Auto-run toggle button + # Settings buttons with vuetify.VTooltip( - text=( - "auto_run_conversation_file ? " - + "'Auto-run conversation files on load' : " - + "'Do not auto-run conversation files on load'", - "Auto-run conversation files on load", - ), + text="Load or download files", location="bottom", ): with vuetify.Template(v_slot_activator="{ props }"): with vuetify.VBtn( icon=True, v_bind="props", - click="auto_run_conversation_file = !auto_run_conversation_file", - classes="mr-2", + click="advanced_settings_open = true; active_settings_tab = 'files';", + classes="mr-4", color="primary", ): - vuetify.VIcon( - "mdi-autorenew", - v_show="auto_run_conversation_file", - ) - vuetify.VIcon( - "mdi-autorenew-off", - v_show="!auto_run_conversation_file", - ) + vuetify.VIcon("mdi-file-cog-outline") - # Download conversation button with vuetify.VTooltip( - text="Download conversation file", + text="Change model settings", location="bottom", ): with vuetify.Template(v_slot_activator="{ props }"): with vuetify.VBtn( icon=True, v_bind="props", - disabled=("!conversation",), - click="utils.download(" - + "`vtk-prompt_${provider}_${model}.json`," - + "trigger('save_conversation')," - + "'application/json'" - + ")", - classes="mr-2", + click="advanced_settings_open = true; active_settings_tab = 'model';", + classes="mr-4", color="primary", - density="compact", ): - vuetify.VIcon("mdi-file-download-outline") + vuetify.VIcon("mdi-brain") - # Download config button with vuetify.VTooltip( - text="Download config file", + text="Advanced settings", location="bottom", ): with vuetify.Template(v_slot_activator="{ props }"): with vuetify.VBtn( icon=True, v_bind="props", - click="utils.download(" - + "`vtk-prompt_config.yml`," - + "trigger('save_config')," - + "'application/x-yaml'" - + ")", + click="advanced_settings_open = true; active_settings_tab = 'advanced';", classes="mr-4", color="primary", - density="compact", ): - vuetify.VIcon("mdi-content-save-cog-outline") + vuetify.VIcon("mdi-cog-outline") # Theme switcher vuetify.VSwitch( v_model=("theme_mode", "light"), hide_details=True, - density="compact", classes="mr-2", true_value="light", false_value="dark", diff --git a/src/vtk_prompt/utils/file_handlers.py b/src/vtk_prompt/utils/file_handlers.py index 04890a3..287d52e 100644 --- a/src/vtk_prompt/utils/file_handlers.py +++ b/src/vtk_prompt/utils/file_handlers.py @@ -11,7 +11,7 @@ def load_js(server: Any) -> None: """Load JavaScript utilities for VTK Prompt UI.""" - js_file = Path(__file__).parent.parent.with_name("utils.js") + js_file = Path(__file__).parent.parent / "utils.js" server.enable_module( { "serve": {"vtk_prompt": str(js_file.parent)}, diff --git a/src/vtk_prompt/vtk_prompt_ui.py b/src/vtk_prompt/vtk_prompt_ui.py index 41c4df6..8cbfa94 100644 --- a/src/vtk_prompt/vtk_prompt_ui.py +++ b/src/vtk_prompt/vtk_prompt_ui.py @@ -22,7 +22,7 @@ import vtk from trame.app import TrameApp from trame.decorators import change, controller, trigger -from trame.ui.vuetify3 import SinglePageWithDrawerLayout +from trame.ui.vuetify3 import SinglePageLayout from vtkmodules.vtkInteractionStyle import vtkInteractorStyleSwitch # noqa from . import get_logger @@ -32,7 +32,7 @@ setup_vtk_renderer, ) from .state import config_state, config_validator, initializer -from .ui.layout import build_content, build_drawer, build_toolbar +from .ui.layout import build_content, build_settings_dialog, build_toolbar from .utils import file_handlers, prompt_loader logger = get_logger(__name__) @@ -154,6 +154,18 @@ def _execute_with_renderer(self, code_string: str) -> None: """Execute VTK code with our renderer.""" generation.execute_with_renderer(self, code_string) + @change("uploaded_files") + def _on_uploaded_files_change(self, uploaded_files, **kwargs): + """Handle multiple file uploads with intelligent routing.""" + if uploaded_files: + from .controllers.conversation import process_uploaded_files + + process_uploaded_files(self, uploaded_files) + else: + # Clear file state when no files uploaded + self.state.prompt_file = None + self.state.conversation_file = None + @change("conversation_object") def on_conversation_file_data_change( self, conversation_object: dict[str, Any] | None, **_: Any @@ -211,15 +223,15 @@ def _build_ui(self) -> None: # Initialize drawer state as collapsed self.state.main_drawer = False - with SinglePageWithDrawerLayout( + with SinglePageLayout( self.server, theme=("theme_mode", "light"), style="max-height: 100vh;" ) as layout: layout.title.set_text("VTK Prompt UI") # Build UI sections using layout modules - build_toolbar(layout) - build_drawer(layout) + build_toolbar(layout, self) build_content(layout, self) + build_settings_dialog(layout, self) def start(self) -> None: """Start the trame server."""