1st commit
This commit is contained in:
parent
e4174218ed
commit
6f6c66891f
546
ramble.py
546
ramble.py
|
|
@ -1,15 +1,36 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
r"""
|
r"""
|
||||||
ramble.py — Lean "Ramble → Generate" dialog (PlantUML + optional images)
|
Ramble → Generate (PlantUML + optional images, locked-field context, per-field criteria)
|
||||||
|
|
||||||
Changes (per latest request)
|
What’s new:
|
||||||
- Fields are always editable (no Edit button)
|
- Locked fields feed back into the prompt as authoritative context.
|
||||||
- Lock via checkbox; locked fields are highlighted and not overwritten by Generate
|
- Summary is just another field; all fields can have custom criteria.
|
||||||
- Hide UML code; only show rendered PlantUML image
|
- Fields are always editable; “Lock” checkbox prevents overwrite and highlights the field.
|
||||||
- Scrollable main content so long generations fit smaller screens
|
- “How to use” toggle with simple instructions.
|
||||||
- "Generate / Update" moved under the Ramble input
|
- Soft colors; Ramble input visually stands out.
|
||||||
- Bottom-right "Submit" button returns JSON (summary + fields) via the public API and prints in CLI demo
|
- Scrollable main content, Generate under ramble, Submit bottom-right returns JSON.
|
||||||
|
|
||||||
|
CLI examples
|
||||||
|
------------
|
||||||
|
# Mock provider (no external calls)
|
||||||
|
python3 ramble.py --fields Title Summary "Problem it solves" --criteria '{"Summary":"<=2 sentences","Title":"camelCase, no spaces, <=20 chars"}'
|
||||||
|
|
||||||
|
# Claude CLI
|
||||||
|
python3 ramble.py \
|
||||||
|
--provider=claude \
|
||||||
|
--claude-cmd=/home/rob/.npm-global/bin/claude \
|
||||||
|
--fields Title Summary "Problem it solves" "Brief overview" \
|
||||||
|
--criteria '{"Summary":"<= 2 sentences","Title":"camelCase, no spaces, <= 20 characters"}' \
|
||||||
|
--tail 6000 --debug
|
||||||
|
|
||||||
|
Requirements
|
||||||
|
------------
|
||||||
|
- PlantUML CLI in PATH -> sudo apt install plantuml
|
||||||
|
- PySide6 (or PyQt5) -> pip install PySide6
|
||||||
|
- requests (only for images) -> pip install requests
|
||||||
|
- Stability key (optional) -> STABILITY_API_KEY
|
||||||
|
- Pexels key (optional) -> PEXELS_API_KEY
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -20,9 +41,9 @@ import os, sys, json, textwrap, base64, re, time, shutil, subprocess, argparse,
|
||||||
try:
|
try:
|
||||||
import requests # type: ignore
|
import requests # type: ignore
|
||||||
except ImportError:
|
except ImportError:
|
||||||
requests = None # runtime-guarded
|
requests = None
|
||||||
|
|
||||||
# --- Qt Import (PySide6 preferred; fallback to PyQt5) -------------------------
|
# ── Qt (PySide6 preferred; PyQt5 fallback) ────────────────────────────────────
|
||||||
QT_LIB = None
|
QT_LIB = None
|
||||||
try:
|
try:
|
||||||
from PySide6.QtCore import (Qt, QTimer, QPropertyAnimation, QEasingCurve, QObject,
|
from PySide6.QtCore import (Qt, QTimer, QPropertyAnimation, QEasingCurve, QObject,
|
||||||
|
|
@ -31,7 +52,7 @@ try:
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QApplication, QDialog, QGridLayout, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
|
QApplication, QDialog, QGridLayout, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
|
||||||
QPlainTextEdit, QTextEdit, QWidget, QGroupBox, QFormLayout, QSizePolicy,
|
QPlainTextEdit, QTextEdit, QWidget, QGroupBox, QFormLayout, QSizePolicy,
|
||||||
QFrame, QMessageBox, QScrollArea, QCheckBox, QSpacerItem
|
QFrame, QMessageBox, QScrollArea, QCheckBox
|
||||||
)
|
)
|
||||||
QT_LIB = "PySide6"
|
QT_LIB = "PySide6"
|
||||||
except ImportError:
|
except ImportError:
|
||||||
|
|
@ -41,32 +62,54 @@ except ImportError:
|
||||||
from PyQt5.QtWidgets import (
|
from PyQt5.QtWidgets import (
|
||||||
QApplication, QDialog, QGridLayout, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
|
QApplication, QDialog, QGridLayout, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
|
||||||
QPlainTextEdit, QTextEdit, QWidget, QGroupBox, QFormLayout, QSizePolicy,
|
QPlainTextEdit, QTextEdit, QWidget, QGroupBox, QFormLayout, QSizePolicy,
|
||||||
QFrame, QMessageBox, QScrollArea, QCheckBox, QSpacerItem
|
QFrame, QMessageBox, QScrollArea, QCheckBox
|
||||||
)
|
)
|
||||||
QT_LIB = "PyQt5"
|
QT_LIB = "PyQt5"
|
||||||
|
|
||||||
|
|
||||||
# --- Provider Protocol ---------------------------------------------------------
|
# ── Provider Protocol ─────────────────────────────────────────────────────────
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class RambleProvider(Protocol):
|
class RambleProvider(Protocol):
|
||||||
"""Return a dict with: summary, fields, uml_blocks, image_descriptions."""
|
"""
|
||||||
def generate(self, *, prompt: str, ramble_text: str, fields: List[str]) -> Dict[str, Any]: ...
|
generate() must return:
|
||||||
|
- summary: str
|
||||||
|
- fields: Dict[str, str]
|
||||||
|
- uml_blocks: List[Tuple[str, Optional[bytes]]]
|
||||||
|
- image_descriptions: List[str]
|
||||||
|
"""
|
||||||
|
def generate(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
prompt: str,
|
||||||
|
ramble_text: str,
|
||||||
|
fields: List[str],
|
||||||
|
field_criteria: Dict[str, str],
|
||||||
|
locked_context: Dict[str, str],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
# --- Mock Provider (offline/testing) ------------------------------------------
|
# ── Mock Provider ─────────────────────────────────────────────────────────────
|
||||||
class MockProvider:
|
class MockProvider:
|
||||||
def generate(self, *, prompt: str, ramble_text: str, fields: List[str]) -> Dict[str, Any]:
|
def generate(
|
||||||
|
self, *, prompt: str, ramble_text: str, fields: List[str],
|
||||||
|
field_criteria: Dict[str, str], locked_context: Dict[str, str]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
words = ramble_text.strip().split()
|
words = ramble_text.strip().split()
|
||||||
cap = min(25, len(words))
|
cap = min(25, len(words))
|
||||||
summary = " ".join(words[-cap:]) if words else "(no content yet)"
|
summary = " ".join(words[-cap:]) if words else "(no content yet)"
|
||||||
summary = (summary[:1].upper() + summary[1:]).rstrip(".") + "."
|
summary = (summary[:1].upper() + summary[1:]).rstrip(".") + "."
|
||||||
field_map = {f: f"{f}: Derived from ramble ({len(words)} words)." for f in fields}
|
field_map = {}
|
||||||
|
for f in fields:
|
||||||
|
crit = field_criteria.get(f, "").strip()
|
||||||
|
suffix = f" [criteria: {crit}]" if crit else ""
|
||||||
|
field_map[f] = f"{f}: Derived from ramble ({len(words)} words).{suffix}"
|
||||||
uml_blocks = [
|
uml_blocks = [
|
||||||
("@startuml\nactor User\nUser -> System: Ramble\nSystem -> LLM: Generate\nLLM --> System: Summary/Fields/UML\nSystem --> User: Updates\n@enduml", None)
|
("@startuml\nactor User\nUser -> System: Ramble\nSystem -> LLM: Generate\nLLM --> System: Summary/Fields/UML\nSystem --> User: Updates\n@enduml", None)
|
||||||
]
|
]
|
||||||
image_descriptions = [
|
image_descriptions = [
|
||||||
"A conceptual sketch representing the core entities mentioned in the ramble.",
|
"Illustrate the core actor interacting with the system.",
|
||||||
"An abstract icon related to the domain of the idea."
|
"Abstract icon that represents the idea’s domain."
|
||||||
]
|
]
|
||||||
return {
|
return {
|
||||||
"summary": summary,
|
"summary": summary,
|
||||||
|
|
@ -76,7 +119,7 @@ class MockProvider:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# --- Claude CLI Provider -------------------------------------------------------
|
# ── Claude CLI Provider ───────────────────────────────────────────────────────
|
||||||
class ClaudeCLIProvider:
|
class ClaudeCLIProvider:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|
@ -101,25 +144,41 @@ class ClaudeCLIProvider:
|
||||||
with open(self.log_path, "a", encoding="utf-8") as f:
|
with open(self.log_path, "a", encoding="utf-8") as f:
|
||||||
print(f"[{time.strftime('%H:%M:%S')}] {msg}", file=f)
|
print(f"[{time.strftime('%H:%M:%S')}] {msg}", file=f)
|
||||||
|
|
||||||
def _build_prompt(self, *, user_prompt: str, ramble_text: str, fields: List[str]) -> str:
|
def _build_prompt(
|
||||||
fields_list = "\n".join(f'- "{f}"' for f in fields)
|
self, *, user_prompt: str, ramble_text: str,
|
||||||
|
fields: List[str], field_criteria: Dict[str,str], locked_context: Dict[str,str]
|
||||||
|
) -> str:
|
||||||
|
fields_yaml = "\n".join([f'- "{f}"' for f in fields])
|
||||||
|
criteria_yaml = "\n".join([f'- {name}: {field_criteria[name]}' for name in fields if field_criteria.get(name)])
|
||||||
|
locked_yaml = "\n".join([f'- {k}: {locked_context[k]}' for k in locked_context.keys()]) if locked_context else ""
|
||||||
ramble_tail = ramble_text[-self.tail_chars:] if self.tail_chars and len(ramble_text) > self.tail_chars else ramble_text
|
ramble_tail = ramble_text[-self.tail_chars:] if self.tail_chars and len(ramble_text) > self.tail_chars else ramble_text
|
||||||
|
|
||||||
return textwrap.dedent(f"""\
|
return textwrap.dedent(f"""\
|
||||||
You are an assistant that returns ONLY compact JSON. No preamble, no markdown, no code fences.
|
You are an assistant that returns ONLY compact JSON. No preamble, no markdown, no code fences.
|
||||||
|
|
||||||
Task:
|
Goal:
|
||||||
1) Read the user's 'ramble' text.
|
- Read the user's "ramble" and synthesize structured outputs.
|
||||||
2) Produce a JSON object with exactly these keys:
|
|
||||||
"summary": string (≤ 2 sentences)
|
|
||||||
"fields": object with these keys: {fields_list}
|
|
||||||
"uml_blocks": array of objects: {{"uml_text": string, "png_base64": null}}
|
|
||||||
"image_descriptions": array of short strings
|
|
||||||
Constraints:
|
|
||||||
- Output MUST be valid JSON. No markdown fences. No extra keys.
|
|
||||||
|
|
||||||
Context:
|
Required JSON keys:
|
||||||
- Prompt: {user_prompt}
|
- "summary": string
|
||||||
- Ramble (tail, possibly truncated):
|
- "fields": object with these keys: {fields_yaml}
|
||||||
|
- "uml_blocks": array of objects: {{"uml_text": string, "png_base64": null}}
|
||||||
|
- "image_descriptions": array of short strings
|
||||||
|
|
||||||
|
Per-field criteria (enforce tightly; if ambiguous, choose the most useful interpretation):
|
||||||
|
{criteria_yaml if criteria_yaml else "- (no special criteria provided)"}
|
||||||
|
|
||||||
|
Guidance:
|
||||||
|
- The PlantUML diagram must reflect concrete entities/flows drawn from the ramble and any locked fields.
|
||||||
|
- Use 3–7 nodes where possible; prefer meaningful arrow labels.
|
||||||
|
- Image descriptions must be specific to this idea (avoid generic phrasing).
|
||||||
|
|
||||||
|
Authoritative context from previously LOCKED fields (treat as ground truth if present):
|
||||||
|
{locked_yaml if locked_yaml else "- (none locked yet)"}
|
||||||
|
|
||||||
|
Prompt: {user_prompt}
|
||||||
|
|
||||||
|
Ramble (tail, possibly truncated):
|
||||||
{ramble_tail}
|
{ramble_tail}
|
||||||
""").strip() + "\n"
|
""").strip() + "\n"
|
||||||
|
|
||||||
|
|
@ -138,21 +197,15 @@ class ClaudeCLIProvider:
|
||||||
else:
|
else:
|
||||||
argv = [self.cmd, *self.extra_args]
|
argv = [self.cmd, *self.extra_args]
|
||||||
stdin = prompt_text
|
stdin = prompt_text
|
||||||
|
|
||||||
self._log(f"argv: {argv}")
|
self._log(f"argv: {argv}")
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
try:
|
try:
|
||||||
proc = subprocess.run(
|
proc = subprocess.run(argv, input=stdin, capture_output=True, text=True, timeout=timeout, check=False)
|
||||||
argv, input=stdin, capture_output=True, text=True,
|
|
||||||
timeout=timeout, check=False,
|
|
||||||
)
|
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
self._log(f"FileNotFoundError: {e}")
|
self._log(f"FileNotFoundError: {e}")
|
||||||
raise RuntimeError(f"Claude CLI not found at {self.cmd}.") from e
|
raise RuntimeError(f"Claude CLI not found at {self.cmd}.") from e
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
self._log("TimeoutExpired")
|
self._log("TimeoutExpired"); raise
|
||||||
raise
|
|
||||||
|
|
||||||
out = (proc.stdout or "").strip()
|
out = (proc.stdout or "").strip()
|
||||||
err = (proc.stderr or "").strip()
|
err = (proc.stderr or "").strip()
|
||||||
self._log(f"rc={proc.returncode} elapsed={time.time()-t0:.2f}s out={len(out)}B err={len(err)}B")
|
self._log(f"rc={proc.returncode} elapsed={time.time()-t0:.2f}s out={len(out)}B err={len(err)}B")
|
||||||
|
|
@ -183,12 +236,24 @@ class ClaudeCLIProvider:
|
||||||
"image_descriptions": [str(s) for s in image_desc],
|
"image_descriptions": [str(s) for s in image_desc],
|
||||||
}
|
}
|
||||||
|
|
||||||
def generate(self, *, prompt: str, ramble_text: str, fields: List[str]) -> Dict[str, Any]:
|
def generate(
|
||||||
p = self._build_prompt(user_prompt=prompt, ramble_text=ramble_text, fields=fields)
|
self, *, prompt: str, ramble_text: str, fields: List[str],
|
||||||
|
field_criteria: Dict[str, str], locked_context: Dict[str, str]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
p = self._build_prompt(
|
||||||
|
user_prompt=prompt, ramble_text=ramble_text,
|
||||||
|
fields=fields, field_criteria=field_criteria, locked_context=locked_context
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
out = self._run_once(p, timeout=self.timeout_s)
|
out = self._run_once(p, timeout=self.timeout_s)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
shorter = self._build_prompt(user_prompt=prompt, ramble_text=ramble_text[-3000:], fields=fields)
|
# Retry with a smaller tail but still honoring tail_chars if set
|
||||||
|
rt = ramble_text[-min(self.tail_chars or 3000, 3000):]
|
||||||
|
self._log("Retrying with smaller prompt…")
|
||||||
|
shorter = self._build_prompt(
|
||||||
|
user_prompt=prompt, ramble_text=rt,
|
||||||
|
fields=fields, field_criteria=field_criteria, locked_context=locked_context
|
||||||
|
)
|
||||||
out = self._run_once(shorter, timeout=max(45, self.timeout_s // 2))
|
out = self._run_once(shorter, timeout=max(45, self.timeout_s // 2))
|
||||||
|
|
||||||
txt = self._strip_fences(out)
|
txt = self._strip_fences(out)
|
||||||
|
|
@ -206,7 +271,7 @@ class ClaudeCLIProvider:
|
||||||
return self._normalize(data, fields)
|
return self._normalize(data, fields)
|
||||||
|
|
||||||
|
|
||||||
# --- Helpers: Fade label + fading ramble edit ---------------------------------
|
# ── UI helpers ─────────────────────────────────────────────────────────────────
|
||||||
class FadeLabel(QLabel):
|
class FadeLabel(QLabel):
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
|
@ -214,43 +279,37 @@ class FadeLabel(QLabel):
|
||||||
self._anim.setDuration(300)
|
self._anim.setDuration(300)
|
||||||
self._anim.setEasingCurve(QEasingCurve.InOutQuad)
|
self._anim.setEasingCurve(QEasingCurve.InOutQuad)
|
||||||
self.setWindowOpacity(1.0)
|
self.setWindowOpacity(1.0)
|
||||||
|
|
||||||
def fade_to_text(self, text: str):
|
def fade_to_text(self, text: str):
|
||||||
def set_text():
|
def set_text():
|
||||||
self.setText(text)
|
self.setText(text)
|
||||||
try:
|
try: self._anim.finished.disconnect(set_text) # type: ignore
|
||||||
self._anim.finished.disconnect(set_text) # type: ignore
|
except Exception: pass
|
||||||
except Exception:
|
self._anim.setStartValue(0.0); self._anim.setEndValue(1.0); self._anim.start()
|
||||||
pass
|
|
||||||
self._anim.setStartValue(0.0)
|
|
||||||
self._anim.setEndValue(1.0)
|
|
||||||
self._anim.start()
|
|
||||||
self._anim.stop()
|
self._anim.stop()
|
||||||
self._anim.setStartValue(1.0)
|
self._anim.setStartValue(1.0); self._anim.setEndValue(0.0)
|
||||||
self._anim.setEndValue(0.0)
|
|
||||||
self._anim.finished.connect(set_text) # type: ignore
|
self._anim.finished.connect(set_text) # type: ignore
|
||||||
self._anim.start()
|
self._anim.start()
|
||||||
|
|
||||||
|
|
||||||
class FadingRambleEdit(QPlainTextEdit):
|
class FadingRambleEdit(QPlainTextEdit):
|
||||||
def __init__(self, max_blocks: int = 500, parent=None):
|
def __init__(self, max_blocks: int = 500, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setPlaceholderText("Type freely… just keep rambling.")
|
self.setPlaceholderText("Start here. Ramble about your idea…")
|
||||||
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||||
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||||
self.setFrameStyle(QFrame.StyledPanel | QFrame.Raised)
|
self.setFrameStyle(QFrame.StyledPanel | QFrame.Raised)
|
||||||
self.document().setMaximumBlockCount(max_blocks)
|
self.document().setMaximumBlockCount(max_blocks)
|
||||||
font = QFont("DejaVu Sans Mono"); font.setPointSize(10)
|
font = QFont("DejaVu Sans Mono"); font.setPointSize(11)
|
||||||
self.setFont(font)
|
self.setFont(font)
|
||||||
self.setFixedHeight(180)
|
self.setFixedHeight(190)
|
||||||
|
# Soft highlight
|
||||||
def wheelEvent(self, event): # prevent manual scrolling
|
self.setStyleSheet("""
|
||||||
event.ignore()
|
QPlainTextEdit {
|
||||||
|
background:#f4fbff; border:2px solid #9ed6ff; border-radius:8px; padding:8px;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
def wheelEvent(self, event): event.ignore()
|
||||||
def keyPressEvent(self, event):
|
def keyPressEvent(self, event):
|
||||||
super().keyPressEvent(event)
|
super().keyPressEvent(event); self.moveCursor(QTextCursor.End)
|
||||||
self.moveCursor(QTextCursor.End)
|
|
||||||
|
|
||||||
def paintEvent(self, e):
|
def paintEvent(self, e):
|
||||||
super().paintEvent(e)
|
super().paintEvent(e)
|
||||||
painter = QPainter(self.viewport())
|
painter = QPainter(self.viewport())
|
||||||
|
|
@ -262,77 +321,60 @@ class FadingRambleEdit(QPlainTextEdit):
|
||||||
painter.end()
|
painter.end()
|
||||||
|
|
||||||
|
|
||||||
# --- Image backends ------------------------------------------------------------
|
# ── Images (optional backends) ────────────────────────────────────────────────
|
||||||
def have_requests_or_raise():
|
def have_requests_or_raise():
|
||||||
if requests is None:
|
if requests is None:
|
||||||
raise RuntimeError("The 'requests' library is required for image fetching. Install with: pip install requests")
|
raise RuntimeError("The 'requests' library is required for image fetching. pip install requests")
|
||||||
|
|
||||||
def fetch_image_stability(prompt: str) -> Optional[bytes]:
|
def fetch_image_stability(prompt: str) -> Optional[bytes]:
|
||||||
have_requests_or_raise(); assert requests is not None
|
have_requests_or_raise(); assert requests is not None
|
||||||
api_key = os.getenv("STABILITY_API_KEY", "").strip()
|
api_key = os.getenv("STABILITY_API_KEY", "").strip()
|
||||||
if not api_key:
|
if not api_key: raise RuntimeError("STABILITY_API_KEY is not set")
|
||||||
raise RuntimeError("STABILITY_API_KEY is not set")
|
|
||||||
url = "https://api.stability.ai/v2beta/stable-image/generate/core"
|
url = "https://api.stability.ai/v2beta/stable-image/generate/core"
|
||||||
headers = {"Authorization": f"Bearer {api_key}", "Accept": "image/png"}
|
headers = {"Authorization": f"Bearer {api_key}", "Accept": "image/png"}
|
||||||
data = {"prompt": prompt[:1000], "output_format": "png"}
|
data = {"prompt": prompt[:1000], "output_format": "png"}
|
||||||
resp = requests.post(url, headers=headers, data=data, timeout=60)
|
resp = requests.post(url, headers=headers, data=data, timeout=60)
|
||||||
if resp.status_code == 200 and resp.content:
|
if resp.status_code == 200 and resp.content: return resp.content
|
||||||
return resp.content
|
|
||||||
raise RuntimeError(f"Stability API error: {resp.status_code} {resp.text[:200]}")
|
raise RuntimeError(f"Stability API error: {resp.status_code} {resp.text[:200]}")
|
||||||
|
|
||||||
def fetch_image_pexels(query: str) -> Optional[bytes]:
|
def fetch_image_pexels(query: str) -> Optional[bytes]:
|
||||||
have_requests_or_raise(); assert requests is not None
|
have_requests_or_raise(); assert requests is not None
|
||||||
api_key = os.getenv("PEXELS_API_KEY", "").strip()
|
api_key = os.getenv("PEXELS_API_KEY", "").strip()
|
||||||
if not api_key:
|
if not api_key: raise RuntimeError("PEXELS_API_KEY is not set")
|
||||||
raise RuntimeError("PEXELS_API_KEY is not set")
|
|
||||||
s_url = "https://api.pexels.com/v1/search"
|
s_url = "https://api.pexels.com/v1/search"
|
||||||
headers = {"Authorization": api_key}
|
headers = {"Authorization": api_key}
|
||||||
params = {"query": query, "per_page": 1}
|
params = {"query": query, "per_page": 1}
|
||||||
r = requests.get(s_url, headers=headers, params=params, timeout=30)
|
r = requests.get(s_url, headers=headers, params=params, timeout=30); r.raise_for_status()
|
||||||
r.raise_for_status()
|
data = r.json(); photos = data.get("photos") or []
|
||||||
data = r.json()
|
if not photos: return None
|
||||||
photos = data.get("photos") or []
|
|
||||||
if not photos:
|
|
||||||
return None
|
|
||||||
src = photos[0].get("src") or {}
|
src = photos[0].get("src") or {}
|
||||||
raw_url = src.get("original") or src.get("large") or src.get("medium")
|
raw_url = src.get("original") or src.get("large") or src.get("medium")
|
||||||
if not raw_url:
|
if not raw_url: return None
|
||||||
return None
|
img = requests.get(raw_url, timeout=60); img.raise_for_status()
|
||||||
img = requests.get(raw_url, timeout=60)
|
|
||||||
img.raise_for_status()
|
|
||||||
return img.content
|
return img.content
|
||||||
|
|
||||||
|
|
||||||
# --- PlantUML rendering --------------------------------------------------------
|
# ── PlantUML ──────────────────────────────────────────────────────────────────
|
||||||
def ensure_plantuml_present():
|
def ensure_plantuml_present():
|
||||||
exe = shutil.which("plantuml")
|
exe = shutil.which("plantuml")
|
||||||
if not exe:
|
if not exe: raise RuntimeError("plantuml not found in PATH. sudo apt install plantuml")
|
||||||
raise RuntimeError("plantuml not found in PATH. Install it (e.g., sudo apt install plantuml).")
|
|
||||||
return exe
|
return exe
|
||||||
|
|
||||||
def render_plantuml_to_png_bytes(uml_text: str) -> bytes:
|
def render_plantuml_to_png_bytes(uml_text: str) -> bytes:
|
||||||
exe = ensure_plantuml_present()
|
exe = ensure_plantuml_present()
|
||||||
proc = subprocess.run(
|
proc = subprocess.run([exe, "-tpng", "-pipe"], input=uml_text.encode("utf-8"),
|
||||||
[exe, "-tpng", "-pipe"],
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
|
||||||
input=uml_text.encode("utf-8"),
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if proc.returncode != 0 or not proc.stdout:
|
if proc.returncode != 0 or not proc.stdout:
|
||||||
raise RuntimeError(f"plantuml failed: {proc.stderr.decode('utf-8', 'ignore')[:200]}")
|
raise RuntimeError(f"plantuml failed: {proc.stderr.decode('utf-8','ignore')[:200]}")
|
||||||
return proc.stdout
|
return proc.stdout
|
||||||
|
|
||||||
|
|
||||||
# --- Background worker ---------------------------------------------------------
|
# ── Background worker ─────────────────────────────────────────────────────────
|
||||||
class GenWorker(QRunnable):
|
class GenWorker(QRunnable):
|
||||||
class Signals(QObject):
|
class Signals(QObject):
|
||||||
finished = Signal(dict)
|
finished = Signal(dict); error = Signal(str)
|
||||||
error = Signal(str)
|
|
||||||
def __init__(self, provider: RambleProvider, payload: Dict[str, Any]):
|
def __init__(self, provider: RambleProvider, payload: Dict[str, Any]):
|
||||||
super().__init__()
|
super().__init__(); self.provider = provider; self.payload = payload
|
||||||
self.provider = provider
|
|
||||||
self.payload = payload
|
|
||||||
self.signals = GenWorker.Signals()
|
self.signals = GenWorker.Signals()
|
||||||
def run(self):
|
def run(self):
|
||||||
try:
|
try:
|
||||||
|
|
@ -342,13 +384,11 @@ class GenWorker(QRunnable):
|
||||||
self.signals.error.emit(str(e)) # type: ignore
|
self.signals.error.emit(str(e)) # type: ignore
|
||||||
|
|
||||||
|
|
||||||
# --- Main Dialog ---------------------------------------------------------------
|
# ── Dialog ────────────────────────────────────────────────────────────────────
|
||||||
@dataclass
|
@dataclass
|
||||||
class RambleResult:
|
class RambleResult:
|
||||||
summary: str
|
summary: str
|
||||||
fields: Dict[str, str]
|
fields: Dict[str, str]
|
||||||
uml_blocks: List[Tuple[str, Optional[bytes]]]
|
|
||||||
image_descriptions: List[str]
|
|
||||||
|
|
||||||
class RambleDialog(QDialog):
|
class RambleDialog(QDialog):
|
||||||
def __init__(
|
def __init__(
|
||||||
|
|
@ -356,6 +396,7 @@ class RambleDialog(QDialog):
|
||||||
*,
|
*,
|
||||||
prompt: str,
|
prompt: str,
|
||||||
fields: List[str],
|
fields: List[str],
|
||||||
|
field_criteria: Optional[Dict[str,str]] = None,
|
||||||
hints: Optional[List[str]] = None,
|
hints: Optional[List[str]] = None,
|
||||||
provider: Optional[RambleProvider] = None,
|
provider: Optional[RambleProvider] = None,
|
||||||
enable_stability: bool = False,
|
enable_stability: bool = False,
|
||||||
|
|
@ -364,82 +405,79 @@ class RambleDialog(QDialog):
|
||||||
):
|
):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setWindowTitle("Ramble → Generate")
|
self.setWindowTitle("Ramble → Generate")
|
||||||
self.resize(1100, 750) # sensible default, scroll keeps it usable
|
self.resize(1120, 760)
|
||||||
|
|
||||||
self.provider = provider or cast(RambleProvider, MockProvider())
|
self.provider = provider or cast(RambleProvider, MockProvider())
|
||||||
self._prompt_text = prompt
|
self._prompt_text = prompt
|
||||||
self._fields = fields[:]
|
self._fields = fields[:] if fields else ["Summary"]
|
||||||
|
if "Summary" not in self._fields:
|
||||||
|
self._fields.insert(0, "Summary")
|
||||||
|
self._criteria = field_criteria or {}
|
||||||
|
# sensible default for Summary if not provided
|
||||||
|
self._criteria.setdefault("Summary", "<= 2 sentences")
|
||||||
|
|
||||||
self._hints = hints or [
|
self._hints = hints or [
|
||||||
"What is it called?",
|
"What is it called?",
|
||||||
"What is it for?",
|
|
||||||
"What type of idea is it?",
|
|
||||||
"What problem does it solve?",
|
|
||||||
"Who benefits most?",
|
"Who benefits most?",
|
||||||
"What are success criteria?",
|
"What problem does it solve?",
|
||||||
|
"What would success look like?",
|
||||||
]
|
]
|
||||||
self.enable_stability = enable_stability
|
self.enable_stability = enable_stability
|
||||||
self.enable_pexels = enable_pexels and not enable_stability # stability takes precedence
|
self.enable_pexels = enable_pexels and not enable_stability
|
||||||
|
|
||||||
self.thread_pool = QThreadPool.globalInstance()
|
self.thread_pool = QThreadPool.globalInstance()
|
||||||
self.result: Optional[RambleResult] = None
|
self.result: Optional[RambleResult] = None
|
||||||
|
|
||||||
# Track per-field lock states
|
# Track per-field lock states
|
||||||
self.field_locked: Dict[str, bool] = {f: False for f in self._fields}
|
self.field_lock_boxes: Dict[str, QCheckBox] = {}
|
||||||
|
self.field_outputs: Dict[str, QTextEdit] = {}
|
||||||
|
|
||||||
# --- Outer layout: scrollable content + fixed footer row
|
# Layout: scrollable content + fixed footer
|
||||||
outer = QVBoxLayout(self)
|
outer = QVBoxLayout(self)
|
||||||
|
|
||||||
# Scroll area wraps the main content
|
self.scroll = QScrollArea(); self.scroll.setWidgetResizable(True)
|
||||||
self.scroll = QScrollArea()
|
content = QWidget(); self.scroll.setWidget(content)
|
||||||
self.scroll.setWidgetResizable(True)
|
|
||||||
content = QWidget()
|
|
||||||
self.scroll.setWidget(content)
|
|
||||||
outer.addWidget(self.scroll, 1)
|
outer.addWidget(self.scroll, 1)
|
||||||
|
|
||||||
# Footer with Submit button (fixed)
|
|
||||||
footer = QHBoxLayout()
|
footer = QHBoxLayout()
|
||||||
|
# “How to use” toggle
|
||||||
|
self.help_btn = QPushButton("How to use ▾"); self.help_btn.setCheckable(True); self.help_btn.setChecked(False)
|
||||||
|
self.help_btn.clicked.connect(self._toggle_help) # type: ignore
|
||||||
|
footer.addWidget(self.help_btn)
|
||||||
footer.addStretch(1)
|
footer.addStretch(1)
|
||||||
self.submit_btn = QPushButton("Submit")
|
self.submit_btn = QPushButton("Submit"); self.submit_btn.clicked.connect(self.on_submit) # type: ignore
|
||||||
self.submit_btn.clicked.connect(self.on_submit) # type: ignore
|
|
||||||
footer.addWidget(self.submit_btn)
|
footer.addWidget(self.submit_btn)
|
||||||
outer.addLayout(footer)
|
outer.addLayout(footer)
|
||||||
|
|
||||||
# === Inside the scrollable content ===
|
|
||||||
grid = QGridLayout(content)
|
grid = QGridLayout(content)
|
||||||
grid.setHorizontalSpacing(16)
|
grid.setHorizontalSpacing(16); grid.setVerticalSpacing(12)
|
||||||
grid.setVerticalSpacing(12)
|
|
||||||
|
|
||||||
# Top prompt + hint
|
# Title & hint
|
||||||
prompt_label = QLabel(f"<b>{self._prompt_text}</b>"); prompt_label.setWordWrap(True)
|
title = QLabel("<b>Ramble about your idea. Fields will fill themselves.</b>")
|
||||||
hint_label = FadeLabel(); hint_label.setText(self._hints[0])
|
title.setWordWrap(True)
|
||||||
hint_label.setStyleSheet("color:#666; font-style:italic;")
|
grid.addWidget(title, 0, 0, 1, 2)
|
||||||
grid.addWidget(prompt_label, 0, 0, 1, 2)
|
self.hint_label = FadeLabel(); self.hint_label.setText(self._hints[0])
|
||||||
grid.addWidget(hint_label, 1, 0, 1, 2)
|
self.hint_label.setStyleSheet("color:#666; font-style:italic;")
|
||||||
self.hint_label = hint_label
|
grid.addWidget(self.hint_label, 1, 0, 1, 2)
|
||||||
|
|
||||||
# Left column -----------------------------------------------------------
|
# Left column: Ramble → Generate → UML → Images
|
||||||
left_col = QVBoxLayout()
|
left_col = QVBoxLayout()
|
||||||
|
|
||||||
# Ramble input
|
self.ramble_edit = FadingRambleEdit(max_blocks=900)
|
||||||
self.ramble_edit = FadingRambleEdit(max_blocks=600)
|
|
||||||
left_col.addWidget(self.ramble_edit)
|
left_col.addWidget(self.ramble_edit)
|
||||||
|
|
||||||
# Generate button right under input
|
|
||||||
gen_row = QHBoxLayout()
|
gen_row = QHBoxLayout()
|
||||||
self.generate_btn = QPushButton("Generate / Update")
|
self.generate_btn = QPushButton("Generate / Update")
|
||||||
|
self.generate_btn.setStyleSheet("QPushButton { background:#e6f4ff; border:1px solid #9ed6ff; padding:6px 12px; }")
|
||||||
self.generate_btn.clicked.connect(self.on_generate) # type: ignore
|
self.generate_btn.clicked.connect(self.on_generate) # type: ignore
|
||||||
self.provider_label = QLabel(f"Provider: {type(self.provider).__name__}")
|
self.provider_label = QLabel(f"Provider: {type(self.provider).__name__}")
|
||||||
self.provider_label.setStyleSheet("color:#555;")
|
self.provider_label.setStyleSheet("color:#555;")
|
||||||
gen_row.addWidget(self.generate_btn)
|
gen_row.addWidget(self.generate_btn); gen_row.addStretch(1); gen_row.addWidget(self.provider_label)
|
||||||
gen_row.addStretch(1)
|
|
||||||
gen_row.addWidget(self.provider_label)
|
|
||||||
left_col.addLayout(gen_row)
|
left_col.addLayout(gen_row)
|
||||||
|
|
||||||
# UML group (image only)
|
# UML (image only)
|
||||||
uml_group = QGroupBox("Generated UML (PlantUML)")
|
uml_group = QGroupBox("Diagram (PlantUML)"); uml_v = QVBoxLayout(uml_group)
|
||||||
uml_v = QVBoxLayout(uml_group)
|
self.uml_image_label = QLabel(); self.uml_image_label.setAlignment(Qt.AlignCenter)
|
||||||
self.uml_image_label = QLabel()
|
|
||||||
self.uml_image_label.setAlignment(Qt.AlignCenter)
|
|
||||||
if QT_LIB == "PyQt5":
|
if QT_LIB == "PyQt5":
|
||||||
self.uml_image_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
self.uml_image_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||||
else:
|
else:
|
||||||
|
|
@ -448,70 +486,63 @@ class RambleDialog(QDialog):
|
||||||
left_col.addWidget(uml_group)
|
left_col.addWidget(uml_group)
|
||||||
|
|
||||||
# Images / descriptions
|
# Images / descriptions
|
||||||
img_group = QGroupBox("Generated Images / Descriptions")
|
img_group = QGroupBox("Images / Descriptions"); img_v = QVBoxLayout(img_group)
|
||||||
img_v = QVBoxLayout(img_group)
|
self.img_desc_text = QTextEdit(); self.img_desc_text.setReadOnly(True); self.img_desc_text.setMinimumHeight(80)
|
||||||
self.img_desc_text = QTextEdit(); self.img_desc_text.setReadOnly(True); self.img_desc_text.setMinimumHeight(90)
|
self.img_desc_text.setPlaceholderText("Descriptions of suggested images…")
|
||||||
self.img_desc_text.setPlaceholderText("Descriptions of suggested/related images…")
|
|
||||||
img_v.addWidget(self.img_desc_text)
|
img_v.addWidget(self.img_desc_text)
|
||||||
|
|
||||||
self.img_scroll_inner = QScrollArea()
|
|
||||||
self.img_scroll_inner.setWidgetResizable(True)
|
|
||||||
self.img_panel = QWidget()
|
|
||||||
self.img_panel_layout = QVBoxLayout(self.img_panel)
|
|
||||||
self.img_scroll_inner.setWidget(self.img_panel)
|
|
||||||
img_v.addWidget(self.img_scroll_inner)
|
|
||||||
left_col.addWidget(img_group)
|
left_col.addWidget(img_group)
|
||||||
|
|
||||||
# Right column ----------------------------------------------------------
|
# Right column: Fields
|
||||||
right_col = QVBoxLayout()
|
right_col = QVBoxLayout()
|
||||||
|
|
||||||
# Summary (editable)
|
fields_group = QGroupBox("Fields")
|
||||||
summary_group = QGroupBox("Generated Summary")
|
fields_group.setStyleSheet("QGroupBox { background:#fafafa; border:1px solid #ddd; border-radius:8px; margin-top:6px; }")
|
||||||
sg_v = QVBoxLayout(summary_group)
|
|
||||||
self.summary_edit = QTextEdit()
|
|
||||||
self.summary_edit.setReadOnly(False)
|
|
||||||
self.summary_edit.setMinimumHeight(110)
|
|
||||||
self.summary_edit.setPlaceholderText("Summary will appear here…")
|
|
||||||
sg_v.addWidget(self.summary_edit)
|
|
||||||
right_col.addWidget(summary_group)
|
|
||||||
|
|
||||||
# Fields (editable) with Lock checkbox
|
|
||||||
fields_group = QGroupBox("Generated Fields")
|
|
||||||
fg_form = QFormLayout(fields_group)
|
fg_form = QFormLayout(fields_group)
|
||||||
self.field_outputs: Dict[str, QTextEdit] = {}
|
|
||||||
self.field_lock_boxes: Dict[str, QCheckBox] = {}
|
|
||||||
|
|
||||||
def apply_lock_style(widget: QTextEdit, locked: bool):
|
def apply_lock_style(widget: QTextEdit, locked: bool):
|
||||||
if locked:
|
widget.setStyleSheet("background-color: #fff7cc;" if locked else "")
|
||||||
widget.setStyleSheet("background-color: #fff7cc;") # light yellow
|
|
||||||
else:
|
|
||||||
widget.setStyleSheet("")
|
|
||||||
|
|
||||||
for f in self._fields:
|
for f in self._fields:
|
||||||
row = QHBoxLayout()
|
row = QHBoxLayout()
|
||||||
te = QTextEdit()
|
te = QTextEdit(); te.setReadOnly(False); te.setMinimumHeight(64)
|
||||||
te.setReadOnly(False) # always editable
|
|
||||||
te.setMinimumHeight(70)
|
|
||||||
te.setPlaceholderText(f"{f}…")
|
te.setPlaceholderText(f"{f}…")
|
||||||
self.field_outputs[f] = te
|
self.field_outputs[f] = te
|
||||||
|
lock = QCheckBox("Lock"); lock.setChecked(False)
|
||||||
lock = QCheckBox("Lock")
|
|
||||||
lock.setChecked(False)
|
|
||||||
self.field_lock_boxes[f] = lock
|
self.field_lock_boxes[f] = lock
|
||||||
lock.stateChanged.connect(lambda _=0, name=f: apply_lock_style(self.field_outputs[name], self.field_lock_boxes[name].isChecked())) # type: ignore
|
lock.stateChanged.connect(lambda _=0, name=f: apply_lock_style(self.field_outputs[name], self.field_lock_boxes[name].isChecked())) # type: ignore
|
||||||
|
|
||||||
row.addWidget(te, 1)
|
# show criteria hint (subtle)
|
||||||
row.addWidget(lock)
|
crit = self._criteria.get(f, "")
|
||||||
fg_form.addRow(f"{f}:", row)
|
if crit:
|
||||||
|
hint = QLabel(f"<span style='color:#777; font-size:11px'>({crit})</span>")
|
||||||
|
else:
|
||||||
|
hint = QLabel("")
|
||||||
|
row.addWidget(te, 1); row.addWidget(lock)
|
||||||
|
fg_form.addRow(QLabel(f"{f}:"), row)
|
||||||
|
fg_form.addRow(hint)
|
||||||
apply_lock_style(te, False)
|
apply_lock_style(te, False)
|
||||||
|
|
||||||
right_col.addWidget(fields_group, 1)
|
right_col.addWidget(fields_group, 1)
|
||||||
|
|
||||||
# Status label at the very bottom of content (inside scroll)
|
# Inline help panel (hidden by default)
|
||||||
|
self.help_panel = QLabel(
|
||||||
|
"How to use:\n"
|
||||||
|
"• Start in the blue box above and just ramble about your idea.\n"
|
||||||
|
"• Click “Generate / Update” to fill fields automatically.\n"
|
||||||
|
"• Not happy? Add more detail to your ramble or edit fields directly.\n"
|
||||||
|
"• Love a field? Tick “Lock” so it won’t change next time. Locked fields also help guide the next generation.\n"
|
||||||
|
"• When satisfied, click Submit to output your structured JSON."
|
||||||
|
)
|
||||||
|
self.help_panel.setWordWrap(True)
|
||||||
|
self.help_panel.setVisible(False)
|
||||||
|
self.help_panel.setStyleSheet("background:#f8fbff; border:1px dashed #bcdcff; padding:8px; border-radius:6px;")
|
||||||
|
right_col.addWidget(self.help_panel)
|
||||||
|
|
||||||
|
# Status
|
||||||
self.status_label = QLabel(""); self.status_label.setStyleSheet("color:#777;")
|
self.status_label = QLabel(""); self.status_label.setStyleSheet("color:#777;")
|
||||||
right_col.addWidget(self.status_label)
|
right_col.addWidget(self.status_label)
|
||||||
|
|
||||||
# Place columns into the grid
|
# Place columns
|
||||||
left_w = QWidget(); left_w.setLayout(left_col)
|
left_w = QWidget(); left_w.setLayout(left_col)
|
||||||
right_w = QWidget(); right_w.setLayout(right_col)
|
right_w = QWidget(); right_w.setLayout(right_col)
|
||||||
grid.addWidget(left_w, 2, 0)
|
grid.addWidget(left_w, 2, 0)
|
||||||
|
|
@ -519,10 +550,13 @@ class RambleDialog(QDialog):
|
||||||
|
|
||||||
self._setup_hint_timer()
|
self._setup_hint_timer()
|
||||||
|
|
||||||
|
def _toggle_help(self):
|
||||||
|
self.help_panel.setVisible(self.help_btn.isChecked())
|
||||||
|
self.help_btn.setText("How to use ▴" if self.help_btn.isChecked() else "How to use ▾")
|
||||||
|
|
||||||
def _setup_hint_timer(self):
|
def _setup_hint_timer(self):
|
||||||
self._hint_idx = 0
|
self._hint_idx = 0
|
||||||
self._hint_timer = QTimer(self)
|
self._hint_timer = QTimer(self); self._hint_timer.setInterval(3500)
|
||||||
self._hint_timer.setInterval(3500)
|
|
||||||
self._hint_timer.timeout.connect(self._advance_hint) # type: ignore
|
self._hint_timer.timeout.connect(self._advance_hint) # type: ignore
|
||||||
self._hint_timer.start()
|
self._hint_timer.start()
|
||||||
|
|
||||||
|
|
@ -531,7 +565,6 @@ class RambleDialog(QDialog):
|
||||||
self._hint_idx = (self._hint_idx + 1) % len(self._hints)
|
self._hint_idx = (self._hint_idx + 1) % len(self._hints)
|
||||||
self.hint_label.fade_to_text(self._hints[self._hint_idx])
|
self.hint_label.fade_to_text(self._hints[self._hint_idx])
|
||||||
|
|
||||||
# Actions -------------------------------------------------------------------
|
|
||||||
@Slot()
|
@Slot()
|
||||||
def on_generate(self):
|
def on_generate(self):
|
||||||
ramble_text = self.ramble_edit.toPlainText()
|
ramble_text = self.ramble_edit.toPlainText()
|
||||||
|
|
@ -539,25 +572,41 @@ class RambleDialog(QDialog):
|
||||||
QMessageBox.information(self, "Nothing to generate", "Type your thoughts first, then click Generate / Update.")
|
QMessageBox.information(self, "Nothing to generate", "Type your thoughts first, then click Generate / Update.")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Collect locked fields to feed back as authoritative context
|
||||||
|
locked_context: Dict[str, str] = {}
|
||||||
|
for name, box in self.field_lock_boxes.items():
|
||||||
|
if box.isChecked():
|
||||||
|
locked_context[name] = self.field_outputs[name].toPlainText().strip()
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"prompt": self._prompt_text,
|
||||||
|
"ramble_text": ramble_text,
|
||||||
|
"fields": self._fields,
|
||||||
|
"field_criteria": self._criteria,
|
||||||
|
"locked_context": locked_context,
|
||||||
|
}
|
||||||
|
|
||||||
self._set_busy(True, "Generating…")
|
self._set_busy(True, "Generating…")
|
||||||
payload = {"prompt": self._prompt_text, "ramble_text": ramble_text, "fields": self._fields}
|
|
||||||
worker = GenWorker(self.provider, payload)
|
worker = GenWorker(self.provider, payload)
|
||||||
worker.signals.finished.connect(self._on_generated) # type: ignore
|
worker.signals.finished.connect(self._on_generated) # type: ignore
|
||||||
worker.signals.error.connect(self._on_gen_error) # type: ignore
|
worker.signals.error.connect(self._on_gen_error) # type: ignore
|
||||||
self.thread_pool.start(worker)
|
self.thread_pool.start(worker)
|
||||||
|
|
||||||
def _on_generated(self, data: Dict[str, Any]):
|
def _on_generated(self, data: Dict[str, Any]):
|
||||||
# Summary: do not auto-overwrite if locked? (No lock UI for summary by request; always overwrite but is editable)
|
# Respect locks
|
||||||
self.summary_edit.setPlainText(data.get("summary", ""))
|
|
||||||
|
|
||||||
# Fields: respect locks
|
|
||||||
new_fields: Dict[str, str] = data.get("fields", {})
|
new_fields: Dict[str, str] = data.get("fields", {})
|
||||||
for name, widget in self.field_outputs.items():
|
for name, widget in self.field_outputs.items():
|
||||||
if self.field_lock_boxes.get(name, QCheckBox()).isChecked():
|
if self.field_lock_boxes[name].isChecked():
|
||||||
continue # keep user's text
|
continue
|
||||||
widget.setPlainText(new_fields.get(name, ""))
|
widget.setPlainText(new_fields.get(name, ""))
|
||||||
|
|
||||||
# UML: render PlantUML if provider didn't supply PNG bytes (show image only)
|
# Also update Summary if present and not locked
|
||||||
|
if "Summary" in self.field_outputs and not self.field_lock_boxes["Summary"].isChecked():
|
||||||
|
# If provider also returns top-level "summary", prefer that
|
||||||
|
s = data.get("summary", "") or new_fields.get("Summary", "")
|
||||||
|
if s: self.field_outputs["Summary"].setPlainText(s)
|
||||||
|
|
||||||
|
# UML render (first diagram)
|
||||||
uml_blocks = data.get("uml_blocks", [])
|
uml_blocks = data.get("uml_blocks", [])
|
||||||
self.uml_image_label.clear()
|
self.uml_image_label.clear()
|
||||||
for (uml_text, maybe_png) in uml_blocks:
|
for (uml_text, maybe_png) in uml_blocks:
|
||||||
|
|
@ -572,71 +621,14 @@ class RambleDialog(QDialog):
|
||||||
pix = QPixmap()
|
pix = QPixmap()
|
||||||
if pix.loadFromData(png_bytes):
|
if pix.loadFromData(png_bytes):
|
||||||
self.uml_image_label.setPixmap(pix)
|
self.uml_image_label.setPixmap(pix)
|
||||||
break # show first image only
|
break
|
||||||
|
|
||||||
# Images / descriptions
|
# Images / descriptions
|
||||||
img_desc = [str(s) for s in data.get("image_descriptions", [])]
|
img_desc = [str(s) for s in data.get("image_descriptions", [])]
|
||||||
self.img_desc_text.setPlainText("\n\n".join(f"• {d}" for d in img_desc))
|
self.img_desc_text.setPlainText("\n\n".join(f"• {d}" for d in img_desc))
|
||||||
self._clear_img_panel()
|
|
||||||
|
|
||||||
if img_desc and (self.enable_stability or self.enable_pexels):
|
|
||||||
threading.Thread(target=self._fetch_and_show_images, args=(img_desc,), daemon=True).start()
|
|
||||||
|
|
||||||
# Store result (fields reflect current UI, including locks/edits)
|
|
||||||
self.result = RambleResult(
|
|
||||||
summary=self.summary_edit.toPlainText(),
|
|
||||||
fields={k: self.field_outputs[k].toPlainText() for k in self.field_outputs.keys()},
|
|
||||||
uml_blocks=uml_blocks,
|
|
||||||
image_descriptions=img_desc,
|
|
||||||
)
|
|
||||||
self._set_busy(False, "Ready.")
|
self._set_busy(False, "Ready.")
|
||||||
|
|
||||||
def _fetch_and_show_images(self, descriptions: List[str]):
|
|
||||||
images: List[Tuple[str, Optional[bytes], Optional[str]]] = []
|
|
||||||
for d in descriptions:
|
|
||||||
try:
|
|
||||||
if self.enable_stability:
|
|
||||||
img_bytes = fetch_image_stability(d)
|
|
||||||
elif self.enable_pexels:
|
|
||||||
img_bytes = fetch_image_pexels(d)
|
|
||||||
else:
|
|
||||||
img_bytes = None
|
|
||||||
images.append((d, img_bytes, None))
|
|
||||||
except Exception as e:
|
|
||||||
images.append((d, None, str(e)))
|
|
||||||
|
|
||||||
def apply():
|
|
||||||
for (label, img_bytes, err) in images:
|
|
||||||
row = QHBoxLayout()
|
|
||||||
lbl = QLabel(label); lbl.setWordWrap(True)
|
|
||||||
row.addWidget(lbl, 1)
|
|
||||||
if img_bytes:
|
|
||||||
pix = QPixmap()
|
|
||||||
if pix.loadFromData(img_bytes):
|
|
||||||
thumb = QLabel()
|
|
||||||
thumb.setPixmap(pix)
|
|
||||||
thumb.setAlignment(Qt.AlignCenter)
|
|
||||||
if QT_LIB == "PyQt5":
|
|
||||||
thumb.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
|
||||||
else:
|
|
||||||
thumb.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
|
||||||
row.addWidget(thumb, 1)
|
|
||||||
elif err:
|
|
||||||
err_lbl = QLabel(f"(image error: {err})"); err_lbl.setStyleSheet("color:#b33;")
|
|
||||||
row.addWidget(err_lbl)
|
|
||||||
container = QWidget(); container.setLayout(row)
|
|
||||||
self.img_panel_layout.addWidget(container)
|
|
||||||
self.img_panel_layout.addStretch(1)
|
|
||||||
QTimer.singleShot(0, apply)
|
|
||||||
|
|
||||||
def _clear_img_panel(self):
|
|
||||||
lay = self.img_panel_layout
|
|
||||||
while lay.count():
|
|
||||||
item = lay.takeAt(0)
|
|
||||||
w = item.widget()
|
|
||||||
if w:
|
|
||||||
w.setParent(None)
|
|
||||||
|
|
||||||
def _on_gen_error(self, msg: str):
|
def _on_gen_error(self, msg: str):
|
||||||
self._set_busy(False, "Error.")
|
self._set_busy(False, "Error.")
|
||||||
QMessageBox.critical(self, "Generation Error", msg)
|
QMessageBox.critical(self, "Generation Error", msg)
|
||||||
|
|
@ -648,21 +640,19 @@ class RambleDialog(QDialog):
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
def on_submit(self):
|
def on_submit(self):
|
||||||
"""Collect current summary + fields and return them to caller."""
|
out_fields = {k: self.field_outputs[k].toPlainText() for k in self.field_outputs.keys()}
|
||||||
self.result = RambleResult(
|
# prefer explicit Summary field if present
|
||||||
summary=self.summary_edit.toPlainText(),
|
summary = out_fields.get("Summary", "").strip()
|
||||||
fields={k: self.field_outputs[k].toPlainText() for k in self.field_outputs.keys()},
|
self.result = RambleResult(summary=summary, fields=out_fields)
|
||||||
uml_blocks=[], # you can include PNG bytes if you want to pipe them out too
|
|
||||||
image_descriptions=self.img_desc_text.toPlainText().splitlines(),
|
|
||||||
)
|
|
||||||
self.accept()
|
self.accept()
|
||||||
|
|
||||||
|
|
||||||
# --- Public API ---------------------------------------------------------------
|
# ── Public API ────────────────────────────────────────────────────────────────
|
||||||
def open_ramble_dialog(
|
def open_ramble_dialog(
|
||||||
*,
|
*,
|
||||||
prompt: str,
|
prompt: str,
|
||||||
fields: List[str],
|
fields: List[str],
|
||||||
|
field_criteria: Optional[Dict[str, str]] = None,
|
||||||
hints: Optional[List[str]] = None,
|
hints: Optional[List[str]] = None,
|
||||||
provider: Optional[RambleProvider] = None,
|
provider: Optional[RambleProvider] = None,
|
||||||
enable_stability: bool = False,
|
enable_stability: bool = False,
|
||||||
|
|
@ -678,6 +668,7 @@ def open_ramble_dialog(
|
||||||
dlg = RambleDialog(
|
dlg = RambleDialog(
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
fields=fields,
|
fields=fields,
|
||||||
|
field_criteria=field_criteria,
|
||||||
hints=hints,
|
hints=hints,
|
||||||
provider=provider,
|
provider=provider,
|
||||||
enable_stability=enable_stability,
|
enable_stability=enable_stability,
|
||||||
|
|
@ -691,9 +682,6 @@ def open_ramble_dialog(
|
||||||
out = {
|
out = {
|
||||||
"summary": dlg.result.summary,
|
"summary": dlg.result.summary,
|
||||||
"fields": dlg.result.fields,
|
"fields": dlg.result.fields,
|
||||||
# keeping UML/image lists out of the returned JSON by default; add if you need them:
|
|
||||||
# "uml_blocks": dlg.result.uml_blocks,
|
|
||||||
# "image_descriptions": dlg.result.image_descriptions,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if app_created:
|
if app_created:
|
||||||
|
|
@ -701,7 +689,7 @@ def open_ramble_dialog(
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
# --- CLI demo ------------------------------------------------------------------
|
# ── CLI demo ──────────────────────────────────────────────────────────────────
|
||||||
def parse_args():
|
def parse_args():
|
||||||
p = argparse.ArgumentParser(description="Ramble → Generate (PlantUML + optional images)")
|
p = argparse.ArgumentParser(description="Ramble → Generate (PlantUML + optional images)")
|
||||||
p.add_argument("--provider", choices=["mock", "claude"], default="mock")
|
p.add_argument("--provider", choices=["mock", "claude"], default="mock")
|
||||||
|
|
@ -709,7 +697,8 @@ def parse_args():
|
||||||
p.add_argument("--stability", action="store_true", help="Enable Stability AI images (needs STABILITY_API_KEY)")
|
p.add_argument("--stability", action="store_true", help="Enable Stability AI images (needs STABILITY_API_KEY)")
|
||||||
p.add_argument("--pexels", action="store_true", help="Enable Pexels images (needs PEXELS_API_KEY); ignored if --stability set")
|
p.add_argument("--pexels", action="store_true", help="Enable Pexels images (needs PEXELS_API_KEY); ignored if --stability set")
|
||||||
p.add_argument("--prompt", default="Explain your new feature idea")
|
p.add_argument("--prompt", default="Explain your new feature idea")
|
||||||
p.add_argument("--fields", nargs="+", default=["Title", "Intent", "Problem it solves", "Brief overview of logical flow"])
|
p.add_argument("--fields", nargs="+", default=["Summary","Title","Intent","ProblemItSolves","BriefOverview"])
|
||||||
|
p.add_argument("--criteria", default="", help="JSON mapping of field -> criteria")
|
||||||
p.add_argument("--timeout", type=int, default=90)
|
p.add_argument("--timeout", type=int, default=90)
|
||||||
p.add_argument("--tail", type=int, default=6000)
|
p.add_argument("--tail", type=int, default=6000)
|
||||||
p.add_argument("--debug", action="store_true")
|
p.add_argument("--debug", action="store_true")
|
||||||
|
|
@ -718,35 +707,40 @@ def parse_args():
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
args = parse_args()
|
args = parse_args()
|
||||||
|
|
||||||
|
# Field criteria parsing
|
||||||
|
criteria: Dict[str, str] = {}
|
||||||
|
if args.criteria.strip():
|
||||||
|
try:
|
||||||
|
criteria = json.loads(args.criteria)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WARN] Could not parse --criteria JSON: {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
# Provider
|
||||||
if args.provider == "claude":
|
if args.provider == "claude":
|
||||||
provider = cast(RambleProvider, ClaudeCLIProvider(
|
provider = cast(RambleProvider, ClaudeCLIProvider(
|
||||||
cmd=args.claude_cmd,
|
cmd=args.claude_cmd, use_arg_p=True, timeout_s=args.timeout, tail_chars=args.tail, debug=args.debug,
|
||||||
use_arg_p=True,
|
|
||||||
timeout_s=args.timeout,
|
|
||||||
tail_chars=args.tail,
|
|
||||||
debug=args.debug,
|
|
||||||
))
|
))
|
||||||
else:
|
else:
|
||||||
provider = cast(RambleProvider, MockProvider())
|
provider = cast(RambleProvider, MockProvider())
|
||||||
|
|
||||||
# Ensure PlantUML exists early (fail fast)
|
# Ensure PlantUML
|
||||||
try:
|
try:
|
||||||
ensure_plantuml_present()
|
ensure_plantuml_present()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[FATAL] {e}", file=sys.stderr); sys.exit(2)
|
print(f"[FATAL] {e}", file=sys.stderr); sys.exit(2)
|
||||||
|
|
||||||
if (args.stability or args.pexels) and requests is None:
|
if (args.stability or args.pexels) and requests is None:
|
||||||
print("[FATAL] 'requests' is required for image backends. Install with: pip install requests", file=sys.stderr)
|
print("[FATAL] 'requests' is required for image backends. pip install requests", file=sys.stderr)
|
||||||
sys.exit(3)
|
sys.exit(3)
|
||||||
|
|
||||||
demo = open_ramble_dialog(
|
demo = open_ramble_dialog(
|
||||||
prompt=args.prompt,
|
prompt=args.prompt,
|
||||||
fields=args.fields,
|
fields=args.fields,
|
||||||
|
field_criteria=criteria,
|
||||||
hints=None,
|
hints=None,
|
||||||
provider=provider,
|
provider=provider,
|
||||||
enable_stability=args.stability,
|
enable_stability=args.stability,
|
||||||
enable_pexels=args.pexels,
|
enable_pexels=args.pexels,
|
||||||
)
|
)
|
||||||
if demo:
|
if demo:
|
||||||
# Emit JSON to stdout (standard practice for tool-style dialogs)
|
|
||||||
print(json.dumps(demo, ensure_ascii=False, indent=2))
|
print(json.dumps(demo, ensure_ascii=False, indent=2))
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue