Rebase #5337 (public read-only conversation sharing) onto master

Rebased 363 commits forward (branch-point 2026-07-05). Applied the PR's code
diff cleanly across 16 files; resolved api/models.py additively (both master's
process_wakeup_pause/anchor_scene_index fields AND the PR's share_token/
share_created_at fields kept in the Session constructor, metadata list, and
to_dict). CI workflow (.github/workflows/tests.yml) intentionally NOT taken from
the stale branch — master owns it (the branch's apparent removal of Playwright/
Office steps was a PAT-workflow-scope revert; rebase restores master's CI).

Co-authored-by: MicoRobot. Concept approved by maintainer (opt-in public share,
2026-07-11). Note: 4 dynamic share tests fail identically on the original
unrebased head (pre-existing test-env Session.load fragility, not rebase-caused);
to be resolved during the security re-gate in a proper sandbox.
This commit is contained in:
nesquena-hermes committed 2026-07-11 01:39:42 +00:00
1 parent 91804b30fc
commit e061e8534c
17 files changed
+1383 -6

No files matched your search

+4
View File
@@ -1307,6 +1307,8 @@ Complete list of all HTTP endpoints as of Sprint 1 (v0.3).
/api/sessions List of all session compact() dicts, sorted by updated_at
/api/list ?session_id=X&path=. -> directory listing for session workspace
/api/file ?session_id=X&path=rel -> file content (text, 200KB limit)
/share/<token> Public read-only HTML shell for a sanitized shared transcript snapshot
/api/share/<token> Public JSON payload for a sanitized shared transcript snapshot
/api/chat/stream ?stream_id=X -> SSE stream. Long-lived. Emits token/tool/
approval/done/error events.
/api/chat/stream/status ?stream_id=X -> {"active": true/false, "stream_id": X}
@@ -1327,6 +1329,8 @@ Complete list of all HTTP endpoints as of Sprint 1 (v0.3).
-> {"stream_id", "session_id"}. Starts agent daemon thread.
/api/chat (fallback, sync) {"session_id", "message", "model"?, "workspace"?}
-> blocks until agent finishes. Returns full result.
/api/share/create {"session_id"} -> creates or refreshes a public read-only snapshot link
/api/share/revoke {"session_id"} -> revokes the current public snapshot link
/api/approval/respond {"session_id", "choice": once|session|always|deny}
-> {"ok": true, "choice": choice}
+1
View File
@@ -223,6 +223,7 @@ If an AI assistant is helping with install, reinstall, bootstrap, provider setup
- Session tags -- add #tag to titles for colored chips and click-to-filter
- Grouped by Today / Yesterday / Earlier in the sidebar (collapsible date groups)
- Download as Markdown transcript, full JSON export, or import from JSON
- Create a public read-only share link for the active conversation from the Control Center; shared pages show a sanitized transcript snapshot without workspace, profile, or live controls
- Sessions persist across page reloads and SSH tunnel reconnects
- Browser tab title reflects the active session name
- CLI session bridge -- CLI sessions from hermes-agent's SQLite store appear in the sidebar with a gold "cli" badge; click to import with full history and reply normally
+21
View File
@@ -557,6 +557,27 @@ EXPECT:
(response text)
FAIL: No download triggered, file is empty, file is corrupted JSON instead of markdown.
### T8.2: Create and Revoke a Public Share Link
SETUP: A session with at least 2 visible messages (1 user + 1 assistant).
STEPS:
1. Click the "Hermes" button in the sidebar footer
2. In the Conversation section, click "Share"
EXPECT:
- A public `/share/<token>` page opens in a new tab
- The link is copied to the clipboard
- The Conversation section meta line shows that a public share is active
- The shared page uses the current Hermes theme/skin and shows only a read-only transcript snapshot
FAIL: No link opens, the page requires login unexpectedly, or the shared page exposes workspace/profile/live controls.
STEPS:
3. Return to the app and click "Stop sharing"
4. Confirm the revoke dialog
EXPECT:
- A toast confirms the link was revoked
- Reopening the old share URL shows an unavailable/not found state
- The active-share status disappears from the Conversation section
FAIL: The old link still loads the transcript, or the session still appears as publicly shared after revocation.
---
## Section 9: Reconnect Banner (Sprint 1 - B4/B5)
+11 -1
View File
@@ -53,6 +53,7 @@ PUBLIC_PATHS = frozenset({
'/api/auth/login', '/api/auth/status',
'/api/auth/oidc/start', '/api/auth/oidc/callback',
'/api/auth/passkey/options', '/api/auth/passkey/login',
'/share',
'/manifest.json', '/manifest.webmanifest',
'/session/manifest.json', '/session/manifest.webmanifest',
})
@@ -731,7 +732,16 @@ def check_auth(handler, parsed) -> bool:
if not is_auth_enabled():
return True
# Public paths don't require auth
if parsed.path in PUBLIC_PATHS or parsed.path.startswith('/static/') or parsed.path.startswith('/session/static/'):
if (
parsed.path in PUBLIC_PATHS
or parsed.path.startswith('/share/')
or (
parsed.path.startswith('/api/share/')
and parsed.path not in {'/api/share/create', '/api/share/revoke'}
)
or parsed.path.startswith('/static/')
or parsed.path.startswith('/session/static/')
):
return True
# Check session cookie
cookie_val = parse_cookie(handler)
+10 -1
View File
@@ -256,7 +256,13 @@ def j(handler, payload, status: int=200, extra_headers: dict=None, *, pretty: bo
_safe_write(handler, body)
def t(handler, payload, status: int=200, content_type: str='text/plain; charset=utf-8') -> None:
def t(
handler,
payload,
status: int=200,
content_type: str='text/plain; charset=utf-8',
extra_headers: dict=None,
) -> None:
"""Send a plain text or HTML response."""
body = payload if isinstance(payload, bytes) else str(payload).encode('utf-8')
handler.send_response(status)
@@ -264,6 +270,9 @@ def t(handler, payload, status: int=200, content_type: str='text/plain; charset=
handler.send_header('Content-Length', str(len(body)))
handler.send_header('Cache-Control', 'no-store')
_security_headers(handler)
if extra_headers:
for k, v in extra_headers.items():
handler.send_header(k, v)
_safe_write(handler, body)
+8 -1
View File
@@ -1127,6 +1127,8 @@ class Session:
composer_draft=None,
anchor_activity_scenes=None,
process_wakeup_pause=None,
share_token=None,
share_created_at=None,
**kwargs):
self.session_id = session_id or uuid.uuid4().hex[:12]
self.title = title
@@ -1199,6 +1201,8 @@ class Session:
self.composer_draft = composer_draft if isinstance(composer_draft, dict) else {}
self.anchor_activity_scenes = anchor_activity_scenes if isinstance(anchor_activity_scenes, dict) else {}
self.process_wakeup_pause = process_wakeup_pause if isinstance(process_wakeup_pause, dict) else {}
self.share_token = str(share_token).strip() if share_token else None
self.share_created_at = share_created_at
# #5854: a compact fingerprint of anchor_activity_scenes ({scene_key:
# updated_at}) persisted BEFORE the messages array so the sidebar-poll
# freshness check can compare scene freshness without parsing the full
@@ -1266,8 +1270,9 @@ class Session:
'parent_session_id',
'worktree_path', 'worktree_branch', 'worktree_repo_root', 'worktree_created_at',
'is_cli_session', 'source_tag', 'raw_source', 'session_source', 'source_label', 'read_only',
'enabled_toolsets', 'composer_draft',
'enabled_toolsets', 'composer_draft', 'anchor_activity_scenes',
'process_wakeup_pause',
'share_token', 'share_created_at',
]
meta = {k: getattr(self, k, None) for k in METADATA_FIELDS}
# #5854: message_count and a compact anchor-scene fingerprint go in the
@@ -1648,6 +1653,8 @@ class Session:
'enabled_toolsets': self.enabled_toolsets,
'composer_draft': self.composer_draft if isinstance(self.composer_draft, dict) else {},
'process_wakeup_pause': self.process_wakeup_pause if isinstance(self.process_wakeup_pause, dict) else {},
'share_token': self.share_token,
'share_created_at': self.share_created_at,
'is_streaming': _is_streaming_session(
self.active_stream_id, active_stream_ids
) if include_runtime else False,
+250
View File
@@ -55,6 +55,7 @@ from api.session_events import (
unsubscribe_session_events,
)
from api.gateway_restart import restart_active_profile_gateway
from api.shares import create_or_refresh_share, load_share, revoke_share
logger = logging.getLogger(__name__)
@@ -4991,6 +4992,146 @@ def _get_or_materialize_session(sid: str, *, refresh_cli_messages: bool = False)
return s
def _share_snapshot_messages_for_session(session, *, cli_meta: dict | None = None) -> list:
"""Return the visible transcript that a public share should snapshot.
External sessions (Telegram/Discord/Slack/CLI/etc.) may have no WebUI sidecar
or may persist only local metadata in the sidecar while the transcript lives
in state.db. Public sharing should snapshot the same visible conversation the
session page renders, not the bare local sidecar payload.
"""
sid = str(getattr(session, "session_id", "") or "").strip()
current_messages = list(getattr(session, "messages", None) or [])
if not sid:
return current_messages
profile = getattr(session, "profile", None)
is_messaging = (
_is_messaging_session_record(session)
or _is_messaging_session_record(cli_meta)
)
if is_messaging or not current_messages:
cli_messages = get_cli_session_messages(sid, profile=profile)
if cli_messages:
if is_messaging:
return _merged_session_messages_for_display(session, cli_messages)
return list(cli_messages)
return current_messages
def _build_share_metadata_sidecar(
sid: str,
snapshot_session,
*,
cli_meta: dict | None = None,
):
"""Create a minimal WebUI sidecar for share metadata on external sessions."""
cli_meta = dict(cli_meta or {})
workspace = (
cli_meta.get("workspace")
or cli_meta.get("cwd")
or getattr(snapshot_session, "workspace", None)
)
if not workspace:
workspace = get_last_workspace()
session = Session(
session_id=sid,
title=(
cli_meta.get("title")
or getattr(snapshot_session, "title", None)
or title_from(getattr(snapshot_session, "messages", None) or [], "CLI Session")
),
workspace=workspace,
messages=[],
model=cli_meta.get("model") or getattr(snapshot_session, "model", None) or "unknown",
model_provider=(
cli_meta.get("model_provider")
or getattr(snapshot_session, "model_provider", None)
),
created_at=cli_meta.get("created_at") or getattr(snapshot_session, "created_at", None),
updated_at=cli_meta.get("updated_at") or getattr(snapshot_session, "updated_at", None),
profile=cli_meta.get("profile") or getattr(snapshot_session, "profile", None),
)
session.is_cli_session = bool(
getattr(snapshot_session, "is_cli_session", False)
or is_cli_session_row(cli_meta)
)
session.source_tag = cli_meta.get("source_tag") or getattr(snapshot_session, "source_tag", None)
session.raw_source = (
cli_meta.get("raw_source")
or getattr(snapshot_session, "raw_source", None)
or session.source_tag
)
session.session_source = (
cli_meta.get("session_source")
or getattr(snapshot_session, "session_source", None)
)
session.source_label = (
cli_meta.get("source_label")
or getattr(snapshot_session, "source_label", None)
)
session.read_only = bool(
cli_meta.get("read_only") or getattr(snapshot_session, "read_only", False)
)
for attr in (
"user_id",
"chat_id",
"chat_type",
"thread_id",
"session_key",
"platform",
"origin_chat_id",
"origin_user_id",
"parent_session_id",
):
value = cli_meta.get(attr)
if value is None:
value = getattr(snapshot_session, attr, None)
if value is not None:
setattr(session, attr, value)
return session
def _resolve_share_session_pair(sid: str, handler):
"""Resolve a shareable session plus the sidecar that stores share metadata.
Returns ``(snapshot_session, stored_session_or_none, cli_meta)``. The
snapshot session always carries the transcript that should become the public
share payload. ``stored_session`` is the WebUI-owned sidecar to mutate for
share_token/share_created_at persistence; it may be absent for pure external
sessions that have not yet created local metadata.
"""
try:
stored_session = get_session(sid)
cli_meta = (
_lookup_cli_session_metadata(sid)
if _session_requires_cli_metadata_lookup(stored_session)
else {}
)
effective_profile = (
(cli_meta or {}).get("profile")
or getattr(stored_session, "profile", None)
or None
)
if not _session_visible_to_active_profile(effective_profile, handler):
raise KeyError(sid)
stored_session = _ensure_full_session_before_mutation(sid, stored_session)
snapshot_session = copy.copy(stored_session)
snapshot_session.messages = _share_snapshot_messages_for_session(
stored_session,
cli_meta=cli_meta,
)
return snapshot_session, stored_session, cli_meta or {}
except KeyError:
cli_meta = _lookup_cli_session_metadata(sid) or {}
effective_profile = cli_meta.get("profile") or None
if not _session_visible_to_active_profile(effective_profile, handler):
raise KeyError(sid)
synth, reason = _claim_or_synthesize_cli_session(sid, cli_meta=cli_meta)
if reason == "was_webui" or synth is None:
raise KeyError(sid)
return synth, None, cli_meta
def _reconcile_stale_stream_state_for_session_rows(session_rows) -> bool:
"""Clear stale persisted stream fields before /api/sessions serializes rows."""
changed = False
@@ -11589,6 +11730,17 @@ def handle_get(handler, parsed) -> bool:
except Exception as exc:
return _serve_shell_unavailable(handler, exc)
if parsed.path == "/share" or parsed.path.startswith("/share/"):
share_path = (Path(__file__).parent.parent / "static" / "share.html").resolve()
return t(
handler,
share_path.read_text(encoding="utf-8"),
content_type="text/html; charset=utf-8",
extra_headers={
"X-Robots-Tag": "noindex, nofollow",
},
)
if parsed.path == "/login":
_settings = load_settings()
_bn = _html.escape(_settings.get("bot_name") or "Hermes")
@@ -11699,6 +11851,20 @@ def handle_get(handler, parsed) -> bool:
"auth_disabled_acknowledged": bool(load_settings().get("auth_disabled_acknowledged")) if not auth_enabled else False,
})
if parsed.path.startswith("/api/share/"):
token = parsed.path[len("/api/share/"):].strip()
share = load_share(token)
if not share:
return bad(handler, "Shared conversation not found", 404)
return j(
handler,
{"share": share},
extra_headers={
"Cache-Control": "no-store",
"X-Robots-Tag": "noindex, nofollow",
},
)
if parsed.path in ("/manifest.json", "/manifest.webmanifest"):
return _serve_manifest(handler)
@@ -13604,6 +13770,90 @@ def handle_post(handler, parsed) -> bool:
_save_saved_prompts(prompts)
return j(handler, {"ok": True, "prompt": new_prompt})
if parsed.path == "/api/share/create":
sid = str(body.get("session_id") or "").strip()
if not sid:
return bad(handler, "session_id is required", 400)
try:
snapshot_session, stored_session, cli_meta = _resolve_share_session_pair(sid, handler)
except KeyError:
return bad(handler, "Session not found", 404)
try:
share_meta = create_or_refresh_share(snapshot_session)
except ValueError as exc:
return bad(handler, str(exc), 400)
persisted_session = stored_session
if persisted_session is None:
persisted_session = _build_share_metadata_sidecar(
sid,
snapshot_session,
cli_meta=cli_meta,
)
persisted_session.share_token = share_meta["share_token"]
persisted_session.share_created_at = share_meta["share_created_at"]
persisted_session.save(touch_updated_at=False)
_publish_session_list_changed(
"session_share_create",
profile=getattr(persisted_session, "profile", None),
session_id=sid,
)
response_session = copy.copy(persisted_session)
response_session.messages = list(getattr(snapshot_session, "messages", None) or [])
return j(
handler,
{
"ok": True,
"share": {
"token": share_meta["share_token"],
"url": f"/share/{share_meta['share_token']}",
"title": share_meta["share_title"],
"message_count": share_meta["share_message_count"],
"created_at": share_meta["share_created_at"],
"updated_at": share_meta["share_updated_at"],
},
"session": response_session.compact() | {"messages": response_session.messages},
},
)
if parsed.path == "/api/share/revoke":
sid = str(body.get("session_id") or "").strip()
if not sid:
return bad(handler, "session_id is required", 400)
try:
snapshot_session, stored_session, cli_meta = _resolve_share_session_pair(sid, handler)
except KeyError:
return bad(handler, "Session not found", 404)
target_session = stored_session
if target_session is None:
token = str(getattr(snapshot_session, "share_token", "") or "").strip()
if not token:
return bad(handler, "Session not found", 404)
target_session = _build_share_metadata_sidecar(
sid,
snapshot_session,
cli_meta=cli_meta,
)
target_session.share_token = token
target_session.share_created_at = getattr(snapshot_session, "share_created_at", None)
revoke_share(target_session)
target_session.share_token = None
target_session.share_created_at = None
target_session.save(touch_updated_at=False)
_publish_session_list_changed(
"session_share_revoke",
profile=getattr(target_session, "profile", None),
session_id=sid,
)
response_session = copy.copy(target_session)
response_session.messages = list(getattr(snapshot_session, "messages", None) or [])
return j(
handler,
{
"ok": True,
"session": response_session.compact() | {"messages": response_session.messages},
},
)
if parsed.path == "/api/session/new":
try:
workspace = str(resolve_trusted_workspace(body.get("workspace"))) if body.get("workspace") else None
+202
View File
@@ -0,0 +1,202 @@
"""
Hermes Web UI -- public read-only share snapshots.
Stores a sanitized, immutable snapshot of a conversation under STATE_DIR/shares.
The snapshot is intentionally narrower than a full session export so public
links do not leak local workspace paths, profile details, or raw tool payloads.
"""
from __future__ import annotations
import json
import logging
import os
import secrets
import tempfile
import threading
import time
from pathlib import Path
from api.config import STATE_DIR
from api.helpers import redact_session_data
logger = logging.getLogger(__name__)
SHARES_DIR = STATE_DIR / "shares"
_SHARE_LOCK = threading.Lock()
def _ensure_share_dir() -> None:
SHARES_DIR.mkdir(parents=True, exist_ok=True)
def _share_path(token: str) -> Path:
token = str(token or "").strip()
if not token:
raise ValueError("share token is required")
if not token.replace("-", "").replace("_", "").isalnum():
raise ValueError("invalid share token")
return SHARES_DIR / f"{token}.json"
def _write_json_atomic(path: Path, payload: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp(
dir=str(path.parent),
prefix=f"{path.stem}.",
suffix=".tmp",
text=True,
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as fh:
json.dump(payload, fh, ensure_ascii=False, indent=2)
fh.flush()
os.fsync(fh.fileno())
os.replace(tmp_name, path)
except Exception:
try:
os.unlink(tmp_name)
except OSError:
pass
raise
def _share_message_text(message: dict) -> str:
content = message.get("content") if isinstance(message, dict) else ""
if isinstance(content, list):
parts = []
for item in content:
if not isinstance(item, dict):
continue
if item.get("type") == "text":
parts.append(str(item.get("text") or ""))
return "".join(parts).strip()
return str(content or "").strip()
def _sanitize_message(message: dict) -> dict | None:
if not isinstance(message, dict):
return None
role = str(message.get("role") or "").strip().lower()
if role not in {"user", "assistant"}:
return None
text = _share_message_text(message)
if not text:
return None
sanitized = {
"role": role,
"content": text,
}
ts = message.get("timestamp")
if isinstance(ts, (int, float)):
sanitized["timestamp"] = ts
return sanitized
def _public_share_payload(payload: dict) -> dict:
messages = payload.get("messages")
if not isinstance(messages, list):
messages = []
public = {
"title": str(payload.get("title") or "Untitled"),
"messages": messages,
"message_count": int(payload.get("message_count") or len(messages)),
}
created_at = payload.get("created_at")
updated_at = payload.get("updated_at")
if isinstance(created_at, (int, float)):
public["created_at"] = created_at
if isinstance(updated_at, (int, float)):
public["updated_at"] = updated_at
return public
def build_share_snapshot(session) -> dict:
safe_session = redact_session_data(getattr(session, "__dict__", {}) or {})
safe_messages = []
for raw in safe_session.get("messages") or []:
sanitized = _sanitize_message(raw)
if sanitized:
safe_messages.append(sanitized)
if not safe_messages:
raise ValueError("This conversation has no shareable messages yet.")
return {
"title": str(safe_session.get("title") or "Untitled"),
"messages": safe_messages,
"message_count": len(safe_messages),
}
def create_or_refresh_share(session) -> dict:
snapshot = build_share_snapshot(session)
with _SHARE_LOCK:
_ensure_share_dir()
existing_token = str(getattr(session, "share_token", "") or "").strip()
token = existing_token or secrets.token_urlsafe(18)
now = time.time()
payload = {
"token": token,
"source_session_id": str(getattr(session, "session_id", "") or ""),
"title": snapshot["title"],
"messages": snapshot["messages"],
"message_count": snapshot["message_count"],
"created_at": now,
"updated_at": now,
"revoked_at": None,
}
path = _share_path(token)
if path.exists():
try:
existing = json.loads(path.read_text(encoding="utf-8"))
if isinstance(existing, dict):
payload["created_at"] = existing.get("created_at") or now
except Exception:
logger.debug("Ignoring malformed share snapshot at %s", path, exc_info=True)
_write_json_atomic(path, payload)
return {
"share_token": token,
"share_title": payload["title"],
"share_message_count": payload["message_count"],
"share_created_at": payload["created_at"],
"share_updated_at": payload["updated_at"],
}
def load_share(token: str) -> dict | None:
try:
path = _share_path(token)
except ValueError:
return None
if not path.exists():
return None
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except Exception:
logger.warning("Failed to read share snapshot %s", path, exc_info=True)
return None
if not isinstance(payload, dict):
return None
if payload.get("revoked_at"):
return None
return _public_share_payload(payload)
def revoke_share(session) -> bool:
token = str(getattr(session, "share_token", "") or "").strip()
if not token:
return False
with _SHARE_LOCK:
try:
path = _share_path(token)
except ValueError:
return False
if path.exists():
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except Exception:
payload = {}
if not isinstance(payload, dict):
payload = {}
payload["revoked_at"] = time.time()
_write_json_atomic(path, payload)
return True
+47
View File
@@ -1781,6 +1781,53 @@ $('btnExportJSON').onclick=()=>{
const a=document.createElement('a');a.href=url;
a.download=`hermes-${S.session.session_id}.json`;a.click();
};
$('btnShareSession').onclick=async()=>{
if(!S.session) return;
try{
const existing=(S.session&&S.session.share_token)?new URL(`/share/${encodeURIComponent(S.session.share_token)}`,location.origin).href:null;
if(existing){
const reuse=await showConfirmDialog({
title:t('share_session'),
message:t('share_session_existing_confirm'),
confirmLabel:t('share_session_copy_existing'),
cancelLabel:t('share_session_refresh_snapshot'),
});
if(reuse){
await _copyText(existing);
showToast(t('share_session_link_copied'));
window.open(existing,'_blank','noopener');
return;
}
}
const res=await api('/api/share/create',{method:'POST',body:JSON.stringify({session_id:S.session.session_id})});
if(res&&res.session) S.session=res.session;
const href=new URL(String(res&&res.share&&res.share.url||''),location.origin).href;
await _copyText(href);
showToast(t('share_session_created'));
if(typeof _syncHermesPanelSessionActions==='function') _syncHermesPanelSessionActions();
window.open(href,'_blank','noopener');
}catch(err){
showToast(t('share_session_failed')+(err&&err.message?err.message:String(err||'')),4000,'error');
}
};
$('btnStopSharingSession').onclick=async()=>{
if(!S.session||!S.session.share_token) return;
const ok=await showConfirmDialog({
title:t('stop_sharing_session'),
message:t('stop_sharing_session_confirm'),
confirmLabel:t('stop_sharing_session'),
danger:true,
});
if(!ok) return;
try{
const res=await api('/api/share/revoke',{method:'POST',body:JSON.stringify({session_id:S.session.session_id})});
if(res&&res.session) S.session=res.session;
showToast(t('share_session_revoked'));
if(typeof _syncHermesPanelSessionActions==='function') _syncHermesPanelSessionActions();
}catch(err){
showToast(t('share_session_revoke_failed')+(err&&err.message?err.message:String(err||'')),4000,'error');
}
};
function exportSessionHTML(session){
const target=session||S.session;
if(!target||!target.session_id)return;
+196
View File
@@ -505,6 +505,20 @@ const LOCALES = {
session_copy_link_desc: 'Copy a direct link to this conversation',
session_link_copied: 'Conversation link copied to clipboard',
session_link_copy_failed: 'Failed to copy conversation link: ',
share_session: 'Share',
share_session_tooltip: 'Create a public read-only share link',
share_session_status_active: 'Public share active',
share_session_existing_confirm: 'This conversation already has a public read-only share link.\n\nChoose Copy existing to reuse that link, or Refresh snapshot to rebuild the public snapshot from the latest visible messages.',
share_session_copy_existing: 'Copy existing',
share_session_refresh_snapshot: 'Refresh snapshot',
share_session_link_copied: 'Share link copied to clipboard',
share_session_created: 'Public share link created',
share_session_failed: 'Share failed: ',
stop_sharing_session: 'Stop sharing',
stop_sharing_session_tooltip: 'Revoke the public read-only share link for this conversation',
stop_sharing_session_confirm: 'Revoke this public share link? Anyone opening the old link will see that it is no longer available.',
share_session_revoked: 'Public share link revoked',
share_session_revoke_failed: 'Failed to revoke share link: ',
new_file_prompt: 'New file name (e.g. notes.md):',
new_file: 'New File',
new_folder: 'New Folder',
@@ -2207,6 +2221,20 @@ const LOCALES = {
session_copy_link_desc: 'Copia un link diretto a questa conversazione',
session_link_copied: 'Link conversazione copiato negli appunti',
session_link_copy_failed: 'Impossibile copiare il link della conversazione: ',
share_session: 'Share',
share_session_tooltip: 'Create a public read-only share link',
share_session_status_active: 'Public share active',
share_session_existing_confirm: 'This conversation already has a public read-only share link.\n\nChoose Copy existing to reuse that link, or Refresh snapshot to rebuild the public snapshot from the latest visible messages.',
share_session_copy_existing: 'Copy existing',
share_session_refresh_snapshot: 'Refresh snapshot',
share_session_link_copied: 'Share link copied to clipboard',
share_session_created: 'Public share link created',
share_session_failed: 'Share failed: ',
stop_sharing_session: 'Stop sharing',
stop_sharing_session_tooltip: 'Revoke the public read-only share link for this conversation',
stop_sharing_session_confirm: 'Revoke this public share link? Anyone opening the old link will see that it is no longer available.',
share_session_revoked: 'Public share link revoked',
share_session_revoke_failed: 'Failed to revoke share link: ',
new_file_prompt: 'Nuovo nome file (es. note.md):',
new_file: 'Nuovo file',
new_folder: 'Nuova cartella',
@@ -3900,6 +3928,20 @@ const LOCALES = {
session_copy_link_desc: 'この会話への直接リンクをコピー',
session_link_copied: '会話リンクをクリップボードにコピーしました',
session_link_copy_failed: '会話リンクをコピーできませんでした: ',
share_session: 'Share',
share_session_tooltip: 'Create a public read-only share link',
share_session_status_active: 'Public share active',
share_session_existing_confirm: 'This conversation already has a public read-only share link.\n\nChoose Copy existing to reuse that link, or Refresh snapshot to rebuild the public snapshot from the latest visible messages.',
share_session_copy_existing: 'Copy existing',
share_session_refresh_snapshot: 'Refresh snapshot',
share_session_link_copied: 'Share link copied to clipboard',
share_session_created: 'Public share link created',
share_session_failed: 'Share failed: ',
stop_sharing_session: 'Stop sharing',
stop_sharing_session_tooltip: 'Revoke the public read-only share link for this conversation',
stop_sharing_session_confirm: 'Revoke this public share link? Anyone opening the old link will see that it is no longer available.',
share_session_revoked: 'Public share link revoked',
share_session_revoke_failed: 'Failed to revoke share link: ',
new_file_prompt: '新しいファイル名 (例: notes.md):',
new_file: '新規ファイル',
new_folder: '新規フォルダ',
@@ -5517,6 +5559,20 @@ const LOCALES = {
session_copy_link_desc: 'Скопировать прямую ссылку на эту беседу',
session_link_copied: 'Ссылка на беседу скопирована в буфер обмена',
session_link_copy_failed: 'Не удалось скопировать ссылку на беседу: ',
share_session: 'Share',
share_session_tooltip: 'Create a public read-only share link',
share_session_status_active: 'Public share active',
share_session_existing_confirm: 'This conversation already has a public read-only share link.\n\nChoose Copy existing to reuse that link, or Refresh snapshot to rebuild the public snapshot from the latest visible messages.',
share_session_copy_existing: 'Copy existing',
share_session_refresh_snapshot: 'Refresh snapshot',
share_session_link_copied: 'Share link copied to clipboard',
share_session_created: 'Public share link created',
share_session_failed: 'Share failed: ',
stop_sharing_session: 'Stop sharing',
stop_sharing_session_tooltip: 'Revoke the public read-only share link for this conversation',
stop_sharing_session_confirm: 'Revoke this public share link? Anyone opening the old link will see that it is no longer available.',
share_session_revoked: 'Public share link revoked',
share_session_revoke_failed: 'Failed to revoke share link: ',
new_file_prompt: 'Имя нового файла (например, notes.md):',
new_file: 'Новый файл',
new_folder: 'Новая папка',
@@ -7127,6 +7183,20 @@ const LOCALES = {
session_copy_link_desc: 'Copia un enlace directo a esta conversación',
session_link_copied: 'Enlace de conversación copiado al portapapeles',
session_link_copy_failed: 'No se pudo copiar el enlace de conversación: ',
share_session: 'Share',
share_session_tooltip: 'Create a public read-only share link',
share_session_status_active: 'Public share active',
share_session_existing_confirm: 'This conversation already has a public read-only share link.\n\nChoose Copy existing to reuse that link, or Refresh snapshot to rebuild the public snapshot from the latest visible messages.',
share_session_copy_existing: 'Copy existing',
share_session_refresh_snapshot: 'Refresh snapshot',
share_session_link_copied: 'Share link copied to clipboard',
share_session_created: 'Public share link created',
share_session_failed: 'Share failed: ',
stop_sharing_session: 'Stop sharing',
stop_sharing_session_tooltip: 'Revoke the public read-only share link for this conversation',
stop_sharing_session_confirm: 'Revoke this public share link? Anyone opening the old link will see that it is no longer available.',
share_session_revoked: 'Public share link revoked',
share_session_revoke_failed: 'Failed to revoke share link: ',
new_file_prompt: 'Nombre del archivo nuevo (p. ej. notes.md):',
new_file: 'Nuevo archivo',
new_folder: 'Nueva carpeta',
@@ -8732,6 +8802,20 @@ const LOCALES = {
session_copy_link_desc: 'Direkten Link zu dieser Unterhaltung kopieren',
session_link_copied: 'Unterhaltungslink in die Zwischenablage kopiert',
session_link_copy_failed: 'Unterhaltungslink konnte nicht kopiert werden: ',
share_session: 'Share',
share_session_tooltip: 'Create a public read-only share link',
share_session_status_active: 'Public share active',
share_session_existing_confirm: 'This conversation already has a public read-only share link.\n\nChoose Copy existing to reuse that link, or Refresh snapshot to rebuild the public snapshot from the latest visible messages.',
share_session_copy_existing: 'Copy existing',
share_session_refresh_snapshot: 'Refresh snapshot',
share_session_link_copied: 'Share link copied to clipboard',
share_session_created: 'Public share link created',
share_session_failed: 'Share failed: ',
stop_sharing_session: 'Stop sharing',
stop_sharing_session_tooltip: 'Revoke the public read-only share link for this conversation',
stop_sharing_session_confirm: 'Revoke this public share link? Anyone opening the old link will see that it is no longer available.',
share_session_revoked: 'Public share link revoked',
share_session_revoke_failed: 'Failed to revoke share link: ',
new_file_prompt: 'Neuer Dateiname (z.B. notes.md):',
new_file: 'Neue Datei',
new_folder: 'Neuer Ordner',
@@ -10408,6 +10492,20 @@ const LOCALES = {
session_copy_link_desc: '复制此对话的直接链接',
session_link_copied: '对话链接已复制到剪贴板',
session_link_copy_failed: '无法复制对话链接:',
share_session: 'Share',
share_session_tooltip: 'Create a public read-only share link',
share_session_status_active: 'Public share active',
share_session_existing_confirm: 'This conversation already has a public read-only share link.\n\nChoose Copy existing to reuse that link, or Refresh snapshot to rebuild the public snapshot from the latest visible messages.',
share_session_copy_existing: 'Copy existing',
share_session_refresh_snapshot: 'Refresh snapshot',
share_session_link_copied: 'Share link copied to clipboard',
share_session_created: 'Public share link created',
share_session_failed: 'Share failed: ',
stop_sharing_session: 'Stop sharing',
stop_sharing_session_tooltip: 'Revoke the public read-only share link for this conversation',
stop_sharing_session_confirm: 'Revoke this public share link? Anyone opening the old link will see that it is no longer available.',
share_session_revoked: 'Public share link revoked',
share_session_revoke_failed: 'Failed to revoke share link: ',
new_file_prompt: '新文件名(例如 notes.md):',
new_file: '新建文件',
new_folder: '新建文件夹',
@@ -12115,6 +12213,20 @@ const LOCALES = {
session_copy_link_desc: '複製此對話的直接連結',
session_link_copied: '對話連結已複製到剪貼簿',
session_link_copy_failed: '無法複製對話連結:',
share_session: 'Share',
share_session_tooltip: 'Create a public read-only share link',
share_session_status_active: 'Public share active',
share_session_existing_confirm: 'This conversation already has a public read-only share link.\n\nChoose Copy existing to reuse that link, or Refresh snapshot to rebuild the public snapshot from the latest visible messages.',
share_session_copy_existing: 'Copy existing',
share_session_refresh_snapshot: 'Refresh snapshot',
share_session_link_copied: 'Share link copied to clipboard',
share_session_created: 'Public share link created',
share_session_failed: 'Share failed: ',
stop_sharing_session: 'Stop sharing',
stop_sharing_session_tooltip: 'Revoke the public read-only share link for this conversation',
stop_sharing_session_confirm: 'Revoke this public share link? Anyone opening the old link will see that it is no longer available.',
share_session_revoked: 'Public share link revoked',
share_session_revoke_failed: 'Failed to revoke share link: ',
new_file_prompt: '新檔案名稱(例如 notes.md):',
new_file: '新建檔案',
new_folder: '新建資料夾',
@@ -13709,6 +13821,20 @@ const LOCALES = {
session_copy_link_desc: 'Copia um link direto para esta conversa',
session_link_copied: 'Link da conversa copiado para a área de transferência',
session_link_copy_failed: 'Falha ao copiar link da conversa: ',
share_session: 'Share',
share_session_tooltip: 'Create a public read-only share link',
share_session_status_active: 'Public share active',
share_session_existing_confirm: 'This conversation already has a public read-only share link.\n\nChoose Copy existing to reuse that link, or Refresh snapshot to rebuild the public snapshot from the latest visible messages.',
share_session_copy_existing: 'Copy existing',
share_session_refresh_snapshot: 'Refresh snapshot',
share_session_link_copied: 'Share link copied to clipboard',
share_session_created: 'Public share link created',
share_session_failed: 'Share failed: ',
stop_sharing_session: 'Stop sharing',
stop_sharing_session_tooltip: 'Revoke the public read-only share link for this conversation',
stop_sharing_session_confirm: 'Revoke this public share link? Anyone opening the old link will see that it is no longer available.',
share_session_revoked: 'Public share link revoked',
share_session_revoke_failed: 'Failed to revoke share link: ',
new_file_prompt: 'Nome do novo arquivo (ex: notes.md):',
new_file: 'Novo arquivo',
new_folder: 'Nova pasta',
@@ -15296,6 +15422,20 @@ const LOCALES = {
session_copy_link_desc: '이 대화로 바로 가는 링크 복사',
session_link_copied: '대화 링크가 클립보드에 복사되었습니다',
session_link_copy_failed: '대화 링크를 복사하지 못했습니다: ',
share_session: 'Share',
share_session_tooltip: 'Create a public read-only share link',
share_session_status_active: 'Public share active',
share_session_existing_confirm: 'This conversation already has a public read-only share link.\n\nChoose Copy existing to reuse that link, or Refresh snapshot to rebuild the public snapshot from the latest visible messages.',
share_session_copy_existing: 'Copy existing',
share_session_refresh_snapshot: 'Refresh snapshot',
share_session_link_copied: 'Share link copied to clipboard',
share_session_created: 'Public share link created',
share_session_failed: 'Share failed: ',
stop_sharing_session: 'Stop sharing',
stop_sharing_session_tooltip: 'Revoke the public read-only share link for this conversation',
stop_sharing_session_confirm: 'Revoke this public share link? Anyone opening the old link will see that it is no longer available.',
share_session_revoked: 'Public share link revoked',
share_session_revoke_failed: 'Failed to revoke share link: ',
new_file_prompt: 'New file name (e.g. notes.md):',
new_file: '새 파일',
new_folder: '새 폴더',
@@ -17002,6 +17142,20 @@ const LOCALES = {
session_copy_link_desc: 'Copier un lien direct vers cette conversation',
session_link_copied: 'Lien de la conversation copié dans le presse-papiers',
session_link_copy_failed: 'Échec de la copie du lien de la conversation : ',
share_session: 'Share',
share_session_tooltip: 'Create a public read-only share link',
share_session_status_active: 'Public share active',
share_session_existing_confirm: 'This conversation already has a public read-only share link.\n\nChoose Copy existing to reuse that link, or Refresh snapshot to rebuild the public snapshot from the latest visible messages.',
share_session_copy_existing: 'Copy existing',
share_session_refresh_snapshot: 'Refresh snapshot',
share_session_link_copied: 'Share link copied to clipboard',
share_session_created: 'Public share link created',
share_session_failed: 'Share failed: ',
stop_sharing_session: 'Stop sharing',
stop_sharing_session_tooltip: 'Revoke the public read-only share link for this conversation',
stop_sharing_session_confirm: 'Revoke this public share link? Anyone opening the old link will see that it is no longer available.',
share_session_revoked: 'Public share link revoked',
share_session_revoke_failed: 'Failed to revoke share link: ',
new_file_prompt: 'Nom du nouveau fichier (ex. notes.md) :',
new_file: 'Nouveau fichier',
new_folder: 'Nouveau dossier',
@@ -20314,6 +20468,20 @@ const LOCALES = {
session_copy_link_desc: 'Bu konuşmaya doğrudan bağlantı kopyala',
session_link_copied: 'Konuşma bağlantısı panoya kopyalandı',
session_link_copy_failed: 'Konuşma bağlantısı kopyalanamadı: ',
share_session: 'Share',
share_session_tooltip: 'Create a public read-only share link',
share_session_status_active: 'Public share active',
share_session_existing_confirm: 'This conversation already has a public read-only share link.\n\nChoose Copy existing to reuse that link, or Refresh snapshot to rebuild the public snapshot from the latest visible messages.',
share_session_copy_existing: 'Copy existing',
share_session_refresh_snapshot: 'Refresh snapshot',
share_session_link_copied: 'Share link copied to clipboard',
share_session_created: 'Public share link created',
share_session_failed: 'Share failed: ',
stop_sharing_session: 'Stop sharing',
stop_sharing_session_tooltip: 'Revoke the public read-only share link for this conversation',
stop_sharing_session_confirm: 'Revoke this public share link? Anyone opening the old link will see that it is no longer available.',
share_session_revoked: 'Public share link revoked',
share_session_revoke_failed: 'Failed to revoke share link: ',
new_file_prompt: 'Yeni dosya adı (örn. Notes.md):',
new_file: 'Yeni Dosya',
new_folder: 'Yeni Klasör',
@@ -22023,6 +22191,20 @@ const LOCALES = {
session_copy_link_desc: 'Kopiuj bezpośredni link do tej konwersacji',
session_link_copied: 'Link do konwersacji skopiowany do schowka',
session_link_copy_failed: 'Nie udało się skopiować linku do konwersacji: ',
share_session: 'Share',
share_session_tooltip: 'Create a public read-only share link',
share_session_status_active: 'Public share active',
share_session_existing_confirm: 'This conversation already has a public read-only share link.\n\nChoose Copy existing to reuse that link, or Refresh snapshot to rebuild the public snapshot from the latest visible messages.',
share_session_copy_existing: 'Copy existing',
share_session_refresh_snapshot: 'Refresh snapshot',
share_session_link_copied: 'Share link copied to clipboard',
share_session_created: 'Public share link created',
share_session_failed: 'Share failed: ',
stop_sharing_session: 'Stop sharing',
stop_sharing_session_tooltip: 'Revoke the public read-only share link for this conversation',
stop_sharing_session_confirm: 'Revoke this public share link? Anyone opening the old link will see that it is no longer available.',
share_session_revoked: 'Public share link revoked',
share_session_revoke_failed: 'Failed to revoke share link: ',
new_file_prompt: 'Nazwa nowego pliku (np. notes.md):',
new_file: 'Nowy plik',
new_folder: 'Nowy folder',
@@ -23668,6 +23850,20 @@ const LOCALES = {
session_copy_link_desc: 'Sao chép liên kết trực tiếp tới cuộc trò chuyện này',
session_link_copied: 'Đã sao chép liên kết cuộc trò chuyện vào clipboard',
session_link_copy_failed: 'Sao chép liên kết cuộc trò chuyện thất bại: ',
share_session: 'Share',
share_session_tooltip: 'Create a public read-only share link',
share_session_status_active: 'Public share active',
share_session_existing_confirm: 'This conversation already has a public read-only share link.\n\nChoose Copy existing to reuse that link, or Refresh snapshot to rebuild the public snapshot from the latest visible messages.',
share_session_copy_existing: 'Copy existing',
share_session_refresh_snapshot: 'Refresh snapshot',
share_session_link_copied: 'Share link copied to clipboard',
share_session_created: 'Public share link created',
share_session_failed: 'Share failed: ',
stop_sharing_session: 'Stop sharing',
stop_sharing_session_tooltip: 'Revoke the public read-only share link for this conversation',
stop_sharing_session_confirm: 'Revoke this public share link? Anyone opening the old link will see that it is no longer available.',
share_session_revoked: 'Public share link revoked',
share_session_revoke_failed: 'Failed to revoke share link: ',
new_file_prompt: 'Tên file mới (ví dụ: notes.md):',
new_file: 'File mới',
new_folder: 'Thư mục mới',
+5
View File
@@ -940,6 +940,11 @@
<div class="hermes-action-grid">
<button class="settings-action-btn" id="btnDownload" title="Download as Markdown" data-i18n-title="download_transcript"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> <span data-i18n="transcript">Transcript</span></button>
<button class="settings-action-btn" id="btnExportJSON" title="Export full session as JSON" data-i18n-title="export_session_json_tooltip"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5c0 1.1.9 2 2 2h1"/><path d="M16 3h1a2 2 0 0 1 2 2v5a2 2 0 0 0 2 2 2 2 0 0 0-2 2v5a2 2 0 0 1-2 2h-1"/></svg> <span data-i18n="export_session_json">JSON</span></button>
<button class="settings-action-btn" id="btnShareSession" title="Create a public read-only share link" data-i18n-title="share_session_tooltip"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.6 13.5l6.8 4"/><path d="M15.4 6.5 8.6 10.5"/></svg> <span data-i18n="share_session">Share</span></button>
<button class="settings-action-btn danger" id="btnStopSharingSession" title="Revoke the public read-only share link for this conversation" data-i18n-title="stop_sharing_session_tooltip"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8a6 6 0 0 0-8.49 0L6 11.5"/><path d="M18 16a6 6 0 0 1-8.49 0L6 12.5"/><line x1="3" y1="3" x2="21" y2="21"/></svg> <span data-i18n="stop_sharing_session">Stop sharing</span></button>
<button class="settings-action-btn" id="btnExportHTML" title="Export as a self-contained HTML page" data-i18n-title="export_session_html_tooltip"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M9 13l-2 2 2 2"/><path d="M15 13l2 2-2 2"/></svg> <span data-i18n="export_session_html">HTML</span></button>
<button class="settings-action-btn" id="btnShareSession" title="Create a public read-only share link" data-i18n-title="share_session_tooltip"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.6 13.5l6.8 4"/><path d="M15.4 6.5 8.6 10.5"/></svg> <span data-i18n="share_session">Share</span></button>
<button class="settings-action-btn danger" id="btnStopSharingSession" title="Revoke the public read-only share link for this conversation" data-i18n-title="stop_sharing_session_tooltip"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8a6 6 0 0 0-8.49 0L6 11.5"/><path d="M18 16a6 6 0 0 1-8.49 0L6 12.5"/><line x1="3" y1="3" x2="21" y2="21"/></svg> <span data-i18n="stop_sharing_session">Stop sharing</span></button>
<button class="settings-action-btn" id="btnExportHTML" title="Export as a self-contained HTML page" data-i18n-title="export_session_html_tooltip"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M9 13l-2 2 2 2"/><path d="M15 13l2 2-2 2"/></svg> <span data-i18n="export_session_html">HTML</span></button>
<button class="settings-action-btn" id="btnImportJSON" title="Import session from JSON" data-i18n-title="import_session_json_tooltip"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg> <span data-i18n="import">Import</span></button>
<button class="settings-action-btn danger" id="btnClearConvModal" onclick="clearConversation()" title="Clear all messages in this conversation" data-i18n-title="clear_conversation_btn_tooltip"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 1 2 2 2v2"/></svg> <span data-i18n="clear">Clear</span></button>
+11 -3
View File
@@ -8230,10 +8230,16 @@ function _syncHermesPanelSessionActions(){
const visibleMessages=hasSession?(S.messages||[]).filter(m=>m&&m.role&&m.role!=='tool').length:0;
const title=hasSession?(S.session.title||t('untitled')):t('active_conversation_none');
const meta=$('hermesSessionMeta');
const hasShare=!!(hasSession&&S.session&&S.session.share_token);
if(meta){
meta.textContent=hasSession
? t('active_conversation_meta', title, visibleMessages)
: t('active_conversation_none');
if(!hasSession){
meta.textContent=t('active_conversation_none');
}else{
const base=t('active_conversation_meta', title, visibleMessages);
meta.textContent=hasShare
? `${base} · ${t('share_session_status_active')}`
: base;
}
}
const setDisabled=(id,disabled)=>{
const el=$(id);
@@ -8243,6 +8249,8 @@ function _syncHermesPanelSessionActions(){
};
setDisabled('btnDownload',!hasSession||visibleMessages===0);
setDisabled('btnExportJSON',!hasSession);
setDisabled('btnShareSession',!hasSession||visibleMessages===0);
setDisabled('btnStopSharingSession',!hasShare);
setDisabled('btnClearConvModal',!hasSession||visibleMessages===0);
}
+114
View File
@@ -4109,6 +4109,119 @@ function _appendSessionCopyLinkAction(menu, session){
));
}
function _sessionPublicShareUrl(session){
const token=session&&session.share_token?String(session.share_token).trim():'';
if(!token) return '';
return new URL(`/share/${encodeURIComponent(token)}`,location.origin).href;
}
function _syncSessionShareState(session, nextSession){
if(!session||!nextSession) return;
session.share_token=nextSession.share_token||null;
session.share_created_at=nextSession.share_created_at||null;
const cached=(_allSessions||[]).find(s=>s&&s.session_id===session.session_id);
if(cached){
cached.share_token=session.share_token;
cached.share_created_at=session.share_created_at;
}
if(S.session&&S.session.session_id===session.session_id){
S.session.share_token=session.share_token;
S.session.share_created_at=session.share_created_at;
if(typeof _syncHermesPanelSessionActions==='function') _syncHermesPanelSessionActions();
}
renderSessionListFromCache();
void renderSessionList();
}
async function _createOrRefreshSessionShare(session){
if(!session||!session.session_id) return;
const existing=_sessionPublicShareUrl(session);
if(existing){
const reuse=await showConfirmDialog({
title:t('share_session'),
message:t('share_session_existing_confirm'),
confirmLabel:t('share_session_copy_existing'),
cancelLabel:t('share_session_refresh_snapshot'),
});
if(reuse){
await _copyTextToClipboard(existing);
showToast(t('share_session_link_copied'));
window.open(existing,'_blank','noopener');
return;
}
}
const res=await api('/api/share/create',{method:'POST',body:JSON.stringify({session_id:session.session_id})});
if(res&&res.session) _syncSessionShareState(session,res.session);
const href=new URL(String(res&&res.share&&res.share.url||''),location.origin).href;
await _copyTextToClipboard(href);
showToast(existing?t('share_session_link_copied'):t('share_session_created'));
window.open(href,'_blank','noopener');
}
async function _revokeSessionShare(session){
if(!session||!session.session_id||!session.share_token) return;
const ok=await showConfirmDialog({
title:t('stop_sharing_session'),
message:t('stop_sharing_session_confirm'),
confirmLabel:t('stop_sharing_session'),
danger:true,
});
if(!ok) return;
const res=await api('/api/share/revoke',{method:'POST',body:JSON.stringify({session_id:session.session_id})});
if(res&&res.session) _syncSessionShareState(session,res.session);
showToast(t('share_session_revoked'));
}
function _appendSessionShareActions(menu, session){
const hasMessages=Number(session&&session.message_count||0)>0;
if(!hasMessages) return;
menu.appendChild(_buildSessionAction(
t('share_session'),
session&&session.share_token?t('share_session_status_active'):t('share_session_tooltip'),
ICONS.link,
async()=>{
closeSessionActionMenu();
try{
await _createOrRefreshSessionShare(session);
}catch(err){
showToast(t('share_session_failed')+(err&&err.message?err.message:String(err||'')),4000,'error');
}
},
session&&session.share_token?'is-active':''
));
if(!(session&&session.share_token)) return;
menu.appendChild(_buildSessionAction(
t('share_session_copy_existing'),
t('share_session_tooltip'),
ICONS.link,
async()=>{
closeSessionActionMenu();
try{
const href=_sessionPublicShareUrl(session);
if(!href) return;
await _copyTextToClipboard(href);
showToast(t('share_session_link_copied'));
}catch(err){
showToast(t('share_session_failed')+(err&&err.message?err.message:String(err||'')),4000,'error');
}
}
));
menu.appendChild(_buildSessionAction(
t('stop_sharing_session'),
t('stop_sharing_session_tooltip'),
ICONS.trash,
async()=>{
closeSessionActionMenu();
try{
await _revokeSessionShare(session);
}catch(err){
showToast(t('share_session_revoke_failed')+(err&&err.message?err.message:String(err||'')),4000,'error');
}
},
'danger'
));
}
function _appendSessionDuplicateAction(menu, session){
menu.appendChild(_buildSessionAction(
t('session_duplicate'),
@@ -4233,6 +4346,7 @@ function _openSessionActionMenu(session, anchorEl){
}
));
}
_appendSessionShareActions(menu, session);
menu.appendChild(_buildSessionAction(
session.pinned?t('session_unpin'):t('session_pin'),
session.pinned?t('session_unpin_desc'):t('session_pin_desc'),
+82
View File
@@ -0,0 +1,82 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Shared Conversation - Hermes</title>
<meta name="robots" content="noindex,nofollow,noarchive">
<meta name="referrer" content="same-origin">
<script>(function(){try{var themes={light:1,dark:1,system:1},skins={codex:1,terracotta:1,default:1,ares:1,mono:1,graphite:1,github:1,slate:1,poseidon:1,sisyphus:1,charizard:1,sienna:1,catppuccin:1,hepburn:1,nous:1,'geist-contrast':1,neon:1,zeus:1,'verdigris':1},legacy={slate:['dark','slate'],solarized:['dark','poseidon'],monokai:['dark','sisyphus'],nord:['dark','slate'],oled:['dark','default']},t=(localStorage.getItem('hermes-theme')||'dark').toLowerCase(),s=(localStorage.getItem('hermes-skin')||'').toLowerCase(),m=legacy[t],theme=m?m[0]:(themes[t]?t:'dark');var pendingExt=s&&s!=='default'&&!skins[s]&&!legacy[s];var skin=skins[s]?s:(pendingExt?s:(m?m[1]:'default'));localStorage.setItem('hermes-theme',theme);localStorage.setItem('hermes-skin',skin);if(theme==='system')theme=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';if(theme==='dark')document.documentElement.classList.add('dark');if(skin!=='default')document.documentElement.dataset.skin=skin;}catch(e){document.documentElement.classList.add('dark');}})()</script>
<link rel="stylesheet" href="/static/style.css">
<style>
html,body{height:auto;min-height:100%}
body{overflow-y:auto;overflow-x:hidden;display:block}
.share-shell{min-height:100vh;background:var(--bg);color:var(--text);}
.share-topbar{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:18px 22px;border-bottom:1px solid var(--border);background:var(--sidebar);position:sticky;top:0;z-index:10}
.share-brand{display:flex;align-items:center;gap:10px;min-width:0}
.share-brand-title{font-size:13px;font-weight:700;color:var(--text);letter-spacing:.01em}
.share-brand-sub{font-size:11px;color:var(--muted)}
.share-actions{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.share-btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;padding:10px 14px;border-radius:10px;border:1px solid var(--border);background:var(--surface);color:var(--text);font-size:13px;font-weight:500;cursor:pointer;transition:background .15s,border-color .15s,color .15s;text-decoration:none}
.share-btn:hover{background:var(--accent-bg);border-color:var(--accent-bg-strong);color:var(--accent-text)}
.share-btn:disabled{opacity:.45;cursor:not-allowed}
.share-wrap{width:min(920px,calc(100vw - 32px));margin:0 auto;padding:28px 0 72px}
.share-hero{padding:6px 0 18px}
.share-kicker{display:inline-flex;align-items:center;gap:8px;padding:6px 10px;border-radius:999px;background:var(--surface);border:1px solid var(--border2);font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.08em}
.share-title{margin:16px 0 10px;font-size:32px;line-height:1.1;letter-spacing:-.02em;color:var(--strong,var(--text))}
.share-meta{font-size:13px;color:var(--muted);line-height:1.6}
.share-transcript{margin-top:22px;padding:18px 18px 8px;border:1px solid var(--border);border-radius:18px;background:linear-gradient(180deg,color-mix(in srgb,var(--surface) 88%,transparent),color-mix(in srgb,var(--sidebar) 94%,transparent))}
.share-empty,.share-error{padding:40px 16px;text-align:center;color:var(--muted)}
.share-error strong{color:var(--text)}
.share-message{padding:14px 0 18px;border-top:1px solid color-mix(in srgb,var(--border) 78%,transparent)}
.share-message:first-child{border-top:none;padding-top:4px}
.share-role{display:flex;align-items:center;gap:10px;font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);margin-bottom:10px}
.share-role-badge{display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:26px;padding:0 8px;border-radius:999px;border:1px solid var(--border2);background:var(--surface-subtle);color:var(--text)}
.share-message[data-role="user"] .share-role-badge{background:var(--accent-bg);border-color:var(--accent-bg-strong);color:var(--accent-text)}
.share-message-body{padding-left:0;max-width:none}
.share-foot{margin-top:18px;padding-top:18px;border-top:1px solid var(--border);font-size:12px;color:var(--muted);line-height:1.7}
@media (max-width: 720px){
.share-topbar{padding:14px 14px 12px;align-items:flex-start;flex-direction:column}
.share-wrap{width:min(100vw - 20px,920px)}
.share-title{font-size:26px}
.share-transcript{padding:14px 14px 6px;border-radius:16px}
.share-actions{width:100%}
.share-btn{flex:1 1 180px}
}
</style>
</head>
<body>
<div class="share-shell">
<header class="share-topbar">
<div class="share-brand">
<span class="app-titlebar-icon" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="18" height="18" aria-hidden="true"><defs><linearGradient id="share-mark" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#08EBF1"/><stop offset="1" stop-color="#3889FD"/></linearGradient><radialGradient id="share-tile" cx="50%" cy="42%" r="75%"><stop offset="0%" stop-color="#0D3460"/><stop offset="100%" stop-color="#021128"/></radialGradient></defs><rect width="64" height="64" rx="14.3" fill="url(#share-tile)"/><g transform="translate(-8.632 -11.731) scale(0.06483)"><path fill="url(#share-mark)" fill-rule="evenodd" d="M630.5 961.9 C634.9 960.7 638.5 957.9 640.5 953.9 C642.5 950.1 643.3 865.1 641.4 864.3 C640 863.8 623.9 872.5 618.2 876.8 C616.4 878.2 613.8 881.2 612.5 883.5 L610 887.7 610 918.4 C610 951.8 610.2 953.1 615.7 958.3 C618.3 960.8 622.4 962.7 625.5 962.9 C626 963 628.3 962.5 630.5 961.9 Z"/></g></svg>
</span>
<div>
<div class="share-brand-title">Hermes Shared Conversation</div>
<div class="share-brand-sub">Read-only snapshot</div>
</div>
</div>
<div class="share-actions">
<button class="share-btn" id="shareCopyBtn" type="button">Copy link</button>
<a class="share-btn" href="/" id="shareHomeLink">Open Hermes</a>
</div>
</header>
<main class="share-wrap">
<section class="share-hero">
<div class="share-kicker">Public share</div>
<h1 class="share-title" id="shareTitle">Loading...</h1>
<div class="share-meta" id="shareMeta">Fetching shared conversation...</div>
</section>
<section class="share-transcript" id="shareTranscript">
<div class="share-empty">Loading shared conversation...</div>
</section>
<div class="share-foot">
This link shows a read-only snapshot prepared by Hermes WebUI. It does not expose the original workspace browser, profile settings, or live session controls.
</div>
</main>
</div>
<script src="/static/ui.js"></script>
<script src="/static/share.js"></script>
</body>
</html>
+119
View File
@@ -0,0 +1,119 @@
function _shareTokenFromPath(){
const path=window.location.pathname||'';
const m=path.match(/\/share\/([^/?#]+)/);
return m?decodeURIComponent(m[1]):'';
}
function _shareEscapeHtml(text){
return String(text==null?'':text)
.replace(/&/g,'&amp;')
.replace(/</g,'&lt;')
.replace(/>/g,'&gt;')
.replace(/"/g,'&quot;')
.replace(/'/g,'&#39;');
}
function _shareRoleLabel(role){
if(role==='user') return 'User';
if(role==='assistant') return 'Assistant';
return 'System';
}
function _shareRenderMessages(messages){
const wrap=$('shareTranscript');
if(!wrap) return;
if(!Array.isArray(messages)||!messages.length){
wrap.innerHTML='<div class="share-empty">This shared conversation has no visible messages.</div>';
return;
}
wrap.innerHTML='';
messages.forEach(msg=>{
const row=document.createElement('article');
row.className='share-message';
row.dataset.role=String(msg.role||'assistant');
const bodyHtml=(typeof renderMd==='function')
? renderMd(String(msg.content||''))
: `<p>${_shareEscapeHtml(msg.content||'')}</p>`;
row.innerHTML=
`<div class="share-role"><span class="share-role-badge">${_shareEscapeHtml(_shareRoleLabel(msg.role))}</span></div>`+
`<div class="msg-body share-message-body">${bodyHtml}</div>`;
wrap.appendChild(row);
});
if(typeof highlightCode==='function') highlightCode(wrap);
}
function _shareSetError(message){
const title=$('shareTitle');
const meta=$('shareMeta');
const wrap=$('shareTranscript');
if(title) title.textContent='Share unavailable';
if(meta) meta.textContent='This public snapshot could not be loaded.';
if(wrap) wrap.innerHTML=`<div class="share-error"><strong>Could not open this share.</strong><div style="margin-top:8px">${_shareEscapeHtml(message||'The link may have expired or been revoked.')}</div></div>`;
}
async function _shareLoad(){
const token=_shareTokenFromPath();
if(!token){
_shareSetError('Missing share token.');
return;
}
try{
const data=await fetch(new URL(`/api/share/${encodeURIComponent(token)}`,window.location.origin).href,{credentials:'same-origin',cache:'no-store'});
if(!data.ok){
let message='The link may have expired or been revoked.';
try{
const payload=await data.json();
if(payload&&payload.error) message=payload.error;
}catch(_){}
_shareSetError(message);
return;
}
const payload=await data.json();
const share=payload&&payload.share;
if(!share||!Array.isArray(share.messages)){
_shareSetError('Malformed share payload.');
return;
}
const title=$('shareTitle');
const meta=$('shareMeta');
if(title) title.textContent=share.title||'Untitled';
if(meta){
const count=Number(share.message_count||share.messages.length||0);
meta.textContent=`${count} message${count===1?'':'s'} - public read-only snapshot`;
}
_shareRenderMessages(share.messages);
}catch(err){
_shareSetError(err&&err.message?err.message:String(err||'Failed to load share.'));
}
}
async function _shareCopyLink(){
const btn=$('shareCopyBtn');
const text=window.location.href;
const done=()=>{
if(btn){
const original=btn.textContent;
btn.textContent='Copied!';
setTimeout(()=>{btn.textContent=original;},1200);
}
};
if(typeof _copyText==='function'){
try{
await _copyText(text);
done();
return;
}catch(_){}
}
try{
if(navigator&&navigator.clipboard&&navigator.clipboard.writeText){
await navigator.clipboard.writeText(text);
done();
}
}catch(_){}
}
document.addEventListener('DOMContentLoaded',()=>{
const copyBtn=$('shareCopyBtn');
if(copyBtn) copyBtn.addEventListener('click',_shareCopyLink);
_shareLoad();
});
+226
View File
@@ -0,0 +1,226 @@
import json
import urllib.error
import urllib.request
from tests._pytest_port import BASE
def get(path):
try:
with urllib.request.urlopen(BASE + path, timeout=10) as r:
data = r.read()
content_type = r.headers.get("Content-Type", "")
if "application/json" in content_type:
return json.loads(data), r.status, dict(r.headers)
return data.decode("utf-8"), r.status, dict(r.headers)
except urllib.error.HTTPError as e:
data = e.read()
content_type = e.headers.get("Content-Type", "")
if "application/json" in content_type:
return json.loads(data), e.code, dict(e.headers)
return data.decode("utf-8"), e.code, dict(e.headers)
def post(path, body=None):
data = json.dumps(body or {}).encode()
req = urllib.request.Request(
BASE + path,
data=data,
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=10) as r:
return json.loads(r.read()), r.status
except urllib.error.HTTPError as e:
return json.loads(e.read()), e.code
def _make_session_with_messages():
created, _ = post("/api/session/new", {})
sid = created["session"]["session_id"]
from api.models import Session
session = Session.load(sid)
assert session is not None
session.title = "Shared Test"
session.messages = [
{"role": "system", "content": "internal system instructions should stay private"},
{"role": "user", "content": "Please summarize this."},
{
"role": "assistant",
"content": "Here is a concise summary.",
"provider_details": "HTTP 401: expired upstream token",
"provider_details_label": "Provider details",
},
{"role": "tool", "content": "raw tool output should not be public"},
]
session.workspace = "/very/private/workspace"
session.profile = None
session.save()
return sid
def test_share_create_returns_public_url_and_persists_session_fields():
sid = _make_session_with_messages()
try:
payload, status = post("/api/share/create", {"session_id": sid})
assert status == 200
assert payload["ok"] is True
share = payload["share"]
assert share["token"]
assert share["url"].startswith("/share/")
assert payload["session"]["share_token"] == share["token"]
assert payload["session"]["share_created_at"]
finally:
post("/api/session/delete", {"session_id": sid})
def test_public_share_payload_is_sanitized_and_read_only():
sid = _make_session_with_messages()
try:
created, _ = post("/api/share/create", {"session_id": sid})
token = created["share"]["token"]
payload, status, headers = get(f"/api/share/{token}")
assert status == 200
assert headers.get("X-Robots-Tag") == "noindex, nofollow"
share = payload["share"]
assert share["title"] == "Shared Test"
assert "workspace" not in share
assert "profile" not in share
assert "source_session_id" not in share
assert "token" not in share
assert "revoked_at" not in share
assert share["message_count"] == 2
assert [m["role"] for m in share["messages"]] == ["user", "assistant"]
assert all("system" != m["role"] for m in share["messages"])
assert all("tool" != m["role"] for m in share["messages"])
assert "provider_details" not in share["messages"][1]
assert "provider_details_label" not in share["messages"][1]
finally:
post("/api/session/delete", {"session_id": sid})
def test_share_revoke_makes_link_unavailable():
sid = _make_session_with_messages()
try:
created, _ = post("/api/share/create", {"session_id": sid})
token = created["share"]["token"]
revoked, status = post("/api/share/revoke", {"session_id": sid})
assert status == 200
assert revoked["ok"] is True
missing, status, _ = get(f"/api/share/{token}")
assert status == 404
assert missing["error"] == "Shared conversation not found"
finally:
post("/api/session/delete", {"session_id": sid})
def test_share_revoke_endpoint_hides_share_token_from_session():
sid = _make_session_with_messages()
try:
post("/api/share/create", {"session_id": sid})
payload, status = post("/api/share/revoke", {"session_id": sid})
assert status == 200
assert payload["session"]["share_token"] is None
assert payload["session"]["share_created_at"] is None
finally:
post("/api/session/delete", {"session_id": sid})
def test_share_page_serves_public_html():
body, status, _ = get("/share/example-token")
assert status == 200
assert "Hermes Shared Conversation" in body
assert "static/share.js" in body
def test_share_create_supports_raw_messaging_session_without_webui_sidecar():
from tests.test_gateway_sync import _ensure_state_db, _insert_gateway_session, _remove_test_sessions
conn = _ensure_state_db()
sid = "share_tg_external_001"
try:
_insert_gateway_session(
conn,
session_id=sid,
source="telegram",
title="Telegram Share",
)
payload, status = post("/api/share/create", {"session_id": sid})
assert status == 200
assert payload["ok"] is True
token = payload["share"]["token"]
assert token
assert payload["session"]["share_token"] == token
assert payload["session"]["session_source"] == "messaging"
assert payload["session"]["raw_source"] == "telegram"
assert [m["role"] for m in payload["session"]["messages"]] == ["user", "assistant"]
shared, status, _ = get(f"/api/share/{token}")
assert status == 200
assert shared["share"]["title"] == "Telegram Share"
assert [m["content"] for m in shared["share"]["messages"]] == [
"Hello from Telegram",
"Hi there!",
]
revoked, status = post("/api/share/revoke", {"session_id": sid})
assert status == 200
assert revoked["session"]["share_token"] is None
assert [m["role"] for m in revoked["session"]["messages"]] == ["user", "assistant"]
finally:
try:
post("/api/session/delete", {"session_id": sid})
except Exception:
pass
_remove_test_sessions(conn, sid)
conn.close()
def test_share_create_uses_messaging_display_transcript_when_sidecar_has_no_messages():
from api.models import Session
from tests.test_gateway_sync import _ensure_state_db, _insert_gateway_session, _remove_test_sessions
conn = _ensure_state_db()
sid = "share_discord_imported_001"
try:
_insert_gateway_session(
conn,
session_id=sid,
source="discord",
title="Discord Share",
)
local = Session(
session_id=sid,
title="Discord Share",
messages=[],
model="openai/gpt-5",
created_at=1.0,
updated_at=2.0,
)
local.is_cli_session = True
local.session_source = "messaging"
local.raw_source = "discord"
local.source_tag = "discord"
local.source_label = "Discord"
local.save(touch_updated_at=False)
payload, status = post("/api/share/create", {"session_id": sid})
assert status == 200
token = payload["share"]["token"]
assert token
assert [m["content"] for m in payload["session"]["messages"]] == [
"Hello from Telegram",
"Hi there!",
]
shared, status, _ = get(f"/api/share/{token}")
assert status == 200
assert [m["content"] for m in shared["share"]["messages"]] == [
"Hello from Telegram",
"Hi there!",
]
finally:
post("/api/session/delete", {"session_id": sid})
_remove_test_sessions(conn, sid)
conn.close()
+76
View File
@@ -0,0 +1,76 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
INDEX_HTML = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
BOOT_JS = (ROOT / "static" / "boot.js").read_text(encoding="utf-8")
PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8")
I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
SHARE_HTML = (ROOT / "static" / "share.html").read_text(encoding="utf-8")
SHARE_JS = (ROOT / "static" / "share.js").read_text(encoding="utf-8")
def test_conversation_panel_has_share_actions():
assert 'id="btnShareSession"' in INDEX_HTML
assert 'id="btnStopSharingSession"' in INDEX_HTML
assert 'data-i18n="share_session"' in INDEX_HTML
assert 'data-i18n="stop_sharing_session"' in INDEX_HTML
def test_boot_js_wires_share_create_and_revoke():
assert "$('btnShareSession').onclick=async()=>{" in BOOT_JS
assert "new URL(`/share/${encodeURIComponent(S.session.share_token)}`,location.origin).href" in BOOT_JS
assert "api('/api/share/create'" in BOOT_JS
assert "$('btnStopSharingSession').onclick=async()=>{" in BOOT_JS
assert "api('/api/share/revoke'" in BOOT_JS
assert "share_session_created" in BOOT_JS
assert "share_session_revoked" in BOOT_JS
def test_panels_sync_exposes_share_status_and_button_states():
assert "share_session_status_active" in PANELS_JS
assert "setDisabled('btnShareSession'" in PANELS_JS
assert "setDisabled('btnStopSharingSession'" in PANELS_JS
def test_share_i18n_keys_exist_in_english_locale():
for key in [
"share_session",
"share_session_tooltip",
"share_session_status_active",
"share_session_existing_confirm",
"share_session_copy_existing",
"share_session_refresh_snapshot",
"share_session_link_copied",
"share_session_created",
"share_session_failed",
"stop_sharing_session",
"stop_sharing_session_tooltip",
"stop_sharing_session_confirm",
"share_session_revoked",
"share_session_revoke_failed",
]:
assert f"{key}:" in I18N_JS
def test_public_share_page_assets_exist():
assert "Hermes Shared Conversation" in SHARE_HTML
assert "/static/style.css" in SHARE_HTML
assert "/static/share.js" in SHARE_HTML
assert "function _shareLoad()" in SHARE_JS
assert "/api/share/" in SHARE_JS
def test_session_action_menu_exposes_public_share_actions():
assert "_appendSessionShareActions(menu, session);" in SESSIONS_JS
assert "function _createOrRefreshSessionShare(session){" in SESSIONS_JS
assert "new URL(`/share/${encodeURIComponent(token)}`,location.origin).href;" in SESSIONS_JS
assert "api('/api/share/create'" in SESSIONS_JS
assert "api('/api/share/revoke'" in SESSIONS_JS
assert "t('stop_sharing_session')" in SESSIONS_JS
def test_public_share_page_does_not_render_provider_details_blocks():
assert "provider-error-details" not in SHARE_JS
assert "provider_details_label||'Provider details'" not in SHARE_JS