diff --git a/src/vtk_prompt/client.py b/src/vtk_prompt/client.py index 2c6ee86..1f9b9d2 100644 --- a/src/vtk_prompt/client.py +++ b/src/vtk_prompt/client.py @@ -397,6 +397,7 @@ def query( request=message, ui_mode=ui_mode, context_snippets=context_snippets, + mcp_active=bool(mcp_client), VTK_VERSION=VTK_VERSION, PYTHON_VERSION=PYTHON_VERSION, ) diff --git a/src/vtk_prompt/controllers/configuration.py b/src/vtk_prompt/controllers/configuration.py index 20bab0e..601841b 100644 --- a/src/vtk_prompt/controllers/configuration.py +++ b/src/vtk_prompt/controllers/configuration.py @@ -44,6 +44,7 @@ def save_config(app: Any) -> str: max_tokens = int(getattr(app.state, "max_tokens", 1000)) retries = int(getattr(app.state, "retry_attempts", 1)) mcp_url = getattr(app.state, "mcp_url", "").strip() + data_root = getattr(app.state, "data_root", "").strip() top_k = int(getattr(app.state, "top_k", 5)) base_url = getattr(app.state, "local_base_url", "").strip() if not use_cloud else "" @@ -53,6 +54,7 @@ def save_config(app: Any) -> str: "model": provider_model, "base_url": base_url, "mcp_url": mcp_url, + "data_root": data_root, "top_k": top_k, "retries": retries, "modelParameters": { diff --git a/src/vtk_prompt/controllers/conversation.py b/src/vtk_prompt/controllers/conversation.py index d2f9b33..153820b 100644 --- a/src/vtk_prompt/controllers/conversation.py +++ b/src/vtk_prompt/controllers/conversation.py @@ -200,9 +200,7 @@ def toggle_favorite_conversation(app: Any, conversation_index: int) -> None: def save_conversation(app: Any) -> str: """Save current conversation history as JSON string.""" - if hasattr(app, "prompt_client") and app.prompt_client is not None: - return json.dumps(app.prompt_client.conversation, indent=2) - return "" + return json.dumps(getattr(app.state, "conversation", None) or [], indent=2) def _parse_assistant_content(content: str) -> tuple[str | None, str | None]: @@ -350,8 +348,7 @@ def _update_navigation_state(app: Any) -> None: def sync_with_prompt_client(app: Any) -> None: """Sync conversation navigation with prompt client conversation.""" - if app.prompt_client and app.prompt_client.conversation: - app.state.conversation = app.prompt_client.conversation + if app.state.conversation: build_conversation_navigation(app) diff --git a/src/vtk_prompt/controllers/generation.py b/src/vtk_prompt/controllers/generation.py index f0569ad..576f0ed 100644 --- a/src/vtk_prompt/controllers/generation.py +++ b/src/vtk_prompt/controllers/generation.py @@ -82,6 +82,10 @@ async def generate_and_execute_code(app: Any) -> None: app._init_prompt_client() if hasattr(app.state, "error_message") and app.state.error_message: return + # Tie this generation to the active conversation. A reset or session + # switch during the offloaded query bumps the epoch, so a stale result + # is discarded rather than written back over the new conversation. + epoch = getattr(app, "_conversation_epoch", 0) # Refine the CURRENT editor code (including manual edits), not the # model's previous output, so generation mutates what is on screen. @@ -103,6 +107,8 @@ async def generate_and_execute_code(app: Any) -> None: custom_prompt=app.custom_prompt_data, ui_mode=True, # This tells the client to use UI-specific components ) + if getattr(app, "_conversation_epoch", 0) != epoch: + return # conversation was reset/switched while the query ran # Keep UI in sync with conversation app.state.conversation = app.prompt_client.conversation @@ -205,12 +211,42 @@ async def generate_and_execute_code(app: Any) -> None: app.state.flush() # push final state (result/error, spinner off) to client +def _editor_line_for(displayed_code: str, line_text: str | None) -> int: + """Find the 1-based line of ``line_text`` in the code shown in the editor.""" + if not line_text: + return 0 + target = line_text.strip() + for i, line in enumerate(displayed_code.splitlines(), start=1): + if line.strip() == target: + return i + return 0 + + +def _format_exec_error(displayed_code: str, error_message: str, line_text: str | None) -> str: + """Prefix a run error with the editor line and the offending source text.""" + if not line_text: + return error_message + editor_line = _editor_line_for(displayed_code, line_text) + where = f"Line {editor_line}: {line_text}" if editor_line else f"At: {line_text}" + return f"{where}\n{error_message}" + + def execute_with_renderer(app: Any, code_string: str) -> tuple[bool, str | None]: """Execute VTK code with our renderer. Returns (success, error_message).""" - success, error_message = execute_vtk_code(code_string, app.renderer, app.render_window) + # Resolve bare data-file references (e.g. 'cow.g') to fetched local paths so + # example-style code runs. No-op unless a VTK data tree is configured. Only + # the executed copy is rewritten; the stored/displayed code keeps bare names. + from ..data.resolver import stage_code + + exec_code = stage_code(code_string) + success, error_message, error_line_text = execute_vtk_code( + exec_code, app.renderer, app.render_window + ) if not success and error_message: - app.state.error_message = error_message + app.state.error_message = _format_exec_error( + code_string, error_message, error_line_text + ) if success: app.state.rendered_code = code_string diff --git a/src/vtk_prompt/controllers/sessions.py b/src/vtk_prompt/controllers/sessions.py index de764a0..5c44985 100644 --- a/src/vtk_prompt/controllers/sessions.py +++ b/src/vtk_prompt/controllers/sessions.py @@ -91,8 +91,7 @@ def _maybe_title(app: Any, sess: dict) -> None: def capture_current_session(app: Any) -> None: """Snapshot the live app state into the current session object.""" sess = current_session(app) - client = getattr(app, "prompt_client", None) - messages = list(getattr(client, "conversation", None) or app.state.conversation or []) + messages = list(app.state.conversation or []) sess["messages"] = messages sess["code_history"] = list(app.state.code_history or []) sess["code_history_labels"] = list(app.state.code_history_labels or []) @@ -126,6 +125,8 @@ def _key(s: dict): def _reset_live(app: Any) -> None: """Clear all live conversation/code state (the fresh-conversation hinge).""" + # Invalidate any in-flight generation so its result is not written back here. + app._conversation_epoch = getattr(app, "_conversation_epoch", 0) + 1 client = getattr(app, "prompt_client", None) if client: client.conversation = [] @@ -155,6 +156,7 @@ def load_session(app: Any, session_id: str, execute: bool = True) -> None: return sess = sessions[session_id] app.state.current_session_id = session_id + app._conversation_epoch = getattr(app, "_conversation_epoch", 0) + 1 client = getattr(app, "prompt_client", None) if client: @@ -242,6 +244,17 @@ def toggle_pin_session(app: Any, session_id: str) -> None: refresh_sessions_list(app) +def export_session(app: Any, session_id: str) -> str: + """Return a session's persisted JSON for download (empty string if unknown).""" + if session_id == (getattr(app.state, "current_session_id", "") or ""): + capture_current_session(app) # fold in any unsaved live messages first + sess = _sessions(app).get(session_id) + if not sess: + return "" + data = {key: sess.get(key) for key in _PERSIST_KEYS} + return json.dumps(data, indent=2) + + def _sessions_dir() -> Path: """Directory holding one JSON file per persisted session.""" directory = _config_home() / "sessions" diff --git a/src/vtk_prompt/data/__init__.py b/src/vtk_prompt/data/__init__.py new file mode 100644 index 0000000..dec1cba --- /dev/null +++ b/src/vtk_prompt/data/__init__.py @@ -0,0 +1,5 @@ +"""Sample-data resolution for example-style prompts that read files.""" + +from .resolver import artifacts, available_names, has_data_root, referenced, resolve + +__all__ = ["artifacts", "available_names", "has_data_root", "referenced", "resolve"] diff --git a/src/vtk_prompt/data/resolver.py b/src/vtk_prompt/data/resolver.py new file mode 100644 index 0000000..ac8c696 --- /dev/null +++ b/src/vtk_prompt/data/resolver.py @@ -0,0 +1,198 @@ +"""Resolve named VTK example datasets to local file paths. + +VTK examples reference data files by bare name (for example ``cow.g``). The bytes +live in VTK's content-addressed ExternalData store; each file has a +``.sha512`` pointer somewhere in a VTK data tree. This module builds a +``basename -> sha512`` index from a local VTK data tree (pointed at by the +``VTK_PROMPT_DATA_ROOT`` environment variable), then resolves a name by fetching +the blob from the ExternalData store, caching it locally and verifying its +checksum. That lets generated example-style code which reads such files run. +""" + +import hashlib +import logging +import os +import re +import shutil +import urllib.request +from pathlib import Path +from urllib.error import URLError + +logger = logging.getLogger(__name__) + +_STORE_URL = "https://data.kitware.com/api/v1/file/hashsum/sha512/{digest}/download" +_DATA_ROOT_ENV = "VTK_PROMPT_DATA_ROOT" +_POINTER_SUFFIX = ".sha512" + +_index_cache: dict[str, str] | None = None +_data_root_override: Path | None = None + + +def set_data_root(path: str | None) -> None: + """Point the resolver at a VTK data tree at runtime (the Settings field). + + A falsy value clears the override and falls back to the VTK_PROMPT_DATA_ROOT + environment variable. Always invalidates the cached index so the next lookup + rebuilds against the new root. + """ + global _data_root_override, _index_cache + cleaned = (path or "").strip() + _data_root_override = Path(cleaned) if cleaned else None + _index_cache = None + + +def _cache_dir() -> Path: + base = os.environ.get("XDG_CACHE_HOME") + root = Path(base) if base else Path.home() / ".cache" + directory = root / "vtk-prompt" / "data" + directory.mkdir(parents=True, exist_ok=True) + return directory + + +def _data_root() -> Path | None: + if _data_root_override is not None and _data_root_override.is_dir(): + return _data_root_override + root = os.environ.get(_DATA_ROOT_ENV) + if root and Path(root).is_dir(): + return Path(root) + return None + + +def _build_index(root: Path) -> dict[str, str]: + index: dict[str, str] = {} + for dirpath, _dirs, files in os.walk(root): + for filename in files: + if not filename.endswith(_POINTER_SUFFIX): + continue + name = filename[: -len(_POINTER_SUFFIX)] + if name in index: + continue # first occurrence wins on duplicate basenames + try: + digest = (Path(dirpath) / filename).read_text().strip() + except OSError: + continue + if digest: + index[name] = digest + return index + + +def _load_index() -> dict[str, str]: + global _index_cache + if _index_cache is None: + root = _data_root() + _index_cache = _build_index(root) if root else {} + return _index_cache + + +def _sha512(path: Path) -> str: + digest = hashlib.sha512() + with open(path, "rb") as handle: + for chunk in iter(lambda: handle.read(65536), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _download(digest: str, dest: Path) -> bool: + url = _STORE_URL.format(digest=digest) + try: + with urllib.request.urlopen(url, timeout=60) as response, open(dest, "wb") as out: + shutil.copyfileobj(response, out) + return True + except (OSError, URLError) as exc: + logger.warning("Could not download %s: %s", dest.name, exc) + dest.unlink(missing_ok=True) + return False + + +def resolve(name: str) -> str | None: + """Return a local path for a named dataset, downloading and caching as needed. + + Returns None if the name is unknown (not in the data index) or the download + or checksum verification fails. + """ + name = os.path.basename((name or "").strip()) + if not name: + return None + digest = _load_index().get(name) + cached = _cache_dir() / name + + if cached.exists() and (not digest or _sha512(cached) == digest): + return str(cached) + if not digest: + return None + + if not _download(digest, cached): + return None + if _sha512(cached) != digest: + logger.warning("Checksum mismatch for %s; discarding", name) + cached.unlink(missing_ok=True) + return None + return str(cached) + + +def available_names() -> list[str]: + """Return the sorted list of dataset names known to the resolver.""" + return sorted(_load_index().keys()) + + +def has_data_root() -> bool: + """Whether a local VTK data tree is configured (enables name resolution).""" + return _data_root() is not None + + +# Match single- or double-quoted string literals with no embedded quote/newline. +_LITERAL_RE = re.compile(r"([\'\"])([^\'\"\n]+?)\1") + + +def stage_code(code: str) -> str: + """Rewrite bare data-file references in code to resolved local paths. + + Only string literals that are a bare filename (no directory separator) whose + basename is a known dataset are rewritten, so example code like + ``reader.SetFileName('cow.g')`` runs against the fetched file. Explicit paths + and unrelated strings are left untouched. + """ + index = _load_index() + if not index or not code: + return code + + def _replace(match: "re.Match[str]") -> str: + quote, value = match.group(1), match.group(2) + if ("/" in value) or ("\\" in value): + return match.group(0) + if value in index: + path = resolve(value) + if path: + return f"{quote}{path}{quote}" + return match.group(0) + + return _LITERAL_RE.sub(_replace, code) + + +def referenced(code: str) -> list[str]: + """Return dataset names referenced as bare string literals in code (no fetch).""" + index = _load_index() + if not index or not code: + return [] + found: list[str] = [] + for match in _LITERAL_RE.finditer(code): + value = match.group(2) + if ("/" in value) or ("\\" in value): + continue + if value in index and value not in found: + found.append(value) + return found + + +def artifacts(code: str) -> list[dict]: + """Return data artifacts referenced by code (name, cache path, fetched flag). + + Pure lookup (no download); used to surface what files the current code pulls + in and where they live. + """ + cache = _cache_dir() + result: list[dict] = [] + for name in referenced(code): + path = cache / name + result.append({"name": name, "path": str(path), "cached": path.exists()}) + return result diff --git a/src/vtk_prompt/prompts/components/tool_use.yml b/src/vtk_prompt/prompts/components/tool_use.yml new file mode 100644 index 0000000..96182e8 --- /dev/null +++ b/src/vtk_prompt/prompts/components/tool_use.yml @@ -0,0 +1,15 @@ +role: assistant +content: | + You have access to VTK reference tools provided by a vtk-mcp server. Use them to + ground your code rather than relying on memory for uncommon or uncertain APIs: + - Find and confirm classes: search for the right class when unsure which to use, + and verify exact class names, method names, and signatures for anything you are + not fully certain about. + - Check data flow: confirm a filter's expected input and output types before + wiring it into a pipeline. + - Validate before finalizing: validate the generated code and fix any reported + invalid class names, methods, or imports. + - Adapt examples: when helpful, retrieve a relevant VTK example and base your code + on it. + For simple, well-known operations you are confident about, answer directly without + tools. Prefer tool-verified facts over assumptions for anything uncommon. diff --git a/src/vtk_prompt/prompts/prompt_component_assembler.py b/src/vtk_prompt/prompts/prompt_component_assembler.py index 6cde261..f024f88 100644 --- a/src/vtk_prompt/prompts/prompt_component_assembler.py +++ b/src/vtk_prompt/prompts/prompt_component_assembler.py @@ -197,6 +197,7 @@ def assemble_vtk_prompt( request: str, ui_mode: bool = False, context_snippets: str | None = None, + mcp_active: bool = False, **variables: Any, ) -> PromptData: """Assemble VTK prompt from file-based components. @@ -205,6 +206,7 @@ def assemble_vtk_prompt( request: User's request text ui_mode: Whether to include UI-specific instructions context_snippets: Optional context snippets from vtk-mcp (enables rag_context component) + mcp_active: Whether a vtk-mcp server is reachable (enables tool_use guidance) **variables: Additional variables for substitution Returns: @@ -218,6 +220,7 @@ def assemble_vtk_prompt( assembler.add_component("vtk_instructions") # Conditional components (order matters for message composition) + assembler.add_if(mcp_active, "tool_use") assembler.add_if(bool(context_snippets), "rag_context") assembler.add_if(ui_mode, "ui_renderer") diff --git a/src/vtk_prompt/rendering/code_executor.py b/src/vtk_prompt/rendering/code_executor.py index 95738bb..2be9aaa 100644 --- a/src/vtk_prompt/rendering/code_executor.py +++ b/src/vtk_prompt/rendering/code_executor.py @@ -1,5 +1,7 @@ """VTK Code Execution Module.""" +import traceback + import vtk from .. import get_logger @@ -10,7 +12,7 @@ def execute_vtk_code( code_string: str, renderer: vtk.vtkRenderer, render_window: vtk.vtkRenderWindow -) -> tuple[bool, str | None]: +) -> tuple[bool, str | None, str | None]: """Execute VTK code with renderer context. Clears previous actors, cleans the code string, executes it with the renderer @@ -49,9 +51,39 @@ def execute_vtk_code( except Exception as render_error: logger.warning("Render error: %s", render_error) - return True, None + return True, None, None - except Exception as e: - error_message = f"Error executing code: {str(e)}" + except (Exception, SystemExit) as e: + # SystemExit is NOT an Exception subclass: generated code that calls + # sys.exit() or argparse.parse_args() (common in VTK example scripts that + # read command-line data files) would otherwise propagate out and kill the + # whole trame app. Trap it here and report it as a normal code error. + if isinstance(e, SystemExit): + error_message = ( + "Error executing code: the generated code runs as a standalone " + "script (it uses argparse/sys.exit and expects command-line " + "arguments), so it cannot run inside the app as written." + ) + else: + error_message = f"Error executing code: {str(e)}" logger.error(error_message) - return False, error_message + # Identify the offending line *within the executed code* and return its + # text (not its number). The executed code differs from what the editor + # shows: an explanation banner is prepended, a markdown fence may be + # stripped, an import may be added, and data literals may be rewritten. + # The caller matches on text to locate the editor line robustly. + line_text = None + segment = locals().get("code_segment") + if isinstance(segment, str): + lineno = None + if isinstance(e, SyntaxError) and e.lineno: + lineno = e.lineno + else: + for frame, frame_line in traceback.walk_tb(e.__traceback__): + if frame.f_code.co_filename == "": + lineno = frame_line + if lineno is not None: + lines = segment.splitlines() + if 1 <= lineno <= len(lines): + line_text = lines[lineno - 1].strip() + return False, error_message, line_text diff --git a/src/vtk_prompt/state/initializer.py b/src/vtk_prompt/state/initializer.py index 63a198c..4f8c5ae 100644 --- a/src/vtk_prompt/state/initializer.py +++ b/src/vtk_prompt/state/initializer.py @@ -27,6 +27,13 @@ def initialize_state(app: Any) -> None: app.state.generated_code = "" app.state.generated_explanation = "" app.state.current_prompt = "" # the sent prompt shown inline with the explanation + app.state.data_artifacts = [] # datasets referenced by the current code + # Sample-data resolver root: defaults to the env var, overridable in Settings. + import os as _os + from ..data.resolver import set_data_root as _set_data_root + + app.state.data_root = _os.environ.get("VTK_PROMPT_DATA_ROOT", "") + _set_data_root(app.state.data_root) # Code currently shown in the 3D view (last successful render); used to # disable Run when the editor already matches what is rendered. app.state.rendered_code = "" @@ -154,7 +161,7 @@ def init_prompt_client(app: Any) -> None: mcp_url = getattr(app.state, "mcp_url", "").strip() or None app.prompt_client = VTKPromptClient( verbose=False, - conversation=app.state.conversation, + conversation=list(app.state.conversation or []), mcp_url=mcp_url, ) except ValueError as e: diff --git a/src/vtk_prompt/ui/layout/content.py b/src/vtk_prompt/ui/layout/content.py index be73b70..58feb13 100644 --- a/src/vtk_prompt/ui/layout/content.py +++ b/src/vtk_prompt/ui/layout/content.py @@ -29,6 +29,41 @@ def build_content(layout: Any, app: Any) -> None: with vuetify.VCardTitle( "Generated Code", classes="d-flex align-center" ): + # Data files this code references, with where they live. + with html.Div( + classes="d-flex align-center ml-3", + v_show="data_artifacts.length > 0", + ): + with vuetify.VTooltip( + v_for="a in data_artifacts", + key="a.name", + location="bottom", + ): + with vuetify.Template(v_slot_activator="{ props }"): + vuetify.VChip( + "{{ a.name }}", + v_bind="props", + size="small", + variant="tonal", + classes="mr-1", + color=("a.cached ? 'success' : 'primary'", "primary"), + prepend_icon=( + "a.cached ? 'mdi-file-check'" + + " : 'mdi-cloud-download-outline'", + "mdi-file-outline", + ), + ) + with html.Div(): + html.Div( + "{{ a.cached ? 'Fetched' :" + + " 'Will download on run' }}", + classes="font-weight-medium", + ) + html.Div("{{ a.path }}", classes="text-caption") + html.Div( + "Source: VTK data store", + classes="text-caption text-medium-emphasis", + ) vuetify.VSpacer() # Undo across code versions (generations, runs, edits) with vuetify.VTooltip(text="Undo code change", location="bottom"): diff --git a/src/vtk_prompt/ui/layout/conversation_history.py b/src/vtk_prompt/ui/layout/conversation_history.py index 0a30be2..41980e9 100644 --- a/src/vtk_prompt/ui/layout/conversation_history.py +++ b/src/vtk_prompt/ui/layout/conversation_history.py @@ -20,6 +20,16 @@ def _header(app: Any) -> None: disabled=("conversation_navigation.length === 0", True), v_bind="props", ) + with vuetify.VTooltip(text="Import conversation", location="bottom"): + with vuetify.Template(v_slot_activator="{ props }"): + vuetify.VBtn( + icon="mdi-tray-arrow-up", + click="import_dialog = true", + variant="text", + density="compact", + color="primary", + v_bind="props", + ) with vuetify.VTooltip(text="Toggle sort order", location="bottom"): with vuetify.Template(v_slot_activator="{ props }"): vuetify.VBtn( @@ -57,6 +67,10 @@ def _row_menu(app: Any) -> None: click="rename_target_id = s.id; rename_text = s.title; rename_dialog = true" ): vuetify.VListItemTitle("Rename") + with vuetify.VListItem( + click="window.trame.utils.vtk_prompt.exportSession(s.id, s.title)" + ): + vuetify.VListItemTitle("Export") with vuetify.VListItem( click=( "delete_target_id = s.id; delete_target_title = s.title;" @@ -88,6 +102,23 @@ def _dialogs(app: Any) -> None: color="primary", variant="text", ) + # Import + with vuetify.VDialog(v_model=("import_dialog", False), max_width="480"): + with vuetify.VCard(): + vuetify.VCardTitle("Import conversation") + with vuetify.VCardText(): + vuetify.VFileUpload( + label="Choose a .json conversation file", + v_model=("uploaded_files", None), + accept=".json", + multiple=True, + hide_details="auto", + density="compact", + color="teal-lighten-5", + ) + with vuetify.VCardActions(): + vuetify.VSpacer() + vuetify.VBtn("Close", click="import_dialog = false", variant="text") # Delete with vuetify.VDialog(v_model=("delete_dialog", False), max_width="420"): with vuetify.VCard(): diff --git a/src/vtk_prompt/ui/layout/settings_dialog.py b/src/vtk_prompt/ui/layout/settings_dialog.py index 71c6742..a51bc21 100644 --- a/src/vtk_prompt/ui/layout/settings_dialog.py +++ b/src/vtk_prompt/ui/layout/settings_dialog.py @@ -7,238 +7,239 @@ from typing import Any +from trame.widgets import html from trame.widgets import vuetify3 as vuetify from ...provider_utils import DEFAULT_MODEL, DEFAULT_PROVIDER vuetify.enable_lab() +_LABEL = "text-overline text-medium-emphasis d-block mb-1" +_DESC = "text-caption text-medium-emphasis d-block mb-3" + + +def _section(title: str) -> None: + html.Div(title, classes=_LABEL) + 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.VDialog(v_model=("advanced_settings_open", False), max_width="560"): with vuetify.VCard(): with vuetify.VTabs( v_model=("active_settings_tab", "files"), color="primary", - classes="pa-1", + classes="px-2", ): - vuetify.VTab("Files", value="files") + vuetify.VTab("Config", value="files") vuetify.VTab("Model", value="model") vuetify.VTab("Advanced", value="advanced") + vuetify.VDivider() 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"): - # vtk-mcp Settings Card - with vuetify.VCard(classes="mt-2"): - vuetify.VCardTitle("🔌 vtk-mcp", classes="pb-0") - with vuetify.VCardText(): - vuetify.VTextField( - label="vtk-mcp Server URL", - v_model=("mcp_url", ""), - placeholder="http://localhost:8000", - clearable=True, - density="compact", - variant="outlined", - prepend_icon="mdi-bookshelf", - hint="Enables context retrieval and VTK API " - "validation; leave blank for baseline generation", - persistent_hint=True, - ) - vuetify.VTextField( - label="Top K", - v_model=("top_k", 5), - type="number", - min=1, - max=15, - density="compact", - disabled=("!mcp_url",), - 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", 3), - type="number", - min=1, - max=5, - density="compact", - variant="outlined", - prepend_icon="mdi-repeat", - ) + _config_tab() + _model_tab() + _advanced_tab() + + +def _config_tab() -> None: + with vuetify.VTabsWindowItem(value="files"): + with vuetify.VCardText(classes="pa-4"): + _section("Configuration") + html.Div( + "Import or export the model/prompt config (.yaml). To import a " + "conversation, use Import in Recents.", + classes=_DESC, + ) + vuetify.VFileInput( + label="Import config file (.yaml, .yml)", + v_model=("uploaded_files", None), + accept=".yaml,.yml", + multiple=True, + prepend_icon="", + prepend_inner_icon="mdi-upload", + density="compact", + variant="outlined", + hide_details=True, + classes="mb-3", + ) + vuetify.VBtn( + "Download config", + variant="outlined", + color="primary", + size="small", + prepend_icon="mdi-download", + click="window.trame.utils.vtk_prompt.exportConfig()", + ) + + vuetify.VDivider(classes="my-5") + _section("Behavior") + vuetify.VCheckbox( + label="Automatically run imported conversations", + v_model=("auto_run_conversation_file", True), + density="compact", + color="primary", + hide_details=True, + ) + + vuetify.VDivider(classes="my-5") + _section("Sample data") + html.Div( + "Local VTK data tree used to resolve example datasets by name " + "(e.g. cow.g).", + classes=_DESC, + ) + vuetify.VTextField( + label="Sample data location", + v_model=("data_root", ""), + placeholder="/path/to/VTK/Testing/Data", + hint="Folder of .sha512 data pointers; blank uses the " + "VTK_PROMPT_DATA_ROOT environment variable", + persistent_hint=True, + density="compact", + variant="outlined", + clearable=True, + ) + + +def _model_tab() -> None: + with vuetify.VTabsWindowItem(value="model"): + with vuetify.VCardText(classes="pa-4"): + with vuetify.VTabs( + v_model=("tab_index", 0), + color="primary", + density="compact", + classes="mb-4", + ): + vuetify.VTab("Cloud", prepend_icon="mdi-cloud-outline") + vuetify.VTab("Local", prepend_icon="mdi-laptop") + with vuetify.VTabsWindow(v_model="tab_index", classes="pt-3"): + with vuetify.VTabsWindowItem(): + vuetify.VSelect( + label="Provider", + v_model=("provider", DEFAULT_PROVIDER), + items=("available_providers", []), + density="compact", + variant="outlined", + classes="mb-3", + ) + vuetify.VSelect( + label="Model", + v_model=("model", DEFAULT_MODEL), + items=("available_models[provider] || []",), + density="compact", + variant="outlined", + classes="mb-3", + ) + vuetify.VTextField( + label="API token", + v_model=("api_token", ""), + placeholder="Enter your API token", + type="password", + density="compact", + variant="outlined", + hint="Required for cloud providers", + persistent_hint=True, + error=("!api_token", False), + ) + with vuetify.VTabsWindowItem(): + vuetify.VTextField( + label="Base URL", + v_model=("local_base_url", "http://localhost:11434/v1"), + placeholder="http://localhost:11434/v1", + density="compact", + variant="outlined", + hint="Ollama, LM Studio, and other OpenAI-compatible servers", + persistent_hint=True, + classes="mb-3", + ) + vuetify.VTextField( + label="Model name", + v_model=("local_model", "devstral"), + placeholder="devstral", + density="compact", + variant="outlined", + hint="Model identifier as served by your endpoint", + persistent_hint=True, + classes="mb-3", + ) + vuetify.VTextField( + label="API token", + v_model=("api_token", "ollama"), + placeholder="ollama", + type="password", + density="compact", + variant="outlined", + hint="Optional for local servers", + persistent_hint=True, + ) + + +def _advanced_tab() -> None: + with vuetify.VTabsWindowItem(value="advanced"): + with vuetify.VCardText(classes="pa-4"): + _section("vtk-mcp") + html.Div( + "Connect a vtk-mcp server to ground generation in the real VTK API " + "and validate code before returning it.", + classes=_DESC, + ) + vuetify.VTextField( + label="Server URL", + v_model=("mcp_url", ""), + placeholder="http://localhost:8000", + clearable=True, + density="compact", + variant="outlined", + hint="Leave blank for baseline generation without tools", + persistent_hint=True, + classes="mb-3", + ) + vuetify.VTextField( + label="Top K", + v_model=("top_k", 5), + type="number", + min=1, + max=15, + density="compact", + variant="outlined", + disabled=("!mcp_url",), + hint="Context snippets retrieved per request", + persistent_hint=True, + ) + + vuetify.VDivider(classes="my-5") + _section("Generation") + with html.Div(classes="d-flex align-center justify-space-between mt-2 mb-1"): + html.Span("Temperature", classes="text-body-2") + html.Span( + "{{ temperature }}", classes="text-body-2 text-medium-emphasis" + ) + vuetify.VSlider( + v_model=("temperature", 0.1), + min=0.0, + max=1.0, + step=0.1, + thumb_label=True, + color="primary", + hide_details=True, + disabled=("!temperature_supported",), + classes="mb-4", + ) + vuetify.VTextField( + label="Max tokens", + v_model=("max_tokens", 1000), + type="number", + density="compact", + variant="outlined", + classes="mb-3", + ) + vuetify.VTextField( + label="Retry attempts", + v_model=("retry_attempts", 3), + type="number", + min=1, + max=5, + density="compact", + variant="outlined", + ) diff --git a/src/vtk_prompt/utils.js b/src/vtk_prompt/utils.js index 5cbfeae..e019583 100644 --- a/src/vtk_prompt/utils.js +++ b/src/vtk_prompt/utils.js @@ -10,4 +10,32 @@ window.trame.utils.vtk_prompt = { return true; }, }, + + download(name, text, mime) { + const blob = new Blob([text], { type: mime || "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = name; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + }, + + sanitize(name) { + return (name || "conversation").replace(/[^\w.-]+/g, "_").slice(0, 80); + }, + + async exportSession(id, title) { + const text = await window.trame.trigger("export_session", [id]); + if (text) { + this.download(this.sanitize(title) + ".json", text, "application/json"); + } + }, + + async exportConfig() { + const text = await window.trame.trigger("save_config"); + if (text) this.download("vtk-prompt-config.yaml", text, "text/yaml"); + }, }; diff --git a/src/vtk_prompt/utils/prompt_loader.py b/src/vtk_prompt/utils/prompt_loader.py index d8a7627..668d89a 100644 --- a/src/vtk_prompt/utils/prompt_loader.py +++ b/src/vtk_prompt/utils/prompt_loader.py @@ -124,6 +124,12 @@ def _process_rag_and_generation_settings(app: Any) -> None: _base = app.custom_prompt_data.get("base_url") if isinstance(_base, str) and _base.strip(): app.state.local_base_url = _base.strip() + if "data_root" in app.custom_prompt_data: + _dr = app.custom_prompt_data.get("data_root") + if isinstance(_dr, str): + app.state.data_root = _dr.strip() + from ..data.resolver import set_data_root + set_data_root(app.state.data_root) def _process_model_parameters(app: Any) -> None: diff --git a/src/vtk_prompt/vtk_prompt_ui.py b/src/vtk_prompt/vtk_prompt_ui.py index 93daddf..341fdec 100644 --- a/src/vtk_prompt/vtk_prompt_ui.py +++ b/src/vtk_prompt/vtk_prompt_ui.py @@ -344,6 +344,11 @@ def save_conversation(self) -> str: """Save current conversation history as JSON string.""" return conversation.save_conversation(self) + @trigger("export_session") + def export_session(self, session_id: str) -> str: + """Return one session's JSON for download (called from utils.js).""" + return sessions.export_session(self, session_id) + @trigger("save_config") def save_config(self) -> str: """Save current configuration as YAML string for download.""" @@ -354,6 +359,14 @@ def _on_provider_change(self, provider, **kwargs) -> None: """Handle provider selection change.""" configuration.on_provider_change(self, provider, **kwargs) + @change("data_root") + def _on_data_root_change(self, data_root, **_: Any) -> None: + """Repoint the sample-data resolver and refresh referenced-file chips.""" + from .data.resolver import artifacts, set_data_root + + set_data_root(data_root or "") + self.state.data_artifacts = artifacts(self.state.generated_code) + @change("history_sort_order") def _on_sessions_sort_change(self, **_: Any) -> None: """Re-render the Recents list when its sort order changes.""" @@ -362,6 +375,11 @@ def _on_sessions_sort_change(self, **_: Any) -> None: @change("generated_code") def _on_generated_code_change(self, **_: Any) -> None: """Debounce-snapshot manual edits so undo/redo can step through them.""" + # Surface the data files the current code references (pure index lookup). + from .data.resolver import artifacts + + self.state.data_artifacts = artifacts(self.state.generated_code) + if self._snapshot_task is not None and not self._snapshot_task.done(): self._snapshot_task.cancel() try: diff --git a/tests/test_executor_guarded_main.py b/tests/test_executor_guarded_main.py index f58628d..517198f 100644 --- a/tests/test_executor_guarded_main.py +++ b/tests/test_executor_guarded_main.py @@ -46,6 +46,6 @@ def test_guarded_main_script_adds_actor_to_renderer(): except Exception: # pragma: no cover - no VTK rendering backend available pytest.skip("VTK render window unavailable in this environment") - ok, err = execute_vtk_code(GUARDED_SPHERE, renderer, render_window) + ok, err, _ = execute_vtk_code(GUARDED_SPHERE, renderer, render_window) assert ok, err assert renderer.GetActors().GetNumberOfItems() == 1