2026-04-14 17:14:01 +00:00
|
|
|
from collections import Counter
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
import re
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
REPO = Path(__file__).resolve().parent.parent
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def read(path: Path) -> str:
|
|
|
|
|
return path.read_text(encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
|
2026-04-14 17:14:01 +00:00
|
|
|
def extract_locale_block(src: str, locale_key: str) -> str:
|
|
|
|
|
start_match = re.search(rf"\b{re.escape(locale_key)}\s*:\s*\{{", src)
|
|
|
|
|
assert start_match, f"{locale_key} locale block not found"
|
|
|
|
|
|
|
|
|
|
start = start_match.end() - 1 # "{"
|
|
|
|
|
depth = 0
|
|
|
|
|
in_single = False
|
|
|
|
|
in_double = False
|
|
|
|
|
in_backtick = False
|
|
|
|
|
escape = False
|
|
|
|
|
|
|
|
|
|
for i in range(start, len(src)):
|
|
|
|
|
ch = src[i]
|
|
|
|
|
|
|
|
|
|
if escape:
|
|
|
|
|
escape = False
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
if in_single:
|
|
|
|
|
if ch == "\\":
|
|
|
|
|
escape = True
|
|
|
|
|
elif ch == "'":
|
|
|
|
|
in_single = False
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
if in_double:
|
|
|
|
|
if ch == "\\":
|
|
|
|
|
escape = True
|
|
|
|
|
elif ch == '"':
|
|
|
|
|
in_double = False
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
if in_backtick:
|
|
|
|
|
if ch == "\\":
|
|
|
|
|
escape = True
|
|
|
|
|
elif ch == "`":
|
|
|
|
|
in_backtick = False
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
if ch == "'":
|
|
|
|
|
in_single = True
|
|
|
|
|
continue
|
|
|
|
|
if ch == '"':
|
|
|
|
|
in_double = True
|
|
|
|
|
continue
|
|
|
|
|
if ch == "`":
|
|
|
|
|
in_backtick = True
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
if ch == "{":
|
|
|
|
|
depth += 1
|
|
|
|
|
continue
|
|
|
|
|
if ch == "}":
|
|
|
|
|
depth -= 1
|
|
|
|
|
if depth == 0:
|
|
|
|
|
return src[start + 1 : i]
|
|
|
|
|
|
|
|
|
|
raise AssertionError(f"{locale_key} locale block braces are not balanced")
|
|
|
|
|
|
|
|
|
|
|
2026-04-14 17:14:01 +00:00
|
|
|
def test_chinese_locale_block_exists():
|
|
|
|
|
src = read(REPO / "static" / "i18n.js")
|
|
|
|
|
assert "\n zh: {" in src
|
|
|
|
|
assert "_lang: 'zh'" in src
|
|
|
|
|
assert "_speech: 'zh-CN'" in src
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_chinese_locale_includes_representative_translations():
|
|
|
|
|
src = read(REPO / "static" / "i18n.js")
|
|
|
|
|
expected = [
|
|
|
|
|
"settings_title: '\\u8bbe\\u7f6e'",
|
|
|
|
|
"login_title: '\\u767b\\u5f55'",
|
|
|
|
|
"approval_heading: '需要审批'",
|
|
|
|
|
"tab_tasks: '任务'",
|
|
|
|
|
"tab_profiles: '配置'",
|
v0.50.211: compact timestamps, adaptive title refresh, settings picker fix (#1061)
* Shorten session sidebar relative time labels
* feat: adaptive session title refresh based on conversation evolution
Addresses #869 — the 'Optional' part: adapt session names to current
conversation context instead of only generating once from the first exchange.
Backend (api/streaming.py):
- Add _latest_exchange_snippets() to extract last user+assistant pair
- Add _count_exchanges() to count user messages
- Add _get_title_refresh_interval() to read the setting
- Add _run_background_title_refresh() — refreshes title from latest exchange
with LLM, skips if title is unchanged or user manually renamed
- Add _maybe_schedule_title_refresh() — checks exchange count and schedules
refresh after stream_end (non-blocking)
Config (api/config.py):
- Add auto_title_refresh_every setting (default '0' = off)
- Enum validation: {'0', '5', '10', '20'}
Frontend:
- Settings UI dropdown (static/index.html)
- Wire up load/save in panels.js
- i18n keys for all 6 locales (en/ru/es/de/zh/zh-Hant)
Default: off. Opt-in via Settings > Conversation > Adaptive title refresh.
* test: add 37 tests for adaptive title refresh helpers
Covers all five new functions introduced in this PR:
_count_exchanges, _latest_exchange_snippets, _get_title_refresh_interval,
_run_background_title_refresh, _maybe_schedule_title_refresh
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
* fix(settings): show selected state on theme/skin/font-size picker cards
The CSS rule `#mainSettings .theme-pick-btn { border-color: var(--border) !important }` was
overriding the inline `style.borderColor = "var(--accent)"` set by `_syncThemePicker()` and
siblings — `!important` beats inline styles. Active cards showed no visual highlight.
Fix: move to `.active` CSS class with `border-color:var(--accent)!important` so the active
rule wins over the base rule, and clear the stale inline borderColor/boxShadow from the
sync functions. 5 regression tests added.
Closes #1057
* fix: rename test file to match PR number, fix stale issue reference
* docs: v0.50.211 release notes and version bump
Compact sidebar timestamps, adaptive title refresh (opt-in), settings picker fix.
* docs(changelog): correct settings tab for adaptive title refresh
The v0.50.211 entry for #1058 said "Settings → Appearance" but the
toggle is actually rendered inside settingsPanePreferences (the
Preferences tab) per static/index.html:604+. The commit message also
had the wrong tab ("Conversation"). Updated CHANGELOG to match the
actual UI surface so users can find the toggle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: create state dir before writing settings file
save_settings() called SETTINGS_FILE.write_text() without ensuring the
parent directory exists. In fresh environments (CI, first run without
HERMES_WEBUI_STATE_DIR set) this raised FileNotFoundError.
Add mkdir(parents=True, exist_ok=True) before the write.
---------
Co-authored-by: Pavol Biely <biely@webtec.sk>
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 00:50:58 +00:00
|
|
|
"session_time_bucket_today: '今天'",
|
2026-04-14 17:14:01 +00:00
|
|
|
"onboarding_title: '欢迎使用 Hermes Web UI'",
|
|
|
|
|
"onboarding_complete: '引导完成'",
|
|
|
|
|
]
|
|
|
|
|
for entry in expected:
|
|
|
|
|
assert entry in src
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_chinese_locale_covers_english_keys():
|
|
|
|
|
src = read(REPO / "static" / "i18n.js")
|
|
|
|
|
key_pattern = re.compile(r"^\s{4}([a-zA-Z0-9_]+):", re.MULTILINE)
|
2026-04-14 17:14:01 +00:00
|
|
|
en_keys = set(key_pattern.findall(extract_locale_block(src, "en")))
|
|
|
|
|
zh_keys = set(key_pattern.findall(extract_locale_block(src, "zh")))
|
2026-04-14 17:14:01 +00:00
|
|
|
|
|
|
|
|
missing = sorted(en_keys - zh_keys)
|
|
|
|
|
assert not missing, f"Chinese locale missing keys: {missing}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_chinese_locale_has_no_duplicate_keys():
|
|
|
|
|
src = read(REPO / "static" / "i18n.js")
|
|
|
|
|
key_pattern = re.compile(r"^\s{4}([a-zA-Z0-9_]+):", re.MULTILINE)
|
2026-04-14 17:14:01 +00:00
|
|
|
keys = key_pattern.findall(extract_locale_block(src, "zh"))
|
2026-04-14 17:14:01 +00:00
|
|
|
duplicates = sorted(k for k, count in Counter(keys).items() if count > 1)
|
|
|
|
|
assert not duplicates, f"Chinese locale has duplicate keys: {duplicates}"
|
2026-04-30 15:24:31 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_traditional_chinese_mcp_and_tree_labels_are_not_cyrillic():
|
|
|
|
|
"""Regression for PR #1254/#1274 locale cross-paste fallout.
|
|
|
|
|
|
|
|
|
|
zh-Hant inherited Russian MCP/tree-view labels such as "MCP Серверы",
|
|
|
|
|
"Дерево", and "Исходный". Those labels show up under JSON/YAML code
|
|
|
|
|
block tree toggles and Settings → System → MCP Servers for zh-TW users.
|
|
|
|
|
"""
|
|
|
|
|
src = read(REPO / "static" / "i18n.js")
|
|
|
|
|
start = src.index(" 'zh-Hant': {")
|
|
|
|
|
end = src.index("\n pt:", start)
|
|
|
|
|
block = src[start:end]
|
|
|
|
|
|
|
|
|
|
expected = [
|
|
|
|
|
"tree_view: '樹狀'",
|
|
|
|
|
"raw_view: '原始'",
|
|
|
|
|
"parse_failed_note: '解析失敗'",
|
|
|
|
|
"mcp_servers_title: 'MCP 伺服器'",
|
|
|
|
|
"mcp_no_servers: '未設定 MCP 伺服器。'",
|
|
|
|
|
"mcp_add_server: '+ 新增伺服器'",
|
|
|
|
|
]
|
|
|
|
|
for entry in expected:
|
|
|
|
|
assert entry in block
|
|
|
|
|
|
|
|
|
|
assert not re.search(r"[\u0400-\u04FF]", block), "zh-Hant locale contains Cyrillic text"
|