mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-03 02:11:48 +00:00
refactor: extract clipboard methods + comprehensive tests (37 tests)
Refactored image paste internals for testability:
- Extracted _try_attach_clipboard_image() method (clipboard → state)
- Extracted _build_multimodal_content() method (images → OpenAI format)
- chat() now delegates to these instead of inline logic
Tests organized in 4 levels:
Level 1 (19 tests): Clipboard module — every platform path with
realistic subprocess simulation (tools writing files, timeouts,
empty files, cleanup on failure)
Level 2 (8 tests): _build_multimodal_content — base64 encoding,
MIME types (png/jpg/webp/unknown), missing files, multiple images,
default question for empty text
Level 3 (5 tests): _try_attach_clipboard_image — state management,
counter increment/rollback, naming convention, mixed success/failure
Level 4 (5 tests): Queue routing — tuple unpacking, command detection,
images-only payloads, text-only payloads
This commit is contained in:
parent
ffc752a79e
commit
e2a834578d
3 changed files with 636 additions and 162 deletions
88
cli.py
88
cli.py
|
|
@ -1113,6 +1113,52 @@ class HermesCLI:
|
|||
|
||||
self.console.print()
|
||||
|
||||
def _try_attach_clipboard_image(self) -> bool:
|
||||
"""Check clipboard for an image and attach it if found.
|
||||
|
||||
Saves the image to ~/.hermes/images/ and appends the path to
|
||||
``_attached_images``. Returns True if an image was attached.
|
||||
"""
|
||||
from hermes_cli.clipboard import save_clipboard_image
|
||||
|
||||
img_dir = Path.home() / ".hermes" / "images"
|
||||
self._image_counter += 1
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
img_path = img_dir / f"clip_{ts}_{self._image_counter}.png"
|
||||
|
||||
if save_clipboard_image(img_path):
|
||||
self._attached_images.append(img_path)
|
||||
return True
|
||||
self._image_counter -= 1
|
||||
return False
|
||||
|
||||
def _build_multimodal_content(self, text: str, images: list) -> list:
|
||||
"""Convert text + image paths into OpenAI vision multimodal content.
|
||||
|
||||
Returns a list of content parts suitable for the ``content`` field
|
||||
of a ``user`` message.
|
||||
"""
|
||||
import base64 as _b64
|
||||
|
||||
content_parts = []
|
||||
text_part = text if isinstance(text, str) and text else "What do you see in this image?"
|
||||
content_parts.append({"type": "text", "text": text_part})
|
||||
|
||||
_MIME = {
|
||||
"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
|
||||
"gif": "image/gif", "webp": "image/webp",
|
||||
}
|
||||
for img_path in images:
|
||||
if img_path.exists():
|
||||
data = _b64.b64encode(img_path.read_bytes()).decode()
|
||||
ext = img_path.suffix.lower().lstrip(".")
|
||||
mime = _MIME.get(ext, "image/png")
|
||||
content_parts.append({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"data:{mime};base64,{data}"}
|
||||
})
|
||||
return content_parts
|
||||
|
||||
def _show_tool_availability_warnings(self):
|
||||
"""Show warnings about disabled tools due to missing API keys."""
|
||||
try:
|
||||
|
|
@ -2164,25 +2210,12 @@ class HermesCLI:
|
|||
|
||||
# Convert attached images to OpenAI vision multimodal content
|
||||
if images:
|
||||
import base64 as _b64
|
||||
content_parts = []
|
||||
text_part = message if isinstance(message, str) else ""
|
||||
if not text_part:
|
||||
text_part = "What do you see in this image?"
|
||||
content_parts.append({"type": "text", "text": text_part})
|
||||
message = self._build_multimodal_content(
|
||||
message if isinstance(message, str) else "", images
|
||||
)
|
||||
for img_path in images:
|
||||
if img_path.exists():
|
||||
data = _b64.b64encode(img_path.read_bytes()).decode()
|
||||
ext = img_path.suffix.lower().lstrip(".")
|
||||
mime = {"png": "image/png", "jpg": "image/jpeg",
|
||||
"jpeg": "image/jpeg", "gif": "image/gif",
|
||||
"webp": "image/webp"}.get(ext, "image/png")
|
||||
content_parts.append({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"data:{mime};base64,{data}"}
|
||||
})
|
||||
_cprint(f" {_DIM}📎 attached {img_path.name} ({img_path.stat().st_size // 1024}KB){_RST}")
|
||||
message = content_parts
|
||||
|
||||
# Add user message to history
|
||||
self.conversation_history.append({"role": "user", "content": message})
|
||||
|
|
@ -2565,29 +2598,10 @@ class HermesCLI:
|
|||
|
||||
@kb.add(Keys.BracketedPaste, eager=True)
|
||||
def handle_paste(event):
|
||||
"""Handle Cmd+V / Ctrl+V paste — detect clipboard images.
|
||||
|
||||
On every paste event, check the system clipboard for image data.
|
||||
If found, save to ~/.hermes/images/ and attach it to the next
|
||||
message. Any pasted text is inserted into the buffer normally.
|
||||
"""
|
||||
from hermes_cli.clipboard import save_clipboard_image
|
||||
|
||||
"""Handle Cmd+V / Ctrl+V paste — detect clipboard images."""
|
||||
pasted_text = event.data or ""
|
||||
|
||||
# Check clipboard for image
|
||||
img_dir = Path.home() / ".hermes" / "images"
|
||||
self._image_counter += 1
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
img_path = img_dir / f"clip_{ts}_{self._image_counter}.png"
|
||||
|
||||
if save_clipboard_image(img_path):
|
||||
self._attached_images.append(img_path)
|
||||
if self._try_attach_clipboard_image():
|
||||
event.app.invalidate()
|
||||
else:
|
||||
self._image_counter -= 1
|
||||
|
||||
# Insert any pasted text normally
|
||||
if pasted_text:
|
||||
event.current_buffer.insert_text(pasted_text)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue