mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 03:00:23 +00:00
04ed0ff43d
* fix: restore mobile chat scrolling and drawer close (#397)
- static/style.css: add min-height:0 to .layout and .main (flex shrink chain fix for mobile scroll)
- static/style.css: add -webkit-overflow-scrolling:touch, touch-action:pan-y, overscroll-behavior-y:contain to .messages
- static/boot.js: call closeMobileSidebar() on new-conversation button onclick and Ctrl+K shortcut
- tests/test_mobile_layout.py: 41 new lines covering all three CSS fixes and both JS call sites
Original PR by @Jordan-SkyLF
* fix: preserve imported session timestamps (#395)
- api/models.py: add touch_updated_at: bool = True param to Session.save(); import_cli_session() accepts created_at/updated_at kwargs and saves with touch_updated_at=False
- api/routes.py: extract created_at/updated_at from get_cli_sessions() metadata and forward to import_cli_session(); use touch_updated_at=False on post-import save
- tests/test_gateway_sync.py: +53 lines — integration test verifying imported session keeps original timestamp and sorts correctly vs newer sessions; also fix: add WebUI session file cleanup in finally block
Original PR by @Jordan-SkyLF
* fix(profiles): block path traversal in profile switch and delete flows (#399)
Master was vulnerable: switch_profile and delete_profile_api joined user-supplied profile
names directly into filesystem paths with no validation. An attacker could send
'../../etc/passwd' as a profile name to traverse outside the profiles directory.
- api/profiles.py: add _resolve_named_profile_home(name) — validates name with
^[a-z0-9][a-z0-9_-]{0,63}$ regex then enforces path containment via
candidate.resolve().relative_to(profiles_root); use in switch_profile()
- api/profiles.py: add _validate_profile_name() call to delete_profile_api() entry
- api/routes.py: add _validate_profile_name() call at HTTP handler level for
both /api/profile/switch and /api/profile/delete (fail-fast at API boundary)
- tests/test_profile_path_security.py: 3 tests — traversal rejected, valid name passes
Cherry-picked commit aae7a30 from @Hinotoi-agent (PR was 62 commits behind master)
* feat: add desktop microphone transcription fallback (#396)
Mic button now works in browsers that support getUserMedia/MediaRecorder but
lack SpeechRecognition (e.g. Firefox desktop, some Chromium builds).
- static/boot.js: detect _canRecordAudio (navigator.mediaDevices + getUserMedia + MediaRecorder);
keep mic button enabled when either SpeechRecognition or MediaRecorder is available;
MediaRecorder fallback records audio, sends blob to /api/transcribe, inserts transcript
into the composer; _stopMic() handles all three states (recognition, mediaRecorder, neither)
- api/upload.py: add transcribe_audio() helper — saves uploaded blob to temp file, calls
transcription_tools.transcribe_audio(), always cleans up temp file
- api/routes.py: add /api/transcribe POST handler — CSRF protected, auth-gated, 20MB limit,
returns {text:...} or {error:...}
- api/helpers.py: change Permissions-Policy microphone=() to microphone=(self) (required to
allow getUserMedia in the same origin)
- tests/test_voice_transcribe_endpoint.py: 87 new lines — 3 tests with mocked transcription
- tests/test_sprint19.py: +1 regression guard (microphone=(self) in Permissions-Policy)
- tests/test_sprint20.py: 3 updated tests for new fallback-capability checks
Original PR by @Jordan-SkyLF
* docs: v0.50.25 release — version badge and CHANGELOG
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
88 lines
2.9 KiB
Python
88 lines
2.9 KiB
Python
import io
|
|
import json
|
|
import sys
|
|
import types
|
|
|
|
from api.upload import handle_transcribe
|
|
|
|
|
|
def _multipart_body(fields=None, files=None, boundary=b"voiceboundary"):
|
|
fields = fields or {}
|
|
files = files or {}
|
|
body = b""
|
|
for name, value in fields.items():
|
|
body += b"--" + boundary + b"\r\n"
|
|
body += f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode()
|
|
body += str(value).encode() + b"\r\n"
|
|
for name, (filename, data, content_type) in files.items():
|
|
body += b"--" + boundary + b"\r\n"
|
|
body += (
|
|
f'Content-Disposition: form-data; name="{name}"; filename="{filename}"\r\n'
|
|
f'Content-Type: {content_type}\r\n\r\n'
|
|
).encode()
|
|
body += data + b"\r\n"
|
|
body += b"--" + boundary + b"--\r\n"
|
|
return body, f"multipart/form-data; boundary={boundary.decode()}"
|
|
|
|
|
|
class _FakeHandler:
|
|
def __init__(self, body: bytes, content_type: str):
|
|
self.rfile = io.BytesIO(body)
|
|
self.wfile = io.BytesIO()
|
|
self.headers = {
|
|
"Content-Type": content_type,
|
|
"Content-Length": str(len(body)),
|
|
}
|
|
self.status = None
|
|
self.sent_headers = {}
|
|
|
|
def send_response(self, status):
|
|
self.status = status
|
|
|
|
def send_header(self, key, value):
|
|
self.sent_headers[key] = value
|
|
|
|
def end_headers(self):
|
|
pass
|
|
|
|
def payload(self):
|
|
return json.loads(self.wfile.getvalue().decode("utf-8"))
|
|
|
|
|
|
def test_handle_transcribe_requires_file_field():
|
|
body, content_type = _multipart_body(fields={"note": "missing file"})
|
|
handler = _FakeHandler(body, content_type)
|
|
handle_transcribe(handler)
|
|
assert handler.status == 400
|
|
assert handler.payload()["error"] == "No file field in request"
|
|
|
|
|
|
def test_handle_transcribe_returns_transcript(monkeypatch):
|
|
fake_mod = types.ModuleType("tools.transcription_tools")
|
|
fake_mod.transcribe_audio = lambda path: {"success": True, "transcript": "hello from audio"}
|
|
monkeypatch.setitem(sys.modules, "tools.transcription_tools", fake_mod)
|
|
|
|
body, content_type = _multipart_body(
|
|
files={"file": ("voice.webm", b"RIFFfakeaudio", "audio/webm")}
|
|
)
|
|
handler = _FakeHandler(body, content_type)
|
|
handle_transcribe(handler)
|
|
|
|
assert handler.status == 200
|
|
assert handler.payload() == {"ok": True, "transcript": "hello from audio"}
|
|
|
|
|
|
def test_handle_transcribe_surfaces_provider_error(monkeypatch):
|
|
fake_mod = types.ModuleType("tools.transcription_tools")
|
|
fake_mod.transcribe_audio = lambda path: {"success": False, "error": "STT not configured"}
|
|
monkeypatch.setitem(sys.modules, "tools.transcription_tools", fake_mod)
|
|
|
|
body, content_type = _multipart_body(
|
|
files={"file": ("voice.webm", b"RIFFfakeaudio", "audio/webm")}
|
|
)
|
|
handler = _FakeHandler(body, content_type)
|
|
handle_transcribe(handler)
|
|
|
|
assert handler.status == 503
|
|
assert handler.payload()["error"] == "STT not configured"
|