Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/vtk_prompt/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
2 changes: 2 additions & 0 deletions src/vtk_prompt/controllers/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""

Expand All @@ -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": {
Expand Down
7 changes: 2 additions & 5 deletions src/vtk_prompt/controllers/conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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)


Expand Down
40 changes: 38 additions & 2 deletions src/vtk_prompt/controllers/generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down
17 changes: 15 additions & 2 deletions src/vtk_prompt/controllers/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 [])
Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"
Expand Down
5 changes: 5 additions & 0 deletions src/vtk_prompt/data/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
198 changes: 198 additions & 0 deletions src/vtk_prompt/data/resolver.py
Original file line number Diff line number Diff line change
@@ -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
``<path>.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
15 changes: 15 additions & 0 deletions src/vtk_prompt/prompts/components/tool_use.yml
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions src/vtk_prompt/prompts/prompt_component_assembler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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:
Expand All @@ -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")

Expand Down
Loading
Loading