Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
350f4a6448 | ||
|
|
a6c60a2902 |
@@ -108,12 +108,6 @@ docs/superpowers/*
|
||||
# logs, and per-session caches are never artifacts of the codebase.
|
||||
.hermes/
|
||||
|
||||
# Desktop/bootstrap install marker written into the managed checkout root by the
|
||||
# bootstrap installer. It is Hermes-managed runtime state, never a code change —
|
||||
# ignore it so `hermes update`'s `git stash push --include-untracked` does not
|
||||
# treat it as a local edit and autostash it on every run (#38529).
|
||||
.hermes-bootstrap-complete
|
||||
|
||||
# Tool Search live-test harness output — non-deterministic model transcripts,
|
||||
# regenerated by scripts/tool_search_livetest.py. Never an artifact of the repo.
|
||||
scripts/out/
|
||||
|
||||
@@ -2720,61 +2720,6 @@ def run_conversation(
|
||||
# compress history and retry, not abort immediately.
|
||||
status_code = getattr(api_error, "status_code", None)
|
||||
|
||||
# ── Respect disabled auto-compaction on overflow ──────
|
||||
# Ported from anomalyco/opencode#30749. When the user has
|
||||
# turned auto-compaction off (``compression.enabled: false``),
|
||||
# NO automatic compaction trigger may fire — including the
|
||||
# provider/request-size overflow recovery paths below
|
||||
# (long-context-tier 429, 413 payload-too-large, and
|
||||
# context-overflow). Without this guard the proactive
|
||||
# threshold path correctly honours the setting (see the
|
||||
# preflight check and the post-response ``should_compress``
|
||||
# gate) but a provider overflow error would still silently
|
||||
# compress + rotate the session, bypassing the user's
|
||||
# explicit choice. Surface a terminal error instead so the
|
||||
# user can compact manually (``/compress``), start fresh
|
||||
# (``/new``), switch to a larger-context model, or reduce
|
||||
# attachments. Forced compaction via ``/compress``
|
||||
# (``force=True``) is unaffected — it never reaches this loop.
|
||||
_overflow_reasons = {
|
||||
FailoverReason.long_context_tier,
|
||||
FailoverReason.payload_too_large,
|
||||
FailoverReason.context_overflow,
|
||||
}
|
||||
if (
|
||||
classified.reason in _overflow_reasons
|
||||
and not getattr(agent, "compression_enabled", True)
|
||||
):
|
||||
agent._flush_status_buffer()
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix}❌ Context overflow, but auto-compaction is disabled "
|
||||
f"(compression.enabled: false).",
|
||||
force=True,
|
||||
)
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix} 💡 Run /compress to compact manually, /new to start fresh, "
|
||||
f"switch to a larger-context model, or reduce attachments.",
|
||||
force=True,
|
||||
)
|
||||
logger.error(
|
||||
f"{agent.log_prefix}Context overflow ({classified.reason.value}) with "
|
||||
f"auto-compaction disabled — not compressing."
|
||||
)
|
||||
agent._persist_session(messages, conversation_history)
|
||||
return {
|
||||
"messages": messages,
|
||||
"completed": False,
|
||||
"api_calls": api_call_count,
|
||||
"error": (
|
||||
"Context overflow and auto-compaction is disabled "
|
||||
"(compression.enabled: false). Run /compress to compact manually, "
|
||||
"/new to start fresh, or switch to a larger-context model."
|
||||
),
|
||||
"partial": True,
|
||||
"failed": True,
|
||||
"compaction_disabled": True,
|
||||
}
|
||||
|
||||
# ── Anthropic Sonnet long-context tier gate ───────────
|
||||
# Anthropic returns HTTP 429 "Extra usage is required for
|
||||
# long context requests" when a Claude Max (or similar)
|
||||
|
||||
@@ -33,13 +33,6 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
|
||||
|
||||
# Published max output-token ceiling shared by every current Gemini text model
|
||||
# (2.5 + 3.x: flash, flash-lite, pro). Used as the default when the caller
|
||||
# passes max_tokens=None, because Gemini's native API otherwise applies a low
|
||||
# internal default and truncates output (unlike OpenAI-compat endpoints where
|
||||
# an omitted limit means full budget).
|
||||
GEMINI_DEFAULT_MAX_OUTPUT_TOKENS = 65535
|
||||
|
||||
|
||||
def is_native_gemini_base_url(base_url: str) -> bool:
|
||||
"""Return True when the endpoint speaks Gemini's native REST API."""
|
||||
@@ -421,18 +414,6 @@ def build_gemini_request(
|
||||
generation_config["temperature"] = temperature
|
||||
if max_tokens is not None:
|
||||
generation_config["maxOutputTokens"] = max_tokens
|
||||
else:
|
||||
# Gemini's native generateContent does NOT treat an omitted
|
||||
# maxOutputTokens as "use the model's full output budget" — it applies
|
||||
# a low internal default and the model stops early with
|
||||
# finishReason=MAX_TOKENS, truncating tool calls mid-stream (Hermes
|
||||
# then retries 3× and refuses the incomplete call). Every current
|
||||
# Gemini text model (2.5 + 3.x, flash / flash-lite / pro) caps at
|
||||
# 65,535 output tokens, so default to that ceiling when the caller
|
||||
# passes None ("unlimited"). See the OpenAI-compat path where omitting
|
||||
# the field genuinely means full budget — that assumption does not
|
||||
# hold on the native API.
|
||||
generation_config["maxOutputTokens"] = GEMINI_DEFAULT_MAX_OUTPUT_TOKENS
|
||||
if top_p is not None:
|
||||
generation_config["topP"] = top_p
|
||||
if stop:
|
||||
|
||||
@@ -571,28 +571,7 @@ class ChatCompletionsTransport(ProviderTransport):
|
||||
api_kwargs[k] = v
|
||||
|
||||
if extra_body:
|
||||
# Native Gemini (generativelanguage.googleapis.com, non-/openai)
|
||||
# speaks Google's REST schema, not OpenAI's. OpenAI-style extra_body
|
||||
# keys (tags, reasoning, provider, plugins, …) are unknown fields
|
||||
# there and Gemini rejects the whole request with a non-retryable
|
||||
# HTTP 400 ("Invalid JSON payload received. Unknown name 'tags'").
|
||||
# This happens when a profile that emits extra_body (e.g. the Nous
|
||||
# profile's portal `tags`) is active but the resolved endpoint is a
|
||||
# Gemini base_url — typical when only Google credentials are set and
|
||||
# a fallback/aux call lands on Gemini. The native client only reads
|
||||
# thinking_config from extra_body, so drop everything else here.
|
||||
try:
|
||||
from agent.gemini_native_adapter import is_native_gemini_base_url
|
||||
_native_gemini = is_native_gemini_base_url(params.get("base_url"))
|
||||
except Exception:
|
||||
_native_gemini = False
|
||||
if _native_gemini:
|
||||
extra_body = {
|
||||
k: v for k, v in extra_body.items()
|
||||
if k in ("thinking_config", "thinkingConfig")
|
||||
}
|
||||
if extra_body:
|
||||
api_kwargs["extra_body"] = extra_body
|
||||
api_kwargs["extra_body"] = extra_body
|
||||
|
||||
return api_kwargs
|
||||
|
||||
|
||||
@@ -171,19 +171,12 @@ async fn run_update(app: AppHandle) -> Result<()> {
|
||||
let child_env = update_child_env(&install_root);
|
||||
let mut update_args: Vec<String> =
|
||||
vec!["update".into(), "--yes".into(), "--gateway".into()];
|
||||
// --force skips `hermes update`'s Windows running-exe guard (which would
|
||||
// `sys.exit(2)` and dead-end the handoff). By contract the desktop has
|
||||
// already exited and waited for the venv shim to unlock before launching
|
||||
// us, and wait_for_venv_free below force-kills any straggler — so by the
|
||||
// time `hermes update` runs there is no legitimate hermes.exe to protect,
|
||||
// and the guard would only produce a false "Hermes is still running" stop.
|
||||
update_args.push("--force".into());
|
||||
update_args.push("--branch".into());
|
||||
update_args.push(update_branch);
|
||||
|
||||
emit_stage(&app, "update", StageState::Running, None, None);
|
||||
let started = Instant::now();
|
||||
let mut update = run_streamed(
|
||||
let update = run_streamed(
|
||||
&app,
|
||||
&hermes,
|
||||
&update_args,
|
||||
@@ -192,38 +185,6 @@ async fn run_update(app: AppHandle) -> Result<()> {
|
||||
Some("update"),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Retry-once for the update-boundary crash. `hermes update` lazily imports
|
||||
// the FRESHLY PULLED modules, but the dependency-install step still runs the
|
||||
// already-in-memory pre-pull code for one invocation. A release that changed
|
||||
// an updater-path contract across that boundary (e.g. #39780's `_UvResult`,
|
||||
// whose `__iter__` injected a bool into the argv and crashed Windows
|
||||
// `list2cmdline` with `TypeError: sequence item 1: expected str instance,
|
||||
// bool found`, fixed in #39820) therefore kills the FIRST update on the
|
||||
// parked population — even though the fix is already on disk by then. A
|
||||
// second `hermes update` runs clean because the now-current module is loaded
|
||||
// from the start. Rather than make the parked user click Update twice (and
|
||||
// stare at a scary crash first), retry once automatically. Skip the retry
|
||||
// for the concurrent-instance guard (exit 2) — that's a "close Hermes" state
|
||||
// a retry can't fix.
|
||||
if !matches!(update.exit_code, Some(0) | Some(UPDATE_EXIT_CONCURRENT)) {
|
||||
emit_log(
|
||||
&app,
|
||||
Some("update"),
|
||||
LogStream::Stdout,
|
||||
"[update] first update attempt failed; retrying once (the fix it just \
|
||||
pulled loads on the second run)…",
|
||||
);
|
||||
update = run_streamed(
|
||||
&app,
|
||||
&hermes,
|
||||
&update_args,
|
||||
&install_root,
|
||||
&child_env,
|
||||
Some("update"),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let update_ms = started.elapsed().as_millis() as u64;
|
||||
|
||||
match update.exit_code {
|
||||
@@ -405,77 +366,18 @@ async fn wait_for_venv_free(install_root: &Path, app: &AppHandle) {
|
||||
return;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
// Last resort: a backend hermes.exe (or a grandchild it spawned)
|
||||
// is still holding the shim. The desktop should have reaped its
|
||||
// tree before handing off, but SIGTERM races / detached
|
||||
// grandchildren / AV handles can leave a straggler. Rather than
|
||||
// "proceed anyway" straight into uv's "Access is denied", force-kill
|
||||
// every hermes.exe except ourselves, then give the OS a beat to
|
||||
// unload the image.
|
||||
emit_log(
|
||||
app,
|
||||
Some("update"),
|
||||
LogStream::Stdout,
|
||||
"[update] Hermes still holding the venv shim; force-killing stragglers…",
|
||||
"[update] timed out waiting for Hermes to exit; proceeding anyway",
|
||||
);
|
||||
force_kill_other_hermes();
|
||||
tokio::time::sleep(Duration::from_millis(800)).await;
|
||||
if !is_locked(&shim) {
|
||||
emit_log(
|
||||
app,
|
||||
Some("update"),
|
||||
LogStream::Stdout,
|
||||
"[update] venv shim freed after force-kill",
|
||||
);
|
||||
} else {
|
||||
emit_log(
|
||||
app,
|
||||
Some("update"),
|
||||
LogStream::Stdout,
|
||||
"[update] venv shim still locked; proceeding (--force + quarantine will handle it)",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(DESKTOP_EXIT_POLL).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Force-kill any `hermes.exe` other than this process. Windows-only; a no-op
|
||||
/// elsewhere (POSIX has no mandatory-lock contention). We can't selectively
|
||||
/// target "the backend" by PID here — the desktop already exited and we never
|
||||
/// knew its children — so we kill the whole `hermes.exe` image tree via
|
||||
/// taskkill, excluding our own PID.
|
||||
///
|
||||
/// Safe w.r.t. our own update child: this runs inside `wait_for_venv_free`,
|
||||
/// which completes BEFORE we spawn `venv\Scripts\hermes.exe update`. At this
|
||||
/// point no update-driven hermes.exe exists yet, so the only hermes.exe images
|
||||
/// are stragglers from the old desktop — exactly what we want gone. (`/FI PID
|
||||
/// ne <self>` also spares this Tauri process, though it isn't named
|
||||
/// hermes.exe.)
|
||||
fn force_kill_other_hermes() {
|
||||
if !cfg!(target_os = "windows") {
|
||||
return;
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let my_pid = std::process::id();
|
||||
// /FI excludes our own PID; /T kills the tree; /F forces.
|
||||
let _ = std::process::Command::new("taskkill")
|
||||
.args([
|
||||
"/F",
|
||||
"/T",
|
||||
"/IM",
|
||||
"hermes.exe",
|
||||
"/FI",
|
||||
&format!("PID ne {my_pid}"),
|
||||
])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status();
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort lock probe: try to open the file for read+write. On Windows an
|
||||
/// exclusively-held running .exe refuses the open with a sharing violation.
|
||||
/// On Unix this almost always succeeds (no mandatory locking), which is fine —
|
||||
|
||||
@@ -24,6 +24,12 @@
|
||||
|
||||
### Install with Hermes (recommended)
|
||||
|
||||
Add `--include-desktop` to the [one-line installer](../../README.md#quick-install) and it sets up the agent and builds the desktop app in one go:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash -s -- --include-desktop
|
||||
```
|
||||
|
||||
Already have the Hermes CLI? Just run:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -18,24 +18,11 @@
|
||||
* this via the public `/api/status` field `auth_required: true`.
|
||||
*/
|
||||
|
||||
// Bare + prefixed variants of the session cookies the gateway may set,
|
||||
// Bare + prefixed variants of the access-token cookie the gateway may set,
|
||||
// depending on its deploy shape (HTTPS direct → __Host-, behind a path prefix
|
||||
// → __Secure-, loopback HTTP → bare). Mirrors
|
||||
// hermes_cli/dashboard_auth/cookies.py.
|
||||
//
|
||||
// Two cookies are in play (see that module):
|
||||
// - hermes_session_at: the OAuth access token. Short-lived (~15 min); its
|
||||
// Max-Age tracks the access-token TTL, so the cookie jar drops it the
|
||||
// instant the AT expires.
|
||||
// - hermes_session_rt: the OAuth refresh token. Long-lived (24h rotating,
|
||||
// reuse-detected — Portal NAS #293 / hermes #37247). When the AT cookie
|
||||
// has lapsed but the RT cookie is still present, the gateway middleware
|
||||
// transparently rotates a fresh AT on the next authenticated request
|
||||
// (POST /api/auth/ws-ticket), so the session is still LIVE even with no
|
||||
// AT cookie. A liveness check that looked only at the AT cookie would
|
||||
// force a needless full re-login every ~15 min — hence cookiesHaveLiveSession.
|
||||
const AT_COOKIE_VARIANTS = ['__Host-hermes_session_at', '__Secure-hermes_session_at', 'hermes_session_at']
|
||||
const RT_COOKIE_VARIANTS = ['__Host-hermes_session_rt', '__Secure-hermes_session_rt', 'hermes_session_rt']
|
||||
|
||||
function normalizeRemoteBaseUrl(rawUrl) {
|
||||
const value = String(rawUrl || '').trim()
|
||||
@@ -131,41 +118,6 @@ async function resolveTestWsUrl(baseUrl, authMode, token, deps = {}) {
|
||||
return buildGatewayWsUrl(baseUrl, token)
|
||||
}
|
||||
|
||||
// Normalize a profile name to a connection scope key, or null for the global
|
||||
// (default) connection. Shared by the resolver and the IPC layer.
|
||||
function connectionScopeKey(profile) {
|
||||
return String(profile ?? '').trim() || null
|
||||
}
|
||||
|
||||
// Coerce a remote auth mode to one of the two supported values ('token' default).
|
||||
function normAuthMode(mode) {
|
||||
return mode === 'oauth' ? 'oauth' : 'token'
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a profile's explicit remote override from a connection config, or null
|
||||
* when it has none (so the caller falls back to env → global remote → local).
|
||||
*
|
||||
* The config may carry a `profiles` map keyed by name; an entry counts as an
|
||||
* override only with `mode === 'remote'` and a non-empty `url`. Pure: `token`
|
||||
* is the raw stored secret; main.cjs decrypts it. Returns
|
||||
* `{ url, authMode, token } | null`.
|
||||
*/
|
||||
function profileRemoteOverride(config, profile) {
|
||||
const key = connectionScopeKey(profile)
|
||||
const entry = key ? config?.profiles?.[key] : null
|
||||
if (!entry || typeof entry !== 'object' || entry.mode !== 'remote') {
|
||||
return null
|
||||
}
|
||||
|
||||
const url = String(entry.url || '').trim()
|
||||
if (!url) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { url, authMode: normAuthMode(entry.authMode), token: entry.token }
|
||||
}
|
||||
|
||||
function tokenPreview(value) {
|
||||
const raw = String(value || '')
|
||||
|
||||
@@ -198,56 +150,22 @@ function resolveAuthMode(inputAuthMode, existingAuthMode) {
|
||||
}
|
||||
|
||||
/**
|
||||
* True if any cookie in `cookies` is a hermes session ACCESS-token cookie
|
||||
* True if any cookie in `cookies` is a hermes session access-token cookie
|
||||
* with a non-empty value. `cookies` is an array of {name, value} (the shape
|
||||
* Electron's session.cookies.get returns).
|
||||
*
|
||||
* Note: this is AT-only. A session whose AT cookie has lapsed but whose RT
|
||||
* cookie is still alive is STILL connectable (the gateway refreshes the AT on
|
||||
* the next request) — use `cookiesHaveLiveSession` for a connectivity/display
|
||||
* check. `cookiesHaveSession` remains exported for callers that specifically
|
||||
* need to know whether an unexpired access token is present right now.
|
||||
*/
|
||||
function cookiesHaveSession(cookies) {
|
||||
if (!Array.isArray(cookies)) return false
|
||||
return cookies.some(c => c && AT_COOKIE_VARIANTS.includes(c.name) && c.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* True if the cookie jar holds a credential that can yield an authenticated
|
||||
* request — EITHER a live access-token cookie OR a refresh-token cookie. The
|
||||
* RT cookie outlives the AT cookie (24h vs ~15min), and the gateway middleware
|
||||
* transparently rotates a fresh AT from the RT on the next authenticated
|
||||
* request. Gating connectivity on the AT alone would force a full IDP
|
||||
* re-login every ~15 min even though a valid 24h RT is sitting in the jar.
|
||||
*
|
||||
* This answers "should we even attempt to connect / show as signed in?", not
|
||||
* "is the access token unexpired?". The authoritative liveness check is still
|
||||
* the actual ws-ticket mint at connect time (which surfaces a true 401 when
|
||||
* the RT is also dead/revoked).
|
||||
*/
|
||||
function cookiesHaveLiveSession(cookies) {
|
||||
if (!Array.isArray(cookies)) return false
|
||||
return cookies.some(
|
||||
c =>
|
||||
c &&
|
||||
c.value &&
|
||||
(AT_COOKIE_VARIANTS.includes(c.name) || RT_COOKIE_VARIANTS.includes(c.name))
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AT_COOKIE_VARIANTS,
|
||||
RT_COOKIE_VARIANTS,
|
||||
authModeFromStatus,
|
||||
buildGatewayWsUrl,
|
||||
buildGatewayWsUrlWithTicket,
|
||||
connectionScopeKey,
|
||||
cookiesHaveSession,
|
||||
cookiesHaveLiveSession,
|
||||
normAuthMode,
|
||||
normalizeRemoteBaseUrl,
|
||||
profileRemoteOverride,
|
||||
resolveAuthMode,
|
||||
resolveTestWsUrl,
|
||||
tokenPreview
|
||||
|
||||
@@ -15,81 +15,16 @@ const assert = require('node:assert/strict')
|
||||
|
||||
const {
|
||||
AT_COOKIE_VARIANTS,
|
||||
RT_COOKIE_VARIANTS,
|
||||
authModeFromStatus,
|
||||
buildGatewayWsUrl,
|
||||
buildGatewayWsUrlWithTicket,
|
||||
connectionScopeKey,
|
||||
cookiesHaveSession,
|
||||
cookiesHaveLiveSession,
|
||||
normAuthMode,
|
||||
normalizeRemoteBaseUrl,
|
||||
profileRemoteOverride,
|
||||
resolveAuthMode,
|
||||
resolveTestWsUrl,
|
||||
tokenPreview
|
||||
} = require('./connection-config.cjs')
|
||||
|
||||
// --- connectionScopeKey / normAuthMode ---
|
||||
|
||||
test('connectionScopeKey trims to a name or null for the global scope', () => {
|
||||
assert.equal(connectionScopeKey(' coder '), 'coder')
|
||||
assert.equal(connectionScopeKey(''), null)
|
||||
assert.equal(connectionScopeKey(null), null)
|
||||
assert.equal(connectionScopeKey(undefined), null)
|
||||
})
|
||||
|
||||
test('normAuthMode coerces to token unless explicitly oauth', () => {
|
||||
assert.equal(normAuthMode('oauth'), 'oauth')
|
||||
assert.equal(normAuthMode('token'), 'token')
|
||||
assert.equal(normAuthMode(undefined), 'token')
|
||||
assert.equal(normAuthMode('weird'), 'token')
|
||||
})
|
||||
|
||||
// --- profileRemoteOverride ---
|
||||
|
||||
test('profileRemoteOverride returns null when no profile is given', () => {
|
||||
const config = { profiles: { coder: { mode: 'remote', url: 'https://x' } } }
|
||||
assert.equal(profileRemoteOverride(config, ''), null)
|
||||
assert.equal(profileRemoteOverride(config, null), null)
|
||||
assert.equal(profileRemoteOverride(config, undefined), null)
|
||||
})
|
||||
|
||||
test('profileRemoteOverride returns null when the profile has no entry', () => {
|
||||
const config = { profiles: { coder: { mode: 'remote', url: 'https://x' } } }
|
||||
assert.equal(profileRemoteOverride(config, 'writer'), null)
|
||||
})
|
||||
|
||||
test('profileRemoteOverride ignores local or url-less profile entries', () => {
|
||||
assert.equal(profileRemoteOverride({ profiles: { p: { mode: 'local', url: 'https://x' } } }, 'p'), null)
|
||||
assert.equal(profileRemoteOverride({ profiles: { p: { mode: 'remote', url: '' } } }, 'p'), null)
|
||||
assert.equal(profileRemoteOverride({ profiles: { p: { mode: 'remote' } } }, 'p'), null)
|
||||
})
|
||||
|
||||
test('profileRemoteOverride returns the per-profile remote with defaulted auth mode', () => {
|
||||
const config = {
|
||||
profiles: {
|
||||
coder: { mode: 'remote', url: ' https://coder.example.com/hermes ', token: { value: 'sek' } }
|
||||
}
|
||||
}
|
||||
assert.deepEqual(profileRemoteOverride(config, 'coder'), {
|
||||
url: 'https://coder.example.com/hermes',
|
||||
authMode: 'token',
|
||||
token: { value: 'sek' }
|
||||
})
|
||||
})
|
||||
|
||||
test('profileRemoteOverride preserves an explicit oauth auth mode', () => {
|
||||
const config = { profiles: { coder: { mode: 'remote', url: 'https://x', authMode: 'oauth' } } }
|
||||
assert.equal(profileRemoteOverride(config, 'coder').authMode, 'oauth')
|
||||
})
|
||||
|
||||
test('profileRemoteOverride tolerates a missing/!object profiles map', () => {
|
||||
assert.equal(profileRemoteOverride({}, 'coder'), null)
|
||||
assert.equal(profileRemoteOverride({ profiles: null }, 'coder'), null)
|
||||
assert.equal(profileRemoteOverride(null, 'coder'), null)
|
||||
})
|
||||
|
||||
// --- normalizeRemoteBaseUrl ---
|
||||
|
||||
test('normalizeRemoteBaseUrl strips trailing slashes, hash, and query', () => {
|
||||
@@ -196,10 +131,7 @@ test('cookiesHaveSession is false for an empty value', () => {
|
||||
assert.equal(cookiesHaveSession([{ name: 'hermes_session_at', value: '' }]), false)
|
||||
})
|
||||
|
||||
test('cookiesHaveSession ignores unrelated cookies (AT-only by design)', () => {
|
||||
// cookiesHaveSession is deliberately access-token-only — a lone RT cookie
|
||||
// is NOT an access token, so this returns false. Connectivity callers must
|
||||
// use cookiesHaveLiveSession instead (see below).
|
||||
test('cookiesHaveSession ignores unrelated cookies', () => {
|
||||
assert.equal(cookiesHaveSession([{ name: 'hermes_session_rt', value: 'x' }]), false)
|
||||
assert.equal(cookiesHaveSession([{ name: 'other', value: 'x' }]), false)
|
||||
})
|
||||
@@ -214,56 +146,6 @@ test('AT_COOKIE_VARIANTS covers all three deploy shapes', () => {
|
||||
assert.deepEqual(AT_COOKIE_VARIANTS, ['__Host-hermes_session_at', '__Secure-hermes_session_at', 'hermes_session_at'])
|
||||
})
|
||||
|
||||
test('RT_COOKIE_VARIANTS covers all three deploy shapes', () => {
|
||||
assert.deepEqual(RT_COOKIE_VARIANTS, ['__Host-hermes_session_rt', '__Secure-hermes_session_rt', 'hermes_session_rt'])
|
||||
})
|
||||
|
||||
// --- cookiesHaveLiveSession (AT or RT — the connectivity check) ---
|
||||
|
||||
test('cookiesHaveLiveSession is true for a live access-token cookie', () => {
|
||||
assert.equal(cookiesHaveLiveSession([{ name: 'hermes_session_at', value: 'x' }]), true)
|
||||
assert.equal(cookiesHaveLiveSession([{ name: '__Host-hermes_session_at', value: 'x' }]), true)
|
||||
assert.equal(cookiesHaveLiveSession([{ name: '__Secure-hermes_session_at', value: 'x' }]), true)
|
||||
})
|
||||
|
||||
test('cookiesHaveLiveSession is true for an RT cookie even with NO access-token cookie', () => {
|
||||
// This is the bug-fix case: the AT cookie has lapsed (dropped from the jar)
|
||||
// but the 24h RT cookie is still alive. The session is still connectable —
|
||||
// the gateway rotates a fresh AT from the RT on the next request.
|
||||
assert.equal(cookiesHaveLiveSession([{ name: 'hermes_session_rt', value: 'x' }]), true)
|
||||
assert.equal(cookiesHaveLiveSession([{ name: '__Host-hermes_session_rt', value: 'x' }]), true)
|
||||
assert.equal(cookiesHaveLiveSession([{ name: '__Secure-hermes_session_rt', value: 'x' }]), true)
|
||||
})
|
||||
|
||||
test('cookiesHaveLiveSession is true when both AT and RT are present', () => {
|
||||
assert.equal(
|
||||
cookiesHaveLiveSession([
|
||||
{ name: 'hermes_session_at', value: 'a' },
|
||||
{ name: 'hermes_session_rt', value: 'r' }
|
||||
]),
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('cookiesHaveLiveSession is false for empty values', () => {
|
||||
assert.equal(cookiesHaveLiveSession([{ name: 'hermes_session_at', value: '' }]), false)
|
||||
assert.equal(cookiesHaveLiveSession([{ name: 'hermes_session_rt', value: '' }]), false)
|
||||
assert.equal(
|
||||
cookiesHaveLiveSession([
|
||||
{ name: 'hermes_session_at', value: '' },
|
||||
{ name: 'hermes_session_rt', value: '' }
|
||||
]),
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
test('cookiesHaveLiveSession is false for unrelated cookies and non-arrays', () => {
|
||||
assert.equal(cookiesHaveLiveSession([{ name: 'other', value: 'x' }]), false)
|
||||
assert.equal(cookiesHaveLiveSession(null), false)
|
||||
assert.equal(cookiesHaveLiveSession(undefined), false)
|
||||
assert.equal(cookiesHaveLiveSession([]), false)
|
||||
})
|
||||
|
||||
// --- tokenPreview ---
|
||||
|
||||
test('tokenPreview returns null for empty', () => {
|
||||
|
||||
+94
-537
@@ -32,12 +32,8 @@ const {
|
||||
authModeFromStatus,
|
||||
buildGatewayWsUrl,
|
||||
buildGatewayWsUrlWithTicket,
|
||||
connectionScopeKey,
|
||||
cookiesHaveSession,
|
||||
cookiesHaveLiveSession,
|
||||
normAuthMode,
|
||||
normalizeRemoteBaseUrl,
|
||||
profileRemoteOverride,
|
||||
resolveAuthMode,
|
||||
resolveTestWsUrl,
|
||||
tokenPreview
|
||||
@@ -1313,111 +1309,6 @@ function resolveUpdaterBinary() {
|
||||
return fileExists(candidate) ? candidate : null
|
||||
}
|
||||
|
||||
// Path to the venv shim whose lock decides whether `hermes update` can write
|
||||
// fresh entry points. On Windows this is the file the running backend
|
||||
// `hermes.exe` holds open; on POSIX it's never mandatory-locked.
|
||||
function venvHermesShimPath(updateRoot) {
|
||||
return IS_WINDOWS
|
||||
? path.join(updateRoot, 'venv', 'Scripts', 'hermes.exe')
|
||||
: path.join(updateRoot, 'venv', 'bin', 'hermes')
|
||||
}
|
||||
|
||||
// Best-effort lock probe mirroring the Rust updater's is_locked(): a running
|
||||
// .exe on Windows refuses an O_RDWR open with a sharing violation. On POSIX
|
||||
// this practically always succeeds (no mandatory locking), so it returns false
|
||||
// — correct, since the shim-contention brick is Windows-only.
|
||||
function isShimLocked(shimPath) {
|
||||
if (!IS_WINDOWS) return false
|
||||
let fd
|
||||
try {
|
||||
fd = fs.openSync(shimPath, 'r+')
|
||||
return false
|
||||
} catch (err) {
|
||||
// ENOENT ⇒ not there ⇒ nothing locking it. Anything else (EBUSY/EPERM/
|
||||
// EACCES) on Windows means a live handle holds it.
|
||||
return err && err.code !== 'ENOENT'
|
||||
} finally {
|
||||
if (fd !== undefined) {
|
||||
try {
|
||||
fs.closeSync(fd)
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Force-kill the entire process TREE rooted at each PID. Node's child.kill()
|
||||
// only signals the direct child, so on Windows a backend `hermes.exe` that
|
||||
// spawned its own grandchildren (a `hermes` REPL, a pty terminal session, the
|
||||
// gateway) would survive and keep the venv shim locked. taskkill /T /F reaps
|
||||
// the whole tree synchronously. Windows-only: this is called solely from the
|
||||
// Windows shim-unlock path, and the backend is NOT spawned detached (so it's
|
||||
// not a process-group leader — a POSIX negative-pgid kill would be meaningless
|
||||
// here anyway). POSIX teardown stays with the existing before-quit SIGTERM.
|
||||
function forceKillProcessTree(pid) {
|
||||
if (!IS_WINDOWS) return
|
||||
if (!Number.isInteger(pid) || pid <= 0) return
|
||||
try {
|
||||
execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' })
|
||||
} catch {
|
||||
// Already gone, or no permission — best effort; the unlock wait below is
|
||||
// the real gate.
|
||||
}
|
||||
}
|
||||
|
||||
// Before handing off the update on Windows, the desktop MUST stop every backend
|
||||
// it spawned and WAIT for the venv shim to actually unlock. The old code did
|
||||
// `hermesProcess.kill('SIGTERM')` + `app.quit()` fire-and-forget: SIGTERM on
|
||||
// Windows doesn't reap the backend's grandchildren, and quit didn't wait for
|
||||
// teardown, so the updater raced a still-locked `hermes.exe`, the quarantine
|
||||
// rename failed, uv's `pip install` hit "Access is denied", and the git path
|
||||
// bailed into a full ZIP re-download that ALSO couldn't write the locked shim —
|
||||
// a half-applied install (ryanc's update.log). Here we tree-kill the primary +
|
||||
// pool backends and poll the shim until it's writable (or a bounded timeout),
|
||||
// so by the time we spawn the updater the lock is genuinely gone.
|
||||
//
|
||||
// Windows-only: the venv-shim mandatory lock is a Windows phenomenon. On
|
||||
// macOS/Linux there's no REPLACE-on-running-exe block, the existing before-quit
|
||||
// SIGTERM + app.quit() teardown already works (the macOS path is flawless), and
|
||||
// aggressively SIGKILL-ing the backend here would be an untested behavior change
|
||||
// for no benefit. So we no-op off Windows and leave that path exactly as it was.
|
||||
async function releaseBackendLockForUpdate(updateRoot) {
|
||||
if (!IS_WINDOWS) return { unlocked: true }
|
||||
|
||||
// Collect every backend PID the desktop owns: primary window backend + pool.
|
||||
const pids = []
|
||||
if (hermesProcess && Number.isInteger(hermesProcess.pid)) pids.push(hermesProcess.pid)
|
||||
for (const entry of backendPool.values()) {
|
||||
if (entry.process && Number.isInteger(entry.process.pid)) pids.push(entry.process.pid)
|
||||
}
|
||||
|
||||
// Graceful first (lets Python flush), then tree-kill to catch grandchildren.
|
||||
if (hermesProcess && !hermesProcess.killed) {
|
||||
try {
|
||||
hermesProcess.kill('SIGTERM')
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
stopAllPoolBackends()
|
||||
for (const pid of pids) forceKillProcessTree(pid)
|
||||
|
||||
const shim = venvHermesShimPath(updateRoot)
|
||||
const deadlineMs = Date.now() + 15000
|
||||
while (Date.now() < deadlineMs) {
|
||||
if (!isShimLocked(shim)) {
|
||||
rememberLog('[updates] venv shim unlocked; safe to hand off the update')
|
||||
return { unlocked: true }
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
}
|
||||
// Timed out: the updater's own wait_for_venv_free + force-kill is the second
|
||||
// line of defense, and we pass --force so the guard won't dead-end. Log it.
|
||||
rememberLog('[updates] venv shim still locked after 15s; handing off anyway (updater will force)')
|
||||
return { unlocked: false }
|
||||
}
|
||||
|
||||
// applyUpdates — hand off to the installer's --update flow, then exit.
|
||||
//
|
||||
// The desktop is a pure consumer: it does NOT git pull / pip install / rebuild
|
||||
@@ -1484,12 +1375,6 @@ async function applyUpdates(opts = {}) {
|
||||
}
|
||||
const venvBin = path.join(updateRoot, 'venv', IS_WINDOWS ? 'Scripts' : 'bin')
|
||||
|
||||
// Stop our own backend(s) and wait for the venv shim to unlock BEFORE we
|
||||
// spawn the updater. Without this the updater races a still-locked
|
||||
// hermes.exe (held by the backend child / its grandchildren) and the update
|
||||
// bricks. See releaseBackendLockForUpdate for the full failure analysis.
|
||||
await releaseBackendLockForUpdate(updateRoot)
|
||||
|
||||
// Detached so the updater outlives this process — it needs us GONE before
|
||||
// `hermes update` will run (the venv shim is locked while we live).
|
||||
const child = spawn(updater, updaterArgs, {
|
||||
@@ -3282,16 +3167,8 @@ function installMediaPermissions() {
|
||||
// * WebSocket upgrades require a single-use ``?ticket=`` minted at
|
||||
// ``POST /api/auth/ws-ticket`` (cookie-authed). The legacy ``?token=``
|
||||
// path is unconditionally rejected by gated gateways.
|
||||
// * Nous Portal now issues a 24h ROTATING, reuse-detected refresh token
|
||||
// alongside the ~15-min access token (Portal NAS #293 / hermes #37247).
|
||||
// Both are set as HttpOnly cookies (``hermes_session_at`` ~15 min,
|
||||
// ``hermes_session_rt`` 24h). When the AT cookie lapses but the RT cookie
|
||||
// is still alive, the gateway middleware transparently rotates a fresh AT
|
||||
// on the next authenticated request — so connectivity must NOT be gated on
|
||||
// the AT cookie alone. We probe liveness by actually minting a ws-ticket
|
||||
// (which triggers that server-side refresh) and treat a real 401 as
|
||||
// "needs re-login"; the AT-or-RT cookie presence check is only a cheap
|
||||
// "is the user signed in at all?" gate / display signal.
|
||||
// * Nous Portal contract v1 issues NO refresh token; the access cookie has
|
||||
// a ~15-min TTL. On 401 we must re-run the login round trip.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const OAUTH_SESSION_PARTITION = 'persist:hermes-remote-oauth'
|
||||
@@ -3302,9 +3179,8 @@ function getOauthSession() {
|
||||
return oauthSession
|
||||
}
|
||||
|
||||
// Bare + prefixed variants of the session cookies live in
|
||||
// connection-config.cjs (cookiesHaveSession / cookiesHaveLiveSession). See
|
||||
// that module for details.
|
||||
// Bare + prefixed variants of the access-token cookie live in
|
||||
// connection-config.cjs (cookiesHaveSession). See that module for details.
|
||||
|
||||
async function hasOauthSessionCookie(baseUrl) {
|
||||
const sess = getOauthSession()
|
||||
@@ -3325,30 +3201,6 @@ async function hasOauthSessionCookie(baseUrl) {
|
||||
}
|
||||
}
|
||||
|
||||
// Like hasOauthSessionCookie, but returns true when EITHER a live access-token
|
||||
// cookie OR a (longer-lived) refresh-token cookie is present. This is the right
|
||||
// "is the user signed in at all?" check: an expired AT with a live RT is still
|
||||
// a connectable session because the gateway rotates a fresh AT server-side on
|
||||
// the next authenticated request. Gating on the AT alone forces a needless full
|
||||
// re-login every ~15 min. Used for the Settings "connected" indicator and as a
|
||||
// cheap early-out before attempting a network round-trip in resolveRemoteBackend.
|
||||
async function hasLiveOauthSession(baseUrl) {
|
||||
const sess = getOauthSession()
|
||||
if (!sess) return false
|
||||
const parsed = new URL(baseUrl)
|
||||
try {
|
||||
const cookies = await sess.cookies.get({ url: baseUrl })
|
||||
return cookiesHaveLiveSession(cookies)
|
||||
} catch {
|
||||
try {
|
||||
const cookies = await sess.cookies.get({ domain: parsed.hostname })
|
||||
return cookiesHaveLiveSession(cookies)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function clearOauthSession(baseUrl) {
|
||||
const sess = getOauthSession()
|
||||
if (!sess) return
|
||||
@@ -3595,38 +3447,6 @@ function decryptDesktopSecret(secret) {
|
||||
return value
|
||||
}
|
||||
|
||||
// Validate + normalize the per-profile remote overrides map read from disk.
|
||||
// Drops malformed names/entries and keeps only the recognized fields so a
|
||||
// hand-edited or stale connection.json can't inject junk into resolution.
|
||||
function sanitizeConnectionProfiles(raw) {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return {}
|
||||
}
|
||||
|
||||
const out = {}
|
||||
for (const [name, entry] of Object.entries(raw)) {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
continue
|
||||
}
|
||||
if (name !== 'default' && !PROFILE_NAME_RE.test(name)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const cleaned = { mode: entry.mode === 'remote' ? 'remote' : 'local' }
|
||||
const url = String(entry.url || '').trim()
|
||||
if (url) {
|
||||
cleaned.url = url
|
||||
}
|
||||
cleaned.authMode = normAuthMode(entry.authMode)
|
||||
if (entry.token && typeof entry.token === 'object') {
|
||||
cleaned.token = entry.token
|
||||
}
|
||||
out[name] = cleaned
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
function readDesktopConnectionConfig() {
|
||||
// Check if file changed on disk since last read (e.g. modified by another
|
||||
// process or an external tool). Our own writes update the cache inline
|
||||
@@ -3642,7 +3462,7 @@ function readDesktopConnectionConfig() {
|
||||
return connectionConfigCache
|
||||
}
|
||||
|
||||
let config = { mode: 'local', remote: {}, profiles: {} }
|
||||
let config = { mode: 'local', remote: {} }
|
||||
|
||||
try {
|
||||
const raw = fs.readFileSync(DESKTOP_CONNECTION_CONFIG_PATH, 'utf8')
|
||||
@@ -3656,11 +3476,7 @@ function readDesktopConnectionConfig() {
|
||||
remote.authMode = remote.authMode === 'oauth' ? 'oauth' : 'token'
|
||||
config = {
|
||||
mode: parsed.mode === 'remote' ? 'remote' : 'local',
|
||||
remote,
|
||||
// Per-profile remote overrides: each profile may point at its own
|
||||
// backend (local spawn or its own remote URL). Preserved verbatim so
|
||||
// profileRemoteOverride() can resolve them; normalized lazily on save.
|
||||
profiles: sanitizeConnectionProfiles(parsed.profiles)
|
||||
remote
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -3712,114 +3528,104 @@ function writeActiveDesktopProfile(name) {
|
||||
return value || null
|
||||
}
|
||||
|
||||
// Sanitize a connection config into the renderer-facing shape. With no
|
||||
// `profile` this describes the global/default connection (the existing
|
||||
// behavior); with a `profile` it describes that profile's per-profile remote
|
||||
// override (or an empty "local/inherit" view when the profile has none).
|
||||
async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionConfig(), profile = null) {
|
||||
const key = connectionScopeKey(profile)
|
||||
const scoped = key ? config.profiles?.[key] || null : null
|
||||
const block = key ? scoped || {} : config.remote || {}
|
||||
|
||||
const remoteToken = decryptDesktopSecret(block.token)
|
||||
const authMode = normAuthMode(block.authMode)
|
||||
const remoteUrl = String(block.url || '')
|
||||
const mode = (key ? scoped?.mode : config.mode) === 'remote' ? 'remote' : 'local'
|
||||
async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionConfig()) {
|
||||
const remoteToken = decryptDesktopSecret(config.remote?.token)
|
||||
const authMode = config.remote?.authMode === 'oauth' ? 'oauth' : 'token'
|
||||
const remoteUrl = String(config.remote?.url || '')
|
||||
|
||||
let remoteOauthConnected = false
|
||||
if (authMode === 'oauth' && remoteUrl) {
|
||||
try {
|
||||
// Display signal: treat a live RT cookie as "connected" even if the AT
|
||||
// cookie has lapsed — the gateway refreshes the AT on the next request,
|
||||
// so the session is still usable. The authoritative liveness check is
|
||||
// the ws-ticket mint in resolveRemoteBackend at actual connect time.
|
||||
remoteOauthConnected = await hasLiveOauthSession(remoteUrl)
|
||||
remoteOauthConnected = await hasOauthSessionCookie(remoteUrl)
|
||||
} catch {
|
||||
remoteOauthConnected = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
mode,
|
||||
// Echo the scope back so the UI knows which profile (if any) this reflects.
|
||||
profile: key,
|
||||
mode: config.mode === 'remote' ? 'remote' : 'local',
|
||||
remoteAuthMode: authMode,
|
||||
remoteOauthConnected,
|
||||
remoteUrl,
|
||||
remoteTokenPreview: tokenPreview(remoteToken),
|
||||
remoteTokenSet: Boolean(remoteToken),
|
||||
// The env override only forces the global/primary connection; a per-profile
|
||||
// scope is never overridden by HERMES_DESKTOP_REMOTE_URL.
|
||||
envOverride: key ? false : Boolean(process.env.HERMES_DESKTOP_REMOTE_URL)
|
||||
envOverride: Boolean(process.env.HERMES_DESKTOP_REMOTE_URL)
|
||||
}
|
||||
}
|
||||
|
||||
// Build + validate a `{ url, authMode, token }` remote block. OAuth gateways
|
||||
// authenticate via the login-window session cookie (verified at connect time in
|
||||
// resolveRemoteBackend), so only token-auth remotes require a saved token.
|
||||
function buildRemoteBlock(remoteUrl, authMode, token) {
|
||||
if (authMode !== 'oauth' && !decryptDesktopSecret(token)) {
|
||||
throw new Error('Remote gateway session token is required.')
|
||||
}
|
||||
return { url: normalizeRemoteBaseUrl(remoteUrl), authMode, token }
|
||||
}
|
||||
|
||||
function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnectionConfig(), options = {}) {
|
||||
const persistToken = options.persistToken !== false
|
||||
const key = connectionScopeKey(input.profile)
|
||||
const mode = input.mode === 'remote' ? 'remote' : 'local'
|
||||
|
||||
// The block being edited: a per-profile entry or the global remote block.
|
||||
const existingBlock = key ? existing.profiles?.[key] || {} : existing.remote || {}
|
||||
const remoteUrl = String(input.remoteUrl ?? existingBlock.url ?? '').trim()
|
||||
const remoteUrl = String(input.remoteUrl ?? existing.remote?.url ?? '').trim()
|
||||
// authMode: explicit input wins; otherwise inherit the saved value, default 'token'.
|
||||
const authMode = resolveAuthMode(input.remoteAuthMode, existingBlock.authMode)
|
||||
const authMode = resolveAuthMode(input.remoteAuthMode, existing.remote?.authMode)
|
||||
const incomingToken = typeof input.remoteToken === 'string' ? input.remoteToken.trim() : ''
|
||||
const nextToken = incomingToken
|
||||
? persistToken
|
||||
? encryptDesktopSecret(incomingToken)
|
||||
: { encoding: 'plain', value: incomingToken }
|
||||
: existingBlock.token
|
||||
|
||||
if (key) {
|
||||
// Per-profile scope: a remote entry pins this profile to its own backend; a
|
||||
// local entry clears the override so the profile inherits the default.
|
||||
const profiles = { ...(existing.profiles || {}) }
|
||||
if (mode === 'remote') {
|
||||
profiles[key] = { mode: 'remote', ...buildRemoteBlock(remoteUrl, authMode, nextToken) }
|
||||
} else {
|
||||
delete profiles[key]
|
||||
}
|
||||
return { mode: existing.mode === 'remote' ? 'remote' : 'local', remote: existing.remote || {}, profiles }
|
||||
const existingToken = existing.remote?.token
|
||||
const nextRemote = {
|
||||
url: remoteUrl,
|
||||
authMode,
|
||||
token: incomingToken
|
||||
? persistToken
|
||||
? encryptDesktopSecret(incomingToken)
|
||||
: { encoding: 'plain', value: incomingToken }
|
||||
: existingToken
|
||||
}
|
||||
|
||||
const nextRemote =
|
||||
mode === 'remote'
|
||||
? buildRemoteBlock(remoteUrl, authMode, nextToken)
|
||||
: { url: remoteUrl ? normalizeRemoteBaseUrl(remoteUrl) : remoteUrl, authMode, token: nextToken }
|
||||
if (mode === 'remote') {
|
||||
nextRemote.url = normalizeRemoteBaseUrl(remoteUrl)
|
||||
|
||||
// Preserve per-profile overrides when saving the global connection.
|
||||
return { mode, remote: nextRemote, profiles: existing.profiles || {} }
|
||||
// OAuth gateways authenticate via the session cookie established by the
|
||||
// login window, NOT a static token — so no token is required here. The
|
||||
// cookie presence is verified at connect time (resolveRemoteBackend).
|
||||
if (authMode !== 'oauth' && !decryptDesktopSecret(nextRemote.token)) {
|
||||
throw new Error('Remote gateway session token is required.')
|
||||
}
|
||||
} else if (remoteUrl) {
|
||||
nextRemote.url = normalizeRemoteBaseUrl(remoteUrl)
|
||||
}
|
||||
|
||||
return { mode, remote: nextRemote }
|
||||
}
|
||||
|
||||
// Build a remote backend connection descriptor from an already-resolved remote
|
||||
// config. Handles both auth models (OAuth ws-ticket vs static session token)
|
||||
// and is shared by the per-profile, env, and global resolution paths. `token`
|
||||
// is the DECRYPTED static token (or null in OAuth mode). `source` is a label
|
||||
// for diagnostics ('profile' | 'env' | 'settings').
|
||||
async function buildRemoteConnection(rawUrl, authMode, token, source) {
|
||||
const baseUrl = normalizeRemoteBaseUrl(rawUrl)
|
||||
async function resolveRemoteBackend() {
|
||||
const rawEnvUrl = process.env.HERMES_DESKTOP_REMOTE_URL
|
||||
const rawEnvToken = process.env.HERMES_DESKTOP_REMOTE_TOKEN
|
||||
|
||||
if (rawEnvUrl) {
|
||||
if (!rawEnvToken) {
|
||||
throw new Error(
|
||||
'HERMES_DESKTOP_REMOTE_URL is set but HERMES_DESKTOP_REMOTE_TOKEN is not. ' +
|
||||
'Both must be provided to connect to a remote Hermes backend.'
|
||||
)
|
||||
}
|
||||
|
||||
const baseUrl = normalizeRemoteBaseUrl(rawEnvUrl)
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
mode: 'remote',
|
||||
source: 'env',
|
||||
authMode: 'token',
|
||||
token: rawEnvToken,
|
||||
wsUrl: buildGatewayWsUrl(baseUrl, rawEnvToken)
|
||||
}
|
||||
}
|
||||
|
||||
const config = readDesktopConnectionConfig()
|
||||
|
||||
if (config.mode !== 'remote') {
|
||||
return null
|
||||
}
|
||||
|
||||
const baseUrl = normalizeRemoteBaseUrl(config.remote?.url)
|
||||
const authMode = config.remote?.authMode === 'oauth' ? 'oauth' : 'token'
|
||||
|
||||
if (authMode === 'oauth') {
|
||||
// OAuth gateway: auth comes from the session cookies in the OAuth
|
||||
// partition. Liveness is NOT "is the access-token cookie present?" —
|
||||
// Portal issues a 24h rotating refresh token (hermes #37247), and the
|
||||
// gateway middleware transparently rotates a fresh ~15-min access token
|
||||
// from it on the next authenticated request. So a session with an expired
|
||||
// AT cookie but a live RT cookie is still perfectly connectable. We
|
||||
// early-out only when neither cookie is present, then mint a ws-ticket as
|
||||
// the authoritative liveness check.
|
||||
if (!(await hasLiveOauthSession(baseUrl))) {
|
||||
// OAuth gateway: auth comes from the session cookie in the OAuth partition.
|
||||
// Verify the cookie is present, then mint a single-use WS ticket (the
|
||||
// gateway rejects ?token= in gated mode). A missing cookie / 401 means the
|
||||
// user needs to (re-)log in via Settings → Gateway.
|
||||
if (!(await hasOauthSessionCookie(baseUrl))) {
|
||||
const err = new Error(
|
||||
'Remote Hermes gateway uses OAuth, but you are not signed in. ' +
|
||||
'Open Settings → Gateway and click "Sign in", or switch back to Local.'
|
||||
@@ -3843,7 +3649,7 @@ async function buildRemoteConnection(rawUrl, authMode, token, source) {
|
||||
return {
|
||||
baseUrl,
|
||||
mode: 'remote',
|
||||
source,
|
||||
source: 'settings',
|
||||
authMode: 'oauth',
|
||||
// No static token in OAuth mode; REST is cookie-authed via the partition.
|
||||
token: null,
|
||||
@@ -3851,6 +3657,8 @@ async function buildRemoteConnection(rawUrl, authMode, token, source) {
|
||||
}
|
||||
}
|
||||
|
||||
const token = decryptDesktopSecret(config.remote?.token)
|
||||
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
'Remote Hermes gateway is selected, but no session token is saved. ' +
|
||||
@@ -3861,92 +3669,13 @@ async function buildRemoteConnection(rawUrl, authMode, token, source) {
|
||||
return {
|
||||
baseUrl,
|
||||
mode: 'remote',
|
||||
source,
|
||||
source: 'settings',
|
||||
authMode: 'token',
|
||||
token,
|
||||
wsUrl: buildGatewayWsUrl(baseUrl, token)
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the remote backend for a given profile, or null when that profile
|
||||
// should run a LOCAL backend. Precedence:
|
||||
// 1. explicit per-profile remote override (connection.json `profiles[name]`)
|
||||
// 2. env override (HERMES_DESKTOP_REMOTE_URL/_TOKEN) — applies app-wide
|
||||
// 3. global remote (connection.json `mode: 'remote'`)
|
||||
// A null/empty profile resolves the env/global remote, so legacy callers and
|
||||
// the connection test (which pass no profile) are unchanged.
|
||||
async function resolveRemoteBackend(profile) {
|
||||
const config = readDesktopConnectionConfig()
|
||||
|
||||
// 1. Per-profile override — "a profile with its own remote host". Wins even
|
||||
// over the env override so an explicitly-configured profile always
|
||||
// reaches its intended backend.
|
||||
const override = profileRemoteOverride(config, profile)
|
||||
if (override) {
|
||||
const token = override.authMode === 'oauth' ? null : decryptDesktopSecret(override.token)
|
||||
return buildRemoteConnection(override.url, override.authMode, token, 'profile')
|
||||
}
|
||||
|
||||
// 2. Env override (global, token-auth only).
|
||||
const rawEnvUrl = process.env.HERMES_DESKTOP_REMOTE_URL
|
||||
const rawEnvToken = process.env.HERMES_DESKTOP_REMOTE_TOKEN
|
||||
if (rawEnvUrl) {
|
||||
if (!rawEnvToken) {
|
||||
throw new Error(
|
||||
'HERMES_DESKTOP_REMOTE_URL is set but HERMES_DESKTOP_REMOTE_TOKEN is not. ' +
|
||||
'Both must be provided to connect to a remote Hermes backend.'
|
||||
)
|
||||
}
|
||||
return buildRemoteConnection(rawEnvUrl, 'token', rawEnvToken, 'env')
|
||||
}
|
||||
|
||||
// 3. Global remote.
|
||||
if (config.mode !== 'remote') {
|
||||
return null
|
||||
}
|
||||
const authMode = normAuthMode(config.remote?.authMode)
|
||||
const token = authMode === 'oauth' ? null : decryptDesktopSecret(config.remote?.token)
|
||||
return buildRemoteConnection(config.remote?.url, authMode, token, 'settings')
|
||||
}
|
||||
|
||||
// A remote profile's sessions live on its remote host's state.db, not on a local
|
||||
// file the primary can open — so reads for it must route to the remote backend,
|
||||
// not the local-disk fast path. These three helpers drive that (see
|
||||
// interceptSessionReadForRemote).
|
||||
function profileHasRemoteOverride(profile) {
|
||||
return Boolean(profileRemoteOverride(readDesktopConnectionConfig(), profile))
|
||||
}
|
||||
|
||||
function configuredRemoteProfileNames() {
|
||||
const config = readDesktopConnectionConfig()
|
||||
return Object.keys(config.profiles || {}).filter(name => profileRemoteOverride(config, name))
|
||||
}
|
||||
|
||||
// True when the app is in app-global remote mode (Settings → "All profiles" →
|
||||
// Remote, or the env override): a SINGLE remote backend serves every profile via
|
||||
// ?profile=. Distinct from per-profile overrides — here there's one host for all.
|
||||
function globalRemoteActive() {
|
||||
if (process.env.HERMES_DESKTOP_REMOTE_URL) {
|
||||
return true
|
||||
}
|
||||
return readDesktopConnectionConfig().mode === 'remote'
|
||||
}
|
||||
|
||||
// GET a profile's resolved backend (remote pool or local primary), parsed JSON.
|
||||
async function fetchJsonForProfile(profile, path) {
|
||||
return requestJsonForProfile(profile, path, 'GET')
|
||||
}
|
||||
|
||||
// Issue an arbitrary method against a profile's resolved backend, parsed JSON.
|
||||
async function requestJsonForProfile(profile, path, method, body) {
|
||||
const conn = await ensureBackend(profile)
|
||||
const url = `${conn.baseUrl}${path}`
|
||||
const opts = { method, body, timeoutMs: DEFAULT_FETCH_TIMEOUT_MS }
|
||||
return conn.authMode === 'oauth'
|
||||
? fetchJsonViaOauthSession(url, opts)
|
||||
: fetchJson(url, conn.token, opts)
|
||||
}
|
||||
|
||||
async function probeRemoteAuthMode(rawUrl) {
|
||||
// Determine how a remote gateway expects callers to authenticate, WITHOUT
|
||||
// sending any credentials. ``/api/status`` is public on every Hermes
|
||||
@@ -4014,11 +3743,6 @@ async function probeRemoteAuthMode(rawUrl) {
|
||||
|
||||
async function testDesktopConnectionConfig(input = {}) {
|
||||
const config = coerceDesktopConnectionConfig(input, readDesktopConnectionConfig(), { persistToken: false })
|
||||
const key = connectionScopeKey(input.profile)
|
||||
// The block under test: a per-profile entry or the global remote. Coerce has
|
||||
// already normalized the URL and resolved token inheritance for the scope.
|
||||
const block = key ? config.profiles?.[key] || null : config.remote
|
||||
const wantRemote = block?.mode === 'remote' || (!key && config.mode === 'remote') || (input.mode === 'remote' && block)
|
||||
// ``/api/status`` is public on every gateway (no creds needed), so a
|
||||
// reachability test works for local, token, and oauth modes alike — we only
|
||||
// need a base URL. For a remote config we normalize the URL from the input;
|
||||
@@ -4026,17 +3750,17 @@ async function testDesktopConnectionConfig(input = {}) {
|
||||
let baseUrl
|
||||
let token = null
|
||||
let authMode = 'token'
|
||||
if (wantRemote && block?.url) {
|
||||
baseUrl = normalizeRemoteBaseUrl(block.url)
|
||||
authMode = normAuthMode(block.authMode)
|
||||
if (config.mode === 'remote') {
|
||||
baseUrl = normalizeRemoteBaseUrl(config.remote.url)
|
||||
authMode = config.remote.authMode === 'oauth' ? 'oauth' : 'token'
|
||||
if (authMode !== 'oauth') {
|
||||
token = decryptDesktopSecret(block.token)
|
||||
token = decryptDesktopSecret(config.remote.token)
|
||||
}
|
||||
} else {
|
||||
const remote = (await resolveRemoteBackend(key)) || (await startHermes())
|
||||
const remote = (await resolveRemoteBackend()) || (await startHermes())
|
||||
baseUrl = remote.baseUrl
|
||||
token = remote.token
|
||||
authMode = normAuthMode(remote.authMode)
|
||||
authMode = remote.authMode === 'oauth' ? 'oauth' : 'token'
|
||||
}
|
||||
const status = await fetchJson(`${baseUrl}/api/status`, token, { timeoutMs: 8_000 })
|
||||
|
||||
@@ -4208,21 +3932,10 @@ function startPoolIdleReaper() {
|
||||
// local-spawn portion of startHermes() but without the boot-progress UI,
|
||||
// bootstrap, or remote handling (those belong to the primary backend only).
|
||||
async function spawnPoolBackend(profile, entry) {
|
||||
// A profile may point at its OWN remote backend (connection.json
|
||||
// `profiles[name]`), or inherit the app-wide remote (env / global settings).
|
||||
// In either case there is no local child to spawn — we just verify the
|
||||
// remote is reachable and hand back its connection descriptor. The pool
|
||||
// entry keeps `entry.process === null`, which stopPoolBackend/evict already
|
||||
// tolerate.
|
||||
const remote = await resolveRemoteBackend(profile)
|
||||
// Remote deployments are single-tenant; profiles only apply to local backends.
|
||||
const remote = await resolveRemoteBackend()
|
||||
if (remote) {
|
||||
await waitForHermes(remote.baseUrl, remote.token)
|
||||
return {
|
||||
...remote,
|
||||
profile,
|
||||
logs: hermesLog.slice(-80),
|
||||
...getWindowState()
|
||||
}
|
||||
throw new Error('Profiles are unavailable when connected to a remote Hermes backend.')
|
||||
}
|
||||
|
||||
const port = await pickPort()
|
||||
@@ -4323,9 +4036,7 @@ async function startHermes() {
|
||||
|
||||
connectionPromise = (async () => {
|
||||
await advanceBootProgress('backend.resolve', 'Resolving Hermes backend', 8)
|
||||
// Resolve for the desktop's primary profile so a per-profile remote
|
||||
// override on the active profile is honored (falls back to env / global).
|
||||
const remote = await resolveRemoteBackend(primaryProfileKey())
|
||||
const remote = await resolveRemoteBackend()
|
||||
if (remote) {
|
||||
await advanceBootProgress('backend.remote', `Connecting to remote Hermes backend at ${remote.baseUrl}`, 24)
|
||||
await waitForHermes(remote.baseUrl, remote.token)
|
||||
@@ -4662,9 +4373,7 @@ ipcMain.handle('hermes:bootstrap:cancel', async () => {
|
||||
})
|
||||
ipcMain.handle('hermes:boot-progress:get', async () => bootProgressState)
|
||||
ipcMain.handle('hermes:bootstrap:get', async () => getBootstrapState())
|
||||
ipcMain.handle('hermes:connection-config:get', async (_event, profile) =>
|
||||
sanitizeDesktopConnectionConfig(readDesktopConnectionConfig(), profile)
|
||||
)
|
||||
ipcMain.handle('hermes:connection-config:get', async () => sanitizeDesktopConnectionConfig())
|
||||
ipcMain.handle('hermes:connection-config:test', async (_event, payload) => testDesktopConnectionConfig(payload))
|
||||
ipcMain.handle('hermes:connection-config:probe', async (_event, rawUrl) => probeRemoteAuthMode(rawUrl))
|
||||
ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) => {
|
||||
@@ -4679,36 +4388,22 @@ ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) =>
|
||||
ipcMain.handle('hermes:connection-config:oauth-logout', async (_event, rawUrl) => {
|
||||
const baseUrl = rawUrl ? normalizeRemoteBaseUrl(rawUrl) : ''
|
||||
await clearOauthSession(baseUrl || undefined)
|
||||
// Report against the SAME liveness notion the Settings indicator uses
|
||||
// (AT-or-RT) so a logout that left any session cookie behind is reflected
|
||||
// as still-connected rather than silently signed-out.
|
||||
return { ok: true, connected: baseUrl ? await hasLiveOauthSession(baseUrl) : false }
|
||||
return { ok: true, connected: baseUrl ? await hasOauthSessionCookie(baseUrl) : false }
|
||||
})
|
||||
ipcMain.handle('hermes:connection-config:save', async (_event, payload) => {
|
||||
const config = coerceDesktopConnectionConfig(payload)
|
||||
writeDesktopConnectionConfig(config)
|
||||
|
||||
return sanitizeDesktopConnectionConfig(config, payload?.profile)
|
||||
return sanitizeDesktopConnectionConfig(config)
|
||||
})
|
||||
ipcMain.handle('hermes:connection-config:apply', async (_event, payload) => {
|
||||
const config = coerceDesktopConnectionConfig(payload)
|
||||
writeDesktopConnectionConfig(config)
|
||||
|
||||
const key = connectionScopeKey(payload?.profile)
|
||||
await teardownPrimaryBackendAndWait()
|
||||
|
||||
if (key && key !== primaryProfileKey()) {
|
||||
// Editing a NON-primary profile's connection: don't disturb the window's
|
||||
// primary backend. Drop the profile's pooled backend so the next switch
|
||||
// re-resolves against the new remote/local target.
|
||||
stopPoolBackend(key)
|
||||
} else {
|
||||
// Global connection, or the primary profile's connection: re-home the
|
||||
// window backend by tearing it down and reloading the renderer.
|
||||
await teardownPrimaryBackendAndWait()
|
||||
mainWindow?.reload()
|
||||
}
|
||||
|
||||
return sanitizeDesktopConnectionConfig(config, payload?.profile)
|
||||
mainWindow?.reload()
|
||||
return sanitizeDesktopConnectionConfig(config)
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:profile:get', async () => ({ profile: readActiveDesktopProfile() }))
|
||||
@@ -4736,145 +4431,7 @@ ipcMain.handle('hermes:requestMicrophoneAccess', async () => {
|
||||
return systemPreferences.askForMediaAccess('microphone')
|
||||
})
|
||||
|
||||
// Re-route remote-profile session requests to the owning remote backend. Returns
|
||||
// `undefined` when not interceptable (caller takes the normal local path), else
|
||||
// the response. Reads tag the profile as ?profile=<name>; mutations carry it in
|
||||
// request.profile. Either way, a remote profile's session lives only on its
|
||||
// remote host, so the request must go there (where it serves its own state.db).
|
||||
// GET /api/profiles/sessions → splice each remote profile's rows in
|
||||
// GET /api/sessions/{id}[/messages] → read from remote
|
||||
// DELETE /api/sessions/{id} → delete on remote
|
||||
// PATCH /api/sessions/{id} → rename/archive on remote
|
||||
async function interceptSessionRequestForRemote(request) {
|
||||
if (typeof request?.path !== 'string') {
|
||||
return undefined
|
||||
}
|
||||
const method = (request.method || 'GET').toUpperCase()
|
||||
|
||||
let parsed
|
||||
try {
|
||||
parsed = new URL(request.path, 'http://x')
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
const { pathname, searchParams } = parsed
|
||||
|
||||
if (method === 'GET' && pathname === '/api/profiles/sessions') {
|
||||
const remoteProfiles = configuredRemoteProfileNames()
|
||||
if (remoteProfiles.length === 0) {
|
||||
return undefined // no remote profiles → local fast path
|
||||
}
|
||||
const requested = (searchParams.get('profile') || 'all').trim() || 'all'
|
||||
if (requested !== 'all') {
|
||||
return profileHasRemoteOverride(requested) ? remoteSessionList(requested, searchParams) : undefined
|
||||
}
|
||||
return mergeRemoteProfileSessions(searchParams, remoteProfiles)
|
||||
}
|
||||
|
||||
// Per-session read/mutation. Owner is in ?profile= (reads) or request.profile
|
||||
// (mutations). Two remote shapes:
|
||||
// - per-profile override: route to that profile's own remote, sans profile
|
||||
// param (it serves its own state.db natively).
|
||||
// - global remote mode: ONE backend serves every profile via ?profile=, so
|
||||
// route there and KEEP the profile param so it opens the right state.db.
|
||||
if (/^\/api\/sessions\/[^/]+(\/messages)?$/.test(pathname)) {
|
||||
const profile = (searchParams.get('profile') || request.profile || '').trim()
|
||||
if (!profile) {
|
||||
return undefined
|
||||
}
|
||||
if (profileHasRemoteOverride(profile)) {
|
||||
if (method === 'GET') {
|
||||
return fetchJsonForProfile(profile, pathname)
|
||||
}
|
||||
const body = request.body && typeof request.body === 'object' ? { ...request.body } : request.body
|
||||
if (body) delete body.profile
|
||||
return requestJsonForProfile(profile, pathname, method, body)
|
||||
}
|
||||
if (globalRemoteActive()) {
|
||||
// Single global backend: keep ?profile= so it opens the right state.db.
|
||||
const sep = pathname.includes('?') ? '&' : '?'
|
||||
const path = `${pathname}${sep}profile=${encodeURIComponent(profile)}`
|
||||
if (method === 'GET') {
|
||||
return fetchJsonForProfile(null, path)
|
||||
}
|
||||
const body = request.body && typeof request.body === 'object' ? { ...request.body, profile } : { profile }
|
||||
return requestJsonForProfile(null, path, method, body)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
const rowsOf = data => (Array.isArray(data?.sessions) ? data.sessions : [])
|
||||
|
||||
// A remote profile's session list, read from its remote host and tagged with the
|
||||
// desktop-facing profile name (the remote's /api/sessions doesn't know it).
|
||||
async function remoteSessionList(profile, searchParams) {
|
||||
const qs = new URLSearchParams(searchParams)
|
||||
qs.delete('profile') // remote serves its own db; no cross-profile read there
|
||||
const data = await fetchJsonForProfile(profile, `/api/sessions?${qs}`)
|
||||
for (const s of rowsOf(data)) {
|
||||
s.profile = profile
|
||||
s.is_default_profile = false
|
||||
}
|
||||
return { ...data, sessions: rowsOf(data) }
|
||||
}
|
||||
|
||||
// Unified list: primary's local aggregate, with each remote profile's stale local
|
||||
// rows/totals swapped for the remote's real ones, re-sorted by recency and
|
||||
// re-windowed to the requested page. A dead remote contributes nothing rather
|
||||
// than breaking the sidebar.
|
||||
async function mergeRemoteProfileSessions(searchParams, remoteProfiles) {
|
||||
const limit = Math.max(1, Number(searchParams.get('limit')) || 20)
|
||||
const offset = Math.max(0, Number(searchParams.get('offset')) || 0)
|
||||
const order = searchParams.get('order') === 'created' ? 'started_at' : 'last_active'
|
||||
|
||||
const primary = await ensureBackend(null)
|
||||
const base = await fetchJson(`${primary.baseUrl}/api/profiles/sessions?${searchParams}`, primary.token, {
|
||||
method: 'GET',
|
||||
timeoutMs: DEFAULT_FETCH_TIMEOUT_MS
|
||||
}).catch(() => ({ sessions: [], total: 0, profile_totals: {} }))
|
||||
|
||||
// Over-fetch each remote from offset 0 (limit+offset rows) so the merged window
|
||||
// is correct for this page — mirrors the primary's per-profile over-fetch.
|
||||
const remoteParams = new URLSearchParams(searchParams)
|
||||
remoteParams.set('limit', String(limit + offset))
|
||||
remoteParams.set('offset', '0')
|
||||
|
||||
const remoteSet = new Set(remoteProfiles)
|
||||
const merged = rowsOf(base).filter(s => !remoteSet.has(s?.profile))
|
||||
const profileTotals = { ...(base.profile_totals || {}) }
|
||||
let total = (Number(base.total) || 0) - remoteProfiles.reduce((n, p) => n + (profileTotals[p] || 0), 0)
|
||||
|
||||
// Swap each remote profile's stale local rows/total for the remote's real ones.
|
||||
await Promise.all(remoteProfiles.map(async name => {
|
||||
const list = await remoteSessionList(name, remoteParams).catch(() => null)
|
||||
if (!list) {
|
||||
delete profileTotals[name] // dead remote → drop its stale local total too
|
||||
return
|
||||
}
|
||||
const rows = rowsOf(list)
|
||||
merged.push(...rows)
|
||||
profileTotals[name] = Number(list.total) || rows.length
|
||||
total += profileTotals[name]
|
||||
}))
|
||||
|
||||
const recency = s => s?.[order] ?? s?.started_at ?? 0
|
||||
merged.sort((a, b) => recency(b) - recency(a))
|
||||
return { ...base, sessions: merged.slice(offset, offset + limit), total, profile_totals: profileTotals }
|
||||
}
|
||||
|
||||
ipcMain.handle('hermes:api', async (_event, request) => {
|
||||
// Remote-profile session requests would otherwise hit the local primary off
|
||||
// each profile's on-disk state.db — fine for local profiles, but a remote
|
||||
// profile's sessions live on its remote host, so the UI's IDs 404 (or mutations
|
||||
// no-op) the moment they run there. Route reads + mutations to the remote.
|
||||
const rerouted = await interceptSessionRequestForRemote(request)
|
||||
if (rerouted !== undefined) {
|
||||
return rerouted
|
||||
}
|
||||
|
||||
const connection = await ensureBackend(request?.profile)
|
||||
const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
|
||||
const url = `${connection.baseUrl}${request.path}`
|
||||
|
||||
@@ -5,7 +5,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile),
|
||||
getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile),
|
||||
getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'),
|
||||
getConnectionConfig: profile => ipcRenderer.invoke('hermes:connection-config:get', profile),
|
||||
getConnectionConfig: () => ipcRenderer.invoke('hermes:connection-config:get'),
|
||||
saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload),
|
||||
applyConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:apply', payload),
|
||||
testConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:test', payload),
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
"react": "^19.2.5",
|
||||
"react-arborist": "^3.5.0",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.17.0",
|
||||
"react-router-dom": "^7.14.2",
|
||||
"react-shiki": "^0.9.3",
|
||||
"remark-math": "^6.0.0",
|
||||
"shiki": "^4.0.2",
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useElapsedSeconds } from '@/components/chat/activity-timer'
|
||||
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
|
||||
import { BrailleSpinner } from '@/components/ui/braille-spinner'
|
||||
import { FadeText } from '@/components/ui/fade-text'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { AlertCircle, CheckCircle2, Sparkles } from '@/lib/icons'
|
||||
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -22,11 +21,11 @@ import { OverlayView } from '../overlays/overlay-view'
|
||||
|
||||
// Mirrors statusGlyph() in tool-fallback.tsx so subagent rows speak the
|
||||
// same visual vocabulary as the chat tool blocks.
|
||||
function statusGlyph(status: SubagentStatus, a: Translations['agents']): ReactNode {
|
||||
function statusGlyph(status: SubagentStatus): ReactNode {
|
||||
if (status === 'running' || status === 'queued') {
|
||||
return (
|
||||
<BrailleSpinner
|
||||
ariaLabel={a.running}
|
||||
ariaLabel="Running"
|
||||
className="size-3.5 shrink-0 text-[0.95rem] text-muted-foreground/80"
|
||||
spinner="breathe"
|
||||
/>
|
||||
@@ -34,10 +33,10 @@ function statusGlyph(status: SubagentStatus, a: Translations['agents']): ReactNo
|
||||
}
|
||||
|
||||
if (status === 'failed' || status === 'interrupted') {
|
||||
return <AlertCircle aria-label={a.failed} className="size-3.5 shrink-0 text-destructive" />
|
||||
return <AlertCircle aria-label="Failed" className="size-3.5 shrink-0 text-destructive" />
|
||||
}
|
||||
|
||||
return <CheckCircle2 aria-label={a.done} className="size-3.5 shrink-0 text-emerald-600/85 dark:text-emerald-400/85" />
|
||||
return <CheckCircle2 aria-label="Done" className="size-3.5 shrink-0 text-emerald-600/85 dark:text-emerald-400/85" />
|
||||
}
|
||||
|
||||
const STREAM_TONE: Record<SubagentStreamEntry['kind'], string> = {
|
||||
@@ -76,7 +75,6 @@ interface AgentsViewProps {
|
||||
}
|
||||
|
||||
export function AgentsView({ onClose }: AgentsViewProps) {
|
||||
const { t } = useI18n()
|
||||
const activeSessionId = useStore($activeSessionId)
|
||||
const subagentsBySession = useStore($subagentsBySession)
|
||||
|
||||
@@ -89,61 +87,61 @@ export function AgentsView({ onClose }: AgentsViewProps) {
|
||||
|
||||
return (
|
||||
<OverlayView
|
||||
closeLabel={t.agents.close}
|
||||
closeLabel="Close agents"
|
||||
contentClassName="px-5 pt-5 pb-4 sm:px-6"
|
||||
onClose={onClose}
|
||||
rootClassName="mx-auto max-w-3xl"
|
||||
>
|
||||
<header className="mb-3 shrink-0">
|
||||
<h2 className="text-sm font-semibold text-foreground">{t.agents.title}</h2>
|
||||
<p className="text-xs text-muted-foreground/80">{t.agents.subtitle}</p>
|
||||
<h2 className="text-sm font-semibold text-foreground">Spawn tree</h2>
|
||||
<p className="text-xs text-muted-foreground/80">Live subagent activity for the current turn.</p>
|
||||
</header>
|
||||
<SubagentTree tree={tree} />
|
||||
</OverlayView>
|
||||
)
|
||||
}
|
||||
|
||||
const fmtDuration = (seconds: number | undefined, a: Translations['agents']) => {
|
||||
const fmtDuration = (seconds?: number) => {
|
||||
if (!seconds || seconds <= 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (seconds < 60) {
|
||||
return a.durationSeconds(seconds.toFixed(1))
|
||||
return `${seconds.toFixed(1)}s`
|
||||
}
|
||||
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.round(seconds % 60)
|
||||
|
||||
return a.durationMinutes(m, s)
|
||||
return `${m}m ${s}s`
|
||||
}
|
||||
|
||||
const fmtTokens = (value: number | undefined, a: Translations['agents']) => {
|
||||
const fmtTokens = (value?: number) => {
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return value >= 1000 ? a.tokensK((value / 1000).toFixed(1)) : a.tokens(value)
|
||||
return value >= 1000 ? `${(value / 1000).toFixed(1)}k tok` : `${value} tok`
|
||||
}
|
||||
|
||||
const fmtAge = (updatedAt: number, nowMs: number, a: Translations['agents']) => {
|
||||
const fmtAge = (updatedAt: number, nowMs: number) => {
|
||||
const s = Math.max(0, Math.round((nowMs - updatedAt) / 1000))
|
||||
|
||||
if (s < 2) {
|
||||
return a.ageNow
|
||||
return 'now'
|
||||
}
|
||||
|
||||
if (s < 60) {
|
||||
return a.ageSeconds(s)
|
||||
return `${s}s ago`
|
||||
}
|
||||
|
||||
const m = Math.floor(s / 60)
|
||||
|
||||
if (m < 60) {
|
||||
return a.ageMinutes(m)
|
||||
return `${m}m ago`
|
||||
}
|
||||
|
||||
return a.ageHours(Math.floor(m / 60))
|
||||
return `${Math.floor(m / 60)}h ago`
|
||||
}
|
||||
|
||||
const flatten = (nodes: readonly SubagentNode[]): SubagentNode[] =>
|
||||
@@ -151,7 +149,7 @@ const flatten = (nodes: readonly SubagentNode[]): SubagentNode[] =>
|
||||
|
||||
interface RootGroup {
|
||||
id: string
|
||||
delegationIndex: number
|
||||
label: string
|
||||
nodes: SubagentNode[]
|
||||
taskCount: number
|
||||
}
|
||||
@@ -175,19 +173,18 @@ function groupDelegations(roots: readonly SubagentNode[]): RootGroup[] {
|
||||
|
||||
if (node.taskCount > 1) {
|
||||
n += 1
|
||||
groups.push({ id: `delegation-${n}`, delegationIndex: n, nodes: [node], taskCount: node.taskCount })
|
||||
groups.push({ id: `delegation-${n}`, label: `Delegation ${n}`, nodes: [node], taskCount: node.taskCount })
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
groups.push({ id: node.id, delegationIndex: 0, nodes: [node], taskCount: node.taskCount })
|
||||
groups.push({ id: node.id, label: '', nodes: [node], taskCount: node.taskCount })
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
function SubagentTree({ tree }: { tree: SubagentNode[] }) {
|
||||
const { t } = useI18n()
|
||||
const flat = useMemo(() => flatten(tree), [tree])
|
||||
const groups = useMemo(() => groupDelegations(tree), [tree])
|
||||
const [nowMs, setNowMs] = useState(() => Date.now())
|
||||
@@ -213,19 +210,21 @@ function SubagentTree({ tree }: { tree: SubagentNode[] }) {
|
||||
return (
|
||||
<div className="grid place-items-center gap-3 py-12 text-center">
|
||||
<Sparkles className="size-6 text-muted-foreground/60" />
|
||||
<p className="text-sm font-medium text-foreground/90">{t.agents.emptyTitle}</p>
|
||||
<p className="max-w-md text-xs leading-relaxed text-muted-foreground/75">{t.agents.emptyDesc}</p>
|
||||
<p className="text-sm font-medium text-foreground/90">No live subagents</p>
|
||||
<p className="max-w-md text-xs leading-relaxed text-muted-foreground/75">
|
||||
When a turn delegates work, child agents stream their progress here.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const summary = [
|
||||
t.agents.agentsCount(flat.length),
|
||||
active > 0 ? t.agents.activeCount(active) : '',
|
||||
failed > 0 ? t.agents.failedCount(failed) : '',
|
||||
tools > 0 ? t.agents.toolsCount(tools) : '',
|
||||
files > 0 ? t.agents.filesCount(files) : '',
|
||||
tokens > 0 ? fmtTokens(tokens, t.agents) : '',
|
||||
`${flat.length} ${flat.length === 1 ? 'agent' : 'agents'}`,
|
||||
active > 0 ? `${active} active` : '',
|
||||
failed > 0 ? `${failed} failed` : '',
|
||||
tools > 0 ? `${tools} tools` : '',
|
||||
files > 0 ? `${files} files` : '',
|
||||
tokens > 0 ? fmtTokens(tokens) : '',
|
||||
cost > 0 ? `$${cost.toFixed(2)}` : ''
|
||||
].filter(Boolean)
|
||||
|
||||
@@ -244,8 +243,6 @@ function SubagentTree({ tree }: { tree: SubagentNode[] }) {
|
||||
}
|
||||
|
||||
function DelegationGroup({ group, nowMs }: { group: RootGroup; nowMs: number }) {
|
||||
const { t } = useI18n()
|
||||
|
||||
if (group.nodes.length === 1 && group.taskCount <= 1) {
|
||||
return <SubagentRow node={group.nodes[0]!} nowMs={nowMs} />
|
||||
}
|
||||
@@ -255,9 +252,8 @@ function DelegationGroup({ group, nowMs }: { group: RootGroup; nowMs: number })
|
||||
return (
|
||||
<section className="grid min-w-0 gap-3">
|
||||
<p className="text-[0.66rem] font-medium uppercase tracking-wider text-muted-foreground/70">
|
||||
{group.delegationIndex > 0 ? t.agents.delegation(group.delegationIndex) : ''}{' '}
|
||||
<span className="text-muted-foreground/50">·</span> {t.agents.workers(group.nodes.length)}
|
||||
{activeWorkers > 0 ? <span className="text-primary/85"> · {t.agents.workersActive(activeWorkers)}</span> : null}
|
||||
{group.label} <span className="text-muted-foreground/50">·</span> {group.nodes.length} workers
|
||||
{activeWorkers > 0 ? <span className="text-primary/85"> · {activeWorkers} active</span> : null}
|
||||
</p>
|
||||
<div className="grid min-w-0 gap-4">
|
||||
{group.nodes.map(node => (
|
||||
@@ -279,7 +275,6 @@ function StreamLine({
|
||||
parentRunning: boolean
|
||||
rowKey: string
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const enterRef = useEnterAnimation(parentRunning, `subagent-stream:${rowKey}`)
|
||||
const isMono = entry.kind === 'tool'
|
||||
const tone = entry.isError ? 'text-destructive' : STREAM_TONE[entry.kind]
|
||||
@@ -291,7 +286,7 @@ function StreamLine({
|
||||
{entry.text}
|
||||
{active ? (
|
||||
<BrailleSpinner
|
||||
ariaLabel={t.agents.streaming}
|
||||
ariaLabel="Streaming"
|
||||
className="ml-1 inline-block size-2.5 align-middle text-muted-foreground/70"
|
||||
spinner="breathe"
|
||||
/>
|
||||
@@ -302,7 +297,6 @@ function StreamLine({
|
||||
}
|
||||
|
||||
function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: number; nowMs: number }) {
|
||||
const { t } = useI18n()
|
||||
const running = node.status === 'running' || node.status === 'queued'
|
||||
const elapsed = useElapsedSeconds(running, `subagent:${node.id}`)
|
||||
|
||||
@@ -323,10 +317,10 @@ function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: n
|
||||
|
||||
const subtitle = [
|
||||
node.model,
|
||||
fmtDuration(durationSeconds, t.agents),
|
||||
node.toolCount ? t.agents.toolsCount(node.toolCount) : '',
|
||||
fmtTokens((node.inputTokens ?? 0) + (node.outputTokens ?? 0), t.agents),
|
||||
t.agents.updatedAgo(fmtAge(node.updatedAt, nowMs, t.agents))
|
||||
fmtDuration(durationSeconds),
|
||||
node.toolCount ? `${node.toolCount} tools` : '',
|
||||
fmtTokens((node.inputTokens ?? 0) + (node.outputTokens ?? 0)),
|
||||
`updated ${fmtAge(node.updatedAt, nowMs)}`
|
||||
].filter(Boolean)
|
||||
|
||||
return (
|
||||
@@ -337,7 +331,7 @@ function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: n
|
||||
onClick={() => setOpen(v => !v)}
|
||||
type="button"
|
||||
>
|
||||
<span className="mt-0.5 flex h-[1.1rem] shrink-0 items-center">{statusGlyph(node.status, t.agents)}</span>
|
||||
<span className="mt-0.5 flex h-[1.1rem] shrink-0 items-center">{statusGlyph(node.status)}</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span
|
||||
className={cn(
|
||||
@@ -372,7 +366,7 @@ function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: n
|
||||
|
||||
{open && fileLines.length > 0 ? (
|
||||
<div className="grid min-w-0 gap-0.5 pl-6">
|
||||
<p className="text-[0.58rem] font-medium tracking-wider text-muted-foreground/60 uppercase">{t.agents.files}</p>
|
||||
<p className="text-[0.58rem] font-medium tracking-wider text-muted-foreground/60 uppercase">Files</p>
|
||||
{fileLines.slice(0, 8).map(line => (
|
||||
<p className="wrap-break-word font-mono text-[0.67rem] leading-relaxed text-muted-foreground/80" key={line}>
|
||||
{line}
|
||||
@@ -380,7 +374,7 @@ function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: n
|
||||
))}
|
||||
{fileLines.length > 8 ? (
|
||||
<p className="font-mono text-[0.67rem] leading-relaxed text-muted-foreground/65">
|
||||
{t.agents.moreFiles(fileLines.length - 8)}
|
||||
+{fileLines.length - 8} more files
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useNavigate } from 'react-router-dom'
|
||||
import { ZoomableImage } from '@/components/chat/zoomable-image'
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import {
|
||||
Pagination,
|
||||
@@ -19,7 +18,6 @@ import {
|
||||
import { TextTab, TextTabMeta } from '@/components/ui/text-tab'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { getSessionMessages, listSessions } from '@/hermes'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link'
|
||||
import { FileImage, FileText, FolderOpen, Link2 } from '@/lib/icons'
|
||||
@@ -313,15 +311,15 @@ function formatArtifactTime(timestamp: number): string {
|
||||
return ARTIFACT_TIME_FMT.format(new Date(timestamp))
|
||||
}
|
||||
|
||||
function pageRangeLabel(total: number, page: number, pageSize: number, a: Translations['artifacts']): string {
|
||||
function pageRangeLabel(total: number, page: number, pageSize: number): string {
|
||||
if (total === 0) {
|
||||
return a.zero
|
||||
return '0'
|
||||
}
|
||||
|
||||
const start = (page - 1) * pageSize + 1
|
||||
const end = Math.min(total, page * pageSize)
|
||||
|
||||
return a.rangeOf(start, end, total)
|
||||
return `${start}-${end} of ${total}`
|
||||
}
|
||||
|
||||
function paginationItems(page: number, pageCount: number): Array<number | 'ellipsis'> {
|
||||
@@ -358,25 +356,21 @@ type CellCtx = {
|
||||
interface ArtifactColumn {
|
||||
Cell: (props: { artifact: ArtifactRecord; ctx: CellCtx }) => React.ReactElement
|
||||
bodyClassName: string
|
||||
header: (filter: ArtifactFilter, a: Translations['artifacts']) => string
|
||||
header: (filter: ArtifactFilter) => string
|
||||
id: 'location' | 'primary' | 'session'
|
||||
width: (filter: ArtifactFilter) => string
|
||||
}
|
||||
|
||||
const itemsLabel = (f: ArtifactFilter, a: Translations['artifacts']) =>
|
||||
f === 'link' ? a.itemsLink : f === 'file' ? a.itemsFile : a.itemsGeneric
|
||||
const itemsLabel = (f: ArtifactFilter) => (f === 'link' ? 'links' : f === 'file' ? 'files' : 'items')
|
||||
|
||||
interface ArtifactsViewProps extends React.ComponentProps<'section'> {
|
||||
setStatusbarItemGroup?: SetStatusbarItemGroup
|
||||
}
|
||||
|
||||
export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: ArtifactsViewProps) {
|
||||
const { t } = useI18n()
|
||||
const a = t.artifacts
|
||||
const navigate = useNavigate()
|
||||
const [artifacts, setArtifacts] = useState<ArtifactRecord[] | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const [kindFilter, setKindFilter] = useRouteEnumParam('tab', ARTIFACT_FILTERS, 'all')
|
||||
|
||||
@@ -385,8 +379,6 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
const [filePage, setFilePage] = useState(1)
|
||||
|
||||
const refreshArtifacts = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
|
||||
try {
|
||||
const sessions = (await listSessions(30, 1)).sessions
|
||||
const results = await Promise.allSettled(sessions.map(session => getSessionMessages(session.id)))
|
||||
@@ -401,14 +393,12 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
nextArtifacts.push(...collectArtifactsForSession(session, result.value.messages))
|
||||
})
|
||||
|
||||
setArtifacts(nextArtifacts.sort((left, right) => right.timestamp - left.timestamp))
|
||||
setArtifacts(nextArtifacts.sort((a, b) => b.timestamp - a.timestamp))
|
||||
} catch (err) {
|
||||
notifyError(err, a.failedLoad)
|
||||
notifyError(err, 'Artifacts failed to load')
|
||||
setArtifacts([])
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [a])
|
||||
}, [])
|
||||
|
||||
useRefreshHotkey(refreshArtifacts)
|
||||
|
||||
@@ -489,9 +479,9 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
window.open(href, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
} catch (err) {
|
||||
notifyError(err, a.openFailed)
|
||||
notifyError(err, 'Open failed')
|
||||
}
|
||||
}, [a])
|
||||
}, [])
|
||||
|
||||
const markImageFailed = useCallback((id: string) => {
|
||||
setFailedImageIds(current => {
|
||||
@@ -513,46 +503,34 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
{...props}
|
||||
onSearchChange={setQuery}
|
||||
searchHidden={counts.all === 0}
|
||||
searchPlaceholder={a.search}
|
||||
searchTrailingAction={
|
||||
<Button
|
||||
aria-label={refreshing ? a.refreshing : a.refresh}
|
||||
className="text-(--ui-text-tertiary) hover:bg-transparent hover:text-foreground"
|
||||
disabled={refreshing}
|
||||
onClick={() => void refreshArtifacts()}
|
||||
size="icon-xs"
|
||||
title={refreshing ? a.refreshing : a.refresh}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="refresh" size="0.875rem" spinning={refreshing} />
|
||||
</Button>
|
||||
}
|
||||
searchPlaceholder="Search artifacts..."
|
||||
searchValue={query}
|
||||
tabs={
|
||||
<>
|
||||
<TextTab active={kindFilter === 'all'} onClick={() => setKindFilter('all')}>
|
||||
{a.tabAll} <TextTabMeta>({counts.all})</TextTabMeta>
|
||||
All <TextTabMeta>({counts.all})</TextTabMeta>
|
||||
</TextTab>
|
||||
<TextTab active={kindFilter === 'image'} onClick={() => setKindFilter('image')}>
|
||||
{a.tabImages} <TextTabMeta>({counts.image})</TextTabMeta>
|
||||
Images <TextTabMeta>({counts.image})</TextTabMeta>
|
||||
</TextTab>
|
||||
<TextTab active={kindFilter === 'file'} onClick={() => setKindFilter('file')}>
|
||||
{a.tabFiles} <TextTabMeta>({counts.file})</TextTabMeta>
|
||||
Files <TextTabMeta>({counts.file})</TextTabMeta>
|
||||
</TextTab>
|
||||
<TextTab active={kindFilter === 'link'} onClick={() => setKindFilter('link')}>
|
||||
{a.tabLinks} <TextTabMeta>({counts.link})</TextTabMeta>
|
||||
Links <TextTabMeta>({counts.link})</TextTabMeta>
|
||||
</TextTab>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{!artifacts ? (
|
||||
<PageLoader label={a.indexing} />
|
||||
<PageLoader label="Indexing recent session artifacts" />
|
||||
) : visibleArtifacts.length === 0 ? (
|
||||
<div className="grid h-full place-items-center px-6 text-center">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{a.noArtifactsTitle}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{a.noArtifactsDesc}</div>
|
||||
<div className="text-sm font-medium">No artifacts found</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
Generated images and file outputs will appear here as sessions produce them.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -569,7 +547,7 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
>
|
||||
<ArtifactsPagination
|
||||
className="ml-auto justify-end px-0"
|
||||
itemLabel={a.itemsImage}
|
||||
itemLabel="images"
|
||||
onPageChange={setImagePage}
|
||||
page={currentImagePage}
|
||||
pageSize={24}
|
||||
@@ -601,7 +579,7 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
>
|
||||
<ArtifactsPagination
|
||||
className="ml-auto justify-end px-0"
|
||||
itemLabel={itemsLabel(kindFilter, a)}
|
||||
itemLabel={itemsLabel(kindFilter)}
|
||||
onPageChange={setFilePage}
|
||||
page={currentFilePage}
|
||||
pageSize={100}
|
||||
@@ -630,14 +608,12 @@ interface ArtifactsPaginationProps {
|
||||
}
|
||||
|
||||
function ArtifactsPagination({ className, itemLabel, onPageChange, page, pageSize, total }: ArtifactsPaginationProps) {
|
||||
const { t } = useI18n()
|
||||
const a = t.artifacts
|
||||
const pageCount = Math.max(1, Math.ceil(total / pageSize))
|
||||
|
||||
return (
|
||||
<div className={cn('flex h-6 items-center justify-between gap-2 px-1', className)}>
|
||||
<div className="shrink-0 text-[0.62rem] text-muted-foreground">
|
||||
{pageRangeLabel(total, page, pageSize, a)} {itemLabel}
|
||||
{pageRangeLabel(total, page, pageSize)} {itemLabel}
|
||||
</div>
|
||||
{pageCount > 1 && (
|
||||
<Pagination className="mx-0 w-auto min-w-0 justify-end">
|
||||
@@ -651,7 +627,7 @@ function ArtifactsPagination({ className, itemLabel, onPageChange, page, pageSiz
|
||||
<PaginationEllipsis />
|
||||
) : (
|
||||
<PaginationButton
|
||||
aria-label={a.goToPage(itemLabel, item)}
|
||||
aria-label={`Go to ${itemLabel} page ${item}`}
|
||||
isActive={page === item}
|
||||
onClick={() => onPageChange(item)}
|
||||
>
|
||||
@@ -681,10 +657,6 @@ interface ArtifactImageCardProps {
|
||||
}
|
||||
|
||||
function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }: ArtifactImageCardProps) {
|
||||
const { t } = useI18n()
|
||||
const a = t.artifacts
|
||||
const kindLabel = artifact.kind === 'image' ? a.kindImage : artifact.kind === 'file' ? a.kindFile : a.kindLink
|
||||
|
||||
return (
|
||||
<article className="group/artifact overflow-hidden rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background)">
|
||||
<div
|
||||
@@ -711,7 +683,7 @@ function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }:
|
||||
<div className="min-w-0">
|
||||
<div className="mb-0.5 flex items-center gap-1 text-[0.625rem] uppercase tracking-[0.08em] text-(--ui-text-tertiary)">
|
||||
<FileImage className="size-3" />
|
||||
{kindLabel}
|
||||
{artifact.kind}
|
||||
</div>
|
||||
<div className="truncate text-[length:var(--conversation-caption-font-size)] font-medium">
|
||||
{artifact.label}
|
||||
@@ -726,7 +698,7 @@ function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }:
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Button onClick={() => onOpenChat(artifact.sessionId)} size="xs" type="button" variant="textStrong">
|
||||
<FolderOpen className="size-3" />
|
||||
{a.chat}
|
||||
Chat
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -796,10 +768,9 @@ function PrimaryCell({ artifact, ctx }: { artifact: ArtifactRecord; ctx: CellCtx
|
||||
}
|
||||
|
||||
function LocationCell({ artifact }: { artifact: ArtifactRecord; ctx: CellCtx }) {
|
||||
const { t } = useI18n()
|
||||
const isLink = artifact.kind === 'link'
|
||||
const value = isLink ? hostPathLabel(artifact.value) : artifact.value
|
||||
const copyLabel = isLink ? t.artifacts.copyUrl : t.artifacts.copyPath
|
||||
const copyLabel = isLink ? 'Copy URL' : 'Copy path'
|
||||
|
||||
return (
|
||||
<div className="group/location flex min-w-0 items-center gap-1.5">
|
||||
@@ -843,22 +814,21 @@ const ARTIFACT_COLUMNS: readonly ArtifactColumn[] = [
|
||||
{
|
||||
Cell: PrimaryCell,
|
||||
bodyClassName: 'p-0',
|
||||
header: (filter, a) => (filter === 'link' ? a.colTitleLink : filter === 'file' ? a.colTitleFile : a.colTitleDefault),
|
||||
header: filter => (filter === 'link' ? 'Link title' : filter === 'file' ? 'Name' : 'Title / name'),
|
||||
id: 'primary',
|
||||
width: filter => (filter === 'link' ? 'w-[50%]' : 'w-[35%]')
|
||||
},
|
||||
{
|
||||
Cell: LocationCell,
|
||||
bodyClassName: 'px-2.5 py-1.5',
|
||||
header: (filter, a) =>
|
||||
filter === 'link' ? a.colLocationLink : filter === 'file' ? a.colLocationFile : a.colLocationDefault,
|
||||
header: filter => (filter === 'link' ? 'URL' : filter === 'file' ? 'Path' : 'Location'),
|
||||
id: 'location',
|
||||
width: filter => (filter === 'link' ? 'w-[30%]' : 'w-[41%]')
|
||||
},
|
||||
{
|
||||
Cell: SessionCell,
|
||||
bodyClassName: 'p-0',
|
||||
header: (_filter, a) => a.colSession,
|
||||
header: () => 'Session',
|
||||
id: 'session',
|
||||
width: filter => (filter === 'link' ? 'w-[20%]' : 'w-[24%]')
|
||||
}
|
||||
@@ -873,15 +843,13 @@ function ArtifactTable({
|
||||
ctx: CellCtx
|
||||
filter: ArtifactFilter
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<table className="w-full min-w-176 table-fixed text-left text-[length:var(--conversation-caption-font-size)]">
|
||||
<thead className="border-b border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) text-[0.625rem] uppercase tracking-[0.08em] text-(--ui-text-tertiary)">
|
||||
<tr>
|
||||
{ARTIFACT_COLUMNS.map(col => (
|
||||
<th className={cn(col.width(filter), 'px-2.5 py-1.5 font-medium')} key={col.id}>
|
||||
{col.header(filter, t.artifacts)}
|
||||
{col.header(filter)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useStore } from '@nanostores/react'
|
||||
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { FileText, FolderOpen, ImageIcon, Link, Terminal } from '@/lib/icons'
|
||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
@@ -27,8 +26,6 @@ export function AttachmentList({
|
||||
}
|
||||
|
||||
function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachment; onRemove?: (id: string) => void }) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
const Icon = { folder: FolderOpen, url: Link, image: ImageIcon, file: FileText, terminal: Terminal }[attachment.kind]
|
||||
const cwd = useStore($currentCwd)
|
||||
const canPreview = attachment.kind !== 'folder' && attachment.kind !== 'terminal'
|
||||
@@ -56,12 +53,12 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
|
||||
const preview = await normalizeOrLocalPreviewTarget(target, cwd || undefined)
|
||||
|
||||
if (!preview) {
|
||||
throw new Error(c.couldNotPreview(attachment.label))
|
||||
throw new Error(`Could not preview ${attachment.label}`)
|
||||
}
|
||||
|
||||
setCurrentSessionPreviewTarget(preview, 'manual', target)
|
||||
} catch (error) {
|
||||
notifyError(error, c.previewUnavailable)
|
||||
notifyError(error, 'Preview unavailable')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +66,7 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
|
||||
<Tip label={attachment.path || attachment.detail || attachment.label}>
|
||||
<div className="group/attachment relative min-w-0 shrink-0">
|
||||
<button
|
||||
aria-label={canPreview ? c.previewLabel(attachment.label) : attachment.label}
|
||||
aria-label={canPreview ? `Preview ${attachment.label}` : attachment.label}
|
||||
className="flex max-w-56 items-center gap-2 border border-border/60 bg-background/50 px-2 py-1.5 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.25)] transition-colors hover:border-primary/35 hover:bg-accent/45 disabled:cursor-default"
|
||||
disabled={!canPreview}
|
||||
onClick={() => void openPreview()}
|
||||
@@ -100,7 +97,7 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
|
||||
</button>
|
||||
{onRemove && (
|
||||
<button
|
||||
aria-label={c.removeAttachment(attachment.label)}
|
||||
aria-label={`Remove ${attachment.label}`}
|
||||
className="absolute -right-1 -top-1 grid size-3.5 place-items-center rounded-full border border-border/70 bg-background text-muted-foreground opacity-0 shadow-xs transition hover:bg-accent hover:text-foreground group-hover/attachment:opacity-100 focus-visible:opacity-100"
|
||||
onClick={() => onRemove(attachment.id)}
|
||||
type="button"
|
||||
|
||||
@@ -11,14 +11,29 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { Clipboard, FileText, FolderOpen, type IconComponent, ImageIcon, Link, MessageSquareText } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { GHOST_ICON_BTN } from './controls'
|
||||
import type { ChatBarState } from './types'
|
||||
|
||||
const SNIPPET_KEYS = ['codeReview', 'implementationPlan', 'explainThis']
|
||||
const PROMPT_SNIPPETS: readonly PromptSnippet[] = [
|
||||
{
|
||||
description: 'Audit the current change for regressions, dropped edge cases, and missing tests.',
|
||||
label: 'Code review',
|
||||
text: 'Please review this for bugs, regressions, and missing tests.'
|
||||
},
|
||||
{
|
||||
description: 'Outline an approach before touching code so the diff stays focused.',
|
||||
label: 'Implementation plan',
|
||||
text: 'Please make a concise implementation plan before changing code.'
|
||||
},
|
||||
{
|
||||
description: 'Walk through how the selected code works and link to the key files.',
|
||||
label: 'Explain this',
|
||||
text: 'Please explain how this works and point me to the key files.'
|
||||
}
|
||||
]
|
||||
|
||||
export function ContextMenu({
|
||||
state,
|
||||
@@ -29,8 +44,6 @@ export function ContextMenu({
|
||||
onPickFolders,
|
||||
onPickImages
|
||||
}: ContextMenuProps) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
// Prompt snippets used to be a Radix submenu. That submenu didn't open
|
||||
// reliably when the parent menu was positioned at the bottom of the
|
||||
// window (composer "+" anchor), so we promoted it to a real Dialog —
|
||||
@@ -58,81 +71,78 @@ export function ContextMenu({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-60" side="top" sideOffset={10}>
|
||||
<DropdownMenuLabel className="text-[0.7rem] font-medium uppercase tracking-wide text-muted-foreground/85">
|
||||
{c.attachLabel}
|
||||
Attach
|
||||
</DropdownMenuLabel>
|
||||
<ContextMenuItem disabled={!onPickFiles} icon={FileText} onSelect={onPickFiles}>
|
||||
{c.files}
|
||||
Files…
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!onPickFolders} icon={FolderOpen} onSelect={onPickFolders}>
|
||||
{c.folder}
|
||||
Folder…
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!onPickImages} icon={ImageIcon} onSelect={onPickImages}>
|
||||
{c.images}
|
||||
Images…
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!onPasteClipboardImage} icon={Clipboard} onSelect={onPasteClipboardImage}>
|
||||
{c.pasteImage}
|
||||
Paste image
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem icon={Link} onSelect={onOpenUrlDialog}>
|
||||
{c.url}
|
||||
URL…
|
||||
</ContextMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<ContextMenuItem icon={MessageSquareText} onSelect={() => setSnippetsOpen(true)}>
|
||||
{c.promptSnippets}
|
||||
Prompt snippets…
|
||||
</ContextMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<div className="px-2 py-1 text-[0.7rem] text-muted-foreground/80">
|
||||
{c.tipPre}
|
||||
<kbd className="rounded bg-muted/70 px-1 py-px font-mono text-[0.65rem]">@</kbd>
|
||||
{c.tipPost}
|
||||
Tip: type <kbd className="rounded bg-muted/70 px-1 py-px font-mono text-[0.65rem]">@</kbd> to reference
|
||||
files inline.
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<PromptSnippetsDialog onInsertText={onInsertText} onOpenChange={setSnippetsOpen} open={snippetsOpen} />
|
||||
<PromptSnippetsDialog
|
||||
onInsertText={onInsertText}
|
||||
onOpenChange={setSnippetsOpen}
|
||||
open={snippetsOpen}
|
||||
snippets={PROMPT_SNIPPETS}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PromptSnippetsDialog({ onInsertText, onOpenChange, open }: PromptSnippetsDialogProps) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
|
||||
function PromptSnippetsDialog({ onInsertText, onOpenChange, open, snippets }: PromptSnippetsDialogProps) {
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className="max-w-md gap-3">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{c.snippetsTitle}</DialogTitle>
|
||||
<DialogDescription>{c.snippetsDesc}</DialogDescription>
|
||||
<DialogTitle>Prompt snippets</DialogTitle>
|
||||
<DialogDescription>Pick a starter prompt to drop into the composer.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ul className="grid gap-1">
|
||||
{SNIPPET_KEYS.map(key => {
|
||||
const snippet = c.snippets[key]
|
||||
|
||||
return (
|
||||
<li key={key}>
|
||||
<button
|
||||
className="group/snippet flex w-full cursor-pointer items-start gap-2.5 rounded-md border border-transparent px-2.5 py-2 text-left transition-colors hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) focus-visible:border-(--ui-stroke-tertiary) focus-visible:bg-(--ui-control-hover-background) focus-visible:outline-none"
|
||||
onClick={() => {
|
||||
onInsertText(snippet.text)
|
||||
onOpenChange(false)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<MessageSquareText className="mt-0.5 size-3.5 shrink-0 text-(--ui-text-tertiary) group-hover/snippet:text-foreground" />
|
||||
<span className="grid min-w-0 gap-0.5">
|
||||
<span className="text-sm font-medium text-foreground">{snippet.label}</span>
|
||||
<span className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
{snippet.description}
|
||||
</span>
|
||||
{snippets.map(snippet => (
|
||||
<li key={snippet.label}>
|
||||
<button
|
||||
className="group/snippet flex w-full items-start gap-2.5 rounded-md border border-transparent px-2.5 py-2 text-left transition-colors hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) focus-visible:border-(--ui-stroke-tertiary) focus-visible:bg-(--ui-control-hover-background) focus-visible:outline-none"
|
||||
onClick={() => {
|
||||
onInsertText(snippet.text)
|
||||
onOpenChange(false)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<MessageSquareText className="mt-0.5 size-3.5 shrink-0 text-(--ui-text-tertiary) group-hover/snippet:text-foreground" />
|
||||
<span className="grid min-w-0 gap-0.5">
|
||||
<span className="text-sm font-medium text-foreground">{snippet.label}</span>
|
||||
<span className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
{snippet.description}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -165,8 +175,15 @@ interface ContextMenuProps {
|
||||
state: ChatBarState
|
||||
}
|
||||
|
||||
interface PromptSnippet {
|
||||
description: string
|
||||
label: string
|
||||
text: string
|
||||
}
|
||||
|
||||
interface PromptSnippetsDialogProps {
|
||||
onInsertText: (text: string) => void
|
||||
onOpenChange: (open: boolean) => void
|
||||
open: boolean
|
||||
snippets: readonly PromptSnippet[]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { AudioLines, Layers3, Loader2, Square } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -56,9 +55,6 @@ export function ComposerControls({
|
||||
voiceStatus: VoiceStatus
|
||||
onDictate: () => void
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
|
||||
if (conversation.active) {
|
||||
return <ConversationPill {...conversation} disabled={disabled} />
|
||||
}
|
||||
@@ -69,9 +65,9 @@ export function ComposerControls({
|
||||
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
|
||||
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
|
||||
{showVoicePrimary ? (
|
||||
<Tip label={c.startVoice}>
|
||||
<Tip label="Start voice conversation">
|
||||
<Button
|
||||
aria-label={c.startVoice}
|
||||
aria-label="Start voice conversation"
|
||||
className={PRIMARY_ICON_BTN}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
@@ -85,9 +81,9 @@ export function ComposerControls({
|
||||
</Button>
|
||||
</Tip>
|
||||
) : (
|
||||
<Tip label={busy ? (busyAction === 'queue' ? c.queueMessage : c.stop) : c.send}>
|
||||
<Tip label={busy ? (busyAction === 'queue' ? 'Queue message' : 'Stop') : 'Send'}>
|
||||
<Button
|
||||
aria-label={busy ? (busyAction === 'queue' ? c.queueMessage : c.stop) : c.send}
|
||||
aria-label={busy ? (busyAction === 'queue' ? 'Queue message' : 'Stop') : 'Send'}
|
||||
className={PRIMARY_ICON_BTN}
|
||||
disabled={disabled || !canSubmit}
|
||||
type="submit"
|
||||
@@ -117,27 +113,25 @@ function ConversationPill({
|
||||
onToggleMute,
|
||||
status
|
||||
}: ConversationProps & { disabled: boolean }) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
const speaking = status === 'speaking'
|
||||
const listening = status === 'listening' && !muted
|
||||
|
||||
const label =
|
||||
status === 'speaking'
|
||||
? c.speaking
|
||||
? 'Speaking'
|
||||
: status === 'transcribing'
|
||||
? c.transcribing
|
||||
? 'Transcribing'
|
||||
: status === 'thinking'
|
||||
? c.thinking
|
||||
? 'Thinking'
|
||||
: muted
|
||||
? c.muted
|
||||
: c.listening
|
||||
? 'Muted'
|
||||
: 'Listening'
|
||||
|
||||
return (
|
||||
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
|
||||
<Tip label={muted ? c.unmuteMic : c.muteMic}>
|
||||
<Tip label={muted ? 'Unmute microphone' : 'Mute microphone'}>
|
||||
<Button
|
||||
aria-label={muted ? c.unmuteMic : c.muteMic}
|
||||
aria-label={muted ? 'Unmute microphone' : 'Mute microphone'}
|
||||
aria-pressed={muted}
|
||||
className={cn(GHOST_ICON_BTN, 'p-0', muted && 'bg-muted text-muted-foreground')}
|
||||
disabled={disabled}
|
||||
@@ -154,34 +148,32 @@ function ConversationPill({
|
||||
</Tip>
|
||||
{listening && (
|
||||
<Button
|
||||
aria-label={c.stopListening}
|
||||
aria-label="Stop listening and send"
|
||||
className="h-(--composer-control-size) shrink-0 gap-1.5 rounded-full px-2.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
triggerHaptic('submit')
|
||||
onStopTurn()
|
||||
}}
|
||||
title={c.stopListening}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Square className="fill-current" size={11} />
|
||||
<span>{c.stopShort}</span>
|
||||
<span>Stop</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
aria-label={c.endConversation}
|
||||
aria-label="End voice conversation"
|
||||
className="h-(--composer-control-size) gap-1.5 rounded-full bg-primary px-3 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
triggerHaptic('close')
|
||||
onEnd()
|
||||
}}
|
||||
title={c.endConversation}
|
||||
type="button"
|
||||
>
|
||||
<ConversationIndicator level={level} listening={listening} speaking={speaking} />
|
||||
<span>{c.endShort}</span>
|
||||
<span>End</span>
|
||||
</Button>
|
||||
<span className="sr-only" role="status">
|
||||
{label}
|
||||
@@ -228,12 +220,10 @@ function DictationButton({
|
||||
status: VoiceStatus
|
||||
onToggle: () => void
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
const active = state.active || status !== 'idle'
|
||||
|
||||
const aria =
|
||||
status === 'recording' ? c.stopDictation : status === 'transcribing' ? c.transcribingDictation : c.voiceDictation
|
||||
status === 'recording' ? 'Stop dictation' : status === 'transcribing' ? 'Transcribing dictation' : 'Voice dictation'
|
||||
|
||||
return (
|
||||
<Tip label={aria}>
|
||||
|
||||
@@ -1,32 +1,44 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
import { COMPLETION_DRAWER_CLASS } from './completion-drawer'
|
||||
|
||||
const COMMON_COMMAND_KEYS = ['/help', '/clear', '/resume', '/details', '/copy', '/quit']
|
||||
const HOTKEY_KEYS = ['@', '/', '?', 'Enter', 'Cmd/Ctrl+K', 'Cmd/Ctrl+L', 'Esc', '↑ / ↓']
|
||||
const COMMON_COMMANDS: [string, string][] = [
|
||||
['/help', 'full list of commands + hotkeys'],
|
||||
['/clear', 'start a new session'],
|
||||
['/resume', 'resume a prior session'],
|
||||
['/details', 'control transcript detail level'],
|
||||
['/copy', 'copy selection or last assistant message'],
|
||||
['/quit', 'exit hermes']
|
||||
]
|
||||
|
||||
const HOTKEYS: [string, string][] = [
|
||||
['@', 'reference files, folders, urls, git'],
|
||||
['/', 'slash command palette'],
|
||||
['?', 'this quick help (delete to dismiss)'],
|
||||
['Enter', 'send · Shift+Enter for newline'],
|
||||
['Cmd/Ctrl+K', 'send next queued turn'],
|
||||
['Cmd/Ctrl+L', 'redraw'],
|
||||
['Esc', 'close popover · cancel run'],
|
||||
['↑ / ↓', 'cycle popover / history']
|
||||
]
|
||||
|
||||
export function HelpHint() {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
|
||||
return (
|
||||
<div className={COMPLETION_DRAWER_CLASS} data-slot="composer-completion-drawer" data-state="open" role="dialog">
|
||||
<Section title={c.commonCommands}>
|
||||
{COMMON_COMMAND_KEYS.map(key => (
|
||||
<Row description={c.commandDescs[key] ?? ''} key={key} keyLabel={key} mono />
|
||||
<Section title="Common commands">
|
||||
{COMMON_COMMANDS.map(([key, desc]) => (
|
||||
<Row description={desc} key={key} keyLabel={key} mono />
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<Section title={c.hotkeys}>
|
||||
{HOTKEY_KEYS.map(key => (
|
||||
<Row description={c.hotkeyDescs[key] ?? ''} key={key} keyLabel={key} />
|
||||
<Section title="Hotkeys">
|
||||
{HOTKEYS.map(([key, desc]) => (
|
||||
<Row description={desc} key={key} keyLabel={key} />
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<p className="px-2.5 py-1 text-xs text-muted-foreground/80">
|
||||
<span className="font-mono text-foreground/80">/help</span> {c.helpFooter}
|
||||
<span className="font-mono text-foreground/80">/help</span> opens the full panel · backspace dismisses
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -17,7 +17,6 @@ import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-te
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useMediaQuery } from '@/hooks/use-media-query'
|
||||
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { chatMessageText } from '@/lib/chat-messages'
|
||||
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
|
||||
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
|
||||
@@ -85,6 +84,29 @@ const COMPOSER_SINGLE_LINE_MAX_PX = 36
|
||||
const COMPOSER_FADE_BACKGROUND =
|
||||
'linear-gradient(to bottom, transparent, color-mix(in srgb, var(--dt-background) 10%, transparent))'
|
||||
|
||||
// Resting composer placeholders. New sessions get open-ended starters; an
|
||||
// existing chat gets phrasings that read as a continuation of the thread.
|
||||
// One is picked at random per session (stable until the session changes).
|
||||
const NEW_SESSION_PLACEHOLDERS = [
|
||||
'What are we building?',
|
||||
'Give Hermes a task',
|
||||
"What's on your mind?",
|
||||
'Describe what you need',
|
||||
'What should we tackle?',
|
||||
'Ask anything',
|
||||
'Start with a goal'
|
||||
]
|
||||
|
||||
const FOLLOW_UP_PLACEHOLDERS = [
|
||||
'Send a follow-up',
|
||||
'Add more context',
|
||||
'Refine the request',
|
||||
"What's next?",
|
||||
'Keep it going',
|
||||
'Push it further',
|
||||
'Adjust or continue'
|
||||
]
|
||||
|
||||
const pickPlaceholder = (pool: readonly string[]) => pool[Math.floor(Math.random() * pool.length)]
|
||||
|
||||
interface QueueEditState {
|
||||
@@ -168,10 +190,7 @@ export function ChatBar({
|
||||
const busyAction = busy && hasComposerPayload ? 'queue' : 'stop'
|
||||
const showHelpHint = draft === '?'
|
||||
|
||||
const { t } = useI18n()
|
||||
const gatewayState = useStore($gatewayState)
|
||||
const newSessionPlaceholders = t.composer.newSessionPlaceholders
|
||||
const followUpPlaceholders = t.composer.followUpPlaceholders
|
||||
|
||||
// Resting placeholder: a starter for brand-new sessions, a continuation for
|
||||
// existing ones. Picked once and only re-rolled when we genuinely move to a
|
||||
@@ -179,7 +198,7 @@ export function ChatBar({
|
||||
// started session (null → id, on the first send) is treated as the same
|
||||
// conversation so the placeholder doesn't visibly flip mid-stream.
|
||||
const [restingPlaceholder, setRestingPlaceholder] = useState(() =>
|
||||
pickPlaceholder(sessionId ? followUpPlaceholders : newSessionPlaceholders)
|
||||
pickPlaceholder(sessionId ? FOLLOW_UP_PLACEHOLDERS : NEW_SESSION_PLACEHOLDERS)
|
||||
)
|
||||
|
||||
const prevSessionIdRef = useRef(sessionId)
|
||||
@@ -198,16 +217,16 @@ export function ChatBar({
|
||||
return
|
||||
}
|
||||
|
||||
setRestingPlaceholder(pickPlaceholder(sessionId ? followUpPlaceholders : newSessionPlaceholders))
|
||||
}, [followUpPlaceholders, newSessionPlaceholders, sessionId])
|
||||
setRestingPlaceholder(pickPlaceholder(sessionId ? FOLLOW_UP_PLACEHOLDERS : NEW_SESSION_PLACEHOLDERS))
|
||||
}, [sessionId])
|
||||
|
||||
// When the bar is disabled it's because the gateway isn't open. Distinguish a
|
||||
// cold start ("Starting Hermes...") from a dropped connection we're trying to
|
||||
// restore (e.g. after the Mac slept) so the stuck state reads as recoverable.
|
||||
const placeholder = disabled
|
||||
? gatewayState === 'closed' || gatewayState === 'error'
|
||||
? t.composer.placeholderReconnecting
|
||||
: t.composer.placeholderStarting
|
||||
? 'Reconnecting to Hermes…'
|
||||
: 'Starting Hermes...'
|
||||
: restingPlaceholder
|
||||
|
||||
const focusInput = useCallback(() => {
|
||||
@@ -1194,7 +1213,7 @@ export function ChatBar({
|
||||
const input = (
|
||||
<div className={cn('relative', stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1')}>
|
||||
<div
|
||||
aria-label={t.composer.message}
|
||||
aria-label="Message"
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
className={cn(
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { ArrowUp, Pencil, Trash2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { QueuedPromptEntry } from '@/store/composer-queue'
|
||||
@@ -17,12 +16,10 @@ interface QueuePanelProps {
|
||||
onSendNow: (id: string) => void
|
||||
}
|
||||
|
||||
const entryPreview = (entry: QueuedPromptEntry, c: Translations['composer']) =>
|
||||
entry.text.trim() || (entry.attachments.length > 0 ? c.attachmentOnly : c.emptyTurn)
|
||||
const entryPreview = (entry: QueuedPromptEntry) =>
|
||||
entry.text.trim() || (entry.attachments.length > 0 ? 'Attachment-only turn' : 'Empty turn')
|
||||
|
||||
export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendNow }: QueuePanelProps) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
|
||||
if (entries.length === 0) {
|
||||
@@ -37,7 +34,7 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
|
||||
type="button"
|
||||
>
|
||||
<DisclosureCaret className="shrink-0" open={!collapsed} size="0.875rem" />
|
||||
<span className="truncate">{c.queued(entries.length)}</span>
|
||||
<span className="truncate">{entries.length} Queued</span>
|
||||
</button>
|
||||
|
||||
{!collapsed && (
|
||||
@@ -60,17 +57,17 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
|
||||
className="h-3.5 w-3.5 shrink-0 rounded-full border border-foreground/35 bg-transparent"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[0.73rem] leading-4 text-foreground/92">{entryPreview(entry, c)}</p>
|
||||
<p className="truncate text-[0.73rem] leading-4 text-foreground/92">{entryPreview(entry)}</p>
|
||||
{(attachmentsCount > 0 || isEditing) && (
|
||||
<div className="mt-0.5 flex items-center gap-1.5 text-[0.64rem] text-muted-foreground/75">
|
||||
{attachmentsCount > 0 && (
|
||||
<span>
|
||||
{c.attachments(attachmentsCount)}
|
||||
{attachmentsCount} attachment{attachmentsCount === 1 ? '' : 's'}
|
||||
</span>
|
||||
)}
|
||||
{isEditing && (
|
||||
<span className="text-[color-mix(in_srgb,var(--dt-composer-ring)_78%,var(--muted-foreground))]">
|
||||
{c.editingInComposer}
|
||||
Editing in composer
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -84,9 +81,9 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
|
||||
: 'opacity-0 group-hover/queue-row:opacity-100 group-focus-within/queue-row:opacity-100'
|
||||
)}
|
||||
>
|
||||
<Tip label={c.editQueued}>
|
||||
<Tip label="Edit queued turn">
|
||||
<Button
|
||||
aria-label={c.editQueued}
|
||||
aria-label="Edit queued turn"
|
||||
className="h-5 w-5 rounded-md"
|
||||
disabled={Boolean(editingId) && !isEditing}
|
||||
onClick={() => onEdit(entry)}
|
||||
@@ -97,9 +94,9 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
|
||||
<Pencil size={11} />
|
||||
</Button>
|
||||
</Tip>
|
||||
<Tip label={c.sendQueuedNow}>
|
||||
<Tip label="Send queued turn now">
|
||||
<Button
|
||||
aria-label={c.sendQueuedNow}
|
||||
aria-label="Send queued turn now"
|
||||
className="h-5 w-5 rounded-md"
|
||||
disabled={busy || isEditing}
|
||||
onClick={() => onSendNow(entry.id)}
|
||||
@@ -110,9 +107,9 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
|
||||
<ArrowUp size={11} />
|
||||
</Button>
|
||||
</Tip>
|
||||
<Tip label={c.deleteQueued}>
|
||||
<Tip label="Delete queued turn">
|
||||
<Button
|
||||
aria-label={c.deleteQueued}
|
||||
aria-label="Delete queued turn"
|
||||
className="h-5 w-5 rounded-md"
|
||||
onClick={() => onDelete(entry.id)}
|
||||
size="icon-xs"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useI18n } from '@/i18n'
|
||||
import { desktopSkinSlashCompletions } from '@/lib/desktop-slash-commands'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { useTheme } from '@/themes/context'
|
||||
@@ -11,8 +10,6 @@ interface SkinSlashPopoverProps {
|
||||
}
|
||||
|
||||
export function SkinSlashPopover({ draft, onSelect }: SkinSlashPopoverProps) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
const { availableThemes, themeName } = useTheme()
|
||||
const match = draft.match(/^\/skin\s+(\S*)$/i)
|
||||
|
||||
@@ -24,7 +21,7 @@ export function SkinSlashPopover({ draft, onSelect }: SkinSlashPopoverProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-label={c.themeSuggestions}
|
||||
aria-label="Desktop theme suggestions"
|
||||
className={COMPLETION_DRAWER_CLASS}
|
||||
data-slot="composer-skin-completion-drawer"
|
||||
data-state="open"
|
||||
@@ -32,10 +29,8 @@ export function SkinSlashPopover({ draft, onSelect }: SkinSlashPopoverProps) {
|
||||
>
|
||||
<div className="grid gap-0.5 pt-0.5">
|
||||
{items.length === 0 ? (
|
||||
<CompletionDrawerEmpty title={c.noMatchingThemes}>
|
||||
{c.themeTryPre}
|
||||
<span className="font-mono text-foreground/80">/skin list</span>
|
||||
{c.themeTryPost}
|
||||
<CompletionDrawerEmpty title="No matching themes.">
|
||||
Try <span className="font-mono text-foreground/80">/skin list</span>.
|
||||
</CompletionDrawerEmpty>
|
||||
) : (
|
||||
items.map(item => (
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { Globe } from '@/lib/icons'
|
||||
|
||||
const URL_HINT = /^https?:\/\//i
|
||||
@@ -30,8 +29,6 @@ export function UrlDialog({
|
||||
open: boolean
|
||||
value: string
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
const trimmed = value.trim()
|
||||
const looksLikeUrl = trimmed.length > 0 && URL_HINT.test(trimmed)
|
||||
|
||||
@@ -46,8 +43,8 @@ export function UrlDialog({
|
||||
<Globe className="size-4" />
|
||||
</span>
|
||||
<div className="grid gap-0.5 text-left">
|
||||
<DialogTitle>{c.attachUrlTitle}</DialogTitle>
|
||||
<DialogDescription>{c.attachUrlDesc}</DialogDescription>
|
||||
<DialogTitle>Attach a URL</DialogTitle>
|
||||
<DialogDescription>Hermes will fetch the page and include it as context for this turn.</DialogDescription>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<form
|
||||
@@ -63,24 +60,23 @@ export function UrlDialog({
|
||||
autoCorrect="off"
|
||||
inputMode="url"
|
||||
onChange={e => onChange(e.target.value)}
|
||||
placeholder={c.urlPlaceholder}
|
||||
placeholder="https://example.com/post"
|
||||
ref={inputRef}
|
||||
spellCheck={false}
|
||||
value={value}
|
||||
/>
|
||||
{trimmed.length > 0 && !looksLikeUrl && (
|
||||
<p className="text-xs text-muted-foreground/85">
|
||||
{c.urlHintPre}
|
||||
<span className="font-mono">https://…</span>
|
||||
Include the full URL, e.g. <span className="font-mono">https://…</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => onOpenChange(false)} type="button" variant="ghost">
|
||||
{t.common.cancel}
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={!looksLikeUrl} type="submit">
|
||||
{c.attach}
|
||||
Attach
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { Loader2, Mic, Volume2, VolumeX } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { stopVoicePlayback } from '@/lib/voice-playback'
|
||||
@@ -164,14 +163,12 @@ function PlaybackWaveform({ audioElement }: { audioElement: HTMLAudioElement | n
|
||||
}
|
||||
|
||||
export function VoiceActivity({ state }: { state: VoiceActivityState }) {
|
||||
const { t } = useI18n()
|
||||
|
||||
if (state.status === 'idle') {
|
||||
return null
|
||||
}
|
||||
|
||||
const recording = state.status === 'recording'
|
||||
const title = recording ? t.composer.dictating : t.composer.transcribing
|
||||
const title = recording ? 'Dictating' : 'Transcribing'
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -204,7 +201,6 @@ export function VoiceActivity({ state }: { state: VoiceActivityState }) {
|
||||
}
|
||||
|
||||
export function VoicePlaybackActivity() {
|
||||
const { t } = useI18n()
|
||||
const playback = useStore($voicePlayback)
|
||||
|
||||
if (playback.status === 'idle') {
|
||||
@@ -214,10 +210,10 @@ export function VoicePlaybackActivity() {
|
||||
const preparing = playback.status === 'preparing'
|
||||
|
||||
const title = preparing
|
||||
? t.composer.preparingAudio
|
||||
? 'Preparing audio'
|
||||
: playback.source === 'voice-conversation'
|
||||
? t.composer.speakingResponse
|
||||
: t.composer.readingAloud
|
||||
? 'Speaking response'
|
||||
: 'Reading aloud'
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -36,7 +36,6 @@ import {
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { profileColor } from '@/lib/profile-color'
|
||||
import { sessionMatchesSearch } from '@/lib/session-search'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -177,13 +176,13 @@ function searchResultToSession(result: SessionSearchResult): SessionInfo {
|
||||
}
|
||||
}
|
||||
|
||||
function workspaceGroupsFor(sessions: SessionInfo[], noWorkspaceLabel: string): SidebarSessionGroup[] {
|
||||
function workspaceGroupsFor(sessions: SessionInfo[]): SidebarSessionGroup[] {
|
||||
const groups = new Map<string, SidebarSessionGroup>()
|
||||
|
||||
for (const session of sessions) {
|
||||
const path = session.cwd?.trim() || ''
|
||||
const id = path || '__no_workspace__'
|
||||
const label = baseName(path) || path || noWorkspaceLabel
|
||||
const label = baseName(path) || path || 'No workspace'
|
||||
|
||||
const group = groups.get(id) ?? { id, label, path: path || null, sessions: [] }
|
||||
group.sessions.push(session)
|
||||
@@ -234,8 +233,6 @@ export function ChatSidebar({
|
||||
onArchiveSession,
|
||||
onNewSessionInWorkspace
|
||||
}: ChatSidebarProps) {
|
||||
const { t } = useI18n()
|
||||
const s = t.sidebar
|
||||
const sidebarOpen = useStore($sidebarOpen)
|
||||
const panesFlipped = useStore($panesFlipped)
|
||||
const agentsGrouped = useStore($sidebarAgentsGrouped)
|
||||
@@ -405,8 +402,8 @@ export function ChatSidebar({
|
||||
)
|
||||
|
||||
const agentGroups = useMemo(
|
||||
() => orderByIds(workspaceGroupsFor(agentSessions, s.noWorkspace), g => g.id, workspaceOrderIds),
|
||||
[agentSessions, s.noWorkspace, workspaceOrderIds]
|
||||
() => orderByIds(workspaceGroupsFor(agentSessions), g => g.id, workspaceOrderIds),
|
||||
[agentSessions, workspaceOrderIds]
|
||||
)
|
||||
|
||||
const loadMoreForProfileGroup = useCallback(
|
||||
@@ -592,15 +589,13 @@ export function ChatSidebar({
|
||||
|
||||
onNavigate(item)
|
||||
}}
|
||||
tooltip={s.nav[item.id] ?? item.label}
|
||||
tooltip={item.label}
|
||||
type="button"
|
||||
>
|
||||
<item.icon className="size-4 shrink-0 text-[color-mix(in_srgb,currentColor_72%,transparent)]" />
|
||||
{sidebarOpen && (
|
||||
<>
|
||||
<span className="min-w-0 flex-1 truncate max-[46.25rem]:hidden">
|
||||
{s.nav[item.id] ?? item.label}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate max-[46.25rem]:hidden">{item.label}</span>
|
||||
{isNewSession && (
|
||||
<KbdGroup
|
||||
className={cn('ml-auto max-[46.25rem]:hidden', newSessionKbdFlash && 'opacity-100!')}
|
||||
@@ -620,9 +615,9 @@ export function ChatSidebar({
|
||||
{sidebarOpen && showSessionSections && (
|
||||
<div className="shrink-0 px-2 pb-1 pt-1">
|
||||
<SearchField
|
||||
aria-label={s.searchAria}
|
||||
aria-label="Search sessions"
|
||||
onChange={setSearchQuery}
|
||||
placeholder={s.searchPlaceholder}
|
||||
placeholder="Search sessions…"
|
||||
value={searchQuery}
|
||||
/>
|
||||
</div>
|
||||
@@ -634,10 +629,10 @@ export function ChatSidebar({
|
||||
contentClassName="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75"
|
||||
emptyState={
|
||||
<div className="grid min-h-24 place-items-center rounded-lg px-2 text-center text-xs text-(--ui-text-tertiary)">
|
||||
{s.noMatch(trimmedQuery)}
|
||||
No sessions match “{trimmedQuery}”.
|
||||
</div>
|
||||
}
|
||||
label={s.results}
|
||||
label="Results"
|
||||
labelMeta={String(searchResults.length)}
|
||||
onArchiveSession={onArchiveSession}
|
||||
onDeleteSession={onDeleteSession}
|
||||
@@ -658,7 +653,7 @@ export function ChatSidebar({
|
||||
contentClassName="flex min-h-10 shrink-0 flex-col gap-px rounded-lg pb-2 pt-1"
|
||||
dndSensors={dndSensors}
|
||||
emptyState={<SidebarPinnedEmptyState />}
|
||||
label={s.pinned}
|
||||
label="Pinned"
|
||||
onArchiveSession={onArchiveSession}
|
||||
onDeleteSession={onDeleteSession}
|
||||
onReorder={handlePinnedDragEnd}
|
||||
@@ -708,9 +703,9 @@ export function ChatSidebar({
|
||||
// view (always grouped by profile), so hide the button (not the slot).
|
||||
<div className="grid size-6 shrink-0 place-items-center">
|
||||
{!showAllProfiles && agentSessions.length > 0 ? (
|
||||
<Tip label={agentsGrouped ? s.groupTitleGrouped : s.groupTitleUngrouped}>
|
||||
<Tip label={agentsGrouped ? 'Ungroup sessions' : 'Group by workspace'}>
|
||||
<Button
|
||||
aria-label={agentsGrouped ? s.groupAriaGrouped : s.groupAriaUngrouped}
|
||||
aria-label={agentsGrouped ? 'Show sessions as a single list' : 'Group sessions by workspace'}
|
||||
className={cn(
|
||||
'text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100',
|
||||
agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'
|
||||
@@ -729,7 +724,7 @@ export function ChatSidebar({
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
label={s.sessions}
|
||||
label="Sessions"
|
||||
labelMeta={recentsMeta}
|
||||
onArchiveSession={onArchiveSession}
|
||||
onDeleteSession={onDeleteSession}
|
||||
@@ -800,25 +795,19 @@ function SidebarSessionSkeletons() {
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarAllPinnedState() {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<div className="grid min-h-24 place-items-center rounded-lg text-center text-xs text-(--ui-text-tertiary)">
|
||||
{t.sidebar.allPinned}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const SidebarAllPinnedState = () => (
|
||||
<div className="grid min-h-24 place-items-center rounded-lg text-center text-xs text-(--ui-text-tertiary)">
|
||||
Everything here is pinned. Unpin a chat to show it in recents.
|
||||
</div>
|
||||
)
|
||||
|
||||
function SidebarPinnedEmptyState() {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<div className="flex min-h-7 items-center gap-1.5 rounded-lg pl-2 text-[0.75rem] text-(--ui-text-tertiary)">
|
||||
<span className="grid w-3.5 shrink-0 place-items-center text-(--ui-text-quaternary)">
|
||||
<Codicon name="pin" size="0.75rem" />
|
||||
</span>
|
||||
<span>{t.sidebar.shiftClickHint}</span>
|
||||
<span>Shift-click a chat to pin</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1017,8 +1006,6 @@ function SidebarWorkspaceGroup({
|
||||
ref,
|
||||
...rest
|
||||
}: SidebarWorkspaceGroupProps) {
|
||||
const { t } = useI18n()
|
||||
const s = t.sidebar
|
||||
const isProfileGroup = group.mode === 'profile'
|
||||
const pageStep = isProfileGroup ? PROFILE_INITIAL_PAGE : WORKSPACE_PAGE
|
||||
const [open, setOpen] = useState(true)
|
||||
@@ -1065,9 +1052,9 @@ function SidebarWorkspaceGroup({
|
||||
/>
|
||||
</button>
|
||||
{(onNewSession || isProfileGroup) && (
|
||||
<Tip label={s.newSessionIn(group.label)}>
|
||||
<Tip label={`New session in ${group.label}`}>
|
||||
<button
|
||||
aria-label={s.newSessionIn(group.label)}
|
||||
aria-label={`New session in ${group.label}`}
|
||||
className="grid size-4 shrink-0 place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/workspace:opacity-100"
|
||||
// Profile groups start a fresh session in that profile but keep the
|
||||
// all-profiles browse view (newSessionInProfile leaves the scope
|
||||
@@ -1082,7 +1069,7 @@ function SidebarWorkspaceGroup({
|
||||
{reorderable && (
|
||||
<span
|
||||
{...dragHandleProps}
|
||||
aria-label={s.reorderWorkspace(group.label)}
|
||||
aria-label={`Reorder workspace ${group.label}`}
|
||||
className="ml-auto -my-0.5 grid w-4 shrink-0 cursor-grab touch-none place-items-center self-stretch overflow-hidden active:cursor-grabbing"
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
@@ -1104,9 +1091,9 @@ function SidebarWorkspaceGroup({
|
||||
(isProfileGroup ? (
|
||||
<SidebarLoadMoreRow loading={Boolean(group.loadingMore)} onClick={handleProfileLoadMore} step={nextCount} />
|
||||
) : (
|
||||
<Tip label={s.showMoreIn(nextCount, group.label)}>
|
||||
<Tip label={`Show ${nextCount} more in ${group.label}`}>
|
||||
<button
|
||||
aria-label={s.showMoreIn(nextCount, group.label)}
|
||||
aria-label={`Show ${nextCount} more in ${group.label}`}
|
||||
className="ml-auto grid size-5 place-items-center rounded-sm bg-transparent text-(--ui-text-tertiary) transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground"
|
||||
onClick={() => setVisibleCount(count => count + WORKSPACE_PAGE)}
|
||||
type="button"
|
||||
@@ -1157,8 +1144,7 @@ interface SidebarLoadMoreRowProps {
|
||||
}
|
||||
|
||||
function SidebarLoadMoreRow({ loading, onClick, step }: SidebarLoadMoreRowProps) {
|
||||
const { t } = useI18n()
|
||||
const label = loading ? t.sidebar.loading : step > 0 ? t.sidebar.loadCount(step) : t.sidebar.loadMore
|
||||
const label = loading ? 'Loading…' : step > 0 ? `Load ${step} more` : 'Load more'
|
||||
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { renameSession } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { exportSession } from '@/lib/session-export'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
@@ -44,15 +43,13 @@ interface ItemSpec {
|
||||
}
|
||||
|
||||
function useSessionActions({ sessionId, title, pinned = false, profile, onPin, onArchive, onDelete }: SessionActions) {
|
||||
const { t } = useI18n()
|
||||
const r = t.sidebar.row
|
||||
const [renameOpen, setRenameOpen] = useState(false)
|
||||
|
||||
const items: ItemSpec[] = [
|
||||
{
|
||||
disabled: !onPin,
|
||||
icon: 'pin',
|
||||
label: pinned ? r.unpin : r.pin,
|
||||
label: pinned ? 'Unpin' : 'Pin',
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
onPin?.()
|
||||
@@ -61,17 +58,17 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
|
||||
{
|
||||
disabled: !sessionId,
|
||||
icon: 'copy',
|
||||
label: r.copyId,
|
||||
label: 'Copy ID',
|
||||
onSelect: event => {
|
||||
event.preventDefault()
|
||||
triggerHaptic('selection')
|
||||
void writeClipboardText(sessionId).catch(err => notifyError(err, r.copyIdFailed))
|
||||
void writeClipboardText(sessionId).catch(err => notifyError(err, 'Could not copy session ID'))
|
||||
}
|
||||
},
|
||||
{
|
||||
disabled: !sessionId,
|
||||
icon: 'cloud-download',
|
||||
label: r.export,
|
||||
label: 'Export',
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
void exportSession(sessionId, { title })
|
||||
@@ -80,7 +77,7 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
|
||||
{
|
||||
disabled: !sessionId,
|
||||
icon: 'edit',
|
||||
label: r.rename,
|
||||
label: 'Rename',
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
setRenameOpen(true)
|
||||
@@ -89,7 +86,7 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
|
||||
{
|
||||
disabled: !onArchive,
|
||||
icon: 'archive',
|
||||
label: r.archive,
|
||||
label: 'Archive',
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
onArchive?.()
|
||||
@@ -99,7 +96,7 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
|
||||
className: 'text-destructive focus:text-destructive',
|
||||
disabled: !onDelete,
|
||||
icon: 'trash',
|
||||
label: t.common.delete,
|
||||
label: 'Delete',
|
||||
onSelect: () => {
|
||||
triggerHaptic('warning')
|
||||
onDelete?.()
|
||||
@@ -135,7 +132,6 @@ interface SessionActionsMenuProps
|
||||
}
|
||||
|
||||
export function SessionActionsMenu({ children, align = 'end', sideOffset = 6, ...actions }: SessionActionsMenuProps) {
|
||||
const { t } = useI18n()
|
||||
const { renameDialog, renderItems } = useSessionActions(actions)
|
||||
|
||||
return (
|
||||
@@ -144,7 +140,7 @@ export function SessionActionsMenu({ children, align = 'end', sideOffset = 6, ..
|
||||
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align={align}
|
||||
aria-label={t.sidebar.row.actionsFor(actions.title)}
|
||||
aria-label={`Actions for ${actions.title}`}
|
||||
className="w-40"
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
@@ -161,14 +157,13 @@ interface SessionContextMenuProps extends SessionActions {
|
||||
}
|
||||
|
||||
export function SessionContextMenu({ children, ...actions }: SessionContextMenuProps) {
|
||||
const { t } = useI18n()
|
||||
const { renameDialog, renderItems } = useSessionActions(actions)
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
||||
<ContextMenuContent aria-label={t.sidebar.row.actionsFor(actions.title)} className="w-40">
|
||||
<ContextMenuContent aria-label={`Actions for ${actions.title}`} className="w-40">
|
||||
{renderItems(ContextMenuItem)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
@@ -186,8 +181,6 @@ interface RenameSessionDialogProps {
|
||||
}
|
||||
|
||||
function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle, profile }: RenameSessionDialogProps) {
|
||||
const { t } = useI18n()
|
||||
const r = t.sidebar.row
|
||||
const [value, setValue] = useState(currentTitle)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
@@ -218,10 +211,10 @@ function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle, prof
|
||||
const result = await renameSession(sessionId, next, profile)
|
||||
const finalTitle = result.title || next || ''
|
||||
setSessions(prev => prev.map(s => (s.id === sessionId ? { ...s, title: finalTitle || null } : s)))
|
||||
notify({ durationMs: 2_000, kind: 'success', message: r.renamed })
|
||||
notify({ durationMs: 2_000, kind: 'success', message: 'Renamed' })
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
notifyError(err, r.renameFailed)
|
||||
notifyError(err, 'Rename failed')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -231,8 +224,8 @@ function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle, prof
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{r.renameTitle}</DialogTitle>
|
||||
<DialogDescription>{r.renameDesc}</DialogDescription>
|
||||
<DialogTitle>Rename session</DialogTitle>
|
||||
<DialogDescription>Give this chat a memorable title. Leave empty to clear.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
autoFocus
|
||||
@@ -246,16 +239,16 @@ function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle, prof
|
||||
onOpenChange(false)
|
||||
}
|
||||
}}
|
||||
placeholder={r.untitledPlaceholder}
|
||||
placeholder="Untitled session"
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button disabled={submitting} onClick={() => onOpenChange(false)} type="button" variant="ghost">
|
||||
{t.common.cancel}
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={submitting} onClick={() => void submit()} type="button">
|
||||
{t.common.save}
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { writeSessionDrag } from '@/app/chat/composer/inline-refs'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import type { SessionInfo } from '@/hermes'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -27,22 +26,22 @@ interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
|
||||
dragHandleProps?: React.HTMLAttributes<HTMLElement>
|
||||
}
|
||||
|
||||
const AGE_TICKS: ReadonlyArray<[number, 'ageDay' | 'ageHour' | 'ageMin']> = [
|
||||
[86_400_000, 'ageDay'],
|
||||
[3_600_000, 'ageHour'],
|
||||
[60_000, 'ageMin']
|
||||
const AGE_TICKS: ReadonlyArray<[number, string]> = [
|
||||
[86_400_000, 'd'],
|
||||
[3_600_000, 'h'],
|
||||
[60_000, 'm']
|
||||
]
|
||||
|
||||
function formatAge(seconds: number, r: Translations['sidebar']['row']): string {
|
||||
function formatAge(seconds: number): string {
|
||||
const delta = Math.max(0, Date.now() - seconds * 1000)
|
||||
|
||||
for (const [ms, key] of AGE_TICKS) {
|
||||
for (const [ms, suffix] of AGE_TICKS) {
|
||||
if (delta >= ms) {
|
||||
return `${Math.floor(delta / ms)}${r[key]}`
|
||||
return `${Math.floor(delta / ms)}${suffix}`
|
||||
}
|
||||
}
|
||||
|
||||
return r.ageNow
|
||||
return 'now'
|
||||
}
|
||||
|
||||
export function SidebarSessionRow({
|
||||
@@ -62,10 +61,8 @@ export function SidebarSessionRow({
|
||||
ref,
|
||||
...rest
|
||||
}: SidebarSessionRowProps) {
|
||||
const { t } = useI18n()
|
||||
const r = t.sidebar.row
|
||||
const title = sessionTitle(session)
|
||||
const age = formatAge(session.last_active || session.started_at, r)
|
||||
const age = formatAge(session.last_active || session.started_at)
|
||||
const handleLabel = `Reorder ${title}`
|
||||
// Subscribe per-row (the leaf) instead of drilling a set through the list —
|
||||
// the atom is tiny and rarely non-empty. True when a clarify prompt in this
|
||||
@@ -199,10 +196,10 @@ export function SidebarSessionRow({
|
||||
title={title}
|
||||
>
|
||||
<Button
|
||||
aria-label={r.actionsFor(title)}
|
||||
aria-label={`Actions for ${title}`}
|
||||
className="size-5 rounded-[4px] bg-transparent text-transparent transition-colors duration-100 hover:bg-(--ui-control-active-background) hover:text-foreground focus-visible:bg-(--ui-control-active-background) focus-visible:text-foreground focus-visible:ring-0 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground group-hover:text-(--ui-text-tertiary) [&_svg]:size-3.5!"
|
||||
size="icon"
|
||||
title={r.sessionActions}
|
||||
title="Session actions"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="ellipsis" size="0.875rem" />
|
||||
@@ -223,9 +220,6 @@ function SidebarRowDot({
|
||||
needsInput?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const r = t.sidebar.row
|
||||
|
||||
// "Needs input" wins over "working": a clarify-blocked session is technically
|
||||
// still running, but the actionable state is that it's waiting on the user.
|
||||
// Amber + steady (no ping) reads as "your turn", distinct from the accent
|
||||
@@ -233,17 +227,17 @@ function SidebarRowDot({
|
||||
if (needsInput) {
|
||||
return (
|
||||
<span
|
||||
aria-label={r.needsInput}
|
||||
aria-label="Needs your input"
|
||||
className={cn('quest-glow relative size-1.5 rounded-full bg-amber-500', className)}
|
||||
role="status"
|
||||
title={r.waitingForAnswer}
|
||||
title="Waiting for your answer"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-label={isWorking ? r.sessionRunning : undefined}
|
||||
aria-label={isWorking ? 'Session running' : undefined}
|
||||
className={cn(
|
||||
'rounded-full',
|
||||
isWorking
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,6 @@ import type * as React from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
|
||||
interface CronJobActions {
|
||||
@@ -33,15 +32,12 @@ export function CronJobActionsMenu({
|
||||
sideOffset = 6,
|
||||
title
|
||||
}: CronJobActionsMenuProps) {
|
||||
const { t } = useI18n()
|
||||
const c = t.cron
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align={align}
|
||||
aria-label={c.actionsFor(title)}
|
||||
aria-label={`Actions for ${title}`}
|
||||
className="w-44"
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
@@ -53,7 +49,7 @@ export function CronJobActionsMenu({
|
||||
}}
|
||||
>
|
||||
<Codicon name={isPaused ? 'play' : 'debug-pause'} size="0.875rem" />
|
||||
<span>{isPaused ? c.resumeTitle : c.pauseTitle}</span>
|
||||
<span>{isPaused ? 'Resume' : 'Pause'}</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
@@ -64,7 +60,7 @@ export function CronJobActionsMenu({
|
||||
}}
|
||||
>
|
||||
<Codicon name="zap" size="0.875rem" />
|
||||
<span>{c.triggerNow}</span>
|
||||
<span>Trigger now</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
@@ -74,7 +70,7 @@ export function CronJobActionsMenu({
|
||||
}}
|
||||
>
|
||||
<Codicon name="edit" size="0.875rem" />
|
||||
<span>{c.edit}</span>
|
||||
<span>Edit</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
@@ -85,7 +81,7 @@ export function CronJobActionsMenu({
|
||||
variant="destructive"
|
||||
>
|
||||
<Codicon name="trash" size="0.875rem" />
|
||||
<span>{t.common.delete}</span>
|
||||
<span>Delete</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -97,14 +93,12 @@ interface CronJobActionsTriggerProps extends Omit<React.ComponentProps<typeof Bu
|
||||
}
|
||||
|
||||
export function CronJobActionsTrigger({ className, title, ...props }: CronJobActionsTriggerProps) {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label={t.cron.actionsFor(title)}
|
||||
aria-label={`Actions for ${title}`}
|
||||
className={className}
|
||||
size="icon-sm"
|
||||
title={t.cron.actionsTitle}
|
||||
title="Cron job actions"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
|
||||
+181
-180
@@ -1,9 +1,10 @@
|
||||
import type * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Badge, type BadgeProps } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { SearchField } from '@/components/ui/search-field'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
@@ -25,49 +27,78 @@ import {
|
||||
triggerCronJob,
|
||||
updateCronJob
|
||||
} from '@/hermes'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { AlertTriangle, Clock } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
|
||||
import { OverlayView } from '../overlays/overlay-view'
|
||||
import { PageSearchShell } from '../page-search-shell'
|
||||
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
|
||||
|
||||
import { CronJobActionsMenu, CronJobActionsTrigger } from './cron-job-actions-menu'
|
||||
|
||||
const DEFAULT_DELIVER = 'local'
|
||||
|
||||
const DELIVERY_VALUES: readonly string[] = ['local', 'telegram', 'discord', 'slack', 'email']
|
||||
|
||||
const SCHEDULE_OPTIONS: ReadonlyArray<ScheduleOption> = [
|
||||
{ expr: '0 9 * * *', value: 'daily' },
|
||||
{ expr: '0 9 * * 1-5', value: 'weekdays' },
|
||||
{ expr: '0 9 * * 1', value: 'weekly' },
|
||||
{ expr: '0 9 1 * *', value: 'monthly' },
|
||||
{ expr: '0 * * * *', value: 'hourly' },
|
||||
{ expr: '*/15 * * * *', value: 'every-15-minutes' },
|
||||
{ value: 'custom' }
|
||||
const DELIVERY_OPTIONS: ReadonlyArray<{ label: string; value: string }> = [
|
||||
{ label: 'This desktop', value: 'local' },
|
||||
{ label: 'Telegram', value: 'telegram' },
|
||||
{ label: 'Discord', value: 'discord' },
|
||||
{ label: 'Slack', value: 'slack' },
|
||||
{ label: 'Email', value: 'email' }
|
||||
]
|
||||
|
||||
const STATE_TONE: Record<string, 'good' | 'muted' | 'warn' | 'bad'> = {
|
||||
enabled: 'good',
|
||||
scheduled: 'good',
|
||||
running: 'good',
|
||||
const SCHEDULE_OPTIONS: ReadonlyArray<ScheduleOption> = [
|
||||
{
|
||||
expr: '0 9 * * *',
|
||||
hint: 'Every day at 9:00 AM',
|
||||
label: 'Daily',
|
||||
value: 'daily'
|
||||
},
|
||||
{
|
||||
expr: '0 9 * * 1-5',
|
||||
hint: 'Monday through Friday at 9:00 AM',
|
||||
label: 'Weekdays',
|
||||
value: 'weekdays'
|
||||
},
|
||||
{
|
||||
expr: '0 9 * * 1',
|
||||
hint: 'Every Monday at 9:00 AM',
|
||||
label: 'Weekly',
|
||||
value: 'weekly'
|
||||
},
|
||||
{
|
||||
expr: '0 9 1 * *',
|
||||
hint: 'The first day of each month at 9:00 AM',
|
||||
label: 'Monthly',
|
||||
value: 'monthly'
|
||||
},
|
||||
{
|
||||
expr: '0 * * * *',
|
||||
hint: 'At the top of every hour',
|
||||
label: 'Hourly',
|
||||
value: 'hourly'
|
||||
},
|
||||
{
|
||||
expr: '*/15 * * * *',
|
||||
hint: 'Every 15 minutes',
|
||||
label: 'Every 15 minutes',
|
||||
value: 'every-15-minutes'
|
||||
},
|
||||
{
|
||||
hint: 'Cron syntax or natural language',
|
||||
label: 'Custom',
|
||||
value: 'custom'
|
||||
}
|
||||
]
|
||||
|
||||
const STATE_VARIANT: Record<string, BadgeProps['variant']> = {
|
||||
enabled: 'default',
|
||||
scheduled: 'default',
|
||||
running: 'default',
|
||||
paused: 'warn',
|
||||
disabled: 'muted',
|
||||
error: 'bad',
|
||||
error: 'destructive',
|
||||
completed: 'muted'
|
||||
}
|
||||
|
||||
const PILL_TONE: Record<'good' | 'muted' | 'warn' | 'bad', string> = {
|
||||
good: 'bg-primary/10 text-primary',
|
||||
muted: 'bg-muted text-muted-foreground',
|
||||
warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-300',
|
||||
bad: 'bg-destructive/10 text-destructive'
|
||||
}
|
||||
|
||||
const asText = (value: unknown): string => (typeof value === 'string' ? value : '')
|
||||
|
||||
const truncate = (value: string, max = 80): string => (value.length > max ? `${value.slice(0, max)}…` : value)
|
||||
@@ -124,8 +155,19 @@ function cronParts(expr: string): null | string[] {
|
||||
return parts.length === 5 ? parts : null
|
||||
}
|
||||
|
||||
function dayName(value: string, c: Translations['cron']): string {
|
||||
return c.days[value] ?? c.dayFallback(value)
|
||||
function dayName(value: string): string {
|
||||
const names: Record<string, string> = {
|
||||
'0': 'Sunday',
|
||||
'1': 'Monday',
|
||||
'2': 'Tuesday',
|
||||
'3': 'Wednesday',
|
||||
'4': 'Thursday',
|
||||
'5': 'Friday',
|
||||
'6': 'Saturday',
|
||||
'7': 'Sunday'
|
||||
}
|
||||
|
||||
return names[value] ?? `day ${value}`
|
||||
}
|
||||
|
||||
function formatCronTime(minute: string, hour: string): string {
|
||||
@@ -201,36 +243,36 @@ function scheduleOptionForExpr(expr: string): ScheduleOption {
|
||||
return SCHEDULE_OPTIONS[SCHEDULE_OPTIONS.length - 1]
|
||||
}
|
||||
|
||||
function scheduleSummary(option: ScheduleOption, expr: string, c: Translations['cron']): string {
|
||||
function scheduleSummary(option: ScheduleOption, expr: string): string {
|
||||
const parts = cronParts(expr)
|
||||
|
||||
if (!parts) {
|
||||
return c.scheduleHints[option.value] ?? ''
|
||||
return option.hint
|
||||
}
|
||||
|
||||
const [minute, hour, dayOfMonth, , dayOfWeek] = parts
|
||||
|
||||
if (option.value === 'daily') {
|
||||
return c.everyDayAt(formatCronTime(minute, hour))
|
||||
return `Every day at ${formatCronTime(minute, hour)}`
|
||||
}
|
||||
|
||||
if (option.value === 'weekdays') {
|
||||
return c.weekdaysAt(formatCronTime(minute, hour))
|
||||
return `Weekdays at ${formatCronTime(minute, hour)}`
|
||||
}
|
||||
|
||||
if (option.value === 'weekly') {
|
||||
return c.everyDayOfWeekAt(dayName(dayOfWeek, c), formatCronTime(minute, hour))
|
||||
return `Every ${dayName(dayOfWeek)} at ${formatCronTime(minute, hour)}`
|
||||
}
|
||||
|
||||
if (option.value === 'monthly') {
|
||||
return c.monthlyOnDayAt(dayOfMonth, formatCronTime(minute, hour))
|
||||
return `Monthly on day ${dayOfMonth} at ${formatCronTime(minute, hour)}`
|
||||
}
|
||||
|
||||
if (option.value === 'hourly') {
|
||||
return minute === '0' ? c.topOfHour : c.everyHourAt(minute.padStart(2, '0'))
|
||||
return minute === '0' ? 'At the top of every hour' : `Every hour at :${minute.padStart(2, '0')}`
|
||||
}
|
||||
|
||||
return c.scheduleHints[option.value] ?? ''
|
||||
return option.hint
|
||||
}
|
||||
|
||||
function formatTime(iso?: null | string): string {
|
||||
@@ -259,35 +301,26 @@ function matchesQuery(job: CronJob, q: string): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
interface CronViewProps extends React.ComponentProps<'section'> {
|
||||
interface CronViewProps {
|
||||
onClose: () => void
|
||||
setStatusbarItemGroup?: SetStatusbarItemGroup
|
||||
}
|
||||
|
||||
export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: CronViewProps) {
|
||||
const { t } = useI18n()
|
||||
const c = t.cron
|
||||
export function CronView({ onClose }: CronViewProps) {
|
||||
const [jobs, setJobs] = useState<CronJob[] | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [busyJobId, setBusyJobId] = useState<null | string>(null)
|
||||
|
||||
const [editor, setEditor] = useState<EditorState>({ mode: 'closed' })
|
||||
const [pendingDelete, setPendingDelete] = useState<CronJob | null>(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
|
||||
try {
|
||||
const result = await getCronJobs()
|
||||
setJobs(result)
|
||||
} catch (err) {
|
||||
notifyError(err, c.failedLoad)
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
notifyError(err, 'Failed to load cron jobs')
|
||||
}
|
||||
}, [c])
|
||||
}, [])
|
||||
|
||||
useRefreshHotkey(refresh)
|
||||
|
||||
@@ -315,11 +348,11 @@ export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGrou
|
||||
setJobs(current => (current ? current.map(row => (row.id === job.id ? updated : row)) : current))
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: isPaused ? c.resumed : c.paused,
|
||||
title: isPaused ? 'Cron resumed' : 'Cron paused',
|
||||
message: truncate(jobTitle(job), 60)
|
||||
})
|
||||
} catch (err) {
|
||||
notifyError(err, c.failedUpdate)
|
||||
notifyError(err, 'Failed to update cron job')
|
||||
} finally {
|
||||
setBusyJobId(null)
|
||||
}
|
||||
@@ -331,33 +364,14 @@ export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGrou
|
||||
try {
|
||||
const updated = await triggerCronJob(job.id)
|
||||
setJobs(current => (current ? current.map(row => (row.id === job.id ? updated : row)) : current))
|
||||
notify({ kind: 'success', title: c.triggered, message: truncate(jobTitle(job), 60) })
|
||||
notify({ kind: 'success', title: 'Cron triggered', message: truncate(jobTitle(job), 60) })
|
||||
} catch (err) {
|
||||
notifyError(err, c.failedTrigger)
|
||||
notifyError(err, 'Failed to trigger cron job')
|
||||
} finally {
|
||||
setBusyJobId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfirmDelete() {
|
||||
if (!pendingDelete) {
|
||||
return
|
||||
}
|
||||
|
||||
setDeleting(true)
|
||||
|
||||
try {
|
||||
await deleteCronJob(pendingDelete.id)
|
||||
setJobs(current => (current ? current.filter(row => row.id !== pendingDelete.id) : current))
|
||||
notify({ kind: 'success', title: c.deleted, message: truncate(jobTitle(pendingDelete), 60) })
|
||||
setPendingDelete(null)
|
||||
} catch (err) {
|
||||
notifyError(err, c.failedDelete)
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEditorSave(values: EditorValues) {
|
||||
if (editor.mode === 'create') {
|
||||
const created = await createCronJob({
|
||||
@@ -368,7 +382,7 @@ export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGrou
|
||||
})
|
||||
|
||||
setJobs(current => (current ? [...current, created] : [created]))
|
||||
notify({ kind: 'success', title: c.created, message: truncate(jobTitle(created), 60) })
|
||||
notify({ kind: 'success', title: 'Cron created', message: truncate(jobTitle(created), 60) })
|
||||
} else if (editor.mode === 'edit') {
|
||||
const updated = await updateCronJob(editor.job.id, {
|
||||
prompt: values.prompt,
|
||||
@@ -378,67 +392,61 @@ export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGrou
|
||||
})
|
||||
|
||||
setJobs(current => (current ? current.map(row => (row.id === updated.id ? updated : row)) : current))
|
||||
notify({ kind: 'success', title: c.updated, message: truncate(jobTitle(updated), 60) })
|
||||
notify({ kind: 'success', title: 'Cron updated', message: truncate(jobTitle(updated), 60) })
|
||||
}
|
||||
|
||||
setEditor({ mode: 'closed' })
|
||||
}
|
||||
|
||||
return (
|
||||
<OverlayView closeLabel={c.close} onClose={onClose}>
|
||||
<PageSearchShell
|
||||
{...props}
|
||||
onSearchChange={setQuery}
|
||||
searchPlaceholder={c.search}
|
||||
searchTrailingAction={
|
||||
<Button
|
||||
aria-label={refreshing ? c.refreshing : c.refresh}
|
||||
className="text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
|
||||
disabled={refreshing}
|
||||
onClick={() => void refresh()}
|
||||
size="icon-xs"
|
||||
title={refreshing ? c.refreshing : c.refresh}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="refresh" size="0.875rem" spinning={refreshing} />
|
||||
</Button>
|
||||
}
|
||||
searchValue={query}
|
||||
>
|
||||
<OverlayView closeLabel="Close cron" onClose={onClose}>
|
||||
<div className="flex min-h-0 flex-1 flex-col pt-[calc(var(--titlebar-height)+0.5rem)]">
|
||||
{totalCount > 0 && (
|
||||
<div className="mx-auto flex w-full max-w-4xl items-center gap-2 px-4 pb-2">
|
||||
<SearchField
|
||||
containerClassName="max-w-[60vw]"
|
||||
onChange={setQuery}
|
||||
placeholder="Search cron jobs…"
|
||||
value={query}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!jobs ? (
|
||||
<PageLoader label={c.loading} />
|
||||
<PageLoader label="Loading cron jobs..." />
|
||||
) : visibleJobs.length === 0 ? (
|
||||
// Empty state owns the primary "create" CTA — we used to also have
|
||||
// one in the filters bar but it was redundant. Only show the button
|
||||
// when there are zero jobs total; the search-empty case ("No
|
||||
// matches") just asks the user to broaden their query.
|
||||
<EmptyState
|
||||
actionLabel={totalCount === 0 ? c.createFirst : undefined}
|
||||
description={totalCount === 0 ? c.emptyDescNew : c.emptyDescSearch}
|
||||
actionLabel={totalCount === 0 ? 'Create first cron' : undefined}
|
||||
description={
|
||||
totalCount === 0
|
||||
? 'Schedule a prompt to run on a cron expression. Hermes will run it and deliver results to the destination you pick.'
|
||||
: 'Try a broader search query.'
|
||||
}
|
||||
onAction={totalCount === 0 ? () => setEditor({ mode: 'create' }) : undefined}
|
||||
title={totalCount === 0 ? c.emptyTitleNew : c.emptyTitleSearch}
|
||||
title={totalCount === 0 ? 'No scheduled jobs yet' : 'No matches'}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full overflow-y-auto px-4 py-3">
|
||||
<div className="mx-auto w-full max-w-4xl min-h-0 flex-1 overflow-y-auto px-4 py-3">
|
||||
{/* Inline header replaces the old top-bar "New cron" button. We
|
||||
still need a single, always-visible affordance to add a job
|
||||
when the list is non-empty (rows themselves only expose
|
||||
edit/pause/trigger/delete). */}
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-[0.7rem] uppercase tracking-wide text-muted-foreground">
|
||||
{c.active(enabledCount, totalCount)}
|
||||
{enabledCount}/{totalCount} active
|
||||
</span>
|
||||
<Button onClick={() => setEditor({ mode: 'create' })} size="sm">
|
||||
<Codicon name="add" />
|
||||
{c.newCron}
|
||||
New cron
|
||||
</Button>
|
||||
</div>
|
||||
<div className="divide-y divide-border/40 rounded-lg border border-border/40 bg-background/70">
|
||||
<div>
|
||||
{visibleJobs.map(job => (
|
||||
<CronJobRow
|
||||
busy={busyJobId === job.id}
|
||||
c={c}
|
||||
job={job}
|
||||
key={job.id}
|
||||
onDelete={() => setPendingDelete(job)}
|
||||
@@ -450,40 +458,42 @@ export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGrou
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<CronEditorDialog editor={editor} onClose={() => setEditor({ mode: 'closed' })} onSave={handleEditorSave} />
|
||||
</div>
|
||||
<CronEditorDialog editor={editor} onClose={() => setEditor({ mode: 'closed' })} onSave={handleEditorSave} />
|
||||
|
||||
<Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{c.deleteTitle}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{pendingDelete ? (
|
||||
<>
|
||||
{c.deleteDescPrefix}
|
||||
<span className="font-medium text-foreground">{truncate(jobTitle(pendingDelete), 60)}</span>
|
||||
{c.deleteDescSuffix}
|
||||
</>
|
||||
) : null}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button disabled={deleting} onClick={() => setPendingDelete(null)} variant="outline">
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button disabled={deleting} onClick={() => void handleConfirmDelete()} variant="destructive">
|
||||
{deleting ? c.deleting : t.common.delete}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</PageSearchShell>
|
||||
<ConfirmDialog
|
||||
busyLabel="Deleting…"
|
||||
confirmLabel="Delete"
|
||||
description={
|
||||
pendingDelete ? (
|
||||
<>
|
||||
This will remove{' '}
|
||||
<span className="font-medium text-foreground">{truncate(jobTitle(pendingDelete), 60)}</span> permanently.
|
||||
It will stop firing immediately.
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
destructive
|
||||
doneLabel="Deleted"
|
||||
onClose={() => setPendingDelete(null)}
|
||||
onConfirm={async () => {
|
||||
if (!pendingDelete) {
|
||||
return
|
||||
}
|
||||
|
||||
await deleteCronJob(pendingDelete.id)
|
||||
setJobs(current => (current ? current.filter(row => row.id !== pendingDelete.id) : current))
|
||||
notify({ kind: 'success', message: truncate(jobTitle(pendingDelete), 60), title: 'Cron deleted' })
|
||||
}}
|
||||
open={pendingDelete !== null}
|
||||
title="Delete cron job?"
|
||||
/>
|
||||
</OverlayView>
|
||||
)
|
||||
}
|
||||
|
||||
function CronJobRow({
|
||||
busy,
|
||||
c,
|
||||
job,
|
||||
onDelete,
|
||||
onEdit,
|
||||
@@ -491,7 +501,6 @@ function CronJobRow({
|
||||
onTrigger
|
||||
}: {
|
||||
busy: boolean
|
||||
c: Translations['cron']
|
||||
job: CronJob
|
||||
onDelete: () => void
|
||||
onEdit: () => void
|
||||
@@ -507,15 +516,19 @@ function CronJobRow({
|
||||
return (
|
||||
<div className="grid gap-3 px-3 py-2.5 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-start">
|
||||
<button
|
||||
className="min-w-0 cursor-pointer rounded-md text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
|
||||
className="min-w-0 rounded-md text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
|
||||
onClick={onEdit}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">{jobTitle(job)}</span>
|
||||
<StatePill tone={STATE_TONE[state] ?? 'muted'}>{c.states[state] ?? state}</StatePill>
|
||||
<Badge className="capitalize" variant={STATE_VARIANT[state] ?? 'muted'}>
|
||||
{state}
|
||||
</Badge>
|
||||
{deliver && deliver !== DEFAULT_DELIVER && (
|
||||
<StatePill tone="muted">{c.deliveryLabels[deliver] ?? deliver}</StatePill>
|
||||
<Badge className="capitalize" variant="muted">
|
||||
{deliver}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{hasName && prompt && <p className="mt-1 truncate text-xs text-muted-foreground">{truncate(prompt, 120)}</p>}
|
||||
@@ -524,12 +537,8 @@ function CronJobRow({
|
||||
<Clock className="size-3" />
|
||||
{jobScheduleDisplay(job)}
|
||||
</span>
|
||||
<span>
|
||||
{c.last} {formatTime(job.last_run_at)}
|
||||
</span>
|
||||
<span>
|
||||
{c.next} {formatTime(job.next_run_at)}
|
||||
</span>
|
||||
<span>Last: {formatTime(job.last_run_at)}</span>
|
||||
<span>Next: {formatTime(job.next_run_at)}</span>
|
||||
</div>
|
||||
{job.last_error && (
|
||||
<p className="mt-1 inline-flex items-start gap-1 text-[0.68rem] text-destructive">
|
||||
@@ -560,16 +569,6 @@ function CronJobRow({
|
||||
)
|
||||
}
|
||||
|
||||
function StatePill({ children, tone }: { children: string; tone: keyof typeof PILL_TONE }) {
|
||||
return (
|
||||
<span
|
||||
className={cn('inline-flex items-center rounded-full px-1.5 py-0.5 text-[0.64rem] capitalize', PILL_TONE[tone])}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState({
|
||||
actionLabel,
|
||||
description,
|
||||
@@ -606,8 +605,6 @@ function CronEditorDialog({
|
||||
onClose: () => void
|
||||
onSave: (values: EditorValues) => Promise<void>
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const c = t.cron
|
||||
const open = editor.mode !== 'closed'
|
||||
const isEdit = editor.mode === 'edit'
|
||||
const initial = isEdit ? editor.job : null
|
||||
@@ -650,7 +647,7 @@ function CronEditorDialog({
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleHint = scheduleSummary(selectedScheduleOption, schedule, c)
|
||||
const scheduleHint = scheduleSummary(selectedScheduleOption, schedule)
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
@@ -658,7 +655,7 @@ function CronEditorDialog({
|
||||
const trimmedSchedule = schedule.trim()
|
||||
|
||||
if (!trimmedPrompt || !trimmedSchedule) {
|
||||
setError(c.promptScheduleRequired)
|
||||
setError('Prompt and schedule are required.')
|
||||
|
||||
return
|
||||
}
|
||||
@@ -674,7 +671,7 @@ function CronEditorDialog({
|
||||
schedule: trimmedSchedule
|
||||
})
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : c.failedSave)
|
||||
setError(err instanceof Error ? err.message : 'Failed to save cron job')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -684,56 +681,60 @@ function CronEditorDialog({
|
||||
<Dialog onOpenChange={value => !value && !saving && onClose()} open={open}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? c.editTitle : c.createTitle}</DialogTitle>
|
||||
<DialogDescription>{isEdit ? c.editDesc : c.createDesc}</DialogDescription>
|
||||
<DialogTitle>{isEdit ? 'Edit cron job' : 'New cron job'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit
|
||||
? 'Update the schedule, prompt, or delivery target. Changes apply on next run.'
|
||||
: 'Schedule a prompt to run automatically. Use cron syntax or a natural phrase like "every 15 minutes".'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form className="grid gap-4" onSubmit={handleSubmit}>
|
||||
<Field htmlFor="cron-name" label={c.nameLabel} optional optionalLabel={c.optional}>
|
||||
<Field htmlFor="cron-name" label="Name" optional>
|
||||
<Input
|
||||
autoFocus
|
||||
id="cron-name"
|
||||
onChange={event => setName(event.target.value)}
|
||||
placeholder={c.namePlaceholder}
|
||||
placeholder="Morning briefing"
|
||||
value={name}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field htmlFor="cron-prompt" label={c.promptLabel}>
|
||||
<Field htmlFor="cron-prompt" label="Prompt">
|
||||
<Textarea
|
||||
className="min-h-24 font-mono"
|
||||
id="cron-prompt"
|
||||
onChange={event => setPrompt(event.target.value)}
|
||||
placeholder={c.promptPlaceholder}
|
||||
placeholder="Summarize my unread Slack threads and email me the top 5..."
|
||||
value={prompt}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="grid items-start gap-4 sm:grid-cols-2">
|
||||
<Field htmlFor="cron-frequency" label={c.frequencyLabel}>
|
||||
<Field htmlFor="cron-frequency" label="Frequency">
|
||||
<Select onValueChange={handleSchedulePresetChange} value={schedulePreset}>
|
||||
<SelectTrigger className="h-9 rounded-md" id="cron-frequency">
|
||||
<SelectTrigger id="cron-frequency">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SCHEDULE_OPTIONS.map(option => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{c.scheduleLabels[option.value]}
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field htmlFor="cron-deliver" label={c.deliverLabel}>
|
||||
<Field htmlFor="cron-deliver" label="Deliver to">
|
||||
<Select onValueChange={setDeliver} value={deliver}>
|
||||
<SelectTrigger className="h-9 rounded-md" id="cron-deliver">
|
||||
<SelectTrigger id="cron-deliver">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DELIVERY_VALUES.map(value => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{c.deliveryLabels[value]}
|
||||
{DELIVERY_OPTIONS.map(option => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -742,15 +743,15 @@ function CronEditorDialog({
|
||||
</div>
|
||||
|
||||
{schedulePreset === 'custom' ? (
|
||||
<Field htmlFor="cron-schedule" label={c.customScheduleLabel}>
|
||||
<Field htmlFor="cron-schedule" label="Custom schedule">
|
||||
<Input
|
||||
className="font-mono"
|
||||
id="cron-schedule"
|
||||
onChange={event => setSchedule(event.target.value)}
|
||||
placeholder={c.customPlaceholder}
|
||||
placeholder="0 9 * * * or weekdays at 9am"
|
||||
value={schedule}
|
||||
/>
|
||||
<FieldHint>{c.customHint}</FieldHint>
|
||||
<FieldHint>Cron expression, or phrases like "every hour" or "weekdays at 9am".</FieldHint>
|
||||
</Field>
|
||||
) : (
|
||||
<div className="rounded-md border border-border/60 bg-muted/30 px-3 py-2">
|
||||
@@ -770,10 +771,10 @@ function CronEditorDialog({
|
||||
|
||||
<DialogFooter>
|
||||
<Button disabled={saving} onClick={onClose} type="button" variant="outline">
|
||||
{t.common.cancel}
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={saving} type="submit">
|
||||
{saving ? t.common.saving : isEdit ? c.saveChanges : c.createAction}
|
||||
{saving ? 'Saving...' : isEdit ? 'Save changes' : 'Create cron'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
@@ -786,20 +787,18 @@ function Field({
|
||||
children,
|
||||
htmlFor,
|
||||
label,
|
||||
optional,
|
||||
optionalLabel
|
||||
optional
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
htmlFor: string
|
||||
label: string
|
||||
optional?: boolean
|
||||
optionalLabel?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<label className="flex items-baseline gap-2 text-xs font-medium text-foreground" htmlFor={htmlFor}>
|
||||
{label}
|
||||
{optional && <span className="text-[0.65rem] font-normal text-muted-foreground">{optionalLabel}</span>}
|
||||
{optional && <span className="text-[0.65rem] font-normal text-muted-foreground">Optional</span>}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
@@ -821,5 +820,7 @@ interface EditorValues {
|
||||
|
||||
interface ScheduleOption {
|
||||
expr?: string
|
||||
hint: string
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
unpinSession
|
||||
} from '../store/layout'
|
||||
import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview'
|
||||
import { $activeGatewayProfile, $freshSessionRequest, normalizeProfileKey, refreshActiveProfile } from '../store/profile'
|
||||
import { $freshSessionRequest, normalizeProfileKey, refreshActiveProfile } from '../store/profile'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$currentCwd,
|
||||
@@ -506,25 +506,6 @@ export function DesktopController() {
|
||||
startFreshSessionDraft()
|
||||
}, [freshSessionRequest, startFreshSessionDraft])
|
||||
|
||||
// Swapping the live gateway to another profile must re-pull that profile's
|
||||
// global model + active-profile pill. Both are nanostores, so the blanket
|
||||
// invalidateQueries() the profile store fires on swap doesn't touch them —
|
||||
// without this the statusbar keeps showing the previous profile's model
|
||||
// (the "forgets the LLM setting" report). gatewayState stays 'open' across a
|
||||
// swap (background sockets persist), so the open→open effect won't re-run.
|
||||
const activeGatewayProfile = useStore($activeGatewayProfile)
|
||||
const lastGatewayProfileRef = useRef(activeGatewayProfile)
|
||||
|
||||
useEffect(() => {
|
||||
if (activeGatewayProfile === lastGatewayProfileRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
lastGatewayProfileRef.current = activeGatewayProfile
|
||||
void refreshCurrentModel()
|
||||
void refreshActiveProfile()
|
||||
}, [activeGatewayProfile, refreshCurrentModel])
|
||||
|
||||
const composer = useComposerActions({
|
||||
activeSessionId,
|
||||
currentCwd,
|
||||
@@ -700,7 +681,6 @@ export function DesktopController() {
|
||||
initialSection={commandCenterInitialSection}
|
||||
onClose={closeOverlayToPreviousRoute}
|
||||
onDeleteSession={removeSession}
|
||||
onNavigateRoute={path => navigate(path)}
|
||||
onOpenSession={sessionId => navigate(sessionRoute(sessionId))}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useEffect, useRef } from 'react'
|
||||
|
||||
import type { HermesConnection } from '@/global'
|
||||
import { HermesGateway } from '@/hermes'
|
||||
import { translateNow } from '@/i18n'
|
||||
import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@/lib/gateway-ws-url'
|
||||
import {
|
||||
$desktopBoot,
|
||||
@@ -152,7 +151,7 @@ export function useGatewayBoot({
|
||||
// backoff in the finally block below.
|
||||
if (!cancelled && isGatewayReauthRequired(err) && !reauthNotified) {
|
||||
reauthNotified = true
|
||||
notifyError(err, translateNow('boot.errors.gatewaySignInRequired'))
|
||||
notifyError(err, 'Gateway sign-in required')
|
||||
}
|
||||
} finally {
|
||||
reconnecting = false
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { StatusDot, type StatusTone } from '@/components/status-dot'
|
||||
import { Badge, type BadgeProps } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -13,7 +14,6 @@ import {
|
||||
type MessagingPlatformInfo,
|
||||
updateMessagingPlatform
|
||||
} from '@/hermes'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { AlertTriangle, ExternalLink, Save, Trash2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
@@ -33,15 +33,31 @@ interface MessagingViewProps extends React.ComponentProps<'section'> {
|
||||
|
||||
type EditMap = Record<string, Record<string, string>>
|
||||
|
||||
const PILL_TONE: Record<StatusTone, string> = {
|
||||
good: 'bg-primary/10 text-primary',
|
||||
muted: 'bg-muted text-muted-foreground',
|
||||
warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-300',
|
||||
bad: 'bg-destructive/10 text-destructive'
|
||||
const STATE_LABELS: Record<string, string> = {
|
||||
connected: 'Connected',
|
||||
connecting: 'Connecting',
|
||||
disabled: 'Disabled',
|
||||
fatal: 'Error',
|
||||
gateway_stopped: 'Messaging gateway stopped',
|
||||
not_configured: 'Needs setup',
|
||||
pending_restart: 'Restart needed',
|
||||
retrying: 'Retrying',
|
||||
startup_failed: 'Startup failed'
|
||||
}
|
||||
|
||||
const stateLabel = (state: null | string | undefined, m: Translations['messaging']) =>
|
||||
state ? m.states[state] || state.replace(/_/g, ' ') : m.unknown
|
||||
const TONE_VARIANT: Record<StatusTone, BadgeProps['variant']> = {
|
||||
good: 'default',
|
||||
muted: 'muted',
|
||||
warn: 'warn',
|
||||
bad: 'destructive'
|
||||
}
|
||||
|
||||
const HINT_BY_STATE: Record<string, string> = {
|
||||
pending_restart: 'Restart the gateway from the status bar to apply this change.',
|
||||
gateway_stopped: 'Start the gateway from the status bar to connect.'
|
||||
}
|
||||
|
||||
const stateLabel = (state?: null | string) => (state ? STATE_LABELS[state] || state.replace(/_/g, ' ') : 'Unknown')
|
||||
|
||||
function stateTone({ enabled, state }: MessagingPlatformInfo): StatusTone {
|
||||
if (!enabled) {
|
||||
@@ -70,7 +86,7 @@ const FIELD_COPY: Record<string, { advanced?: boolean; help?: string; label: str
|
||||
TELEGRAM_BOT_TOKEN: {
|
||||
label: 'Bot token',
|
||||
help: 'Create a bot with @BotFather, then paste the token it gives you.',
|
||||
placeholder: 'Paste Telegram bot token'
|
||||
placeholder: '123456:ABC...'
|
||||
},
|
||||
TELEGRAM_ALLOWED_USERS: {
|
||||
label: 'Allowed Telegram user IDs',
|
||||
@@ -137,13 +153,13 @@ const FIELD_COPY: Record<string, { advanced?: boolean; help?: string; label: str
|
||||
},
|
||||
SLACK_BOT_TOKEN: {
|
||||
label: 'Slack bot token',
|
||||
help: 'Use the bot token from OAuth & Permissions after installing your Slack app.',
|
||||
placeholder: 'Paste Slack bot token'
|
||||
help: 'Starts with xoxb-. Found under OAuth & Permissions after installing your Slack app.',
|
||||
placeholder: 'xoxb-...'
|
||||
},
|
||||
SLACK_APP_TOKEN: {
|
||||
label: 'Slack app token',
|
||||
help: 'Use the app-level token required for Socket Mode.',
|
||||
placeholder: 'Paste Slack app token'
|
||||
help: 'Starts with xapp-. Required for Socket Mode.',
|
||||
placeholder: 'xapp-...'
|
||||
},
|
||||
SLACK_ALLOWED_USERS: {
|
||||
label: 'Allowed Slack user IDs',
|
||||
@@ -203,21 +219,18 @@ const FIELD_COPY: Record<string, { advanced?: boolean; help?: string; label: str
|
||||
}
|
||||
}
|
||||
|
||||
function fieldCopy(field: MessagingEnvVarInfo, m: Translations['messaging']) {
|
||||
function fieldCopy(field: MessagingEnvVarInfo) {
|
||||
const copy = FIELD_COPY[field.key] || {}
|
||||
const localized = m.fieldCopy[field.key] || {}
|
||||
|
||||
return {
|
||||
label: localized.label || copy.label || field.prompt || field.key,
|
||||
help: localized.help || copy.help || field.description,
|
||||
placeholder: localized.placeholder || copy.placeholder || field.prompt,
|
||||
label: copy.label || field.prompt || field.key,
|
||||
help: copy.help || field.description,
|
||||
placeholder: copy.placeholder || field.prompt,
|
||||
advanced: Boolean(copy.advanced || field.advanced)
|
||||
}
|
||||
}
|
||||
|
||||
export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: MessagingViewProps) {
|
||||
const { t } = useI18n()
|
||||
const m = t.messaging
|
||||
const [platforms, setPlatforms] = useState<MessagingPlatformInfo[] | null>(null)
|
||||
const [edits, setEdits] = useState<EditMap>({})
|
||||
const [query, setQuery] = useState('')
|
||||
@@ -236,14 +249,14 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
setPlatforms(result.platforms)
|
||||
} catch (err) {
|
||||
if (!silent) {
|
||||
notifyError(err, m.loadFailed)
|
||||
notifyError(err, 'Messaging platforms failed to load')
|
||||
}
|
||||
} finally {
|
||||
if (!silent) {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
}, [m])
|
||||
}, [])
|
||||
|
||||
useRefreshHotkey(() => void refreshPlatforms())
|
||||
|
||||
@@ -317,11 +330,11 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
)
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: enabled ? m.platformEnabled(platform.name) : m.platformDisabled(platform.name),
|
||||
message: m.restartToApply
|
||||
title: enabled ? `${platform.name} enabled` : `${platform.name} disabled`,
|
||||
message: 'Restart the gateway for this change to take effect.'
|
||||
})
|
||||
} catch (err) {
|
||||
notifyError(err, m.failedUpdate(platform.name))
|
||||
notifyError(err, `Failed to update ${platform.name}`)
|
||||
} finally {
|
||||
setSaving(null)
|
||||
}
|
||||
@@ -342,11 +355,11 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
await refreshPlatforms()
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: m.setupSaved(platform.name),
|
||||
message: m.restartToReconnect
|
||||
title: `${platform.name} setup saved`,
|
||||
message: 'Restart the gateway to reconnect with the new credentials.'
|
||||
})
|
||||
} catch (err) {
|
||||
notifyError(err, m.failedSave(platform.name))
|
||||
notifyError(err, `Failed to save ${platform.name}`)
|
||||
} finally {
|
||||
setSaving(null)
|
||||
}
|
||||
@@ -365,9 +378,9 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
}
|
||||
}))
|
||||
await refreshPlatforms()
|
||||
notify({ kind: 'success', title: m.keyCleared(key), message: m.setupUpdated(platform.name) })
|
||||
notify({ kind: 'success', title: `${key} cleared`, message: `${platform.name} setup was updated.` })
|
||||
} catch (err) {
|
||||
notifyError(err, m.failedClear(key))
|
||||
notifyError(err, `Failed to clear ${key}`)
|
||||
} finally {
|
||||
setSaving(null)
|
||||
}
|
||||
@@ -378,11 +391,11 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
{...props}
|
||||
onSearchChange={setQuery}
|
||||
searchHidden={(platforms?.length ?? 0) === 0}
|
||||
searchPlaceholder={m.search}
|
||||
searchPlaceholder="Search messaging..."
|
||||
searchValue={query}
|
||||
>
|
||||
{!platforms ? (
|
||||
<PageLoader label={m.loading} />
|
||||
<PageLoader label="Loading messaging platforms..." />
|
||||
) : (
|
||||
<div className="grid h-full min-h-0 grid-cols-1 lg:grid-cols-[14rem_minmax(0,1fr)]">
|
||||
<aside className="min-h-0 overflow-y-auto p-2">
|
||||
@@ -472,14 +485,12 @@ function PlatformDetail({
|
||||
platform: MessagingPlatformInfo
|
||||
saving: string | null
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const m = t.messaging
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
|
||||
const hasEdits = Object.keys(trimEdits(edits)).length > 0
|
||||
const requiredFields = platform.env_vars.filter(field => field.required)
|
||||
const optionalFields = platform.env_vars.filter(field => !field.required && !fieldCopy(field, m).advanced)
|
||||
const advancedFields = platform.env_vars.filter(field => !field.required && fieldCopy(field, m).advanced)
|
||||
const optionalFields = platform.env_vars.filter(field => !field.required && !fieldCopy(field).advanced)
|
||||
const advancedFields = platform.env_vars.filter(field => !field.required && fieldCopy(field).advanced)
|
||||
const hiddenCount = advancedFields.length
|
||||
const isSavingEnv = saving === `env:${platform.id}`
|
||||
|
||||
@@ -495,11 +506,11 @@ function PlatformDetail({
|
||||
{platform.description}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<StatePill tone={stateTone(platform)}>{stateLabel(platform.state, m)}</StatePill>
|
||||
<StatePill tone={stateTone(platform)}>{stateLabel(platform.state)}</StatePill>
|
||||
<SetupPill active={platform.configured}>
|
||||
{platform.configured ? m.credentialsSet : m.needsSetup}
|
||||
{platform.configured ? 'Credentials set' : 'Needs setup'}
|
||||
</SetupPill>
|
||||
{!platform.gateway_running && <SetupPill active={false}>{m.gatewayStopped}</SetupPill>}
|
||||
{!platform.gateway_running && <SetupPill active={false}>Messaging gateway stopped</SetupPill>}
|
||||
</div>
|
||||
<PlatformHint platform={platform} />
|
||||
</div>
|
||||
@@ -513,14 +524,14 @@ function PlatformDetail({
|
||||
)}
|
||||
|
||||
<section>
|
||||
<SectionTitle>{m.getCredentials}</SectionTitle>
|
||||
<SectionTitle>Get your credentials</SectionTitle>
|
||||
<p className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{introCopy(platform, m)}
|
||||
{introCopy(platform)}
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<Button asChild size="sm" variant="textStrong">
|
||||
<a href={platform.docs_url} rel="noreferrer" target="_blank">
|
||||
{m.openSetupGuide}
|
||||
Open setup guide
|
||||
<ExternalLink className="size-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
@@ -528,7 +539,7 @@ function PlatformDetail({
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionTitle>{m.required}</SectionTitle>
|
||||
<SectionTitle>Required</SectionTitle>
|
||||
<div className="mt-3 grid gap-1">
|
||||
{requiredFields.length > 0 ? (
|
||||
requiredFields.map(field => (
|
||||
@@ -543,7 +554,7 @@ function PlatformDetail({
|
||||
))
|
||||
) : (
|
||||
<p className="text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{m.noTokenNeeded}
|
||||
This platform does not need a token here. Use the setup guide above, then enable it below.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -551,7 +562,7 @@ function PlatformDetail({
|
||||
|
||||
{optionalFields.length > 0 && (
|
||||
<section>
|
||||
<SectionTitle>{m.recommended}</SectionTitle>
|
||||
<SectionTitle>Recommended</SectionTitle>
|
||||
<div className="mt-3 grid gap-1">
|
||||
{optionalFields.map(field => (
|
||||
<MessagingField
|
||||
@@ -574,7 +585,7 @@ function PlatformDetail({
|
||||
onClick={() => setShowAdvanced(value => !value)}
|
||||
type="button"
|
||||
>
|
||||
<span>{m.advanced(hiddenCount)}</span>
|
||||
<span>Advanced ({hiddenCount})</span>
|
||||
<DisclosureCaret open={showAdvanced} size="0.875rem" />
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
@@ -598,23 +609,19 @@ function PlatformDetail({
|
||||
|
||||
<footer className="bg-(--ui-chat-surface-background) px-5 py-2.5">
|
||||
<div className="mx-auto flex max-w-2xl flex-wrap items-center gap-2">
|
||||
<label className="flex shrink-0 items-center gap-2 rounded-md border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-2.5 py-1.5 text-[length:var(--conversation-text-font-size)]">
|
||||
<Switch
|
||||
aria-label={platform.enabled ? m.disableAria(platform.name) : m.enableAria(platform.name)}
|
||||
checked={platform.enabled}
|
||||
disabled={saving === `enabled:${platform.id}`}
|
||||
onCheckedChange={onToggle}
|
||||
/>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{platform.enabled ? m.enabled : m.disabled}
|
||||
</span>
|
||||
</label>
|
||||
<Switch
|
||||
aria-label={platform.enabled ? `Disable ${platform.name}` : `Enable ${platform.name}`}
|
||||
checked={platform.enabled}
|
||||
disabled={saving === `enabled:${platform.id}`}
|
||||
onCheckedChange={onToggle}
|
||||
size="xs"
|
||||
/>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{hasEdits && <span className="text-xs text-muted-foreground">{m.unsavedChanges}</span>}
|
||||
{hasEdits && <span className="text-xs text-muted-foreground">Unsaved changes</span>}
|
||||
<Button disabled={!hasEdits || isSavingEnv} onClick={onSave} size="sm">
|
||||
<Save />
|
||||
{isSavingEnv ? m.saving : m.saveChanges}
|
||||
{isSavingEnv ? 'Saving...' : 'Save changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -629,7 +636,7 @@ const PLATFORM_INTRO: Record<string, string> = {
|
||||
discord:
|
||||
'Open the Discord Developer Portal, create an application, add a Bot, then copy its token. Invite the bot to your server with the right scopes.',
|
||||
slack:
|
||||
'Create a Slack app, enable Socket Mode, install it to your workspace, then copy the bot token and app-level token.',
|
||||
'Create a Slack app, enable Socket Mode, install it to your workspace, then copy the Bot token (xoxb-) and App-level token (xapp-).',
|
||||
mattermost:
|
||||
'On your Mattermost server, create a bot account or personal access token, then paste the server URL and token here.',
|
||||
matrix: 'Sign in to your homeserver with the bot account, then copy the access token, user ID, and homeserver URL.',
|
||||
@@ -660,8 +667,7 @@ const PLATFORM_INTRO: Record<string, string> = {
|
||||
'Run an HTTP server that other tools (GitHub, GitLab, custom apps) can POST to. Use the secret to verify signatures.'
|
||||
}
|
||||
|
||||
const introCopy = (platform: MessagingPlatformInfo, m: Translations['messaging']) =>
|
||||
m.platformIntro[platform.id] || PLATFORM_INTRO[platform.id] || platform.description
|
||||
const introCopy = (platform: MessagingPlatformInfo) => PLATFORM_INTRO[platform.id] || platform.description
|
||||
|
||||
function MessagingField({
|
||||
edits,
|
||||
@@ -676,9 +682,7 @@ function MessagingField({
|
||||
onEdit: (key: string, value: string) => void
|
||||
saving: string | null
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const m = t.messaging
|
||||
const copy = fieldCopy(field, m)
|
||||
const copy = fieldCopy(field)
|
||||
const fieldId = `messaging-field-${field.key}`
|
||||
|
||||
return (
|
||||
@@ -689,12 +693,12 @@ function MessagingField({
|
||||
className={CREDENTIAL_CONTROL_CLASS}
|
||||
id={fieldId}
|
||||
onChange={event => onEdit(field.key, event.target.value)}
|
||||
placeholder={field.is_set ? field.redacted_value || m.replaceValue : copy.placeholder}
|
||||
placeholder={field.is_set ? field.redacted_value || 'Replace current value' : copy.placeholder}
|
||||
type={field.is_password ? 'password' : 'text'}
|
||||
value={edits[field.key] || ''}
|
||||
/>
|
||||
{field.url && (
|
||||
<Button asChild className="size-8 shrink-0" title={m.openDocs} variant="ghost">
|
||||
<Button asChild className="size-8 shrink-0" title="Open docs" variant="ghost">
|
||||
<a href={field.url} rel="noreferrer" target="_blank">
|
||||
<ExternalLink className="size-3.5" />
|
||||
</a>
|
||||
@@ -705,7 +709,7 @@ function MessagingField({
|
||||
className="size-8 shrink-0"
|
||||
disabled={saving === `clear:${field.key}`}
|
||||
onClick={() => onClear(field.key)}
|
||||
title={m.clearField(field.key)}
|
||||
title={`Clear ${field.key}`}
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
@@ -717,7 +721,7 @@ function MessagingField({
|
||||
title={
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<label htmlFor={fieldId}>{copy.label}</label>
|
||||
{field.is_set && <span className="text-[0.66rem] font-medium text-primary">{m.saved}</span>}
|
||||
{field.is_set && <span className="text-[0.66rem] font-medium text-primary">Saved</span>}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
@@ -729,45 +733,24 @@ function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
function PlatformHint({ platform }: { platform: MessagingPlatformInfo }) {
|
||||
const { t } = useI18n()
|
||||
|
||||
if (!platform.enabled || platform.state === 'connected') {
|
||||
return null
|
||||
}
|
||||
|
||||
const hint =
|
||||
platform.state === 'pending_restart'
|
||||
? t.messaging.hintPendingRestart
|
||||
: platform.gateway_running
|
||||
? null
|
||||
: t.messaging.hintGatewayStopped
|
||||
const hint = HINT_BY_STATE[platform.state || ''] || (platform.gateway_running ? null : HINT_BY_STATE.gateway_stopped)
|
||||
|
||||
return hint ? <p className="mt-2 text-xs leading-5 text-muted-foreground">{hint}</p> : null
|
||||
}
|
||||
|
||||
function StatePill({ children, tone }: { children: string; tone: StatusTone }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center gap-1.5 rounded-full px-2 py-0.5 text-[0.66rem] font-medium',
|
||||
PILL_TONE[tone]
|
||||
)}
|
||||
>
|
||||
<Badge variant={TONE_VARIANT[tone]}>
|
||||
<StatusDot tone={tone} />
|
||||
{children}
|
||||
</span>
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function SetupPill({ active, children }: { active: boolean; children: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full px-2 py-0.5 text-[0.66rem] font-medium',
|
||||
PILL_TONE[active ? 'good' : 'muted']
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
return <Badge variant={active ? 'default' : 'muted'}>{children}</Badge>
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { RefObject } from 'react'
|
||||
|
||||
import { SearchField } from '@/components/ui/search-field'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface OverlaySearchInputProps {
|
||||
containerClassName?: string
|
||||
inputRef?: RefObject<HTMLInputElement | null>
|
||||
loading?: boolean
|
||||
onChange: (value: string) => void
|
||||
placeholder: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export function OverlaySearchInput({
|
||||
containerClassName,
|
||||
inputRef,
|
||||
loading = false,
|
||||
onChange,
|
||||
placeholder,
|
||||
value
|
||||
}: OverlaySearchInputProps) {
|
||||
return (
|
||||
<SearchField
|
||||
containerClassName={cn(
|
||||
'rounded-md border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-2 shadow-sm focus-within:border-(--ui-stroke-secondary)',
|
||||
containerClassName
|
||||
)}
|
||||
inputClassName="h-8 text-[0.8125rem]"
|
||||
inputRef={inputRef}
|
||||
loading={loading}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -11,7 +11,6 @@ interface PageSearchShellProps extends React.ComponentProps<'section'> {
|
||||
filters?: ReactNode
|
||||
onSearchChange: (value: string) => void
|
||||
searchPlaceholder: string
|
||||
searchTrailingAction?: ReactNode
|
||||
searchValue: string
|
||||
/** Hide the search field when there's nothing to search (empty dataset). */
|
||||
searchHidden?: boolean
|
||||
@@ -24,7 +23,6 @@ export function PageSearchShell({
|
||||
filters,
|
||||
onSearchChange,
|
||||
searchPlaceholder,
|
||||
searchTrailingAction,
|
||||
searchValue,
|
||||
searchHidden = false,
|
||||
...props
|
||||
@@ -60,7 +58,6 @@ export function PageSearchShell({
|
||||
containerClassName="max-w-[45vw]"
|
||||
onChange={onSearchChange}
|
||||
placeholder={searchPlaceholder}
|
||||
trailingAction={searchTrailingAction}
|
||||
value={searchValue}
|
||||
/>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -331,14 +331,7 @@ export function useSessionActions({
|
||||
// so single-profile users are unaffected).
|
||||
await ensureGatewayProfile($newChatProfile.get())
|
||||
const cwd = $currentCwd.get().trim() || getRememberedWorkspaceCwd()
|
||||
// Pass the owning profile so a new chat under a non-launch profile (global
|
||||
// remote mode) builds its agent + persists against THAT profile's home/db.
|
||||
const newChatProfile = $newChatProfile.get()
|
||||
const created = await requestGateway<SessionCreateResponse>('session.create', {
|
||||
cols: 96,
|
||||
...(cwd && { cwd }),
|
||||
...(newChatProfile ? { profile: newChatProfile } : {})
|
||||
})
|
||||
const created = await requestGateway<SessionCreateResponse>('session.create', { cols: 96, ...(cwd && { cwd }) })
|
||||
const stored = created.stored_session_id ?? null
|
||||
|
||||
if (
|
||||
@@ -460,31 +453,15 @@ export function useSessionActions({
|
||||
clearComposerDraft()
|
||||
clearComposerAttachments()
|
||||
|
||||
try {
|
||||
const usage = await requestGateway<UsageStats>('session.usage', { session_id: cachedRuntimeId })
|
||||
void requestGateway<UsageStats>('session.usage', { session_id: cachedRuntimeId })
|
||||
.then(usage => {
|
||||
if (isCurrentResume() && usage) {
|
||||
setCurrentUsage(current => ({ ...current, ...usage }))
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
|
||||
if (!isCurrentResume()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (usage) {
|
||||
setCurrentUsage(current => ({ ...current, ...usage }))
|
||||
}
|
||||
|
||||
return
|
||||
} catch {
|
||||
// The cached runtime id was minted by a prior backend instance. A
|
||||
// pooled profile backend that gets idle-reaped (pruneSecondaryGateways)
|
||||
// and respawned across a profile swap mints fresh ids, so this mapping
|
||||
// now 404s ("session not found"). Drop it and fall through to a full
|
||||
// resume that rebinds a live runtime id.
|
||||
if (!isCurrentResume()) {
|
||||
return
|
||||
}
|
||||
|
||||
runtimeIdByStoredSessionIdRef.current.delete(storedSessionId)
|
||||
sessionStateByRuntimeIdRef.current.delete(cachedRuntimeId)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
setFreshDraftReady(false)
|
||||
@@ -536,11 +513,7 @@ export function useSessionActions({
|
||||
|
||||
const resumed = await requestGateway<SessionResumeResponse>('session.resume', {
|
||||
session_id: storedSessionId,
|
||||
cols: 96,
|
||||
// Owning profile: in app-global remote mode one backend serves every
|
||||
// profile, so the gateway opens this profile's state.db + home to
|
||||
// resume + persist the right session (no-op for single/launch profile).
|
||||
...(sessionProfile ? { profile: sessionProfile } : {})
|
||||
cols: 96
|
||||
})
|
||||
|
||||
if (!isCurrentResume()) {
|
||||
@@ -774,7 +747,7 @@ export function useSessionActions({
|
||||
await requestGateway('session.close', { session_id: closingRuntimeId }).catch(() => undefined)
|
||||
}
|
||||
|
||||
await deleteSession(storedSessionId, removed?.profile)
|
||||
await deleteSession(storedSessionId)
|
||||
clearQueuedPrompts(storedSessionId)
|
||||
|
||||
if (closingRuntimeId) {
|
||||
@@ -850,7 +823,7 @@ export function useSessionActions({
|
||||
}
|
||||
|
||||
try {
|
||||
await setSessionArchived(storedSessionId, true, archived?.profile)
|
||||
await setSessionArchived(storedSessionId, true)
|
||||
notify({ durationMs: 2_000, kind: 'success', message: 'Archived' })
|
||||
} catch (err) {
|
||||
if (archived) {
|
||||
|
||||
@@ -2,8 +2,7 @@ import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { CheckCircle2, ExternalLink, Loader2, RefreshCw, Sparkles } from '@/lib/icons'
|
||||
import { Loader2, RefreshCw, Sparkles } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$desktopVersion,
|
||||
@@ -19,31 +18,29 @@ import { ListRow, SectionHeading, SettingsContent } from './primitives'
|
||||
|
||||
const RELEASE_NOTES_URL = 'https://github.com/NousResearch/hermes-agent/releases'
|
||||
|
||||
function relativeTime(ms: number | undefined, a: Translations['settings']['about']) {
|
||||
function relativeTime(ms: number | undefined) {
|
||||
if (!ms) {
|
||||
return a.never
|
||||
return 'never'
|
||||
}
|
||||
|
||||
const diff = Date.now() - ms
|
||||
|
||||
if (diff < 60_000) {
|
||||
return a.justNow
|
||||
return 'just now'
|
||||
}
|
||||
|
||||
if (diff < 3_600_000) {
|
||||
return a.minAgo(Math.round(diff / 60_000))
|
||||
return `${Math.round(diff / 60_000)} min ago`
|
||||
}
|
||||
|
||||
if (diff < 86_400_000) {
|
||||
return a.hoursAgo(Math.round(diff / 3_600_000))
|
||||
return `${Math.round(diff / 3_600_000)} hours ago`
|
||||
}
|
||||
|
||||
return a.daysAgo(Math.round(diff / 86_400_000))
|
||||
return `${Math.round(diff / 86_400_000)} days ago`
|
||||
}
|
||||
|
||||
export function AboutSettings() {
|
||||
const { t } = useI18n()
|
||||
const a = t.settings.about
|
||||
const version = useStore($desktopVersion)
|
||||
const status = useStore($updateStatus)
|
||||
const apply = useStore($updateApply)
|
||||
@@ -72,21 +69,21 @@ export function AboutSettings() {
|
||||
let statusTone: 'idle' | 'available' | 'error' = 'idle'
|
||||
|
||||
if (!supported) {
|
||||
statusLine = status?.message ?? a.cantUpdate
|
||||
statusLine = status?.message ?? "This build can't update itself from inside the app."
|
||||
statusTone = 'error'
|
||||
} else if (status?.error) {
|
||||
statusLine = a.cantReach
|
||||
statusLine = "We couldn't reach the update server."
|
||||
statusTone = 'error'
|
||||
} else if (applying) {
|
||||
statusLine = a.installing
|
||||
statusLine = 'An update is currently installing.'
|
||||
statusTone = 'available'
|
||||
} else if (behind > 0) {
|
||||
statusLine = a.updateReady(behind)
|
||||
statusLine = `A new update is ready (${behind} change${behind === 1 ? '' : 's'} included).`
|
||||
statusTone = 'available'
|
||||
} else if (status) {
|
||||
statusLine = a.onLatest
|
||||
statusLine = "You're on the latest version."
|
||||
} else {
|
||||
statusLine = a.tapCheck
|
||||
statusLine = 'Tap "Check now" to look for updates.'
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -96,15 +93,15 @@ export function AboutSettings() {
|
||||
<Sparkles className="size-8" />
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">{a.heading}</h2>
|
||||
<h2 className="text-lg font-semibold tracking-tight">Hermes Desktop</h2>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{version?.appVersion ? a.version(version.appVersion) : a.versionUnavailable}
|
||||
{version?.appVersion ? `Version ${version.appVersion}` : 'Version unavailable'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto mt-4 w-full max-w-2xl">
|
||||
<SectionHeading icon={RefreshCw} title={a.updates} />
|
||||
<SectionHeading icon={RefreshCw} title="Updates" />
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
@@ -114,19 +111,12 @@ export function AboutSettings() {
|
||||
statusTone === 'idle' && 'border-border/70 bg-muted/20 text-foreground'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{statusTone === 'available' ? (
|
||||
<Sparkles className="mt-0.5 size-4 shrink-0 text-primary" />
|
||||
) : statusTone === 'error' ? null : (
|
||||
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-emerald-600 dark:text-emerald-400" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">{statusLine}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{a.lastChecked(relativeTime(status?.fetchedAt, a))}
|
||||
{justChecked && !checking ? a.justNowSuffix : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">{statusLine}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Last checked {relativeTime(status?.fetchedAt)}
|
||||
{justChecked && !checking ? ' · just now' : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-4">
|
||||
@@ -136,13 +126,13 @@ export function AboutSettings() {
|
||||
size="sm"
|
||||
variant="textStrong"
|
||||
>
|
||||
{checking ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
|
||||
{checking ? a.checking : a.checkNow}
|
||||
{checking && <Loader2 className="size-3 animate-spin" />}
|
||||
{checking ? 'Checking…' : 'Check now'}
|
||||
</Button>
|
||||
|
||||
{behind > 0 && supported && !applying && (
|
||||
<Button onClick={() => openUpdatesWindow()} size="sm">
|
||||
{a.seeWhatsNew}
|
||||
See what's new
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -156,17 +146,16 @@ export function AboutSettings() {
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<ExternalLink className="size-3" />
|
||||
{a.releaseNotes}
|
||||
Release notes
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ListRow
|
||||
description={a.automaticUpdatesDesc}
|
||||
hint={a.branchCommit(status?.branch ?? 'unknown', status?.currentSha?.slice(0, 7) ?? 'unknown')}
|
||||
title={a.automaticUpdates}
|
||||
description="Hermes checks for updates automatically in the background and lets you know when one is ready."
|
||||
hint={`Branch ${status?.branch ?? 'unknown'} · Commit ${status?.currentSha?.slice(0, 7) ?? 'unknown'}`}
|
||||
title="Automatic updates"
|
||||
/>
|
||||
</div>
|
||||
</SettingsContent>
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { type Locale, LOCALE_META, useI18n } from '@/i18n'
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Check, Palette } from '@/lib/icons'
|
||||
import { Check } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
|
||||
import { useTheme } from '@/themes/context'
|
||||
import { BUILTIN_THEMES } from '@/themes/presets'
|
||||
|
||||
import { MODE_OPTIONS } from './constants'
|
||||
import { Pill, SectionHeading, SettingsContent } from './primitives'
|
||||
import { SettingsContent } from './primitives'
|
||||
|
||||
function ThemePreview({ name }: { name: string }) {
|
||||
const t = BUILTIN_THEMES[name]
|
||||
@@ -52,193 +52,80 @@ function ThemePreview({ name }: { name: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function SectionHead({ title, description, control }: { title: string; description: string; control?: ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[length:var(--conversation-text-font-size)] font-medium">{title}</div>
|
||||
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
{control && <div className="shrink-0">{control}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppearanceSettings() {
|
||||
const { t, isSavingLocale, locale, setLocale } = useI18n()
|
||||
const { themeName, mode, availableThemes, setTheme, setMode } = useTheme()
|
||||
const toolViewMode = useStore($toolViewMode)
|
||||
const activeTheme = availableThemes.find(theme => theme.name === themeName)
|
||||
const a = t.settings.appearance
|
||||
const locales = Object.keys(LOCALE_META) as Locale[]
|
||||
|
||||
const selectLocale = async (code: Locale) => {
|
||||
if (code === locale || isSavingLocale) {
|
||||
return
|
||||
}
|
||||
|
||||
triggerHaptic('selection')
|
||||
|
||||
try {
|
||||
await setLocale(code)
|
||||
triggerHaptic('success')
|
||||
} catch (error) {
|
||||
notifyError(error, t.language.saveError)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<SectionHeading icon={Palette} title={a.title} />
|
||||
<p className="max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{a.intro}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-8">
|
||||
<p className="max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
These are desktop-only display preferences. Mode controls brightness; theme controls the accent palette and
|
||||
chat surface styling.
|
||||
</p>
|
||||
|
||||
<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) p-3 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{t.language.label}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{t.language.description}</div>
|
||||
{isSavingLocale && <div className="mt-1 text-xs text-muted-foreground">{t.language.saving}</div>}
|
||||
</div>
|
||||
<Pill>{LOCALE_META[locale].name}</Pill>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
{locales.map(code => {
|
||||
const active = locale === code
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'group rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2.5 text-left transition hover:bg-(--chrome-action-hover)',
|
||||
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
|
||||
)}
|
||||
disabled={isSavingLocale}
|
||||
key={code}
|
||||
onClick={() => void selectLocale(code)}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="text-[length:var(--conversation-text-font-size)] font-medium">
|
||||
{LOCALE_META[code].name}
|
||||
</div>
|
||||
{active && (
|
||||
<span className="grid size-5 place-items-center rounded-full bg-primary text-primary-foreground">
|
||||
<Check className="size-3.5" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] uppercase tracking-wide text-(--ui-text-tertiary)">
|
||||
{code}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<section>
|
||||
<SectionHead
|
||||
control={
|
||||
<SegmentedControl
|
||||
onChange={id => {
|
||||
triggerHaptic('crisp')
|
||||
setMode(id)
|
||||
}}
|
||||
options={MODE_OPTIONS}
|
||||
value={mode}
|
||||
/>
|
||||
}
|
||||
description="Pick a fixed mode or let Hermes follow your system setting."
|
||||
title="Color Mode"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) p-3 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{a.colorMode}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{a.colorModeDesc}</div>
|
||||
</div>
|
||||
<Pill>{t.settings.modeOptions[mode].label}</Pill>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
{MODE_OPTIONS.map(({ id, icon: Icon }) => {
|
||||
const active = mode === id
|
||||
const copy = t.settings.modeOptions[id]
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'group rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2.5 text-left transition hover:bg-(--chrome-action-hover)',
|
||||
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
|
||||
)}
|
||||
key={id}
|
||||
onClick={() => {
|
||||
triggerHaptic('crisp')
|
||||
setMode(id)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<span className="flex size-9 items-center justify-center rounded-lg bg-muted text-foreground transition group-hover:bg-background">
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
{active && (
|
||||
<span className="grid size-5 place-items-center rounded-full bg-primary text-primary-foreground">
|
||||
<Check className="size-3.5" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-[length:var(--conversation-text-font-size)] font-medium">{copy.label}</div>
|
||||
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{copy.description}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<section>
|
||||
<SectionHead
|
||||
control={
|
||||
<SegmentedControl
|
||||
onChange={id => {
|
||||
triggerHaptic('selection')
|
||||
setToolViewMode(id)
|
||||
}}
|
||||
options={
|
||||
[
|
||||
{ id: 'product', label: 'Product' },
|
||||
{ id: 'technical', label: 'Technical' }
|
||||
] as const
|
||||
}
|
||||
value={toolViewMode}
|
||||
/>
|
||||
}
|
||||
description="Product hides raw tool payloads; Technical shows full input/output."
|
||||
title="Tool Call Display"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) p-3 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{a.toolViewTitle}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{a.toolViewDesc}</div>
|
||||
</div>
|
||||
<Pill>{toolViewMode === 'technical' ? a.technical : a.product}</Pill>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{(
|
||||
[
|
||||
{ id: 'product', label: a.product, description: a.productDesc },
|
||||
{ id: 'technical', label: a.technical, description: a.technicalDesc }
|
||||
] as const
|
||||
).map(option => {
|
||||
const active = toolViewMode === option.id
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'group rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2.5 text-left transition hover:bg-(--chrome-action-hover)',
|
||||
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
|
||||
)}
|
||||
key={option.id}
|
||||
onClick={() => {
|
||||
triggerHaptic('selection')
|
||||
setToolViewMode(option.id)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="text-[length:var(--conversation-text-font-size)] font-medium">{option.label}</div>
|
||||
{active && (
|
||||
<span className="grid size-5 place-items-center rounded-full bg-primary text-primary-foreground">
|
||||
<Check className="size-3.5" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{option.description}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) p-3 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{a.themeTitle}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{a.themeDesc}</div>
|
||||
</div>
|
||||
{activeTheme && <Pill>{activeTheme.label}</Pill>}
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<section className="grid gap-3">
|
||||
<SectionHead description="Desktop palettes only. The selected mode is applied on top." title="Theme" />
|
||||
<div className="grid gap-x-4 gap-y-5 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{availableThemes.map(theme => {
|
||||
const active = themeName === theme.name
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2 text-left transition hover:bg-(--chrome-action-hover)',
|
||||
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
|
||||
)}
|
||||
className="group text-left"
|
||||
key={theme.name}
|
||||
onClick={() => {
|
||||
triggerHaptic('crisp')
|
||||
@@ -246,8 +133,17 @@ export function AppearanceSettings() {
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<ThemePreview name={theme.name} />
|
||||
<div className="mt-3 flex items-start justify-between gap-3 px-1">
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl transition',
|
||||
active
|
||||
? 'ring-2 ring-primary ring-offset-2 ring-offset-background'
|
||||
: 'opacity-90 group-hover:opacity-100'
|
||||
)}
|
||||
>
|
||||
<ThemePreview name={theme.name} />
|
||||
</div>
|
||||
<div className="mt-2.5 flex items-start justify-between gap-2 px-0.5">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
|
||||
{theme.label}
|
||||
@@ -256,11 +152,7 @@ export function AppearanceSettings() {
|
||||
{theme.description}
|
||||
</div>
|
||||
</div>
|
||||
{active && (
|
||||
<span className="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground">
|
||||
<Check className="size-3.5" />
|
||||
</span>
|
||||
)}
|
||||
{active && <Check className="mt-0.5 size-4 shrink-0 text-primary" />}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
getHermesConfigSchema,
|
||||
saveHermesConfig
|
||||
} from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { ConfigFieldSchema, HermesConfigRecord } from '@/types/hermes'
|
||||
@@ -38,20 +37,9 @@ function ConfigField({
|
||||
optionLabels?: Record<string, string>
|
||||
onChange: (value: unknown) => void
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
|
||||
const label =
|
||||
t.settings.fieldLabels[schemaKey] ?? FIELD_LABELS[schemaKey] ?? prettyName(schemaKey.split('.').pop() ?? schemaKey)
|
||||
|
||||
const label = FIELD_LABELS[schemaKey] ?? prettyName(schemaKey.split('.').pop() ?? schemaKey)
|
||||
const normalize = (v: string) => v.toLowerCase().replace(/[^a-z0-9]+/g, '')
|
||||
|
||||
const rawDescription = (
|
||||
t.settings.fieldDescriptions[schemaKey] ??
|
||||
FIELD_DESCRIPTIONS[schemaKey] ??
|
||||
schema.description ??
|
||||
''
|
||||
).trim()
|
||||
|
||||
const rawDescription = (FIELD_DESCRIPTIONS[schemaKey] ?? schema.description ?? '').trim()
|
||||
const normalizedDesc = normalize(rawDescription)
|
||||
|
||||
const description =
|
||||
|
||||
@@ -241,8 +241,7 @@ export const ENUM_OPTIONS: Record<string, string[]> = {
|
||||
'memory.provider': ['', 'builtin', 'honcho'],
|
||||
'stt.elevenlabs.model_id': ['scribe_v2', 'scribe_v1'],
|
||||
'stt.local.model': ['tiny', 'base', 'small', 'medium', 'large-v3'],
|
||||
'tts.openai.voice': ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer'],
|
||||
'updates.non_interactive_local_changes': ['stash', 'discard']
|
||||
'tts.openai.voice': ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer']
|
||||
}
|
||||
|
||||
export const FIELD_LABELS: Record<string, string> = {
|
||||
@@ -310,8 +309,7 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
'delegation.max_iterations': 'Subagent Turn Limit',
|
||||
'delegation.max_concurrent_children': 'Parallel Subagents',
|
||||
'delegation.child_timeout_seconds': 'Subagent Timeout',
|
||||
'delegation.reasoning_effort': 'Subagent Reasoning Effort',
|
||||
'updates.non_interactive_local_changes': 'In-App Update Local Changes'
|
||||
'delegation.reasoning_effort': 'Subagent Reasoning Effort'
|
||||
}
|
||||
|
||||
export const FIELD_DESCRIPTIONS: Record<string, string> = {
|
||||
@@ -338,9 +336,7 @@ export const FIELD_DESCRIPTIONS: Record<string, string> = {
|
||||
'voice.auto_tts': 'Automatically speak assistant responses.',
|
||||
'stt.enabled': 'Enable local or provider-backed speech transcription.',
|
||||
'stt.elevenlabs.language_code': 'Optional ISO-639-3 language code. Blank lets ElevenLabs auto-detect.',
|
||||
'agent.max_turns': 'Upper bound for tool-calling turns before Hermes stops a run.',
|
||||
'updates.non_interactive_local_changes':
|
||||
'When Hermes updates itself from the app (no terminal prompt), keep local source edits (stash) or throw them away (discard). Terminal updates always ask.'
|
||||
'agent.max_turns': 'Upper bound for tool-calling turns before Hermes stops a run.'
|
||||
}
|
||||
|
||||
// Curated desktop config surface: only fields a user might tune from the app.
|
||||
@@ -453,8 +449,7 @@ export const SECTIONS: DesktopConfigSection[] = [
|
||||
'delegation.max_iterations',
|
||||
'delegation.max_concurrent_children',
|
||||
'delegation.child_timeout_seconds',
|
||||
'delegation.reasoning_effort',
|
||||
'updates.non_interactive_local_changes'
|
||||
'delegation.reasoning_effort'
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -7,7 +6,6 @@ import type { DesktopAuthProvider, DesktopConnectionProbeResult } from '@/global
|
||||
import { AlertCircle, Check, FileText, Globe, Loader2, LogIn, Monitor } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $profiles, refreshActiveProfile } from '@/store/profile'
|
||||
|
||||
import { CONTROL_TEXT } from './constants'
|
||||
import { EmptyState, ListRow, LoadingState, Pill, SettingsContent } from './primitives'
|
||||
@@ -76,23 +74,6 @@ function ModeCard({
|
||||
)
|
||||
}
|
||||
|
||||
function ScopeChip({ active, label, onSelect }: { active: boolean; label: string; onSelect: () => void }) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'rounded-full border px-3 py-1 text-[length:var(--conversation-caption-font-size)] transition',
|
||||
active
|
||||
? 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary) text-(--ui-text-primary)'
|
||||
: 'border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover)'
|
||||
)}
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function GatewaySettings() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
@@ -102,16 +83,6 @@ export function GatewaySettings() {
|
||||
const [remoteToken, setRemoteToken] = useState('')
|
||||
const [lastTest, setLastTest] = useState<null | string>(null)
|
||||
|
||||
// Connection scope: null = the global/default connection (the original
|
||||
// behavior); a profile name = that profile's per-profile remote override, so
|
||||
// each profile can point at its own backend.
|
||||
const [scope, setScope] = useState<null | string>(null)
|
||||
const profiles = useStore($profiles)
|
||||
|
||||
useEffect(() => {
|
||||
void refreshActiveProfile()
|
||||
}, [])
|
||||
|
||||
// Auth-mode probe: as the user types a remote URL we ask the gateway (via
|
||||
// its public /api/status) whether it gates with OAuth or a static session
|
||||
// token, so we can show the right control (login button vs token box).
|
||||
@@ -129,14 +100,8 @@ export function GatewaySettings() {
|
||||
return () => void (cancelled = true)
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
// Clear scope-local entry state so a token from one scope can't leak into
|
||||
// the next when switching profiles.
|
||||
setRemoteToken('')
|
||||
setLastTest(null)
|
||||
|
||||
desktop
|
||||
.getConnectionConfig(scope)
|
||||
.getConnectionConfig()
|
||||
.then(config => {
|
||||
if (cancelled) {
|
||||
return
|
||||
@@ -152,7 +117,7 @@ export function GatewaySettings() {
|
||||
})
|
||||
|
||||
return () => void (cancelled = true)
|
||||
}, [scope])
|
||||
}, [])
|
||||
|
||||
// Debounced probe of the entered remote URL. Only runs in remote mode with a
|
||||
// syntactically plausible URL. The probe result drives whether we render the
|
||||
@@ -258,10 +223,6 @@ export function GatewaySettings() {
|
||||
return providers.length > 0 && providers.every(p => p.supportsPassword)
|
||||
}, [probe])
|
||||
|
||||
// The 'default' profile uses the global ("All profiles") connection, so the
|
||||
// per-profile scopes are the named, non-default profiles.
|
||||
const namedProfiles = useMemo(() => profiles.filter(profile => profile.name !== 'default'), [profiles])
|
||||
|
||||
const oauthConnected = state.remoteOauthConnected
|
||||
|
||||
const canUseRemote = useMemo(() => {
|
||||
@@ -278,7 +239,6 @@ export function GatewaySettings() {
|
||||
|
||||
const payload = () => ({
|
||||
mode: state.mode,
|
||||
profile: scope ?? undefined,
|
||||
remoteAuthMode: authMode,
|
||||
remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined,
|
||||
remoteUrl: trimmedUrl
|
||||
@@ -336,7 +296,6 @@ export function GatewaySettings() {
|
||||
// oauth mode is persisted, without yet flipping the live connection.
|
||||
const saved = await window.hermesDesktop.saveConnectionConfig({
|
||||
mode: state.mode,
|
||||
profile: scope ?? undefined,
|
||||
remoteAuthMode: 'oauth',
|
||||
remoteUrl: trimmedUrl
|
||||
})
|
||||
@@ -346,7 +305,7 @@ export function GatewaySettings() {
|
||||
const result = await window.hermesDesktop.oauthLoginConnectionConfig(trimmedUrl)
|
||||
|
||||
if (result.connected) {
|
||||
const refreshed = await window.hermesDesktop.getConnectionConfig(scope)
|
||||
const refreshed = await window.hermesDesktop.getConnectionConfig()
|
||||
setState(refreshed)
|
||||
notify({ kind: 'success', title: 'Signed in', message: `Connected to ${providerLabel}.` })
|
||||
} else {
|
||||
@@ -368,7 +327,7 @@ export function GatewaySettings() {
|
||||
|
||||
try {
|
||||
await window.hermesDesktop.oauthLogoutConnectionConfig(trimmedUrl || undefined)
|
||||
const refreshed = await window.hermesDesktop.getConnectionConfig(scope)
|
||||
const refreshed = await window.hermesDesktop.getConnectionConfig()
|
||||
setState(refreshed)
|
||||
notify({ kind: 'success', title: 'Signed out', message: 'Cleared the remote gateway session.' })
|
||||
} catch (err) {
|
||||
@@ -398,7 +357,6 @@ export function GatewaySettings() {
|
||||
try {
|
||||
const result = await window.hermesDesktop.testConnectionConfig({
|
||||
mode: 'remote',
|
||||
profile: scope ?? undefined,
|
||||
remoteAuthMode: authMode,
|
||||
remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined,
|
||||
remoteUrl: trimmedUrl
|
||||
@@ -437,35 +395,10 @@ export function GatewaySettings() {
|
||||
</div>
|
||||
<p className="mt-2 max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
Hermes Desktop starts its own local gateway by default. Use a remote gateway when you want this app to control
|
||||
an already-running Hermes backend on another machine or behind a trusted proxy. Pick a profile below to give it
|
||||
its own remote host.
|
||||
an already-running Hermes backend on another machine or behind a trusted proxy.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{namedProfiles.length > 0 ? (
|
||||
<div className="mb-5 grid gap-2">
|
||||
<div className="text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-secondary)">
|
||||
Applies to
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<ScopeChip active={scope === null} label="All profiles" onSelect={() => setScope(null)} />
|
||||
{namedProfiles.map(profile => (
|
||||
<ScopeChip
|
||||
active={scope === profile.name}
|
||||
key={profile.name}
|
||||
label={profile.name}
|
||||
onSelect={() => setScope(profile.name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{scope === null
|
||||
? 'Default connection for every profile that has no override of its own.'
|
||||
: `Connection used only when “${scope}” is the active profile. Set it to Local to inherit the default.`}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.envOverride ? (
|
||||
<div className="mb-5 flex items-start gap-2 rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2.5 text-[length:var(--conversation-caption-font-size)] text-destructive">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useRef } from 'react'
|
||||
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Archive, Globe, Info, KeyRound, Settings2, Sparkles, Wrench, Zap } from '@/lib/icons'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
@@ -35,7 +34,6 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
|
||||
]
|
||||
|
||||
export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) {
|
||||
const { t } = useI18n()
|
||||
const [activeView, setActiveView] = useRouteEnumParam('tab', SETTINGS_VIEWS, 'config:model' as SettingsViewId)
|
||||
// Providers subnav (Accounts vs API keys) lives in its own param so each
|
||||
// sub-view is deep-linkable and survives a refresh.
|
||||
@@ -66,12 +64,12 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
||||
URL.revokeObjectURL(url)
|
||||
triggerHaptic('success')
|
||||
} catch (err) {
|
||||
notifyError(err, t.settings.exportFailed)
|
||||
notifyError(err, 'Export failed')
|
||||
}
|
||||
}
|
||||
|
||||
const resetConfig = async () => {
|
||||
if (!window.confirm(t.settings.resetConfirm)) {
|
||||
if (!window.confirm('Reset all settings to Hermes defaults?')) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -80,12 +78,12 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
||||
triggerHaptic('success')
|
||||
onConfigSaved?.()
|
||||
} catch (err) {
|
||||
notifyError(err, t.settings.resetFailed)
|
||||
notifyError(err, 'Reset failed')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<OverlayView closeLabel={t.settings.closeSettings} onClose={onClose}>
|
||||
<OverlayView closeLabel="Close settings" onClose={onClose}>
|
||||
<OverlaySplitLayout>
|
||||
<OverlaySidebar>
|
||||
{SECTIONS.map(s => {
|
||||
@@ -96,7 +94,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
||||
active={activeView === view}
|
||||
icon={s.icon}
|
||||
key={s.id}
|
||||
label={t.settings.sections[s.id] ?? s.label}
|
||||
label={s.label}
|
||||
onClick={() => setActiveView(view)}
|
||||
/>
|
||||
)
|
||||
@@ -129,13 +127,13 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
||||
<OverlayNavItem
|
||||
active={activeView === 'gateway'}
|
||||
icon={Globe}
|
||||
label={t.settings.nav.gateway}
|
||||
label="Gateway"
|
||||
onClick={() => setActiveView('gateway')}
|
||||
/>
|
||||
<OverlayNavItem
|
||||
active={activeView === 'keys'}
|
||||
icon={KeyRound}
|
||||
label={t.settings.nav.apiKeys}
|
||||
label="Tools & Keys"
|
||||
onClick={() => setActiveView('keys')}
|
||||
/>
|
||||
{activeView === 'keys' && (
|
||||
@@ -159,29 +157,29 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
||||
<OverlayNavItem
|
||||
active={activeView === 'mcp'}
|
||||
icon={Wrench}
|
||||
label={t.settings.nav.mcp}
|
||||
label="MCP"
|
||||
onClick={() => setActiveView('mcp')}
|
||||
/>
|
||||
<OverlayNavItem
|
||||
active={activeView === 'sessions'}
|
||||
icon={Archive}
|
||||
label={t.settings.nav.archivedChats}
|
||||
label="Archived Chats"
|
||||
onClick={() => setActiveView('sessions')}
|
||||
/>
|
||||
<div className="my-2 h-px bg-border/30" />
|
||||
<OverlayNavItem
|
||||
active={activeView === 'about'}
|
||||
icon={Info}
|
||||
label={t.settings.nav.about}
|
||||
label="About"
|
||||
onClick={() => setActiveView('about')}
|
||||
/>
|
||||
<div className="mt-auto flex items-center gap-1 pt-2">
|
||||
<Tip label={t.settings.exportConfig}>
|
||||
<Tip label="Export config">
|
||||
<OverlayIconButton onClick={() => void exportConfig()}>
|
||||
<IconDownload className="size-3.5" />
|
||||
</OverlayIconButton>
|
||||
</Tip>
|
||||
<Tip label={t.settings.importConfig}>
|
||||
<Tip label="Import config">
|
||||
<OverlayIconButton
|
||||
onClick={() => {
|
||||
triggerHaptic('open')
|
||||
@@ -191,7 +189,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
||||
<IconUpload className="size-3.5" />
|
||||
</OverlayIconButton>
|
||||
</Tip>
|
||||
<Tip label={t.settings.resetToDefaults}>
|
||||
<Tip label="Reset to defaults">
|
||||
<OverlayIconButton
|
||||
className="hover:text-destructive"
|
||||
onClick={() => {
|
||||
|
||||
@@ -57,7 +57,7 @@ export function SessionsSettings() {
|
||||
setBusyId(session.id)
|
||||
|
||||
try {
|
||||
await setSessionArchived(session.id, false, session.profile)
|
||||
await setSessionArchived(session.id, false)
|
||||
setLocalSessions(prev => prev.filter(s => s.id !== session.id))
|
||||
// Surface it again in the sidebar without waiting for a full refresh.
|
||||
setSessions(prev => [{ ...session, archived: false }, ...prev.filter(s => s.id !== session.id)])
|
||||
@@ -78,7 +78,7 @@ export function SessionsSettings() {
|
||||
setBusyId(session.id)
|
||||
|
||||
try {
|
||||
await deleteSession(session.id, session.profile)
|
||||
await deleteSession(session.id)
|
||||
setLocalSessions(prev => prev.filter(s => s.id !== session.id))
|
||||
triggerHaptic('warning')
|
||||
} catch (err) {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $hapticsMuted, toggleHapticsMuted } from '@/store/haptics'
|
||||
@@ -45,7 +44,6 @@ interface TitlebarControlsProps extends ComponentProps<'div'> {
|
||||
}
|
||||
|
||||
export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: TitlebarControlsProps) {
|
||||
const { t } = useI18n()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const hapticsMuted = useStore($hapticsMuted)
|
||||
@@ -78,7 +76,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
||||
{
|
||||
icon: <Codicon name="layout-sidebar-left" />,
|
||||
id: 'sidebar',
|
||||
label: leftEdge.open ? t.titlebar.hideSidebar : t.titlebar.showSidebar,
|
||||
label: `${leftEdge.open ? 'Hide' : 'Show'} left sidebar`,
|
||||
onSelect: () => {
|
||||
triggerHaptic('tap')
|
||||
leftEdge.toggle()
|
||||
@@ -87,12 +85,12 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
||||
{
|
||||
icon: <Codicon name="arrow-swap" />,
|
||||
id: 'flip-panes',
|
||||
label: t.titlebar.swapSidebarSides,
|
||||
label: 'Swap sidebar sides',
|
||||
onSelect: () => {
|
||||
triggerHaptic('tap')
|
||||
togglePanesFlipped()
|
||||
},
|
||||
title: t.titlebar.swapSidebarSidesTitle
|
||||
title: 'Swap the sessions and file browser sides'
|
||||
},
|
||||
...leftTools
|
||||
]
|
||||
@@ -100,7 +98,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
||||
const rightSidebarTool: TitlebarTool = {
|
||||
icon: <Codicon name="layout-sidebar-right" />,
|
||||
id: 'right-sidebar',
|
||||
label: rightEdge.open ? t.titlebar.hideRightSidebar : t.titlebar.showRightSidebar,
|
||||
label: `${rightEdge.open ? 'Hide' : 'Show'} right sidebar`,
|
||||
onSelect: () => {
|
||||
triggerHaptic('tap')
|
||||
rightEdge.toggle()
|
||||
@@ -113,13 +111,13 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
||||
active: hapticsMuted,
|
||||
icon: <Codicon name={hapticsMuted ? 'mute' : 'unmute'} />,
|
||||
id: 'haptics',
|
||||
label: hapticsMuted ? t.titlebar.unmuteHaptics : t.titlebar.muteHaptics,
|
||||
label: hapticsMuted ? 'Unmute haptics' : 'Mute haptics',
|
||||
onSelect: toggleHaptics
|
||||
},
|
||||
{
|
||||
icon: <Codicon name="settings-gear" />,
|
||||
id: 'settings',
|
||||
label: t.titlebar.openSettings,
|
||||
label: 'Open settings',
|
||||
onSelect: () => {
|
||||
triggerHaptic('open')
|
||||
onOpenSettings()
|
||||
@@ -201,7 +199,6 @@ function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType<typeof us
|
||||
onPointerDown={event => event.stopPropagation()}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title={tool.title ?? tool.label}
|
||||
>
|
||||
{tool.icon}
|
||||
</a>
|
||||
@@ -224,7 +221,6 @@ function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType<typeof us
|
||||
}}
|
||||
onPointerDown={event => event.stopPropagation()}
|
||||
size="icon-titlebar"
|
||||
title={tool.title ?? tool.label}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
|
||||
@@ -81,7 +81,7 @@ describe('SkillsView toolset management', () => {
|
||||
|
||||
await renderSkills()
|
||||
|
||||
expect(await screen.findByText('Cron Jobs')).toBeTruthy()
|
||||
expect(screen.getByText('Cron Jobs')).toBeTruthy()
|
||||
expect(screen.queryByText(/⏰/)).toBeNull()
|
||||
})
|
||||
|
||||
|
||||
@@ -3,18 +3,16 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { TextTab, TextTabMeta } from '@/components/ui/text-tab'
|
||||
import { getSkills, getToolsets, toggleSkill, toggleToolset } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { SkillInfo, ToolsetInfo } from '@/types/hermes'
|
||||
|
||||
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
|
||||
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
import { PAGE_INSET_X } from '../layout-constants'
|
||||
import { PageSearchShell } from '../page-search-shell'
|
||||
import { asText, includesQuery, prettyName, toolNames, toolsetDisplayLabel } from '../settings/helpers'
|
||||
import { ToolsetConfigPanel } from '../settings/toolset-config-panel'
|
||||
@@ -72,39 +70,33 @@ interface SkillsViewProps extends React.ComponentProps<'section'> {
|
||||
}
|
||||
|
||||
export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: SkillsViewProps) {
|
||||
const { t } = useI18n()
|
||||
const [mode, setMode] = useRouteEnumParam('tab', SKILLS_MODES, 'skills')
|
||||
|
||||
const [query, setQuery] = useState('')
|
||||
const [skills, setSkills] = useState<SkillInfo[] | null>(null)
|
||||
const [toolsets, setToolsets] = useState<ToolsetInfo[] | null>(null)
|
||||
const [activeCategory, setActiveCategory] = useState<string | null>(null)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [savingSkill, setSavingSkill] = useState<string | null>(null)
|
||||
const [savingToolset, setSavingToolset] = useState<string | null>(null)
|
||||
const [expandedToolset, setExpandedToolset] = useState<string | null>(null)
|
||||
|
||||
const refreshCapabilities = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
|
||||
try {
|
||||
const [nextSkills, nextToolsets] = await Promise.all([getSkills(), getToolsets()])
|
||||
setSkills(nextSkills)
|
||||
setToolsets(nextToolsets)
|
||||
} catch (err) {
|
||||
notifyError(err, t.skills.skillsLoadFailed)
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
notifyError(err, 'Skills failed to load')
|
||||
}
|
||||
}, [t])
|
||||
}, [])
|
||||
|
||||
useRefreshHotkey(refreshCapabilities)
|
||||
|
||||
const refreshToolsets = useCallback(() => {
|
||||
getToolsets()
|
||||
.then(setToolsets)
|
||||
.catch(err => notifyError(err, t.skills.toolsetsRefreshFailed))
|
||||
}, [t])
|
||||
|
||||
useRefreshHotkey(refreshCapabilities)
|
||||
.catch(err => notifyError(err, 'Toolsets failed to refresh'))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void refreshCapabilities()
|
||||
@@ -156,11 +148,11 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
||||
setSkills(current => current?.map(row => (row.name === skill.name ? { ...row, enabled } : row)) ?? current)
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: enabled ? t.skills.skillEnabled : t.skills.skillDisabled,
|
||||
message: t.skills.appliesToNewSessions(skill.name)
|
||||
title: enabled ? 'Skill enabled' : 'Skill disabled',
|
||||
message: `${skill.name} applies to new sessions.`
|
||||
})
|
||||
} catch (err) {
|
||||
notifyError(err, t.skills.failedToUpdate(skill.name))
|
||||
notifyError(err, `Failed to update ${skill.name}`)
|
||||
} finally {
|
||||
setSavingSkill(null)
|
||||
}
|
||||
@@ -177,11 +169,11 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
||||
)
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: enabled ? t.skills.toolsetEnabled : t.skills.toolsetDisabled,
|
||||
message: t.skills.appliesToNewSessions(toolsetDisplayLabel(toolset))
|
||||
title: enabled ? 'Toolset enabled' : 'Toolset disabled',
|
||||
message: `${toolsetDisplayLabel(toolset)} applies to new sessions.`
|
||||
})
|
||||
} catch (err) {
|
||||
notifyError(err, t.skills.failedToUpdate(toolsetDisplayLabel(toolset)))
|
||||
notifyError(err, `Failed to update ${toolsetDisplayLabel(toolset)}`)
|
||||
} finally {
|
||||
setSavingToolset(null)
|
||||
}
|
||||
@@ -191,66 +183,54 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
||||
<PageSearchShell
|
||||
{...props}
|
||||
filters={
|
||||
<>
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-2 gap-y-1">
|
||||
<TextTab active={mode === 'skills'} onClick={() => setMode('skills')}>
|
||||
{t.skills.tabSkills}
|
||||
mode === 'skills' && categories.length > 0 ? (
|
||||
<>
|
||||
<TextTab active={activeCategory === null} onClick={() => setActiveCategory(null)}>
|
||||
All <TextTabMeta>{totalSkills}</TextTabMeta>
|
||||
</TextTab>
|
||||
<TextTab active={mode === 'toolsets'} onClick={() => setMode('toolsets')}>
|
||||
{t.skills.tabToolsets}
|
||||
</TextTab>
|
||||
</div>
|
||||
{mode === 'skills' && categories.length > 0 && (
|
||||
<div className="flex flex-wrap justify-center gap-x-2 gap-y-1">
|
||||
<TextTab active={activeCategory === null} onClick={() => setActiveCategory(null)}>
|
||||
{t.skills.all} <TextTabMeta>{totalSkills}</TextTabMeta>
|
||||
{categories.map(category => (
|
||||
<TextTab
|
||||
active={activeCategory === category.key}
|
||||
key={category.key}
|
||||
onClick={() => setActiveCategory(activeCategory === category.key ? null : category.key)}
|
||||
>
|
||||
{prettyName(category.key)} <TextTabMeta>{category.count}</TextTabMeta>
|
||||
</TextTab>
|
||||
{categories.map(category => (
|
||||
<TextTab
|
||||
active={activeCategory === category.key}
|
||||
key={category.key}
|
||||
onClick={() => setActiveCategory(activeCategory === category.key ? null : category.key)}
|
||||
>
|
||||
{prettyName(category.key)} <TextTabMeta>{category.count}</TextTabMeta>
|
||||
</TextTab>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
onSearchChange={setQuery}
|
||||
searchHidden={mode === 'skills' ? (skills?.length ?? 0) === 0 : (toolsets?.length ?? 0) === 0}
|
||||
searchPlaceholder={mode === 'skills' ? t.skills.searchSkills : t.skills.searchToolsets}
|
||||
searchTrailingAction={
|
||||
<Button
|
||||
aria-label={refreshing ? t.skills.refreshing : t.skills.refresh}
|
||||
className="text-(--ui-text-tertiary) hover:bg-transparent hover:text-foreground"
|
||||
disabled={refreshing}
|
||||
onClick={() => void refreshCapabilities()}
|
||||
size="icon-xs"
|
||||
title={refreshing ? t.skills.refreshing : t.skills.refresh}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="refresh" size="0.875rem" spinning={refreshing} />
|
||||
</Button>
|
||||
}
|
||||
searchPlaceholder={mode === 'skills' ? 'Search skills...' : 'Search toolsets...'}
|
||||
searchValue={query}
|
||||
tabs={
|
||||
<>
|
||||
<TextTab active={mode === 'skills'} onClick={() => setMode('skills')}>
|
||||
Skills
|
||||
</TextTab>
|
||||
<TextTab active={mode === 'toolsets'} onClick={() => setMode('toolsets')}>
|
||||
Toolsets
|
||||
</TextTab>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{!skills || !toolsets ? (
|
||||
<PageLoader label={t.skills.loading} />
|
||||
<PageLoader label="Loading capabilities..." />
|
||||
) : mode === 'skills' ? (
|
||||
<div className="h-full overflow-y-auto px-4 py-3">
|
||||
<div className={cn('h-full overflow-y-auto py-3', PAGE_INSET_X)}>
|
||||
{visibleSkills.length === 0 ? (
|
||||
<EmptyState description={t.skills.noSkillsDesc} title={t.skills.noSkillsTitle} />
|
||||
<EmptyState description="Try a broader search or different category." title="No skills found" />
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{skillGroups.map(([category, list]) => (
|
||||
<div className="space-y-1.5" key={category}>
|
||||
<div className="text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
{prettyName(category)}
|
||||
</div>
|
||||
<div className="divide-y divide-(--ui-stroke-quaternary)">
|
||||
{activeCategory === null && (
|
||||
<div className="text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
{prettyName(category)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
{list.map(skill => (
|
||||
<div
|
||||
className="grid gap-3 px-0 py-2.5 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center"
|
||||
@@ -259,7 +239,7 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{skill.name}</div>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{asText(skill.description) || t.skills.noDescription}
|
||||
{asText(skill.description) || 'No description.'}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -276,15 +256,15 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full overflow-y-auto px-4 py-3">
|
||||
<div className={cn('h-full overflow-y-auto py-3', PAGE_INSET_X)}>
|
||||
{visibleToolsets.length === 0 ? (
|
||||
<EmptyState description={t.skills.noToolsetsDesc} title={t.skills.noToolsetsTitle} />
|
||||
<EmptyState description="Try a broader search query." title="No toolsets found" />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t.skills.toolsetsEnabled(enabledToolsets, toolsets.length)}
|
||||
{enabledToolsets}/{toolsets.length} toolsets enabled
|
||||
</div>
|
||||
<div className="divide-y divide-(--ui-stroke-quaternary)">
|
||||
<div>
|
||||
{visibleToolsets.map(toolset => {
|
||||
const tools = toolNames(toolset)
|
||||
const label = toolsetDisplayLabel(toolset)
|
||||
@@ -297,19 +277,19 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<button
|
||||
aria-expanded={expanded}
|
||||
aria-label={t.skills.configureToolset(label)}
|
||||
className="cursor-pointer rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
aria-label={`Configure ${label}`}
|
||||
className="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
onClick={() =>
|
||||
setExpandedToolset(current => (current === toolset.name ? null : toolset.name))
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<StatusPill active={toolset.configured}>
|
||||
{toolset.configured ? t.skills.configured : t.skills.needsKeys}
|
||||
{toolset.configured ? 'Configured' : 'Needs keys'}
|
||||
</StatusPill>
|
||||
</button>
|
||||
<Switch
|
||||
aria-label={t.skills.toggleToolset(label)}
|
||||
aria-label={`Toggle ${label} toolset`}
|
||||
checked={toolset.enabled}
|
||||
disabled={savingToolset === toolset.name}
|
||||
onCheckedChange={checked => void handleToggleToolset(toolset, checked)}
|
||||
@@ -317,7 +297,7 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{asText(toolset.description) || t.skills.noDescription}
|
||||
{asText(toolset.description) || 'No description.'}
|
||||
</p>
|
||||
{tools.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { DesktopConnectionConfig } from '@/global'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { AlertTriangle, FileText, Loader2, LogIn, RefreshCw, Wrench } from '@/lib/icons'
|
||||
import { $desktopBoot } from '@/store/boot'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
@@ -28,7 +27,6 @@ type BusyAction = 'local' | 'repair' | 'retry' | 'signin' | null
|
||||
export function BootFailureOverlay() {
|
||||
const boot = useStore($desktopBoot)
|
||||
const onboarding = useStore($desktopOnboarding)
|
||||
const { t } = useI18n()
|
||||
const [busy, setBusy] = useState<BusyAction>(null)
|
||||
const [logs, setLogs] = useState<string[]>([])
|
||||
const [showLogs, setShowLogs] = useState(false)
|
||||
@@ -143,7 +141,7 @@ export function BootFailureOverlay() {
|
||||
const result = await window.hermesDesktop?.oauthLoginConnectionConfig(remoteReauth.url)
|
||||
|
||||
if (result?.connected) {
|
||||
notify({ kind: 'success', title: t.boot.failure.signedInTitle, message: t.boot.failure.signedInMessage })
|
||||
notify({ kind: 'success', title: 'Signed in', message: 'Reconnecting to the remote gateway…' })
|
||||
window.location.reload()
|
||||
|
||||
return
|
||||
@@ -151,24 +149,19 @@ export function BootFailureOverlay() {
|
||||
|
||||
notify({
|
||||
kind: 'warning',
|
||||
title: t.boot.failure.signInIncompleteTitle,
|
||||
message: t.boot.failure.signInIncompleteMessage
|
||||
title: 'Sign-in incomplete',
|
||||
message: 'The login window closed before authentication finished.'
|
||||
})
|
||||
} catch (err) {
|
||||
notifyError(err, t.boot.failure.signInFailed)
|
||||
notifyError(err, 'Sign-in failed')
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const openLogs = () => void window.hermesDesktop?.revealLogs().catch(() => undefined)
|
||||
const copy = t.boot.failure
|
||||
|
||||
const label = signInLabel(remoteReauth, {
|
||||
identityProvider: copy.identityProvider,
|
||||
remoteGateway: copy.signInToRemoteGateway,
|
||||
withProvider: copy.signInWithProvider
|
||||
})
|
||||
const label = signInLabel(remoteReauth)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-(--ui-chat-surface-background) p-6">
|
||||
@@ -179,10 +172,12 @@ export function BootFailureOverlay() {
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-[0.9375rem] font-semibold tracking-tight">
|
||||
{remoteReauth ? copy.remoteTitle : copy.title}
|
||||
{remoteReauth ? 'Remote gateway sign-in required' : "Hermes couldn't start"}
|
||||
</h2>
|
||||
<p className="mt-1 text-[0.8125rem] leading-5 text-(--ui-text-tertiary)">
|
||||
{remoteReauth ? copy.remoteDescription : copy.description}
|
||||
{remoteReauth
|
||||
? 'Your remote gateway session has expired (the dashboard likely restarted). Sign in again to reconnect — nothing here deletes your chats or settings.'
|
||||
: "The background gateway didn't come up. Try one of the recovery steps below — nothing here deletes your chats or settings."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -202,26 +197,28 @@ export function BootFailureOverlay() {
|
||||
) : (
|
||||
<Button disabled={Boolean(busy)} onClick={() => void retry()}>
|
||||
{busy === 'retry' ? <Loader2 className="size-4 animate-spin" /> : <RefreshCw className="size-4" />}
|
||||
{copy.retry}
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
{!remoteReauth ? (
|
||||
<Button disabled={Boolean(busy)} onClick={() => void repair()} variant="outline">
|
||||
{busy === 'repair' ? <Loader2 className="size-4 animate-spin" /> : <Wrench className="size-4" />}
|
||||
{copy.repairInstall}
|
||||
Repair install
|
||||
</Button>
|
||||
) : null}
|
||||
<Button disabled={Boolean(busy)} onClick={() => void switchToLocalGateway()} variant="outline">
|
||||
{busy === 'local' ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
{copy.useLocalGateway}
|
||||
Use local gateway
|
||||
</Button>
|
||||
<Button onClick={openLogs} variant="ghost">
|
||||
<FileText className="size-4" />
|
||||
{copy.openLogs}
|
||||
Open logs
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{remoteReauth ? copy.remoteSignInHint : copy.repairHint}
|
||||
{remoteReauth
|
||||
? 'Opens the gateway login window. Use “Use local gateway” to switch to the bundled backend instead.'
|
||||
: 'Repair re-runs the installer and can take a few minutes on a fresh machine.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -232,7 +229,7 @@ export function BootFailureOverlay() {
|
||||
onClick={() => setShowLogs(v => !v)}
|
||||
type="button"
|
||||
>
|
||||
{showLogs ? copy.hideRecentLogs : copy.showRecentLogs}
|
||||
{showLogs ? 'Hide' : 'Show'} recent logs
|
||||
</button>
|
||||
{showLogs ? (
|
||||
<pre className="max-h-48 overflow-auto rounded-2xl border border-border bg-secondary/30 p-3 font-mono text-[0.7rem] leading-4 text-muted-foreground">
|
||||
|
||||
@@ -8,7 +8,6 @@ function config(overrides: Partial<DesktopConnectionConfig> = {}): DesktopConnec
|
||||
return {
|
||||
envOverride: false,
|
||||
mode: 'remote',
|
||||
profile: null,
|
||||
remoteAuthMode: 'oauth',
|
||||
remoteOauthConnected: false,
|
||||
remoteTokenPreview: null,
|
||||
|
||||
@@ -14,18 +14,6 @@ export interface RemoteReauth {
|
||||
providerLabel: string
|
||||
}
|
||||
|
||||
interface SignInCopy {
|
||||
identityProvider: string
|
||||
remoteGateway: string
|
||||
withProvider: (provider: string) => string
|
||||
}
|
||||
|
||||
const DEFAULT_SIGN_IN_COPY: SignInCopy = {
|
||||
identityProvider: 'your identity provider',
|
||||
remoteGateway: 'Sign in to remote gateway',
|
||||
withProvider: provider => `Sign in with ${provider}`
|
||||
}
|
||||
|
||||
// A remote, gated (oauth-bucket), not-currently-connected gateway is a
|
||||
// remote-reauth boot failure: the access cookie lapsed (e.g. the remote
|
||||
// dashboard restarted) and the local-recovery buttons (Retry/Repair) can't
|
||||
@@ -70,12 +58,10 @@ export function deriveProviderShape(providers: DesktopAuthProvider[] | null | un
|
||||
}
|
||||
|
||||
// Button copy for the remote sign-in action.
|
||||
export function signInLabel(reauth: RemoteReauth | null, copy: SignInCopy = DEFAULT_SIGN_IN_COPY): string {
|
||||
export function signInLabel(reauth: RemoteReauth | null): string {
|
||||
if (reauth?.isPassword) {
|
||||
return copy.remoteGateway
|
||||
return 'Sign in to remote gateway'
|
||||
}
|
||||
|
||||
const provider = reauth?.providerLabel === DEFAULT_SIGN_IN_COPY.identityProvider ? copy.identityProvider : reauth?.providerLabel
|
||||
|
||||
return copy.withProvider(provider ?? copy.identityProvider)
|
||||
return `Sign in with ${reauth?.providerLabel ?? 'your identity provider'}`
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { createPortal } from 'react-dom'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { AlertCircle, AlertTriangle, CheckCircle2, type IconComponent, Info } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -31,10 +30,8 @@ const GHOST_BTN = 'bg-transparent text-muted-foreground hover:text-foreground'
|
||||
|
||||
export function NotificationStack() {
|
||||
const notifications = useStore($notifications)
|
||||
const { t } = useI18n()
|
||||
const lastNotificationIdRef = useRef<string | null>(null)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const copy = t.notifications
|
||||
|
||||
useEffect(() => {
|
||||
if (notifications.length <= 1) {
|
||||
@@ -75,7 +72,7 @@ export function NotificationStack() {
|
||||
// scope, so fall back to its constant (34px) when mounted on <body>.
|
||||
return createPortal(
|
||||
<div
|
||||
aria-label={copy.region}
|
||||
aria-label="Notifications"
|
||||
className="pointer-events-none fixed left-1/2 top-[calc(var(--titlebar-height,34px)+0.75rem)] z-[200] flex w-[min(32rem,calc(100%-2rem))] -translate-x-1/2 flex-col gap-2"
|
||||
role="region"
|
||||
>
|
||||
@@ -84,10 +81,10 @@ export function NotificationStack() {
|
||||
{overflowCount > 0 && (
|
||||
<div className={cn(STACK_SURFACE, 'flex min-h-8 items-center justify-between rounded-lg px-3 text-xs')}>
|
||||
<button className={cn(GHOST_BTN, 'font-medium')} onClick={() => setExpanded(v => !v)} type="button">
|
||||
{expanded ? copy.hide : copy.show} {copy.more(overflowCount)}
|
||||
{expanded ? 'Hide' : 'Show'} {overflowCount} more {overflowCount === 1 ? 'notification' : 'notifications'}
|
||||
</button>
|
||||
<button className={GHOST_BTN} onClick={clearNotifications} type="button">
|
||||
{copy.clearAll}
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -100,8 +97,6 @@ function NotificationItem({ notification }: { notification: AppNotification }) {
|
||||
const styles = tone[notification.kind]
|
||||
const Icon = styles.icon
|
||||
const hasDetail = Boolean(notification.detail && notification.detail !== notification.message)
|
||||
const { t } = useI18n()
|
||||
const copy = t.notifications
|
||||
|
||||
return (
|
||||
<Alert
|
||||
@@ -131,7 +126,7 @@ function NotificationItem({ notification }: { notification: AppNotification }) {
|
||||
</AlertDescription>
|
||||
</div>
|
||||
<button
|
||||
aria-label={copy.dismiss}
|
||||
aria-label="Dismiss notification"
|
||||
className="col-start-3 -mr-1 grid size-6 place-items-center rounded-md bg-transparent text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={() => dismissNotification(notification.id)}
|
||||
type="button"
|
||||
@@ -143,12 +138,9 @@ function NotificationItem({ notification }: { notification: AppNotification }) {
|
||||
}
|
||||
|
||||
function NotificationDetail({ detail }: { detail: string }) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.notifications
|
||||
|
||||
return (
|
||||
<details className="mt-2 text-xs text-muted-foreground">
|
||||
<summary className="select-none font-medium text-muted-foreground hover:text-foreground">{copy.details}</summary>
|
||||
<summary className="select-none font-medium text-muted-foreground hover:text-foreground">Details</summary>
|
||||
<div className="mt-1 rounded-md border border-border/70 bg-background/65 p-2">
|
||||
<pre className="max-h-32 whitespace-pre-wrap wrap-break-word font-mono text-[0.6875rem] leading-relaxed">
|
||||
{detail}
|
||||
@@ -156,12 +148,12 @@ function NotificationDetail({ detail }: { detail: string }) {
|
||||
<CopyButton
|
||||
appearance="inline"
|
||||
className="mt-1 inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[0.6875rem] text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
errorMessage={copy.copyDetailFailed}
|
||||
errorMessage="Could not copy notification detail"
|
||||
iconClassName="size-3"
|
||||
label={copy.copyDetail}
|
||||
label="Copy detail"
|
||||
text={detail}
|
||||
>
|
||||
{copy.copyDetail}
|
||||
Copy detail
|
||||
</CopyButton>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
Vendored
+1
-7
@@ -12,7 +12,7 @@ declare global {
|
||||
touchBackend: (profile?: string | null) => Promise<{ ok: boolean }>
|
||||
getGatewayWsUrl: (profile?: null | string) => Promise<string>
|
||||
getBootProgress: () => Promise<DesktopBootProgress>
|
||||
getConnectionConfig: (profile?: null | string) => Promise<DesktopConnectionConfig>
|
||||
getConnectionConfig: () => Promise<DesktopConnectionConfig>
|
||||
saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
|
||||
applyConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
|
||||
testConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionTestResult>
|
||||
@@ -190,9 +190,6 @@ export interface DesktopActiveProfile {
|
||||
export interface DesktopConnectionConfig {
|
||||
envOverride: boolean
|
||||
mode: 'local' | 'remote'
|
||||
// The profile this config describes, or null for the global/default
|
||||
// connection. Per-profile entries let a profile point at its own backend.
|
||||
profile: null | string
|
||||
remoteAuthMode: 'oauth' | 'token'
|
||||
remoteOauthConnected: boolean
|
||||
remoteTokenPreview: string | null
|
||||
@@ -202,9 +199,6 @@ export interface DesktopConnectionConfig {
|
||||
|
||||
export interface DesktopConnectionConfigInput {
|
||||
mode: 'local' | 'remote'
|
||||
// When set, the save/apply/test targets this profile's per-profile remote
|
||||
// override instead of the global connection.
|
||||
profile?: null | string
|
||||
remoteAuthMode?: 'oauth' | 'token'
|
||||
remoteToken?: string
|
||||
remoteUrl?: string
|
||||
|
||||
@@ -29,7 +29,6 @@ import type {
|
||||
OAuthSubmitResponse,
|
||||
PaginatedSessions,
|
||||
ProfileCreatePayload,
|
||||
ProfileSetupCommand,
|
||||
ProfileSoul,
|
||||
ProfilesResponse,
|
||||
SessionMessagesResponse,
|
||||
@@ -81,7 +80,6 @@ export type {
|
||||
PaginatedSessions,
|
||||
ProfileCreatePayload,
|
||||
ProfileInfo,
|
||||
ProfileSetupCommand,
|
||||
ProfileSoul,
|
||||
ProfilesResponse,
|
||||
RpcEvent,
|
||||
@@ -168,13 +166,8 @@ export async function listAllProfileSessions(
|
||||
}
|
||||
}
|
||||
|
||||
// Mutations take the owning `profile` so Electron routes them to that profile's
|
||||
// backend (remote pool or local primary) via request.profile — matching the
|
||||
// read path. A remote session's row lives only on its remote host, so a mutation
|
||||
// that hit the local primary would no-op or 404. Omit for the current/default.
|
||||
export function setSessionArchived(id: string, archived: boolean, profile?: string | null): Promise<{ ok: boolean }> {
|
||||
export function setSessionArchived(id: string, archived: boolean): Promise<{ ok: boolean }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean }>({
|
||||
...(profile ? { profile } : {}),
|
||||
path: `/api/sessions/${encodeURIComponent(id)}`,
|
||||
method: 'PATCH',
|
||||
body: { archived }
|
||||
@@ -187,10 +180,8 @@ export function searchSessions(query: string): Promise<SessionSearchResponse> {
|
||||
})
|
||||
}
|
||||
|
||||
// Reads another profile's transcript. For a remote profile Electron reroutes
|
||||
// this GET to the remote backend (which serves its own state.db); for a local
|
||||
// profile the primary opens that profile's state.db via ?profile=. Omit for
|
||||
// the current/default profile.
|
||||
// `profile` reads another profile's transcript straight off its state.db via the
|
||||
// primary backend (no spawn). Omit for the current/default profile.
|
||||
export function getSessionMessages(id: string, profile?: string | null): Promise<SessionMessagesResponse> {
|
||||
const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : ''
|
||||
|
||||
@@ -199,9 +190,8 @@ export function getSessionMessages(id: string, profile?: string | null): Promise
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteSession(id: string, profile?: string | null): Promise<{ ok: boolean }> {
|
||||
export function deleteSession(id: string): Promise<{ ok: boolean }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean }>({
|
||||
...(profile ? { profile } : {}),
|
||||
path: `/api/sessions/${encodeURIComponent(id)}`,
|
||||
method: 'DELETE'
|
||||
})
|
||||
@@ -213,7 +203,6 @@ export function renameSession(
|
||||
profile?: string | null
|
||||
): Promise<{ ok: boolean; title: string }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean; title: string }>({
|
||||
...(profile ? { profile } : {}),
|
||||
path: `/api/sessions/${encodeURIComponent(id)}`,
|
||||
method: 'PATCH',
|
||||
body: { title, ...(profile ? { profile } : {}) }
|
||||
@@ -565,12 +554,6 @@ export function updateProfileSoul(name: string, content: string): Promise<{ ok:
|
||||
})
|
||||
}
|
||||
|
||||
export function getProfileSetupCommand(name: string): Promise<ProfileSetupCommand> {
|
||||
return window.hermesDesktop.api<ProfileSetupCommand>({
|
||||
path: `/api/profiles/${encodeURIComponent(name)}/setup-command`
|
||||
})
|
||||
}
|
||||
|
||||
export function getUsageAnalytics(days = 30): Promise<AnalyticsResponse> {
|
||||
return window.hermesDesktop.api<AnalyticsResponse>({
|
||||
...profileScoped(),
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { en } from './en'
|
||||
import type { Locale, Translations } from './types'
|
||||
import { zh } from './zh'
|
||||
|
||||
export const TRANSLATIONS: Record<Locale, Translations> = {
|
||||
en,
|
||||
zh
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { HermesConfigRecord } from '@/hermes'
|
||||
|
||||
import { type I18nConfigClient, I18nProvider, useI18n } from './context'
|
||||
import type { Locale } from './types'
|
||||
|
||||
function LanguageProbe({ target = 'zh' }: { target?: Locale }) {
|
||||
const { isLoadingConfig, isSavingLocale, locale, saveError, setLocale, t } = useI18n()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p data-testid="locale">{locale}</p>
|
||||
<p data-testid="label">{t.language.label}</p>
|
||||
<p data-testid="loading">{String(isLoadingConfig)}</p>
|
||||
<p data-testid="saving">{String(isSavingLocale)}</p>
|
||||
<p data-testid="save-error">{saveError?.message ?? ''}</p>
|
||||
<button onClick={() => void setLocale(target).catch(() => undefined)} type="button">
|
||||
switch
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
describe('I18nProvider', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('defaults to English without a config client', () => {
|
||||
render(
|
||||
<I18nProvider configClient={null}>
|
||||
<LanguageProbe />
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('locale').textContent).toBe('en')
|
||||
expect(screen.getByTestId('label').textContent).toBe('Language')
|
||||
})
|
||||
|
||||
it('normalizes an initial locale alias and switches translations', async () => {
|
||||
render(
|
||||
<I18nProvider configClient={null} initialLocale="zh-CN">
|
||||
<LanguageProbe target="en" />
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('locale').textContent).toBe('zh')
|
||||
expect(screen.getByTestId('label').textContent).toBe('语言')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'switch' }))
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('locale').textContent).toBe('en'))
|
||||
expect(screen.getByTestId('label').textContent).toBe('Language')
|
||||
})
|
||||
|
||||
it('loads the initial locale from display.language config', async () => {
|
||||
const configClient: I18nConfigClient = {
|
||||
getConfig: vi.fn().mockResolvedValue({ display: { language: 'zh-Hans' } }),
|
||||
saveConfig: vi.fn()
|
||||
}
|
||||
|
||||
render(
|
||||
<I18nProvider configClient={configClient}>
|
||||
<LanguageProbe />
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false'))
|
||||
|
||||
expect(screen.getByTestId('locale').textContent).toBe('zh')
|
||||
expect(screen.getByTestId('label').textContent).toBe('语言')
|
||||
expect(configClient.saveConfig).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps English usable when config loading fails', async () => {
|
||||
const configClient: I18nConfigClient = {
|
||||
getConfig: vi.fn().mockRejectedValue(new Error('config unavailable')),
|
||||
saveConfig: vi.fn()
|
||||
}
|
||||
|
||||
render(
|
||||
<I18nProvider configClient={configClient} initialLocale="zh">
|
||||
<LanguageProbe />
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false'))
|
||||
|
||||
expect(screen.getByTestId('locale').textContent).toBe('en')
|
||||
expect(screen.getByTestId('label').textContent).toBe('Language')
|
||||
expect(configClient.saveConfig).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not overwrite unsupported configured languages', async () => {
|
||||
const configClient: I18nConfigClient = {
|
||||
getConfig: vi.fn().mockResolvedValue({ display: { language: 'ja' } }),
|
||||
saveConfig: vi.fn()
|
||||
}
|
||||
|
||||
render(
|
||||
<I18nProvider configClient={configClient} initialLocale="zh">
|
||||
<LanguageProbe />
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false'))
|
||||
|
||||
expect(screen.getByTestId('locale').textContent).toBe('en')
|
||||
expect(screen.getByTestId('label').textContent).toBe('Language')
|
||||
expect(configClient.saveConfig).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reads latest config before saving language and preserves unrelated values', async () => {
|
||||
const saveConfig = vi.fn().mockResolvedValue({ ok: true })
|
||||
|
||||
const latestConfig: HermesConfigRecord = {
|
||||
display: { language: 'en', skin: 'slate' },
|
||||
terminal: { cwd: '/new' }
|
||||
}
|
||||
|
||||
const configClient: I18nConfigClient = {
|
||||
getConfig: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ display: { language: 'en', skin: 'mono' }, terminal: { cwd: '/old' } })
|
||||
.mockResolvedValueOnce(latestConfig),
|
||||
saveConfig
|
||||
}
|
||||
|
||||
render(
|
||||
<I18nProvider configClient={configClient}>
|
||||
<LanguageProbe />
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'switch' }))
|
||||
|
||||
await waitFor(() => expect(saveConfig).toHaveBeenCalledTimes(1))
|
||||
expect(saveConfig).toHaveBeenCalledWith({
|
||||
display: { language: 'zh', skin: 'slate' },
|
||||
terminal: { cwd: '/new' }
|
||||
})
|
||||
})
|
||||
|
||||
it('rolls back the visible locale when saving fails', async () => {
|
||||
const configClient: I18nConfigClient = {
|
||||
getConfig: vi.fn().mockResolvedValue({ display: { language: 'en' } }),
|
||||
saveConfig: vi.fn().mockRejectedValue(new Error('save failed'))
|
||||
}
|
||||
|
||||
render(
|
||||
<I18nProvider configClient={configClient}>
|
||||
<LanguageProbe />
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'switch' }))
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('save-error').textContent).toBe('save failed'))
|
||||
|
||||
expect(screen.getByTestId('locale').textContent).toBe('en')
|
||||
expect(screen.getByTestId('label').textContent).toBe('Language')
|
||||
})
|
||||
})
|
||||
@@ -1,183 +0,0 @@
|
||||
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { getHermesConfigRecord, type HermesConfigRecord, saveHermesConfig } from '@/hermes'
|
||||
|
||||
import { TRANSLATIONS } from './catalog'
|
||||
import { DEFAULT_LOCALE, localeConfigValue, normalizeLocale } from './languages'
|
||||
import { setRuntimeI18nLocale } from './runtime'
|
||||
import type { Locale, Translations } from './types'
|
||||
|
||||
export { LOCALE_META } from './languages'
|
||||
|
||||
export interface I18nConfigClient {
|
||||
getConfig: () => Promise<HermesConfigRecord>
|
||||
saveConfig: (config: HermesConfigRecord) => Promise<{ ok: boolean }>
|
||||
}
|
||||
|
||||
const defaultConfigClient: I18nConfigClient = {
|
||||
getConfig: () => {
|
||||
if (typeof window === 'undefined' || !window.hermesDesktop?.api) {
|
||||
return Promise.resolve({})
|
||||
}
|
||||
|
||||
return getHermesConfigRecord()
|
||||
},
|
||||
saveConfig: config => {
|
||||
if (typeof window === 'undefined' || !window.hermesDesktop?.api) {
|
||||
return Promise.resolve({ ok: true })
|
||||
}
|
||||
|
||||
return saveHermesConfig(config)
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
export function getConfigDisplayLanguage(config: HermesConfigRecord): unknown {
|
||||
return isRecord(config.display) ? config.display.language : undefined
|
||||
}
|
||||
|
||||
export function withConfigDisplayLanguage(config: HermesConfigRecord, locale: Locale): HermesConfigRecord {
|
||||
const display = isRecord(config.display) ? config.display : {}
|
||||
|
||||
return {
|
||||
...config,
|
||||
display: {
|
||||
...display,
|
||||
language: localeConfigValue(locale)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error))
|
||||
}
|
||||
|
||||
export interface I18nContextValue {
|
||||
configLoadError: Error | null
|
||||
isLoadingConfig: boolean
|
||||
isSavingLocale: boolean
|
||||
locale: Locale
|
||||
saveError: Error | null
|
||||
setLocale: (next: Locale) => Promise<void>
|
||||
t: Translations
|
||||
}
|
||||
|
||||
const I18nContext = createContext<I18nContextValue>({
|
||||
configLoadError: null,
|
||||
isLoadingConfig: false,
|
||||
isSavingLocale: false,
|
||||
locale: DEFAULT_LOCALE,
|
||||
saveError: null,
|
||||
setLocale: async () => {},
|
||||
t: TRANSLATIONS[DEFAULT_LOCALE]
|
||||
})
|
||||
|
||||
export interface I18nProviderProps {
|
||||
children: ReactNode
|
||||
configClient?: I18nConfigClient | null
|
||||
initialLocale?: unknown
|
||||
}
|
||||
|
||||
export function I18nProvider({ children, configClient = defaultConfigClient, initialLocale }: I18nProviderProps) {
|
||||
const [locale, setLocaleState] = useState<Locale>(() => normalizeLocale(initialLocale))
|
||||
const [isLoadingConfig, setIsLoadingConfig] = useState(false)
|
||||
const [isSavingLocale, setIsSavingLocale] = useState(false)
|
||||
const [configLoadError, setConfigLoadError] = useState<Error | null>(null)
|
||||
const [saveError, setSaveError] = useState<Error | null>(null)
|
||||
const localeRef = useRef(locale)
|
||||
|
||||
useEffect(() => {
|
||||
localeRef.current = locale
|
||||
setRuntimeI18nLocale(locale)
|
||||
}, [locale])
|
||||
|
||||
useEffect(() => {
|
||||
if (!configClient) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
setIsLoadingConfig(true)
|
||||
setConfigLoadError(null)
|
||||
|
||||
configClient
|
||||
.getConfig()
|
||||
.then(config => {
|
||||
if (!cancelled) {
|
||||
setLocaleState(normalizeLocale(getConfigDisplayLanguage(config)))
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
if (!cancelled) {
|
||||
setConfigLoadError(toError(error))
|
||||
setLocaleState(DEFAULT_LOCALE)
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsLoadingConfig(false)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [configClient, initialLocale])
|
||||
|
||||
const setLocale = useCallback(
|
||||
async (next: Locale) => {
|
||||
const previousLocale = localeRef.current
|
||||
|
||||
setSaveError(null)
|
||||
setLocaleState(next)
|
||||
|
||||
if (!configClient) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsSavingLocale(true)
|
||||
|
||||
try {
|
||||
const latestConfig = await configClient.getConfig()
|
||||
const result = await configClient.saveConfig(withConfigDisplayLanguage(latestConfig, next))
|
||||
|
||||
if (!result.ok) {
|
||||
throw new Error('Failed to save language')
|
||||
}
|
||||
} catch (error) {
|
||||
const nextError = toError(error)
|
||||
|
||||
setLocaleState(previousLocale)
|
||||
setSaveError(nextError)
|
||||
|
||||
throw nextError
|
||||
} finally {
|
||||
setIsSavingLocale(false)
|
||||
}
|
||||
},
|
||||
[configClient]
|
||||
)
|
||||
|
||||
const value = useMemo<I18nContextValue>(
|
||||
() => ({
|
||||
configLoadError,
|
||||
isLoadingConfig,
|
||||
isSavingLocale,
|
||||
locale,
|
||||
saveError,
|
||||
setLocale,
|
||||
t: TRANSLATIONS[locale]
|
||||
}),
|
||||
[configLoadError, isLoadingConfig, isSavingLocale, locale, saveError, setLocale]
|
||||
)
|
||||
|
||||
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>
|
||||
}
|
||||
|
||||
export function useI18n(): I18nContextValue {
|
||||
return useContext(I18nContext)
|
||||
}
|
||||
@@ -1,744 +0,0 @@
|
||||
import { FIELD_DESCRIPTIONS, FIELD_LABELS } from '@/app/settings/constants'
|
||||
|
||||
import type { Translations } from './types'
|
||||
|
||||
export const en: Translations = {
|
||||
common: {
|
||||
save: 'Save',
|
||||
saving: 'Saving…',
|
||||
cancel: 'Cancel',
|
||||
close: 'Close',
|
||||
confirm: 'Confirm',
|
||||
delete: 'Delete',
|
||||
refresh: 'Refresh',
|
||||
retry: 'Retry',
|
||||
on: 'On',
|
||||
off: 'Off'
|
||||
},
|
||||
|
||||
boot: {
|
||||
ready: 'Hermes Desktop is ready',
|
||||
desktopBootFailedWithMessage: message => `Desktop boot failed: ${message}`,
|
||||
steps: {
|
||||
connectingGateway: 'Connecting live desktop gateway',
|
||||
loadingSettings: 'Loading Hermes settings',
|
||||
loadingSessions: 'Loading recent sessions',
|
||||
startingDesktopConnection: 'Starting desktop connection',
|
||||
startingHermesDesktop: 'Starting Hermes Desktop…'
|
||||
},
|
||||
errors: {
|
||||
backgroundExited: 'Hermes background process exited.',
|
||||
backgroundExitedDuringStartup: 'Hermes background process exited during startup.',
|
||||
backendStopped: 'Backend stopped',
|
||||
desktopBootFailed: 'Desktop boot failed',
|
||||
gatewaySignInRequired: 'Gateway sign-in required',
|
||||
ipcBridgeUnavailable: 'Desktop IPC bridge is unavailable.'
|
||||
},
|
||||
failure: {
|
||||
title: "Hermes couldn't start",
|
||||
description:
|
||||
"The background gateway didn't come up. Try one of the recovery steps below. Nothing here deletes your chats or settings.",
|
||||
remoteTitle: 'Remote gateway sign-in required',
|
||||
remoteDescription:
|
||||
'Your remote gateway session has expired. Sign in again to reconnect. Nothing here deletes your chats or settings.',
|
||||
retry: 'Retry',
|
||||
repairInstall: 'Repair install',
|
||||
useLocalGateway: 'Use local gateway',
|
||||
openLogs: 'Open logs',
|
||||
repairHint: 'Repair re-runs the installer and can take a few minutes on a fresh machine.',
|
||||
remoteSignInHint: 'Opens the gateway login window. Use local gateway to switch to the bundled backend instead.',
|
||||
hideRecentLogs: 'Hide recent logs',
|
||||
showRecentLogs: 'Show recent logs',
|
||||
signedInTitle: 'Signed in',
|
||||
signedInMessage: 'Reconnecting to the remote gateway…',
|
||||
signInIncompleteTitle: 'Sign-in incomplete',
|
||||
signInIncompleteMessage: 'The login window closed before authentication finished.',
|
||||
signInFailed: 'Sign-in failed',
|
||||
signInToRemoteGateway: 'Sign in to remote gateway',
|
||||
signInWithProvider: provider => `Sign in with ${provider}`,
|
||||
identityProvider: 'your identity provider'
|
||||
}
|
||||
},
|
||||
|
||||
notifications: {
|
||||
region: 'Notifications',
|
||||
hide: 'Hide',
|
||||
show: 'Show',
|
||||
more: count => `${count} more ${count === 1 ? 'notification' : 'notifications'}`,
|
||||
clearAll: 'Clear all',
|
||||
dismiss: 'Dismiss notification',
|
||||
details: 'Details',
|
||||
copyDetail: 'Copy detail',
|
||||
copyDetailFailed: 'Could not copy notification detail',
|
||||
backendOutOfDateTitle: 'Backend out of date',
|
||||
backendOutOfDateMessage:
|
||||
'Your Hermes backend is older than this desktop build and may not work correctly. Update to align them.',
|
||||
updateHermes: 'Update Hermes',
|
||||
updateReadyTitle: 'Update ready',
|
||||
updateReadyMessage: count => `${count} new change${count === 1 ? '' : 's'} available.`,
|
||||
seeWhatsNew: "See what's new",
|
||||
errors: {
|
||||
elevenLabsNeedsKey: 'ElevenLabs STT needs ELEVENLABS_API_KEY.',
|
||||
elevenLabsRejectedKey: 'ElevenLabs rejected the API key (401).',
|
||||
methodNotAllowed:
|
||||
'The desktop backend rejected that request (405 Method Not Allowed). Try restarting Hermes Desktop.',
|
||||
microphonePermission: 'Microphone permission was denied.',
|
||||
openaiRejectedApiKey: 'OpenAI rejected the API key.',
|
||||
openaiRejectedApiKeyWithStatus: status => `OpenAI rejected the API key (${status} invalid_api_key).`,
|
||||
openaiTtsNeedsKey: 'OpenAI TTS needs VOICE_TOOLS_OPENAI_KEY or OPENAI_API_KEY.'
|
||||
}
|
||||
},
|
||||
|
||||
titlebar: {
|
||||
hideSidebar: 'Hide sidebar',
|
||||
showSidebar: 'Show sidebar',
|
||||
search: 'Search',
|
||||
searchTitle: 'Search sessions, views, and actions',
|
||||
swapSidebarSides: 'Swap sidebar sides',
|
||||
swapSidebarSidesTitle: 'Swap the sessions and file browser sides',
|
||||
hideRightSidebar: 'Hide right sidebar',
|
||||
showRightSidebar: 'Show right sidebar',
|
||||
muteHaptics: 'Mute haptics',
|
||||
unmuteHaptics: 'Unmute haptics',
|
||||
openSettings: 'Open settings'
|
||||
},
|
||||
|
||||
language: {
|
||||
label: 'Language',
|
||||
description: 'Choose the language for the desktop interface.',
|
||||
saving: 'Saving language…',
|
||||
saveError: 'Language update failed'
|
||||
},
|
||||
|
||||
settings: {
|
||||
closeSettings: 'Close settings',
|
||||
exportConfig: 'Export config',
|
||||
importConfig: 'Import config',
|
||||
resetToDefaults: 'Reset to defaults',
|
||||
resetConfirm: 'Reset all settings to Hermes defaults?',
|
||||
exportFailed: 'Export failed',
|
||||
resetFailed: 'Reset failed',
|
||||
nav: {
|
||||
gateway: 'Gateway',
|
||||
apiKeys: 'Tools & Keys',
|
||||
mcp: 'MCP',
|
||||
archivedChats: 'Archived Chats',
|
||||
about: 'About'
|
||||
},
|
||||
sections: {
|
||||
model: 'Model',
|
||||
chat: 'Chat',
|
||||
appearance: 'Appearance',
|
||||
workspace: 'Workspace',
|
||||
safety: 'Safety',
|
||||
memory: 'Memory & Context',
|
||||
voice: 'Voice',
|
||||
advanced: 'Advanced'
|
||||
},
|
||||
searchPlaceholder: {
|
||||
about: 'About Hermes Desktop',
|
||||
config: 'Search settings...',
|
||||
gateway: 'Gateway connection...',
|
||||
keys: 'Search API keys...',
|
||||
mcp: 'Search MCP servers...',
|
||||
sessions: 'Search archived sessions...'
|
||||
},
|
||||
modeOptions: {
|
||||
light: { label: 'Light', description: 'Bright desktop surfaces' },
|
||||
dark: { label: 'Dark', description: 'Low-glare workspace' },
|
||||
system: { label: 'System', description: 'Follow OS appearance' }
|
||||
},
|
||||
appearance: {
|
||||
title: 'Appearance',
|
||||
intro:
|
||||
'These are desktop-only display preferences. Mode controls brightness; theme controls the accent palette and chat surface styling.',
|
||||
colorMode: 'Color Mode',
|
||||
colorModeDesc: 'Pick a fixed mode or let Hermes follow your system setting.',
|
||||
toolViewTitle: 'Tool Call Display',
|
||||
toolViewDesc: 'Product hides raw tool payloads; Technical shows full input/output.',
|
||||
product: 'Product',
|
||||
productDesc: 'Human-friendly tool activity with concise summaries.',
|
||||
technical: 'Technical',
|
||||
technicalDesc: 'Include raw tool args/results and low-level details.',
|
||||
themeTitle: 'Theme',
|
||||
themeDesc: 'Desktop palettes only. The selected mode is applied on top.'
|
||||
},
|
||||
fieldLabels: FIELD_LABELS,
|
||||
fieldDescriptions: FIELD_DESCRIPTIONS,
|
||||
about: {
|
||||
heading: 'Hermes Desktop',
|
||||
version: value => `Version ${value}`,
|
||||
versionUnavailable: 'Version unavailable',
|
||||
updates: 'Updates',
|
||||
checkNow: 'Check now',
|
||||
checking: 'Checking…',
|
||||
seeWhatsNew: "See what's new",
|
||||
releaseNotes: 'Release notes',
|
||||
onLatest: "You're on the latest version.",
|
||||
installing: 'An update is currently installing.',
|
||||
cantUpdate: "This build can't update itself from inside the app.",
|
||||
cantReach: "We couldn't reach the update server.",
|
||||
tapCheck: 'Tap "Check now" to look for updates.',
|
||||
updateReady: count => `A new update is ready (${count} change${count === 1 ? '' : 's'} included).`,
|
||||
lastChecked: age => `Last checked ${age}`,
|
||||
justNowSuffix: ' · just now',
|
||||
automaticUpdates: 'Automatic updates',
|
||||
automaticUpdatesDesc:
|
||||
'Hermes checks for updates automatically in the background and lets you know when one is ready.',
|
||||
branchCommit: (branch, commit) => `Branch ${branch} · Commit ${commit}`,
|
||||
never: 'never',
|
||||
justNow: 'just now',
|
||||
minAgo: count => `${count} min ago`,
|
||||
hoursAgo: count => `${count} hours ago`,
|
||||
daysAgo: count => `${count} days ago`
|
||||
}
|
||||
},
|
||||
|
||||
skills: {
|
||||
tabSkills: 'Skills',
|
||||
tabToolsets: 'Toolsets',
|
||||
all: 'All',
|
||||
searchSkills: 'Search skills...',
|
||||
searchToolsets: 'Search toolsets...',
|
||||
refresh: 'Refresh skills',
|
||||
refreshing: 'Refreshing skills',
|
||||
loading: 'Loading capabilities...',
|
||||
noSkillsTitle: 'No skills found',
|
||||
noSkillsDesc: 'Try a broader search or different category.',
|
||||
noToolsetsTitle: 'No toolsets found',
|
||||
noToolsetsDesc: 'Try a broader search query.',
|
||||
noDescription: 'No description.',
|
||||
configured: 'Configured',
|
||||
needsKeys: 'Needs keys',
|
||||
toolsetsEnabled: (enabled, total) => `${enabled}/${total} toolsets enabled`,
|
||||
configureToolset: label => `Configure ${label}`,
|
||||
toggleToolset: label => `Toggle ${label} toolset`,
|
||||
skillsLoadFailed: 'Skills failed to load',
|
||||
toolsetsRefreshFailed: 'Toolsets failed to refresh',
|
||||
skillEnabled: 'Skill enabled',
|
||||
skillDisabled: 'Skill disabled',
|
||||
toolsetEnabled: 'Toolset enabled',
|
||||
toolsetDisabled: 'Toolset disabled',
|
||||
appliesToNewSessions: name => `${name} applies to new sessions.`,
|
||||
failedToUpdate: name => `Failed to update ${name}`
|
||||
},
|
||||
|
||||
agents: {
|
||||
close: 'Close agents',
|
||||
title: 'Spawn tree',
|
||||
subtitle: 'Live subagent activity for the current turn.',
|
||||
emptyTitle: 'No live subagents',
|
||||
emptyDesc: 'When a turn delegates work, child agents stream their progress here.',
|
||||
running: 'Running',
|
||||
failed: 'Failed',
|
||||
done: 'Done',
|
||||
streaming: 'Streaming',
|
||||
files: 'Files',
|
||||
moreFiles: count => `+${count} more files`,
|
||||
delegation: index => `Delegation ${index}`,
|
||||
workers: count => `${count} workers`,
|
||||
workersActive: count => `${count} active`,
|
||||
agentsCount: count => `${count} ${count === 1 ? 'agent' : 'agents'}`,
|
||||
activeCount: count => `${count} active`,
|
||||
failedCount: count => `${count} failed`,
|
||||
toolsCount: count => `${count} tools`,
|
||||
filesCount: count => `${count} files`,
|
||||
updatedAgo: age => `updated ${age}`,
|
||||
ageNow: 'now',
|
||||
ageSeconds: seconds => `${seconds}s ago`,
|
||||
ageMinutes: minutes => `${minutes}m ago`,
|
||||
ageHours: hours => `${hours}h ago`,
|
||||
durationSeconds: seconds => `${seconds}s`,
|
||||
durationMinutes: (minutes, seconds) => `${minutes}m ${seconds}s`,
|
||||
tokensK: k => `${k}k tok`,
|
||||
tokens: value => `${value} tok`
|
||||
},
|
||||
|
||||
commandCenter: {
|
||||
close: 'Close command center',
|
||||
searchPlaceholder: 'Search sessions, views, and actions',
|
||||
sections: { sessions: 'Sessions', system: 'System', usage: 'Usage' },
|
||||
sectionDescriptions: {
|
||||
sessions: 'Search and manage sessions',
|
||||
system: 'Status, logs, and system actions',
|
||||
usage: 'Token, cost, and skill activity over time'
|
||||
},
|
||||
nav: {
|
||||
newChat: { title: 'New session', detail: 'Start a fresh session' },
|
||||
settings: { title: 'Settings', detail: 'Configure Hermes desktop' },
|
||||
skills: { title: 'Skills & Tools', detail: 'Enable skills, toolsets, and providers' },
|
||||
messaging: { title: 'Messaging', detail: 'Set up Telegram, Slack, Discord, and more' },
|
||||
artifacts: { title: 'Artifacts', detail: 'Browse generated outputs' }
|
||||
},
|
||||
sectionEntries: {
|
||||
sessions: { title: 'Sessions panel', detail: 'Search, pin, and manage sessions' },
|
||||
system: { title: 'System panel', detail: 'Gateway status, logs, restart/update' },
|
||||
usage: { title: 'Usage panel', detail: 'Token, cost, and skill activity' }
|
||||
},
|
||||
providerNavigate: 'Navigate',
|
||||
providerSessions: 'Sessions',
|
||||
refresh: 'Refresh',
|
||||
refreshing: 'Refreshing...',
|
||||
noResults: 'No matching results found.',
|
||||
pinSession: 'Pin session',
|
||||
unpinSession: 'Unpin session',
|
||||
exportSession: 'Export session',
|
||||
deleteSession: 'Delete session',
|
||||
noSessions: 'No sessions yet.',
|
||||
gatewayRunning: 'Messaging gateway running',
|
||||
gatewayStopped: 'Messaging gateway stopped',
|
||||
hermesActiveSessions: (version, count) => `Hermes ${version} · Active sessions ${count}`,
|
||||
restartMessaging: 'Restart messaging',
|
||||
updateHermes: 'Update Hermes',
|
||||
actionRunning: 'running',
|
||||
actionDone: 'done',
|
||||
actionFailed: 'failed',
|
||||
actionStartedWaiting: 'Action started, waiting for status...',
|
||||
loadingStatus: 'Loading status...',
|
||||
recentLogs: 'Recent logs',
|
||||
noLogs: 'No logs loaded yet.',
|
||||
days: count => `${count}d`,
|
||||
statSessions: 'Sessions',
|
||||
statApiCalls: 'API calls',
|
||||
statTokens: 'Tokens in/out',
|
||||
statCost: 'Est. cost',
|
||||
actualCost: cost => `actual ${cost}`,
|
||||
loadingUsage: 'Loading usage...',
|
||||
noUsage: period => `No usage in the last ${period} days.`,
|
||||
retry: 'Retry',
|
||||
dailyTokens: 'Daily tokens',
|
||||
input: 'input',
|
||||
output: 'output',
|
||||
noDailyActivity: 'No daily activity.',
|
||||
topModels: 'Top models',
|
||||
noModelUsage: 'No model usage yet.',
|
||||
topSkills: 'Top skills',
|
||||
noSkillActivity: 'No skill activity yet.',
|
||||
actions: count => `${count} actions`
|
||||
},
|
||||
|
||||
messaging: {
|
||||
search: 'Search messaging...',
|
||||
loading: 'Loading messaging platforms...',
|
||||
loadFailed: 'Messaging platforms failed to load',
|
||||
states: {
|
||||
connected: 'Connected',
|
||||
connecting: 'Connecting',
|
||||
disabled: 'Disabled',
|
||||
fatal: 'Error',
|
||||
gateway_stopped: 'Messaging gateway stopped',
|
||||
not_configured: 'Needs setup',
|
||||
pending_restart: 'Restart needed',
|
||||
retrying: 'Retrying',
|
||||
startup_failed: 'Startup failed'
|
||||
},
|
||||
unknown: 'Unknown',
|
||||
hintPendingRestart: 'Restart the gateway from the status bar to apply this change.',
|
||||
hintGatewayStopped: 'Start the gateway from the status bar to connect.',
|
||||
credentialsSet: 'Credentials set',
|
||||
needsSetup: 'Needs setup',
|
||||
gatewayStopped: 'Messaging gateway stopped',
|
||||
getCredentials: 'Get your credentials',
|
||||
openSetupGuide: 'Open setup guide',
|
||||
required: 'Required',
|
||||
recommended: 'Recommended',
|
||||
advanced: count => `Advanced (${count})`,
|
||||
noTokenNeeded: 'This platform does not need a token here. Use the setup guide above, then enable it below.',
|
||||
enabled: 'Enabled',
|
||||
disabled: 'Disabled',
|
||||
unsavedChanges: 'Unsaved changes',
|
||||
saving: 'Saving...',
|
||||
saveChanges: 'Save changes',
|
||||
saved: 'Saved',
|
||||
replaceValue: 'Replace current value',
|
||||
openDocs: 'Open docs',
|
||||
clearField: key => `Clear ${key}`,
|
||||
enableAria: name => `Enable ${name}`,
|
||||
disableAria: name => `Disable ${name}`,
|
||||
platformEnabled: name => `${name} enabled`,
|
||||
platformDisabled: name => `${name} disabled`,
|
||||
restartToApply: 'Restart the gateway for this change to take effect.',
|
||||
setupSaved: name => `${name} setup saved`,
|
||||
restartToReconnect: 'Restart the gateway to reconnect with the new credentials.',
|
||||
keyCleared: key => `${key} cleared`,
|
||||
setupUpdated: name => `${name} setup was updated.`,
|
||||
failedUpdate: name => `Failed to update ${name}`,
|
||||
failedSave: name => `Failed to save ${name}`,
|
||||
failedClear: key => `Failed to clear ${key}`,
|
||||
fieldCopy: {},
|
||||
platformIntro: {}
|
||||
},
|
||||
|
||||
profiles: {
|
||||
close: 'Close profiles',
|
||||
nameHint: 'Lowercase letters, digits, hyphens, and underscores. Must start with a letter or digit.',
|
||||
title: 'Profiles',
|
||||
count: count => `${count} ${count === 1 ? 'profile' : 'profiles'}`,
|
||||
loading: 'Loading profiles...',
|
||||
newProfile: 'New profile',
|
||||
noProfiles: 'No profiles yet.',
|
||||
selectPrompt: 'Select a profile to view its details.',
|
||||
refresh: 'Refresh profiles',
|
||||
refreshing: 'Refreshing profiles',
|
||||
default: 'default',
|
||||
skills: count => `${count} ${count === 1 ? 'skill' : 'skills'}`,
|
||||
env: 'env',
|
||||
defaultBadge: 'Default',
|
||||
rename: 'Rename',
|
||||
copySetup: 'Copy setup',
|
||||
copying: 'Copying...',
|
||||
modelLabel: 'Model',
|
||||
skillsLabel: 'Skills',
|
||||
notSet: 'Not set',
|
||||
soulDesc: 'The system prompt and persona instructions baked into this profile.',
|
||||
unsavedChanges: 'Unsaved changes',
|
||||
loadingSoul: 'Loading SOUL.md...',
|
||||
emptySoul: 'Empty SOUL.md — start writing the persona...',
|
||||
saving: 'Saving...',
|
||||
saveSoul: 'Save SOUL.md',
|
||||
deleteTitle: 'Delete profile?',
|
||||
deleteDescPrefix: 'This will delete ',
|
||||
deleteDescMid: ' and remove its ',
|
||||
deleteDescSuffix: ' directory. This cannot be undone.',
|
||||
deleting: 'Deleting...',
|
||||
createDesc: 'Profiles are independent Hermes environments: separate config, skills, and SOUL.md.',
|
||||
nameLabel: 'Name',
|
||||
cloneFromDefault: 'Clone from default',
|
||||
cloneFromDefaultDesc: 'Copy config, skills, and SOUL.md from your default profile.',
|
||||
invalidName: hint => `Invalid name. ${hint}`,
|
||||
nameRequired: 'Name is required.',
|
||||
creating: 'Creating...',
|
||||
createAction: 'Create profile',
|
||||
renameTitle: 'Rename profile',
|
||||
renameDescPrefix: 'Renaming updates the profile directory and any wrapper scripts in ',
|
||||
renameDescSuffix: '.',
|
||||
newNameLabel: 'New name',
|
||||
renaming: 'Renaming...',
|
||||
created: 'Profile created',
|
||||
renamed: 'Profile renamed',
|
||||
deleted: 'Profile deleted',
|
||||
setupCopied: 'Setup command copied',
|
||||
soulSaved: 'SOUL.md saved',
|
||||
failedLoad: 'Failed to load profiles',
|
||||
failedDelete: 'Failed to delete profile',
|
||||
failedCopy: 'Failed to copy setup command',
|
||||
failedLoadSoul: 'Failed to load SOUL.md',
|
||||
failedSaveSoul: 'Failed to save SOUL.md',
|
||||
failedCreate: 'Failed to create profile',
|
||||
failedRename: 'Failed to rename profile'
|
||||
},
|
||||
|
||||
cron: {
|
||||
close: 'Close cron',
|
||||
search: 'Search cron jobs...',
|
||||
refresh: 'Refresh cron jobs',
|
||||
refreshing: 'Refreshing cron jobs',
|
||||
loading: 'Loading cron jobs...',
|
||||
states: {
|
||||
enabled: 'enabled',
|
||||
scheduled: 'scheduled',
|
||||
running: 'running',
|
||||
paused: 'paused',
|
||||
disabled: 'disabled',
|
||||
error: 'error',
|
||||
completed: 'completed'
|
||||
},
|
||||
deliveryLabels: {
|
||||
local: 'This desktop',
|
||||
telegram: 'Telegram',
|
||||
discord: 'Discord',
|
||||
slack: 'Slack',
|
||||
email: 'Email'
|
||||
},
|
||||
scheduleLabels: {
|
||||
daily: 'Daily',
|
||||
weekdays: 'Weekdays',
|
||||
weekly: 'Weekly',
|
||||
monthly: 'Monthly',
|
||||
hourly: 'Hourly',
|
||||
'every-15-minutes': 'Every 15 minutes',
|
||||
custom: 'Custom'
|
||||
},
|
||||
scheduleHints: {
|
||||
daily: 'Every day at 9:00 AM',
|
||||
weekdays: 'Monday through Friday at 9:00 AM',
|
||||
weekly: 'Every Monday at 9:00 AM',
|
||||
monthly: 'The first day of each month at 9:00 AM',
|
||||
hourly: 'At the top of every hour',
|
||||
'every-15-minutes': 'Every 15 minutes',
|
||||
custom: 'Cron syntax or natural language'
|
||||
},
|
||||
days: {
|
||||
'0': 'Sunday',
|
||||
'1': 'Monday',
|
||||
'2': 'Tuesday',
|
||||
'3': 'Wednesday',
|
||||
'4': 'Thursday',
|
||||
'5': 'Friday',
|
||||
'6': 'Saturday',
|
||||
'7': 'Sunday'
|
||||
},
|
||||
dayFallback: value => `day ${value}`,
|
||||
everyDayAt: time => `Every day at ${time}`,
|
||||
weekdaysAt: time => `Weekdays at ${time}`,
|
||||
everyDayOfWeekAt: (day, time) => `Every ${day} at ${time}`,
|
||||
monthlyOnDayAt: (dayOfMonth, time) => `Monthly on day ${dayOfMonth} at ${time}`,
|
||||
topOfHour: 'At the top of every hour',
|
||||
everyHourAt: minute => `Every hour at :${minute}`,
|
||||
active: (enabled, total) => `${enabled}/${total} active`,
|
||||
newCron: 'New cron',
|
||||
createFirst: 'Create first cron',
|
||||
emptyDescNew:
|
||||
'Schedule a prompt to run on a cron expression. Hermes will run it and deliver results to the destination you pick.',
|
||||
emptyDescSearch: 'Try a broader search query.',
|
||||
emptyTitleNew: 'No scheduled jobs yet',
|
||||
emptyTitleSearch: 'No matches',
|
||||
last: 'Last:',
|
||||
next: 'Next:',
|
||||
actionsFor: title => `Actions for ${title}`,
|
||||
actionsTitle: 'Cron job actions',
|
||||
resume: 'Resume cron',
|
||||
pause: 'Pause cron',
|
||||
resumeTitle: 'Resume',
|
||||
pauseTitle: 'Pause',
|
||||
triggerNow: 'Trigger now',
|
||||
edit: 'Edit cron',
|
||||
deleteTitle: 'Delete cron job?',
|
||||
deleteDescPrefix: 'This will remove ',
|
||||
deleteDescSuffix: ' permanently. It will stop firing immediately.',
|
||||
deleting: 'Deleting...',
|
||||
resumed: 'Cron resumed',
|
||||
paused: 'Cron paused',
|
||||
triggered: 'Cron triggered',
|
||||
deleted: 'Cron deleted',
|
||||
created: 'Cron created',
|
||||
updated: 'Cron updated',
|
||||
failedLoad: 'Failed to load cron jobs',
|
||||
failedUpdate: 'Failed to update cron job',
|
||||
failedTrigger: 'Failed to trigger cron job',
|
||||
failedDelete: 'Failed to delete cron job',
|
||||
failedSave: 'Failed to save cron job',
|
||||
editTitle: 'Edit cron job',
|
||||
createTitle: 'New cron job',
|
||||
editDesc: 'Update the schedule, prompt, or delivery target. Changes apply on next run.',
|
||||
createDesc:
|
||||
'Schedule a prompt to run automatically. Use cron syntax or a natural phrase like "every 15 minutes".',
|
||||
nameLabel: 'Name',
|
||||
namePlaceholder: 'Morning briefing',
|
||||
promptLabel: 'Prompt',
|
||||
promptPlaceholder: 'Summarize my unread Slack threads and email me the top 5...',
|
||||
frequencyLabel: 'Frequency',
|
||||
deliverLabel: 'Deliver to',
|
||||
customScheduleLabel: 'Custom schedule',
|
||||
customPlaceholder: '0 9 * * * or weekdays at 9am',
|
||||
customHint: 'Cron expression, or phrases like "every hour" or "weekdays at 9am".',
|
||||
optional: 'Optional',
|
||||
promptScheduleRequired: 'Prompt and schedule are required.',
|
||||
saveChanges: 'Save changes',
|
||||
createAction: 'Create cron'
|
||||
},
|
||||
|
||||
artifacts: {
|
||||
search: 'Search artifacts...',
|
||||
refresh: 'Refresh artifacts',
|
||||
refreshing: 'Refreshing artifacts',
|
||||
indexing: 'Indexing recent session artifacts',
|
||||
tabAll: 'All',
|
||||
tabImages: 'Images',
|
||||
tabFiles: 'Files',
|
||||
tabLinks: 'Links',
|
||||
noArtifactsTitle: 'No artifacts found',
|
||||
noArtifactsDesc: 'Generated images and file outputs will appear here as sessions produce them.',
|
||||
failedLoad: 'Artifacts failed to load',
|
||||
openFailed: 'Open failed',
|
||||
itemsImage: 'images',
|
||||
itemsLink: 'links',
|
||||
itemsFile: 'files',
|
||||
itemsGeneric: 'items',
|
||||
zero: '0',
|
||||
rangeOf: (start, end, total) => `${start}-${end} of ${total}`,
|
||||
goToPage: (itemLabel, page) => `Go to ${itemLabel} page ${page}`,
|
||||
colTitleLink: 'Link title',
|
||||
colTitleFile: 'Name',
|
||||
colTitleDefault: 'Title / name',
|
||||
colLocationLink: 'URL',
|
||||
colLocationFile: 'Path',
|
||||
colLocationDefault: 'Location',
|
||||
colSession: 'Session',
|
||||
kindImage: 'image',
|
||||
kindFile: 'file',
|
||||
kindLink: 'link',
|
||||
chat: 'Chat',
|
||||
copyUrl: 'Copy URL',
|
||||
copyPath: 'Copy path'
|
||||
},
|
||||
|
||||
sidebar: {
|
||||
nav: {
|
||||
'new-session': 'New session',
|
||||
skills: 'Skills & Tools',
|
||||
messaging: 'Messaging',
|
||||
artifacts: 'Artifacts'
|
||||
},
|
||||
searchAria: 'Search sessions',
|
||||
searchPlaceholder: 'Search sessions…',
|
||||
clearSearch: 'Clear search',
|
||||
noMatch: query => `No sessions match “${query}”.`,
|
||||
results: 'Results',
|
||||
pinned: 'Pinned',
|
||||
sessions: 'Sessions',
|
||||
groupAriaGrouped: 'Show sessions as a single list',
|
||||
groupAriaUngrouped: 'Group sessions by workspace',
|
||||
groupTitleGrouped: 'Ungroup sessions',
|
||||
groupTitleUngrouped: 'Group by workspace',
|
||||
allPinned: 'Everything here is pinned. Unpin a chat to show it in recents.',
|
||||
shiftClickHint: 'Shift-click a chat to pin · drag to reorder',
|
||||
noWorkspace: 'No workspace',
|
||||
newSessionIn: label => `New session in ${label}`,
|
||||
reorderWorkspace: label => `Reorder workspace ${label}`,
|
||||
showMoreIn: (count, label) => `Show ${count} more in ${label}`,
|
||||
loading: 'Loading…',
|
||||
loadMore: 'Load more',
|
||||
loadCount: step => `Load ${step} more`,
|
||||
row: {
|
||||
pin: 'Pin',
|
||||
unpin: 'Unpin',
|
||||
copyId: 'Copy ID',
|
||||
export: 'Export',
|
||||
rename: 'Rename',
|
||||
archive: 'Archive',
|
||||
copyIdFailed: 'Could not copy session ID',
|
||||
actionsFor: title => `Actions for ${title}`,
|
||||
sessionActions: 'Session actions',
|
||||
sessionRunning: 'Session running',
|
||||
needsInput: 'Needs your input',
|
||||
waitingForAnswer: 'Waiting for your answer',
|
||||
renamed: 'Renamed',
|
||||
renameFailed: 'Rename failed',
|
||||
renameTitle: 'Rename session',
|
||||
renameDesc: 'Give this chat a memorable title. Leave empty to clear.',
|
||||
untitledPlaceholder: 'Untitled session',
|
||||
ageNow: 'now',
|
||||
ageDay: 'd',
|
||||
ageHour: 'h',
|
||||
ageMin: 'm'
|
||||
}
|
||||
},
|
||||
|
||||
composer: {
|
||||
message: 'Message',
|
||||
placeholderStarting: 'Starting Hermes...',
|
||||
placeholderReconnecting: 'Reconnecting to Hermes…',
|
||||
placeholderFollowUp: 'Send follow-up',
|
||||
newSessionPlaceholders: [
|
||||
'What are we building?',
|
||||
'Give Hermes a task',
|
||||
"What's on your mind?",
|
||||
'Describe what you need',
|
||||
'What should we tackle?',
|
||||
'Ask anything',
|
||||
'Start with a goal'
|
||||
],
|
||||
followUpPlaceholders: [
|
||||
'Send a follow-up',
|
||||
'Add more context',
|
||||
'Refine the request',
|
||||
"What's next?",
|
||||
'Keep it going',
|
||||
'Push it further',
|
||||
'Adjust or continue'
|
||||
],
|
||||
startVoice: 'Start voice conversation',
|
||||
queueMessage: 'Queue message',
|
||||
stop: 'Stop',
|
||||
send: 'Send',
|
||||
speaking: 'Speaking',
|
||||
transcribing: 'Transcribing',
|
||||
thinking: 'Thinking',
|
||||
muted: 'Muted',
|
||||
listening: 'Listening',
|
||||
muteMic: 'Mute microphone',
|
||||
unmuteMic: 'Unmute microphone',
|
||||
stopListening: 'Stop listening and send',
|
||||
stopShort: 'Stop',
|
||||
endConversation: 'End voice conversation',
|
||||
endShort: 'End',
|
||||
stopDictation: 'Stop dictation',
|
||||
transcribingDictation: 'Transcribing dictation',
|
||||
voiceDictation: 'Voice dictation',
|
||||
commonCommands: 'Common commands',
|
||||
hotkeys: 'Hotkeys',
|
||||
helpFooter: 'opens the full panel · backspace dismisses',
|
||||
commandDescs: {
|
||||
'/help': 'full list of commands + hotkeys',
|
||||
'/clear': 'start a new session',
|
||||
'/resume': 'resume a prior session',
|
||||
'/details': 'control transcript detail level',
|
||||
'/copy': 'copy selection or last assistant message',
|
||||
'/quit': 'exit hermes'
|
||||
},
|
||||
hotkeyDescs: {
|
||||
'@': 'reference files, folders, urls, git',
|
||||
'/': 'slash command palette',
|
||||
'?': 'this quick help (delete to dismiss)',
|
||||
Enter: 'send · Shift+Enter for newline',
|
||||
'Cmd/Ctrl+K': 'send next queued turn',
|
||||
'Cmd/Ctrl+L': 'redraw',
|
||||
Esc: 'close popover · cancel run',
|
||||
'↑ / ↓': 'cycle popover / history'
|
||||
},
|
||||
attachUrlTitle: 'Attach a URL',
|
||||
attachUrlDesc: 'Hermes will fetch the page and include it as context for this turn.',
|
||||
urlPlaceholder: 'https://example.com/post',
|
||||
urlHintPre: 'Include the full URL, e.g. ',
|
||||
attach: 'Attach',
|
||||
queued: count => `${count} Queued`,
|
||||
attachmentOnly: 'Attachment-only turn',
|
||||
emptyTurn: 'Empty turn',
|
||||
attachments: count => `${count} attachment${count === 1 ? '' : 's'}`,
|
||||
editingInComposer: 'Editing in composer',
|
||||
editQueued: 'Edit queued turn',
|
||||
sendQueuedNow: 'Send queued turn now',
|
||||
deleteQueued: 'Delete queued turn',
|
||||
previewUnavailable: 'Preview unavailable',
|
||||
previewLabel: label => `Preview ${label}`,
|
||||
couldNotPreview: label => `Could not preview ${label}`,
|
||||
removeAttachment: label => `Remove ${label}`,
|
||||
dictating: 'Dictating',
|
||||
preparingAudio: 'Preparing audio',
|
||||
speakingResponse: 'Speaking response',
|
||||
readingAloud: 'Reading aloud',
|
||||
themeSuggestions: 'Desktop theme suggestions',
|
||||
noMatchingThemes: 'No matching themes.',
|
||||
themeTryPre: 'Try ',
|
||||
themeTryPost: '.',
|
||||
attachLabel: 'Attach',
|
||||
files: 'Files…',
|
||||
folder: 'Folder…',
|
||||
images: 'Images…',
|
||||
pasteImage: 'Paste image',
|
||||
url: 'URL…',
|
||||
promptSnippets: 'Prompt snippets…',
|
||||
tipPre: 'Tip: type ',
|
||||
tipPost: ' to reference files inline.',
|
||||
snippetsTitle: 'Prompt snippets',
|
||||
snippetsDesc: 'Pick a starter prompt to drop into the composer.',
|
||||
snippets: {
|
||||
codeReview: {
|
||||
label: 'Code review',
|
||||
description: 'Audit the current change for regressions, dropped edge cases, and missing tests.',
|
||||
text: 'Please review this for bugs, regressions, and missing tests.'
|
||||
},
|
||||
implementationPlan: {
|
||||
label: 'Implementation plan',
|
||||
description: 'Outline an approach before touching code so the diff stays focused.',
|
||||
text: 'Please make a concise implementation plan before changing code.'
|
||||
},
|
||||
explainThis: {
|
||||
label: 'Explain this',
|
||||
description: 'Walk through how the selected code works and link to the key files.',
|
||||
text: 'Please explain how this works and point me to the key files.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
export { TRANSLATIONS } from './catalog'
|
||||
export {
|
||||
getConfigDisplayLanguage,
|
||||
type I18nConfigClient,
|
||||
type I18nContextValue,
|
||||
I18nProvider,
|
||||
LOCALE_META,
|
||||
useI18n,
|
||||
withConfigDisplayLanguage
|
||||
} from './context'
|
||||
export {
|
||||
DEFAULT_LOCALE,
|
||||
isLocale,
|
||||
isSupportedLocaleValue,
|
||||
LOCALE_OPTIONS,
|
||||
localeConfigValue,
|
||||
normalizeLocale
|
||||
} from './languages'
|
||||
export { setRuntimeI18nLocale, translateNow } from './runtime'
|
||||
export type { Locale, Translations } from './types'
|
||||
@@ -1,38 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
isLocale,
|
||||
isSupportedLocaleValue,
|
||||
localeConfigValue,
|
||||
normalizeLocale
|
||||
} from './languages'
|
||||
|
||||
describe('desktop i18n languages', () => {
|
||||
it('normalizes supported locale aliases', () => {
|
||||
expect(normalizeLocale('en')).toBe('en')
|
||||
expect(normalizeLocale('EN-US')).toBe('en')
|
||||
expect(normalizeLocale('zh')).toBe('zh')
|
||||
expect(normalizeLocale('zh-CN')).toBe('zh')
|
||||
expect(normalizeLocale('zh-Hans')).toBe('zh')
|
||||
expect(normalizeLocale(' zh_hans_cn ')).toBe('zh')
|
||||
})
|
||||
|
||||
it('falls back to English for empty or unsupported values', () => {
|
||||
expect(normalizeLocale(null)).toBe(DEFAULT_LOCALE)
|
||||
expect(normalizeLocale('')).toBe(DEFAULT_LOCALE)
|
||||
expect(normalizeLocale('ja')).toBe(DEFAULT_LOCALE)
|
||||
})
|
||||
|
||||
it('distinguishes exact locale ids from supported config aliases', () => {
|
||||
expect(isSupportedLocaleValue('zh-CN')).toBe(true)
|
||||
expect(isSupportedLocaleValue('ja')).toBe(false)
|
||||
expect(isLocale('zh-CN')).toBe(false)
|
||||
expect(isLocale('zh')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns the persisted config value for supported locales', () => {
|
||||
expect(localeConfigValue('en')).toBe('en')
|
||||
expect(localeConfigValue('zh')).toBe('zh')
|
||||
})
|
||||
})
|
||||
@@ -1,56 +0,0 @@
|
||||
import type { Locale } from './types'
|
||||
|
||||
export const DEFAULT_LOCALE: Locale = 'en'
|
||||
|
||||
export const LOCALE_OPTIONS = [
|
||||
{
|
||||
id: 'en',
|
||||
name: 'English',
|
||||
configValue: 'en'
|
||||
},
|
||||
{
|
||||
id: 'zh',
|
||||
name: '简体中文',
|
||||
configValue: 'zh'
|
||||
}
|
||||
] as const satisfies readonly { configValue: string; id: Locale; name: string }[]
|
||||
|
||||
// Endonyms (native names) for the language picker so users recognize their
|
||||
// language regardless of the current UI language. No country flags:
|
||||
// languages are not countries.
|
||||
export const LOCALE_META: Record<Locale, { name: string }> = Object.fromEntries(
|
||||
LOCALE_OPTIONS.map(locale => [locale.id, { name: locale.name }])
|
||||
) as Record<Locale, { name: string }>
|
||||
|
||||
const LOCALE_ALIASES: Record<string, Locale> = {
|
||||
en: 'en',
|
||||
'en-us': 'en',
|
||||
en_us: 'en',
|
||||
zh: 'zh',
|
||||
'zh-cn': 'zh',
|
||||
zh_cn: 'zh',
|
||||
'zh-hans': 'zh',
|
||||
zh_hans: 'zh',
|
||||
'zh-hans-cn': 'zh',
|
||||
zh_hans_cn: 'zh'
|
||||
}
|
||||
|
||||
export function isLocale(value: unknown): value is Locale {
|
||||
return typeof value === 'string' && LOCALE_OPTIONS.some(locale => locale.id === value)
|
||||
}
|
||||
|
||||
export function normalizeLocale(value: unknown): Locale {
|
||||
if (typeof value !== 'string') {
|
||||
return DEFAULT_LOCALE
|
||||
}
|
||||
|
||||
return LOCALE_ALIASES[value.trim().toLowerCase()] ?? DEFAULT_LOCALE
|
||||
}
|
||||
|
||||
export function isSupportedLocaleValue(value: unknown): boolean {
|
||||
return typeof value === 'string' && LOCALE_ALIASES[value.trim().toLowerCase()] != null
|
||||
}
|
||||
|
||||
export function localeConfigValue(locale: Locale): string {
|
||||
return LOCALE_OPTIONS.find(item => item.id === locale)?.configValue ?? DEFAULT_LOCALE
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { setRuntimeI18nLocale, translateNow } from './runtime'
|
||||
|
||||
describe('desktop i18n runtime translator', () => {
|
||||
beforeEach(() => {
|
||||
setRuntimeI18nLocale('en')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setRuntimeI18nLocale('en')
|
||||
})
|
||||
|
||||
it('translates string paths for the active runtime locale', () => {
|
||||
setRuntimeI18nLocale('zh')
|
||||
|
||||
expect(translateNow('boot.ready')).toBe('Hermes Desktop 已就绪')
|
||||
})
|
||||
|
||||
it('passes arguments to function translations', () => {
|
||||
expect(translateNow('notifications.updateReadyMessage', 2)).toBe('2 new changes available.')
|
||||
})
|
||||
|
||||
it('returns the key when no locale can resolve a path', () => {
|
||||
setRuntimeI18nLocale('zh')
|
||||
|
||||
expect(translateNow('missing.path')).toBe('missing.path')
|
||||
})
|
||||
})
|
||||
@@ -1,53 +0,0 @@
|
||||
import { TRANSLATIONS } from './catalog'
|
||||
import { DEFAULT_LOCALE } from './languages'
|
||||
import type { Locale, Translations } from './types'
|
||||
|
||||
let runtimeLocale: Locale = DEFAULT_LOCALE
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function resolvePath(catalog: Translations, key: string): unknown {
|
||||
return key.split('.').reduce<unknown>((current, part) => {
|
||||
if (!isRecord(current)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return current[part]
|
||||
}, catalog)
|
||||
}
|
||||
|
||||
function renderTranslation(value: unknown, args: unknown[]): string | null {
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
|
||||
if (typeof value === 'function') {
|
||||
return (value as (...args: unknown[]) => string)(...args)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function setRuntimeI18nLocale(locale: Locale) {
|
||||
runtimeLocale = locale
|
||||
}
|
||||
|
||||
export function translateNow(key: string, ...args: unknown[]): string {
|
||||
const active = renderTranslation(resolvePath(TRANSLATIONS[runtimeLocale], key), args)
|
||||
|
||||
if (active !== null) {
|
||||
return active
|
||||
}
|
||||
|
||||
if (runtimeLocale !== DEFAULT_LOCALE) {
|
||||
const fallback = renderTranslation(resolvePath(TRANSLATIONS[DEFAULT_LOCALE], key), args)
|
||||
|
||||
if (fallback !== null) {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
return key
|
||||
}
|
||||
@@ -1,610 +0,0 @@
|
||||
// Desktop i18n type contract.
|
||||
//
|
||||
// `Translations` is the single source of truth for every translatable string
|
||||
// surface. Each locale file (`en.ts`, `zh.ts`, …) must satisfy this interface,
|
||||
// so a missing key is a compile error — that's the completeness guard for
|
||||
// "full" coverage as more surfaces are migrated off hardcoded English.
|
||||
|
||||
export type Locale = 'en' | 'zh'
|
||||
|
||||
interface ModeOptionCopy {
|
||||
label: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface Translations {
|
||||
common: {
|
||||
save: string
|
||||
saving: string
|
||||
cancel: string
|
||||
close: string
|
||||
confirm: string
|
||||
delete: string
|
||||
refresh: string
|
||||
retry: string
|
||||
on: string
|
||||
off: string
|
||||
}
|
||||
|
||||
boot: {
|
||||
ready: string
|
||||
desktopBootFailedWithMessage: (message: string) => string
|
||||
steps: {
|
||||
connectingGateway: string
|
||||
loadingSettings: string
|
||||
loadingSessions: string
|
||||
startingDesktopConnection: string
|
||||
startingHermesDesktop: string
|
||||
}
|
||||
errors: {
|
||||
backgroundExited: string
|
||||
backgroundExitedDuringStartup: string
|
||||
backendStopped: string
|
||||
desktopBootFailed: string
|
||||
gatewaySignInRequired: string
|
||||
ipcBridgeUnavailable: string
|
||||
}
|
||||
failure: {
|
||||
title: string
|
||||
description: string
|
||||
remoteTitle: string
|
||||
remoteDescription: string
|
||||
retry: string
|
||||
repairInstall: string
|
||||
useLocalGateway: string
|
||||
openLogs: string
|
||||
repairHint: string
|
||||
remoteSignInHint: string
|
||||
hideRecentLogs: string
|
||||
showRecentLogs: string
|
||||
signedInTitle: string
|
||||
signedInMessage: string
|
||||
signInIncompleteTitle: string
|
||||
signInIncompleteMessage: string
|
||||
signInFailed: string
|
||||
signInToRemoteGateway: string
|
||||
signInWithProvider: (provider: string) => string
|
||||
identityProvider: string
|
||||
}
|
||||
}
|
||||
|
||||
notifications: {
|
||||
region: string
|
||||
hide: string
|
||||
show: string
|
||||
more: (count: number) => string
|
||||
clearAll: string
|
||||
dismiss: string
|
||||
details: string
|
||||
copyDetail: string
|
||||
copyDetailFailed: string
|
||||
backendOutOfDateTitle: string
|
||||
backendOutOfDateMessage: string
|
||||
updateHermes: string
|
||||
updateReadyTitle: string
|
||||
updateReadyMessage: (count: number) => string
|
||||
seeWhatsNew: string
|
||||
errors: {
|
||||
elevenLabsNeedsKey: string
|
||||
elevenLabsRejectedKey: string
|
||||
methodNotAllowed: string
|
||||
microphonePermission: string
|
||||
openaiRejectedApiKey: string
|
||||
openaiRejectedApiKeyWithStatus: (status: string) => string
|
||||
openaiTtsNeedsKey: string
|
||||
}
|
||||
}
|
||||
|
||||
titlebar: {
|
||||
hideSidebar: string
|
||||
showSidebar: string
|
||||
search: string
|
||||
searchTitle: string
|
||||
swapSidebarSides: string
|
||||
swapSidebarSidesTitle: string
|
||||
hideRightSidebar: string
|
||||
showRightSidebar: string
|
||||
muteHaptics: string
|
||||
unmuteHaptics: string
|
||||
openSettings: string
|
||||
}
|
||||
|
||||
language: {
|
||||
label: string
|
||||
description: string
|
||||
saving: string
|
||||
saveError: string
|
||||
}
|
||||
|
||||
settings: {
|
||||
closeSettings: string
|
||||
exportConfig: string
|
||||
importConfig: string
|
||||
resetToDefaults: string
|
||||
resetConfirm: string
|
||||
exportFailed: string
|
||||
resetFailed: string
|
||||
nav: {
|
||||
gateway: string
|
||||
apiKeys: string
|
||||
mcp: string
|
||||
archivedChats: string
|
||||
about: string
|
||||
}
|
||||
sections: Record<string, string>
|
||||
searchPlaceholder: Record<'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions', string>
|
||||
modeOptions: Record<'light' | 'dark' | 'system', ModeOptionCopy>
|
||||
appearance: {
|
||||
title: string
|
||||
intro: string
|
||||
colorMode: string
|
||||
colorModeDesc: string
|
||||
toolViewTitle: string
|
||||
toolViewDesc: string
|
||||
product: string
|
||||
productDesc: string
|
||||
technical: string
|
||||
technicalDesc: string
|
||||
themeTitle: string
|
||||
themeDesc: string
|
||||
}
|
||||
fieldLabels: Record<string, string>
|
||||
fieldDescriptions: Record<string, string>
|
||||
about: {
|
||||
heading: string
|
||||
version: (value: string) => string
|
||||
versionUnavailable: string
|
||||
updates: string
|
||||
checkNow: string
|
||||
checking: string
|
||||
seeWhatsNew: string
|
||||
releaseNotes: string
|
||||
onLatest: string
|
||||
installing: string
|
||||
cantUpdate: string
|
||||
cantReach: string
|
||||
tapCheck: string
|
||||
updateReady: (count: number) => string
|
||||
lastChecked: (age: string) => string
|
||||
justNowSuffix: string
|
||||
automaticUpdates: string
|
||||
automaticUpdatesDesc: string
|
||||
branchCommit: (branch: string, commit: string) => string
|
||||
never: string
|
||||
justNow: string
|
||||
minAgo: (count: number) => string
|
||||
hoursAgo: (count: number) => string
|
||||
daysAgo: (count: number) => string
|
||||
}
|
||||
}
|
||||
|
||||
skills: {
|
||||
tabSkills: string
|
||||
tabToolsets: string
|
||||
all: string
|
||||
searchSkills: string
|
||||
searchToolsets: string
|
||||
refresh: string
|
||||
refreshing: string
|
||||
loading: string
|
||||
noSkillsTitle: string
|
||||
noSkillsDesc: string
|
||||
noToolsetsTitle: string
|
||||
noToolsetsDesc: string
|
||||
noDescription: string
|
||||
configured: string
|
||||
needsKeys: string
|
||||
toolsetsEnabled: (enabled: number, total: number) => string
|
||||
configureToolset: (label: string) => string
|
||||
toggleToolset: (label: string) => string
|
||||
skillsLoadFailed: string
|
||||
toolsetsRefreshFailed: string
|
||||
skillEnabled: string
|
||||
skillDisabled: string
|
||||
toolsetEnabled: string
|
||||
toolsetDisabled: string
|
||||
appliesToNewSessions: (name: string) => string
|
||||
failedToUpdate: (name: string) => string
|
||||
}
|
||||
|
||||
agents: {
|
||||
close: string
|
||||
title: string
|
||||
subtitle: string
|
||||
emptyTitle: string
|
||||
emptyDesc: string
|
||||
running: string
|
||||
failed: string
|
||||
done: string
|
||||
streaming: string
|
||||
files: string
|
||||
moreFiles: (count: number) => string
|
||||
delegation: (index: number) => string
|
||||
workers: (count: number) => string
|
||||
workersActive: (count: number) => string
|
||||
agentsCount: (count: number) => string
|
||||
activeCount: (count: number) => string
|
||||
failedCount: (count: number) => string
|
||||
toolsCount: (count: number) => string
|
||||
filesCount: (count: number) => string
|
||||
updatedAgo: (age: string) => string
|
||||
ageNow: string
|
||||
ageSeconds: (seconds: number) => string
|
||||
ageMinutes: (minutes: number) => string
|
||||
ageHours: (hours: number) => string
|
||||
durationSeconds: (seconds: string) => string
|
||||
durationMinutes: (minutes: number, seconds: number) => string
|
||||
tokensK: (k: string) => string
|
||||
tokens: (value: number) => string
|
||||
}
|
||||
|
||||
commandCenter: {
|
||||
close: string
|
||||
searchPlaceholder: string
|
||||
sections: Record<'sessions' | 'system' | 'usage', string>
|
||||
sectionDescriptions: Record<'sessions' | 'system' | 'usage', string>
|
||||
nav: Record<'newChat' | 'settings' | 'skills' | 'messaging' | 'artifacts', { title: string; detail: string }>
|
||||
sectionEntries: Record<'sessions' | 'system' | 'usage', { title: string; detail: string }>
|
||||
providerNavigate: string
|
||||
providerSessions: string
|
||||
refresh: string
|
||||
refreshing: string
|
||||
noResults: string
|
||||
pinSession: string
|
||||
unpinSession: string
|
||||
exportSession: string
|
||||
deleteSession: string
|
||||
noSessions: string
|
||||
gatewayRunning: string
|
||||
gatewayStopped: string
|
||||
hermesActiveSessions: (version: string, count: number) => string
|
||||
restartMessaging: string
|
||||
updateHermes: string
|
||||
actionRunning: string
|
||||
actionDone: string
|
||||
actionFailed: string
|
||||
actionStartedWaiting: string
|
||||
loadingStatus: string
|
||||
recentLogs: string
|
||||
noLogs: string
|
||||
days: (count: number) => string
|
||||
statSessions: string
|
||||
statApiCalls: string
|
||||
statTokens: string
|
||||
statCost: string
|
||||
actualCost: (cost: string) => string
|
||||
loadingUsage: string
|
||||
noUsage: (period: number) => string
|
||||
retry: string
|
||||
dailyTokens: string
|
||||
input: string
|
||||
output: string
|
||||
noDailyActivity: string
|
||||
topModels: string
|
||||
noModelUsage: string
|
||||
topSkills: string
|
||||
noSkillActivity: string
|
||||
actions: (count: string) => string
|
||||
}
|
||||
|
||||
messaging: {
|
||||
search: string
|
||||
loading: string
|
||||
loadFailed: string
|
||||
states: Record<string, string>
|
||||
unknown: string
|
||||
hintPendingRestart: string
|
||||
hintGatewayStopped: string
|
||||
credentialsSet: string
|
||||
needsSetup: string
|
||||
gatewayStopped: string
|
||||
getCredentials: string
|
||||
openSetupGuide: string
|
||||
required: string
|
||||
recommended: string
|
||||
advanced: (count: number) => string
|
||||
noTokenNeeded: string
|
||||
enabled: string
|
||||
disabled: string
|
||||
unsavedChanges: string
|
||||
saving: string
|
||||
saveChanges: string
|
||||
saved: string
|
||||
replaceValue: string
|
||||
openDocs: string
|
||||
clearField: (key: string) => string
|
||||
enableAria: (name: string) => string
|
||||
disableAria: (name: string) => string
|
||||
platformEnabled: (name: string) => string
|
||||
platformDisabled: (name: string) => string
|
||||
restartToApply: string
|
||||
setupSaved: (name: string) => string
|
||||
restartToReconnect: string
|
||||
keyCleared: (key: string) => string
|
||||
setupUpdated: (name: string) => string
|
||||
failedUpdate: (name: string) => string
|
||||
failedSave: (name: string) => string
|
||||
failedClear: (key: string) => string
|
||||
fieldCopy: Record<string, { label?: string; help?: string; placeholder?: string }>
|
||||
platformIntro: Record<string, string>
|
||||
}
|
||||
|
||||
profiles: {
|
||||
close: string
|
||||
nameHint: string
|
||||
title: string
|
||||
count: (count: number) => string
|
||||
loading: string
|
||||
newProfile: string
|
||||
noProfiles: string
|
||||
selectPrompt: string
|
||||
refresh: string
|
||||
refreshing: string
|
||||
default: string
|
||||
skills: (count: number) => string
|
||||
env: string
|
||||
defaultBadge: string
|
||||
rename: string
|
||||
copySetup: string
|
||||
copying: string
|
||||
modelLabel: string
|
||||
skillsLabel: string
|
||||
notSet: string
|
||||
soulDesc: string
|
||||
unsavedChanges: string
|
||||
loadingSoul: string
|
||||
emptySoul: string
|
||||
saving: string
|
||||
saveSoul: string
|
||||
deleteTitle: string
|
||||
deleteDescPrefix: string
|
||||
deleteDescMid: string
|
||||
deleteDescSuffix: string
|
||||
deleting: string
|
||||
createDesc: string
|
||||
nameLabel: string
|
||||
cloneFromDefault: string
|
||||
cloneFromDefaultDesc: string
|
||||
invalidName: (hint: string) => string
|
||||
nameRequired: string
|
||||
creating: string
|
||||
createAction: string
|
||||
renameTitle: string
|
||||
renameDescPrefix: string
|
||||
renameDescSuffix: string
|
||||
newNameLabel: string
|
||||
renaming: string
|
||||
created: string
|
||||
renamed: string
|
||||
deleted: string
|
||||
setupCopied: string
|
||||
soulSaved: string
|
||||
failedLoad: string
|
||||
failedDelete: string
|
||||
failedCopy: string
|
||||
failedLoadSoul: string
|
||||
failedSaveSoul: string
|
||||
failedCreate: string
|
||||
failedRename: string
|
||||
}
|
||||
|
||||
cron: {
|
||||
close: string
|
||||
search: string
|
||||
refresh: string
|
||||
refreshing: string
|
||||
loading: string
|
||||
states: Record<string, string>
|
||||
deliveryLabels: Record<string, string>
|
||||
scheduleLabels: Record<string, string>
|
||||
scheduleHints: Record<string, string>
|
||||
days: Record<string, string>
|
||||
dayFallback: (value: string) => string
|
||||
everyDayAt: (time: string) => string
|
||||
weekdaysAt: (time: string) => string
|
||||
everyDayOfWeekAt: (day: string, time: string) => string
|
||||
monthlyOnDayAt: (dayOfMonth: string, time: string) => string
|
||||
topOfHour: string
|
||||
everyHourAt: (minute: string) => string
|
||||
active: (enabled: number, total: number) => string
|
||||
newCron: string
|
||||
createFirst: string
|
||||
emptyDescNew: string
|
||||
emptyDescSearch: string
|
||||
emptyTitleNew: string
|
||||
emptyTitleSearch: string
|
||||
last: string
|
||||
next: string
|
||||
actionsFor: (title: string) => string
|
||||
actionsTitle: string
|
||||
resume: string
|
||||
pause: string
|
||||
resumeTitle: string
|
||||
pauseTitle: string
|
||||
triggerNow: string
|
||||
edit: string
|
||||
deleteTitle: string
|
||||
deleteDescPrefix: string
|
||||
deleteDescSuffix: string
|
||||
deleting: string
|
||||
resumed: string
|
||||
paused: string
|
||||
triggered: string
|
||||
deleted: string
|
||||
created: string
|
||||
updated: string
|
||||
failedLoad: string
|
||||
failedUpdate: string
|
||||
failedTrigger: string
|
||||
failedDelete: string
|
||||
failedSave: string
|
||||
editTitle: string
|
||||
createTitle: string
|
||||
editDesc: string
|
||||
createDesc: string
|
||||
nameLabel: string
|
||||
namePlaceholder: string
|
||||
promptLabel: string
|
||||
promptPlaceholder: string
|
||||
frequencyLabel: string
|
||||
deliverLabel: string
|
||||
customScheduleLabel: string
|
||||
customPlaceholder: string
|
||||
customHint: string
|
||||
optional: string
|
||||
promptScheduleRequired: string
|
||||
saveChanges: string
|
||||
createAction: string
|
||||
}
|
||||
|
||||
artifacts: {
|
||||
search: string
|
||||
refresh: string
|
||||
refreshing: string
|
||||
indexing: string
|
||||
tabAll: string
|
||||
tabImages: string
|
||||
tabFiles: string
|
||||
tabLinks: string
|
||||
noArtifactsTitle: string
|
||||
noArtifactsDesc: string
|
||||
failedLoad: string
|
||||
openFailed: string
|
||||
itemsImage: string
|
||||
itemsLink: string
|
||||
itemsFile: string
|
||||
itemsGeneric: string
|
||||
zero: string
|
||||
rangeOf: (start: number, end: number, total: number) => string
|
||||
goToPage: (itemLabel: string, page: number) => string
|
||||
colTitleLink: string
|
||||
colTitleFile: string
|
||||
colTitleDefault: string
|
||||
colLocationLink: string
|
||||
colLocationFile: string
|
||||
colLocationDefault: string
|
||||
colSession: string
|
||||
kindImage: string
|
||||
kindFile: string
|
||||
kindLink: string
|
||||
chat: string
|
||||
copyUrl: string
|
||||
copyPath: string
|
||||
}
|
||||
|
||||
sidebar: {
|
||||
nav: Record<string, string>
|
||||
searchAria: string
|
||||
searchPlaceholder: string
|
||||
clearSearch: string
|
||||
noMatch: (query: string) => string
|
||||
results: string
|
||||
pinned: string
|
||||
sessions: string
|
||||
groupAriaGrouped: string
|
||||
groupAriaUngrouped: string
|
||||
groupTitleGrouped: string
|
||||
groupTitleUngrouped: string
|
||||
allPinned: string
|
||||
shiftClickHint: string
|
||||
noWorkspace: string
|
||||
newSessionIn: (label: string) => string
|
||||
reorderWorkspace: (label: string) => string
|
||||
showMoreIn: (count: number, label: string) => string
|
||||
loading: string
|
||||
loadMore: string
|
||||
loadCount: (step: number) => string
|
||||
row: {
|
||||
pin: string
|
||||
unpin: string
|
||||
copyId: string
|
||||
export: string
|
||||
rename: string
|
||||
archive: string
|
||||
copyIdFailed: string
|
||||
actionsFor: (title: string) => string
|
||||
sessionActions: string
|
||||
sessionRunning: string
|
||||
needsInput: string
|
||||
waitingForAnswer: string
|
||||
renamed: string
|
||||
renameFailed: string
|
||||
renameTitle: string
|
||||
renameDesc: string
|
||||
untitledPlaceholder: string
|
||||
ageNow: string
|
||||
ageDay: string
|
||||
ageHour: string
|
||||
ageMin: string
|
||||
}
|
||||
}
|
||||
|
||||
composer: {
|
||||
message: string
|
||||
placeholderStarting: string
|
||||
placeholderReconnecting: string
|
||||
placeholderFollowUp: string
|
||||
newSessionPlaceholders: readonly string[]
|
||||
followUpPlaceholders: readonly string[]
|
||||
startVoice: string
|
||||
queueMessage: string
|
||||
stop: string
|
||||
send: string
|
||||
speaking: string
|
||||
transcribing: string
|
||||
thinking: string
|
||||
muted: string
|
||||
listening: string
|
||||
muteMic: string
|
||||
unmuteMic: string
|
||||
stopListening: string
|
||||
stopShort: string
|
||||
endConversation: string
|
||||
endShort: string
|
||||
stopDictation: string
|
||||
transcribingDictation: string
|
||||
voiceDictation: string
|
||||
commonCommands: string
|
||||
hotkeys: string
|
||||
helpFooter: string
|
||||
commandDescs: Record<string, string>
|
||||
hotkeyDescs: Record<string, string>
|
||||
attachUrlTitle: string
|
||||
attachUrlDesc: string
|
||||
urlPlaceholder: string
|
||||
urlHintPre: string
|
||||
attach: string
|
||||
queued: (count: number) => string
|
||||
attachmentOnly: string
|
||||
emptyTurn: string
|
||||
attachments: (count: number) => string
|
||||
editingInComposer: string
|
||||
editQueued: string
|
||||
sendQueuedNow: string
|
||||
deleteQueued: string
|
||||
previewUnavailable: string
|
||||
previewLabel: (label: string) => string
|
||||
couldNotPreview: (label: string) => string
|
||||
removeAttachment: (label: string) => string
|
||||
dictating: string
|
||||
preparingAudio: string
|
||||
speakingResponse: string
|
||||
readingAloud: string
|
||||
themeSuggestions: string
|
||||
noMatchingThemes: string
|
||||
themeTryPre: string
|
||||
themeTryPost: string
|
||||
attachLabel: string
|
||||
files: string
|
||||
folder: string
|
||||
images: string
|
||||
pasteImage: string
|
||||
url: string
|
||||
promptSnippets: string
|
||||
tipPre: string
|
||||
tipPost: string
|
||||
snippetsTitle: string
|
||||
snippetsDesc: string
|
||||
snippets: Record<string, { label: string; description: string; text: string }>
|
||||
}
|
||||
}
|
||||
@@ -1,873 +0,0 @@
|
||||
import type { Translations } from './types'
|
||||
|
||||
export const zh: Translations = {
|
||||
common: {
|
||||
save: '保存',
|
||||
saving: '保存中…',
|
||||
cancel: '取消',
|
||||
close: '关闭',
|
||||
confirm: '确认',
|
||||
delete: '删除',
|
||||
refresh: '刷新',
|
||||
retry: '重试',
|
||||
on: '开',
|
||||
off: '关'
|
||||
},
|
||||
|
||||
boot: {
|
||||
ready: 'Hermes Desktop 已就绪',
|
||||
desktopBootFailedWithMessage: message => `桌面启动失败:${message}`,
|
||||
steps: {
|
||||
connectingGateway: '正在连接实时桌面网关',
|
||||
loadingSettings: '正在加载 Hermes 设置',
|
||||
loadingSessions: '正在加载最近会话',
|
||||
startingDesktopConnection: '正在启动桌面连接',
|
||||
startingHermesDesktop: '正在启动 Hermes Desktop…'
|
||||
},
|
||||
errors: {
|
||||
backgroundExited: 'Hermes 后台进程已退出。',
|
||||
backgroundExitedDuringStartup: 'Hermes 后台进程在启动期间退出。',
|
||||
backendStopped: '后端已停止',
|
||||
desktopBootFailed: '桌面启动失败',
|
||||
gatewaySignInRequired: '需要登录网关',
|
||||
ipcBridgeUnavailable: '桌面 IPC 桥不可用。'
|
||||
},
|
||||
failure: {
|
||||
title: 'Hermes 无法启动',
|
||||
description: '后台网关没有启动。请尝试下面的恢复步骤。这些操作不会删除你的对话或设置。',
|
||||
remoteTitle: '需要重新登录远程网关',
|
||||
remoteDescription: '你的远程网关会话已过期。请重新登录以恢复连接。这些操作不会删除你的对话或设置。',
|
||||
retry: '重试',
|
||||
repairInstall: '修复安装',
|
||||
useLocalGateway: '使用本地网关',
|
||||
openLogs: '打开日志',
|
||||
repairHint: '修复会重新运行安装器。在新机器上可能需要几分钟。',
|
||||
remoteSignInHint: '打开网关登录窗口。也可以使用本地网关切换到随应用提供的后端。',
|
||||
hideRecentLogs: '隐藏最近日志',
|
||||
showRecentLogs: '显示最近日志',
|
||||
signedInTitle: '已登录',
|
||||
signedInMessage: '正在重新连接远程网关…',
|
||||
signInIncompleteTitle: '登录未完成',
|
||||
signInIncompleteMessage: '登录窗口在认证完成前关闭。',
|
||||
signInFailed: '登录失败',
|
||||
signInToRemoteGateway: '登录远程网关',
|
||||
signInWithProvider: provider => `使用 ${provider} 登录`,
|
||||
identityProvider: '你的身份提供方'
|
||||
}
|
||||
},
|
||||
|
||||
notifications: {
|
||||
region: '通知',
|
||||
hide: '隐藏',
|
||||
show: '显示',
|
||||
more: count => `另外 ${count} 条通知`,
|
||||
clearAll: '全部清除',
|
||||
dismiss: '关闭通知',
|
||||
details: '详情',
|
||||
copyDetail: '复制详情',
|
||||
copyDetailFailed: '无法复制通知详情',
|
||||
backendOutOfDateTitle: '后端版本过旧',
|
||||
backendOutOfDateMessage: '你的 Hermes 后端早于当前桌面构建,可能无法正常工作。请更新以保持一致。',
|
||||
updateHermes: '更新 Hermes',
|
||||
updateReadyTitle: '有可用更新',
|
||||
updateReadyMessage: count => `有 ${count} 项新更改可用。`,
|
||||
seeWhatsNew: '查看更新内容',
|
||||
errors: {
|
||||
elevenLabsNeedsKey: 'ElevenLabs STT 需要 ELEVENLABS_API_KEY。',
|
||||
elevenLabsRejectedKey: 'ElevenLabs 拒绝了该 API key (401)。',
|
||||
methodNotAllowed: '桌面后端拒绝了该请求(405 Method Not Allowed)。请尝试重启 Hermes Desktop。',
|
||||
microphonePermission: '麦克风权限已被拒绝。',
|
||||
openaiRejectedApiKey: 'OpenAI 拒绝了该 API key。',
|
||||
openaiRejectedApiKeyWithStatus: status => `OpenAI 拒绝了该 API key (${status} invalid_api_key)。`,
|
||||
openaiTtsNeedsKey: 'OpenAI TTS 需要 VOICE_TOOLS_OPENAI_KEY 或 OPENAI_API_KEY。'
|
||||
}
|
||||
},
|
||||
|
||||
titlebar: {
|
||||
hideSidebar: '隐藏侧边栏',
|
||||
showSidebar: '显示侧边栏',
|
||||
search: '搜索',
|
||||
searchTitle: '搜索会话、视图与操作',
|
||||
swapSidebarSides: '交换侧边栏位置',
|
||||
swapSidebarSidesTitle: '交换会话栏和文件浏览器的位置',
|
||||
hideRightSidebar: '隐藏右侧栏',
|
||||
showRightSidebar: '显示右侧栏',
|
||||
muteHaptics: '关闭触感反馈',
|
||||
unmuteHaptics: '开启触感反馈',
|
||||
openSettings: '打开设置'
|
||||
},
|
||||
|
||||
language: {
|
||||
label: '语言',
|
||||
description: '选择桌面界面的语言。',
|
||||
saving: '正在保存语言…',
|
||||
saveError: '语言更新失败'
|
||||
},
|
||||
|
||||
settings: {
|
||||
closeSettings: '关闭设置',
|
||||
exportConfig: '导出配置',
|
||||
importConfig: '导入配置',
|
||||
resetToDefaults: '恢复默认',
|
||||
resetConfirm: '将所有设置恢复为 Hermes 默认值?',
|
||||
exportFailed: '导出失败',
|
||||
resetFailed: '重置失败',
|
||||
nav: {
|
||||
gateway: '网关',
|
||||
apiKeys: '工具与密钥',
|
||||
mcp: 'MCP',
|
||||
archivedChats: '已归档对话',
|
||||
about: '关于'
|
||||
},
|
||||
sections: {
|
||||
model: '模型',
|
||||
chat: '对话',
|
||||
appearance: '外观',
|
||||
workspace: '工作区',
|
||||
safety: '安全',
|
||||
memory: '记忆与上下文',
|
||||
voice: '语音',
|
||||
advanced: '高级'
|
||||
},
|
||||
searchPlaceholder: {
|
||||
about: '关于 Hermes Desktop',
|
||||
config: '搜索设置…',
|
||||
gateway: '网关连接…',
|
||||
keys: '搜索 API 密钥…',
|
||||
mcp: '搜索 MCP 服务器…',
|
||||
sessions: '搜索已归档会话…'
|
||||
},
|
||||
modeOptions: {
|
||||
light: { label: '明亮', description: '明亮的桌面界面' },
|
||||
dark: { label: '暗色', description: '低眩光工作区' },
|
||||
system: { label: '跟随系统', description: '跟随系统外观' }
|
||||
},
|
||||
appearance: {
|
||||
title: '外观',
|
||||
intro: '这些是仅桌面端的显示偏好。模式控制明暗;主题控制强调色与对话界面样式。',
|
||||
colorMode: '颜色模式',
|
||||
colorModeDesc: '选择固定模式,或让 Hermes 跟随系统设置。',
|
||||
toolViewTitle: '工具调用显示',
|
||||
toolViewDesc: '产品模式隐藏原始工具数据;技术模式显示完整输入/输出。',
|
||||
product: '产品',
|
||||
productDesc: '易读的工具活动与简洁摘要。',
|
||||
technical: '技术',
|
||||
technicalDesc: '包含原始工具参数/结果及底层细节。',
|
||||
themeTitle: '主题',
|
||||
themeDesc: '仅桌面端调色板。所选模式叠加其上。'
|
||||
},
|
||||
fieldLabels: {
|
||||
model: '默认模型',
|
||||
model_context_length: '上下文窗口',
|
||||
fallback_providers: '备用模型',
|
||||
toolsets: '启用的工具集',
|
||||
timezone: '时区',
|
||||
'display.personality': '人格',
|
||||
'display.show_reasoning': '推理过程块',
|
||||
'agent.max_turns': '最大智能体步数',
|
||||
'agent.image_input_mode': '图片附件',
|
||||
'terminal.cwd': '工作目录',
|
||||
'terminal.backend': '执行后端',
|
||||
'terminal.timeout': '命令超时',
|
||||
'terminal.persistent_shell': '持久化 Shell',
|
||||
'terminal.env_passthrough': '环境变量透传',
|
||||
file_read_max_chars: '文件读取上限',
|
||||
'tool_output.max_bytes': '终端输出上限',
|
||||
'tool_output.max_lines': '文件分页上限',
|
||||
'tool_output.max_line_length': '行长度上限',
|
||||
'code_execution.mode': '代码执行模式',
|
||||
'approvals.mode': '审批模式',
|
||||
'approvals.timeout': '审批超时',
|
||||
'approvals.mcp_reload_confirm': '确认 MCP 重载',
|
||||
command_allowlist: '命令白名单',
|
||||
'security.redact_secrets': '隐去密钥',
|
||||
'security.allow_private_urls': '允许私有 URL',
|
||||
'browser.allow_private_urls': '浏览器私有 URL',
|
||||
'browser.auto_local_for_private_urls': '私有 URL 使用本地浏览器',
|
||||
'checkpoints.enabled': '文件检查点',
|
||||
'checkpoints.max_snapshots': '检查点上限',
|
||||
'voice.record_key': '语音快捷键',
|
||||
'voice.max_recording_seconds': '最长录音时长',
|
||||
'voice.auto_tts': '朗读回复',
|
||||
'stt.enabled': '语音转文字',
|
||||
'stt.provider': '语音转文字提供方',
|
||||
'stt.local.model': '本地转写模型',
|
||||
'stt.local.language': '转写语言',
|
||||
'stt.elevenlabs.model_id': 'ElevenLabs STT 模型',
|
||||
'stt.elevenlabs.language_code': 'ElevenLabs 语言',
|
||||
'stt.elevenlabs.tag_audio_events': '标记音频事件',
|
||||
'stt.elevenlabs.diarize': '说话人区分',
|
||||
'tts.provider': '文字转语音提供方',
|
||||
'tts.edge.voice': 'Edge 语音',
|
||||
'tts.openai.model': 'OpenAI TTS 模型',
|
||||
'tts.openai.voice': 'OpenAI 语音',
|
||||
'tts.elevenlabs.voice_id': 'ElevenLabs 语音',
|
||||
'tts.elevenlabs.model_id': 'ElevenLabs 模型',
|
||||
'memory.memory_enabled': '持久记忆',
|
||||
'memory.user_profile_enabled': '用户画像',
|
||||
'memory.memory_char_limit': '记忆预算',
|
||||
'memory.user_char_limit': '画像预算',
|
||||
'memory.provider': '记忆提供方',
|
||||
'context.engine': '上下文引擎',
|
||||
'compression.enabled': '自动压缩',
|
||||
'compression.threshold': '压缩阈值',
|
||||
'compression.target_ratio': '压缩目标',
|
||||
'compression.protect_last_n': '保护最近消息',
|
||||
'agent.api_max_retries': 'API 重试次数',
|
||||
'agent.service_tier': '服务等级',
|
||||
'agent.tool_use_enforcement': '工具调用强制',
|
||||
'delegation.model': '子智能体模型',
|
||||
'delegation.provider': '子智能体提供方',
|
||||
'delegation.max_iterations': '子智能体轮次上限',
|
||||
'delegation.max_concurrent_children': '并行子智能体',
|
||||
'delegation.child_timeout_seconds': '子智能体超时',
|
||||
'delegation.reasoning_effort': '子智能体推理强度'
|
||||
},
|
||||
fieldDescriptions: {
|
||||
model: '用于新对话,除非你在输入框中选择其他模型。',
|
||||
model_context_length: '保持为 0 则使用所选模型检测到的上下文窗口。',
|
||||
fallback_providers: '默认模型失败时尝试的备用 provider:model 条目。',
|
||||
'display.personality': '新会话的默认助手风格。',
|
||||
timezone: '当 Hermes 需要本地时间上下文时使用。留空则使用系统时区。',
|
||||
'display.show_reasoning': '当后端提供推理内容时予以显示。',
|
||||
'agent.image_input_mode': '控制图片附件如何发送给模型。',
|
||||
'terminal.cwd': '工具与终端操作的默认项目目录。',
|
||||
'code_execution.mode': '代码执行被限定到当前项目的严格程度。',
|
||||
'terminal.persistent_shell': '当后端支持时,在命令之间保留 Shell 状态。',
|
||||
'terminal.env_passthrough': '传入工具执行的环境变量。',
|
||||
file_read_max_chars: 'Hermes 单次文件读取可读取的最大字符数。',
|
||||
'approvals.mode': 'Hermes 如何处理需要显式审批的命令。',
|
||||
'approvals.timeout': '审批提示在超时前等待的时长。',
|
||||
'security.redact_secrets': '尽可能从模型可见内容中隐藏检测到的密钥。',
|
||||
'checkpoints.enabled': '在文件编辑前创建可回滚的快照。',
|
||||
'memory.memory_enabled': '保存有助于未来会话的持久记忆。',
|
||||
'memory.user_profile_enabled': '维护一份精简的用户偏好画像。',
|
||||
'context.engine': '在接近上下文上限时管理长对话的策略。',
|
||||
'compression.enabled': '当对话变大时对较早的上下文进行摘要。',
|
||||
'voice.auto_tts': '自动朗读助手回复。',
|
||||
'stt.enabled': '启用本地或提供方支持的语音转写。',
|
||||
'stt.elevenlabs.language_code': '可选的 ISO-639-3 语言代码。留空让 ElevenLabs 自动检测。',
|
||||
'agent.max_turns': 'Hermes 停止一次运行前工具调用轮次的上限。'
|
||||
},
|
||||
about: {
|
||||
heading: 'Hermes Desktop',
|
||||
version: value => `版本 ${value}`,
|
||||
versionUnavailable: '版本不可用',
|
||||
updates: '更新',
|
||||
checkNow: '立即检查',
|
||||
checking: '检查中…',
|
||||
seeWhatsNew: '查看新增内容',
|
||||
releaseNotes: '发行说明',
|
||||
onLatest: '你已是最新版本。',
|
||||
installing: '正在安装更新。',
|
||||
cantUpdate: '此版本无法在应用内自我更新。',
|
||||
cantReach: '无法连接更新服务器。',
|
||||
tapCheck: '点击"立即检查"以查找更新。',
|
||||
updateReady: count => `已准备好新更新(包含 ${count} 项更改)。`,
|
||||
lastChecked: age => `上次检查:${age}`,
|
||||
justNowSuffix: ' · 刚刚',
|
||||
automaticUpdates: '自动更新',
|
||||
automaticUpdatesDesc: 'Hermes 会在后台自动检查更新,并在有可用更新时通知你。',
|
||||
branchCommit: (branch, commit) => `分支 ${branch} · 提交 ${commit}`,
|
||||
never: '从未',
|
||||
justNow: '刚刚',
|
||||
minAgo: count => `${count} 分钟前`,
|
||||
hoursAgo: count => `${count} 小时前`,
|
||||
daysAgo: count => `${count} 天前`
|
||||
}
|
||||
},
|
||||
|
||||
skills: {
|
||||
tabSkills: '技能',
|
||||
tabToolsets: '工具集',
|
||||
all: '全部',
|
||||
searchSkills: '搜索技能…',
|
||||
searchToolsets: '搜索工具集…',
|
||||
refresh: '刷新技能',
|
||||
refreshing: '正在刷新技能',
|
||||
loading: '正在加载能力…',
|
||||
noSkillsTitle: '未找到技能',
|
||||
noSkillsDesc: '尝试更宽泛的搜索或其他分类。',
|
||||
noToolsetsTitle: '未找到工具集',
|
||||
noToolsetsDesc: '尝试更宽泛的搜索词。',
|
||||
noDescription: '暂无描述。',
|
||||
configured: '已配置',
|
||||
needsKeys: '需要密钥',
|
||||
toolsetsEnabled: (enabled, total) => `已启用 ${enabled}/${total} 个工具集`,
|
||||
configureToolset: label => `配置 ${label}`,
|
||||
toggleToolset: label => `切换 ${label} 工具集`,
|
||||
skillsLoadFailed: '技能加载失败',
|
||||
toolsetsRefreshFailed: '工具集刷新失败',
|
||||
skillEnabled: '技能已启用',
|
||||
skillDisabled: '技能已禁用',
|
||||
toolsetEnabled: '工具集已启用',
|
||||
toolsetDisabled: '工具集已禁用',
|
||||
appliesToNewSessions: name => `${name} 将应用于新会话。`,
|
||||
failedToUpdate: name => `更新 ${name} 失败`
|
||||
},
|
||||
|
||||
agents: {
|
||||
close: '关闭代理',
|
||||
title: '派生树',
|
||||
subtitle: '当前回合的子代理实时活动。',
|
||||
emptyTitle: '暂无活跃子代理',
|
||||
emptyDesc: '当某个回合派发任务时,子代理会在此实时显示进度。',
|
||||
running: '运行中',
|
||||
failed: '失败',
|
||||
done: '完成',
|
||||
streaming: '流式传输',
|
||||
files: '文件',
|
||||
moreFiles: count => `还有 ${count} 个文件`,
|
||||
delegation: index => `派发 ${index}`,
|
||||
workers: count => `${count} 个工作单元`,
|
||||
workersActive: count => `${count} 个活跃`,
|
||||
agentsCount: count => `${count} 个代理`,
|
||||
activeCount: count => `${count} 个活跃`,
|
||||
failedCount: count => `${count} 个失败`,
|
||||
toolsCount: count => `${count} 个工具`,
|
||||
filesCount: count => `${count} 个文件`,
|
||||
updatedAgo: age => `更新于 ${age}`,
|
||||
ageNow: '刚刚',
|
||||
ageSeconds: seconds => `${seconds} 秒前`,
|
||||
ageMinutes: minutes => `${minutes} 分钟前`,
|
||||
ageHours: hours => `${hours} 小时前`,
|
||||
durationSeconds: seconds => `${seconds} 秒`,
|
||||
durationMinutes: (minutes, seconds) => `${minutes} 分 ${seconds} 秒`,
|
||||
tokensK: k => `${k}k 词元`,
|
||||
tokens: value => `${value} 词元`
|
||||
},
|
||||
|
||||
commandCenter: {
|
||||
close: '关闭命令中心',
|
||||
searchPlaceholder: '搜索会话、视图与操作',
|
||||
sections: { sessions: '会话', system: '系统', usage: '用量' },
|
||||
sectionDescriptions: {
|
||||
sessions: '搜索与管理会话',
|
||||
system: '状态、日志与系统操作',
|
||||
usage: '一段时间内的词元、成本与技能活动'
|
||||
},
|
||||
nav: {
|
||||
newChat: { title: '新建会话', detail: '开始一个新会话' },
|
||||
settings: { title: '设置', detail: '配置 Hermes 桌面端' },
|
||||
skills: { title: '技能与工具', detail: '启用技能、工具集与提供方' },
|
||||
messaging: { title: '消息平台', detail: '配置 Telegram、Slack、Discord 等' },
|
||||
artifacts: { title: '产物', detail: '浏览生成的输出' }
|
||||
},
|
||||
sectionEntries: {
|
||||
sessions: { title: '会话面板', detail: '搜索、置顶与管理会话' },
|
||||
system: { title: '系统面板', detail: '网关状态、日志、重启/更新' },
|
||||
usage: { title: '用量面板', detail: '词元、成本与技能活动' }
|
||||
},
|
||||
providerNavigate: '导航',
|
||||
providerSessions: '会话',
|
||||
refresh: '刷新',
|
||||
refreshing: '刷新中…',
|
||||
noResults: '未找到匹配结果。',
|
||||
pinSession: '置顶会话',
|
||||
unpinSession: '取消置顶',
|
||||
exportSession: '导出会话',
|
||||
deleteSession: '删除会话',
|
||||
noSessions: '暂无会话。',
|
||||
gatewayRunning: '消息网关运行中',
|
||||
gatewayStopped: '消息网关已停止',
|
||||
hermesActiveSessions: (version, count) => `Hermes ${version} · 活跃会话 ${count}`,
|
||||
restartMessaging: '重启消息服务',
|
||||
updateHermes: '更新 Hermes',
|
||||
actionRunning: '运行中',
|
||||
actionDone: '完成',
|
||||
actionFailed: '失败',
|
||||
actionStartedWaiting: '操作已启动,等待状态…',
|
||||
loadingStatus: '正在加载状态…',
|
||||
recentLogs: '最近日志',
|
||||
noLogs: '尚未加载日志。',
|
||||
days: count => `${count} 天`,
|
||||
statSessions: '会话',
|
||||
statApiCalls: 'API 调用',
|
||||
statTokens: '输入/输出词元',
|
||||
statCost: '预估成本',
|
||||
actualCost: cost => `实际 ${cost}`,
|
||||
loadingUsage: '正在加载用量…',
|
||||
noUsage: period => `最近 ${period} 天暂无用量。`,
|
||||
retry: '重试',
|
||||
dailyTokens: '每日词元',
|
||||
input: '输入',
|
||||
output: '输出',
|
||||
noDailyActivity: '暂无每日活动。',
|
||||
topModels: '常用模型',
|
||||
noModelUsage: '暂无模型用量。',
|
||||
topSkills: '常用技能',
|
||||
noSkillActivity: '暂无技能活动。',
|
||||
actions: count => `${count} 次操作`
|
||||
},
|
||||
|
||||
messaging: {
|
||||
search: '搜索消息平台…',
|
||||
loading: '正在加载消息平台…',
|
||||
loadFailed: '消息平台加载失败',
|
||||
states: {
|
||||
connected: '已连接',
|
||||
connecting: '连接中',
|
||||
disabled: '已禁用',
|
||||
fatal: '错误',
|
||||
gateway_stopped: '消息网关已停止',
|
||||
not_configured: '需要设置',
|
||||
pending_restart: '需要重启',
|
||||
retrying: '重试中',
|
||||
startup_failed: '启动失败'
|
||||
},
|
||||
unknown: '未知',
|
||||
hintPendingRestart: '在状态栏重启网关以应用此更改。',
|
||||
hintGatewayStopped: '在状态栏启动网关以建立连接。',
|
||||
credentialsSet: '凭据已设置',
|
||||
needsSetup: '需要设置',
|
||||
gatewayStopped: '消息网关已停止',
|
||||
getCredentials: '获取你的凭据',
|
||||
openSetupGuide: '打开设置指南',
|
||||
required: '必填',
|
||||
recommended: '推荐',
|
||||
advanced: count => `高级 (${count})`,
|
||||
noTokenNeeded: '此平台无需在此填写令牌。请按上方设置指南操作,然后在下方启用。',
|
||||
enabled: '已启用',
|
||||
disabled: '已禁用',
|
||||
unsavedChanges: '有未保存的更改',
|
||||
saving: '保存中…',
|
||||
saveChanges: '保存更改',
|
||||
saved: '已保存',
|
||||
replaceValue: '替换当前值',
|
||||
openDocs: '打开文档',
|
||||
clearField: key => `清除 ${key}`,
|
||||
enableAria: name => `启用 ${name}`,
|
||||
disableAria: name => `禁用 ${name}`,
|
||||
platformEnabled: name => `${name} 已启用`,
|
||||
platformDisabled: name => `${name} 已禁用`,
|
||||
restartToApply: '重启网关后此更改才会生效。',
|
||||
setupSaved: name => `${name} 设置已保存`,
|
||||
restartToReconnect: '重启网关以使用新凭据重新连接。',
|
||||
keyCleared: key => `${key} 已清除`,
|
||||
setupUpdated: name => `${name} 设置已更新。`,
|
||||
failedUpdate: name => `更新 ${name} 失败`,
|
||||
failedSave: name => `保存 ${name} 失败`,
|
||||
failedClear: key => `清除 ${key} 失败`,
|
||||
fieldCopy: {
|
||||
TELEGRAM_BOT_TOKEN: { label: 'Bot 令牌', help: '用 @BotFather 创建一个机器人,然后粘贴它给你的令牌。' },
|
||||
TELEGRAM_ALLOWED_USERS: {
|
||||
label: '允许的 Telegram 用户 ID',
|
||||
help: '推荐。来自 @userinfobot 的逗号分隔数字 ID。不设置则任何人都能私信你的机器人。'
|
||||
},
|
||||
TELEGRAM_PROXY: { label: '代理 URL', help: '仅在 Telegram 被屏蔽的网络中需要。' },
|
||||
DISCORD_BOT_TOKEN: { label: 'Bot 令牌', help: '在 Discord 开发者门户创建应用,添加机器人,然后粘贴其令牌。' },
|
||||
DISCORD_ALLOWED_USERS: { label: '允许的 Discord 用户 ID', help: '推荐。逗号分隔的 Discord 用户 ID。' },
|
||||
DISCORD_REPLY_TO_MODE: { label: '回复方式', help: 'first、all 或 off。' },
|
||||
SLACK_BOT_TOKEN: { label: 'Slack bot 令牌', help: '安装 Slack 应用后,在 OAuth & Permissions 中找到 bot 令牌。' },
|
||||
SLACK_APP_TOKEN: { label: 'Slack app 令牌', help: 'Socket Mode 需要 app 级令牌。' },
|
||||
SLACK_ALLOWED_USERS: { label: '允许的 Slack 用户 ID', help: '推荐。逗号分隔的 Slack 用户 ID。' },
|
||||
MATTERMOST_URL: { label: '服务器 URL' },
|
||||
MATTERMOST_TOKEN: { label: 'Bot 令牌' },
|
||||
MATTERMOST_ALLOWED_USERS: { label: '允许的用户 ID', help: '推荐。逗号分隔的 Mattermost 用户 ID。' },
|
||||
MATRIX_HOMESERVER: { label: 'Homeserver URL' },
|
||||
MATRIX_ACCESS_TOKEN: { label: '访问令牌' },
|
||||
MATRIX_USER_ID: { label: 'Bot 用户 ID' },
|
||||
MATRIX_ALLOWED_USERS: { label: '允许的 Matrix 用户 ID', help: '推荐。@user:server 格式的逗号分隔用户 ID。' },
|
||||
SIGNAL_HTTP_URL: { label: 'Signal 桥接 URL', help: '运行中的 signal-cli REST 桥接的 URL。' },
|
||||
SIGNAL_ACCOUNT: { label: '电话号码', help: '在 signal-cli 桥接中注册的号码。' },
|
||||
SIGNAL_ALLOWED_USERS: { label: '允许的 Signal 用户', help: '推荐。逗号分隔的 Signal 标识符。' },
|
||||
WHATSAPP_ENABLED: { label: '启用 WhatsApp 桥接', help: '由下方开关自动设置。除非确知需要,否则请勿改动。' },
|
||||
WHATSAPP_MODE: { label: '桥接模式' },
|
||||
WHATSAPP_ALLOWED_USERS: { label: '允许的 WhatsApp 用户', help: '推荐。逗号分隔的电话号码或 WhatsApp ID。' }
|
||||
},
|
||||
platformIntro: {
|
||||
telegram:
|
||||
'在 Telegram 中,与 @BotFather 对话,运行 /newbot,复制它给你的令牌。然后从 @userinfobot 获取你的数字用户 ID。',
|
||||
discord:
|
||||
'打开 Discord 开发者门户,创建应用,添加 Bot,然后复制其令牌。用正确的权限范围把机器人邀请到你的服务器。',
|
||||
slack: '创建 Slack 应用,启用 Socket Mode,安装到你的工作区,然后复制 bot 令牌和 app 级令牌。',
|
||||
mattermost: '在你的 Mattermost 服务器上,创建机器人账户或个人访问令牌,然后在此粘贴服务器 URL 和令牌。',
|
||||
matrix: '用机器人账户登录你的 homeserver,然后复制访问令牌、用户 ID 和 homeserver URL。',
|
||||
signal: '在可访问的位置运行 signal-cli REST 桥接,然后把 Hermes 指向该 URL 和已注册的电话号码。',
|
||||
whatsapp: '启动 Hermes 自带的 WhatsApp 桥接,首次运行时扫描二维码,然后启用该平台。',
|
||||
bluebubbles: '在装有 iMessage 的 Mac 上运行 BlueBubbles Server,暴露其 API,然后用服务器密码把 Hermes 指向该 URL。',
|
||||
homeassistant: '在 Home Assistant 中打开你的个人资料并创建长期访问令牌。把它连同你的 HA URL 一起粘贴到这里。',
|
||||
email: '使用专用邮箱。对于 Gmail/Workspace,创建应用专用密码并使用 imap.gmail.com / smtp.gmail.com。',
|
||||
sms: '从 Twilio 控制台获取你的 Account SID 和 Auth Token,以及一个可发送短信的电话号码。',
|
||||
dingtalk: '在开发者控制台创建钉钉应用,然后在此复制 Client ID(App key)和 Client Secret。',
|
||||
feishu: '创建飞书 / Lark 应用,配置机器人能力,复制 App ID、App secret 和事件加密密钥。',
|
||||
wecom: '在企业微信中添加群机器人,复制其 webhook key 作为 WECOM_BOT_ID。仅可发送——双向请用企业微信(应用)选项。',
|
||||
wecom_callback: '设置一个企业微信自建应用,暴露其回调 URL,并提供 corp ID、secret、agent ID 和 AES key。',
|
||||
weixin: '登录微信公众平台,复制 AppID 和 Token,并把消息回调 URL 指向 Hermes。',
|
||||
qqbot: '在 QQ 开放平台(q.qq.com)注册一个应用,复制 App ID 和 Client Secret。',
|
||||
api_server: '把 Hermes 暴露为兼容 OpenAI 的 API。设置一个鉴权密钥,然后把 Open WebUI / LobeChat 等指向 host:port。',
|
||||
webhook: '运行一个 HTTP 服务器,供其他工具(GitHub、GitLab、自定义应用)POST。用 secret 验证签名。'
|
||||
}
|
||||
},
|
||||
|
||||
profiles: {
|
||||
close: '关闭配置档案',
|
||||
nameHint: '小写字母、数字、连字符和下划线。必须以字母或数字开头。',
|
||||
title: '配置档案',
|
||||
count: count => `${count} 个配置档案`,
|
||||
loading: '正在加载配置档案…',
|
||||
newProfile: '新建配置档案',
|
||||
noProfiles: '暂无配置档案。',
|
||||
selectPrompt: '选择一个配置档案以查看其详情。',
|
||||
refresh: '刷新配置档案',
|
||||
refreshing: '正在刷新配置档案',
|
||||
default: '默认',
|
||||
skills: count => `${count} 个技能`,
|
||||
env: 'env',
|
||||
defaultBadge: '默认',
|
||||
rename: '重命名',
|
||||
copySetup: '复制安装命令',
|
||||
copying: '复制中…',
|
||||
modelLabel: '模型',
|
||||
skillsLabel: '技能',
|
||||
notSet: '未设置',
|
||||
soulDesc: '内置于此配置档案的系统提示词与人格指令。',
|
||||
unsavedChanges: '有未保存的更改',
|
||||
loadingSoul: '正在加载 SOUL.md…',
|
||||
emptySoul: '空的 SOUL.md —— 开始撰写人格设定…',
|
||||
saving: '保存中…',
|
||||
saveSoul: '保存 SOUL.md',
|
||||
deleteTitle: '删除配置档案?',
|
||||
deleteDescPrefix: '这将删除 ',
|
||||
deleteDescMid: ' 并移除其 ',
|
||||
deleteDescSuffix: ' 目录。此操作无法撤销。',
|
||||
deleting: '删除中…',
|
||||
createDesc: '配置档案是相互独立的 Hermes 环境:各自拥有独立的配置、技能和 SOUL.md。',
|
||||
nameLabel: '名称',
|
||||
cloneFromDefault: '从默认档案克隆',
|
||||
cloneFromDefaultDesc: '从你的默认配置档案复制配置、技能和 SOUL.md。',
|
||||
invalidName: hint => `名称无效。${hint}`,
|
||||
nameRequired: '名称为必填项。',
|
||||
creating: '创建中…',
|
||||
createAction: '创建配置档案',
|
||||
renameTitle: '重命名配置档案',
|
||||
renameDescPrefix: '重命名会更新配置档案目录以及 ',
|
||||
renameDescSuffix: ' 中的所有包装脚本。',
|
||||
newNameLabel: '新名称',
|
||||
renaming: '重命名中…',
|
||||
created: '配置档案已创建',
|
||||
renamed: '配置档案已重命名',
|
||||
deleted: '配置档案已删除',
|
||||
setupCopied: '安装命令已复制',
|
||||
soulSaved: 'SOUL.md 已保存',
|
||||
failedLoad: '加载配置档案失败',
|
||||
failedDelete: '删除配置档案失败',
|
||||
failedCopy: '复制安装命令失败',
|
||||
failedLoadSoul: '加载 SOUL.md 失败',
|
||||
failedSaveSoul: '保存 SOUL.md 失败',
|
||||
failedCreate: '创建配置档案失败',
|
||||
failedRename: '重命名配置档案失败'
|
||||
},
|
||||
|
||||
cron: {
|
||||
close: '关闭定时任务',
|
||||
search: '搜索定时任务…',
|
||||
refresh: '刷新定时任务',
|
||||
refreshing: '正在刷新定时任务',
|
||||
loading: '正在加载定时任务…',
|
||||
states: {
|
||||
enabled: '已启用',
|
||||
scheduled: '已排程',
|
||||
running: '运行中',
|
||||
paused: '已暂停',
|
||||
disabled: '已禁用',
|
||||
error: '错误',
|
||||
completed: '已完成'
|
||||
},
|
||||
deliveryLabels: {
|
||||
local: '此桌面',
|
||||
telegram: 'Telegram',
|
||||
discord: 'Discord',
|
||||
slack: 'Slack',
|
||||
email: '电子邮件'
|
||||
},
|
||||
scheduleLabels: {
|
||||
daily: '每天',
|
||||
weekdays: '工作日',
|
||||
weekly: '每周',
|
||||
monthly: '每月',
|
||||
hourly: '每小时',
|
||||
'every-15-minutes': '每 15 分钟',
|
||||
custom: '自定义'
|
||||
},
|
||||
scheduleHints: {
|
||||
daily: '每天上午 9:00',
|
||||
weekdays: '周一至周五上午 9:00',
|
||||
weekly: '每周一上午 9:00',
|
||||
monthly: '每月第一天上午 9:00',
|
||||
hourly: '每个整点',
|
||||
'every-15-minutes': '每 15 分钟',
|
||||
custom: 'Cron 语法或自然语言'
|
||||
},
|
||||
days: {
|
||||
'0': '周日',
|
||||
'1': '周一',
|
||||
'2': '周二',
|
||||
'3': '周三',
|
||||
'4': '周四',
|
||||
'5': '周五',
|
||||
'6': '周六',
|
||||
'7': '周日'
|
||||
},
|
||||
dayFallback: value => `第 ${value} 天`,
|
||||
everyDayAt: time => `每天 ${time}`,
|
||||
weekdaysAt: time => `工作日 ${time}`,
|
||||
everyDayOfWeekAt: (day, time) => `每${day} ${time}`,
|
||||
monthlyOnDayAt: (dayOfMonth, time) => `每月 ${dayOfMonth} 日 ${time}`,
|
||||
topOfHour: '每个整点',
|
||||
everyHourAt: minute => `每小时的 :${minute}`,
|
||||
active: (enabled, total) => `${enabled}/${total} 个启用`,
|
||||
newCron: '新建定时任务',
|
||||
createFirst: '创建第一个定时任务',
|
||||
emptyDescNew: '按 cron 表达式排程一个提示词。Hermes 会运行它,并把结果发送到你选择的目的地。',
|
||||
emptyDescSearch: '尝试更宽泛的搜索词。',
|
||||
emptyTitleNew: '暂无排程任务',
|
||||
emptyTitleSearch: '无匹配项',
|
||||
last: '上次:',
|
||||
next: '下次:',
|
||||
actionsFor: title => `${title} 的操作`,
|
||||
actionsTitle: '定时任务操作',
|
||||
resume: '恢复定时任务',
|
||||
pause: '暂停定时任务',
|
||||
resumeTitle: '恢复',
|
||||
pauseTitle: '暂停',
|
||||
triggerNow: '立即触发',
|
||||
edit: '编辑定时任务',
|
||||
deleteTitle: '删除定时任务?',
|
||||
deleteDescPrefix: '这将永久移除 ',
|
||||
deleteDescSuffix: '。它会立即停止触发。',
|
||||
deleting: '删除中…',
|
||||
resumed: '定时任务已恢复',
|
||||
paused: '定时任务已暂停',
|
||||
triggered: '定时任务已触发',
|
||||
deleted: '定时任务已删除',
|
||||
created: '定时任务已创建',
|
||||
updated: '定时任务已更新',
|
||||
failedLoad: '加载定时任务失败',
|
||||
failedUpdate: '更新定时任务失败',
|
||||
failedTrigger: '触发定时任务失败',
|
||||
failedDelete: '删除定时任务失败',
|
||||
failedSave: '保存定时任务失败',
|
||||
editTitle: '编辑定时任务',
|
||||
createTitle: '新建定时任务',
|
||||
editDesc: '更新排程、提示词或投递目标。更改将在下次运行时生效。',
|
||||
createDesc: '排程一个提示词以自动运行。使用 cron 语法或类似"每 15 分钟"的自然语言。',
|
||||
nameLabel: '名称',
|
||||
namePlaceholder: '晨间简报',
|
||||
promptLabel: '提示词',
|
||||
promptPlaceholder: '总结我未读的 Slack 话题,并把前 5 条邮件发给我…',
|
||||
frequencyLabel: '频率',
|
||||
deliverLabel: '投递至',
|
||||
customScheduleLabel: '自定义排程',
|
||||
customPlaceholder: '0 9 * * * 或 weekdays at 9am',
|
||||
customHint: 'Cron 表达式,或类似"每小时""工作日上午 9 点"的短语。',
|
||||
optional: '可选',
|
||||
promptScheduleRequired: '提示词和排程为必填项。',
|
||||
saveChanges: '保存更改',
|
||||
createAction: '创建定时任务'
|
||||
},
|
||||
|
||||
artifacts: {
|
||||
search: '搜索产物…',
|
||||
refresh: '刷新产物',
|
||||
refreshing: '正在刷新产物',
|
||||
indexing: '正在索引最近会话的产物',
|
||||
tabAll: '全部',
|
||||
tabImages: '图片',
|
||||
tabFiles: '文件',
|
||||
tabLinks: '链接',
|
||||
noArtifactsTitle: '未找到产物',
|
||||
noArtifactsDesc: '当会话生成图片和文件输出时,它们会显示在这里。',
|
||||
failedLoad: '产物加载失败',
|
||||
openFailed: '打开失败',
|
||||
itemsImage: '张图片',
|
||||
itemsLink: '个链接',
|
||||
itemsFile: '个文件',
|
||||
itemsGeneric: '项',
|
||||
zero: '0',
|
||||
rangeOf: (start, end, total) => `${start}-${end},共 ${total}`,
|
||||
goToPage: (itemLabel, page) => `前往${itemLabel}第 ${page} 页`,
|
||||
colTitleLink: '链接标题',
|
||||
colTitleFile: '名称',
|
||||
colTitleDefault: '标题 / 名称',
|
||||
colLocationLink: 'URL',
|
||||
colLocationFile: '路径',
|
||||
colLocationDefault: '位置',
|
||||
colSession: '会话',
|
||||
kindImage: '图片',
|
||||
kindFile: '文件',
|
||||
kindLink: '链接',
|
||||
chat: '对话',
|
||||
copyUrl: '复制 URL',
|
||||
copyPath: '复制路径'
|
||||
},
|
||||
|
||||
sidebar: {
|
||||
nav: {
|
||||
'new-session': '新建会话',
|
||||
skills: '技能与工具',
|
||||
messaging: '消息平台',
|
||||
artifacts: '产物'
|
||||
},
|
||||
searchAria: '搜索会话',
|
||||
searchPlaceholder: '搜索会话…',
|
||||
clearSearch: '清除搜索',
|
||||
noMatch: query => `没有会话匹配"${query}"。`,
|
||||
results: '结果',
|
||||
pinned: '已置顶',
|
||||
sessions: '会话',
|
||||
groupAriaGrouped: '以单一列表显示会话',
|
||||
groupAriaUngrouped: '按工作区分组会话',
|
||||
groupTitleGrouped: '取消分组',
|
||||
groupTitleUngrouped: '按工作区分组',
|
||||
allPinned: '这里的全部已置顶。取消置顶某个对话即可在最近中显示。',
|
||||
shiftClickHint: 'Shift+单击对话以置顶 · 拖动以重新排序',
|
||||
noWorkspace: '无工作区',
|
||||
newSessionIn: label => `在 ${label} 中新建会话`,
|
||||
reorderWorkspace: label => `重新排序工作区 ${label}`,
|
||||
showMoreIn: (count, label) => `在 ${label} 中再显示 ${count} 个`,
|
||||
loading: '加载中…',
|
||||
loadMore: '加载更多',
|
||||
loadCount: step => `再加载 ${step} 个`,
|
||||
row: {
|
||||
pin: '置顶',
|
||||
unpin: '取消置顶',
|
||||
copyId: '复制 ID',
|
||||
export: '导出',
|
||||
rename: '重命名',
|
||||
archive: '归档',
|
||||
copyIdFailed: '无法复制会话 ID',
|
||||
actionsFor: title => `${title} 的操作`,
|
||||
sessionActions: '会话操作',
|
||||
sessionRunning: '会话运行中',
|
||||
needsInput: '需要你输入',
|
||||
waitingForAnswer: '正在等待你的回答',
|
||||
renamed: '已重命名',
|
||||
renameFailed: '重命名失败',
|
||||
renameTitle: '重命名会话',
|
||||
renameDesc: '给这个对话起一个好记的标题。留空则清除。',
|
||||
untitledPlaceholder: '无标题会话',
|
||||
ageNow: '刚刚',
|
||||
ageDay: '天',
|
||||
ageHour: '时',
|
||||
ageMin: '分'
|
||||
}
|
||||
},
|
||||
|
||||
composer: {
|
||||
message: '消息',
|
||||
placeholderStarting: '正在启动 Hermes…',
|
||||
placeholderReconnecting: '正在重新连接 Hermes…',
|
||||
placeholderFollowUp: '发送后续消息',
|
||||
newSessionPlaceholders: [
|
||||
'我们要构建什么?',
|
||||
'给 Hermes 一个任务',
|
||||
'你在想什么?',
|
||||
'描述你需要什么',
|
||||
'我们该处理什么?',
|
||||
'随便问点什么',
|
||||
'从一个目标开始'
|
||||
],
|
||||
followUpPlaceholders: [
|
||||
'发送后续消息',
|
||||
'补充更多上下文',
|
||||
'细化这个请求',
|
||||
'下一步是什么?',
|
||||
'继续推进',
|
||||
'再深入一点',
|
||||
'调整或继续'
|
||||
],
|
||||
startVoice: '开始语音对话',
|
||||
queueMessage: '排队消息',
|
||||
stop: '停止',
|
||||
send: '发送',
|
||||
speaking: '讲话中',
|
||||
transcribing: '转写中',
|
||||
thinking: '思考中',
|
||||
muted: '已静音',
|
||||
listening: '聆听中',
|
||||
muteMic: '麦克风静音',
|
||||
unmuteMic: '取消麦克风静音',
|
||||
stopListening: '停止聆听并发送',
|
||||
stopShort: '停止',
|
||||
endConversation: '结束语音对话',
|
||||
endShort: '结束',
|
||||
stopDictation: '停止听写',
|
||||
transcribingDictation: '正在转写听写',
|
||||
voiceDictation: '语音听写',
|
||||
commonCommands: '常用命令',
|
||||
hotkeys: '快捷键',
|
||||
helpFooter: '打开完整面板 · 退格键关闭',
|
||||
commandDescs: {
|
||||
'/help': '命令与快捷键的完整列表',
|
||||
'/clear': '开始新会话',
|
||||
'/resume': '恢复之前的会话',
|
||||
'/details': '控制对话记录的详细程度',
|
||||
'/copy': '复制所选内容或最后一条助手消息',
|
||||
'/quit': '退出 hermes'
|
||||
},
|
||||
hotkeyDescs: {
|
||||
'@': '引用文件、文件夹、URL、git',
|
||||
'/': '斜杠命令面板',
|
||||
'?': '此快速帮助(删除以关闭)',
|
||||
Enter: '发送 · Shift+Enter 换行',
|
||||
'Cmd/Ctrl+K': '发送下一条排队的回合',
|
||||
'Cmd/Ctrl+L': '重绘',
|
||||
Esc: '关闭弹窗 · 取消运行',
|
||||
'↑ / ↓': '循环弹窗 / 历史'
|
||||
},
|
||||
attachUrlTitle: '附加 URL',
|
||||
attachUrlDesc: 'Hermes 将抓取该页面并作为本回合的上下文。',
|
||||
urlPlaceholder: 'https://example.com/post',
|
||||
urlHintPre: '请包含完整 URL,例如 ',
|
||||
attach: '附加',
|
||||
queued: count => `${count} 条排队`,
|
||||
attachmentOnly: '仅附件回合',
|
||||
emptyTurn: '空回合',
|
||||
attachments: count => `${count} 个附件`,
|
||||
editingInComposer: '正在输入框中编辑',
|
||||
editQueued: '编辑排队回合',
|
||||
sendQueuedNow: '立即发送排队回合',
|
||||
deleteQueued: '删除排队回合',
|
||||
previewUnavailable: '预览不可用',
|
||||
previewLabel: label => `预览 ${label}`,
|
||||
couldNotPreview: label => `无法预览 ${label}`,
|
||||
removeAttachment: label => `移除 ${label}`,
|
||||
dictating: '听写中',
|
||||
preparingAudio: '正在准备音频',
|
||||
speakingResponse: '正在朗读回复',
|
||||
readingAloud: '朗读中',
|
||||
themeSuggestions: '桌面主题建议',
|
||||
noMatchingThemes: '没有匹配的主题。',
|
||||
themeTryPre: '试试 ',
|
||||
themeTryPost: '。',
|
||||
attachLabel: '附加',
|
||||
files: '文件…',
|
||||
folder: '文件夹…',
|
||||
images: '图片…',
|
||||
pasteImage: '粘贴图片',
|
||||
url: 'URL…',
|
||||
promptSnippets: '提示词片段…',
|
||||
tipPre: '提示:输入 ',
|
||||
tipPost: ' 以内联引用文件。',
|
||||
snippetsTitle: '提示词片段',
|
||||
snippetsDesc: '选择一个起始提示词放入输入框。',
|
||||
snippets: {
|
||||
codeReview: {
|
||||
label: '代码审查',
|
||||
description: '审查当前更改是否存在回归、遗漏的边界情况和缺失的测试。',
|
||||
text: '请审查这部分是否存在缺陷、回归和缺失的测试。'
|
||||
},
|
||||
implementationPlan: {
|
||||
label: '实现计划',
|
||||
description: '在动代码之前先勾勒方案,让 diff 保持聚焦。',
|
||||
text: '请在修改代码前制定一个简洁的实现计划。'
|
||||
},
|
||||
explainThis: {
|
||||
label: '解释这段',
|
||||
description: '讲解所选代码的工作方式,并链接到关键文件。',
|
||||
text: '请解释这是如何工作的,并指给我关键文件。'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,6 @@ describe('desktop slash command curation', () => {
|
||||
expect(isDesktopSlashSuggestion('/branch')).toBe(true)
|
||||
expect(isDesktopSlashSuggestion('/skin')).toBe(true)
|
||||
expect(isDesktopSlashSuggestion('/usage')).toBe(true)
|
||||
expect(isDesktopSlashSuggestion('/version')).toBe(true)
|
||||
expect(isDesktopSlashSuggestion('/yolo')).toBe(true)
|
||||
expect(isDesktopSlashCommand('/yolo')).toBe(true)
|
||||
})
|
||||
|
||||
@@ -43,7 +43,6 @@ const DESKTOP_COMMAND_META = [
|
||||
['/title', 'Rename the current session'],
|
||||
['/undo', 'Remove the last user/assistant exchange'],
|
||||
['/usage', 'Show token usage for this session'],
|
||||
['/version', 'Show Hermes Agent version'],
|
||||
['/yolo', 'Toggle YOLO — auto-approve dangerous commands']
|
||||
] as const
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import { HashRouter } from 'react-router-dom'
|
||||
import App from './app'
|
||||
import { ErrorBoundary } from './components/error-boundary'
|
||||
import { HapticsProvider } from './components/haptics-provider'
|
||||
import { I18nProvider } from './i18n'
|
||||
import { installClipboardShim } from './lib/clipboard'
|
||||
import { queryClient } from './lib/query-client'
|
||||
import { ThemeProvider } from './themes/context'
|
||||
@@ -28,15 +27,13 @@ createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ErrorBoundary label="root">
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider>
|
||||
<ThemeProvider>
|
||||
<HapticsProvider>
|
||||
<HashRouter>
|
||||
<App />
|
||||
</HashRouter>
|
||||
</HapticsProvider>
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
<ThemeProvider>
|
||||
<HapticsProvider>
|
||||
<HashRouter>
|
||||
<App />
|
||||
</HashRouter>
|
||||
</HapticsProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</ErrorBoundary>
|
||||
</StrictMode>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
import type { DesktopBootProgress } from '@/global'
|
||||
import { translateNow } from '@/i18n'
|
||||
|
||||
export interface DesktopBootState extends DesktopBootProgress {
|
||||
visible: boolean
|
||||
@@ -10,7 +9,7 @@ export interface DesktopBootState extends DesktopBootProgress {
|
||||
const INITIAL_BOOT_STATE: DesktopBootState = {
|
||||
error: null,
|
||||
fakeMode: false,
|
||||
message: translateNow('boot.steps.startingHermesDesktop'),
|
||||
message: 'Starting Hermes Desktop…',
|
||||
phase: 'renderer.init',
|
||||
progress: 2,
|
||||
running: true,
|
||||
@@ -62,7 +61,7 @@ export function setDesktopBootStep(step: {
|
||||
})
|
||||
}
|
||||
|
||||
export function completeDesktopBoot(message = translateNow('boot.ready')) {
|
||||
export function completeDesktopBoot(message = 'Hermes Desktop is ready') {
|
||||
const current = $desktopBoot.get()
|
||||
$desktopBoot.set({
|
||||
...current,
|
||||
@@ -81,7 +80,7 @@ export function failDesktopBoot(message: string) {
|
||||
$desktopBoot.set({
|
||||
...current,
|
||||
error: message,
|
||||
message: translateNow('boot.desktopBootFailedWithMessage', message),
|
||||
message: `Desktop boot failed: ${message}`,
|
||||
phase: 'renderer.error',
|
||||
progress: clampProgress(current.progress),
|
||||
running: false,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
import { translateNow } from '@/i18n'
|
||||
|
||||
export type NotificationKind = 'error' | 'warning' | 'info' | 'success'
|
||||
|
||||
export interface NotificationAction {
|
||||
@@ -54,29 +52,28 @@ const ERROR_SUMMARIES: { test: (msg: string) => boolean; summarize: (msg: string
|
||||
summarize: msg => {
|
||||
const status = msg.match(/(?:error code|status(?:Code)?)[^\d]*(\d{3})/i)?.[1]
|
||||
|
||||
return status
|
||||
? translateNow('notifications.errors.openaiRejectedApiKeyWithStatus', status)
|
||||
: translateNow('notifications.errors.openaiRejectedApiKey')
|
||||
return `OpenAI rejected the API key${status ? ` (${status} invalid_api_key)` : ''}.`
|
||||
}
|
||||
},
|
||||
{
|
||||
test: msg => /neither voice_tools_openai_key nor openai_api_key is set/i.test(msg),
|
||||
summarize: () => translateNow('notifications.errors.openaiTtsNeedsKey')
|
||||
summarize: () => 'OpenAI TTS needs VOICE_TOOLS_OPENAI_KEY or OPENAI_API_KEY.'
|
||||
},
|
||||
{
|
||||
test: msg => /ELEVENLABS_API_KEY not set/i.test(msg) || /ElevenLabs STT API error \(HTTP 401\)/i.test(msg),
|
||||
summarize: msg =>
|
||||
/ELEVENLABS_API_KEY not set/i.test(msg)
|
||||
? translateNow('notifications.errors.elevenLabsNeedsKey')
|
||||
: translateNow('notifications.errors.elevenLabsRejectedKey')
|
||||
? 'ElevenLabs STT needs ELEVENLABS_API_KEY.'
|
||||
: 'ElevenLabs rejected the API key (401).'
|
||||
},
|
||||
{
|
||||
test: msg => /method not allowed/i.test(msg),
|
||||
summarize: () => translateNow('notifications.errors.methodNotAllowed')
|
||||
summarize: () =>
|
||||
'The desktop backend rejected that request (405 Method Not Allowed). Try restarting Hermes Desktop.'
|
||||
},
|
||||
{
|
||||
test: msg => /microphone permission/i.test(msg),
|
||||
summarize: () => translateNow('notifications.errors.microphonePermission')
|
||||
summarize: () => 'Microphone permission was denied.'
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import type {
|
||||
DesktopUpdateStatus,
|
||||
DesktopVersionInfo
|
||||
} from '@/global'
|
||||
import { translateNow } from '@/i18n'
|
||||
import { persistString, storedString } from '@/lib/storage'
|
||||
import { dismissNotification, notify } from '@/store/notifications'
|
||||
|
||||
@@ -86,12 +85,12 @@ export function reportBackendContract(contract: number | undefined): void {
|
||||
}
|
||||
|
||||
notify({
|
||||
action: { label: translateNow('notifications.updateHermes'), onClick: () => void applyUpdates() },
|
||||
action: { label: 'Update Hermes', onClick: () => void applyUpdates() },
|
||||
durationMs: 0,
|
||||
id: SKEW_TOAST_ID,
|
||||
kind: 'warning',
|
||||
message: translateNow('notifications.backendOutOfDateMessage'),
|
||||
title: translateNow('notifications.backendOutOfDateTitle')
|
||||
message: 'Your Hermes backend is older than this desktop build and may not work correctly. Update to align them.',
|
||||
title: 'Backend out of date'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -122,7 +121,7 @@ export function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
|
||||
|
||||
notify({
|
||||
action: {
|
||||
label: translateNow('notifications.seeWhatsNew'),
|
||||
label: "See what's new",
|
||||
onClick: () => {
|
||||
snoozeUpdateToast()
|
||||
openUpdatesWindow()
|
||||
@@ -131,9 +130,9 @@ export function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
|
||||
durationMs: 0,
|
||||
id: UPDATE_TOAST_ID,
|
||||
kind: 'info',
|
||||
message: translateNow('notifications.updateReadyMessage', behind),
|
||||
message: `${behind} new change${behind === 1 ? '' : 's'} available.`,
|
||||
onDismiss: () => snoozeUpdateToast(),
|
||||
title: translateNow('notifications.updateReadyTitle')
|
||||
title: 'Update ready'
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -466,10 +466,6 @@ export interface ProfileInfo {
|
||||
skill_count: number
|
||||
}
|
||||
|
||||
export interface ProfileSetupCommand {
|
||||
command: string
|
||||
}
|
||||
|
||||
export interface ProfileSoul {
|
||||
content: string
|
||||
exists: boolean
|
||||
|
||||
@@ -871,7 +871,7 @@ delegation:
|
||||
max_iterations: 50 # Max tool-calling turns per child (default: 50)
|
||||
# max_concurrent_children: 3 # Max parallel child agents per batch (default: 3, floor: 1, no ceiling).
|
||||
# WARNING: values above 10 multiply API cost linearly.
|
||||
# max_spawn_depth: 1 # Delegation tree depth (floor 1, no ceiling; default: 1 = flat).
|
||||
# max_spawn_depth: 1 # Delegation tree depth cap (range: 1-3, default: 1 = flat).
|
||||
# Raise to 2 to allow workers to spawn their own subagents.
|
||||
# Requires role="orchestrator" on intermediate agents.
|
||||
# orchestrator_enabled: true # Kill switch for role="orchestrator" children (default: true).
|
||||
|
||||
@@ -3194,18 +3194,6 @@ class HermesCLI:
|
||||
_config_model = (_model_config.get("default") or _model_config.get("model") or "") if isinstance(_model_config, dict) else (_model_config or "")
|
||||
_DEFAULT_CONFIG_MODEL = ""
|
||||
self.model = model or _config_model or _DEFAULT_CONFIG_MODEL
|
||||
# Read max_tokens from config (env var override: HERMES_MAX_TOKENS)
|
||||
_env_mt = os.environ.get("HERMES_MAX_TOKENS")
|
||||
if _env_mt:
|
||||
try:
|
||||
self.max_tokens = int(_env_mt)
|
||||
except (ValueError, TypeError):
|
||||
self.max_tokens = None
|
||||
elif isinstance(_model_config, dict):
|
||||
_mt = _model_config.get("max_tokens")
|
||||
self.max_tokens = _mt if isinstance(_mt, int) else None
|
||||
else:
|
||||
self.max_tokens = None
|
||||
# Auto-detect model from local server if still on default
|
||||
if self.model == _DEFAULT_CONFIG_MODEL:
|
||||
_base_url = (_model_config.get("base_url") or "") if isinstance(_model_config, dict) else ""
|
||||
@@ -5109,9 +5097,9 @@ class HermesCLI:
|
||||
resolved_id = self.session_id
|
||||
if resolved_id and resolved_id != self.session_id:
|
||||
ChatConsole().print(
|
||||
f"[dim]Session {_escape(self.session_id)} was compressed into "
|
||||
f"[{_DIM}]Session {_escape(self.session_id)} was compressed into "
|
||||
f"{_escape(resolved_id)}; resuming the descendant with your "
|
||||
f"transcript.[/dim]"
|
||||
f"transcript.[/]"
|
||||
)
|
||||
self.session_id = resolved_id
|
||||
resolved_meta = self._session_db.get_session(self.session_id)
|
||||
@@ -5180,7 +5168,6 @@ class HermesCLI:
|
||||
acp_command=runtime.get("command"),
|
||||
acp_args=runtime.get("args"),
|
||||
credential_pool=runtime.get("credential_pool"),
|
||||
max_tokens=self.max_tokens,
|
||||
max_iterations=self.max_turns,
|
||||
enabled_toolsets=self.enabled_toolsets,
|
||||
disabled_toolsets=self.disabled_toolsets,
|
||||
@@ -5391,7 +5378,7 @@ class HermesCLI:
|
||||
if quiet:
|
||||
print(msg, file=sys.stderr)
|
||||
else:
|
||||
self._console_print(f"[dim]{_escape(msg)}[/dim]")
|
||||
self._console_print(f"[{_DIM}]{_escape(msg)}[/]")
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -5401,7 +5388,7 @@ class HermesCLI:
|
||||
if quiet:
|
||||
print(msg, file=sys.stderr)
|
||||
else:
|
||||
self._console_print(f"[dim]{_escape(msg)}[/dim]")
|
||||
self._console_print(f"[{_DIM}]{_escape(msg)}[/]")
|
||||
return
|
||||
|
||||
# Retarget the terminal/code-exec tools to match the process cwd.
|
||||
@@ -5411,7 +5398,7 @@ class HermesCLI:
|
||||
if quiet:
|
||||
print(msg, file=sys.stderr)
|
||||
else:
|
||||
self._console_print(f"[dim]{_escape(msg)}[/dim]")
|
||||
self._console_print(f"[{_DIM}]{_escape(msg)}[/]")
|
||||
|
||||
def _preload_resumed_session(self) -> bool:
|
||||
"""Load a resumed session's history from the DB early (before first chat).
|
||||
@@ -9015,10 +9002,6 @@ class HermesCLI:
|
||||
elif canonical == "update":
|
||||
if self._handle_update_command():
|
||||
return False
|
||||
elif canonical == "version":
|
||||
from hermes_cli.main import _print_version_info
|
||||
|
||||
_print_version_info(check_updates=True)
|
||||
elif canonical == "paste":
|
||||
self._handle_paste_command()
|
||||
elif canonical == "image":
|
||||
@@ -9301,7 +9284,6 @@ class HermesCLI:
|
||||
api_mode=turn_route["runtime"].get("api_mode"),
|
||||
acp_command=turn_route["runtime"].get("command"),
|
||||
acp_args=turn_route["runtime"].get("args"),
|
||||
max_tokens=turn_route["runtime"].get("max_tokens"),
|
||||
max_iterations=self.max_turns,
|
||||
enabled_toolsets=self.enabled_toolsets,
|
||||
quiet_mode=True,
|
||||
@@ -13112,16 +13094,6 @@ class HermesCLI:
|
||||
_welcome_color = "#FFF8DC"
|
||||
self._console_print(f"[{_welcome_color}]{_welcome_text}[/]")
|
||||
|
||||
# Warm the /model picker's provider-models cache off-thread during this
|
||||
# idle window (banner shown, user about to type). The no-args picker
|
||||
# otherwise blocks ~1-2s on serial /v1/models fetches the first time
|
||||
# it's opened in a session. Fire-and-forget, guarded once-per-process.
|
||||
try:
|
||||
from hermes_cli.model_switch import prewarm_picker_cache_async
|
||||
prewarm_picker_cache_async()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Redaction opt-out warning (#17691): ON by default, loud when off.
|
||||
# The redactor snapshots its state at import time so any toggle now
|
||||
# won't affect the running process — we just want the operator to
|
||||
|
||||
@@ -3016,17 +3016,6 @@ class BasePlatformAdapter(ABC):
|
||||
expanded = os.path.expanduser(raw)
|
||||
if os.path.isfile(expanded):
|
||||
found.append((raw, expanded))
|
||||
else:
|
||||
# The reply mentions a deliverable-looking path that does not
|
||||
# exist on disk, so it is silently dropped from native delivery.
|
||||
# This is the most common reason a promised file never arrives
|
||||
# (the model said "here's your file" but never wrote it, or
|
||||
# referenced the wrong path). Log it so the gap is visible in
|
||||
# gateway.log rather than vanishing without a trace.
|
||||
logger.info(
|
||||
"Skipping bare file path in reply (no file on disk): %s",
|
||||
_log_safe_path(raw),
|
||||
)
|
||||
|
||||
# Deduplicate by expanded path, preserving discovery order
|
||||
seen: set = set()
|
||||
|
||||
@@ -4073,7 +4073,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
)
|
||||
return SendResult(success=True, message_id=str(msg.message_id))
|
||||
except Exception as e:
|
||||
logger.warning("[%s] Failed to send document: %s", self.name, e, exc_info=True)
|
||||
print(f"[{self.name}] Failed to send document: {e}")
|
||||
return await super().send_document(chat_id, file_path, caption, file_name, reply_to, metadata=metadata)
|
||||
|
||||
async def send_video(
|
||||
@@ -4120,7 +4120,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
)
|
||||
return SendResult(success=True, message_id=str(msg.message_id))
|
||||
except Exception as e:
|
||||
logger.warning("[%s] Failed to send video: %s", self.name, e, exc_info=True)
|
||||
print(f"[{self.name}] Failed to send video: {e}")
|
||||
return await super().send_video(chat_id, video_path, caption, reply_to, metadata=metadata)
|
||||
|
||||
async def send_image(
|
||||
|
||||
+2
-94
@@ -1179,7 +1179,6 @@ def _resolve_runtime_agent_kwargs() -> dict:
|
||||
from hermes_cli.runtime_provider import (
|
||||
resolve_runtime_provider,
|
||||
format_runtime_provider_error,
|
||||
_get_model_config,
|
||||
)
|
||||
from hermes_cli.auth import AuthError, is_rate_limited_auth_error
|
||||
|
||||
@@ -1201,26 +1200,6 @@ def _resolve_runtime_agent_kwargs() -> dict:
|
||||
except Exception as exc:
|
||||
raise RuntimeError(format_runtime_provider_error(exc)) from exc
|
||||
|
||||
model_cfg = _get_model_config()
|
||||
max_tokens = None
|
||||
_env_mt = os.environ.get("HERMES_MAX_TOKENS")
|
||||
if _env_mt:
|
||||
try:
|
||||
max_tokens = int(_env_mt)
|
||||
except (ValueError, TypeError):
|
||||
max_tokens = None
|
||||
elif isinstance(model_cfg, dict):
|
||||
mt = model_cfg.get("max_tokens")
|
||||
if isinstance(mt, int):
|
||||
max_tokens = mt
|
||||
# Fall back to a per-provider output cap (custom_providers max_output_tokens)
|
||||
# only when the documented global model.max_tokens isn't set, so the global
|
||||
# key always wins.
|
||||
if max_tokens is None:
|
||||
_runtime_mot = runtime.get("max_output_tokens")
|
||||
if isinstance(_runtime_mot, int) and _runtime_mot > 0:
|
||||
max_tokens = _runtime_mot
|
||||
|
||||
return {
|
||||
"api_key": runtime.get("api_key"),
|
||||
"base_url": runtime.get("base_url"),
|
||||
@@ -1229,7 +1208,6 @@ def _resolve_runtime_agent_kwargs() -> dict:
|
||||
"command": runtime.get("command"),
|
||||
"args": list(runtime.get("args") or []),
|
||||
"credential_pool": runtime.get("credential_pool"),
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
|
||||
|
||||
@@ -2618,7 +2596,6 @@ class GatewayRunner:
|
||||
"api_key": override.get("api_key"),
|
||||
"base_url": override.get("base_url"),
|
||||
"api_mode": override.get("api_mode"),
|
||||
"max_tokens": override.get("max_tokens"),
|
||||
}
|
||||
if override_runtime.get("api_key"):
|
||||
logger.debug(
|
||||
@@ -2716,7 +2693,6 @@ class GatewayRunner:
|
||||
"command": runtime_kwargs.get("command"),
|
||||
"args": list(runtime_kwargs.get("args") or []),
|
||||
"credential_pool": runtime_kwargs.get("credential_pool"),
|
||||
"max_tokens": runtime_kwargs.get("max_tokens"),
|
||||
}
|
||||
route = {
|
||||
"model": model,
|
||||
@@ -7932,8 +7908,6 @@ class GatewayRunner:
|
||||
return await self._handle_profile_command(event)
|
||||
if _cmd_def_inner.name == "update":
|
||||
return await self._handle_update_command(event)
|
||||
if _cmd_def_inner.name == "version":
|
||||
return await self._handle_version_command(event)
|
||||
|
||||
# Catch-all: any other recognized slash command reached the
|
||||
# running-agent guard. Reject gracefully rather than falling
|
||||
@@ -8290,9 +8264,6 @@ class GatewayRunner:
|
||||
if canonical == "update":
|
||||
return await self._handle_update_command(event)
|
||||
|
||||
if canonical == "version":
|
||||
return await self._handle_version_command(event)
|
||||
|
||||
if canonical == "debug":
|
||||
return await self._handle_debug_command(event)
|
||||
|
||||
@@ -10918,12 +10889,6 @@ class GatewayRunner:
|
||||
return event.platform_update_id <= recorded_uid
|
||||
|
||||
|
||||
async def _handle_version_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /version — show the running Hermes Agent version."""
|
||||
from hermes_cli.banner import format_banner_version_label
|
||||
|
||||
return format_banner_version_label()
|
||||
|
||||
async def _handle_help_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /help command - list available commands."""
|
||||
from hermes_cli.commands import gateway_help_lines
|
||||
@@ -12033,24 +11998,13 @@ class GatewayRunner:
|
||||
self._save_voice_modes()
|
||||
if adapter:
|
||||
self._set_adapter_auto_tts_enabled(adapter, chat_id, enabled=True)
|
||||
toggle_line = t("gateway.voice.enabled_short")
|
||||
return t("gateway.voice.enabled_short")
|
||||
else:
|
||||
self._voice_mode[voice_key] = "off"
|
||||
self._save_voice_modes()
|
||||
if adapter:
|
||||
self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=True)
|
||||
toggle_line = t("gateway.voice.disabled_short")
|
||||
# Bare /voice still toggles, but append an explainer so users
|
||||
# discover the on/off/tts/status subcommands (and, on Discord,
|
||||
# live voice-channel join/leave). The toggle result is shown
|
||||
# first via the {toggle} placeholder.
|
||||
supports_voice_channels = adapter is not None and hasattr(
|
||||
adapter, "join_voice_channel"
|
||||
)
|
||||
channels = (
|
||||
t("gateway.voice.help_channels") if supports_voice_channels else ""
|
||||
)
|
||||
return t("gateway.voice.help", toggle=toggle_line, channels=channels)
|
||||
return t("gateway.voice.disabled_short")
|
||||
|
||||
async def _handle_voice_channel_join(self, event: MessageEvent) -> str:
|
||||
"""Join the user's current Discord voice channel."""
|
||||
@@ -17070,47 +17024,6 @@ class GatewayRunner:
|
||||
last_progress_msg = [None] # Track last message for dedup
|
||||
repeat_count = [0] # How many times the same message repeated
|
||||
|
||||
# ── Discord voice "verbal ack before tool calls" ────────────────
|
||||
# When the bot is in a voice channel with the continuous mixer
|
||||
# installed (discord.voice_fx.enabled), speak a short phrase ("let me
|
||||
# look into that") over the ambient idle bed on the FIRST tool call of
|
||||
# the turn. Fires from tool_start_callback (independent of the
|
||||
# tool-progress text gate), at most once per turn. No-op on every
|
||||
# other platform / when not in a voice channel.
|
||||
_voice_ack_fired = [False]
|
||||
_voice_ack_guild: List[Optional[int]] = [None]
|
||||
if source.platform == Platform.DISCORD:
|
||||
_va = self.adapters.get(Platform.DISCORD)
|
||||
# source.chat_id is the linked text channel; resolve the guild whose
|
||||
# voice connection is bound to it (mirrors DiscordAdapter.play_tts).
|
||||
_vtc = getattr(_va, "_voice_text_channels", None)
|
||||
if isinstance(_vtc, dict) and hasattr(_va, "voice_mixer_active"):
|
||||
for _gid, _tc in _vtc.items():
|
||||
if str(_tc) == str(source.chat_id) and _va.voice_mixer_active(_gid):
|
||||
_voice_ack_guild[0] = _gid
|
||||
break
|
||||
_voice_ack_loop = asyncio.get_running_loop()
|
||||
|
||||
def voice_ack_callback(call_id, tool_name, args):
|
||||
"""tool_start_callback: speak a one-time ack in the voice channel."""
|
||||
if _voice_ack_fired[0] or _voice_ack_guild[0] is None:
|
||||
return
|
||||
if not _run_still_current():
|
||||
return
|
||||
_voice_ack_fired[0] = True
|
||||
_adapter = self.adapters.get(Platform.DISCORD)
|
||||
if _adapter is None or not hasattr(_adapter, "play_ack_in_voice"):
|
||||
return
|
||||
try:
|
||||
safe_schedule_threadsafe(
|
||||
_adapter.play_ack_in_voice(_voice_ack_guild[0]),
|
||||
_voice_ack_loop,
|
||||
logger=logger,
|
||||
log_message="voice ack scheduling error",
|
||||
)
|
||||
except Exception as _ack_err:
|
||||
logger.debug("voice ack schedule failed: %s", _ack_err)
|
||||
|
||||
# Auto-cleanup of temporary progress bubbles (Telegram + any adapter
|
||||
# that implements ``delete_message``). When enabled via
|
||||
# ``display.platforms.<platform>.cleanup_progress: true``, message IDs
|
||||
@@ -17921,11 +17834,6 @@ class GatewayRunner:
|
||||
# Per-message state — callbacks and reasoning config change every
|
||||
# turn and must not be baked into the cached agent constructor.
|
||||
agent.tool_progress_callback = progress_callback if tool_progress_enabled else None
|
||||
# Discord voice verbal-ack hook (fires once per turn on first tool
|
||||
# call; armed only when in a voice channel with the mixer running).
|
||||
agent.tool_start_callback = (
|
||||
voice_ack_callback if _voice_ack_guild[0] is not None else None
|
||||
)
|
||||
agent.step_callback = _step_callback_sync if _hooks_ref.loaded_hooks else None
|
||||
agent.stream_delta_callback = _stream_delta_cb
|
||||
agent.interim_assistant_callback = _interim_assistant_cb if _want_interim_messages else None
|
||||
|
||||
+3
-7
@@ -227,10 +227,7 @@ def _read_json_file(path: Path) -> Optional[dict[str, Any]]:
|
||||
return None
|
||||
try:
|
||||
raw = path.read_text(encoding="utf-8").strip()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
# OSError: file vanished or permission flipped between exists() and
|
||||
# read. UnicodeDecodeError: file holds non-UTF-8 / binary garbage
|
||||
# (a truncated or clobbered status file). Either way it's unusable.
|
||||
except OSError:
|
||||
return None
|
||||
if not raw:
|
||||
return None
|
||||
@@ -252,9 +249,8 @@ def _read_pid_record(pid_path: Optional[Path] = None) -> Optional[dict]:
|
||||
|
||||
try:
|
||||
raw = pid_path.read_text().strip()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
# File was deleted between exists() and read_text(), permission
|
||||
# flipped, or it holds non-UTF-8 / binary garbage.
|
||||
except OSError:
|
||||
# File was deleted between exists() and read_text(), or permission flipped.
|
||||
return None
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
@@ -225,25 +225,6 @@ def check_for_updates() -> Optional[int]:
|
||||
cache_file = hermes_home / ".update_check"
|
||||
embedded_rev = os.environ.get("HERMES_REVISION") or None
|
||||
|
||||
# Docker images have no working tree to count commits against — the
|
||||
# published image excludes `.git` (see .dockerignore) and sets no
|
||||
# HERMES_REVISION (that's nix-only). Without this guard the checks below
|
||||
# fall through to `check_via_pypi()`, whose PyPI-version mismatch flag (1)
|
||||
# then gets rendered by the CLI banner and the TUI badge as a phantom
|
||||
# "1 commit behind" — even though no git repo or commit math is involved,
|
||||
# and `hermes update` correctly refuses to run in-place inside the
|
||||
# container anyway. The dashboard's REST `/api/hermes/update/check`
|
||||
# endpoint already short-circuits docker the same way (web_server.py);
|
||||
# mirror that here so the banner/TUI surfaces agree. Returning None makes
|
||||
# both the Rich banner (build_welcome_banner) and the Ink badge
|
||||
# (branding.tsx, guarded on `typeof === 'number' && > 0`) show nothing.
|
||||
try:
|
||||
from hermes_cli.config import detect_install_method
|
||||
if detect_install_method() == "docker":
|
||||
return None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Read cache — invalidate if the embedded rev OR installed version has
|
||||
# changed since the last check. The version guard matters for pip installs:
|
||||
# `check_via_pypi()` compares against VERSION, so a `pip install --upgrade`
|
||||
|
||||
+85
-3
@@ -216,7 +216,6 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
CommandDef("image", "Attach a local image file for your next prompt", "Info",
|
||||
cli_only=True, args_hint="<path>"),
|
||||
CommandDef("update", "Update Hermes Agent to the latest version", "Info"),
|
||||
CommandDef("version", "Show Hermes Agent version", "Info", aliases=("v",)),
|
||||
CommandDef("debug", "Upload debug report (system info + logs) and get shareable links", "Info"),
|
||||
|
||||
# Exit
|
||||
@@ -350,7 +349,6 @@ ACTIVE_SESSION_BYPASS_COMMANDS: frozenset[str] = frozenset(
|
||||
"steer",
|
||||
"stop",
|
||||
"update",
|
||||
"version",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1150,6 +1148,41 @@ def slack_subcommand_map() -> dict[str, str]:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Per-process cache for /model<space> LM Studio autocomplete. Probing on
|
||||
# every keystroke would block the UI; a short TTL keeps it live without
|
||||
# hammering the server.
|
||||
_LMSTUDIO_COMPLETION_CACHE: tuple[float, list[str]] | None = None
|
||||
|
||||
|
||||
def _lmstudio_completion_models() -> list[str]:
|
||||
"""Locally-loaded LM Studio models for /model autocomplete (cached, gated)."""
|
||||
global _LMSTUDIO_COMPLETION_CACHE
|
||||
# Gate: don't probe 127.0.0.1 on every keystroke for users who don't use LM Studio.
|
||||
if not (os.environ.get("LM_API_KEY") or os.environ.get("LM_BASE_URL")):
|
||||
try:
|
||||
from hermes_cli.auth import _load_auth_store
|
||||
store = _load_auth_store() or {}
|
||||
if "lmstudio" not in (store.get("providers") or {}) \
|
||||
and "lmstudio" not in (store.get("credential_pool") or {}):
|
||||
return []
|
||||
except Exception:
|
||||
return []
|
||||
now = time.time()
|
||||
if _LMSTUDIO_COMPLETION_CACHE and (now - _LMSTUDIO_COMPLETION_CACHE[0]) < 30.0:
|
||||
return _LMSTUDIO_COMPLETION_CACHE[1]
|
||||
try:
|
||||
from hermes_cli.models import fetch_lmstudio_models
|
||||
models = fetch_lmstudio_models(
|
||||
api_key=os.environ.get("LM_API_KEY", ""),
|
||||
base_url=os.environ.get("LM_BASE_URL") or "http://127.0.0.1:1234/v1",
|
||||
timeout=0.8,
|
||||
)
|
||||
except Exception:
|
||||
models = []
|
||||
_LMSTUDIO_COMPLETION_CACHE = (now, models)
|
||||
return models
|
||||
|
||||
|
||||
class SlashCommandCompleter(Completer):
|
||||
"""Autocomplete for built-in slash commands, subcommands, and skill commands."""
|
||||
|
||||
@@ -1566,6 +1599,52 @@ class SlashCommandCompleter(Completer):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _model_completions(self, sub_text: str, sub_lower: str):
|
||||
"""Yield completions for /model from config aliases + built-in aliases."""
|
||||
seen = set()
|
||||
# Config-based direct aliases (preferred — include provider info)
|
||||
try:
|
||||
from hermes_cli.model_switch import (
|
||||
_ensure_direct_aliases, DIRECT_ALIASES, MODEL_ALIASES,
|
||||
)
|
||||
_ensure_direct_aliases()
|
||||
for name, da in DIRECT_ALIASES.items():
|
||||
if name.startswith(sub_lower) and name != sub_lower:
|
||||
seen.add(name)
|
||||
yield Completion(
|
||||
name,
|
||||
start_position=-len(sub_text),
|
||||
display=name,
|
||||
display_meta=f"{da.model} ({da.provider})",
|
||||
)
|
||||
# Built-in catalog aliases not already covered
|
||||
for name in sorted(MODEL_ALIASES.keys()):
|
||||
if name in seen:
|
||||
continue
|
||||
if name.startswith(sub_lower) and name != sub_lower:
|
||||
identity = MODEL_ALIASES[name]
|
||||
yield Completion(
|
||||
name,
|
||||
start_position=-len(sub_text),
|
||||
display=name,
|
||||
display_meta=f"{identity.vendor}/{identity.family}",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
# LM Studio: surface locally-loaded models. Gated on the user actually
|
||||
# having LM Studio configured (env var or auth-store entry) so we
|
||||
# don't probe 127.0.0.1 on every keystroke for users who don't use it.
|
||||
for name in _lmstudio_completion_models():
|
||||
if name in seen:
|
||||
continue
|
||||
if name.startswith(sub_lower) and name != sub_lower:
|
||||
yield Completion(
|
||||
name,
|
||||
start_position=-len(sub_text),
|
||||
display=name,
|
||||
display_meta="LM Studio",
|
||||
)
|
||||
|
||||
def get_completions(self, document, complete_event):
|
||||
text = document.text_before_cursor
|
||||
if not text.startswith("/"):
|
||||
@@ -1589,6 +1668,9 @@ class SlashCommandCompleter(Completer):
|
||||
|
||||
# Dynamic completions for commands with runtime lists
|
||||
if " " not in sub_text:
|
||||
if base_cmd == "/model":
|
||||
yield from self._model_completions(sub_text, sub_lower)
|
||||
return
|
||||
if base_cmd == "/skin":
|
||||
yield from self._skin_completions(sub_text, sub_lower)
|
||||
return
|
||||
@@ -1706,7 +1788,7 @@ class SlashCommandAutoSuggest(AutoSuggest):
|
||||
return Suggestion(cmd_name[len(word):])
|
||||
return None
|
||||
|
||||
# Command is complete — suggest subcommands
|
||||
# Command is complete — suggest subcommands or model names
|
||||
sub_text = parts[1] if len(parts) > 1 else ""
|
||||
sub_lower = sub_text.lower()
|
||||
|
||||
|
||||
+5
-43
@@ -1677,9 +1677,9 @@ DEFAULT_CONFIG = {
|
||||
# "low", "minimal", "none" (empty = inherit parent's level)
|
||||
"max_concurrent_children": 3, # max parallel children per batch; floor of 1 enforced, no ceiling
|
||||
# Orchestrator role controls (see tools/delegate_tool.py:_get_max_spawn_depth
|
||||
# and _get_orchestrator_enabled). Floored at 1, no upper ceiling —
|
||||
# raise deliberately, each level multiplies API cost.
|
||||
"max_spawn_depth": 1, # depth (1 = flat [default], 2 = orchestrator→leaf, 3+ = deeper)
|
||||
# and _get_orchestrator_enabled). Values are clamped to [1, 3] with a
|
||||
# warning log if out of range.
|
||||
"max_spawn_depth": 1, # depth cap (1 = flat [default], 2 = orchestrator→leaf, 3 = three-level)
|
||||
"orchestrator_enabled": True, # kill switch for role="orchestrator"
|
||||
# When a subagent hits a dangerous-command approval prompt, the parent's
|
||||
# prompt_toolkit TUI owns stdin — a thread-local input() call from the
|
||||
@@ -1841,28 +1841,6 @@ DEFAULT_CONFIG = {
|
||||
# real memory cost. Default 32 MiB matches the historical hardcoded
|
||||
# cap. Set to 0 for no cap. Env override: DISCORD_MAX_ATTACHMENT_BYTES.
|
||||
"max_attachment_bytes": 33554432,
|
||||
# Voice-channel audio effects (the continuous mixer). OFF by default.
|
||||
# When enabled, the bot installs a software mixer on the outgoing voice
|
||||
# stream so a low ambient "thinking" bed, verbal acknowledgements, and
|
||||
# TTS replies can OVERLAP (ducking the ambient under speech) instead of
|
||||
# stop-and-swap — the Grok-voice-mode feel. discord.py ships no mixer;
|
||||
# this is implemented in plugins/platforms/discord/voice_mixer.py.
|
||||
"voice_fx": {
|
||||
"enabled": False, # master switch for the mixer subsystem
|
||||
"ambient_enabled": True, # play the idle "thinking" bed while tools run
|
||||
"ambient_path": "", # custom loop audio file; "" = synthesised pad
|
||||
"ambient_gain": 0.18, # idle bed loudness, 0.0–1.0
|
||||
"duck_gain": 0.06, # ambient loudness while speech plays
|
||||
"speech_gain": 1.0, # TTS / ack loudness, 0.0–1.0
|
||||
"ack_enabled": True, # speak a short phrase before the first tool call
|
||||
"ack_phrases": [ # picked at random; set [] to disable phrases
|
||||
"Let me look into that.",
|
||||
"One moment.",
|
||||
"Checking on that now.",
|
||||
"Give me a sec.",
|
||||
"On it.",
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
# WhatsApp platform settings (gateway mode)
|
||||
@@ -2275,22 +2253,6 @@ DEFAULT_CONFIG = {
|
||||
# disable backups entirely, set ``pre_update_backup: false`` above
|
||||
# rather than ``backup_keep: 0``.
|
||||
"backup_keep": 5,
|
||||
# What `hermes update` does with uncommitted local changes to the
|
||||
# source tree when it runs NON-interactively — i.e. triggered from
|
||||
# the desktop/chat app or the gateway, where there's no TTY to answer
|
||||
# a restore prompt. Interactive (terminal) updates are unaffected:
|
||||
# they always stash the changes and ask whether to restore, exactly
|
||||
# as they always have.
|
||||
# "stash" — auto-stash the changes, pull, then auto-restore them
|
||||
# on top of the updated code (the safe default; nothing
|
||||
# is ever lost — conflicts are preserved in a git stash).
|
||||
# "discard" — auto-stash the changes and throw the stash away after
|
||||
# the pull. Use this only if you never intend to keep
|
||||
# local edits to the source tree on this machine.
|
||||
# Stash-and-drop (not `reset --hard` + `clean -fd`) so
|
||||
# ignored paths — node_modules, venv, build outputs —
|
||||
# are never touched.
|
||||
"non_interactive_local_changes": "stash",
|
||||
},
|
||||
|
||||
# Language Server Protocol — semantic diagnostics from real
|
||||
@@ -2420,7 +2382,7 @@ DEFAULT_CONFIG = {
|
||||
|
||||
|
||||
# Config schema version - bump this when adding new required fields
|
||||
"_config_version": 27,
|
||||
"_config_version": 26,
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
@@ -3975,7 +3937,7 @@ _KNOWN_ROOT_KEYS = {
|
||||
"fallback_providers", "credential_pool_strategies", "toolsets",
|
||||
"agent", "terminal", "display", "compression", "delegation",
|
||||
"auxiliary", "custom_providers", "context", "memory", "gateway",
|
||||
"sessions", "streaming", "updates",
|
||||
"sessions", "streaming",
|
||||
}
|
||||
|
||||
# Valid fields inside a custom_providers list entry
|
||||
|
||||
+1
-4
@@ -81,10 +81,7 @@ def cron_list(show_all: bool = False):
|
||||
state = job.get("state", "scheduled" if job.get("enabled", True) else "paused")
|
||||
next_run = job.get("next_run_at", "?")
|
||||
|
||||
# `repeat` may be present-but-null in the job record (e.g. a one-shot
|
||||
# job persisted with "repeat": null), so coalesce to {} rather than
|
||||
# relying on the dict-default, which only applies to a missing key.
|
||||
repeat_info = job.get("repeat") or {}
|
||||
repeat_info = job.get("repeat", {})
|
||||
repeat_times = repeat_info.get("times")
|
||||
repeat_completed = repeat_info.get("completed", 0)
|
||||
repeat_str = f"{repeat_completed}/{repeat_times}" if repeat_times else "∞"
|
||||
|
||||
@@ -2,18 +2,13 @@
|
||||
|
||||
Three cookies in play:
|
||||
- hermes_session_at: the OAuth access token
|
||||
(HttpOnly, lifetime = token TTL, ~15 min)
|
||||
(HttpOnly, lifetime = token TTL)
|
||||
- hermes_session_rt: the OAuth refresh token
|
||||
(HttpOnly, lifetime = 24h, ROTATING + reuse-detected)
|
||||
Nous Portal issues a rotating refresh token for the
|
||||
dashboard auth-code grant (Portal NAS #293 / hermes
|
||||
#37247). ``set_session_cookies`` writes this cookie
|
||||
whenever the provider returns a non-empty
|
||||
``refresh_token``; the middleware uses it to rotate a
|
||||
fresh access token transparently on AT expiry. A
|
||||
provider that omits the refresh token (empty string)
|
||||
degrades gracefully to access-token-only sessions —
|
||||
the RT cookie is simply not written.
|
||||
(HttpOnly, lifetime = 30 days)
|
||||
**DEPRECATED in OAuth contract v1** — Nous Portal
|
||||
does not issue refresh tokens; we keep the cookie
|
||||
name and clear semantics for forward compatibility
|
||||
and to flush stale cookies from old browsers.
|
||||
- hermes_session_pkce: short-lived PKCE state + CSRF nonce + provider
|
||||
hint (HttpOnly, lifetime = 10 minutes)
|
||||
|
||||
@@ -44,15 +39,13 @@ The setters and readers BOTH consult the active prefix because the
|
||||
cookie *name* changes — a reader that looked up the bare name when the
|
||||
setter wrote ``__Secure-hermes_session_at`` would never find the value.
|
||||
|
||||
Refresh-token handling:
|
||||
``set_session_cookies`` accepts ``refresh_token=""`` (provider omitted
|
||||
it) and silently skips writing the RT cookie in that case, so a
|
||||
refresh-token-less provider degrades to access-token-only sessions.
|
||||
``clear_session_cookies`` always emits a Max-Age=0 deletion for the RT
|
||||
cookie on logout / session expiry so a stale cookie from an earlier
|
||||
deployment gets cleared. The transparent rotation flow ("expired AT +
|
||||
live RT → rotate server-side, else 401 → /login") lives in
|
||||
``middleware._attempt_refresh``.
|
||||
.. deprecated:: contract v1
|
||||
``set_session_cookies`` accepts ``refresh_token=""`` (the contract-v1
|
||||
default) and silently skips writing the RT cookie in that case.
|
||||
``clear_session_cookies`` still emits a Max-Age=0 deletion for the RT
|
||||
cookie so users carrying a stale cookie from an earlier deployment get
|
||||
it cleared on logout / session expiry. The full refresh-flow machinery
|
||||
was rewritten as "401 → redirect to /login" in Phase 6.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -73,13 +66,7 @@ PKCE_COOKIE = "hermes_session_pkce"
|
||||
# practice — a single request emits exactly one variant).
|
||||
_NAME_VARIANTS = ("__Host-", "__Secure-", "")
|
||||
|
||||
# RT cookie Max-Age. Kept at 30 days as a generous upper bound on the cookie's
|
||||
# browser lifetime; Portal's actual refresh-token TTL (24h, rotating) is the
|
||||
# real authority — once the RT itself expires/rotates out, a refresh attempt
|
||||
# returns 400 → RefreshExpiredError → clean re-login, regardless of how long
|
||||
# the cookie lingers. (Not tightened to 24h here to avoid coupling the cookie
|
||||
# lifetime to a server-side TTL that can change independently; revisit if the
|
||||
# stale-cookie refresh churn ever matters.)
|
||||
# 30 days — matches Portal's REFRESH_TOKEN_TTL_SECONDS
|
||||
_RT_MAX_AGE = 30 * 24 * 60 * 60
|
||||
_PKCE_MAX_AGE = 10 * 60
|
||||
|
||||
@@ -139,11 +126,11 @@ def set_session_cookies(
|
||||
``access_token_expires_in`` is in seconds. Use the provider's reported
|
||||
TTL for the access token.
|
||||
|
||||
``refresh_token`` is written as the RT cookie when non-empty. Nous Portal
|
||||
issues a 24h rotating refresh token (hermes #37247); a provider that
|
||||
omits it returns ``Session.refresh_token == ""`` and we simply don't
|
||||
persist the RT cookie — the session then behaves as access-token-only
|
||||
until the AT expires. No other branch changes between the two cases.
|
||||
``refresh_token`` is accepted for backward / forward compatibility but
|
||||
SKIPPED when empty — Nous Portal contract v1 issues no refresh tokens
|
||||
so a ``Session.refresh_token == ""`` from the provider means we don't
|
||||
persist anything. If a future contract revision starts emitting refresh
|
||||
tokens, this helper will write the RT cookie again with no other change.
|
||||
|
||||
``prefix`` is the normalised X-Forwarded-Prefix value (e.g. ``/hermes``)
|
||||
or ``""`` for a direct deploy. It influences both the cookie name
|
||||
|
||||
@@ -4382,35 +4382,6 @@ def _setup_standard_platform(platform: dict):
|
||||
if not prompt_yes_no(f" Reconfigure {label}?", False):
|
||||
return
|
||||
|
||||
auto_token_saved = False
|
||||
auto_owner_user_id = None
|
||||
if platform.get("key") == "telegram":
|
||||
print()
|
||||
print_info(" Telegram can be configured automatically with a managed bot:")
|
||||
print_info(" [1] Automatic (scan QR → confirm in Telegram → done)")
|
||||
print_info(" [2] Manual BotFather token")
|
||||
choice = prompt(" Choice [1/2]", default="1")
|
||||
if choice.strip() == "1":
|
||||
try:
|
||||
from hermes_cli.telegram_managed_bot import (
|
||||
auto_setup_telegram_bot_result,
|
||||
is_valid_telegram_bot_token,
|
||||
)
|
||||
except ImportError:
|
||||
print_warning(" Automatic setup is unavailable in this install.")
|
||||
else:
|
||||
result = auto_setup_telegram_bot_result()
|
||||
if result and is_valid_telegram_bot_token(result.token):
|
||||
save_env_value(token_var, result.token)
|
||||
print_success(" Saved TELEGRAM_BOT_TOKEN")
|
||||
auto_token_saved = True
|
||||
auto_owner_user_id = result.owner_user_id
|
||||
else:
|
||||
if result:
|
||||
print_warning(" Automatic setup returned an invalid Telegram token.")
|
||||
print()
|
||||
print_info(" Falling back to manual setup...")
|
||||
|
||||
allowed_val_set = None # Track if user set an allowlist (for home channel offer)
|
||||
|
||||
for var in platform["vars"]:
|
||||
@@ -4420,30 +4391,8 @@ def _setup_standard_platform(platform: dict):
|
||||
if existing and var["name"] != token_var:
|
||||
print_info(f" Current: {existing}")
|
||||
|
||||
if auto_token_saved and var["name"] == token_var:
|
||||
print_info(" Token saved by automatic setup.")
|
||||
continue
|
||||
|
||||
# Allowlist fields get special handling for the deny-by-default security model
|
||||
if var.get("is_allowlist"):
|
||||
if "TELEGRAM" in var["name"] and auto_owner_user_id:
|
||||
detected_id = str(auto_owner_user_id)
|
||||
print_success(f" Detected your Telegram user ID: {detected_id}")
|
||||
if prompt_yes_no(" Allow this Telegram account to use the bot?", True):
|
||||
extra = prompt(
|
||||
" Additional allowed user IDs (comma-separated, optional)",
|
||||
password=False,
|
||||
)
|
||||
ids = [detected_id]
|
||||
for uid in extra.replace(" ", "").split(","):
|
||||
if uid and uid not in ids:
|
||||
ids.append(uid)
|
||||
cleaned = ",".join(ids)
|
||||
save_env_value(var["name"], cleaned)
|
||||
print_success(" Saved — only these users can interact with the bot.")
|
||||
allowed_val_set = cleaned
|
||||
continue
|
||||
|
||||
print_info(" The gateway DENIES all users by default for security.")
|
||||
print_info(" Enter user IDs to create an allowlist, or leave empty")
|
||||
print_info(" and you'll be asked about open access next.")
|
||||
|
||||
+219
-207
@@ -1264,32 +1264,6 @@ def _workspace_root(dir: Path) -> Path:
|
||||
return dir
|
||||
|
||||
|
||||
def _termux_workspace_install_context(
|
||||
dir: Path, *, include_child_workspaces: bool = False
|
||||
) -> tuple[Path, tuple[str, ...]]:
|
||||
"""Return Termux-only ``(cwd, npm_args)`` for installing deps for *dir* only."""
|
||||
ws_root = _workspace_root(dir)
|
||||
if ws_root == dir:
|
||||
return dir, ()
|
||||
|
||||
try:
|
||||
workspace = dir.relative_to(ws_root).as_posix()
|
||||
except ValueError:
|
||||
return ws_root, ()
|
||||
|
||||
workspace_args: list[str] = ["--workspace", workspace]
|
||||
if include_child_workspaces:
|
||||
packages_dir = dir / "packages"
|
||||
if packages_dir.is_dir():
|
||||
for child in sorted(packages_dir.iterdir()):
|
||||
if child.is_dir() and (child / "package.json").is_file():
|
||||
workspace_args.extend(
|
||||
["--workspace", child.relative_to(ws_root).as_posix()]
|
||||
)
|
||||
workspace_args.append("--include-workspace-root=false")
|
||||
return ws_root, tuple(workspace_args)
|
||||
|
||||
|
||||
def _tui_need_npm_install(root: Path) -> bool:
|
||||
"""True when @hermes/ink is missing or node_modules is behind package-lock.json.
|
||||
|
||||
@@ -1550,43 +1524,16 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
|
||||
|
||||
# 2. Normal flow: npm install if needed, always esbuild, then node dist/entry.js.
|
||||
# --dev flow: npm install if needed, then tsx src/entry.tsx.
|
||||
# Existing desktop behaviour runs npm from the workspace root. Termux
|
||||
# scopes the install to ui-tui so launch does not pull desktop/web
|
||||
# dependencies into the hot path.
|
||||
# npm install runs from the workspace root (where package-lock.json lives);
|
||||
# npm workspaces resolves ui-tui deps automatically.
|
||||
did_install = False
|
||||
termux_startup = _is_termux_startup_environment()
|
||||
termux_need_rebuild = False
|
||||
if termux_startup and not tui_dev:
|
||||
termux_need_rebuild = _tui_need_rebuild(tui_dir)
|
||||
|
||||
skip_install_for_fresh_termux_bundle = (
|
||||
termux_startup and not tui_dev and not termux_need_rebuild
|
||||
)
|
||||
if (
|
||||
not skip_install_for_fresh_termux_bundle
|
||||
and _tui_need_npm_install(tui_dir)
|
||||
):
|
||||
if _tui_need_npm_install(tui_dir):
|
||||
npm = _node_bin("npm")
|
||||
if not os.environ.get("HERMES_QUIET"):
|
||||
print("Installing TUI dependencies…")
|
||||
npm_cwd = _workspace_root(tui_dir)
|
||||
npm_workspace_args: tuple[str, ...] = ()
|
||||
if termux_startup:
|
||||
npm_cwd, npm_workspace_args = _termux_workspace_install_context(
|
||||
tui_dir,
|
||||
include_child_workspaces=True,
|
||||
)
|
||||
result = subprocess.run(
|
||||
[
|
||||
npm,
|
||||
"install",
|
||||
*npm_workspace_args,
|
||||
"--silent",
|
||||
"--no-fund",
|
||||
"--no-audit",
|
||||
"--progress=false",
|
||||
],
|
||||
cwd=str(npm_cwd),
|
||||
[npm, "install", "--silent", "--no-fund", "--no-audit", "--progress=false"],
|
||||
cwd=str(_workspace_root(tui_dir)),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
@@ -1632,8 +1579,8 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
|
||||
# Termux cold starts use the freshness check because esbuild startup is
|
||||
# expensive on old mobile CPUs.
|
||||
should_build = True
|
||||
if termux_startup:
|
||||
should_build = did_install or termux_need_rebuild
|
||||
if _is_termux_startup_environment():
|
||||
should_build = did_install or _tui_need_rebuild(tui_dir)
|
||||
|
||||
if should_build:
|
||||
npm = _node_bin("npm")
|
||||
@@ -2610,8 +2557,6 @@ def select_provider_and_model(args=None):
|
||||
"api_key": entry.get("api_key", ""),
|
||||
"key_env": entry.get("key_env", ""),
|
||||
"model": entry.get("model", ""),
|
||||
"models": entry.get("models", {}),
|
||||
"discover_models": entry.get("discover_models", True),
|
||||
"api_mode": entry.get("api_mode", ""),
|
||||
"provider_key": provider_key,
|
||||
"api_key_ref": _lookup_ref(
|
||||
@@ -4794,45 +4739,17 @@ def _model_flow_named_custom(config, provider_info):
|
||||
api_key = os.environ.get(key_env, "")
|
||||
config_api_key = _custom_provider_api_key_config_value(provider_info, api_key)
|
||||
|
||||
# Honor ``discover_models: false`` (default True) — when discovery is
|
||||
# disabled, use the configured ``models:`` list verbatim and skip the
|
||||
# live /models probe. This lets operators restrict the picker to the
|
||||
# subset their plan actually serves instead of the endpoint's full
|
||||
# catalog (#18726: Baidu Qianfan returns 100+ models for a 2-3 model
|
||||
# plan). Same semantics as the slash-command picker (model_switch.py
|
||||
# sections 3 & 4): default discovers, false keeps the explicit list.
|
||||
discover = provider_info.get("discover_models", True)
|
||||
if isinstance(discover, str):
|
||||
discover = discover.lower() not in {"false", "no", "0"}
|
||||
configured_models: list[str] = []
|
||||
cfg_models = provider_info.get("models", {})
|
||||
if isinstance(cfg_models, dict):
|
||||
configured_models = [str(m) for m in cfg_models if str(m).strip()]
|
||||
elif isinstance(cfg_models, list):
|
||||
configured_models = [
|
||||
str(m) for m in cfg_models if isinstance(m, str) and m.strip()
|
||||
]
|
||||
|
||||
print(f" Provider: {name}")
|
||||
print(f" URL: {base_url}")
|
||||
if saved_model:
|
||||
print(f" Current: {saved_model}")
|
||||
print()
|
||||
|
||||
if not discover and configured_models:
|
||||
# Discovery disabled with an explicit list — use it verbatim, no probe.
|
||||
print(f"Using configured models (discover_models: false): {len(configured_models)}")
|
||||
models = configured_models
|
||||
else:
|
||||
print("Fetching available models...")
|
||||
fetch_kwargs = {"timeout": 8.0}
|
||||
if api_mode:
|
||||
fetch_kwargs["api_mode"] = api_mode
|
||||
models = fetch_api_models(api_key, base_url, **fetch_kwargs)
|
||||
# If the probe came back empty but the operator configured an explicit
|
||||
# list, fall back to it rather than forcing manual entry.
|
||||
if not models and configured_models:
|
||||
models = configured_models
|
||||
print("Fetching available models...")
|
||||
fetch_kwargs = {"timeout": 8.0}
|
||||
if api_mode:
|
||||
fetch_kwargs["api_mode"] = api_mode
|
||||
models = fetch_api_models(api_key, base_url, **fetch_kwargs)
|
||||
|
||||
if models:
|
||||
default_idx = 0
|
||||
@@ -6647,9 +6564,7 @@ def cmd_import(args):
|
||||
|
||||
|
||||
def _print_version_info(*, check_updates: bool = True) -> None:
|
||||
from hermes_cli.banner import format_banner_version_label
|
||||
|
||||
print(format_banner_version_label())
|
||||
print(f"Hermes Agent v{__version__} ({__release_date__})")
|
||||
print(f"Project: {PROJECT_ROOT}")
|
||||
|
||||
# Show Python version
|
||||
@@ -7089,14 +7004,10 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
|
||||
if text:
|
||||
_say(text)
|
||||
|
||||
npm_cwd = _workspace_root(web_dir)
|
||||
npm_workspace_args: tuple[str, ...] = ()
|
||||
if _is_termux_startup_environment():
|
||||
npm_cwd, npm_workspace_args = _termux_workspace_install_context(web_dir)
|
||||
r1 = _run_npm_install_deterministic(
|
||||
npm,
|
||||
npm_cwd,
|
||||
extra_args=(*npm_workspace_args, "--silent"),
|
||||
_workspace_root(web_dir),
|
||||
extra_args=("--silent",),
|
||||
)
|
||||
if r1.returncode != 0:
|
||||
_say(
|
||||
@@ -8237,6 +8148,59 @@ def _stash_local_changes_if_needed(git_cmd: list[str], cwd: Path) -> Optional[st
|
||||
return stash_ref
|
||||
|
||||
|
||||
def _clean_managed_worktree(git_cmd: list[str], cwd: Path) -> bool:
|
||||
"""Discard working-tree dirt on a managed (non-fork) clone.
|
||||
|
||||
On a managed install (%LOCALAPPDATA%\\hermes\\hermes-agent or
|
||||
~/.hermes/hermes-agent) the user never edits the source tree, so any
|
||||
"dirty" state is pure git artifact: CRLF renormalization, npm lockfile
|
||||
churn, or files left behind when a directory was deleted upstream (e.g.
|
||||
apps/bootstrap-installer/). Stashing that dirt and re-applying it after a
|
||||
pull is actively dangerous — the stash/restore cycle has been observed to
|
||||
clobber freshly-pulled source files (apps/desktop/ deletion →
|
||||
"[UNRESOLVED_ENTRY] Cannot resolve entry module index.html").
|
||||
|
||||
For a managed clone the correct move is to throw the dirt away with
|
||||
``git reset --hard HEAD`` + ``git clean -fd`` (mirroring install.ps1's
|
||||
update path), NOT preserve it. Forks keep the stash machinery because
|
||||
their local edits are intentional.
|
||||
|
||||
Returns True if the tree was cleaned (or was already clean), False on
|
||||
a git failure (caller should fall back to the stash path).
|
||||
"""
|
||||
status = subprocess.run(
|
||||
git_cmd + ["status", "--porcelain"],
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if status.returncode != 0:
|
||||
return False
|
||||
if not status.stdout.strip():
|
||||
return True
|
||||
|
||||
print("→ Discarding working-tree changes on managed clone before update...")
|
||||
reset = subprocess.run(
|
||||
git_cmd + ["reset", "--hard", "HEAD"],
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if reset.returncode != 0:
|
||||
return False
|
||||
# Drop untracked files too (e.g. orphaned build artifacts), but never
|
||||
# touch ignored paths — node_modules, venv, build outputs, and the like
|
||||
# are expensive to rebuild and not git-artifact dirt.
|
||||
subprocess.run(
|
||||
git_cmd + ["clean", "-fd"],
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
def _resolve_stash_selector(
|
||||
git_cmd: list[str], cwd: Path, stash_ref: str
|
||||
) -> Optional[str]:
|
||||
@@ -8377,54 +8341,6 @@ def _restore_stashed_changes(
|
||||
return True
|
||||
|
||||
|
||||
def _discard_stashed_changes(
|
||||
git_cmd: list[str],
|
||||
cwd: Path,
|
||||
stash_ref: str,
|
||||
) -> bool:
|
||||
"""Throw away a stash created before an update, without applying it.
|
||||
|
||||
Used only on a NON-interactive update when the user has set
|
||||
``updates.non_interactive_local_changes: discard`` — i.e. they've opted out
|
||||
of keeping local source edits on this machine. Drops the stash entry
|
||||
instead of re-applying it, so the working tree stays clean at the freshly
|
||||
pulled HEAD. Unlike ``git reset --hard`` + ``git clean -fd``, this only
|
||||
affects what was stashed (tracked changes + the untracked files we
|
||||
explicitly captured) — ignored paths like node_modules/venv/build outputs
|
||||
are never touched, since they were never stashed.
|
||||
|
||||
Returns True if the stash was dropped, False on a git failure (in which
|
||||
case the stash is left in place for safety).
|
||||
"""
|
||||
stash_selector = _resolve_stash_selector(git_cmd, cwd, stash_ref)
|
||||
if stash_selector is None:
|
||||
print(
|
||||
"⚠ Configured to discard local changes on non-interactive update, "
|
||||
"but Hermes couldn't find the stash entry to drop."
|
||||
)
|
||||
_print_stash_cleanup_guidance(stash_ref)
|
||||
return False
|
||||
|
||||
drop = subprocess.run(
|
||||
git_cmd + ["stash", "drop", stash_selector],
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if drop.returncode != 0:
|
||||
print(
|
||||
"⚠ Configured to discard local changes, but Hermes couldn't drop "
|
||||
"the saved stash entry."
|
||||
)
|
||||
if drop.stderr.strip():
|
||||
print(f" {drop.stderr.strip().splitlines()[0]}")
|
||||
_print_stash_cleanup_guidance(stash_ref, stash_selector)
|
||||
return False
|
||||
|
||||
print("→ Discarded local source changes (updates.non_interactive_local_changes=discard).")
|
||||
return True
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Fork detection and upstream management for `hermes update`
|
||||
# =========================================================================
|
||||
@@ -10215,6 +10131,62 @@ def _cmd_update_pip(args):
|
||||
print("✓ Update complete! Restart hermes to use the new version.")
|
||||
|
||||
|
||||
def _should_handoff_after_pull(finalize_only: bool) -> bool:
|
||||
"""Whether to hand the post-pull steps off to a fresh subprocess on new
|
||||
code.
|
||||
|
||||
Returns False (finish in-process) when:
|
||||
- this IS already the finalize subprocess (avoid an infinite loop),
|
||||
- under pytest, so the test runner's interpreter never spawns a real
|
||||
recursive update mid-suite,
|
||||
- the ``HERMES_UPDATE_NO_HANDOFF`` escape hatch is set.
|
||||
|
||||
Cross-platform: unlike an ``os.exec*`` replacement (which on Windows
|
||||
spawns a *new* PID and breaks the desktop installer's exit-code wait on
|
||||
the original process), a child subprocess + exit-code forwarding keeps the
|
||||
parent PID intact everywhere, so this stays on for Windows too.
|
||||
"""
|
||||
if finalize_only or os.environ.get("HERMES_UPDATE_FINALIZE") == "1":
|
||||
return False
|
||||
if os.environ.get("HERMES_UPDATE_NO_HANDOFF") == "1":
|
||||
return False
|
||||
if "pytest" in sys.modules:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _handoff_update_to_refreshed_code():
|
||||
"""Finish the update in a fresh subprocess running the just-pulled code.
|
||||
|
||||
Called after a successful git pull + dependency install (so the new source
|
||||
AND its deps are on disk). Spawns ``hermes update`` again with
|
||||
``HERMES_UPDATE_FINALIZE=1`` set; that child skips the fetch/pull/snapshot
|
||||
work and this hand-off, and runs only the post-pull finalize steps with
|
||||
new code. stdin/stdout/stderr are inherited, so interactive prompts and a
|
||||
parent streaming our output both keep working.
|
||||
|
||||
Returns the child's exit code, or ``None`` if the subprocess could not be
|
||||
launched at all — in which case the caller finishes the update in-process,
|
||||
the historical behavior.
|
||||
"""
|
||||
try:
|
||||
env = dict(os.environ)
|
||||
env["HERMES_UPDATE_FINALIZE"] = "1"
|
||||
cmd = [sys.executable, "-m", "hermes_cli.main", *sys.argv[1:]]
|
||||
print("→ Finishing update with refreshed code...")
|
||||
sys.stdout.flush()
|
||||
sys.stderr.flush()
|
||||
result = subprocess.run(cmd, cwd=PROJECT_ROOT, env=env)
|
||||
return result.returncode
|
||||
except Exception as exc: # pragma: no cover - spawn almost never fails
|
||||
logger.warning(
|
||||
"update: could not hand off to refreshed code (%s); "
|
||||
"finishing in-process",
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _cmd_update_impl(args, gateway_mode: bool):
|
||||
"""Body of ``cmd_update`` — kept separate so the wrapper can always
|
||||
restore stdio even on ``sys.exit``."""
|
||||
@@ -10226,31 +10198,18 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
)
|
||||
assume_yes = bool(getattr(args, "yes", False))
|
||||
|
||||
# Whether this update is running without a human at the keyboard.
|
||||
# Interactive terminal updates always stash-and-ask (unchanged behavior);
|
||||
# only non-interactive updates (desktop/chat app, gateway, `--yes`) consult
|
||||
# the `updates.non_interactive_local_changes` config setting to decide
|
||||
# whether to auto-restore stashed local source changes or throw them away.
|
||||
_non_interactive_update = (
|
||||
gateway_mode
|
||||
or assume_yes
|
||||
or not (sys.stdin.isatty() and sys.stdout.isatty())
|
||||
)
|
||||
discard_local_changes = False
|
||||
if _non_interactive_update:
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
# Self-update hand-off marker. ``hermes update`` runs from the *old*
|
||||
# install, so its post-pull steps (dep install, config migration, gateway
|
||||
# restart) would otherwise execute stale in-memory code even though the new
|
||||
# source is already on disk. After a successful pull we re-exec into the
|
||||
# refreshed code with HERMES_UPDATE_FINALIZE=1; ``finalize_only`` is True on
|
||||
# that second pass and makes us skip the fetch/pull work and the re-exec.
|
||||
finalize_only = os.environ.get("HERMES_UPDATE_FINALIZE") == "1"
|
||||
|
||||
_update_cfg = (load_config() or {}).get("updates", {})
|
||||
if isinstance(_update_cfg, dict):
|
||||
_mode = str(_update_cfg.get("non_interactive_local_changes", "stash")).lower()
|
||||
discard_local_changes = _mode == "discard"
|
||||
except Exception as exc:
|
||||
# Never let a config read failure change the safe default.
|
||||
logger.debug("Could not read updates.non_interactive_local_changes: %s", exc)
|
||||
discard_local_changes = False
|
||||
|
||||
print("⚕ Updating Hermes Agent...")
|
||||
if finalize_only:
|
||||
print("⚕ Finalizing update with refreshed code...")
|
||||
else:
|
||||
print("⚕ Updating Hermes Agent...")
|
||||
print()
|
||||
|
||||
# On Windows, abort early if another hermes.exe is holding the venv shim
|
||||
@@ -10266,8 +10225,10 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
sys.exit(2)
|
||||
|
||||
# Pre-update backup — runs before any git/file mutation so users can
|
||||
# always roll back to the exact state they had before this update.
|
||||
_run_pre_update_backup(args)
|
||||
# always roll back to the exact state they had before this update. Skipped
|
||||
# on the finalize re-exec (the original pass already took it).
|
||||
if not finalize_only:
|
||||
_run_pre_update_backup(args)
|
||||
|
||||
# Try git-based update first, fall back to ZIP download on Windows
|
||||
# when git file I/O is broken (antivirus, NTFS filter drivers, etc.)
|
||||
@@ -10311,6 +10272,21 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
if sys.platform == "win32":
|
||||
git_cmd = ["git", "-c", "windows.appendAtomically=false"]
|
||||
|
||||
# On Windows, Git-for-Windows defaults to core.autocrlf=true, which
|
||||
# renormalizes the repo's LF-only text files to CRLF in the working tree.
|
||||
# On a managed, never-user-edited clone that makes tracked files read as
|
||||
# "locally modified", which forces an autostash on every update (and the
|
||||
# stash/restore cycle can clobber source files — see _stash_local_changes_
|
||||
# if_needed below). Pin autocrlf=false so the dirt is never created. This
|
||||
# mirrors what install.ps1's update path already does (PR #38239).
|
||||
if sys.platform == "win32" and git_dir.exists():
|
||||
subprocess.run(
|
||||
git_cmd + ["config", "core.autocrlf", "false"],
|
||||
cwd=PROJECT_ROOT,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
# Discard npm lockfile churn before any stash/branch logic. npm rewrites
|
||||
# tracked package-lock.json files non-deterministically at install/build
|
||||
# time (platform-specific optional deps, ideallyInert annotations, etc.),
|
||||
@@ -10388,8 +10364,14 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
else f"branch '{current_branch}'"
|
||||
)
|
||||
print(f" ⚠ Currently on {label} — switching to {branch} for update...")
|
||||
# Stash before checkout so uncommitted work isn't lost
|
||||
auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT)
|
||||
# Stash before checkout so uncommitted work isn't lost — but on a
|
||||
# managed (non-fork) clone there's nothing to preserve, so discard
|
||||
# git-artifact dirt instead (a dirty tree would otherwise block the
|
||||
# checkout). Forks keep the stash so their edits survive.
|
||||
if not is_fork and _clean_managed_worktree(git_cmd, PROJECT_ROOT):
|
||||
auto_stash_ref = None
|
||||
else:
|
||||
auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT)
|
||||
checkout_result = subprocess.run(
|
||||
git_cmd + ["checkout", branch],
|
||||
cwd=PROJECT_ROOT,
|
||||
@@ -10423,7 +10405,16 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
print(f" {track_result.stderr.strip().splitlines()[0]}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT)
|
||||
# On a managed (non-fork) clone the user never edits the source
|
||||
# tree, so any dirt is git artifact (CRLF, lockfile churn,
|
||||
# upstream-deleted dirs). Throw it away rather than stash/restore
|
||||
# it — the stash/restore cycle has clobbered freshly-pulled source
|
||||
# (apps/desktop/ → "[UNRESOLVED_ENTRY] index.html"). Forks fall
|
||||
# through to the stash path so their intentional edits survive.
|
||||
if not is_fork and _clean_managed_worktree(git_cmd, PROJECT_ROOT):
|
||||
auto_stash_ref = None
|
||||
else:
|
||||
auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT)
|
||||
|
||||
prompt_for_restore = (
|
||||
auto_stash_ref is not None
|
||||
@@ -10441,7 +10432,11 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
)
|
||||
commit_count = int(result.stdout.strip())
|
||||
|
||||
if commit_count == 0:
|
||||
# On the finalize re-exec the pull already happened in the original
|
||||
# pass, so origin is level with HEAD (count == 0). Don't take the
|
||||
# "Already up to date" early return — fall through and run the
|
||||
# post-pull steps (now with refreshed code).
|
||||
if commit_count == 0 and not finalize_only:
|
||||
_invalidate_update_cache()
|
||||
|
||||
# Even if origin is up to date, the fork may be behind upstream
|
||||
@@ -10468,24 +10463,30 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
print("✓ Already up to date!")
|
||||
return
|
||||
|
||||
print(f"→ Found {commit_count} new commit(s)")
|
||||
|
||||
# Snapshot critical state (state.db, config, pairing JSONs, etc.)
|
||||
# before pulling so a user can recover if something goes wrong.
|
||||
# Issue #15733 reported missing pairing data after an update; even
|
||||
# though `git pull` can't touch $HERMES_HOME, this is cheap
|
||||
# belt-and-suspenders insurance and gives the user something to
|
||||
# restore from via `/snapshot list` / `/snapshot restore <id>`.
|
||||
# The "found N commits" notice and pre-update snapshot belong to the
|
||||
# original pass only — the finalize re-exec sees count == 0 and the
|
||||
# snapshot was already taken before the pull.
|
||||
pre_update_snapshot_id = None
|
||||
try:
|
||||
from hermes_cli.backup import create_quick_snapshot
|
||||
if not finalize_only:
|
||||
print(f"→ Found {commit_count} new commit(s)")
|
||||
|
||||
pre_update_snapshot_id = create_quick_snapshot(label="pre-update", keep=1)
|
||||
if pre_update_snapshot_id:
|
||||
print(f" ✓ Pre-update snapshot: {pre_update_snapshot_id}")
|
||||
except Exception as exc:
|
||||
# Never let a snapshot failure block an update.
|
||||
logger.debug("Pre-update snapshot failed: %s", exc)
|
||||
# Snapshot critical state (state.db, config, pairing JSONs, etc.)
|
||||
# before pulling so a user can recover if something goes wrong.
|
||||
# Issue #15733 reported missing pairing data after an update; even
|
||||
# though `git pull` can't touch $HERMES_HOME, this is cheap
|
||||
# belt-and-suspenders insurance and gives the user something to
|
||||
# restore from via `/snapshot list` / `/snapshot restore <id>`.
|
||||
try:
|
||||
from hermes_cli.backup import create_quick_snapshot
|
||||
|
||||
pre_update_snapshot_id = create_quick_snapshot(
|
||||
label="pre-update", keep=1
|
||||
)
|
||||
if pre_update_snapshot_id:
|
||||
print(f" ✓ Pre-update snapshot: {pre_update_snapshot_id}")
|
||||
except Exception as exc:
|
||||
# Never let a snapshot failure block an update.
|
||||
logger.debug("Pre-update snapshot failed: %s", exc)
|
||||
|
||||
print("→ Pulling updates...")
|
||||
update_succeeded = False
|
||||
@@ -10575,15 +10576,6 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
f" ℹ️ Local changes preserved in stash (ref: {auto_stash_ref})"
|
||||
)
|
||||
print(f" Restore manually with: git stash apply")
|
||||
elif discard_local_changes:
|
||||
# Non-interactive update + user opted into discarding local
|
||||
# source edits (updates.non_interactive_local_changes:
|
||||
# discard). Throw the stash away instead of re-applying it.
|
||||
_discard_stashed_changes(
|
||||
git_cmd,
|
||||
PROJECT_ROOT,
|
||||
auto_stash_ref,
|
||||
)
|
||||
else:
|
||||
_restore_stashed_changes(
|
||||
git_cmd,
|
||||
@@ -10666,6 +10658,26 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
|
||||
_refresh_active_lazy_features()
|
||||
|
||||
# Hand off the remaining post-pull work (node deps, web/desktop build,
|
||||
# config migration, skills sync, gateway restart) to the freshly-pulled
|
||||
# code. These are the most frequently-changed and historically most
|
||||
# fragile update steps, yet they used to run from the modules this
|
||||
# process imported at startup — so a bug fixed in the pulled version
|
||||
# still crashed here, forcing users to run ``hermes update`` twice. The
|
||||
# git pull AND dependency install are done, so the new source and its
|
||||
# deps are on disk; finish in a fresh subprocess running new code and
|
||||
# forward its exit code. On the finalize pass (or under pytest /
|
||||
# opt-out) we skip the hand-off and finish in-process — see
|
||||
# _should_handoff_after_pull.
|
||||
if _should_handoff_after_pull(finalize_only):
|
||||
handoff_rc = _handoff_update_to_refreshed_code()
|
||||
if handoff_rc is not None:
|
||||
# The child ran every remaining post-pull step on new code;
|
||||
# forward its result and stop (cmd_update's finally still
|
||||
# restores stdio on the way out).
|
||||
sys.exit(handoff_rc)
|
||||
# else: spawning the child failed — fall through and finish here.
|
||||
|
||||
_update_node_dependencies()
|
||||
_build_web_ui(PROJECT_ROOT / "web")
|
||||
|
||||
|
||||
@@ -51,48 +51,12 @@ def resolve_uv() -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
class _UvResult(str):
|
||||
"""``ensure_uv()`` return value that survives an update boundary.
|
||||
def ensure_uv() -> Optional[str]:
|
||||
"""Return the managed uv path, installing it first if necessary.
|
||||
|
||||
``ensure_uv()``'s arity has flipped between a single path string and a
|
||||
``(path, fresh_bootstrap)`` tuple across releases. ``hermes update`` runs
|
||||
the call site from the *old*, already-imported ``hermes_cli.main`` against
|
||||
this *freshly pulled* module, so the two can disagree on how many values
|
||||
``ensure_uv()`` returns. An install parked on a 2-tuple release runs
|
||||
``uv_bin, fresh_bootstrap = ensure_uv()`` against the single-value module
|
||||
and crashes the first update: the returned path is a plain ``str``, which is
|
||||
itself iterable, so the 2-target unpack walks its characters and raises
|
||||
``ValueError: too many values to unpack (expected 2)`` (and on the failure
|
||||
path the ``None`` return raises ``TypeError: cannot unpack non-iterable
|
||||
NoneType``). This wrapper answers to both conventions:
|
||||
|
||||
uv_bin = ensure_uv() # behaves as the path str ("" when absent)
|
||||
uv_bin, fresh = ensure_uv() # unpacks as (path|None, fresh_bootstrap)
|
||||
|
||||
Missing uv is the empty string (falsy) instead of ``None`` so legacy
|
||||
2-target call sites can still unpack a failure without raising, while
|
||||
``if not uv_bin`` keeps working for single-value callers.
|
||||
|
||||
POSIX only. This wrapper is **never** returned on Windows — see
|
||||
``ensure_uv()`` for why the ``__iter__`` override is unsafe there.
|
||||
On failure returns ``None`` (never raises) so callers can fall
|
||||
back to pip gracefully.
|
||||
"""
|
||||
|
||||
fresh_bootstrap: bool
|
||||
|
||||
def __new__(cls, path: Optional[str], fresh: bool = False) -> "_UvResult":
|
||||
self = super().__new__(cls, path or "")
|
||||
self.fresh_bootstrap = fresh
|
||||
return self
|
||||
|
||||
def __iter__(self):
|
||||
# Tuple-unpacking hook for legacy ``uv_bin, fresh = ensure_uv()`` sites.
|
||||
# First element mirrors the historical contract: the path string, or
|
||||
# ``None`` when uv is unavailable.
|
||||
return iter(((str(self) or None), self.fresh_bootstrap))
|
||||
|
||||
|
||||
def _ensure_uv_path() -> Optional[str]:
|
||||
"""Resolve the managed uv path, installing it if necessary (plain ``str``/``None``)."""
|
||||
existing = resolve_uv()
|
||||
if existing:
|
||||
return existing
|
||||
@@ -124,37 +88,6 @@ def _ensure_uv_path() -> Optional[str]:
|
||||
return result
|
||||
|
||||
|
||||
def ensure_uv():
|
||||
"""Return the managed uv path, installing it first if necessary.
|
||||
|
||||
On **POSIX** the result is a :class:`_UvResult` (a ``str`` subclass) that is
|
||||
both usable directly as the path *and* unpackable as
|
||||
``(path, fresh_bootstrap)`` for older call sites parked on a 2-tuple
|
||||
release — see :class:`_UvResult` for the update-boundary rationale.
|
||||
|
||||
On **Windows** we deliberately return a plain ``str``/``None`` instead.
|
||||
``subprocess`` there serializes the argv via ``subprocess.list2cmdline``,
|
||||
which iterates every entry *as a string* (``for c in arg``). The dependency
|
||||
installer passes uv straight into the command list (``[uv_bin, "pip", ...]``),
|
||||
so a ``_UvResult`` — whose ``__iter__`` yields ``(path, fresh_bootstrap)``
|
||||
rather than characters — would inject the bool into the command line and
|
||||
crash the install with ``TypeError: sequence item 1: expected str instance,
|
||||
bool found``. A plain ``str`` matches the historical Windows contract and is
|
||||
subprocess-safe. (A single value cannot satisfy both 2-target unpacking and
|
||||
Windows char-iteration: both use the iterator protocol, with contradictory
|
||||
results.)
|
||||
|
||||
On failure the result is falsy — never raises — so callers can fall back to
|
||||
pip gracefully.
|
||||
"""
|
||||
result = _ensure_uv_path()
|
||||
if platform.system() == "Windows":
|
||||
# See docstring: a str subclass with an overridden __iter__ is unsafe as
|
||||
# a Windows subprocess argument. Hand back the plain path (or None).
|
||||
return result
|
||||
return _UvResult(result)
|
||||
|
||||
|
||||
def update_managed_uv() -> Optional[str]:
|
||||
"""Run ``uv self update`` on the managed uv binary.
|
||||
|
||||
|
||||
@@ -1117,62 +1117,6 @@ def switch_model(
|
||||
# Authenticated providers listing (for /model no-args display)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Process-level guard so the picker prewarm thread is spawned at most once per
|
||||
# process — mirrors run_agent's _openrouter_prewarm_done. Without a guard a
|
||||
# long-lived process (or repeated triggers) would leak one OS thread per call.
|
||||
import threading as _threading # noqa: E402
|
||||
|
||||
_picker_prewarm_done = _threading.Event()
|
||||
|
||||
|
||||
def prewarm_picker_cache_async() -> Optional["_threading.Thread"]:
|
||||
"""Warm the provider-models disk cache in a background daemon thread.
|
||||
|
||||
The no-args ``/model`` picker calls ``list_authenticated_providers()``,
|
||||
which fetches each authenticated provider's live ``/v1/models`` list on a
|
||||
cold/stale cache. Those fetches are independent HTTP round-trips but run
|
||||
serially, so the first ``/model`` open in a session (or any open after the
|
||||
1h cache TTL expires) blocks ~1-2s on the user's critical path.
|
||||
|
||||
This pre-warms that exact path off-thread during idle session time: it
|
||||
runs ``list_authenticated_providers()`` once, which populates
|
||||
``provider_models_cache.json`` for every authed provider. By the time the
|
||||
user types ``/model``, the picker hits the warm disk cache and renders in
|
||||
~100ms.
|
||||
|
||||
Fire-and-forget. Process-level Event guard ensures it runs at most once.
|
||||
Fully exception-isolated — a slow or offline provider can never affect the
|
||||
session. Returns the spawned thread (for tests) or None if already warmed.
|
||||
"""
|
||||
if _picker_prewarm_done.is_set():
|
||||
return None
|
||||
_picker_prewarm_done.set()
|
||||
|
||||
def _warm() -> None:
|
||||
try:
|
||||
from hermes_cli.inventory import load_picker_context
|
||||
|
||||
ctx = load_picker_context()
|
||||
# Calling this is what populates cached_provider_model_ids() ->
|
||||
# provider_models_cache.json for each authed provider. We discard
|
||||
# the result; the side effect (warm disk cache) is the point.
|
||||
list_authenticated_providers(
|
||||
current_provider=ctx.current_provider,
|
||||
current_base_url=ctx.current_base_url,
|
||||
current_model=ctx.current_model,
|
||||
user_providers=ctx.user_providers,
|
||||
custom_providers=ctx.custom_providers,
|
||||
max_models=50,
|
||||
)
|
||||
except Exception:
|
||||
# Best-effort warmup — never surface errors into the session.
|
||||
logger.debug("picker cache prewarm failed", exc_info=True)
|
||||
|
||||
t = _threading.Thread(target=_warm, daemon=True, name="picker-cache-prewarm")
|
||||
t.start()
|
||||
return t
|
||||
|
||||
|
||||
def list_authenticated_providers(
|
||||
current_provider: str = "",
|
||||
current_base_url: str = "",
|
||||
@@ -1790,13 +1734,6 @@ def list_authenticated_providers(
|
||||
else (f"env:{key_env}" if key_env else "")
|
||||
)
|
||||
|
||||
# Read discover_models from the entry (same semantics as
|
||||
# section 3: true by default, set false to keep the explicit
|
||||
# ``models:`` list instead of replacing it with live /models).
|
||||
discover = entry.get("discover_models", True)
|
||||
if isinstance(discover, str):
|
||||
discover = discover.lower() not in {"false", "no", "0"}
|
||||
|
||||
group_key = (api_url, credential_identity, api_mode)
|
||||
if group_key not in groups:
|
||||
# Strip per-model suffix so "Ollama — GLM 5.1" becomes
|
||||
@@ -1817,15 +1754,9 @@ def list_authenticated_providers(
|
||||
"api_url": api_url,
|
||||
"api_key": api_key,
|
||||
"models": [],
|
||||
"discover_models": discover,
|
||||
}
|
||||
else:
|
||||
if api_key and not groups[group_key].get("api_key"):
|
||||
groups[group_key]["api_key"] = api_key
|
||||
# If any entry in this group opts out of discovery,
|
||||
# honour that for the whole grouped row.
|
||||
if not discover:
|
||||
groups[group_key]["discover_models"] = False
|
||||
elif api_key and not groups[group_key].get("api_key"):
|
||||
groups[group_key]["api_key"] = api_key
|
||||
|
||||
# The singular ``model:`` field only holds the currently
|
||||
# active model. Hermes's own writer (main.py::_save_custom_provider)
|
||||
@@ -1914,16 +1845,7 @@ def list_authenticated_providers(
|
||||
# - Without an api_key AND no explicit models, fall through to
|
||||
# live discovery so bare-endpoint custom providers (local
|
||||
# llama.cpp / Ollama servers) still appear populated.
|
||||
# - When discover_models: false is set, skip live discovery and
|
||||
# keep the explicit ``models:`` list regardless of whether an
|
||||
# api_key is present. This supports endpoints that expose a
|
||||
# full aggregator catalog via /models but only serve a subset
|
||||
# (parity with section 3's user ``providers:`` behaviour).
|
||||
should_probe = (
|
||||
bool(api_url)
|
||||
and (bool(api_key) or not grp["models"])
|
||||
and grp.get("discover_models", True)
|
||||
)
|
||||
should_probe = bool(api_url) and (bool(api_key) or not grp["models"])
|
||||
if should_probe:
|
||||
try:
|
||||
from hermes_cli.models import fetch_api_models
|
||||
|
||||
+6
-35
@@ -1150,46 +1150,17 @@ _PROVIDER_ALIASES = {
|
||||
}
|
||||
|
||||
|
||||
# Cost-safe overrides for the *silent* auto-default
|
||||
# (``get_default_model_for_provider``). Most providers' curated lists lead with a
|
||||
# sensible default, but Nous Portal is a per-token *metered aggregator* whose
|
||||
# list is ordered best-/most-capable-first — entry [0] is the priciest flagship
|
||||
# (``anthropic/claude-opus-4.8``, $5/$25 per Mtok). Using that as the
|
||||
# non-interactive fallback when a profile sets ``provider: nous`` with no model
|
||||
# silently bills the most expensive model for traffic the user never opted into
|
||||
# (a missing default escalated to Opus and billed 863 requests before the user
|
||||
# noticed). Pin the silent default to a low-cost curated model instead so a
|
||||
# missing model can never escalate to the flagship.
|
||||
#
|
||||
# This is deliberately a fixed, side-effect-free default for the hot resolution
|
||||
# path. The *interactive* default (GUI onboarding / ``hermes model``) uses the
|
||||
# richer free/paid-tier-aware resolver — see ``get_recommended_default_model``
|
||||
# in hermes_cli/web_server.py and ``partition_nous_models_by_tier`` — which can
|
||||
# hit the Portal; this fallback must stay cheap and network-free.
|
||||
_PROVIDER_SILENT_DEFAULT_OVERRIDES: dict[str, str] = {
|
||||
"nous": "deepseek/deepseek-v4-flash",
|
||||
}
|
||||
|
||||
|
||||
def get_default_model_for_provider(provider: str) -> str:
|
||||
"""Return a cost-safe default model for a provider, or "" if unknown.
|
||||
"""Return the default model for a provider, or empty string if unknown.
|
||||
|
||||
Used as a NON-INTERACTIVE fallback when a provider is configured but no
|
||||
model was ever selected (e.g. ``hermes auth add openai-codex`` without
|
||||
``hermes model``, or a profile that sets ``provider`` with no ``model``).
|
||||
Uses the first entry in _PROVIDER_MODELS as the default. This is the
|
||||
model a user would be offered first in the ``hermes model`` picker.
|
||||
|
||||
For most providers this is the first entry in ``_PROVIDER_MODELS`` — the
|
||||
same model the ``hermes model`` picker offers first. For metered aggregators
|
||||
whose curated list is ordered most-capable-first, that entry is also the
|
||||
most EXPENSIVE one, so silently defaulting to it is a billing footgun. Such
|
||||
providers carry an explicit low-cost override in
|
||||
``_PROVIDER_SILENT_DEFAULT_OVERRIDES``; a missing model must never
|
||||
auto-escalate to the flagship.
|
||||
Used as a fallback when the user has configured a provider but never
|
||||
selected a model (e.g. ``hermes auth add openai-codex`` without
|
||||
``hermes model``).
|
||||
"""
|
||||
models = _PROVIDER_MODELS.get(provider, [])
|
||||
override = _PROVIDER_SILENT_DEFAULT_OVERRIDES.get(provider)
|
||||
if override and override in models:
|
||||
return override
|
||||
return models[0] if models else ""
|
||||
|
||||
|
||||
|
||||
@@ -159,33 +159,6 @@ def _has_agent_browser() -> bool:
|
||||
return bool(agent_browser_bin or local_bin.exists())
|
||||
|
||||
|
||||
def _local_browser_runnable() -> bool:
|
||||
"""Return True when the *local* browser backend would actually start.
|
||||
|
||||
The ``agent-browser`` CLI being present is necessary but not sufficient for
|
||||
local mode: agent-browser also needs a Chromium build on disk (without one
|
||||
it hangs on first use until the command timeout fires), unless the
|
||||
Lightpanda engine is selected — text-only navigation needs no Chromium.
|
||||
|
||||
This mirrors the local-mode tail of
|
||||
:func:`tools.browser_tool.check_browser_requirements`, so the setup/status
|
||||
surfaces advertise local browser readiness only when the runtime would
|
||||
actually run it. Cloud providers (Browserbase, Browser Use, Firecrawl) host
|
||||
their own Chromium and therefore gate on :func:`_has_agent_browser` alone.
|
||||
"""
|
||||
if not _has_agent_browser():
|
||||
return False
|
||||
try:
|
||||
from tools.browser_tool import _chromium_installed, _using_lightpanda_engine
|
||||
except Exception:
|
||||
# If the runtime probe can't be imported, fall back to binary presence
|
||||
# (prior behaviour) rather than crashing the setup/status surface.
|
||||
return True
|
||||
if _using_lightpanda_engine():
|
||||
return True
|
||||
return _chromium_installed()
|
||||
|
||||
|
||||
def _browser_label(current_provider: str) -> str:
|
||||
mapping = {
|
||||
"browserbase": "Browserbase",
|
||||
@@ -215,23 +188,13 @@ def _resolve_browser_feature_state(
|
||||
browser_provider: str,
|
||||
browser_provider_explicit: bool,
|
||||
browser_local_available: bool,
|
||||
browser_local_runnable: bool,
|
||||
direct_camofox: bool,
|
||||
direct_browserbase: bool,
|
||||
direct_browser_use: bool,
|
||||
direct_firecrawl: bool,
|
||||
managed_browser_available: bool,
|
||||
) -> tuple[str, bool, bool, bool]:
|
||||
"""Resolve browser availability using the same precedence as runtime.
|
||||
|
||||
``browser_local_available`` means "the agent-browser CLI is present" — the
|
||||
only local requirement for cloud providers, which host their own Chromium.
|
||||
``browser_local_runnable`` additionally requires a usable local Chromium
|
||||
build (or the Lightpanda engine), mirroring the local-mode tail of
|
||||
:func:`tools.browser_tool.check_browser_requirements`. Local mode must gate
|
||||
on the latter, or setup/status advertise a browser that fails on first use
|
||||
when Chromium is missing.
|
||||
"""
|
||||
"""Resolve browser availability using the same precedence as runtime."""
|
||||
if direct_camofox:
|
||||
return "camofox", True, bool(browser_tool_enabled), False
|
||||
|
||||
@@ -260,7 +223,7 @@ def _resolve_browser_feature_state(
|
||||
return current_provider, False, False, False
|
||||
|
||||
current_provider = "local"
|
||||
available = bool(browser_local_runnable)
|
||||
available = bool(browser_local_available)
|
||||
active = bool(browser_tool_enabled and available)
|
||||
return current_provider, available, active, False
|
||||
|
||||
@@ -280,7 +243,7 @@ def _resolve_browser_feature_state(
|
||||
active = bool(browser_tool_enabled and available)
|
||||
return "browserbase", available, active, False
|
||||
|
||||
available = bool(browser_local_runnable)
|
||||
available = bool(browser_local_available)
|
||||
active = bool(browser_tool_enabled and available)
|
||||
return "local", available, active, False
|
||||
|
||||
@@ -482,7 +445,6 @@ def get_nous_subscription_features(
|
||||
tts_active = bool(tts_tool_enabled and tts_available)
|
||||
|
||||
browser_local_available = _has_agent_browser()
|
||||
browser_local_runnable = _local_browser_runnable()
|
||||
(
|
||||
browser_current_provider,
|
||||
browser_available,
|
||||
@@ -493,7 +455,6 @@ def get_nous_subscription_features(
|
||||
browser_provider=browser_provider,
|
||||
browser_provider_explicit=browser_provider_explicit,
|
||||
browser_local_available=browser_local_available,
|
||||
browser_local_runnable=browser_local_runnable,
|
||||
direct_camofox=direct_camofox,
|
||||
direct_browserbase=direct_browserbase,
|
||||
direct_browser_use=direct_browser_use,
|
||||
|
||||
@@ -474,21 +474,6 @@ def _try_resolve_from_custom_pool(
|
||||
return None
|
||||
|
||||
|
||||
def _lift_max_output_tokens(entry: Dict[str, Any], result: Dict[str, Any]) -> None:
|
||||
"""Propagate a per-provider output cap onto the resolved runtime dict.
|
||||
|
||||
Accepts ``max_output_tokens`` or ``max_tokens`` on a ``custom_providers``
|
||||
entry so a provider block can pin its own output limit. Gateway and CLI
|
||||
map this onto ``AIAgent.max_tokens`` only when the top-level
|
||||
``model.max_tokens`` isn't set, so the documented global key still wins.
|
||||
"""
|
||||
for _k in ("max_output_tokens", "max_tokens"):
|
||||
_v = entry.get(_k)
|
||||
if isinstance(_v, int) and _v > 0:
|
||||
result["max_output_tokens"] = _v
|
||||
return
|
||||
|
||||
|
||||
def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, Any]]:
|
||||
requested_norm = _normalize_custom_provider_name(requested_provider or "")
|
||||
if not requested_norm or requested_norm == "custom":
|
||||
@@ -556,7 +541,6 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An
|
||||
api_mode = _parse_api_mode(entry.get("api_mode") or entry.get("transport"))
|
||||
if api_mode:
|
||||
result["api_mode"] = api_mode
|
||||
_lift_max_output_tokens(entry, result)
|
||||
return result
|
||||
# Also check the 'name' field if present
|
||||
display_name = entry.get("name", "")
|
||||
@@ -578,7 +562,6 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An
|
||||
api_mode = _parse_api_mode(entry.get("api_mode") or entry.get("transport"))
|
||||
if api_mode:
|
||||
result["api_mode"] = api_mode
|
||||
_lift_max_output_tokens(entry, result)
|
||||
return result
|
||||
|
||||
# Fall back to custom_providers: list (legacy format)
|
||||
@@ -628,7 +611,6 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An
|
||||
model_name = str(entry.get("model", "") or "").strip()
|
||||
if model_name:
|
||||
result["model"] = model_name
|
||||
_lift_max_output_tokens(entry, result)
|
||||
return result
|
||||
|
||||
return None
|
||||
@@ -717,8 +699,6 @@ def _resolve_named_custom_runtime(
|
||||
model_name = custom_provider.get("model")
|
||||
if model_name:
|
||||
pool_result["model"] = model_name
|
||||
if isinstance(custom_provider.get("max_output_tokens"), int):
|
||||
pool_result["max_output_tokens"] = custom_provider["max_output_tokens"]
|
||||
request_overrides = _custom_provider_request_overrides(custom_provider)
|
||||
if request_overrides:
|
||||
pool_result["request_overrides"] = {
|
||||
@@ -756,8 +736,6 @@ def _resolve_named_custom_runtime(
|
||||
# provider name differs from the actual model string the API expects.
|
||||
if custom_provider.get("model"):
|
||||
result["model"] = custom_provider["model"]
|
||||
if isinstance(custom_provider.get("max_output_tokens"), int):
|
||||
result["max_output_tokens"] = custom_provider["max_output_tokens"]
|
||||
request_overrides = _custom_provider_request_overrides(custom_provider)
|
||||
if request_overrides:
|
||||
result["request_overrides"] = request_overrides
|
||||
|
||||
+17
-104
@@ -415,9 +415,7 @@ def _print_setup_summary(config: dict, hermes_home):
|
||||
elif browser_provider == "Camofox":
|
||||
missing_browser_hint = "CAMOFOX_URL"
|
||||
elif browser_provider == "Local browser":
|
||||
missing_browser_hint = (
|
||||
"npm install -g agent-browser && agent-browser install --with-deps"
|
||||
)
|
||||
missing_browser_hint = "npm install -g agent-browser"
|
||||
tool_status.append(
|
||||
("Browser Automation", False, missing_browser_hint)
|
||||
)
|
||||
@@ -1639,52 +1637,6 @@ def setup_agent_settings(config: dict):
|
||||
# =============================================================================
|
||||
|
||||
|
||||
_TELEGRAM_BOT_TOKEN_RE = re.compile(r"^\d+:[A-Za-z0-9_-]{30,}$")
|
||||
|
||||
|
||||
def _is_valid_telegram_bot_token(token: str) -> bool:
|
||||
return bool(_TELEGRAM_BOT_TOKEN_RE.match(token))
|
||||
|
||||
|
||||
def _setup_telegram_auto_result():
|
||||
"""Attempt automatic Telegram bot creation via managed QR onboarding."""
|
||||
try:
|
||||
from hermes_cli.telegram_managed_bot import auto_setup_telegram_bot_result
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
profile_name: str | None = None
|
||||
try:
|
||||
hermes_home = str(get_hermes_home())
|
||||
if "/profiles/" in hermes_home:
|
||||
profile_name = hermes_home.rstrip("/").rsplit("/", 1)[-1]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return auto_setup_telegram_bot_result(profile_name=profile_name)
|
||||
|
||||
|
||||
def _setup_telegram_auto() -> str | None:
|
||||
"""Attempt automatic Telegram bot creation and return only the token."""
|
||||
result = _setup_telegram_auto_result()
|
||||
return result.token if result else None
|
||||
|
||||
|
||||
def _prompt_telegram_bot_token() -> str | None:
|
||||
print_info("Create a bot via @BotFather on Telegram")
|
||||
while True:
|
||||
token = prompt("Telegram bot token", password=True)
|
||||
if not token:
|
||||
return None
|
||||
if not _is_valid_telegram_bot_token(token):
|
||||
print_error(
|
||||
"Invalid token format. Expected: <numeric_id>:<alphanumeric_hash> "
|
||||
"(e.g., 123456789:ABCdefGHI-jklMNOpqrSTUvwxYZ)"
|
||||
)
|
||||
continue
|
||||
return token
|
||||
|
||||
|
||||
def _setup_telegram():
|
||||
"""Configure Telegram bot credentials and allowlist."""
|
||||
print_header("Telegram")
|
||||
@@ -1703,40 +1655,20 @@ def _setup_telegram():
|
||||
print_success("Telegram allowlist configured")
|
||||
return
|
||||
|
||||
print_info("How would you like to create your Telegram bot?")
|
||||
print()
|
||||
print_info(" [1] Automatic (recommended)")
|
||||
print_info(" Scan a QR code → confirm in Telegram → done.")
|
||||
print_info(" No token copy-paste needed.")
|
||||
print()
|
||||
print_info(" [2] Manual")
|
||||
print_info(" Create a bot via @BotFather yourself and paste the token.")
|
||||
print()
|
||||
print_info("Create a bot via @BotFather on Telegram")
|
||||
import re
|
||||
|
||||
choice = prompt("Choice [1/2]", default="1")
|
||||
token = None
|
||||
setup_result = None
|
||||
|
||||
if choice.strip() == "1":
|
||||
setup_result = _setup_telegram_auto_result()
|
||||
if setup_result:
|
||||
token = setup_result.token
|
||||
if not _is_valid_telegram_bot_token(token):
|
||||
print_error("Automatic setup returned an invalid Telegram bot token.")
|
||||
token = None
|
||||
setup_result = None
|
||||
else:
|
||||
token = None
|
||||
while True:
|
||||
token = prompt("Telegram bot token", password=True)
|
||||
if not token:
|
||||
print()
|
||||
print_info("Falling back to manual setup...")
|
||||
print()
|
||||
|
||||
if not token:
|
||||
token = _prompt_telegram_bot_token()
|
||||
if not token:
|
||||
return
|
||||
|
||||
return
|
||||
if not re.match(r"^\d+:[A-Za-z0-9_-]{30,}$", token):
|
||||
print_error(
|
||||
"Invalid token format. Expected: <numeric_id>:<alphanumeric_hash> "
|
||||
"(e.g., 123456789:ABCdefGHI-jklMNOpqrSTUvwxYZ)"
|
||||
)
|
||||
continue
|
||||
break
|
||||
save_env_value("TELEGRAM_BOT_TOKEN", token)
|
||||
print_success("Telegram token saved")
|
||||
|
||||
@@ -1746,30 +1678,11 @@ def _setup_telegram():
|
||||
print_info(" 1. Message @userinfobot on Telegram")
|
||||
print_info(" 2. It will reply with your numeric ID (e.g., 123456789)")
|
||||
print()
|
||||
|
||||
detected_user_id = getattr(setup_result, "owner_user_id", None)
|
||||
if detected_user_id:
|
||||
detected_id = str(detected_user_id)
|
||||
print_success(f"Detected your Telegram user ID: {detected_id}")
|
||||
if prompt_yes_no("Allow this Telegram account to use the bot?", True):
|
||||
extra = prompt("Additional allowed user IDs (comma-separated, optional)")
|
||||
ids = [detected_id]
|
||||
for uid in extra.replace(" ", "").split(","):
|
||||
if uid and uid not in ids:
|
||||
ids.append(uid)
|
||||
allowed_users = ",".join(ids)
|
||||
else:
|
||||
allowed_users = prompt(
|
||||
"Allowed user IDs (comma-separated, leave empty for open access)"
|
||||
)
|
||||
else:
|
||||
allowed_users = prompt(
|
||||
"Allowed user IDs (comma-separated, leave empty for open access)"
|
||||
)
|
||||
|
||||
allowed_users = prompt(
|
||||
"Allowed user IDs (comma-separated, leave empty for open access)"
|
||||
)
|
||||
if allowed_users:
|
||||
allowed_users = allowed_users.replace(" ", "")
|
||||
save_env_value("TELEGRAM_ALLOWED_USERS", allowed_users)
|
||||
save_env_value("TELEGRAM_ALLOWED_USERS", allowed_users.replace(" ", ""))
|
||||
print_success("Telegram allowlist configured - only listed users can use the bot")
|
||||
else:
|
||||
print_info("⚠️ No allowlist set - anyone who finds your bot can use it!")
|
||||
|
||||
@@ -1,358 +0,0 @@
|
||||
"""Telegram Managed Bot onboarding client.
|
||||
|
||||
Uses Telegram's Managed Bots feature to create a user-owned child bot without
|
||||
manual BotFather token copy-paste. Hermes talks only to the Nous onboarding
|
||||
service; the raw Telegram token is saved locally after one-time retrieval.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import sys
|
||||
import time
|
||||
import urllib.parse
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
# Default pairing API base URL (Nous-hosted Cloudflare Worker).
|
||||
# Override for PoC/staging with TELEGRAM_ONBOARDING_URL.
|
||||
DEFAULT_API_URL = "https://setup.hermes-agent.nousresearch.com"
|
||||
TELEGRAM_ONBOARDING_URL_ENV = "TELEGRAM_ONBOARDING_URL"
|
||||
|
||||
# The Nous-hosted manager bot username (without @). The backend returns the
|
||||
# actual deep link, so this is only used by local helpers/tests.
|
||||
DEFAULT_MANAGER_BOT = "HermesSetupBot"
|
||||
|
||||
DEFAULT_BOT_NAME = "Hermes Agent"
|
||||
DEFAULT_POLL_TIMEOUT = 180
|
||||
POLL_INTERVAL = 2
|
||||
|
||||
_USERNAME_SLUG_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567"
|
||||
_TELEGRAM_BOT_TOKEN_RE = re.compile(r"^\d+:[A-Za-z0-9_-]{30,}$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TelegramPairing:
|
||||
"""Pairing record returned by the Telegram onboarding service."""
|
||||
|
||||
pairing_id: str
|
||||
poll_token: str
|
||||
suggested_username: str
|
||||
deep_link: str
|
||||
qr_payload: str
|
||||
expires_at: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TelegramBotSetupResult:
|
||||
"""Successful Telegram onboarding result returned by the setup service."""
|
||||
|
||||
token: str
|
||||
bot_username: str | None = None
|
||||
owner_user_id: int | None = None
|
||||
|
||||
|
||||
def _api_url(api_url: str | None = None) -> str:
|
||||
"""Resolve the onboarding API URL, honoring the PoC env override."""
|
||||
return (
|
||||
api_url or os.environ.get(TELEGRAM_ONBOARDING_URL_ENV) or DEFAULT_API_URL
|
||||
).rstrip("/")
|
||||
|
||||
|
||||
def is_valid_telegram_bot_token(token: object) -> bool:
|
||||
"""Return True when *token* has Telegram's bot-token shape."""
|
||||
return isinstance(token, str) and bool(_TELEGRAM_BOT_TOKEN_RE.match(token))
|
||||
|
||||
|
||||
def _parse_owner_user_id(value: object) -> int | None:
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
if isinstance(value, int):
|
||||
return value if value > 0 else None
|
||||
if isinstance(value, str) and value.isdecimal():
|
||||
parsed = int(value)
|
||||
return parsed if parsed > 0 else None
|
||||
return None
|
||||
|
||||
|
||||
def render_qr_terminal(url: str) -> str:
|
||||
"""Render a URL as a QR code string suitable for terminal output."""
|
||||
try:
|
||||
import io
|
||||
|
||||
import qrcode # type: ignore[import-untyped]
|
||||
|
||||
qr = qrcode.QRCode(
|
||||
version=None,
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_L,
|
||||
box_size=1,
|
||||
border=1,
|
||||
)
|
||||
qr.add_data(url)
|
||||
qr.make(fit=True)
|
||||
|
||||
buf = io.StringIO()
|
||||
qr.print_ascii(out=buf, invert=True)
|
||||
return buf.getvalue()
|
||||
except ImportError:
|
||||
return ""
|
||||
|
||||
|
||||
def print_qr_code(url: str, *, include_link: bool = True) -> None:
|
||||
"""Print a QR code to stdout, with URL fallback if qrcode is missing."""
|
||||
qr_text = render_qr_terminal(url)
|
||||
if qr_text:
|
||||
print(qr_text)
|
||||
else:
|
||||
print(" (Install 'qrcode' for a scannable QR code: pip install qrcode)")
|
||||
if include_link:
|
||||
print(f" Link: {url}")
|
||||
|
||||
|
||||
def generate_username_slug(length: int = 16) -> str:
|
||||
"""Generate a base32-ish slug for Telegram username correlation.
|
||||
|
||||
Sixteen characters from a 32-symbol alphabet gives 80 bits of entropy while
|
||||
keeping ``hermes_<slug>_bot`` under Telegram's 32-character username limit.
|
||||
"""
|
||||
return "".join(secrets.choice(_USERNAME_SLUG_ALPHABET) for _ in range(length))
|
||||
|
||||
|
||||
def generate_bot_username(profile_name: Optional[str] = None) -> str:
|
||||
"""Generate a secure suggested bot username like ``hermes_<slug>_bot``.
|
||||
|
||||
``profile_name`` is accepted for backward compatibility with the original
|
||||
PoC, but is intentionally not embedded in the username. The username has to
|
||||
carry enough entropy for backend correlation.
|
||||
"""
|
||||
_ = profile_name
|
||||
return f"hermes_{generate_username_slug()}_bot"
|
||||
|
||||
|
||||
def generate_deep_link(
|
||||
manager_bot: str = DEFAULT_MANAGER_BOT,
|
||||
suggested_username: Optional[str] = None,
|
||||
suggested_name: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Build a ``t.me/newbot`` deep link for managed bot creation."""
|
||||
manager = manager_bot.lstrip("@")
|
||||
username = suggested_username or generate_bot_username()
|
||||
base_url = (
|
||||
"https://t.me/newbot/"
|
||||
f"{urllib.parse.quote(manager)}/"
|
||||
f"{urllib.parse.quote(username)}"
|
||||
)
|
||||
|
||||
if suggested_name:
|
||||
params = urllib.parse.urlencode({"name": suggested_name})
|
||||
return f"{base_url}?{params}"
|
||||
return base_url
|
||||
|
||||
|
||||
def generate_pairing_nonce() -> str:
|
||||
"""Generate a legacy-compatible random nonce string.
|
||||
|
||||
The new protocol uses service-created ``pairing_id`` + bearer
|
||||
``poll_token`` instead of a path nonce, but this helper is harmless and
|
||||
still useful for callers/tests that need a generic random id.
|
||||
"""
|
||||
return secrets.token_hex(16)
|
||||
|
||||
|
||||
def create_pairing(
|
||||
api_url: str | None = None,
|
||||
bot_name: str = DEFAULT_BOT_NAME,
|
||||
timeout: float = 10.0,
|
||||
) -> TelegramPairing | None:
|
||||
"""Create a Telegram onboarding pairing.
|
||||
|
||||
``POST /v1/telegram/pairings`` returns the deep link, QR payload, public
|
||||
pairing id, and secret poll token. The token is only used as a bearer
|
||||
credential while polling.
|
||||
"""
|
||||
try:
|
||||
resp = httpx.post(
|
||||
f"{_api_url(api_url)}/v1/telegram/pairings",
|
||||
json={"bot_name": bot_name},
|
||||
timeout=timeout,
|
||||
)
|
||||
if resp.status_code not in (200, 201):
|
||||
return None
|
||||
data = resp.json()
|
||||
except (httpx.HTTPError, ValueError):
|
||||
return None
|
||||
|
||||
required = ("pairing_id", "poll_token", "suggested_username", "deep_link")
|
||||
if not all(isinstance(data.get(key), str) and data.get(key) for key in required):
|
||||
return None
|
||||
|
||||
qr_payload = data.get("qr_payload") or data["deep_link"]
|
||||
if not isinstance(qr_payload, str):
|
||||
return None
|
||||
|
||||
expires_at = data.get("expires_at")
|
||||
return TelegramPairing(
|
||||
pairing_id=data["pairing_id"],
|
||||
poll_token=data["poll_token"],
|
||||
suggested_username=data["suggested_username"],
|
||||
deep_link=data["deep_link"],
|
||||
qr_payload=qr_payload,
|
||||
expires_at=expires_at if isinstance(expires_at, str) else None,
|
||||
)
|
||||
|
||||
|
||||
def poll_pairing_result_once(
|
||||
api_url: str | None,
|
||||
pairing: TelegramPairing,
|
||||
timeout: float = 10.0,
|
||||
) -> TelegramBotSetupResult | None:
|
||||
"""Poll the onboarding service once. Returns setup metadata when ready."""
|
||||
resp = httpx.get(
|
||||
f"{_api_url(api_url)}/v1/telegram/pairings/{pairing.pairing_id}",
|
||||
headers={"Authorization": f"Bearer {pairing.poll_token}"},
|
||||
timeout=timeout,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
|
||||
data = resp.json()
|
||||
if data.get("status") != "ready":
|
||||
return None
|
||||
token = data.get("token")
|
||||
if not is_valid_telegram_bot_token(token):
|
||||
return None
|
||||
|
||||
bot_username = data.get("bot_username")
|
||||
return TelegramBotSetupResult(
|
||||
token=token,
|
||||
bot_username=bot_username
|
||||
if isinstance(bot_username, str) and bot_username
|
||||
else None,
|
||||
owner_user_id=_parse_owner_user_id(data.get("owner_user_id")),
|
||||
)
|
||||
|
||||
|
||||
def poll_pairing_once(
|
||||
api_url: str | None,
|
||||
pairing: TelegramPairing,
|
||||
timeout: float = 10.0,
|
||||
) -> str | None:
|
||||
"""Poll the onboarding service once. Returns the token when ready."""
|
||||
result = poll_pairing_result_once(api_url, pairing, timeout=timeout)
|
||||
return result.token if result else None
|
||||
|
||||
|
||||
def poll_for_setup_result(
|
||||
api_url: str | None,
|
||||
pairing: TelegramPairing,
|
||||
timeout: float = DEFAULT_POLL_TIMEOUT,
|
||||
interval: float = POLL_INTERVAL,
|
||||
) -> Optional[TelegramBotSetupResult]:
|
||||
"""Poll the pairing API until setup metadata is available or timeout."""
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
result = poll_pairing_result_once(api_url, pairing)
|
||||
if result:
|
||||
return result
|
||||
except (httpx.HTTPError, ValueError):
|
||||
pass
|
||||
time.sleep(interval)
|
||||
return None
|
||||
|
||||
|
||||
def poll_for_token(
|
||||
api_url: str | None,
|
||||
pairing: TelegramPairing,
|
||||
timeout: float = DEFAULT_POLL_TIMEOUT,
|
||||
interval: float = POLL_INTERVAL,
|
||||
) -> Optional[str]:
|
||||
"""Poll the pairing API until the bot token is available or timeout."""
|
||||
result = poll_for_setup_result(api_url, pairing, timeout=timeout, interval=interval)
|
||||
return result.token if result else None
|
||||
|
||||
|
||||
def auto_setup_telegram_bot_result(
|
||||
api_url: str | None = None,
|
||||
manager_bot: str = DEFAULT_MANAGER_BOT,
|
||||
profile_name: Optional[str] = None,
|
||||
poll_timeout: float = DEFAULT_POLL_TIMEOUT,
|
||||
) -> Optional[TelegramBotSetupResult]:
|
||||
"""Run the full automatic Telegram bot creation flow."""
|
||||
_ = manager_bot, profile_name
|
||||
resolved_api_url = _api_url(api_url)
|
||||
print()
|
||||
print(f" Contacting Hermes Telegram onboarding service: {resolved_api_url}")
|
||||
sys.stdout.flush()
|
||||
pairing = create_pairing(resolved_api_url)
|
||||
if not pairing:
|
||||
print(" ✗ Could not reach the Hermes Telegram onboarding service.")
|
||||
print(" Try the manual setup instead, or check your network.")
|
||||
return None
|
||||
|
||||
print(" ✓ Pairing created")
|
||||
print(" Rendering QR code...")
|
||||
sys.stdout.flush()
|
||||
print()
|
||||
print(" Scan this QR code with your phone, or open the link below:")
|
||||
print()
|
||||
print_qr_code(pairing.qr_payload, include_link=False)
|
||||
print()
|
||||
print(f" Link: {pairing.deep_link}")
|
||||
print()
|
||||
print(" When Telegram opens, tap 'Create Bot' to confirm.")
|
||||
print(" (You can edit the bot display name before confirming)")
|
||||
print()
|
||||
|
||||
spinner_chars = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
||||
start = time.monotonic()
|
||||
deadline = start + poll_timeout
|
||||
idx = 0
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
char = spinner_chars[idx % len(spinner_chars)]
|
||||
elapsed = int(time.monotonic() - start)
|
||||
remaining = max(0, int(poll_timeout - elapsed))
|
||||
sys.stdout.write(
|
||||
f"\r {char} Waiting for bot creation... ({remaining}s remaining) "
|
||||
)
|
||||
sys.stdout.flush()
|
||||
idx += 1
|
||||
|
||||
try:
|
||||
result = poll_pairing_result_once(resolved_api_url, pairing)
|
||||
if result:
|
||||
sys.stdout.write(
|
||||
"\r ✓ Bot created successfully! \n"
|
||||
)
|
||||
sys.stdout.flush()
|
||||
return result
|
||||
except (httpx.HTTPError, ValueError):
|
||||
pass
|
||||
time.sleep(POLL_INTERVAL)
|
||||
|
||||
sys.stdout.write("\r ✗ Timed out waiting for bot creation. \n")
|
||||
sys.stdout.flush()
|
||||
print(" The bot may still be created — check Telegram.")
|
||||
print(" You can paste the token manually below, or re-run setup.")
|
||||
return None
|
||||
|
||||
|
||||
def auto_setup_telegram_bot(
|
||||
api_url: str | None = None,
|
||||
manager_bot: str = DEFAULT_MANAGER_BOT,
|
||||
profile_name: Optional[str] = None,
|
||||
poll_timeout: float = DEFAULT_POLL_TIMEOUT,
|
||||
) -> Optional[str]:
|
||||
"""Run automatic Telegram bot creation and return only the bot token."""
|
||||
result = auto_setup_telegram_bot_result(
|
||||
api_url=api_url,
|
||||
manager_bot=manager_bot,
|
||||
profile_name=profile_name,
|
||||
poll_timeout=poll_timeout,
|
||||
)
|
||||
return result.token if result else None
|
||||
@@ -439,16 +439,6 @@ _SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = {
|
||||
"description": "Reasoning effort for delegated subagents",
|
||||
"options": ["", "low", "medium", "high"],
|
||||
},
|
||||
"updates.non_interactive_local_changes": {
|
||||
"type": "select",
|
||||
"description": (
|
||||
"When the chat app / gateway updates Hermes (no terminal prompt), "
|
||||
"what to do with uncommitted local source edits. 'stash' keeps them "
|
||||
"and re-applies them after the update; 'discard' throws them away. "
|
||||
"Terminal updates always ask, regardless of this setting."
|
||||
),
|
||||
"options": ["stash", "discard"],
|
||||
},
|
||||
}
|
||||
|
||||
# Categories with fewer fields get merged into "general" to avoid tab sprawl.
|
||||
@@ -465,7 +455,6 @@ _CATEGORY_MERGE: Dict[str, str] = {
|
||||
"code_execution": "agent",
|
||||
"prompt_caching": "agent",
|
||||
"goals": "agent",
|
||||
"updates": "general",
|
||||
# Only `telegram.reactions` currently lives under telegram — fold it in
|
||||
# with the other messaging-platform config (discord) so it isn't an
|
||||
# orphan tab of one field.
|
||||
@@ -5257,11 +5246,9 @@ async def get_session_messages(session_id: str, profile: Optional[str] = None):
|
||||
|
||||
|
||||
@app.delete("/api/sessions/{session_id}")
|
||||
async def delete_session_endpoint(session_id: str, profile: Optional[str] = None):
|
||||
# ``profile`` deletes a session belonging to another (local) profile by
|
||||
# opening its state.db directly. Remote profiles never reach here — the
|
||||
# desktop routes their DELETE to the remote backend. Omit for current/default.
|
||||
db = _open_session_db_for_profile(profile)
|
||||
async def delete_session_endpoint(session_id: str):
|
||||
from hermes_state import SessionDB
|
||||
db = SessionDB()
|
||||
try:
|
||||
if not db.delete_session(session_id):
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
@@ -343,8 +343,6 @@ gateway:
|
||||
label_off: "Af (slegs teks)"
|
||||
label_voice_only: "Aan (stemantwoord op stemboodskappe)"
|
||||
label_all: "TTS (stemantwoord op alle boodskappe)"
|
||||
help: "{toggle}\n\n**Hoe /voice werk**\n• `/voice on` — stemantwoord wanneer jy 'n stemboodskap stuur\n• `/voice tts` — stemantwoord op *elke* boodskap\n• `/voice off` — terug na slegs-teks antwoorde\n• `/voice status` — wys die huidige modus\n• `/voice` (geen argument) — wissel vinnig tussen aan en af{channels}"
|
||||
help_channels: "\n\n**Lewendige stemkanale (Discord)**\n• Sluit eers by 'n stemkanaal aan, dan `/voice channel` — ek sluit aan, luister en praat my antwoorde\n• `/voice leave` — ontkoppel van die stemkanaal"
|
||||
|
||||
yolo:
|
||||
disabled: "⚠️ YOLO-modus **AF** vir hierdie sessie — gevaarlike opdragte sal goedkeuring vereis."
|
||||
|
||||
@@ -343,8 +343,6 @@ gateway:
|
||||
label_off: "Aus (nur Text)"
|
||||
label_voice_only: "An (Sprachantwort auf Sprachnachrichten)"
|
||||
label_all: "TTS (Sprachantwort auf alle Nachrichten)"
|
||||
help: "{toggle}\n\n**So funktioniert /voice**\n• `/voice on` — Sprachantwort, wenn du eine Sprachnachricht sendest\n• `/voice tts` — Sprachantwort auf *jede* Nachricht\n• `/voice off` — zurück zu reinen Textantworten\n• `/voice status` — aktuellen Modus anzeigen\n• `/voice` (ohne Argument) — schnelles Umschalten zwischen an und aus{channels}"
|
||||
help_channels: "\n\n**Live-Sprachkanäle (Discord)**\n• Tritt zuerst einem Sprachkanal bei, dann `/voice channel` — ich trete bei, höre zu und spreche meine Antworten\n• `/voice leave` — vom Sprachkanal trennen"
|
||||
|
||||
yolo:
|
||||
disabled: "⚠️ YOLO-Modus für diese Sitzung **AUS** — gefährliche Befehle benötigen eine Genehmigung."
|
||||
|
||||
@@ -358,8 +358,6 @@ gateway:
|
||||
label_off: "Off (text only)"
|
||||
label_voice_only: "On (voice reply to voice messages)"
|
||||
label_all: "TTS (voice reply to all messages)"
|
||||
help: "{toggle}\n\n**How /voice works**\n• `/voice on` — voice reply when you send a voice message\n• `/voice tts` — voice reply to *every* message\n• `/voice off` — back to text-only replies\n• `/voice status` — show the current mode\n• `/voice` (no argument) — quick toggle between on and off{channels}"
|
||||
help_channels: "\n\n**Live voice channels (Discord)**\n• Join a voice channel first, then `/voice channel` — I'll join, listen, and speak my replies\n• `/voice leave` — disconnect from the voice channel"
|
||||
|
||||
yolo:
|
||||
disabled: "⚠️ YOLO mode **OFF** for this session — dangerous commands will require approval."
|
||||
|
||||
@@ -343,8 +343,6 @@ gateway:
|
||||
label_off: "Desactivado (solo texto)"
|
||||
label_voice_only: "Activado (responder con voz a mensajes de voz)"
|
||||
label_all: "TTS (responder con voz a todos los mensajes)"
|
||||
help: "{toggle}\n\n**Cómo funciona /voice**\n• `/voice on` — respuesta de voz cuando envías un mensaje de voz\n• `/voice tts` — respuesta de voz a *cada* mensaje\n• `/voice off` — volver a respuestas solo de texto\n• `/voice status` — mostrar el modo actual\n• `/voice` (sin argumento) — alternar rápido entre activado y desactivado{channels}"
|
||||
help_channels: "\n\n**Canales de voz en vivo (Discord)**\n• Únete primero a un canal de voz, luego `/voice channel` — me uno, escucho y digo mis respuestas\n• `/voice leave` — desconectar del canal de voz"
|
||||
|
||||
yolo:
|
||||
disabled: "⚠️ Modo YOLO **DESACTIVADO** en esta sesión — los comandos peligrosos requerirán aprobación."
|
||||
|
||||
@@ -343,8 +343,6 @@ gateway:
|
||||
label_off: "Désactivé (texte seulement)"
|
||||
label_voice_only: "Activé (réponse vocale aux messages vocaux)"
|
||||
label_all: "TTS (réponse vocale à tous les messages)"
|
||||
help: "{toggle}\n\n**Comment fonctionne /voice**\n• `/voice on` — réponse vocale quand vous envoyez un message vocal\n• `/voice tts` — réponse vocale à *chaque* message\n• `/voice off` — retour aux réponses texte uniquement\n• `/voice status` — afficher le mode actuel\n• `/voice` (sans argument) — bascule rapide entre activé et désactivé{channels}"
|
||||
help_channels: "\n\n**Salons vocaux en direct (Discord)**\n• Rejoignez d'abord un salon vocal, puis `/voice channel` — je rejoins, j'écoute et je parle mes réponses\n• `/voice leave` — se déconnecter du salon vocal"
|
||||
|
||||
yolo:
|
||||
disabled: "⚠️ Mode YOLO **DÉSACTIVÉ** pour cette session — les commandes dangereuses nécessiteront une approbation."
|
||||
|
||||
@@ -347,8 +347,6 @@ gateway:
|
||||
label_off: "As (téacs amháin)"
|
||||
label_voice_only: "Ar (freagra gutha do theachtaireachtaí gutha)"
|
||||
label_all: "TTS (freagra gutha do gach teachtaireacht)"
|
||||
help: "{toggle}\n\n**Conas a oibríonn /voice**\n• `/voice on` — freagra gutha nuair a sheolann tú teachtaireacht gutha\n• `/voice tts` — freagra gutha do *gach* teachtaireacht\n• `/voice off` — ar ais go freagraí téacs amháin\n• `/voice status` — taispeáin an mód reatha\n• `/voice` (gan argóint) — scoránaigh go tapa idir air agus as{channels}"
|
||||
help_channels: "\n\n**Cainéil gutha bheo (Discord)**\n• Téigh isteach i gcainéal gutha ar dtús, ansin `/voice channel` — téim isteach, éistim agus labhraím mo fhreagraí\n• `/voice leave` — dícheangail ón gcainéal gutha"
|
||||
|
||||
yolo:
|
||||
disabled: "⚠️ Mód YOLO **AS** don seisiún seo — beidh cead de dhíth d'orduithe contúirteacha."
|
||||
|
||||
@@ -343,8 +343,6 @@ gateway:
|
||||
label_off: "Ki (csak szöveg)"
|
||||
label_voice_only: "Be (hangválasz hangüzenetekre)"
|
||||
label_all: "TTS (hangválasz minden üzenetre)"
|
||||
help: "{toggle}\n\n**Hogyan működik a /voice**\n• `/voice on` — hangválasz, amikor hangüzenetet küldesz\n• `/voice tts` — hangválasz *minden* üzenetre\n• `/voice off` — vissza a csak szöveges válaszokhoz\n• `/voice status` — az aktuális mód megjelenítése\n• `/voice` (argumentum nélkül) — gyors váltás be és ki között{channels}"
|
||||
help_channels: "\n\n**Élő hangcsatornák (Discord)**\n• Először lépj be egy hangcsatornába, majd `/voice channel` — csatlakozom, hallgatok és hangosan válaszolok\n• `/voice leave` — lecsatlakozás a hangcsatornáról"
|
||||
|
||||
yolo:
|
||||
disabled: "⚠️ YOLO mód **KI** ebben a munkamenetben — a veszélyes parancsok jóváhagyást igényelnek."
|
||||
|
||||
@@ -343,8 +343,6 @@ gateway:
|
||||
label_off: "Off (solo testo)"
|
||||
label_voice_only: "On (risposta vocale ai messaggi vocali)"
|
||||
label_all: "TTS (risposta vocale a tutti i messaggi)"
|
||||
help: "{toggle}\n\n**Come funziona /voice**\n• `/voice on` — risposta vocale quando invii un messaggio vocale\n• `/voice tts` — risposta vocale a *ogni* messaggio\n• `/voice off` — torna alle risposte solo testo\n• `/voice status` — mostra la modalità attuale\n• `/voice` (senza argomento) — alterna rapidamente tra attivo e disattivo{channels}"
|
||||
help_channels: "\n\n**Canali vocali dal vivo (Discord)**\n• Entra prima in un canale vocale, poi `/voice channel` — mi unisco, ascolto e parlo le mie risposte\n• `/voice leave` — disconnetti dal canale vocale"
|
||||
|
||||
yolo:
|
||||
disabled: "⚠️ Modalità YOLO **OFF** per questa sessione — i comandi pericolosi richiederanno approvazione."
|
||||
|
||||
@@ -343,8 +343,6 @@ gateway:
|
||||
label_off: "オフ (テキストのみ)"
|
||||
label_voice_only: "オン (音声メッセージにのみ音声で返信)"
|
||||
label_all: "TTS (すべてのメッセージに音声で返信)"
|
||||
help: "{toggle}\n\n**/voice の使い方**\n• `/voice on` — 音声メッセージを送ると音声で返信\n• `/voice tts` — *すべての*メッセージに音声で返信\n• `/voice off` — テキストのみの返信に戻す\n• `/voice status` — 現在のモードを表示\n• `/voice`(引数なし)— オンとオフをすばやく切り替え{channels}"
|
||||
help_channels: "\n\n**ライブ音声チャンネル (Discord)**\n• 先に音声チャンネルに参加してから `/voice channel` — 参加して聞き取り、音声で返信します\n• `/voice leave` — 音声チャンネルから切断"
|
||||
|
||||
yolo:
|
||||
disabled: "⚠️ このセッションの YOLO モードは **OFF** — 危険なコマンドには承認が必要です。"
|
||||
|
||||
@@ -343,8 +343,6 @@ gateway:
|
||||
label_off: "꺼짐 (텍스트 전용)"
|
||||
label_voice_only: "켜짐 (음성 메시지에 음성으로 응답)"
|
||||
label_all: "TTS (모든 메시지에 음성으로 응답)"
|
||||
help: "{toggle}\n\n**/voice 사용법**\n• `/voice on` — 음성 메시지를 보내면 음성으로 답변\n• `/voice tts` — *모든* 메시지에 음성으로 답변\n• `/voice off` — 텍스트 전용 답변으로 복귀\n• `/voice status` — 현재 모드 표시\n• `/voice` (인자 없음) — 켜기와 끄기를 빠르게 전환{channels}"
|
||||
help_channels: "\n\n**라이브 음성 채널 (Discord)**\n• 먼저 음성 채널에 들어간 다음 `/voice channel` — 제가 참여해 듣고 음성으로 답변합니다\n• `/voice leave` — 음성 채널에서 연결 해제"
|
||||
|
||||
yolo:
|
||||
disabled: "⚠️ 이 세션에서 YOLO 모드 **꺼짐** — 위험한 명령은 승인이 필요합니다."
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user