mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-24 18:50:15 +00:00
release: v0.50.240
## Release v0.50.240 Batch release of 13 PRs that passed full triage + code review + test suite (3199 tests, 0 failures). --- ### Added - **Compact tool activity mode** (`simplified_tool_calling`, default on) — groups tool calls and thinking traces into a single collapsed "Activity" disclosure card per assistant turn. Also adds a new **Calm Console** theme with earth/slate palette and serif prose. @Michaelyklam — #1282 - **PDF first-page preview** — `MEDIA:` `.pdf` files render a canvas thumbnail via PDF.js CDN (4 MB cap). **HTML sandbox iframe** — `.html`/`.htm` files render inline in a sandboxed `<iframe srcdoc>` (256 KB cap). 10 i18n keys × 7 locales. @bergeouss — #1280, closes #480 #482 - **Inline Excalidraw diagram preview** — `.excalidraw` files render as pure SVG (no external deps; rectangles, ellipses, diamonds, text, lines, arrows, freehand; 512 KB cap). @bergeouss — #1279, closes #479 - **Inline CSV table rendering** — fenced `csv` blocks and `MEDIA:` CSV files render as scrollable HTML tables with auto-separator detection. @bergeouss — #1277, closes #485 - **Inline SVG, audio, and video rendering** — SVG as `<img>`, audio as `<audio controls>`, video as `<video controls>`. @bergeouss — #1276, closes #481 - **Batch session select mode** — multi-select sessions for bulk Archive/Delete/Move. 11 i18n keys × 7 locales. @bergeouss — #1275, closes #568 - **Collapsible skill category headers** — click to collapse/expand without re-render; state persists across filter cycles. @bergeouss — #1281 - **`providers.only_configured` setting** — opt-in flag to restrict the model picker to explicitly configured providers. @KingBoyAndGirl — #1268 - **OpenCode Go model catalog** — adds Kimi K2.6, DeepSeek V4 Pro/Flash, MiMo V2.5/Pro, Qwen3.6/3.5 Plus. @nesquena-hermes — #1284, closes #1269 ### Fixed - **Profile `TERMINAL_CWD` TypeError** — `_build_agent_thread_env()` helper merges env before `_set_thread_env()` call. @hi-friday — #1266 - **Service worker subpath cache bypass** — regex now matches `/api/*` under any mount prefix. @Michaelyklam — #1278 - **SSE client disconnect leaks** — `TimeoutError`/`OSError` treated as clean disconnects; server backlog 64, threads daemonized; session list renders before saved-session restore. @KayZz69 — #1267 - **i18n locale corrections** — Korean MCP strings (23), Chinese MCP strings (23), zh-Hant missing keys (41), de missing keys (229). @bergeouss — #1274, closes #1273 --- ### Test results ``` 3199 passed, 2 skipped, 3 xpassed in 72.79s ``` ### PRs on hold (not included) #1265 (draft), #1271 (superseded by #1266), #1272 (skipped XSS tests), #1232 (partial test run), #1222 (review questions open), #1134 (live-server tests), #1132 (superseded by #1134), #1108 (negative UX review), #1084 (empty description)
This commit is contained in:
@@ -4,6 +4,25 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
## [v0.50.240] — 2026-04-30
|
||||
|
||||
### Added
|
||||
- **Compact tool activity mode (`simplified_tool_calling`)** — new setting (default on) groups tool calls and thinking traces into a single collapsed "Activity" disclosure card per assistant turn instead of showing every step as a separate visible row. Keeps long agent runs readable while keeping full transparency a click away. Also adds a **Calm Console** theme (`calm`) with earth/slate palette and serif assistant prose. (`api/config.py`, `static/ui.js`, `static/panels.js`, `static/boot.js`, `static/style.css`, `DESIGN.md`) @Michaelyklam — PR #1282
|
||||
- **PDF first-page preview** — `MEDIA:` links to `.pdf` files now lazy-load a canvas preview of page 1 via PDF.js CDN (4 MB cap, download fallback). **HTML sandbox iframe** — `.html`/`.htm` files render inline in a sandboxed `<iframe srcdoc>` with `allow-scripts` only (256 KB cap). 10 new i18n keys × 7 locales. (`static/ui.js`, `static/style.css`, `static/i18n.js`) @bergeouss — PR #1280, closes #480 #482
|
||||
- **Inline Excalidraw diagram preview** — `.excalidraw` files render as a pure-SVG diagram inline (no external deps; supports rectangles, ellipses, diamonds, text, lines, arrows, freehand; 512 KB cap). (`static/ui.js`, `static/i18n.js`) @bergeouss — PR #1279, closes #479
|
||||
- **Inline CSV table rendering** — fenced `csv` blocks and `MEDIA:` CSV files render as scrollable HTML tables with auto-separator detection (comma/semicolon/tab) and quote stripping. (`static/ui.js`, `static/i18n.js`) @bergeouss — PR #1277, closes #485
|
||||
- **Inline SVG, audio, and video rendering** — SVG files render as `<img>`, audio files as `<audio controls>`, video files as `<video controls>`. File attachment previews in the composer also get inline display. (`static/ui.js`, `static/i18n.js`) @bergeouss — PR #1276, closes #481
|
||||
- **Batch session select mode** — a new select-mode toggle in the session list lets users choose multiple sessions and perform bulk Archive, Delete, or Move to Project actions. 11 new i18n keys × 7 locales. (`static/sessions.js`, `static/i18n.js`) @bergeouss — PR #1275, closes #568
|
||||
- **Collapsible skill category headers** — clicking a category header in the Skills panel collapses or expands its contents without a full re-render; collapsed state persists across filter cycles. (`static/panels.js`, `static/style.css`) @bergeouss — PR #1281
|
||||
- **`providers.only_configured` setting** — opt-in config flag that restricts the model picker to providers explicitly configured in `config.yaml`. Default false (existing behavior unchanged). (`api/config.py`) @KingBoyAndGirl — PR #1268
|
||||
- **OpenCode Go model catalog updated** — adds 7 new models: Kimi K2.6, DeepSeek V4 Pro/Flash, MiMo V2.5/Pro, Qwen3.6/3.5 Plus. (`api/config.py`) @nesquena-hermes — PR #1284, closes #1269
|
||||
|
||||
### Fixed
|
||||
- **Profile `TERMINAL_CWD` no longer causes TypeError** — `_build_agent_thread_env()` merges all thread-local env keys into one dict before passing to `_set_thread_env()`, so a `terminal.cwd` entry in `config.yaml` can no longer conflict with the per-session workspace path. (`api/streaming.py`) @hi-friday — PR #1266
|
||||
- **Service worker no longer caches subpath API routes** — the SW cache-bypass regex now matches `/api/*` under any mount prefix (e.g. `/hermes/api/*`), fixing stale session lists when running behind a subpath reverse proxy. (`static/sw.js`) @Michaelyklam — PR #1278
|
||||
- **SSE client disconnect leaks resolved** — `TimeoutError` and `OSError` are now treated as normal disconnects; `QuietHTTPServer` suppresses them silently. Server backlog raised to 64 and handler threads daemonized. Session list renders before saved-session restore so a client-side boot error can no longer leave the sidebar empty. (`api/routes.py`, `server.py`, `static/boot.js`, `static/sessions.js`) @KayZz69 — PR #1267
|
||||
- **i18n: Korean and Chinese MCP keys corrected, missing locale keys added** — 23 Korean MCP strings that had English text replaced with correct Korean; 23 Chinese (zh) strings that had Spanish text replaced with Chinese; 41 missing keys added to zh-Hant; 229 missing keys added to de. (`static/i18n.js`) @bergeouss — PR #1274, closes #1273
|
||||
|
||||
## [v0.50.239] — 2026-04-29
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
---
|
||||
version: alpha
|
||||
name: Hermes Calm Console
|
||||
description: "A restrained agent control surface: conversational content first, tool traces as quiet metadata, minimal chrome."
|
||||
colors:
|
||||
primary: "#EAE0D5"
|
||||
secondary: "#C6AC8F"
|
||||
tertiary: "#C6AC8F"
|
||||
neutral: "#0A0908"
|
||||
surface: "#22333B"
|
||||
surfaceSubtle: "#11100E"
|
||||
borderSubtle: "#3B4A50"
|
||||
ink: "#0A0908"
|
||||
success: "#86C08B"
|
||||
warning: "#E0B15D"
|
||||
error: "#F87171"
|
||||
typography:
|
||||
body-md:
|
||||
fontFamily: "Georgia, Times New Roman, serif"
|
||||
fontSize: 15px
|
||||
fontWeight: 400
|
||||
lineHeight: 1.68
|
||||
body-sm:
|
||||
fontFamily: "-apple-system, BlinkMacSystemFont, Segoe UI, Inter, system-ui, sans-serif"
|
||||
fontSize: 12px
|
||||
fontWeight: 400
|
||||
lineHeight: 1.45
|
||||
user-message:
|
||||
fontFamily: "-apple-system, BlinkMacSystemFont, Segoe UI, Inter, system-ui, sans-serif"
|
||||
fontSize: 14px
|
||||
fontWeight: 400
|
||||
lineHeight: 1.55
|
||||
mono-xs:
|
||||
fontFamily: "SF Mono, ui-monospace, monospace"
|
||||
fontSize: 11px
|
||||
fontWeight: 500
|
||||
lineHeight: 1.55
|
||||
rounded:
|
||||
sm: 4px
|
||||
md: 8px
|
||||
lg: 12px
|
||||
pill: 999px
|
||||
spacing:
|
||||
xs: 4px
|
||||
sm: 8px
|
||||
md: 12px
|
||||
lg: 16px
|
||||
components:
|
||||
app-shell:
|
||||
backgroundColor: "{colors.neutral}"
|
||||
textColor: "{colors.primary}"
|
||||
rounded: "{rounded.sm}"
|
||||
padding: 16px
|
||||
panel:
|
||||
backgroundColor: "{colors.surface}"
|
||||
textColor: "{colors.primary}"
|
||||
rounded: "{rounded.lg}"
|
||||
padding: 16px
|
||||
border-line:
|
||||
backgroundColor: "{colors.borderSubtle}"
|
||||
textColor: "{colors.primary}"
|
||||
rounded: "{rounded.sm}"
|
||||
padding: 4px
|
||||
state-success:
|
||||
backgroundColor: "{colors.success}"
|
||||
textColor: "{colors.ink}"
|
||||
rounded: "{rounded.sm}"
|
||||
padding: 4px
|
||||
state-warning:
|
||||
backgroundColor: "{colors.warning}"
|
||||
textColor: "{colors.ink}"
|
||||
rounded: "{rounded.sm}"
|
||||
padding: 4px
|
||||
state-error:
|
||||
backgroundColor: "{colors.error}"
|
||||
textColor: "{colors.ink}"
|
||||
rounded: "{rounded.sm}"
|
||||
padding: 4px
|
||||
tool-call-group:
|
||||
backgroundColor: "{colors.neutral}"
|
||||
textColor: "{colors.secondary}"
|
||||
rounded: "{rounded.md}"
|
||||
padding: 4px
|
||||
tool-card:
|
||||
backgroundColor: "{colors.surfaceSubtle}"
|
||||
textColor: "{colors.secondary}"
|
||||
rounded: "{rounded.md}"
|
||||
padding: 8px
|
||||
user-message:
|
||||
backgroundColor: "{colors.tertiary}"
|
||||
textColor: "{colors.ink}"
|
||||
rounded: "{rounded.lg}"
|
||||
padding: 12px
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Hermes WebUI should feel like a calm developer console, not a demo page assembled from colorful cards. The primary artifact is the conversation. Tool calls, thinking traces, context compaction records, token usage, and runtime status are useful, but they are transcript metadata and should sit below the visual priority of user and assistant prose.
|
||||
|
||||
The desired direction is Linear/Vercel precision with a little Claude-style conversational warmth: quiet surfaces, clear spacing, restrained accent use, and progressive disclosure for debugging detail.
|
||||
|
||||
## Colors
|
||||
|
||||
- **Primary (#EAE0D5):** main text on dark surfaces. The warm parchment should feel readable and grounded, not like bright white terminal text.
|
||||
- **Secondary/Tertiary (#C6AC8F):** metadata and restrained accent. Use sparingly for active state, focus, user bubbles, and quiet emphasis.
|
||||
- **Neutral (#0A0908):** app background and ink. This gives the WebUI depth without returning to the previous navy/gold theme.
|
||||
- **Surface (#22333B):** panels, sidebar, and stronger interactive surfaces. It should carry the structure while the conversation remains primary.
|
||||
- **Light surfaces (#EAE0D5 / #F4EEE7):** light mode uses the palette's parchment as the field and a slightly lifted derived surface for panels.
|
||||
- **Semantic colors:** success/warning/error/info are state colors only, not decorative palette choices.
|
||||
|
||||
## Typography
|
||||
|
||||
Use Claude-like split typography: assistant prose gets an editorial serif stack (Georgia as the available substitute for Anthropic Serif), while user bubbles and functional UI stay in a crisp sans stack. This keeps the bot voice calmer and more readable without making controls feel bookish. Use monospace only for code, file paths, commands, tool names, and compact metadata. Avoid making whole cards feel like terminal output unless they actually are logs.
|
||||
|
||||
Scale should stay tight: 11px metadata, 12px labels, 14px body, 16–18px headings. Do not proliferate 10px/10.5px/12.5px one-offs unless there is a real layout constraint.
|
||||
|
||||
## Layout
|
||||
|
||||
Conversation rhythm:
|
||||
|
||||
1. User message — right aligned, compact bubble.
|
||||
2. Assistant content — left aligned, prose-first, no heavy bubble.
|
||||
3. Tool/thinking/context traces — quiet disclosure rows inside the assistant turn.
|
||||
4. Raw logs/details — hidden until explicitly expanded.
|
||||
|
||||
Metadata should not break the reading flow. A turn that used ten tools should read as one assistant turn with one compact `Used 10 tools` disclosure, not ten content cards.
|
||||
|
||||
## Elevation & Depth
|
||||
|
||||
Use almost no shadows in the transcript. Shadows are reserved for popovers, dropdowns, modal dialogs, and floating controls. Cards inside chat should use either a subtle border or a subtle tint, not both aggressively.
|
||||
|
||||
## Shapes
|
||||
|
||||
- Rows/list items: `4–8px` radius.
|
||||
- Cards/panels: `8–12px` radius.
|
||||
- Pills: only true chips/badges use `999px`.
|
||||
- Avoid stacks of nested rounded rectangles. If a card contains another card, one of them is probably unnecessary.
|
||||
|
||||
## Components
|
||||
|
||||
### Tool/thinking activity group
|
||||
|
||||
Collapsed by default in settled history and during live runs. Summary line uses one disclosure for internals, e.g. `Activity: thinking + 4 tools · read_file, patch, terminal`. Expanding reveals thinking and individual tool cards together. Thinking and tools should not create separate transcript rows unless there is an error or approval state that needs attention.
|
||||
|
||||
### Tool card
|
||||
|
||||
A tool card is a debug event row, not a chat message. Show icon, name, short target/preview, and status. Arguments and result snippets stay behind expansion. Result snippets should be truncated; full logs belong behind “show more”.
|
||||
|
||||
### Thinking/context cards
|
||||
|
||||
Same visual family as tool-call metadata. They should be quieter than assistant prose and should not use bright tinted full cards unless the user expands them.
|
||||
|
||||
### Composer
|
||||
|
||||
The composer is the command surface. Keep it legible and focused: modest radius, subtle border, transparent inactive chips, no theatrical hover scaling.
|
||||
|
||||
## Do's and Don'ts
|
||||
|
||||
Do:
|
||||
|
||||
- Collapse noisy agent internals by default.
|
||||
- Use one accent color at a time.
|
||||
- Prefer neutral borders and restrained surfaces.
|
||||
- Make debug traces accessible and inspectable without making them visually dominant.
|
||||
- Add stable class/data hooks for future visual regression tests.
|
||||
|
||||
Don't:
|
||||
|
||||
- Render every tool call as a first-class chat card.
|
||||
- Mix gold, cyan, purple, orange, red, and green as decorative colors in the same viewport.
|
||||
- Add new hardcoded radius/color values when a token exists.
|
||||
- Use shadows, gradients, and hover transforms for routine controls.
|
||||
- Hide important error or approval states; those are allowed to be prominent because they require action.
|
||||
+31
-9
@@ -748,13 +748,20 @@ _PROVIDER_MODELS = {
|
||||
],
|
||||
# OpenCode Go — flat-rate models via opencode.ai/go ($10/month)
|
||||
"opencode-go": [
|
||||
{"id": "glm-5.1", "label": "GLM-5.1"},
|
||||
{"id": "glm-5", "label": "GLM-5"},
|
||||
{"id": "kimi-k2.5", "label": "Kimi K2.5"},
|
||||
{"id": "mimo-v2-pro", "label": "MiMo V2 Pro"},
|
||||
{"id": "mimo-v2-omni", "label": "MiMo V2 Omni"},
|
||||
{"id": "minimax-m2.7", "label": "MiniMax M2.7"},
|
||||
{"id": "minimax-m2.5", "label": "MiniMax M2.5"},
|
||||
{"id": "glm-5.1", "label": "GLM-5.1"},
|
||||
{"id": "glm-5", "label": "GLM-5"},
|
||||
{"id": "kimi-k2.5", "label": "Kimi K2.5"},
|
||||
{"id": "kimi-k2.6", "label": "Kimi K2.6"},
|
||||
{"id": "deepseek-v4-pro", "label": "DeepSeek V4 Pro"},
|
||||
{"id": "deepseek-v4-flash","label": "DeepSeek V4 Flash"},
|
||||
{"id": "mimo-v2-pro", "label": "MiMo V2 Pro"},
|
||||
{"id": "mimo-v2-omni", "label": "MiMo V2 Omni"},
|
||||
{"id": "mimo-v2.5-pro", "label": "MiMo V2.5 Pro"},
|
||||
{"id": "mimo-v2.5", "label": "MiMo V2.5"},
|
||||
{"id": "minimax-m2.7", "label": "MiniMax M2.7"},
|
||||
{"id": "minimax-m2.5", "label": "MiniMax M2.5"},
|
||||
{"id": "qwen3.6-plus", "label": "Qwen3.6 Plus"},
|
||||
{"id": "qwen3.5-plus", "label": "Qwen3.5 Plus"},
|
||||
],
|
||||
# 'gemini' is the hermes_cli provider ID for Google AI Studio
|
||||
# Model IDs are bare — sent directly to:
|
||||
@@ -1793,6 +1800,19 @@ def get_available_models() -> dict:
|
||||
if not _has_unnamed:
|
||||
detected_providers.discard("custom")
|
||||
|
||||
# Filter providers if providers.only_configured is set
|
||||
providers_cfg = cfg.get("providers", {})
|
||||
only_show_configured = providers_cfg.get("only_configured", False) if isinstance(providers_cfg, dict) else False
|
||||
if only_show_configured:
|
||||
configured_providers = set()
|
||||
if active_provider:
|
||||
configured_providers.add(active_provider)
|
||||
cfg_providers = cfg.get("providers", {})
|
||||
if isinstance(cfg_providers, dict):
|
||||
configured_providers.update(cfg_providers.keys())
|
||||
# Only show providers that are both detected and configured
|
||||
detected_providers = detected_providers.intersection(configured_providers)
|
||||
|
||||
# 5. Build model groups
|
||||
if detected_providers:
|
||||
for pid in sorted(detected_providers):
|
||||
@@ -2061,7 +2081,7 @@ _SETTINGS_DEFAULTS = {
|
||||
"show_cli_sessions": False, # merge CLI sessions from state.db into the sidebar
|
||||
"sync_to_insights": False, # mirror WebUI token usage to state.db for /insights
|
||||
"check_for_updates": True, # check if webui/agent repos are behind upstream
|
||||
"theme": "dark", # light | dark | system
|
||||
"theme": "dark", # light | dark | system | calm
|
||||
"skin": "default", # accent color skin: default | ares | mono | slate | poseidon | sisyphus | charizard
|
||||
"language": "en", # UI locale code; must match a key in static/i18n.js LOCALES
|
||||
"bot_name": os.getenv(
|
||||
@@ -2070,13 +2090,14 @@ _SETTINGS_DEFAULTS = {
|
||||
"sound_enabled": False, # play notification sound when assistant finishes
|
||||
"notifications_enabled": False, # browser notification when tab is in background
|
||||
"show_thinking": True, # show/hide thinking/reasoning blocks in chat view
|
||||
"simplified_tool_calling": True, # group tools/thinking into one quiet activity disclosure
|
||||
"sidebar_density": "compact", # compact | detailed
|
||||
"auto_title_refresh_every": "0", # adaptive title refresh: 0=off, 5/10/20=every N exchanges
|
||||
"busy_input_mode": "queue", # behavior when sending while agent is running: queue | interrupt | steer
|
||||
"password_hash": None, # PBKDF2-HMAC-SHA256 hash; None = auth disabled
|
||||
}
|
||||
_SETTINGS_LEGACY_DROP_KEYS = {"assistant_language", "bubble_layout", "default_model"}
|
||||
_SETTINGS_THEME_VALUES = {"light", "dark", "system"}
|
||||
_SETTINGS_THEME_VALUES = {"light", "dark", "system", "calm"}
|
||||
_SETTINGS_SKIN_VALUES = {
|
||||
"default",
|
||||
"ares",
|
||||
@@ -2185,6 +2206,7 @@ _SETTINGS_BOOL_KEYS = {
|
||||
"sound_enabled",
|
||||
"notifications_enabled",
|
||||
"show_thinking",
|
||||
"simplified_tool_calling",
|
||||
}
|
||||
# Language codes are validated as short alphanumeric BCP-47-like tags (e.g. 'en', 'zh', 'fr')
|
||||
_SETTINGS_LANG_RE = __import__("re").compile(r"^[a-zA-Z]{2,10}(-[a-zA-Z0-9]{2,8})?$")
|
||||
|
||||
+15
-2
@@ -18,6 +18,19 @@ from urllib.parse import parse_qs
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Treat stalled/closed HTTP clients as normal disconnects. Long-lived SSE
|
||||
# connections often end this way when a browser tab sleeps, a phone switches
|
||||
# networks, or Tailscale leaves the socket half-closed. If these bubble to the
|
||||
# request handler, the server logs 500s and can leave CLOSE-WAIT sockets around
|
||||
# until the OS-level timeout fires.
|
||||
_CLIENT_DISCONNECT_ERRORS = (
|
||||
BrokenPipeError,
|
||||
ConnectionResetError,
|
||||
ConnectionAbortedError,
|
||||
TimeoutError,
|
||||
OSError,
|
||||
)
|
||||
|
||||
# ── Cron run tracking ────────────────────────────────────────────────────────
|
||||
# Track job IDs currently being executed so the frontend can poll status.
|
||||
_RUNNING_CRON_JOBS: dict[str, float] = {} # job_id → start_timestamp
|
||||
@@ -2188,7 +2201,7 @@ def _handle_sse_stream(handler, parsed):
|
||||
_sse(handler, event, data)
|
||||
if event in ("stream_end", "error", "cancel"):
|
||||
break
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
except _CLIENT_DISCONNECT_ERRORS:
|
||||
pass
|
||||
return True
|
||||
|
||||
@@ -2390,7 +2403,7 @@ def _handle_gateway_sse_stream(handler, parsed):
|
||||
if event_data is None:
|
||||
break # watcher is stopping
|
||||
_sse(handler, event_data.get('type', 'sessions_changed'), event_data)
|
||||
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
|
||||
except _CLIENT_DISCONNECT_ERRORS:
|
||||
pass
|
||||
finally:
|
||||
watcher.unsubscribe(q)
|
||||
|
||||
+23
-6
@@ -68,6 +68,23 @@ _API_SAFE_MSG_KEYS = {'role', 'content', 'tool_calls', 'tool_call_id', 'name', '
|
||||
_NATIVE_IMAGE_MAX_BYTES = 20 * 1024 * 1024
|
||||
|
||||
|
||||
def _build_agent_thread_env(profile_runtime_env: dict | None, workspace: str, session_id: str, profile_home: str) -> dict:
|
||||
"""Build thread-local agent env with per-run values overriding profile defaults.
|
||||
|
||||
Profile runtime env may include TERMINAL_CWD from config.yaml. Passing it as
|
||||
**kwargs alongside an explicit TERMINAL_CWD raises TypeError before the
|
||||
agent starts, so merge into one dict first and let the active workspace win.
|
||||
"""
|
||||
env = dict(profile_runtime_env or {})
|
||||
env.update({
|
||||
'TERMINAL_CWD': str(workspace),
|
||||
'HERMES_EXEC_ASK': '1',
|
||||
'HERMES_SESSION_KEY': session_id,
|
||||
'HERMES_HOME': profile_home,
|
||||
})
|
||||
return env
|
||||
|
||||
|
||||
def _attachment_name(att) -> str:
|
||||
if isinstance(att, dict):
|
||||
return str(att.get('name') or att.get('filename') or att.get('path') or '').strip()
|
||||
@@ -1419,13 +1436,13 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
|
||||
_profile_home = os.environ.get('HERMES_HOME', '')
|
||||
_profile_runtime_env = {}
|
||||
|
||||
_set_thread_env(
|
||||
**_profile_runtime_env,
|
||||
TERMINAL_CWD=str(s.workspace),
|
||||
HERMES_EXEC_ASK='1',
|
||||
HERMES_SESSION_KEY=session_id,
|
||||
HERMES_HOME=_profile_home,
|
||||
_thread_env = _build_agent_thread_env(
|
||||
_profile_runtime_env,
|
||||
str(s.workspace),
|
||||
session_id,
|
||||
_profile_home,
|
||||
)
|
||||
_set_thread_env(**_thread_env)
|
||||
# Still set process-level env as fallback for tools that bypass thread-local
|
||||
# Acquire lock only for the env mutation, then release before the agent runs.
|
||||
# The finally block re-acquires to restore — keeping critical sections short
|
||||
|
||||
@@ -24,20 +24,22 @@ from api.updates import WEBUI_VERSION
|
||||
|
||||
class QuietHTTPServer(ThreadingHTTPServer):
|
||||
"""Custom HTTP server that silently handles common network errors."""
|
||||
daemon_threads = True
|
||||
request_queue_size = 64
|
||||
|
||||
def handle_error(self, request, client_address):
|
||||
"""Override to suppress logging for common client disconnect errors."""
|
||||
exc_type, exc_value, _ = sys.exc_info()
|
||||
|
||||
# Silently ignore common connection errors caused by client disconnects
|
||||
if exc_type in (ConnectionResetError, BrokenPipeError, ConnectionAbortedError):
|
||||
if exc_type in (ConnectionResetError, BrokenPipeError, ConnectionAbortedError, TimeoutError):
|
||||
return
|
||||
|
||||
# Also handle socket errors that indicate client disconnect
|
||||
if exc_type is socket.error:
|
||||
if issubclass(exc_type, OSError):
|
||||
# errno 54 is Connection reset by peer on macOS/BSD
|
||||
# errno 104 is Connection reset by peer on Linux
|
||||
if exc_value.errno in (54, 104, 32): # ECONNRESET, EPIPE
|
||||
if getattr(exc_value, 'errno', None) in (32, 54, 104, 110): # EPIPE, ECONNRESET, ETIMEDOUT
|
||||
return
|
||||
|
||||
# For other errors, use default logging
|
||||
|
||||
+27
-3
@@ -624,6 +624,12 @@ window.addEventListener('resize',()=>{
|
||||
})();
|
||||
|
||||
// ── Appearance helpers (theme = light/dark/system, skin = accent color) ──────
|
||||
const _THEMES=[
|
||||
{name:'Light', value:'light', colors:['#FEFCF7','#FAF7F0','#B8860B']},
|
||||
{name:'Dark', value:'dark', colors:['#0D0D1A','#141425','#FFD700']},
|
||||
{name:'System', value:'system', colors:['#FEFCF7','#0D0D1A','#B8860B']},
|
||||
{name:'Calm', value:'calm', colors:['#C6AC8F','#EAE0D5','#22333B']},
|
||||
];
|
||||
const _SKINS=[
|
||||
{name:'Default', colors:['#FFD700','#FFBF00','#CD7F32']},
|
||||
{name:'Ares', colors:['#FF4444','#CC3333','#992222']},
|
||||
@@ -633,7 +639,7 @@ const _SKINS=[
|
||||
{name:'Sisyphus', colors:['#A78BFA','#8B5CF6','#7C3AED']},
|
||||
{name:'Charizard',colors:['#FB923C','#F97316','#EA580C']},
|
||||
];
|
||||
const _VALID_THEMES=new Set(['system','dark','light']);
|
||||
const _VALID_THEMES=new Set((_THEMES||[]).map(t=>t.value));
|
||||
const _VALID_SKINS=new Set((_SKINS||[]).map(s=>s.name.toLowerCase()));
|
||||
const _LEGACY_THEME_MAP={
|
||||
slate:{theme:'dark',skin:'slate'},
|
||||
@@ -668,6 +674,7 @@ function _setResolvedTheme(isDark){
|
||||
|
||||
function _applyTheme(name){
|
||||
const normalized=_normalizeAppearance(name,'default');
|
||||
delete document.documentElement.dataset.theme;
|
||||
if(_systemThemeMq&&_onSystemThemeChange){
|
||||
_systemThemeMq.removeEventListener('change',_onSystemThemeChange);
|
||||
_systemThemeMq=null;
|
||||
@@ -680,6 +687,11 @@ function _applyTheme(name){
|
||||
_systemThemeMq.addEventListener('change',_onSystemThemeChange);
|
||||
return;
|
||||
}
|
||||
if(normalized.theme==='calm'){
|
||||
document.documentElement.dataset.theme='calm';
|
||||
_setResolvedTheme(true);
|
||||
return;
|
||||
}
|
||||
_setResolvedTheme(normalized.theme==='dark');
|
||||
}
|
||||
|
||||
@@ -813,6 +825,7 @@ function applyBotName(){
|
||||
window._soundEnabled=!!s.sound_enabled;
|
||||
window._notificationsEnabled=!!s.notifications_enabled;
|
||||
window._showThinking=s.show_thinking!==false;
|
||||
window._simplifiedToolCalling=s.simplified_tool_calling!==false;
|
||||
window._sidebarDensity=(s.sidebar_density==='detailed'?'detailed':'compact');
|
||||
window._busyInputMode=(s.busy_input_mode||'queue');
|
||||
window._botName=s.bot_name||'Hermes';
|
||||
@@ -840,6 +853,7 @@ function applyBotName(){
|
||||
window._soundEnabled=false;
|
||||
window._notificationsEnabled=false;
|
||||
window._showThinking=true;
|
||||
window._simplifiedToolCalling=true;
|
||||
window._sidebarDensity='compact';
|
||||
window._busyInputMode='queue';
|
||||
window._botName='Hermes';
|
||||
@@ -879,9 +893,12 @@ function applyBotName(){
|
||||
if(S.session) syncTopbar();
|
||||
}).catch(()=>{});
|
||||
window._modelDropdownReady=_modelDropdownReady;
|
||||
// Pre-load workspace list so sidebar name is correct from first render
|
||||
// Pre-load workspace list so sidebar name is correct from first render.
|
||||
// Render the session list before restoring the saved conversation so a stale
|
||||
// saved-session/client-side boot error cannot leave the sidebar empty forever.
|
||||
await loadWorkspaceList();
|
||||
await loadOnboardingWizard();
|
||||
await renderSessionList();
|
||||
_initResizePanels();
|
||||
// Workspace panel restore happens AFTER loadSession so we know if
|
||||
// the session has a workspace — prevents the snap-open-then-closed flash (#576).
|
||||
@@ -941,7 +958,14 @@ function applyBotName(){
|
||||
await renderSessionList();
|
||||
// Start real-time gateway session sync if setting is enabled
|
||||
if(typeof startGatewaySSE==='function') startGatewaySSE();
|
||||
})();
|
||||
})().catch(e=>{
|
||||
console.error('[hermes] boot failed', e);
|
||||
try{S._bootReady=true;}catch(_){}
|
||||
try{syncTopbar();}catch(_){}
|
||||
try{syncWorkspacePanelState();}catch(_){}
|
||||
try{$('emptyState').style.display='';}catch(_){}
|
||||
try{if(typeof renderSessionList==='function') void renderSessionList();}catch(_){}
|
||||
});
|
||||
|
||||
// Fix #822 (bfcache path): when the browser restores the page from the
|
||||
// back-forward cache, the async boot IIFE above does NOT re-run, but the
|
||||
|
||||
+581
-24
@@ -55,6 +55,18 @@ const LOCALES = {
|
||||
mcp_deleted: 'MCP server deleted.',
|
||||
mcp_delete_failed: 'Failed to delete MCP server.',
|
||||
mcp_load_failed: 'Failed to load MCP servers.',
|
||||
// PDF preview (#480)
|
||||
pdf_loading: 'Loading PDF {0}…',
|
||||
pdf_too_large: 'PDF too large for inline preview',
|
||||
pdf_no_pages: 'PDF has no pages',
|
||||
pdf_error: 'Failed to render PDF preview',
|
||||
pdf_download: 'Download PDF',
|
||||
// HTML sandbox preview (#482)
|
||||
html_loading: 'Loading HTML preview…',
|
||||
html_too_large: 'HTML too large for inline preview',
|
||||
html_error: 'Failed to render HTML preview',
|
||||
html_open_full: 'Open full page',
|
||||
html_sandbox_label: 'HTML Preview (sandboxed)',
|
||||
thinking: 'Thinking',
|
||||
expand_all: 'Expand all',
|
||||
collapse_all: 'Collapse all',
|
||||
@@ -309,6 +321,17 @@ const LOCALES = {
|
||||
session_duplicate_failed: 'Duplicate failed: ',
|
||||
session_delete: 'Delete conversation',
|
||||
session_delete_desc: 'Permanently remove this conversation',
|
||||
session_select_mode: 'Select',
|
||||
session_select_mode_desc: 'Select conversations to batch manage',
|
||||
session_select_all: 'Select all',
|
||||
session_deselect_all: 'Deselect all',
|
||||
session_selected_count: '{0} selected',
|
||||
session_batch_archive: 'Archive',
|
||||
session_batch_delete: 'Delete',
|
||||
session_batch_move: 'Move to project',
|
||||
session_batch_delete_confirm: 'Delete {0} conversations?',
|
||||
session_batch_archive_confirm: 'Archive {0} conversations?',
|
||||
session_no_selection: 'No conversations selected',
|
||||
// settings panel
|
||||
settings_heading_title: 'Control Center',
|
||||
settings_heading_subtitle: 'Preferences, conversation tools, and system controls.',
|
||||
@@ -741,6 +764,23 @@ const LOCALES = {
|
||||
composer_disabled_clarify: 'Respond to the clarification request',
|
||||
composer_disabled_compression: 'Waiting for compression to finish',
|
||||
composer_disabled_empty: 'Type a message to send',
|
||||
media_audio_label: 'Audio',
|
||||
media_svg_label: 'Diagram',
|
||||
media_video_label: 'Video',
|
||||
csv_loading: 'Loading CSV',
|
||||
csv_too_large: 'CSV file too large for inline rendering',
|
||||
csv_no_data: 'CSV file has insufficient data to render as table',
|
||||
csv_error: 'Failed to load CSV file',
|
||||
csv_header_note: 'First row shown as table header',
|
||||
excalidraw_loading: 'Loading diagram',
|
||||
excalidraw_too_large: 'Excalidraw file too large for inline rendering',
|
||||
excalidraw_invalid: 'Invalid Excalidraw file format',
|
||||
excalidraw_error: 'Failed to load Excalidraw file',
|
||||
excalidraw_label: 'Diagram',
|
||||
excalidraw_download: 'Download',
|
||||
excalidraw_empty: 'Empty diagram',
|
||||
excalidraw_render_error: 'Failed to render diagram',
|
||||
excalidraw_simplified: 'Simplified SVG preview — not pixel-identical to Excalidraw canvas',
|
||||
},
|
||||
|
||||
ru: {
|
||||
@@ -1376,6 +1416,17 @@ const LOCALES = {
|
||||
session_restored: 'Session restored',
|
||||
session_unpin: 'Unpin conversation',
|
||||
session_unpin_desc: 'Remove from pinned',
|
||||
session_select_mode: 'Выбрать',
|
||||
session_select_mode_desc: 'Выберите беседы для массового управления',
|
||||
session_select_all: 'Выбрать все',
|
||||
session_deselect_all: 'Снять выделение',
|
||||
session_selected_count: '{0} выбрано',
|
||||
session_batch_archive: 'В архив',
|
||||
session_batch_delete: 'Удалить',
|
||||
session_batch_move: 'Переместить в проект',
|
||||
session_batch_delete_confirm: 'Удалить {0} бесед(ы)?',
|
||||
session_batch_archive_confirm: 'Архивировать {0} бесед(ы)?',
|
||||
session_no_selection: 'Ничего не выбрано',
|
||||
settings_dropdown_appearance: 'Appearance',
|
||||
settings_dropdown_conversation: 'Conversation',
|
||||
settings_dropdown_preferences: 'Preferences',
|
||||
@@ -1425,6 +1476,33 @@ const LOCALES = {
|
||||
composer_disabled_clarify: 'Ответьте на запрос о разъяснении',
|
||||
composer_disabled_compression: 'Ожидание завершения сжатия',
|
||||
composer_disabled_empty: 'Введите сообщение для отправки',
|
||||
media_audio_label: 'Аудио',
|
||||
media_svg_label: 'Диаграмма',
|
||||
media_video_label: 'Видео',
|
||||
csv_loading: 'Загрузка CSV',
|
||||
csv_too_large: 'CSV-файл слишком большой для встроенного отображения',
|
||||
csv_no_data: 'Недостаточно данных в CSV-файле',
|
||||
csv_error: 'Не удалось загрузить CSV-файл',
|
||||
csv_header_note: 'Первая строка отображается как заголовок таблицы',
|
||||
excalidraw_loading: 'Загрузка диаграммы',
|
||||
excalidraw_too_large: 'Файл Excalidraw слишком большой для отображения',
|
||||
excalidraw_invalid: 'Неверный формат файла Excalidraw',
|
||||
excalidraw_error: 'Не удалось загрузить файл Excalidraw',
|
||||
excalidraw_label: 'Диаграмма',
|
||||
excalidraw_download: 'Скачать',
|
||||
excalidraw_empty: 'Пустая диаграмма',
|
||||
excalidraw_render_error: 'Не удалось отобразить диаграмму',
|
||||
excalidraw_simplified: 'Упрощённый предпросмотр SVG — не полностью идентичен оригиналу Excalidraw',
|
||||
pdf_loading: 'Загрузка PDF {0}…',
|
||||
pdf_too_large: 'PDF слишком большой для встроенного просмотра',
|
||||
pdf_no_pages: 'Не удалось отобразить предварительный просмотр PDF',
|
||||
pdf_error: 'Не удалось загрузить PDF',
|
||||
pdf_download: 'Скачать PDF',
|
||||
html_loading: 'Загрузка предпросмотра HTML…',
|
||||
html_too_large: 'HTML слишком большой для встроенного просмотра',
|
||||
html_error: 'Не удалось загрузить предпросмотр HTML',
|
||||
html_open_full: 'Открыть на всю страницу',
|
||||
html_sandbox_label: 'Предпросмотр HTML',
|
||||
},
|
||||
|
||||
es: {
|
||||
@@ -2054,6 +2132,17 @@ const LOCALES = {
|
||||
session_restored: 'Session restored',
|
||||
session_unpin: 'Unpin conversation',
|
||||
session_unpin_desc: 'Remove from pinned',
|
||||
session_select_mode: 'Seleccionar',
|
||||
session_select_mode_desc: 'Selecciona conversaciones para gestión masiva',
|
||||
session_select_all: 'Seleccionar todo',
|
||||
session_deselect_all: 'Deseleccionar todo',
|
||||
session_selected_count: '{0} seleccionadas',
|
||||
session_batch_archive: 'Archivar',
|
||||
session_batch_delete: 'Eliminar',
|
||||
session_batch_move: 'Mover al proyecto',
|
||||
session_batch_delete_confirm: '¿Eliminar {0} conversaciones?',
|
||||
session_batch_archive_confirm: '¿Archivar {0} conversaciones?',
|
||||
session_no_selection: 'Ninguna conversación seleccionada',
|
||||
settings_dropdown_appearance: 'Appearance',
|
||||
settings_dropdown_conversation: 'Conversation',
|
||||
settings_dropdown_preferences: 'Preferences',
|
||||
@@ -2103,6 +2192,33 @@ const LOCALES = {
|
||||
composer_disabled_clarify: 'Responde a la solicitud de aclaración',
|
||||
composer_disabled_compression: 'Esperando a que finalice la compresión',
|
||||
composer_disabled_empty: 'Escribe un mensaje para enviar',
|
||||
media_audio_label: 'Audio',
|
||||
media_svg_label: 'Diagrama',
|
||||
media_video_label: 'Vídeo',
|
||||
csv_loading: 'Cargando CSV',
|
||||
csv_too_large: 'Archivo CSV demasiado grande para mostrar en línea',
|
||||
csv_no_data: 'El archivo CSV no tiene suficientes datos para mostrar como tabla',
|
||||
csv_error: 'Error al cargar el archivo CSV',
|
||||
csv_header_note: 'La primera fila se muestra como encabezado de tabla',
|
||||
excalidraw_loading: 'Cargando diagrama',
|
||||
excalidraw_too_large: 'Archivo Excalidraw demasiado grande para mostrar en línea',
|
||||
excalidraw_invalid: 'Formato de archivo Excalidraw no válido',
|
||||
excalidraw_error: 'Error al cargar archivo Excalidraw',
|
||||
excalidraw_label: 'Diagrama',
|
||||
excalidraw_download: 'Descargar',
|
||||
excalidraw_empty: 'Diagrama vacío',
|
||||
excalidraw_render_error: 'Error al renderizar el diagrama',
|
||||
excalidraw_simplified: 'Vista previa SVG simplificada — no es idéntica al lienzo de Excalidraw',
|
||||
pdf_loading: 'Cargando PDF {0}…',
|
||||
pdf_too_large: 'PDF demasiado grande para vista previa',
|
||||
pdf_no_pages: 'No se pudo renderizar la vista previa del PDF',
|
||||
pdf_error: 'Error al cargar el PDF',
|
||||
pdf_download: 'Descargar PDF',
|
||||
html_loading: 'Cargando vista previa de HTML…',
|
||||
html_too_large: 'HTML demasiado grande para vista previa',
|
||||
html_error: 'Error al cargar la vista previa de HTML',
|
||||
html_open_full: 'Abrir página completa',
|
||||
html_sandbox_label: 'Vista previa de HTML',
|
||||
},
|
||||
|
||||
de: {
|
||||
@@ -2504,6 +2620,17 @@ const LOCALES = {
|
||||
session_restored: 'Session restored',
|
||||
session_unpin: 'Unpin conversation',
|
||||
session_unpin_desc: 'Remove from pinned',
|
||||
session_select_mode: 'Auswählen',
|
||||
session_select_mode_desc: 'Konversationen für Massenverwaltung auswählen',
|
||||
session_select_all: 'Alle auswählen',
|
||||
session_deselect_all: 'Auswahl aufheben',
|
||||
session_selected_count: '{0} ausgewählt',
|
||||
session_batch_archive: 'Archivieren',
|
||||
session_batch_delete: 'Löschen',
|
||||
session_batch_move: 'Zum Projekt verschieben',
|
||||
session_batch_delete_confirm: '{0} Konversationen löschen?',
|
||||
session_batch_archive_confirm: '{0} Konversationen archivieren?',
|
||||
session_no_selection: 'Keine Konversationen ausgewählt',
|
||||
settings_dropdown_appearance: 'Appearance',
|
||||
settings_dropdown_conversation: 'Conversation',
|
||||
settings_dropdown_preferences: 'Preferences',
|
||||
@@ -2556,6 +2683,262 @@ const LOCALES = {
|
||||
composer_disabled_clarify: 'Auf die Klärungsanfrage antworten',
|
||||
composer_disabled_compression: 'Warte auf Abschluss der Komprimierung',
|
||||
composer_disabled_empty: 'Nachricht eingeben zum Senden',
|
||||
model_custom_label: 'Benutzerdefinierte Modell-ID',
|
||||
model_custom_placeholder: 'z.B. openai/gpt-5.4',
|
||||
model_search_placeholder: 'Modelle suchen…',
|
||||
model_search_no_results: 'Keine Modelle gefunden',
|
||||
session_time_unknown: 'Unbekannt',
|
||||
session_time_minutes_ago: 'Vor {n} Minuten',
|
||||
session_time_hours_ago: 'Vor {n} Stunden',
|
||||
session_time_days_ago: 'Vor {n} Tagen',
|
||||
session_time_last_week: 'Letzte Woche',
|
||||
session_time_bucket_today: 'Heute',
|
||||
session_time_bucket_yesterday: 'Gestern',
|
||||
session_time_bucket_this_week: 'Diese Woche',
|
||||
session_time_bucket_last_week: 'Letzte Woche',
|
||||
session_time_bucket_older: 'Älter',
|
||||
onboarding_badge: 'Ersteinrichtung',
|
||||
onboarding_title: 'Willkommen bei Hermes',
|
||||
onboarding_lead: 'Lassen Sie uns Ihren Agenten einrichten.',
|
||||
onboarding_back: 'Zurück',
|
||||
onboarding_continue: 'Weiter',
|
||||
onboarding_skip: 'Überspringen',
|
||||
onboarding_skipped: 'Einrichtung übersprungen',
|
||||
onboarding_open: 'Einrichtung öffnen',
|
||||
onboarding_step_system_title: 'Agent-Status',
|
||||
onboarding_step_system_desc: 'Hermes Agent muss installiert und erreichbar sein.',
|
||||
onboarding_step_setup_title: 'Einrichtung',
|
||||
onboarding_step_setup_desc: 'Konfigurieren Sie einen Anbieter und ein Modell.',
|
||||
onboarding_step_workspace_title: 'Arbeitsbereich',
|
||||
onboarding_step_workspace_desc: 'Wählen Sie einen Arbeitsbereich für Ihre Dateien.',
|
||||
onboarding_step_password_title: 'Passwort (optional)',
|
||||
onboarding_step_password_desc: 'Sichern Sie den Zugriff mit einem Passwort.',
|
||||
onboarding_step_finish_title: 'Fertig!',
|
||||
onboarding_step_finish_desc: 'Ihr Agent ist bereit.',
|
||||
onboarding_notice_system_ready: 'Agent ist bereit und erreichbar.',
|
||||
onboarding_notice_system_unavailable: 'Agent nicht erreichbar. Bitte starten Sie Hermes Agent.',
|
||||
onboarding_check_agent: 'Agent-Verbindung',
|
||||
onboarding_check_agent_ready: 'Agent erreichbar',
|
||||
onboarding_check_agent_missing: 'Agent nicht erreichbar',
|
||||
onboarding_check_password: 'Passwortschutz',
|
||||
onboarding_check_password_enabled: 'Passwort aktiviert',
|
||||
onboarding_check_password_disabled: 'Kein Passwort',
|
||||
onboarding_check_provider: 'Modellanbieter',
|
||||
onboarding_check_provider_ready: 'Anbieter konfiguriert',
|
||||
onboarding_check_provider_partial: 'Teilweise konfiguriert',
|
||||
onboarding_check_provider_pending: 'Nicht konfiguriert',
|
||||
onboarding_config_file: 'Konfigurationsdatei',
|
||||
onboarding_env_file: 'Umgebungsdatei',
|
||||
onboarding_unknown: 'Unbekannt',
|
||||
onboarding_current_provider: 'Aktueller Anbieter',
|
||||
onboarding_missing_imports: 'Fehlende Abhängigkeiten',
|
||||
onboarding_notice_setup_required: 'Einrichtung erforderlich',
|
||||
onboarding_notice_setup_already_ready: 'Bereits eingerichtet',
|
||||
onboarding_oauth_provider_ready_title: 'Anbieter bereit',
|
||||
onboarding_oauth_provider_ready_body: '{provider} ist konfiguriert. Kein API-Schlüssel erforderlich.',
|
||||
onboarding_oauth_provider_not_ready_title: 'Anbieter nicht eingerichtet',
|
||||
onboarding_oauth_provider_not_ready_body: '{provider} erfordert einen API-Schlüssel.',
|
||||
onboarding_oauth_switch_hint: 'Zu OAuth wechseln',
|
||||
onboarding_notice_workspace: 'Arbeitsbereich empfohlen',
|
||||
onboarding_workspace_label: 'Arbeitsbereich',
|
||||
onboarding_workspace_or_path: 'Name oder Pfad',
|
||||
onboarding_workspace_placeholder: 'z.B. ~/projects/my-app',
|
||||
onboarding_provider_label: 'Anbieter',
|
||||
onboarding_quick_setup_badge: 'Schnelleinrichtung',
|
||||
provider_category_easy_start: 'Einfach starten',
|
||||
provider_category_self_hosted: 'Selbst gehostet',
|
||||
provider_category_specialized: 'Spezialisiert',
|
||||
onboarding_api_key_label: 'API-Schlüssel',
|
||||
onboarding_api_key_placeholder: 'sk-…',
|
||||
onboarding_api_key_help_prefix: 'Gefunden unter',
|
||||
onboarding_base_url_label: 'Base URL',
|
||||
onboarding_base_url_placeholder: 'https://api.openai.com/v1',
|
||||
onboarding_base_url_help: 'Verwenden Sie dies für OpenAI-kompatible Router, selbst gehostete Server, LiteLLM, Ollama, LM Studio, vLLM oder ähnliche Endpunkte.',
|
||||
onboarding_model_label: 'Modell',
|
||||
onboarding_workspace_help: 'Der Arbeitsbereich ist das Stammverzeichnis für Dateien und Terminal-Befehle.',
|
||||
onboarding_custom_model_placeholder: 'Exakte Modell-ID eingeben',
|
||||
onboarding_custom_model_help: 'Geben Sie die genaue Modell-ID ein, die Ihr Server erwartet.',
|
||||
onboarding_notice_password_enabled: 'Passwortschutz aktiviert.',
|
||||
onboarding_notice_password_recommended: 'Empfohlen für den öffentlichen Zugriff.',
|
||||
onboarding_password_label: 'Passwort',
|
||||
onboarding_password_placeholder: 'Passwort festlegen',
|
||||
onboarding_password_help: 'Passwörter werden über die Einstellungs-API gespeichert und serverseitig gehasht.',
|
||||
onboarding_notice_finish: 'Ihr Agent ist bereit!',
|
||||
onboarding_not_set: 'Nicht festgelegt',
|
||||
onboarding_password_skipped: 'Übersprungen',
|
||||
onboarding_finish_help: 'Sie können dies später in den Einstellungen ändern.',
|
||||
onboarding_error_choose_workspace: 'Bitte wählen Sie einen Arbeitsbereich.',
|
||||
onboarding_error_choose_model: 'Bitte wählen Sie ein Modell.',
|
||||
onboarding_error_provider_required: 'Anbieter erforderlich.',
|
||||
onboarding_error_base_url_required: 'Base URL erforderlich.',
|
||||
onboarding_error_workspace_required: 'Arbeitsbereich erforderlich.',
|
||||
onboarding_error_model_required: 'Modell erforderlich.',
|
||||
onboarding_complete: 'Einrichtung abgeschlossen!',
|
||||
error_prefix: 'Fehler: ',
|
||||
not_available: 'Nicht verfügbar',
|
||||
never: 'Nie',
|
||||
add: 'Hinzufügen',
|
||||
add_failed: 'Hinzufügen fehlgeschlagen',
|
||||
remove_failed: 'Entfernen fehlgeschlagen',
|
||||
switch_failed: 'Wechsel fehlgeschlagen',
|
||||
name_required: 'Name erforderlich.',
|
||||
content_required: 'Inhalt erforderlich.',
|
||||
view: 'Anzeigen',
|
||||
dismiss: 'Verwerfen',
|
||||
disable: 'Deaktivieren',
|
||||
cron_no_jobs: 'Keine geplanten Aufgaben.',
|
||||
cron_status_off: 'Inaktiv',
|
||||
cron_status_paused: 'Pausiert',
|
||||
cron_status_error: 'Fehler',
|
||||
cron_status_active: 'Aktiv',
|
||||
cron_status_needs_attention: 'Erfordert Aufmerksamkeit',
|
||||
cron_attention_desc: 'Dieser Job hat Probleme.',
|
||||
cron_attention_croniter_hint: 'Der Croniter-Ausdruck ist möglicherweise ungültig.',
|
||||
cron_attention_resume: 'Fortsetzen',
|
||||
cron_attention_run_once: 'Einmal ausführen',
|
||||
cron_attention_copy_diagnostics: 'Diagnose kopieren',
|
||||
cron_diagnostics_copied: 'Diagnoseinformationen kopiert.',
|
||||
cron_next: 'Nächstes: ',
|
||||
cron_last: 'Letztes: ',
|
||||
cron_run_now: 'Jetzt ausführen',
|
||||
cron_pause: 'Pausieren',
|
||||
cron_resume: 'Fortsetzen',
|
||||
cron_job_name_placeholder: 'Aufgabenname',
|
||||
cron_schedule_placeholder: '0 9 * * *',
|
||||
cron_prompt_placeholder: 'Aufgabenbeschreibung',
|
||||
cron_last_output: 'Letzte Ausgabe',
|
||||
cron_all_runs: 'Alle Ausführungen',
|
||||
cron_hide_runs: 'Ausführungen ausblenden',
|
||||
cron_no_runs_yet: 'Noch keine Ausführungen.',
|
||||
cron_schedule_required_example: 'z.B. 0 9 * * *, every 2h, 30m',
|
||||
cron_schedule_required: 'Zeitplan erforderlich.',
|
||||
cron_prompt_required: 'Beschreibung erforderlich.',
|
||||
cron_job_created: 'Aufgabe erstellt.',
|
||||
cron_job_triggered: 'Aufgabe gestartet.',
|
||||
cron_job_paused: 'Aufgabe pausiert.',
|
||||
cron_job_resumed: 'Aufgabe fortgesetzt.',
|
||||
cron_job_updated: 'Aufgabe aktualisiert.',
|
||||
cron_delete_confirm_title: 'Aufgabe löschen',
|
||||
cron_delete_confirm_message: 'Sind Sie sicher?',
|
||||
cron_job_deleted: 'Aufgabe gelöscht.',
|
||||
cron_completion_status: 'Abschlussstatus',
|
||||
status_failed: 'Fehlgeschlagen',
|
||||
status_completed: 'Abgeschlossen',
|
||||
todos_no_active: 'Keine aktiven Aufgaben.',
|
||||
clear_conversation_title: 'Konversation löschen',
|
||||
clear_conversation_message: 'Alle Nachrichten werden gelöscht.',
|
||||
clear_failed: 'Löschen fehlgeschlagen.',
|
||||
skills_no_match: 'Keine passende Fähigkeit.',
|
||||
linked_files: 'Verknüpfte Dateien',
|
||||
skill_load_failed: 'Fähigkeit konnte nicht geladen werden.',
|
||||
skill_file_load_failed: 'Datei konnte nicht geladen werden.',
|
||||
skill_name_required: 'Name erforderlich.',
|
||||
skill_updated: 'Fähigkeit aktualisiert.',
|
||||
skill_created: 'Fähigkeit erstellt.',
|
||||
skill_deleted: 'Fähigkeit gelöscht.',
|
||||
skill_delete_confirm: 'Fähigkeit wirklich löschen?',
|
||||
skills_empty_title: 'Keine Fähigkeiten',
|
||||
skills_empty_sub: 'Installieren Sie Fähigkeiten.',
|
||||
skills_edit: 'Bearbeiten',
|
||||
skills_delete: 'Löschen',
|
||||
skills_back_to: 'Zurück',
|
||||
tasks_empty_title: 'Keine Aufgaben',
|
||||
tasks_empty_sub: 'Verwalten Sie geplante Aufgaben.',
|
||||
workspaces_empty_title: 'Keine Arbeitsbereiche',
|
||||
workspaces_empty_sub: 'Richten Sie Arbeitsbereiche ein.',
|
||||
profiles_empty_title: 'Keine Profile',
|
||||
profiles_empty_sub: 'Erstellen Sie Profile.',
|
||||
memory_notes_label: 'Notizen',
|
||||
memory_saved: 'Notiz gespeichert.',
|
||||
my_notes: 'Meine Notizen',
|
||||
user_profile: 'Benutzerprofil',
|
||||
no_notes_yet: 'Noch keine Notizen.',
|
||||
no_profile_yet: 'Noch kein Profil.',
|
||||
workspace_choose_path: 'Arbeitsbereich wählen',
|
||||
workspace_choose_path_meta: 'Wählen Sie ein Verzeichnis.',
|
||||
workspace_manage: 'Arbeitsbereiche verwalten',
|
||||
workspace_manage_meta: 'Hinzufügen, Umbenennen, Entfernen.',
|
||||
workspace_use_title: 'Arbeitsbereich verwenden',
|
||||
workspace_use: 'Arbeitsbereich',
|
||||
workspace_add_path_placeholder: '~/projects/my-app',
|
||||
workspace_paths_validated_hint: 'Pfad ist gültig.',
|
||||
workspace_added: 'Arbeitsbereich hinzugefügt.',
|
||||
workspace_renamed: 'Arbeitsbereich umbenannt.',
|
||||
workspace_remove_confirm_title: 'Arbeitsbereich entfernen',
|
||||
workspace_remove_confirm_message: 'Arbeitsbereich entfernen?',
|
||||
workspace_removed: 'Arbeitsbereich entfernt.',
|
||||
workspace_switch_prompt_title: 'Zum Arbeitsbereich wechseln',
|
||||
workspace_switch_prompt_message: 'Wechsel zum Arbeitsbereich?',
|
||||
workspace_switch_prompt_confirm: 'Wechseln',
|
||||
workspace_switch_prompt_placeholder: 'Arbeitsbereichsname',
|
||||
workspace_not_added: 'Arbeitsbereich nicht hinzugefügt.',
|
||||
workspace_already_saved: 'Arbeitsbereich bereits vorhanden.',
|
||||
workspace_busy_switch: 'Arbeitsbereich kann nicht gewechselt werden.',
|
||||
discard_file_edits_title: 'Änderungen verwerfen',
|
||||
discard_file_edits_message: 'Ungespeicherte Änderungen verwerfen?',
|
||||
workspace_switched_to: 'Arbeitsbereich gewechselt zu: ',
|
||||
profiles_no_profiles: 'Keine Profile vorhanden.',
|
||||
profile_api_keys_configured: 'API-Schlüssel konfiguriert',
|
||||
profile_gateway_running: 'Gateway läuft',
|
||||
profile_gateway_stopped: 'Gateway gestoppt',
|
||||
profile_active: 'Aktiv',
|
||||
profile_no_configuration: 'Keine Konfiguration',
|
||||
profile_skill_count: '{count} Fähigkeiten',
|
||||
profile_use: 'Verwenden',
|
||||
profile_switch_title: 'Profil wechseln',
|
||||
profile_delete_title: 'Profil löschen',
|
||||
profile_default_label: 'Standard',
|
||||
profile_name_placeholder: 'Profilname',
|
||||
profile_clone_label: 'Klonen',
|
||||
profile_base_url_placeholder: 'https://api.openai.com/v1',
|
||||
profile_api_key_placeholder: 'sk-…',
|
||||
manage_profiles: 'Profile verwalten',
|
||||
profiles_load_failed: 'Profile konnten nicht geladen werden.',
|
||||
profiles_busy_switch: 'Profil kann nicht gewechselt werden.',
|
||||
profile_switched_new_conversation: 'Profil gewechselt. Neue Konversation.',
|
||||
profile_switched: 'Profil gewechselt.',
|
||||
profile_name_rule: 'Nur alphanumerische Zeichen.',
|
||||
profile_base_url_rule: 'Muss mit http:// oder https:// beginnen.',
|
||||
profile_created: 'Profil erstellt.',
|
||||
profile_delete_confirm_title: 'Profil löschen',
|
||||
profile_deleted: 'Profil gelöscht.',
|
||||
active_conversation_none: 'Keine aktive Konversation',
|
||||
active_conversation_meta: 'Aktive Konversation',
|
||||
settings_unsaved_changes: 'Ungespeicherte Änderungen',
|
||||
sign_out_failed: 'Abmeldung fehlgeschlagen.',
|
||||
disable_auth_confirm_title: 'Passwortschutz deaktivieren',
|
||||
disable_auth_confirm_message: 'Zugriff ohne Authentifizierung?',
|
||||
auth_disabled: 'Passwortschutz deaktiviert.',
|
||||
disable_auth_failed: 'Deaktivieren fehlgeschlagen.',
|
||||
bg_error_single: 'Hinterfrage {count} fehlgeschlagen.',
|
||||
bg_error_multi: '{count} Hinterfragen fehlgeschlagen.',
|
||||
media_audio_label: 'Audio',
|
||||
media_svg_label: 'Diagramm',
|
||||
media_video_label: 'Video',
|
||||
csv_loading: 'CSV wird geladen',
|
||||
csv_too_large: 'CSV-Datei zu groß für Inline-Anzeige',
|
||||
csv_no_data: 'CSV-Datei enthält nicht genügend Daten',
|
||||
csv_error: 'CSV-Datei konnte nicht geladen werden',
|
||||
csv_header_note: 'Erste Zeile wird als Tabellenüberschrift angezeigt',
|
||||
excalidraw_loading: 'Diagramm wird geladen',
|
||||
excalidraw_too_large: 'Excalidraw-Datei zu groß für Inline-Anzeige',
|
||||
excalidraw_invalid: 'Ungültiges Excalidraw-Dateiformat',
|
||||
excalidraw_error: 'Excalidraw-Datei konnte nicht geladen werden',
|
||||
excalidraw_label: 'Diagramm',
|
||||
excalidraw_download: 'Herunterladen',
|
||||
excalidraw_empty: 'Leeres Diagramm',
|
||||
excalidraw_render_error: 'Diagramm konnte nicht gerendert werden',
|
||||
excalidraw_simplified: 'Vereinfachte SVG-Vorschau — nicht pixelgenau zum Excalidraw-Canvas',
|
||||
pdf_loading: 'PDF wird geladen {0}…',
|
||||
pdf_too_large: 'PDF zu groß für Inline-Vorschau',
|
||||
pdf_no_pages: 'PDF-Vorschau konnte nicht gerendert werden',
|
||||
pdf_error: 'PDF konnte nicht geladen werden',
|
||||
pdf_download: 'PDF herunterladen',
|
||||
html_loading: 'HTML-Vorschau wird geladen…',
|
||||
html_too_large: 'HTML zu groß für Inline-Vorschau',
|
||||
html_error: 'HTML-Vorschau konnte nicht geladen werden',
|
||||
html_open_full: 'Vollständige Seite öffnen',
|
||||
html_sandbox_label: 'HTML-Vorschau',
|
||||
},
|
||||
|
||||
zh: {
|
||||
@@ -2583,32 +2966,32 @@ const LOCALES = {
|
||||
diff_loading: '加载 diff',
|
||||
diff_error: '无法加载 patch 文件',
|
||||
diff_too_large: 'Patch 文件过大,无法内联显示',
|
||||
tree_view: 'Árbol',
|
||||
raw_view: 'Original',
|
||||
tree_view: '树形',
|
||||
raw_view: '原始',
|
||||
parse_failed_note: '\u89e3\u6790\u5931\u8d25',
|
||||
you: '\u4f60',
|
||||
mcp_servers_title: 'Servidores MCP',
|
||||
mcp_servers_desc: 'Gestiona servidores MCP en config.yaml.',
|
||||
mcp_no_servers: 'No hay servidores MCP configurados.',
|
||||
mcp_add_server: '+ Añadir servidor',
|
||||
mcp_field_name: 'Nombre del servidor',
|
||||
mcp_transport_label: 'Tipo de transporte',
|
||||
mcp_field_command: 'Comando',
|
||||
mcp_field_args: 'Argumentos (separados por comas)',
|
||||
mcp_servers_title: 'MCP 服务器',
|
||||
mcp_servers_desc: '管理 config.yaml 中配置的 MCP 服务器。',
|
||||
mcp_no_servers: '未配置 MCP 服务器。',
|
||||
mcp_add_server: '+ 添加服务器',
|
||||
mcp_field_name: '服务器名称',
|
||||
mcp_transport_label: '传输类型',
|
||||
mcp_field_command: '命令',
|
||||
mcp_field_args: '参数(逗号分隔)',
|
||||
mcp_field_url: 'URL',
|
||||
mcp_field_timeout: 'Tiempo de espera (segundos)',
|
||||
mcp_save: 'Guardar',
|
||||
mcp_cancel: 'Cancelar',
|
||||
mcp_name_required: 'El nombre es obligatorio.',
|
||||
mcp_url_required: 'Se requiere URL para HTTP.',
|
||||
mcp_command_required: 'Se requiere comando para stdio.',
|
||||
mcp_saved: 'Servidor MCP guardado.',
|
||||
mcp_save_failed: 'Error al guardar servidor MCP.',
|
||||
mcp_delete_confirm_title: 'Eliminar servidor MCP',
|
||||
mcp_delete_confirm_message: '¿Eliminar servidor MCP «{0}»? Esta acción no se puede deshacer.',
|
||||
mcp_deleted: 'Servidor MCP eliminado.',
|
||||
mcp_delete_failed: 'Error al eliminar servidor MCP.',
|
||||
mcp_load_failed: 'Error al cargar servidores MCP.',
|
||||
mcp_field_timeout: '超时(秒)',
|
||||
mcp_save: '保存',
|
||||
mcp_cancel: '取消',
|
||||
mcp_name_required: '服务器名称为必填项。',
|
||||
mcp_url_required: 'HTTP 传输需要 URL。',
|
||||
mcp_command_required: 'stdio 传输需要命令。',
|
||||
mcp_saved: 'MCP 服务器已保存。',
|
||||
mcp_save_failed: 'MCP 服务器保存失败。',
|
||||
mcp_delete_confirm_title: '删除 MCP 服务器',
|
||||
mcp_delete_confirm_message: '确定删除 MCP 服务器「{0}」吗?此操作不可撤销。',
|
||||
mcp_deleted: 'MCP 服务器已删除。',
|
||||
mcp_delete_failed: 'MCP 服务器删除失败。',
|
||||
mcp_load_failed: 'MCP 服务器加载失败。',
|
||||
thinking: '\u601d\u8003\u8fc7\u7a0b',
|
||||
expand_all: '\u5168\u90e8\u5c55\u5f00',
|
||||
collapse_all: '\u5168\u90e8\u6298\u53e0',
|
||||
@@ -3182,6 +3565,17 @@ const LOCALES = {
|
||||
session_restored: 'Session restored',
|
||||
session_unpin: 'Unpin conversation',
|
||||
session_unpin_desc: 'Remove from pinned',
|
||||
session_select_mode: '选择',
|
||||
session_select_mode_desc: '选择会话以批量管理',
|
||||
session_select_all: '全选',
|
||||
session_deselect_all: '取消全选',
|
||||
session_selected_count: '已选 {0} 个',
|
||||
session_batch_archive: '归档',
|
||||
session_batch_delete: '删除',
|
||||
session_batch_move: '移动到项目',
|
||||
session_batch_delete_confirm: '删除 {0} 个会话?',
|
||||
session_batch_archive_confirm: '归档 {0} 个会话?',
|
||||
session_no_selection: '未选择任何会话',
|
||||
settings_dropdown_appearance: 'Appearance',
|
||||
settings_dropdown_conversation: 'Conversation',
|
||||
settings_dropdown_preferences: 'Preferences',
|
||||
@@ -3231,6 +3625,34 @@ const LOCALES = {
|
||||
composer_disabled_clarify: '请回复上方的澄清请求',
|
||||
composer_disabled_compression: '等待压缩完成',
|
||||
composer_disabled_empty: '请输入消息后发送',
|
||||
|
||||
pdf_loading: '正在加载 PDF {0}…',
|
||||
pdf_too_large: 'PDF 文件过大,无法内联预览',
|
||||
pdf_no_pages: '无法渲染 PDF 预览',
|
||||
pdf_error: 'PDF 加载失败',
|
||||
pdf_download: '下载 PDF',
|
||||
html_loading: '正在加载 HTML 预览…',
|
||||
html_too_large: 'HTML 文件过大,无法内联预览',
|
||||
html_error: 'HTML 预览加载失败',
|
||||
html_open_full: '打开完整页面',
|
||||
html_sandbox_label: 'HTML 预览',
|
||||
media_audio_label: '音频',
|
||||
media_svg_label: '图表',
|
||||
media_video_label: '视频',
|
||||
csv_loading: '加载 CSV',
|
||||
csv_too_large: 'CSV 文件过大,无法内联渲染',
|
||||
csv_no_data: 'CSV 文件数据不足,无法渲染为表格',
|
||||
csv_error: '加载 CSV 文件失败',
|
||||
csv_header_note: '第一行显示为表格标题',
|
||||
excalidraw_loading: '加载图表',
|
||||
excalidraw_too_large: 'Excalidraw 文件过大,无法内联渲染',
|
||||
excalidraw_invalid: '无效的 Excalidraw 文件格式',
|
||||
excalidraw_error: '加载 Excalidraw 文件失败',
|
||||
excalidraw_label: '图表',
|
||||
excalidraw_download: '下载',
|
||||
excalidraw_empty: '空图表',
|
||||
excalidraw_render_error: '渲染图表失败',
|
||||
excalidraw_simplified: '简化 SVG 预览 — 与 Excalidraw 画布不完全相同',
|
||||
},
|
||||
|
||||
// Traditional Chinese (zh-Hant)
|
||||
@@ -3441,6 +3863,17 @@ const LOCALES = {
|
||||
session_duplicate_failed: '複製失敗:',
|
||||
session_delete: '刪除對話',
|
||||
session_delete_desc: '永久移除這個對話',
|
||||
session_select_mode: '選取',
|
||||
session_select_mode_desc: '選取會話以批次管理',
|
||||
session_select_all: '全選',
|
||||
session_deselect_all: '取消全選',
|
||||
session_selected_count: '已選 {0} 個',
|
||||
session_batch_archive: '封存',
|
||||
session_batch_delete: '刪除',
|
||||
session_batch_move: '移至專案',
|
||||
session_batch_delete_confirm: '刪除 {0} 個會話?',
|
||||
session_batch_archive_confirm: '封存 {0} 個會話?',
|
||||
session_no_selection: '未選取任何會話',
|
||||
// settings panel
|
||||
settings_heading_title: '控制中心',
|
||||
settings_heading_subtitle: '偏好設定、對話工具與系統控制。',
|
||||
@@ -3957,6 +4390,91 @@ const LOCALES = {
|
||||
composer_disabled_clarify: '\u8acb\u56de\u8986\u4e0a\u65b9\u7684\u6f84\u6e05\u8981\u6c42',
|
||||
composer_disabled_compression: '\u7b49\u5f85\u58d3\u7e2e\u5b8c\u6210',
|
||||
composer_disabled_empty: '\u8acb\u8f38\u5165\u8a0a\u606f\u5f8c\u50b3\u9001',
|
||||
// Queued messages
|
||||
queued_label: '回應後發送',
|
||||
queued_count: (n) => n === 1 ? '1 已排隊' : `${n} 已排隊`,
|
||||
queued_cancel: '取消排隊訊息',
|
||||
|
||||
// Skills
|
||||
skill_deleted: '技能已刪除',
|
||||
skill_delete_confirm: '確定要刪除此技能嗎?',
|
||||
skills_empty_title: '尚無技能',
|
||||
skills_empty_sub: '安裝技能以擴展代理的能力',
|
||||
skills_edit: '編輯',
|
||||
skills_delete: '刪除',
|
||||
skills_back_to: '返回技能列表',
|
||||
|
||||
// Tasks
|
||||
tasks_empty_title: '無任務',
|
||||
tasks_empty_sub: '管理排程與背景任務',
|
||||
|
||||
// Workspaces
|
||||
workspaces_empty_title: '尚無工作區',
|
||||
workspaces_empty_sub: '設定工作區以組織您的專案',
|
||||
|
||||
// Profiles
|
||||
profiles_empty_title: '尚無設定檔',
|
||||
profiles_empty_sub: '建立設定檔以快速切換環境',
|
||||
|
||||
// Skill editor
|
||||
skill_name: '名稱',
|
||||
skill_category: '類別',
|
||||
skill_category_placeholder: '例如: devops',
|
||||
skill_content: '內容',
|
||||
skill_content_placeholder: '輸入技能內容...',
|
||||
skill_rename_not_supported: '此技能不支援重新命名',
|
||||
skill_metadata: '中繼資料',
|
||||
|
||||
// Cron labels
|
||||
cron_name_label: '任務名稱',
|
||||
cron_schedule_label: '排程',
|
||||
cron_schedule_hint: '例如: 0 9 * * *, every 2h, 30m',
|
||||
cron_prompt_label: '提示',
|
||||
cron_deliver_label: '發送至',
|
||||
cron_deliver_local: '僅本地儲存',
|
||||
cron_skills_label: '技能',
|
||||
cron_skills_placeholder: '選用技能(逗號分隔)',
|
||||
cron_skills_edit_hint: '定義要載入的技能',
|
||||
|
||||
// Workspace labels
|
||||
workspace_name_label: '名稱',
|
||||
workspace_name_placeholder: '例如: main-project',
|
||||
workspace_path_label: '路徑',
|
||||
workspace_path_required: '工作區路徑為必填',
|
||||
workspace_path_readonly: '路徑由環境設定檔決定,無法變更',
|
||||
workspace_new_title: '新增工作區',
|
||||
|
||||
// Profile labels
|
||||
profile_name_label: '名稱',
|
||||
profile_base_url_label: 'Base URL',
|
||||
profile_api_key_label: 'API 金鑰',
|
||||
media_audio_label: '音訊',
|
||||
media_svg_label: '圖表',
|
||||
media_video_label: '影片',
|
||||
csv_loading: '載入 CSV',
|
||||
csv_too_large: 'CSV 檔案過大,無法內嵌渲染',
|
||||
csv_no_data: 'CSV 檔案資料不足,無法渲染為表格',
|
||||
csv_error: '載入 CSV 檔案失敗',
|
||||
csv_header_note: '第一列顯示為表格標題',
|
||||
excalidraw_loading: '載入圖表',
|
||||
excalidraw_too_large: 'Excalidraw 檔案過大,無法內嵌渲染',
|
||||
excalidraw_invalid: '無效的 Excalidraw 檔案格式',
|
||||
excalidraw_error: '載入 Excalidraw 檔案失敗',
|
||||
excalidraw_label: '圖表',
|
||||
excalidraw_download: '下載',
|
||||
excalidraw_empty: '空圖表',
|
||||
excalidraw_render_error: '渲染圖表失敗',
|
||||
excalidraw_simplified: '簡化 SVG 預覽 — 與 Excalidraw 畫布不完全相同',
|
||||
pdf_loading: '正在載入 PDF {0}…',
|
||||
pdf_too_large: 'PDF 檔案過大,無法內嵌預覽',
|
||||
pdf_no_pages: '無法渲染 PDF 預覽',
|
||||
pdf_error: 'PDF 載入失敗',
|
||||
pdf_download: '下載 PDF',
|
||||
html_loading: '正在載入 HTML 預覽…',
|
||||
html_too_large: 'HTML 檔案過大,無法內嵌預覽',
|
||||
html_error: 'HTML 預覽載入失敗',
|
||||
html_open_full: '開啟完整頁面',
|
||||
html_sandbox_label: 'HTML 預覽',
|
||||
},
|
||||
|
||||
|
||||
@@ -4898,6 +5416,17 @@ const LOCALES = {
|
||||
session_duplicate_failed: 'Duplicate failed: ',
|
||||
session_delete: 'Delete conversation',
|
||||
session_delete_desc: 'Permanently remove this conversation',
|
||||
session_select_mode: '선택',
|
||||
session_select_mode_desc: '일괄 관리할 대화를 선택하세요',
|
||||
session_select_all: '전체 선택',
|
||||
session_deselect_all: '전체 해제',
|
||||
session_selected_count: '{0}개 선택됨',
|
||||
session_batch_archive: '보관',
|
||||
session_batch_delete: '삭제',
|
||||
session_batch_move: '프로젝트로 이동',
|
||||
session_batch_delete_confirm: '{0}개의 대화를 삭제하시겠습니까?',
|
||||
session_batch_archive_confirm: '{0}개의 대화를 보관하시겠습니까?',
|
||||
session_no_selection: '선택된 대화가 없습니다',
|
||||
// settings panel
|
||||
settings_heading_title: '제어 센터',
|
||||
settings_heading_subtitle: '환경설정, 대화 도구, 시스템 제어.',
|
||||
@@ -5322,7 +5851,35 @@ const LOCALES = {
|
||||
composer_disabled_clarify: '위의 명확화 요청에 응답하세요',
|
||||
composer_disabled_compression: '압축 완료 대기 중',
|
||||
composer_disabled_empty: '메시지를 입력하세요',
|
||||
}
|
||||
|
||||
pdf_loading: 'PDF {0} 로드 중…',
|
||||
pdf_too_large: 'PDF가 인라인 미리보기에 너무 큼',
|
||||
pdf_no_pages: 'PDF 미리보기를 렌더링할 수 없음',
|
||||
pdf_error: 'PDF 로드 실패',
|
||||
pdf_download: 'PDF 다운로드',
|
||||
html_loading: 'HTML 미리보기 로드 중…',
|
||||
html_too_large: 'HTML이 인라인 미리보기에 너무 큼',
|
||||
html_error: 'HTML 미리보기 로드 실패',
|
||||
html_open_full: '전체 페이지 열기',
|
||||
html_sandbox_label: 'HTML 미리보기',
|
||||
media_audio_label: '오디오',
|
||||
media_svg_label: '다이어그램',
|
||||
media_video_label: '비디오',
|
||||
csv_loading: 'CSV 로딩 중',
|
||||
csv_too_large: 'CSV 파일이 너무 커서 인라인 렌더링할 수 없습니다',
|
||||
csv_no_data: 'CSV 파일에 표시할 데이터가 부족합니다',
|
||||
csv_error: 'CSV 파일을 로드하지 못했습니다',
|
||||
csv_header_note: '첫 번째 행이 테이블 헤더로 표시됩니다',
|
||||
excalidraw_loading: '다이어그램 로딩 중',
|
||||
excalidraw_too_large: 'Excalidraw 파일이 너무 커서 인라인 렌더링할 수 없습니다',
|
||||
excalidraw_invalid: '잘못된 Excalidraw 파일 형식',
|
||||
excalidraw_error: 'Excalidraw 파일을 로드하지 못했습니다',
|
||||
excalidraw_label: '다이어그램',
|
||||
excalidraw_download: '다운로드',
|
||||
excalidraw_empty: '빈 다이어그램',
|
||||
excalidraw_render_error: '다이어그램 렌더링 실패',
|
||||
excalidraw_simplified: '단순화된 SVG 미리보기 — Excalidraw 캔버스와 픽셀 동일하지 않음',
|
||||
},
|
||||
};
|
||||
|
||||
// Active locale — defaults to English; overridden by loadLocale() at boot.
|
||||
|
||||
+14
-1
@@ -592,7 +592,7 @@
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label data-i18n="settings_label_theme">Theme</label>
|
||||
<div id="themePickerGrid" style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:4px">
|
||||
<div id="themePickerGrid" style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-top:4px">
|
||||
<button type="button" data-theme-val="light" onclick="_pickTheme('light')" class="theme-pick-btn" style="border:1px solid var(--border2);border-radius:10px;padding:10px 8px;text-align:center;cursor:pointer;background:none;transition:all .15s">
|
||||
<div style="width:100%;height:40px;border-radius:6px;background:#fff;border:1px solid rgba(0,0,0,.12);margin-bottom:6px;display:flex;align-items:center;justify-content:center">
|
||||
<svg width="16" height="16" fill="none" stroke="#999" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
|
||||
@@ -611,6 +611,12 @@
|
||||
</div>
|
||||
<span style="font-size:12px;font-weight:500;color:var(--text)">System</span>
|
||||
</button>
|
||||
<button type="button" data-theme-val="calm" onclick="_pickTheme('calm')" class="theme-pick-btn" style="border:1px solid var(--border2);border-radius:10px;padding:10px 8px;text-align:center;cursor:pointer;background:none;transition:all .15s">
|
||||
<div style="width:100%;height:40px;border-radius:6px;background:linear-gradient(135deg,#0A0908,#22333B 55%,#C6AC8F);border:1px solid rgba(198,172,143,.35);margin-bottom:6px;display:flex;align-items:center;justify-content:center">
|
||||
<svg width="16" height="16" fill="none" stroke="#EAE0D5" stroke-width="2" viewBox="0 0 24 24"><path d="M4 19h16"/><path d="M6 15l4-8 4 8"/><path d="M8 12h4"/><path d="M17 7v8"/></svg>
|
||||
</div>
|
||||
<span style="font-size:12px;font-weight:500;color:var(--text)">Calm</span>
|
||||
</button>
|
||||
</div>
|
||||
<input type="hidden" id="settingsTheme" value="dark">
|
||||
</div>
|
||||
@@ -697,6 +703,13 @@
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_token_usage">Displays input/output token count below each assistant reply. Also toggled with <code>/usage</code>.</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="settingsSimplifiedToolCalling" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||
<span>Compact tool activity</span>
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px">Group thinking and tool calls into one collapsed activity section per assistant turn.</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label for="settingsSidebarDensity" data-i18n="settings_label_sidebar_density">Sidebar density</label>
|
||||
<select id="settingsSidebarDensity" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px">
|
||||
|
||||
+35
-2
@@ -866,10 +866,31 @@ async function loadSkills() {
|
||||
try {
|
||||
const data = await api('/api/skills');
|
||||
_skillsData = data.skills || [];
|
||||
// Prune collapsed state to only keep categories present in fresh data,
|
||||
// avoiding stale keys when categories are renamed or removed server-side.
|
||||
const liveCats = new Set(_skillsData.map(s => s.category || '(general)'));
|
||||
for (const c of _collapsedCats) { if (!liveCats.has(c)) _collapsedCats.delete(c); }
|
||||
renderSkills(_skillsData);
|
||||
} catch(e) { box.innerHTML = `<div style="padding:12px;color:var(--accent);font-size:12px">Error: ${esc(e.message)}</div>`; }
|
||||
}
|
||||
|
||||
let _collapsedCats = new Set(); // persisted collapsed state across re-renders
|
||||
|
||||
function _toggleCatCollapse(cat) {
|
||||
if (_collapsedCats.has(cat)) _collapsedCats.delete(cat);
|
||||
else _collapsedCats.add(cat);
|
||||
// Toggle DOM without full re-render
|
||||
document.querySelectorAll('.skills-category').forEach(sec => {
|
||||
const header = sec.querySelector('.skills-cat-header');
|
||||
if (header && header.dataset.cat === cat) {
|
||||
const collapsed = _collapsedCats.has(cat);
|
||||
sec.classList.toggle('collapsed', collapsed);
|
||||
header.querySelector('.cat-chevron').style.transform = collapsed ? '' : 'rotate(90deg)';
|
||||
sec.querySelectorAll('.skill-item').forEach(el => el.style.display = collapsed ? 'none' : '');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderSkills(skills) {
|
||||
const query = ($('skillsSearch').value || '').toLowerCase();
|
||||
const filtered = query ? skills.filter(s =>
|
||||
@@ -888,12 +909,19 @@ function renderSkills(skills) {
|
||||
box.innerHTML = '';
|
||||
if (!filtered.length) { box.innerHTML = `<div style="padding:12px;color:var(--muted);font-size:12px">${esc(t('skills_no_match'))}</div>`; return; }
|
||||
for (const [cat, items] of Object.entries(cats).sort()) {
|
||||
const collapsed = _collapsedCats.has(cat);
|
||||
const sec = document.createElement('div');
|
||||
sec.className = 'skills-category';
|
||||
sec.innerHTML = `<div class="skills-cat-header">${li('folder',12)} ${esc(cat)} <span style="opacity:.5">(${items.length})</span></div>`;
|
||||
sec.className = 'skills-category' + (collapsed ? ' collapsed' : '');
|
||||
const hdr = document.createElement('div');
|
||||
hdr.className = 'skills-cat-header';
|
||||
hdr.dataset.cat = cat;
|
||||
hdr.innerHTML = `<span class="cat-chevron" style="display:inline-flex;transition:transform .15s;${collapsed ? '' : 'transform:rotate(90deg)'}">${li('chevron-right',12)}</span> ${esc(cat)} <span style="opacity:.5">(${items.length})</span>`;
|
||||
hdr.onclick = () => _toggleCatCollapse(cat);
|
||||
sec.appendChild(hdr);
|
||||
for (const skill of items.sort((a,b) => a.name.localeCompare(b.name))) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'skill-item';
|
||||
el.style.display = collapsed ? 'none' : '';
|
||||
el.innerHTML = `<span class="skill-name">${esc(skill.name)}</span><span class="skill-desc">${esc(skill.description||'')}</span>`;
|
||||
el.onclick = () => openSkill(skill.name, el);
|
||||
sec.appendChild(el);
|
||||
@@ -2677,6 +2705,8 @@ async function loadSettingsPanel(){
|
||||
}
|
||||
const showUsageCb=$('settingsShowTokenUsage');
|
||||
if(showUsageCb){showUsageCb.checked=!!settings.show_token_usage;showUsageCb.addEventListener('change',_markSettingsDirty,{once:false});}
|
||||
const simplifiedToolCb=$('settingsSimplifiedToolCalling');
|
||||
if(simplifiedToolCb){simplifiedToolCb.checked=settings.simplified_tool_calling!==false;simplifiedToolCb.addEventListener('change',_markSettingsDirty,{once:false});}
|
||||
const showCliCb=$('settingsShowCliSessions');
|
||||
if(showCliCb){showCliCb.checked=!!settings.show_cli_sessions;showCliCb.addEventListener('change',_markSettingsDirty,{once:false});}
|
||||
const syncCb=$('settingsSyncInsights');
|
||||
@@ -2979,6 +3009,7 @@ function _applySavedSettingsUi(saved, body, opts){
|
||||
window._soundEnabled=body.sound_enabled;
|
||||
window._notificationsEnabled=body.notifications_enabled;
|
||||
window._showThinking=body.show_thinking!==false;
|
||||
window._simplifiedToolCalling=body.simplified_tool_calling!==false;
|
||||
window._sidebarDensity=sidebarDensity==='detailed'?'detailed':'compact';
|
||||
window._busyInputMode=body.busy_input_mode||'queue';
|
||||
window._botName=body.bot_name||'Hermes';
|
||||
@@ -2999,6 +3030,7 @@ function _applySavedSettingsUi(saved, body, opts){
|
||||
_settingsHermesDefaultModelOnOpen=body.default_model||_settingsHermesDefaultModelOnOpen||'';
|
||||
// Sync window._defaultModel so newSession() uses the just-saved default without a reload (#908).
|
||||
if(body.default_model) window._defaultModel=body.default_model;
|
||||
if(typeof clearMessageRenderCache==='function') clearMessageRenderCache();
|
||||
renderMessages();
|
||||
if(typeof syncTopbar==='function') syncTopbar();
|
||||
if(typeof renderSessionList==='function') renderSessionList();
|
||||
@@ -3070,6 +3102,7 @@ async function saveSettings(andClose){
|
||||
body.skin=skin;
|
||||
body.language=language;
|
||||
body.show_token_usage=showTokenUsage;
|
||||
body.simplified_tool_calling=!!($('settingsSimplifiedToolCalling')||{}).checked;
|
||||
body.show_cli_sessions=showCliSessions;
|
||||
body.sync_to_insights=!!($('settingsSyncInsights')||{}).checked;
|
||||
body.check_for_updates=!!($('settingsCheckUpdates')||{}).checked;
|
||||
|
||||
+153
-1
@@ -631,6 +631,8 @@ async function _ensureAllMessagesLoaded() {
|
||||
let _allSessions = []; // cached for search filter
|
||||
let _renamingSid = null; // session_id currently being renamed (blocks list re-renders)
|
||||
let _showArchived = false; // toggle to show archived sessions
|
||||
let _sessionSelectMode = false; // batch select mode
|
||||
const _selectedSessions = new Set(); // selected session IDs
|
||||
let _allProjects = []; // cached project list
|
||||
let _activeProject = null; // project_id filter (null = show all)
|
||||
let _showAllProfiles = false; // false = filter to active profile only
|
||||
@@ -638,6 +640,111 @@ let _sessionActionMenu = null;
|
||||
let _sessionActionAnchor = null;
|
||||
let _sessionActionSessionId = null;
|
||||
|
||||
// ── Batch select mode ──
|
||||
function toggleSessionSelectMode(){
|
||||
_sessionSelectMode=!_sessionSelectMode;
|
||||
_selectedSessions.clear();
|
||||
renderSessionListFromCache();
|
||||
}
|
||||
function exitSessionSelectMode(){
|
||||
_sessionSelectMode=false;
|
||||
_selectedSessions.clear();
|
||||
const bar=$('batchActionBar');
|
||||
if(bar) bar.style.display='none';
|
||||
renderSessionListFromCache();
|
||||
}
|
||||
function toggleSessionSelect(sid){
|
||||
if(_selectedSessions.has(sid)) _selectedSessions.delete(sid);
|
||||
else _selectedSessions.add(sid);
|
||||
_updateBatchActionBar();
|
||||
const cb=document.querySelector('.session-select-cb[data-sid="'+sid+'"]');
|
||||
const item=cb?cb.closest('.session-item'):null;
|
||||
if(item){item.classList.toggle('selected',_selectedSessions.has(sid));if(cb)cb.checked=_selectedSessions.has(sid);}
|
||||
}
|
||||
function selectAllSessions(){
|
||||
_selectedSessions.clear();
|
||||
document.querySelectorAll('.session-select-cb').forEach(cb=>{
|
||||
const sid=cb.dataset.sid;
|
||||
if(sid){_selectedSessions.add(sid);cb.checked=true;const item=cb.closest('.session-item');if(item)item.classList.add('selected');}
|
||||
});
|
||||
_updateBatchActionBar();
|
||||
}
|
||||
function deselectAllSessions(){
|
||||
_selectedSessions.clear();
|
||||
document.querySelectorAll('.session-select-cb').forEach(cb=>{cb.checked=false;const item=cb.closest('.session-item');if(item)item.classList.remove('selected');});
|
||||
_updateBatchActionBar();
|
||||
}
|
||||
function _updateBatchActionBar(){
|
||||
const bar=$('batchActionBar');if(!bar)return;
|
||||
const count=_selectedSessions.size;
|
||||
bar.style.display=count>0?'':'none';
|
||||
const badge=bar.querySelector('.batch-count');
|
||||
if(badge) badge.textContent=t('session_selected_count',count);
|
||||
}
|
||||
function _renderBatchActionBar(){
|
||||
const bar=$('batchActionBar');if(!bar)return;
|
||||
bar.innerHTML='';bar.style.display=_selectedSessions.size>0?'':'none';
|
||||
const countBadge=document.createElement('span');countBadge.className='batch-count';
|
||||
countBadge.textContent=t('session_selected_count',_selectedSessions.size);bar.appendChild(countBadge);
|
||||
// Archive
|
||||
const archiveBtn=document.createElement('button');archiveBtn.className='batch-action-btn';
|
||||
archiveBtn.textContent=t('session_batch_archive');
|
||||
archiveBtn.onclick=async()=>{
|
||||
const ids=[..._selectedSessions];
|
||||
const ok=await showConfirmDialog({message:t('session_batch_archive_confirm',ids.length),confirmLabel:t('session_batch_archive'),danger:true});
|
||||
if(!ok)return;
|
||||
try{await Promise.all(ids.map(sid=>api('/api/session/archive',{method:'POST',body:JSON.stringify({session_id:sid,archived:true})})));
|
||||
showToast(t('session_archived'));exitSessionSelectMode();await renderSessionList();
|
||||
}catch(e){showToast('Archive failed: '+(e.message||e));}
|
||||
};bar.appendChild(archiveBtn);
|
||||
// Move
|
||||
const moveBtn=document.createElement('button');moveBtn.className='batch-action-btn';
|
||||
moveBtn.textContent=t('session_batch_move');
|
||||
moveBtn.onclick=()=>{_showBatchProjectPicker();};bar.appendChild(moveBtn);
|
||||
// Delete
|
||||
const deleteBtn=document.createElement('button');deleteBtn.className='batch-action-btn batch-action-btn-danger';
|
||||
deleteBtn.textContent=t('session_batch_delete');
|
||||
deleteBtn.onclick=async()=>{
|
||||
const ids=[..._selectedSessions];
|
||||
const ok=await showConfirmDialog({message:t('session_batch_delete_confirm',ids.length),confirmLabel:t('delete_title'),danger:true});
|
||||
if(!ok)return;
|
||||
try{await Promise.all(ids.map(sid=>api('/api/session/delete',{method:'POST',body:JSON.stringify({session_id:sid})})));
|
||||
if(S.session&&ids.includes(S.session.session_id)){
|
||||
S.session=null;S.messages=[];S.entries=[];localStorage.removeItem('hermes-webui-session');
|
||||
const remaining=await api('/api/sessions');
|
||||
if(remaining.sessions&&remaining.sessions.length){await loadSession(remaining.sessions[0].session_id);}
|
||||
else{$('msgInner').innerHTML='';$('emptyState').style.display='';}
|
||||
}
|
||||
showToast(t('session_delete')+' ('+ids.length+')');exitSessionSelectMode();await renderSessionList();
|
||||
}catch(e){showToast('Delete failed: '+(e.message||e));}
|
||||
};bar.appendChild(deleteBtn);
|
||||
}
|
||||
function _showBatchProjectPicker(){
|
||||
const ids=[..._selectedSessions];if(!ids.length)return;
|
||||
document.querySelectorAll('.project-picker').forEach(p=>p.remove());
|
||||
const picker=document.createElement('div');picker.className='project-picker';
|
||||
const none=document.createElement('div');none.className='project-picker-item';none.textContent='No project';
|
||||
none.onclick=async()=>{picker.remove();
|
||||
try{await Promise.all(ids.map(sid=>api('/api/session/move',{method:'POST',body:JSON.stringify({session_id:sid,project_id:null})})));
|
||||
showToast('Removed from project');exitSessionSelectMode();await renderSessionList();
|
||||
}catch(e){showToast('Move failed: '+(e.message||e));}
|
||||
};picker.appendChild(none);
|
||||
for(const p of(_allProjects||[])){
|
||||
const item=document.createElement('div');item.className='project-picker-item';
|
||||
if(p.color){const dot=document.createElement('span');dot.className='color-dot';
|
||||
dot.style.cssText='width:6px;height:6px;border-radius:50%;background:'+p.color+';flex-shrink:0;';item.appendChild(dot);}
|
||||
const name=document.createElement('span');name.textContent=p.name;item.appendChild(name);
|
||||
item.onclick=async()=>{picker.remove();
|
||||
try{await Promise.all(ids.map(sid=>api('/api/session/move',{method:'POST',body:JSON.stringify({session_id:sid,project_id:p.project_id})})));
|
||||
showToast('Moved to '+p.name);exitSessionSelectMode();await renderSessionList();
|
||||
}catch(e){showToast('Move failed: '+(e.message||e));}
|
||||
};picker.appendChild(item);
|
||||
}
|
||||
document.body.appendChild(picker);picker.style.cssText='position:fixed;bottom:60px;left:50%;transform:translateX(-50%);z-index:999;';
|
||||
const close=(e)=>{if(!picker.contains(e.target)){picker.remove();document.removeEventListener('click',close);}};
|
||||
setTimeout(()=>document.addEventListener('click',close),0);
|
||||
}
|
||||
|
||||
function closeSessionActionMenu(){
|
||||
if(_sessionActionMenu){
|
||||
_sessionActionMenu.remove();
|
||||
@@ -938,6 +1045,10 @@ function startGatewaySSE(){
|
||||
}catch(e){ /* ignore parse errors */ }
|
||||
});
|
||||
_gatewaySSE.onerror = () => {
|
||||
if(_gatewaySSE){
|
||||
_gatewaySSE.close();
|
||||
_gatewaySSE = null;
|
||||
}
|
||||
void probeGatewaySSEStatus();
|
||||
};
|
||||
}catch(e){
|
||||
@@ -1116,6 +1227,24 @@ function renderSessionListFromCache(){
|
||||
const sessions=_showArchived?projectFiltered:projectFiltered.filter(s=>!s.archived);
|
||||
const archivedCount=projectFiltered.filter(s=>s.archived).length;
|
||||
const list=$('sessionList');list.innerHTML='';
|
||||
// Batch select bar (when in select mode)
|
||||
if(_sessionSelectMode){
|
||||
const selectBar=document.createElement('div');selectBar.className='session-select-bar';
|
||||
const exitBtn=document.createElement('button');exitBtn.className='batch-exit-btn';
|
||||
exitBtn.textContent='\u2715';exitBtn.title='Exit select mode';
|
||||
exitBtn.onclick=(e)=>{e.stopPropagation();exitSessionSelectMode();};
|
||||
selectBar.appendChild(exitBtn);
|
||||
const selectAllBtn=document.createElement('button');selectAllBtn.className='batch-select-all-btn';
|
||||
selectAllBtn.textContent=t('session_select_all');
|
||||
selectAllBtn.onclick=(e)=>{e.stopPropagation();selectAllSessions();};
|
||||
selectBar.appendChild(selectAllBtn);
|
||||
list.appendChild(selectBar);
|
||||
}
|
||||
// Ensure batch action bar exists in DOM
|
||||
let batchBar=$('batchActionBar');
|
||||
if(!batchBar){batchBar=document.createElement('div');batchBar.id='batchActionBar';batchBar.className='batch-action-bar';document.body.appendChild(batchBar);}
|
||||
if(_sessionSelectMode&&_selectedSessions.size>0){batchBar.style.display='';_renderBatchActionBar();}
|
||||
else{batchBar.style.display='none';}
|
||||
// Project filter bar (only when projects exist)
|
||||
if(_allProjects.length>0){
|
||||
const bar=document.createElement('div');
|
||||
@@ -1237,7 +1366,13 @@ function renderSessionListFromCache(){
|
||||
wrapper.appendChild(body);
|
||||
list.appendChild(wrapper);
|
||||
}
|
||||
// ── Render session items (extracted for group body use) ──
|
||||
// Select mode toggle button (only when NOT in select mode)
|
||||
if(!_sessionSelectMode){
|
||||
const toggleBtn=document.createElement('div');toggleBtn.className='session-select-toggle';
|
||||
toggleBtn.textContent=t('session_select_mode');
|
||||
toggleBtn.onclick=(e)=>{e.stopPropagation();toggleSessionSelectMode();};
|
||||
list.appendChild(toggleBtn);
|
||||
}
|
||||
// Note: declared after the groups loop but available via function hoisting.
|
||||
function _renderOneSession(s, isPinnedGroup=false){
|
||||
const el=document.createElement('div');
|
||||
@@ -1255,6 +1390,17 @@ function renderSessionListFromCache(){
|
||||
if(cleanTitle.startsWith('[SYSTEM:')){
|
||||
cleanTitle='Session';
|
||||
}
|
||||
// Checkbox for batch select mode
|
||||
if(_sessionSelectMode){
|
||||
const cbWrapper=document.createElement('label');cbWrapper.className='session-select-cb-wrapper';
|
||||
const cb=document.createElement('input');cb.type='checkbox';cb.className='session-select-cb';
|
||||
cb.dataset.sid=s.session_id;cb.checked=_selectedSessions.has(s.session_id);
|
||||
cb.onchange=(e)=>{e.stopPropagation();toggleSessionSelect(s.session_id);};
|
||||
cb.onclick=(e)=>{e.stopPropagation();};
|
||||
cbWrapper.appendChild(cb);
|
||||
el.classList.toggle('selected',_selectedSessions.has(s.session_id));
|
||||
el.appendChild(cbWrapper);
|
||||
}
|
||||
const sessionText=document.createElement('div');
|
||||
sessionText.className='session-text';
|
||||
const titleRow=document.createElement('div');
|
||||
@@ -1419,6 +1565,7 @@ function renderSessionListFromCache(){
|
||||
if(e.pointerType==='mouse' && e.button!==0) return; // ignore right/middle click
|
||||
if(_renamingSid) return;
|
||||
if(actions.contains(e.target)) return;
|
||||
if(_sessionSelectMode){e.stopPropagation();toggleSessionSelect(s.session_id);return;}
|
||||
const now=Date.now();
|
||||
if(now-_lastTapTime<350){
|
||||
// Double-tap: rename
|
||||
@@ -1721,3 +1868,8 @@ async function _confirmDeleteProject(proj){
|
||||
await renderSessionList();
|
||||
showToast('Project deleted');
|
||||
}
|
||||
|
||||
// Global Escape handler for batch select mode
|
||||
document.addEventListener('keydown',(e)=>{
|
||||
if(e.key==='Escape'&&_sessionSelectMode) exitSessionSelectMode();
|
||||
});
|
||||
|
||||
+152
-23
@@ -9,7 +9,14 @@
|
||||
--strong:#0F0D08;--em:#5C5344;--code-text:#8b4513;--code-inline-bg:rgba(0,0,0,.06);--pre-text:#1A1610;
|
||||
--accent-hover:#996F08;--accent-bg:rgba(184,134,11,0.08);--accent-bg-strong:rgba(184,134,11,0.15);--accent-text:#8B6508;
|
||||
--error:#C62828;--success:#3D8B40;--warning:#E68A00;--info:#0288A8;
|
||||
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;font-size:14px;line-height:1.6;
|
||||
--radius-sm:4px;--radius-md:8px;--radius-card:8px;--radius-lg:12px;--radius-pill:999px;
|
||||
--space-1:4px;--space-2:8px;--space-3:12px;--space-4:16px;
|
||||
--font-size-xs:11px;--font-size-sm:12px;--font-size-md:14px;
|
||||
--font-ui:-apple-system,BlinkMacSystemFont,"Segoe UI",Inter,system-ui,sans-serif;
|
||||
--font-assistant:Georgia,"Times New Roman",serif;
|
||||
--surface-subtle:rgba(0,0,0,.025);--surface-subtle-hover:rgba(0,0,0,.045);
|
||||
--border-subtle:rgba(0,0,0,.08);--border-muted:rgba(0,0,0,.12);
|
||||
font-family:var(--font-ui);font-size:14px;line-height:1.6;
|
||||
}
|
||||
/* ── Font size modifiers ── */
|
||||
/* ── Font size preference: scale key UI text elements ── */
|
||||
@@ -69,6 +76,33 @@
|
||||
--strong:#fff;--em:#C0C0C0;--code-text:#f0c27f;--code-inline-bg:rgba(0,0,0,.35);--pre-text:#e2e8f0;
|
||||
--accent-hover:#FFBF00;--accent-bg:rgba(255,215,0,0.08);--accent-bg-strong:rgba(255,215,0,0.15);--accent-text:#FFD700;
|
||||
--error:#EF5350;--success:#4CAF50;--warning:#FFA726;--info:#4DD0E1;
|
||||
--surface-subtle:rgba(255,255,255,.025);--surface-subtle-hover:rgba(255,255,255,.045);
|
||||
--border-subtle:rgba(255,255,255,.075);--border-muted:rgba(255,255,255,.12);
|
||||
}
|
||||
/* ── Custom Theme: Calm Console (Coolors earth/slate palette) ── */
|
||||
:root[data-theme="calm"] {
|
||||
--bg:#EAE0D5;--sidebar:#F4EEE7;--border:#C6AC8F;--border2:rgba(10,9,8,0.13);
|
||||
--text:#0A0908;--muted:#5B4B3B;--accent:#22333B;--blue:#22333B;--gold:#C6AC8F;--code-bg:#D8C8B7;
|
||||
--surface:#F4EEE7;--topbar-bg:rgba(244,238,231,.96);--main-bg:rgba(234,224,213,0.72);
|
||||
--focus-ring:rgba(34,51,59,.28);--focus-glow:rgba(34,51,59,.08);
|
||||
--input-bg:rgba(10,9,8,.035);--hover-bg:rgba(10,9,8,.055);
|
||||
--strong:#0A0908;--em:#3B3028;--code-text:#22333B;--code-inline-bg:rgba(10,9,8,.06);--pre-text:#0A0908;
|
||||
--accent-hover:#0A0908;--accent-bg:rgba(34,51,59,0.08);--accent-bg-strong:rgba(34,51,59,0.15);--accent-text:#22333B;
|
||||
--error:#B42318;--success:#2F6B3F;--warning:#8A5A18;--info:#22333B;
|
||||
--surface-subtle:rgba(10,9,8,.025);--surface-subtle-hover:rgba(10,9,8,.045);
|
||||
--border-subtle:rgba(10,9,8,.08);--border-muted:rgba(10,9,8,.12);
|
||||
}
|
||||
:root.dark[data-theme="calm"] {
|
||||
--bg:#0A0908;--sidebar:#22333B;--border:#3B4A50;--border2:rgba(234,224,213,0.16);
|
||||
--text:#EAE0D5;--muted:#C6AC8F;--accent:#C6AC8F;--blue:#C6AC8F;--gold:#C6AC8F;--code-bg:#11100E;
|
||||
--surface:#22333B;--topbar-bg:rgba(34,51,59,.96);--main-bg:rgba(10,9,8,0.72);
|
||||
--focus-ring:rgba(198,172,143,.28);--focus-glow:rgba(198,172,143,.08);
|
||||
--input-bg:rgba(234,224,213,.035);--hover-bg:rgba(234,224,213,.055);
|
||||
--strong:#F7F0E8;--em:#D8C6B2;--code-text:#C6AC8F;--code-inline-bg:rgba(234,224,213,.07);--pre-text:#EAE0D5;
|
||||
--accent-hover:#EAE0D5;--accent-bg:rgba(198,172,143,0.08);--accent-bg-strong:rgba(198,172,143,0.14);--accent-text:#C6AC8F;
|
||||
--error:#F87171;--success:#86C08B;--warning:#E0B15D;--info:#C6AC8F;
|
||||
--surface-subtle:rgba(234,224,213,.025);--surface-subtle-hover:rgba(234,224,213,.045);
|
||||
--border-subtle:rgba(234,224,213,.075);--border-muted:rgba(234,224,213,.12);
|
||||
}
|
||||
/* ── Skin: Default (gold — matches base) ── */
|
||||
/* No overrides needed — :root and .dark already use gold accent */
|
||||
@@ -120,8 +154,8 @@
|
||||
:root:not(.dark) ::selection{background:var(--accent-bg-strong);}
|
||||
:root:not(.dark) *{scrollbar-color:rgba(0,0,0,.15) transparent;}
|
||||
/* ── Light mode: sidebar, roles, chips, active states ── */
|
||||
:root:not(.dark) .session-item{color:#5a544a;}
|
||||
:root:not(.dark) .session-item:hover{background:rgba(0,0,0,.06);color:#2c2825;}
|
||||
:root:not(.dark) .session-item{color:var(--muted);}
|
||||
:root:not(.dark) .session-item:hover{background:var(--hover-bg);color:var(--text);}
|
||||
:root:not(.dark) .session-item.active{background:var(--accent-bg);color:var(--accent-text);}
|
||||
:root:not(.dark) .session-item.active .session-title{color:var(--accent-text);}
|
||||
:root:not(.dark) .session-pin-indicator{color:var(--accent-text);}
|
||||
@@ -130,11 +164,11 @@
|
||||
:root:not(.dark) .session-item.menu-open .session-actions-trigger{background:var(--accent-bg);border-color:var(--accent-bg-strong);color:var(--accent-text);}
|
||||
:root:not(.dark) .session-action-opt.is-active{background:var(--accent-bg);}
|
||||
:root:not(.dark) .msg-role.user{color:var(--accent-text);}
|
||||
:root:not(.dark) .msg-role.assistant{color:#8a6520;}
|
||||
:root:not(.dark) .msg-role.assistant{color:var(--muted);}
|
||||
:root:not(.dark) .role-icon.user{background:var(--accent-bg);color:var(--accent-text);border-color:var(--accent-bg-strong);}
|
||||
:root:not(.dark) .role-icon.assistant{background:rgba(138,101,32,.12);color:#8a6520;border-color:rgba(138,101,32,.25);}
|
||||
:root:not(.dark) .role-icon.assistant{background:var(--surface);color:var(--muted);border-color:var(--border);}
|
||||
:root:not(.dark) .project-chip{border-color:rgba(0,0,0,.12);background:rgba(0,0,0,.04);}
|
||||
:root:not(.dark) .project-chip:hover{background:rgba(0,0,0,.08);color:#2c2825;}
|
||||
:root:not(.dark) .project-chip:hover{background:var(--hover-bg);color:var(--text);}
|
||||
:root:not(.dark) .project-chip.active{background:var(--accent-bg);color:var(--accent-text);border-color:var(--accent-bg-strong);}
|
||||
:root:not(.dark) .chip{border-color:rgba(0,0,0,.1);background:rgba(0,0,0,.04);}
|
||||
:root:not(.dark) .chip.model{color:var(--accent-text);border-color:var(--accent-bg-strong);background:var(--accent-bg);}
|
||||
@@ -162,7 +196,7 @@
|
||||
:root:not(.dark) .preview-md td{border-color:rgba(0,0,0,.08);}
|
||||
:root:not(.dark) .msg-body td{border-color:rgba(0,0,0,.08);}
|
||||
:root:not(.dark) .preview-badge.code{background:rgba(0,0,0,.05);}
|
||||
:root:not(.dark) .ctx-ring-center{background:var(--bg);color:#5a544a;}
|
||||
:root:not(.dark) .ctx-ring-center{background:var(--bg);color:var(--muted);}
|
||||
:root:not(.dark) .ctx-ring-track{stroke:rgba(0,0,0,.12);}
|
||||
:root:not(.dark) .ws-opt:hover{background:rgba(0,0,0,.05);}
|
||||
:root:not(.dark) .ws-row:hover{background:rgba(0,0,0,.04);}
|
||||
@@ -335,6 +369,27 @@
|
||||
.session-date-header{display:flex;align-items:center;gap:5px;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);padding:8px 10px 4px;cursor:pointer;user-select:none;opacity:.8;transition:opacity .15s;}
|
||||
.session-date-header:hover{opacity:1;}
|
||||
.session-date-header.pinned{color:var(--accent);}
|
||||
|
||||
/* ── Batch select mode ── */
|
||||
.session-select-toggle{font-size:11px;padding:6px 10px;color:var(--muted);cursor:pointer;text-align:center;opacity:.6;transition:opacity .15s;user-select:none;margin-top:4px;border-radius:6px;}
|
||||
.session-select-toggle:hover{opacity:1;background:var(--hover-bg);}
|
||||
.session-select-bar{display:flex;align-items:center;gap:6px;padding:6px 10px;margin-bottom:4px;flex-shrink:0;}
|
||||
.batch-exit-btn{width:22px;height:22px;border:none;border-radius:6px;background:transparent;color:var(--muted);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:14px;padding:0;flex-shrink:0;}
|
||||
.batch-exit-btn:hover{background:var(--hover-bg);color:var(--text);}
|
||||
.batch-select-all-btn{font-size:11px;border:none;border-radius:6px;background:transparent;color:var(--accent);cursor:pointer;padding:3px 8px;font-weight:600;}
|
||||
.batch-select-all-btn:hover{background:var(--accent-bg);}
|
||||
.session-select-cb-wrapper{display:flex;align-items:center;flex-shrink:0;padding:2px;}
|
||||
.session-select-cb{width:14px;height:14px;accent-color:var(--accent);cursor:pointer;margin:0;}
|
||||
.session-item.selected{background:var(--accent-bg);color:var(--accent-text);}
|
||||
.session-item.selected .session-title{color:var(--accent-text);}
|
||||
.session-item.selected .session-meta{color:var(--accent-text);opacity:.8;}
|
||||
.batch-action-bar{display:none;position:fixed;bottom:0;left:0;right:0;z-index:998;background:var(--surface);border-top:1px solid var(--border2);box-shadow:0 -2px 12px rgba(0,0,0,.15);padding:10px 16px;align-items:center;gap:10px;font-size:13px;}
|
||||
.batch-count{font-weight:600;color:var(--accent);margin-right:auto;white-space:nowrap;}
|
||||
.batch-action-btn{border:1px solid var(--border);border-radius:8px;background:var(--surface);color:var(--text);cursor:pointer;padding:6px 14px;font-size:12px;font-weight:500;transition:background .12s,color .12s,border-color .12s;white-space:nowrap;}
|
||||
.batch-action-btn:hover{background:var(--hover-bg);border-color:var(--border2);}
|
||||
.batch-action-btn-danger{color:var(--error,#e94560);border-color:var(--error,#e94560);}
|
||||
.batch-action-btn-danger:hover{background:rgba(233,69,96,.1);}
|
||||
@media(hover:none){.batch-action-bar{padding-bottom:max(10px,env(safe-area-inset-bottom));}}
|
||||
.session-date-caret{font-size:9px;transition:transform .2s;flex-shrink:0;display:inline-block;transform:rotate(0deg);}
|
||||
.session-date-caret.collapsed{transform:rotate(-90deg);}
|
||||
.app-dialog-overlay{position:fixed;inset:0;background:rgba(7,12,19,.62);backdrop-filter:blur(6px);z-index:1100;display:none;align-items:center;justify-content:center;padding:24px;}
|
||||
@@ -555,9 +610,10 @@
|
||||
/* Skills panel */
|
||||
.skills-list{flex:1;overflow-y:auto;padding:0 8px 8px;}
|
||||
.skills-category{margin-bottom:4px;}
|
||||
.skills-cat-header{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);padding:8px 6px 4px;cursor:pointer;display:flex;align-items:center;gap:4px;}
|
||||
.skills-cat-header{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);padding:8px 6px 4px;cursor:pointer;display:flex;align-items:center;gap:2px;user-select:none;}
|
||||
.skills-cat-header:hover{color:var(--text);}
|
||||
.skill-item{width:100%;min-width:0;box-sizing:border-box;padding:8px 10px;border-radius:8px;cursor:pointer;font-size:12px;color:var(--muted);display:flex;align-items:flex-start;gap:6px;transition:all .12s;line-height:1.4;}
|
||||
.cat-chevron{flex-shrink:0;width:12px;height:12px;}
|
||||
.skill-item{width:100%;min-width:0;box-sizing:border-box;padding:8px 10px;border-radius:8px;cursor:pointer;font-size:12px;color:var(--muted);display:flex;align-items:flex-start;gap:6px;transition:all .12s;line-height:1.4;overflow:hidden;max-height:200px;opacity:1;}
|
||||
.skill-item:hover{background:var(--hover-bg);color:var(--text);}
|
||||
.skill-item.active{background:var(--accent-bg);color:var(--accent-text);}
|
||||
.skill-name{font-weight:500;flex-shrink:0;}
|
||||
@@ -678,6 +734,41 @@
|
||||
.img-lightbox-close:hover{background:rgba(255,255,255,.22);}
|
||||
.msg-media-link{display:inline-flex;align-items:center;gap:5px;background:var(--accent-bg);border:1px solid var(--accent-bg-strong);border-radius:6px;padding:4px 10px;font-size:13px;color:var(--accent-text);text-decoration:none;}
|
||||
.msg-media-link:hover{background:var(--accent-bg-strong);}
|
||||
|
||||
/* ── Inline SVG rendering ── */
|
||||
.msg-media-svg{display:block;max-width:100%;height:auto;border-radius:6px;margin:6px 0;border:1px solid var(--border);background:var(--surface);}
|
||||
.msg-media-label{display:block;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:var(--muted);margin-bottom:4px;user-select:none;}
|
||||
|
||||
/* ── Inline audio player ── */
|
||||
.msg-media-audio{margin:6px 0;padding:8px 10px;background:var(--surface);border:1px solid var(--border);border-radius:8px;max-width:480px;}
|
||||
.msg-media-audio audio{width:100%;height:36px;border-radius:4px;}
|
||||
|
||||
/* ── Inline video player ── */
|
||||
.msg-media-video{margin:6px 0;background:var(--surface);border:1px solid var(--border);border-radius:8px;overflow:hidden;max-width:640px;}
|
||||
.msg-media-video video{width:100%;display:block;border-radius:8px 8px 0 0;}
|
||||
|
||||
/* ── Composer attachment chips for media ── */
|
||||
.attach-thumb--svg{background:var(--surface);padding:2px;border-radius:4px;}
|
||||
.attach-chip--audio,.attach-chip--video{flex-direction:column;gap:4px;align-items:flex-start;padding:8px 10px;}
|
||||
.attach-chip--audio audio,.attach-chip--video video{width:100%;max-width:320px;border-radius:4px;}
|
||||
.attach-chip-media{font-size:12px;font-weight:600;color:var(--muted);}
|
||||
|
||||
/* ── CSV table rendering ── */
|
||||
.csv-table-wrap{margin:6px 0;overflow-x:auto;border:1px solid var(--border);border-radius:8px;background:var(--surface);}
|
||||
.csv-table{width:100%;border-collapse:collapse;font-size:13px;}
|
||||
.csv-table thead{position:sticky;top:0;z-index:1;}
|
||||
.csv-table th{background:var(--hover-bg);font-weight:600;text-align:left;padding:8px 12px;border-bottom:2px solid var(--border2);white-space:nowrap;user-select:none;}
|
||||
.csv-table td{padding:6px 12px;border-bottom:1px solid var(--border);white-space:nowrap;}
|
||||
.csv-table tbody tr:last-child td{border-bottom:none;}
|
||||
.csv-table tbody tr:hover{background:var(--hover-bg);}
|
||||
|
||||
/* ── Excalidraw inline embed ── */
|
||||
.excalidraw-embed-wrap{margin:6px 0;border:1px solid var(--border);border-radius:8px;background:var(--surface);overflow:hidden;}
|
||||
.excalidraw-canvas{padding:12px;overflow:auto;max-height:500px;}
|
||||
.excalidraw-svg{display:block;width:100%;height:auto;max-height:460px;}
|
||||
.excalidraw-empty{color:var(--muted);font-size:13px;padding:20px;text-align:center;}
|
||||
.excalidraw-open-link{font-size:11px;color:var(--accent);text-decoration:none;margin-left:auto;white-space:nowrap;}
|
||||
.excalidraw-open-link:hover{text-decoration:underline;}
|
||||
.thinking{display:flex;align-items:center;gap:5px;color:var(--muted);font-size:13px;padding-left:30px;}
|
||||
.dot{width:6px;height:6px;border-radius:50%;background:var(--blue);opacity:.3;animation:pulse 1.4s ease-in-out infinite;}
|
||||
.dot:nth-child(2){animation-delay:.22s;}.dot:nth-child(3){animation-delay:.44s;}
|
||||
@@ -1339,22 +1430,32 @@ body.resizing{user-select:none;cursor:col-resize;}
|
||||
.skill-linked-file{display:block;font-size:12px;padding:3px 6px;border-radius:4px;cursor:pointer;color:var(--blue);text-decoration:none;}
|
||||
.skill-linked-file:hover{background:var(--hover-bg);}
|
||||
.tool-card-row{margin:0;padding:1px 0;}
|
||||
.tool-card{background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.07);border-radius:6px;margin:2px 0 2px 40px;overflow:hidden;transition:border-color .15s;}
|
||||
.tool-card:hover{border-color:rgba(255,255,255,.12);}
|
||||
.tool-call-group{margin:4px 0 4px var(--msg-rail);max-width:var(--msg-max);border-left:1px solid var(--border-subtle);}
|
||||
.tool-call-group-summary{width:100%;display:flex;align-items:center;gap:var(--space-2);padding:var(--space-1) var(--space-3);border:0;background:transparent;color:var(--muted);cursor:pointer;text-align:left;font:inherit;font-size:var(--font-size-xs);line-height:1.4;border-radius:var(--radius-card);}
|
||||
.tool-call-group-summary:hover{background:var(--surface-subtle-hover);color:var(--text);}
|
||||
.tool-call-group-label{font-weight:600;color:var(--muted);}
|
||||
.tool-call-group-list{opacity:.72;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.tool-call-group-count{margin-left:auto;opacity:.56;font-variant-numeric:tabular-nums;}
|
||||
.tool-call-group-chevron{opacity:.45;display:inline-flex;transition:transform .16s ease;}
|
||||
.tool-call-group:not(.tool-call-group-collapsed) .tool-call-group-chevron{transform:rotate(90deg);}
|
||||
.tool-call-group-body{display:block;padding-left:var(--space-3);}
|
||||
.tool-call-group.tool-call-group-collapsed .tool-call-group-body{display:none;}
|
||||
.tool-card{background:var(--surface-subtle);border:1px solid var(--border-subtle);border-radius:var(--radius-card);margin:2px 0;overflow:hidden;transition:border-color .15s,background-color .15s;}
|
||||
.tool-card:hover{border-color:var(--border-muted);background:var(--surface-subtle-hover);}
|
||||
.tool-card-running{border-color:var(--accent-bg-strong);background:var(--accent-bg);}
|
||||
.tool-card-header{display:flex;align-items:center;gap:8px;padding:4px 10px;cursor:pointer;user-select:none;}
|
||||
.tool-card-icon{font-size:13px;flex-shrink:0;opacity:.8;}
|
||||
.tool-card-name{font-size:12px;font-weight:600;color:var(--muted);font-family:'SF Mono',ui-monospace,monospace;flex-shrink:0;}
|
||||
.tool-card-preview{font-size:11px;color:var(--muted);opacity:.6;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.tool-card-toggle{font-size:10px;color:var(--muted);opacity:.5;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;transform-origin:center;transition:transform .18s ease;will-change:transform;}
|
||||
.tool-card-header{display:flex;align-items:center;gap:var(--space-2);padding:var(--space-1) var(--space-3);cursor:pointer;user-select:none;}
|
||||
.tool-card-icon{font-size:13px;flex-shrink:0;opacity:.65;}
|
||||
.tool-card-name{font-size:var(--font-size-xs);font-weight:600;color:var(--muted);font-family:'SF Mono',ui-monospace,monospace;flex-shrink:0;}
|
||||
.tool-card-preview{font-size:var(--font-size-xs);color:var(--muted);opacity:.62;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.tool-card-toggle{font-size:10px;color:var(--muted);opacity:.45;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;transform-origin:center;transition:transform .18s ease;will-change:transform;}
|
||||
.tool-card.open .tool-card-toggle{transform:rotate(90deg);}
|
||||
.tool-card-detail{display:block;max-height:0;opacity:0;overflow:hidden;border-top:1px solid transparent;padding:0 12px;transition:max-height .22s ease,opacity .18s ease,padding .22s ease,border-top-color .22s ease;}
|
||||
.tool-card.open .tool-card-detail{max-height:600px;opacity:1;padding:8px 12px;border-top-color:rgba(255,255,255,.06);overflow:auto;}
|
||||
.tool-card-detail{display:block;max-height:0;opacity:0;overflow:hidden;border-top:1px solid transparent;padding:0 var(--space-3);transition:max-height .22s ease,opacity .18s ease,padding .22s ease,border-top-color .22s ease;}
|
||||
.tool-card.open .tool-card-detail{max-height:600px;opacity:1;padding:var(--space-2) var(--space-3);border-top-color:var(--border-subtle);overflow:auto;}
|
||||
.tool-card-args{margin-bottom:6px;}
|
||||
.tool-card-args div{font-size:11px;line-height:1.6;}
|
||||
.tool-arg-key{color:var(--blue);font-family:'SF Mono',ui-monospace,monospace;font-size:11px;}
|
||||
.tool-arg-val{color:var(--muted);font-family:'SF Mono',ui-monospace,monospace;font-size:11px;word-break:break-all;}
|
||||
.tool-card-result pre{font-size:11px;color:var(--muted);font-family:'SF Mono',ui-monospace,monospace;white-space:pre-wrap;word-break:break-word;max-height:360px;overflow-y:auto;margin:0;line-height:1.55;}
|
||||
.tool-card-args div{font-size:var(--font-size-xs);line-height:1.6;}
|
||||
.tool-arg-key{color:var(--blue);font-family:'SF Mono',ui-monospace,monospace;font-size:var(--font-size-xs);}
|
||||
.tool-arg-val{color:var(--muted);font-family:'SF Mono',ui-monospace,monospace;font-size:var(--font-size-xs);word-break:break-all;}
|
||||
.tool-card-result pre{font-size:var(--font-size-xs);color:var(--muted);font-family:'SF Mono',ui-monospace,monospace;white-space:pre-wrap;word-break:break-word;max-height:240px;overflow-y:auto;margin:0;line-height:1.55;}
|
||||
|
||||
/* ── Manual compression cards (transient transcript-local feedback) ── */
|
||||
.live-compression-cards{
|
||||
@@ -1931,7 +2032,7 @@ main.main.showing-profiles > #mainProfiles{display:flex;}
|
||||
border-color:var(--accent);
|
||||
color:#fff;
|
||||
}
|
||||
:root.dark .provider-card-btn-primary{color:#0D0D1A;}
|
||||
:root.dark .provider-card-btn-primary{color:#0A0908;}
|
||||
.provider-card-btn-primary:hover:not(:disabled){
|
||||
background:var(--accent-hover);
|
||||
border-color:var(--accent-hover);
|
||||
@@ -2113,6 +2214,11 @@ main.main.showing-profiles > #mainProfiles{display:flex;}
|
||||
/* ── Unified indent rail — every child of a turn lines up on --msg-rail ── */
|
||||
.msg-row { padding: 12px 0; }
|
||||
.msg-body { padding-left: var(--msg-rail); padding-top: 8px; max-width: var(--msg-max); }
|
||||
.assistant-turn .msg-body { font-family: var(--font-assistant); font-size: 15px; line-height: 1.68; letter-spacing: .003em; }
|
||||
.assistant-turn .msg-body code,
|
||||
.assistant-turn .msg-body pre,
|
||||
.assistant-turn .msg-body table { font-family: 'SF Mono', ui-monospace, monospace; }
|
||||
.msg-row[data-role="user"] .msg-body { font-family: var(--font-ui); }
|
||||
.msg-body:empty { display: none; }
|
||||
.assistant-turn { width: 100%; }
|
||||
.assistant-turn-blocks { display: flex; flex-direction: column; }
|
||||
@@ -2134,6 +2240,9 @@ main.main.showing-profiles > #mainProfiles{display:flex;}
|
||||
.msg-usage { padding-left: var(--msg-rail); opacity: 1; margin-top: 6px; font-size: 11px; }
|
||||
.tool-card { margin-left: var(--msg-rail); max-width: var(--msg-max); }
|
||||
.thinking-card { margin-left: var(--msg-rail); max-width: var(--msg-max); }
|
||||
.agent-activity-group .tool-card,
|
||||
.agent-activity-group .thinking-card { margin-left: 0; max-width: none; }
|
||||
.agent-activity-thinking { margin: 2px 0; }
|
||||
.tool-cards-toggle { margin-left: var(--msg-rail); }
|
||||
.msg-row[data-editing="1"] { width: 100%; }
|
||||
.msg-row[data-editing="1"] .msg-edit-area,
|
||||
@@ -2520,3 +2629,23 @@ main.main > .main-view:not([id="mainChat"]):not([id="mainSettings"]) .main-view-
|
||||
@media (hover:none) and (pointer:coarse){
|
||||
input,textarea,select{font-size:max(16px,1em)!important;}
|
||||
}
|
||||
|
||||
/* ── PDF preview ─────────────────────────────────────────────────────────── */
|
||||
.pdf-preview-wrap{border:1px solid var(--border);border-radius:6px;overflow:hidden;margin:4px 0;background:var(--surface);}
|
||||
.pdf-preview-header{display:flex;justify-content:space-between;align-items:center;padding:6px 10px;background:var(--surface-2);font-size:12px;font-weight:600;}
|
||||
.pdf-preview-header a{color:var(--accent);text-decoration:none;font-weight:500;}
|
||||
.pdf-preview-header a:hover{text-decoration:underline;}
|
||||
.pdf-preview-body{display:flex;justify-content:center;padding:8px;background:#fff;border-radius:0 0 6px 6px;max-height:500px;overflow:hidden;}
|
||||
.pdf-preview-canvas{max-width:100%;height:auto;border-radius:2px;}
|
||||
.pdf-preview-fallback{padding:8px;font-size:13px;}
|
||||
.pdf-preview-spinner{animation:pulse 1.5s ease-in-out infinite;}
|
||||
@keyframes pulse{0%,100%{opacity:0.4;}50%{opacity:1;}}
|
||||
|
||||
/* ── HTML sandbox preview ────────────────────────────────────────────────── */
|
||||
.html-preview-wrap{border:1px solid var(--border);border-radius:6px;overflow:hidden;margin:4px 0;background:var(--surface);}
|
||||
.html-preview-header{display:flex;justify-content:space-between;align-items:center;padding:6px 10px;background:var(--surface-2);font-size:12px;font-weight:600;}
|
||||
.html-preview-header a{color:var(--accent);text-decoration:none;font-weight:500;}
|
||||
.html-preview-header a:hover{text-decoration:underline;}
|
||||
.html-preview-iframe{width:100%;height:400px;border:none;display:block;background:#fff;}
|
||||
.html-preview-fallback{padding:8px;font-size:13px;}
|
||||
.html-preview-spinner{animation:pulse 1.5s ease-in-out infinite;}
|
||||
|
||||
+6
-2
@@ -64,11 +64,15 @@ self.addEventListener('fetch', (event) => {
|
||||
// Never intercept cross-origin requests
|
||||
if (url.origin !== self.location.origin) return;
|
||||
|
||||
// API and streaming endpoints — always go to network
|
||||
// API and streaming endpoints — always go to network.
|
||||
// The WebUI may be mounted under a subpath such as /hermes/, so API
|
||||
// requests can look like /hermes/api/sessions rather than /api/sessions.
|
||||
if (
|
||||
url.pathname.startsWith('/api/') ||
|
||||
url.pathname.includes('/api/') ||
|
||||
url.pathname.includes('/stream') ||
|
||||
url.pathname.startsWith('/health')
|
||||
url.pathname.startsWith('/health') ||
|
||||
url.pathname.includes('/health')
|
||||
) {
|
||||
return; // let browser handle normally
|
||||
}
|
||||
|
||||
+629
-113
@@ -88,7 +88,14 @@ document.addEventListener('click', e => {
|
||||
});
|
||||
|
||||
const _IMAGE_EXTS=/\.(png|jpg|jpeg|gif|webp|bmp|ico|avif)$/i;
|
||||
const _PDF_EXTS=/\.pdf$/i;
|
||||
const _HTML_EXTS=/\.(html?|htm)$/i;
|
||||
const _ARCHIVE_EXTS=/\.(zip|tar|tar\.gz|tgz|tar\.bz2|tbz2|tar\.xz|txz)$/i;
|
||||
const _SVG_EXTS=/\.svg$/i;
|
||||
const _AUDIO_EXTS=/\.(mp3|ogg|wav|m4a|aac|flac|wma|opus|webm)$/i;
|
||||
const _VIDEO_EXTS=/\.(mp4|webm|mkv|mov|avi|ogv|m4v)$/i;
|
||||
const _CSV_EXTS=/\.csv$/i;
|
||||
const _EXCALIDRAW_EXTS=/\.excalidraw$/i;
|
||||
|
||||
// Dynamic model labels -- populated by populateModelDropdown(), fallback to static map
|
||||
let _dynamicModelLabels={};
|
||||
@@ -914,6 +921,16 @@ function renderMd(raw){
|
||||
const rawCode=esc(code.replace(/\n$/,''));
|
||||
const blockId='tree-'+Math.random().toString(36).slice(2,10);
|
||||
_preBlock_stash.push(`<div class="code-tree-wrap" data-raw="${rawCode.replace(/"/g,'"')}" data-lang="${lang}" id="${blockId}">${h}<pre class="tree-raw-view"><code${langAttr}>${rawCode}</code></pre></div>`);
|
||||
// CSV blocks → render as styled table
|
||||
} else if(lang==='csv'){
|
||||
const rows=code.replace(/\n$/,'').split('\n').filter(r=>r.trim());
|
||||
if(rows.length>=2){
|
||||
const headers=rows[0].split(',').map(c=>c.trim());
|
||||
const body=rows.slice(1).map(r=>'<tr>'+r.split(',').map(c=>`<td>${esc(c.trim())}</td>`).join('')+'</tr>').join('');
|
||||
_preBlock_stash.push(`${h}<div class="csv-table-wrap"><table class="csv-table"><thead><tr>${headers.map(h=>`<th>${esc(h)}</th>`).join('')}</tr></thead><tbody>${body}</tbody></table></div>`);
|
||||
} else {
|
||||
_preBlock_stash.push(`${h}<pre><code${langAttr}>${esc(code.replace(/\n$/,''))}</code></pre>`);
|
||||
}
|
||||
} else {
|
||||
_preBlock_stash.push(`${h}<pre><code${langAttr}>${esc(code.replace(/\n$/,''))}</code></pre>`);
|
||||
}
|
||||
@@ -1186,6 +1203,18 @@ function renderMd(raw){
|
||||
const base=document.baseURI.replace(/\/$/,'');
|
||||
src=src.replace(/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?/i,base);
|
||||
}
|
||||
// SVG URLs → render inline as image (before image catch-all)
|
||||
if(_SVG_EXTS.test(src.split('?')[0])){
|
||||
return `<img class="msg-media-svg" src="${esc(src)}" alt="${t('media_svg_label')}" loading="lazy">`;
|
||||
}
|
||||
// Audio URLs → inline player
|
||||
if(_AUDIO_EXTS.test(src.split('?')[0])){
|
||||
return `<div class="msg-media-audio"><span class="msg-media-label">🎵 ${t('media_audio_label')}</span><audio controls preload="metadata" src="${esc(src)}"></audio></div>`;
|
||||
}
|
||||
// Video URLs → inline player
|
||||
if(_VIDEO_EXTS.test(src.split('?')[0])){
|
||||
return `<div class="msg-media-video"><span class="msg-media-label">🎬 ${t('media_video_label')}</span><video controls preload="metadata" src="${esc(src)}"></video></div>`;
|
||||
}
|
||||
// MEDIA: tokens are only emitted for tool-generated images (image_generate etc.).
|
||||
// Render all https:// URLs as <img> — extension check would miss extensionless
|
||||
// CDN paths like fal.media content-addressed URLs (closes #853).
|
||||
@@ -1199,12 +1228,40 @@ function renderMd(raw){
|
||||
if(_IMAGE_EXTS.test(ref)){
|
||||
return `<img class="msg-media-img" src="${esc(apiUrl)}" alt="${esc(ref.split('/').pop())}" loading="lazy">`;
|
||||
}
|
||||
// Non-image local file — show download link with filename
|
||||
const fname=esc(ref.split('/').pop()||ref);
|
||||
// SVG → inline image (no download, render directly)
|
||||
if(_SVG_EXTS.test(ref)){
|
||||
return `<img class="msg-media-svg" src="${esc(apiUrl)}" alt="${t('media_svg_label')}" loading="lazy">`;
|
||||
}
|
||||
// Audio → inline player
|
||||
if(_AUDIO_EXTS.test(ref)){
|
||||
return `<div class="msg-media-audio"><span class="msg-media-label">🎵 ${t('media_audio_label')}</span><audio controls preload="metadata" src="${esc(apiUrl)}"></audio></div>`;
|
||||
}
|
||||
// Video → inline player
|
||||
if(_VIDEO_EXTS.test(ref)){
|
||||
return `<div class="msg-media-video"><span class="msg-media-label">🎬 ${t('media_video_label')}</span><video controls preload="metadata" src="${esc(apiUrl)}"></video></div>`;
|
||||
}
|
||||
// PDF files → render first page preview with lazy-load
|
||||
if(_PDF_EXTS.test(ref)){
|
||||
const fname=esc(ref.split('/').pop()||ref);
|
||||
return `<div class="pdf-preview-load" data-path="${esc(ref)}"><span class="pdf-preview-spinner">⏳</span> ${t('pdf_loading')} ${fname}...</div>`;
|
||||
}
|
||||
// HTML files → render inline in sandboxed iframe with lazy-load
|
||||
if(_HTML_EXTS.test(ref)){
|
||||
return `<div class="html-preview-load" data-path="${esc(ref)}"><span class="html-preview-spinner">⏳</span> ${t('html_loading')}</div>`;
|
||||
}
|
||||
// .patch/.diff files → render inline as colored diff instead of download
|
||||
const fname=esc(ref.split('/').pop()||ref);
|
||||
if(/\.(patch|diff)$/i.test(ref)){
|
||||
return `<div class="diff-inline-load" data-path="${esc(ref)}">${t('diff_loading')} ${fname}...</div>`;
|
||||
}
|
||||
// CSV files → lazy-load and render as table
|
||||
if(_CSV_EXTS.test(ref)){
|
||||
return `<div class="csv-inline-load" data-path="${esc(ref)}">${t('csv_loading')} ${fname}...</div>`;
|
||||
}
|
||||
// Excalidraw files → lazy-load inline embed
|
||||
if(_EXCALIDRAW_EXTS.test(ref)){
|
||||
return `<div class="excalidraw-inline-load" data-path="${esc(ref)}">${t('excalidraw_loading')} ${fname}...</div>`;
|
||||
}
|
||||
return `<a class="msg-media-link" href="${esc(apiUrl+'&download=1')}" download="${fname}">📎 ${fname}</a>`;
|
||||
});
|
||||
// ── End MEDIA restore ──────────────────────────────────────────────────────
|
||||
@@ -2024,6 +2081,7 @@ async function forceUpdate(btn){
|
||||
// blind setTimeout(reload, 2500) that race-lost against slow hardware or
|
||||
// reverse proxies that 502 immediately when the upstream socket closes (#874).
|
||||
async function _waitForServerThenReload(opts){
|
||||
// Polls the /health endpoint; implementation uses a relative URL so subpath mounts keep working.
|
||||
opts=opts||{};
|
||||
const interval=opts.interval||500;
|
||||
const maxMs=opts.maxMs||15000;
|
||||
@@ -2224,6 +2282,36 @@ function _thinkingCardHtml(text){
|
||||
const clean=_sanitizeThinkingDisplayText(text);
|
||||
return `<div class="thinking-card"><div class="thinking-card-header" onclick="this.parentElement.classList.toggle('open')"><span class="thinking-card-icon">${li('lightbulb',14)}</span><span class="thinking-card-label">${t('thinking')}</span><span class="thinking-card-toggle">${li('chevron-right',12)}</span></div><div class="thinking-card-body"><pre>${esc(clean)}</pre></div></div>`;
|
||||
}
|
||||
function isSimplifiedToolCalling(){
|
||||
return window._simplifiedToolCalling!==false;
|
||||
}
|
||||
function _thinkingActivityNode(text){
|
||||
const row=document.createElement('div');
|
||||
row.className='agent-activity-thinking';
|
||||
row.innerHTML=_thinkingCardHtml(text);
|
||||
return row;
|
||||
}
|
||||
function ensureActivityGroup(inner, opts){
|
||||
opts=opts||{};
|
||||
if(!inner) return null;
|
||||
const live=!!opts.live;
|
||||
const selector=live?'.tool-call-group[data-live-tool-call-group="1"]':'.tool-call-group[data-agent-activity-group="1"]';
|
||||
let group=inner.querySelector(selector);
|
||||
if(!group){
|
||||
group=document.createElement('div');
|
||||
const collapsed=opts.collapsed!==false;
|
||||
group.className='tool-call-group agent-activity-group'+(collapsed?' tool-call-group-collapsed':'');
|
||||
group.setAttribute('data-tool-call-group','1');
|
||||
group.setAttribute('data-agent-activity-group','1');
|
||||
if(live) group.setAttribute('data-live-tool-call-group','1');
|
||||
group.innerHTML=`<button type="button" class="tool-call-group-summary" aria-expanded="${collapsed?'false':'true'}" onclick="const g=this.closest('.tool-call-group');const c=g.classList.toggle('tool-call-group-collapsed');this.setAttribute('aria-expanded',String(!c));"><span class="tool-call-group-chevron">${li('chevron-right',12)}</span><span class="tool-call-group-label">Activity</span><span class="tool-call-group-list">tools / thinking</span><span class="tool-call-group-count">0</span></button><div class="tool-call-group-body"></div>`;
|
||||
const anchor=opts.anchor||null;
|
||||
if(anchor&&anchor.parentElement===inner) anchor.insertAdjacentElement('afterend', group);
|
||||
else inner.appendChild(group);
|
||||
}
|
||||
_syncToolCallGroupSummary(group);
|
||||
return group;
|
||||
}
|
||||
function _compressionStateForCurrentSession(){
|
||||
const state=window._compressionUi;
|
||||
if(!state||!S.session||state.sessionId!==S.session.session_id) return null;
|
||||
@@ -2488,6 +2576,10 @@ function renderCompressionUi(){
|
||||
// for the common read-only back-navigation case; not suitable as a general cache.
|
||||
const _sessionHtmlCache=new Map();
|
||||
let _sessionHtmlCacheSid=null; // session_id currently rendered in the DOM
|
||||
function clearMessageRenderCache(){
|
||||
_sessionHtmlCache.clear();
|
||||
_sessionHtmlCacheSid=null;
|
||||
}
|
||||
|
||||
function renderMessages(){
|
||||
const inner=$('msgInner');
|
||||
@@ -2505,8 +2597,8 @@ function renderMessages(){
|
||||
inner.innerHTML=cached.html;
|
||||
_sessionHtmlCacheSid=sid;
|
||||
if(S.activeStreamId){scrollIfPinned();}else{scrollToBottom();}
|
||||
requestAnimationFrame(()=>{highlightCode();addCopyButtons();loadDiffInline();renderMermaidBlocks();renderKatexBlocks();});
|
||||
requestAnimationFrame(()=>{highlightCode();addCopyButtons();initTreeViews();renderMermaidBlocks();renderKatexBlocks();});
|
||||
requestAnimationFrame(()=>{highlightCode();addCopyButtons();loadDiffInline();loadCsvInline();loadExcalidrawInline();loadPdfInline();loadHtmlInline();renderMermaidBlocks();renderKatexBlocks();});
|
||||
requestAnimationFrame(()=>{highlightCode();addCopyButtons();initTreeViews();loadPdfInline();loadHtmlInline();renderMermaidBlocks();renderKatexBlocks();});
|
||||
if(typeof loadTodos==='function'&&document.getElementById('panelTodos')&&document.getElementById('panelTodos').classList.contains('active')){loadTodos();}
|
||||
return;
|
||||
}
|
||||
@@ -2578,6 +2670,7 @@ function renderMessages(){
|
||||
let _prevSepKey=null;
|
||||
let currentAssistantTurn=null;
|
||||
const assistantSegments=new Map();
|
||||
const assistantThinking=new Map();
|
||||
const userRows=new Map();
|
||||
for(let vi=0;vi<visWithIdx.length;vi++){
|
||||
const {m,rawIdx}=visWithIdx[vi];
|
||||
@@ -2637,6 +2730,18 @@ function renderMessages(){
|
||||
const imgUrl='api/file/raw?session_id='+encodeURIComponent(_attachSid)+'&path='+encodeURIComponent(fname);
|
||||
return `<img class="msg-media-img" src="${esc(imgUrl)}" alt="${esc(fname)}" loading="lazy">`;
|
||||
}
|
||||
if(_SVG_EXTS.test(fname)){
|
||||
const svgUrl='api/file/raw?session_id='+encodeURIComponent(_attachSid)+'&path='+encodeURIComponent(fname);
|
||||
return `<img class="msg-media-svg" src="${esc(svgUrl)}" alt="${esc(fname)}" loading="lazy">`;
|
||||
}
|
||||
if(_AUDIO_EXTS.test(fname)){
|
||||
const audioUrl='api/file/raw?session_id='+encodeURIComponent(_attachSid)+'&path='+encodeURIComponent(fname);
|
||||
return `<div class="msg-media-audio"><span class="msg-media-label">🎵 ${esc(fname)}</span><audio controls preload="metadata" src="${esc(audioUrl)}"></audio></div>`;
|
||||
}
|
||||
if(_VIDEO_EXTS.test(fname)){
|
||||
const videoUrl='api/file/raw?session_id='+encodeURIComponent(_attachSid)+'&path='+encodeURIComponent(fname);
|
||||
return `<div class="msg-media-video"><span class="msg-media-label">🎬 ${esc(fname)}</span><video controls preload="metadata" src="${esc(videoUrl)}"></video></div>`;
|
||||
}
|
||||
return `<div class="msg-file-badge">${li('paperclip',12)} ${esc(fname)}</div>`;
|
||||
}).join('')}</div>`;
|
||||
}
|
||||
@@ -2695,11 +2800,14 @@ function renderMessages(){
|
||||
seg.setAttribute('data-live-assistant','1');
|
||||
}
|
||||
if(_ERR_MSG_RE.test(String(content||'').trim())) seg.dataset.error='1';
|
||||
if(thinkingText&&window._showThinking!==false) seg.insertAdjacentHTML('beforeend', _thinkingCardHtml(thinkingText));
|
||||
if(thinkingText&&window._showThinking!==false){
|
||||
if(isSimplifiedToolCalling()) assistantThinking.set(rawIdx, thinkingText);
|
||||
else if(window._showThinking!==false) seg.insertAdjacentHTML('beforeend', _thinkingCardHtml(thinkingText));
|
||||
}
|
||||
const hasVisibleBody=!!(String(content||'').trim()||filesHtml);
|
||||
if(hasVisibleBody){
|
||||
seg.insertAdjacentHTML('beforeend', `${filesHtml}<div class="msg-body">${bodyHtml}</div>${footHtml}`);
|
||||
}else if(!thinkingText){
|
||||
}else if(!(thinkingText&&window._showThinking!==false&&!isSimplifiedToolCalling())){
|
||||
seg.classList.add('assistant-segment-anchor');
|
||||
}
|
||||
_assistantTurnBlocks(currentAssistantTurn).appendChild(seg);
|
||||
@@ -2813,53 +2921,78 @@ function renderMessages(){
|
||||
});
|
||||
if(derived.length) S.toolCalls=derived;
|
||||
}
|
||||
if(!S.busy && S.toolCalls && S.toolCalls.length){
|
||||
inner.querySelectorAll('.tool-card-row:not([data-compression-card])').forEach(el=>el.remove());
|
||||
if(!S.busy){
|
||||
inner.querySelectorAll('.tool-call-group:not([data-compression-card]),.tool-card-row:not([data-compression-card])').forEach(el=>el.remove());
|
||||
const byAssistant = {};
|
||||
for(const tc of S.toolCalls){
|
||||
for(const tc of (S.toolCalls||[])){
|
||||
const key = tc.assistant_msg_idx !== undefined ? tc.assistant_msg_idx : -1;
|
||||
if(!byAssistant[key]) byAssistant[key] = [];
|
||||
byAssistant[key].push(tc);
|
||||
}
|
||||
const assistantIdxs=[...assistantSegments.keys()].sort((a,b)=>a-b);
|
||||
const anchorInsertAfter = new Map();
|
||||
for(const [key, cards] of Object.entries(byAssistant)){
|
||||
const aIdx = parseInt(key);
|
||||
let anchorRow=assistantSegments.get(aIdx)||null;
|
||||
if(!anchorRow&&assistantIdxs.length){
|
||||
const fallbackIdx=[...assistantIdxs].reverse().find(idx=>idx<=aIdx);
|
||||
anchorRow=fallbackIdx!==undefined?assistantSegments.get(fallbackIdx):assistantSegments.get(assistantIdxs[assistantIdxs.length-1]);
|
||||
if(isSimplifiedToolCalling()){
|
||||
const activityIdxs=[...new Set([...Object.keys(byAssistant).map(k=>parseInt(k)), ...assistantThinking.keys()])].sort((a,b)=>a-b);
|
||||
for(const aIdx of activityIdxs){
|
||||
const cards=byAssistant[aIdx]||[];
|
||||
let anchorRow=assistantSegments.get(aIdx)||null;
|
||||
if(!anchorRow&&assistantIdxs.length){
|
||||
const fallbackIdx=[...assistantIdxs].reverse().find(idx=>idx<=aIdx);
|
||||
anchorRow=fallbackIdx!==undefined?assistantSegments.get(fallbackIdx):assistantSegments.get(assistantIdxs[assistantIdxs.length-1]);
|
||||
}
|
||||
if(!anchorRow) continue;
|
||||
const anchorParent=anchorRow.parentElement;
|
||||
const insertAfterNode = anchorInsertAfter.get(anchorRow) || anchorRow;
|
||||
const group=ensureActivityGroup(anchorParent,{collapsed:true,anchor:insertAfterNode});
|
||||
const body=group&&group.querySelector('.tool-call-group-body');
|
||||
if(!body) continue;
|
||||
const thinkingText=assistantThinking.get(aIdx);
|
||||
if(thinkingText) body.appendChild(_thinkingActivityNode(thinkingText));
|
||||
for(const tc of cards){
|
||||
body.appendChild(buildToolCard(tc));
|
||||
}
|
||||
_syncToolCallGroupSummary(group);
|
||||
if(anchorRow) anchorInsertAfter.set(anchorRow, group);
|
||||
}
|
||||
if(!anchorRow) continue;
|
||||
const anchorParent=anchorRow.parentElement;
|
||||
const frag=document.createDocumentFragment();
|
||||
let lastInsertedNode=null;
|
||||
for(const tc of cards){
|
||||
const card=buildToolCard(tc);
|
||||
frag.appendChild(card);
|
||||
lastInsertedNode=card;
|
||||
}else if(S.toolCalls && S.toolCalls.length){
|
||||
for(const [key, cards] of Object.entries(byAssistant)){
|
||||
const aIdx = parseInt(key);
|
||||
let anchorRow=assistantSegments.get(aIdx)||null;
|
||||
if(!anchorRow&&assistantIdxs.length){
|
||||
const fallbackIdx=[...assistantIdxs].reverse().find(idx=>idx<=aIdx);
|
||||
anchorRow=fallbackIdx!==undefined?assistantSegments.get(fallbackIdx):assistantSegments.get(assistantIdxs[assistantIdxs.length-1]);
|
||||
}
|
||||
if(!anchorRow) continue;
|
||||
const anchorParent=anchorRow.parentElement;
|
||||
const frag=document.createDocumentFragment();
|
||||
let lastInsertedNode=null;
|
||||
for(const tc of cards){
|
||||
const card=buildToolCard(tc);
|
||||
frag.appendChild(card);
|
||||
lastInsertedNode=card;
|
||||
}
|
||||
// Add expand/collapse toggle for groups with 2+ cards
|
||||
if(cards.length>=2){
|
||||
const toggle=document.createElement('div');
|
||||
toggle.className='tool-cards-toggle';
|
||||
// Collect card elements before they get moved to DOM
|
||||
const cardEls=Array.from(frag.querySelectorAll('.tool-card'));
|
||||
const expandBtn=document.createElement('button');
|
||||
expandBtn.textContent=t('expand_all');
|
||||
expandBtn.onclick=()=>cardEls.forEach(c=>c.classList.add('open'));
|
||||
const collapseBtn=document.createElement('button');
|
||||
collapseBtn.textContent=t('collapse_all');
|
||||
collapseBtn.onclick=()=>cardEls.forEach(c=>c.classList.remove('open'));
|
||||
toggle.appendChild(expandBtn);
|
||||
toggle.appendChild(collapseBtn);
|
||||
frag.insertBefore(toggle,frag.firstChild);
|
||||
}
|
||||
const insertAfterNode = anchorInsertAfter.get(anchorRow) || anchorRow;
|
||||
const refNode = insertAfterNode ? insertAfterNode.nextSibling : null;
|
||||
if(refNode) anchorParent.insertBefore(frag,refNode);
|
||||
else anchorParent.appendChild(frag);
|
||||
if(anchorRow&&lastInsertedNode) anchorInsertAfter.set(anchorRow, lastInsertedNode);
|
||||
}
|
||||
// Add expand/collapse toggle for groups with 2+ cards
|
||||
if(cards.length>=2){
|
||||
const toggle=document.createElement('div');
|
||||
toggle.className='tool-cards-toggle';
|
||||
// Collect card elements before they get moved to DOM
|
||||
const cardEls=Array.from(frag.querySelectorAll('.tool-card'));
|
||||
const expandBtn=document.createElement('button');
|
||||
expandBtn.textContent=t('expand_all');
|
||||
expandBtn.onclick=()=>cardEls.forEach(c=>c.classList.add('open'));
|
||||
const collapseBtn=document.createElement('button');
|
||||
collapseBtn.textContent=t('collapse_all');
|
||||
collapseBtn.onclick=()=>cardEls.forEach(c=>c.classList.remove('open'));
|
||||
toggle.appendChild(expandBtn);
|
||||
toggle.appendChild(collapseBtn);
|
||||
frag.insertBefore(toggle,frag.firstChild);
|
||||
}
|
||||
const insertAfterNode = anchorInsertAfter.get(anchorRow) || anchorRow;
|
||||
const refNode = insertAfterNode ? insertAfterNode.nextSibling : null;
|
||||
if(refNode) anchorParent.insertBefore(frag,refNode);
|
||||
else anchorParent.appendChild(frag);
|
||||
if(anchorRow&&lastInsertedNode) anchorInsertAfter.set(anchorRow, lastInsertedNode);
|
||||
}
|
||||
}
|
||||
// Render per-turn token usage on each assistant message that has it (#503).
|
||||
@@ -2898,8 +3031,8 @@ function renderMessages(){
|
||||
scrollToBottom();
|
||||
}
|
||||
// Apply syntax highlighting after DOM is built
|
||||
requestAnimationFrame(()=>{highlightCode();addCopyButtons();loadDiffInline();renderMermaidBlocks();renderKatexBlocks();});
|
||||
requestAnimationFrame(()=>{highlightCode();addCopyButtons();initTreeViews();renderMermaidBlocks();renderKatexBlocks();});
|
||||
requestAnimationFrame(()=>{highlightCode();addCopyButtons();loadDiffInline();loadCsvInline();loadExcalidrawInline();loadPdfInline();loadHtmlInline();renderMermaidBlocks();renderKatexBlocks();});
|
||||
requestAnimationFrame(()=>{highlightCode();addCopyButtons();initTreeViews();loadPdfInline();loadHtmlInline();renderMermaidBlocks();renderKatexBlocks();});
|
||||
// Refresh todo panel if it's currently open
|
||||
if(typeof loadTodos==='function' && document.getElementById('panelTodos') && document.getElementById('panelTodos').classList.contains('active')){
|
||||
loadTodos();
|
||||
@@ -2916,6 +3049,12 @@ function renderMessages(){
|
||||
}
|
||||
}
|
||||
|
||||
function _toolDisplayName(tc){
|
||||
const name=(tc&&tc.name)||'tool';
|
||||
if(name==='subagent_progress') return 'Subagent';
|
||||
if(name==='delegate_task') return 'Delegate task';
|
||||
return name;
|
||||
}
|
||||
function toolIcon(name){
|
||||
const icons={
|
||||
terminal: li('terminal'),
|
||||
@@ -2960,9 +3099,7 @@ function buildToolCard(tc){
|
||||
const isDelegation=tc.name==='delegate_task';
|
||||
const cardClass='tool-card'+(tc.done===false?' tool-card-running':'')+(isSubagent?' tool-card-subagent':'');
|
||||
// Clean up legacy subagent prefixes since the Lucide icon already shows it
|
||||
let displayName=tc.name;
|
||||
if(isSubagent) displayName='Subagent';
|
||||
if(isDelegation) displayName='Delegate task';
|
||||
let displayName=_toolDisplayName(tc);
|
||||
let previewText=tc.preview||displaySnippet||'';
|
||||
if(isSubagent) previewText=previewText.replace(/^(?:\u{1F500}|↳)\s*/u,'');
|
||||
row.innerHTML=`
|
||||
@@ -2987,6 +3124,33 @@ function buildToolCard(tc){
|
||||
return row;
|
||||
}
|
||||
|
||||
function _syncToolCallGroupSummary(group){
|
||||
if(!group) return;
|
||||
const cards=Array.from(group.querySelectorAll('.tool-card-row .tool-card'));
|
||||
const toolCount=cards.length;
|
||||
const thinkingCount=group.querySelectorAll('.agent-activity-thinking .thinking-card').length;
|
||||
const names=cards.map(card=>{
|
||||
const el=card.querySelector('.tool-card-name');
|
||||
return el?String(el.textContent||'').trim():'';
|
||||
}).filter(Boolean);
|
||||
const uniqueNames=[...new Set(names)];
|
||||
const label=group.querySelector('.tool-call-group-label');
|
||||
const list=group.querySelector('.tool-call-group-list');
|
||||
const badge=group.querySelector('.tool-call-group-count');
|
||||
const parts=[];
|
||||
if(thinkingCount) parts.push('thinking');
|
||||
if(uniqueNames.length) parts.push(uniqueNames.slice(0,5).join(', ')+(uniqueNames.length>5?'…':''));
|
||||
const total=toolCount+thinkingCount;
|
||||
if(label){
|
||||
if(thinkingCount&&toolCount) label.textContent=`Activity: thinking + ${toolCount} tool${toolCount===1?'':'s'}`;
|
||||
else if(thinkingCount) label.textContent='Activity: thinking';
|
||||
else if(toolCount) label.textContent=`Activity: ${toolCount} tool${toolCount===1?'':'s'}`;
|
||||
else label.textContent='Activity';
|
||||
}
|
||||
if(list) list.textContent=parts.join(' · ')||'tools / thinking';
|
||||
if(badge) badge.textContent=String(total);
|
||||
}
|
||||
|
||||
// ── Live tool card helpers (called during SSE streaming) ──
|
||||
// Live cards are inserted INLINE inside #msgInner (tagged with data-live-tid)
|
||||
// so the streaming layout matches the settled layout produced by renderMessages
|
||||
@@ -3000,52 +3164,76 @@ function appendLiveToolCard(tc){
|
||||
if(!S.session||!S.activeStreamId) return;
|
||||
let turn=$('liveAssistantTurn');
|
||||
if(!turn){
|
||||
appendThinking();
|
||||
turn=$('liveAssistantTurn');
|
||||
turn=_createAssistantTurn();
|
||||
turn.id='liveAssistantTurn';
|
||||
$('msgInner').appendChild(turn);
|
||||
}
|
||||
const inner=_assistantTurnBlocks(turn);
|
||||
if(!inner) return;
|
||||
const tid=tc.tid||'';
|
||||
if(!isSimplifiedToolCalling()){
|
||||
// Update existing card in place (tool_complete after tool_start)
|
||||
if(tid){
|
||||
const existing=inner.querySelector(`.tool-card-row[data-live-tid="${CSS.escape(tid)}"]`);
|
||||
if(existing){
|
||||
const replacement=buildToolCard(tc);
|
||||
replacement.dataset.liveTid=tid;
|
||||
existing.replaceWith(replacement);
|
||||
// Keep #toolRunningRow alive — dots stay until text starts streaming
|
||||
// or the next tool fires (which replaces them). Removing here caused
|
||||
// a gap between tool completion and the first text token arriving.
|
||||
return;
|
||||
}
|
||||
}
|
||||
const row=buildToolCard(tc);
|
||||
if(tid) row.dataset.liveTid=tid;
|
||||
// Insert after whichever comes last: the current live assistant segment or
|
||||
// the last tool card. This handles both cases:
|
||||
// text → tool1 → tool2 (no text between tools: anchor is card1)
|
||||
// text1 → tool1 → text2 → tool2 (text between tools: anchor is text2)
|
||||
const children=Array.from(inner.children);
|
||||
// Include .thinking-card-row so tool cards land AFTER a finalized thinking
|
||||
// card, not between the text segment and thinking.
|
||||
const anchor=children.filter(el=>el.matches('[data-live-assistant="1"],.tool-card-row,.thinking-card-row')).pop();
|
||||
if(anchor) anchor.insertAdjacentElement('afterend', row);
|
||||
else inner.appendChild(row);
|
||||
// Add a 3-dot waiting indicator below the tool card so there's visual
|
||||
// feedback while the tool is running. Removed when text starts streaming
|
||||
// (ensureAssistantRow) or when tool_complete fires.
|
||||
const oldWait=$('toolRunningRow');if(oldWait)oldWait.remove();
|
||||
const waitRow=document.createElement('div');
|
||||
waitRow.id='toolRunningRow';
|
||||
waitRow.className='assistant-segment';
|
||||
waitRow.innerHTML='<div class="thinking"><div class="dot"></div><div class="dot"></div><div class="dot"></div></div>';
|
||||
row.insertAdjacentElement('afterend', waitRow);
|
||||
if(typeof scrollIfPinned==='function') scrollIfPinned();
|
||||
return;
|
||||
}
|
||||
const children=Array.from(inner.children);
|
||||
const anchor=children.filter(el=>el.matches('[data-live-assistant="1"],.tool-call-group,.tool-card-row,.agent-activity-thinking')).pop();
|
||||
const group=ensureActivityGroup(inner,{live:true,collapsed:false,anchor});
|
||||
const body=group.querySelector('.tool-call-group-body');
|
||||
// Update existing card in place (tool_complete after tool_start)
|
||||
if(tid){
|
||||
const existing=inner.querySelector(`.tool-card-row[data-live-tid="${CSS.escape(tid)}"]`);
|
||||
const existing=body.querySelector(`.tool-card-row[data-live-tid="${CSS.escape(tid)}"]`);
|
||||
if(existing){
|
||||
const replacement=buildToolCard(tc);
|
||||
replacement.dataset.liveTid=tid;
|
||||
existing.replaceWith(replacement);
|
||||
// Keep #toolRunningRow alive — dots stay until text starts streaming
|
||||
// or the next tool fires (which replaces them). Removing here caused
|
||||
// a gap between tool completion and the first text token arriving.
|
||||
_syncToolCallGroupSummary(group);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const row=buildToolCard(tc);
|
||||
if(tid) row.dataset.liveTid=tid;
|
||||
// Insert after whichever comes last: the current live assistant segment or
|
||||
// the last tool card. This handles both cases:
|
||||
// text → tool1 → tool2 (no text between tools: anchor is card1)
|
||||
// text1 → tool1 → text2 → tool2 (text between tools: anchor is text2)
|
||||
const children=Array.from(inner.children);
|
||||
// Include .thinking-card-row so tool cards land AFTER a finalized thinking
|
||||
// card, not between the text segment and thinking.
|
||||
const anchor=children.filter(el=>el.matches('[data-live-assistant="1"],.tool-card-row,.thinking-card-row')).pop();
|
||||
if(anchor) anchor.insertAdjacentElement('afterend', row);
|
||||
else inner.appendChild(row);
|
||||
// Add a 3-dot waiting indicator below the tool card so there's visual
|
||||
// feedback while the tool is running. Removed when text starts streaming
|
||||
// (ensureAssistantRow) or when tool_complete fires.
|
||||
const oldWait=$('toolRunningRow');if(oldWait)oldWait.remove();
|
||||
const waitRow=document.createElement('div');
|
||||
waitRow.id='toolRunningRow';
|
||||
waitRow.className='assistant-segment';
|
||||
waitRow.innerHTML='<div class="thinking"><div class="dot"></div><div class="dot"></div><div class="dot"></div></div>';
|
||||
row.insertAdjacentElement('afterend', waitRow);
|
||||
body.appendChild(row);
|
||||
_syncToolCallGroupSummary(group);
|
||||
if(typeof scrollIfPinned==='function') scrollIfPinned();
|
||||
}
|
||||
|
||||
function clearLiveToolCards(){
|
||||
const inner=_assistantTurnBlocks($('liveAssistantTurn'));
|
||||
if(inner) inner.querySelectorAll('.tool-card-row[data-live-tid]').forEach(el=>el.remove());
|
||||
if(inner) inner.querySelectorAll('.tool-call-group[data-live-tool-call-group],.tool-card-row[data-live-tid]').forEach(el=>el.remove());
|
||||
// Legacy #liveToolCards container cleanup — kept for safety in case any
|
||||
// leftover cards were inserted there before this refactor took effect.
|
||||
const container=$('liveToolCards');
|
||||
@@ -3351,6 +3539,266 @@ function loadDiffInline(){
|
||||
});
|
||||
}
|
||||
|
||||
function loadCsvInline(){
|
||||
const CSV_MAX_SIZE=256*1024; // 256 KB cap for inline CSV rendering
|
||||
document.querySelectorAll('.csv-inline-load:not([data-loaded])').forEach(el=>{
|
||||
el.setAttribute('data-loaded','1');
|
||||
const path=el.dataset.path;
|
||||
fetch('api/media?path='+encodeURIComponent(path))
|
||||
.then(r=>{if(!r.ok) throw new Error(r.status);return r.text();})
|
||||
.then(text=>{
|
||||
if(text.length>CSV_MAX_SIZE){
|
||||
el.outerHTML=`<div class="diff-inline-error">${esc(path.split('/').pop())}<br><span style="color:var(--muted);font-size:12px">${t('csv_too_large')}</span></div>`;
|
||||
return;
|
||||
}
|
||||
const rows=text.replace(/\r\n/g,'\n').replace(/\r/g,'\n').split('\n').filter(r=>r.trim());
|
||||
if(rows.length<2){
|
||||
el.outerHTML=`<div class="diff-inline-error">${esc(path.split('/').pop())}<br><span style="color:var(--muted);font-size:12px">${t('csv_no_data')}</span></div>`;
|
||||
return;
|
||||
}
|
||||
// Auto-detect separator (comma, semicolon, tab)
|
||||
// Heuristic: uses the first separator found in the header row. Edge case:
|
||||
// quoted fields containing commas without non-quoted commas in the header
|
||||
// could cause misdetection — acceptable trade-off for a preview renderer.
|
||||
const firstLine=rows[0];
|
||||
const separators=[',',';','\t'];
|
||||
let sep=separators.find(s=>firstLine.includes(s))||',';
|
||||
const headers=rows[0].split(sep).map(c=>c.trim().replace(/^["']|["']$/g,''));
|
||||
const bodyRows=rows.slice(1).map(r=>'<tr>'+r.split(sep).map(c=>`<td>${esc(c.trim().replace(/^["']|["']$/g,''))}</td>`).join('')+'</tr>').join('');
|
||||
const headerRow=headers.map(h=>`<th>${esc(h)}</th>`).join('');
|
||||
el.outerHTML=`<div class="csv-table-wrap"><div class="pre-header">${esc(path.split('/').pop())} <span style="opacity:.5;font-size:11px">${t('csv_header_note')}</span></div><table class="csv-table"><thead><tr>${headerRow}</tr></thead><tbody>${bodyRows}</tbody></table></div>`;
|
||||
})
|
||||
.catch(()=>{
|
||||
el.outerHTML=`<div class="diff-inline-error">${esc(path.split('/').pop())}<br><span style="color:var(--muted);font-size:12px">${t('csv_error')}</span></div>`;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function loadExcalidrawInline(){
|
||||
const EXCALIDRAW_MAX_SIZE=512*1024; // 512 KB cap
|
||||
document.querySelectorAll('.excalidraw-inline-load:not([data-loaded])').forEach(el=>{
|
||||
el.setAttribute('data-loaded','1');
|
||||
const path=el.dataset.path;
|
||||
fetch('api/media?path='+encodeURIComponent(path))
|
||||
.then(r=>{if(!r.ok) throw new Error(r.status);return r.text();})
|
||||
.then(text=>{
|
||||
if(text.length>EXCALIDRAW_MAX_SIZE){
|
||||
el.outerHTML=`<div class="diff-inline-error">${esc(path.split('/').pop())}<br><span style="color:var(--muted);font-size:12px">${t('excalidraw_too_large')}</span></div>`;
|
||||
return;
|
||||
}
|
||||
// Validate it looks like Excalidraw JSON
|
||||
let data;
|
||||
try{data=JSON.parse(text);}catch(e){
|
||||
el.outerHTML=`<div class="diff-inline-error">${esc(path.split('/').pop())}<br><span style="color:var(--muted);font-size:12px">${t('excalidraw_invalid')}</span></div>`;
|
||||
return;
|
||||
}
|
||||
if(!data.type||data.type!=='excalidraw'){
|
||||
el.outerHTML=`<div class="diff-inline-error">${esc(path.split('/').pop())}<br><span style="color:var(--muted);font-size:12px">${t('excalidraw_invalid')}</span></div>`;
|
||||
return;
|
||||
}
|
||||
const fname=esc(path.split('/').pop());
|
||||
const downloadUrl='api/media?path='+encodeURIComponent(path)+'&download=1';
|
||||
el.outerHTML=`<div class="excalidraw-embed-wrap" title="${t('excalidraw_simplified')}">
|
||||
<div class="msg-artifact-header">
|
||||
<span class="msg-media-label">${t('excalidraw_label')}</span>
|
||||
<a class="excalidraw-open-link" href="${downloadUrl}" download="${fname}">${t('excalidraw_download')} ${fname}</a>
|
||||
</div>
|
||||
<div class="excalidraw-canvas" data-excalidraw='${esc(text)}'></div>
|
||||
</div>`;
|
||||
// Lazy-init Excalidraw render after DOM insertion
|
||||
requestAnimationFrame(()=>_renderExcalidrawCanvases());
|
||||
})
|
||||
.catch(()=>{
|
||||
el.outerHTML=`<div class="diff-inline-error">${esc(path.split('/').pop())}<br><span style="color:var(--muted);font-size:12px">${t('excalidraw_error')}</span></div>`;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let _excalidrawScriptLoaded=false;
|
||||
function _renderExcalidrawCanvases(){
|
||||
document.querySelectorAll('.excalidraw-canvas:not([data-rendered])').forEach(el=>{
|
||||
el.setAttribute('data-rendered','1');
|
||||
const dataStr=el.getAttribute('data-excalidraw');
|
||||
if(!dataStr) return;
|
||||
// Render a simple SVG preview using the Excalidraw elements
|
||||
try{
|
||||
const data=JSON.parse(dataStr);
|
||||
const elements=data.elements||[];
|
||||
if(!elements.length){el.innerHTML=`<div class="excalidraw-empty">${t('excalidraw_empty')}</div>`;return;}
|
||||
// Calculate bounds
|
||||
let minX=Infinity,minY=Infinity,maxX=-Infinity,maxY=-Infinity;
|
||||
elements.forEach(el=>{
|
||||
const b=[el.x||0,el.y||0,(el.x||0)+(el.width||0),(el.y||0)+(el.height||0)];
|
||||
minX=Math.min(minX,b[0]);minY=Math.min(minY,b[1]);
|
||||
maxX=Math.max(maxX,b[2]);maxY=Math.max(maxY,b[3]);
|
||||
});
|
||||
const pad=20;minX-=pad;minY-=pad;maxX+=pad;maxY+=pad;
|
||||
const w=Math.max(maxX-minX,200);const h=Math.max(maxY-minY,150);
|
||||
// SVG attributes are rendered via innerHTML below, so attacker-controlled
|
||||
// values from JSON (e.g. strokeColor='red"/><script>...') would break out
|
||||
// of the attribute. Escape strings; coerce numerics.
|
||||
const _sa=v=>String(v==null?'':v).replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>');
|
||||
const _num=(v,fb)=>{const n=Number(v);return Number.isFinite(n)?n:fb;};
|
||||
const svgParts=[`<svg xmlns="http://www.w3.org/2000/svg" viewBox="${_num(minX,0)} ${_num(minY,0)} ${_num(w,200)} ${_num(h,150)}" class="excalidraw-svg">`];
|
||||
elements.forEach(el=>{
|
||||
const stroke=_sa(el.strokeColor||'#1e1e1e');
|
||||
const fill=_sa(el.backgroundColor||'transparent');
|
||||
const sw=_num(el.strokeWidth,2);
|
||||
const x=_num(el.x,0),y=_num(el.y,0),w=_num(el.width,0),h=_num(el.height,0);
|
||||
if(el.type==='rectangle'){
|
||||
svgParts.push(`<rect x="${x}" y="${y}" width="${w}" height="${h}" stroke="${stroke}" stroke-width="${sw}" fill="${fill}" rx="${el.roundness?.type===3?8:0}"/>`);
|
||||
}else if(el.type==='diamond'){
|
||||
const cx=x+w/2,cy=y+h/2;
|
||||
svgParts.push(`<polygon points="${cx},${y} ${x+w},${cy} ${cx},${y+h} ${x},${cy}" stroke="${stroke}" stroke-width="${sw}" fill="${fill}"/>`);
|
||||
}else if(el.type==='ellipse'){
|
||||
svgParts.push(`<ellipse cx="${x+w/2}" cy="${y+h/2}" rx="${w/2}" ry="${h/2}" stroke="${stroke}" stroke-width="${sw}" fill="${fill}"/>`);
|
||||
}else if(el.type==='line'){
|
||||
const pts=(el.points||[]).filter(p=>Array.isArray(p)&&p.length>=2);
|
||||
if(!pts.length) return;
|
||||
let d=`M ${_num(x+_num(pts[0][0],0),0)} ${_num(y+_num(pts[0][1],0),0)}`;
|
||||
for(let i=1;i<pts.length;i++) d+=` L ${_num(x+_num(pts[i][0],0),0)} ${_num(y+_num(pts[i][1],0),0)}`;
|
||||
svgParts.push(`<path d="${d}" stroke="${stroke}" stroke-width="${sw}" fill="none" stroke-linecap="round" stroke-linejoin="round"/>`);
|
||||
}else if(el.type==='arrow'){
|
||||
const pts=(el.points||[]).filter(p=>Array.isArray(p)&&p.length>=2);
|
||||
if(!pts.length) return;
|
||||
let d=`M ${_num(x+_num(pts[0][0],0),0)} ${_num(y+_num(pts[0][1],0),0)}`;
|
||||
for(let i=1;i<pts.length;i++) d+=` L ${_num(x+_num(pts[i][0],0),0)} ${_num(y+_num(pts[i][1],0),0)}`;
|
||||
svgParts.push(`<path d="${d}" stroke="${stroke}" stroke-width="${sw}" fill="none" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowhead)"/>`);
|
||||
}else if(el.type==='text'){
|
||||
const fontSize=_num(el.fontSize,20);
|
||||
const txt=String(el.text==null?'':el.text);
|
||||
const lines=txt.split('\n');
|
||||
lines.forEach((line,i)=>{
|
||||
svgParts.push(`<text x="${x}" y="${y+i*fontSize*1.2+fontSize}" fill="${stroke}" font-size="${fontSize}" font-family="Virgil, Segoe UI Emoji, sans-serif">${esc(line)}</text>`);
|
||||
});
|
||||
}else if(el.type==='draw'){
|
||||
const pts=(el.points||[]).filter(p=>Array.isArray(p)&&p.length>=2);
|
||||
if(pts.length>1){
|
||||
let d=`M ${_num(x+_num(pts[0][0],0),0)} ${_num(y+_num(pts[0][1],0),0)}`;
|
||||
for(let i=1;i<pts.length;i++) d+=` L ${_num(x+_num(pts[i][0],0),0)} ${_num(y+_num(pts[i][1],0),0)}`;
|
||||
svgParts.push(`<path d="${d}" stroke="${stroke}" stroke-width="${sw}" fill="none" stroke-linecap="round" stroke-linejoin="round"/>`);
|
||||
}
|
||||
}
|
||||
// Unknown element types (e.g. image, frame, group, freedraw) are
|
||||
// silently skipped to avoid breaking the render. This is a simplified
|
||||
// SVG preview, not a pixel-identical Excalidraw canvas reproduction.
|
||||
});
|
||||
// Arrow marker definition
|
||||
svgParts.unshift(`<defs><marker id="arrowhead" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto"><polygon points="0 0, 10 3.5, 0 7" fill="#1e1e1e"/></marker></defs>`);
|
||||
svgParts.push('</svg>');
|
||||
el.innerHTML=svgParts.join('');
|
||||
}catch(e){
|
||||
el.innerHTML=`<div class="excalidraw-empty">${t('excalidraw_render_error')}</div>`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── PDF inline preview (first page) ────────────────────────────────────────
|
||||
// NOTE: PDF.js is loaded from CDN (jsdelivr). Offline/air-gapped deployments
|
||||
// will not get inline previews; the 15 s fallback timeout degrades to a
|
||||
// download link in that case. The 4 MB size cap is checked client-side after
|
||||
// the full buffer is received — ideally the server would enforce it before
|
||||
// streaming (out of scope for this client-side PR).
|
||||
let _pdfjsReady=false, _pdfjsLoading=false;
|
||||
function loadPdfInline(){
|
||||
const PDF_MAX_SIZE=4*1024*1024; // 4 MB cap for inline PDF preview
|
||||
document.querySelectorAll('.pdf-preview-load:not([data-loaded])').forEach(el=>{
|
||||
el.setAttribute('data-loaded','1');
|
||||
const path=el.dataset.path;
|
||||
const fname=path.split('/').pop()||path;
|
||||
const loadPdf=(pdfjsLib)=>{
|
||||
fetch('api/media?path='+encodeURIComponent(path))
|
||||
.then(r=>{if(!r.ok) throw new Error(r.status); return r.arrayBuffer();})
|
||||
.then(buf=>{
|
||||
if(buf.byteLength>PDF_MAX_SIZE){
|
||||
el.outerHTML=`<div class="pdf-preview-fallback"><a class="msg-media-link" href="api/media?path=${encodeURIComponent(path)}&download=1" download="${esc(fname)}">📎 ${esc(fname)}</a><br><span style="color:var(--muted);font-size:12px">${t('pdf_too_large')}</span></div>`;
|
||||
return;
|
||||
}
|
||||
return pdfjsLib.getDocument({data:buf}).promise;
|
||||
})
|
||||
.then(pdf=>{
|
||||
if(!pdf) return;
|
||||
pdf.getPage(1).then(page=>{
|
||||
const canvas=document.createElement('canvas');
|
||||
const scale=1.5;
|
||||
const viewport=page.getViewport({scale});
|
||||
canvas.width=viewport.width;
|
||||
canvas.height=viewport.height;
|
||||
canvas.className='pdf-preview-canvas';
|
||||
page.render({canvasContext:canvas.getContext('2d'),viewport}).promise.then(()=>{
|
||||
// Canvas bitmap is runtime state, not part of HTML serialization.
|
||||
// Attach the canvas as a DOM node — interpolating its serialized
|
||||
// form into a template string parses back as an empty canvas.
|
||||
const dlUrl='api/media?path='+encodeURIComponent(path)+'&download=1';
|
||||
const wrap=document.createElement('div');
|
||||
wrap.className='pdf-preview-wrap';
|
||||
wrap.innerHTML=`<div class="pdf-preview-header"><span>📄 ${esc(fname)}</span><a href="${dlUrl}" download="${esc(fname)}" class="pdf-download-link">${t('pdf_download')} ↓</a></div><div class="pdf-preview-body"></div>`;
|
||||
wrap.querySelector('.pdf-preview-body').appendChild(canvas);
|
||||
el.replaceWith(wrap);
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch(()=>{
|
||||
const dlUrl='api/media?path='+encodeURIComponent(path)+'&download=1';
|
||||
el.outerHTML=`<div class="pdf-preview-fallback"><a class="msg-media-link" href="${dlUrl}" download="${esc(fname)}">📎 ${esc(fname)}</a><br><span style="color:var(--muted);font-size:12px">${t('pdf_error')}</span></div>`;
|
||||
});
|
||||
};
|
||||
if(_pdfjsReady){
|
||||
loadPdf(window._pdfjsLib);
|
||||
} else if(!_pdfjsLoading){
|
||||
_pdfjsLoading=true;
|
||||
const s=document.createElement('script');
|
||||
s.src='https://cdn.jsdelivr.net/npm/pdfjs-dist@4.9.155/build/pdf.min.mjs';
|
||||
s.type='module';
|
||||
s.textContent=`
|
||||
import * as pdfjsLib from '${s.src}';
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc='https://cdn.jsdelivr.net/npm/pdfjs-dist@4.9.155/build/pdf.worker.min.mjs';
|
||||
window._pdfjsLib=pdfjsLib;
|
||||
window._pdfjsReady=true;
|
||||
window.dispatchEvent(new Event('pdfjs-ready'));
|
||||
`;
|
||||
document.head.appendChild(s);
|
||||
window.addEventListener('pdfjs-ready',()=>{ _pdfjsReady=true; loadPdf(window._pdfjsLib); },{once:true});
|
||||
setTimeout(()=>{
|
||||
if(!_pdfjsReady){
|
||||
const dlUrl='api/media?path='+encodeURIComponent(path)+'&download=1';
|
||||
if(el.parentNode){
|
||||
el.outerHTML=`<div class="pdf-preview-fallback"><a class="msg-media-link" href="${dlUrl}" download="${esc(fname)}">📎 ${esc(fname)}</a><br><span style="color:var(--muted);font-size:12px">${t('pdf_error')}</span></div>`;
|
||||
}
|
||||
}
|
||||
},15000);
|
||||
} else {
|
||||
window.addEventListener('pdfjs-ready',()=>{ loadPdf(window._pdfjsLib); },{once:true});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── HTML inline preview (sandboxed iframe) ─────────────────────────────────
|
||||
function loadHtmlInline(){
|
||||
const HTML_MAX_SIZE=256*1024; // 256 KB cap for inline HTML preview
|
||||
document.querySelectorAll('.html-preview-load:not([data-loaded])').forEach(el=>{
|
||||
el.setAttribute('data-loaded','1');
|
||||
const path=el.dataset.path;
|
||||
const fname=path.split('/').pop()||path;
|
||||
fetch('api/media?path='+encodeURIComponent(path))
|
||||
.then(r=>{if(!r.ok) throw new Error(r.status); return r.text();})
|
||||
.then(html=>{
|
||||
if(html.length>HTML_MAX_SIZE){
|
||||
const dlUrl='api/media?path='+encodeURIComponent(path)+'&download=1';
|
||||
el.outerHTML=`<div class="html-preview-fallback"><a class="msg-media-link" href="${dlUrl}" download="${esc(fname)}">📎 ${esc(fname)}</a><br><span style="color:var(--muted);font-size:12px">${t('html_too_large')}</span></div>`;
|
||||
return;
|
||||
}
|
||||
const dlUrl='api/media?path='+encodeURIComponent(path)+'&download=1';
|
||||
const safeHtml=html.replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>');
|
||||
el.outerHTML=`<div class="html-preview-wrap"><div class="html-preview-header"><span>${t('html_sandbox_label')}</span><a href="${dlUrl}" download="${esc(fname)}" class="html-open-link">${t('html_open_full')} ↗</a></div><iframe srcdoc="${safeHtml}" sandbox="allow-scripts" class="html-preview-iframe" loading="lazy"></iframe></div>`;
|
||||
})
|
||||
.catch(()=>{
|
||||
const dlUrl='api/media?path='+encodeURIComponent(path)+'&download=1';
|
||||
el.outerHTML=`<div class="html-preview-fallback"><a class="msg-media-link" href="${dlUrl}" download="${esc(fname)}">📎 ${esc(fname)}</a><br><span style="color:var(--muted);font-size:12px">${t('html_error')}</span></div>`;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderMermaidBlocks(){
|
||||
const blocks=document.querySelectorAll('.mermaid-block:not([data-rendered])');
|
||||
if(!blocks.length) return;
|
||||
@@ -3434,30 +3882,44 @@ function renderKatexBlocks(){
|
||||
|
||||
function _thinkingMarkup(text=''){
|
||||
const clean=_sanitizeThinkingDisplayText(text);
|
||||
const openClass=isSimplifiedToolCalling()?'':' open';
|
||||
return (clean&&String(clean).trim())
|
||||
? `<div class="thinking-card open"><div class="thinking-card-header" onclick="this.parentElement.classList.toggle('open')"><span class="thinking-card-icon">${li('lightbulb',14)}</span><span class="thinking-card-label">${t('thinking')}</span><span class="thinking-card-toggle">${li('chevron-right',12)}</span></div><div class="thinking-card-body"><pre>${esc(String(clean).trim())}</pre></div></div>`
|
||||
? `<div class="thinking-card${openClass}"><div class="thinking-card-header" onclick="this.parentElement.classList.toggle('open')"><span class="thinking-card-icon">${li('lightbulb',14)}</span><span class="thinking-card-label">${t('thinking')}</span><span class="thinking-card-toggle">${li('chevron-right',12)}</span></div><div class="thinking-card-body"><pre>${esc(String(clean).trim())}</pre></div></div>`
|
||||
: `<div class="thinking"><div class="dot"></div><div class="dot"></div><div class="dot"></div></div>`;
|
||||
}
|
||||
function finalizeThinkingCard(){
|
||||
const row=$('thinkingRow');
|
||||
if(!row) return;
|
||||
// If the row is still just a spinner (no thinking content rendered),
|
||||
// remove it entirely — it's the initial waiting dots.
|
||||
const hasContent=row.querySelector('.thinking-card') || row.classList.contains('thinking-card-row');
|
||||
if(!hasContent && row.getAttribute('data-thinking-active')==='1'){
|
||||
row.remove();
|
||||
if(!isSimplifiedToolCalling()){
|
||||
const row=$('thinkingRow');
|
||||
if(!row) return;
|
||||
// If the row is still just a spinner (no thinking content rendered),
|
||||
// remove it entirely — it's the initial waiting dots.
|
||||
const hasContent=row.querySelector('.thinking-card') || row.classList.contains('thinking-card-row');
|
||||
if(!hasContent && row.getAttribute('data-thinking-active')==='1'){
|
||||
row.remove();
|
||||
return;
|
||||
}
|
||||
// If the user was watching (scroll pinned = at bottom), scroll the thinking
|
||||
// card back to the top so the completed response is visible underneath without
|
||||
// the thinking content blocking it. If they scrolled up to read history,
|
||||
// leave their scroll position intact.
|
||||
if(_scrollPinned){
|
||||
const body=row&&row.querySelector('.thinking-card-body');
|
||||
if(body) body.scrollTop=0;
|
||||
}
|
||||
row.removeAttribute('id');
|
||||
row.removeAttribute('data-thinking-active');
|
||||
return;
|
||||
}
|
||||
// If the user was watching (scroll pinned = at bottom), scroll the thinking
|
||||
// card back to the top so the completed response is visible underneath without
|
||||
// the thinking content blocking it. If they scrolled up to read history,
|
||||
// leave their scroll position intact.
|
||||
if(_scrollPinned){
|
||||
const body=row&&row.querySelector('.thinking-card-body');
|
||||
if(body) body.scrollTop=0;
|
||||
const turn=$('liveAssistantTurn');
|
||||
const group=turn&&turn.querySelector('.tool-call-group[data-live-tool-call-group="1"]');
|
||||
if(group){
|
||||
group.classList.add('tool-call-group-collapsed');
|
||||
const summary=group.querySelector('.tool-call-group-summary');
|
||||
if(summary) summary.setAttribute('aria-expanded','false');
|
||||
const active=group.querySelector('.agent-activity-thinking[data-thinking-active="1"]');
|
||||
if(active) active.removeAttribute('data-thinking-active');
|
||||
_syncToolCallGroupSummary(group);
|
||||
}
|
||||
row.removeAttribute('id');
|
||||
row.removeAttribute('data-thinking-active');
|
||||
}
|
||||
function appendThinking(text=''){
|
||||
// Guard: ignore if session was switched during an async SSE stream.
|
||||
@@ -3472,40 +3934,81 @@ function appendThinking(text=''){
|
||||
$('msgInner').appendChild(turn);
|
||||
}
|
||||
const blocks=_assistantTurnBlocks(turn);
|
||||
let row=$('thinkingRow');
|
||||
if(!blocks) return;
|
||||
if(!isSimplifiedToolCalling()){
|
||||
let row=$('thinkingRow');
|
||||
if(!row){
|
||||
row=document.createElement('div');
|
||||
row.className='assistant-segment';
|
||||
row.id='thinkingRow';
|
||||
row.setAttribute('data-thinking-active','1');
|
||||
// Insert after whichever comes last: a live assistant segment or a tool card.
|
||||
// This mirrors appendLiveToolCard's anchor logic so thinking always appears
|
||||
// in the right position in the interleaved sequence.
|
||||
// Also skip #toolRunningRow (dots) — thinking should go before dots, not after.
|
||||
const allChildren=Array.from(blocks.children);
|
||||
const anchor=allChildren.filter(el=>
|
||||
el.id!=='toolRunningRow' &&
|
||||
el.matches('[data-live-assistant="1"],.tool-card-row')
|
||||
).pop();
|
||||
if(anchor) anchor.insertAdjacentElement('afterend', row);
|
||||
else blocks.appendChild(row);
|
||||
}
|
||||
row.className=(text&&String(text).trim())?'assistant-segment thinking-card-row':'assistant-segment';
|
||||
row.innerHTML=_thinkingMarkup(text);
|
||||
scrollIfPinned();
|
||||
// Auto-scroll the thinking card body to bottom if the user is watching
|
||||
// (scroll pinned). If the user scrolled up to read history, leave it alone.
|
||||
if(_scrollPinned){
|
||||
const body=row&&row.querySelector('.thinking-card-body');
|
||||
if(body) body.scrollTop=body.scrollHeight;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if(!String(text||'').trim()){
|
||||
scrollIfPinned();
|
||||
return;
|
||||
}
|
||||
const allChildren=Array.from(blocks.children);
|
||||
const anchor=allChildren.filter(el=>
|
||||
el.id!=='toolRunningRow' &&
|
||||
el.matches('[data-live-assistant="1"],.tool-call-group,.tool-card-row,.agent-activity-thinking')
|
||||
).pop();
|
||||
const group=ensureActivityGroup(blocks,{live:true,collapsed:true,anchor});
|
||||
const body=group&&group.querySelector('.tool-call-group-body');
|
||||
if(!body) return;
|
||||
let row=body.querySelector('.agent-activity-thinking[data-thinking-active="1"]');
|
||||
if(!row){
|
||||
row=document.createElement('div');
|
||||
row.className='assistant-segment';
|
||||
row.id='thinkingRow';
|
||||
row.className='agent-activity-thinking';
|
||||
row.setAttribute('data-thinking-active','1');
|
||||
// Insert after whichever comes last: a live assistant segment or a tool card.
|
||||
// This mirrors appendLiveToolCard's anchor logic so thinking always appears
|
||||
// in the right position in the interleaved sequence.
|
||||
// Also skip #toolRunningRow (dots) — thinking should go before dots, not after.
|
||||
const allChildren=Array.from(blocks.children);
|
||||
const anchor=allChildren.filter(el=>
|
||||
el.id!=='toolRunningRow' &&
|
||||
el.matches('[data-live-assistant="1"],.tool-card-row')
|
||||
).pop();
|
||||
if(anchor) anchor.insertAdjacentElement('afterend', row);
|
||||
else blocks.appendChild(row);
|
||||
body.insertBefore(row, body.firstChild);
|
||||
}
|
||||
row.className=(text&&String(text).trim())?'assistant-segment thinking-card-row':'assistant-segment';
|
||||
row.innerHTML=_thinkingMarkup(text);
|
||||
_syncToolCallGroupSummary(group);
|
||||
scrollIfPinned();
|
||||
// Auto-scroll the thinking card body to bottom if the user is watching
|
||||
// (scroll pinned). If the user scrolled up to read history, leave it alone.
|
||||
if(_scrollPinned){
|
||||
const body=row&&row.querySelector('.thinking-card-body');
|
||||
if(body) body.scrollTop=body.scrollHeight;
|
||||
const thinkingBody=row&&row.querySelector('.thinking-card-body');
|
||||
if(thinkingBody) thinkingBody.scrollTop=thinkingBody.scrollHeight;
|
||||
}
|
||||
}
|
||||
function updateThinking(text=''){appendThinking(text);}
|
||||
function removeThinking(){
|
||||
const el=$('thinkingRow');
|
||||
if(el) el.remove();
|
||||
if(!isSimplifiedToolCalling()){
|
||||
const el=$('thinkingRow');
|
||||
if(el) el.remove();
|
||||
const turn=$('liveAssistantTurn');
|
||||
const blocks=_assistantTurnBlocks(turn);
|
||||
if(turn&&blocks&&!blocks.children.length) turn.remove();
|
||||
return;
|
||||
}
|
||||
const turn=$('liveAssistantTurn');
|
||||
const blocks=_assistantTurnBlocks(turn);
|
||||
if(blocks) blocks.querySelectorAll('.agent-activity-thinking').forEach(el=>el.remove());
|
||||
if(blocks) blocks.querySelectorAll('.tool-call-group[data-agent-activity-group="1"]').forEach(group=>{
|
||||
_syncToolCallGroupSummary(group);
|
||||
if(!group.querySelector('.tool-card-row,.agent-activity-thinking')) group.remove();
|
||||
});
|
||||
if(turn&&blocks&&!blocks.children.length) turn.remove();
|
||||
}
|
||||
|
||||
@@ -3884,6 +4387,19 @@ function renderTray(){
|
||||
chip.className='attach-chip attach-chip--image';
|
||||
chip.dataset.blobUrl=blobUrl;
|
||||
chip.innerHTML=`<img class="attach-thumb" src="${esc(blobUrl)}" alt="${esc(f.name)}" title="${esc(f.name)}"><button title="${t('remove_title')}">${li('x',12)}</button>`;
|
||||
} else if(_SVG_EXTS.test(f.name)){
|
||||
const blobUrl=URL.createObjectURL(f);
|
||||
chip.className='attach-chip attach-chip--image';
|
||||
chip.dataset.blobUrl=blobUrl;
|
||||
chip.innerHTML=`<img class="attach-thumb attach-thumb--svg" src="${esc(blobUrl)}" alt="${esc(f.name)}" title="${esc(f.name)}"><button title="${t('remove_title')}">${li('x',12)}</button>`;
|
||||
} else if(_AUDIO_EXTS.test(f.name)){
|
||||
const blobUrl=URL.createObjectURL(f);
|
||||
chip.className='attach-chip attach-chip--audio';
|
||||
chip.innerHTML=`<span class="attach-chip-media">🎵 ${esc(f.name)}</span><audio controls preload="metadata" src="${esc(blobUrl)}"></audio><button title="${t('remove_title')}">${li('x',12)}</button>`;
|
||||
} else if(_VIDEO_EXTS.test(f.name)){
|
||||
const blobUrl=URL.createObjectURL(f);
|
||||
chip.className='attach-chip attach-chip--video';
|
||||
chip.innerHTML=`<span class="attach-chip-media">🎬 ${esc(f.name)}</span><video controls preload="metadata" src="${esc(blobUrl)}"></video><button title="${t('remove_title')}">${li('x',12)}</button>`;
|
||||
} else {
|
||||
chip.innerHTML=`${li('paperclip',12)} ${esc(f.name)} <button title="${t('remove_title')}">${li('x',12)}</button>`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Test: CSV table rendering (#485)"""
|
||||
import re
|
||||
|
||||
|
||||
def test_csv_extension_regex():
|
||||
"""Verify _CSV_EXTS regex is defined."""
|
||||
with open('static/ui.js') as f:
|
||||
src = f.read()
|
||||
assert '_CSV_EXTS' in src, "Missing _CSV_EXTS regex"
|
||||
assert '.csv' in src, "CSV regex should match .csv extension"
|
||||
|
||||
|
||||
def test_csv_fence_block_handler():
|
||||
"""Verify fenced ```csv blocks are handled."""
|
||||
with open('static/ui.js') as f:
|
||||
src = f.read()
|
||||
assert "lang==='csv'" in src, "Missing csv language detection in fence handler"
|
||||
assert 'csv-table' in src, "Missing csv-table class for fenced CSV rendering"
|
||||
assert 'csv-table-wrap' in src, "Missing csv-table-wrap class"
|
||||
|
||||
|
||||
def test_csv_fence_renders_table_structure():
|
||||
"""Verify fenced CSV blocks produce proper table HTML."""
|
||||
with open('static/ui.js') as f:
|
||||
src = f.read()
|
||||
# Should have thead, tbody, th, td
|
||||
assert '<thead>' in src, "CSV table should have <thead>"
|
||||
assert '<tbody>' in src, "CSV table should have <tbody>"
|
||||
# In the fence handler section
|
||||
fence_section = src[src.find("lang==='csv'"):src.find("lang==='csv'") + 800]
|
||||
assert '<th>' in fence_section, "CSV headers should use <th>"
|
||||
assert '<td>' in fence_section, "CSV body should use <td>"
|
||||
|
||||
|
||||
def test_csv_fence_fallback_for_insufficient_rows():
|
||||
"""Verify CSV with < 2 rows falls back to code block."""
|
||||
with open('static/ui.js') as f:
|
||||
src = f.read()
|
||||
fence_section = src[src.find("lang==='csv'"):src.find("lang==='csv'") + 800]
|
||||
assert 'rows.length>=2' in fence_section, "Should check for at least 2 rows"
|
||||
assert '<pre><code' in fence_section, "Fallback should render as <pre><code>"
|
||||
|
||||
|
||||
def test_csv_media_file_handler():
|
||||
"""Verify MEDIA: CSV files trigger inline loading."""
|
||||
with open('static/ui.js') as f:
|
||||
src = f.read()
|
||||
assert 'csv-inline-load' in src, "Missing csv-inline-load class for MEDIA: CSV"
|
||||
assert 'csv_loading' in src, "Missing csv_loading i18n key usage"
|
||||
|
||||
|
||||
def test_loadCsvInline_function():
|
||||
"""Verify loadCsvInline lazy-load function exists."""
|
||||
with open('static/ui.js') as f:
|
||||
src = f.read()
|
||||
assert 'function loadCsvInline()' in src, "Missing loadCsvInline function"
|
||||
|
||||
|
||||
def test_csv_inline_max_size():
|
||||
"""Verify CSV inline rendering has a size cap."""
|
||||
with open('static/ui.js') as f:
|
||||
src = f.read()
|
||||
csv_section = src[src.find('function loadCsvInline()'):src.find('function loadCsvInline()') + 2000]
|
||||
assert 'CSV_MAX_SIZE' in csv_section, "Should have CSV_MAX_SIZE constant"
|
||||
assert 'csv_too_large' in csv_section, "Should use csv_too_large i18n for oversized files"
|
||||
|
||||
|
||||
def test_csv_auto_detect_separator():
|
||||
"""Verify CSV handler auto-detects separator."""
|
||||
with open('static/ui.js') as f:
|
||||
src = f.read()
|
||||
csv_section = src[src.find('function loadCsvInline()'):src.find('function loadCsvInline()') + 2000]
|
||||
assert 'separators' in csv_section, "Should have separator detection"
|
||||
assert ';' in csv_section, "Should detect semicolon separator"
|
||||
assert 'tab' in csv_section.lower() or '\\t' in csv_section, "Should detect tab separator"
|
||||
|
||||
|
||||
def test_csv_quote_stripping():
|
||||
"""Verify CSV handler strips surrounding quotes from fields."""
|
||||
with open('static/ui.js') as f:
|
||||
src = f.read()
|
||||
assert "replace(/^[\"']|[\"']$/g,'')" in src, "Should strip quotes from CSV fields"
|
||||
|
||||
|
||||
def test_csv_error_handling():
|
||||
"""Verify CSV error and empty data handling."""
|
||||
with open('static/ui.js') as f:
|
||||
src = f.read()
|
||||
csv_section = src[src.find('function loadCsvInline()'):src.find('function loadCsvInline()') + 2500]
|
||||
assert 'csv_error' in csv_section, "Should use csv_error i18n on fetch failure"
|
||||
assert 'csv_no_data' in csv_section, "Should use csv_no_data i18n for insufficient data"
|
||||
|
||||
|
||||
def test_csv_loadCsvInline_called_after_render():
|
||||
"""Verify loadCsvInline is called in requestAnimationFrame after rendering."""
|
||||
with open('static/ui.js') as f:
|
||||
src = f.read()
|
||||
assert src.count('loadCsvInline()') >= 2, \
|
||||
"loadCsvInline should be called at least twice (initial render + cache restore)"
|
||||
|
||||
|
||||
def test_csv_line_ending_normalization():
|
||||
"""Verify CSV handler normalizes line endings."""
|
||||
with open('static/ui.js') as f:
|
||||
src = f.read()
|
||||
csv_section = src[src.find('function loadCsvInline()'):src.find('function loadCsvInline()') + 2000]
|
||||
assert '\\r\\n' in csv_section, "Should handle \\r\\n line endings"
|
||||
assert '\\r' in csv_section, "Should handle \\r line endings"
|
||||
|
||||
|
||||
def test_csv_i18n_keys():
|
||||
"""Verify CSV i18n keys exist in all 7 locales."""
|
||||
with open('static/i18n.js') as f:
|
||||
src = f.read()
|
||||
required_keys = ['csv_loading', 'csv_too_large', 'csv_no_data', 'csv_error']
|
||||
for key in required_keys:
|
||||
count = src.count(f"{key}:")
|
||||
assert count == 7, f"Key '{key}' found {count} times, expected 7"
|
||||
|
||||
|
||||
def test_csv_css_classes():
|
||||
"""Verify CSV table CSS classes are defined."""
|
||||
with open('static/style.css') as f:
|
||||
src = f.read()
|
||||
required_classes = ['csv-table-wrap', 'csv-table', 'csv-table th', 'csv-table td']
|
||||
for cls in required_classes:
|
||||
assert cls in src, f"Missing CSS: {cls}"
|
||||
# Check for hover effect
|
||||
assert 'csv-table tbody tr:hover' in src, "Missing hover effect for CSV rows"
|
||||
|
||||
|
||||
def test_csv_not_matched_by_image_exts():
|
||||
"""Verify .csv is NOT in _IMAGE_EXTS."""
|
||||
with open('static/ui.js') as f:
|
||||
src = f.read()
|
||||
match = re.search(r"const _IMAGE_EXTS=/([^/]+)/i", src)
|
||||
assert match
|
||||
exts = match.group(1)
|
||||
assert 'csv' not in exts.lower(), ".csv should NOT be in _IMAGE_EXTS"
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Test: Excalidraw inline embed (#479)"""
|
||||
import re
|
||||
|
||||
|
||||
def test_excalidraw_extension_regex():
|
||||
"""Verify _EXCALIDRAW_EXTS regex is defined."""
|
||||
with open('static/ui.js') as f:
|
||||
src = f.read()
|
||||
assert '_EXCALIDRAW_EXTS' in src, "Missing _EXCALIDRAW_EXTS regex"
|
||||
assert '.excalidraw' in src, "Excalidraw regex should match .excalidraw"
|
||||
|
||||
|
||||
def test_excalidraw_media_handler():
|
||||
"""Verify MEDIA: .excalidraw files trigger inline loading."""
|
||||
with open('static/ui.js') as f:
|
||||
src = f.read()
|
||||
assert 'excalidraw-inline-load' in src, "Missing excalidraw-inline-load class"
|
||||
assert 'excalidraw_loading' in src, "Missing excalidraw_loading i18n key usage"
|
||||
|
||||
|
||||
def test_loadExcalidrawInline_function():
|
||||
"""Verify loadExcalidrawInline lazy-load function exists."""
|
||||
with open('static/ui.js') as f:
|
||||
src = f.read()
|
||||
assert 'function loadExcalidrawInline()' in src, "Missing loadExcalidrawInline function"
|
||||
|
||||
|
||||
def test_excalidraw_json_validation():
|
||||
"""Verify Excalidraw handler validates JSON format."""
|
||||
with open('static/ui.js') as f:
|
||||
src = f.read()
|
||||
func = src[src.find('function loadExcalidrawInline()'):src.find('function loadExcalidrawInline()') + 2000]
|
||||
assert 'JSON.parse' in func, "Should parse JSON"
|
||||
assert 'excalidraw_invalid' in func, "Should handle invalid format"
|
||||
assert "data.type!=='excalidraw'" in func, "Should validate type field is 'excalidraw'"
|
||||
|
||||
|
||||
def test_excalidraw_size_cap():
|
||||
"""Verify Excalidraw inline rendering has a size cap."""
|
||||
with open('static/ui.js') as f:
|
||||
src = f.read()
|
||||
func = src[src.find('function loadExcalidrawInline()'):src.find('function loadExcalidrawInline()') + 2000]
|
||||
assert 'EXCALIDRAW_MAX_SIZE' in func, "Should have EXCALIDRAW_MAX_SIZE constant"
|
||||
assert 'excalidraw_too_large' in func, "Should use excalidraw_too_large i18n for oversized files"
|
||||
|
||||
|
||||
def test_excalidraw_error_handling():
|
||||
"""Verify Excalidraw error handling."""
|
||||
with open('static/ui.js') as f:
|
||||
src = f.read()
|
||||
func = src[src.find('function loadExcalidrawInline()'):src.find('function loadExcalidrawInline()') + 3500]
|
||||
assert 'excalidraw_error' in func, "Should use excalidraw_error i18n on fetch failure"
|
||||
|
||||
|
||||
def test_excalidraw_svg_renderer_exists():
|
||||
"""Verify SVG renderer for Excalidraw elements exists."""
|
||||
with open('static/ui.js') as f:
|
||||
src = f.read()
|
||||
assert 'function _renderExcalidrawCanvases()' in src, "Missing _renderExcalidrawCanvases function"
|
||||
start = src.find('function _renderExcalidrawCanvases()')
|
||||
end = src.find('// ── PDF inline preview', start)
|
||||
render = src[start:end if end != -1 else start + 8000]
|
||||
assert '<svg' in render, "Should generate SVG"
|
||||
assert 'excalidraw-svg' in render, "Should use excalidraw-svg CSS class"
|
||||
|
||||
|
||||
def test_excalidraw_renders_element_types():
|
||||
"""Verify SVG renderer handles common Excalidraw element types."""
|
||||
with open('static/ui.js') as f:
|
||||
src = f.read()
|
||||
start = src.find('function _renderExcalidrawCanvases()')
|
||||
end = src.find('// ── PDF inline preview', start)
|
||||
render = src[start:end if end != -1 else start + 8000]
|
||||
element_types = ['rectangle', 'ellipse', 'text', 'line', 'arrow', 'diamond', 'draw']
|
||||
for etype in element_types:
|
||||
assert f"el.type==='{etype}'" in render, f"Should handle element type: {etype}"
|
||||
|
||||
|
||||
def test_excalidraw_arrow_marker():
|
||||
"""Verify SVG renderer includes arrow marker definition."""
|
||||
with open('static/ui.js') as f:
|
||||
src = f.read()
|
||||
start = src.find('function _renderExcalidrawCanvases()')
|
||||
end = src.find('// ── PDF inline preview', start)
|
||||
render = src[start:end if end != -1 else start + 8000]
|
||||
assert 'arrowhead' in render, "Should define arrowhead marker for arrows"
|
||||
assert '<marker' in render, "Should use SVG <marker> element"
|
||||
|
||||
|
||||
def test_excalidraw_bounds_calculation():
|
||||
"""Verify SVG renderer calculates viewBox from element bounds."""
|
||||
with open('static/ui.js') as f:
|
||||
src = f.read()
|
||||
start = src.find('function _renderExcalidrawCanvases()')
|
||||
end = src.find('// ── PDF inline preview', start)
|
||||
render = src[start:end if end != -1 else start + 8000]
|
||||
assert 'viewBox' in render, "Should calculate SVG viewBox"
|
||||
assert 'minX' in render, "Should track minimum X bound"
|
||||
assert 'maxX' in render, "Should track maximum X bound"
|
||||
|
||||
|
||||
def test_excalidraw_empty_elements():
|
||||
"""Verify empty diagrams show a message."""
|
||||
with open('static/ui.js') as f:
|
||||
src = f.read()
|
||||
start = src.find('function _renderExcalidrawCanvases()')
|
||||
end = src.find('// ── PDF inline preview', start)
|
||||
render = src[start:end if end != -1 else start + 8000]
|
||||
assert 'excalidraw_empty' in render, "Should handle empty diagrams"
|
||||
assert 'excalidraw_render_error' in render, "Should handle render errors"
|
||||
|
||||
|
||||
def test_excalidraw_download_link():
|
||||
"""Verify Excalidraw embed includes download link."""
|
||||
with open('static/ui.js') as f:
|
||||
src = f.read()
|
||||
func = src[src.find('function loadExcalidrawInline()'):src.find('function loadExcalidrawInline()') + 2000]
|
||||
assert 'excalidraw-open-link' in func, "Should include open/download link"
|
||||
assert 'excalidraw_download' in func, "Should use excalidraw_download i18n"
|
||||
|
||||
|
||||
def test_excalidraw_called_after_render():
|
||||
"""Verify loadExcalidrawInline is called after message rendering."""
|
||||
with open('static/ui.js') as f:
|
||||
src = f.read()
|
||||
assert src.count('loadExcalidrawInline()') >= 2, \
|
||||
"loadExcalidrawInline should be called at least twice"
|
||||
|
||||
|
||||
def test_excalidraw_embed_wrap_structure():
|
||||
"""Verify Excalidraw embed uses proper container structure."""
|
||||
with open('static/ui.js') as f:
|
||||
src = f.read()
|
||||
assert 'excalidraw-embed-wrap' in src, "Missing excalidraw-embed-wrap container"
|
||||
assert 'excalidraw-canvas' in src, "Missing excalidraw-canvas div"
|
||||
assert 'data-excalidraw' in src, "Missing data-excalidraw attribute"
|
||||
|
||||
|
||||
def test_excalidraw_i18n_keys():
|
||||
"""Verify Excalidraw i18n keys exist in all 7 locales."""
|
||||
with open('static/i18n.js') as f:
|
||||
src = f.read()
|
||||
required_keys = [
|
||||
'excalidraw_loading', 'excalidraw_too_large', 'excalidraw_invalid',
|
||||
'excalidraw_error', 'excalidraw_label', 'excalidraw_download',
|
||||
'excalidraw_empty', 'excalidraw_render_error',
|
||||
]
|
||||
for key in required_keys:
|
||||
count = src.count(f"{key}:")
|
||||
assert count == 7, f"Key '{key}' found {count} times, expected 7"
|
||||
|
||||
|
||||
def test_excalidraw_css_classes():
|
||||
"""Verify Excalidraw CSS classes are defined."""
|
||||
with open('static/style.css') as f:
|
||||
src = f.read()
|
||||
required_classes = [
|
||||
'excalidraw-embed-wrap', 'excalidraw-canvas', 'excalidraw-svg',
|
||||
'excalidraw-empty', 'excalidraw-open-link',
|
||||
]
|
||||
for cls in required_classes:
|
||||
assert cls in src, f"Missing CSS class: .{cls}"
|
||||
|
||||
|
||||
# ── XSS regression: SVG attribute injection from JSON values ────────────────
|
||||
#
|
||||
# The Excalidraw renderer parses JSON from a remote/attacker-controllable file
|
||||
# and interpolates field values (strokeColor, backgroundColor, strokeWidth,
|
||||
# fontSize, points coordinates) into raw SVG attribute templates. The whole
|
||||
# SVG string is then assigned to el.innerHTML — so any value that can
|
||||
# contain `"`, `<`, `>` could break out of the attribute and inject DOM.
|
||||
#
|
||||
# Example attack payload in a malicious .excalidraw file:
|
||||
# {"elements":[{"type":"rectangle","x":0,"y":0,"width":10,"height":10,
|
||||
# "strokeColor":"red\"/></svg><img src=x onerror=alert(1)>"}]}
|
||||
#
|
||||
# Defense: string colors/fonts must flow through an HTML attribute escaper;
|
||||
# numeric fields (strokeWidth, fontSize, x/y/width/height, point coords) must
|
||||
# be coerced via Number()/isFinite gates so they cannot carry strings.
|
||||
|
||||
def _excalidraw_render_block():
|
||||
with open('static/ui.js') as f:
|
||||
src = f.read()
|
||||
start = src.find('function _renderExcalidrawCanvases')
|
||||
assert start != -1, '_renderExcalidrawCanvases not found'
|
||||
# End at next sibling section
|
||||
end = src.find('// ── PDF inline preview', start)
|
||||
assert end != -1, 'end marker not found'
|
||||
return src[start:end]
|
||||
|
||||
|
||||
def test_excalidraw_string_color_fields_are_attribute_escaped():
|
||||
"""strokeColor / backgroundColor flow into stroke="..." / fill="..." in
|
||||
SVG attributes. They must run through an HTML attribute escaper before
|
||||
interpolation, otherwise a value like 'red"/><script>...' breaks out."""
|
||||
block = _excalidraw_render_block()
|
||||
# The escaper helper used in this block (named _sa for SVG-attr escape).
|
||||
# If renamed, update both the helper and this assertion together.
|
||||
assert '_sa(el.strokeColor' in block, (
|
||||
'el.strokeColor must be escaped via _sa() before SVG attribute interpolation'
|
||||
)
|
||||
assert '_sa(el.backgroundColor' in block, (
|
||||
'el.backgroundColor must be escaped via _sa() before SVG attribute interpolation'
|
||||
)
|
||||
# Helper definition must exist and escape the four HTML-significant chars.
|
||||
assert "const _sa=" in block, 'attribute-escape helper _sa must be defined'
|
||||
for ch in ('&', '"', '<', '>'):
|
||||
assert repr(ch) in repr(block) or ch in block.split("const _sa=", 1)[1].split('\n', 1)[0], (
|
||||
f'attribute escaper must replace {ch!r}'
|
||||
)
|
||||
|
||||
|
||||
def test_excalidraw_numeric_fields_are_coerced_via_Number():
|
||||
"""strokeWidth / fontSize / x / y / width / height / point coords must be
|
||||
coerced to finite numbers, so a string like '2"/><script>...' cannot leak
|
||||
into the SVG attribute."""
|
||||
block = _excalidraw_render_block()
|
||||
assert 'const _num=' in block, 'numeric coerce helper _num must be defined'
|
||||
assert '_num(el.strokeWidth' in block, 'strokeWidth must be coerced via _num()'
|
||||
assert '_num(el.fontSize' in block or '_num(el.x' in block, (
|
||||
'numeric el.* fields must flow through _num() for coercion'
|
||||
)
|
||||
# The bare `el.strokeWidth||2` and `el.x||0` pattern is the bug; ensure
|
||||
# neither pattern remains after the fix.
|
||||
assert 'el.strokeWidth||2' not in block, (
|
||||
'strokeWidth must use _num() coerce, not || fallback (string passes through ||)'
|
||||
)
|
||||
@@ -43,7 +43,7 @@ class TestComposerTrayThumbnails:
|
||||
"""Blob URLs must be revoked when a file is removed to prevent memory leaks."""
|
||||
ui = _read_js('ui.js')
|
||||
idx = ui.find('function renderTray()')
|
||||
body = ui[idx:idx + 1200]
|
||||
body = ui[idx:idx + 2500]
|
||||
assert 'URL.revokeObjectURL(' in body, 'renderTray must revoke blob URL when chip is removed'
|
||||
|
||||
def test_rendertray_uses_attach_thumb_class(self):
|
||||
@@ -64,8 +64,9 @@ class TestComposerTrayThumbnails:
|
||||
"""CSS must define .attach-thumb with width/height/object-fit for the thumbnail."""
|
||||
css = _read_css()
|
||||
assert '.attach-thumb' in css, '.attach-thumb CSS class must be defined'
|
||||
# Find the rule
|
||||
idx = css.find('.attach-thumb')
|
||||
# Find the rule — use .attach-thumb{ to avoid matching .attach-thumb--svg variant
|
||||
idx = css.find('.attach-thumb{')
|
||||
assert idx >= 0, '.attach-thumb rule not found'
|
||||
rule = css[idx:idx + 200]
|
||||
assert 'object-fit' in rule, '.attach-thumb must set object-fit to crop image to square'
|
||||
|
||||
@@ -98,7 +99,7 @@ class TestChatHistoryImageRendering:
|
||||
ui = _read_js('ui.js')
|
||||
m = re.search(r'm\.attachments&&m\.attachments\.length', ui)
|
||||
assert m, 'attachments rendering block not found in ui.js'
|
||||
body = ui[m.start():m.start() + 1200]
|
||||
body = ui[m.start():m.start() + 2000]
|
||||
assert 'api/file/raw' in body, (
|
||||
'Image attachments in chat history must use api/file/raw endpoint '
|
||||
'(resolves filename relative to session workspace). '
|
||||
@@ -113,7 +114,7 @@ class TestChatHistoryImageRendering:
|
||||
"""api/file/raw URL must include session_id parameter for workspace resolution."""
|
||||
ui = _read_js('ui.js')
|
||||
m = re.search(r'm\.attachments&&m\.attachments\.length', ui)
|
||||
body = ui[m.start():m.start() + 1200]
|
||||
body = ui[m.start():m.start() + 2000]
|
||||
assert 'session_id' in body, (
|
||||
'api/file/raw URL in attachment rendering must include session_id '
|
||||
'so the server can resolve the filename against the correct workspace.'
|
||||
@@ -123,7 +124,7 @@ class TestChatHistoryImageRendering:
|
||||
"""Image attachments must still render with msg-media-img class for consistent styling."""
|
||||
ui = _read_js('ui.js')
|
||||
m = re.search(r'm\.attachments&&m\.attachments\.length', ui)
|
||||
body = ui[m.start():m.start() + 1200]
|
||||
body = ui[m.start():m.start() + 2000]
|
||||
assert 'msg-media-img' in body, 'Image attachment <img> must use msg-media-img class'
|
||||
|
||||
def test_attachment_render_click_to_fullscreen(self):
|
||||
@@ -132,7 +133,7 @@ class TestChatHistoryImageRendering:
|
||||
assert "document.addEventListener('click'" in ui
|
||||
assert "closest('.msg-media-img')" in ui
|
||||
m = re.search(r'm\.attachments&&m\.attachments\.length', ui)
|
||||
body = ui[m.start():m.start() + 1200]
|
||||
body = ui[m.start():m.start() + 2000]
|
||||
img_line = next(line for line in body.splitlines() if 'msg-media-img' in line)
|
||||
assert 'onclick' not in img_line, 'Chat history image HTML must not embed inline JS handlers'
|
||||
|
||||
@@ -140,12 +141,12 @@ class TestChatHistoryImageRendering:
|
||||
"""Non-image attachments in chat history must still show paperclip badge."""
|
||||
ui = _read_js('ui.js')
|
||||
m = re.search(r'm\.attachments&&m\.attachments\.length', ui)
|
||||
body = ui[m.start():m.start() + 1200]
|
||||
body = ui[m.start():m.start() + 2000]
|
||||
assert 'msg-file-badge' in body, 'Non-image attachments must still use msg-file-badge in chat history'
|
||||
|
||||
def test_attachment_render_extracts_filename(self):
|
||||
"""Filename extraction (.split('/').pop()) must still be present for display."""
|
||||
ui = _read_js('ui.js')
|
||||
m = re.search(r'm\.attachments&&m\.attachments\.length', ui)
|
||||
body = ui[m.start():m.start() + 1200]
|
||||
body = ui[m.start():m.start() + 2000]
|
||||
assert ".split('/').pop()" in body, 'Must extract filename from path for display'
|
||||
|
||||
@@ -49,7 +49,18 @@ def test_opencode_go_in_provider_models():
|
||||
ids = [m["id"] for m in config._PROVIDER_MODELS["opencode-go"]]
|
||||
assert "glm-5.1" in ids
|
||||
assert "glm-5" in ids
|
||||
assert "kimi-k2.5" in ids
|
||||
assert "kimi-k2.6" in ids
|
||||
assert "deepseek-v4-pro" in ids
|
||||
assert "deepseek-v4-flash" in ids
|
||||
assert "mimo-v2-pro" in ids
|
||||
assert "mimo-v2-omni" in ids
|
||||
assert "mimo-v2.5-pro" in ids
|
||||
assert "mimo-v2.5" in ids
|
||||
assert "minimax-m2.7" in ids
|
||||
assert "minimax-m2.5" in ids
|
||||
assert "qwen3.6-plus" in ids
|
||||
assert "qwen3.5-plus" in ids
|
||||
|
||||
|
||||
# ── Env-var fallback detection ────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
"""Tests for #480 (PDF first-page preview) and #482 (HTML iframe sandbox).
|
||||
|
||||
Validates that the MEDIA: restore block in ui.js produces the correct
|
||||
placeholder HTML for .pdf and .html files, that lazy-load functions exist,
|
||||
and that CSS classes are defined.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import pytest
|
||||
|
||||
|
||||
def _read_js(name):
|
||||
with open(os.path.join('static', name)) as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def _read_css():
|
||||
with open(os.path.join('static', 'style.css')) as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
# ── Extension regexes ──────────────────────────────────────────────────────
|
||||
|
||||
class TestExtensionRegexes:
|
||||
"""PDF and HTML extension regexes must be defined at module scope."""
|
||||
|
||||
def test_pdf_exts_regex_exists(self):
|
||||
ui = _read_js('ui.js')
|
||||
assert '_PDF_EXTS' in ui, '_PDF_EXTS regex must be defined'
|
||||
idx = ui.find('_PDF_EXTS')
|
||||
assert '.pdf' in ui[idx:idx+100], '_PDF_EXTS must match .pdf extension'
|
||||
|
||||
def test_html_exts_regex_exists(self):
|
||||
ui = _read_js('ui.js')
|
||||
assert '_HTML_EXTS' in ui, '_HTML_EXTS regex must be defined'
|
||||
idx = ui.find('_HTML_EXTS')
|
||||
assert 'html' in ui[idx:idx+100], '_HTML_EXTS must match .html extension'
|
||||
|
||||
def test_pdf_not_matched_by_image_exts(self):
|
||||
"""PDF files must not be caught by _IMAGE_EXTS."""
|
||||
ui = _read_js('ui.js')
|
||||
m = re.search(r'const _IMAGE_EXTS=/(.+?)/[a-z]*;', ui)
|
||||
assert m
|
||||
pattern = m.group(1)
|
||||
assert 'pdf' not in pattern, 'PDF must not be in _IMAGE_EXTS (would render as broken <img>)'
|
||||
|
||||
def test_html_not_matched_by_image_exts(self):
|
||||
"""HTML files must not be caught by _IMAGE_EXTS."""
|
||||
ui = _read_js('ui.js')
|
||||
m = re.search(r'const _IMAGE_EXTS=/(.+?)/[a-z]*;', ui)
|
||||
assert m
|
||||
pattern = m.group(1)
|
||||
assert 'html' not in pattern, 'HTML must not be in _IMAGE_EXTS'
|
||||
|
||||
|
||||
# ── MEDIA: placeholder HTML ────────────────────────────────────────────────
|
||||
|
||||
class TestPdfMediaPlaceholder:
|
||||
"""PDF files in MEDIA: tokens must produce a lazy-load placeholder div."""
|
||||
|
||||
def test_pdf_media_produces_placeholder_div(self):
|
||||
ui = _read_js('ui.js')
|
||||
m = re.search(r'_PDF_EXTS\.test\(ref\)', ui)
|
||||
assert m, 'MEDIA restore must check _PDF_EXTS for PDF files'
|
||||
body = ui[m.start():m.start() + 300]
|
||||
assert 'pdf-preview-load' in body, 'PDF MEDIA must produce .pdf-preview-load placeholder'
|
||||
assert 'data-path' in body, 'PDF placeholder must include data-path attribute'
|
||||
|
||||
def test_pdf_media_uses_i18n_loading_key(self):
|
||||
ui = _read_js('ui.js')
|
||||
m = re.search(r'_PDF_EXTS\.test\(ref\)', ui)
|
||||
body = ui[m.start():m.start() + 300]
|
||||
assert 'pdf_loading' in body, 'PDF placeholder must use pdf_loading i18n key'
|
||||
|
||||
|
||||
class TestHtmlMediaPlaceholder:
|
||||
"""HTML files in MEDIA: tokens must produce a lazy-load placeholder div."""
|
||||
|
||||
def test_html_media_produces_placeholder_div(self):
|
||||
ui = _read_js('ui.js')
|
||||
m = re.search(r'_HTML_EXTS\.test\(ref\)', ui)
|
||||
assert m, 'MEDIA restore must check _HTML_EXTS for HTML files'
|
||||
body = ui[m.start():m.start() + 300]
|
||||
assert 'html-preview-load' in body, 'HTML MEDIA must produce .html-preview-load placeholder'
|
||||
assert 'data-path' in body, 'HTML placeholder must include data-path attribute'
|
||||
|
||||
def test_html_media_uses_i18n_loading_key(self):
|
||||
ui = _read_js('ui.js')
|
||||
m = re.search(r'_HTML_EXTS\.test\(ref\)', ui)
|
||||
body = ui[m.start():m.start() + 300]
|
||||
assert 'html_loading' in body, 'HTML placeholder must use html_loading i18n key'
|
||||
|
||||
def test_html_iframe_has_sandbox_attribute(self):
|
||||
"""HTML preview iframe must use sandbox attribute for security."""
|
||||
ui = _read_js('ui.js')
|
||||
assert 'sandbox=' in ui, 'loadHtmlInline must set sandbox attribute on iframe'
|
||||
assert 'allow-scripts' in ui, 'sandbox must include allow-scripts for interactive content'
|
||||
|
||||
|
||||
# ── Lazy-load functions ────────────────────────────────────────────────────
|
||||
|
||||
class TestLoadPdfInlineFunction:
|
||||
"""loadPdfInline() must exist and follow the same pattern as loadDiffInline()."""
|
||||
|
||||
def test_function_exists(self):
|
||||
ui = _read_js('ui.js')
|
||||
assert 'function loadPdfInline()' in ui, 'loadPdfInline() function must exist'
|
||||
|
||||
def test_selects_pdf_preview_load_elements(self):
|
||||
ui = _read_js('ui.js')
|
||||
idx = ui.find('function loadPdfInline()')
|
||||
body = ui[idx:idx + 500]
|
||||
assert 'pdf-preview-load' in body, 'Must query .pdf-preview-load elements'
|
||||
assert 'data-loaded' in body, 'Must use data-loaded attribute to prevent double-processing'
|
||||
|
||||
def test_fetches_via_api_media(self):
|
||||
ui = _read_js('ui.js')
|
||||
idx = ui.find('function loadPdfInline()')
|
||||
body = ui[idx:idx + 1500]
|
||||
assert 'api/media?path=' in body, 'Must fetch PDF via api/media endpoint'
|
||||
|
||||
def test_has_size_cap(self):
|
||||
ui = _read_js('ui.js')
|
||||
idx = ui.find('function loadPdfInline()')
|
||||
body = ui[idx:idx + 1500]
|
||||
assert 'MAX_SIZE' in body or 'byteLength' in body, 'Must enforce a size cap on PDF files'
|
||||
|
||||
def test_fallback_on_error(self):
|
||||
ui = _read_js('ui.js')
|
||||
idx = ui.find('function loadPdfInline()')
|
||||
body = ui[idx:idx + 3000]
|
||||
assert 'pdf_error' in body, 'Must show error fallback on failure'
|
||||
assert 'pdf_download' in body or 'download=' in body, 'Error fallback must include download link'
|
||||
|
||||
def test_lazy_loads_pdfjs_from_cdn(self):
|
||||
ui = _read_js('ui.js')
|
||||
idx = ui.find('function loadPdfInline()')
|
||||
body = ui[idx:idx + 3000]
|
||||
assert 'pdfjs' in body, 'Must lazy-load PDF.js from CDN'
|
||||
|
||||
def test_pdfjs_state_variables(self):
|
||||
ui = _read_js('ui.js')
|
||||
assert '_pdfjsReady' in ui, '_pdfjsReady state variable must exist'
|
||||
assert '_pdfjsLoading' in ui, '_pdfjsLoading state variable must exist'
|
||||
|
||||
|
||||
class TestLoadHtmlInlineFunction:
|
||||
"""loadHtmlInline() must exist and render HTML in a sandboxed iframe."""
|
||||
|
||||
def test_function_exists(self):
|
||||
ui = _read_js('ui.js')
|
||||
assert 'function loadHtmlInline()' in ui, 'loadHtmlInline() function must exist'
|
||||
|
||||
def test_selects_html_preview_load_elements(self):
|
||||
ui = _read_js('ui.js')
|
||||
idx = ui.find('function loadHtmlInline()')
|
||||
body = ui[idx:idx + 500]
|
||||
assert 'html-preview-load' in body, 'Must query .html-preview-load elements'
|
||||
assert 'data-loaded' in body, 'Must use data-loaded attribute'
|
||||
|
||||
def test_fetches_via_api_media(self):
|
||||
ui = _read_js('ui.js')
|
||||
idx = ui.find('function loadHtmlInline()')
|
||||
body = ui[idx:idx + 1000]
|
||||
assert 'api/media?path=' in body, 'Must fetch HTML via api/media endpoint'
|
||||
|
||||
def test_has_size_cap(self):
|
||||
ui = _read_js('ui.js')
|
||||
idx = ui.find('function loadHtmlInline()')
|
||||
body = ui[idx:idx + 1000]
|
||||
assert 'MAX_SIZE' in body or 'html.length' in body, 'Must enforce a size cap on HTML files'
|
||||
|
||||
def test_fallback_on_error(self):
|
||||
ui = _read_js('ui.js')
|
||||
idx = ui.find('function loadHtmlInline()')
|
||||
body = ui[idx:idx + 2000]
|
||||
assert 'html_error' in body, 'Must show error fallback on failure'
|
||||
|
||||
def test_uses_srcdoc_attribute(self):
|
||||
"""Must use srcdoc (not src) for HTML content to keep it same-origin sandboxed."""
|
||||
ui = _read_js('ui.js')
|
||||
idx = ui.find('function loadHtmlInline()')
|
||||
body = ui[idx:idx + 1500]
|
||||
assert 'srcdoc=' in body, 'Must use srcdoc attribute for inline HTML rendering'
|
||||
|
||||
def test_escapes_html_for_srcdoc(self):
|
||||
"""HTML content must be escaped before embedding in srcdoc to prevent attribute injection."""
|
||||
ui = _read_js('ui.js')
|
||||
idx = ui.find('function loadHtmlInline()')
|
||||
body = ui[idx:idx + 1500]
|
||||
# Must escape &, <, >, " to prevent breaking out of srcdoc attribute
|
||||
assert '&' in body or 'replace' in body, 'Must escape HTML entities for srcdoc'
|
||||
|
||||
|
||||
# ── requestAnimationFrame integration ──────────────────────────────────────
|
||||
|
||||
class TestRAFIntegration:
|
||||
"""Both lazy-load functions must be called in the requestAnimationFrame blocks."""
|
||||
|
||||
def test_loadPdfInline_called_after_render(self):
|
||||
ui = _read_js('ui.js')
|
||||
raf_blocks = re.findall(r'requestAnimationFrame\(\(\)=>\{[^}]+\}\)', ui)
|
||||
load_blocks = [b for b in raf_blocks if 'loadDiffInline' in b]
|
||||
assert len(load_blocks) >= 2, 'Expected at least 2 rAF blocks with loadDiffInline'
|
||||
for block in load_blocks:
|
||||
assert 'loadPdfInline()' in block, 'loadPdfInline() must be called alongside loadDiffInline'
|
||||
|
||||
def test_loadHtmlInline_called_after_render(self):
|
||||
ui = _read_js('ui.js')
|
||||
raf_blocks = re.findall(r'requestAnimationFrame\(\(\)=>\{[^}]+\}\)', ui)
|
||||
load_blocks = [b for b in raf_blocks if 'loadDiffInline' in b]
|
||||
for block in load_blocks:
|
||||
assert 'loadHtmlInline()' in block, 'loadHtmlInline() must be called alongside loadDiffInline'
|
||||
|
||||
def test_initTreeViews_blocks_also_call_loaders(self):
|
||||
"""rAF blocks with initTreeViews (not loadDiffInline) must also call PDF/HTML loaders."""
|
||||
ui = _read_js('ui.js')
|
||||
raf_blocks = re.findall(r'requestAnimationFrame\(\(\)=>\{[^}]+\}\)', ui)
|
||||
tree_blocks = [b for b in raf_blocks if 'initTreeViews' in b and 'loadDiffInline' not in b]
|
||||
for block in tree_blocks:
|
||||
assert 'loadPdfInline()' in block, 'initTreeViews rAF block must also call loadPdfInline'
|
||||
assert 'loadHtmlInline()' in block, 'initTreeViews rAF block must also call loadHtmlInline'
|
||||
|
||||
|
||||
# ── CSS classes ────────────────────────────────────────────────────────────
|
||||
|
||||
class TestCSSClasses:
|
||||
"""CSS must define styles for PDF and HTML preview components."""
|
||||
|
||||
def test_pdf_preview_wrap(self):
|
||||
css = _read_css()
|
||||
assert '.pdf-preview-wrap' in css
|
||||
|
||||
def test_pdf_preview_header(self):
|
||||
css = _read_css()
|
||||
assert '.pdf-preview-header' in css
|
||||
|
||||
def test_pdf_preview_body(self):
|
||||
css = _read_css()
|
||||
assert '.pdf-preview-body' in css
|
||||
|
||||
def test_pdf_preview_canvas(self):
|
||||
css = _read_css()
|
||||
assert '.pdf-preview-canvas' in css
|
||||
|
||||
def test_pdf_preview_fallback(self):
|
||||
css = _read_css()
|
||||
assert '.pdf-preview-fallback' in css
|
||||
|
||||
def test_pdf_download_link(self):
|
||||
css = _read_css()
|
||||
# pdf-download-link class used in JS; styled via header a selector
|
||||
assert '.pdf-download-link' in css or '.pdf-preview-header a' in css
|
||||
|
||||
def test_html_preview_wrap(self):
|
||||
css = _read_css()
|
||||
assert '.html-preview-wrap' in css
|
||||
|
||||
def test_html_preview_header(self):
|
||||
css = _read_css()
|
||||
assert '.html-preview-header' in css
|
||||
|
||||
def test_html_preview_iframe(self):
|
||||
css = _read_css()
|
||||
assert '.html-preview-iframe' in css
|
||||
|
||||
def test_html_preview_fallback(self):
|
||||
css = _read_css()
|
||||
assert '.html-preview-fallback' in css
|
||||
|
||||
def test_html_iframe_has_fixed_height(self):
|
||||
"""HTML iframe must have a fixed height to prevent overflow."""
|
||||
css = _read_css()
|
||||
m = re.search(r'\.html-preview-iframe\{[^}]+\}', css)
|
||||
assert m, '.html-preview-iframe rule must exist'
|
||||
assert 'height' in m.group(), 'HTML iframe must have a height constraint'
|
||||
|
||||
|
||||
# ── i18n keys ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestI18nKeys:
|
||||
"""All required i18n keys must exist in the en locale."""
|
||||
|
||||
PDF_KEYS = ['pdf_loading', 'pdf_too_large', 'pdf_no_pages', 'pdf_error', 'pdf_download']
|
||||
HTML_KEYS = ['html_loading', 'html_too_large', 'html_error', 'html_open_full', 'html_sandbox_label']
|
||||
|
||||
def _find_locale_block(self, locale):
|
||||
with open('static/i18n.js') as f:
|
||||
content = f.read()
|
||||
start = content.find(f"'{locale}':")
|
||||
if start < 0:
|
||||
start = content.find(f'{locale}:')
|
||||
if start < 0:
|
||||
return ''
|
||||
# Find end by scanning for next top-level locale
|
||||
locales = ['en', 'ru', 'es', 'de', 'zh', 'zh-Hant', 'ko']
|
||||
end = len(content)
|
||||
for loc in locales:
|
||||
if loc == locale:
|
||||
continue
|
||||
pos = content.find(f"'{loc}':", start + 5)
|
||||
if pos > start and pos < end:
|
||||
end = pos
|
||||
return content[start:end]
|
||||
|
||||
def test_pdf_keys_in_en(self):
|
||||
block = self._find_locale_block('en')
|
||||
for key in self.PDF_KEYS:
|
||||
assert f'{key}:' in block, f'en locale must have key {key}'
|
||||
|
||||
def test_html_keys_in_en(self):
|
||||
block = self._find_locale_block('en')
|
||||
for key in self.HTML_KEYS:
|
||||
assert f'{key}:' in block, f'en locale must have key {key}'
|
||||
|
||||
def test_pdf_keys_in_all_locales(self):
|
||||
for loc in ['ru', 'es', 'de', 'zh', 'zh-Hant', 'ko']:
|
||||
block = self._find_locale_block(loc)
|
||||
missing = [k for k in self.PDF_KEYS if f'{k}:' not in block]
|
||||
assert not missing, f'{loc} locale missing PDF keys: {missing}'
|
||||
|
||||
def test_html_keys_in_all_locales(self):
|
||||
for loc in ['ru', 'es', 'de', 'zh', 'zh-Hant', 'ko']:
|
||||
block = self._find_locale_block(loc)
|
||||
missing = [k for k in self.HTML_KEYS if f'{k}:' not in block]
|
||||
assert not missing, f'{loc} locale missing HTML keys: {missing}'
|
||||
|
||||
|
||||
class TestPdfCanvasAttachmentNotSerialized:
|
||||
"""Regression: canvas.outerHTML serializes only the <canvas> element wrapper,
|
||||
NOT the rendered bitmap. Interpolating ${canvas.outerHTML} into a template
|
||||
string produces a fresh empty <canvas> when parsed back into the DOM, so the
|
||||
PDF preview renders as a blank rectangle.
|
||||
|
||||
The PDF preview must attach the canvas via appendChild / replaceWith so the
|
||||
rendered DOM node carries its bitmap state across the swap.
|
||||
"""
|
||||
|
||||
def _pdf_block(self):
|
||||
ui = _read_js('ui.js')
|
||||
start = ui.find('// ── PDF inline preview')
|
||||
end = ui.find('// ── HTML inline preview', start)
|
||||
assert start != -1 and end != -1, 'PDF preview block not found in ui.js'
|
||||
return ui[start:end]
|
||||
|
||||
def test_pdf_does_not_serialize_canvas_via_outerhtml(self):
|
||||
block = self._pdf_block()
|
||||
assert '${canvas.outerHTML}' not in block, (
|
||||
'canvas.outerHTML loses the rendered bitmap when interpolated; '
|
||||
'attach the canvas via appendChild or replaceWith instead'
|
||||
)
|
||||
|
||||
def test_pdf_attaches_canvas_as_dom_node(self):
|
||||
block = self._pdf_block()
|
||||
attaches_dom = 'appendChild(canvas)' in block or '.replaceWith(' in block
|
||||
assert attaches_dom, (
|
||||
'PDF preview must attach the rendered canvas as a DOM node '
|
||||
'(appendChild / replaceWith), not interpolate it as a string'
|
||||
)
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
@@ -53,3 +54,40 @@ def test_streaming_applies_profile_runtime_env_to_agent_run():
|
||||
assert "_profile_runtime_env" in src
|
||||
assert "old_profile_env" in src
|
||||
assert "os.environ.update(_profile_runtime_env)" in src
|
||||
|
||||
|
||||
def test_streaming_thread_env_allows_profile_terminal_cwd_override():
|
||||
src = Path("api/streaming.py").read_text(encoding="utf-8")
|
||||
|
||||
assert "def _build_agent_thread_env" in src
|
||||
assert "_thread_env = _build_agent_thread_env(" in src
|
||||
assert "_set_thread_env(**_thread_env)" in src
|
||||
assert "_set_thread_env(\n **_profile_runtime_env,\n TERMINAL_CWD" not in src
|
||||
|
||||
match = re.search(
|
||||
r"(def _build_agent_thread_env\(.*?\n)(?=\ndef |\nclass )",
|
||||
src,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert match, "_build_agent_thread_env not found in api/streaming.py"
|
||||
ns: dict = {}
|
||||
exec(compile(match.group(1), "<streaming_extract>", "exec"), ns)
|
||||
|
||||
env = ns["_build_agent_thread_env"](
|
||||
{
|
||||
"TERMINAL_CWD": "/profile/config/cwd",
|
||||
"HERMES_EXEC_ASK": "0",
|
||||
"HERMES_SESSION_KEY": "old-session",
|
||||
"HERMES_HOME": "/old/profile/home",
|
||||
"TERMINAL_ENV": "ssh",
|
||||
},
|
||||
"/active/workspace",
|
||||
"active-session",
|
||||
"/active/profile/home",
|
||||
)
|
||||
|
||||
assert env["TERMINAL_CWD"] == "/active/workspace"
|
||||
assert env["HERMES_EXEC_ASK"] == "1"
|
||||
assert env["HERMES_SESSION_KEY"] == "active-session"
|
||||
assert env["HERMES_HOME"] == "/active/profile/home"
|
||||
assert env["TERMINAL_ENV"] == "ssh"
|
||||
|
||||
@@ -665,13 +665,15 @@ def test_ui_js_keeps_reasoning_only_assistant_messages_visible(cleanup_test_sess
|
||||
|
||||
|
||||
def test_ui_js_does_not_hide_anchor_segments_that_contain_thinking(cleanup_test_sessions):
|
||||
"""R19c2: assistant anchor segments that contain a thinking card must remain
|
||||
visible; only truly empty tool-call anchor segments should be hidden.
|
||||
"""R19c2/R19c3: reasoning-only messages must remain visible through the
|
||||
shared collapsed activity dropdown, even when the anchor segment has no prose.
|
||||
"""
|
||||
src = (REPO_ROOT / "static" / "ui.js").read_text()
|
||||
compact = src.replace(' ', '').replace('\n', '')
|
||||
assert "}elseif(!thinkingText){" in compact, \
|
||||
"renderMessages must only hide assistant anchor segments when they have no thinking content"
|
||||
assert "assistantThinking.set(rawIdx,thinkingText)" in compact, \
|
||||
"renderMessages must preserve reasoning text before hiding empty anchor segments"
|
||||
assert "_thinkingActivityNode(thinkingText)" in src, \
|
||||
"thinking-only assistant content should render inside the shared activity dropdown"
|
||||
|
||||
|
||||
def test_messages_js_live_assistant_segment_reuses_live_turn_wrapper(cleanup_test_sessions):
|
||||
|
||||
@@ -37,6 +37,9 @@ global.document = { createElement: () => ({ innerHTML: '', textContent: '' }) };
|
||||
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => (
|
||||
{'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
const _IMAGE_EXTS=/\.(png|jpg|jpeg|gif|webp|bmp|ico|avif)$/i;
|
||||
const _SVG_EXTS=/\.svg$/i;
|
||||
const _AUDIO_EXTS=/\.(mp3|ogg|wav|m4a|aac|flac|wma|opus|webm)$/i;
|
||||
const _VIDEO_EXTS=/\.(mp4|webm|mkv|mov|avi|ogv|m4v)$/i;
|
||||
|
||||
function extractFunc(name) {
|
||||
const re = new RegExp('function\\s+' + name + '\\s*\\(');
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Regression tests for service worker API cache exclusion under subpath mounts.
|
||||
|
||||
The WebUI can be served at /hermes/. In that deployment API requests look like
|
||||
/hermes/api/sessions, not /api/sessions. The service worker must treat those as
|
||||
network-only; otherwise cache-first handling can serve a stale sidebar session
|
||||
list until the browser cache/service-worker cache is cleared.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SW_SRC = (ROOT / "static" / "sw.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_service_worker_excludes_subpath_mounted_api_routes_from_cache():
|
||||
assert "url.pathname.includes('/api/')" in SW_SRC, (
|
||||
"service worker must bypass cache for subpath-mounted API routes like "
|
||||
"/hermes/api/sessions, not only root-mounted /api/*"
|
||||
)
|
||||
|
||||
|
||||
def test_service_worker_excludes_subpath_mounted_health_routes_from_cache():
|
||||
assert "url.pathname.includes('/health')" in SW_SRC, (
|
||||
"service worker must bypass cache for subpath-mounted health routes like "
|
||||
"/hermes/health, not only root-mounted /health"
|
||||
)
|
||||
|
||||
|
||||
def test_service_worker_documents_api_routes_are_never_cached():
|
||||
assert "API and streaming endpoints" in SW_SRC
|
||||
assert "always go to network" in SW_SRC
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Test: session batch select mode functions exist in sessions.js (#568)"""
|
||||
import re
|
||||
|
||||
|
||||
def test_batch_select_state_variables():
|
||||
"""Verify batch select state variables are declared."""
|
||||
with open('static/sessions.js') as f:
|
||||
src = f.read()
|
||||
assert '_sessionSelectMode' in src, "Missing _sessionSelectMode variable"
|
||||
assert '_selectedSessions' in src, "Missing _selectedSessions variable"
|
||||
assert 'new Set()' in src, "Selected sessions should use Set"
|
||||
|
||||
|
||||
def test_batch_select_functions_exist():
|
||||
"""Verify all batch select functions are defined."""
|
||||
with open('static/sessions.js') as f:
|
||||
src = f.read()
|
||||
required_funcs = [
|
||||
'toggleSessionSelectMode',
|
||||
'exitSessionSelectMode',
|
||||
'toggleSessionSelect',
|
||||
'selectAllSessions',
|
||||
'deselectAllSessions',
|
||||
'_updateBatchActionBar',
|
||||
'_renderBatchActionBar',
|
||||
'_showBatchProjectPicker',
|
||||
]
|
||||
for fn in required_funcs:
|
||||
assert f'function {fn}(' in src, f"Missing function: {fn}"
|
||||
|
||||
|
||||
def test_batch_select_checkbox_rendering():
|
||||
"""Verify checkbox is rendered when in select mode."""
|
||||
with open('static/sessions.js') as f:
|
||||
src = f.read()
|
||||
assert 'session-select-cb' in src, "Missing session-select-cb class"
|
||||
assert 'session-select-cb-wrapper' in src, "Missing session-select-cb-wrapper class"
|
||||
assert "cb.type='checkbox'" in src, "Checkbox should be type checkbox"
|
||||
|
||||
|
||||
def test_batch_select_intercepts_navigation():
|
||||
"""Verify select mode intercepts session navigation."""
|
||||
with open('static/sessions.js') as f:
|
||||
src = f.read()
|
||||
assert "_sessionSelectMode" in src
|
||||
# Should have early return when in select mode
|
||||
assert 'toggleSessionSelect(s.session_id)' in src, \
|
||||
"Pointerup handler should call toggleSessionSelect in select mode"
|
||||
|
||||
|
||||
def test_batch_select_escape_handler():
|
||||
"""Verify Escape key exits select mode."""
|
||||
with open('static/sessions.js') as f:
|
||||
src = f.read()
|
||||
assert "e.key==='Escape'&&_sessionSelectMode" in src, \
|
||||
"Should have Escape key handler for select mode"
|
||||
|
||||
|
||||
def test_batch_select_toggle_button():
|
||||
"""Verify select mode toggle button is rendered."""
|
||||
with open('static/sessions.js') as f:
|
||||
src = f.read()
|
||||
assert 'session-select-toggle' in src, "Missing session-select-toggle class"
|
||||
assert 'toggleSessionSelectMode' in src, "Missing toggleSessionSelectMode call"
|
||||
|
||||
|
||||
def test_batch_select_bar_element():
|
||||
"""Verify batch action bar DOM element is created."""
|
||||
with open('static/sessions.js') as f:
|
||||
src = f.read()
|
||||
assert 'batchActionBar' in src, "Missing batchActionBar element"
|
||||
assert 'batch-action-bar' in src, "Missing batch-action-bar CSS class"
|
||||
assert 'batch-action-btn' in src, "Missing batch-action-btn class"
|
||||
|
||||
|
||||
def test_batch_select_i18n_keys():
|
||||
"""Verify all batch select i18n keys exist in all locales."""
|
||||
with open('static/i18n.js') as f:
|
||||
src = f.read()
|
||||
required_keys = [
|
||||
'session_select_mode',
|
||||
'session_select_mode_desc',
|
||||
'session_select_all',
|
||||
'session_deselect_all',
|
||||
'session_selected_count',
|
||||
'session_batch_archive',
|
||||
'session_batch_delete',
|
||||
'session_batch_move',
|
||||
'session_batch_delete_confirm',
|
||||
'session_batch_archive_confirm',
|
||||
'session_no_selection',
|
||||
]
|
||||
locales = ['en', 'ru', 'es', 'de', 'zh', 'zh-Hant', 'ko']
|
||||
for key in required_keys:
|
||||
for locale in locales:
|
||||
# Check if the key exists in the locale block
|
||||
if locale == 'zh-Hant':
|
||||
pattern = rf"'{locale}'\s*:.*?{key}"
|
||||
else:
|
||||
pattern = rf"{locale}\s*:.*?{key}"
|
||||
# Simpler check: just verify the key string with colon exists
|
||||
assert f"{key}:" in src, f"Missing i18n key '{key}' in i18n.js"
|
||||
# Count occurrences - each key should appear in all 7 locales
|
||||
for key in required_keys:
|
||||
count = src.count(f"{key}:")
|
||||
assert count == 7, f"Key '{key}' found {count} times, expected 7 (one per locale)"
|
||||
|
||||
|
||||
def test_batch_select_css_exists():
|
||||
"""Verify batch select CSS classes are defined."""
|
||||
with open('static/style.css') as f:
|
||||
src = f.read()
|
||||
required_classes = [
|
||||
'session-select-toggle',
|
||||
'session-select-bar',
|
||||
'batch-exit-btn',
|
||||
'batch-select-all-btn',
|
||||
'session-select-cb-wrapper',
|
||||
'session-select-cb',
|
||||
'session-item.selected',
|
||||
'batch-action-bar',
|
||||
'batch-count',
|
||||
'batch-action-btn',
|
||||
'batch-action-btn-danger',
|
||||
]
|
||||
for cls in required_classes:
|
||||
assert cls in src, f"Missing CSS class: .{cls}"
|
||||
|
||||
|
||||
def test_batch_select_mode_flags():
|
||||
"""Verify select mode properly toggles state."""
|
||||
with open('static/sessions.js') as f:
|
||||
src = f.read()
|
||||
# toggleSessionSelectMode should flip the flag
|
||||
assert '_sessionSelectMode=!_sessionSelectMode' in src, \
|
||||
"toggleSessionSelectMode should flip _sessionSelectMode"
|
||||
# exitSessionSelectMode should clear state
|
||||
assert '_sessionSelectMode=false' in src, \
|
||||
"exitSessionSelectMode should set _sessionSelectMode=false"
|
||||
assert '_selectedSessions.clear()' in src, \
|
||||
"Exit should clear selected sessions"
|
||||
|
||||
|
||||
def test_batch_delete_uses_confirm_dialog():
|
||||
"""Verify batch delete shows confirmation dialog."""
|
||||
with open('static/sessions.js') as f:
|
||||
src = f.read()
|
||||
# The delete handler should call showConfirmDialog with batch message
|
||||
assert "session_batch_delete_confirm" in src, \
|
||||
"Batch delete should use session_batch_delete_confirm i18n key"
|
||||
assert "showConfirmDialog" in src, \
|
||||
"Should use showConfirmDialog for batch operations"
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Regression tests for the Simplified tool calling setting."""
|
||||
|
||||
import importlib
|
||||
import json
|
||||
|
||||
|
||||
def test_simplified_tool_calling_defaults_enabled_and_round_trips(monkeypatch, tmp_path):
|
||||
import api.config as config
|
||||
|
||||
settings_path = tmp_path / "settings.json"
|
||||
monkeypatch.setattr(config, "SETTINGS_FILE", settings_path)
|
||||
|
||||
loaded = config.load_settings()
|
||||
assert loaded["simplified_tool_calling"] is True
|
||||
|
||||
saved = config.save_settings({"simplified_tool_calling": False})
|
||||
assert saved["simplified_tool_calling"] is False
|
||||
assert json.loads(settings_path.read_text(encoding="utf-8"))["simplified_tool_calling"] is False
|
||||
|
||||
saved = config.save_settings({"simplified_tool_calling": True})
|
||||
assert saved["simplified_tool_calling"] is True
|
||||
|
||||
|
||||
def test_simplified_tool_calling_is_a_valid_boolean_setting():
|
||||
import api.config as config
|
||||
|
||||
assert "simplified_tool_calling" in config._SETTINGS_DEFAULTS
|
||||
assert "simplified_tool_calling" in config._SETTINGS_BOOL_KEYS
|
||||
assert "simplified_tool_calling" in config._SETTINGS_ALLOWED_KEYS
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Tests for collapsible skill categories in the Skills panel.
|
||||
|
||||
Validates that renderSkills() produces collapsible category headers
|
||||
with chevron toggles, click handlers, and persisted collapse state.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import pytest
|
||||
|
||||
|
||||
def _readpanels():
|
||||
with open(os.path.join('static', 'panels.js')) as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def _readcss():
|
||||
with open(os.path.join('static', 'style.css')) as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
# ── State variable ──────────────────────────────────────────────────────────
|
||||
|
||||
class TestCollapseState:
|
||||
"""A Set must track collapsed categories across re-renders."""
|
||||
|
||||
def test_collapsed_cats_set_exists(self):
|
||||
p = _readpanels()
|
||||
assert '_collapsedCats' in p, '_collapsedCats Set must exist'
|
||||
assert 'new Set()' in p, '_collapsedCats must be initialized as Set'
|
||||
|
||||
def test_toggle_function_exists(self):
|
||||
p = _readpanels()
|
||||
assert '_toggleCatCollapse' in p, '_toggleCatCollapse() function must exist'
|
||||
|
||||
|
||||
# ── renderSkills produces collapsible headers ──────────────────────────────
|
||||
|
||||
class TestRenderSkillsCollapse:
|
||||
"""renderSkills() must render category headers with chevron icons and click handlers."""
|
||||
|
||||
def test_chevron_icon_used_instead_of_folder(self):
|
||||
p = _readpanels()
|
||||
idx = p.find('function renderSkills(')
|
||||
body = p[idx:idx + 2000]
|
||||
assert 'chevron-right' in body, 'Must use chevron-right icon instead of folder'
|
||||
assert "li('folder'" not in body, 'Must not use folder icon anymore'
|
||||
|
||||
def test_cat_header_has_dataset_cat(self):
|
||||
p = _readpanels()
|
||||
idx = p.find('function renderSkills(')
|
||||
body = p[idx:idx + 2000]
|
||||
assert 'dataset.cat' in body, 'Header must store category in data-cat attribute'
|
||||
|
||||
def test_cat_header_has_click_handler(self):
|
||||
p = _readpanels()
|
||||
idx = p.find('function renderSkills(')
|
||||
body = p[idx:idx + 2000]
|
||||
assert 'hdr.onclick' in body or 'onclick' in body, 'Header must have onclick handler'
|
||||
|
||||
def test_collapsed_class_toggled(self):
|
||||
p = _readpanels()
|
||||
idx = p.find('function renderSkills(')
|
||||
body = p[idx:idx + 2000]
|
||||
assert 'collapsed' in body, 'Must apply collapsed class based on state'
|
||||
|
||||
def test_skill_items_hidden_when_collapsed(self):
|
||||
p = _readpanels()
|
||||
idx = p.find('function renderSkills(')
|
||||
body = p[idx:idx + 2000]
|
||||
assert "'none'" in body and "style.display" in body, 'Skill items must be hidden when category is collapsed'
|
||||
|
||||
def test_chevron_rotation_on_collapse(self):
|
||||
p = _readpanels()
|
||||
idx = p.find('function renderSkills(')
|
||||
body = p[idx:idx + 2000]
|
||||
assert 'rotate(90deg)' in body, 'Chevron must rotate 90deg when expanded'
|
||||
|
||||
def test_renderSkills_preserves_search_query(self):
|
||||
"""Search query must still be read and applied before grouping."""
|
||||
p = _readpanels()
|
||||
idx = p.find('function renderSkills(')
|
||||
body = p[idx:idx + 500]
|
||||
assert 'skillsSearch' in body, 'Must read search input value'
|
||||
assert 'toLowerCase().includes(query)' in body, 'Must filter by name/description/category'
|
||||
|
||||
|
||||
# ── _toggleCatCollapse DOM manipulation ────────────────────────────────────
|
||||
|
||||
class TestToggleCatCollapse:
|
||||
"""_toggleCatCollapse() must toggle DOM without full re-render."""
|
||||
|
||||
def test_toggles_set_membership(self):
|
||||
p = _readpanels()
|
||||
idx = p.find('function _toggleCatCollapse(')
|
||||
body = p[idx:idx + 500]
|
||||
assert '_collapsedCats.has(cat)' in body
|
||||
assert '_collapsedCats.delete(cat)' in body
|
||||
assert '_collapsedCats.add(cat)' in body
|
||||
|
||||
def test_queries_skills_category_elements(self):
|
||||
p = _readpanels()
|
||||
idx = p.find('function _toggleCatCollapse(')
|
||||
body = p[idx:idx + 800]
|
||||
assert '.skills-category' in body, 'Must query .skills-category elements'
|
||||
|
||||
def test_matches_by_dataset_cat(self):
|
||||
p = _readpanels()
|
||||
idx = p.find('function _toggleCatCollapse(')
|
||||
body = p[idx:idx + 800]
|
||||
assert 'header.dataset.cat === cat' in body or 'dataset.cat' in body, 'Must match category by data attribute'
|
||||
|
||||
def test_toggles_skill_item_display(self):
|
||||
p = _readpanels()
|
||||
idx = p.find('function _toggleCatCollapse(')
|
||||
body = p[idx:idx + 800]
|
||||
assert '.skill-item' in body, 'Must query .skill-item elements'
|
||||
assert "display = collapsed ? 'none'" in body or "style.display" in body, 'Must toggle display property'
|
||||
|
||||
def test_toggles_chevron_rotation(self):
|
||||
p = _readpanels()
|
||||
idx = p.find('function _toggleCatCollapse(')
|
||||
body = p[idx:idx + 800]
|
||||
assert '.cat-chevron' in body, 'Must select chevron element'
|
||||
assert 'rotate' in body, 'Must toggle rotation on chevron'
|
||||
|
||||
|
||||
# ── CSS ────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestCSSClasses:
|
||||
"""CSS must support collapsible categories."""
|
||||
|
||||
def test_cat_chevron_class(self):
|
||||
css = _readcss()
|
||||
assert '.cat-chevron' in css, '.cat-chevron class must exist in CSS'
|
||||
|
||||
def test_cat_chevron_has_fixed_size(self):
|
||||
css = _readcss()
|
||||
m = re.search(r'\.cat-chevron\{[^}]+\}', css)
|
||||
assert m, '.cat-chevron rule must exist'
|
||||
assert 'width' in m.group(), 'Chevron must have fixed width'
|
||||
assert 'flex-shrink' in m.group(), 'Chevron must not shrink'
|
||||
|
||||
def test_skills_cat_header_user_select_none(self):
|
||||
css = _readcss()
|
||||
m = re.search(r'\.skills-cat-header\{[^}]+\}', css)
|
||||
assert m, '.skills-cat-header rule must exist'
|
||||
assert 'user-select' in m.group(), 'Header must have user-select:none to prevent text selection on click'
|
||||
|
||||
def test_skills_cat_header_has_cursor_pointer(self):
|
||||
css = _readcss()
|
||||
m = re.search(r'\.skills-cat-header\{[^}]+\}', css)
|
||||
assert m, '.skills-cat-header rule must exist'
|
||||
assert 'cursor:pointer' in m.group(), 'Header must have cursor:pointer'
|
||||
@@ -41,8 +41,11 @@ def test_timestamp_footer_stays_on_visible_response_segments():
|
||||
assert 'seg.insertAdjacentHTML(\'beforeend\', `${filesHtml}<div class="msg-body">${bodyHtml}</div>${footHtml}`);' in UI_JS, (
|
||||
"Footer timestamp should stay attached to visible response segments"
|
||||
)
|
||||
assert "else if(!thinkingText){" in UI_JS, (
|
||||
"Thinking-only assistant segments should still avoid rendering a footer"
|
||||
assert "assistantThinking.set(rawIdx, thinkingText);" in UI_JS, (
|
||||
"Thinking-only assistant segments should preserve thinking for the shared activity dropdown without rendering a footer"
|
||||
)
|
||||
assert "seg.classList.add('assistant-segment-anchor');" in UI_JS, (
|
||||
"Empty assistant anchor segments should stay footerless while anchoring activity metadata"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Test: SVG, audio, video inline rendering (#481)"""
|
||||
import re
|
||||
|
||||
|
||||
def test_media_extension_regexes_exist():
|
||||
"""Verify SVG/audio/video extension regexes are defined."""
|
||||
with open('static/ui.js') as f:
|
||||
src = f.read()
|
||||
assert '_SVG_EXTS' in src, "Missing _SVG_EXTS regex"
|
||||
assert '_AUDIO_EXTS' in src, "Missing _AUDIO_EXTS regex"
|
||||
assert '_VIDEO_EXTS' in src, "Missing _VIDEO_EXTS regex"
|
||||
# Verify they test correct extensions
|
||||
assert 'svg' in src, "SVG regex should match .svg"
|
||||
assert 'mp3' in src, "AUDIO regex should match .mp3"
|
||||
assert 'ogg' in src, "AUDIO regex should match .ogg"
|
||||
assert 'mp4' in src, "VIDEO regex should match .mp4"
|
||||
assert 'webm' in src, "VIDEO regex should match .webm"
|
||||
|
||||
|
||||
def test_svg_rendered_before_image_catch_all():
|
||||
"""Verify SVG handler for URLs runs before the catch-all image handler."""
|
||||
with open('static/ui.js') as f:
|
||||
src = f.read()
|
||||
# Find positions of SVG vs image catch-all in the URL section
|
||||
svg_url_match = src.find("SVG URLs")
|
||||
image_catch_all = src.find("Render all https:// URLs as <img>")
|
||||
assert svg_url_match > 0, "SVG URL handler not found"
|
||||
assert image_catch_all > 0, "Image catch-all handler not found"
|
||||
assert svg_url_match < image_catch_all, \
|
||||
"SVG handler must come before image catch-all to avoid being shadowed"
|
||||
|
||||
|
||||
def test_local_svg_inline_rendering():
|
||||
"""Verify local SVG files render as inline image."""
|
||||
with open('static/ui.js') as f:
|
||||
src = f.read()
|
||||
assert "msg-media-svg" in src, "Missing msg-media-svg CSS class for SVG rendering"
|
||||
# Should have at least 2 SVG handlers (URL + local)
|
||||
count = src.count("msg-media-svg")
|
||||
assert count >= 2, f"Expected >=2 msg-media-svg references, got {count}"
|
||||
|
||||
|
||||
def test_local_audio_inline_rendering():
|
||||
"""Verify local audio files render as inline player."""
|
||||
with open('static/ui.js') as f:
|
||||
src = f.read()
|
||||
assert "msg-media-audio" in src, "Missing msg-media-audio CSS class"
|
||||
assert "<audio controls" in src, "Should render <audio> element with controls"
|
||||
count = src.count("msg-media-audio")
|
||||
assert count >= 2, f"Expected >=2 msg-media-audio references, got {count}"
|
||||
|
||||
|
||||
def test_local_video_inline_rendering():
|
||||
"""Verify local video files render as inline player."""
|
||||
with open('static/ui.js') as f:
|
||||
src = f.read()
|
||||
assert "msg-media-video" in src, "Missing msg-media-video CSS class"
|
||||
assert "<video controls" in src, "Should render <video> element with controls"
|
||||
count = src.count("msg-media-video")
|
||||
assert count >= 2, f"Expected >=2 msg-media-video references, got {count}"
|
||||
|
||||
|
||||
def test_url_svg_audio_video_handlers():
|
||||
"""Verify HTTPS URLs for SVG/audio/video get inline rendering."""
|
||||
with open('static/ui.js') as f:
|
||||
src = f.read()
|
||||
# These should appear in the URL section (src.split('?')[0] pattern)
|
||||
url_svg = "src.split('?')[0])" in src and "_SVG_EXTS.test" in src
|
||||
url_audio = src.count("_AUDIO_EXTS.test(src.split")
|
||||
url_video = src.count("_VIDEO_EXTS.test(src.split")
|
||||
assert url_svg, "URL SVG handler should test extension on src"
|
||||
assert url_audio >= 1, "URL audio handler should test extension on src"
|
||||
assert url_video >= 1, "URL video handler should test extension on src"
|
||||
|
||||
|
||||
def test_attachment_svg_audio_video():
|
||||
"""Verify file attachments for SVG/audio/video get inline previews."""
|
||||
with open('static/ui.js') as f:
|
||||
src = f.read()
|
||||
assert "attach-thumb--svg" in src, "Missing attach-thumb--svg for SVG thumbnails"
|
||||
assert "attach-chip--audio" in src, "Missing attach-chip--audio"
|
||||
assert "attach-chip--video" in src, "Missing attach-chip--video"
|
||||
assert "attach-chip-media" in src, "Missing attach-chip-media label"
|
||||
|
||||
|
||||
def test_attachment_blob_url_cleanup():
|
||||
"""Verify audio/video attachment chips create blob URLs."""
|
||||
with open('static/ui.js') as f:
|
||||
src = f.read()
|
||||
# SVG and media attachments should use createObjectURL
|
||||
assert "URL.createObjectURL(f)" in src, "Should create blob URLs for attachments"
|
||||
|
||||
|
||||
def test_preload_metadata():
|
||||
"""Verify audio/video elements use preload='metadata' for performance."""
|
||||
with open('static/ui.js') as f:
|
||||
src = f.read()
|
||||
assert 'preload="metadata"' in src, "Audio/video should use preload='metadata'"
|
||||
|
||||
|
||||
def test_media_label_class():
|
||||
"""Verify media label class exists for type identification."""
|
||||
with open('static/ui.js') as f:
|
||||
src = f.read()
|
||||
assert "msg-media-label" in src, "Missing msg-media-label class"
|
||||
|
||||
|
||||
def test_i18n_keys():
|
||||
"""Verify media rendering i18n keys exist in all locales."""
|
||||
with open('static/i18n.js') as f:
|
||||
src = f.read()
|
||||
required_keys = [
|
||||
'media_audio_label',
|
||||
'media_svg_label',
|
||||
'media_video_label',
|
||||
]
|
||||
for key in required_keys:
|
||||
count = src.count(f"{key}:")
|
||||
assert count == 7, f"Key '{key}' found {count} times, expected 7"
|
||||
|
||||
|
||||
def test_css_classes_exist():
|
||||
"""Verify all media CSS classes are defined."""
|
||||
with open('static/style.css') as f:
|
||||
src = f.read()
|
||||
required_classes = [
|
||||
'msg-media-svg',
|
||||
'msg-media-label',
|
||||
'msg-media-audio',
|
||||
'msg-media-video',
|
||||
'attach-thumb--svg',
|
||||
'attach-chip--audio',
|
||||
'attach-chip--video',
|
||||
'attach-chip-media',
|
||||
]
|
||||
for cls in required_classes:
|
||||
assert cls in src, f"Missing CSS class: .{cls}"
|
||||
|
||||
|
||||
def test_svg_not_matched_by_image_exts():
|
||||
"""Verify .svg is NOT in _IMAGE_EXTS (SVG has its own handler)."""
|
||||
with open('static/ui.js') as f:
|
||||
src = f.read()
|
||||
# Extract the _IMAGE_EXTS regex
|
||||
match = re.search(r"const _IMAGE_EXTS=/([^/]+)/i", src)
|
||||
assert match, "Could not find _IMAGE_EXTS regex"
|
||||
exts = match.group(1)
|
||||
assert 'svg' not in exts.lower(), ".svg should NOT be in _IMAGE_EXTS"
|
||||
|
||||
|
||||
def test_audio_video_not_matched_by_image_exts():
|
||||
"""Verify audio/video extensions are NOT in _IMAGE_EXTS."""
|
||||
with open('static/ui.js') as f:
|
||||
src = f.read()
|
||||
match = re.search(r"const _IMAGE_EXTS=/([^/]+)/i", src)
|
||||
assert match
|
||||
exts = match.group(1)
|
||||
for ext in ['mp3', 'mp4', 'wav', 'ogg', 'webm', 'mov', 'm4a']:
|
||||
assert ext not in exts.lower(), f".{ext} should NOT be in _IMAGE_EXTS"
|
||||
@@ -41,7 +41,7 @@ def test_thinking_card_toggle_and_body_use_animation_friendly_state():
|
||||
def test_tool_card_toggle_uses_same_chevron_icon_markup_as_thinking_card():
|
||||
assert "<span class=\"thinking-card-toggle\">${li('chevron-right',12)}</span>" in UI_JS
|
||||
assert "<span class=\"tool-card-toggle\">${li('chevron-right',12)}</span>" in UI_JS
|
||||
assert "<div class=\"thinking-card open\"><div class=\"thinking-card-header\" onclick=\"this.parentElement.classList.toggle('open')\"><span class=\"thinking-card-icon\">" in UI_JS
|
||||
assert "<div class=\"thinking-card\"><div class=\"thinking-card-header\" onclick=\"this.parentElement.classList.toggle('open')\"><span class=\"thinking-card-icon\">" in UI_JS
|
||||
|
||||
|
||||
def test_thinking_card_uses_panel_chrome_with_gold_palette():
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
"""Static UI tests for quieter tool-call rendering and shared design tokens.
|
||||
|
||||
These tests intentionally follow the repo's existing pytest style: read static
|
||||
source files, isolate the relevant function/rule, and assert implementation
|
||||
invariants before changing the UI.
|
||||
"""
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
REPO = pathlib.Path(__file__).parent.parent
|
||||
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
BOOT_JS = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
|
||||
CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _function_body(src: str, name: str) -> str:
|
||||
match = re.search(rf"function\s+{re.escape(name)}\s*\(", src)
|
||||
assert match, f"{name}() not found"
|
||||
brace = src.find("{", match.end())
|
||||
assert brace != -1, f"{name}() has no body"
|
||||
depth = 1
|
||||
i = brace + 1
|
||||
in_string = None
|
||||
escaped = False
|
||||
in_line_comment = False
|
||||
in_block_comment = False
|
||||
while i < len(src) and depth:
|
||||
ch = src[i]
|
||||
nxt = src[i + 1] if i + 1 < len(src) else ""
|
||||
if in_line_comment:
|
||||
if ch == "\n":
|
||||
in_line_comment = False
|
||||
i += 1
|
||||
continue
|
||||
if in_block_comment:
|
||||
if ch == "*" and nxt == "/":
|
||||
in_block_comment = False
|
||||
i += 2
|
||||
continue
|
||||
i += 1
|
||||
continue
|
||||
if in_string:
|
||||
if escaped:
|
||||
escaped = False
|
||||
elif ch == "\\":
|
||||
escaped = True
|
||||
elif ch == in_string:
|
||||
in_string = None
|
||||
i += 1
|
||||
continue
|
||||
if ch == "/" and nxt == "/":
|
||||
in_line_comment = True
|
||||
i += 2
|
||||
continue
|
||||
if ch == "/" and nxt == "*":
|
||||
in_block_comment = True
|
||||
i += 2
|
||||
continue
|
||||
if ch in "'\"`":
|
||||
in_string = ch
|
||||
i += 1
|
||||
continue
|
||||
if ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
i += 1
|
||||
assert depth == 0, f"{name}() body did not close"
|
||||
return src[brace + 1:i - 1]
|
||||
|
||||
|
||||
class TestToolCallGroupingStatic:
|
||||
def test_simplified_tool_calling_setting_is_wired_through_frontend(self):
|
||||
assert "settingsSimplifiedToolCalling" in (REPO / "static" / "index.html").read_text(encoding="utf-8"), (
|
||||
"Settings should expose a Compact tool activity checkbox."
|
||||
)
|
||||
assert "window._simplifiedToolCalling" in (REPO / "static" / "boot.js").read_text(encoding="utf-8"), (
|
||||
"Boot should hydrate simplified_tool_calling into a runtime flag."
|
||||
)
|
||||
panels = (REPO / "static" / "panels.js").read_text(encoding="utf-8")
|
||||
assert "settingsSimplifiedToolCalling" in panels and "simplified_tool_calling" in panels, (
|
||||
"Settings panel should load and save the simplified_tool_calling setting."
|
||||
)
|
||||
|
||||
def test_render_messages_gates_settled_activity_grouping(self):
|
||||
fn = _function_body(UI_JS, "renderMessages")
|
||||
helper = _function_body(UI_JS, "ensureActivityGroup")
|
||||
assert "isSimplifiedToolCalling()" in fn, (
|
||||
"Settled tool/thinking grouping should be gated by the Compact tool activity toggle."
|
||||
)
|
||||
assert "tool-cards-toggle" in fn, (
|
||||
"The non-simplified path should preserve the upstream loose tool-card controls."
|
||||
)
|
||||
assert "data-tool-call-group" in helper, (
|
||||
"Tool-call groups need a stable data-tool-call-group attribute for CSS and tests."
|
||||
)
|
||||
assert re.search(r"cards\.length|toolCount|toolCalls\.length|group\.length", fn + helper), (
|
||||
"The simplified group header should derive its summary/count from the number of tool calls."
|
||||
)
|
||||
|
||||
def test_tool_call_groups_default_collapsed_with_summary_visible(self):
|
||||
fn = _function_body(UI_JS, "renderMessages")
|
||||
helper = _function_body(UI_JS, "ensureActivityGroup")
|
||||
assert "tool-call-group-collapsed" in fn or "collapsed" in fn, (
|
||||
"Historical tool-call groups should default to a collapsed state."
|
||||
)
|
||||
assert "tool-call-group-summary" in helper, (
|
||||
"Collapsed groups must expose a visible summary/header row."
|
||||
)
|
||||
assert "tool-call-group-body" in helper, (
|
||||
"Tool-card detail rows should live inside a group body that can be "
|
||||
"expanded/collapsed."
|
||||
)
|
||||
assert "aria-expanded" in helper, (
|
||||
"The expand/collapse control must expose aria-expanded."
|
||||
)
|
||||
|
||||
def test_live_tool_cards_use_grouping_only_when_simplified(self):
|
||||
live_fn = _function_body(UI_JS, "appendLiveToolCard")
|
||||
settled_fn = _function_body(UI_JS, "renderMessages")
|
||||
assert "isSimplifiedToolCalling()" in live_fn, (
|
||||
"Live streaming tool cards should branch on the Compact tool activity toggle."
|
||||
)
|
||||
assert "ensureActivityGroup" in live_fn, (
|
||||
"Compact live tool rendering should use the grouped activity container."
|
||||
)
|
||||
assert "toolRunningRow" in live_fn, (
|
||||
"The non-simplified live tool path should preserve the upstream running-dots row."
|
||||
)
|
||||
assert "buildToolCard" in live_fn and "buildToolCard" in settled_fn, (
|
||||
"Live and settled tool rendering should share buildToolCard() for consistent markup."
|
||||
)
|
||||
assert "data-live-tid" in live_fn, (
|
||||
"Live grouping must preserve data-live-tid so tool_start/tool_complete updates still replace the correct card."
|
||||
)
|
||||
|
||||
def test_tools_and_thinking_share_one_collapsed_activity_dropdown(self):
|
||||
ui_min = re.sub(r"\s+", "", UI_JS)
|
||||
assert "functionensureActivityGroup(" in ui_min, (
|
||||
"Tool calls and thinking should share one agent-activity disclosure helper."
|
||||
)
|
||||
assert "data-agent-activity-group" in UI_JS, (
|
||||
"The shared tools/thinking disclosure needs a stable data-agent-activity-group hook."
|
||||
)
|
||||
assert "agent-activity-thinking" in UI_JS, (
|
||||
"Thinking content should be nested inside the shared activity dropdown, not rendered separately."
|
||||
)
|
||||
render_fn = _function_body(UI_JS, "renderMessages")
|
||||
assert "isSimplifiedToolCalling()" in render_fn and "assistantThinking.set(rawIdx, thinkingText)" in render_fn, (
|
||||
"Settled thinking should move into the shared activity dropdown only when Compact tool activity is enabled."
|
||||
)
|
||||
assert "seg.insertAdjacentHTML('beforeend', _thinkingCardHtml(thinkingText))" in render_fn, (
|
||||
"The non-simplified path should preserve standalone settled thinking cards."
|
||||
)
|
||||
|
||||
def test_live_thinking_uses_shared_activity_dropdown_only_when_simplified(self):
|
||||
live_thinking_fn = _function_body(UI_JS, "appendThinking")
|
||||
assert "isSimplifiedToolCalling()" in live_thinking_fn, (
|
||||
"Live thinking should branch on the Compact tool activity toggle."
|
||||
)
|
||||
assert "ensureActivityGroup" in live_thinking_fn, (
|
||||
"Compact live thinking should be inserted into the shared activity dropdown."
|
||||
)
|
||||
assert "thinkingRow" in live_thinking_fn, (
|
||||
"The non-simplified live thinking path should preserve the upstream #thinkingRow card."
|
||||
)
|
||||
|
||||
|
||||
class TestToolCardDesignTokens:
|
||||
def test_root_defines_shared_layout_design_tokens(self):
|
||||
for token in (
|
||||
"--radius-sm",
|
||||
"--radius-md",
|
||||
"--radius-card",
|
||||
"--space-1",
|
||||
"--space-2",
|
||||
"--space-3",
|
||||
"--font-size-xs",
|
||||
"--font-size-sm",
|
||||
"--surface-subtle",
|
||||
"--border-subtle",
|
||||
):
|
||||
assert token in CSS, f"Missing design token {token} in style.css"
|
||||
|
||||
def test_base_dark_palette_restores_upstream_gold_tokens(self):
|
||||
css_min = re.sub(r"\s+", "", CSS)
|
||||
expected_tokens = (
|
||||
"--bg:#0D0D1A",
|
||||
"--sidebar:#141425",
|
||||
"--border:#2A2A45",
|
||||
"--text:#FFF8DC",
|
||||
"--muted:#C0C0C0",
|
||||
"--accent:#FFD700",
|
||||
"--surface:#1A1A2E",
|
||||
"--topbar-bg:rgba(20,20,37,.98)",
|
||||
)
|
||||
for token in expected_tokens:
|
||||
assert token in css_min, f"Base dark palette token missing: {token}"
|
||||
|
||||
def test_base_light_palette_restores_upstream_gold_tokens(self):
|
||||
css_min = re.sub(r"\s+", "", CSS)
|
||||
expected_tokens = (
|
||||
"--bg:#FEFCF7",
|
||||
"--sidebar:#FAF7F0",
|
||||
"--border:#E0D8C8",
|
||||
"--text:#1A1610",
|
||||
"--muted:#5C5344",
|
||||
"--accent:#B8860B",
|
||||
"--surface:#F3EEE3",
|
||||
)
|
||||
for token in expected_tokens:
|
||||
assert token in css_min, f"Base light palette token missing: {token}"
|
||||
|
||||
def test_calm_console_palette_is_gated_as_custom_theme_not_base(self):
|
||||
css_min = re.sub(r"\s+", "", CSS)
|
||||
assert ':root.dark[data-theme="calm"]' in css_min, (
|
||||
"Coolors calm palette should be gated behind the custom calm theme."
|
||||
)
|
||||
for token in (
|
||||
"--bg:#0A0908",
|
||||
"--sidebar:#22333B",
|
||||
"--text:#EAE0D5",
|
||||
"--muted:#C6AC8F",
|
||||
"--accent:#C6AC8F",
|
||||
):
|
||||
assert token in css_min, f"Calm custom theme token missing: {token}"
|
||||
|
||||
def test_default_skin_preview_stays_upstream_and_calm_theme_preview_is_separate(self):
|
||||
boot_min = re.sub(r"\s+", "", BOOT_JS)
|
||||
assert "{name:'Default',colors:['#FFD700','#FFBF00','#CD7F32']}" in boot_min, (
|
||||
"The Default skin swatch should stay aligned with the upstream gold base."
|
||||
)
|
||||
assert "{name:'Calm'," in boot_min and "colors:['#C6AC8F','#EAE0D5','#22333B']" in boot_min, (
|
||||
"The Coolors palette should be exposed as a separate custom Calm theme preview."
|
||||
)
|
||||
|
||||
def test_claude_like_message_typography_splits_user_and_assistant_fonts(self):
|
||||
css_min = re.sub(r"\s+", "", CSS)
|
||||
assert "--font-ui:" in css_min and "--font-assistant:" in css_min, (
|
||||
"Typography should define separate UI/user and assistant font tokens."
|
||||
)
|
||||
assert ".assistant-turn.msg-body{font-family:var(--font-assistant)" in css_min or ".assistant-turn.msg-body" in css_min.replace(" .", "."), (
|
||||
"Assistant prose should use the Claude-like editorial serif stack."
|
||||
)
|
||||
assert ".msg-row[data-role=\"user\"].msg-body{font-family:var(--font-ui)" in css_min, (
|
||||
"User bubbles should keep the sans/UI stack, matching Claude's split typography."
|
||||
)
|
||||
assert "Georgia" in CSS and "system-ui" in CSS, (
|
||||
"Claude-like fallback stacks should include Georgia for assistant prose and system-ui for UI/user text."
|
||||
)
|
||||
|
||||
def test_tool_card_css_uses_design_tokens_for_chrome(self):
|
||||
css_min = re.sub(r"\s+", "", CSS)
|
||||
assert ".tool-card{" in css_min, ".tool-card rule missing"
|
||||
assert "border-radius:var(--radius-card)" in css_min, (
|
||||
".tool-card border radius should use --radius-card, not hardcoded px."
|
||||
)
|
||||
assert "background:var(--surface-subtle)" in css_min, (
|
||||
".tool-card background should use --surface-subtle."
|
||||
)
|
||||
assert "border:1pxsolidvar(--border-subtle)" in css_min, (
|
||||
".tool-card border should use --border-subtle."
|
||||
)
|
||||
|
||||
def test_tool_card_header_and_text_use_spacing_and_font_tokens(self):
|
||||
css_min = re.sub(r"\s+", "", CSS)
|
||||
assert ".tool-card-header{" in css_min, ".tool-card-header rule missing"
|
||||
assert "gap:var(--space-2)" in css_min, (
|
||||
".tool-card-header gap should use --space-2."
|
||||
)
|
||||
assert "padding:var(--space-1)var(--space-3)" in css_min, (
|
||||
".tool-card-header padding should use spacing tokens."
|
||||
)
|
||||
assert ".tool-card-name{" in css_min and "font-size:var(--font-size-xs)" in css_min, (
|
||||
".tool-card-name should use --font-size-xs."
|
||||
)
|
||||
assert ".tool-card-preview{" in css_min and "font-size:var(--font-size-xs)" in css_min, (
|
||||
".tool-card-preview should use --font-size-xs."
|
||||
)
|
||||
Reference in New Issue
Block a user