v0.50.25: mobile scroll, import timestamps, profile security, mic fallback (#404)
* 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>
This commit is contained in:
32
CHANGELOG.md
32
CHANGELOG.md
@@ -1,5 +1,37 @@
|
|||||||
# Hermes Web UI -- Changelog
|
# Hermes Web UI -- Changelog
|
||||||
|
|
||||||
|
## [v0.50.25] Multi-PR batch: mobile scroll, import timestamps, profile security, mic fallback
|
||||||
|
|
||||||
|
### fix: restore mobile chat scrolling and drawer close (#397)
|
||||||
|
- `static/style.css`: `min-height:0` on `.layout` and `.main` (flex shrink chain fix); `-webkit-overflow-scrolling:touch`, `touch-action:pan-y`, `overscroll-behavior-y:contain` on `.messages`
|
||||||
|
- `static/boot.js`: call `closeMobileSidebar()` on new-conversation button and Ctrl+K shortcut so the transcript is visible immediately after starting a chat
|
||||||
|
- `tests/test_mobile_layout.py`: 41 new lines covering CSS fixes and both JS call sites
|
||||||
|
- Original PR by @Jordan-SkyLF
|
||||||
|
|
||||||
|
### fix: preserve imported session timestamps (#395)
|
||||||
|
- `api/models.py`: `Session.save(touch_updated_at=True)` — new flag; `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; post-import save also uses `touch_updated_at=False`
|
||||||
|
- `tests/test_gateway_sync.py`: +53 lines — integration test verifying imported session keeps original timestamp and sorts correctly; also fix session file cleanup in test finally block
|
||||||
|
- Original PR by @Jordan-SkyLF
|
||||||
|
|
||||||
|
### fix(profiles): block path traversal in profile switch and delete flows (#399) [SECURITY]
|
||||||
|
- `api/profiles.py`: new `_resolve_named_profile_home(name)` — validates name via `^[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()` at HTTP handler level for both `/api/profile/switch` and `/api/profile/delete`
|
||||||
|
- `tests/test_profile_path_security.py`: 3 new tests — traversal rejected, valid name passes (cherry-picked from @Hinotoi-agent's PR, which was 62 commits behind master)
|
||||||
|
|
||||||
|
### feat: add desktop microphone transcription fallback (#396)
|
||||||
|
- `static/boot.js`: detect `_canRecordAudio`; keep mic button enabled when MediaRecorder available even without SpeechRecognition; full MediaRecorder recording → `/api/transcribe` fallback path with proper cleanup and error handling
|
||||||
|
- `api/upload.py`: add `transcribe_audio()` helper — temp file, calls transcription_tools, always cleans up
|
||||||
|
- `api/routes.py`: add `/api/transcribe` POST handler — CSRF-protected, auth-gated, 20MB limit
|
||||||
|
- `api/helpers.py`: change `Permissions-Policy` `microphone=()` → `microphone=(self)` (required for getUserMedia)
|
||||||
|
- `tests/test_voice_transcribe_endpoint.py`: 87 new lines (3 tests with mocked transcription)
|
||||||
|
- `tests/test_sprint19.py`: regression guard for microphone Permissions-Policy
|
||||||
|
- `tests/test_sprint20.py`: 3 updated tests for new fallback capability checks
|
||||||
|
- Original PR by @Jordan-SkyLF
|
||||||
|
|
||||||
|
- 1020 tests total (up from 1003)
|
||||||
|
|
||||||
## [v0.50.24] feat: opt-in chat bubble layout (closes #336)
|
## [v0.50.24] feat: opt-in chat bubble layout (closes #336)
|
||||||
|
|
||||||
- `api/config.py`: Add `bubble_layout` bool to `_SETTINGS_DEFAULTS` (default `False`) and `_SETTINGS_BOOL_KEYS` — new setting is opt-in, server-persisted, and coerced to bool on save
|
- `api/config.py`: Add `bubble_layout` bool to `_SETTINGS_DEFAULTS` (default `False`) and `_SETTINGS_BOOL_KEYS` — new setting is opt-in, server-persisted, and coerced to bool on save
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ def _security_headers(handler):
|
|||||||
)
|
)
|
||||||
handler.send_header(
|
handler.send_header(
|
||||||
'Permissions-Policy',
|
'Permissions-Policy',
|
||||||
'camera=(), microphone=(), geolocation=()'
|
'camera=(), microphone=(self), geolocation=()'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,8 @@ class Session:
|
|||||||
def path(self):
|
def path(self):
|
||||||
return SESSION_DIR / f'{self.session_id}.json'
|
return SESSION_DIR / f'{self.session_id}.json'
|
||||||
|
|
||||||
def save(self) -> None:
|
def save(self, touch_updated_at: bool = True) -> None:
|
||||||
|
if touch_updated_at:
|
||||||
self.updated_at = time.time()
|
self.updated_at = time.time()
|
||||||
self.path.write_text(
|
self.path.write_text(
|
||||||
json.dumps(self.__dict__, ensure_ascii=False, indent=2),
|
json.dumps(self.__dict__, ensure_ascii=False, indent=2),
|
||||||
@@ -211,7 +212,15 @@ def save_projects(projects) -> None:
|
|||||||
PROJECTS_FILE.write_text(json.dumps(projects, ensure_ascii=False, indent=2), encoding='utf-8')
|
PROJECTS_FILE.write_text(json.dumps(projects, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||||
|
|
||||||
|
|
||||||
def import_cli_session(session_id: str, title: str, messages, model: str='unknown', profile=None):
|
def import_cli_session(
|
||||||
|
session_id: str,
|
||||||
|
title: str,
|
||||||
|
messages,
|
||||||
|
model: str='unknown',
|
||||||
|
profile=None,
|
||||||
|
created_at=None,
|
||||||
|
updated_at=None,
|
||||||
|
):
|
||||||
"""Create a new WebUI session populated with CLI messages.
|
"""Create a new WebUI session populated with CLI messages.
|
||||||
Returns the Session object.
|
Returns the Session object.
|
||||||
"""
|
"""
|
||||||
@@ -222,8 +231,10 @@ def import_cli_session(session_id: str, title: str, messages, model: str='unknow
|
|||||||
model=model,
|
model=model,
|
||||||
messages=messages,
|
messages=messages,
|
||||||
profile=profile,
|
profile=profile,
|
||||||
|
created_at=created_at,
|
||||||
|
updated_at=updated_at,
|
||||||
)
|
)
|
||||||
s.save()
|
s.save(touch_updated_at=False)
|
||||||
return s
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ def switch_profile(name: str) -> dict:
|
|||||||
if name == 'default':
|
if name == 'default':
|
||||||
home = _DEFAULT_HERMES_HOME
|
home = _DEFAULT_HERMES_HOME
|
||||||
else:
|
else:
|
||||||
home = _DEFAULT_HERMES_HOME / 'profiles' / name
|
home = _resolve_named_profile_home(name)
|
||||||
if not home.is_dir():
|
if not home.is_dir():
|
||||||
raise ValueError(f"Profile '{name}' does not exist.")
|
raise ValueError(f"Profile '{name}' does not exist.")
|
||||||
|
|
||||||
@@ -287,6 +287,24 @@ def _validate_profile_name(name: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _profiles_root() -> Path:
|
||||||
|
"""Return the canonical root that contains named profiles."""
|
||||||
|
return (_DEFAULT_HERMES_HOME / 'profiles').resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_named_profile_home(name: str) -> Path:
|
||||||
|
"""Resolve a named profile to a directory under the profiles root.
|
||||||
|
|
||||||
|
Validates *name* as a logical profile identifier first, then resolves the
|
||||||
|
final filesystem path and enforces containment under ~/.hermes/profiles.
|
||||||
|
"""
|
||||||
|
_validate_profile_name(name)
|
||||||
|
profiles_root = _profiles_root()
|
||||||
|
candidate = (profiles_root / name).resolve()
|
||||||
|
candidate.relative_to(profiles_root)
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
def _create_profile_fallback(name: str, clone_from: str = None,
|
def _create_profile_fallback(name: str, clone_from: str = None,
|
||||||
clone_config: bool = False) -> Path:
|
clone_config: bool = False) -> Path:
|
||||||
"""Create a profile directory without hermes_cli (Docker/standalone fallback)."""
|
"""Create a profile directory without hermes_cli (Docker/standalone fallback)."""
|
||||||
@@ -405,6 +423,7 @@ def delete_profile_api(name: str) -> dict:
|
|||||||
"""Delete a profile. Switches to default first if it's the active one."""
|
"""Delete a profile. Switches to default first if it's the active one."""
|
||||||
if name == 'default':
|
if name == 'default':
|
||||||
raise ValueError("Cannot delete the default profile.")
|
raise ValueError("Cannot delete the default profile.")
|
||||||
|
_validate_profile_name(name)
|
||||||
|
|
||||||
# If deleting the active profile, switch to default first
|
# If deleting the active profile, switch to default first
|
||||||
if _active_profile == name:
|
if _active_profile == name:
|
||||||
@@ -422,7 +441,7 @@ def delete_profile_api(name: str) -> dict:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
# Manual fallback: just remove the directory
|
# Manual fallback: just remove the directory
|
||||||
import shutil
|
import shutil
|
||||||
profile_dir = _DEFAULT_HERMES_HOME / 'profiles' / name
|
profile_dir = _resolve_named_profile_home(name)
|
||||||
if profile_dir.is_dir():
|
if profile_dir.is_dir():
|
||||||
shutil.rmtree(str(profile_dir))
|
shutil.rmtree(str(profile_dir))
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ from api.workspace import (
|
|||||||
read_file_content,
|
read_file_content,
|
||||||
safe_resolve_ws,
|
safe_resolve_ws,
|
||||||
)
|
)
|
||||||
from api.upload import handle_upload
|
from api.upload import handle_upload, handle_transcribe
|
||||||
from api.streaming import _sse, _run_agent_streaming, cancel_stream
|
from api.streaming import _sse, _run_agent_streaming, cancel_stream
|
||||||
from api.onboarding import (
|
from api.onboarding import (
|
||||||
apply_onboarding_setup,
|
apply_onboarding_setup,
|
||||||
@@ -630,6 +630,9 @@ def handle_post(handler, parsed) -> bool:
|
|||||||
if parsed.path == "/api/upload":
|
if parsed.path == "/api/upload":
|
||||||
return handle_upload(handler)
|
return handle_upload(handler)
|
||||||
|
|
||||||
|
if parsed.path == "/api/transcribe":
|
||||||
|
return handle_transcribe(handler)
|
||||||
|
|
||||||
body = read_body(handler)
|
body = read_body(handler)
|
||||||
|
|
||||||
if parsed.path == "/api/session/new":
|
if parsed.path == "/api/session/new":
|
||||||
@@ -845,8 +848,10 @@ def handle_post(handler, parsed) -> bool:
|
|||||||
if not name:
|
if not name:
|
||||||
return bad(handler, "name is required")
|
return bad(handler, "name is required")
|
||||||
try:
|
try:
|
||||||
from api.profiles import switch_profile
|
from api.profiles import switch_profile, _validate_profile_name
|
||||||
|
|
||||||
|
if name != 'default':
|
||||||
|
_validate_profile_name(name)
|
||||||
result = switch_profile(name)
|
result = switch_profile(name)
|
||||||
return j(handler, result)
|
return j(handler, result)
|
||||||
except (ValueError, FileNotFoundError) as e:
|
except (ValueError, FileNotFoundError) as e:
|
||||||
@@ -893,8 +898,9 @@ def handle_post(handler, parsed) -> bool:
|
|||||||
if not name:
|
if not name:
|
||||||
return bad(handler, "name is required")
|
return bad(handler, "name is required")
|
||||||
try:
|
try:
|
||||||
from api.profiles import delete_profile_api
|
from api.profiles import delete_profile_api, _validate_profile_name
|
||||||
|
|
||||||
|
_validate_profile_name(name)
|
||||||
result = delete_profile_api(name)
|
result = delete_profile_api(name)
|
||||||
return j(handler, result)
|
return j(handler, result)
|
||||||
except (ValueError, FileNotFoundError) as e:
|
except (ValueError, FileNotFoundError) as e:
|
||||||
@@ -2209,18 +2215,30 @@ def _handle_session_import_cli(handler, body):
|
|||||||
title = title_from(msgs, "CLI Session")
|
title = title_from(msgs, "CLI Session")
|
||||||
model = "unknown"
|
model = "unknown"
|
||||||
|
|
||||||
# Get profile and model from CLI session metadata
|
# Get profile, model, and timestamps from CLI session metadata
|
||||||
profile = None
|
profile = None
|
||||||
|
created_at = None
|
||||||
|
updated_at = None
|
||||||
for cs in get_cli_sessions():
|
for cs in get_cli_sessions():
|
||||||
if cs["session_id"] == sid:
|
if cs["session_id"] == sid:
|
||||||
profile = cs.get("profile")
|
profile = cs.get("profile")
|
||||||
model = cs.get("model", "unknown")
|
model = cs.get("model", "unknown")
|
||||||
|
created_at = cs.get("created_at")
|
||||||
|
updated_at = cs.get("updated_at")
|
||||||
break
|
break
|
||||||
|
|
||||||
s = import_cli_session(sid, title, msgs, model, profile=profile)
|
s = import_cli_session(
|
||||||
|
sid,
|
||||||
|
title,
|
||||||
|
msgs,
|
||||||
|
model,
|
||||||
|
profile=profile,
|
||||||
|
created_at=created_at,
|
||||||
|
updated_at=updated_at,
|
||||||
|
)
|
||||||
s.is_cli_session = True
|
s.is_cli_session = True
|
||||||
s._cli_origin = sid
|
s._cli_origin = sid
|
||||||
s.save()
|
s.save(touch_updated_at=False)
|
||||||
return j(
|
return j(
|
||||||
handler,
|
handler,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ Hermes Web UI -- File upload: multipart parser and upload handler.
|
|||||||
"""
|
"""
|
||||||
import re as _re
|
import re as _re
|
||||||
import email.parser
|
import email.parser
|
||||||
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from api.config import MAX_UPLOAD_BYTES
|
from api.config import MAX_UPLOAD_BYTES
|
||||||
@@ -50,8 +51,15 @@ def parse_multipart(rfile, content_type, content_length) -> tuple:
|
|||||||
return fields, files
|
return fields, files
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_upload_name(filename: str) -> str:
|
||||||
|
safe_name = _re.sub(r'[^\w.\-]', '_', Path(filename).name)[:200]
|
||||||
|
if not safe_name or safe_name.strip('.') == '':
|
||||||
|
raise ValueError('Invalid filename')
|
||||||
|
return safe_name
|
||||||
|
|
||||||
|
|
||||||
def handle_upload(handler):
|
def handle_upload(handler):
|
||||||
import re as _re, traceback as _tb
|
import traceback as _tb
|
||||||
try:
|
try:
|
||||||
content_type = handler.headers.get('Content-Type', '')
|
content_type = handler.headers.get('Content-Type', '')
|
||||||
content_length = int(handler.headers.get('Content-Length', 0) or 0)
|
content_length = int(handler.headers.get('Content-Length', 0) or 0)
|
||||||
@@ -69,14 +77,55 @@ def handle_upload(handler):
|
|||||||
except KeyError:
|
except KeyError:
|
||||||
return j(handler, {'error': 'Session not found'}, status=404)
|
return j(handler, {'error': 'Session not found'}, status=404)
|
||||||
workspace = Path(s.workspace)
|
workspace = Path(s.workspace)
|
||||||
safe_name = _re.sub(r'[^\w.\-]', '_', Path(filename).name)[:200]
|
safe_name = _sanitize_upload_name(filename)
|
||||||
# Reject names that are purely dots (path traversal: ".." survives regex)
|
|
||||||
if not safe_name or safe_name.strip('.') == '':
|
|
||||||
return j(handler, {'error': 'Invalid filename'}, status=400)
|
|
||||||
# Verify the resolved path stays within the workspace
|
|
||||||
dest = safe_resolve_ws(workspace, safe_name)
|
dest = safe_resolve_ws(workspace, safe_name)
|
||||||
dest.write_bytes(file_bytes)
|
dest.write_bytes(file_bytes)
|
||||||
return j(handler, {'filename': safe_name, 'path': str(dest), 'size': dest.stat().st_size})
|
return j(handler, {'filename': safe_name, 'path': str(dest), 'size': dest.stat().st_size})
|
||||||
except Exception as e:
|
except ValueError as e:
|
||||||
|
return j(handler, {'error': str(e)}, status=400)
|
||||||
|
except Exception:
|
||||||
print('[webui] upload error: ' + _tb.format_exc(), flush=True)
|
print('[webui] upload error: ' + _tb.format_exc(), flush=True)
|
||||||
return j(handler, {'error': 'Upload failed'}, status=500)
|
return j(handler, {'error': 'Upload failed'}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_transcribe(handler):
|
||||||
|
import traceback as _tb
|
||||||
|
temp_path = None
|
||||||
|
try:
|
||||||
|
content_type = handler.headers.get('Content-Type', '')
|
||||||
|
content_length = int(handler.headers.get('Content-Length', 0) or 0)
|
||||||
|
if content_length > MAX_UPLOAD_BYTES:
|
||||||
|
return j(handler, {'error': f'File too large (max {MAX_UPLOAD_BYTES//1024//1024}MB)'}, status=413)
|
||||||
|
fields, files = parse_multipart(handler.rfile, content_type, content_length)
|
||||||
|
if 'file' not in files:
|
||||||
|
return j(handler, {'error': 'No file field in request'}, status=400)
|
||||||
|
filename, file_bytes = files['file']
|
||||||
|
if not filename:
|
||||||
|
return j(handler, {'error': 'No filename in upload'}, status=400)
|
||||||
|
safe_name = _sanitize_upload_name(filename)
|
||||||
|
suffix = Path(safe_name).suffix or '.webm'
|
||||||
|
with tempfile.NamedTemporaryFile(prefix='webui-stt-', suffix=suffix, delete=False) as tmp:
|
||||||
|
temp_path = tmp.name
|
||||||
|
tmp.write(file_bytes)
|
||||||
|
try:
|
||||||
|
from tools.transcription_tools import transcribe_audio
|
||||||
|
except ImportError:
|
||||||
|
return j(handler, {'error': 'Speech-to-text is unavailable on this server'}, status=503)
|
||||||
|
result = transcribe_audio(temp_path)
|
||||||
|
if not result.get('success'):
|
||||||
|
msg = str(result.get('error') or 'Transcription failed')
|
||||||
|
status = 503 if 'unavailable' in msg.lower() or 'not configured' in msg.lower() else 400
|
||||||
|
return j(handler, {'error': msg}, status=status)
|
||||||
|
transcript = str(result.get('transcript') or '').strip()
|
||||||
|
return j(handler, {'ok': True, 'transcript': transcript})
|
||||||
|
except ValueError as e:
|
||||||
|
return j(handler, {'error': str(e)}, status=400)
|
||||||
|
except Exception:
|
||||||
|
print('[webui] transcribe error: ' + _tb.format_exc(), flush=True)
|
||||||
|
return j(handler, {'error': 'Transcription failed'}, status=500)
|
||||||
|
finally:
|
||||||
|
if temp_path:
|
||||||
|
try:
|
||||||
|
Path(temp_path).unlink(missing_ok=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|||||||
149
static/boot.js
149
static/boot.js
@@ -172,24 +172,32 @@ function mobileSwitchPanel(name){
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
$('btnSend').onclick=()=>{if(window._micActive)_stopMic();send();};
|
$('btnSend').onclick=()=>{
|
||||||
|
if(window._micActive){
|
||||||
|
window._micPendingSend=true;
|
||||||
|
_stopMic();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
send();
|
||||||
|
};
|
||||||
$('btnAttach').onclick=()=>$('fileInput').click();
|
$('btnAttach').onclick=()=>$('fileInput').click();
|
||||||
|
|
||||||
// ── Voice input (Web Speech API) ─────────────────────────────────────────
|
// ── Voice input (Web Speech API + MediaRecorder fallback) ───────────────────
|
||||||
(function(){
|
(function(){
|
||||||
const SpeechRecognition=window.SpeechRecognition||window.webkitSpeechRecognition;
|
const SpeechRecognition=window.SpeechRecognition||window.webkitSpeechRecognition;
|
||||||
if(!SpeechRecognition) return; // Browser unsupported — mic button stays hidden
|
const _canRecordAudio=!!(navigator.mediaDevices&&navigator.mediaDevices.getUserMedia&&window.MediaRecorder);
|
||||||
|
if(!SpeechRecognition&&!_canRecordAudio) return; // Browser unsupported — mic button stays hidden
|
||||||
|
|
||||||
const btn=$('btnMic');
|
const btn=$('btnMic');
|
||||||
const status=$('micStatus');
|
const status=$('micStatus');
|
||||||
const ta=$('msg');
|
const ta=$('msg');
|
||||||
btn.style.display=''; // Show button — browser supports speech
|
const statusText=status?status.querySelector('.status-text'):null;
|
||||||
|
btn.style.display=''; // Show button — browser supports speech recognition or recording fallback
|
||||||
const recognition=new SpeechRecognition();
|
|
||||||
recognition.continuous=false;
|
|
||||||
recognition.interimResults=true;
|
|
||||||
recognition.lang=(typeof _locale!=='undefined'&&_locale._speech)||'en-US';
|
|
||||||
|
|
||||||
|
let recognition=SpeechRecognition?new SpeechRecognition():null;
|
||||||
|
let mediaRecorder=null;
|
||||||
|
let mediaStream=null;
|
||||||
|
let audioChunks=[];
|
||||||
let _finalText='';
|
let _finalText='';
|
||||||
let _prefix='';
|
let _prefix='';
|
||||||
|
|
||||||
@@ -197,9 +205,70 @@ $('btnAttach').onclick=()=>$('fileInput').click();
|
|||||||
window._micActive=on;
|
window._micActive=on;
|
||||||
btn.classList.toggle('recording',on);
|
btn.classList.toggle('recording',on);
|
||||||
status.style.display=on?'':'none';
|
status.style.display=on?'':'none';
|
||||||
|
if(statusText) statusText.textContent=on?'Listening':'Listening';
|
||||||
if(!on){ _finalText=''; _prefix=''; }
|
if(!on){ _finalText=''; _prefix=''; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _commitTranscript(text){
|
||||||
|
const clean=(text||'').trim();
|
||||||
|
const committed=clean
|
||||||
|
? (_prefix&&!_prefix.endsWith(' ')&&!_prefix.endsWith('\n')
|
||||||
|
? _prefix+' '+clean.trimStart()
|
||||||
|
: _prefix+clean)
|
||||||
|
: ta.value;
|
||||||
|
ta.value=committed;
|
||||||
|
autoResize();
|
||||||
|
if(window._micPendingSend){
|
||||||
|
window._micPendingSend=false;
|
||||||
|
send();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _transcribeBlob(blob){
|
||||||
|
const ext=(blob.type&&blob.type.includes('ogg'))?'ogg':'webm';
|
||||||
|
const form=new FormData();
|
||||||
|
form.append('file',new File([blob],`voice-input.${ext}`,{type:blob.type||`audio/${ext}`}));
|
||||||
|
setComposerStatus('Transcribing…');
|
||||||
|
try{
|
||||||
|
const res=await fetch('/api/transcribe',{method:'POST',body:form});
|
||||||
|
const data=await res.json().catch(()=>({}));
|
||||||
|
if(!res.ok) throw new Error(data.error||'Transcription failed');
|
||||||
|
_commitTranscript(data.transcript||'');
|
||||||
|
}catch(err){
|
||||||
|
window._micPendingSend=false;
|
||||||
|
showToast(err.message||t('mic_network'));
|
||||||
|
}finally{
|
||||||
|
setComposerStatus('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _stopTracks(){
|
||||||
|
if(mediaStream){
|
||||||
|
mediaStream.getTracks().forEach(track=>track.stop());
|
||||||
|
mediaStream=null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _stopMic(){
|
||||||
|
if(!window._micActive) return;
|
||||||
|
if(recognition){
|
||||||
|
recognition.stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(mediaRecorder&&mediaRecorder.state!=='inactive'){
|
||||||
|
mediaRecorder.stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_setRecording(false);
|
||||||
|
_stopTracks();
|
||||||
|
}
|
||||||
|
window._stopMic=_stopMic; // expose for send-guard above
|
||||||
|
|
||||||
|
if(recognition){
|
||||||
|
recognition.continuous=false;
|
||||||
|
recognition.interimResults=true;
|
||||||
|
recognition.lang=(typeof _locale!=='undefined'&&_locale._speech)||'en-US';
|
||||||
|
|
||||||
recognition.onstart=()=>{ _finalText=''; };
|
recognition.onstart=()=>{ _finalText=''; };
|
||||||
|
|
||||||
recognition.onresult=(event)=>{
|
recognition.onresult=(event)=>{
|
||||||
@@ -210,25 +279,28 @@ $('btnAttach').onclick=()=>$('fileInput').click();
|
|||||||
if(event.results[i].isFinal){ final+=t; _finalText=final; }
|
if(event.results[i].isFinal){ final+=t; _finalText=final; }
|
||||||
else{ interim+=t; }
|
else{ interim+=t; }
|
||||||
}
|
}
|
||||||
// Append to whatever was already in the textarea before mic started
|
|
||||||
ta.value=_prefix+(final||interim);
|
ta.value=_prefix+(final||interim);
|
||||||
autoResize();
|
autoResize();
|
||||||
};
|
};
|
||||||
|
|
||||||
recognition.onend=()=>{
|
recognition.onend=()=>{
|
||||||
// Commit: prefix + final transcription; trim trailing space if prefix was non-empty
|
|
||||||
const committed=_finalText
|
const committed=_finalText
|
||||||
? (_prefix&&!_prefix.endsWith(' ')&&!_prefix.endsWith('\n')
|
? (_prefix&&!_prefix.endsWith(' ')&&!_prefix.endsWith('\n')
|
||||||
? _prefix+' '+_finalText.trimStart()
|
? _prefix+' '+_finalText.trimStart()
|
||||||
: _prefix+_finalText)
|
: _prefix+_finalText)
|
||||||
: ta.value; // no speech detected — leave whatever is there
|
: ta.value;
|
||||||
_setRecording(false);
|
_setRecording(false);
|
||||||
ta.value=committed;
|
ta.value=committed;
|
||||||
autoResize();
|
autoResize();
|
||||||
|
if(window._micPendingSend){
|
||||||
|
window._micPendingSend=false;
|
||||||
|
send();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
recognition.onerror=(event)=>{
|
recognition.onerror=(event)=>{
|
||||||
_setRecording(false);
|
_setRecording(false);
|
||||||
|
window._micPendingSend=false;
|
||||||
const msgs={
|
const msgs={
|
||||||
'not-allowed':t('mic_denied'),
|
'not-allowed':t('mic_denied'),
|
||||||
'no-speech':t('mic_no_speech'),
|
'no-speech':t('mic_no_speech'),
|
||||||
@@ -236,28 +308,59 @@ $('btnAttach').onclick=()=>$('fileInput').click();
|
|||||||
};
|
};
|
||||||
showToast(msgs[event.error]||t('mic_error')+event.error);
|
showToast(msgs[event.error]||t('mic_error')+event.error);
|
||||||
};
|
};
|
||||||
|
|
||||||
function _stopMic(){
|
|
||||||
if(window._micActive){ recognition.stop(); }
|
|
||||||
}
|
}
|
||||||
window._stopMic=_stopMic; // expose for send-guard above
|
|
||||||
|
|
||||||
btn.onclick=()=>{
|
btn.onclick=async()=>{
|
||||||
if(window._micActive){
|
if(window._micActive){
|
||||||
recognition.stop();
|
_stopMic();
|
||||||
// _setRecording(false) will be called by onend
|
return;
|
||||||
} else {
|
}
|
||||||
_finalText='';
|
_finalText='';
|
||||||
// Snapshot existing textarea content so we append rather than replace
|
|
||||||
_prefix=ta.value;
|
_prefix=ta.value;
|
||||||
|
if(recognition){
|
||||||
recognition.start();
|
recognition.start();
|
||||||
_setRecording(true);
|
_setRecording(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(!_canRecordAudio){
|
||||||
|
showToast(t('mic_network'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try{
|
||||||
|
mediaStream=await navigator.mediaDevices.getUserMedia({audio:true});
|
||||||
|
const preferredTypes=['audio/webm;codecs=opus','audio/webm','audio/ogg;codecs=opus','audio/ogg'];
|
||||||
|
const mimeType=preferredTypes.find(type=>window.MediaRecorder.isTypeSupported?.(type))||'';
|
||||||
|
mediaRecorder=new MediaRecorder(mediaStream,mimeType?{mimeType}:undefined);
|
||||||
|
audioChunks=[];
|
||||||
|
mediaRecorder.ondataavailable=e=>{if(e.data&&e.data.size)audioChunks.push(e.data);};
|
||||||
|
mediaRecorder.onerror=()=>{
|
||||||
|
_setRecording(false);
|
||||||
|
window._micPendingSend=false;
|
||||||
|
_stopTracks();
|
||||||
|
showToast(t('mic_network'));
|
||||||
|
};
|
||||||
|
mediaRecorder.onstop=async()=>{
|
||||||
|
const blob=new Blob(audioChunks,{type:mediaRecorder.mimeType||mimeType||'audio/webm'});
|
||||||
|
_setRecording(false);
|
||||||
|
_stopTracks();
|
||||||
|
if(blob.size){ await _transcribeBlob(blob); }
|
||||||
|
else if(window._micPendingSend){
|
||||||
|
window._micPendingSend=false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
mediaRecorder.start();
|
||||||
|
_setRecording(true);
|
||||||
|
}catch(err){
|
||||||
|
window._micPendingSend=false;
|
||||||
|
_stopTracks();
|
||||||
|
showToast(t('mic_denied'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
window._micActive=window._micActive||false;
|
window._micActive=window._micActive||false;
|
||||||
|
window._micPendingSend=window._micPendingSend||false;
|
||||||
$('fileInput').onchange=e=>{addFiles(Array.from(e.target.files));e.target.value='';};
|
$('fileInput').onchange=e=>{addFiles(Array.from(e.target.files));e.target.value='';};
|
||||||
$('btnNewChat').onclick=async()=>{await newSession();await renderSessionList();$('msg').focus();};
|
$('btnNewChat').onclick=async()=>{await newSession();await renderSessionList();closeMobileSidebar();$('msg').focus();};
|
||||||
$('btnDownload').onclick=()=>{
|
$('btnDownload').onclick=()=>{
|
||||||
if(!S.session)return;
|
if(!S.session)return;
|
||||||
const blob=new Blob([transcript()],{type:'text/markdown'});
|
const blob=new Blob([transcript()],{type:'text/markdown'});
|
||||||
@@ -374,7 +477,7 @@ document.addEventListener('keydown',async e=>{
|
|||||||
}
|
}
|
||||||
if((e.metaKey||e.ctrlKey)&&e.key==='k'){
|
if((e.metaKey||e.ctrlKey)&&e.key==='k'){
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if(!S.busy){await newSession();await renderSessionList();$('msg').focus();}
|
if(!S.busy){await newSession();await renderSessionList();closeMobileSidebar();$('msg').focus();}
|
||||||
}
|
}
|
||||||
if(e.key==='Escape'){
|
if(e.key==='Escape'){
|
||||||
// Close settings overlay if open
|
// Close settings overlay if open
|
||||||
|
|||||||
@@ -535,7 +535,7 @@
|
|||||||
<div class="settings-section-title">System</div>
|
<div class="settings-section-title">System</div>
|
||||||
<div class="settings-section-meta">Instance version and access controls.</div>
|
<div class="settings-section-meta">Instance version and access controls.</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="settings-version-badge">v0.50.24</span>
|
<span class="settings-version-badge">v0.50.25</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
|
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
|
||||||
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>
|
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>
|
||||||
|
|||||||
@@ -114,7 +114,7 @@
|
|||||||
--input-bg:rgba(255,255,255,.03);--hover-bg:rgba(255,255,255,.05);
|
--input-bg:rgba(255,255,255,.03);--hover-bg:rgba(255,255,255,.05);
|
||||||
}
|
}
|
||||||
body{background:var(--bg);color:var(--text);height:100vh;height:100dvh;overflow:hidden;display:flex;}
|
body{background:var(--bg);color:var(--text);height:100vh;height:100dvh;overflow:hidden;display:flex;}
|
||||||
.layout{display:flex;width:100%;height:100vh;height:100dvh;}
|
.layout{display:flex;width:100%;height:100vh;height:100dvh;min-height:0;}
|
||||||
.sidebar{width:300px;background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:visible;flex-shrink:0;}
|
.sidebar{width:300px;background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:visible;flex-shrink:0;}
|
||||||
.sidebar-header{padding:16px 18px 14px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;}
|
.sidebar-header{padding:16px 18px 14px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;}
|
||||||
.logo{width:32px;height:32px;border-radius:9px;background:linear-gradient(145deg,#e8a030,var(--accent));display:flex;align-items:center;justify-content:center;font-weight:800;font-size:14px;color:#fff;flex-shrink:0;box-shadow:0 2px 8px rgba(233,69,96,.3);}
|
.logo{width:32px;height:32px;border-radius:9px;background:linear-gradient(145deg,#e8a030,var(--accent));display:flex;align-items:center;justify-content:center;font-weight:800;font-size:14px;color:#fff;flex-shrink:0;box-shadow:0 2px 8px rgba(233,69,96,.3);}
|
||||||
@@ -334,7 +334,7 @@
|
|||||||
.sm-btn{flex:1;padding:8px 0;border-radius:8px;font-size:11px;font-weight:500;background:var(--input-bg);border:1px solid var(--border);color:var(--muted);cursor:pointer;transition:all .15s;text-align:center;letter-spacing:.02em;}
|
.sm-btn{flex:1;padding:8px 0;border-radius:8px;font-size:11px;font-weight:500;background:var(--input-bg);border:1px solid var(--border);color:var(--muted);cursor:pointer;transition:all .15s;text-align:center;letter-spacing:.02em;}
|
||||||
.sm-btn:hover{background:rgba(255,255,255,0.09);color:var(--text);border-color:rgba(255,255,255,.15);}
|
.sm-btn:hover{background:rgba(255,255,255,0.09);color:var(--text);border-color:rgba(255,255,255,.15);}
|
||||||
.sm-btn:disabled{opacity:.45;cursor:not-allowed;}
|
.sm-btn:disabled{opacity:.45;cursor:not-allowed;}
|
||||||
.main{flex:1;display:flex;flex-direction:column;overflow:hidden;min-width:0;background:var(--main-bg);}
|
.main{flex:1;display:flex;flex-direction:column;overflow:hidden;min-width:0;min-height:0;background:var(--main-bg);}
|
||||||
.topbar{padding:12px 20px;border-bottom:1px solid var(--border);background:var(--topbar-bg);backdrop-filter:blur(12px);display:flex;align-items:center;justify-content:space-between;flex-shrink:0;position:relative;z-index:10;}
|
.topbar{padding:12px 20px;border-bottom:1px solid var(--border);background:var(--topbar-bg);backdrop-filter:blur(12px);display:flex;align-items:center;justify-content:space-between;flex-shrink:0;position:relative;z-index:10;}
|
||||||
.topbar-title{font-size:15px;font-weight:600;letter-spacing:-.01em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
.topbar-title{font-size:15px;font-weight:600;letter-spacing:-.01em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||||
.topbar-meta{font-size:11px;color:var(--muted);margin-top:3px;opacity:.75;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
.topbar-meta{font-size:11px;color:var(--muted);margin-top:3px;opacity:.75;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||||
@@ -344,7 +344,7 @@
|
|||||||
.workspace-toggle-btn.active{color:var(--blue);border-color:rgba(124,185,255,.35);background:rgba(124,185,255,.1);}
|
.workspace-toggle-btn.active{color:var(--blue);border-color:rgba(124,185,255,.35);background:rgba(124,185,255,.1);}
|
||||||
.workspace-toggle-btn:disabled{opacity:.38;cursor:not-allowed;}
|
.workspace-toggle-btn:disabled{opacity:.38;cursor:not-allowed;}
|
||||||
.chip.model{color:var(--blue);border-color:rgba(124,185,255,0.35);background:rgba(124,185,255,0.1);}
|
.chip.model{color:var(--blue);border-color:rgba(124,185,255,0.35);background:rgba(124,185,255,0.1);}
|
||||||
.messages{flex:1;overflow-y:auto;display:flex;flex-direction:column;min-height:0;position:relative;z-index:0;}
|
.messages{flex:1;overflow-y:auto;display:flex;flex-direction:column;min-height:0;position:relative;z-index:0;-webkit-overflow-scrolling:touch;touch-action:pan-y;overscroll-behavior-y:contain;}
|
||||||
.messages-inner{margin:0 auto;width:100%;padding:20px 24px 32px;display:flex;flex-direction:column;}
|
.messages-inner{margin:0 auto;width:100%;padding:20px 24px 32px;display:flex;flex-direction:column;}
|
||||||
@media(min-width:1400px){.messages-inner{max-width:1100px;}}
|
@media(min-width:1400px){.messages-inner{max-width:1100px;}}
|
||||||
@media(min-width:1800px){.messages-inner{max-width:1200px;}}
|
@media(min-width:1800px){.messages-inner{max-width:1200px;}}
|
||||||
|
|||||||
@@ -275,6 +275,64 @@ def test_gateway_session_messages_readable():
|
|||||||
post('/api/settings', {'show_cli_sessions': False})
|
post('/api/settings', {'show_cli_sessions': False})
|
||||||
|
|
||||||
|
|
||||||
|
def test_importing_older_gateway_session_preserves_original_timestamps_and_order():
|
||||||
|
"""Importing an older gateway session should not bump it above newer WebUI sessions."""
|
||||||
|
conn = _ensure_state_db()
|
||||||
|
older_started_at = time.time() - 1800
|
||||||
|
imported_sid = 'gw_import_old_001'
|
||||||
|
newer_webui_sid = None
|
||||||
|
try:
|
||||||
|
newer_webui, status = post('/api/session/new', {'model': 'openai/gpt-5'})
|
||||||
|
assert status == 200, newer_webui
|
||||||
|
newer_webui_sid = newer_webui['session']['session_id']
|
||||||
|
|
||||||
|
rename, rename_status = post(
|
||||||
|
'/api/session/rename',
|
||||||
|
{'session_id': newer_webui_sid, 'title': 'Newer WebUI Session'},
|
||||||
|
)
|
||||||
|
assert rename_status == 200, rename
|
||||||
|
|
||||||
|
_insert_gateway_session(
|
||||||
|
conn,
|
||||||
|
session_id=imported_sid,
|
||||||
|
source='discord',
|
||||||
|
title='Older imported gateway session',
|
||||||
|
started_at=older_started_at,
|
||||||
|
)
|
||||||
|
post('/api/settings', {'show_cli_sessions': True})
|
||||||
|
|
||||||
|
imported, imported_status = post('/api/session/import_cli', {'session_id': imported_sid})
|
||||||
|
assert imported_status == 200, imported
|
||||||
|
imported_session = imported['session']
|
||||||
|
assert abs(imported_session['created_at'] - older_started_at) < 2, imported_session
|
||||||
|
assert abs(imported_session['updated_at'] - older_started_at) < 5, imported_session
|
||||||
|
|
||||||
|
sessions_payload, sessions_status = get('/api/sessions')
|
||||||
|
assert sessions_status == 200, sessions_payload
|
||||||
|
ordered_ids = [item['session_id'] for item in sessions_payload.get('sessions', [])]
|
||||||
|
assert newer_webui_sid in ordered_ids, ordered_ids
|
||||||
|
assert imported_sid in ordered_ids, ordered_ids
|
||||||
|
assert ordered_ids.index(newer_webui_sid) < ordered_ids.index(imported_sid), ordered_ids
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
_remove_test_sessions(conn, imported_sid)
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if imported_sid:
|
||||||
|
try:
|
||||||
|
post('/api/session/delete', {'session_id': imported_sid})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if newer_webui_sid:
|
||||||
|
try:
|
||||||
|
post('/api/session/delete', {'session_id': newer_webui_sid})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
post('/api/settings', {'show_cli_sessions': False})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def test_gateway_sse_stream_endpoint_exists():
|
def test_gateway_sse_stream_endpoint_exists():
|
||||||
"""GET /api/sessions/gateway/stream returns a response (200 or 200-range)."""
|
"""GET /api/sessions/gateway/stream returns a response (200 or 200-range)."""
|
||||||
# The SSE endpoint requires show_cli_sessions to be enabled
|
# The SSE endpoint requires show_cli_sessions to be enabled
|
||||||
|
|||||||
@@ -133,6 +133,21 @@ def test_toggle_mobile_files_js_defined():
|
|||||||
"toggleMobileFiles() must toggle mobile-open class on the right panel"
|
"toggleMobileFiles() must toggle mobile-open class on the right panel"
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_conversation_closes_mobile_sidebar():
|
||||||
|
"""New conversation must close the mobile drawer so the chat pane is visible immediately."""
|
||||||
|
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
|
||||||
|
click_line = next((ln for ln in boot_js.splitlines() if "$('btnNewChat').onclick" in ln), "")
|
||||||
|
assert click_line, "btnNewChat onclick handler missing from static/boot.js"
|
||||||
|
assert "closeMobileSidebar" in click_line, \
|
||||||
|
"btnNewChat handler must closeMobileSidebar() after creating the new session"
|
||||||
|
|
||||||
|
shortcut_line = next((ln for ln in boot_js.splitlines() if "e.key==='k'" in ln or "e.key === 'k'" in ln), "")
|
||||||
|
assert shortcut_line, "Cmd/Ctrl+K new chat shortcut missing from static/boot.js"
|
||||||
|
shortcut_block = "\n".join(boot_js.splitlines()[boot_js.splitlines().index(shortcut_line):boot_js.splitlines().index(shortcut_line)+4])
|
||||||
|
assert "closeMobileSidebar" in shortcut_block, \
|
||||||
|
"Cmd/Ctrl+K new chat shortcut must closeMobileSidebar() after creating the new session"
|
||||||
|
|
||||||
|
|
||||||
# ── Viewport and scroll safety ────────────────────────────────────────────────
|
# ── Viewport and scroll safety ────────────────────────────────────────────────
|
||||||
|
|
||||||
def test_body_overflow_hidden():
|
def test_body_overflow_hidden():
|
||||||
@@ -143,6 +158,32 @@ def test_body_overflow_hidden():
|
|||||||
"body must have overflow:hidden to prevent double scrollbars"
|
"body must have overflow:hidden to prevent double scrollbars"
|
||||||
|
|
||||||
|
|
||||||
|
def test_flex_parents_allow_message_scroller_to_shrink():
|
||||||
|
"""The top-level flex containers must opt into min-height:0 so .messages can scroll on mobile.
|
||||||
|
|
||||||
|
Mobile Safari/Chrome can trap scroll when a flex child with overflow:auto sits inside
|
||||||
|
parents whose min-height remains auto. Both .layout and .main need min-height:0.
|
||||||
|
"""
|
||||||
|
assert re.search(r'\.layout\{[^}]*min-height:0', CSS), \
|
||||||
|
".layout must set min-height:0 so the chat column can shrink and scroll"
|
||||||
|
assert re.search(r'\.main\{[^}]*min-height:0', CSS), \
|
||||||
|
".main must set min-height:0 so .messages remains scrollable while busy"
|
||||||
|
|
||||||
|
|
||||||
|
def test_messages_touch_scrolling_hints_present():
|
||||||
|
"""The messages scroller must advertise touch-friendly scrolling behavior.
|
||||||
|
|
||||||
|
On mobile browsers, momentum scrolling and explicit pan-y/overscroll behavior help
|
||||||
|
prevent the chat area from feeling locked while the app body itself stays overflow:hidden.
|
||||||
|
"""
|
||||||
|
assert re.search(r'\.messages\{[^}]*-webkit-overflow-scrolling:\s*touch', CSS), \
|
||||||
|
".messages must enable -webkit-overflow-scrolling:touch for mobile momentum scroll"
|
||||||
|
assert re.search(r'\.messages\{[^}]*touch-action:\s*pan-y', CSS), \
|
||||||
|
".messages must set touch-action:pan-y so vertical swipe gestures scroll the transcript"
|
||||||
|
assert re.search(r'\.messages\{[^}]*overscroll-behavior-y:\s*contain', CSS), \
|
||||||
|
".messages must contain vertical overscroll so the transcript keeps the gesture"
|
||||||
|
|
||||||
|
|
||||||
def test_100dvh_viewport_height():
|
def test_100dvh_viewport_height():
|
||||||
"""Layout must use 100dvh (dynamic viewport height) for correct mobile sizing.
|
"""Layout must use 100dvh (dynamic viewport height) for correct mobile sizing.
|
||||||
|
|
||||||
|
|||||||
63
tests/test_profile_path_security.py
Normal file
63
tests/test_profile_path_security.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import importlib
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).parent.parent.resolve()
|
||||||
|
if str(REPO_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(REPO_ROOT))
|
||||||
|
|
||||||
|
|
||||||
|
def _reload_profiles_module(base_home: Path):
|
||||||
|
os.environ["HERMES_BASE_HOME"] = str(base_home)
|
||||||
|
os.environ["HERMES_HOME"] = str(base_home)
|
||||||
|
|
||||||
|
for name in ["api.config", "api.profiles"]:
|
||||||
|
if name in sys.modules:
|
||||||
|
del sys.modules[name]
|
||||||
|
|
||||||
|
profiles = importlib.import_module("api.profiles")
|
||||||
|
return profiles
|
||||||
|
|
||||||
|
|
||||||
|
def test_switch_profile_rejects_path_traversal():
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
temp_root = Path(td)
|
||||||
|
base = temp_root / ".hermes"
|
||||||
|
(base / "profiles").mkdir(parents=True)
|
||||||
|
(temp_root / "escape-target").mkdir()
|
||||||
|
|
||||||
|
profiles = _reload_profiles_module(base)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
profiles.switch_profile("../../escape-target")
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_profile_rejects_path_traversal():
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
temp_root = Path(td)
|
||||||
|
base = temp_root / ".hermes"
|
||||||
|
(base / "profiles").mkdir(parents=True)
|
||||||
|
(temp_root / "escape-target").mkdir()
|
||||||
|
|
||||||
|
profiles = _reload_profiles_module(base)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
profiles.delete_profile_api("../../escape-target")
|
||||||
|
|
||||||
|
|
||||||
|
def test_switch_profile_allows_valid_profile_name():
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
temp_root = Path(td)
|
||||||
|
base = temp_root / ".hermes"
|
||||||
|
profile_dir = base / "profiles" / "demo"
|
||||||
|
profile_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
profiles = _reload_profiles_module(base)
|
||||||
|
result = profiles.switch_profile("demo")
|
||||||
|
|
||||||
|
assert result["active"] == "demo"
|
||||||
|
assert Path(os.environ["HERMES_HOME"]).resolve() == profile_dir.resolve()
|
||||||
@@ -80,6 +80,16 @@ def test_security_headers_on_health():
|
|||||||
assert headers.get("X-Content-Type-Options") == "nosniff"
|
assert headers.get("X-Content-Type-Options") == "nosniff"
|
||||||
|
|
||||||
|
|
||||||
|
def test_permissions_policy_does_not_disable_microphone():
|
||||||
|
"""Permissions-Policy must not hard-disable microphone access for same-origin voice input."""
|
||||||
|
_, status, headers = get("/health")
|
||||||
|
assert status == 200
|
||||||
|
policy = headers.get("Permissions-Policy", "")
|
||||||
|
assert policy, "Permissions-Policy header missing"
|
||||||
|
assert "microphone=()" not in policy, \
|
||||||
|
"Permissions-Policy must not block microphone access or desktop/mobile voice input cannot work"
|
||||||
|
|
||||||
|
|
||||||
def test_cache_control_no_store():
|
def test_cache_control_no_store():
|
||||||
"""API responses should have Cache-Control: no-store."""
|
"""API responses should have Cache-Control: no-store."""
|
||||||
d, status, headers = get("/api/sessions")
|
d, status, headers = get("/api/sessions")
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ the browser with no server-side component.
|
|||||||
import re
|
import re
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import json
|
import json
|
||||||
|
import pathlib
|
||||||
|
|
||||||
BASE = "http://127.0.0.1:8788"
|
BASE = "http://127.0.0.1:8788"
|
||||||
|
|
||||||
@@ -315,15 +316,31 @@ def test_boot_js_iife_guard():
|
|||||||
assert '(function(){' in js or '(function () {' in js
|
assert '(function(){' in js or '(function () {' in js
|
||||||
|
|
||||||
|
|
||||||
def test_boot_js_browser_unsupported_return():
|
def test_boot_js_browser_unsupported_guard_uses_fallback_capabilities():
|
||||||
"""boot.js must bail out (return) early when SpeechRecognition is unavailable."""
|
"""boot.js must keep the mic available when either speech recognition OR recorder capture exists."""
|
||||||
js, _ = get_text("/static/boot.js")
|
js, _ = get_text("/static/boot.js")
|
||||||
# The IIFE should have an early return when SpeechRecognition is falsy
|
assert 'navigator.mediaDevices' in js
|
||||||
assert 'if(!SpeechRecognition)' in js or 'if (!SpeechRecognition)' in js
|
assert 'getUserMedia' in js
|
||||||
|
assert 'MediaRecorder' in js
|
||||||
|
assert '_canRecordAudio' in js or 'canRecordAudio' in js, \
|
||||||
|
"boot.js should compute a recorder fallback instead of bailing only on SpeechRecognition"
|
||||||
|
|
||||||
|
|
||||||
def test_boot_js_shows_mic_button_when_supported():
|
def test_boot_js_media_recorder_fallback_posts_to_transcribe_api():
|
||||||
"""boot.js must set display='' on btnMic when SpeechRecognition is available."""
|
"""Desktop fallback must send recorded audio to /api/transcribe for transcription."""
|
||||||
|
js, _ = get_text("/static/boot.js")
|
||||||
|
assert '/api/transcribe' in js
|
||||||
|
assert 'fetch(' in js
|
||||||
|
|
||||||
|
|
||||||
|
def test_routes_define_transcribe_endpoint():
|
||||||
|
"""Server routes must expose /api/transcribe for MediaRecorder fallback uploads."""
|
||||||
|
routes = pathlib.Path(__file__).parent.parent.joinpath("api/routes.py").read_text(encoding="utf-8")
|
||||||
|
assert '"/api/transcribe"' in routes
|
||||||
|
|
||||||
|
|
||||||
|
def test_boot_js_shows_mic_button_when_any_voice_path_is_supported():
|
||||||
|
"""boot.js must reveal btnMic when speech recognition or recorder fallback is available."""
|
||||||
js, _ = get_text("/static/boot.js")
|
js, _ = get_text("/static/boot.js")
|
||||||
assert "btn.style.display=''" in js or 'btn.style.display = ""' in js
|
assert "btn.style.display=''" in js or 'btn.style.display = ""' in js
|
||||||
|
|
||||||
|
|||||||
87
tests/test_voice_transcribe_endpoint.py
Normal file
87
tests/test_voice_transcribe_endpoint.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
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"
|
||||||
Reference in New Issue
Block a user