diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5e396d0d..3d271b81 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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/ Public read-only HTML shell for a sanitized shared transcript snapshot + /api/share/ 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} diff --git a/README.md b/README.md index d8e52ae7..6e085875 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/TESTING.md b/TESTING.md index 41216ca6..3b2ed95e 100644 --- a/TESTING.md +++ b/TESTING.md @@ -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/` 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) diff --git a/api/auth.py b/api/auth.py index 5c89c4d0..2eb38266 100644 --- a/api/auth.py +++ b/api/auth.py @@ -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) diff --git a/api/helpers.py b/api/helpers.py index 2b413d1b..e606d62a 100644 --- a/api/helpers.py +++ b/api/helpers.py @@ -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) diff --git a/api/models.py b/api/models.py index be3d45d0..bc93f625 100644 --- a/api/models.py +++ b/api/models.py @@ -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, diff --git a/api/routes.py b/api/routes.py index e5e8e587..23c5b699 100644 --- a/api/routes.py +++ b/api/routes.py @@ -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 diff --git a/api/shares.py b/api/shares.py new file mode 100644 index 00000000..9c740657 --- /dev/null +++ b/api/shares.py @@ -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 diff --git a/static/boot.js b/static/boot.js index 10166587..a05b8306 100644 --- a/static/boot.js +++ b/static/boot.js @@ -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; diff --git a/static/i18n.js b/static/i18n.js index b6a1c718..0b027384 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -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', diff --git a/static/index.html b/static/index.html index 1d617469..41e0d242 100644 --- a/static/index.html +++ b/static/index.html @@ -940,6 +940,11 @@
+ + + + + diff --git a/static/panels.js b/static/panels.js index 9fc2a1e0..204cb0ac 100644 --- a/static/panels.js +++ b/static/panels.js @@ -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); } diff --git a/static/sessions.js b/static/sessions.js index 04557bf4..4395045b 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -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'), diff --git a/static/share.html b/static/share.html new file mode 100644 index 00000000..e7dcf832 --- /dev/null +++ b/static/share.html @@ -0,0 +1,82 @@ + + + + + + Shared Conversation - Hermes + + + + + + + + + + + + diff --git a/static/share.js b/static/share.js new file mode 100644 index 00000000..e49b706d --- /dev/null +++ b/static/share.js @@ -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,'&') + .replace(//g,'>') + .replace(/"/g,'"') + .replace(/'/g,'''); +} + +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=''; + 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||'')) + : `

${_shareEscapeHtml(msg.content||'')}

`; + row.innerHTML= + ``+ + ``; + 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=``; +} + +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(); +}); diff --git a/tests/test_session_public_share.py b/tests/test_session_public_share.py new file mode 100644 index 00000000..0eb082ce --- /dev/null +++ b/tests/test_session_public_share.py @@ -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() diff --git a/tests/test_session_public_share_static.py b/tests/test_session_public_share_static.py new file mode 100644 index 00000000..954d18e8 --- /dev/null +++ b/tests/test_session_public_share_static.py @@ -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