mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-05 01:27:52 +08:00
Compare commits
193 Commits
fix/codex-
...
bb/ableton
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9335a24f49 | ||
|
|
42bea9e298 | ||
|
|
d40b5735a4 | ||
|
|
9d225fbf4e | ||
|
|
92b5987ca2 | ||
|
|
0d777453fa | ||
|
|
e4d026aa3b | ||
|
|
b82c83d320 | ||
|
|
751adfa6b9 | ||
|
|
ff8920299c | ||
|
|
ca714f6189 | ||
|
|
0654319644 | ||
|
|
d9bd7ce827 | ||
|
|
2107b86024 | ||
|
|
510bf40705 | ||
|
|
2a1e615565 | ||
|
|
d7021af30f | ||
|
|
4efec63a34 | ||
|
|
2c02583c2b | ||
|
|
525ee58b43 | ||
|
|
150afea942 | ||
|
|
73c8d5a1e7 | ||
|
|
1a38a8ff7d | ||
|
|
edf35918be | ||
|
|
e8561d61e6 | ||
|
|
da73223f4a | ||
|
|
1ca1f9f2c7 | ||
|
|
3bf00e459a | ||
|
|
c4c590e4a1 | ||
|
|
5de8a8fbe8 | ||
|
|
6208d6b3be | ||
|
|
0be10607d9 | ||
|
|
d682f320b3 | ||
|
|
c210e23a02 | ||
|
|
6305ac0e4b | ||
|
|
0aea0c3654 | ||
|
|
a53fc78c02 | ||
|
|
15ee2d6f04 | ||
|
|
d93abd75d1 | ||
|
|
8278d82e17 | ||
|
|
931a5e92cc | ||
|
|
70319626a9 | ||
|
|
2d286a6d00 | ||
|
|
88e01d92e6 | ||
|
|
1d9ed7f48a | ||
|
|
a6a28ce3e2 | ||
|
|
d6269da7fd | ||
|
|
e62afaca62 | ||
|
|
60a2feeebf | ||
|
|
6f2b2a1f34 | ||
|
|
736e981abf | ||
|
|
d6cf383d74 | ||
|
|
d0df264213 | ||
|
|
f3372d3407 | ||
|
|
d0f9c4bcc6 | ||
|
|
818f03cdd8 | ||
|
|
6b3ea2cea6 | ||
|
|
5196575d40 | ||
|
|
4362c1a3af | ||
|
|
f3d6d9bbd3 | ||
|
|
3af22c0ed5 | ||
|
|
a5849917a8 | ||
|
|
25c31cab62 | ||
|
|
7078d9d1e2 | ||
|
|
a8e6a4f00b | ||
|
|
41f302fa73 | ||
|
|
7a65800fed | ||
|
|
72ae163250 | ||
|
|
0c442fa1d3 | ||
|
|
e92b5c6af8 | ||
|
|
380d660cab | ||
|
|
d473e5d07a | ||
|
|
1512bad0bc | ||
|
|
da0320bf40 | ||
|
|
a5a2edd451 | ||
|
|
2f1a47b90e | ||
|
|
7ef0f360d0 | ||
|
|
f0beb6f617 | ||
|
|
fcbdf3c356 | ||
|
|
b177d4ee48 | ||
|
|
b693bee100 | ||
|
|
98f3c19282 | ||
|
|
c06ceb3232 | ||
|
|
1b181724fa | ||
|
|
532b7ed408 | ||
|
|
281b333cc5 | ||
|
|
f2c45e2c81 | ||
|
|
cbe5c5689f | ||
|
|
0c3f197cff | ||
|
|
c15945655f | ||
|
|
411faf08bd | ||
|
|
a4fa1481e2 | ||
|
|
d1cac0e5ef | ||
|
|
96af4bec30 | ||
|
|
4aeaba6922 | ||
|
|
7e2db0a140 | ||
|
|
17beb55e3c | ||
|
|
284be6cc24 | ||
|
|
7157b213f5 | ||
|
|
153ad79524 | ||
|
|
a05a9b0e07 | ||
|
|
2ea94c6c45 | ||
|
|
d635a6d507 | ||
|
|
42e14d1089 | ||
|
|
b649cdee4a | ||
|
|
538c419d2e | ||
|
|
f1617a7ebb | ||
|
|
592c462e3c | ||
|
|
9a4600c5fb | ||
|
|
65b13e9dbc | ||
|
|
463bf2be25 | ||
|
|
cb6edbf448 | ||
|
|
a6485bddb8 | ||
|
|
1fe013ee16 | ||
|
|
d335164833 | ||
|
|
a378b1e980 | ||
|
|
4127332f15 | ||
|
|
70650e82a3 | ||
|
|
9a94865552 | ||
|
|
93192059c9 | ||
|
|
2a75c4a8cb | ||
|
|
8d1706ae5c | ||
|
|
41b9b7e719 | ||
|
|
cbd6ba1bdd | ||
|
|
a268dfff0a | ||
|
|
404b06ac4f | ||
|
|
cedbb4cfa2 | ||
|
|
085096fd59 | ||
|
|
7d2c1f3f84 | ||
|
|
710cd48fb1 | ||
|
|
dbf0797335 | ||
|
|
8d1f6debfd | ||
|
|
77d2b50751 | ||
|
|
4d589b1e13 | ||
|
|
489b85ee1e | ||
|
|
e25b56fc64 | ||
|
|
1e4df599ec | ||
|
|
7a79a4447c | ||
|
|
8f0a12ce09 | ||
|
|
9c994377ed | ||
|
|
aacc6bb0a8 | ||
|
|
ed1fdb5b61 | ||
|
|
e0272cfef2 | ||
|
|
59acaa972f | ||
|
|
6800fd6608 | ||
|
|
cae1ee44a7 | ||
|
|
25e2312230 | ||
|
|
b13e2fd694 | ||
|
|
b674f7ba28 | ||
|
|
9214aa7dde | ||
|
|
0225480369 | ||
|
|
743985bf1e | ||
|
|
aab49f6927 | ||
|
|
3faf768cde | ||
|
|
32f837add1 | ||
|
|
de281bcebc | ||
|
|
5b065e32ed | ||
|
|
a130b62678 | ||
|
|
2de7549fe0 | ||
|
|
b41d9b845d | ||
|
|
35e9c63d89 | ||
|
|
6638199c53 | ||
|
|
7e55b934ea | ||
|
|
d8fe1c0b41 | ||
|
|
6da615c77c | ||
|
|
9259d1e5da | ||
|
|
c42d44cb2f | ||
|
|
7fb2027d85 | ||
|
|
f477f892b3 | ||
|
|
fce2af780f | ||
|
|
1a435a6d5d | ||
|
|
b85c460540 | ||
|
|
2187fd884c | ||
|
|
1a174dfb50 | ||
|
|
ae20c3fb90 | ||
|
|
6879d77d74 | ||
|
|
d68a133458 | ||
|
|
7634488074 | ||
|
|
4f521a5382 | ||
|
|
ab9134bf16 | ||
|
|
721cf54fb1 | ||
|
|
f0c5d812b0 | ||
|
|
ac822e4d36 | ||
|
|
a4a74ca9e9 | ||
|
|
d398076c21 | ||
|
|
7243111c57 | ||
|
|
66a0907c95 | ||
|
|
89540d592b | ||
|
|
33926eb315 | ||
|
|
8446c15706 | ||
|
|
c93b9f9057 | ||
|
|
a4e61ddf04 | ||
|
|
791c992b55 |
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
@@ -12,7 +12,6 @@ name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
|
||||
1
.github/workflows/docker-publish.yml
vendored
1
.github/workflows/docker-publish.yml
vendored
@@ -16,7 +16,6 @@ on:
|
||||
# reports a status (path-gated workflows leave checks "pending" forever
|
||||
# when no matching files change, which blocks merge).
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
13
Dockerfile
13
Dockerfile
@@ -290,6 +290,19 @@ ENV HERMES_TUI_DIR=/opt/hermes/ui-tui
|
||||
ENV HERMES_HOME=/opt/data
|
||||
ENV HERMES_WRITE_SAFE_ROOT=/opt/data
|
||||
ENV HERMES_DISABLE_LAZY_INSTALLS=1
|
||||
# The published image seals /opt/hermes (root-owned, read-only) so a runtime
|
||||
# lazy install can't mutate the agent's own venv and brick it. But opt-in
|
||||
# backends (Firecrawl web search, Exa, Feishu, …) keep their SDKs in
|
||||
# tools/lazy_deps.py — deliberately NOT baked into [all] (see pyproject.toml
|
||||
# policy 2026-05-12: one quarantined release must not break every install).
|
||||
# Redirect those lazy installs to a writable dir on the durable data volume.
|
||||
# lazy_deps appends this dir to the END of sys.path, so a package installed
|
||||
# here can only ADD modules — it can never shadow or downgrade a core module,
|
||||
# so the sealed-venv guarantee holds even with installs re-enabled. The dir
|
||||
# is seeded + chowned to the hermes user by docker/stage2-hook.sh and lives
|
||||
# on the /opt/data volume, so it persists across container recreates / image
|
||||
# updates (an ABI stamp invalidates it if a rebuild bumps the interpreter).
|
||||
ENV HERMES_LAZY_INSTALL_TARGET=/opt/data/lazy-packages
|
||||
|
||||
# `docker exec` privilege-drop shim. When operators run
|
||||
# `docker exec <c> hermes ...` they default to root, and any file the
|
||||
|
||||
@@ -106,7 +106,12 @@ def _custom_provider_extra_body_for_agent(
|
||||
base_url: str,
|
||||
custom_providers: List[Dict[str, Any]],
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if (provider or "").strip().lower() != "custom":
|
||||
provider_norm = (provider or "").strip().lower()
|
||||
if provider_norm == "custom":
|
||||
provider_key_filter = ""
|
||||
elif provider_norm.startswith("custom:"):
|
||||
provider_key_filter = provider_norm.split(":", 1)[1].strip()
|
||||
else:
|
||||
return None
|
||||
|
||||
target_url = _normalized_custom_base_url(base_url)
|
||||
@@ -117,6 +122,13 @@ def _custom_provider_extra_body_for_agent(
|
||||
for entry in custom_providers or []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if provider_key_filter:
|
||||
entry_keys = {
|
||||
str(entry.get("provider_key", "") or "").strip().lower(),
|
||||
str(entry.get("name", "") or "").strip().lower(),
|
||||
}
|
||||
if provider_key_filter not in entry_keys:
|
||||
continue
|
||||
if _normalized_custom_base_url(entry.get("base_url")) != target_url:
|
||||
continue
|
||||
extra_body = entry.get("extra_body")
|
||||
@@ -1506,6 +1518,7 @@ def init_agent(
|
||||
# 3. Check general plugin system (user-installed plugins)
|
||||
# 4. Fall back to built-in ContextCompressor
|
||||
_selected_engine = None
|
||||
_copy_failed = False
|
||||
_engine_name = "compressor" # default
|
||||
try:
|
||||
_ctx_cfg = _agent_cfg.get("context", {}) if isinstance(_agent_cfg, dict) else {}
|
||||
@@ -1523,15 +1536,35 @@ def init_agent(
|
||||
|
||||
# Try general plugin system as fallback
|
||||
if _selected_engine is None:
|
||||
_candidate = None
|
||||
try:
|
||||
from hermes_cli.plugins import get_plugin_context_engine
|
||||
_candidate = get_plugin_context_engine()
|
||||
if _candidate and _candidate.name == _engine_name:
|
||||
_selected_engine = _candidate
|
||||
except Exception:
|
||||
pass
|
||||
_candidate = None
|
||||
if _candidate is not None and _candidate.name == _engine_name:
|
||||
# Deep-copy the shared plugin singleton so a child agent's
|
||||
# update_model() can't mutate the parent's compressor (#42449).
|
||||
# Copy can fail for engines holding uncopyable state (locks, DB
|
||||
# connections, clients); in that case fall back to the built-in
|
||||
# compressor with an ACCURATE message rather than silently
|
||||
# mislabelling it "not found".
|
||||
import copy
|
||||
try:
|
||||
_selected_engine = copy.deepcopy(_candidate)
|
||||
except Exception as _copy_err:
|
||||
_copy_failed = True
|
||||
_ra().logger.warning(
|
||||
"Context engine '%s' could not be safely copied for this "
|
||||
"agent (%s) — falling back to built-in compressor. Plugin "
|
||||
"engines that hold uncopyable state (locks, DB connections) "
|
||||
"should implement __deepcopy__ to copy only mutable budget "
|
||||
"state.",
|
||||
_engine_name, _copy_err,
|
||||
)
|
||||
_selected_engine = None
|
||||
|
||||
if _selected_engine is None:
|
||||
if _selected_engine is None and not _copy_failed:
|
||||
_ra().logger.warning(
|
||||
"Context engine '%s' not found — falling back to built-in compressor",
|
||||
_engine_name,
|
||||
@@ -1621,16 +1654,27 @@ def init_agent(
|
||||
for t in agent.tools
|
||||
if isinstance(t, dict)
|
||||
}
|
||||
for _schema in agent.context_compressor.get_tool_schemas():
|
||||
_tname = _schema.get("name", "")
|
||||
if _tname and _tname in _existing_tool_names:
|
||||
from agent.memory_manager import normalize_tool_schema as _normalize_tool_schema
|
||||
for _raw_schema in agent.context_compressor.get_tool_schemas():
|
||||
_schema = _normalize_tool_schema(_raw_schema)
|
||||
if _schema is None:
|
||||
# A schema with no resolvable name (e.g. an already-wrapped
|
||||
# entry) would append a nameless tool that strict providers
|
||||
# 400 on, disabling the whole toolset (#47707). Skip it.
|
||||
_ra().logger.warning(
|
||||
"Context engine returned a tool schema with no resolvable "
|
||||
"name; skipping to avoid poisoning the request (%r)",
|
||||
_raw_schema,
|
||||
)
|
||||
continue
|
||||
_tname = _schema["name"]
|
||||
if _tname in _existing_tool_names:
|
||||
continue # already registered via plugin/cache path
|
||||
_wrapped = {"type": "function", "function": _schema}
|
||||
agent.tools.append(_wrapped)
|
||||
if _tname:
|
||||
agent.valid_tool_names.add(_tname)
|
||||
agent._context_engine_tool_names.add(_tname)
|
||||
_existing_tool_names.add(_tname)
|
||||
agent.valid_tool_names.add(_tname)
|
||||
agent._context_engine_tool_names.add(_tname)
|
||||
_existing_tool_names.add(_tname)
|
||||
|
||||
# Notify context engine of session start
|
||||
if hasattr(agent, "context_compressor") and agent.context_compressor:
|
||||
|
||||
@@ -101,6 +101,7 @@ class _OpenAIProxy:
|
||||
OpenAI = _OpenAIProxy() # module-level name, resolves lazily on call/isinstance
|
||||
|
||||
from agent.credential_pool import load_pool
|
||||
from agent.model_metadata import MINIMUM_CONTEXT_LENGTH, get_model_context_length
|
||||
from hermes_cli.config import get_hermes_home
|
||||
from hermes_constants import OPENROUTER_BASE_URL
|
||||
from utils import base_url_host_matches, base_url_hostname, env_float, model_forces_max_completion_tokens, normalize_proxy_env_vars
|
||||
@@ -2470,7 +2471,7 @@ def _is_payment_error(exc: Exception) -> bool:
|
||||
# but sometimes wrap them in 429 or other codes.
|
||||
# Daily quota exhaustion from Bedrock, Vertex AI, and similar providers
|
||||
# uses different language but is semantically identical to credit exhaustion.
|
||||
if status in {402, 404, 429, None}:
|
||||
if status in {402, 403, 404, 429, None}:
|
||||
if any(kw in err_lower for kw in (
|
||||
"credits", "insufficient funds",
|
||||
"can only afford", "billing",
|
||||
@@ -2479,6 +2480,8 @@ def _is_payment_error(exc: Exception) -> bool:
|
||||
"balance_depleted", "no usable credits",
|
||||
"model_not_supported_on_free_tier",
|
||||
"not available on the free tier",
|
||||
"requires a subscription", "upgrade for access",
|
||||
"upgrade for higher limits", "reached your session usage limit",
|
||||
# Daily / monthly / weekly quota exhaustion keywords
|
||||
"quota exceeded", "quota_exceeded",
|
||||
"too many tokens per day", "daily limit",
|
||||
@@ -2697,6 +2700,60 @@ def _is_model_not_found_error(exc: Exception) -> bool:
|
||||
))
|
||||
|
||||
|
||||
def _is_model_incompatible_error(exc: Exception) -> bool:
|
||||
"""Detect "this route cannot serve this model" 400s (capability mismatch).
|
||||
|
||||
Distinct from :func:`_is_model_not_found_error` (the model does not exist
|
||||
anywhere): here the model name is valid but the *current provider/account*
|
||||
is structurally unable to run it. The canonical case is a configured
|
||||
fallback that cannot run the main model — e.g. an ``openai-codex`` /
|
||||
ChatGPT-account fallback asked to compress a ``glm-5.2`` conversation::
|
||||
|
||||
Error code: 400 - {'detail': "The 'glm-5.2' model is not supported
|
||||
when using Codex with a ChatGPT account."}
|
||||
|
||||
The candidate authenticates fine and builds a client, so the auth and
|
||||
payment predicates don't fire and the call would otherwise raise and
|
||||
abort the whole auxiliary task (commonly compression — which then drops
|
||||
middle turns and churns the session, destroying the prompt cache).
|
||||
Treating it as a fallback-worthy capability error lets the chain skip the
|
||||
incapable route and continue to the next candidate, mirroring the
|
||||
context-window feasibility screen (#52392).
|
||||
|
||||
Billing/quota 400s belong to :func:`_is_payment_error`; "model does not
|
||||
exist" 400s belong to :func:`_is_model_not_found_error`. This predicate
|
||||
explicitly excludes both so the three don't overlap.
|
||||
"""
|
||||
status = getattr(exc, "status_code", None)
|
||||
if status not in {400, None}:
|
||||
return False
|
||||
err_lower = str(exc).lower()
|
||||
# Not-found 400s ("invalid model ID", "model does not exist") are owned by
|
||||
# _is_model_not_found_error. Billing/free-tier 400s are owned by the
|
||||
# payment path — key on the billing keywords directly here rather than
|
||||
# calling _is_payment_error(), because that predicate is status-gated
|
||||
# ({402,403,404,429,None}) and would not recognise a 400-coded billing
|
||||
# body, letting it leak into this capability bucket.
|
||||
if _is_model_not_found_error(exc):
|
||||
return False
|
||||
if any(kw in err_lower for kw in (
|
||||
"credits", "insufficient funds", "billing", "out of funds",
|
||||
"balance_depleted", "no usable credits", "payment required",
|
||||
"free tier", "free-tier", "not available on the free tier",
|
||||
"model_not_supported_on_free_tier", "quota",
|
||||
)):
|
||||
return False
|
||||
return any(kw in err_lower for kw in (
|
||||
"is not supported when using", # codex/ChatGPT-account model gating
|
||||
"model is not supported",
|
||||
"not supported with this",
|
||||
"not supported for this account",
|
||||
"model_not_supported",
|
||||
"does not support this model",
|
||||
"unsupported model",
|
||||
))
|
||||
|
||||
|
||||
def _evict_cached_clients(provider: str) -> None:
|
||||
"""Drop cached auxiliary clients for a provider so fresh creds are used."""
|
||||
normalized = _normalize_aux_provider(provider)
|
||||
@@ -3147,6 +3204,88 @@ def _try_main_agent_model_fallback(
|
||||
return client, resolved_model or main_model, label
|
||||
|
||||
|
||||
# ── Context-window screening for runtime fallback chains (issue #52392) ──
|
||||
#
|
||||
# When the runtime auxiliary fallback chain selects a candidate that is
|
||||
# reachable but has a context window smaller than the compression task
|
||||
# requires, the call errors out instead of continuing to the next, viable
|
||||
# candidate. The startup feasibility check in
|
||||
# ``agent.conversation_compression.check_compression_model_feasibility``
|
||||
# already filters too-small auxiliary models at startup, but the runtime
|
||||
# fallback chain (``_try_configured_fallback_chain`` and
|
||||
# ``_try_main_fallback_chain``) does not apply the same filter, so
|
||||
# compression can stop at the first alive door even if the room behind it
|
||||
# is too small.
|
||||
#
|
||||
# The helpers below screen each candidate by its effective context window
|
||||
# before it is returned. ``None`` results from ``get_model_context_length``
|
||||
# are passed through (we cannot prove a model is too small, so we do not
|
||||
# block it). This preserves the existing fallback surface for
|
||||
# unrecognised/custom models while closing the gap on the well-known ones.
|
||||
|
||||
def _task_minimum_context_length(task: Optional[str]) -> Optional[int]:
|
||||
"""Return the minimum context length required for an auxiliary task.
|
||||
|
||||
Only ``compression`` carries an explicit minimum today (the same
|
||||
``MINIMUM_CONTEXT_LENGTH`` (64K) floor that
|
||||
``check_compression_model_feasibility`` already enforces at startup).
|
||||
Other tasks (``vision``, ``title_generation``, ``web_extract``,
|
||||
``skills_hub``, ``mcp``, ``session_search``) return ``None`` — they
|
||||
have no per-task context floor and the runtime chain must remain
|
||||
permissive for them.
|
||||
|
||||
Returns ``None`` for an empty/``None`` task name so the helper is a
|
||||
safe no-op when called from generic sites.
|
||||
"""
|
||||
if not task:
|
||||
return None
|
||||
if task == "compression":
|
||||
return MINIMUM_CONTEXT_LENGTH
|
||||
return None
|
||||
|
||||
|
||||
def _candidate_context_window(
|
||||
provider: str,
|
||||
model: str,
|
||||
base_url: str = "",
|
||||
api_key: str = "",
|
||||
) -> Optional[int]:
|
||||
"""Resolve the effective context window for a fallback candidate.
|
||||
|
||||
Thin wrapper around :func:`agent.model_metadata.get_model_context_length`
|
||||
that swallows probe failures (returns ``None``). Callers treat
|
||||
``None`` as "unknown — pass through" so the existing fallback
|
||||
surface is preserved when the context-length resolver chain cannot
|
||||
determine a value (custom endpoints, models not in the registry,
|
||||
offline endpoints).
|
||||
|
||||
Best-effort, never raises — the runtime fallback chain must keep
|
||||
moving even if the resolver hits a probe error.
|
||||
"""
|
||||
if not model:
|
||||
return None
|
||||
try:
|
||||
ctx = get_model_context_length(
|
||||
model,
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
provider=provider,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Auxiliary fallback: could not resolve context window for %s/%s: %s",
|
||||
provider, model, exc,
|
||||
)
|
||||
return None
|
||||
# ``get_model_context_length`` returns an int (with a 256K default
|
||||
# fallback when nothing else matches). We still propagate ``None`` if
|
||||
# a future change returns ``Optional[int]`` — being explicit is
|
||||
# cheap and the test suite covers both shapes.
|
||||
if isinstance(ctx, int) and ctx > 0:
|
||||
return ctx
|
||||
return None
|
||||
|
||||
|
||||
def _try_configured_fallback_chain(
|
||||
task: str,
|
||||
failed_provider: str,
|
||||
@@ -3171,6 +3310,7 @@ def _try_configured_fallback_chain(
|
||||
|
||||
skip = failed_provider.lower().strip()
|
||||
tried = []
|
||||
min_ctx = _task_minimum_context_length(task)
|
||||
|
||||
for i, entry in enumerate(chain):
|
||||
if not isinstance(entry, dict):
|
||||
@@ -3188,6 +3328,20 @@ def _try_configured_fallback_chain(
|
||||
fb_client, resolved_model = None, None
|
||||
|
||||
if fb_client is not None:
|
||||
if min_ctx is not None and resolved_model:
|
||||
fb_ctx = _candidate_context_window(
|
||||
fb_provider,
|
||||
resolved_model,
|
||||
base_url=str(entry.get("base_url") or ""),
|
||||
api_key=_fallback_entry_api_key(entry) or "",
|
||||
)
|
||||
if fb_ctx is not None and fb_ctx < min_ctx:
|
||||
logger.info(
|
||||
"Auxiliary %s: skipping %s (%s context=%d < min=%d), continuing chain",
|
||||
task, label, resolved_model, fb_ctx, min_ctx,
|
||||
)
|
||||
tried.append(f"{label} (context too small: {fb_ctx}<{min_ctx})")
|
||||
continue
|
||||
logger.info(
|
||||
"Auxiliary %s: %s on %s — configured fallback to %s (%s)",
|
||||
task, reason, failed_provider, label, resolved_model or fb_model or "default",
|
||||
@@ -3203,6 +3357,28 @@ def _try_configured_fallback_chain(
|
||||
return None, None, ""
|
||||
|
||||
|
||||
def _try_configured_fallback_for_unavailable_client(
|
||||
task: Optional[str],
|
||||
failed_provider: str,
|
||||
) -> Tuple[Optional[Any], Optional[str], str]:
|
||||
"""Try task fallback_chain when an explicit aux provider cannot build.
|
||||
|
||||
This covers the "no client" case before any request is sent: missing
|
||||
raw env key, unavailable OAuth/pool credentials, or provider resolver
|
||||
returning ``(None, None)``. It deliberately stops at the configured
|
||||
per-task fallback chain; the main-agent model remains the last-resort
|
||||
runtime fallback for request-time capacity errors.
|
||||
"""
|
||||
explicit = (failed_provider or "").strip().lower()
|
||||
if not task or not explicit or explicit in {"auto"}:
|
||||
return None, None, ""
|
||||
return _try_configured_fallback_chain(
|
||||
task,
|
||||
explicit,
|
||||
reason="provider unavailable",
|
||||
)
|
||||
|
||||
|
||||
def _fallback_entry_api_key(entry: Dict[str, Any]) -> Optional[str]:
|
||||
"""Resolve inline or env-backed API key from a fallback-chain entry."""
|
||||
explicit = str(entry.get("api_key") or "").strip()
|
||||
@@ -3261,6 +3437,7 @@ def _try_main_fallback_chain(
|
||||
main_norm = (_read_main_provider() or "").strip().lower()
|
||||
skip = {p for p in (failed_norm, main_norm, "auto") if p}
|
||||
tried: List[str] = []
|
||||
min_ctx = _task_minimum_context_length(task)
|
||||
|
||||
for i, entry in enumerate(chain):
|
||||
if not isinstance(entry, dict):
|
||||
@@ -3284,6 +3461,20 @@ def _try_main_fallback_chain(
|
||||
logger.debug("Auxiliary %s: main fallback %s failed to resolve: %s", task or "call", label, exc)
|
||||
fb_client, resolved_model = None, None
|
||||
if fb_client is not None:
|
||||
if min_ctx is not None:
|
||||
fb_ctx = _candidate_context_window(
|
||||
fb_provider,
|
||||
resolved_model or fb_model,
|
||||
base_url=str(entry.get("base_url") or ""),
|
||||
api_key=_fallback_entry_api_key(entry) or "",
|
||||
)
|
||||
if fb_ctx is not None and fb_ctx < min_ctx:
|
||||
logger.info(
|
||||
"Auxiliary %s: skipping %s (context=%d < min=%d), continuing chain",
|
||||
task or "call", label, fb_ctx, min_ctx,
|
||||
)
|
||||
tried.append(f"{label} (context too small: {fb_ctx}<{min_ctx})")
|
||||
continue
|
||||
logger.info(
|
||||
"Auxiliary %s: %s on %s — main fallback chain to %s (%s)",
|
||||
task or "call", reason, failed_provider or "auto", label,
|
||||
@@ -5344,21 +5535,30 @@ def call_llm(
|
||||
)
|
||||
if client is None:
|
||||
# When the user explicitly chose a non-OpenRouter provider but no
|
||||
# credentials were found, fail fast instead of silently routing
|
||||
# through OpenRouter (which causes confusing 404s).
|
||||
# credentials were found, honor the task fallback_chain before
|
||||
# raising. Missing raw env keys are recoverable for auxiliary
|
||||
# tasks because fallback entries may use OAuth / credential-pool
|
||||
# auth (for example openai-codex).
|
||||
_explicit = (resolved_provider or "").strip().lower()
|
||||
if _explicit and _explicit not in {"auto", "openrouter", "custom"}:
|
||||
raise RuntimeError(
|
||||
f"Provider '{_explicit}' is set in config.yaml but no API key "
|
||||
f"was found. Set the {_explicit.upper()}_API_KEY environment "
|
||||
f"variable, or switch to a different provider with `hermes model`."
|
||||
fb_client, fb_model, fb_label = _try_configured_fallback_for_unavailable_client(
|
||||
task, _explicit,
|
||||
)
|
||||
if fb_client is not None:
|
||||
client, final_model = fb_client, fb_model
|
||||
resolved_provider = fb_label or resolved_provider
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f"Provider '{_explicit}' is set in config.yaml but no API key "
|
||||
f"was found. Set the {_explicit.upper()}_API_KEY environment "
|
||||
f"variable, or switch to a different provider with `hermes model`."
|
||||
)
|
||||
# For auto/custom with no credentials, try the full auto chain
|
||||
# rather than hardcoding OpenRouter (which may be depleted).
|
||||
# Pass model=None so each provider uses its own default —
|
||||
# resolved_model may be an OpenRouter-format slug that doesn't
|
||||
# work on other providers.
|
||||
if not resolved_base_url:
|
||||
if client is None and not resolved_base_url:
|
||||
logger.info("Auxiliary %s: provider %s unavailable, trying auto-detection chain",
|
||||
task or "call", resolved_provider)
|
||||
client, final_model = _get_cached_client("auto", main_runtime=main_runtime, task=task)
|
||||
@@ -5657,6 +5857,7 @@ def call_llm(
|
||||
_is_payment_error(first_err)
|
||||
or _is_connection_error(first_err)
|
||||
or _is_rate_limit_error(first_err)
|
||||
or _is_model_incompatible_error(first_err)
|
||||
)
|
||||
# Respect explicit provider choice for transient errors (auth, request
|
||||
# validation, etc.) but allow fallback when the provider clearly cannot
|
||||
@@ -5667,7 +5868,19 @@ def call_llm(
|
||||
is_auto = resolved_provider in {"auto", "", None}
|
||||
# Capacity errors bypass the explicit-provider gate: the provider
|
||||
# literally cannot serve this request regardless of user intent.
|
||||
is_capacity_error = _is_payment_error(first_err) or _is_connection_error(first_err)
|
||||
# Rate limits are included: after retries are exhausted, a 429 means
|
||||
# the provider cannot serve this request — fall back. See #52228.
|
||||
# Model-incompatibility 400s are also a hard capability mismatch (the
|
||||
# route cannot run this model at all — e.g. a codex/ChatGPT-account
|
||||
# fallback asked to compress a glm-5.2 conversation), so they bypass
|
||||
# the explicit-provider gate and continue to the next candidate
|
||||
# instead of aborting the auxiliary task and churning the session.
|
||||
is_capacity_error = (
|
||||
_is_payment_error(first_err)
|
||||
or _is_connection_error(first_err)
|
||||
or _is_rate_limit_error(first_err)
|
||||
or _is_model_incompatible_error(first_err)
|
||||
)
|
||||
if should_fallback and (is_auto or is_capacity_error):
|
||||
if _is_payment_error(first_err):
|
||||
reason = "payment error"
|
||||
@@ -5680,6 +5893,8 @@ def call_llm(
|
||||
)
|
||||
elif _is_rate_limit_error(first_err):
|
||||
reason = "rate limit"
|
||||
elif _is_model_incompatible_error(first_err):
|
||||
reason = "model incompatible with route"
|
||||
else:
|
||||
reason = "connection error"
|
||||
logger.info("Auxiliary %s: %s on %s (%s), trying fallback",
|
||||
@@ -5854,12 +6069,21 @@ async def async_call_llm(
|
||||
if client is None:
|
||||
_explicit = (resolved_provider or "").strip().lower()
|
||||
if _explicit and _explicit not in {"auto", "openrouter", "custom"}:
|
||||
raise RuntimeError(
|
||||
f"Provider '{_explicit}' is set in config.yaml but no API key "
|
||||
f"was found. Set the {_explicit.upper()}_API_KEY environment "
|
||||
f"variable, or switch to a different provider with `hermes model`."
|
||||
fb_client, fb_model, fb_label = _try_configured_fallback_for_unavailable_client(
|
||||
task, _explicit,
|
||||
)
|
||||
if not resolved_base_url:
|
||||
if fb_client is not None:
|
||||
client, final_model = _to_async_client(
|
||||
fb_client, fb_model or "", is_vision=(task == "vision")
|
||||
)
|
||||
resolved_provider = fb_label or resolved_provider
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f"Provider '{_explicit}' is set in config.yaml but no API key "
|
||||
f"was found. Set the {_explicit.upper()}_API_KEY environment "
|
||||
f"variable, or switch to a different provider with `hermes model`."
|
||||
)
|
||||
if client is None and not resolved_base_url:
|
||||
logger.info("Auxiliary %s: provider %s unavailable, trying auto-detection chain",
|
||||
task or "call", resolved_provider)
|
||||
client, final_model = _get_cached_client("auto", async_mode=True, main_runtime=main_runtime, task=task)
|
||||
@@ -6109,12 +6333,22 @@ async def async_call_llm(
|
||||
_is_payment_error(first_err)
|
||||
or _is_connection_error(first_err)
|
||||
or _is_rate_limit_error(first_err)
|
||||
or _is_model_incompatible_error(first_err)
|
||||
)
|
||||
# Capacity errors (payment/quota/connection) bypass the explicit-provider
|
||||
# gate — the provider cannot serve the request regardless of user intent.
|
||||
# Capacity errors (payment/quota/connection/rate-limit) bypass the
|
||||
# explicit-provider gate — the provider cannot serve the request
|
||||
# regardless of user intent. Rate limits are included: after retries
|
||||
# are exhausted, a 429 means the provider is at capacity. See #52228.
|
||||
# See #26803: daily token quota must fall back like a 402 credit error.
|
||||
# Model-incompatibility 400s (route cannot run this model at all)
|
||||
# bypass the gate too — see the sync call_llm() path for rationale.
|
||||
is_auto = resolved_provider in {"auto", "", None}
|
||||
is_capacity_error = _is_payment_error(first_err) or _is_connection_error(first_err)
|
||||
is_capacity_error = (
|
||||
_is_payment_error(first_err)
|
||||
or _is_connection_error(first_err)
|
||||
or _is_rate_limit_error(first_err)
|
||||
or _is_model_incompatible_error(first_err)
|
||||
)
|
||||
if should_fallback and (is_auto or is_capacity_error):
|
||||
if _is_payment_error(first_err):
|
||||
reason = "payment error"
|
||||
@@ -6123,6 +6357,8 @@ async def async_call_llm(
|
||||
)
|
||||
elif _is_rate_limit_error(first_err):
|
||||
reason = "rate limit"
|
||||
elif _is_model_incompatible_error(first_err):
|
||||
reason = "model incompatible with route"
|
||||
else:
|
||||
reason = "connection error"
|
||||
logger.info("Auxiliary %s (async): %s on %s (%s), trying fallback",
|
||||
|
||||
@@ -890,7 +890,15 @@ class ContextCompressor(ContextEngine):
|
||||
# This is independent of the abort_on_summary_failure config flag:
|
||||
# rotating on a broken credential is never the right behavior.
|
||||
self._last_summary_auth_failure: bool = False
|
||||
# When a user-configured summary model fails and we recover by
|
||||
# Set when summary generation ultimately fails due to a transient
|
||||
# network/connection error (httpx/httpcore connection drop, premature
|
||||
# stream close, etc.) — distinct from auth failures but treated the
|
||||
# same way by compress(): ABORT and preserve the session unchanged
|
||||
# rather than destroy the middle window for a deterministic
|
||||
# "summary unavailable" marker. Retrying once the network recovers is
|
||||
# strictly better than discarding context for a transient blip
|
||||
# (#29559, #25585). Independent of abort_on_summary_failure.
|
||||
self._last_summary_network_failure: bool = False
|
||||
# retrying on the main model, record the failure so gateway /
|
||||
# CLI callers can still warn the user even though compression
|
||||
# succeeded. Silent recovery would hide the broken config.
|
||||
@@ -1687,6 +1695,7 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
self._summary_model_fallen_back = False
|
||||
self._last_summary_error = None
|
||||
self._last_summary_auth_failure = False
|
||||
self._last_summary_network_failure = False
|
||||
return self._with_summary_prefix(summary)
|
||||
except Exception as e:
|
||||
# ``call_llm`` raises ``RuntimeError`` for two very different cases:
|
||||
@@ -1819,6 +1828,15 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
if len(err_text) > 220:
|
||||
err_text = err_text[:217].rstrip() + "..."
|
||||
self._last_summary_error = err_text
|
||||
# A terminal connection/network failure (we reach this branch only
|
||||
# after any main-model fallback has already been tried or is
|
||||
# unavailable). Flag it so compress() ABORTS and preserves the
|
||||
# session unchanged instead of destroying the middle window for a
|
||||
# placeholder marker — retrying once the network recovers is
|
||||
# strictly better than dropping context (#29559, #25585). Mirrors
|
||||
# the auth-failure carve-out; independent of abort_on_summary_failure.
|
||||
if _is_streaming_closed:
|
||||
self._last_summary_network_failure = True
|
||||
logger.warning(
|
||||
"Failed to generate context summary: %s. "
|
||||
"Further summary attempts paused for %d seconds.",
|
||||
@@ -2382,6 +2400,7 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
self._last_aux_model_failure_model = None
|
||||
self._last_compress_aborted = False
|
||||
self._last_summary_auth_failure = False
|
||||
self._last_summary_network_failure = False
|
||||
|
||||
# Manual /compress (force=True) bypasses the failure cooldown so the
|
||||
# user can retry immediately after an auto-compress abort. Without
|
||||
@@ -2498,15 +2517,21 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
# surface a warning.
|
||||
# Default is False (historical behavior).
|
||||
#
|
||||
# EXCEPTION — auth failures always abort. A 401/403 from the summary
|
||||
# call means the credential or endpoint is broken (invalid/blocked
|
||||
# key, or a token pointed at the wrong inference host). Rotating into
|
||||
# EXCEPTION — auth AND transient network failures always abort. A
|
||||
# 401/403 from the summary call means the credential or endpoint is
|
||||
# broken (invalid/blocked key, or a token pointed at the wrong
|
||||
# inference host). A connection/stream-close error means the network
|
||||
# blipped at the compaction moment (#29559). In BOTH cases rotating into
|
||||
# a child session with a placeholder summary on a broken credential
|
||||
# strands the user on a degraded session for zero benefit — every
|
||||
# subsequent call fails the same way. So when the failure was an auth
|
||||
# error we abort regardless of abort_on_summary_failure, preserving
|
||||
# the conversation unchanged until the credential is fixed.
|
||||
if not summary and (self.abort_on_summary_failure or self._last_summary_auth_failure):
|
||||
if not summary and (
|
||||
self.abort_on_summary_failure
|
||||
or self._last_summary_auth_failure
|
||||
or self._last_summary_network_failure
|
||||
):
|
||||
n_skipped = compress_end - compress_start
|
||||
self._last_summary_dropped_count = 0 # nothing actually dropped
|
||||
self._last_summary_fallback_used = False
|
||||
@@ -2521,6 +2546,15 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
"with /compress or start fresh with /new.",
|
||||
n_skipped,
|
||||
)
|
||||
elif self._last_summary_network_failure:
|
||||
logger.warning(
|
||||
"Summary generation failed with a network/connection "
|
||||
"error — aborting compression. %d message(s) preserved "
|
||||
"unchanged; the session was NOT rotated. This is "
|
||||
"transient: retry with /compress once connectivity "
|
||||
"recovers, or continue the conversation as-is.",
|
||||
n_skipped,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Summary generation failed — aborting compression "
|
||||
|
||||
@@ -90,6 +90,7 @@ def check_compression_model_feasibility(agent: Any) -> None:
|
||||
try:
|
||||
from agent.auxiliary_client import (
|
||||
_resolve_task_provider_model,
|
||||
_try_configured_fallback_for_unavailable_client,
|
||||
get_text_auxiliary_client,
|
||||
)
|
||||
from agent.model_metadata import (
|
||||
@@ -97,10 +98,6 @@ def check_compression_model_feasibility(agent: Any) -> None:
|
||||
get_model_context_length,
|
||||
)
|
||||
|
||||
client, aux_model = get_text_auxiliary_client(
|
||||
"compression",
|
||||
main_runtime=agent._current_main_runtime(),
|
||||
)
|
||||
# Best-effort aux provider label for the warning message. The
|
||||
# configured provider may be "auto", in which case we fall back
|
||||
# to the client's base_url hostname so the user can still tell
|
||||
@@ -109,6 +106,19 @@ def check_compression_model_feasibility(agent: Any) -> None:
|
||||
_aux_cfg_provider, _, _, _, _ = _resolve_task_provider_model("compression")
|
||||
except Exception:
|
||||
_aux_cfg_provider = ""
|
||||
client, aux_model = get_text_auxiliary_client(
|
||||
"compression",
|
||||
main_runtime=agent._current_main_runtime(),
|
||||
)
|
||||
if client is None or not aux_model:
|
||||
fb_client, fb_model, fb_label = _try_configured_fallback_for_unavailable_client(
|
||||
"compression",
|
||||
_aux_cfg_provider,
|
||||
)
|
||||
if fb_client is not None and fb_model:
|
||||
client, aux_model = fb_client, fb_model
|
||||
if "(" in fb_label and fb_label.endswith(")"):
|
||||
_aux_cfg_provider = fb_label.rsplit("(", 1)[1][:-1]
|
||||
if client is None or not aux_model:
|
||||
if _aux_cfg_provider and _aux_cfg_provider != "auto":
|
||||
msg = (
|
||||
|
||||
@@ -35,6 +35,7 @@ from agent.turn_context import build_turn_context
|
||||
from agent.turn_retry_state import TurnRetryState
|
||||
from agent.memory_manager import build_memory_context_block
|
||||
from agent.message_sanitization import (
|
||||
close_interrupted_tool_sequence,
|
||||
_repair_tool_call_arguments,
|
||||
_sanitize_messages_non_ascii,
|
||||
_sanitize_messages_surrogates,
|
||||
@@ -55,7 +56,7 @@ from agent.model_metadata import (
|
||||
)
|
||||
from agent.process_bootstrap import _install_safe_stdio
|
||||
from agent.prompt_caching import apply_anthropic_cache_control
|
||||
from agent.retry_utils import jittered_backoff
|
||||
from agent.retry_utils import adaptive_rate_limit_backoff, jittered_backoff
|
||||
from agent.trajectory import has_incomplete_scratchpad
|
||||
from agent.usage_pricing import estimate_usage_cost, normalize_usage
|
||||
from hermes_constants import PARTIAL_STREAM_STUB_ID
|
||||
@@ -1396,10 +1397,12 @@ def run_conversation(
|
||||
while time.time() < sleep_end:
|
||||
if agent._interrupt_requested:
|
||||
agent._vprint(f"{agent.log_prefix}⚡ Interrupt detected during retry wait, aborting.", force=True)
|
||||
_interrupt_text = f"Operation interrupted during retry ({_failure_hint}, attempt {retry_count}/{max_retries})."
|
||||
close_interrupted_tool_sequence(messages, _interrupt_text)
|
||||
agent._persist_session(messages, conversation_history)
|
||||
agent.clear_interrupt()
|
||||
return {
|
||||
"final_response": f"Operation interrupted during retry ({_failure_hint}, attempt {retry_count}/{max_retries}).",
|
||||
"final_response": _interrupt_text,
|
||||
"messages": messages,
|
||||
"api_calls": api_call_count,
|
||||
"completed": False,
|
||||
@@ -2663,10 +2666,12 @@ def run_conversation(
|
||||
# Check for interrupt before deciding to retry
|
||||
if agent._interrupt_requested:
|
||||
agent._vprint(f"{agent.log_prefix}⚡ Interrupt detected during error handling, aborting retries.", force=True)
|
||||
_interrupt_text = f"Operation interrupted: handling API error ({error_type}: {agent._clean_error_message(str(api_error))})."
|
||||
close_interrupted_tool_sequence(messages, _interrupt_text)
|
||||
agent._persist_session(messages, conversation_history)
|
||||
agent.clear_interrupt()
|
||||
return {
|
||||
"final_response": f"Operation interrupted: handling API error ({error_type}: {agent._clean_error_message(str(api_error))}).",
|
||||
"final_response": _interrupt_text,
|
||||
"messages": messages,
|
||||
"api_calls": api_call_count,
|
||||
"completed": False,
|
||||
@@ -3537,16 +3542,38 @@ def run_conversation(
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
wait_time = _retry_after if _retry_after else jittered_backoff(retry_count, base_delay=2.0, max_delay=60.0)
|
||||
_backoff_policy = None
|
||||
if is_rate_limited and not _retry_after:
|
||||
wait_time, _backoff_policy = adaptive_rate_limit_backoff(
|
||||
retry_count,
|
||||
base_url=str(_base),
|
||||
model=_model,
|
||||
error=api_error,
|
||||
default_wait=wait_time,
|
||||
)
|
||||
if is_rate_limited:
|
||||
agent._buffer_status(f"⏱️ Rate limited. Waiting {wait_time:.1f}s (attempt {retry_count + 1}/{max_retries})...")
|
||||
_policy_note = ""
|
||||
if _backoff_policy == "zai_coding_overload_long":
|
||||
_policy_note = " (Z.AI Coding overload adaptive long backoff)"
|
||||
elif _backoff_policy == "zai_coding_overload_short":
|
||||
_policy_note = " (Z.AI Coding overload short retry)"
|
||||
_rate_limit_status = f"⏱️ Rate limited. Waiting {wait_time:.1f}s (attempt {retry_count + 1}/{max_retries}){_policy_note}..."
|
||||
# Normal retries are buffered to avoid noisy transient chatter. Long
|
||||
# Z.AI Coding waits are different: they can last minutes, so surface
|
||||
# progress immediately instead of making the TUI look frozen.
|
||||
if _backoff_policy == "zai_coding_overload_long":
|
||||
agent._emit_status(_rate_limit_status)
|
||||
else:
|
||||
agent._buffer_status(_rate_limit_status)
|
||||
else:
|
||||
agent._buffer_status(f"⏳ Retrying in {wait_time:.1f}s (attempt {retry_count}/{max_retries})...")
|
||||
logger.warning(
|
||||
"Retrying API call in %ss (attempt %s/%s) %s error=%s",
|
||||
"Retrying API call in %ss (attempt %s/%s) %s policy=%s error=%s",
|
||||
wait_time,
|
||||
retry_count,
|
||||
max_retries,
|
||||
agent._client_log_context(),
|
||||
_backoff_policy or "default",
|
||||
api_error,
|
||||
)
|
||||
# Sleep in small increments so we can respond to interrupts quickly
|
||||
@@ -3556,10 +3583,12 @@ def run_conversation(
|
||||
while time.time() < sleep_end:
|
||||
if agent._interrupt_requested:
|
||||
agent._vprint(f"{agent.log_prefix}⚡ Interrupt detected during retry wait, aborting.", force=True)
|
||||
_interrupt_text = f"Operation interrupted: retrying API call after error (retry {retry_count}/{max_retries})."
|
||||
close_interrupted_tool_sequence(messages, _interrupt_text)
|
||||
agent._persist_session(messages, conversation_history)
|
||||
agent.clear_interrupt()
|
||||
return {
|
||||
"final_response": f"Operation interrupted: retrying API call after error (retry {retry_count}/{max_retries}).",
|
||||
"final_response": _interrupt_text,
|
||||
"messages": messages,
|
||||
"api_calls": api_call_count,
|
||||
"completed": False,
|
||||
@@ -4492,9 +4521,10 @@ def run_conversation(
|
||||
final_msg = agent._build_assistant_message(assistant_message, finish_reason)
|
||||
|
||||
# Pop thinking-only prefill and empty-response retry
|
||||
# scaffolding before appending the final response. These
|
||||
# internal turns are only for the next API retry and should
|
||||
# not become durable transcript context.
|
||||
# scaffolding before appending either a final response or a
|
||||
# verification-stop follow-up. These internal turns are only
|
||||
# for the next API retry and should not become durable
|
||||
# transcript context.
|
||||
while (
|
||||
messages
|
||||
and isinstance(messages[-1], dict)
|
||||
@@ -4506,6 +4536,44 @@ def run_conversation(
|
||||
):
|
||||
messages.pop()
|
||||
|
||||
try:
|
||||
from agent.verification_stop import (
|
||||
build_verify_on_stop_nudge,
|
||||
verify_on_stop_enabled,
|
||||
)
|
||||
|
||||
if verify_on_stop_enabled():
|
||||
_verify_nudge = build_verify_on_stop_nudge(
|
||||
session_id=getattr(agent, "session_id", None),
|
||||
changed_paths=getattr(agent, "_turn_file_mutation_paths", set()),
|
||||
attempts=getattr(agent, "_verification_stop_nudges", 0),
|
||||
)
|
||||
else:
|
||||
_verify_nudge = None
|
||||
except Exception:
|
||||
logger.debug("verification stop-loop check failed", exc_info=True)
|
||||
_verify_nudge = None
|
||||
|
||||
if _verify_nudge:
|
||||
agent._verification_stop_nudges = (
|
||||
getattr(agent, "_verification_stop_nudges", 0) + 1
|
||||
)
|
||||
final_msg["finish_reason"] = "verification_required"
|
||||
messages.append(final_msg)
|
||||
# Keep the attempted final answer in model history so the
|
||||
# synthetic user nudge preserves role alternation, but do
|
||||
# not surface it to the user as an interim answer. The
|
||||
# whole point of this guard is to prevent premature
|
||||
# "done" claims before checks run.
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": _verify_nudge,
|
||||
"_verification_stop_synthetic": True,
|
||||
})
|
||||
agent._session_messages = messages
|
||||
agent._emit_status("↻ Verification required before finishing")
|
||||
continue
|
||||
|
||||
messages.append(final_msg)
|
||||
|
||||
_turn_exit_reason = f"text_response(finish_reason={finish_reason})"
|
||||
|
||||
@@ -11,7 +11,6 @@ import uuid
|
||||
import re
|
||||
from dataclasses import dataclass, fields, replace
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
from hermes_constants import OPENROUTER_BASE_URL
|
||||
@@ -448,63 +447,6 @@ def get_pool_strategy(provider: str) -> str:
|
||||
DEFAULT_MAX_CONCURRENT_PER_CREDENTIAL = 1
|
||||
|
||||
|
||||
def _write_through_provider_state_to_global_root(
|
||||
provider_id: str, state: Dict[str, Any]
|
||||
) -> None:
|
||||
"""Persist a rotated OAuth ``state`` into the global-root auth.json.
|
||||
|
||||
Best-effort write-through for the multi-profile rotation hazard
|
||||
(#48415 / #43589): nous, openai-codex, and xai-oauth rotate the
|
||||
refresh_token on refresh, so when a profile pool refresh rotates a grant
|
||||
it resolved from the root fallback, the rotated chain must land back in
|
||||
root. Otherwise root keeps a now-revoked refresh token and every other
|
||||
profile reading the stale root grant dies with ``refresh_token_reused`` /
|
||||
``invalid_grant`` once its access token expires.
|
||||
|
||||
Only updates ``providers.<provider_id>`` in the root store; never touches
|
||||
the profile store (the caller already saved that). Swallows all errors — a
|
||||
failed write-through degrades to the pre-existing behavior (root stale), it
|
||||
must never break the profile's own successful save. Mirrors
|
||||
``hermes_cli.auth._write_through_xai_oauth_to_global_root`` (which covers
|
||||
the non-pool xAI refresh path) for the credential-pool refresh path.
|
||||
"""
|
||||
try:
|
||||
global_path = auth_mod._global_auth_file_path()
|
||||
except Exception:
|
||||
return
|
||||
if global_path is None:
|
||||
# Classic mode (profile == root); the profile save already hit root.
|
||||
return
|
||||
# Seat belt: under pytest, refuse to write the real user's
|
||||
# ~/.hermes/auth.json even when HERMES_HOME points at a profile path
|
||||
# (mirrors the read-side guard in _load_global_auth_store). Uses the
|
||||
# unmodified HOME env, not Path.home() which fixtures may monkeypatch.
|
||||
if os.environ.get("PYTEST_CURRENT_TEST"):
|
||||
real_home_env = os.environ.get("HOME", "")
|
||||
if real_home_env:
|
||||
real_root = Path(real_home_env) / ".hermes" / "auth.json"
|
||||
try:
|
||||
if global_path.resolve(strict=False) == real_root.resolve(strict=False):
|
||||
return
|
||||
except Exception:
|
||||
return
|
||||
try:
|
||||
if global_path.exists():
|
||||
global_store = _load_auth_store(global_path)
|
||||
else:
|
||||
global_store = {}
|
||||
if not isinstance(global_store, dict):
|
||||
return
|
||||
_store_provider_state(global_store, provider_id, dict(state), set_active=False)
|
||||
auth_mod._save_auth_store(global_store, global_path)
|
||||
except Exception as exc: # pragma: no cover - best effort
|
||||
logger.debug(
|
||||
"%s pool refresh: write-through to global root failed: %s",
|
||||
provider_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
|
||||
class CredentialPool:
|
||||
def __init__(self, provider: str, entries: List[PooledCredential]):
|
||||
self.provider = provider
|
||||
@@ -858,28 +800,6 @@ class CredentialPool:
|
||||
try:
|
||||
with _auth_store_lock():
|
||||
auth_store = _load_auth_store()
|
||||
# Decide BEFORE writing whether this profile is reading the
|
||||
# grant from the global root (no own providers.<id> block) vs.
|
||||
# genuinely shadowing it. A pool refresh rotates single-use
|
||||
# OAuth refresh tokens, so a profile that resolved the grant
|
||||
# from root MUST write the rotated chain back to root too —
|
||||
# otherwise root keeps a revoked refresh token and every other
|
||||
# profile reading the stale root grant dies with
|
||||
# refresh_token_reused / invalid_grant once its access token
|
||||
# expires. This mirrors the xAI write-through in
|
||||
# hermes_cli.auth._save_xai_oauth_tokens (#43589); the pool
|
||||
# refresh path is the Codex/xAI analog reported in #48415.
|
||||
_wt_provider_id = {
|
||||
"nous": "nous",
|
||||
"openai-codex": "openai-codex",
|
||||
"xai-oauth": "xai-oauth",
|
||||
}.get(self.provider)
|
||||
write_through_to_root = bool(_wt_provider_id) and not (
|
||||
isinstance(auth_store.get("providers"), dict)
|
||||
and isinstance(
|
||||
auth_store["providers"].get(_wt_provider_id), dict
|
||||
)
|
||||
)
|
||||
if self.provider == "nous":
|
||||
state = _load_provider_state(auth_store, "nous")
|
||||
if state is None:
|
||||
@@ -935,10 +855,6 @@ class CredentialPool:
|
||||
return
|
||||
|
||||
_save_auth_store(auth_store)
|
||||
if write_through_to_root and _wt_provider_id:
|
||||
_write_through_provider_state_to_global_root(
|
||||
_wt_provider_id, state
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to sync %s pool entry back to auth store: %s", self.provider, exc)
|
||||
|
||||
|
||||
183
agent/display.py
183
agent/display.py
@@ -6,6 +6,7 @@ Used by AIAgent._execute_tool_calls for CLI feedback.
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
@@ -177,6 +178,167 @@ def _truncate_preview(text: str, max_len: int | None) -> str:
|
||||
return text
|
||||
|
||||
|
||||
_SHELL_SILENT_HEADS = {"cd", "pushd", "popd", "export", "set", "unset", "source", ".", "true", "false", ":"}
|
||||
_SHELL_PIPE_TAIL_HEADS = {"head", "tail", "wc", "sort", "uniq"}
|
||||
|
||||
|
||||
def _shell_basename(head: str) -> str:
|
||||
return head.rsplit("/", 1)[-1] if head else ""
|
||||
|
||||
|
||||
def _split_shell_words(segment: str) -> list[str]:
|
||||
words: list[str] = []
|
||||
buf: list[str] = []
|
||||
quote: str | None = None
|
||||
|
||||
for i, ch in enumerate(segment):
|
||||
if quote:
|
||||
buf.append(ch)
|
||||
if ch == quote and (i == 0 or segment[i - 1] != "\\"):
|
||||
quote = None
|
||||
continue
|
||||
|
||||
if ch in {"'", '"'}:
|
||||
quote = ch
|
||||
buf.append(ch)
|
||||
continue
|
||||
|
||||
if ch.isspace():
|
||||
if buf:
|
||||
words.append("".join(buf))
|
||||
buf = []
|
||||
continue
|
||||
|
||||
buf.append(ch)
|
||||
|
||||
if buf:
|
||||
words.append("".join(buf))
|
||||
|
||||
return words
|
||||
|
||||
|
||||
def _strip_shell_pipe_tail(segment: str) -> str:
|
||||
words = _split_shell_words(segment)
|
||||
out: list[str] = []
|
||||
|
||||
for i, word in enumerate(words):
|
||||
if word == "|" and _shell_basename(words[i + 1] if i + 1 < len(words) else "") in _SHELL_PIPE_TAIL_HEADS:
|
||||
break
|
||||
out.append(word)
|
||||
|
||||
return " ".join(out).strip()
|
||||
|
||||
|
||||
def _split_shell_compound(command: str) -> list[str]:
|
||||
segments: list[str] = []
|
||||
buf: list[str] = []
|
||||
quote: str | None = None
|
||||
i = 0
|
||||
|
||||
while i < len(command):
|
||||
ch = command[i]
|
||||
|
||||
if quote:
|
||||
buf.append(ch)
|
||||
if ch == quote and (i == 0 or command[i - 1] != "\\"):
|
||||
quote = None
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if ch in {"'", '"'}:
|
||||
quote = ch
|
||||
buf.append(ch)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
op_len = 2 if command.startswith("&&", i) or command.startswith("||", i) else 1 if ch in {";", "\n"} else 0
|
||||
if op_len:
|
||||
segment = _strip_shell_pipe_tail("".join(buf).strip())
|
||||
if segment:
|
||||
segments.append(segment)
|
||||
buf = []
|
||||
i += op_len
|
||||
continue
|
||||
|
||||
buf.append(ch)
|
||||
i += 1
|
||||
|
||||
segment = _strip_shell_pipe_tail("".join(buf).strip())
|
||||
if segment:
|
||||
segments.append(segment)
|
||||
|
||||
return segments
|
||||
|
||||
|
||||
def _shell_head_word(segment: str) -> str:
|
||||
words = _split_shell_words(segment)
|
||||
index = 0
|
||||
while index < len(words) and re.match(r"^[A-Za-z_]\w*=", words[index]):
|
||||
index += 1
|
||||
return _shell_basename(words[index] if index < len(words) else "")
|
||||
|
||||
|
||||
def _clean_shell_segment(segment: str) -> str:
|
||||
words = _split_shell_words(segment)
|
||||
out: list[str] = []
|
||||
i = 0
|
||||
while i < len(words):
|
||||
word = words[i]
|
||||
if re.match(r"^\d*(?:>>?|<)$", word):
|
||||
i += 2
|
||||
continue
|
||||
if re.match(r"^\d*(?:>&|<&)\d+$", word) or re.match(r"^\d*>&\d+$", word):
|
||||
i += 1
|
||||
continue
|
||||
out.append(word)
|
||||
i += 1
|
||||
return " ".join(out).strip()
|
||||
|
||||
|
||||
def _is_shell_boundary_echo(segment: str) -> bool:
|
||||
words = _split_shell_words(segment)
|
||||
if _shell_basename(words[0] if words else "") != "echo":
|
||||
return False
|
||||
rest = " ".join(words[1:])
|
||||
return bool(re.search(r"-{2,}|_exit=|(?:^|\s|=)\$[?{]|PIPESTATUS", rest))
|
||||
|
||||
|
||||
def summarize_shell_command(command: str) -> str:
|
||||
"""Compact shell wrapper/plumbing for display while preserving raw command elsewhere."""
|
||||
original = _oneline(command)
|
||||
if not original:
|
||||
return ""
|
||||
|
||||
segments = _split_shell_compound(original)
|
||||
if len(segments) <= 1:
|
||||
return _clean_shell_segment(segments[0] if segments else original) or original
|
||||
|
||||
core: list[str] = []
|
||||
for segment in segments:
|
||||
cleaned = _clean_shell_segment(segment)
|
||||
head = _shell_head_word(cleaned)
|
||||
if cleaned and head not in _SHELL_SILENT_HEADS and not _is_shell_boundary_echo(cleaned):
|
||||
core.append(cleaned)
|
||||
|
||||
if not core:
|
||||
return original
|
||||
if len(core) == 1:
|
||||
return core[0]
|
||||
|
||||
count = len(core) - 1
|
||||
return f"{core[0]} + {count} {'command' if count == 1 else 'commands'}"
|
||||
|
||||
|
||||
def _read_file_line_label(args: dict) -> str:
|
||||
offset = args.get("offset")
|
||||
limit = args.get("limit")
|
||||
if not isinstance(offset, int) or offset <= 0:
|
||||
return ""
|
||||
if not isinstance(limit, int) or limit <= 1:
|
||||
return f"L{offset}"
|
||||
return f"L{offset}-{offset + limit - 1}"
|
||||
|
||||
|
||||
def _delegate_task_goal_parts(tasks: Any, *, per_goal_len: int) -> tuple[int, list[str]]:
|
||||
if not isinstance(tasks, list):
|
||||
return 0, []
|
||||
@@ -253,6 +415,23 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -
|
||||
else:
|
||||
return f"planning {len(todos_arg)} task(s)"
|
||||
|
||||
if tool_name in {"terminal", "execute_code"}:
|
||||
key = "code" if tool_name == "execute_code" else "command"
|
||||
command = args.get(key)
|
||||
if command is None:
|
||||
return None
|
||||
preview = summarize_shell_command(str(command))
|
||||
return _truncate_preview(preview, max_len) if preview else None
|
||||
|
||||
if tool_name == "read_file":
|
||||
path = args.get("path") or args.get("file") or args.get("filepath")
|
||||
if path is None:
|
||||
return None
|
||||
label = Path(str(path).replace("\\", "/")).name or str(path)
|
||||
line_label = _read_file_line_label(args)
|
||||
preview = f"{label} {line_label}".strip()
|
||||
return _truncate_preview(preview, max_len) if preview else None
|
||||
|
||||
if tool_name == "session_search":
|
||||
query = _oneline(args.get("query", ""))
|
||||
return f"recall: \"{query[:25]}{'...' if len(query) > 25 else ''}\""
|
||||
@@ -943,7 +1122,7 @@ def get_cute_tool_message(
|
||||
return _wrap(f"┊ 📄 fetch {_trunc(domain, 35)}{extra} {dur}")
|
||||
return _wrap(f"┊ 📄 fetch pages {dur}")
|
||||
if tool_name == "terminal":
|
||||
return _wrap(f"┊ 💻 $ {_trunc(args.get('command', ''), 42)} {dur}")
|
||||
return _wrap(f"┊ 💻 $ {_trunc(build_tool_preview(tool_name, args) or args.get('command', ''), 42)} {dur}")
|
||||
if tool_name == "process":
|
||||
action = args.get("action", "?")
|
||||
sid = args.get("session_id", "")[:12]
|
||||
@@ -951,7 +1130,7 @@ def get_cute_tool_message(
|
||||
"wait": f"wait {sid}", "kill": f"kill {sid}", "write": f"write {sid}", "submit": f"submit {sid}"}
|
||||
return _wrap(f"┊ ⚙️ proc {labels.get(action, f'{action} {sid}')} {dur}")
|
||||
if tool_name == "read_file":
|
||||
return _wrap(f"┊ 📖 read {_path(args.get('path', ''))} {dur}")
|
||||
return _wrap(f"┊ 📖 read {_trunc(build_tool_preview(tool_name, args) or args.get('path', ''), 42)} {dur}")
|
||||
if tool_name == "write_file":
|
||||
return _wrap(f"┊ ✍️ write {_path(args.get('path', ''))} {dur}")
|
||||
if tool_name == "patch":
|
||||
|
||||
@@ -28,15 +28,35 @@ from __future__ import annotations
|
||||
# (HARDLINE)" and the hermes-agent-dev new-skill salvage reference. Embedded in
|
||||
# the prompt so the agent authors skills the way a maintainer would by hand.
|
||||
_AUTHORING_STANDARDS = """\
|
||||
Follow the Hermes skill-authoring standards exactly:
|
||||
Follow the Hermes skill-authoring standards exactly. These are the same
|
||||
HARDLINE rules a maintainer enforces in review:
|
||||
|
||||
Frontmatter:
|
||||
- name: lowercase-hyphenated, <=64 chars, no spaces.
|
||||
- description: ONE sentence, <=60 characters, ends with a period. State the
|
||||
- description: ONE sentence, **<=60 characters**, ends with a period. State the
|
||||
capability, not the implementation. No marketing words (powerful,
|
||||
comprehensive, seamless, advanced). Do NOT repeat the skill name. If the
|
||||
description contains a colon, wrap the whole value in double quotes.
|
||||
comprehensive, seamless, advanced, robust). Do NOT repeat the skill name. If
|
||||
the description contains a colon, wrap the whole value in double quotes.
|
||||
This is the most-violated rule and it is NOT cosmetic: the system-prompt
|
||||
skill index truncates the description to 60 chars and loads it every
|
||||
session, so anything past char 60 is silently cut and never routes. After
|
||||
you write the description, COUNT the characters; if it is over 60, cut it
|
||||
down before saving — do not ship a sentence and hope.
|
||||
Good (<=60): `Search arXiv papers by keyword, author, or ID.`
|
||||
Bad (123): `A comprehensive skill that lets the agent search arXiv for
|
||||
academic papers using keywords, authors, and categories.`
|
||||
- version: 0.1.0
|
||||
- author: always the literal value `Hermes`. NEVER fill it from the host
|
||||
environment — the OS/login username (e.g. the `user=` line in your
|
||||
environment hints), git config, or any identity you can probe must not be
|
||||
written. Skills get shared and published, so an environment-derived name is
|
||||
a privacy leak the user never opted into; the skill names itself as Hermes.
|
||||
- platforms: declare `[macos]`, `[linux]`, and/or `[windows]` IF the skill
|
||||
uses OS-bound primitives (osascript/apt/systemctl => the matching OS; /proc,
|
||||
os.setsid, signal.SIGKILL => linux; fcntl/termios => POSIX). Prefer fixing it
|
||||
cross-platform first (tempfile.gettempdir(), pathlib.Path, psutil); gate only
|
||||
when the dependency is genuinely platform-bound. Omit the field for portable
|
||||
skills.
|
||||
- metadata.hermes.tags: a few Capitalized, Relevant, Tags.
|
||||
|
||||
Body section order (omit a section only if it genuinely has no content):
|
||||
@@ -52,10 +72,16 @@ Body section order (omit a section only if it genuinely has no content):
|
||||
|
||||
Hermes-tool framing (this is what makes it a skill, not shell docs):
|
||||
- Frame running scripts as "invoke through the `terminal` tool".
|
||||
- Use `read_file` (not cat/head/tail), `search_files` (not grep/find/ls),
|
||||
`patch` (not sed/awk), `web_extract` (not curl-to-scrape),
|
||||
`vision_analyze` for images. Reference these tools by name in backticks.
|
||||
- Do NOT name shell utilities the agent already has wrapped.
|
||||
- Reference Hermes tools by name in backticks: `terminal`, `read_file`,
|
||||
`write_file`, `search_files`, `patch`, `web_extract`, `web_search`,
|
||||
`vision_analyze`, `browser_navigate`, `delegate_task`, `image_generate`,
|
||||
`text_to_speech`, `cronjob`, `memory`, `skill_view`, `execute_code`.
|
||||
- Do NOT name shell utilities the agent already has wrapped: say `read_file`
|
||||
not cat/head/tail, `search_files` not grep/rg/find/ls, `patch` not sed/awk,
|
||||
`web_extract` not curl-to-scrape, `write_file` not echo>file or heredocs.
|
||||
- Third-party CLIs (ffmpeg, gh, an SDK) are fine inside a script file, but the
|
||||
prose still frames them as "invoke through the `terminal` tool". If the
|
||||
skill needs an MCP server, name it and document its setup in Prerequisites.
|
||||
|
||||
Quality bar:
|
||||
- Prefer exact commands, endpoint URLs, function signatures, and config keys
|
||||
@@ -66,7 +92,8 @@ Quality bar:
|
||||
- Don't write a router/index/hub skill that only points at other skills.
|
||||
- Larger scripts/parsers belong in a `scripts/` file (add via
|
||||
`skill_manage` write_file), referenced from SKILL.md by relative path — not
|
||||
inlined for the agent to re-type every run."""
|
||||
inlined for the agent to re-type every run. References go in `references/`,
|
||||
templates in `templates/`."""
|
||||
|
||||
|
||||
def build_learn_prompt(user_request: str) -> str:
|
||||
|
||||
@@ -46,6 +46,39 @@ logger = logging.getLogger(__name__)
|
||||
_SYNC_DRAIN_TIMEOUT_S = 5.0
|
||||
|
||||
|
||||
def normalize_tool_schema(schema: Any) -> Optional[Dict[str, Any]]:
|
||||
"""Return a function-tool dict with a resolvable top-level ``name``.
|
||||
|
||||
Context engines and memory providers expose tool schemas via
|
||||
``get_tool_schemas()``. The expected shape is a bare function schema
|
||||
(``{"name": ..., "description": ..., "parameters": ...}``) which callers
|
||||
wrap as ``{"type": "function", "function": schema}``.
|
||||
|
||||
Some providers instead return an entry that is *already* in OpenAI tool
|
||||
form (``{"type": "function", "function": {"name": ...}}``). Wrapping that
|
||||
a second time produces ``{"type": "function", "function": {"type":
|
||||
"function", "function": {...}}}`` whose ``function`` has no top-level
|
||||
``name``. Strict providers (e.g. DeepSeek) reject the *entire* request
|
||||
with ``tools[N].function: missing field name`` (HTTP 400), so one bad
|
||||
schema disables the whole toolset and breaks every turn (#47707).
|
||||
|
||||
This helper normalizes both shapes to the bare function schema and
|
||||
returns ``None`` for anything without a resolvable name, so callers can
|
||||
skip-with-warning rather than appending a nameless tool.
|
||||
"""
|
||||
if not isinstance(schema, dict):
|
||||
return None
|
||||
# Unwrap an already-wrapped OpenAI tool entry.
|
||||
if schema.get("type") == "function" and isinstance(schema.get("function"), dict):
|
||||
schema = schema["function"]
|
||||
if not isinstance(schema, dict):
|
||||
return None
|
||||
name = schema.get("name", "")
|
||||
if not name or not isinstance(name, str):
|
||||
return None
|
||||
return schema
|
||||
|
||||
|
||||
def memory_provider_tools_enabled(enabled_toolsets: Optional[List[str]]) -> bool:
|
||||
"""Return whether external memory-provider tools should be exposed."""
|
||||
if enabled_toolsets is None:
|
||||
@@ -92,11 +125,17 @@ def inject_memory_provider_tools(agent: Any) -> int:
|
||||
agent.valid_tool_names = valid_tool_names
|
||||
|
||||
added = 0
|
||||
for schema in get_schemas():
|
||||
if not isinstance(schema, dict):
|
||||
for raw_schema in get_schemas():
|
||||
schema = normalize_tool_schema(raw_schema)
|
||||
if schema is None:
|
||||
logger.warning(
|
||||
"Memory provider returned a tool schema with no resolvable "
|
||||
"name; skipping to avoid poisoning the request (%r)",
|
||||
raw_schema,
|
||||
)
|
||||
continue
|
||||
tool_name = schema.get("name", "")
|
||||
if not tool_name or tool_name in existing_tool_names:
|
||||
tool_name = schema["name"]
|
||||
if tool_name in existing_tool_names:
|
||||
continue
|
||||
tools.append({"type": "function", "function": schema})
|
||||
valid_tool_names.add(tool_name)
|
||||
@@ -370,8 +409,11 @@ class MemoryManager:
|
||||
_core_tool_names = set(_HERMES_CORE_TOOLS)
|
||||
|
||||
# Index tool names → provider for routing
|
||||
for schema in provider.get_tool_schemas():
|
||||
tool_name = schema.get("name", "")
|
||||
for raw_schema in provider.get_tool_schemas():
|
||||
schema = normalize_tool_schema(raw_schema)
|
||||
if schema is None:
|
||||
continue
|
||||
tool_name = schema["name"]
|
||||
if tool_name in _core_tool_names:
|
||||
logger.warning(
|
||||
"Memory provider '%s' tool '%s' shadows a reserved core "
|
||||
@@ -658,11 +700,19 @@ class MemoryManager:
|
||||
seen = set()
|
||||
for provider in self._providers:
|
||||
try:
|
||||
for schema in provider.get_tool_schemas():
|
||||
name = schema.get("name", "")
|
||||
for raw_schema in provider.get_tool_schemas():
|
||||
schema = normalize_tool_schema(raw_schema)
|
||||
if schema is None:
|
||||
logger.warning(
|
||||
"Memory provider '%s' returned a tool schema with "
|
||||
"no resolvable name; skipping (%r)",
|
||||
provider.name, raw_schema,
|
||||
)
|
||||
continue
|
||||
name = schema["name"]
|
||||
if name in _core_tool_names:
|
||||
continue
|
||||
if name and name not in seen:
|
||||
if name not in seen:
|
||||
schemas.append(schema)
|
||||
seen.add(name)
|
||||
except Exception as e:
|
||||
|
||||
@@ -279,6 +279,38 @@ def _repair_tool_call_arguments(raw_args: str, tool_name: str = "?") -> str:
|
||||
return "{}"
|
||||
|
||||
|
||||
def close_interrupted_tool_sequence(messages: list, final_response: Any = None) -> bool:
|
||||
"""Append a synthetic assistant turn when an interrupted tail is a tool result.
|
||||
|
||||
A turn cut short by ``/stop`` can leave the transcript ending on a raw
|
||||
``tool`` message (a tool finished, or its execution was cancelled, but the
|
||||
model never streamed a closing assistant turn). Persisting that tail means
|
||||
the next user message lands as ``… tool → user`` — a role-alternation
|
||||
violation that strict providers (Gemini, Claude) react to by hallucinating
|
||||
a continuation of the user's message and ignoring prior context, which
|
||||
reads to the user as "lost context" (#48879).
|
||||
|
||||
``finalize_turn`` closes this on the happy interrupt path, but the
|
||||
retry/backoff/error interrupt aborts in ``conversation_loop`` ``return``
|
||||
early and never reach it — this shared helper closes the sequence on all of
|
||||
them. ``final_response`` is usually empty on an interrupt, so an explicit
|
||||
placeholder is used rather than an empty-content assistant turn.
|
||||
|
||||
Mutates ``messages`` in place. Returns True if a closing turn was appended.
|
||||
"""
|
||||
if not messages:
|
||||
return False
|
||||
last = messages[-1]
|
||||
if not isinstance(last, dict) or last.get("role") != "tool":
|
||||
return False
|
||||
text = final_response if isinstance(final_response, str) else ""
|
||||
messages.append({
|
||||
"role": "assistant",
|
||||
"content": text.strip() or "Operation interrupted.",
|
||||
})
|
||||
return True
|
||||
|
||||
|
||||
def _strip_non_ascii(text: str) -> str:
|
||||
"""Remove non-ASCII characters, replacing with closest ASCII equivalent or removing.
|
||||
|
||||
@@ -431,6 +463,7 @@ def _sanitize_structure_non_ascii(payload: Any) -> bool:
|
||||
|
||||
__all__ = [
|
||||
"_SURROGATE_RE",
|
||||
"close_interrupted_tool_sequence",
|
||||
"_sanitize_surrogates",
|
||||
"_sanitize_structure_surrogates",
|
||||
"_sanitize_messages_surrogates",
|
||||
|
||||
29
agent/pet/generate/__init__.py
Normal file
29
agent/pet/generate/__init__.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""Pet generation — base-draft → hatch pipeline.
|
||||
|
||||
Public surface used by the gateway RPCs, the CLI ``hermes pets generate``
|
||||
command, and tests:
|
||||
|
||||
- :func:`generate_base_drafts` / :func:`hatch_pet` — the two-step flow.
|
||||
- :class:`HatchResult`, :class:`GenerationError`.
|
||||
- :mod:`atlas` — deterministic frame extraction + atlas composition/validation.
|
||||
|
||||
Image generation is delegated to the active reference-capable
|
||||
:class:`~agent.image_gen_provider.ImageGenProvider` (OpenAI gpt-image-2 or Krea);
|
||||
atlas assembly is fully deterministic so it's testable without any API calls.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from agent.pet.generate.imagegen import GenerationError
|
||||
from agent.pet.generate.orchestrate import (
|
||||
HatchResult,
|
||||
generate_base_drafts,
|
||||
hatch_pet,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"GenerationError",
|
||||
"HatchResult",
|
||||
"generate_base_drafts",
|
||||
"hatch_pet",
|
||||
]
|
||||
1183
agent/pet/generate/atlas.py
Normal file
1183
agent/pet/generate/atlas.py
Normal file
File diff suppressed because it is too large
Load Diff
251
agent/pet/generate/imagegen.py
Normal file
251
agent/pet/generate/imagegen.py
Normal file
@@ -0,0 +1,251 @@
|
||||
"""Thin image-generation layer for pet sprites.
|
||||
|
||||
Wraps the active :class:`~agent.image_gen_provider.ImageGenProvider` with the
|
||||
two things sprite generation needs that the agent-facing ``image_generate`` tool
|
||||
doesn't expose: **N variants** (loop) and **reference-image grounding** (so each
|
||||
animation row stays the same character as the chosen base).
|
||||
|
||||
Reference grounding only works on providers that support it — currently OpenAI
|
||||
``gpt-image-2`` (image edits) and Krea (style references). We resolve to one of
|
||||
those and surface a clear, actionable error otherwise rather than silently
|
||||
producing an ungrounded, drifting pet.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Providers that can ground generation on a reference image, in preference order
|
||||
# (Nous Portal → OpenAI → OpenRouter → …). OpenRouter/Nous run a quality-first
|
||||
# model chain and may fall back depending on account access and endpoint behavior,
|
||||
# so fidelity can vary by configured backend + model availability.
|
||||
_REF_CAPABLE = ("nous", "openai", "openai-codex", "openrouter", "krea")
|
||||
|
||||
# Friendly display label per reference-capable provider, surfaced in the desktop
|
||||
# pet-gen picker.
|
||||
_PROVIDER_LABELS: dict[str, str] = {
|
||||
"nous": "Nous Portal",
|
||||
"openrouter": "OpenRouter",
|
||||
"openai": "OpenAI",
|
||||
"openai-codex": "OpenAI (Codex)",
|
||||
"krea": "Krea",
|
||||
}
|
||||
|
||||
|
||||
def _forced_provider_from_env() -> str | None:
|
||||
"""Optional QA override to force a pet-gen backend.
|
||||
|
||||
`HERMES_PET_IMAGE_PROVIDER=<name>` (e.g. `openrouter`) bypasses the normal
|
||||
active/default provider resolution for pet generation only. Unknown values are
|
||||
ignored so existing users are unaffected.
|
||||
"""
|
||||
forced = os.environ.get("HERMES_PET_IMAGE_PROVIDER", "").strip().lower()
|
||||
return forced if forced in _REF_CAPABLE else None
|
||||
|
||||
|
||||
class GenerationError(RuntimeError):
|
||||
"""Raised on any image-generation failure (no provider, API error, IO)."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SpriteProvider:
|
||||
"""Resolved provider plus whether it can take reference images."""
|
||||
|
||||
name: str
|
||||
provider: object
|
||||
supports_references: bool
|
||||
|
||||
|
||||
def _discover() -> None:
|
||||
try:
|
||||
from hermes_cli.plugins import _ensure_plugins_discovered
|
||||
|
||||
_ensure_plugins_discovered()
|
||||
except Exception as exc: # noqa: BLE001 - discovery is best-effort
|
||||
logger.debug("image-gen plugin discovery failed: %s", exc)
|
||||
|
||||
|
||||
def resolve_provider(*, require_references: bool = True, prefer: str | None = None) -> SpriteProvider:
|
||||
"""Pick the image provider to use for sprite work.
|
||||
|
||||
Preference: an explicit *prefer* choice (the desktop pet-gen picker) when it's
|
||||
reference-capable and configured, then the configured/active provider when
|
||||
it's reference-capable, else the first available reference-capable provider.
|
||||
With *require_references* off we fall back to any available provider (used for
|
||||
prompt-only base drafts).
|
||||
"""
|
||||
_discover()
|
||||
from agent.image_gen_registry import get_active_provider, get_provider
|
||||
|
||||
# QA override: force one provider for pet-gen iteration regardless of the
|
||||
# globally active image_gen backend.
|
||||
forced = _forced_provider_from_env()
|
||||
if forced:
|
||||
chosen = get_provider(forced)
|
||||
if chosen is not None and chosen.is_available():
|
||||
return SpriteProvider(name=forced, provider=chosen, supports_references=True)
|
||||
|
||||
# An explicit user pick wins when it's reference-capable and has credentials;
|
||||
# otherwise we ignore it and fall through to the normal resolution.
|
||||
if prefer:
|
||||
chosen = get_provider(prefer)
|
||||
if prefer in _REF_CAPABLE and chosen is not None and chosen.is_available():
|
||||
return SpriteProvider(name=prefer, provider=chosen, supports_references=True)
|
||||
|
||||
# Configured / active provider first.
|
||||
active = None
|
||||
try:
|
||||
active = get_active_provider()
|
||||
except Exception: # noqa: BLE001
|
||||
active = None
|
||||
if active is not None:
|
||||
name = getattr(active, "name", "")
|
||||
if name in _REF_CAPABLE and active.is_available():
|
||||
return SpriteProvider(name=name, provider=active, supports_references=True)
|
||||
|
||||
# Any available reference-capable provider.
|
||||
for name in _REF_CAPABLE:
|
||||
provider = get_provider(name)
|
||||
if provider is not None and provider.is_available():
|
||||
return SpriteProvider(name=name, provider=provider, supports_references=True)
|
||||
|
||||
if not require_references and active is not None and active.is_available():
|
||||
return SpriteProvider(
|
||||
name=getattr(active, "name", "unknown"), provider=active, supports_references=False
|
||||
)
|
||||
|
||||
raise GenerationError(
|
||||
"Pet generation needs an image backend that supports reference images. "
|
||||
"Open `hermes tools` → Image Generation and configure Nous Portal, "
|
||||
"OpenRouter, or OpenAI (gpt-image-2) with an API key."
|
||||
)
|
||||
|
||||
|
||||
def list_sprite_providers() -> list[dict]:
|
||||
"""The reference-capable providers available to pick for pet generation.
|
||||
|
||||
Returns ``[{name, label, default}]`` for every ref-capable provider the user
|
||||
actually has credentials for, in preference order, marking the one
|
||||
:func:`resolve_provider` would choose with no explicit preference. Empty when
|
||||
none is configured (the picker hides itself). Best-effort: discovery hiccups
|
||||
yield an empty list.
|
||||
"""
|
||||
_discover()
|
||||
from agent.image_gen_registry import get_provider
|
||||
|
||||
try:
|
||||
default_name = resolve_provider(require_references=True).name
|
||||
except GenerationError:
|
||||
default_name = ""
|
||||
|
||||
out: list[dict] = []
|
||||
for name in _REF_CAPABLE:
|
||||
provider = get_provider(name)
|
||||
if provider is None or not provider.is_available():
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"name": name,
|
||||
"label": _PROVIDER_LABELS.get(name, name),
|
||||
"default": name == default_name,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _save_local(image_ref: str, *, prefix: str) -> Path:
|
||||
"""Return a local path for *image_ref*, downloading it if it's a URL."""
|
||||
if image_ref.startswith(("http://", "https://")):
|
||||
from agent.image_gen_provider import save_url_image
|
||||
|
||||
return Path(save_url_image(image_ref, prefix=prefix))
|
||||
return Path(image_ref)
|
||||
|
||||
|
||||
def _rejected_background(error: str) -> bool:
|
||||
"""True when a provider error is specifically about the ``background`` param.
|
||||
|
||||
Transparent backgrounds are a per-model capability (e.g. some gpt-image tiers
|
||||
reject ``background=transparent`` outright). We detect that one rejection so
|
||||
we can retry without the flag rather than failing the whole pet — our chroma
|
||||
key pass makes the result transparent regardless.
|
||||
"""
|
||||
lowered = (error or "").lower()
|
||||
return "background" in lowered and ("not supported" in lowered or "transparent" in lowered)
|
||||
|
||||
|
||||
def generate(
|
||||
prompt: str,
|
||||
*,
|
||||
n: int = 1,
|
||||
reference_images: list[Path] | None = None,
|
||||
provider: SpriteProvider | None = None,
|
||||
prefix: str = "pet_gen",
|
||||
aspect_ratio: str = "square",
|
||||
) -> list[Path]:
|
||||
"""Generate *n* sprite images and return their local paths.
|
||||
|
||||
*reference_images* grounds the output on a base image (required for rows).
|
||||
*aspect_ratio* picks the canvas: ``"square"`` for single-character base
|
||||
drafts, ``"landscape"`` for multi-frame row strips (the wider 1536px canvas
|
||||
gives every frame real horizontal room so winged poses don't have to be
|
||||
shrunk to avoid touching their neighbors).
|
||||
We *ask* for a transparent background, but fall back to an opaque generation
|
||||
(cleaned up downstream by the chroma-key pass) on models that reject the
|
||||
flag. Raises :class:`GenerationError` if nothing usable comes back.
|
||||
"""
|
||||
sprite = provider or resolve_provider(require_references=bool(reference_images))
|
||||
if reference_images and not sprite.supports_references:
|
||||
raise GenerationError(
|
||||
f"image backend '{sprite.name}' cannot use reference images; "
|
||||
"configure OpenAI gpt-image-2 or Krea for pet generation"
|
||||
)
|
||||
|
||||
refs = [str(p) for p in (reference_images or [])]
|
||||
|
||||
def _run(extra: dict) -> tuple[Path | None, str]:
|
||||
kwargs: dict = {"aspect_ratio": aspect_ratio, **extra}
|
||||
if refs:
|
||||
# Providers disagree on the ref kwarg name: our OpenRouter/Nous
|
||||
# backends read ``reference_images``, OpenAI's gpt-image-2 reads
|
||||
# ``reference_image_urls``. Send both; each ignores the other.
|
||||
kwargs["reference_images"] = refs
|
||||
kwargs["reference_image_urls"] = refs
|
||||
try:
|
||||
result = sprite.provider.generate(prompt, **kwargs)
|
||||
except Exception as exc: # noqa: BLE001 - normalize provider crashes
|
||||
logger.debug("provider.generate crashed: %s", exc)
|
||||
return None, str(exc)
|
||||
if not isinstance(result, dict) or not result.get("success"):
|
||||
return None, (result or {}).get("error", "unknown error") if isinstance(result, dict) else "no result"
|
||||
image_ref = result.get("image")
|
||||
if not image_ref:
|
||||
return None, "provider returned no image"
|
||||
try:
|
||||
return _save_local(str(image_ref), prefix=prefix), ""
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return None, f"could not save generated image: {exc}"
|
||||
|
||||
out: list[Path] = []
|
||||
last_error = ""
|
||||
allow_transparent = True
|
||||
for _ in range(max(1, n)):
|
||||
path, err = _run({"background": "transparent"} if allow_transparent else {})
|
||||
# Model doesn't support the transparent flag → drop it for this and every
|
||||
# remaining variant (no point re-probing a capability we just disproved).
|
||||
if path is None and allow_transparent and _rejected_background(err):
|
||||
allow_transparent = False
|
||||
path, err = _run({})
|
||||
if path is not None:
|
||||
out.append(path)
|
||||
else:
|
||||
last_error = err
|
||||
|
||||
if not out:
|
||||
raise GenerationError(last_error or "image generation produced no output")
|
||||
return out
|
||||
358
agent/pet/generate/orchestrate.py
Normal file
358
agent/pet/generate/orchestrate.py
Normal file
@@ -0,0 +1,358 @@
|
||||
"""Pet generation orchestration — the base-draft → hatch flow.
|
||||
|
||||
Two steps, mirroring the UX across every surface:
|
||||
|
||||
1. :func:`generate_base_drafts` — a handful of prompt-only "what should this pet
|
||||
look like" variants. Cheap; the user picks one (or retries for a fresh set).
|
||||
2. :func:`hatch_pet` — takes the chosen base and generates one grounded row
|
||||
strip per Hermes state, slices each into frames, composes the atlas, validates
|
||||
it, and writes the pet into the store.
|
||||
|
||||
Splitting it this way bounds cost (4 cheap base calls per round; the ~6 row
|
||||
calls happen once, on the pet you actually keep) and gives each UI a natural
|
||||
preview/loading point.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from agent.pet.generate import atlas, imagegen, prompts
|
||||
from agent.pet.generate.imagegen import GenerationError, SpriteProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# (event, detail) — e.g. ("row", "idle"), ("compose", ""), ("save", "<slug>").
|
||||
ProgressFn = Callable[[str, str], None]
|
||||
|
||||
# Image generations are independent network calls, so we fan them out instead of
|
||||
# blocking on each in turn — a hatch is ~8 row calls that would otherwise run
|
||||
# back-to-back and routinely blow past the client's RPC timeout. Capped so we
|
||||
# don't hammer the provider's rate limit (one cold call can still be slow).
|
||||
_MAX_PARALLEL_GENERATIONS = 4
|
||||
# How many times to (re)generate a single row before accepting a best-effort
|
||||
# slice. Early attempts demand clean per-pose gutters; the last is lenient so a
|
||||
# stubborn row still yields frames instead of dropping out entirely.
|
||||
_ROW_GEN_ATTEMPTS = 3
|
||||
_MIN_FILLED_STATES = 6
|
||||
_REQUIRED_STATES = frozenset({"idle", "running-right", "waving"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HatchResult:
|
||||
"""Outcome of a successful :func:`hatch_pet`."""
|
||||
|
||||
slug: str
|
||||
display_name: str
|
||||
spritesheet: Path
|
||||
states: list[str]
|
||||
validation: dict
|
||||
|
||||
|
||||
def _harden_transparency(path: Path) -> Path:
|
||||
"""Key out any solid backdrop the provider painted; save as an RGBA PNG.
|
||||
|
||||
``background=transparent`` is requested on every call, but image models honor
|
||||
it inconsistently — some still paint a flat (often near-white) backdrop. We
|
||||
run the same chroma-key pass the row extractor uses so every base draft the
|
||||
user picks between (and the reference the rows are grounded on) is a clean
|
||||
cutout. Best-effort: a decode failure leaves the original untouched.
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
try:
|
||||
with Image.open(path) as opened:
|
||||
keyed = atlas.remove_background(opened.convert("RGBA"))
|
||||
# Zero the RGB of any leftover semi-transparent edge pixels so a keyed
|
||||
# draft has no colored halo when composited on the dark UI.
|
||||
keyed = atlas._clear_transparent_rgb(keyed)
|
||||
out = path.with_suffix(".png")
|
||||
keyed.save(out, format="PNG")
|
||||
return out
|
||||
except Exception as exc: # noqa: BLE001 - cosmetic; fall back to the raw image
|
||||
logger.debug("base draft transparency hardening failed for %s: %s", path, exc)
|
||||
return path
|
||||
|
||||
|
||||
def generate_base_drafts(
|
||||
concept: str,
|
||||
*,
|
||||
n: int = 4,
|
||||
style: str = "auto",
|
||||
reference_images: list[Path] | None = None,
|
||||
provider: SpriteProvider | None = None,
|
||||
on_draft: Callable[[int, Path], None] | None = None,
|
||||
is_cancelled: Callable[[], bool] | None = None,
|
||||
) -> list[Path]:
|
||||
"""Generate *n* candidate base looks for *concept*; returns image paths.
|
||||
|
||||
Each draft is hardened to a transparent cutout (see :func:`_harden_transparency`).
|
||||
Drafts are generated concurrently and *on_draft(index, path)* fires as each
|
||||
one finishes (not at the end) so callers can stream previews to the UI
|
||||
instead of leaving it blank until the whole batch is done.
|
||||
|
||||
*is_cancelled*, when supplied, is polled cooperatively: a draft that hasn't
|
||||
started yet is skipped, and once it trips we stop staging/streaming further
|
||||
drafts and cancel any queued work (already-in-flight provider calls can't be
|
||||
hard-killed, but their results are dropped).
|
||||
"""
|
||||
# A user reference image (e.g. their own pet) grounds every draft, so it
|
||||
# needs a reference-capable provider — same requirement as the row passes.
|
||||
refs = reference_images or None
|
||||
sprite = provider or imagegen.resolve_provider(require_references=bool(refs))
|
||||
cancelled = is_cancelled or (lambda: False)
|
||||
|
||||
# Each draft is its own one-shot generation, run concurrently so the user
|
||||
# waits for one image, not N. A single draft failing must not sink the set.
|
||||
# Each gets a distinct variation nudge so the options aren't near-duplicates.
|
||||
logger.info("pet generate: drafting %d base looks for %r (style=%s)", n, concept, style)
|
||||
|
||||
def _one(index: int) -> tuple[int, Path | None, str | None]:
|
||||
if cancelled():
|
||||
return index, None, None
|
||||
t0 = time.monotonic()
|
||||
variation = prompts.BASE_VARIATIONS[index % len(prompts.BASE_VARIATIONS)]
|
||||
prompt = prompts.build_base_prompt(concept, style=style, variation=variation)
|
||||
try:
|
||||
out = imagegen.generate(prompt, n=1, reference_images=refs, provider=sprite, prefix="pet_base")
|
||||
except Exception as exc: # noqa: BLE001 - tolerate a single failed draft
|
||||
logger.warning("pet generate: draft %d failed after %.1fs: %s", index, time.monotonic() - t0, exc)
|
||||
return index, None, str(exc)
|
||||
if not out:
|
||||
logger.warning("pet generate: draft %d produced no image", index)
|
||||
return index, None, "the image provider returned no image"
|
||||
logger.info("pet generate: draft %d ready in %.1fs", index, time.monotonic() - t0)
|
||||
return index, _harden_transparency(out[0]), None
|
||||
|
||||
workers = max(1, min(n, _MAX_PARALLEL_GENERATIONS))
|
||||
results: dict[int, Path] = {}
|
||||
errors: list[str] = []
|
||||
with ThreadPoolExecutor(max_workers=workers) as pool:
|
||||
futures = [pool.submit(_one, i) for i in range(n)]
|
||||
# as_completed runs in *this* (the caller's) thread, so on_draft — and any
|
||||
# gateway event it emits — inherits the request's bound transport, unlike
|
||||
# the worker threads above.
|
||||
for fut in as_completed(futures):
|
||||
if cancelled():
|
||||
logger.info("pet generate: cancelled — dropping remaining drafts")
|
||||
for pending in futures:
|
||||
pending.cancel()
|
||||
break
|
||||
index, path, err = fut.result()
|
||||
if path is None:
|
||||
if err:
|
||||
errors.append(err)
|
||||
continue
|
||||
results[index] = path
|
||||
if on_draft is not None:
|
||||
try:
|
||||
on_draft(index, path)
|
||||
except Exception as exc: # noqa: BLE001 - progress is best-effort
|
||||
logger.debug("on_draft callback failed: %s", exc)
|
||||
|
||||
drafts = [results[i] for i in sorted(results)]
|
||||
if not drafts and not cancelled():
|
||||
# Surface *why* — every draft failed for a reason (a content-policy refusal
|
||||
# on a name like "minion", a provider/auth error, …); the most common one
|
||||
# is the representative cause. Far more useful than "no usable drafts".
|
||||
raise GenerationError(_drafts_failed_reason(errors))
|
||||
return drafts
|
||||
|
||||
|
||||
def _drafts_failed_reason(errors: list[str]) -> str:
|
||||
"""The representative reason a draft round produced nothing, humanized."""
|
||||
if not errors:
|
||||
return "image generation produced no usable drafts"
|
||||
from collections import Counter
|
||||
|
||||
return _humanize_image_error(Counter(errors).most_common(1)[0][0])
|
||||
|
||||
|
||||
def _humanize_image_error(error: str) -> str:
|
||||
"""Turn a raw provider error into a friendly, actionable sentence.
|
||||
|
||||
The big one is moderation: image models refuse trademarked characters and
|
||||
real people (e.g. "minion"), which reads as an opaque 400 otherwise.
|
||||
"""
|
||||
low = error.lower()
|
||||
if any(s in low for s in ("moderation_blocked", "safety system", "content policy", "content_policy")):
|
||||
return (
|
||||
"The image provider blocked this prompt — its safety filter rejects "
|
||||
"trademarked characters and real people. Try an original description."
|
||||
)
|
||||
if any(s in low for s in ("api key", "unauthorized", "401", "auth")):
|
||||
return "The image provider rejected the request — check your API key in Settings → Providers."
|
||||
if "rate limit" in low or "429" in low:
|
||||
return "The image provider is rate-limiting — wait a moment and try again."
|
||||
# Otherwise the first line, trimmed of the noisy provider envelope.
|
||||
return error.splitlines()[0].strip()[:200]
|
||||
|
||||
|
||||
def hatch_pet(
|
||||
*,
|
||||
base_image: str | Path,
|
||||
slug: str,
|
||||
display_name: str = "",
|
||||
description: str = "",
|
||||
concept: str = "",
|
||||
style: str = "auto",
|
||||
on_progress: ProgressFn | None = None,
|
||||
provider: SpriteProvider | None = None,
|
||||
is_cancelled: Callable[[], bool] | None = None,
|
||||
) -> HatchResult:
|
||||
"""Turn an approved base image into a full, installed Hermes pet.
|
||||
|
||||
Generates a grounded row strip per state, extracts frames, composes +
|
||||
validates the atlas, and registers it. The idle row falls back to the base
|
||||
look so the pet always renders. Raises :class:`GenerationError` on failure.
|
||||
|
||||
*is_cancelled*, when supplied, is polled cooperatively: rows that haven't
|
||||
started are skipped, queued rows are cancelled, and once every row is done we
|
||||
abort (raising :class:`GenerationError`) before composing/saving so a stopped
|
||||
hatch never writes a half-built pet.
|
||||
"""
|
||||
base = Path(base_image)
|
||||
if not base.is_file():
|
||||
raise GenerationError(f"base image not found: {base}")
|
||||
|
||||
sprite = provider or imagegen.resolve_provider(require_references=True)
|
||||
progress = on_progress or (lambda *_: None)
|
||||
cancelled = is_cancelled or (lambda: False)
|
||||
label = concept or display_name or slug
|
||||
|
||||
frames_by_state: dict[str, list] = {}
|
||||
total_rows = len(atlas.ROW_SPECS)
|
||||
logger.info("pet hatch %r: generating %d animation rows", slug, total_rows)
|
||||
|
||||
# Generate every state's row strip concurrently — they're independent
|
||||
# grounded calls, so the hatch waits for the slowest row, not their sum. A
|
||||
# single row failing is tolerated (idle is guaranteed below).
|
||||
def _gen_row(spec: tuple[str, int, int]) -> tuple[str, list | None]:
|
||||
state, _row, count = spec
|
||||
if cancelled():
|
||||
return state, None
|
||||
t0 = time.monotonic()
|
||||
last_exc: Exception | None = None
|
||||
# Self-healing: a model occasionally returns a row whose poses are touching
|
||||
# (no clean gutters), which slices badly. We retry such rolls; only the
|
||||
# final attempt falls back to lenient ``auto`` slicing so a stubborn row
|
||||
# still yields *something* rather than dropping the whole row.
|
||||
for attempt in range(_ROW_GEN_ATTEMPTS):
|
||||
if cancelled():
|
||||
return state, None
|
||||
strict = attempt < _ROW_GEN_ATTEMPTS - 1
|
||||
try:
|
||||
strips = imagegen.generate(
|
||||
prompts.build_row_prompt(state, count, label, style=style),
|
||||
n=1,
|
||||
reference_images=[base],
|
||||
provider=sprite,
|
||||
prefix=f"pet_row_{state}",
|
||||
# Wider canvas → each frame gets real horizontal room, so winged
|
||||
# poses keep a full, healthy size and still leave clean gutters.
|
||||
aspect_ratio="landscape",
|
||||
)
|
||||
# ``components`` requires clean per-pose gutters (raises otherwise),
|
||||
# so a touching roll is rejected and regenerated; the last attempt
|
||||
# uses ``auto`` (equal-slot fallback, never raises). Raw (fit=False)
|
||||
# so normalize_cells registers the whole pet at once.
|
||||
method = "components" if strict else "auto"
|
||||
frames = atlas.extract_strip_frames(strips[0], count, method=method, fit=False)
|
||||
logger.info(
|
||||
"pet hatch %r: row %r ready in %.1fs (attempt %d)",
|
||||
slug, state, time.monotonic() - t0, attempt + 1,
|
||||
)
|
||||
return state, frames
|
||||
except Exception as exc: # noqa: BLE001 - retried; one bad row is tolerated
|
||||
last_exc = exc
|
||||
logger.warning(
|
||||
"pet hatch %r: row %r attempt %d/%d failed: %s",
|
||||
slug, state, attempt + 1, _ROW_GEN_ATTEMPTS, exc,
|
||||
)
|
||||
logger.warning(
|
||||
"pet hatch %r: row %r gave up after %.1fs: %s",
|
||||
slug, state, time.monotonic() - t0, last_exc,
|
||||
)
|
||||
return state, None
|
||||
|
||||
# running-left is derived by mirroring running-right (guaranteed-consistent
|
||||
# and one fewer generation), so we don't generate it directly.
|
||||
generated_specs = [spec for spec in atlas.ROW_SPECS if spec[0] != "running-left"]
|
||||
|
||||
workers = max(1, min(len(generated_specs), _MAX_PARALLEL_GENERATIONS))
|
||||
done = 0
|
||||
with ThreadPoolExecutor(max_workers=workers) as pool:
|
||||
futures = [pool.submit(_gen_row, spec) for spec in generated_specs]
|
||||
# as_completed runs on the caller (request) thread, so progress events
|
||||
# emitted here inherit the request transport — unlike the worker threads.
|
||||
for fut in as_completed(futures):
|
||||
if cancelled():
|
||||
logger.info("pet hatch %r: cancelled — dropping remaining rows", slug)
|
||||
for pending in futures:
|
||||
pending.cancel()
|
||||
break
|
||||
state, frames = fut.result()
|
||||
done += 1
|
||||
progress("row", f"{state}:{done}:{total_rows}")
|
||||
if frames:
|
||||
frames_by_state[state] = frames
|
||||
|
||||
if cancelled():
|
||||
raise GenerationError("hatch cancelled")
|
||||
|
||||
# Derive running-left from the approved running-right row (per-frame mirror,
|
||||
# preserving order/timing). Missing running-right is rejected below; a pet
|
||||
# without its canonical walk cycle is a failed hatch, not a shippable mascot.
|
||||
right = frames_by_state.get("running-right")
|
||||
if right:
|
||||
done += 1
|
||||
progress("row", f"running-left:{done}:{total_rows}")
|
||||
frames_by_state["running-left"] = atlas.mirror_frames(right)
|
||||
logger.info("pet hatch %r: row 'running-left' mirrored from running-right", slug)
|
||||
else:
|
||||
logger.warning("pet hatch %r: no running-right to mirror; left walk left empty", slug)
|
||||
|
||||
# Idle is the resting state the renderer falls back to — guarantee it.
|
||||
if not frames_by_state.get("idle"):
|
||||
progress("row", "idle-fallback")
|
||||
frames_by_state["idle"] = [atlas.single_frame(base, fit=False)]
|
||||
|
||||
progress("compose", "")
|
||||
logger.info("pet hatch %r: composing atlas from %d states", slug, len(frames_by_state))
|
||||
# One shared scale + baseline across every state so the pet never slides or
|
||||
# pulses size between frames; compose just packs the normalized cells.
|
||||
sheet = atlas.compose_atlas(atlas.normalize_cells(frames_by_state))
|
||||
validation = atlas.validate_atlas(sheet)
|
||||
if not validation["ok"]:
|
||||
raise GenerationError("; ".join(validation["errors"]) or "atlas validation failed")
|
||||
filled_states = set(validation["filled_states"])
|
||||
missing_required = sorted(_REQUIRED_STATES - filled_states)
|
||||
if missing_required:
|
||||
raise GenerationError(f"missing required animation row(s): {', '.join(missing_required)}")
|
||||
if len(filled_states) < _MIN_FILLED_STATES:
|
||||
raise GenerationError(
|
||||
f"only {len(filled_states)}/{len(atlas.ROW_SPECS)} animation rows were usable; regenerate"
|
||||
)
|
||||
|
||||
from agent.pet import store
|
||||
|
||||
progress("save", slug)
|
||||
logger.info("pet hatch %r: saving pet", slug)
|
||||
pet = store.register_local_pet(
|
||||
sheet,
|
||||
slug=slug,
|
||||
display_name=display_name or slug,
|
||||
description=description,
|
||||
)
|
||||
return HatchResult(
|
||||
slug=pet.slug,
|
||||
display_name=pet.display_name,
|
||||
spritesheet=pet.spritesheet,
|
||||
states=validation["filled_states"],
|
||||
validation=validation,
|
||||
)
|
||||
183
agent/pet/generate/prompts.py
Normal file
183
agent/pet/generate/prompts.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""Prompt builders for pet generation.
|
||||
|
||||
Two prompt shapes: a *base* prompt (prompt-only, produces the canonical look the
|
||||
user picks between) and per-*state* *row* prompts (grounded on the chosen base,
|
||||
produce one horizontal strip of N poses). Prompts stay concise and
|
||||
sprite-production oriented; the identity lock and "one transparent row" framing
|
||||
matter more than flowery description.
|
||||
|
||||
We generate the full petdex/Codex nine-state set (see
|
||||
:data:`agent.pet.generate.atlas.ROW_SPECS`) so a hatched pet is a valid
|
||||
``petdex submit`` spritesheet.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# What each petdex/Codex state should depict (kept short — these go straight into
|
||||
# the row prompt). Phrased to avoid the common sprite-gen failure modes (detached
|
||||
# effects, motion lines, shadows). Critical distinction: ``running`` is the
|
||||
# *working* state (in place), while ``running-right`` / ``running-left`` are the
|
||||
# actual directional walk/run cycles.
|
||||
STATE_ACTIONS: dict[str, str] = {
|
||||
"idle": "a calm idle loop: subtle breathing, a tiny blink or gentle bob, no big gestures",
|
||||
"running-right": (
|
||||
"a sideways walk/run locomotion cycle moving to the RIGHT: the character "
|
||||
"faces and travels right with clear directional steps, a smooth gait loop"
|
||||
),
|
||||
"running-left": (
|
||||
"a sideways walk/run locomotion cycle moving to the LEFT: the character "
|
||||
"faces and travels left with clear directional steps (the mirror of the "
|
||||
"right-facing run)"
|
||||
),
|
||||
"waving": "a friendly greeting: raising a paw/hand/limb to wave, clear up-and-down gesture",
|
||||
"jumping": "a happy celebration jump: anticipation, lift off the ground, peak, and land",
|
||||
"failed": "a sad or deflated reaction: slumped, dejected, small frown — readable but not noisy",
|
||||
"waiting": (
|
||||
"an expectant 'waiting on you' pose: looking up/out as if asking for input "
|
||||
"or approval — distinct from idle and review"
|
||||
),
|
||||
"running": (
|
||||
"focused active work, staying IN PLACE (NOT walking or foot-running): "
|
||||
"leaning in, concentrating, busy 'thinking / processing / typing' energy"
|
||||
),
|
||||
"review": "careful inspection: a focused lean, head tilt, studying something intently",
|
||||
}
|
||||
|
||||
_STYLE_HINTS: dict[str, str] = {
|
||||
# Default to the popular petdex look: crisp 16-bit PIXEL ART, not the smooth
|
||||
# 2D illustration (let alone 3D render) gpt-image reaches for by default.
|
||||
"auto": (
|
||||
" Style: crisp 16-bit PIXEL-ART game sprite — visible square pixels, a small "
|
||||
"limited palette, clean dark outline, flat cel shading, chunky chibi "
|
||||
"proportions, like a classic SNES/JRPG party member or a petdex.dev mascot. "
|
||||
"Absolutely NOT 3D-rendered, NOT a smooth painted or vector illustration, "
|
||||
"NOT photorealistic — no soft gradients, no realistic lighting, no figurine look."
|
||||
),
|
||||
"pixel": " Render in clean 16-bit pixel-art style with visible square pixels and a limited palette.",
|
||||
"plush": " Render as a soft plush toy.",
|
||||
"clay": " Render as a claymation / soft 3D clay figure.",
|
||||
"sticker": " Render as a glossy die-cut sticker.",
|
||||
"flat-vector": " Render in flat vector mascot style.",
|
||||
"3d-toy": " Render as a glossy 3D toy.",
|
||||
"painterly": " Render in a soft painterly style.",
|
||||
}
|
||||
|
||||
_BACKGROUND = (
|
||||
"Center the character on a SINGLE flat, uniform, high-contrast chroma-key "
|
||||
"background — pure hot magenta #FF00FF (only if magenta appears on the "
|
||||
"character, use pure green #00FF00 instead). The background is ONE continuous "
|
||||
"even color that completely surrounds the character with NO gradient, "
|
||||
"vignette, texture, pattern, scenery, shadow, ground line, frame, border, "
|
||||
"panel, comic cell, gutter line, grid, or divider of any kind, so it keys out "
|
||||
"cleanly. The background color must not appear anywhere on the character. "
|
||||
"No text, no labels, no speech bubbles, no UI."
|
||||
)
|
||||
|
||||
|
||||
def style_hint(style: str | None) -> str:
|
||||
return _STYLE_HINTS.get((style or "auto").strip().lower(), "")
|
||||
|
||||
|
||||
# Row strips are generated on the wider landscape canvas (see imagegen.generate /
|
||||
# orchestrate). The extra width is what lets each pose stay a healthy size AND
|
||||
# leave a real gutter — used here only to cite concrete pixel numbers.
|
||||
_ASSUMED_STRIP_WIDTH = 1536
|
||||
|
||||
|
||||
def _spacing_spec(frame_count: int) -> tuple[int, int]:
|
||||
"""(per-pose width px, gap px) for a row of *frame_count* poses.
|
||||
|
||||
Pixel counts alone don't hold — the model fills each slot edge-to-edge with
|
||||
the full wingspan, so neighbors touch even when bodies are spaced. The lever
|
||||
that works is proportional containment on a wide canvas: give each pose its
|
||||
own equal cell and keep the ENTIRE silhouette (wings/tail/halo included)
|
||||
inside it. On the 1536px landscape strip ~70% occupancy still leaves a
|
||||
generous gutter, so the pet stays a normal, good-looking size — no shrinking.
|
||||
"""
|
||||
slots = max(1, frame_count)
|
||||
slot_w = _ASSUMED_STRIP_WIDTH / slots
|
||||
pose_px = round(slot_w * 0.7)
|
||||
gap_px = max(48, round(slot_w * 0.3))
|
||||
return pose_px, gap_px
|
||||
|
||||
|
||||
# Per-draft nudges so the 4 base options are actually distinct — gpt-image returns
|
||||
# near-duplicates for a single prompt. We vary the *look* (palette, build,
|
||||
# expression, accents), NOT the pose, so the chosen base still grounds clean,
|
||||
# consistent animation rows.
|
||||
BASE_VARIATIONS: tuple[str, ...] = (
|
||||
"",
|
||||
"a distinctly different colour palette and markings",
|
||||
"a heavier, broader silhouette with sturdier proportions",
|
||||
"a different facial structure and expression matching the concept tone, with unique accent/accessory details",
|
||||
"a leaner, taller build and an alternate colour scheme",
|
||||
"bolder, more saturated colours and a stronger expression matching the concept tone",
|
||||
)
|
||||
|
||||
|
||||
def build_base_prompt(concept: str, *, style: str | None = "auto", variation: str = "") -> str:
|
||||
"""The base look: a single, clean, centered full-body mascot.
|
||||
|
||||
*variation* differentiates one draft from the next (see :data:`BASE_VARIATIONS`).
|
||||
"""
|
||||
concept = (concept or "a distinctive mascot creature").strip()
|
||||
nudge = f" Make this design distinct: {variation}." if variation else ""
|
||||
return (
|
||||
f"A stylized mascot pet character: {concept}. "
|
||||
"Honor the requested tone and mood exactly (cute, eerie, scary, menacing, whimsical, etc.) "
|
||||
"while staying non-graphic. "
|
||||
"Compact, whole-body silhouette that reads clearly at small size, "
|
||||
"clear readable facial features, simple consistent palette. "
|
||||
# A neutral, symmetric, at-rest stance makes the cleanest identity anchor
|
||||
"Neutral front-facing standing pose, upright and symmetric, arms/limbs "
|
||||
"relaxed at the sides, feet together on the ground, any cape/accessories "
|
||||
"hanging straight and still."
|
||||
f"{nudge} "
|
||||
f"{_BACKGROUND}{style_hint(style)}"
|
||||
)
|
||||
|
||||
|
||||
def build_row_prompt(state: str, frame_count: int, concept: str, *, style: str | None = "auto") -> str:
|
||||
"""A row strip: *frame_count* poses of the SAME character, left→right.
|
||||
|
||||
The attached base image is the identity source of truth; the prompt locks
|
||||
species, palette, face, and props to it.
|
||||
"""
|
||||
action = STATE_ACTIONS.get(state, "a simple idle pose")
|
||||
concept = (concept or "the mascot").strip()
|
||||
pose_px, gap_px = _spacing_spec(frame_count)
|
||||
return (
|
||||
f"Using the attached reference image as the exact same character "
|
||||
f"(same species, face, colors, markings, proportions, and props), "
|
||||
"preserving the same emotional tone/mood (e.g., scary stays scary, cute stays cute), "
|
||||
f"draw a single WIDE horizontal strip of {frame_count} animation frames showing {action}. "
|
||||
f"LAYOUT: arrange {frame_count} poses in ONE horizontal row at equal spacing, "
|
||||
"each pose centered in its own imaginary equal region. Draw NO panel borders, "
|
||||
"NO comic cells, NO boxes, NO vertical divider/gutter lines, NO grid, NO frame "
|
||||
"outlines between poses — the backdrop is one unbroken flat field behind all of them. "
|
||||
"Fill the WHOLE strip with the SAME single flat chroma-key color as the attached "
|
||||
"reference image's background (identical hue in every frame, no per-pose color shifts). "
|
||||
f"SPACING (critical): draw each pose at a consistent, healthy, clearly "
|
||||
f"visible size (roughly {pose_px}px wide on a {_ASSUMED_STRIP_WIDTH}px "
|
||||
f"strip) — do NOT shrink it tiny — but keep its ENTIRE silhouette "
|
||||
f"(wings, tail, halo, horns, cape, every appendage) fully INSIDE its own "
|
||||
f"cell. Leave at least {gap_px}px of empty chroma-key background between "
|
||||
f"neighboring silhouettes at their closest point (wingtip to wingtip), and "
|
||||
f"the same empty margin before the first pose and after the last. If a wing, "
|
||||
f"cape, or tail would reach into a neighbor, FOLD or angle it inward rather "
|
||||
f"than letting it cross the gap. Silhouettes must NEVER touch, overlap, "
|
||||
f"share a shadow, share a ground line, share motion trails, or merge into "
|
||||
f"one connected shape. "
|
||||
# Registration: a clean sprite sheet keeps the character locked in place
|
||||
# so only the action moves — this is what stops the loop sliding/pulsing.
|
||||
"REGISTRATION (critical): the character is the SAME height and SAME width "
|
||||
"in every frame, drawn at the SAME scale, centered over the SAME point, "
|
||||
"with all feet aligned to the SAME invisible horizontal baseline across the "
|
||||
"whole strip — this baseline is conceptual ONLY: draw NO ground line, floor, "
|
||||
"platform, horizon, or contact shadow beneath the feet. Keep the body's center, size, and stance fixed frame to "
|
||||
"frame — ONLY the limbs/features the action needs may move. Capes, cloaks, "
|
||||
"bags, and scarves stay in the SAME place and shape every frame (no "
|
||||
"swinging, flowing, or drifting) unless the action itself requires it. No "
|
||||
"pose is cropped at the strip edges. "
|
||||
f"{_BACKGROUND}{style_hint(style)}"
|
||||
)
|
||||
@@ -21,6 +21,7 @@ Read-only and unauthenticated; no credentials involved.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
@@ -28,7 +29,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
MANIFEST_URL = "https://petdex.dev/api/manifest"
|
||||
|
||||
_DEFAULT_TIMEOUT = 20.0
|
||||
_DEFAULT_TIMEOUT = 10.0
|
||||
|
||||
# In-process cache for the (large, slow, identical-per-call) manifest. The list
|
||||
# is a static CDN object that barely changes, yet a single session can ask for
|
||||
@@ -38,6 +39,9 @@ _DEFAULT_TIMEOUT = 20.0
|
||||
_MANIFEST_TTL = 300.0
|
||||
_cache: tuple[float, list[ManifestEntry]] | None = None
|
||||
|
||||
_prefetch_lock = threading.Lock()
|
||||
_prefetching = False
|
||||
|
||||
|
||||
def clear_cache() -> None:
|
||||
"""Drop the cached manifest (forces the next fetch to hit the network)."""
|
||||
@@ -45,6 +49,39 @@ def clear_cache() -> None:
|
||||
_cache = None
|
||||
|
||||
|
||||
def _cache_is_warm() -> bool:
|
||||
return _cache is not None and time.monotonic() - _cache[0] < _MANIFEST_TTL
|
||||
|
||||
|
||||
def prefetch(*, timeout: float = _DEFAULT_TIMEOUT) -> None:
|
||||
"""Warm the manifest cache in a daemon thread — idempotent, never blocks.
|
||||
|
||||
The desktop picker calls this when it loads the (instant) local-only gallery
|
||||
so the full petdex catalog is usually cached by the time it's requested,
|
||||
without ever holding up the user's own pets on a network round-trip.
|
||||
"""
|
||||
global _prefetching
|
||||
|
||||
if _cache_is_warm():
|
||||
return
|
||||
|
||||
with _prefetch_lock:
|
||||
if _prefetching:
|
||||
return
|
||||
_prefetching = True
|
||||
|
||||
def _run() -> None:
|
||||
global _prefetching
|
||||
try:
|
||||
fetch_manifest(timeout=timeout)
|
||||
except Exception as exc: # noqa: BLE001 - best-effort warm
|
||||
logger.debug("petdex manifest prefetch failed: %s", exc)
|
||||
finally:
|
||||
_prefetching = False
|
||||
|
||||
threading.Thread(target=_run, name="petdex-prefetch", daemon=True).start()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ManifestEntry:
|
||||
"""A single pet's row in the manifest."""
|
||||
|
||||
@@ -18,6 +18,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
@@ -41,11 +42,16 @@ class InstalledPet:
|
||||
description: str
|
||||
directory: Path
|
||||
spritesheet: Path
|
||||
created_by: str = "" # "generator" for pets hatched locally; "" for petdex installs
|
||||
|
||||
@property
|
||||
def exists(self) -> bool:
|
||||
return self.spritesheet.is_file()
|
||||
|
||||
@property
|
||||
def generated(self) -> bool:
|
||||
return self.created_by == "generator"
|
||||
|
||||
|
||||
def pets_dir() -> Path:
|
||||
"""Return the profile-scoped pets directory (created on demand)."""
|
||||
@@ -113,6 +119,7 @@ def load_pet(slug: str) -> InstalledPet | None:
|
||||
description=str(meta.get("description", "") or ""),
|
||||
directory=directory,
|
||||
spritesheet=_resolve_spritesheet(directory, meta),
|
||||
created_by=str(meta.get("createdBy", "") or ""),
|
||||
)
|
||||
|
||||
|
||||
@@ -197,6 +204,101 @@ def install_pet(slug: str, *, force: bool = False, timeout: float = _DOWNLOAD_TI
|
||||
return pet
|
||||
|
||||
|
||||
def slugify(name: str) -> str:
|
||||
"""Lowercase, hyphenate, and strip a display name into a filesystem slug."""
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", (name or "").strip().lower()).strip("-")
|
||||
return slug or "pet"
|
||||
|
||||
|
||||
def unique_slug(name: str) -> str:
|
||||
"""A :func:`slugify` result that doesn't collide with an existing pet dir."""
|
||||
base = slugify(name)
|
||||
slug = base
|
||||
counter = 2
|
||||
while (pets_dir() / slug).exists():
|
||||
slug = f"{base}-{counter}"
|
||||
counter += 1
|
||||
return slug
|
||||
|
||||
|
||||
def _write_spritesheet(source, dest: Path) -> None:
|
||||
"""Write *source* (PIL image, bytes, or path) as a lossless WebP at *dest*."""
|
||||
if isinstance(source, (bytes, bytearray)):
|
||||
dest.write_bytes(bytes(source))
|
||||
return
|
||||
|
||||
from PIL import Image
|
||||
|
||||
if isinstance(source, (str, Path)):
|
||||
with Image.open(source) as opened:
|
||||
image = opened.convert("RGBA")
|
||||
else:
|
||||
image = source.convert("RGBA")
|
||||
image.save(dest, format="WEBP", lossless=True, quality=100, method=6, exact=True)
|
||||
|
||||
|
||||
def register_local_pet(
|
||||
spritesheet,
|
||||
*,
|
||||
slug: str,
|
||||
display_name: str = "",
|
||||
description: str = "",
|
||||
) -> InstalledPet:
|
||||
"""Write a locally-generated pet into the store and return it.
|
||||
|
||||
*spritesheet* may be a PIL image, raw WebP/PNG bytes, or a path. The pet
|
||||
appears in :func:`installed_pets` immediately, and because :func:`install_pet`
|
||||
returns an already-on-disk pet before consulting the manifest, it can be
|
||||
adopted (``pet.select`` / ``/pet <slug>``) without a manifest entry.
|
||||
"""
|
||||
slug = slugify(slug)
|
||||
directory = pets_dir() / slug
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
sprite_path = directory / "spritesheet.webp"
|
||||
try:
|
||||
_write_spritesheet(spritesheet, sprite_path)
|
||||
except Exception as exc: # noqa: BLE001 - normalize to one error type
|
||||
raise PetStoreError(f"could not write spritesheet for '{slug}': {exc}") from exc
|
||||
|
||||
meta = {
|
||||
"id": slug,
|
||||
"displayName": display_name or slug,
|
||||
"description": description or "",
|
||||
"spritesheetPath": sprite_path.name,
|
||||
"createdBy": "generator",
|
||||
}
|
||||
(directory / "pet.json").write_text(json.dumps(meta, indent=2), encoding="utf-8")
|
||||
|
||||
pet = load_pet(slug)
|
||||
if pet is None or not pet.exists:
|
||||
raise PetStoreError(f"register of generated pet '{slug}' did not produce a spritesheet")
|
||||
return pet
|
||||
|
||||
|
||||
def export_pet(slug: str) -> tuple[str, bytes]:
|
||||
"""Zip an installed pet's folder (pet.json + spritesheet) → (filename, bytes).
|
||||
|
||||
Dotfiles (cached thumbs, backups) are skipped so the archive is a clean,
|
||||
re-importable pet package. Raises :class:`PetStoreError` if not installed.
|
||||
"""
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
root = pets_dir()
|
||||
directory = root / slug.strip()
|
||||
# Guard against traversal: the target must be a direct child of pets_dir.
|
||||
if directory.resolve().parent != root.resolve() or not directory.is_dir():
|
||||
raise PetStoreError(f"pet '{slug}' is not installed")
|
||||
|
||||
name = directory.name
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||
for path in sorted(directory.iterdir()):
|
||||
if path.is_file() and not path.name.startswith("."):
|
||||
archive.write(path, f"{name}/{path.name}")
|
||||
return f"{name}.zip", buf.getvalue()
|
||||
|
||||
|
||||
_THUMB_FRAME_W = 192
|
||||
_THUMB_FRAME_H = 208
|
||||
_THUMB_W = 96 # rendered ~40px; 2x+ keeps it crisp on HiDPI
|
||||
@@ -301,6 +403,15 @@ def remove_pet(slug: str) -> bool:
|
||||
slug = _safe_slug(slug)
|
||||
if not slug:
|
||||
return False
|
||||
|
||||
# The cached thumbnail lives in pets/.thumbs/<slug>.png — OUTSIDE the pet
|
||||
# dir, so rmtree won't catch it. Drop it too, or a later pet that reuses this
|
||||
# slug renders this one's stale thumbnail.
|
||||
try:
|
||||
(_thumbs_dir() / f"{slug}.png").unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
directory = pets_dir() / slug
|
||||
if not directory.is_dir():
|
||||
return False
|
||||
@@ -308,6 +419,55 @@ def remove_pet(slug: str) -> bool:
|
||||
return not directory.exists()
|
||||
|
||||
|
||||
def rename_pet(slug: str, display_name: str) -> str | None:
|
||||
"""Rename a pet's ``displayName`` AND realign its slug/dir to match.
|
||||
|
||||
Generated pets are hatched under a provisional, prompt-derived slug; when
|
||||
the user names the pet on the reveal screen we make that name the real
|
||||
identity so lists/subtitles show what they typed, not the prompt. The dir is
|
||||
renamed to ``slugify(name)`` (and the cached thumbnail moved alongside it)
|
||||
whenever that yields a free, different slug — otherwise the slug is left as
|
||||
is. Returns the resulting slug on success, or ``None`` on failure.
|
||||
"""
|
||||
slug = _safe_slug(slug)
|
||||
display_name = (display_name or "").strip()
|
||||
if not slug or not display_name:
|
||||
return None
|
||||
directory = pets_dir() / slug
|
||||
pet_json = directory / "pet.json"
|
||||
if not pet_json.is_file():
|
||||
return None
|
||||
try:
|
||||
meta = json.loads(pet_json.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
meta = {}
|
||||
if not isinstance(meta, dict):
|
||||
meta = {}
|
||||
meta["displayName"] = display_name
|
||||
|
||||
new_slug = slug
|
||||
desired = slugify(display_name)
|
||||
if desired and desired != slug and not (pets_dir() / desired).exists():
|
||||
try:
|
||||
directory.rename(pets_dir() / desired)
|
||||
try:
|
||||
(_thumbs_dir() / f"{slug}.png").rename(_thumbs_dir() / f"{desired}.png")
|
||||
except OSError:
|
||||
pass
|
||||
directory = pets_dir() / desired
|
||||
pet_json = directory / "pet.json"
|
||||
new_slug = desired
|
||||
meta["id"] = new_slug
|
||||
except OSError:
|
||||
new_slug = slug # keep the provisional slug if the move fails
|
||||
|
||||
try:
|
||||
pet_json.write_text(json.dumps(meta, indent=2), encoding="utf-8")
|
||||
except OSError:
|
||||
return None
|
||||
return new_slug
|
||||
|
||||
|
||||
def _download(url: str, dest: Path, *, timeout: float) -> None:
|
||||
import httpx
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ rate-limited provider concurrently.
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
# Monotonic counter for jitter seed uniqueness within the same process.
|
||||
# Protected by a lock to avoid race conditions in concurrent retry paths
|
||||
@@ -15,6 +16,14 @@ import time
|
||||
_jitter_counter = 0
|
||||
_jitter_lock = threading.Lock()
|
||||
|
||||
# Z.AI Coding Plan's GLM-5.2 endpoint often returns HTTP 429 code 1305
|
||||
# ("The service may be temporarily overloaded...") for otherwise valid
|
||||
# Hermes requests. Short retries tend to hammer the same overloaded window;
|
||||
# after a few normal retries, progressively widen the wait window. Keep the
|
||||
# cap interactive-friendly: a simple TUI message should fail visibly in minutes,
|
||||
# not sit silent for 20+ minutes.
|
||||
_ZAI_CODING_OVERLOAD_LONG_BACKOFF = (30.0, 60.0, 90.0, 120.0)
|
||||
|
||||
|
||||
def jittered_backoff(
|
||||
attempt: int,
|
||||
@@ -55,3 +64,66 @@ def jittered_backoff(
|
||||
jitter = rng.uniform(0, jitter_ratio * delay)
|
||||
|
||||
return delay + jitter
|
||||
|
||||
|
||||
def _error_text(error: Any) -> str:
|
||||
"""Best-effort flattened provider error text for retry classification."""
|
||||
parts = [
|
||||
error,
|
||||
getattr(error, "message", None),
|
||||
getattr(error, "body", None),
|
||||
getattr(error, "response", None),
|
||||
]
|
||||
return " ".join(str(part) for part in parts if part is not None).lower()
|
||||
|
||||
|
||||
def is_zai_coding_overload_error(*, base_url: str | None, model: str | None, error: Any) -> bool:
|
||||
"""Return True for Z.AI Coding Plan transient overload 429s.
|
||||
|
||||
The coding-plan endpoint reports overload as HTTP 429 with body code 1305
|
||||
and message "The service may be temporarily overloaded...". Treat only
|
||||
that narrow shape specially so ordinary quota/billing 429s still fail fast
|
||||
through the existing classifier.
|
||||
"""
|
||||
base = (base_url or "").lower()
|
||||
model_name = (model or "").lower()
|
||||
status = getattr(error, "status_code", None)
|
||||
text = _error_text(error)
|
||||
return (
|
||||
status == 429
|
||||
and "api.z.ai/api/coding/paas/v4" in base
|
||||
and "glm-5.2" in model_name
|
||||
and ("1305" in text or "temporarily overloaded" in text)
|
||||
)
|
||||
|
||||
|
||||
def adaptive_rate_limit_backoff(
|
||||
attempt: int,
|
||||
*,
|
||||
base_url: str | None,
|
||||
model: str | None,
|
||||
error: Any,
|
||||
default_wait: float,
|
||||
short_attempts: int = 3,
|
||||
) -> tuple[float, str | None]:
|
||||
"""Provider-aware rate-limit backoff.
|
||||
|
||||
For most providers this returns ``default_wait`` unchanged. For Z.AI
|
||||
Coding Plan GLM-5.2 overloads, keep the first ``short_attempts`` retries on
|
||||
the normal short exponential schedule, then switch to progressively longer
|
||||
waits (30s → 60s → 90s → 120s, capped) plus light jitter.
|
||||
|
||||
``attempt`` is 1-based, matching the retry loop's logged attempt number.
|
||||
Returns ``(wait_seconds, reason_label)`` where ``reason_label`` is suitable
|
||||
for status/log decoration when a provider-specific policy fired.
|
||||
"""
|
||||
if not is_zai_coding_overload_error(base_url=base_url, model=model, error=error):
|
||||
return default_wait, None
|
||||
if attempt <= short_attempts:
|
||||
return default_wait, "zai_coding_overload_short"
|
||||
|
||||
idx = min(attempt - short_attempts - 1, len(_ZAI_CODING_OVERLOAD_LONG_BACKOFF) - 1)
|
||||
base_delay = _ZAI_CODING_OVERLOAD_LONG_BACKOFF[idx]
|
||||
# A smaller jitter ratio keeps long waits readable while still avoiding
|
||||
# synchronized retry storms across concurrent Hermes sessions.
|
||||
return jittered_backoff(1, base_delay=base_delay, max_delay=base_delay, jitter_ratio=0.2), "zai_coding_overload_long"
|
||||
|
||||
@@ -11,7 +11,8 @@ Pure module-level utilities extracted from ``run_agent.py``:
|
||||
``_append_subdir_hint_to_multimodal`` — envelope helpers for the
|
||||
``{"_multimodal": True, "content": [...], "text_summary": ...}`` dict
|
||||
shape returned by tools like ``computer_use``.
|
||||
* ``_extract_file_mutation_targets`` / ``_extract_error_preview`` —
|
||||
* ``_extract_file_mutation_targets`` / ``_extract_landed_file_mutation_paths`` /
|
||||
``_extract_error_preview`` —
|
||||
per-turn file-mutation verifier inputs.
|
||||
* ``_trajectory_normalize_msg`` — strip image blobs from a message for
|
||||
trajectory saving.
|
||||
@@ -269,6 +270,35 @@ def _extract_file_mutation_targets(tool_name: str, args: Dict[str, Any]) -> List
|
||||
return []
|
||||
|
||||
|
||||
def _extract_landed_file_mutation_paths(
|
||||
tool_name: str,
|
||||
args: Dict[str, Any],
|
||||
result: Any,
|
||||
) -> List[str]:
|
||||
"""Return the concrete file paths a successful mutation reports."""
|
||||
targets = _extract_file_mutation_targets(tool_name, args)
|
||||
if tool_name not in _FILE_MUTATING_TOOLS or not isinstance(result, str):
|
||||
return targets
|
||||
try:
|
||||
data = json.loads(result.strip())
|
||||
except Exception:
|
||||
return targets
|
||||
if not isinstance(data, dict):
|
||||
return targets
|
||||
|
||||
files = data.get("files_modified")
|
||||
if isinstance(files, list):
|
||||
landed = [str(p) for p in files if p]
|
||||
if landed:
|
||||
return landed
|
||||
|
||||
resolved = data.get("resolved_path")
|
||||
if resolved:
|
||||
return [str(resolved)]
|
||||
|
||||
return targets
|
||||
|
||||
|
||||
def _extract_error_preview(result: Any, max_len: int = 180) -> str:
|
||||
"""Pull a one-line error summary out of a tool result for footer display."""
|
||||
text = _multimodal_text_summary(result) if result is not None else ""
|
||||
@@ -411,6 +441,7 @@ __all__ = [
|
||||
"_multimodal_text_summary",
|
||||
"_append_subdir_hint_to_multimodal",
|
||||
"_extract_file_mutation_targets",
|
||||
"_extract_landed_file_mutation_paths",
|
||||
"_extract_error_preview",
|
||||
"_trajectory_normalize_msg",
|
||||
"make_tool_result_message",
|
||||
|
||||
@@ -5,12 +5,47 @@ This transport owns format conversion and normalization — NOT client lifecycle
|
||||
streaming, or the _run_codex_stream() call path.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from agent.transports.base import ProviderTransport
|
||||
from agent.transports.types import NormalizedResponse, ToolCall
|
||||
|
||||
|
||||
def _content_cache_key(instructions: str, tools: Optional[List[Dict[str, Any]]]) -> Optional[str]:
|
||||
"""Content-address the prompt cache key from the static request prefix.
|
||||
|
||||
Returns ``pck_<sha256[:24]>`` of (instructions + sorted tool schemas), or
|
||||
None when there is nothing static to key on. The cache key is a routing
|
||||
hint only — never a correctness boundary — so two requests sharing a system
|
||||
prompt and tool set intentionally resolve to the same warm prefix bucket.
|
||||
|
||||
The fix this exists for: recurring cron jobs build session_id as
|
||||
``cron_<id>_<timestamp>``, so using session_id as the cache key made every
|
||||
fire cache-cold. The static prefix (identity + tools) is identical across
|
||||
fires, so hashing it gives a stable key that stays warm within the
|
||||
provider's cache TTL. Sorting tools by name keeps the hash insertion-order
|
||||
independent.
|
||||
"""
|
||||
if not instructions and not tools:
|
||||
return None
|
||||
tools_part = ""
|
||||
if tools:
|
||||
sorted_tools = sorted(
|
||||
(t for t in tools if isinstance(t, dict)),
|
||||
key=lambda t: str(t.get("name") or t.get("type") or ""),
|
||||
)
|
||||
tools_part = json.dumps(
|
||||
sorted_tools, sort_keys=True, ensure_ascii=False, separators=(",", ":")
|
||||
)
|
||||
# \x00 separator so instructions ending in the tool JSON can't collide with
|
||||
# a request whose instructions contain that JSON and whose tools are empty.
|
||||
content = f"{instructions or ''}\x00{tools_part}"
|
||||
digest = hashlib.sha256(content.encode("utf-8", errors="replace")).hexdigest()[:24]
|
||||
return f"pck_{digest}"
|
||||
|
||||
|
||||
class ResponsesApiTransport(ProviderTransport):
|
||||
"""Transport for api_mode='codex_responses'.
|
||||
|
||||
@@ -71,7 +106,10 @@ class ResponsesApiTransport(ProviderTransport):
|
||||
params:
|
||||
instructions: str — system prompt (extracted from messages[0] if not given)
|
||||
reasoning_config: dict | None — {effort, enabled}
|
||||
session_id: str | None — used for prompt_cache_key + xAI conv header
|
||||
session_id: str | None — transcript/session id; drives the xAI
|
||||
x-grok-conv-id header and the Codex cache-scope headers, and is
|
||||
the fallback prompt_cache_key when there is no static prefix to
|
||||
content-address
|
||||
max_tokens: int | None — max_output_tokens
|
||||
timeout: float | None — per-request timeout forwarded to the SDK
|
||||
request_overrides: dict | None — extra kwargs merged in
|
||||
@@ -212,10 +250,17 @@ class ResponsesApiTransport(ProviderTransport):
|
||||
kwargs["parallel_tool_calls"] = True
|
||||
|
||||
session_id = params.get("session_id")
|
||||
# prompt_cache_key is content-addressed from the static prefix
|
||||
# (instructions + tools), NOT session_id — recurring cron jobs carry a
|
||||
# per-fire timestamp in session_id (cron_<id>_<ts>) that made every run
|
||||
# cache-cold. session_id is left untouched for transcript isolation and
|
||||
# the cache-scope routing headers below. Falls back to session_id when
|
||||
# there is no static content to hash.
|
||||
cache_key = _content_cache_key(instructions, response_tools) or session_id
|
||||
# xAI Responses takes prompt_cache_key in extra_body (set further
|
||||
# down); GitHub Models opts out of cache-key routing entirely.
|
||||
if not is_github_responses and not is_xai_responses and session_id:
|
||||
kwargs["prompt_cache_key"] = session_id
|
||||
if not is_github_responses and not is_xai_responses and cache_key:
|
||||
kwargs["prompt_cache_key"] = cache_key
|
||||
|
||||
if reasoning_enabled and is_xai_responses:
|
||||
from agent.model_metadata import grok_supports_reasoning_effort
|
||||
@@ -326,7 +371,7 @@ class ResponsesApiTransport(ProviderTransport):
|
||||
merged_extra_body: Dict[str, Any] = {}
|
||||
if isinstance(existing_extra_body, dict):
|
||||
merged_extra_body.update(existing_extra_body)
|
||||
merged_extra_body.setdefault("prompt_cache_key", session_id)
|
||||
merged_extra_body.setdefault("prompt_cache_key", cache_key)
|
||||
kwargs["extra_body"] = merged_extra_body
|
||||
|
||||
return kwargs
|
||||
|
||||
@@ -29,7 +29,10 @@ from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from agent.iteration_budget import IterationBudget
|
||||
from agent.model_metadata import estimate_request_tokens_rough
|
||||
from agent.model_metadata import (
|
||||
estimate_messages_tokens_rough,
|
||||
estimate_request_tokens_rough,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -57,6 +60,34 @@ def _compression_made_progress(
|
||||
return orig_tokens > 0 and new_tokens < orig_tokens * 0.95
|
||||
|
||||
|
||||
def _should_run_preflight_estimate(
|
||||
messages: List[Dict[str, Any]],
|
||||
protect_first_n: int,
|
||||
protect_last_n: int,
|
||||
threshold_tokens: int,
|
||||
) -> bool:
|
||||
"""Cheap gate for the (expensive) full preflight token estimate.
|
||||
|
||||
Returns ``True`` when either:
|
||||
(a) message count exceeds the protected ranges (the historical gate), or
|
||||
(b) a cheap char-based estimate already crosses the configured threshold
|
||||
— the few-but-huge case from issue #27405 that the count-only gate
|
||||
would silently skip (a handful of very large messages never trips
|
||||
the count condition, so compression was never attempted and the
|
||||
turn hit a hard context-overflow error).
|
||||
|
||||
Branch (b) uses ``estimate_messages_tokens_rough`` (the shared char-based
|
||||
estimator) so a single large base64 image isn't mistaken for ~250K tokens.
|
||||
It intentionally undercounts vs. the full request estimate — it omits the
|
||||
system prompt and tool schemas — because it is only a *hint* deciding
|
||||
whether to pay for the authoritative ``estimate_request_tokens_rough``,
|
||||
which (together with ``should_compress``) makes the real decision.
|
||||
"""
|
||||
if len(messages) > protect_first_n + protect_last_n + 1:
|
||||
return True
|
||||
return estimate_messages_tokens_rough(messages) >= threshold_tokens
|
||||
|
||||
|
||||
@dataclass
|
||||
class TurnContext:
|
||||
"""Values produced by the turn prologue and consumed by the turn loop."""
|
||||
@@ -111,7 +142,13 @@ def build_turn_context(
|
||||
# Guard stdio against OSError from broken pipes (systemd/headless/daemon).
|
||||
install_safe_stdio()
|
||||
|
||||
agent._ensure_db_session()
|
||||
# NOTE: the DB session row is created later, AFTER the system prompt is
|
||||
# restored/built (see _ensure_db_session() below the system-prompt block).
|
||||
# Creating it here — before _cached_system_prompt is populated — inserts a
|
||||
# row with system_prompt=NULL on a fresh API/gateway agent that carries
|
||||
# client-managed history, which then trips the "stored system prompt is
|
||||
# null; rebuilding from scratch" warning and a needless first-turn prefix
|
||||
# cache miss. (Issue #45499.)
|
||||
|
||||
# Tell auxiliary_client what the live main provider/model are for this turn.
|
||||
try:
|
||||
@@ -278,6 +315,11 @@ def build_turn_context(
|
||||
|
||||
active_system_prompt = agent._cached_system_prompt
|
||||
|
||||
# Create the DB session row now that _cached_system_prompt is populated, so
|
||||
# the persisted snapshot is written non-NULL on the first turn (Issue
|
||||
# #45499). Idempotent: _ensure_db_session() no-ops once the row exists.
|
||||
agent._ensure_db_session()
|
||||
|
||||
# Crash-resilience: persist the inbound user turn as soon as the session row exists.
|
||||
try:
|
||||
agent._persist_session(messages, conversation_history)
|
||||
@@ -289,10 +331,14 @@ def build_turn_context(
|
||||
)
|
||||
|
||||
# ── Preflight context compression ──
|
||||
if (
|
||||
agent.compression_enabled
|
||||
and len(messages) > agent.context_compressor.protect_first_n
|
||||
+ agent.context_compressor.protect_last_n + 1
|
||||
# Gate the (expensive) full token estimate behind a cheap pre-check.
|
||||
# See ``_should_run_preflight_estimate`` for the OR semantics that fix
|
||||
# issue #27405 (a few very large messages slipping past the count gate).
|
||||
if agent.compression_enabled and _should_run_preflight_estimate(
|
||||
messages,
|
||||
agent.context_compressor.protect_first_n,
|
||||
agent.context_compressor.protect_last_n,
|
||||
agent.context_compressor.threshold_tokens,
|
||||
):
|
||||
_preflight_tokens = estimate_request_tokens_rough(
|
||||
messages,
|
||||
@@ -392,6 +438,8 @@ def build_turn_context(
|
||||
|
||||
# Per-turn file-mutation verifier state.
|
||||
agent._turn_failed_file_mutations = {}
|
||||
agent._turn_file_mutation_paths = set()
|
||||
agent._verification_stop_nudges = 0
|
||||
|
||||
# Record the execution thread so interrupt()/clear_interrupt() can scope
|
||||
# the tool-level interrupt signal to THIS agent's thread only.
|
||||
|
||||
@@ -181,13 +181,9 @@ def finalize_turn(
|
||||
# here instead. On an interrupt ``final_response`` is typically
|
||||
# empty, so fall back to an explicit placeholder rather than
|
||||
# persisting an empty-content assistant turn.
|
||||
if interrupted and messages and messages[-1].get("role") == "tool":
|
||||
messages.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": (final_response or "").strip() or "Operation interrupted.",
|
||||
}
|
||||
)
|
||||
if interrupted:
|
||||
from agent.message_sanitization import close_interrupted_tool_sequence
|
||||
close_interrupted_tool_sequence(messages, final_response)
|
||||
|
||||
agent._persist_session(messages, conversation_history)
|
||||
except Exception as _persist_err:
|
||||
|
||||
618
agent/verification_evidence.py
Normal file
618
agent/verification_evidence.py
Normal file
@@ -0,0 +1,618 @@
|
||||
"""Coding verification evidence ledger.
|
||||
|
||||
This module records what the agent actually proved while working in a code
|
||||
workspace. It is deliberately passive: it never decides to run a suite, never
|
||||
blocks completion, and never upgrades targeted checks into "repo green".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shlex
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
|
||||
_DB_LOCK = threading.Lock()
|
||||
_MAX_OUTPUT_SUMMARY_CHARS = 2000
|
||||
_MAX_EVIDENCE_AGE_DAYS = 30
|
||||
_MAX_EVENTS_PER_SESSION_ROOT = 100
|
||||
_MAX_TOTAL_UNREFERENCED_EVENTS = 10_000
|
||||
_AD_HOC_SCRIPT_NAME_PREFIXES = ("hermes-verify-", "hermes-ad-hoc-")
|
||||
_VERIFY_SCHEMA_VERSION = 1
|
||||
_SHELL_SPLIT_RE = re.compile(r"\s*(?:&&|\|\||;)\s*")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VerificationEvidence:
|
||||
"""A classified command result worth recording."""
|
||||
|
||||
command: str
|
||||
canonical_command: str
|
||||
kind: str
|
||||
scope: str
|
||||
status: str
|
||||
exit_code: int
|
||||
cwd: str
|
||||
root: str
|
||||
session_id: str
|
||||
output_summary: str = ""
|
||||
|
||||
|
||||
def _utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _retention_cutoff() -> str:
|
||||
return (datetime.now(timezone.utc) - timedelta(days=_MAX_EVIDENCE_AGE_DAYS)).isoformat()
|
||||
|
||||
|
||||
def _db_path() -> Path:
|
||||
return get_hermes_home() / "verification_evidence.db"
|
||||
|
||||
|
||||
def _connect() -> sqlite3.Connection:
|
||||
path = _db_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(path)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=5000")
|
||||
conn.row_factory = sqlite3.Row
|
||||
_ensure_schema(conn)
|
||||
return conn
|
||||
|
||||
|
||||
def _ensure_schema(conn: sqlite3.Connection) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS verification_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
cwd TEXT NOT NULL,
|
||||
root TEXT NOT NULL,
|
||||
command TEXT NOT NULL,
|
||||
canonical_command TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
scope TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
exit_code INTEGER NOT NULL,
|
||||
output_summary TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS verification_state (
|
||||
session_id TEXT NOT NULL,
|
||||
root TEXT NOT NULL,
|
||||
last_event_id INTEGER,
|
||||
last_edit_at TEXT,
|
||||
changed_paths_json TEXT NOT NULL DEFAULT '[]',
|
||||
PRIMARY KEY (session_id, root)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_verification_events_session_root
|
||||
ON verification_events(session_id, root, id DESC)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO meta(key, value) VALUES ('schema_version', ?)",
|
||||
(str(_VERIFY_SCHEMA_VERSION),),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _split_segment_tokens(command: str) -> list[list[str]]:
|
||||
segments: list[list[str]] = []
|
||||
for segment in _SHELL_SPLIT_RE.split(command.strip()):
|
||||
if not segment:
|
||||
continue
|
||||
try:
|
||||
tokens = shlex.split(segment)
|
||||
except ValueError:
|
||||
continue
|
||||
if tokens:
|
||||
segments.append(tokens)
|
||||
return segments
|
||||
|
||||
|
||||
def _clean_token(token: str) -> str:
|
||||
token = token.strip()
|
||||
while token.startswith("./"):
|
||||
token = token[2:]
|
||||
return token
|
||||
|
||||
|
||||
def _canonical_tokens(canonical: str) -> list[str]:
|
||||
try:
|
||||
return [_clean_token(t) for t in shlex.split(canonical) if t]
|
||||
except ValueError:
|
||||
return []
|
||||
|
||||
|
||||
def _find_subsequence(tokens: list[str], needle: list[str]) -> Optional[int]:
|
||||
if not tokens or not needle or len(needle) > len(tokens):
|
||||
return None
|
||||
cleaned = [_clean_token(t) for t in tokens]
|
||||
for idx in range(0, len(cleaned) - len(needle) + 1):
|
||||
if cleaned[idx:idx + len(needle)] == needle:
|
||||
return idx
|
||||
return None
|
||||
|
||||
|
||||
def _strip_command_prefix(tokens: list[str]) -> list[str]:
|
||||
"""Remove harmless command prefixes before matching canonical commands."""
|
||||
remaining = list(tokens)
|
||||
if remaining and remaining[0] == "env":
|
||||
remaining = remaining[1:]
|
||||
while remaining and "=" in remaining[0] and not remaining[0].startswith("-"):
|
||||
remaining = remaining[1:]
|
||||
while remaining and remaining[0] in {"command", "time", "noglob"}:
|
||||
remaining = remaining[1:]
|
||||
return remaining
|
||||
|
||||
|
||||
def _equivalent_needles(needle: list[str]) -> list[list[str]]:
|
||||
"""Return command spellings equivalent to the detected canonical command."""
|
||||
candidates = [needle]
|
||||
if len(needle) >= 3 and needle[1] == "run":
|
||||
package_manager = needle[0]
|
||||
script_name = needle[2]
|
||||
if package_manager in {"npm", "pnpm", "yarn", "bun"}:
|
||||
candidates.append([package_manager, script_name])
|
||||
if len(needle) == 1 and "/" in needle[0]:
|
||||
candidates.extend([["bash", needle[0]], ["sh", needle[0]]])
|
||||
if needle == ["pytest"]:
|
||||
candidates.extend(
|
||||
[
|
||||
["python", "-m", "pytest"],
|
||||
["python3", "-m", "pytest"],
|
||||
["uv", "run", "pytest"],
|
||||
["poetry", "run", "pytest"],
|
||||
["pipenv", "run", "pytest"],
|
||||
]
|
||||
)
|
||||
return candidates
|
||||
|
||||
|
||||
def _find_canonical_match(command: str, canonical_commands: list[str]) -> Optional[tuple[str, list[str]]]:
|
||||
"""Return ``(canonical, trailing_args)`` for the first detected command."""
|
||||
|
||||
segments = _split_segment_tokens(command)
|
||||
for canonical in canonical_commands:
|
||||
needle = _canonical_tokens(canonical)
|
||||
if not needle:
|
||||
continue
|
||||
for tokens in segments:
|
||||
candidate_tokens = _strip_command_prefix(tokens)
|
||||
for candidate in _equivalent_needles(needle):
|
||||
if candidate_tokens[:len(candidate)] == candidate:
|
||||
return canonical, candidate_tokens[len(candidate):]
|
||||
return None
|
||||
|
||||
|
||||
def _kind_for_command(canonical: str) -> str:
|
||||
lowered = canonical.lower()
|
||||
if any(word in lowered for word in ("lint", "eslint", "ruff")):
|
||||
return "lint"
|
||||
if any(word in lowered for word in ("typecheck", "tsc", "mypy", "pyright", "ty")):
|
||||
return "typecheck"
|
||||
if "build" in lowered:
|
||||
return "build"
|
||||
if "fmt" in lowered or "format" in lowered:
|
||||
return "format"
|
||||
if "check" in lowered and "test" not in lowered:
|
||||
return "check"
|
||||
return "test"
|
||||
|
||||
|
||||
def _looks_like_target(arg: str) -> bool:
|
||||
if not arg or arg.startswith("-") or "=" in arg:
|
||||
return False
|
||||
return (
|
||||
"/" in arg
|
||||
or "\\" in arg
|
||||
or "::" in arg
|
||||
or arg.endswith((".py", ".js", ".jsx", ".ts", ".tsx", ".rs", ".go", ".java"))
|
||||
or arg.startswith(("test_", "tests", "spec", "__tests__"))
|
||||
)
|
||||
|
||||
|
||||
def _scope_for_args(args: list[str]) -> str:
|
||||
return "targeted" if any(_looks_like_target(arg) for arg in args) else "full"
|
||||
|
||||
|
||||
def _is_under_temp_dir(token: str) -> bool:
|
||||
if not token or token.startswith("-"):
|
||||
return False
|
||||
try:
|
||||
path = Path(token).expanduser()
|
||||
if not path.is_absolute():
|
||||
return False
|
||||
resolved = path.resolve()
|
||||
temp_root = Path(tempfile.gettempdir()).resolve()
|
||||
return resolved == temp_root or temp_root in resolved.parents
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _is_under_root(token: str, root: str | Path | None) -> bool:
|
||||
if not root:
|
||||
return False
|
||||
try:
|
||||
path = Path(token).expanduser().resolve()
|
||||
root_path = Path(root).expanduser().resolve()
|
||||
return path == root_path or root_path in path.parents
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _is_temp_script_path(token: str, root: str | Path | None) -> bool:
|
||||
try:
|
||||
name = Path(token).expanduser().name
|
||||
except Exception:
|
||||
return False
|
||||
return (
|
||||
name.startswith(_AD_HOC_SCRIPT_NAME_PREFIXES)
|
||||
and _is_under_temp_dir(token)
|
||||
and not _is_under_root(token, root)
|
||||
)
|
||||
|
||||
|
||||
def _ad_hoc_script_args(tokens: list[str], root: str | Path | None) -> Optional[list[str]]:
|
||||
candidate_tokens = _strip_command_prefix(tokens)
|
||||
if not candidate_tokens:
|
||||
return None
|
||||
command = candidate_tokens[0]
|
||||
if _is_temp_script_path(command, root):
|
||||
return candidate_tokens[1:]
|
||||
if command in {"python", "python3", "node", "bash", "sh", "ruby", "perl"}:
|
||||
for idx, token in enumerate(candidate_tokens[1:], start=1):
|
||||
if token == "--":
|
||||
continue
|
||||
if _is_temp_script_path(token, root):
|
||||
return candidate_tokens[idx + 1:]
|
||||
if not token.startswith("-"):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _find_ad_hoc_match(command: str, root: str | Path | None) -> Optional[list[str]]:
|
||||
for tokens in _split_segment_tokens(command):
|
||||
trailing_args = _ad_hoc_script_args(tokens, root)
|
||||
if trailing_args is not None:
|
||||
return trailing_args
|
||||
return None
|
||||
|
||||
|
||||
def _summarize_output(output: str) -> str:
|
||||
text = (output or "").strip()
|
||||
if len(text) <= _MAX_OUTPUT_SUMMARY_CHARS:
|
||||
return text
|
||||
head = _MAX_OUTPUT_SUMMARY_CHARS // 3
|
||||
tail = _MAX_OUTPUT_SUMMARY_CHARS - head
|
||||
return (
|
||||
text[:head]
|
||||
+ f"\n... [{len(text) - _MAX_OUTPUT_SUMMARY_CHARS} chars omitted] ...\n"
|
||||
+ text[-tail:]
|
||||
)
|
||||
|
||||
|
||||
def _prune_old_events(conn: sqlite3.Connection, *, session_id: str, root: str) -> None:
|
||||
"""Bound ledger growth without deleting the current state pointer."""
|
||||
cutoff = _retention_cutoff()
|
||||
conn.execute(
|
||||
"""
|
||||
DELETE FROM verification_events
|
||||
WHERE session_id = ?
|
||||
AND root = ?
|
||||
AND id NOT IN (
|
||||
SELECT id FROM verification_events
|
||||
WHERE session_id = ? AND root = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
)
|
||||
""",
|
||||
(session_id, root, session_id, root, _MAX_EVENTS_PER_SESSION_ROOT),
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
DELETE FROM verification_state
|
||||
WHERE (
|
||||
last_edit_at IS NOT NULL
|
||||
AND last_edit_at < ?
|
||||
)
|
||||
OR (
|
||||
last_edit_at IS NULL
|
||||
AND last_event_id IN (
|
||||
SELECT id FROM verification_events
|
||||
WHERE created_at < ?
|
||||
)
|
||||
)
|
||||
""",
|
||||
(cutoff, cutoff),
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
DELETE FROM verification_events
|
||||
WHERE created_at < ?
|
||||
AND id NOT IN (
|
||||
SELECT last_event_id FROM verification_state
|
||||
WHERE last_event_id IS NOT NULL
|
||||
)
|
||||
""",
|
||||
(cutoff,),
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
DELETE FROM verification_events
|
||||
WHERE id NOT IN (
|
||||
SELECT id FROM verification_events
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
)
|
||||
AND id NOT IN (
|
||||
SELECT last_event_id FROM verification_state
|
||||
WHERE last_event_id IS NOT NULL
|
||||
)
|
||||
""",
|
||||
(_MAX_TOTAL_UNREFERENCED_EVENTS,),
|
||||
)
|
||||
|
||||
|
||||
def classify_verification_command(
|
||||
command: str,
|
||||
*,
|
||||
cwd: str | Path | None = None,
|
||||
session_id: str | None = None,
|
||||
exit_code: int = 0,
|
||||
output: str = "",
|
||||
) -> Optional[VerificationEvidence]:
|
||||
"""Classify a terminal command as verification evidence, if applicable."""
|
||||
|
||||
if not command or not isinstance(command, str):
|
||||
return None
|
||||
try:
|
||||
from agent.coding_context import project_facts_for
|
||||
|
||||
facts = project_facts_for(cwd)
|
||||
except Exception:
|
||||
facts = None
|
||||
if not facts:
|
||||
return None
|
||||
|
||||
verify_commands = list(facts.get("verifyCommands") or [])
|
||||
match = _find_canonical_match(command, verify_commands)
|
||||
is_ad_hoc = False
|
||||
if match is None and not verify_commands:
|
||||
ad_hoc_args = _find_ad_hoc_match(command, facts.get("root"))
|
||||
if ad_hoc_args is not None:
|
||||
match = ("ad-hoc verification script", ad_hoc_args)
|
||||
is_ad_hoc = True
|
||||
if match is None:
|
||||
return None
|
||||
|
||||
canonical, trailing_args = match
|
||||
return VerificationEvidence(
|
||||
command=command,
|
||||
canonical_command=canonical,
|
||||
kind="ad_hoc" if is_ad_hoc else _kind_for_command(canonical),
|
||||
scope="targeted" if is_ad_hoc else _scope_for_args(trailing_args),
|
||||
status="passed" if int(exit_code) == 0 else "failed",
|
||||
exit_code=int(exit_code),
|
||||
cwd=str(Path(cwd or ".").resolve()),
|
||||
root=str(facts.get("root") or Path(cwd or ".").resolve()),
|
||||
session_id=str(session_id or "default"),
|
||||
output_summary=_summarize_output(output),
|
||||
)
|
||||
|
||||
|
||||
def record_terminal_result(
|
||||
*,
|
||||
command: str,
|
||||
cwd: str | Path | None,
|
||||
session_id: str | None,
|
||||
exit_code: int,
|
||||
output: str = "",
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""Record a foreground terminal result when it is verification evidence."""
|
||||
|
||||
evidence = classify_verification_command(
|
||||
command,
|
||||
cwd=cwd,
|
||||
session_id=session_id,
|
||||
exit_code=exit_code,
|
||||
output=output,
|
||||
)
|
||||
if evidence is None:
|
||||
return None
|
||||
|
||||
created_at = _utc_now()
|
||||
with _DB_LOCK:
|
||||
with _connect() as conn:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO verification_events(
|
||||
created_at, session_id, cwd, root, command, canonical_command,
|
||||
kind, scope, status, exit_code, output_summary
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
created_at,
|
||||
evidence.session_id,
|
||||
evidence.cwd,
|
||||
evidence.root,
|
||||
evidence.command,
|
||||
evidence.canonical_command,
|
||||
evidence.kind,
|
||||
evidence.scope,
|
||||
evidence.status,
|
||||
evidence.exit_code,
|
||||
evidence.output_summary,
|
||||
),
|
||||
)
|
||||
if cur.lastrowid is None:
|
||||
raise RuntimeError("verification event insert did not return an id")
|
||||
event_id = int(cur.lastrowid)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO verification_state(
|
||||
session_id, root, last_event_id, last_edit_at, changed_paths_json
|
||||
) VALUES (?, ?, ?, NULL, '[]')
|
||||
ON CONFLICT(session_id, root) DO UPDATE SET
|
||||
last_event_id = excluded.last_event_id,
|
||||
last_edit_at = NULL,
|
||||
changed_paths_json = '[]'
|
||||
""",
|
||||
(evidence.session_id, evidence.root, event_id),
|
||||
)
|
||||
_prune_old_events(conn, session_id=evidence.session_id, root=evidence.root)
|
||||
conn.commit()
|
||||
|
||||
return {"id": event_id, **evidence.__dict__, "created_at": created_at}
|
||||
|
||||
|
||||
def mark_workspace_edited(
|
||||
*,
|
||||
session_id: str | None,
|
||||
cwd: str | Path | None,
|
||||
paths: list[str] | tuple[str, ...] | None = None,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""Mark verification evidence stale after a successful file edit."""
|
||||
|
||||
try:
|
||||
from agent.coding_context import project_facts_for
|
||||
|
||||
facts = project_facts_for(cwd)
|
||||
except Exception:
|
||||
facts = None
|
||||
if not facts:
|
||||
return None
|
||||
|
||||
sid = str(session_id or "default")
|
||||
root = str(facts.get("root") or Path(cwd or ".").resolve())
|
||||
changed_paths = sorted({str(p) for p in (paths or []) if p})
|
||||
edited_at = _utc_now()
|
||||
|
||||
with _DB_LOCK:
|
||||
with _connect() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT changed_paths_json FROM verification_state
|
||||
WHERE session_id = ? AND root = ?
|
||||
""",
|
||||
(sid, root),
|
||||
).fetchone()
|
||||
existing: set[str] = set()
|
||||
if row is not None:
|
||||
try:
|
||||
existing = set(json.loads(row["changed_paths_json"] or "[]"))
|
||||
except (TypeError, ValueError):
|
||||
existing = set()
|
||||
merged = sorted((existing | set(changed_paths)))[-200:]
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO verification_state(
|
||||
session_id, root, last_event_id, last_edit_at, changed_paths_json
|
||||
) VALUES (?, ?, NULL, ?, ?)
|
||||
ON CONFLICT(session_id, root) DO UPDATE SET
|
||||
last_edit_at = excluded.last_edit_at,
|
||||
changed_paths_json = excluded.changed_paths_json
|
||||
""",
|
||||
(sid, root, edited_at, json.dumps(merged)),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {"session_id": sid, "root": root, "last_edit_at": edited_at, "changed_paths": changed_paths}
|
||||
|
||||
|
||||
def verification_status(
|
||||
*,
|
||||
session_id: str | None,
|
||||
cwd: str | Path | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return the best known verification state for a session/workspace."""
|
||||
|
||||
try:
|
||||
from agent.coding_context import project_facts_for
|
||||
|
||||
facts = project_facts_for(cwd)
|
||||
except Exception:
|
||||
facts = None
|
||||
if not facts:
|
||||
return {"status": "not_applicable", "evidence": None}
|
||||
|
||||
sid = str(session_id or "default")
|
||||
root = str(facts.get("root") or Path(cwd or ".").resolve())
|
||||
with _DB_LOCK:
|
||||
with _connect() as conn:
|
||||
state = conn.execute(
|
||||
"""
|
||||
SELECT last_event_id, last_edit_at, changed_paths_json
|
||||
FROM verification_state
|
||||
WHERE session_id = ? AND root = ?
|
||||
""",
|
||||
(sid, root),
|
||||
).fetchone()
|
||||
if state is None:
|
||||
return {
|
||||
"status": "unverified",
|
||||
"evidence": None,
|
||||
"root": root,
|
||||
"session_id": sid,
|
||||
"changed_paths": [],
|
||||
}
|
||||
event = None
|
||||
if state["last_event_id"] is not None:
|
||||
event = conn.execute(
|
||||
"SELECT * FROM verification_events WHERE id = ?",
|
||||
(state["last_event_id"],),
|
||||
).fetchone()
|
||||
|
||||
changed_paths: list[str] = []
|
||||
try:
|
||||
changed_paths = json.loads(state["changed_paths_json"] or "[]")
|
||||
except (TypeError, ValueError):
|
||||
changed_paths = []
|
||||
|
||||
if event is None:
|
||||
return {
|
||||
"status": "unverified",
|
||||
"evidence": None,
|
||||
"root": root,
|
||||
"session_id": sid,
|
||||
"changed_paths": changed_paths,
|
||||
}
|
||||
|
||||
evidence = dict(event)
|
||||
if state["last_edit_at"] and state["last_edit_at"] > evidence["created_at"]:
|
||||
status = "stale"
|
||||
else:
|
||||
status = evidence["status"]
|
||||
return {
|
||||
"status": status,
|
||||
"evidence": evidence,
|
||||
"root": root,
|
||||
"session_id": sid,
|
||||
"changed_paths": changed_paths,
|
||||
}
|
||||
164
agent/verification_stop.py
Normal file
164
agent/verification_stop.py
Normal file
@@ -0,0 +1,164 @@
|
||||
"""Turn-end verification guard for coding edits.
|
||||
|
||||
This module is intentionally policy-only. It never runs checks itself; it turns
|
||||
the passive verification ledger into a bounded follow-up when the model tries to
|
||||
finish immediately after editing code without fresh evidence.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
||||
_MAX_CHANGED_PATHS_IN_NUDGE = 8
|
||||
|
||||
|
||||
def verify_on_stop_enabled(config: dict[str, Any] | None = None) -> bool:
|
||||
"""Return whether edit -> verify-before-finish behavior is enabled."""
|
||||
env = os.environ.get("HERMES_VERIFY_ON_STOP")
|
||||
if env is not None:
|
||||
return env.strip().lower() not in {"0", "false", "no", "off"}
|
||||
if config is None:
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
config = load_config()
|
||||
except Exception:
|
||||
config = {}
|
||||
agent_cfg = (config or {}).get("agent") if isinstance(config, dict) else None
|
||||
if isinstance(agent_cfg, dict) and "verify_on_stop" in agent_cfg:
|
||||
return bool(agent_cfg.get("verify_on_stop"))
|
||||
return True
|
||||
|
||||
|
||||
def _candidate_cwds(paths: Iterable[str]) -> list[Path]:
|
||||
candidates: list[Path] = []
|
||||
seen: set[str] = set()
|
||||
for raw in paths:
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
path = Path(raw).expanduser()
|
||||
candidate = path if path.is_dir() else path.parent
|
||||
resolved = str(candidate.resolve())
|
||||
except Exception:
|
||||
continue
|
||||
if resolved not in seen:
|
||||
seen.add(resolved)
|
||||
candidates.append(Path(resolved))
|
||||
return candidates
|
||||
|
||||
|
||||
def _verification_snapshot(
|
||||
*,
|
||||
session_id: str | None,
|
||||
changed_paths: list[str],
|
||||
) -> tuple[dict[str, Any], dict[str, Any]] | None:
|
||||
"""Return ``(status, facts)`` for the first edited workspace needing proof."""
|
||||
try:
|
||||
from agent.coding_context import project_facts_for
|
||||
from agent.verification_evidence import verification_status
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
first_snapshot: tuple[dict[str, Any], dict[str, Any]] | None = None
|
||||
for cwd in _candidate_cwds(changed_paths):
|
||||
facts = project_facts_for(cwd)
|
||||
if not facts:
|
||||
continue
|
||||
status = verification_status(session_id=session_id, cwd=cwd)
|
||||
snapshot = (status, facts)
|
||||
if first_snapshot is None:
|
||||
first_snapshot = snapshot
|
||||
if str(status.get("status") or "unverified") != "passed":
|
||||
return snapshot
|
||||
return first_snapshot
|
||||
|
||||
|
||||
def _format_changed_paths(paths: list[str]) -> str:
|
||||
shown = paths[:_MAX_CHANGED_PATHS_IN_NUDGE]
|
||||
lines = [f"- `{path}`" for path in shown]
|
||||
remaining = len(paths) - len(shown)
|
||||
if remaining > 0:
|
||||
lines.append(f"- ... and {remaining} more")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _status_detail(status: dict[str, Any]) -> str:
|
||||
state = str(status.get("status") or "unverified")
|
||||
evidence = status.get("evidence") if isinstance(status.get("evidence"), dict) else None
|
||||
if not evidence:
|
||||
return state
|
||||
|
||||
command = evidence.get("canonical_command") or evidence.get("command")
|
||||
summary = str(evidence.get("output_summary") or "").strip()
|
||||
parts = [state]
|
||||
if command:
|
||||
parts.append(f"last command `{command}`")
|
||||
if summary:
|
||||
max_summary = 1200
|
||||
if len(summary) > max_summary:
|
||||
summary = summary[:max_summary].rstrip() + "\n... [truncated]"
|
||||
parts.append(f"last output:\n{summary}")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def build_verify_on_stop_nudge(
|
||||
*,
|
||||
session_id: str | None,
|
||||
changed_paths: Iterable[str],
|
||||
attempts: int = 0,
|
||||
max_attempts: int = 2,
|
||||
) -> str | None:
|
||||
"""Return a synthetic follow-up when edited code lacks fresh verification."""
|
||||
paths = sorted({str(p) for p in changed_paths if p})
|
||||
if not paths or attempts >= max_attempts:
|
||||
return None
|
||||
|
||||
snapshot = _verification_snapshot(session_id=session_id, changed_paths=paths)
|
||||
if snapshot is None:
|
||||
return None
|
||||
status, facts = snapshot
|
||||
|
||||
verify_commands = [
|
||||
str(cmd).strip()
|
||||
for cmd in (facts.get("verifyCommands") or [])
|
||||
if str(cmd).strip()
|
||||
]
|
||||
|
||||
state = str(status.get("status") or "unverified")
|
||||
if state == "passed":
|
||||
return None
|
||||
|
||||
if verify_commands:
|
||||
command_instruction = (
|
||||
"Run the relevant verification command now ("
|
||||
+ ", ".join(f"`{cmd}`" for cmd in verify_commands[:3])
|
||||
+ (", ..." if len(verify_commands) > 3 else "")
|
||||
+ "), read any failure, repair the code, and summarize what passed."
|
||||
)
|
||||
else:
|
||||
temp_dir = tempfile.gettempdir()
|
||||
command_instruction = (
|
||||
"No canonical test/lint/build command was detected. Create a focused "
|
||||
f"temporary verification script under `{temp_dir}` using an OS-safe "
|
||||
"`tempfile` path with a `hermes-verify-` filename prefix, run it "
|
||||
"against the changed behavior, clean it up when possible, and "
|
||||
"summarize it explicitly as ad-hoc verification rather than suite "
|
||||
"green."
|
||||
)
|
||||
|
||||
return (
|
||||
"[System: You edited code in this turn, but the workspace does not have "
|
||||
"fresh passing verification evidence yet.\n\n"
|
||||
f"Verification status: {_status_detail(status)}\n\n"
|
||||
f"Changed paths:\n{_format_changed_paths(paths)}\n\n"
|
||||
f"{command_instruction} If verification is not possible, explain the "
|
||||
"concrete blocker instead of claiming the work is fully verified.]"
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["build_verify_on_stop_nudge", "verify_on_stop_enabled"]
|
||||
@@ -12,6 +12,7 @@ const {
|
||||
powerMonitor,
|
||||
protocol,
|
||||
safeStorage,
|
||||
screen,
|
||||
session,
|
||||
shell,
|
||||
systemPreferences
|
||||
@@ -56,6 +57,7 @@ const {
|
||||
const { gitRootForIpc } = require('./git-root.cjs')
|
||||
const { worktreesForIpc } = require('./git-worktrees.cjs')
|
||||
const { OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote } = require('./update-remote.cjs')
|
||||
const { resolveBehindCount, shouldCountCommits } = require('./update-count.cjs')
|
||||
const { runRebuildWithRetry } = require('./update-rebuild.cjs')
|
||||
const {
|
||||
buildPosixCleanupScript,
|
||||
@@ -67,6 +69,13 @@ const {
|
||||
uninstallArgsForMode
|
||||
} = require('./desktop-uninstall.cjs')
|
||||
const { isPackagedInstallPath: isPackagedInstallPathUnderRoots } = require('./workspace-cwd.cjs')
|
||||
const {
|
||||
MIN_WIDTH: WINDOW_MIN_WIDTH,
|
||||
MIN_HEIGHT: WINDOW_MIN_HEIGHT,
|
||||
sanitizeWindowState,
|
||||
computeWindowOptions,
|
||||
debounce
|
||||
} = require('./window-state.cjs')
|
||||
const {
|
||||
authModeFromStatus,
|
||||
buildGatewayWsUrl,
|
||||
@@ -320,6 +329,7 @@ const BOOTSTRAP_MARKER_SCHEMA_VERSION = 1
|
||||
|
||||
const DESKTOP_CONNECTION_CONFIG_PATH = path.join(app.getPath('userData'), 'connection.json')
|
||||
const DESKTOP_UPDATE_CONFIG_PATH = path.join(app.getPath('userData'), 'updates.json')
|
||||
const DESKTOP_WINDOW_STATE_PATH = path.join(app.getPath('userData'), 'window-state.json')
|
||||
// active-profile.json records which Hermes profile the desktop launches its
|
||||
// local backend as. When set, startHermes() passes `hermes --profile <name>
|
||||
// dashboard …`, which deterministically pins HERMES_HOME (see
|
||||
@@ -1522,6 +1532,36 @@ function writeDesktopUpdateConfig(config) {
|
||||
writeFileAtomic(DESKTOP_UPDATE_CONFIG_PATH, JSON.stringify(config, null, 2))
|
||||
}
|
||||
|
||||
// ─── Main-window geometry persistence (window-state.json) ──────────────────
|
||||
|
||||
function readWindowState() {
|
||||
try {
|
||||
return sanitizeWindowState(JSON.parse(fs.readFileSync(DESKTOP_WINDOW_STATE_PATH, 'utf8')))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Persist the window's restored (non-maximized) bounds plus its maximized flag.
|
||||
// getNormalBounds() keeps the pre-maximize size, so un-maximizing next session
|
||||
// lands back where the user actually sized the window.
|
||||
function persistWindowState() {
|
||||
if (!mainWindow || mainWindow.isDestroyed() || mainWindow.isMinimized()) return
|
||||
try {
|
||||
const { x, y, width, height } = mainWindow.getNormalBounds()
|
||||
fs.mkdirSync(path.dirname(DESKTOP_WINDOW_STATE_PATH), { recursive: true })
|
||||
writeFileAtomic(
|
||||
DESKTOP_WINDOW_STATE_PATH,
|
||||
JSON.stringify({ x, y, width, height, isMaximized: mainWindow.isMaximized() }, null, 2)
|
||||
)
|
||||
} catch (err) {
|
||||
rememberLog(`[window-state] persist failed: ${err?.message || err}`)
|
||||
}
|
||||
}
|
||||
|
||||
// resized/moved fire many times mid-drag on Linux; debounce to one write.
|
||||
const schedulePersistWindowState = debounce(persistWindowState, 250)
|
||||
|
||||
// Match the backend's source resolution but bias toward a real git checkout.
|
||||
// Dev → SOURCE_REPO_ROOT. Packaged/CLI install → ACTIVE_HERMES_ROOT.
|
||||
// HERMES_DESKTOP_HERMES_ROOT always wins so devs can pin a worktree.
|
||||
@@ -1667,15 +1707,34 @@ async function checkUpdates() {
|
||||
}
|
||||
|
||||
const git = args => runGit(args, { cwd: updateRoot }).then(r => r.stdout.trim())
|
||||
const [currentSha, targetSha, countStr, dirtyStr, currentBranch] = await Promise.all([
|
||||
const [currentSha, targetSha, dirtyStr, currentBranch, shallowStr, mergeBaseStr] = await Promise.all([
|
||||
git(['rev-parse', 'HEAD']),
|
||||
git(['rev-parse', `origin/${branch}`]),
|
||||
git(['rev-list', `HEAD..origin/${branch}`, '--count']),
|
||||
git(['status', '--porcelain']),
|
||||
git(['rev-parse', '--abbrev-ref', 'HEAD'])
|
||||
git(['rev-parse', '--abbrev-ref', 'HEAD']),
|
||||
git(['rev-parse', '--is-shallow-repository']),
|
||||
// merge-base exits non-zero with empty stdout when HEAD shares no common
|
||||
// ancestor with the freshly fetched tip — exactly the shallow-clone case.
|
||||
git(['merge-base', 'HEAD', `origin/${branch}`])
|
||||
])
|
||||
|
||||
const behind = Number.parseInt(countStr, 10) || 0
|
||||
const isShallow = shallowStr === 'true'
|
||||
const hasMergeBase = Boolean(mergeBaseStr)
|
||||
// Only enumerate the commit count when it is meaningful. On a shallow checkout
|
||||
// with no merge-base, `rev-list --count` walks the entire remote ancestry
|
||||
// (thousands of commits, see #51922) and resolveBehindCount discards the
|
||||
// result anyway in favour of a SHA compare — so skip the expensive query.
|
||||
const countStr = shouldCountCommits({ isShallow, hasMergeBase })
|
||||
? await git(['rev-list', `HEAD..origin/${branch}`, '--count'])
|
||||
: ''
|
||||
|
||||
const behind = resolveBehindCount({
|
||||
countStr,
|
||||
currentSha,
|
||||
targetSha,
|
||||
isShallow,
|
||||
hasMergeBase
|
||||
})
|
||||
const commits = behind > 0 ? await readCommitLog(updateRoot, branch) : []
|
||||
|
||||
return {
|
||||
@@ -5523,11 +5582,11 @@ function closePetOverlay() {
|
||||
|
||||
function createWindow() {
|
||||
const icon = getAppIconPath()
|
||||
const savedWindowState = readWindowState()
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1220,
|
||||
height: 800,
|
||||
minWidth: 400,
|
||||
minHeight: 620,
|
||||
...computeWindowOptions(savedWindowState, screen.getAllDisplays()),
|
||||
minWidth: WINDOW_MIN_WIDTH,
|
||||
minHeight: WINDOW_MIN_HEIGHT,
|
||||
title: 'Hermes',
|
||||
// Frameless title bar on every platform so the renderer can paint the
|
||||
// "hide sidebar" button (and other left-side titlebar tools) flush with
|
||||
@@ -5569,6 +5628,8 @@ function createWindow() {
|
||||
}
|
||||
}
|
||||
|
||||
if (savedWindowState?.isMaximized) mainWindow.maximize()
|
||||
|
||||
mainWindow.once('ready-to-show', () => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.show()
|
||||
})
|
||||
@@ -5578,6 +5639,14 @@ function createWindow() {
|
||||
mainWindow.on('will-leave-full-screen', () => sendWindowStateChanged(false))
|
||||
mainWindow.on('leave-full-screen', () => sendWindowStateChanged(false))
|
||||
|
||||
// Reopen where the user left off. resized/moved settle once per drag; close is
|
||||
// the cross-platform backstop, flushed synchronously before the window is gone.
|
||||
mainWindow.on('resized', schedulePersistWindowState)
|
||||
mainWindow.on('moved', schedulePersistWindowState)
|
||||
mainWindow.on('maximize', schedulePersistWindowState)
|
||||
mainWindow.on('unmaximize', schedulePersistWindowState)
|
||||
mainWindow.on('close', () => schedulePersistWindowState.flush())
|
||||
|
||||
// The overlay rides the main window — closing the app's primary window must
|
||||
// tear it down too (otherwise it strands as an orphan that blocks
|
||||
// window-all-closed from quitting on Windows/Linux).
|
||||
|
||||
28
apps/desktop/electron/update-count.cjs
Normal file
28
apps/desktop/electron/update-count.cjs
Normal file
@@ -0,0 +1,28 @@
|
||||
'use strict'
|
||||
|
||||
// Whether `git rev-list HEAD..origin/<branch> --count` produces a meaningful
|
||||
// number worth computing. On a SHALLOW checkout (installer clones with
|
||||
// --depth 1) the local history often shares no merge-base with the freshly
|
||||
// fetched origin tip, so the count enumerates the entire remote ancestry and
|
||||
// returns a bogus huge number (e.g. 12104) — see #51922. resolveBehindCount
|
||||
// discards that bogus count in favour of a SHA compare, so the caller should
|
||||
// SKIP the expensive rev-list entirely in that case rather than run it and
|
||||
// throw the result away.
|
||||
function shouldCountCommits({ isShallow, hasMergeBase }) {
|
||||
return !(isShallow && !hasMergeBase)
|
||||
}
|
||||
|
||||
// Resolve how many commits the local checkout is behind origin for the desktop
|
||||
// update indicator. When the count isn't meaningful (shallow + no merge-base)
|
||||
// fall back to a binary up-to-date check by SHA, exactly like the official-SSH
|
||||
// path in checkUpdates() and the CLI guard in hermes_cli/banner.py. Full clones
|
||||
// (developers / Docker dev images) keep the exact count path unchanged.
|
||||
function resolveBehindCount({ countStr, currentSha, targetSha, isShallow, hasMergeBase }) {
|
||||
if (!shouldCountCommits({ isShallow, hasMergeBase })) {
|
||||
if (currentSha && targetSha && currentSha === targetSha) return 0
|
||||
return 1 // behind by an unknown amount — show a generic "update available"
|
||||
}
|
||||
return Number.parseInt(countStr, 10) || 0
|
||||
}
|
||||
|
||||
module.exports = { resolveBehindCount, shouldCountCommits }
|
||||
79
apps/desktop/electron/update-count.test.cjs
Normal file
79
apps/desktop/electron/update-count.test.cjs
Normal file
@@ -0,0 +1,79 @@
|
||||
'use strict'
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const { resolveBehindCount, shouldCountCommits } = require('./update-count.cjs')
|
||||
|
||||
// FAIL-BEFORE: pre-fix the function did `Number.parseInt(countStr) || 0`
|
||||
// unconditionally, so a shallow checkout with no merge-base surfaced the bogus
|
||||
// rev-list count (e.g. 12104). This asserts the new shallow/no-merge-base branch.
|
||||
test('shallow checkout with no merge-base does NOT trust the bogus rev-list count', () => {
|
||||
assert.equal(resolveBehindCount({
|
||||
countStr: '12104', currentSha: 'aaa', targetSha: 'bbb',
|
||||
isShallow: true, hasMergeBase: false,
|
||||
}), 1)
|
||||
})
|
||||
|
||||
test('shallow checkout with no merge-base but identical SHA reports up-to-date', () => {
|
||||
assert.equal(resolveBehindCount({
|
||||
countStr: '12104', currentSha: 'abc', targetSha: 'abc',
|
||||
isShallow: true, hasMergeBase: false,
|
||||
}), 0)
|
||||
})
|
||||
|
||||
test('shallow checkout WITH a merge-base keeps the exact count (reliable)', () => {
|
||||
assert.equal(resolveBehindCount({
|
||||
countStr: '3', currentSha: 'aaa', targetSha: 'bbb',
|
||||
isShallow: true, hasMergeBase: true,
|
||||
}), 3)
|
||||
})
|
||||
|
||||
test('full (non-shallow) clone keeps the exact count path unchanged', () => {
|
||||
assert.equal(resolveBehindCount({
|
||||
countStr: '7', currentSha: 'aaa', targetSha: 'bbb',
|
||||
isShallow: false, hasMergeBase: true,
|
||||
}), 7)
|
||||
})
|
||||
|
||||
test('up-to-date full clone reports 0', () => {
|
||||
assert.equal(resolveBehindCount({
|
||||
countStr: '0', currentSha: 'x', targetSha: 'x',
|
||||
isShallow: false, hasMergeBase: true,
|
||||
}), 0)
|
||||
})
|
||||
|
||||
test('non-numeric count falls back to 0 (defensive, unchanged behaviour)', () => {
|
||||
assert.equal(resolveBehindCount({
|
||||
countStr: '', currentSha: 'aaa', targetSha: 'bbb',
|
||||
isShallow: false, hasMergeBase: true,
|
||||
}), 0)
|
||||
})
|
||||
|
||||
// shouldCountCommits gates the expensive `rev-list --count` in checkUpdates().
|
||||
// FAIL-BEFORE: in the shallow + no-merge-base case the caller ran rev-list
|
||||
// unconditionally and discarded the bogus result; this predicate lets the
|
||||
// caller SKIP the whole-ancestry enumeration in exactly that case (#51922).
|
||||
test('shallow checkout with no merge-base SKIPS the rev-list count', () => {
|
||||
assert.equal(shouldCountCommits({ isShallow: true, hasMergeBase: false }), false)
|
||||
})
|
||||
|
||||
test('shallow checkout WITH a merge-base still runs the count', () => {
|
||||
assert.equal(shouldCountCommits({ isShallow: true, hasMergeBase: true }), true)
|
||||
})
|
||||
|
||||
test('full (non-shallow) clone always runs the count', () => {
|
||||
assert.equal(shouldCountCommits({ isShallow: false, hasMergeBase: true }), true)
|
||||
assert.equal(shouldCountCommits({ isShallow: false, hasMergeBase: false }), true)
|
||||
})
|
||||
|
||||
// The skip path produces an empty countStr; resolveBehindCount must NOT trust
|
||||
// it and must fall through to the SHA compare (mirrors the live call site).
|
||||
test('skipped-count path resolves via SHA compare, never via empty countStr', () => {
|
||||
assert.equal(resolveBehindCount({
|
||||
countStr: '', currentSha: 'aaa', targetSha: 'bbb',
|
||||
isShallow: true, hasMergeBase: false,
|
||||
}), 1)
|
||||
assert.equal(resolveBehindCount({
|
||||
countStr: '', currentSha: 'same', targetSha: 'same',
|
||||
isShallow: true, hasMergeBase: false,
|
||||
}), 0)
|
||||
})
|
||||
117
apps/desktop/electron/window-state.cjs
Normal file
117
apps/desktop/electron/window-state.cjs
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Pure geometry helpers for window-state.json — restoring the main window's
|
||||
* size, position, and maximized flag across launches. Side-effect-free so the
|
||||
* part that actually matters (rejecting garbage + off-screen bounds) is
|
||||
* unit-testable without booting Electron; main.cjs owns the file I/O and the
|
||||
* live `screen` displays.
|
||||
*/
|
||||
|
||||
// Defaults mirror the historical hardcoded BrowserWindow size; MIN_* mirror its
|
||||
// minWidth/minHeight so a restored size never undershoots what the live window
|
||||
// allows. A fresh install (no saved state) is byte-identical to before.
|
||||
const DEFAULT_WIDTH = 1220
|
||||
const DEFAULT_HEIGHT = 800
|
||||
const MIN_WIDTH = 400
|
||||
const MIN_HEIGHT = 620
|
||||
|
||||
// Keep at least this much of the window over a display work area before we trust
|
||||
// a saved position, so the title bar stays grabbable after a monitor unplugs.
|
||||
const MIN_VISIBLE = 48
|
||||
|
||||
const finite = v => typeof v === 'number' && Number.isFinite(v)
|
||||
const clamp = (v, lo, hi) => Math.max(lo, Math.min(v, hi))
|
||||
|
||||
// Parse raw JSON → clean state, or null if garbage. width/height are required
|
||||
// and floored; x/y survive only as a finite pair; isMaximized is strict.
|
||||
function sanitizeWindowState(raw) {
|
||||
if (!raw || typeof raw !== 'object' || !finite(raw.width) || !finite(raw.height)) return null
|
||||
|
||||
const state = {
|
||||
width: Math.max(MIN_WIDTH, Math.round(raw.width)),
|
||||
height: Math.max(MIN_HEIGHT, Math.round(raw.height)),
|
||||
isMaximized: raw.isMaximized === true
|
||||
}
|
||||
if (finite(raw.x) && finite(raw.y)) {
|
||||
state.x = Math.round(raw.x)
|
||||
state.y = Math.round(raw.y)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
// True when `bounds` overlaps some display's work area by ≥ MIN_VISIBLE on both
|
||||
// axes. `displays` is Electron's screen.getAllDisplays() shape.
|
||||
function onScreen(bounds, displays) {
|
||||
if (!Array.isArray(displays)) return false
|
||||
return displays.some(({ workArea: a } = {}) => {
|
||||
if (!a) return false
|
||||
const x = Math.min(bounds.x + bounds.width, a.x + a.width) - Math.max(bounds.x, a.x)
|
||||
const y = Math.min(bounds.y + bounds.height, a.y + a.height) - Math.max(bounds.y, a.y)
|
||||
return x >= MIN_VISIBLE && y >= MIN_VISIBLE
|
||||
})
|
||||
}
|
||||
|
||||
// Sanitized state (or null) → BrowserWindow size/position options. Always sets
|
||||
// width/height, capped to the largest current display so a size saved on a
|
||||
// since-disconnected bigger monitor can't exceed any screen the user now has.
|
||||
// Sets x/y only when still on-screen; otherwise Electron centers the window.
|
||||
function computeWindowOptions(state, displays) {
|
||||
const opts = {
|
||||
width: finite(state?.width) ? state.width : DEFAULT_WIDTH,
|
||||
height: finite(state?.height) ? state.height : DEFAULT_HEIGHT
|
||||
}
|
||||
|
||||
const cap = (Array.isArray(displays) ? displays : []).reduce(
|
||||
(m, { workArea: a } = {}) =>
|
||||
a && finite(a.width) && finite(a.height)
|
||||
? { width: Math.max(m.width, a.width), height: Math.max(m.height, a.height) }
|
||||
: m,
|
||||
{ width: 0, height: 0 }
|
||||
)
|
||||
if (cap.width && cap.height) {
|
||||
opts.width = clamp(opts.width, MIN_WIDTH, cap.width)
|
||||
opts.height = clamp(opts.height, MIN_HEIGHT, cap.height)
|
||||
}
|
||||
|
||||
if (
|
||||
state &&
|
||||
finite(state.x) &&
|
||||
finite(state.y) &&
|
||||
onScreen({ x: state.x, y: state.y, width: opts.width, height: opts.height }, displays)
|
||||
) {
|
||||
opts.x = state.x
|
||||
opts.y = state.y
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
// Trailing debounce: collapse a burst of resize/move events (Linux fires many
|
||||
// mid-drag) into a single run `delayMs` after the last. `.flush()` runs now and
|
||||
// cancels the pending timer — used on close, before the window is gone.
|
||||
function debounce(fn, delayMs) {
|
||||
let timer = null
|
||||
const debounced = () => {
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(() => {
|
||||
timer = null
|
||||
fn()
|
||||
}, delayMs)
|
||||
}
|
||||
debounced.flush = () => {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
fn()
|
||||
}
|
||||
return debounced
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_WIDTH,
|
||||
DEFAULT_HEIGHT,
|
||||
MIN_WIDTH,
|
||||
MIN_HEIGHT,
|
||||
MIN_VISIBLE,
|
||||
sanitizeWindowState,
|
||||
onScreen,
|
||||
computeWindowOptions,
|
||||
debounce
|
||||
}
|
||||
135
apps/desktop/electron/window-state.test.cjs
Normal file
135
apps/desktop/electron/window-state.test.cjs
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Unit tests for the pure window-state geometry helpers. These cover the logic
|
||||
* that protects the user: garbage rejection, off-screen fallback, oversized
|
||||
* clamping, and the debounce that collapses mid-drag write storms.
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const {
|
||||
DEFAULT_WIDTH,
|
||||
DEFAULT_HEIGHT,
|
||||
MIN_WIDTH,
|
||||
MIN_HEIGHT,
|
||||
sanitizeWindowState,
|
||||
onScreen,
|
||||
computeWindowOptions,
|
||||
debounce
|
||||
} = require('./window-state.cjs')
|
||||
|
||||
// A single 1920×1080 monitor (work area trimmed for the taskbar).
|
||||
const PRIMARY = [{ workArea: { x: 0, y: 0, width: 1920, height: 1040 } }]
|
||||
// A laptop panel left behind after a bigger external monitor is unplugged.
|
||||
const LAPTOP = [{ workArea: { x: 0, y: 0, width: 1366, height: 728 } }]
|
||||
|
||||
// ─── sanitizeWindowState ───────────────────────────────────────────────────
|
||||
|
||||
test('sanitizeWindowState rejects missing/garbage input', () => {
|
||||
for (const bad of [null, undefined, 'nope', 42, {}, { width: 'x', height: 800 }, { width: NaN, height: 800 }, { width: 1000 }]) {
|
||||
assert.equal(sanitizeWindowState(bad), null)
|
||||
}
|
||||
})
|
||||
|
||||
test('sanitizeWindowState keeps a valid full state and rounds HiDPI fractions', () => {
|
||||
assert.deepEqual(sanitizeWindowState({ x: 100.6, y: 50.2, width: 1400.4, height: 900.7, isMaximized: true }), {
|
||||
x: 101,
|
||||
y: 50,
|
||||
width: 1400,
|
||||
height: 901,
|
||||
isMaximized: true
|
||||
})
|
||||
})
|
||||
|
||||
test('sanitizeWindowState floors size to the minimums', () => {
|
||||
const state = sanitizeWindowState({ width: 10, height: 10 })
|
||||
assert.equal(state.width, MIN_WIDTH)
|
||||
assert.equal(state.height, MIN_HEIGHT)
|
||||
})
|
||||
|
||||
test('sanitizeWindowState drops a partial position but keeps the size', () => {
|
||||
assert.deepEqual(sanitizeWindowState({ x: 100, width: 1400, height: 900 }), {
|
||||
width: 1400,
|
||||
height: 900,
|
||||
isMaximized: false
|
||||
})
|
||||
})
|
||||
|
||||
test('sanitizeWindowState treats isMaximized strictly', () => {
|
||||
assert.equal(sanitizeWindowState({ width: 1400, height: 900, isMaximized: 'yes' }).isMaximized, false)
|
||||
})
|
||||
|
||||
// ─── onScreen ──────────────────────────────────────────────────────────────
|
||||
|
||||
test('onScreen accepts a window on the primary or a secondary display', () => {
|
||||
const dual = [...PRIMARY, { workArea: { x: 1920, y: 0, width: 2560, height: 1400 } }]
|
||||
assert.equal(onScreen({ x: 100, y: 100, width: 1220, height: 800 }, PRIMARY), true)
|
||||
assert.equal(onScreen({ x: 2200, y: 200, width: 1220, height: 800 }, dual), true)
|
||||
})
|
||||
|
||||
test('onScreen rejects off-screen, slivers, and bad input', () => {
|
||||
assert.equal(onScreen({ x: 3000, y: 100, width: 1220, height: 800 }, PRIMARY), false) // past right edge
|
||||
assert.equal(onScreen({ x: 100, y: -900, width: 1220, height: 800 }, PRIMARY), false) // above top
|
||||
assert.equal(onScreen({ x: 1910, y: 100, width: 1220, height: 800 }, PRIMARY), false) // ~10px sliver
|
||||
assert.equal(onScreen({ x: 0, y: 0, width: 1220, height: 800 }, []), false)
|
||||
assert.equal(onScreen({ x: 0, y: 0, width: 1220, height: 800 }, null), false)
|
||||
})
|
||||
|
||||
// ─── computeWindowOptions ──────────────────────────────────────────────────
|
||||
|
||||
test('computeWindowOptions falls back to defaults with no saved state', () => {
|
||||
assert.deepEqual(computeWindowOptions(null, PRIMARY), { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT })
|
||||
})
|
||||
|
||||
test('computeWindowOptions restores an on-screen position', () => {
|
||||
const saved = sanitizeWindowState({ x: 200, y: 150, width: 1400, height: 900 })
|
||||
assert.deepEqual(computeWindowOptions(saved, PRIMARY), { width: 1400, height: 900, x: 200, y: 150 })
|
||||
})
|
||||
|
||||
test('computeWindowOptions keeps the size but drops an off-screen position', () => {
|
||||
const saved = sanitizeWindowState({ x: 5000, y: 150, width: 1400, height: 900 })
|
||||
assert.deepEqual(computeWindowOptions(saved, PRIMARY), { width: 1400, height: 900 })
|
||||
})
|
||||
|
||||
test('computeWindowOptions clamps a size larger than the only display', () => {
|
||||
const saved = sanitizeWindowState({ width: 2560, height: 1440 })
|
||||
assert.deepEqual(computeWindowOptions(saved, LAPTOP), { width: 1366, height: 728 })
|
||||
})
|
||||
|
||||
test('computeWindowOptions keeps the MIN floor on a sub-minimum display', () => {
|
||||
const tiny = [{ workArea: { x: 0, y: 0, width: 360, height: 480 } }]
|
||||
const saved = sanitizeWindowState({ width: 2000, height: 1500 })
|
||||
assert.deepEqual(computeWindowOptions(saved, tiny), { width: MIN_WIDTH, height: MIN_HEIGHT })
|
||||
})
|
||||
|
||||
test('computeWindowOptions does not clamp when displays are unknown', () => {
|
||||
const saved = sanitizeWindowState({ width: 2560, height: 1440 })
|
||||
assert.deepEqual(computeWindowOptions(saved, []), { width: 2560, height: 1440 })
|
||||
})
|
||||
|
||||
// ─── debounce ──────────────────────────────────────────────────────────────
|
||||
|
||||
test('debounce coalesces a burst into one trailing run', t => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] })
|
||||
let calls = 0
|
||||
const d = debounce(() => { calls += 1 }, 250)
|
||||
|
||||
d(); d(); d()
|
||||
assert.equal(calls, 0)
|
||||
t.mock.timers.tick(249)
|
||||
assert.equal(calls, 0)
|
||||
t.mock.timers.tick(1)
|
||||
assert.equal(calls, 1)
|
||||
})
|
||||
|
||||
test('debounce.flush runs now and cancels the pending timer', t => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] })
|
||||
let calls = 0
|
||||
const d = debounce(() => { calls += 1 }, 250)
|
||||
|
||||
d()
|
||||
d.flush()
|
||||
assert.equal(calls, 1)
|
||||
t.mock.timers.tick(1000)
|
||||
assert.equal(calls, 1)
|
||||
})
|
||||
@@ -37,7 +37,7 @@
|
||||
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
|
||||
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
|
||||
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/window-state.test.cjs",
|
||||
"typecheck": "tsc -p . --noEmit",
|
||||
"lint": "eslint src/ electron/",
|
||||
"lint:fix": "eslint src/ electron/ --fix",
|
||||
|
||||
@@ -9,9 +9,9 @@ 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'
|
||||
import { $activeSessionId } from '@/store/session'
|
||||
import {
|
||||
$subagentsBySession,
|
||||
allSubagents,
|
||||
buildSubagentTree,
|
||||
type SubagentNode,
|
||||
type SubagentStatus,
|
||||
@@ -77,15 +77,12 @@ interface AgentsViewProps {
|
||||
|
||||
export function AgentsView({ onClose }: AgentsViewProps) {
|
||||
const { t } = useI18n()
|
||||
const activeSessionId = useStore($activeSessionId)
|
||||
const subagentsBySession = useStore($subagentsBySession)
|
||||
|
||||
const activeSubagents = useMemo(
|
||||
() => (activeSessionId ? (subagentsBySession[activeSessionId] ?? []) : []),
|
||||
[activeSessionId, subagentsBySession]
|
||||
)
|
||||
|
||||
const tree = useMemo(() => buildSubagentTree(activeSubagents), [activeSubagents])
|
||||
// Aggregate every session, matching the status-bar indicator — a subagent
|
||||
// running in a background session must still be visible here, or the two
|
||||
// desync ("Agents N running" vs an empty tree).
|
||||
const tree = useMemo(() => buildSubagentTree(allSubagents(subagentsBySession)), [subagentsBySession])
|
||||
|
||||
return (
|
||||
<OverlayView
|
||||
|
||||
@@ -34,6 +34,7 @@ interface InsertRefsDetail {
|
||||
const FOCUS_EVENT = 'hermes:composer-focus'
|
||||
const INSERT_EVENT = 'hermes:composer-insert'
|
||||
const INSERT_REFS_EVENT = 'hermes:composer-insert-refs'
|
||||
const VOICE_TOGGLE_EVENT = 'hermes:composer-voice-toggle'
|
||||
|
||||
let activeTarget: ComposerTarget = 'main'
|
||||
|
||||
@@ -105,6 +106,13 @@ export const requestComposerInsertRefs = (
|
||||
export const onComposerInsertRefsRequest = (handler: (detail: InsertRefsDetail) => void) =>
|
||||
subscribe<InsertRefsDetail>(INSERT_REFS_EVENT, handler)
|
||||
|
||||
/** Toggle the active composer's voice conversation — the `composer.voice`
|
||||
* hotkey (Ctrl+B) reaching into the composer that owns the voice state. */
|
||||
export const requestVoiceToggle = () => dispatch<{ at: number }>(VOICE_TOGGLE_EVENT, { at: Date.now() })
|
||||
|
||||
export const onComposerVoiceToggleRequest = (handler: () => void) =>
|
||||
subscribe<{ at: number }>(VOICE_TOGGLE_EVENT, () => handler())
|
||||
|
||||
/**
|
||||
* Focus a composer input across React commit + browser focus restore.
|
||||
*
|
||||
|
||||
@@ -79,7 +79,8 @@ import {
|
||||
markActiveComposer,
|
||||
onComposerFocusRequest,
|
||||
onComposerInsertRefsRequest,
|
||||
onComposerInsertRequest
|
||||
onComposerInsertRequest,
|
||||
onComposerVoiceToggleRequest
|
||||
} from './focus'
|
||||
import { HelpHint } from './help-hint'
|
||||
import { useAtCompletions } from './hooks/use-at-completions'
|
||||
@@ -1844,6 +1845,24 @@ export function ChatBar({
|
||||
pendingResponse
|
||||
})
|
||||
|
||||
// The `composer.voice` hotkey (Ctrl+B) toggles the conversation. Starting
|
||||
// with STT unconfigured lets the conversation surface its own "configure
|
||||
// speech-to-text" notice rather than silently no-opping.
|
||||
const toggleVoiceConversation = useCallback(() => {
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (voiceConversationActive) {
|
||||
setVoiceConversationActive(false)
|
||||
void conversation.end()
|
||||
} else {
|
||||
setVoiceConversationActive(true)
|
||||
}
|
||||
}, [conversation, disabled, voiceConversationActive])
|
||||
|
||||
useEffect(() => onComposerVoiceToggleRequest(toggleVoiceConversation), [toggleVoiceConversation])
|
||||
|
||||
const contextMenu = (
|
||||
<ContextMenu
|
||||
onInsertText={insertText}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Clock,
|
||||
Cpu,
|
||||
Download,
|
||||
Egg,
|
||||
Globe,
|
||||
type IconComponent,
|
||||
Info,
|
||||
@@ -43,6 +44,7 @@ import {
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $commandPaletteOpen, $commandPalettePage, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette'
|
||||
import { $bindings } from '@/store/keybinds'
|
||||
import { openPetGenerate } from '@/store/pet-generate'
|
||||
import { runGatewayRestart } from '@/store/system-actions'
|
||||
import { luminance } from '@/themes/color'
|
||||
import { type ThemeMode, useTheme } from '@/themes/context'
|
||||
@@ -409,6 +411,13 @@ export function CommandPalette() {
|
||||
keywords: ['pet', 'petdex', 'mascot', 'pets', '/pet', 'paw'],
|
||||
label: cc.pets.title,
|
||||
to: 'pets'
|
||||
},
|
||||
{
|
||||
icon: Egg,
|
||||
id: 'appearance-generate-pet',
|
||||
keywords: ['pet', 'generate', 'create', 'make', 'new pet', 'mascot', 'hatch', 'ai'],
|
||||
label: cc.generatePet.title,
|
||||
run: () => openPetGenerate()
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -653,6 +662,8 @@ export function CommandPalette() {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
goBack()
|
||||
|
||||
return
|
||||
}
|
||||
}}
|
||||
onValueChange={setSearch}
|
||||
@@ -663,7 +674,7 @@ export function CommandPalette() {
|
||||
<CommandList className="dt-portal-scrollbar max-h-[min(20rem,56vh)]">
|
||||
{/* Server-driven pages render their own list; the rest show groups. */}
|
||||
{page === 'pets' ? (
|
||||
<PetPalettePage search={search} />
|
||||
<PetPalettePage onGenerate={() => { closeCommandPalette(); openPetGenerate() }} search={search} />
|
||||
) : page === 'install-theme' ? (
|
||||
<MarketplaceThemePage onPickTheme={setTheme} search={search} />
|
||||
) : (
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
|
||||
import { PetThumb } from '@/components/pet/pet-thumb'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Check, Loader2, PawPrint } from '@/lib/icons'
|
||||
import { Check, Egg, Loader2, PawPrint } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$petBusy,
|
||||
@@ -31,9 +31,11 @@ import {
|
||||
|
||||
interface PetPalettePageProps {
|
||||
search: string
|
||||
/** Navigate to the "generate a pet" page (rendered as a header action). */
|
||||
onGenerate?: () => void
|
||||
}
|
||||
|
||||
export function PetPalettePage({ search }: PetPalettePageProps) {
|
||||
export function PetPalettePage({ search, onGenerate }: PetPalettePageProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.commandCenter.pets
|
||||
const { requestGateway } = useGatewayRequest()
|
||||
@@ -72,6 +74,24 @@ export function PetPalettePage({ search }: PetPalettePageProps) {
|
||||
|
||||
return (
|
||||
<div role="listbox">
|
||||
{onGenerate && (
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md text-left text-foreground transition-colors hover:bg-(--chrome-action-hover)',
|
||||
HUD_ITEM,
|
||||
HUD_TEXT
|
||||
)}
|
||||
onClick={onGenerate}
|
||||
onMouseDown={event => event.preventDefault()}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-(--chrome-action-hover)">
|
||||
<Egg className="size-4" />
|
||||
</span>
|
||||
<span className="font-medium">{t.commandCenter.generatePet.title}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{error && <p className="px-2 pb-1 pt-1.5 text-[0.6875rem] text-(--ui-red)">{error}</p>}
|
||||
|
||||
{shown.length === 0 ? (
|
||||
@@ -104,7 +124,14 @@ export function PetPalettePage({ search }: PetPalettePageProps) {
|
||||
url={pet.spritesheetUrl}
|
||||
/>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate font-medium">{pet.displayName}</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="truncate font-medium">{pet.displayName}</span>
|
||||
{pet.generated && (
|
||||
<span className="shrink-0 rounded-full bg-primary/15 px-1.5 py-px text-[0.625rem] font-medium text-primary">
|
||||
{copy.generatedTag}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="truncate text-[0.6875rem] text-muted-foreground/80">
|
||||
{pet.slug}
|
||||
{pet.installed ? ` · ${copy.installed}` : ''}
|
||||
|
||||
@@ -108,6 +108,7 @@ import { useKeybinds } from './hooks/use-keybinds'
|
||||
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from './layout-constants'
|
||||
import { ModelPickerOverlay } from './model-picker-overlay'
|
||||
import { ModelVisibilityOverlay } from './model-visibility-overlay'
|
||||
import { PetGenerateOverlay } from './pet-generate/pet-generate-overlay'
|
||||
import { RightSidebarPane } from './right-sidebar'
|
||||
import { $terminalTakeover } from './right-sidebar/store'
|
||||
import { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent'
|
||||
@@ -1028,6 +1029,7 @@ export function DesktopController() {
|
||||
<GatewayConnectingOverlay />
|
||||
<BootFailureOverlay />
|
||||
<CommandPalette />
|
||||
<PetGenerateOverlay />
|
||||
<SessionSwitcher />
|
||||
|
||||
{settingsOpen && (
|
||||
|
||||
@@ -40,6 +40,13 @@ import {
|
||||
} from '@/store/session'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
// After this many consecutive failed reconnects (≈45s with the 1→15s backoff)
|
||||
// raise a recoverable boot error. Otherwise a dropped remote gateway loops the
|
||||
// backoff forever behind the fullscreen CONNECTING overlay with no way to reach
|
||||
// Settings / sign in / switch to local — the "lost connection breaks the app"
|
||||
// dead end. The next successful reconnect clears it.
|
||||
const RECONNECT_ESCALATE_AFTER = 6
|
||||
|
||||
interface GatewayBootOptions {
|
||||
handleGatewayEvent: (event: RpcEvent) => void
|
||||
onConnectionReady: (
|
||||
@@ -105,6 +112,10 @@ export function useGatewayBoot({
|
||||
// tick — a stale OAuth ticket fails every attempt and would otherwise stack
|
||||
// identical error toasts (and their haptics). Reset on the next clean open.
|
||||
let reauthNotified = false
|
||||
// Raised once the reconnect loop crosses RECONNECT_ESCALATE_AFTER so the
|
||||
// recovery overlay replaces the dead-end CONNECTING screen. Reset on a clean
|
||||
// open or a manual/wake-driven reconnect.
|
||||
let escalated = false
|
||||
|
||||
// Wrap the live getter in a call so TS control-flow analysis doesn't narrow
|
||||
// `connectionState` to a constant across the early-return guards (the state
|
||||
@@ -171,6 +182,11 @@ export function useGatewayBoot({
|
||||
reconnecting = false
|
||||
|
||||
if (!cancelled && !gatewayOpen()) {
|
||||
if (reconnectAttempt >= RECONNECT_ESCALATE_AFTER && !escalated) {
|
||||
escalated = true
|
||||
failDesktopBoot(translateNow('boot.errors.gatewayConnectionLost'))
|
||||
}
|
||||
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
@@ -197,6 +213,7 @@ export function useGatewayBoot({
|
||||
|
||||
clearReconnectTimer()
|
||||
reconnectAttempt = 0
|
||||
escalated = false
|
||||
reconnectSecondaryGateways()
|
||||
|
||||
if (!gatewayOpen()) {
|
||||
@@ -230,6 +247,7 @@ export function useGatewayBoot({
|
||||
if (st === 'open') {
|
||||
reconnectAttempt = 0
|
||||
reauthNotified = false
|
||||
escalated = false
|
||||
clearReconnectTimer()
|
||||
|
||||
// A revalidate-driven reconnect can rebuild the backend in place when the
|
||||
|
||||
@@ -94,7 +94,7 @@ export function useGatewayRequest() {
|
||||
}, [])
|
||||
|
||||
const requestGateway = useCallback(
|
||||
async <T>(method: string, params: Record<string, unknown> = {}) => {
|
||||
async <T>(method: string, params: Record<string, unknown> = {}, timeoutMs?: number, signal?: AbortSignal) => {
|
||||
const gateway = gatewayRef.current
|
||||
|
||||
if (!gateway) {
|
||||
@@ -102,7 +102,7 @@ export function useGatewayRequest() {
|
||||
}
|
||||
|
||||
try {
|
||||
return await gateway.request<T>(method, params)
|
||||
return await gateway.request<T>(method, params, timeoutMs, signal)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
|
||||
@@ -128,7 +128,7 @@ export function useGatewayRequest() {
|
||||
throw error
|
||||
}
|
||||
|
||||
return recovered.request<T>(method, params)
|
||||
return recovered.request<T>(method, params, timeoutMs, signal)
|
||||
}
|
||||
},
|
||||
[ensureGatewayOpen]
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
import { openNewSessionInNewWindow } from '@/store/windows'
|
||||
import { useTheme } from '@/themes/context'
|
||||
|
||||
import { requestComposerFocus } from '../chat/composer/focus'
|
||||
import { requestComposerFocus, requestVoiceToggle } from '../chat/composer/focus'
|
||||
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants'
|
||||
import {
|
||||
AGENTS_ROUTE,
|
||||
@@ -114,6 +114,7 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
||||
|
||||
'composer.focus': () => requestComposerFocus('main'),
|
||||
'composer.modelPicker': () => setModelPickerOpen(true),
|
||||
'composer.voice': requestVoiceToggle,
|
||||
|
||||
'nav.commandPalette': toggleCommandPalette,
|
||||
'nav.commandCenter': deps.toggleCommandCenter,
|
||||
|
||||
19
apps/desktop/src/app/hooks/use-route-overlay-active.ts
Normal file
19
apps/desktop/src/app/hooks/use-route-overlay-active.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
import { appViewForPath, isOverlayView } from '@/app/routes'
|
||||
|
||||
/**
|
||||
* True while a full-screen route overlay (settings, agents, command-center, …)
|
||||
* is showing.
|
||||
*
|
||||
* A portaled Radix modal sits above the app shell, so it would cover such a
|
||||
* route. Any modal that sends the user to one (e.g. "set up image generation" →
|
||||
* `/settings`) can `if (useRouteOverlayActive()) return null` to *yield* the
|
||||
* screen — its open state lives in a store, so it stays open — and reappear,
|
||||
* re-running its mount effects (a free refresh), when the route overlay closes.
|
||||
*/
|
||||
export function useRouteOverlayActive(): boolean {
|
||||
const { pathname } = useLocation()
|
||||
|
||||
return isOverlayView(appViewForPath(pathname))
|
||||
}
|
||||
125
apps/desktop/src/app/pet-generate/components/draft-grid.tsx
Normal file
125
apps/desktop/src/app/pet-generate/components/draft-grid.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { PixelEggSprite } from '@/components/pet/pixel-egg-sprite'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { PawPrint } from '@/lib/icons'
|
||||
import { selectableCardClass } from '@/lib/selectable-card'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const VARIANT_COUNT = 4
|
||||
|
||||
interface DraftGridProps {
|
||||
drafts: { index: number; dataUri: string }[]
|
||||
generating: boolean
|
||||
hasDrafts: boolean
|
||||
onCancel: () => void
|
||||
onHatch: () => void
|
||||
onRemix: (draft: { index: number; dataUri: string }) => void
|
||||
onSelect: (index: number) => void
|
||||
selected: number | null
|
||||
}
|
||||
|
||||
export function DraftGrid({
|
||||
drafts,
|
||||
generating,
|
||||
hasDrafts,
|
||||
onCancel,
|
||||
onHatch,
|
||||
onRemix,
|
||||
onSelect,
|
||||
selected
|
||||
}: DraftGridProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.commandCenter.generatePet
|
||||
|
||||
const slots = generating
|
||||
? Array.from({ length: VARIANT_COUNT }, (_, i) => drafts.find(draft => draft.index === i) ?? null)
|
||||
: drafts
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
<span className={cn(generating && 'shimmer shimmer-color-primary opacity-40', !generating && 'invisible')}>
|
||||
{copy.generating}
|
||||
</span>
|
||||
<span className="tabular-nums">
|
||||
{Math.min(drafts.length, VARIANT_COUNT)}/{VARIANT_COUNT}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{slots.map((draft, i) => {
|
||||
// A streamed draft is selectable immediately — even mid-generation —
|
||||
// so the user can commit to one without waiting for the rest.
|
||||
const isSelected = draft != null && selected === draft.index
|
||||
|
||||
return (
|
||||
<div className="group relative aspect-[192/208]" key={draft ? `draft-${draft.index}` : `slot-${i}`}>
|
||||
<button
|
||||
className={cn(
|
||||
'absolute inset-0 flex items-center justify-center overflow-hidden',
|
||||
selectableCardClass({ active: isSelected, prominent: true })
|
||||
)}
|
||||
disabled={draft == null}
|
||||
onClick={() => draft != null && onSelect(draft.index)}
|
||||
type="button"
|
||||
>
|
||||
{draft != null ? (
|
||||
// Hatches into place as each draft streams back.
|
||||
<img
|
||||
alt=""
|
||||
className="pet-reveal size-full object-contain p-1.5"
|
||||
draggable={false}
|
||||
src={draft.dataUri}
|
||||
/>
|
||||
) : (
|
||||
// Incubating: a creme egg bouncing on its contact shadow.
|
||||
<div className="relative z-10 flex flex-col items-center">
|
||||
<PixelEggSprite index={i} mode="bounce" size={48} />
|
||||
<span className="pet-egg-shadow pet-egg-shadow--sm" style={{ marginTop: '-0.3rem' }} />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Remix: branch a new round off this look. Revealed on hover/focus. */}
|
||||
{draft != null && !generating && (
|
||||
<Tip label={copy.remix}>
|
||||
<Button
|
||||
aria-label={copy.remix}
|
||||
className={cn(
|
||||
'absolute right-1 top-1 z-20',
|
||||
'text-(--ui-text-tertiary) opacity-10 transition',
|
||||
'hover:bg-transparent hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100'
|
||||
)}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
onRemix(draft)
|
||||
}}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="git-branch" size={12} />
|
||||
</Button>
|
||||
</Tip>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Same abort/go-back text link in both states (sits right under the grid);
|
||||
once drafts land, the full-width Hatch drops in below it. */}
|
||||
<Button className="self-center" onClick={onCancel} size="xs" variant="text">
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
{hasDrafts && (
|
||||
<Button className="w-full" disabled={selected === null} onClick={onHatch}>
|
||||
<PawPrint />
|
||||
{copy.hatch}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
27
apps/desktop/src/app/pet-generate/components/empty-hint.tsx
Normal file
27
apps/desktop/src/app/pet-generate/components/empty-hint.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface EmptyHintProps {
|
||||
onExample: (prompt: string) => void
|
||||
}
|
||||
|
||||
// Creative seed prompts — specifics make better pets (petdex's own advice).
|
||||
// Short chips that wrap into a tight, centered cluster (capped width → 2 rows).
|
||||
const EXAMPLE_PROMPTS = ['bubble-tea otter', 'sock elf', 'pixel dragon', 'office cat', 'neon axolotl', 'moss golem']
|
||||
|
||||
export function EmptyHint({ onExample }: EmptyHintProps) {
|
||||
return (
|
||||
<div className="flex max-w-[300px] flex-wrap place-content-center place-items-center gap-2">
|
||||
{EXAMPLE_PROMPTS.map(example => (
|
||||
<Button
|
||||
className="h-auto w-fit rounded-full font-normal"
|
||||
key={example}
|
||||
onClick={() => onExample(`a ${example}`)}
|
||||
size="xs"
|
||||
variant="outline"
|
||||
>
|
||||
{example}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ExternalLink } from '@/lib/external-link'
|
||||
import { PawPrint, Settings2 } from '@/lib/icons'
|
||||
|
||||
interface GenerateUnavailableProps {
|
||||
onSetup: () => void
|
||||
}
|
||||
|
||||
// Shown when no reference-capable image backend is configured: generation is
|
||||
// impossible, so we replace the prompt entirely with a friendly path to set one
|
||||
// up (in-app) plus where to grab a key.
|
||||
export function GenerateUnavailable({ onSetup }: GenerateUnavailableProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<span className="grid size-11 place-items-center rounded-full bg-primary/10 text-primary">
|
||||
<PawPrint className="size-5" />
|
||||
</span>
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-[length:var(--conversation-text-font-size)] font-semibold">Add an image backend to generate</p>
|
||||
<p className="mx-auto max-w-[19rem] text-[length:var(--conversation-caption-font-size)] leading-relaxed text-(--ui-text-tertiary)">
|
||||
Hatching a custom pet needs a provider that can ground on a reference image.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={onSetup} size="sm">
|
||||
<Settings2 className="size-4" />
|
||||
Set up image generation
|
||||
</Button>
|
||||
<p className="flex flex-wrap items-center justify-center gap-x-1.5 text-[0.6875rem] text-(--ui-text-tertiary)">
|
||||
<span>Grab a key from</span>
|
||||
<ExternalLink href="https://portal.nousresearch.com" showExternalIcon={false}>
|
||||
Nous Portal
|
||||
</ExternalLink>
|
||||
<span>·</span>
|
||||
<ExternalLink
|
||||
className="opacity-40 transition-opacity hover:opacity-100"
|
||||
href="https://openrouter.ai/keys"
|
||||
showExternalIcon={false}
|
||||
>
|
||||
OpenRouter
|
||||
</ExternalLink>
|
||||
<span>·</span>
|
||||
<ExternalLink
|
||||
className="opacity-40 transition-opacity hover:opacity-100"
|
||||
href="https://platform.openai.com/api-keys"
|
||||
showExternalIcon={false}
|
||||
>
|
||||
OpenAI
|
||||
</ExternalLink>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
137
apps/desktop/src/app/pet-generate/components/hatch-preview.tsx
Normal file
137
apps/desktop/src/app/pet-generate/components/hatch-preview.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { PetSprite } from '@/components/pet/pet-sprite'
|
||||
import { PetStarShower } from '@/components/pet/pet-star-shower'
|
||||
import { PixelEggSprite } from '@/components/pet/pixel-egg-sprite'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Loader2, PawPrint, RefreshCw } from '@/lib/icons'
|
||||
import { type PetInfo } from '@/store/pet'
|
||||
|
||||
import { frameCountForRow } from '../lib/frame-count'
|
||||
|
||||
const PREVIEW_SCALE = 0.7
|
||||
const PREVIEW_STATE_MS = 1400
|
||||
|
||||
const PREVIEW_ROWS = ['idle', 'waving', 'running-right', 'running-left', 'running', 'review', 'jumping', 'failed', 'waiting']
|
||||
|
||||
interface HatchPreviewProps {
|
||||
pet: PetInfo
|
||||
adopting: boolean
|
||||
error: string | null
|
||||
onAdopt: (name: string) => void
|
||||
onDiscard: () => void
|
||||
}
|
||||
|
||||
export function HatchPreview({ pet, adopting, error, onAdopt, onDiscard }: HatchPreviewProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.commandCenter.generatePet
|
||||
// Empty so the "Name your pet" placeholder shows; blank adopt keeps the
|
||||
// provisional name from the prompt.
|
||||
const [name, setName] = useState('')
|
||||
// Play the egg's crack/hatch frames once before swapping in the live pet.
|
||||
const [revealed, setRevealed] = useState(false)
|
||||
// Right after the egg cracks the pet plays its "yay" jump a couple times, then
|
||||
// hands off to the normal state-cycling preview.
|
||||
const [celebrating, setCelebrating] = useState(false)
|
||||
const [stateIndex, setStateIndex] = useState(0)
|
||||
const previewRows = (pet.stateRows?.length ? pet.stateRows : PREVIEW_ROWS).filter(row => frameCountForRow(pet, row) > 0)
|
||||
const rows = previewRows.length > 0 ? previewRows : ['idle']
|
||||
const activeRow = rows[stateIndex % rows.length] ?? 'idle'
|
||||
const canJump = frameCountForRow(pet, 'jumping') > 0
|
||||
const rowOverride = celebrating && canJump ? 'jumping' : activeRow
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setStateIndex(i => (i + 1) % rows.length), PREVIEW_STATE_MS)
|
||||
|
||||
return () => clearInterval(id)
|
||||
}, [rows.length])
|
||||
|
||||
// On reveal: celebrate (jump) ~2 loops, then drop into the cycling preview.
|
||||
useEffect(() => {
|
||||
if (!revealed) {
|
||||
return
|
||||
}
|
||||
|
||||
setCelebrating(true)
|
||||
|
||||
const id = setTimeout(() => {
|
||||
setCelebrating(false)
|
||||
setStateIndex(0)
|
||||
}, 2 * (pet.loopMs ?? 1100))
|
||||
|
||||
return () => clearTimeout(id)
|
||||
}, [revealed, pet.loopMs])
|
||||
|
||||
useEffect(() => {
|
||||
setStateIndex(0)
|
||||
setName('')
|
||||
setRevealed(false)
|
||||
setCelebrating(false)
|
||||
}, [pet.slug])
|
||||
|
||||
const previewInfo: PetInfo = { ...pet, scale: PREVIEW_SCALE }
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
{/* Fills the (now narrow) dialog so the pet frame is the screen width. */}
|
||||
<div className="relative flex aspect-[192/208] w-full items-center justify-center overflow-hidden rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary)">
|
||||
{revealed ? (
|
||||
<>
|
||||
<div className="relative inline-block">
|
||||
<span aria-hidden className="pet-contact-shadow" />
|
||||
<div className="pet-reveal relative z-10">
|
||||
<PetSprite info={previewInfo} rowOverride={rowOverride} />
|
||||
</div>
|
||||
</div>
|
||||
<PetStarShower />
|
||||
</>
|
||||
) : (
|
||||
// The egg cracks open, then we swap in the live pet.
|
||||
<PixelEggSprite
|
||||
mode="hatch"
|
||||
onDone={() => {
|
||||
setRevealed(true)
|
||||
triggerHaptic('crisp')
|
||||
}}
|
||||
size={150}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Input
|
||||
autoFocus
|
||||
className="w-full"
|
||||
onChange={event => setName(event.target.value)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
onAdopt(name)
|
||||
}
|
||||
}}
|
||||
placeholder={copy.namePlaceholder}
|
||||
value={name}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex w-full items-center gap-1.5">
|
||||
<Button disabled={adopting} onClick={onDiscard} variant="ghost">
|
||||
<RefreshCw />
|
||||
{copy.startOver}
|
||||
</Button>
|
||||
<Button className="flex-1" disabled={adopting} onClick={() => onAdopt(name)}>
|
||||
{adopting ? <Loader2 className="animate-spin" /> : <PawPrint />}
|
||||
{copy.adopt}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { PetEggHatch } from '@/components/pet/pet-egg-hatch'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cancelHatch, type PetHatchStage } from '@/store/pet-generate'
|
||||
|
||||
interface HatchingViewProps {
|
||||
stage: PetHatchStage | null
|
||||
}
|
||||
|
||||
// The hatch progress screen — a beating egg with a phase-tracking subtitle
|
||||
// (per-row → composing → saving).
|
||||
export function HatchingView({ stage }: HatchingViewProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.commandCenter.generatePet
|
||||
|
||||
const subtitle = stage
|
||||
? stage.phase === 'row'
|
||||
? copy.hatchRow(stage.state ?? '', stage.done ?? 0, stage.total ?? 0)
|
||||
: stage.phase === 'compose'
|
||||
? copy.hatchComposing
|
||||
: copy.hatchSaving
|
||||
: copy.hatchingSub
|
||||
|
||||
return <PetEggHatch cancelLabel={t.common.cancel} onCancel={cancelHatch} subtitle={subtitle} />
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { Check, ChevronDown } from '@/lib/icons'
|
||||
import { $petGenProvider, $petGenProviders, setPetGenProvider } from '@/store/pet-generate'
|
||||
|
||||
// Image-backend picker for pet generation — the composer's model-pill pattern:
|
||||
// a quiet trigger + a dropdown of options. No per-option notes: every backend
|
||||
// resolves to the same faithful OpenAI image model, so there's no tradeoff to
|
||||
// describe. Hidden unless there are 2+ reference-capable backends (nothing to pick).
|
||||
export function ProviderPicker() {
|
||||
const providers = useStore($petGenProviders)
|
||||
const picked = useStore($petGenProvider)
|
||||
|
||||
if (providers.length < 2) {
|
||||
return null
|
||||
}
|
||||
|
||||
const fallback = providers.find(p => p.default) ?? providers[0]
|
||||
const current = providers.find(p => p.name === picked) ?? fallback
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
{/* Plain text affordance (matches "Add a reference"), not a padded pill. */}
|
||||
<button
|
||||
className="flex h-6 items-center gap-1 text-[0.6875rem] text-(--ui-text-tertiary) transition hover:text-foreground"
|
||||
type="button"
|
||||
>
|
||||
{current?.label}
|
||||
<ChevronDown className="size-3" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
{/* The picker lives inside the pet-gen Dialog (z-130) and portals to body,
|
||||
so lift its menu above the dialog or it opens behind it. */}
|
||||
<DropdownMenuContent align="start" className="z-[140]">
|
||||
{providers.map(provider => (
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-1.5"
|
||||
key={provider.name}
|
||||
// Picking the default clears the override (no need to pin it).
|
||||
onSelect={() => setPetGenProvider(provider.default ? '' : provider.name)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-foreground">{provider.label}</span>
|
||||
{provider.name === current?.name && <Check className="size-3.5 text-primary" />}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import { ImageLightbox } from '@/components/chat/zoomable-image'
|
||||
import { useImageDownload } from '@/hooks/use-image-download'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { X } from '@/lib/icons'
|
||||
|
||||
interface ReferenceChipProps {
|
||||
name: string
|
||||
onRemove: () => void
|
||||
src: string
|
||||
}
|
||||
|
||||
// The reference photo as an attachment chip: filename + thumbnail that opens
|
||||
// the shared image viewer (lightbox), with a remove affordance.
|
||||
export function ReferenceChip({ name, onRemove, src }: ReferenceChipProps) {
|
||||
const { t } = useI18n()
|
||||
const { download, saving } = useImageDownload(src)
|
||||
const [viewing, setViewing] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="ml-auto flex h-6 items-center gap-2 self-start rounded-lg border border-border/60 bg-background/50 pl-1 pr-2">
|
||||
<button className="shrink-0" onClick={() => setViewing(true)} title={t.desktop.openImage} type="button">
|
||||
<img alt={name} className="size-4 rounded-md object-cover" src={src} />
|
||||
</button>
|
||||
|
||||
<span className="max-w-40 truncate text-[0.64rem] font-medium text-foreground/50">{name || 'Reference'}</span>
|
||||
<button
|
||||
aria-label="Remove reference"
|
||||
className="text-(--ui-text-tertiary) transition not-hover:opacity-50"
|
||||
onClick={onRemove}
|
||||
type="button"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
|
||||
<ImageLightbox
|
||||
alt={name}
|
||||
copy={t.desktop}
|
||||
onClick={download}
|
||||
onOpenChange={setViewing}
|
||||
open={viewing}
|
||||
saving={saving}
|
||||
src={src}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
26
apps/desktop/src/app/pet-generate/lib/frame-count.ts
Normal file
26
apps/desktop/src/app/pet-generate/lib/frame-count.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { type PetInfo } from '@/store/pet'
|
||||
|
||||
// Sprite row → the PetInfo frame-count key it resolves to (directional walks and
|
||||
// aliases collapse onto their base state).
|
||||
const ROW_TO_FRAME_KEY: Record<string, string> = {
|
||||
idle: 'idle',
|
||||
wave: 'wave',
|
||||
waving: 'wave',
|
||||
jump: 'jump',
|
||||
jumping: 'jump',
|
||||
run: 'run',
|
||||
running: 'run',
|
||||
'running-right': 'run',
|
||||
'running-left': 'run',
|
||||
failed: 'failed',
|
||||
review: 'review',
|
||||
waiting: 'waiting'
|
||||
}
|
||||
|
||||
// Real frame count for a row, preferring the concrete per-row count, then the
|
||||
// per-state count, then the mapped base state, then the sheet-wide default.
|
||||
export function frameCountForRow(pet: PetInfo, row: string): number {
|
||||
const mapped = ROW_TO_FRAME_KEY[row]
|
||||
|
||||
return pet.framesByRow?.[row] ?? pet.framesByState?.[row] ?? (mapped ? pet.framesByState?.[mapped] : undefined) ?? pet.framesPerState ?? 0
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
const DEFAULT_MAX_INPUT_BYTES = 16 * 1024 * 1024
|
||||
|
||||
function loadImage(url: string): Promise<HTMLImageElement> {
|
||||
const img = new Image()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
img.onload = () => resolve(img)
|
||||
img.onerror = () => reject(new Error('unreadable image'))
|
||||
img.src = url
|
||||
})
|
||||
}
|
||||
|
||||
// Read an image file as a downscaled PNG data URL. We decode from an object URL
|
||||
// (not readAsDataURL) so large files don't inflate into giant base64 strings
|
||||
// before we scale them down for generation.
|
||||
export async function readReferenceImage(
|
||||
file: File,
|
||||
max = 1024,
|
||||
maxInputBytes = DEFAULT_MAX_INPUT_BYTES
|
||||
): Promise<string> {
|
||||
if (file.size > maxInputBytes) {
|
||||
throw new Error('reference image too large')
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(file)
|
||||
|
||||
try {
|
||||
const img = await loadImage(objectUrl)
|
||||
const scale = Math.min(1, max / Math.max(img.width, img.height))
|
||||
const width = Math.max(1, Math.round(img.width * scale))
|
||||
const height = Math.max(1, Math.round(img.height * scale))
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
if (!ctx) {
|
||||
throw new Error('could not create canvas context')
|
||||
}
|
||||
|
||||
ctx.drawImage(img, 0, 0, width, height)
|
||||
|
||||
return canvas.toDataURL('image/png')
|
||||
} finally {
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
}
|
||||
}
|
||||
336
apps/desktop/src/app/pet-generate/pet-generate-content.tsx
Normal file
336
apps/desktop/src/app/pet-generate/pet-generate-content.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
|
||||
import { SETTINGS_ROUTE } from '@/app/routes'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||
import { DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { GenerateButton } from '@/components/ui/generate-button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Egg, ImageIcon } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$petGenAvailable,
|
||||
$petGenDrafts,
|
||||
$petGenError,
|
||||
$petGenInput,
|
||||
$petGenPreview,
|
||||
$petGenRefImage,
|
||||
$petGenRefName,
|
||||
$petGenRemixConfirmed,
|
||||
$petGenSelected,
|
||||
$petGenStage,
|
||||
$petGenStatus,
|
||||
adoptHatched,
|
||||
cancelGenerate,
|
||||
checkPetGenAvailable,
|
||||
cleanPetName,
|
||||
closePetGenerate,
|
||||
discardDrafts,
|
||||
discardHatched,
|
||||
generateDrafts,
|
||||
hatchSelected,
|
||||
markRemixConfirmed
|
||||
} from '@/store/pet-generate'
|
||||
|
||||
import { DraftGrid } from './components/draft-grid'
|
||||
import { EmptyHint } from './components/empty-hint'
|
||||
import { GenerateUnavailable } from './components/generate-unavailable'
|
||||
import { HatchPreview } from './components/hatch-preview'
|
||||
import { HatchingView } from './components/hatching-view'
|
||||
import { ProviderPicker } from './components/provider-picker'
|
||||
import { ReferenceChip } from './components/reference-chip'
|
||||
import { readReferenceImage } from './lib/read-reference-image'
|
||||
|
||||
// The generate → hatch → adopt controller. A thin view over the `pet-generate`
|
||||
// store; the store owns the steps and persists inputs across close/reopen.
|
||||
export function PetGenerateContent() {
|
||||
const { t } = useI18n()
|
||||
const copy = t.commandCenter.generatePet
|
||||
const { requestGateway } = useGatewayRequest()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const status = useStore($petGenStatus)
|
||||
const error = useStore($petGenError)
|
||||
const available = useStore($petGenAvailable)
|
||||
// `null` = not yet probed → stay optimistic (show the prompt); only the
|
||||
// confirmed-no-backend case swaps in the setup card.
|
||||
const unavailable = available === false
|
||||
const drafts = useStore($petGenDrafts)
|
||||
const selected = useStore($petGenSelected)
|
||||
const preview = useStore($petGenPreview)
|
||||
const stage = useStore($petGenStage)
|
||||
|
||||
// Inputs live in atoms so they survive a close/reopen (and background runs).
|
||||
const prompt = useStore($petGenInput)
|
||||
const refImage = useStore($petGenRefImage)
|
||||
const refName = useStore($petGenRefName)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// The draft awaiting the one-time "remix regenerates" confirmation.
|
||||
const [remixPending, setRemixPending] = useState<{ dataUri: string } | null>(null)
|
||||
|
||||
// Probe backend availability on open — and again whenever the content
|
||||
// remounts (e.g. after returning from the providers settings), so adding a
|
||||
// key flips the setup card to the prompt with no manual refresh.
|
||||
useEffect(() => {
|
||||
void checkPetGenAvailable(requestGateway)
|
||||
}, [requestGateway])
|
||||
|
||||
const busy = status === 'generating' || status === 'hatching'
|
||||
const hasDrafts = drafts.length > 0
|
||||
const generating = status === 'generating'
|
||||
|
||||
// The idle "describe a pet" state — egg + suggestions get generous, equidistant
|
||||
// breathing room (gap-4) from the prompt; the working states stay compact.
|
||||
const isEmptyState =
|
||||
!hasDrafts &&
|
||||
!generating &&
|
||||
status !== 'hatching' &&
|
||||
status !== 'preview' &&
|
||||
status !== 'adopting' &&
|
||||
status !== 'stale'
|
||||
|
||||
const generate = () => {
|
||||
if ((prompt.trim() || refImage) && !busy) {
|
||||
void generateDrafts(requestGateway, { prompt: prompt.trim(), referenceImage: refImage ?? undefined })
|
||||
}
|
||||
}
|
||||
|
||||
const clearReference = () => {
|
||||
$petGenRefImage.set(null)
|
||||
$petGenRefName.set('')
|
||||
}
|
||||
|
||||
const pickReference = (file: File | undefined) => {
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
|
||||
const mapReferenceError = (reason: unknown): string => {
|
||||
const message = reason instanceof Error ? reason.message.toLowerCase() : ''
|
||||
|
||||
return message.includes('too large') ? copy.referenceImageTooLarge : copy.referenceImageInvalid
|
||||
}
|
||||
|
||||
void readReferenceImage(file)
|
||||
.then(dataUrl => {
|
||||
$petGenRefImage.set(dataUrl)
|
||||
$petGenRefName.set(file.name)
|
||||
// Clear picker-only errors once the reference is valid again.
|
||||
|
||||
if ($petGenStatus.get() === 'error' && $petGenDrafts.get().length === 0) {
|
||||
$petGenStatus.set('idle')
|
||||
$petGenError.set(null)
|
||||
}
|
||||
})
|
||||
.catch(reason => {
|
||||
$petGenRefImage.set(null)
|
||||
$petGenRefName.set('')
|
||||
$petGenError.set(mapReferenceError(reason))
|
||||
|
||||
if (!busy) {
|
||||
$petGenStatus.set('error')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// One-click an example prompt straight into a draft round.
|
||||
const runExample = (example: string) => {
|
||||
$petGenInput.set(example)
|
||||
void generateDrafts(requestGateway, { prompt: example })
|
||||
}
|
||||
|
||||
// A remix re-runs generation grounded on an existing draft — same prompt, stay
|
||||
// on step 2 — so the user explores variations without starting over.
|
||||
const runRemix = (draft: { dataUri: string }) => {
|
||||
void generateDrafts(requestGateway, { prompt: prompt.trim(), referenceImage: draft.dataUri })
|
||||
}
|
||||
|
||||
// Slow, and it replaces the current drafts — so confirm once, then remember it.
|
||||
const remixDraft = (draft: { dataUri: string }) => {
|
||||
if (busy) {
|
||||
return
|
||||
}
|
||||
|
||||
if ($petGenRemixConfirmed.get()) {
|
||||
runRemix(draft)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setRemixPending(draft)
|
||||
}
|
||||
|
||||
// Hatch the selected draft. The user can pick one before the rest stream in —
|
||||
// if so, abort the remaining generations first (keeping the drafts we have).
|
||||
// The prompt is grounding text, not a label; the user names it on reveal.
|
||||
const hatch = () => {
|
||||
if (selected === null) {
|
||||
return
|
||||
}
|
||||
|
||||
if (generating) {
|
||||
cancelGenerate()
|
||||
}
|
||||
|
||||
void hatchSelected(requestGateway, { name: cleanPetName(prompt), prompt: prompt.trim() })
|
||||
}
|
||||
|
||||
const adopt = (finalName: string) => {
|
||||
void adoptHatched(requestGateway, finalName).then(out => {
|
||||
if (out.ok) {
|
||||
triggerHaptic('crisp')
|
||||
closePetGenerate()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The header title tracks the phase instead of sticking on "Generate a pet".
|
||||
const headerTitle =
|
||||
status === 'hatching' ? copy.spawning : status === 'preview' || status === 'adopting' ? copy.hatched : copy.title
|
||||
|
||||
// Send the user to set up a key without closing — the overlay yields to the
|
||||
// settings route (useRouteOverlayActive) and reappears + re-checks on return.
|
||||
const setupImageGen = () => navigate(`${SETTINGS_ROUTE}?tab=providers`)
|
||||
|
||||
// Prompt input only belongs on the describe/draft screens (and never when
|
||||
// there's no backend to generate with).
|
||||
const showPrompt = !unavailable && status !== 'hatching' && status !== 'preview' && status !== 'adopting'
|
||||
|
||||
return (
|
||||
<>
|
||||
{unavailable ? (
|
||||
<DialogTitle className="sr-only">{copy.title}</DialogTitle>
|
||||
) : (
|
||||
<DialogHeader>
|
||||
<DialogTitle icon={Egg}>{headerTitle}</DialogTitle>
|
||||
</DialogHeader>
|
||||
)}
|
||||
|
||||
<div className={cn('flex min-h-0 flex-1 flex-col', isEmptyState ? 'gap-4' : 'gap-2.5')}>
|
||||
{/* Concept prompt with the inline sparkle generate/stop affordance (the
|
||||
same primitive as the commit-message + project-idea fields). */}
|
||||
{showPrompt && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="relative">
|
||||
<Input
|
||||
autoFocus
|
||||
className="pr-9"
|
||||
onChange={event => $petGenInput.set(event.target.value)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
generate()
|
||||
}
|
||||
}}
|
||||
placeholder={copy.placeholder}
|
||||
value={prompt}
|
||||
/>
|
||||
<GenerateButton
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2"
|
||||
disabled={!prompt.trim() && !refImage}
|
||||
generating={generating}
|
||||
generatingLabel={t.common.cancel}
|
||||
label={copy.generate}
|
||||
// Inline cancel should match step-2 cancel semantics: abort and
|
||||
// return to step 1 (prompt retained for quick tweaks).
|
||||
onCancel={discardDrafts}
|
||||
onGenerate={generate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderPicker />
|
||||
{refImage ? (
|
||||
<ReferenceChip name={refName} onRemove={clearReference} src={refImage} />
|
||||
) : (
|
||||
<button
|
||||
className="ml-auto flex h-6 items-center gap-1.5 text-[0.6875rem] text-(--ui-text-tertiary) transition hover:text-foreground"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
type="button"
|
||||
>
|
||||
<ImageIcon className="size-3" />
|
||||
Add a reference
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Optional reference photo — make a pet from the user's own image.
|
||||
Styled like the chat composer's attachment pill. */}
|
||||
<Input
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={event => {
|
||||
pickReference(event.target.files?.[0])
|
||||
event.target.value = ''
|
||||
}}
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hatch failed but the drafts are still here — show why above the grid so
|
||||
the user can re-pick and retry without losing their options. */}
|
||||
{status === 'error' && hasDrafts && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error || copy.genericError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{unavailable ? (
|
||||
<GenerateUnavailable onSetup={setupImageGen} />
|
||||
) : status === 'stale' ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{copy.staleBackend}</AlertDescription>
|
||||
</Alert>
|
||||
) : status === 'hatching' ? (
|
||||
<HatchingView stage={stage} />
|
||||
) : (status === 'preview' || status === 'adopting') && preview ? (
|
||||
<HatchPreview
|
||||
adopting={status === 'adopting'}
|
||||
error={error}
|
||||
onAdopt={adopt}
|
||||
onDiscard={() => void discardHatched(requestGateway)}
|
||||
pet={preview}
|
||||
/>
|
||||
) : !hasDrafts && !generating ? (
|
||||
// Doubles as the error-empty state — the failure reason rides the
|
||||
// dialog's footer banner, so here we just offer the retry sparks.
|
||||
<EmptyHint onExample={runExample} />
|
||||
) : (
|
||||
<DraftGrid
|
||||
drafts={drafts}
|
||||
generating={generating}
|
||||
hasDrafts={hasDrafts}
|
||||
onCancel={discardDrafts}
|
||||
onHatch={hatch}
|
||||
onRemix={remixDraft}
|
||||
onSelect={index => $petGenSelected.set(index)}
|
||||
selected={selected}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
confirmLabel={copy.remix}
|
||||
description={copy.remixConfirmBody}
|
||||
onClose={() => setRemixPending(null)}
|
||||
onConfirm={() => {
|
||||
markRemixConfirmed()
|
||||
|
||||
if (remixPending) {
|
||||
runRemix(remixPending)
|
||||
}
|
||||
}}
|
||||
open={remixPending !== null}
|
||||
title={copy.remixConfirmTitle}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
86
apps/desktop/src/app/pet-generate/pet-generate-overlay.tsx
Normal file
86
apps/desktop/src/app/pet-generate/pet-generate-overlay.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* "Hatch a Pet" — a dedicated, Pokédex-style overlay for pet generation.
|
||||
*
|
||||
* Previously generation lived as a cramped nested page inside the Cmd-K command
|
||||
* palette (~34rem popover). This is its own full Radix dialog with room to
|
||||
* breathe: a device-framed header, its own concept prompt, a roomy draft grid
|
||||
* that streams in live, and the egg-hatch + reveal flow. It's a thin view over
|
||||
* the `pet-generate` store; the store owns the generate → hatch → adopt steps.
|
||||
*
|
||||
* This file is just the dialog shell + sizing; the flow lives in
|
||||
* `PetGenerateContent`, and each screen is its own atomic component under
|
||||
* `./components`.
|
||||
*/
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
|
||||
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
|
||||
import { useRouteOverlayActive } from '@/app/hooks/use-route-overlay-active'
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$petGenDrafts,
|
||||
$petGenerateOpen,
|
||||
$petGenError,
|
||||
$petGenStatus,
|
||||
cleanupPetGenOnClose,
|
||||
closePetGenerate
|
||||
} from '@/store/pet-generate'
|
||||
|
||||
import { PetGenerateContent } from './pet-generate-content'
|
||||
|
||||
export function PetGenerateOverlay() {
|
||||
const { t } = useI18n()
|
||||
const { requestGateway } = useGatewayRequest()
|
||||
const open = useStore($petGenerateOpen)
|
||||
const status = useStore($petGenStatus)
|
||||
const error = useStore($petGenError)
|
||||
const drafts = useStore($petGenDrafts)
|
||||
|
||||
// Yield the screen to a full-screen route overlay (e.g. /settings while the
|
||||
// user adds an image-gen key) without tearing down — the store keeps us open,
|
||||
// and we reappear + re-check on return.
|
||||
if (useRouteOverlayActive()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
if (!next) {
|
||||
cleanupPetGenOnClose(requestGateway)
|
||||
// Never interrupt in-flight work. Generating/hatching continues in the
|
||||
// background; only an unadopted finished preview is discarded on close.
|
||||
closePetGenerate()
|
||||
}
|
||||
}
|
||||
|
||||
// The draft screen needs room for the 2×2 grid; the single-pet screens
|
||||
// (hatch egg, reveal) shrink to the pet's frame so it isn't lost in a wide box.
|
||||
// `fitContent` lets the dialog size to content; the `min-w` floors each phase.
|
||||
const single = status === 'hatching' || status === 'preview' || status === 'adopting'
|
||||
const copy = t.commandCenter.generatePet
|
||||
|
||||
// The footer banner narrates the dialog's async state: the failure reason on a
|
||||
// dead-end error, else the "you can close this, we'll notify you" reassurance
|
||||
// while a generate/hatch runs in the background. On step 1, show a neutral ETA.
|
||||
const working = status === 'generating' || status === 'hatching'
|
||||
const errored = status === 'error' && drafts.length === 0
|
||||
const stepOne = status === 'idle' || status === 'ready'
|
||||
const banner = errored ? error || copy.genericError : working ? copy.backgroundHint : stepOne ? copy.slowProviderHint : undefined
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={handleOpenChange} open={open}>
|
||||
<DialogContent
|
||||
aria-describedby={undefined}
|
||||
banner={banner}
|
||||
bannerTone={errored ? 'error' : 'info'}
|
||||
// Cap the width so a long banner (e.g. a provider refusal) wraps instead
|
||||
// of stretching the dialog out; the min-w floors each phase.
|
||||
className={cn('gap-4 text-center', single ? 'min-w-[17rem] max-w-[20rem]' : 'min-w-[19rem] max-w-[22rem]')}
|
||||
fitContent
|
||||
>
|
||||
{open && <PetGenerateContent />}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -42,6 +42,7 @@ import { clearPreviewArtifacts } from '@/store/preview-status'
|
||||
import { clearNotifications, notify, notifyError } from '@/store/notifications'
|
||||
import { requestDesktopOnboarding } from '@/store/onboarding'
|
||||
import { setPetScale } from '@/store/pet-gallery'
|
||||
import { $petGenInput, openPetGenerate } from '@/store/pet-generate'
|
||||
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||
import {
|
||||
$busy,
|
||||
@@ -554,7 +555,14 @@ export function usePromptActions({
|
||||
async (rawText: string, options?: SubmitTextOptions) => {
|
||||
const visibleText = rawText.trim()
|
||||
const usingComposerAttachments = !options?.attachments
|
||||
const attachments = options?.attachments ?? $composerAttachments.get()
|
||||
// Drop undefined/null holes a session switch or draft restore can leave in
|
||||
// the attachments array (same bug class as AttachmentList #49624). Without
|
||||
// this, the sibling iterations below (a.kind / a.label / a.refText, and the
|
||||
// sync step) throw "Cannot read properties of undefined (reading 'refText')"
|
||||
// and break the chat surface.
|
||||
const attachments = (options?.attachments ?? $composerAttachments.get()).filter(
|
||||
(a): a is ComposerAttachment => Boolean(a)
|
||||
)
|
||||
|
||||
const terminalContextBlocks = terminalContextBlocksFromDraft(rawText).join('\n\n')
|
||||
const hasImage = attachments.some(a => a.kind === 'image')
|
||||
@@ -567,14 +575,17 @@ export function usePromptActions({
|
||||
let attachmentRefs = attachments.map(optimisticAttachmentRef).filter((r): r is string => Boolean(r))
|
||||
|
||||
const buildContextText = (atts: ComposerAttachment[]): string => {
|
||||
const contextRefs = atts
|
||||
// atts may be the post-sync array, which can reintroduce holes; filter
|
||||
// before touching a.refText / a.kind.
|
||||
const present = atts.filter((a): a is ComposerAttachment => Boolean(a))
|
||||
const contextRefs = present
|
||||
.map(a => a.refText)
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
|
||||
return (
|
||||
[contextRefs, terminalContextBlocks, visibleText].filter(Boolean).join('\n\n') ||
|
||||
(atts.some(a => a.kind === 'image') ? 'What do you see in this image?' : '')
|
||||
(present.some(a => a.kind === 'image') ? 'What do you see in this image?' : '')
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1178,6 +1189,18 @@ export function usePromptActions({
|
||||
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
},
|
||||
// /hatch opens the pet generator overlay (the desktop's rich, multi-step
|
||||
// generate→pick→hatch→adopt flow). A typed description seeds the prompt
|
||||
// so `/hatch a cyber fox` lands on the composer step prefilled.
|
||||
hatch: async ({ arg }) => {
|
||||
const concept = arg.trim()
|
||||
|
||||
if (concept) {
|
||||
$petGenInput.set(concept)
|
||||
}
|
||||
|
||||
openPetGenerate()
|
||||
},
|
||||
pet: async ctx => {
|
||||
const [sub = '', rawValue = ''] = ctx.arg.trim().split(/\s+/)
|
||||
const lower = sub.toLowerCase()
|
||||
|
||||
@@ -256,4 +256,29 @@ describe('resumeSession failure recovery', () => {
|
||||
|
||||
expect($resumeFailedSessionId.get()).toBeNull()
|
||||
})
|
||||
|
||||
it('resumes via the gateway default (deferred build) — not lazy, no eager opt-out', async () => {
|
||||
// The switch-latency fix lives backend-side: a normal cold resume gets the
|
||||
// gateway's default DEFERRED build (transcript returns immediately, agent
|
||||
// pre-warms in the background). The client must NOT force the synchronous
|
||||
// path (eager_build) and is only `lazy` for subagent watch windows.
|
||||
let resumeParams: Record<string, unknown> | undefined
|
||||
|
||||
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
if (method === 'session.resume') {
|
||||
resumeParams = params
|
||||
|
||||
return { session_id: 'runtime-1', resumed: params?.session_id, messages: [], info: {} } as never
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
vi.mocked(getSessionMessages).mockResolvedValue({ messages: [] } as never)
|
||||
|
||||
await runResume(requestGateway)
|
||||
|
||||
expect(resumeParams).not.toHaveProperty('lazy')
|
||||
expect(resumeParams).not.toHaveProperty('eager_build')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -706,6 +706,11 @@ export function useSessionActions({
|
||||
const resumePromise = requestGateway<SessionResumeResponse>('session.resume', {
|
||||
session_id: storedSessionId,
|
||||
cols: 96,
|
||||
// Watch windows attach lazily (live mirror). Every other cold resume
|
||||
// gets the gateway's default deferred build: the RPC returns the
|
||||
// transcript immediately instead of blocking the switch on _make_agent
|
||||
// (MCP discovery / prompt build), and the agent pre-warms in the
|
||||
// background while the prefetch above paints the transcript.
|
||||
...(watchWindow ? { lazy: true } : {}),
|
||||
...(sessionProfile ? { profile: sessionProfile } : {})
|
||||
})
|
||||
@@ -754,7 +759,13 @@ export function useSessionActions({
|
||||
return chatMessageArraysEquivalent(currentMessages, resumedMessages) ? currentMessages : resumedMessages
|
||||
})()
|
||||
|
||||
const messagesForView = preserveLocalAssistantErrors(preferredMessages, currentMessages)
|
||||
// Prefetch-hit fast path: `preferredMessages` IS the live `$messages`
|
||||
// array (already error-merged when `localSnapshot` was built), so reuse
|
||||
// the ref instead of rebuilding a throwaway transcript+Map every switch.
|
||||
const messagesForView =
|
||||
preferredMessages === currentMessages
|
||||
? currentMessages
|
||||
: preserveLocalAssistantErrors(preferredMessages, currentMessages)
|
||||
|
||||
setActiveSessionId(resumed.session_id)
|
||||
activeSessionIdRef.current = resumed.session_id
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
$busy,
|
||||
$messages,
|
||||
noteSessionActivity,
|
||||
onSessionWatchdogClear,
|
||||
setCurrentFastMode,
|
||||
setCurrentModel,
|
||||
setCurrentPersonality,
|
||||
@@ -276,6 +277,31 @@ export function useSessionStateCache({
|
||||
[ensureSessionState, syncSessionStateToView]
|
||||
)
|
||||
|
||||
// When the store watchdog force-clears a stuck session (8 min of stream
|
||||
// silence — a hung or looping turn that never delivered its terminal event),
|
||||
// also drop that session's busy/awaiting flags here. Clearing the sidebar dot
|
||||
// alone leaves the composer wedged on "Thinking"/Stop; updateSessionState
|
||||
// re-syncs `$busy` when the healed session is the one on screen.
|
||||
useEffect(
|
||||
() =>
|
||||
onSessionWatchdogClear(storedSessionId => {
|
||||
const runtimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
|
||||
const state = runtimeId ? sessionStateByRuntimeIdRef.current.get(runtimeId) : undefined
|
||||
|
||||
if (!runtimeId || !state?.busy) {
|
||||
return
|
||||
}
|
||||
|
||||
updateSessionState(runtimeId, current => ({
|
||||
...current,
|
||||
awaitingResponse: false,
|
||||
busy: false,
|
||||
needsInput: false
|
||||
}))
|
||||
}),
|
||||
[updateSessionState]
|
||||
)
|
||||
|
||||
return {
|
||||
activeSessionIdRef,
|
||||
ensureSessionState,
|
||||
|
||||
@@ -26,6 +26,26 @@ import { ModelSettings } from './model-settings'
|
||||
import { EmptyState, ListRow, LoadingState, SettingsContent } from './primitives'
|
||||
import { ProviderConfigPanel } from './provider-config-panel'
|
||||
|
||||
// On the Voice page, only surface the sub-fields of the *selected* TTS/STT
|
||||
// provider — otherwise every provider's options render at once (the "totally
|
||||
// crazy" wall of ~30 fields). Top-level keys (tts.provider, stt.enabled,
|
||||
// voice.*) always show; STT provider fields hide entirely when STT is off.
|
||||
export function voiceFieldVisible(key: string, config: HermesConfigRecord): boolean {
|
||||
const match = /^(tts|stt)\.([^.]+)\./.exec(key)
|
||||
|
||||
if (!match) {
|
||||
return true
|
||||
}
|
||||
|
||||
const [, domain, provider] = match
|
||||
|
||||
if (domain === 'stt' && !getNested(config, 'stt.enabled')) {
|
||||
return false
|
||||
}
|
||||
|
||||
return provider === String(getNested(config, `${domain}.provider`) ?? '')
|
||||
}
|
||||
|
||||
function ConfigField({
|
||||
schemaKey,
|
||||
schema,
|
||||
@@ -356,6 +376,9 @@ export function ConfigSettings({
|
||||
return <LoadingState label={c.loading} />
|
||||
}
|
||||
|
||||
const visibleFields =
|
||||
activeSectionId === 'voice' ? fields.filter(([key]) => voiceFieldVisible(key, config)) : fields
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
{activeSectionId === 'model' && (
|
||||
@@ -363,11 +386,11 @@ export function ConfigSettings({
|
||||
<ModelSettings onMainModelChanged={onMainModelChanged} />
|
||||
</div>
|
||||
)}
|
||||
{fields.length === 0 ? (
|
||||
{visibleFields.length === 0 ? (
|
||||
<EmptyState description={c.emptyDesc} title={c.emptyTitle} />
|
||||
) : (
|
||||
<div className="grid gap-1">
|
||||
{fields.map(([key, field]) => (
|
||||
{visibleFields.map(([key, field]) => (
|
||||
<div className="scroll-mt-6 rounded-lg" id={`setting-field-${key}`} key={key}>
|
||||
<ConfigField
|
||||
descriptionExtra={
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { type ReactNode, useEffect, useState } from 'react'
|
||||
|
||||
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
|
||||
import { PetThumb } from '@/components/pet/pet-thumb'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Loader2, PawPrint, Trash2 } from '@/lib/icons'
|
||||
import { Download, Loader2, PawPrint, Pencil, Trash2 } from '@/lib/icons'
|
||||
import { selectableCardClass } from '@/lib/selectable-card'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $petInfo } from '@/store/pet'
|
||||
@@ -16,13 +20,16 @@ import {
|
||||
$petGalleryError,
|
||||
$petGalleryStatus,
|
||||
adoptPet,
|
||||
exportPet as exportPetAction,
|
||||
loadPetGallery,
|
||||
loadPetThumb,
|
||||
PET_SCALE_DEFAULT,
|
||||
PET_SCALE_MAX,
|
||||
PET_SCALE_MIN,
|
||||
type GalleryPet,
|
||||
rankedGalleryPets,
|
||||
removePet as removePetAction,
|
||||
renamePet as renamePetAction,
|
||||
setPetEnabled,
|
||||
setPetScale
|
||||
} from '@/store/pet-gallery'
|
||||
@@ -48,6 +55,9 @@ export function PetSettings() {
|
||||
const busySlug = useStore($petBusy)
|
||||
const petInfo = useStore($petInfo)
|
||||
const [query, setQuery] = useState('')
|
||||
const [confirmDelete, setConfirmDelete] = useState<GalleryPet | null>(null)
|
||||
const [renameTarget, setRenameTarget] = useState<GalleryPet | null>(null)
|
||||
const [renameValue, setRenameValue] = useState('')
|
||||
const scale = petInfo.scale ?? PET_SCALE_DEFAULT
|
||||
|
||||
useEffect(() => {
|
||||
@@ -71,6 +81,23 @@ export function PetSettings() {
|
||||
void removePetAction(requestGateway, slug, copy.uninstallFailed(slug)).then(ok => ok && triggerHaptic('crisp'))
|
||||
}
|
||||
|
||||
const exportPet = (slug: string) => {
|
||||
void exportPetAction(requestGateway, slug, copy.exportFailed(slug)).then(ok => ok && triggerHaptic('crisp'))
|
||||
}
|
||||
|
||||
const saveRename = () => {
|
||||
if (!renameTarget || !renameValue.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
// Optimistic: the rename paints instantly, so close now and let the RPC
|
||||
// settle in the background (it rolls back + surfaces an error on failure).
|
||||
const { slug } = renameTarget
|
||||
setRenameTarget(null)
|
||||
triggerHaptic('crisp')
|
||||
void renamePetAction(requestGateway, slug, renameValue, copy.renameFailed(slug))
|
||||
}
|
||||
|
||||
const toggle = (on: boolean) => {
|
||||
void setPetEnabled(requestGateway, on, {
|
||||
noneAvailable: copy.noneAvailable,
|
||||
@@ -142,8 +169,15 @@ export function PetSettings() {
|
||||
url={pet.spritesheetUrl}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[length:var(--conversation-text-font-size)] font-medium">
|
||||
{pet.displayName}
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
|
||||
{pet.displayName}
|
||||
</span>
|
||||
{pet.generated && (
|
||||
<span className="shrink-0 rounded-full bg-primary/15 px-1.5 py-px text-[0.625rem] font-medium text-primary">
|
||||
{copy.generatedTag}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="block truncate text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
{pet.slug}
|
||||
@@ -152,16 +186,36 @@ export function PetSettings() {
|
||||
</span>
|
||||
{isBusy && <Loader2 className="size-4 shrink-0 animate-spin text-(--ui-text-tertiary)" />}
|
||||
</button>
|
||||
{pet.installed && !isBusy && (
|
||||
<button
|
||||
aria-label={copy.uninstall(pet.displayName)}
|
||||
className="absolute right-1.5 top-1.5 grid size-6 place-items-center rounded-md bg-(--ui-bg-elevated)/80 text-(--ui-text-tertiary) opacity-0 backdrop-blur-sm transition hover:text-(--ui-red) focus-visible:opacity-100 group-hover:opacity-100"
|
||||
onClick={() => void removePet(pet.slug)}
|
||||
title={copy.uninstall(pet.displayName)}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
{!isBusy && (pet.installed || pet.generated) && (
|
||||
<div className="absolute right-1.5 top-1.5 flex gap-1 opacity-0 transition focus-within:opacity-100 group-hover:opacity-100">
|
||||
{pet.generated && (
|
||||
<PetAction
|
||||
icon={<Pencil className="size-3.5" />}
|
||||
label={copy.rename(pet.displayName)}
|
||||
onClick={() => {
|
||||
setRenameValue(pet.displayName)
|
||||
setRenameTarget(pet)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{pet.generated && (
|
||||
<PetAction
|
||||
icon={<Download className="size-3.5" />}
|
||||
label={copy.exportPet(pet.displayName)}
|
||||
onClick={() => exportPet(pet.slug)}
|
||||
/>
|
||||
)}
|
||||
{pet.installed && (
|
||||
// Generated pets have no remote source — deletion is
|
||||
// permanent, so confirm; petdex pets just uninstall.
|
||||
<PetAction
|
||||
danger
|
||||
icon={<Trash2 className="size-3.5" />}
|
||||
label={pet.generated ? copy.delete(pet.displayName) : copy.uninstall(pet.displayName)}
|
||||
onClick={() => (pet.generated ? setConfirmDelete(pet) : removePet(pet.slug))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -226,6 +280,80 @@ export function PetSettings() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
confirmLabel={copy.deleteConfirm}
|
||||
description={copy.deleteBody}
|
||||
destructive
|
||||
onClose={() => setConfirmDelete(null)}
|
||||
onConfirm={async () => {
|
||||
if (confirmDelete) {
|
||||
const ok = await removePetAction(requestGateway, confirmDelete.slug, copy.uninstallFailed(confirmDelete.slug))
|
||||
if (!ok) {
|
||||
throw new Error(copy.uninstallFailed(confirmDelete.slug))
|
||||
}
|
||||
triggerHaptic('crisp')
|
||||
}
|
||||
}}
|
||||
open={confirmDelete !== null}
|
||||
title={confirmDelete ? copy.deleteTitle(confirmDelete.displayName) : ''}
|
||||
/>
|
||||
|
||||
<Dialog onOpenChange={open => !open && setRenameTarget(null)} open={renameTarget !== null}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{copy.renameTitle}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
autoFocus
|
||||
onChange={event => setRenameValue(event.target.value)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
saveRename()
|
||||
}
|
||||
}}
|
||||
placeholder={copy.renamePlaceholder}
|
||||
value={renameValue}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setRenameTarget(null)} type="button" variant="ghost">
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button disabled={!renameValue.trim()} onClick={saveRename}>
|
||||
{copy.renameSave}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** A single hover-revealed icon action on a pet card (rename / export / delete). */
|
||||
function PetAction({
|
||||
danger,
|
||||
icon,
|
||||
label,
|
||||
onClick
|
||||
}: {
|
||||
danger?: boolean
|
||||
icon: ReactNode
|
||||
label: string
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
'grid size-6 place-items-center rounded-md bg-(--ui-bg-elevated)/80 text-(--ui-text-tertiary) backdrop-blur-sm transition',
|
||||
danger ? 'hover:text-(--ui-red)' : 'hover:text-foreground'
|
||||
)}
|
||||
onClick={onClick}
|
||||
title={label}
|
||||
type="button"
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
48
apps/desktop/src/app/settings/voice-field-visible.test.ts
Normal file
48
apps/desktop/src/app/settings/voice-field-visible.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { HermesConfigRecord } from '@/types/hermes'
|
||||
|
||||
import { voiceFieldVisible } from './config-settings'
|
||||
|
||||
const cfg = (over: Record<string, unknown> = {}): HermesConfigRecord =>
|
||||
({
|
||||
tts: { provider: 'edge', edge: {}, openai: {} },
|
||||
stt: { enabled: true, provider: 'local', local: {}, groq: {} },
|
||||
...over
|
||||
}) as unknown as HermesConfigRecord
|
||||
|
||||
describe('voiceFieldVisible', () => {
|
||||
it('always shows top-level + non-provider keys', () => {
|
||||
const config = cfg()
|
||||
|
||||
for (const key of ['tts.provider', 'stt.enabled', 'stt.provider', 'voice.auto_tts', 'voice.record_key']) {
|
||||
expect(voiceFieldVisible(key, config)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('shows only the selected TTS provider sub-fields', () => {
|
||||
const config = cfg()
|
||||
expect(voiceFieldVisible('tts.edge.voice', config)).toBe(true)
|
||||
expect(voiceFieldVisible('tts.openai.voice', config)).toBe(false)
|
||||
expect(voiceFieldVisible('tts.elevenlabs.voice_id', config)).toBe(false)
|
||||
})
|
||||
|
||||
it('shows only the selected STT provider sub-fields', () => {
|
||||
const config = cfg()
|
||||
expect(voiceFieldVisible('stt.local.model', config)).toBe(true)
|
||||
expect(voiceFieldVisible('stt.groq.model', config)).toBe(false)
|
||||
})
|
||||
|
||||
it('hides every STT provider sub-field when STT is disabled', () => {
|
||||
const config = cfg({ stt: { enabled: false, provider: 'local', local: {} } })
|
||||
expect(voiceFieldVisible('stt.local.model', config)).toBe(false)
|
||||
// ...but the enable/provider toggles themselves stay visible.
|
||||
expect(voiceFieldVisible('stt.enabled', config)).toBe(true)
|
||||
expect(voiceFieldVisible('stt.provider', config)).toBe(true)
|
||||
})
|
||||
|
||||
it('tracks a provider switch', () => {
|
||||
expect(voiceFieldVisible('tts.openai.voice', cfg({ tts: { provider: 'openai', openai: {} } }))).toBe(true)
|
||||
expect(voiceFieldVisible('tts.edge.voice', cfg({ tts: { provider: 'openai', openai: {} } }))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -22,8 +22,6 @@ import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
|
||||
import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { setGlobalYolo, setSessionYolo } from '@/lib/yolo-session'
|
||||
import { $desktopActionTasks } from '@/store/activity'
|
||||
import { $previewServerRestartStatus } from '@/store/preview'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$busy,
|
||||
@@ -31,11 +29,10 @@ import {
|
||||
$currentUsage,
|
||||
$sessionStartedAt,
|
||||
$turnStartedAt,
|
||||
$workingSessionIds,
|
||||
$yoloActive,
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
import { $subagentsBySession, activeSubagentCount } from '@/store/subagents'
|
||||
import { $subagentsBySession, activeSubagentCount, failedSubagentCount } from '@/store/subagents'
|
||||
import { $gatewayRestarting } from '@/store/system-actions'
|
||||
import {
|
||||
$backendUpdateApply,
|
||||
@@ -90,12 +87,9 @@ export function useStatusbarItems({
|
||||
const yoloActive = useStore($yoloActive)
|
||||
const busy = useStore($busy)
|
||||
const currentUsage = useStore($currentUsage)
|
||||
const desktopActionTasks = useStore($desktopActionTasks)
|
||||
const gatewayRestarting = useStore($gatewayRestarting)
|
||||
const previewServerRestartStatus = useStore($previewServerRestartStatus)
|
||||
const sessionStartedAt = useStore($sessionStartedAt)
|
||||
const turnStartedAt = useStore($turnStartedAt)
|
||||
const workingSessionIds = useStore($workingSessionIds)
|
||||
const subagentsBySession = useStore($subagentsBySession)
|
||||
const updateStatus = useStore($updateStatus)
|
||||
const updateApply = useStore($updateApply)
|
||||
@@ -159,24 +153,17 @@ export function useStatusbarItems({
|
||||
[gatewayLogLines, gatewayState, inferenceStatus, openCommandCenterSection, statusSnapshot]
|
||||
)
|
||||
|
||||
const { bgFailed, bgRunning, subagentsRunning } = useMemo(() => {
|
||||
const actions = Object.values(desktopActionTasks)
|
||||
const running = actions.filter(t => t.status.running).length
|
||||
const failed = actions.filter(t => !t.status.running && (t.status.exit_code ?? 0) !== 0).length
|
||||
const previewRunning = previewServerRestartStatus === 'running' ? 1 : 0
|
||||
const previewFailed = previewServerRestartStatus === 'error' ? 1 : 0
|
||||
|
||||
const subagentsRunning = Object.values(subagentsBySession).reduce(
|
||||
(sum, items) => sum + activeSubagentCount(items),
|
||||
0
|
||||
)
|
||||
// The indicator must speak the same scope as the Spawn-tree panel it opens:
|
||||
// every session's subagents, never background system actions (gateway
|
||||
// restarts, toolset installs) which surface in their own panels.
|
||||
const { subagentsFailed, subagentsRunning } = useMemo(() => {
|
||||
const lists = Object.values(subagentsBySession)
|
||||
|
||||
return {
|
||||
bgFailed: failed + previewFailed,
|
||||
bgRunning: workingSessionIds.length + running + previewRunning,
|
||||
subagentsRunning
|
||||
subagentsFailed: lists.reduce((sum, items) => sum + failedSubagentCount(items), 0),
|
||||
subagentsRunning: lists.reduce((sum, items) => sum + activeSubagentCount(items), 0)
|
||||
}
|
||||
}, [desktopActionTasks, previewServerRestartStatus, subagentsBySession, workingSessionIds])
|
||||
}, [subagentsBySession])
|
||||
|
||||
const gatewayOpen = gatewayState === 'open'
|
||||
const gatewayConnecting = gatewayState === 'connecting'
|
||||
@@ -321,20 +308,18 @@ export function useStatusbarItems({
|
||||
{
|
||||
className: cn(
|
||||
agentsOpen && 'bg-accent/55 text-foreground',
|
||||
bgFailed > 0 && 'text-destructive hover:text-destructive'
|
||||
subagentsFailed > 0 && 'text-destructive hover:text-destructive'
|
||||
),
|
||||
detail:
|
||||
subagentsRunning > 0
|
||||
? copy.subagents(subagentsRunning)
|
||||
: bgFailed > 0
|
||||
? copy.failed(bgFailed)
|
||||
: bgRunning > 0
|
||||
? copy.running(bgRunning)
|
||||
: undefined,
|
||||
: subagentsFailed > 0
|
||||
? copy.failed(subagentsFailed)
|
||||
: undefined,
|
||||
icon:
|
||||
bgFailed > 0 ? (
|
||||
subagentsFailed > 0 ? (
|
||||
<AlertCircle className="size-3" />
|
||||
) : bgRunning > 0 || subagentsRunning > 0 ? (
|
||||
) : subagentsRunning > 0 ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="size-3" />
|
||||
@@ -356,8 +341,6 @@ export function useStatusbarItems({
|
||||
],
|
||||
[
|
||||
agentsOpen,
|
||||
bgFailed,
|
||||
bgRunning,
|
||||
commandCenterOpen,
|
||||
copy,
|
||||
gatewayMenuContent,
|
||||
@@ -367,6 +350,7 @@ export function useStatusbarItems({
|
||||
inferenceReady,
|
||||
inferenceStatus?.reason,
|
||||
openAgents,
|
||||
subagentsFailed,
|
||||
subagentsRunning,
|
||||
toggleCommandCenter
|
||||
]
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ComponentProps, ReactNode } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Shared chrome styling for interactive statusbar items (button / link / menu
|
||||
@@ -97,93 +98,101 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
|
||||
|
||||
if (item.variant === 'menu' && (item.menuContent || (item.menuItems && item.menuItems.length > 0))) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className={cn(STATUSBAR_ACTION_CLASS, item.className)} disabled={item.disabled} type="button">
|
||||
{content}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align={item.menuAlign ?? 'start'}
|
||||
className={cn('w-56', item.menuContent && 'p-0', item.menuClassName)}
|
||||
side="top"
|
||||
sideOffset={8}
|
||||
>
|
||||
{item.menuContent
|
||||
? item.menuContent
|
||||
: (item.menuItems ?? [])
|
||||
.filter(menuItem => !menuItem.hidden)
|
||||
.map(menuItem => (
|
||||
<DropdownMenuItem
|
||||
className={cn('gap-2 text-foreground focus:bg-accent [&_svg]:size-4', menuItem.className)}
|
||||
disabled={menuItem.disabled}
|
||||
key={menuItem.id}
|
||||
onSelect={() => {
|
||||
if (menuItem.to) {
|
||||
navigate(menuItem.to)
|
||||
}
|
||||
<Tip label={item.title}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className={cn(STATUSBAR_ACTION_CLASS, item.className)} disabled={item.disabled} type="button">
|
||||
{content}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align={item.menuAlign ?? 'start'}
|
||||
className={cn('w-56', item.menuContent && 'p-0', item.menuClassName)}
|
||||
side="top"
|
||||
sideOffset={8}
|
||||
>
|
||||
{item.menuContent
|
||||
? item.menuContent
|
||||
: (item.menuItems ?? [])
|
||||
.filter(menuItem => !menuItem.hidden)
|
||||
.map(menuItem => (
|
||||
<DropdownMenuItem
|
||||
className={cn('gap-2 text-foreground focus:bg-accent [&_svg]:size-4', menuItem.className)}
|
||||
disabled={menuItem.disabled}
|
||||
key={menuItem.id}
|
||||
onSelect={() => {
|
||||
if (menuItem.to) {
|
||||
navigate(menuItem.to)
|
||||
}
|
||||
|
||||
menuItem.onSelect?.()
|
||||
}}
|
||||
>
|
||||
{menuItem.href ? (
|
||||
<a
|
||||
className="inline-flex w-full items-center gap-2"
|
||||
href={menuItem.href}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{menuItem.icon}
|
||||
<span className="truncate">{menuItem.label}</span>
|
||||
</a>
|
||||
) : (
|
||||
<>
|
||||
{menuItem.icon}
|
||||
<span className="truncate">{menuItem.label}</span>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
menuItem.onSelect?.()
|
||||
}}
|
||||
>
|
||||
{menuItem.href ? (
|
||||
<a
|
||||
className="inline-flex w-full items-center gap-2"
|
||||
href={menuItem.href}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{menuItem.icon}
|
||||
<span className="truncate">{menuItem.label}</span>
|
||||
</a>
|
||||
) : (
|
||||
<>
|
||||
{menuItem.icon}
|
||||
<span className="truncate">{menuItem.label}</span>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Tip>
|
||||
)
|
||||
}
|
||||
|
||||
if (item.variant === 'text' && !item.onSelect && !item.to && !item.href) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex h-full items-center gap-1 px-1.5 text-[0.6875rem] text-(--ui-text-tertiary)',
|
||||
item.className
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
<Tip label={item.title}>
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex h-full items-center gap-1 px-1.5 text-[0.6875rem] text-(--ui-text-tertiary)',
|
||||
item.className
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
</Tip>
|
||||
)
|
||||
}
|
||||
|
||||
if (item.href || item.variant === 'link') {
|
||||
return (
|
||||
<a className={cn(STATUSBAR_ACTION_CLASS, item.className)} href={item.href} rel="noreferrer" target="_blank">
|
||||
{content}
|
||||
</a>
|
||||
<Tip label={item.title}>
|
||||
<a className={cn(STATUSBAR_ACTION_CLASS, item.className)} href={item.href} rel="noreferrer" target="_blank">
|
||||
{content}
|
||||
</a>
|
||||
</Tip>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(STATUSBAR_ACTION_CLASS, item.className)}
|
||||
disabled={item.disabled}
|
||||
onClick={event => {
|
||||
if (item.to) {
|
||||
navigate(item.to)
|
||||
}
|
||||
<Tip label={item.title}>
|
||||
<button
|
||||
className={cn(STATUSBAR_ACTION_CLASS, item.className)}
|
||||
disabled={item.disabled}
|
||||
onClick={event => {
|
||||
if (item.to) {
|
||||
navigate(item.to)
|
||||
}
|
||||
|
||||
item.onSelect?.({ shiftKey: event.shiftKey })
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
item.onSelect?.({ shiftKey: event.shiftKey })
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
</Tip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -382,6 +382,8 @@ function ApplyingView({ apply, isBackend }: { apply: UpdateApplyState; isBackend
|
||||
const u = t.updates
|
||||
const label = u.stages[apply.stage as DesktopUpdateStage] ?? u.stages.idle
|
||||
const body = isBackend ? u.applyingBodyBackend : u.applyingBody
|
||||
const currentMessage = apply.message.trim()
|
||||
const recentLog = apply.log.slice(-4)
|
||||
|
||||
const percent =
|
||||
typeof apply.percent === 'number' && Number.isFinite(apply.percent)
|
||||
@@ -397,6 +399,12 @@ function ApplyingView({ apply, isBackend }: { apply: UpdateApplyState; isBackend
|
||||
<DialogDescription className="text-center text-sm">
|
||||
{body}
|
||||
</DialogDescription>
|
||||
|
||||
{currentMessage ? (
|
||||
<p className="max-w-lg break-words text-center text-xs leading-5 text-muted-foreground">
|
||||
{currentMessage}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
@@ -409,6 +417,16 @@ function ApplyingView({ apply, isBackend }: { apply: UpdateApplyState; isBackend
|
||||
/>
|
||||
</div>
|
||||
|
||||
{recentLog.length > 1 ? (
|
||||
<div className="max-h-24 overflow-hidden rounded-md border border-border/70 bg-muted/35 px-3 py-2 text-left font-mono text-[11px] leading-4 text-muted-foreground">
|
||||
{recentLog.map((entry, index) => (
|
||||
<div className="truncate" key={`${entry.at}-${index}`}>
|
||||
{entry.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground">{u.applyingClose}</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { setRuntimeI18nLocale } from '@/i18n'
|
||||
|
||||
import {
|
||||
buildToolView,
|
||||
clampForDisplay,
|
||||
countDiffLineStats,
|
||||
inlineDiffFromResult,
|
||||
MAX_TOOL_RENDER_CHARS,
|
||||
type ToolPart
|
||||
} from './tool-fallback-model'
|
||||
|
||||
@@ -17,6 +21,10 @@ const part = (overrides: Partial<ToolPart>): ToolPart => ({
|
||||
...overrides
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setRuntimeI18nLocale('en')
|
||||
})
|
||||
|
||||
describe('buildToolView image handling', () => {
|
||||
// vision_analyze reports the input image as a local path; an <img> pointed at
|
||||
// a bare path resolves against the renderer origin and 404s, so we render the
|
||||
@@ -40,8 +48,7 @@ describe('buildToolView image handling', () => {
|
||||
})
|
||||
|
||||
describe('buildToolView terminal exit-code status', () => {
|
||||
const terminal = (result: Record<string, unknown>) =>
|
||||
buildToolView(part({ result, toolName: 'terminal' }), '')
|
||||
const terminal = (result: Record<string, unknown>) => buildToolView(part({ result, toolName: 'terminal' }), '')
|
||||
|
||||
// A non-zero exit code with real output is not a failure (grep no-match,
|
||||
// diff differences, piped commands surfacing the last stage's code, etc.) —
|
||||
@@ -110,6 +117,207 @@ describe('buildToolView file edit diffs', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildToolView title actions', () => {
|
||||
it('marks the pending action separately from the rest of the title', () => {
|
||||
const read = buildToolView(part({ args: { path: '/tmp/demo.txt' }, result: undefined, toolName: 'read_file' }), '')
|
||||
|
||||
const web = buildToolView(
|
||||
part({ args: { url: 'https://example.com/docs' }, result: undefined, toolName: 'web_extract' }),
|
||||
''
|
||||
)
|
||||
|
||||
const terminal = buildToolView(
|
||||
part({ args: { command: 'npm test -- --runInBand' }, result: undefined, toolName: 'terminal' }),
|
||||
''
|
||||
)
|
||||
|
||||
const code = buildToolView(
|
||||
part({ args: { code: 'print("hello")' }, result: undefined, toolName: 'execute_code' }),
|
||||
''
|
||||
)
|
||||
|
||||
expect(read.title).toBe('Reading demo.txt')
|
||||
expect(read.titleAction).toEqual({ prefix: '', text: 'Reading', suffix: ' demo.txt' })
|
||||
expect(web.title).toBe('Reading example.com/docs')
|
||||
expect(web.titleAction).toEqual({ prefix: '', text: 'Reading', suffix: ' example.com/docs' })
|
||||
expect(terminal.title).toBe('Running npm test -- --runInBand')
|
||||
expect(terminal.titleAction).toEqual({ prefix: '', text: 'Running', suffix: ' npm test -- --runInBand' })
|
||||
expect(code.title).toBe('Scripting print("hello")')
|
||||
expect(code.titleAction).toEqual({ prefix: '', text: 'Scripting', suffix: ' print("hello")' })
|
||||
})
|
||||
|
||||
it('does not mark completed tool titles as pending actions', () => {
|
||||
const view = buildToolView(part({ args: { url: 'https://example.com/docs' }, toolName: 'web_extract' }), '')
|
||||
|
||||
expect(view.title).toBe('Read example.com/docs')
|
||||
expect(view.titleAction).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses the filename for completed read_file rows', () => {
|
||||
const view = buildToolView(
|
||||
part({ args: { path: './package.json' }, result: { content: '1|{"name":"demo"}' }, toolName: 'read_file' }),
|
||||
''
|
||||
)
|
||||
|
||||
expect(view.title).toBe('Read package.json')
|
||||
expect(view.subtitle).toBe('')
|
||||
expect(view.titleAction).toBeUndefined()
|
||||
})
|
||||
|
||||
it('adds a compact line range to line-scoped read_file rows', () => {
|
||||
const view = buildToolView(
|
||||
part({
|
||||
args: { limit: 10, offset: 25, path: './src/main.ts' },
|
||||
result: { content: '25|function toggleDock() {\n26| dock.classList.toggle("hidden");\n34|}' },
|
||||
toolName: 'read_file'
|
||||
}),
|
||||
''
|
||||
)
|
||||
|
||||
expect(view.title).toBe('Read main.ts L25-34')
|
||||
expect(view.subtitle).toBe('')
|
||||
})
|
||||
|
||||
it('uses the requested positive offset/limit for read_file row line ranges', () => {
|
||||
const view = buildToolView(
|
||||
part({
|
||||
args: { limit: 5, offset: 1, path: './package.json' },
|
||||
result: { content: '1|{\n2| "name": "bb-rainbows",\n3| "private": true,\n4| "version": "0.0.1",\n5| "type": "module",\n6| "description": "extra"' },
|
||||
toolName: 'read_file'
|
||||
}),
|
||||
''
|
||||
)
|
||||
|
||||
expect(view.title).toBe('Read package.json L1-5')
|
||||
})
|
||||
|
||||
it('uses inherited backend context for live read_file rows', () => {
|
||||
const view = buildToolView(
|
||||
part({
|
||||
args: { context: 'package.json L1-5', path: './package.json' },
|
||||
result: undefined,
|
||||
toolName: 'read_file'
|
||||
}),
|
||||
''
|
||||
)
|
||||
|
||||
expect(view.title).toBe('Reading package.json L1-5')
|
||||
expect(view.titleAction).toEqual({ prefix: '', text: 'Reading', suffix: ' package.json L1-5' })
|
||||
})
|
||||
|
||||
it('uses returned line numbers for negative-offset read_file rows', () => {
|
||||
const view = buildToolView(
|
||||
part({
|
||||
args: { limit: 2, offset: -2, path: './src/main.ts' },
|
||||
result: { content: '99|lastLine();\n100|done();' },
|
||||
toolName: 'read_file'
|
||||
}),
|
||||
''
|
||||
)
|
||||
|
||||
expect(view.title).toBe('Read main.ts L99-100')
|
||||
})
|
||||
|
||||
it('renders compact terminal titles for session 20260624_231846_bdbd1e commands', () => {
|
||||
const rows = [
|
||||
[
|
||||
'cd /Users/brooklyn/www/bb-rainbows && pnpm run lint 2>&1 | tail -20; echo "lint_exit=${PIPESTATUS[0]}"',
|
||||
'Ran pnpm run lint'
|
||||
],
|
||||
[
|
||||
'cd /Users/brooklyn/www/bb-rainbows && pnpm run build 2>&1 | tail -20; echo "build_exit=${PIPESTATUS[0]}"',
|
||||
'Ran pnpm run build'
|
||||
],
|
||||
[
|
||||
'which node pnpm corepack; node -v; echo "---"; corepack --version 2>&1; echo "---pnpm via corepack---"; pnpm --version 2>&1 | tail -5',
|
||||
'Ran which node pnpm corepack + 3 commands'
|
||||
],
|
||||
[
|
||||
'echo "--- proto pnpm direct ---"; ~/.proto/tools/node/24.11.0/bin/pnpm --version 2>&1 | tail -3; echo "--- proto node ---"; ls ~/.proto/tools/node/ 2>&1; echo "--- corepack cache ---"; ls ~/.cache/node/corepack/v1/pnpm/ 2>&1',
|
||||
'Ran ~/.proto/tools/node/24.11.0/bin/pnpm --version + 2 commands'
|
||||
],
|
||||
[
|
||||
'cd /Users/brooklyn/www/bb-rainbows && COREPACK_ENABLE_DOWNLOAD_PROMPT=0 corepack pnpm@10.20.0 --version 2>&1 | tail -3',
|
||||
'Ran COREPACK_ENABLE_DOWNLOAD_PROMPT=0 corepack pnpm@10.20.0 --version'
|
||||
],
|
||||
[
|
||||
'cd /Users/brooklyn/www/bb-rainbows && COREPACK_ENABLE_DOWNLOAD_PROMPT=0 corepack use pnpm@10.20.0 2>&1 | tail -10; echo "exit=$?"',
|
||||
'Ran COREPACK_ENABLE_DOWNLOAD_PROMPT=0 corepack use pnpm@10.20.0'
|
||||
]
|
||||
] as const
|
||||
|
||||
for (const [command, expectedTitle] of rows) {
|
||||
const view = buildToolView(part({ args: { command }, result: { output: 'ok', exit_code: 0 }, toolName: 'terminal' }), '')
|
||||
|
||||
expect(view.title).toBe(expectedTitle)
|
||||
}
|
||||
})
|
||||
|
||||
it('uses inherited backend context for live terminal rows', () => {
|
||||
const view = buildToolView(
|
||||
part({
|
||||
args: {
|
||||
command: 'cd /Users/brooklyn/www/bb-rainbows && pnpm run lint 2>&1 | tail -20',
|
||||
context: 'pnpm run lint'
|
||||
},
|
||||
result: undefined,
|
||||
toolName: 'terminal'
|
||||
}),
|
||||
''
|
||||
)
|
||||
|
||||
expect(view.title).toBe('Running pnpm run lint')
|
||||
expect(view.subtitle).toBe('')
|
||||
expect(view.titleAction).toEqual({ prefix: '', text: 'Running', suffix: ' pnpm run lint' })
|
||||
})
|
||||
|
||||
it('uses the runtime locale for title text and action placement', () => {
|
||||
setRuntimeI18nLocale('ja')
|
||||
|
||||
const read = buildToolView(part({ args: { path: '/tmp/demo.txt' }, result: undefined, toolName: 'read_file' }), '')
|
||||
|
||||
const web = buildToolView(
|
||||
part({ args: { url: 'https://example.com/docs' }, result: undefined, toolName: 'web_extract' }),
|
||||
''
|
||||
)
|
||||
|
||||
expect(read.title).toBe('demo.txt を読み取り中')
|
||||
expect(read.titleAction).toEqual({ prefix: 'demo.txt を', text: '読み取り中', suffix: '' })
|
||||
expect(web.title).toBe('example.com/docs を読み取り中')
|
||||
expect(web.titleAction).toEqual({ prefix: 'example.com/docs を', text: '読み取り中', suffix: '' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('clampForDisplay', () => {
|
||||
it('passes short payloads through untouched', () => {
|
||||
expect(clampForDisplay('hello')).toBe('hello')
|
||||
expect(clampForDisplay('x'.repeat(MAX_TOOL_RENDER_CHARS))).toHaveLength(MAX_TOOL_RENDER_CHARS)
|
||||
})
|
||||
|
||||
it('truncates oversized payloads and reports the omitted count', () => {
|
||||
const oversized = 'x'.repeat(MAX_TOOL_RENDER_CHARS + 5_000)
|
||||
const clamped = clampForDisplay(oversized)
|
||||
|
||||
expect(clamped.length).toBeLessThan(oversized.length)
|
||||
expect(clamped.startsWith('x'.repeat(MAX_TOOL_RENDER_CHARS))).toBe(true)
|
||||
expect(clamped).toContain('5,000 more characters truncated')
|
||||
expect(clamped).toContain('Copy')
|
||||
})
|
||||
})
|
||||
|
||||
// A large tool result (e.g. a 100KB read_file during a `/learn` run) must not
|
||||
// be serialized into the rendered rawResult at full size — that JSON.stringify
|
||||
// payload is what floods the renderer when many rows stack up.
|
||||
describe('buildToolView caps serialized result size', () => {
|
||||
it('clamps rawResult for an oversized result', () => {
|
||||
const huge = 'y'.repeat(MAX_TOOL_RENDER_CHARS * 3)
|
||||
const view = buildToolView(part({ result: { content: huge }, toolName: 'read_file' }), '')
|
||||
|
||||
expect(view.rawResult.length).toBeLessThanOrEqual(MAX_TOOL_RENDER_CHARS + 200)
|
||||
expect(view.rawResult).toContain('truncated')
|
||||
})
|
||||
})
|
||||
|
||||
describe('countDiffLineStats', () => {
|
||||
it('counts added and removed lines', () => {
|
||||
expect(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type ToolTitleKey, translateNow } from '@/i18n'
|
||||
import { normalizeExternalUrl } from '@/lib/external-link'
|
||||
import { summarizeShellCommand } from '@/lib/summarize-command'
|
||||
import { extractToolErrorMessage, formatToolResultSummary } from '@/lib/tool-result-summary'
|
||||
import { translateNow } from '@/i18n'
|
||||
|
||||
export type ToolTone = 'agent' | 'browser' | 'default' | 'file' | 'image' | 'terminal' | 'web'
|
||||
export type ToolStatus = 'error' | 'running' | 'success' | 'warning'
|
||||
@@ -20,6 +21,12 @@ export interface SearchResultRow {
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface ToolTitleAction {
|
||||
prefix: string
|
||||
suffix: string
|
||||
text: string
|
||||
}
|
||||
|
||||
interface CountMetric {
|
||||
count: number
|
||||
noun: string
|
||||
@@ -51,6 +58,7 @@ export interface ToolView {
|
||||
status: ToolStatus
|
||||
subtitle: string
|
||||
title: string
|
||||
titleAction?: ToolTitleAction
|
||||
tone: ToolTone
|
||||
}
|
||||
|
||||
@@ -58,6 +66,12 @@ interface ToolMeta {
|
||||
done: string
|
||||
icon?: string
|
||||
pending: string
|
||||
pendingAction: string
|
||||
tone: ToolTone
|
||||
}
|
||||
|
||||
interface ToolMetaSpec {
|
||||
icon?: string
|
||||
tone: ToolTone
|
||||
}
|
||||
|
||||
@@ -112,44 +126,135 @@ function fileEditBasename(path: string): string {
|
||||
return normalized.split('/').filter(Boolean).pop() || normalized
|
||||
}
|
||||
|
||||
const TOOL_META: Record<string, ToolMeta> = {
|
||||
browser_click: { done: 'Clicked page element', pending: 'Clicking page element', icon: 'globe', tone: 'browser' },
|
||||
browser_fill: { done: 'Filled form field', pending: 'Filling form field', icon: 'globe', tone: 'browser' },
|
||||
browser_navigate: { done: 'Opened page', pending: 'Opening page', icon: 'globe', tone: 'browser' },
|
||||
function numericField(record: Record<string, unknown>, key: string): number | undefined {
|
||||
const value = record[key]
|
||||
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : undefined
|
||||
}
|
||||
|
||||
function readFileLineLabel(args: Record<string, unknown>, result: Record<string, unknown>): string {
|
||||
if (numericField(args, 'offset') === undefined && numericField(args, 'limit') === undefined) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const content = firstStringField(result, ['content'])
|
||||
const offset = numericField(args, 'offset')
|
||||
const limit = numericField(args, 'limit')
|
||||
|
||||
if (offset !== undefined && offset > 0) {
|
||||
if (limit === undefined || limit <= 1) {
|
||||
return `L${offset}`
|
||||
}
|
||||
|
||||
return `L${offset}-${offset + limit - 1}`
|
||||
}
|
||||
|
||||
const lines = content
|
||||
.split('\n')
|
||||
.map(line => /^(\d+)\|/.exec(line)?.[1])
|
||||
.filter((line): line is string => !!line)
|
||||
.map(Number)
|
||||
|
||||
if (lines.length === 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const start = lines[0]!
|
||||
const end = lines[lines.length - 1]!
|
||||
|
||||
return start === end ? `L${start}` : `L${start}-${end}`
|
||||
}
|
||||
|
||||
function readFileDisplayTarget(args: Record<string, unknown>, result: Record<string, unknown>): string {
|
||||
const inherited = firstStringField(args, ['context', 'preview'])
|
||||
|
||||
if (inherited) {
|
||||
return inherited
|
||||
}
|
||||
|
||||
const path = firstStringField(args, ['path', 'file', 'filepath'])
|
||||
|
||||
if (!path) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const lineLabel = readFileLineLabel(args, result)
|
||||
|
||||
return [fileEditBasename(path), lineLabel].filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
const TOOL_META: Record<ToolTitleKey, ToolMetaSpec> = {
|
||||
browser_click: {
|
||||
icon: 'globe',
|
||||
tone: 'browser'
|
||||
},
|
||||
browser_fill: {
|
||||
icon: 'globe',
|
||||
tone: 'browser'
|
||||
},
|
||||
browser_navigate: {
|
||||
icon: 'globe',
|
||||
tone: 'browser'
|
||||
},
|
||||
browser_snapshot: {
|
||||
done: 'Captured page snapshot',
|
||||
pending: 'Capturing page snapshot',
|
||||
icon: 'globe',
|
||||
tone: 'browser'
|
||||
},
|
||||
browser_take_screenshot: {
|
||||
done: 'Captured screenshot',
|
||||
pending: 'Capturing screenshot',
|
||||
icon: 'file-media',
|
||||
tone: 'browser'
|
||||
},
|
||||
browser_type: { done: 'Typed on page', pending: 'Typing on page', icon: 'globe', tone: 'browser' },
|
||||
clarify: { done: 'Asked a question', pending: 'Asking a question', icon: 'question', tone: 'agent' },
|
||||
cronjob: { done: 'Cron job', pending: 'Scheduling cron job', icon: 'watch', tone: 'agent' },
|
||||
edit_file: { done: 'Edited file', pending: 'Editing file', icon: 'edit', tone: 'file' },
|
||||
execute_code: { done: 'Ran code', pending: 'Running code', icon: 'terminal', tone: 'terminal' },
|
||||
image_generate: { done: 'Generated image', pending: 'Generating image', icon: 'file-media', tone: 'image' },
|
||||
list_files: { done: 'Listed files', pending: 'Listing files', icon: 'files', tone: 'file' },
|
||||
patch: { done: 'Patched file', pending: 'Patching file', icon: 'edit', tone: 'file' },
|
||||
read_file: { done: 'Read file', pending: 'Reading file', icon: 'file', tone: 'file' },
|
||||
search_files: { done: 'Searched files', pending: 'Searching files', icon: 'search', tone: 'file' },
|
||||
browser_type: {
|
||||
icon: 'globe',
|
||||
tone: 'browser'
|
||||
},
|
||||
clarify: {
|
||||
icon: 'question',
|
||||
tone: 'agent'
|
||||
},
|
||||
cronjob: {
|
||||
icon: 'watch',
|
||||
tone: 'agent'
|
||||
},
|
||||
edit_file: { icon: 'edit', tone: 'file' },
|
||||
execute_code: {
|
||||
icon: 'terminal',
|
||||
tone: 'terminal'
|
||||
},
|
||||
image_generate: {
|
||||
icon: 'file-media',
|
||||
tone: 'image'
|
||||
},
|
||||
list_files: {
|
||||
icon: 'files',
|
||||
tone: 'file'
|
||||
},
|
||||
patch: { icon: 'edit', tone: 'file' },
|
||||
read_file: { icon: 'file', tone: 'file' },
|
||||
search_files: {
|
||||
icon: 'search',
|
||||
tone: 'file'
|
||||
},
|
||||
session_search_recall: {
|
||||
done: 'Searched session history',
|
||||
pending: 'Searching session history',
|
||||
icon: 'search',
|
||||
tone: 'agent'
|
||||
},
|
||||
terminal: { done: 'Ran command', pending: 'Running command', icon: 'terminal', tone: 'terminal' },
|
||||
todo: { done: 'Updated todos', pending: 'Updating todos', icon: 'tools', tone: 'agent' },
|
||||
vision_analyze: { done: 'Analyzed image', pending: 'Analyzing image', icon: 'eye', tone: 'image' },
|
||||
web_extract: { done: 'Read webpage', pending: 'Reading webpage', icon: 'globe', tone: 'web' },
|
||||
web_search: { done: 'Searched web', pending: 'Searching web', icon: 'search', tone: 'web' },
|
||||
write_file: { done: 'Edited file', pending: 'Editing file', icon: 'edit', tone: 'file' }
|
||||
terminal: {
|
||||
icon: 'terminal',
|
||||
tone: 'terminal'
|
||||
},
|
||||
todo: { icon: 'tools', tone: 'agent' },
|
||||
vision_analyze: {
|
||||
icon: 'eye',
|
||||
tone: 'image'
|
||||
},
|
||||
web_extract: { icon: 'globe', tone: 'web' },
|
||||
web_search: { icon: 'search', tone: 'web' },
|
||||
write_file: { icon: 'edit', tone: 'file' }
|
||||
}
|
||||
|
||||
function isToolTitleKey(name: string): name is ToolTitleKey {
|
||||
return name in TOOL_META
|
||||
}
|
||||
|
||||
const INLINE_CODE_SPLIT_RE = /(`[^`\n]+`)/g
|
||||
@@ -171,27 +276,45 @@ function titleForTool(name: string): string {
|
||||
)
|
||||
}
|
||||
|
||||
const PREFIX_META: { icon?: string; prefix: string; tone: ToolTone; verb: string }[] = [
|
||||
{ prefix: 'browser_', verb: 'Browser', icon: 'globe', tone: 'browser' },
|
||||
{ prefix: 'web_', verb: 'Web', icon: 'globe', tone: 'web' }
|
||||
const PREFIX_META: { icon?: string; labelKey: string; prefix: string; tone: ToolTone }[] = [
|
||||
{ prefix: 'browser_', labelKey: 'browser', icon: 'globe', tone: 'browser' },
|
||||
{ prefix: 'web_', labelKey: 'web', icon: 'globe', tone: 'web' }
|
||||
]
|
||||
|
||||
function toolMeta(name: string): ToolMeta {
|
||||
if (TOOL_META[name]) {
|
||||
return TOOL_META[name]
|
||||
if (isToolTitleKey(name)) {
|
||||
const meta = TOOL_META[name]
|
||||
|
||||
return {
|
||||
done: translateNow(`assistant.tool.titles.${name}.done`),
|
||||
pending: translateNow(`assistant.tool.titles.${name}.pending`),
|
||||
pendingAction: translateNow(`assistant.tool.titles.${name}.pendingAction`),
|
||||
icon: meta.icon,
|
||||
tone: meta.tone
|
||||
}
|
||||
}
|
||||
|
||||
const action = titleForTool(name)
|
||||
const prefix = PREFIX_META.find(p => name.startsWith(p.prefix))
|
||||
|
||||
return prefix
|
||||
? {
|
||||
done: `${prefix.verb} ${action}`,
|
||||
pending: `Running ${prefix.verb.toLowerCase()} ${action.toLowerCase()}`,
|
||||
icon: prefix.icon,
|
||||
tone: prefix.tone
|
||||
}
|
||||
: { done: action, pending: `Running ${action.toLowerCase()}`, tone: 'default' }
|
||||
if (prefix) {
|
||||
const prefixLabel = translateNow(`assistant.tool.prefixes.${prefix.labelKey}`)
|
||||
|
||||
return {
|
||||
done: translateNow('assistant.tool.titleTemplates.prefixedDone', prefixLabel, action),
|
||||
pending: translateNow('assistant.tool.titleTemplates.runningPrefixedTool', prefixLabel, action),
|
||||
pendingAction: translateNow('assistant.tool.actions.running'),
|
||||
icon: prefix.icon,
|
||||
tone: prefix.tone
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
done: action,
|
||||
pending: translateNow('assistant.tool.titleTemplates.runningTool', action),
|
||||
pendingAction: translateNow('assistant.tool.actions.running'),
|
||||
tone: 'default'
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
@@ -238,8 +361,26 @@ function contextValue(value: unknown): string {
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
|
||||
// Each tool result is server-capped (~100KB), but a turn over a big directory
|
||||
// stacks many rows; painting/serializing them all floods the renderer (freeze,
|
||||
// then OOM). Clamp every inline-painted payload to a bounded slice — the row's
|
||||
// Copy button still reads the uncapped `view.detail` for the full output.
|
||||
export const MAX_TOOL_RENDER_CHARS = 20_000
|
||||
|
||||
export function clampForDisplay(value: string, max = MAX_TOOL_RENDER_CHARS): string {
|
||||
if (value.length <= max) {
|
||||
return value
|
||||
}
|
||||
|
||||
const omitted = value.length - max
|
||||
|
||||
return `${value.slice(0, max)}\n\n… ${omitted.toLocaleString()} more characters truncated — use Copy for the full output.`
|
||||
}
|
||||
|
||||
function prettyJson(value: unknown): string {
|
||||
return typeof value === 'string' ? value : JSON.stringify(value, null, 2)
|
||||
const raw = typeof value === 'string' ? value : JSON.stringify(value, null, 2)
|
||||
|
||||
return clampForDisplay(raw ?? '')
|
||||
}
|
||||
|
||||
function parseMaybeObject(value: unknown): Record<string, unknown> {
|
||||
@@ -949,8 +1090,13 @@ function fallbackDetailText(args: unknown, result: unknown): string {
|
||||
}
|
||||
|
||||
function cronScalar(value: unknown): string {
|
||||
if (typeof value === 'string') return value.trim()
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
|
||||
if (typeof value === 'string') {
|
||||
return value.trim()
|
||||
}
|
||||
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return String(value)
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
@@ -958,7 +1104,9 @@ function cronScalar(value: unknown): string {
|
||||
function formatCronTime(iso: string): string {
|
||||
const ts = Date.parse(iso)
|
||||
|
||||
if (Number.isNaN(ts)) return iso
|
||||
if (Number.isNaN(ts)) {
|
||||
return iso
|
||||
}
|
||||
|
||||
return new Date(ts).toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
@@ -968,10 +1116,7 @@ function formatCronTime(iso: string): string {
|
||||
})
|
||||
}
|
||||
|
||||
function cronjobSubtitle(
|
||||
argsRecord: Record<string, unknown>,
|
||||
resultRecord: Record<string, unknown>
|
||||
): string {
|
||||
function cronjobSubtitle(argsRecord: Record<string, unknown>, resultRecord: Record<string, unknown>): string {
|
||||
const jobs = Array.isArray(resultRecord.jobs) ? resultRecord.jobs : null
|
||||
|
||||
if (jobs) {
|
||||
@@ -980,7 +1125,9 @@ function cronjobSubtitle(
|
||||
|
||||
const message = firstStringField(resultRecord, ['message'])
|
||||
|
||||
if (message) return message
|
||||
if (message) {
|
||||
return message
|
||||
}
|
||||
|
||||
const action = firstStringField(argsRecord, ['action']) || 'manage'
|
||||
const name = firstStringField(resultRecord, ['name']) || firstStringField(argsRecord, ['name', 'job_id'])
|
||||
@@ -989,14 +1136,13 @@ function cronjobSubtitle(
|
||||
return name ? `${label} ${name}` : `Cron ${action}`
|
||||
}
|
||||
|
||||
function cronjobDetail(
|
||||
argsRecord: Record<string, unknown>,
|
||||
resultRecord: Record<string, unknown>
|
||||
): string {
|
||||
function cronjobDetail(argsRecord: Record<string, unknown>, resultRecord: Record<string, unknown>): string {
|
||||
const jobs = Array.isArray(resultRecord.jobs) ? resultRecord.jobs : null
|
||||
|
||||
if (jobs) {
|
||||
if (!jobs.length) return 'No cron jobs scheduled'
|
||||
if (!jobs.length) {
|
||||
return 'No cron jobs scheduled'
|
||||
}
|
||||
|
||||
return jobs
|
||||
.slice(0, 20)
|
||||
@@ -1011,12 +1157,14 @@ function cronjobDetail(
|
||||
}
|
||||
|
||||
const nextRun = cronScalar(resultRecord.next_run_at)
|
||||
|
||||
const rows: [string, string][] = [
|
||||
['Schedule', cronScalar(resultRecord.schedule)],
|
||||
['Repeat', cronScalar(resultRecord.repeat)],
|
||||
['Delivery', cronScalar(resultRecord.deliver)],
|
||||
['Next run', nextRun ? formatCronTime(nextRun) : '']
|
||||
]
|
||||
|
||||
const lines = rows.filter(([, value]) => value).map(([key, value]) => `${key}: ${value}`)
|
||||
|
||||
return lines.length ? lines.join('\n') : fallbackDetailText(argsRecord, resultRecord)
|
||||
@@ -1090,9 +1238,9 @@ function toolSubtitle(
|
||||
}
|
||||
}
|
||||
|
||||
const command = firstStringField(argsRecord, ['command', 'code']) || contextValue(argsRecord)
|
||||
const command = firstStringField(argsRecord, ['context', 'preview', 'command', 'code']) || contextValue(argsRecord)
|
||||
|
||||
return command ? compactPreview(command, 120) : 'Executed command'
|
||||
return command ? '' : 'Executed command'
|
||||
}
|
||||
|
||||
if (toolName === 'read_file' || isFileEditTool(toolName)) {
|
||||
@@ -1200,7 +1348,7 @@ function toolDetailText(
|
||||
}
|
||||
}
|
||||
|
||||
if (part.toolName === 'read_file') {
|
||||
if (part.toolName === 'read_file' && part.result !== undefined) {
|
||||
const content = firstStringField(resultRecord, ['content', 'text', 'data', 'body'])
|
||||
|
||||
if (content) {
|
||||
@@ -1259,6 +1407,7 @@ export function toolCopyPayload(part: ToolPart, view: ToolView): { label: string
|
||||
url: translateNow('assistant.tool.copyUrl'),
|
||||
generic: translateNow('common.copy')
|
||||
}
|
||||
|
||||
const args = parseMaybeObject(part.args)
|
||||
const result = parseMaybeObject(part.result)
|
||||
const detail = view.detail.trim()
|
||||
@@ -1310,7 +1459,7 @@ export function toolCopyPayload(part: ToolPart, view: ToolView): { label: string
|
||||
}
|
||||
}
|
||||
|
||||
if (part.toolName === 'read_file') {
|
||||
if (part.toolName === 'read_file' && part.result !== undefined) {
|
||||
if (hasSubstantialOutput) {
|
||||
return { label: copy.file, text: detail }
|
||||
}
|
||||
@@ -1341,39 +1490,104 @@ export function toolCopyPayload(part: ToolPart, view: ToolView): { label: string
|
||||
return { label: copy.generic, text: view.title }
|
||||
}
|
||||
|
||||
interface ToolTitleParts {
|
||||
action?: ToolTitleAction
|
||||
title: string
|
||||
}
|
||||
|
||||
function titlePartsFromAction(title: string, action?: string): ToolTitleParts {
|
||||
if (!action) {
|
||||
return { title }
|
||||
}
|
||||
|
||||
const actionStart = title.indexOf(action)
|
||||
|
||||
if (actionStart < 0) {
|
||||
return { title }
|
||||
}
|
||||
|
||||
return {
|
||||
action: {
|
||||
prefix: title.slice(0, actionStart),
|
||||
suffix: title.slice(actionStart + action.length),
|
||||
text: action
|
||||
},
|
||||
title
|
||||
}
|
||||
}
|
||||
|
||||
function dynamicTitle(
|
||||
part: ToolPart,
|
||||
args: Record<string, unknown>,
|
||||
result: Record<string, unknown>,
|
||||
fallback: string
|
||||
): string {
|
||||
fallback: ToolTitleParts
|
||||
): ToolTitleParts {
|
||||
const verb = (gerund: string, past: string) => (part.result === undefined ? gerund : past)
|
||||
|
||||
const titledAction = (action: string, title: string): ToolTitleParts =>
|
||||
titlePartsFromAction(title, part.result === undefined ? action : undefined)
|
||||
|
||||
if (part.toolName === 'web_extract') {
|
||||
const url = findFirstUrl(args, result)
|
||||
const action = verb(translateNow('assistant.tool.actions.reading'), translateNow('assistant.tool.actions.read'))
|
||||
|
||||
return url ? `${verb('Reading', 'Read')} ${hostnameOf(url)}` : fallback
|
||||
return url
|
||||
? titledAction(action, translateNow('assistant.tool.titleTemplates.actionTarget', action, hostnameOf(url)))
|
||||
: fallback
|
||||
}
|
||||
|
||||
if (part.toolName === 'browser_navigate') {
|
||||
const url = findFirstUrl(args, result)
|
||||
const action = verb(translateNow('assistant.tool.actions.opening'), translateNow('assistant.tool.actions.opened'))
|
||||
|
||||
return url ? `${verb('Opening', 'Opened')} ${hostnameOf(url)}` : fallback
|
||||
return url
|
||||
? titledAction(action, translateNow('assistant.tool.titleTemplates.actionTarget', action, hostnameOf(url)))
|
||||
: fallback
|
||||
}
|
||||
|
||||
if (part.toolName === 'web_search') {
|
||||
const query = firstStringField(args, ['search_term', 'query']) || contextValue(args)
|
||||
|
||||
return query ? `${verb('Searching', 'Searched')} “${compactPreview(query, 48)}”` : fallback
|
||||
const action = verb(
|
||||
translateNow('assistant.tool.actions.searching'),
|
||||
translateNow('assistant.tool.actions.searched')
|
||||
)
|
||||
|
||||
return query
|
||||
? titledAction(
|
||||
action,
|
||||
translateNow('assistant.tool.titleTemplates.actionQuoted', action, compactPreview(query, 48))
|
||||
)
|
||||
: fallback
|
||||
}
|
||||
|
||||
if (part.toolName === 'read_file') {
|
||||
const target = readFileDisplayTarget(args, result)
|
||||
const action = verb(translateNow('assistant.tool.actions.reading'), translateNow('assistant.tool.actions.read'))
|
||||
|
||||
return target
|
||||
? titledAction(action, translateNow('assistant.tool.titleTemplates.actionTarget', action, target))
|
||||
: fallback
|
||||
}
|
||||
|
||||
if (part.toolName === 'terminal' || part.toolName === 'execute_code') {
|
||||
const command = firstStringField(args, ['command', 'code']) || contextValue(args)
|
||||
const command =
|
||||
firstStringField(args, ['context', 'preview']) || firstStringField(args, ['command', 'code']) || contextValue(args)
|
||||
|
||||
if (command) {
|
||||
const verbText = part.toolName === 'execute_code' ? verb('Running code', 'Ran code') : verb('Running', 'Ran')
|
||||
const action =
|
||||
part.toolName === 'execute_code'
|
||||
? verb(translateNow('assistant.tool.actions.runningCode'), translateNow('assistant.tool.actions.ranCode'))
|
||||
: verb(translateNow('assistant.tool.actions.running'), translateNow('assistant.tool.actions.ran'))
|
||||
|
||||
return `${verbText} · ${compactPreview(command, 160)}`
|
||||
return titledAction(
|
||||
action,
|
||||
translateNow(
|
||||
'assistant.tool.titleTemplates.actionCommand',
|
||||
action,
|
||||
compactPreview(summarizeShellCommand(command), 160)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1381,7 +1595,7 @@ function dynamicTitle(
|
||||
const path = fileEditPath(args, result)
|
||||
|
||||
if (path) {
|
||||
return fileEditBasename(path)
|
||||
return { title: fileEditBasename(path) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1395,7 +1609,15 @@ export function buildToolView(part: ToolPart, inlineDiff: string): ToolView {
|
||||
const status = toolStatus(part, resultRecord)
|
||||
const error = toolErrorText(part, resultRecord)
|
||||
const baseTitle = part.result === undefined ? meta.pending : meta.done
|
||||
const title = dynamicTitle(part, argsRecord, resultRecord, baseTitle)
|
||||
|
||||
const titleParts = dynamicTitle(
|
||||
part,
|
||||
argsRecord,
|
||||
resultRecord,
|
||||
titlePartsFromAction(baseTitle, part.result === undefined ? meta.pendingAction : undefined)
|
||||
)
|
||||
|
||||
const title = titleParts.title
|
||||
const titleEnriched = title !== baseTitle
|
||||
const baseSubtitle = error || toolSubtitle(part, argsRecord, resultRecord)
|
||||
|
||||
@@ -1449,6 +1671,7 @@ export function buildToolView(part: ToolPart, inlineDiff: string): ToolView {
|
||||
status,
|
||||
subtitle,
|
||||
title,
|
||||
titleAction: titleParts.action,
|
||||
tone: meta.tone
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import { $toolDisclosureOpen, $toolViewMode, setToolDisclosureOpen } from '@/sto
|
||||
import { PendingToolApproval } from './tool-approval'
|
||||
import {
|
||||
buildToolView,
|
||||
clampForDisplay,
|
||||
cleanVisibleText,
|
||||
countDiffLineStats,
|
||||
inlineDiffFromResult,
|
||||
@@ -45,7 +46,8 @@ import {
|
||||
toolCopyPayload,
|
||||
type ToolPart,
|
||||
toolPartDisclosureId,
|
||||
type ToolStatus
|
||||
type ToolStatus,
|
||||
type ToolTitleAction
|
||||
} from './tool-fallback-model'
|
||||
|
||||
// `true` when a ToolEntry is rendered inside an embedding wrapper that owns
|
||||
@@ -104,7 +106,7 @@ function rawTechnicalTrace(args: unknown, result: unknown): string {
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
return parts.join('\n')
|
||||
return clampForDisplay(parts.join('\n'))
|
||||
}
|
||||
|
||||
function statusGlyph(status: ToolStatus, copy: ToolStatusCopy): ReactNode {
|
||||
@@ -202,6 +204,39 @@ function LinkifiedText({ className, text }: { className?: string; text: string }
|
||||
return <SharedLinkifiedText className={className} pretty text={cleanVisibleText(text)} />
|
||||
}
|
||||
|
||||
function ToolTitle({
|
||||
isPending,
|
||||
status,
|
||||
title,
|
||||
titleAction
|
||||
}: {
|
||||
isPending: boolean
|
||||
status: ToolStatus
|
||||
title: string
|
||||
titleAction?: ToolTitleAction
|
||||
}) {
|
||||
return (
|
||||
<FadeText
|
||||
className={cn(
|
||||
TOOL_HEADER_TITLE_CLASS,
|
||||
isPending && 'text-(--ui-text-tertiary)',
|
||||
status === 'error' && 'text-destructive',
|
||||
status === 'warning' && 'text-amber-700 dark:text-amber-300'
|
||||
)}
|
||||
>
|
||||
{isPending && titleAction ? (
|
||||
<>
|
||||
{titleAction.prefix}
|
||||
<span className="shimmer">{titleAction.text}</span>
|
||||
{titleAction.suffix}
|
||||
</>
|
||||
) : (
|
||||
title
|
||||
)}
|
||||
</FadeText>
|
||||
)
|
||||
}
|
||||
|
||||
interface ToolEntryProps {
|
||||
part: ToolPart
|
||||
}
|
||||
@@ -220,13 +255,25 @@ function ToolEntry({ part }: ToolEntryProps) {
|
||||
const messageRunning = useAuiState(selectMessageRunning)
|
||||
const embedded = useContext(ToolEmbedContext)
|
||||
const toolViewMode = useStore($toolViewMode)
|
||||
const disclosureId = `tool-entry:${messageId}:${toolPartDisclosureId(part)}`
|
||||
|
||||
// `ToolFallback` rebuilds the `part` wrapper each render, defeating the memos
|
||||
// below and re-running buildToolView (full JSON.stringify of result) on every
|
||||
// stream delta — the freeze on big `/learn` runs. Re-derive a stable part from
|
||||
// the referentially-stable args/result so the memos hold across deltas.
|
||||
const { args, isError, result, toolCallId, toolName } = part
|
||||
|
||||
const stablePart = useMemo<ToolPart>(
|
||||
() => ({ args, isError, result, toolCallId, toolName, type: 'tool-call' }),
|
||||
[args, isError, result, toolCallId, toolName]
|
||||
)
|
||||
|
||||
const disclosureId = `tool-entry:${messageId}:${toolPartDisclosureId(stablePart)}`
|
||||
const dismissed = useStore($toolRowDismissed(disclosureId))
|
||||
const isPending = messageRunning && part.result === undefined
|
||||
const isPending = messageRunning && result === undefined
|
||||
const liveDiffs = useStore($toolInlineDiffs)
|
||||
const sideDiff = part.toolCallId ? liveDiffs[part.toolCallId] || '' : ''
|
||||
const inlineDiff = stripInlineDiffChrome(sideDiff) || inlineDiffFromResult(part.result)
|
||||
const isFileEdit = isFileEditTool(part.toolName)
|
||||
const sideDiff = toolCallId ? liveDiffs[toolCallId] || '' : ''
|
||||
const inlineDiff = stripInlineDiffChrome(sideDiff) || inlineDiffFromResult(result)
|
||||
const isFileEdit = isFileEditTool(toolName)
|
||||
const defaultOpen = Boolean(inlineDiff)
|
||||
const open = useDisclosureOpen(disclosureId, defaultOpen)
|
||||
const canDismiss = !isPending && !embedded
|
||||
@@ -237,13 +284,14 @@ function ToolEntry({ part }: ToolEntryProps) {
|
||||
const enterRef = useEnterAnimation(messageRunning && !embedded, `tool-entry:${disclosureId}`)
|
||||
const elapsed = useElapsedSeconds(isPending, `tool:${disclosureId}`)
|
||||
|
||||
// Stale parts (no result, but message stopped running) get a synthetic
|
||||
// empty result so buildToolView treats them as completed-no-output.
|
||||
// Stale parts (no result, but message stopped running) get a synthetic empty
|
||||
// result so buildToolView treats them as completed-no-output. Keyed on
|
||||
// stablePart so it recomputes only when this tool's data changes.
|
||||
const view = useMemo(() => {
|
||||
const p = !isPending && part.result === undefined ? { ...part, result: {} } : part
|
||||
const p = !isPending && result === undefined ? { ...stablePart, result: {} } : stablePart
|
||||
|
||||
return buildToolView(p, inlineDiff)
|
||||
}, [inlineDiff, isPending, part])
|
||||
}, [inlineDiff, isPending, result, stablePart])
|
||||
|
||||
// Surface a previewable artifact (HTML file / localhost URL) as a compact link
|
||||
// in the composer status stack rather than a bulky inline card. Uses the same
|
||||
@@ -313,7 +361,9 @@ function ToolEntry({ part }: ToolEntryProps) {
|
||||
view.imageUrl || view.inlineDiff || showDetail || hasSearchHits || toolViewMode === 'technical'
|
||||
)
|
||||
|
||||
const copyAction = useMemo(() => toolCopyPayload(part, view), [part, view])
|
||||
// copyAction reads the uncapped view.detail; clampForDisplay below only bounds
|
||||
// what's painted, so the row's Copy button still yields the full output.
|
||||
const copyAction = useMemo(() => toolCopyPayload(stablePart, view), [stablePart, view])
|
||||
|
||||
const diffStats = useMemo(
|
||||
() => (isFileEdit && view.inlineDiff ? countDiffLineStats(view.inlineDiff) : null),
|
||||
@@ -398,16 +448,7 @@ function ToolEntry({ part }: ToolEntryProps) {
|
||||
icon={view.icon}
|
||||
status={leadingStatus(isPending, view.status)}
|
||||
/>
|
||||
<FadeText
|
||||
className={cn(
|
||||
TOOL_HEADER_TITLE_CLASS,
|
||||
isPending && 'shimmer text-(--ui-text-tertiary)',
|
||||
view.status === 'error' && 'text-destructive',
|
||||
view.status === 'warning' && 'text-amber-700 dark:text-amber-300'
|
||||
)}
|
||||
>
|
||||
{view.title}
|
||||
</FadeText>
|
||||
<ToolTitle isPending={isPending} status={view.status} title={view.title} titleAction={view.titleAction} />
|
||||
{!isPending && view.countLabel && <span className={TOOL_HEADER_DURATION_CLASS}>{view.countLabel}</span>}
|
||||
{showDiffStats && diffStats && (
|
||||
<span className="flex shrink-0 items-center gap-1 font-mono text-[0.625rem] tabular-nums">
|
||||
@@ -466,7 +507,7 @@ function ToolEntry({ part }: ToolEntryProps) {
|
||||
detailSections.summary && 'mt-1.5'
|
||||
)}
|
||||
>
|
||||
{detailSections.body}
|
||||
{clampForDisplay(detailSections.body)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
@@ -481,7 +522,7 @@ function ToolEntry({ part }: ToolEntryProps) {
|
||||
<div className="space-y-0.5">
|
||||
{view.stderr && <p className={TOOL_SECTION_LABEL_CLASS}>stdout</p>}
|
||||
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'whitespace-pre-wrap wrap-anywhere')}>
|
||||
{view.rendersAnsi ? <AnsiText text={view.stdout} /> : view.stdout}
|
||||
{view.rendersAnsi ? <AnsiText text={clampForDisplay(view.stdout)} /> : clampForDisplay(view.stdout)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
@@ -494,7 +535,7 @@ function ToolEntry({ part }: ToolEntryProps) {
|
||||
'whitespace-pre-wrap wrap-anywhere text-(--ui-text-tertiary)'
|
||||
)}
|
||||
>
|
||||
{view.rendersAnsi ? <AnsiText text={view.stderr} /> : view.stderr}
|
||||
{view.rendersAnsi ? <AnsiText text={clampForDisplay(view.stderr)} /> : clampForDisplay(view.stderr)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
@@ -504,10 +545,10 @@ function ToolEntry({ part }: ToolEntryProps) {
|
||||
{view.detailLabel && <p className={TOOL_SECTION_LABEL_CLASS}>{view.detailLabel}</p>}
|
||||
{renderDetailAsCode ? (
|
||||
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'whitespace-pre-wrap wrap-anywhere')}>
|
||||
{view.rendersAnsi ? <AnsiText text={view.detail} /> : view.detail}
|
||||
{view.rendersAnsi ? <AnsiText text={clampForDisplay(view.detail)} /> : clampForDisplay(view.detail)}
|
||||
</pre>
|
||||
) : (
|
||||
<CompactMarkdown className={cn(TOOL_SECTION_SURFACE_CLASS, 'wrap-anywhere')} text={view.detail} />
|
||||
<CompactMarkdown className={cn(TOOL_SECTION_SURFACE_CLASS, 'wrap-anywhere')} text={clampForDisplay(view.detail)} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -407,7 +407,11 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
||||
|
||||
const totalCount = stages.length
|
||||
const failed = Boolean(state.error)
|
||||
const progressPct = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0
|
||||
// Count the running stage as half-done so the bar advances *during* a long
|
||||
// stage instead of sitting frozen at the last completed step while its logs
|
||||
// stream (e.g. "0 of 2" pinned at 0% for the whole first stage).
|
||||
const progressUnits = completedCount + (!failed && currentStage ? 0.5 : 0)
|
||||
const progressPct = totalCount > 0 ? Math.round((progressUnits / totalCount) * 100) : 0
|
||||
const currentStartedAt = currentStage ? state.stages[currentStage]?.startedAt : null
|
||||
const currentElapsed = typeof currentStartedAt === 'number' ? formatElapsed(now - currentStartedAt) : ''
|
||||
|
||||
|
||||
@@ -22,6 +22,25 @@ interface Point {
|
||||
y: number
|
||||
}
|
||||
|
||||
interface PetInfoMeta {
|
||||
enabled: boolean
|
||||
slug?: string
|
||||
displayName?: string
|
||||
scale?: number
|
||||
spritesheetRevision?: string
|
||||
}
|
||||
|
||||
function samePetRevision(info: PetInfo, meta: PetInfoMeta): boolean {
|
||||
return (
|
||||
info.enabled &&
|
||||
Boolean(info.spritesheetBase64) &&
|
||||
info.slug === meta.slug &&
|
||||
info.displayName === meta.displayName &&
|
||||
info.scale === meta.scale &&
|
||||
info.spritesheetRevision === meta.spritesheetRevision
|
||||
)
|
||||
}
|
||||
|
||||
function clampToViewport({ x, y }: Point): Point {
|
||||
const maxX = Math.max(0, (window.innerWidth || 800) - 80)
|
||||
const maxY = Math.max(0, (window.innerHeight || 600) - 80)
|
||||
@@ -63,12 +82,15 @@ function loadPosition(): Point {
|
||||
* Adopting a pet is fully in-app: type `/pet boba` in the composer. That
|
||||
* writes `display.pet.*` from the slash worker, so we keep polling `pet.info`
|
||||
* while no pet is active and the mascot pops in within a few seconds — no
|
||||
* reload, no CLI. Once a pet is live we stop polling.
|
||||
* reload, no CLI. Once a pet is live we still refresh more slowly so generated
|
||||
* pets rewritten on disk (or renamed/rebuilt by the hatch flow) repaint without
|
||||
* restarting the app.
|
||||
*
|
||||
* Promotion to a separate frameless OS-level window is a follow-up — the
|
||||
* sprite + state logic here is reused as-is, only the host changes.
|
||||
*/
|
||||
const PET_POLL_MS = 3000
|
||||
const PET_ACTIVE_REFRESH_MS = 15000
|
||||
|
||||
export function FloatingPet() {
|
||||
const { requestGateway } = useGatewayRequest()
|
||||
@@ -93,11 +115,12 @@ export function FloatingPet() {
|
||||
// state is only committed on release.
|
||||
const dragRef = useRef<{ dx: number; dy: number; x: number; y: number } | null>(null)
|
||||
|
||||
// Fetch pet.info on connect, then keep polling while no pet is active so an
|
||||
// in-app `/pet <slug>` shows up live. Stops polling once a pet is enabled.
|
||||
// Fetch pet.info on connect. Poll quickly while inactive so an in-app
|
||||
// `/pet <slug>` appears, then slowly while active so regenerated spritesheets
|
||||
// and row-count metadata replace the cached base64 payload.
|
||||
const active = info.enabled && Boolean(info.spritesheetBase64)
|
||||
useEffect(() => {
|
||||
if (gatewayState !== 'open' || active) {
|
||||
if (gatewayState !== 'open') {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -105,9 +128,39 @@ export function FloatingPet() {
|
||||
|
||||
const pull = async () => {
|
||||
try {
|
||||
if (active) {
|
||||
try {
|
||||
const meta = await requestGateway<PetInfoMeta>('pet.info.meta', { profile: petProfile() })
|
||||
if (cancelled || !meta) {
|
||||
return
|
||||
}
|
||||
if (!meta.enabled) {
|
||||
setPetInfo({ enabled: false })
|
||||
return
|
||||
}
|
||||
if (samePetRevision($petInfo.get(), meta)) {
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Older gateways may not have pet.info.meta yet; fall back to pet.info.
|
||||
}
|
||||
}
|
||||
|
||||
const next = await requestGateway<PetInfo>('pet.info', { profile: petProfile() })
|
||||
|
||||
if (!cancelled && next) {
|
||||
const current = $petInfo.get()
|
||||
if (
|
||||
next.enabled &&
|
||||
current.enabled &&
|
||||
current.slug === next.slug &&
|
||||
current.displayName === next.displayName &&
|
||||
current.scale === next.scale &&
|
||||
current.spritesheetRevision &&
|
||||
current.spritesheetRevision === next.spritesheetRevision
|
||||
) {
|
||||
return
|
||||
}
|
||||
setPetInfo(next)
|
||||
}
|
||||
} catch {
|
||||
@@ -116,10 +169,12 @@ export function FloatingPet() {
|
||||
}
|
||||
|
||||
void pull()
|
||||
const timer = window.setInterval(() => void pull(), PET_POLL_MS)
|
||||
const timer = window.setInterval(() => void pull(), active ? PET_ACTIVE_REFRESH_MS : PET_POLL_MS)
|
||||
window.addEventListener('focus', pull)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.removeEventListener('focus', pull)
|
||||
window.clearInterval(timer)
|
||||
}
|
||||
}, [gatewayState, active, requestGateway])
|
||||
|
||||
68
apps/desktop/src/components/pet/pet-egg-hatch.tsx
Normal file
68
apps/desktop/src/components/pet/pet-egg-hatch.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Egg-hatch visuals for the pet generation flow (Cmd-K → Pets → Generate).
|
||||
*
|
||||
* `PetEggHatch` is the incubation beat shown while `pet.hatch` runs: a wobbling
|
||||
* egg that reads as "something is about to hatch" instead of a bare spinner. The
|
||||
* reveal celebration is the canvas `PetStarShower`. Motion is disabled under
|
||||
* `prefers-reduced-motion`.
|
||||
*/
|
||||
|
||||
import { PixelEggSprite } from '@/components/pet/pixel-egg-sprite'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface PetEggHatchProps {
|
||||
subtitle?: string
|
||||
onCancel?: () => void
|
||||
cancelLabel?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin progress bar. Determinate when given done/total (hatch rows stream one by
|
||||
* one, so a real percentage is meaningful); indeterminate otherwise (drafts
|
||||
* return together, so a count would just snap 0→100).
|
||||
*/
|
||||
export function PetProgress({ done, total }: { done?: number; total?: number }) {
|
||||
const determinate = typeof done === 'number' && typeof total === 'number' && total > 0
|
||||
const pct = determinate ? Math.min(100, Math.round((done / total) * 100)) : 0
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-valuemax={100}
|
||||
aria-valuemin={0}
|
||||
aria-valuenow={determinate ? pct : undefined}
|
||||
className="pet-progress"
|
||||
role="progressbar"
|
||||
>
|
||||
{determinate ? (
|
||||
<div className="pet-progress__fill" style={{ width: `${pct}%` }} />
|
||||
) : (
|
||||
<div className="pet-progress__indeterminate" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PetEggHatch({ subtitle, onCancel, cancelLabel }: PetEggHatchProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
<PixelEggSprite mode="bounce" size={88} />
|
||||
{/* The egg sprite has transparent canvas below the art, so pull the
|
||||
shadow up ~a fifth of its size to sit at the egg's base. */}
|
||||
<span className="pet-egg-shadow" style={{ marginTop: '-0.55rem' }} />
|
||||
</div>
|
||||
|
||||
{subtitle && (
|
||||
<p className="shimmer shimmer-color-primary whitespace-nowrap text-center text-[length:var(--conversation-caption-font-size)] leading-snug text-(--ui-text-tertiary)">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{onCancel && (
|
||||
<Button onClick={onCancel} size="xs" variant="text">
|
||||
{cancelLabel ?? 'Cancel'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
BIN
apps/desktop/src/components/pet/pet-egg-sheet.png
Normal file
BIN
apps/desktop/src/components/pet/pet-egg-sheet.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
@@ -32,10 +32,33 @@ const STATE_ALIASES: Record<PetState, string[]> = {
|
||||
waiting: ['waiting']
|
||||
}
|
||||
|
||||
const ROW_TO_STATE: Record<string, PetState> = {
|
||||
idle: 'idle',
|
||||
wave: 'wave',
|
||||
waving: 'wave',
|
||||
jump: 'jump',
|
||||
jumping: 'jump',
|
||||
run: 'run',
|
||||
running: 'run',
|
||||
'running-right': 'run',
|
||||
'running-left': 'run',
|
||||
failed: 'failed',
|
||||
review: 'review',
|
||||
waiting: 'waiting'
|
||||
}
|
||||
|
||||
interface PetSpriteProps {
|
||||
info: PetInfo
|
||||
/** On-screen scale multiplier applied on top of the pet's native scale. */
|
||||
zoom?: number
|
||||
/**
|
||||
* Force a specific animation state instead of reading the live `$petState`.
|
||||
* Used by the generate-flow preview to showcase every row without driving (or
|
||||
* being driven by) the real agent activity that moves the floating mascot.
|
||||
*/
|
||||
stateOverride?: PetState
|
||||
/** Force a concrete row name from `info.stateRows` (e.g. `running-right`). */
|
||||
rowOverride?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,14 +72,26 @@ interface PetSpriteProps {
|
||||
* with `memo`, this component effectively never re-renders after mount until
|
||||
* the pet itself changes.
|
||||
*/
|
||||
function PetSpriteImpl({ info, zoom = 1 }: PetSpriteProps) {
|
||||
function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSpriteProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||
const stateRef = useRef<PetState>($petState.get())
|
||||
const overrideRef = useRef<PetState | undefined>(stateOverride)
|
||||
const rowOverrideRef = useRef<string | undefined>(rowOverride)
|
||||
|
||||
// Keep the override current without re-running the RAF setup effect.
|
||||
useEffect(() => {
|
||||
overrideRef.current = stateOverride
|
||||
}, [stateOverride])
|
||||
|
||||
useEffect(() => {
|
||||
rowOverrideRef.current = rowOverride
|
||||
}, [rowOverride])
|
||||
|
||||
const frameW = info.frameW ?? DEFAULT_FRAME_W
|
||||
const frameH = info.frameH ?? DEFAULT_FRAME_H
|
||||
const frames = info.framesPerState ?? DEFAULT_FRAMES
|
||||
const framesByState = info.framesByState
|
||||
const framesByRow = info.framesByRow
|
||||
const loopMs = info.loopMs ?? DEFAULT_LOOP_MS
|
||||
const scale = (info.scale ?? DEFAULT_SCALE) * zoom
|
||||
const rows = info.stateRows ?? DEFAULT_STATE_ROWS
|
||||
@@ -100,6 +135,8 @@ function PetSpriteImpl({ info, zoom = 1 }: PetSpriteProps) {
|
||||
let lastStep = performance.now()
|
||||
let drawnFrame = -1
|
||||
let drawnRow = -1
|
||||
let activeRow = -1
|
||||
let activeCount = -1
|
||||
|
||||
const rowIndexForState = (s: PetState): number => {
|
||||
for (const key of STATE_ALIASES[s] ?? [s]) {
|
||||
@@ -116,6 +153,7 @@ function PetSpriteImpl({ info, zoom = 1 }: PetSpriteProps) {
|
||||
// than flashing blank padding.
|
||||
const resolve = (s: PetState): { row: number; count: number } => {
|
||||
const real = framesByState?.[s] ?? frames
|
||||
|
||||
if (real > 0) {
|
||||
return { row: rowIndexForState(s), count: real }
|
||||
}
|
||||
@@ -123,8 +161,28 @@ function PetSpriteImpl({ info, zoom = 1 }: PetSpriteProps) {
|
||||
return { row: rowIndexForState('idle'), count: Math.max(1, framesByState?.idle ?? frames) }
|
||||
}
|
||||
|
||||
const resolveRow = (rowName: string): { row: number; count: number } => {
|
||||
const row = rows.indexOf(rowName)
|
||||
const state = ROW_TO_STATE[rowName]
|
||||
const count = Math.max(
|
||||
1,
|
||||
framesByRow?.[rowName] ?? framesByState?.[rowName] ?? (state ? framesByState?.[state] : 0) ?? frames
|
||||
)
|
||||
return { row: row >= 0 ? row : rowIndexForState(state ?? 'idle'), count }
|
||||
}
|
||||
|
||||
const render = (now: number) => {
|
||||
const { row, count } = resolve(stateRef.current)
|
||||
const forcedRow = rowOverrideRef.current
|
||||
const { row, count } = forcedRow ? resolveRow(forcedRow) : resolve(overrideRef.current ?? stateRef.current)
|
||||
|
||||
if (row !== activeRow || count !== activeCount) {
|
||||
activeRow = row
|
||||
activeCount = count
|
||||
frame = 0
|
||||
lastStep = now
|
||||
drawnFrame = -1
|
||||
}
|
||||
|
||||
// Per-state step keeps every state's loop ~loopMs even when frame counts
|
||||
// differ; counts vary per row so derive the cadence here, not once.
|
||||
const stepMs = loopMs / count
|
||||
@@ -158,7 +216,7 @@ function PetSpriteImpl({ info, zoom = 1 }: PetSpriteProps) {
|
||||
cancelAnimationFrame(raf)
|
||||
unsubState()
|
||||
}
|
||||
}, [image, frameW, frameH, frames, framesByState, loopMs, drawW, drawH, rows])
|
||||
}, [image, frameW, frameH, frames, framesByState, framesByRow, loopMs, drawW, drawH, rows])
|
||||
|
||||
return (
|
||||
<canvas
|
||||
|
||||
204
apps/desktop/src/components/pet/pet-star-shower.tsx
Normal file
204
apps/desktop/src/components/pet/pet-star-shower.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* Canvas hatch celebration layered over a freshly revealed pet: a one-shot
|
||||
* sunburst of rotating god-rays, a fast radial star burst (confetti physics —
|
||||
* velocity + decay + gravity + spin), and a light trickle of rising twinkle
|
||||
* motes. Additive (`lighter`) so the sparkles bloom. No glow-halo flash.
|
||||
*
|
||||
* Sized to its container (absolute inset-0, pointer-events: none) and disabled
|
||||
* under `prefers-reduced-motion`.
|
||||
*/
|
||||
|
||||
const GOLD = '#ffd76a'
|
||||
const BURST = 15
|
||||
const VELOCITY = 500
|
||||
const DECAY = 0.9
|
||||
const GRAVITY = 90
|
||||
const RAY_COUNT = 24
|
||||
const GOLD_MIX = 0.6
|
||||
const MOTE_MS = 333 // ~3 / sec
|
||||
|
||||
interface Star {
|
||||
x: number
|
||||
y: number
|
||||
vx: number
|
||||
vy: number
|
||||
size: number
|
||||
rot: number
|
||||
vrot: number
|
||||
phase: number
|
||||
twinkle: number
|
||||
life: number
|
||||
ttl: number
|
||||
color: string
|
||||
rise: boolean
|
||||
}
|
||||
|
||||
function readAccent(el: HTMLElement): string {
|
||||
return getComputedStyle(el).getPropertyValue('--ui-accent').trim() || '#9aa0ff'
|
||||
}
|
||||
|
||||
function sparkle(ctx: CanvasRenderingContext2D, size: number, rot: number, color: string): void {
|
||||
ctx.rotate(rot)
|
||||
ctx.fillStyle = color
|
||||
for (const [rx, ry] of [
|
||||
[size, size * 0.26],
|
||||
[size * 0.26, size]
|
||||
]) {
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, -ry)
|
||||
ctx.lineTo(rx, 0)
|
||||
ctx.lineTo(0, ry)
|
||||
ctx.lineTo(-rx, 0)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
}
|
||||
const core = Math.max(1, Math.round(size * 0.4))
|
||||
ctx.fillStyle = '#fff'
|
||||
ctx.fillRect(-core / 2, -core / 2, core, core)
|
||||
}
|
||||
|
||||
export function PetStarShower() {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
const ctx = canvas?.getContext('2d')
|
||||
const parent = canvas?.parentElement
|
||||
if (!canvas || !ctx || !parent) {
|
||||
return
|
||||
}
|
||||
if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
|
||||
return
|
||||
}
|
||||
|
||||
const accent = readAccent(canvas)
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 3)
|
||||
let w = 0
|
||||
let h = 0
|
||||
let cx = 0
|
||||
let cy = 0
|
||||
const resize = () => {
|
||||
const r = parent.getBoundingClientRect()
|
||||
w = r.width
|
||||
h = r.height
|
||||
cx = w / 2
|
||||
cy = h * 0.54
|
||||
canvas.width = Math.round(w * dpr)
|
||||
canvas.height = Math.round(h * dpr)
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
}
|
||||
resize()
|
||||
const ro = new ResizeObserver(resize)
|
||||
ro.observe(parent)
|
||||
|
||||
const pick = () => (Math.random() < GOLD_MIX ? GOLD : Math.random() < 0.5 ? accent : '#ffffff')
|
||||
const stars: Star[] = []
|
||||
for (let i = 0; i < BURST; i++) {
|
||||
const a = Math.random() * Math.PI * 2
|
||||
const sp = VELOCITY * (0.4 + Math.random() * 0.7)
|
||||
stars.push({
|
||||
x: cx, y: cy, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp,
|
||||
size: 3.5 + Math.random() * 5.5, rot: Math.random() * 6.28, vrot: (Math.random() - 0.5) * 8,
|
||||
phase: 0, twinkle: 0, life: 0, ttl: 0.8 + Math.random() * 0.7, color: pick(), rise: false
|
||||
})
|
||||
}
|
||||
const rays = { life: 0, ttl: 0.9, rot: Math.random() * 6.28 }
|
||||
|
||||
let raf = 0
|
||||
let last = performance.now()
|
||||
let acc = 0
|
||||
let raysAlive = true
|
||||
|
||||
const tick = (now: number) => {
|
||||
raf = requestAnimationFrame(tick)
|
||||
const ms = now - last
|
||||
last = now
|
||||
const dt = Math.min(0.05, ms / 1000)
|
||||
const decay = Math.pow(DECAY, dt * 60)
|
||||
acc += ms
|
||||
if (acc >= MOTE_MS && stars.length < 40) {
|
||||
acc = 0
|
||||
stars.push({
|
||||
x: cx + (Math.random() - 0.5) * w * 0.85, y: cy + Math.random() * h * 0.25,
|
||||
vx: (Math.random() - 0.5) * 14, vy: -(14 + Math.random() * 26),
|
||||
size: 2.5 + Math.random() * 3.5, rot: Math.random() * 6.28, vrot: (Math.random() - 0.5) * 2,
|
||||
phase: Math.random() * 6.28, twinkle: 5 + Math.random() * 4, life: 0, ttl: 1.2 + Math.random(),
|
||||
color: pick(), rise: true
|
||||
})
|
||||
}
|
||||
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
ctx.globalCompositeOperation = 'lighter'
|
||||
|
||||
// Sunburst god-rays — one-shot bloom + slow spin.
|
||||
if (raysAlive) {
|
||||
rays.life += dt
|
||||
rays.rot += dt * 0.6
|
||||
const t = rays.life / rays.ttl
|
||||
if (t >= 1) {
|
||||
raysAlive = false
|
||||
} else {
|
||||
const len = Math.max(w, h) * 0.62 * (1 - (1 - t) ** 2)
|
||||
ctx.save()
|
||||
ctx.translate(cx, cy)
|
||||
ctx.rotate(rays.rot)
|
||||
for (let i = 0; i < RAY_COUNT; i++) {
|
||||
ctx.rotate((Math.PI * 2) / RAY_COUNT)
|
||||
const a = (1 - t) * 0.3 * (i % 2 ? 0.65 : 1)
|
||||
const wd = len * 0.05
|
||||
const g = ctx.createLinearGradient(0, 0, 0, -len)
|
||||
g.addColorStop(0, `rgba(255,255,255,${a})`)
|
||||
g.addColorStop(1, 'rgba(255,255,255,0)')
|
||||
ctx.fillStyle = g
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(-wd, 0)
|
||||
ctx.lineTo(wd, 0)
|
||||
ctx.lineTo(0, -len)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = stars.length - 1; i >= 0; i--) {
|
||||
const s = stars[i]
|
||||
s.life += dt
|
||||
if (s.rise) {
|
||||
s.vy += 7 * dt
|
||||
s.phase += s.twinkle * dt
|
||||
} else {
|
||||
s.vx *= decay
|
||||
s.vy = s.vy * decay + GRAVITY * dt
|
||||
}
|
||||
s.x += s.vx * dt
|
||||
s.y += s.vy * dt
|
||||
s.rot += s.vrot * dt
|
||||
if (s.life >= s.ttl || s.y < -12) {
|
||||
stars.splice(i, 1)
|
||||
continue
|
||||
}
|
||||
const fade = s.rise
|
||||
? Math.min(1, s.life * 5, (s.ttl - s.life) * 3) * (0.45 + 0.55 * Math.abs(Math.sin(s.phase)))
|
||||
: Math.min(1, (s.ttl - s.life) * 3)
|
||||
ctx.save()
|
||||
ctx.globalAlpha = fade
|
||||
ctx.translate(Math.round(s.x), Math.round(s.y))
|
||||
sparkle(ctx, s.size, s.rot, s.color)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
ctx.globalCompositeOperation = 'source-over'
|
||||
}
|
||||
raf = requestAnimationFrame(tick)
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf)
|
||||
ro.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return <canvas className="pointer-events-none absolute inset-0 z-10 h-full w-full" ref={canvasRef} />
|
||||
}
|
||||
234
apps/desktop/src/components/pet/pixel-egg-sprite.tsx
Normal file
234
apps/desktop/src/components/pet/pixel-egg-sprite.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import { type CSSProperties, useEffect, useRef } from 'react'
|
||||
|
||||
import eggSheetUrl from './pet-egg-sheet.png'
|
||||
|
||||
/**
|
||||
* Animated pixel egg — the iamcrog "bouncing hatching egg" 12-frame sheet
|
||||
* (32×32 cells, stacked vertically), drawn to a canvas and recolored to a warm
|
||||
* white/creme shell.
|
||||
*
|
||||
* The sheet's shell is mid-gray, so a plain multiply only darkens it (still
|
||||
* gray). Instead we remap each pixel's luminance through a creme ramp via a 256-
|
||||
* entry LUT: near-black stays a warm dark outline, midtones become creme shadow,
|
||||
* highlights go near-white. Done on a 32×32 offscreen then nearest-neighbor
|
||||
* scaled up so it stays crisp.
|
||||
*
|
||||
* Frames 0–5 are the intact squash/stretch bounce; 6–11 are the crack/hatch.
|
||||
* `mode="bounce"` loops 0–5 (never shows a crack); `mode="hatch"` plays 6–11
|
||||
* once then calls onDone.
|
||||
*/
|
||||
|
||||
const FRAME = 32
|
||||
const TOTAL_FRAMES = 12
|
||||
const BOUNCE_FRAMES = 6 // 0..5 — intact egg only; cracks start at frame 6
|
||||
const HATCH_START = 6 // first crack frame
|
||||
// Per-frame speed *while* a bounce is playing.
|
||||
const BOUNCE_MS = 250
|
||||
const HATCH_MS = 190
|
||||
// Harvest-Moon idle: the egg rests on frame 0 for a long, randomized gap between
|
||||
// bounces so it reads as "occasionally stirs", not "constantly animating".
|
||||
const REST_MIN_MS = 2600
|
||||
const REST_MAX_MS = 6200
|
||||
|
||||
// Creme ramp endpoints: warm dark outline → creme shadow → near-white highlight.
|
||||
const OUTLINE: [number, number, number] = [78, 66, 58]
|
||||
const SHADOW: [number, number, number] = [214, 198, 168]
|
||||
const HIGHLIGHT: [number, number, number] = [253, 249, 238]
|
||||
const OUTLINE_CUTOFF = 46
|
||||
|
||||
const lerp = (a: number, b: number, t: number) => a + (b - a) * t
|
||||
|
||||
// Precompute the luminance→creme mapping once (shared across every egg). Below
|
||||
// the cutoff it's the flat outline; above, a SHADOW→HIGHLIGHT ramp.
|
||||
const CREME_LUT = (() => {
|
||||
const lut = new Uint8ClampedArray(256 * 3)
|
||||
for (let g = 0; g < 256; g++) {
|
||||
const dark = g < OUTLINE_CUTOFF
|
||||
const t = dark ? 0 : (g - OUTLINE_CUTOFF) / (255 - OUTLINE_CUTOFF)
|
||||
const from = dark ? OUTLINE : SHADOW
|
||||
const to = dark ? OUTLINE : HIGHLIGHT
|
||||
lut.set([lerp(from[0], to[0], t), lerp(from[1], to[1], t), lerp(from[2], to[2], t)], g * 3)
|
||||
}
|
||||
return lut
|
||||
})()
|
||||
|
||||
let _sheet: HTMLImageElement | null = null
|
||||
let _sheetLoading: Promise<HTMLImageElement> | null = null
|
||||
|
||||
function loadSheet(): Promise<HTMLImageElement> {
|
||||
if (_sheet?.complete) {
|
||||
return Promise.resolve(_sheet)
|
||||
}
|
||||
if (!_sheetLoading) {
|
||||
_sheetLoading = new Promise((resolve, reject) => {
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
_sheet = img
|
||||
resolve(img)
|
||||
}
|
||||
img.onerror = reject
|
||||
img.src = eggSheetUrl
|
||||
})
|
||||
}
|
||||
return _sheetLoading
|
||||
}
|
||||
|
||||
interface PixelEggSpriteProps {
|
||||
mode: 'bounce' | 'hatch'
|
||||
/** On-screen size (px, square). */
|
||||
size: number
|
||||
/**
|
||||
* Slot position in a grid of eggs. Used to deterministically spread each egg's
|
||||
* first bounce across the rest window so neighbours never stir together (random
|
||||
* jitter alone can collide with only a handful of eggs).
|
||||
*/
|
||||
index?: number
|
||||
className?: string
|
||||
style?: CSSProperties
|
||||
/** Fired once when a `hatch` run reaches the final frame. */
|
||||
onDone?: () => void
|
||||
}
|
||||
|
||||
export function PixelEggSprite({ mode, size, index = 0, className, style, onDone }: PixelEggSpriteProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||
const onDoneRef = useRef(onDone)
|
||||
onDoneRef.current = onDone
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
const ctx = canvas?.getContext('2d')
|
||||
if (!canvas || !ctx) {
|
||||
return
|
||||
}
|
||||
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 3)
|
||||
const dim = Math.round(size * dpr)
|
||||
canvas.width = dim
|
||||
canvas.height = dim
|
||||
|
||||
const lastFrame = TOTAL_FRAMES - 1
|
||||
// Mild per-egg speed jitter so bounces don't feel mechanical.
|
||||
const frameMs = (mode === 'bounce' ? BOUNCE_MS : HATCH_MS) * (0.85 + Math.random() * 0.3)
|
||||
const restMs = () => REST_MIN_MS + Math.random() * (REST_MAX_MS - REST_MIN_MS)
|
||||
// First bounce: a deterministic per-slot slice of the rest window (so two
|
||||
// eggs never start together) plus a little random jitter on top.
|
||||
const firstDelay = ((index % 4) + 1) * (REST_MIN_MS / 4) + Math.random() * REST_MIN_MS
|
||||
|
||||
// 32×32 offscreen we recolor per frame, then scale up nearest-neighbor.
|
||||
const off = document.createElement('canvas')
|
||||
off.width = FRAME
|
||||
off.height = FRAME
|
||||
const offCtx = off.getContext('2d', { willReadFrequently: true })
|
||||
|
||||
let sheet: HTMLImageElement | null = null
|
||||
void loadSheet().then(img => {
|
||||
sheet = img
|
||||
})
|
||||
|
||||
const render = (frame: number) => {
|
||||
if (!sheet || !offCtx) {
|
||||
return
|
||||
}
|
||||
offCtx.clearRect(0, 0, FRAME, FRAME)
|
||||
offCtx.imageSmoothingEnabled = false
|
||||
offCtx.drawImage(sheet, 0, frame * FRAME, FRAME, FRAME, 0, 0, FRAME, FRAME)
|
||||
const img = offCtx.getImageData(0, 0, FRAME, FRAME)
|
||||
const d = img.data
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
if (d[i + 3] === 0) {
|
||||
continue
|
||||
}
|
||||
const g = d[i] * 3
|
||||
d[i] = CREME_LUT[g]
|
||||
d[i + 1] = CREME_LUT[g + 1]
|
||||
d[i + 2] = CREME_LUT[g + 2]
|
||||
}
|
||||
offCtx.putImageData(img, 0, 0)
|
||||
|
||||
ctx.clearRect(0, 0, dim, dim)
|
||||
ctx.imageSmoothingEnabled = false
|
||||
ctx.drawImage(off, 0, 0, FRAME, FRAME, 0, 0, dim, dim)
|
||||
}
|
||||
|
||||
let raf = 0
|
||||
let step = 0
|
||||
let finished = false
|
||||
// bounce: `nextAt` is when the next thing happens — the next bounce frame, or
|
||||
// the start of a new bounce after a rest. hatch: `lastHatch` time-gates frames.
|
||||
let resting = mode === 'bounce'
|
||||
let nextAt = 0
|
||||
let lastHatch = 0
|
||||
|
||||
const tick = (now: number) => {
|
||||
raf = requestAnimationFrame(tick)
|
||||
if (!sheet) {
|
||||
return
|
||||
}
|
||||
|
||||
if (mode === 'hatch') {
|
||||
if (!lastHatch) {
|
||||
lastHatch = now
|
||||
render(HATCH_START)
|
||||
return
|
||||
}
|
||||
if (now - lastHatch < frameMs) {
|
||||
return
|
||||
}
|
||||
lastHatch = now
|
||||
const frame = Math.min(HATCH_START + step, lastFrame)
|
||||
render(frame)
|
||||
if (frame >= lastFrame) {
|
||||
if (!finished) {
|
||||
finished = true
|
||||
onDoneRef.current?.()
|
||||
}
|
||||
return // hold the cracked-open last frame
|
||||
}
|
||||
step += 1
|
||||
return
|
||||
}
|
||||
|
||||
// bounce: rest on frame 0, play 0..5, then rest again.
|
||||
if (!nextAt) {
|
||||
render(0)
|
||||
nextAt = now + firstDelay // staggered first bounce, per slot
|
||||
return
|
||||
}
|
||||
if (now < nextAt) {
|
||||
return
|
||||
}
|
||||
|
||||
if (resting) {
|
||||
resting = false
|
||||
step = 0
|
||||
render(0)
|
||||
nextAt = now + frameMs
|
||||
return
|
||||
}
|
||||
|
||||
step += 1
|
||||
if (step >= BOUNCE_FRAMES) {
|
||||
resting = true
|
||||
render(0)
|
||||
nextAt = now + restMs()
|
||||
return
|
||||
}
|
||||
render(step)
|
||||
nextAt = now + frameMs
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(tick)
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf)
|
||||
}
|
||||
}, [mode, size, index])
|
||||
|
||||
return (
|
||||
<canvas
|
||||
className={className}
|
||||
ref={canvasRef}
|
||||
style={{ width: size, height: size, imageRendering: 'pixelated', ...style }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -35,16 +35,98 @@ function DialogOverlay({ className, ...props }: React.ComponentProps<typeof Dial
|
||||
)
|
||||
}
|
||||
|
||||
type DialogBannerTone = 'error' | 'warn' | 'info'
|
||||
|
||||
// Tinted, edge-to-edge bottom banner per tone. Error/warn keep their semantic
|
||||
// destructive/primary tokens; info derives from the dialog's own bubble
|
||||
// background so it reads as part of the themed dialog — lifted 30% toward white
|
||||
// in light mode, deepened 20% toward black in dark mode.
|
||||
const DIALOG_BANNER_TONES: Record<DialogBannerTone, string> = {
|
||||
error: 'bg-destructive/12 text-destructive',
|
||||
warn: 'bg-primary/12 text-primary',
|
||||
info: 'bg-[color-mix(in_srgb,var(--ui-chat-bubble-background),white_30%)] text-[color-mix(in_srgb,var(--ui-chat-bubble-background),black_60%)] dark:bg-[color-mix(in_srgb,var(--ui-chat-bubble-background),black_20%)] dark:text-[color-mix(in_srgb,var(--ui-chat-bubble-background),white_60%)]'
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
fitContent = false,
|
||||
banner,
|
||||
bannerTone = 'error',
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
// Size the dialog to its content (capped at the viewport) instead of the
|
||||
// default fixed `max-w-lg`. For content that has no intrinsic width (grids,
|
||||
// full-width inputs) pair it with a `min-w-*` in `className`.
|
||||
fitContent?: boolean
|
||||
// A dialog-level notice rendered as a banner flush to the bottom edge (tinted,
|
||||
// inherited bottom radius) so it reads as part of the dialog, not a floating
|
||||
// alert. Falsy → no banner. Tone picks the colour.
|
||||
banner?: React.ReactNode
|
||||
bannerTone?: DialogBannerTone
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
|
||||
const widthClass = fitContent ? 'w-auto max-w-[92vw]' : 'w-full max-w-lg'
|
||||
|
||||
const closeButton = showCloseButton ? (
|
||||
<DialogPrimitive.Close asChild data-slot="dialog-close-button">
|
||||
<Button
|
||||
aria-label={t.common.close}
|
||||
className="absolute right-2.5 top-2.5 z-20 text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="size-4" />
|
||||
<span className="sr-only">{t.common.close}</span>
|
||||
</Button>
|
||||
</DialogPrimitive.Close>
|
||||
) : null
|
||||
|
||||
// With a banner, the border can't live on the scroll/clip box (it would draw a
|
||||
// line around the banner too). The white body keeps its own bottom radius and
|
||||
// sits over the tinted footer; the outer shell only clips the banner to the
|
||||
// dialog's rounded bottom edge.
|
||||
if (banner) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
className={cn(
|
||||
'fixed left-1/2 top-1/2 z-[130] pointer-events-auto flex max-h-[85vh] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-xl bg-(--ui-chat-bubble-background) text-[length:var(--conversation-text-font-size)] text-foreground shadow-nous duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
widthClass,
|
||||
className,
|
||||
// Callers often pass `gap-*` for the no-banner grid layout — suppress
|
||||
// it here so the banner can tuck under the body's rounded bottom edge.
|
||||
'gap-0'
|
||||
)}
|
||||
data-slot="dialog-content"
|
||||
{...props}
|
||||
>
|
||||
{/* Scroll lives on an inner box so this shell keeps a painted bottom radius. */}
|
||||
<div className="relative z-10 overflow-hidden rounded-xl border border-b-0 border-(--stroke-nous) bg-(--ui-chat-bubble-background)">
|
||||
<div className="grid max-h-[calc(85vh-5rem)] min-h-0 gap-3 overflow-y-auto p-4">{children}</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
// Overlap by one corner radius so the white bottom lobes read clearly
|
||||
// over the tint instead of meeting it on a straight seam.
|
||||
'relative z-0 -mt-[var(--radius-xl)] px-4 pb-2.5 pt-[calc(var(--radius-xl)+0.625rem)] text-center text-[length:var(--conversation-tool-font-size)] leading-relaxed shadow-[inset_0_7px_7px_-4px_rgb(0_0_0/0.28)]',
|
||||
DIALOG_BANNER_TONES[bannerTone]
|
||||
)}
|
||||
data-slot="dialog-banner"
|
||||
role={bannerTone === 'error' ? 'alert' : 'status'}
|
||||
>
|
||||
{banner}
|
||||
</div>
|
||||
{closeButton}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
@@ -53,26 +135,15 @@ function DialogContent({
|
||||
// Cap height at 85vh and let long content scroll inside the dialog
|
||||
// instead of overflowing off-screen (long cron titles, tool detail
|
||||
// dumps, etc.). Individual dialogs can still override via className.
|
||||
'fixed left-1/2 top-1/2 z-[130] pointer-events-auto grid max-h-[85vh] w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-3 overflow-y-auto rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) p-4 text-[length:var(--conversation-text-font-size)] text-foreground shadow-nous duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
'fixed left-1/2 top-1/2 z-[130] pointer-events-auto grid max-h-[85vh] -translate-x-1/2 -translate-y-1/2 gap-3 overflow-y-auto rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) p-4 text-[length:var(--conversation-text-font-size)] text-foreground shadow-nous duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
widthClass,
|
||||
className
|
||||
)}
|
||||
data-slot="dialog-content"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild data-slot="dialog-close-button">
|
||||
<Button
|
||||
aria-label={t.common.close}
|
||||
className="absolute right-2.5 top-2.5 text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="size-4" />
|
||||
<span className="sr-only">{t.common.close}</span>
|
||||
</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
{closeButton}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
|
||||
62
apps/desktop/src/components/ui/generate-button.tsx
Normal file
62
apps/desktop/src/components/ui/generate-button.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import type * as React from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { Square } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface GenerateButtonProps extends Omit<React.ComponentProps<typeof Button>, 'children' | 'onClick'> {
|
||||
/** True while a generation is in flight. */
|
||||
generating: boolean
|
||||
/** Start a generation. */
|
||||
onGenerate: () => void
|
||||
/** Cancel an in-flight generation. When omitted, the button just spins while
|
||||
* generating (for one-shots that can't be cancelled). */
|
||||
onCancel?: () => void
|
||||
/** Tooltip + aria label at rest (and while generating if no `generatingLabel`). */
|
||||
label: string
|
||||
/** Tooltip while generating (e.g. "Stop" with cancel, "Generating…" without). */
|
||||
generatingLabel?: string
|
||||
iconSize?: number | string
|
||||
}
|
||||
|
||||
/** The sparkle "generate with AI" affordance — icon + tooltip, shared by the
|
||||
* commit-message box and the new-project idea field so they stay one pattern.
|
||||
* Sparkle → click generates; with `onCancel`, a Stop square appears mid-run;
|
||||
* without it, the sparkle spins until the one-shot resolves. */
|
||||
export function GenerateButton({
|
||||
generating,
|
||||
onGenerate,
|
||||
onCancel,
|
||||
label,
|
||||
generatingLabel,
|
||||
disabled,
|
||||
iconSize = 12,
|
||||
className,
|
||||
...rest
|
||||
}: GenerateButtonProps) {
|
||||
const tip = generating ? (generatingLabel ?? label) : label
|
||||
const cancellable = generating && !!onCancel
|
||||
|
||||
return (
|
||||
<Tip label={tip}>
|
||||
<Button
|
||||
aria-label={tip}
|
||||
className={cn('text-muted-foreground/80 hover:text-foreground', className)}
|
||||
disabled={generating ? !onCancel : disabled}
|
||||
onClick={cancellable ? onCancel : onGenerate}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
{...rest}
|
||||
>
|
||||
{cancellable ? (
|
||||
<Square className="fill-current" size={11} />
|
||||
) : (
|
||||
<Codicon name="sparkle" size={iconSize} spinning={generating} />
|
||||
)}
|
||||
</Button>
|
||||
</Tip>
|
||||
)
|
||||
}
|
||||
49
apps/desktop/src/hermes-profile-scope.test.ts
Normal file
49
apps/desktop/src/hermes-profile-scope.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
checkHermesUpdate,
|
||||
getActionStatus,
|
||||
getStatus,
|
||||
restartGateway,
|
||||
setApiRequestProfile,
|
||||
updateHermes
|
||||
} from './hermes'
|
||||
|
||||
// Contract: every backend-targeted action helper must carry the active gateway
|
||||
// profile, so a multi-profile / global-remote user's restart, status poll, and
|
||||
// update hit the backend they're actually on — not the primary/default. The
|
||||
// System-panel "restart does nothing" bug was these helpers dropping it.
|
||||
describe('backend action helpers are profile-scoped', () => {
|
||||
const api = vi.fn(async (_req: { path: string; profile?: string }) => ({}) as never)
|
||||
|
||||
beforeEach(() => {
|
||||
;(window as { hermesDesktop?: unknown }).hermesDesktop = { api }
|
||||
api.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setApiRequestProfile(null)
|
||||
delete (window as { hermesDesktop?: unknown }).hermesDesktop
|
||||
})
|
||||
|
||||
const lastProfile = () => api.mock.calls.at(-1)?.[0].profile
|
||||
|
||||
it('omits profile when none is active (single-profile users unaffected)', () => {
|
||||
void getStatus()
|
||||
expect(lastProfile()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('forwards the active profile to every backend action', () => {
|
||||
setApiRequestProfile('coder')
|
||||
|
||||
void getStatus()
|
||||
void restartGateway()
|
||||
void updateHermes()
|
||||
void checkHermesUpdate()
|
||||
void getActionStatus('gateway-restart')
|
||||
|
||||
for (const call of api.mock.calls) {
|
||||
expect(call[0].profile).toBe('coder')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -274,6 +274,7 @@ export function getGlobalModelInfo(): Promise<ModelInfoResponse> {
|
||||
|
||||
export function getStatus(): Promise<StatusResponse> {
|
||||
return window.hermesDesktop.api<StatusResponse>({
|
||||
...profileScoped(),
|
||||
path: '/api/status'
|
||||
})
|
||||
}
|
||||
@@ -756,6 +757,7 @@ export function setModelAssignment(body: ModelAssignmentRequest): Promise<ModelA
|
||||
|
||||
export function restartGateway(): Promise<ActionResponse> {
|
||||
return window.hermesDesktop.api<ActionResponse>({
|
||||
...profileScoped(),
|
||||
path: '/api/gateway/restart',
|
||||
method: 'POST'
|
||||
})
|
||||
@@ -763,6 +765,7 @@ export function restartGateway(): Promise<ActionResponse> {
|
||||
|
||||
export function updateHermes(): Promise<ActionResponse> {
|
||||
return window.hermesDesktop.api<ActionResponse>({
|
||||
...profileScoped(),
|
||||
path: '/api/hermes/update',
|
||||
method: 'POST'
|
||||
})
|
||||
@@ -773,12 +776,14 @@ export function updateHermes(): Promise<ActionResponse> {
|
||||
* distinct from the Electron client clone's git state. */
|
||||
export function checkHermesUpdate(force = false): Promise<BackendUpdateCheckResponse> {
|
||||
return window.hermesDesktop.api<BackendUpdateCheckResponse>({
|
||||
...profileScoped(),
|
||||
path: `/api/hermes/update/check${force ? '?force=true' : ''}`
|
||||
})
|
||||
}
|
||||
|
||||
export function getActionStatus(name: string, lines = 200): Promise<ActionStatusResponse> {
|
||||
return window.hermesDesktop.api<ActionStatusResponse>({
|
||||
...profileScoped(),
|
||||
path: `/api/actions/${encodeURIComponent(name)}/status?lines=${Math.max(1, lines)}`
|
||||
})
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ export const en: Translations = {
|
||||
backgroundExitedDuringStartup: 'Hermes background process exited during startup.',
|
||||
backendStopped: 'Backend stopped',
|
||||
desktopBootFailed: 'Desktop boot failed',
|
||||
gatewayConnectionLost: 'Lost connection to the gateway',
|
||||
gatewaySignInRequired: 'Gateway sign-in required',
|
||||
ipcBridgeUnavailable: 'Desktop IPC bridge is unavailable.'
|
||||
},
|
||||
@@ -211,6 +212,7 @@ export const en: Translations = {
|
||||
'session.togglePin': 'Pin / unpin current session',
|
||||
'composer.focus': 'Focus composer',
|
||||
'composer.modelPicker': 'Open model picker',
|
||||
'composer.voice': 'Start / stop voice conversation',
|
||||
'view.toggleSidebar': 'Toggle sessions sidebar',
|
||||
'view.toggleRightSidebar': 'Toggle file browser',
|
||||
'view.showFiles': 'Show file browser',
|
||||
@@ -389,11 +391,23 @@ export const en: Translations = {
|
||||
unreachable: "Couldn't reach the petdex gallery. Check your connection and reopen this page.",
|
||||
noMatch: query => `No pets match "${query}".`,
|
||||
installedTag: 'installed',
|
||||
generatedTag: 'Generated',
|
||||
countCapped: (cap, total) => `Showing ${cap} of ${total} — type to narrow it down.`,
|
||||
count: n => `${n} pet${n === 1 ? '' : 's'}.`,
|
||||
uninstall: name => `Uninstall ${name}`,
|
||||
delete: name => `Delete ${name}`,
|
||||
deleteTitle: name => `Delete ${name}?`,
|
||||
deleteBody: "This permanently deletes the pet — it can't be reinstalled.",
|
||||
deleteConfirm: 'Delete',
|
||||
rename: name => `Rename ${name}`,
|
||||
renameTitle: 'Rename pet',
|
||||
renamePlaceholder: 'Name your pet',
|
||||
renameSave: 'Save',
|
||||
exportPet: name => `Export ${name}`,
|
||||
adoptFailed: slug => `Could not adopt ${slug}`,
|
||||
uninstallFailed: slug => `Could not uninstall ${slug}`,
|
||||
renameFailed: slug => `Could not rename ${slug}`,
|
||||
exportFailed: slug => `Could not export ${slug}`,
|
||||
noneAvailable: 'No pets available to turn on right now.',
|
||||
turnOnFailed: 'Could not turn the pet on.',
|
||||
turnOffFailed: 'Could not turn the pet off.'
|
||||
@@ -760,10 +774,41 @@ export const en: Translations = {
|
||||
turnOff: 'Turn off',
|
||||
turnOn: 'Turn on',
|
||||
installed: 'Installed',
|
||||
generatedTag: 'Generated',
|
||||
adoptFailed: 'Could not adopt that pet.',
|
||||
toggleFailed: 'Could not toggle the pet.',
|
||||
noneAvailable: 'No pets available — pick one below to install.'
|
||||
},
|
||||
generatePet: {
|
||||
title: 'Generate a pet',
|
||||
placeholder: 'Describe a pet to generate…',
|
||||
promptHint: 'Type a description, then press Enter to draft four looks.',
|
||||
readyHint: 'Press Enter to draft four looks from your description.',
|
||||
generate: 'Generate',
|
||||
generating: 'Generating…',
|
||||
retry: 'Retry',
|
||||
hatch: 'Hatch',
|
||||
spawning: 'Spawning…',
|
||||
hatching: 'Hatching your pet…',
|
||||
hatchingSub: 'Bringing it to life…',
|
||||
hatched: 'It hatched!',
|
||||
hatchRow: (_state, done, total) => `Sketching frame ${done} of ${total}…`,
|
||||
hatchComposing: 'Piecing it together…',
|
||||
hatchSaving: 'Almost there…',
|
||||
namePlaceholder: 'Name your pet',
|
||||
staleBackend: 'Update Hermes to generate pets.',
|
||||
backgroundHint: 'You can close this — Hermes will notify you when it’s done.',
|
||||
slowProviderHint: 'This can take several minutes',
|
||||
remix: 'Remix',
|
||||
remixConfirmTitle: 'Remix this look?',
|
||||
remixConfirmBody:
|
||||
'This generates a fresh set of drafts using this one as the starting point. It can take several minutes.',
|
||||
genericError: 'Generation failed — try again or pick a suggestion.',
|
||||
referenceImageTooLarge: 'Reference image is too large. Use one under 16 MB.',
|
||||
referenceImageInvalid: 'Could not read that reference image. Try a PNG, JPG, WebP, or GIF.',
|
||||
adopt: 'Adopt',
|
||||
startOver: 'Start over'
|
||||
},
|
||||
installTheme: {
|
||||
title: 'Install theme...',
|
||||
placeholder: 'Search the VS Code Marketplace...',
|
||||
@@ -1805,7 +1850,8 @@ export const en: Translations = {
|
||||
restoreCheckpoint: 'Restore checkpoint',
|
||||
restoreFromHere: 'Restore checkpoint — rerun from this prompt',
|
||||
restoreTitle: 'Restore to this checkpoint?',
|
||||
restoreBody: 'Everything after this prompt is removed from the conversation, and the prompt runs again from here.',
|
||||
restoreBody:
|
||||
'Everything after this prompt is removed from the conversation, and the prompt runs again from here.',
|
||||
restoreConfirm: 'Restore & rerun',
|
||||
restoreNext: 'Restore next checkpoint',
|
||||
goForward: 'Go forward',
|
||||
@@ -1861,7 +1907,67 @@ export const en: Translations = {
|
||||
statusRunning: 'Running',
|
||||
statusError: 'Error',
|
||||
statusRecovered: 'Recovered',
|
||||
statusDone: 'Done'
|
||||
statusDone: 'Done',
|
||||
actions: {
|
||||
read: 'Read',
|
||||
reading: 'Reading',
|
||||
opened: 'Opened',
|
||||
opening: 'Opening',
|
||||
searched: 'Searched',
|
||||
searching: 'Searching',
|
||||
ran: 'Ran',
|
||||
running: 'Running',
|
||||
ranCode: 'Ran code',
|
||||
runningCode: 'Scripting'
|
||||
},
|
||||
prefixes: {
|
||||
browser: 'Browser',
|
||||
web: 'Web'
|
||||
},
|
||||
titleTemplates: {
|
||||
actionCommand: (action, command) => `${action} ${command}`,
|
||||
actionQuoted: (action, value) => `${action} “${value}”`,
|
||||
actionTarget: (action, target) => `${action} ${target}`,
|
||||
prefixedDone: (prefix, action) => `${prefix} ${action}`,
|
||||
runningPrefixedTool: (prefix, action) => `Running ${prefix.toLowerCase()} ${action.toLowerCase()}`,
|
||||
runningTool: action => `Running ${action.toLowerCase()}`
|
||||
},
|
||||
titles: {
|
||||
browser_click: { done: 'Clicked page element', pending: 'Clicking page element', pendingAction: 'Clicking' },
|
||||
browser_fill: { done: 'Filled form field', pending: 'Filling form field', pendingAction: 'Filling' },
|
||||
browser_navigate: { done: 'Opened page', pending: 'Opening page', pendingAction: 'Opening' },
|
||||
browser_snapshot: {
|
||||
done: 'Captured page snapshot',
|
||||
pending: 'Capturing page snapshot',
|
||||
pendingAction: 'Capturing'
|
||||
},
|
||||
browser_take_screenshot: {
|
||||
done: 'Captured screenshot',
|
||||
pending: 'Capturing screenshot',
|
||||
pendingAction: 'Capturing'
|
||||
},
|
||||
browser_type: { done: 'Typed on page', pending: 'Typing on page', pendingAction: 'Typing' },
|
||||
clarify: { done: 'Asked a question', pending: 'Asking a question', pendingAction: 'Asking' },
|
||||
cronjob: { done: 'Cron job', pending: 'Scheduling cron job', pendingAction: 'Scheduling' },
|
||||
edit_file: { done: 'Edited file', pending: 'Editing file', pendingAction: 'Editing' },
|
||||
execute_code: { done: 'Ran code', pending: 'Scripting', pendingAction: 'Scripting' },
|
||||
image_generate: { done: 'Generated image', pending: 'Generating image', pendingAction: 'Generating' },
|
||||
list_files: { done: 'Listed files', pending: 'Listing files', pendingAction: 'Listing' },
|
||||
patch: { done: 'Patched file', pending: 'Patching file', pendingAction: 'Patching' },
|
||||
read_file: { done: 'Read file', pending: 'Reading file', pendingAction: 'Reading' },
|
||||
search_files: { done: 'Searched files', pending: 'Searching files', pendingAction: 'Searching' },
|
||||
session_search_recall: {
|
||||
done: 'Searched session history',
|
||||
pending: 'Searching session history',
|
||||
pendingAction: 'Searching'
|
||||
},
|
||||
terminal: { done: 'Ran command', pending: 'Running command', pendingAction: 'Running' },
|
||||
todo: { done: 'Updated todos', pending: 'Updating todos', pendingAction: 'Updating' },
|
||||
vision_analyze: { done: 'Analyzed image', pending: 'Analyzing image', pendingAction: 'Analyzing' },
|
||||
web_extract: { done: 'Read webpage', pending: 'Reading webpage', pendingAction: 'Reading' },
|
||||
web_search: { done: 'Searched web', pending: 'Searching web', pendingAction: 'Searching' },
|
||||
write_file: { done: 'Edited file', pending: 'Editing file', pendingAction: 'Editing' }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1904,7 +2010,8 @@ export const en: Translations = {
|
||||
editFailed: 'Edit failed',
|
||||
resumeFailed: 'Resume failed',
|
||||
resumeStrandedTitle: "Couldn't load this session",
|
||||
resumeStrandedBody: 'The connection to this session failed and automatic retries gave up. Check that the gateway is running, then try again.',
|
||||
resumeStrandedBody:
|
||||
'The connection to this session failed and automatic retries gave up. Check that the gateway is running, then try again.',
|
||||
resumeRetry: 'Retry',
|
||||
nothingToBranch: 'Nothing to branch',
|
||||
branchNeedsChat: 'Start or resume a chat before branching.',
|
||||
|
||||
@@ -17,4 +17,4 @@ export {
|
||||
normalizeLocale
|
||||
} from './languages'
|
||||
export { setRuntimeI18nLocale, translateNow } from './runtime'
|
||||
export type { Locale, Translations } from './types'
|
||||
export type { Locale, ToolTitleKey, Translations } from './types'
|
||||
|
||||
@@ -57,6 +57,7 @@ export const ja = defineLocale({
|
||||
backgroundExitedDuringStartup: '起動中に Hermes バックグラウンドプロセスが終了しました。',
|
||||
backendStopped: 'バックエンドが停止しました',
|
||||
desktopBootFailed: 'デスクトップの起動に失敗しました',
|
||||
gatewayConnectionLost: 'ゲートウェイへの接続が切断されました',
|
||||
gatewaySignInRequired: 'ゲートウェイへのサインインが必要です',
|
||||
ipcBridgeUnavailable: 'デスクトップ IPC ブリッジが利用できません。'
|
||||
},
|
||||
@@ -200,8 +201,7 @@ export const ja = defineLocale({
|
||||
},
|
||||
notifications: {
|
||||
title: '通知',
|
||||
intro:
|
||||
'アプリ内トーストとは別の、ネイティブのデスクトップ通知です。設定は端末ごとに保存されます。',
|
||||
intro: 'アプリ内トーストとは別の、ネイティブのデスクトップ通知です。設定は端末ごとに保存されます。',
|
||||
enableAll: '通知を有効にする',
|
||||
enableAllDesc: 'マスタースイッチ。オフにすると以下のすべての通知を無効にします。',
|
||||
focusedHint: '完了通知は Hermes がバックグラウンドにあるときのみ表示されます。',
|
||||
@@ -304,11 +304,23 @@ export const ja = defineLocale({
|
||||
unreachable: 'petdex ギャラリーに接続できませんでした。接続を確認してこのページを開き直してください。',
|
||||
noMatch: query => `「${query}」に一致するペットがありません。`,
|
||||
installedTag: 'インストール済み',
|
||||
generatedTag: '生成',
|
||||
countCapped: (cap, total) => `${total} 件中 ${cap} 件を表示中——入力して絞り込めます。`,
|
||||
count: n => `${n} 件のペット。`,
|
||||
uninstall: name => `${name} をアンインストール`,
|
||||
delete: name => `${name} を削除`,
|
||||
deleteTitle: name => `${name} を削除しますか?`,
|
||||
deleteBody: 'ペットを完全に削除します。再インストールはできません。',
|
||||
deleteConfirm: '削除',
|
||||
rename: name => `${name} の名前を変更`,
|
||||
renameTitle: 'ペットの名前を変更',
|
||||
renamePlaceholder: 'ペットに名前を付ける',
|
||||
renameSave: '保存',
|
||||
exportPet: name => `${name} をエクスポート`,
|
||||
adoptFailed: slug => `${slug} を採用できませんでした`,
|
||||
uninstallFailed: slug => `${slug} をアンインストールできませんでした`,
|
||||
renameFailed: slug => `${slug} の名前を変更できませんでした`,
|
||||
exportFailed: slug => `${slug} をエクスポートできませんでした`,
|
||||
noneAvailable: 'オンにできるペットがありません。',
|
||||
turnOnFailed: 'ペットをオンにできませんでした。',
|
||||
turnOffFailed: 'ペットをオフにできませんでした。'
|
||||
@@ -880,10 +892,40 @@ export const ja = defineLocale({
|
||||
turnOff: 'オフ',
|
||||
turnOn: 'オン',
|
||||
installed: 'インストール済み',
|
||||
generatedTag: '生成',
|
||||
adoptFailed: 'ペットを採用できませんでした。',
|
||||
toggleFailed: 'ペットを切り替えできませんでした。',
|
||||
noneAvailable: '利用可能なペットがありません。'
|
||||
},
|
||||
generatePet: {
|
||||
title: 'ペットを生成',
|
||||
placeholder: '生成するペットを説明…',
|
||||
promptHint: '説明を入力して Enter を押すと、4 つの見た目を生成します。',
|
||||
readyHint: 'Enter を押すと、説明から 4 つの見た目を生成します。',
|
||||
generate: '生成',
|
||||
generating: '生成中…',
|
||||
retry: '再試行',
|
||||
hatch: '孵化',
|
||||
spawning: 'スポーン中…',
|
||||
hatching: 'ペットを孵化しています…',
|
||||
hatchingSub: '命を吹き込んでいます…',
|
||||
hatched: '孵化しました!',
|
||||
hatchRow: (_state, done, total) => `フレームを描画中… ${done}/${total}`,
|
||||
hatchComposing: 'まとめています…',
|
||||
hatchSaving: 'もうすぐです…',
|
||||
namePlaceholder: 'ペットに名前を付ける',
|
||||
staleBackend: 'ペットを生成するには Hermes を更新してください。',
|
||||
backgroundHint: 'このウィンドウは閉じても大丈夫です。完了したら Hermes が通知します。',
|
||||
slowProviderHint: '数分かかることがあります',
|
||||
remix: 'リミックス',
|
||||
remixConfirmTitle: 'この見た目でリミックスしますか?',
|
||||
remixConfirmBody: 'これを起点に新しい候補を生成します。数分かかることがあります。',
|
||||
genericError: '生成に失敗しました。もう一度試すか、候補を選んでください。',
|
||||
referenceImageTooLarge: '参照画像が大きすぎます。16 MB 未満の画像を使ってください。',
|
||||
referenceImageInvalid: '参照画像を読み込めませんでした。PNG/JPG/WebP/GIF を試してください。',
|
||||
adopt: '迎え入れる',
|
||||
startOver: 'やり直す'
|
||||
},
|
||||
installTheme: {
|
||||
title: 'テーマをインストール...',
|
||||
placeholder: 'VS Code Marketplace を検索...',
|
||||
@@ -1459,7 +1501,8 @@ export const ja = defineLocale({
|
||||
queueSend: '送信',
|
||||
queueDelete: '削除',
|
||||
queueStuckTitle: 'キュー内のメッセージを送信できません',
|
||||
queueStuckBody: 'キューに入れたターンの送信が繰り返し失敗しました。まだキューに残っています。もう一度送信してください。',
|
||||
queueStuckBody:
|
||||
'キューに入れたターンの送信が繰り返し失敗しました。まだキューに残っています。もう一度送信してください。',
|
||||
previewUnavailable: 'プレビューは利用できません',
|
||||
previewLabel: label => `${label} のプレビュー`,
|
||||
couldNotPreview: label => `${label} をプレビューできませんでした`,
|
||||
@@ -1558,7 +1601,8 @@ export const ja = defineLocale({
|
||||
copy: 'コピー',
|
||||
copied: 'コピーしました',
|
||||
done: '完了',
|
||||
applyingBody: 'Hermes アップデーターが独自のウィンドウで引き継ぎ、完了後に自動的に Hermes を再度開きます。更新中はご自分で Hermes を開き直さないでください。',
|
||||
applyingBody:
|
||||
'Hermes アップデーターが独自のウィンドウで引き継ぎ、完了後に自動的に Hermes を再度開きます。更新中はご自分で Hermes を開き直さないでください。',
|
||||
applyingBodyBackend: 'リモートバックエンドが更新を適用して再起動します。復帰すると Hermes が自動的に再接続します。',
|
||||
applyingClose: 'このウィンドウは更新中に閉じ、その後 Hermes が自動的に再度開きます。',
|
||||
errorTitle: '更新が完了しませんでした',
|
||||
@@ -1990,7 +2034,83 @@ export const ja = defineLocale({
|
||||
statusRunning: '実行中',
|
||||
statusError: 'エラー',
|
||||
statusRecovered: '回復しました',
|
||||
statusDone: '完了'
|
||||
statusDone: '完了',
|
||||
actions: {
|
||||
read: '読み取り完了',
|
||||
reading: '読み取り中',
|
||||
opened: 'オープン済み',
|
||||
opening: 'オープン中',
|
||||
searched: '検索完了',
|
||||
searching: '検索中',
|
||||
ran: '実行完了',
|
||||
running: '実行中',
|
||||
ranCode: 'コード実行完了',
|
||||
runningCode: 'スクリプト作成中'
|
||||
},
|
||||
prefixes: {
|
||||
browser: 'ブラウザー',
|
||||
web: 'Web'
|
||||
},
|
||||
titleTemplates: {
|
||||
actionCommand: (action, command) => `${action} ${command}`,
|
||||
actionQuoted: (action, value) => `「${value}」を${action}`,
|
||||
actionTarget: (action, target) => `${target} を${action}`,
|
||||
prefixedDone: (prefix, action) => `${prefix} ${action}`,
|
||||
runningPrefixedTool: (prefix, action) => `${prefix} ${action}を実行中`,
|
||||
runningTool: action => `${action}を実行中`
|
||||
},
|
||||
titles: {
|
||||
browser_click: {
|
||||
done: 'ページ要素をクリックしました',
|
||||
pending: 'ページ要素をクリック中',
|
||||
pendingAction: 'クリック中'
|
||||
},
|
||||
browser_fill: { done: 'フォーム欄に入力しました', pending: 'フォーム欄に入力中', pendingAction: '入力中' },
|
||||
browser_navigate: { done: 'ページを開きました', pending: 'ページをオープン中', pendingAction: 'オープン中' },
|
||||
browser_snapshot: {
|
||||
done: 'ページスナップショットを取得しました',
|
||||
pending: 'ページスナップショットを取得中',
|
||||
pendingAction: '取得中'
|
||||
},
|
||||
browser_take_screenshot: {
|
||||
done: 'スクリーンショットを取得しました',
|
||||
pending: 'スクリーンショットを取得中',
|
||||
pendingAction: '取得中'
|
||||
},
|
||||
browser_type: { done: 'ページに入力しました', pending: 'ページに入力中', pendingAction: '入力中' },
|
||||
clarify: { done: '質問しました', pending: '質問中', pendingAction: '質問中' },
|
||||
cronjob: { done: 'Cron ジョブ', pending: 'Cron ジョブをスケジュール中', pendingAction: 'スケジュール中' },
|
||||
edit_file: { done: 'ファイルを編集しました', pending: 'ファイルを編集中', pendingAction: '編集中' },
|
||||
execute_code: { done: 'コードを実行しました', pending: 'スクリプト作成中', pendingAction: 'スクリプト作成中' },
|
||||
image_generate: { done: '画像を生成しました', pending: '画像を生成中', pendingAction: '生成中' },
|
||||
list_files: {
|
||||
done: 'ファイルを一覧表示しました',
|
||||
pending: 'ファイルを一覧表示中',
|
||||
pendingAction: '一覧表示中'
|
||||
},
|
||||
patch: {
|
||||
done: 'ファイルにパッチを適用しました',
|
||||
pending: 'ファイルにパッチ適用中',
|
||||
pendingAction: 'パッチ適用中'
|
||||
},
|
||||
read_file: { done: 'ファイルを読み取りました', pending: 'ファイルを読み取り中', pendingAction: '読み取り中' },
|
||||
search_files: { done: 'ファイルを検索しました', pending: 'ファイルを検索中', pendingAction: '検索中' },
|
||||
session_search_recall: {
|
||||
done: 'セッション履歴を検索しました',
|
||||
pending: 'セッション履歴を検索中',
|
||||
pendingAction: '検索中'
|
||||
},
|
||||
terminal: { done: 'コマンドを実行しました', pending: 'コマンドを実行中', pendingAction: '実行中' },
|
||||
todo: { done: 'Todo を更新しました', pending: 'Todo を更新中', pendingAction: '更新中' },
|
||||
vision_analyze: { done: '画像を分析しました', pending: '画像を分析中', pendingAction: '分析中' },
|
||||
web_extract: {
|
||||
done: 'Web ページを読み取りました',
|
||||
pending: 'Web ページを読み取り中',
|
||||
pendingAction: '読み取り中'
|
||||
},
|
||||
web_search: { done: 'Web を検索しました', pending: 'Web を検索中', pendingAction: '検索中' },
|
||||
write_file: { done: 'ファイルを編集しました', pending: 'ファイルを編集中', pendingAction: '編集中' }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2034,7 +2154,8 @@ export const ja = defineLocale({
|
||||
editFailed: '編集に失敗しました',
|
||||
resumeFailed: '再開に失敗しました',
|
||||
resumeStrandedTitle: 'このセッションを読み込めませんでした',
|
||||
resumeStrandedBody: 'このセッションへの接続に失敗し、自動再試行も停止しました。ゲートウェイが実行中か確認してから、もう一度お試しください。',
|
||||
resumeStrandedBody:
|
||||
'このセッションへの接続に失敗し、自動再試行も停止しました。ゲートウェイが実行中か確認してから、もう一度お試しください。',
|
||||
resumeRetry: '再試行',
|
||||
nothingToBranch: 'ブランチするものがありません',
|
||||
branchNeedsChat: 'ブランチする前にチャットを開始または再開してください。',
|
||||
|
||||
@@ -7,6 +7,36 @@
|
||||
|
||||
export type Locale = 'en' | 'zh' | 'zh-hant' | 'ja'
|
||||
|
||||
export type ToolTitleKey =
|
||||
| 'browser_click'
|
||||
| 'browser_fill'
|
||||
| 'browser_navigate'
|
||||
| 'browser_snapshot'
|
||||
| 'browser_take_screenshot'
|
||||
| 'browser_type'
|
||||
| 'clarify'
|
||||
| 'cronjob'
|
||||
| 'edit_file'
|
||||
| 'execute_code'
|
||||
| 'image_generate'
|
||||
| 'list_files'
|
||||
| 'patch'
|
||||
| 'read_file'
|
||||
| 'search_files'
|
||||
| 'session_search_recall'
|
||||
| 'terminal'
|
||||
| 'todo'
|
||||
| 'vision_analyze'
|
||||
| 'web_extract'
|
||||
| 'web_search'
|
||||
| 'write_file'
|
||||
|
||||
interface ToolTitleCopy {
|
||||
done: string
|
||||
pending: string
|
||||
pendingAction: string
|
||||
}
|
||||
|
||||
interface ModeOptionCopy {
|
||||
label: string
|
||||
description: string
|
||||
@@ -72,6 +102,7 @@ export interface Translations {
|
||||
backgroundExitedDuringStartup: string
|
||||
backendStopped: string
|
||||
desktopBootFailed: string
|
||||
gatewayConnectionLost: string
|
||||
gatewaySignInRequired: string
|
||||
ipcBridgeUnavailable: string
|
||||
}
|
||||
@@ -284,11 +315,23 @@ export interface Translations {
|
||||
unreachable: string
|
||||
noMatch: (query: string) => string
|
||||
installedTag: string
|
||||
generatedTag: string
|
||||
countCapped: (cap: number, total: number) => string
|
||||
count: (n: number) => string
|
||||
uninstall: (name: string) => string
|
||||
delete: (name: string) => string
|
||||
deleteTitle: (name: string) => string
|
||||
deleteBody: string
|
||||
deleteConfirm: string
|
||||
rename: (name: string) => string
|
||||
renameTitle: string
|
||||
renamePlaceholder: string
|
||||
renameSave: string
|
||||
exportPet: (name: string) => string
|
||||
adoptFailed: (slug: string) => string
|
||||
uninstallFailed: (slug: string) => string
|
||||
renameFailed: (slug: string) => string
|
||||
exportFailed: (slug: string) => string
|
||||
noneAvailable: string
|
||||
turnOnFailed: string
|
||||
turnOffFailed: string
|
||||
@@ -635,10 +678,40 @@ export interface Translations {
|
||||
turnOff: string
|
||||
turnOn: string
|
||||
installed: string
|
||||
generatedTag: string
|
||||
adoptFailed: string
|
||||
toggleFailed: string
|
||||
noneAvailable: string
|
||||
}
|
||||
generatePet: {
|
||||
title: string
|
||||
placeholder: string
|
||||
promptHint: string
|
||||
readyHint: string
|
||||
generate: string
|
||||
generating: string
|
||||
retry: string
|
||||
hatch: string
|
||||
spawning: string
|
||||
hatching: string
|
||||
hatchingSub: string
|
||||
hatched: string
|
||||
hatchRow: (state: string, done: number, total: number) => string
|
||||
hatchComposing: string
|
||||
hatchSaving: string
|
||||
namePlaceholder: string
|
||||
staleBackend: string
|
||||
backgroundHint: string
|
||||
slowProviderHint: string
|
||||
remix: string
|
||||
remixConfirmTitle: string
|
||||
remixConfirmBody: string
|
||||
genericError: string
|
||||
referenceImageTooLarge: string
|
||||
referenceImageInvalid: string
|
||||
adopt: string
|
||||
startOver: string
|
||||
}
|
||||
installTheme: {
|
||||
title: string
|
||||
placeholder: string
|
||||
@@ -1494,6 +1567,31 @@ export interface Translations {
|
||||
statusError: string
|
||||
statusRecovered: string
|
||||
statusDone: string
|
||||
actions: {
|
||||
read: string
|
||||
reading: string
|
||||
opened: string
|
||||
opening: string
|
||||
searched: string
|
||||
searching: string
|
||||
ran: string
|
||||
running: string
|
||||
ranCode: string
|
||||
runningCode: string
|
||||
}
|
||||
prefixes: {
|
||||
browser: string
|
||||
web: string
|
||||
}
|
||||
titleTemplates: {
|
||||
actionCommand: (action: string, command: string) => string
|
||||
actionQuoted: (action: string, value: string) => string
|
||||
actionTarget: (action: string, target: string) => string
|
||||
prefixedDone: (prefix: string, action: string) => string
|
||||
runningPrefixedTool: (prefix: string, action: string) => string
|
||||
runningTool: (action: string) => string
|
||||
}
|
||||
titles: Record<ToolTitleKey, ToolTitleCopy>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ export const zhHant = defineLocale({
|
||||
backgroundExitedDuringStartup: 'Hermes 背景程序在啟動期間結束。',
|
||||
backendStopped: '後端已停止',
|
||||
desktopBootFailed: '桌面啟動失敗',
|
||||
gatewayConnectionLost: '與閘道的連線已中斷',
|
||||
gatewaySignInRequired: '需要閘道登入',
|
||||
ipcBridgeUnavailable: '桌面 IPC 橋接器不可用。'
|
||||
},
|
||||
@@ -279,7 +280,8 @@ export const zhHant = defineLocale({
|
||||
importedBadge: '已匯入',
|
||||
pet: {
|
||||
title: '寵物',
|
||||
intro: '領養一隻懸浮在應用上的 petdex 動畫寵物,它會根據 Hermes 的狀態做出反應——工具執行時奔跑、成功時歡呼、出錯時沮喪。',
|
||||
intro:
|
||||
'領養一隻懸浮在應用上的 petdex 動畫寵物,它會根據 Hermes 的狀態做出反應——工具執行時奔跑、成功時歡呼、出錯時沮喪。',
|
||||
restartHint: '寵物功能需要重新啟動——目前執行的應用在此功能加入前啟動。請結束並重新開啟 Hermes,然後回到此處。',
|
||||
scaleTitle: '大小',
|
||||
scaleDesc: '調整懸浮寵物的大小,所有介面即時生效。',
|
||||
@@ -291,11 +293,23 @@ export const zhHant = defineLocale({
|
||||
unreachable: '無法連線至 petdex 畫廊。請檢查網路連線並重新開啟此頁面。',
|
||||
noMatch: query => `沒有符合「${query}」的寵物。`,
|
||||
installedTag: '已安裝',
|
||||
generatedTag: '生成',
|
||||
countCapped: (cap, total) => `顯示 ${total} 個中的 ${cap} 個——輸入關鍵字以縮小範圍。`,
|
||||
count: n => `${n} 個寵物。`,
|
||||
uninstall: name => `解除安裝 ${name}`,
|
||||
delete: name => `刪除 ${name}`,
|
||||
deleteTitle: name => `刪除 ${name}?`,
|
||||
deleteBody: '此操作會永久刪除寵物,且無法重新安裝。',
|
||||
deleteConfirm: '刪除',
|
||||
rename: name => `重新命名 ${name}`,
|
||||
renameTitle: '重新命名寵物',
|
||||
renamePlaceholder: '為寵物取個名字',
|
||||
renameSave: '儲存',
|
||||
exportPet: name => `匯出 ${name}`,
|
||||
adoptFailed: slug => `無法領養 ${slug}`,
|
||||
uninstallFailed: slug => `無法解除安裝 ${slug}`,
|
||||
renameFailed: slug => `無法重新命名 ${slug}`,
|
||||
exportFailed: slug => `無法匯出 ${slug}`,
|
||||
noneAvailable: '目前沒有可開啟的寵物。',
|
||||
turnOnFailed: '無法開啟寵物。',
|
||||
turnOffFailed: '無法關閉寵物。'
|
||||
@@ -850,10 +864,40 @@ export const zhHant = defineLocale({
|
||||
turnOff: '關閉',
|
||||
turnOn: '開啟',
|
||||
installed: '已安裝',
|
||||
generatedTag: '生成',
|
||||
adoptFailed: '無法領養該寵物。',
|
||||
toggleFailed: '無法切換寵物顯示。',
|
||||
noneAvailable: '尚無可用寵物——請在下方選擇一個安裝。'
|
||||
},
|
||||
generatePet: {
|
||||
title: '生成寵物',
|
||||
placeholder: '描述要生成的寵物……',
|
||||
promptHint: '輸入描述,然後按 Enter 生成四種造型。',
|
||||
readyHint: '按 Enter 依描述生成四種造型。',
|
||||
generate: '生成',
|
||||
generating: '生成中……',
|
||||
retry: '重試',
|
||||
hatch: '孵化',
|
||||
spawning: '召喚中……',
|
||||
hatching: '正在孵化你的寵物……',
|
||||
hatchingSub: '正在注入生命……',
|
||||
hatched: '孵化成功!',
|
||||
hatchRow: (_state, done, total) => `正在繪製畫面…… ${done}/${total}`,
|
||||
hatchComposing: '正在拼合……',
|
||||
hatchSaving: '快好了……',
|
||||
namePlaceholder: '為寵物命名',
|
||||
staleBackend: '請更新 Hermes 以生成寵物。',
|
||||
backgroundHint: '你可以關閉此視窗——完成後 Hermes 會通知你。',
|
||||
slowProviderHint: '這可能需要幾分鐘',
|
||||
remix: '混合生成',
|
||||
remixConfirmTitle: '以此造型混合生成?',
|
||||
remixConfirmBody: '將以此造型為起點生成一組新草圖,可能需要幾分鐘。',
|
||||
genericError: '生成失敗——請重試或選一個建議。',
|
||||
referenceImageTooLarge: '參考圖片過大。請使用小於 16 MB 的圖片。',
|
||||
referenceImageInvalid: '無法讀取該參考圖片。請嘗試 PNG、JPG、WebP 或 GIF。',
|
||||
adopt: '領養',
|
||||
startOver: '重新開始'
|
||||
},
|
||||
installTheme: {
|
||||
title: '安裝主題...',
|
||||
placeholder: '搜尋 VS Code Marketplace...',
|
||||
@@ -1507,7 +1551,8 @@ export const zhHant = defineLocale({
|
||||
copy: '複製',
|
||||
copied: '已複製',
|
||||
done: '完成',
|
||||
applyingBody: 'Hermes 更新程式會在自己的視窗中接管,並在完成後自動重新開啟 Hermes。更新期間請勿自行重新開啟 Hermes。',
|
||||
applyingBody:
|
||||
'Hermes 更新程式會在自己的視窗中接管,並在完成後自動重新開啟 Hermes。更新期間請勿自行重新開啟 Hermes。',
|
||||
applyingBodyBackend: '遠端後端正在套用更新並將重新啟動。恢復後 Hermes 會自動重新連線。',
|
||||
applyingClose: '此視窗會在更新期間關閉,隨後 Hermes 會自動重新開啟。',
|
||||
errorTitle: '更新未完成',
|
||||
@@ -1929,7 +1974,59 @@ export const zhHant = defineLocale({
|
||||
statusRunning: '執行中',
|
||||
statusError: '錯誤',
|
||||
statusRecovered: '已復原',
|
||||
statusDone: '完成'
|
||||
statusDone: '完成',
|
||||
actions: {
|
||||
read: '已讀取',
|
||||
reading: '正在讀取',
|
||||
opened: '已開啟',
|
||||
opening: '正在開啟',
|
||||
searched: '已搜尋',
|
||||
searching: '正在搜尋',
|
||||
ran: '已執行',
|
||||
running: '正在執行',
|
||||
ranCode: '已執行程式碼',
|
||||
runningCode: '正在撰寫腳本'
|
||||
},
|
||||
prefixes: {
|
||||
browser: '瀏覽器',
|
||||
web: '網頁'
|
||||
},
|
||||
titleTemplates: {
|
||||
actionCommand: (action, command) => `${action} ${command}`,
|
||||
actionQuoted: (action, value) => `${action}「${value}」`,
|
||||
actionTarget: (action, target) => `${action} ${target}`,
|
||||
prefixedDone: (prefix, action) => `${prefix}${action}`,
|
||||
runningPrefixedTool: (prefix, action) => `正在執行${prefix}${action}`,
|
||||
runningTool: action => `正在執行 ${action}`
|
||||
},
|
||||
titles: {
|
||||
browser_click: { done: '已點擊頁面元素', pending: '正在點擊頁面元素', pendingAction: '正在點擊' },
|
||||
browser_fill: { done: '已填寫表單欄位', pending: '正在填寫表單欄位', pendingAction: '正在填寫' },
|
||||
browser_navigate: { done: '已開啟頁面', pending: '正在開啟頁面', pendingAction: '正在開啟' },
|
||||
browser_snapshot: { done: '已擷取頁面快照', pending: '正在擷取頁面快照', pendingAction: '正在擷取' },
|
||||
browser_take_screenshot: { done: '已擷取截圖', pending: '正在擷取截圖', pendingAction: '正在擷取' },
|
||||
browser_type: { done: '已在頁面輸入', pending: '正在頁面輸入', pendingAction: '正在輸入' },
|
||||
clarify: { done: '已提問', pending: '正在提問', pendingAction: '正在提問' },
|
||||
cronjob: { done: 'Cron 工作', pending: '正在安排 Cron 工作', pendingAction: '正在安排' },
|
||||
edit_file: { done: '已編輯檔案', pending: '正在編輯檔案', pendingAction: '正在編輯' },
|
||||
execute_code: { done: '已執行程式碼', pending: '正在撰寫腳本', pendingAction: '正在撰寫腳本' },
|
||||
image_generate: { done: '已生成圖片', pending: '正在生成圖片', pendingAction: '正在生成' },
|
||||
list_files: { done: '已列出檔案', pending: '正在列出檔案', pendingAction: '正在列出' },
|
||||
patch: { done: '已修補檔案', pending: '正在修補檔案', pendingAction: '正在修補' },
|
||||
read_file: { done: '已讀取檔案', pending: '正在讀取檔案', pendingAction: '正在讀取' },
|
||||
search_files: { done: '已搜尋檔案', pending: '正在搜尋檔案', pendingAction: '正在搜尋' },
|
||||
session_search_recall: {
|
||||
done: '已搜尋工作階段歷史',
|
||||
pending: '正在搜尋工作階段歷史',
|
||||
pendingAction: '正在搜尋'
|
||||
},
|
||||
terminal: { done: '已執行指令', pending: '正在執行指令', pendingAction: '正在執行' },
|
||||
todo: { done: '已更新待辦', pending: '正在更新待辦', pendingAction: '正在更新' },
|
||||
vision_analyze: { done: '已分析圖片', pending: '正在分析圖片', pendingAction: '正在分析' },
|
||||
web_extract: { done: '已讀取網頁', pending: '正在讀取網頁', pendingAction: '正在讀取' },
|
||||
web_search: { done: '已搜尋網頁', pending: '正在搜尋網頁', pendingAction: '正在搜尋' },
|
||||
write_file: { done: '已編輯檔案', pending: '正在編輯檔案', pendingAction: '正在編輯' }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ export const zh: Translations = {
|
||||
backgroundExitedDuringStartup: 'Hermes 后台进程在启动期间退出。',
|
||||
backendStopped: '后端已停止',
|
||||
desktopBootFailed: '桌面启动失败',
|
||||
gatewayConnectionLost: '与网关的连接已断开',
|
||||
gatewaySignInRequired: '需要登录网关',
|
||||
ipcBridgeUnavailable: '桌面 IPC 桥不可用。'
|
||||
},
|
||||
@@ -206,6 +207,7 @@ export const zh: Translations = {
|
||||
'session.togglePin': '固定/取消固定当前会话',
|
||||
'composer.focus': '聚焦输入框',
|
||||
'composer.modelPicker': '打开模型选择器',
|
||||
'composer.voice': '开始 / 停止语音对话',
|
||||
'view.toggleSidebar': '切换会话侧边栏',
|
||||
'view.toggleRightSidebar': '切换文件浏览器',
|
||||
'view.showFiles': '显示文件浏览器',
|
||||
@@ -367,7 +369,8 @@ export const zh: Translations = {
|
||||
importedBadge: '已导入',
|
||||
pet: {
|
||||
title: '宠物',
|
||||
intro: '领养一只悬浮在应用上的 petdex 动画宠物,它会根据 Hermes 的状态做出反应——工具执行时奔跑、成功时欢呼、出错时沮丧。',
|
||||
intro:
|
||||
'领养一只悬浮在应用上的 petdex 动画宠物,它会根据 Hermes 的状态做出反应——工具执行时奔跑、成功时欢呼、出错时沮丧。',
|
||||
restartHint: '宠物功能需要重启——当前运行的应用在此功能加入前启动。请退出并重新打开 Hermes,然后回到此处。',
|
||||
scaleTitle: '大小',
|
||||
scaleDesc: '调整悬浮宠物的大小,所有界面即时生效。',
|
||||
@@ -379,11 +382,23 @@ export const zh: Translations = {
|
||||
unreachable: '无法连接到 petdex 画廊。请检查网络连接并重新打开此页面。',
|
||||
noMatch: query => `没有匹配「${query}」的宠物。`,
|
||||
installedTag: '已安装',
|
||||
generatedTag: '生成',
|
||||
countCapped: (cap, total) => `显示 ${total} 个中的 ${cap} 个——输入关键词以缩小范围。`,
|
||||
count: n => `${n} 个宠物。`,
|
||||
uninstall: name => `卸载 ${name}`,
|
||||
delete: name => `删除 ${name}`,
|
||||
deleteTitle: name => `删除 ${name}?`,
|
||||
deleteBody: '此操作会永久删除宠物,且无法重新安装。',
|
||||
deleteConfirm: '删除',
|
||||
rename: name => `重命名 ${name}`,
|
||||
renameTitle: '重命名宠物',
|
||||
renamePlaceholder: '给宠物起个名字',
|
||||
renameSave: '保存',
|
||||
exportPet: name => `导出 ${name}`,
|
||||
adoptFailed: slug => `无法领养 ${slug}`,
|
||||
uninstallFailed: slug => `无法卸载 ${slug}`,
|
||||
renameFailed: slug => `无法重命名 ${slug}`,
|
||||
exportFailed: slug => `无法导出 ${slug}`,
|
||||
noneAvailable: '当前没有可开启的宠物。',
|
||||
turnOnFailed: '无法开启宠物。',
|
||||
turnOffFailed: '无法关闭宠物。'
|
||||
@@ -947,10 +962,40 @@ export const zh: Translations = {
|
||||
turnOff: '关闭',
|
||||
turnOn: '开启',
|
||||
installed: '已安装',
|
||||
generatedTag: '生成',
|
||||
adoptFailed: '无法领养该宠物。',
|
||||
toggleFailed: '无法切换宠物显示。',
|
||||
noneAvailable: '暂无可用宠物——请在下方选择一个安装。'
|
||||
},
|
||||
generatePet: {
|
||||
title: '生成宠物',
|
||||
placeholder: '描述要生成的宠物……',
|
||||
promptHint: '输入描述,然后按 Enter 生成四种造型。',
|
||||
readyHint: '按 Enter 根据描述生成四种造型。',
|
||||
generate: '生成',
|
||||
generating: '生成中……',
|
||||
retry: '重试',
|
||||
hatch: '孵化',
|
||||
spawning: '召唤中……',
|
||||
hatching: '正在孵化你的宠物……',
|
||||
hatchingSub: '正在注入生命……',
|
||||
hatched: '孵化成功!',
|
||||
hatchRow: (_state, done, total) => `正在绘制画面…… ${done}/${total}`,
|
||||
hatchComposing: '正在拼合……',
|
||||
hatchSaving: '马上就好……',
|
||||
namePlaceholder: '给宠物起个名字',
|
||||
staleBackend: '请更新 Hermes 以生成宠物。',
|
||||
backgroundHint: '你可以关闭此窗口——完成后 Hermes 会通知你。',
|
||||
slowProviderHint: '这可能需要几分钟',
|
||||
remix: '混合生成',
|
||||
remixConfirmTitle: '以此造型混合生成?',
|
||||
remixConfirmBody: '将以此造型为起点生成一组新草图,可能需要几分钟。',
|
||||
genericError: '生成失败——请重试或选择一个建议。',
|
||||
referenceImageTooLarge: '参考图过大。请使用小于 16 MB 的图片。',
|
||||
referenceImageInvalid: '无法读取该参考图。请尝试 PNG、JPG、WebP 或 GIF。',
|
||||
adopt: '领养',
|
||||
startOver: '重新开始'
|
||||
},
|
||||
installTheme: {
|
||||
title: '安装主题...',
|
||||
placeholder: '搜索 VS Code Marketplace...',
|
||||
@@ -1607,11 +1652,13 @@ export const zh: Translations = {
|
||||
manualBody: '你是从命令行安装的 Hermes,因此更新也需要在那里运行。请将此命令粘贴到终端:',
|
||||
manualPickedUp: '下次启动 Hermes 时会使用新版本。',
|
||||
guiSkewTitle: '请更新桌面应用',
|
||||
guiSkewBody: '后端已更新,但此桌面应用包未更改。请更新或重新安装 Hermes 桌面应用(你的 AppImage / .deb / .rpm)以保持一致。',
|
||||
guiSkewBody:
|
||||
'后端已更新,但此桌面应用包未更改。请更新或重新安装 Hermes 桌面应用(你的 AppImage / .deb / .rpm)以保持一致。',
|
||||
copy: '复制',
|
||||
copied: '已复制',
|
||||
done: '完成',
|
||||
applyingBody: 'Hermes 更新器会在自己的窗口中接管,并在完成后自动重新打开 Hermes。更新期间请不要自行重新打开 Hermes。',
|
||||
applyingBody:
|
||||
'Hermes 更新器会在自己的窗口中接管,并在完成后自动重新打开 Hermes。更新期间请不要自行重新打开 Hermes。',
|
||||
applyingBodyBackend: '远程后端正在应用更新并将重启。恢复后 Hermes 会自动重新连接。',
|
||||
applyingClose: '此窗口会在更新期间关闭,随后 Hermes 会自动重新打开。',
|
||||
errorTitle: '更新未完成',
|
||||
@@ -2035,7 +2082,55 @@ export const zh: Translations = {
|
||||
statusRunning: '运行中',
|
||||
statusError: '错误',
|
||||
statusRecovered: '已恢复',
|
||||
statusDone: '完成'
|
||||
statusDone: '完成',
|
||||
actions: {
|
||||
read: '已读取',
|
||||
reading: '正在读取',
|
||||
opened: '已打开',
|
||||
opening: '正在打开',
|
||||
searched: '已搜索',
|
||||
searching: '正在搜索',
|
||||
ran: '已运行',
|
||||
running: '正在运行',
|
||||
ranCode: '已运行代码',
|
||||
runningCode: '正在编写脚本'
|
||||
},
|
||||
prefixes: {
|
||||
browser: '浏览器',
|
||||
web: '网页'
|
||||
},
|
||||
titleTemplates: {
|
||||
actionCommand: (action, command) => `${action} ${command}`,
|
||||
actionQuoted: (action, value) => `${action}“${value}”`,
|
||||
actionTarget: (action, target) => `${action} ${target}`,
|
||||
prefixedDone: (prefix, action) => `${prefix}${action}`,
|
||||
runningPrefixedTool: (prefix, action) => `正在运行${prefix}${action}`,
|
||||
runningTool: action => `正在运行 ${action}`
|
||||
},
|
||||
titles: {
|
||||
browser_click: { done: '已点击页面元素', pending: '正在点击页面元素', pendingAction: '正在点击' },
|
||||
browser_fill: { done: '已填写表单字段', pending: '正在填写表单字段', pendingAction: '正在填写' },
|
||||
browser_navigate: { done: '已打开页面', pending: '正在打开页面', pendingAction: '正在打开' },
|
||||
browser_snapshot: { done: '已捕获页面快照', pending: '正在捕获页面快照', pendingAction: '正在捕获' },
|
||||
browser_take_screenshot: { done: '已捕获截图', pending: '正在捕获截图', pendingAction: '正在捕获' },
|
||||
browser_type: { done: '已在页面输入', pending: '正在页面输入', pendingAction: '正在输入' },
|
||||
clarify: { done: '已提问', pending: '正在提问', pendingAction: '正在提问' },
|
||||
cronjob: { done: 'Cron 任务', pending: '正在安排 Cron 任务', pendingAction: '正在安排' },
|
||||
edit_file: { done: '已编辑文件', pending: '正在编辑文件', pendingAction: '正在编辑' },
|
||||
execute_code: { done: '已运行代码', pending: '正在编写脚本', pendingAction: '正在编写脚本' },
|
||||
image_generate: { done: '已生成图片', pending: '正在生成图片', pendingAction: '正在生成' },
|
||||
list_files: { done: '已列出文件', pending: '正在列出文件', pendingAction: '正在列出' },
|
||||
patch: { done: '已修补文件', pending: '正在修补文件', pendingAction: '正在修补' },
|
||||
read_file: { done: '已读取文件', pending: '正在读取文件', pendingAction: '正在读取' },
|
||||
search_files: { done: '已搜索文件', pending: '正在搜索文件', pendingAction: '正在搜索' },
|
||||
session_search_recall: { done: '已搜索会话历史', pending: '正在搜索会话历史', pendingAction: '正在搜索' },
|
||||
terminal: { done: '已运行命令', pending: '正在运行命令', pendingAction: '正在运行' },
|
||||
todo: { done: '已更新待办', pending: '正在更新待办', pendingAction: '正在更新' },
|
||||
vision_analyze: { done: '已分析图片', pending: '正在分析图片', pendingAction: '正在分析' },
|
||||
web_extract: { done: '已读取网页', pending: '正在读取网页', pendingAction: '正在读取' },
|
||||
web_search: { done: '已搜索网页', pending: '正在搜索网页', pendingAction: '正在搜索' },
|
||||
write_file: { done: '已编辑文件', pending: '正在编辑文件', pendingAction: '正在编辑' }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
|
||||
import { coerceThinkingText, optimisticAttachmentRef, parseCommandDispatch } from './chat-runtime'
|
||||
import { attachmentDisplayText, coerceThinkingText, optimisticAttachmentRef, parseCommandDispatch } from './chat-runtime'
|
||||
|
||||
const DATA_URL = 'data:image/png;base64,iVBORw0KGgoAAAANS'
|
||||
|
||||
@@ -36,6 +36,32 @@ describe('optimisticAttachmentRef', () => {
|
||||
'@file:src/a.ts'
|
||||
)
|
||||
})
|
||||
|
||||
// Session switches / draft restores can leave undefined|null holes in the
|
||||
// composer attachments array. AttachmentList already filters them (#49624),
|
||||
// but the submit path maps the same array through these helpers — an unguarded
|
||||
// hole threw "Cannot read properties of undefined (reading 'refText')",
|
||||
// crashing the chat surface (blank pane). The helpers must no-op on holes.
|
||||
it('returns null for an undefined attachment instead of throwing', () => {
|
||||
expect(() => optimisticAttachmentRef(undefined as unknown as ComposerAttachment)).not.toThrow()
|
||||
expect(optimisticAttachmentRef(undefined as unknown as ComposerAttachment)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for a null attachment instead of throwing', () => {
|
||||
expect(optimisticAttachmentRef(null as unknown as ComposerAttachment)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('attachmentDisplayText', () => {
|
||||
it('returns null for undefined|null instead of reading .kind/.refText on a hole', () => {
|
||||
expect(() => attachmentDisplayText(undefined as unknown as ComposerAttachment)).not.toThrow()
|
||||
expect(attachmentDisplayText(undefined as unknown as ComposerAttachment)).toBeNull()
|
||||
expect(attachmentDisplayText(null as unknown as ComposerAttachment)).toBeNull()
|
||||
})
|
||||
|
||||
it('still resolves a normal file ref', () => {
|
||||
expect(attachmentDisplayText(attachment({ kind: 'file', refText: '@file:src/a.ts' }))).toBe('@file:src/a.ts')
|
||||
})
|
||||
})
|
||||
|
||||
describe('coerceThinkingText', () => {
|
||||
|
||||
@@ -155,6 +155,13 @@ export function pathLabel(path: string): string {
|
||||
}
|
||||
|
||||
export function attachmentDisplayText(attachment: ComposerAttachment): string | null {
|
||||
// Session switches / draft restores can leave undefined holes in the
|
||||
// composer attachments array (see AttachmentList's filter(Boolean) + #49624).
|
||||
// Every consumer funnels through here, so guard the chokepoint too.
|
||||
if (!attachment) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (attachment.kind === 'terminal' && attachment.detail) {
|
||||
return `\`\`\`terminal\n${attachment.detail.trim()}\n\`\`\``
|
||||
}
|
||||
@@ -188,6 +195,10 @@ export function attachmentDisplayText(attachment: ComposerAttachment): string |
|
||||
* through to `attachmentDisplayText`.
|
||||
*/
|
||||
export function optimisticAttachmentRef(attachment: ComposerAttachment): string | null {
|
||||
if (!attachment) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (attachment.kind === 'image' && attachment.previewUrl?.startsWith('data:')) {
|
||||
return attachment.previewUrl
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ export type DesktopActionId =
|
||||
| 'branch'
|
||||
| 'browser'
|
||||
| 'handoff'
|
||||
| 'hatch'
|
||||
| 'help'
|
||||
| 'new'
|
||||
| 'pet'
|
||||
@@ -130,6 +131,7 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [
|
||||
{ name: '/goal', description: 'Manage the standing goal for this session', surface: exec() },
|
||||
{ name: '/personality', description: 'Switch personality for this session', surface: exec(), args: true },
|
||||
{ name: '/pet', description: 'Toggle or adopt a petdex mascot (/pet, /pet list, /pet boba)', surface: action('pet'), args: true },
|
||||
{ name: '/hatch', description: 'Generate a new pet (opens the pet generator)', aliases: ['/generate-pet'], surface: action('hatch') },
|
||||
{ name: '/queue', description: 'Queue a prompt for the next turn', aliases: ['/q'], surface: exec() },
|
||||
{ name: '/retry', description: 'Retry the last user message', surface: exec() },
|
||||
{ name: '/rollback', description: 'List or restore filesystem checkpoints', surface: exec() },
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
IconCopy as CopyIcon,
|
||||
IconCpu as Cpu,
|
||||
IconDownload as Download,
|
||||
IconEgg as Egg,
|
||||
IconExternalLink as ExternalLink,
|
||||
IconEye as Eye,
|
||||
IconEyeOff as EyeOff,
|
||||
@@ -133,6 +134,7 @@ export {
|
||||
CopyIcon,
|
||||
Cpu,
|
||||
Download,
|
||||
Egg,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
EyeOff,
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
// like navigate / theme); labels come from i18n (`t.keybinds.actions[id]`). To
|
||||
// add a hotkey, add a row here and a handler there — nothing else.
|
||||
|
||||
import { IS_MAC } from './combo'
|
||||
|
||||
export type KeybindCategory = 'composer' | 'profiles' | 'session' | 'navigation' | 'view'
|
||||
|
||||
// The self-referential opener — bound + dispatched like any action, but shown in
|
||||
@@ -55,6 +57,12 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [
|
||||
// ── Composer ─────────────────────────────────────────────────────────────
|
||||
{ id: 'composer.focus', category: 'composer', defaults: [] },
|
||||
{ id: 'composer.modelPicker', category: 'composer', defaults: [] },
|
||||
// Voice conversation toggle. Matches the documented `voice.record_key`
|
||||
// (Ctrl+B). On macOS that's literally ⌃B — distinct from the ⌘B sidebar
|
||||
// toggle. Off macOS `ctrl` folds to `mod`, which IS the ⌘B/Ctrl+B sidebar
|
||||
// chord, so ship it unbound there (rebindable in the panel) rather than
|
||||
// stealing the long-standing sidebar binding.
|
||||
{ id: 'composer.voice', category: 'composer', defaults: IS_MAC ? ['ctrl+b'] : [] },
|
||||
|
||||
// ── Profiles ─────────────────────────────────────────────────────────────
|
||||
{ id: 'profile.default', category: 'profiles', defaults: ['mod+d'] },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { interpretRuntimeReadiness } from './runtime-readiness'
|
||||
import { evaluateRuntimeReadiness, fetchRuntimeReadinessSignals, interpretRuntimeReadiness } from './runtime-readiness'
|
||||
|
||||
describe('interpretRuntimeReadiness', () => {
|
||||
it('prefers runtime_check when both signals exist', () => {
|
||||
@@ -63,3 +63,51 @@ describe('interpretRuntimeReadiness', () => {
|
||||
expect(result.reason).toBe('setup.runtime_check timeout')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchRuntimeReadinessSignals', () => {
|
||||
it('scopes setup.runtime_check to the requested provider', async () => {
|
||||
const calls: Array<{ method: string; params?: Record<string, unknown> }> = []
|
||||
const requestGateway = async <T = unknown>(method: string, params?: Record<string, unknown>) => {
|
||||
calls.push({ method, params })
|
||||
|
||||
if (method === 'setup.status') {
|
||||
return { provider_configured: true } as T
|
||||
}
|
||||
|
||||
if (method === 'setup.runtime_check') {
|
||||
return { ok: true } as T
|
||||
}
|
||||
|
||||
throw new Error(`unexpected method: ${method}`)
|
||||
}
|
||||
|
||||
await fetchRuntimeReadinessSignals(requestGateway, 'nous')
|
||||
|
||||
expect(calls).toEqual([
|
||||
{ method: 'setup.status' },
|
||||
{ method: 'setup.runtime_check', params: { provider: 'nous' } }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('evaluateRuntimeReadiness', () => {
|
||||
it('forwards requestedProvider to setup.runtime_check', async () => {
|
||||
const requestGateway = async <T = unknown>(method: string, params?: Record<string, unknown>) => {
|
||||
if (method === 'setup.status') {
|
||||
return { provider_configured: true } as T
|
||||
}
|
||||
|
||||
if (method === 'setup.runtime_check') {
|
||||
expect(params).toEqual({ provider: 'nous' })
|
||||
|
||||
return { ok: true } as T
|
||||
}
|
||||
|
||||
throw new Error(`unexpected method: ${method}`)
|
||||
}
|
||||
|
||||
const result = await evaluateRuntimeReadiness(requestGateway, { requestedProvider: 'nous' })
|
||||
|
||||
expect(result.ready).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface RuntimeReadinessSignals {
|
||||
|
||||
export interface RuntimeReadinessOptions {
|
||||
defaultReason?: string
|
||||
requestedProvider?: string
|
||||
unknownReady?: boolean
|
||||
}
|
||||
|
||||
@@ -54,21 +55,27 @@ function normalizeMessage(value: null | string | undefined): null | string {
|
||||
|
||||
async function requestWithFallback<T>(
|
||||
requestGateway: RuntimeReadinessRequester,
|
||||
method: string
|
||||
method: string,
|
||||
params?: Record<string, unknown>
|
||||
): Promise<{ error: null | string; value: null | T }> {
|
||||
try {
|
||||
return { error: null, value: await requestGateway<T>(method) }
|
||||
return { error: null, value: await requestGateway<T>(method, params) }
|
||||
} catch (error) {
|
||||
return { error: toErrorMessage(error), value: null }
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchRuntimeReadinessSignals(
|
||||
requestGateway: RuntimeReadinessRequester
|
||||
requestGateway: RuntimeReadinessRequester,
|
||||
requestedProvider?: string
|
||||
): Promise<RuntimeReadinessSignals> {
|
||||
const runtimeParams = requestedProvider?.trim()
|
||||
? { provider: requestedProvider.trim() }
|
||||
: undefined
|
||||
|
||||
const [setup, runtime] = await Promise.all([
|
||||
requestWithFallback<SetupStatusSnapshot>(requestGateway, 'setup.status'),
|
||||
requestWithFallback<RuntimeCheckSnapshot>(requestGateway, 'setup.runtime_check')
|
||||
requestWithFallback<RuntimeCheckSnapshot>(requestGateway, 'setup.runtime_check', runtimeParams)
|
||||
])
|
||||
|
||||
return {
|
||||
@@ -141,7 +148,7 @@ export async function evaluateRuntimeReadiness(
|
||||
requestGateway: RuntimeReadinessRequester,
|
||||
options: RuntimeReadinessOptions = {}
|
||||
): Promise<RuntimeReadinessResult> {
|
||||
const signals = await fetchRuntimeReadinessSignals(requestGateway)
|
||||
const signals = await fetchRuntimeReadinessSignals(requestGateway, options.requestedProvider)
|
||||
|
||||
return interpretRuntimeReadiness(signals, options)
|
||||
}
|
||||
|
||||
110
apps/desktop/src/lib/summarize-command.test.ts
Normal file
110
apps/desktop/src/lib/summarize-command.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { summarizeShellCommand } from './summarize-command'
|
||||
|
||||
describe('summarizeShellCommand', () => {
|
||||
it('strips a leading cd and trailing tail + status echo', () => {
|
||||
expect(
|
||||
summarizeShellCommand(
|
||||
'cd /Users/me/www/bb-rainbows && pnpm run lint 2>&1 | tail -10; echo "lint_exit=${PIPESTATUS[0]}"'
|
||||
)
|
||||
).toBe('pnpm run lint')
|
||||
})
|
||||
|
||||
it('keeps flags on the surviving command', () => {
|
||||
expect(summarizeShellCommand('cd /x && pnpm run preview --port 4317 2>&1')).toBe(
|
||||
'pnpm run preview --port 4317'
|
||||
)
|
||||
})
|
||||
|
||||
it('drops a source/activate prefix', () => {
|
||||
expect(summarizeShellCommand('source .venv/bin/activate && pytest -q')).toBe('pytest -q')
|
||||
})
|
||||
|
||||
it('skips leading env assignments', () => {
|
||||
expect(summarizeShellCommand('cd /x && NODE_ENV=test FOO=bar vitest run 2>&1 | tail -5')).toBe(
|
||||
'NODE_ENV=test FOO=bar vitest run'
|
||||
)
|
||||
})
|
||||
|
||||
it('compacts a genuine multi-command compound without listing every command', () => {
|
||||
const compound = 'git add -A && git commit -m "wip"'
|
||||
expect(summarizeShellCommand(compound)).toBe('git add -A + 1 command')
|
||||
})
|
||||
|
||||
it('leaves a single bare command untouched', () => {
|
||||
expect(summarizeShellCommand('git status --short')).toBe('git status --short')
|
||||
})
|
||||
|
||||
it('does not split on operators inside quotes', () => {
|
||||
const cmd = 'git commit -m "fix: a | b && c"'
|
||||
expect(summarizeShellCommand(cmd)).toBe(cmd)
|
||||
})
|
||||
|
||||
it('does not strip a redirection-looking char inside quotes', () => {
|
||||
expect(summarizeShellCommand('cd /x && git commit -m "a > b"')).toBe('git commit -m "a > b"')
|
||||
})
|
||||
|
||||
it('handles empty / whitespace input', () => {
|
||||
expect(summarizeShellCommand('')).toBe('')
|
||||
expect(summarizeShellCommand(' ')).toBe('')
|
||||
})
|
||||
|
||||
it('returns the original when every segment is plumbing', () => {
|
||||
const allSetup = 'cd /x && export FOO=1'
|
||||
expect(summarizeShellCommand(allSetup)).toBe(allSetup)
|
||||
})
|
||||
|
||||
it('collapses 2>&1 redirection on a plain pipeline', () => {
|
||||
expect(summarizeShellCommand('cd /x && tsc --noEmit 2>&1 | tail -20')).toBe('tsc --noEmit')
|
||||
})
|
||||
|
||||
it('drops a leading echo banner around a single command', () => {
|
||||
expect(
|
||||
summarizeShellCommand('echo "--- proto pnpm direct ---"; ~/.proto/tools/node/24.11.0/bin/pnpm --version 2>&1 | tail -3')
|
||||
).toBe('~/.proto/tools/node/24.11.0/bin/pnpm --version')
|
||||
})
|
||||
|
||||
it('drops echo banners on both sides plus the trailing status echo', () => {
|
||||
expect(summarizeShellCommand('echo "--- build ---"; npm run build 2>&1 | tail -5; echo "build_exit=$?"')).toBe(
|
||||
'npm run build'
|
||||
)
|
||||
})
|
||||
|
||||
it('compacts a genuine multi-command probe from session 20260624_231846_bdbd1e', () => {
|
||||
const probe = 'which node pnpm corepack; node -v; corepack --version 2>&1'
|
||||
expect(summarizeShellCommand(probe)).toBe('which node pnpm corepack + 2 commands')
|
||||
})
|
||||
|
||||
it('compacts the corepack diagnostic command from session 20260624_231846_bdbd1e', () => {
|
||||
expect(
|
||||
summarizeShellCommand(
|
||||
'which node pnpm corepack; node -v; echo "---"; corepack --version 2>&1; echo "---pnpm via corepack---"; pnpm --version 2>&1 | tail -5'
|
||||
)
|
||||
).toBe('which node pnpm corepack + 3 commands')
|
||||
})
|
||||
|
||||
it('compacts the proto/cache probe from session 20260624_231846_bdbd1e', () => {
|
||||
expect(
|
||||
summarizeShellCommand(
|
||||
'echo "--- proto pnpm direct ---"; ~/.proto/tools/node/24.11.0/bin/pnpm --version 2>&1 | tail -3; echo "--- proto node ---"; ls ~/.proto/tools/node/ 2>&1; echo "--- corepack cache ---"; ls ~/.cache/node/corepack/v1/pnpm/ 2>&1'
|
||||
)
|
||||
).toBe('~/.proto/tools/node/24.11.0/bin/pnpm --version + 2 commands')
|
||||
})
|
||||
|
||||
it('summarizes the successful lint command from session 20260624_231846_bdbd1e', () => {
|
||||
expect(
|
||||
summarizeShellCommand(
|
||||
'cd /Users/brooklyn/www/bb-rainbows && pnpm run lint 2>&1 | tail -20; echo "lint_exit=${PIPESTATUS[0]}"'
|
||||
)
|
||||
).toBe('pnpm run lint')
|
||||
})
|
||||
|
||||
it('summarizes a background build command from session 20260624_231846_bdbd1e', () => {
|
||||
expect(
|
||||
summarizeShellCommand(
|
||||
'cd /Users/brooklyn/www/bb-rainbows && pnpm run build 2>&1 | tail -20; echo "build_exit=${PIPESTATUS[0]}"'
|
||||
)
|
||||
).toBe('pnpm run build')
|
||||
})
|
||||
})
|
||||
216
apps/desktop/src/lib/summarize-command.ts
Normal file
216
apps/desktop/src/lib/summarize-command.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
// Adapted from condensed-milk-pi's command dispatcher: split compounds first,
|
||||
// strip pipe tails (`| head`, `| tail`, ...), then clean redirects/env prefixes
|
||||
// before deciding which segment is meaningful. This is display-only; the full
|
||||
// command remains available through Copy / detail.
|
||||
const SILENT_HEADS = new Set(['cd', 'pushd', 'popd', 'export', 'set', 'unset', 'source', '.', 'true', 'false', ':'])
|
||||
const PIPE_TAIL_HEADS = new Set(['head', 'tail', 'wc', 'sort', 'uniq'])
|
||||
|
||||
const basename = (head: string): string => head.split('/').pop() || head
|
||||
|
||||
// Split on command-chain separators, but NOT pipe. A pipe usually belongs to
|
||||
// the segment's output plumbing (`cmd 2>&1 | tail -20`); condensed-milk strips
|
||||
// that after segmenting instead of treating it as a separate producer.
|
||||
function splitCompoundCommand(input: string): string[] {
|
||||
const segments: string[] = []
|
||||
let buf = ''
|
||||
let quote: '"' | "'" | null = null
|
||||
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
const ch = input[i]!
|
||||
|
||||
if (quote) {
|
||||
buf += ch
|
||||
|
||||
if (ch === quote && input[i - 1] !== '\\') {
|
||||
quote = null
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (ch === '"' || ch === "'") {
|
||||
quote = ch
|
||||
buf += ch
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const op =
|
||||
input.startsWith('&&', i) || input.startsWith('||', i)
|
||||
? input.slice(i, i + 2)
|
||||
: ch === ';' || ch === '\n'
|
||||
? ch
|
||||
: ''
|
||||
|
||||
if (op) {
|
||||
segments.push(buf)
|
||||
buf = ''
|
||||
i += op.length - 1
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
buf += ch
|
||||
}
|
||||
|
||||
segments.push(buf)
|
||||
|
||||
return segments.map(segment => stripPipeTail(segment.trim())).filter(Boolean)
|
||||
}
|
||||
|
||||
function splitWords(segment: string): string[] {
|
||||
const words: string[] = []
|
||||
let buf = ''
|
||||
let quote: '"' | "'" | null = null
|
||||
|
||||
for (let i = 0; i < segment.length; i += 1) {
|
||||
const ch = segment[i]!
|
||||
|
||||
if (quote) {
|
||||
buf += ch
|
||||
|
||||
if (ch === quote && segment[i - 1] !== '\\') {
|
||||
quote = null
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (ch === '"' || ch === "'") {
|
||||
quote = ch
|
||||
buf += ch
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (/\s/.test(ch)) {
|
||||
if (buf) {
|
||||
words.push(buf)
|
||||
buf = ''
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
buf += ch
|
||||
}
|
||||
|
||||
if (buf) {
|
||||
words.push(buf)
|
||||
}
|
||||
|
||||
return words
|
||||
}
|
||||
|
||||
// The command word of a segment, skipping any `FOO=bar` env assignments.
|
||||
function headWord(segment: string): string {
|
||||
const tokens = splitWords(segment)
|
||||
let index = 0
|
||||
|
||||
while (index < tokens.length && /^[A-Za-z_]\w*=/.test(tokens[index]!)) {
|
||||
index += 1
|
||||
}
|
||||
|
||||
return basename(tokens[index] ?? '')
|
||||
}
|
||||
|
||||
function stripPipeTail(segment: string): string {
|
||||
const words = splitWords(segment)
|
||||
const out: string[] = []
|
||||
|
||||
for (let i = 0; i < words.length; i += 1) {
|
||||
const word = words[i]!
|
||||
|
||||
if (word === '|' && PIPE_TAIL_HEADS.has(basename(words[i + 1] ?? ''))) {
|
||||
break
|
||||
}
|
||||
|
||||
out.push(word)
|
||||
}
|
||||
|
||||
return out.join(' ').trim()
|
||||
}
|
||||
|
||||
function cleanSegment(segment: string): string {
|
||||
const words = splitWords(segment)
|
||||
const out: string[] = []
|
||||
|
||||
for (let i = 0; i < words.length; i += 1) {
|
||||
const word = words[i]!
|
||||
|
||||
if (/^\d*(?:>>?|<)$/.test(word)) {
|
||||
i += 1
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^\d*(?:>&|<&)\d+$/.test(word) || /^\d*>&\d+$/.test(word)) {
|
||||
continue
|
||||
}
|
||||
|
||||
out.push(word)
|
||||
}
|
||||
|
||||
return out.join(' ').trim()
|
||||
}
|
||||
|
||||
function isBoundaryEcho(segment: string): boolean {
|
||||
const words = splitWords(segment)
|
||||
|
||||
if (basename(words[0] ?? '') !== 'echo') {
|
||||
return false
|
||||
}
|
||||
|
||||
// Banner/status echoes are UI plumbing. Do not treat arbitrary `echo $VALUE`
|
||||
// as noise; it may be the command's actual output.
|
||||
const rest = words.slice(1).join(' ')
|
||||
|
||||
return /-{2,}|_exit=|(?:^|\s|=)\$[?{]|PIPESTATUS/.test(rest)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce a verbose shell command to the "main" command, for display only.
|
||||
*
|
||||
* Agents wrap real work in plumbing — `cd <dir> && <cmd> 2>&1 | tail -N; echo
|
||||
* "x_exit=${PIPESTATUS[0]}"` — which buries the command the user actually cares
|
||||
* about. This peels that wrapper off using small head-word allowlists instead of
|
||||
* one giant regex:
|
||||
*
|
||||
* 1. split into segments on top-level `&&` `||` `;` (quote-aware)
|
||||
* 2. strip trailing pipe tails (`| head`, `| tail`, `| wc`, ...)
|
||||
* 3. clean env var prefixes / redirects
|
||||
* 4. drop setup/banner/status segments
|
||||
*
|
||||
* If one real command survives, show it. If multiple real commands survive,
|
||||
* show a short `first command + N commands` label instead of flooding the row
|
||||
* with every probe. The full command is always still available via Copy/detail.
|
||||
*/
|
||||
export function summarizeShellCommand(raw: string): string {
|
||||
const original = (raw ?? '').trim()
|
||||
|
||||
if (!original) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const segments = splitCompoundCommand(original)
|
||||
|
||||
if (segments.length <= 1) {
|
||||
return cleanSegment(original) || original
|
||||
}
|
||||
|
||||
const core = segments.map(cleanSegment).filter(segment => {
|
||||
const head = headWord(segment)
|
||||
|
||||
return segment && !SILENT_HEADS.has(head) && !isBoundaryEcho(segment)
|
||||
})
|
||||
|
||||
if (core.length === 0) {
|
||||
return original
|
||||
}
|
||||
|
||||
if (core.length === 1) {
|
||||
return core[0]!
|
||||
}
|
||||
|
||||
return `${core[0]} + ${core.length - 1} ${core.length === 2 ? 'command' : 'commands'}`
|
||||
}
|
||||
@@ -8,6 +8,12 @@ import {
|
||||
|
||||
import { sanitizeTextForSpeech } from './speech-text'
|
||||
|
||||
// Free Edge TTS occasionally hands back audio that never fires `playing`/`ended`
|
||||
// nor `error` — leaving voice mode stuck "speaking" forever. Reject if playback
|
||||
// fails to start or stalls mid-stream for this long (rearmed on each progress
|
||||
// tick, so legitimately long speech is never cut off).
|
||||
const PLAYBACK_STALL_MS = 15_000
|
||||
|
||||
let currentAudio: HTMLAudioElement | null = null
|
||||
let currentStop: (() => void) | null = null
|
||||
let sequence = 0
|
||||
@@ -78,12 +84,31 @@ export async function playSpeechText(text: string, options: VoicePlaybackOptions
|
||||
setVoicePlaybackState(currentState('speaking', options, audio))
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let stall: number | null = null
|
||||
|
||||
const cleanup = () => {
|
||||
if (stall !== null) {
|
||||
window.clearTimeout(stall)
|
||||
stall = null
|
||||
}
|
||||
|
||||
audio.removeEventListener('ended', onEnded)
|
||||
audio.removeEventListener('error', onError)
|
||||
audio.removeEventListener('timeupdate', armStall)
|
||||
currentStop = null
|
||||
}
|
||||
|
||||
const armStall = () => {
|
||||
if (stall !== null) {
|
||||
window.clearTimeout(stall)
|
||||
}
|
||||
|
||||
stall = window.setTimeout(() => {
|
||||
cleanup()
|
||||
reject(new Error('Playback stalled'))
|
||||
}, PLAYBACK_STALL_MS)
|
||||
}
|
||||
|
||||
const onEnded = () => {
|
||||
cleanup()
|
||||
resolve()
|
||||
@@ -101,7 +126,9 @@ export async function playSpeechText(text: string, options: VoicePlaybackOptions
|
||||
|
||||
audio.addEventListener('ended', onEnded, { once: true })
|
||||
audio.addEventListener('error', onError, { once: true })
|
||||
void audio.play().catch(reject)
|
||||
audio.addEventListener('timeupdate', armStall)
|
||||
armStall()
|
||||
void audio.play().catch(onError)
|
||||
})
|
||||
|
||||
if (!isCurrent()) {
|
||||
|
||||
@@ -96,6 +96,19 @@ describe('dispatchNativeNotification focus gating', () => {
|
||||
dispatchNativeNotification({ kind: 'approval', sessionId: 'on-screen', title: 'approve' })
|
||||
expect(notify).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fires a global completion notification while away with no active session (pet gen)', () => {
|
||||
setActiveSessionId(null)
|
||||
dispatchNativeNotification({ global: true, kind: 'backgroundDone', title: 'Your pet hatched' })
|
||||
expect(notify).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('suppresses a global notification when the window is focused', () => {
|
||||
setWindowState({ focused: true, hidden: false })
|
||||
setActiveSessionId(null)
|
||||
dispatchNativeNotification({ global: true, kind: 'backgroundDone', title: 'Your pet hatched' })
|
||||
expect(notify).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('dispatchNativeNotification preferences', () => {
|
||||
|
||||
@@ -113,7 +113,15 @@ function isBackgrounded(): boolean {
|
||||
return typeof document.hasFocus === 'function' && !document.hasFocus()
|
||||
}
|
||||
|
||||
function shouldFire(kind: NativeNotificationKind, sessionId?: null | string): boolean {
|
||||
function shouldFire(kind: NativeNotificationKind, sessionId?: null | string, global = false): boolean {
|
||||
// Global notifications aren't tied to a chat session (e.g. pet generation,
|
||||
// which runs from the command center with no active conversation). They fire
|
||||
// whenever the user is away, with no session-match requirement — otherwise a
|
||||
// background run started without an open session would be silently dropped.
|
||||
if (global) {
|
||||
return isBackgrounded()
|
||||
}
|
||||
|
||||
// Attention kinds break through for an off-screen session even while focused.
|
||||
if (ATTENTION_KINDS.has(kind)) {
|
||||
return isBackgrounded() || (Boolean(sessionId) && sessionId !== $activeSessionId.get())
|
||||
@@ -134,6 +142,12 @@ export interface NativeNotificationInput {
|
||||
title: string
|
||||
body?: string
|
||||
sessionId?: null | string
|
||||
/**
|
||||
* Not tied to a chat session (e.g. pet generation). Fires whenever the user
|
||||
* is away, bypassing the session-match gate that completion kinds normally
|
||||
* require.
|
||||
*/
|
||||
global?: boolean
|
||||
silent?: boolean
|
||||
actions?: NativeNotificationAction[]
|
||||
}
|
||||
@@ -145,11 +159,11 @@ export function dispatchNativeNotification(input: NativeNotificationInput): void
|
||||
return
|
||||
}
|
||||
|
||||
if (!shouldFire(input.kind, input.sessionId)) {
|
||||
if (!shouldFire(input.kind, input.sessionId, input.global)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (throttled(`${input.kind}:${input.sessionId ?? ''}`, Date.now())) {
|
||||
if (throttled(`${input.kind}:${input.sessionId ?? (input.global ? 'global' : '')}`, Date.now())) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { OAuthProvider } from '@/types/hermes'
|
||||
|
||||
import * as notifications from '@/store/notifications'
|
||||
|
||||
import {
|
||||
$desktopOnboarding,
|
||||
type DesktopOnboardingState,
|
||||
@@ -63,6 +65,16 @@ function onboardingContext(requestGateway: OnboardingContext['requestGateway']):
|
||||
return { requestGateway }
|
||||
}
|
||||
|
||||
function fallbackTimeoutGateway(): OnboardingContext['requestGateway'] {
|
||||
return async method => {
|
||||
if (method === 'setup.status' || method === 'setup.runtime_check') {
|
||||
throw new Error(`request timed out: ${method}`)
|
||||
}
|
||||
|
||||
throw new Error(`unexpected gateway method: ${method}`)
|
||||
}
|
||||
}
|
||||
|
||||
describe('refreshOnboarding', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
@@ -116,6 +128,108 @@ describe('refreshOnboarding', () => {
|
||||
expect($desktopOnboarding.get().providers?.map(p => p.id)).toEqual(['cached'])
|
||||
})
|
||||
|
||||
it('does not downgrade configured=true on fallback-only readiness failures', async () => {
|
||||
const api = vi.fn(async ({ path }: { path: string }) => {
|
||||
if (path === '/api/providers/oauth') {
|
||||
return { providers: [provider('fresh')] }
|
||||
}
|
||||
|
||||
throw new Error(`unexpected api path: ${path}`)
|
||||
})
|
||||
|
||||
installApiMock(api)
|
||||
// Simulate a returning user: cache is set and store is configured.
|
||||
window.localStorage.setItem('hermes-desktop-onboarded-v1', '1')
|
||||
$desktopOnboarding.set(
|
||||
baseState({
|
||||
configured: true,
|
||||
providers: [provider('cached')],
|
||||
reason: null,
|
||||
requested: false
|
||||
})
|
||||
)
|
||||
|
||||
const ready = await refreshOnboarding(onboardingContext(fallbackTimeoutGateway()))
|
||||
|
||||
expect(ready).toBe(false)
|
||||
expect(api).not.toHaveBeenCalled()
|
||||
expect($desktopOnboarding.get().configured).toBe(true)
|
||||
expect($desktopOnboarding.get().reason).toBeNull()
|
||||
// The cache must survive the refresh — proving we didn't downgrade.
|
||||
expect(window.localStorage.getItem('hermes-desktop-onboarded-v1')).toBe('1')
|
||||
})
|
||||
|
||||
it('shows a non-blocking notification when preserving configured on fallback', async () => {
|
||||
const notifySpy = vi.spyOn(notifications, 'notify')
|
||||
|
||||
installApiMock(vi.fn())
|
||||
$desktopOnboarding.set(
|
||||
baseState({
|
||||
configured: true,
|
||||
providers: [provider('cached')],
|
||||
reason: null,
|
||||
requested: false
|
||||
})
|
||||
)
|
||||
|
||||
await refreshOnboarding(onboardingContext(fallbackTimeoutGateway()))
|
||||
|
||||
expect(notifySpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'runtime-not-ready',
|
||||
kind: 'error'
|
||||
})
|
||||
)
|
||||
expect($desktopOnboarding.get().configured).toBe(true)
|
||||
})
|
||||
|
||||
it('does not preserve configured when onboarding was explicitly requested', async () => {
|
||||
const api = vi.fn(async ({ path }: { path: string }) => {
|
||||
if (path === '/api/providers/oauth') {
|
||||
return { providers: [provider('fresh')] }
|
||||
}
|
||||
|
||||
throw new Error(`unexpected api path: ${path}`)
|
||||
})
|
||||
|
||||
installApiMock(api)
|
||||
$desktopOnboarding.set(
|
||||
baseState({
|
||||
configured: true,
|
||||
providers: [provider('cached')],
|
||||
reason: null,
|
||||
requested: true
|
||||
})
|
||||
)
|
||||
|
||||
const ready = await refreshOnboarding(onboardingContext(fallbackTimeoutGateway()))
|
||||
|
||||
expect(ready).toBe(false)
|
||||
// requested overrides preservation — should downgrade.
|
||||
expect($desktopOnboarding.get().configured).toBe(false)
|
||||
expect(api).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('still surfaces onboarding when fallback failure happens before configured state', async () => {
|
||||
const api = vi.fn(async ({ path }: { path: string }) => {
|
||||
if (path === '/api/providers/oauth') {
|
||||
return { providers: [provider('fresh')] }
|
||||
}
|
||||
|
||||
throw new Error(`unexpected api path: ${path}`)
|
||||
})
|
||||
|
||||
installApiMock(api)
|
||||
$desktopOnboarding.set(baseState({ configured: false, providers: null, requested: true }))
|
||||
|
||||
const ready = await refreshOnboarding(onboardingContext(fallbackTimeoutGateway()))
|
||||
|
||||
expect(ready).toBe(false)
|
||||
expect(api).toHaveBeenCalledTimes(1)
|
||||
expect($desktopOnboarding.get().configured).toBe(false)
|
||||
expect($desktopOnboarding.get().reason).toContain('request timed out')
|
||||
})
|
||||
|
||||
it('deduplicates concurrent provider refresh calls', async () => {
|
||||
let resolveProviders!: (value: { providers: OAuthProvider[] }) => void
|
||||
|
||||
@@ -194,7 +308,7 @@ describe('OAuth onboarding', () => {
|
||||
throw new Error(`unexpected api path: ${path}`)
|
||||
})
|
||||
|
||||
const requestGateway: OnboardingContext['requestGateway'] = async method => {
|
||||
const requestGateway: OnboardingContext['requestGateway'] = async (method, params) => {
|
||||
if (method === 'reload.env') {
|
||||
return {} as never
|
||||
}
|
||||
@@ -204,6 +318,8 @@ describe('OAuth onboarding', () => {
|
||||
}
|
||||
|
||||
if (method === 'setup.runtime_check') {
|
||||
expect(params).toEqual({ provider: 'nous' })
|
||||
|
||||
return { ok: true } as never
|
||||
}
|
||||
|
||||
@@ -241,6 +357,14 @@ describe('OAuth onboarding', () => {
|
||||
}
|
||||
|
||||
expect(calls.some(c => c.path === '/api/model/set')).toBe(true)
|
||||
|
||||
const optionsIndex = calls.findIndex(c => c.path === '/api/model/options')
|
||||
const recommendedIndex = calls.findIndex(c => c.path.startsWith('/api/model/recommended-default'))
|
||||
const setIndex = calls.findIndex(c => c.path === '/api/model/set')
|
||||
|
||||
expect(optionsIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(recommendedIndex).toBeGreaterThan(optionsIndex)
|
||||
expect(setIndex).toBeGreaterThan(recommendedIndex)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -181,13 +181,27 @@ function clearPoll() {
|
||||
}
|
||||
}
|
||||
|
||||
async function checkRuntime(ctx: OnboardingContext): Promise<RuntimeReadinessResult> {
|
||||
async function checkRuntime(
|
||||
ctx: OnboardingContext,
|
||||
requestedProvider?: string
|
||||
): Promise<RuntimeReadinessResult> {
|
||||
return evaluateRuntimeReadiness(ctx.requestGateway, {
|
||||
defaultReason: DEFAULT_ONBOARDING_REASON,
|
||||
requestedProvider,
|
||||
unknownReady: false
|
||||
})
|
||||
}
|
||||
|
||||
function shouldPreserveConfiguredOnFallback(
|
||||
runtime: RuntimeReadinessResult,
|
||||
state: DesktopOnboardingState
|
||||
): boolean {
|
||||
// A fallback result means both runtime probes were non-authoritative
|
||||
// (transport timeout/disconnect). Keep a previously verified configured
|
||||
// state instead of forcing the blocking onboarding overlay.
|
||||
return runtime.source === 'fallback' && state.configured === true && !state.requested
|
||||
}
|
||||
|
||||
function notifyReady(provider: string) {
|
||||
notify({ kind: 'success', title: 'Hermes is ready', message: `${provider} connected.` })
|
||||
}
|
||||
@@ -307,7 +321,28 @@ async function completeWithModelConfirm(
|
||||
ignoreRuntimeGate = false
|
||||
) {
|
||||
await ctx.requestGateway('reload.env').catch(() => undefined)
|
||||
const runtime = await checkRuntime(ctx)
|
||||
|
||||
const defaults = await fetchProviderDefaultModel(preferredSlugs)
|
||||
|
||||
if (defaults) {
|
||||
// Persist the chosen provider/model before the runtime gate so a stale
|
||||
// config provider (e.g. anthropic from a prior failed setup) cannot make
|
||||
// setup.runtime_check validate the wrong backend after a fresh OAuth login.
|
||||
try {
|
||||
const res = await setModelAssignment({
|
||||
scope: 'main',
|
||||
provider: defaults.providerSlug,
|
||||
model: defaults.defaultModel
|
||||
})
|
||||
|
||||
notifyGatewayTools(res.gateway_tools)
|
||||
} catch {
|
||||
// Persistence failed — still run the scoped runtime check below and
|
||||
// show the confirm card so the user can pick something explicitly.
|
||||
}
|
||||
}
|
||||
|
||||
const runtime = await checkRuntime(ctx, preferredSlugs[0])
|
||||
|
||||
if (!runtime.ready && !ignoreRuntimeGate) {
|
||||
onFail(runtime.reason)
|
||||
@@ -315,8 +350,6 @@ async function completeWithModelConfirm(
|
||||
return
|
||||
}
|
||||
|
||||
const defaults = await fetchProviderDefaultModel(preferredSlugs)
|
||||
|
||||
if (!defaults) {
|
||||
// Couldn't get a sensible default — proceed without confirm step.
|
||||
notifyReady(providerLabel)
|
||||
@@ -326,27 +359,6 @@ async function completeWithModelConfirm(
|
||||
return
|
||||
}
|
||||
|
||||
// Persist the default model BEFORE showing the confirm card so that:
|
||||
// (1) "current default: X" shown in the UI is what's actually written
|
||||
// to config — no lying.
|
||||
// (2) If the user clicks "Start chatting" without changing anything,
|
||||
// no extra write is needed.
|
||||
// (3) If they bail out (e.g., refresh the page), they still end up
|
||||
// with a working config, not an empty-model fallback.
|
||||
try {
|
||||
const res = await setModelAssignment({
|
||||
scope: 'main',
|
||||
provider: defaults.providerSlug,
|
||||
model: defaults.defaultModel
|
||||
})
|
||||
|
||||
notifyGatewayTools(res.gateway_tools)
|
||||
} catch {
|
||||
// Persistence failed — still show the confirm card so the user can
|
||||
// pick something explicitly. The backend will pick its own default
|
||||
// at chat time if we end up never persisting.
|
||||
}
|
||||
|
||||
setFlow({
|
||||
status: 'confirming_model',
|
||||
providerSlug: defaults.providerSlug,
|
||||
@@ -515,6 +527,21 @@ export async function refreshOnboarding(ctx: OnboardingContext) {
|
||||
}
|
||||
|
||||
const state = $desktopOnboarding.get()
|
||||
if (shouldPreserveConfiguredOnFallback(runtime, state)) {
|
||||
// Gateway probes timed out but the user was already configured — don't
|
||||
// downgrade to the blocking onboarding overlay. Surface a non-blocking
|
||||
// notification with a stable id so repeated calls during an outage dedup
|
||||
// instead of stacking toasts.
|
||||
notify({
|
||||
id: 'runtime-not-ready',
|
||||
kind: 'error',
|
||||
title: 'Runtime not ready',
|
||||
message: 'Hermes Desktop could not verify the running backend on startup. Some features may be unavailable until the gateway is reachable.'
|
||||
})
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const reason = runtime.reason || state.reason || DEFAULT_ONBOARDING_REASON
|
||||
|
||||
writeCachedConfigured(false)
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface GalleryPet {
|
||||
spritesheetUrl?: string
|
||||
/** petdex's hand-picked set — used only to rank "popular" pets first. */
|
||||
curated?: boolean
|
||||
/** Hatched locally by the user (createdBy=generator) — badged + ranked first. */
|
||||
generated?: boolean
|
||||
}
|
||||
|
||||
export interface PetGallery {
|
||||
@@ -39,7 +41,12 @@ export type PetGalleryStatus = 'idle' | 'loading' | 'ready' | 'stale' | 'error'
|
||||
|
||||
/** The recovering `requestGateway` from `useGatewayRequest` — passed in so the
|
||||
* store reuses the hook's reconnect/reauth handling instead of duplicating it. */
|
||||
export type GatewayRequest = <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
export type GatewayRequest = <T>(
|
||||
method: string,
|
||||
params?: Record<string, unknown>,
|
||||
timeoutMs?: number,
|
||||
signal?: AbortSignal
|
||||
) => Promise<T>
|
||||
|
||||
/** Profile-scoped pet RPC. Pets are per-profile, so every call carries the active
|
||||
* profile (the gateway no-ops it for the launch profile). One chokepoint so no
|
||||
@@ -115,16 +122,21 @@ export function loadPetGallery(request: GatewayRequest, options: { force?: boole
|
||||
$petGalleryStatus.set('loading')
|
||||
}
|
||||
|
||||
let localOk = false
|
||||
|
||||
try {
|
||||
const [next, info] = await Promise.all([
|
||||
petRpc<PetGallery>(request, 'pet.gallery'),
|
||||
// Phase 1: local pets only — instant, never blocks on the remote petdex
|
||||
// manifest. The user's own/generated pets render right away.
|
||||
const [local, info] = await Promise.all([
|
||||
petRpc<PetGallery>(request, 'pet.gallery', { localOnly: true }),
|
||||
petRpc<PetInfo>(request, 'pet.info')
|
||||
])
|
||||
|
||||
if (next) {
|
||||
$petGallery.set(next)
|
||||
if (local) {
|
||||
$petGallery.set(local)
|
||||
$petGalleryStatus.set('ready')
|
||||
$petGalleryError.set(null)
|
||||
localOk = true
|
||||
}
|
||||
|
||||
if (info) {
|
||||
@@ -142,6 +154,21 @@ export function loadPetGallery(request: GatewayRequest, options: { force?: boole
|
||||
} finally {
|
||||
galleryLoad = null
|
||||
}
|
||||
|
||||
// Phase 2: merge in the full petdex catalog in the background. A slow/failed
|
||||
// manifest fetch never hides the local pets shown in phase 1.
|
||||
if (localOk) {
|
||||
try {
|
||||
const full = await petRpc<PetGallery>(request, 'pet.gallery')
|
||||
|
||||
if (full) {
|
||||
$petGallery.set(full)
|
||||
$petGalleryStatus.set('ready')
|
||||
}
|
||||
} catch {
|
||||
// Keep the local-only gallery; the petdex catalog just stays unmerged.
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
return galleryLoad
|
||||
@@ -161,6 +188,24 @@ async function syncInfo(request: GatewayRequest): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reflect a just-adopted *local* pet without any network: optimistically mark it
|
||||
* active/installed in the cached gallery and repaint the live mascot via the
|
||||
* local `pet.info`. Adopting a generated pet is a disk+config op — it must never
|
||||
* wait on `pet.gallery`'s remote petdex manifest fetch.
|
||||
*/
|
||||
export async function applyAdoptedPet(request: GatewayRequest, slug: string, displayName: string): Promise<void> {
|
||||
patchGallery(gallery => ({
|
||||
...gallery,
|
||||
enabled: true,
|
||||
active: slug,
|
||||
pets: gallery.pets.some(p => p.slug === slug)
|
||||
? gallery.pets.map(p => (p.slug === slug ? { ...p, installed: true, displayName } : p))
|
||||
: [...gallery.pets, { slug, displayName, installed: true, spritesheetUrl: '' }]
|
||||
}))
|
||||
await syncInfo(request)
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter (drop the internal `clawd*` pets + apply a search query) and rank the
|
||||
* gallery for a picker. Ranking has no popularity data, so it leans on the
|
||||
@@ -175,8 +220,15 @@ export function rankedGalleryPets(gallery: PetGallery | null, query = ''): Galle
|
||||
|
||||
const needle = query.trim().toLowerCase()
|
||||
|
||||
// User-generated pets first, then the active pet, then installed, then curated.
|
||||
// Guard every term with a boolean — local-only pets omit curated/generated, and
|
||||
// `Number(undefined)` is NaN, which poisons the sort (it would sink those pets
|
||||
// below the render cap and hide them entirely).
|
||||
const rank = (p: GalleryPet) =>
|
||||
Number(gallery.enabled && p.slug === gallery.active) * 4 + Number(p.installed) * 2 + Number(p.curated)
|
||||
(p.generated ? 8 : 0) +
|
||||
(gallery.enabled && p.slug === gallery.active ? 4 : 0) +
|
||||
(p.installed ? 2 : 0) +
|
||||
(p.curated ? 1 : 0)
|
||||
|
||||
return gallery.pets
|
||||
.filter(
|
||||
@@ -309,14 +361,111 @@ export function setPetScale(request: GatewayRequest, scale: number): void {
|
||||
}, 200)
|
||||
}
|
||||
|
||||
/** Export a pet as a `.zip` (pet.json + spritesheet) and save it via the browser. */
|
||||
export async function exportPet(request: GatewayRequest, slug: string, fallback: string): Promise<boolean> {
|
||||
$petBusy.set(slug)
|
||||
$petGalleryError.set(null)
|
||||
try {
|
||||
const res = await petRpc<{ ok: boolean; filename: string; zipBase64: string }>(request, 'pet.export', { slug })
|
||||
if (!res?.ok || !res.zipBase64) {
|
||||
throw new Error(fallback)
|
||||
}
|
||||
const bytes = Uint8Array.from(atob(res.zipBase64), c => c.charCodeAt(0))
|
||||
const url = URL.createObjectURL(new Blob([bytes], { type: 'application/zip' }))
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = res.filename || `${slug}.zip`
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
return true
|
||||
} catch (e) {
|
||||
$petGalleryError.set(e instanceof Error ? e.message : fallback)
|
||||
return false
|
||||
} finally {
|
||||
$petBusy.set(null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a pet — optimistic. The new name shows instantly (so the dialog can
|
||||
* close immediately); the RPC runs in the background and the backend also
|
||||
* realigns the slug/dir, so we reconcile the slug + thumb cache when it returns,
|
||||
* and roll the name back if it fails.
|
||||
*/
|
||||
export function renamePet(request: GatewayRequest, slug: string, name: string, fallback: string): Promise<boolean> {
|
||||
const trimmed = name.trim()
|
||||
|
||||
if (!trimmed) {
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
|
||||
const prev = $petGallery.get()?.pets.find(p => p.slug === slug)?.displayName ?? ''
|
||||
|
||||
// Optimistic: paint the new name now (slug reconciles when the RPC returns).
|
||||
patchGallery(g => ({
|
||||
...g,
|
||||
pets: g.pets.map(p => (p.slug === slug ? { ...p, displayName: trimmed } : p))
|
||||
}))
|
||||
$petGalleryError.set(null)
|
||||
|
||||
return (async () => {
|
||||
try {
|
||||
const res = await petRpc<{ ok: boolean; slug: string; displayName: string }>(request, 'pet.rename', {
|
||||
slug,
|
||||
name: trimmed
|
||||
})
|
||||
|
||||
if (!res?.ok) {
|
||||
throw new Error(fallback)
|
||||
}
|
||||
|
||||
const newSlug = res.slug || slug
|
||||
|
||||
if (newSlug !== slug) {
|
||||
thumbCache.delete(slug)
|
||||
patchGallery(g => ({
|
||||
...g,
|
||||
active: g.active === slug ? newSlug : g.active,
|
||||
pets: g.pets
|
||||
.filter(p => p.slug !== newSlug || p.slug === slug)
|
||||
.map(p => (p.slug === slug ? { ...p, slug: newSlug, displayName: res.displayName || trimmed } : p))
|
||||
}))
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (e) {
|
||||
// Roll the optimistic name back so the list reflects on-disk truth.
|
||||
patchGallery(g => ({
|
||||
...g,
|
||||
pets: g.pets.map(p => (p.slug === slug ? { ...p, displayName: prev } : p))
|
||||
}))
|
||||
$petGalleryError.set(e instanceof Error ? e.message : fallback)
|
||||
|
||||
return false
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
/** Uninstall a pet; turns the mascot off if it was the active one. */
|
||||
export function removePet(request: GatewayRequest, slug: string, fallback: string): Promise<boolean> {
|
||||
return mutate(slug, fallback, request, async () => {
|
||||
await petRpc(request, 'pet.remove', { slug })
|
||||
// Evict the by-slug thumb cache so a reused slug doesn't render this pet's
|
||||
// stale thumbnail (the backend drops its disk thumb in parallel).
|
||||
thumbCache.delete(slug)
|
||||
patchGallery(g => ({
|
||||
...g,
|
||||
enabled: g.active === slug ? false : g.enabled,
|
||||
pets: g.pets.map(p => (p.slug === slug ? { ...p, installed: false } : p))
|
||||
active: g.active === slug ? '' : g.active,
|
||||
// Petdex pets can be reinstalled from the manifest, so we just mark them
|
||||
// uninstalled. Generated / local-only pets have no remote source — once
|
||||
// deleted they're gone, so drop them from the list entirely.
|
||||
pets: g.pets.flatMap(p => {
|
||||
if (p.slug !== slug) {
|
||||
return [p]
|
||||
}
|
||||
return p.generated || !p.spritesheetUrl ? [] : [{ ...p, installed: false }]
|
||||
})
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
665
apps/desktop/src/store/pet-generate.ts
Normal file
665
apps/desktop/src/store/pet-generate.ts
Normal file
@@ -0,0 +1,665 @@
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
import { persistBoolean, persistString, storedBoolean, storedString } from '@/lib/storage'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { dispatchNativeNotification } from '@/store/native-notifications'
|
||||
import { notify } from '@/store/notifications'
|
||||
import { type PetInfo } from '@/store/pet'
|
||||
import { applyAdoptedPet, type GatewayRequest } from '@/store/pet-gallery'
|
||||
/**
|
||||
* Feature store for the "generate a pet" flow (Cmd-K → Pets → Generate).
|
||||
*
|
||||
* Three backend steps, mirrored as state here:
|
||||
* - `pet.generate` produces N cheap base-look *drafts* keyed by a `token`.
|
||||
* - `pet.hatch` turns the chosen draft into a full animated pet — installed but
|
||||
* NOT active — and returns its renderer payload so we can preview all frames.
|
||||
* - the user then *adopts* (`pet.select`) or *discards* (`pet.remove`) it.
|
||||
*
|
||||
* The store owns the draft set, the selected variant, the hatched preview, and
|
||||
* the busy/error status so the page is a thin view. Retry == regenerate (new
|
||||
* token). Kept separate from `pet-gallery` because its lifecycle (ephemeral
|
||||
* drafts + an unadopted preview) is unrelated to the long-lived gallery cache.
|
||||
*/
|
||||
|
||||
// Generation is many grounded image calls — far longer than the default 30s RPC
|
||||
// timeout. Drafts fan out 4 base looks; hatch fans out ~8 animation rows. The
|
||||
// quality-first default (OpenAI image via OpenRouter) is slow, and each hatch
|
||||
// row can retry up to 3x (300s/call) across 2 parallel waves, so the absolute
|
||||
// backend worst case is ~30 min. The hatch ceiling sits above that (1h) so the
|
||||
// frontend never throws "request timed out" before the backend has actually
|
||||
// exhausted its own retries — the background-resumable notify path is the real
|
||||
// UX safety net (the user can close the modal and get pinged on completion).
|
||||
const GENERATE_TIMEOUT_MS = 420_000
|
||||
const HATCH_TIMEOUT_MS = 3_600_000
|
||||
|
||||
// Filler words to drop when deriving a default name from a free-text prompt.
|
||||
const NAME_STOPWORDS = new Set([
|
||||
'a',
|
||||
'an',
|
||||
'and',
|
||||
'at',
|
||||
'by',
|
||||
'cute',
|
||||
'for',
|
||||
'from',
|
||||
'in',
|
||||
'of',
|
||||
'on',
|
||||
'style',
|
||||
'the',
|
||||
'to',
|
||||
'with'
|
||||
])
|
||||
|
||||
/**
|
||||
* Derive a short, friendly default name from a generation prompt. The prompt
|
||||
* (e.g. "2d dragon in the style of ragnarok online") is grounding text, not a
|
||||
* name — using it verbatim makes a terrible label + slug. We keep the first few
|
||||
* meaningful words, title-cased and capped, so a blank adopt still reads well.
|
||||
* The user can always override on the reveal screen or rename later.
|
||||
*/
|
||||
export function cleanPetName(prompt: string): string {
|
||||
const words = prompt
|
||||
.replace(/[^\p{L}\p{N}\s-]/gu, ' ')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
|
||||
const meaningful = words.filter(w => !NAME_STOPWORDS.has(w.toLowerCase()))
|
||||
const picked = (meaningful.length ? meaningful : words).slice(0, 3)
|
||||
|
||||
const name = picked
|
||||
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(' ')
|
||||
.slice(0, 28)
|
||||
.trim()
|
||||
|
||||
return name || 'Pet'
|
||||
}
|
||||
|
||||
export interface PetDraft {
|
||||
index: number
|
||||
/** Downscaled PNG data URI preview from the gateway. */
|
||||
dataUri: string
|
||||
}
|
||||
|
||||
export type PetGenStatus =
|
||||
| 'idle'
|
||||
| 'generating'
|
||||
| 'ready'
|
||||
| 'hatching'
|
||||
| 'preview'
|
||||
| 'adopting'
|
||||
| 'error'
|
||||
| 'stale'
|
||||
|
||||
/** Live hatch step for the egg screen — which row is being drawn, then compose/save. */
|
||||
export interface PetHatchStage {
|
||||
phase: 'row' | 'compose' | 'save'
|
||||
state?: string
|
||||
done?: number
|
||||
total?: number
|
||||
}
|
||||
|
||||
export const $petGenStatus = atom<PetGenStatus>('idle')
|
||||
export const $petGenStage = atom<PetHatchStage | null>(null)
|
||||
export const $petGenError = atom<string | null>(null)
|
||||
|
||||
// Whether a reference-capable image backend is configured. `null` = not yet
|
||||
// probed (treat as available so the prompt shows optimistically); the overlay
|
||||
// re-probes on open and on return from settings.
|
||||
export const $petGenAvailable = atom<boolean | null>(null)
|
||||
|
||||
/** A reference-capable image backend the user can pick for generation. */
|
||||
export interface PetGenProvider {
|
||||
name: string
|
||||
label: string
|
||||
/** Whether this is the backend's default pick (no override needed). */
|
||||
default: boolean
|
||||
}
|
||||
|
||||
const PROVIDER_KEY = 'hermes.desktop.petgen.provider'
|
||||
const REMIX_CONFIRMED_KEY = 'hermes.desktop.petgen.remixConfirmed'
|
||||
|
||||
/** Reference-capable providers available to pick (from `pet.generate.status`). */
|
||||
export const $petGenProviders = atom<PetGenProvider[]>([])
|
||||
/** The picked provider name; `''` means "use the backend default". Persisted. */
|
||||
export const $petGenProvider = atom(storedString(PROVIDER_KEY) ?? '')
|
||||
|
||||
/** Set (and persist) the pet-gen provider override. `''` clears it. */
|
||||
export function setPetGenProvider(name: string): void {
|
||||
$petGenProvider.set(name)
|
||||
persistString(PROVIDER_KEY, name || null)
|
||||
}
|
||||
|
||||
/** Whether the user has acknowledged the one-time "remix regenerates" notice. */
|
||||
export const $petGenRemixConfirmed = atom(storedBoolean(REMIX_CONFIRMED_KEY, false))
|
||||
|
||||
/** Remember that the remix notice has been shown so we don't ask again. */
|
||||
export function markRemixConfirmed(): void {
|
||||
$petGenRemixConfirmed.set(true)
|
||||
persistBoolean(REMIX_CONFIRMED_KEY, true)
|
||||
}
|
||||
|
||||
/** Probe whether generation is possible (a reference-capable backend exists). */
|
||||
export async function checkPetGenAvailable(request: GatewayRequest): Promise<void> {
|
||||
try {
|
||||
const res = await request<{ available: boolean; providers?: PetGenProvider[] }>('pet.generate.status')
|
||||
$petGenAvailable.set(Boolean(res?.available))
|
||||
const providers = res?.providers ?? []
|
||||
$petGenProviders.set(providers)
|
||||
// Drop a stale pick if that backend is no longer configured.
|
||||
const picked = $petGenProvider.get()
|
||||
|
||||
if (picked && !providers.some(p => p.name === picked)) {
|
||||
setPetGenProvider('')
|
||||
}
|
||||
} catch {
|
||||
// Unknown (old backend / transient) — don't gate the UI on a failed probe.
|
||||
$petGenAvailable.set(true)
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the dedicated "Generate a pet" Pokédex overlay is open. */
|
||||
export const $petGenerateOpen = atom(false)
|
||||
|
||||
export function openPetGenerate(): void {
|
||||
// Resume an in-flight or finished-but-unadopted run (so a Stop-free close, or
|
||||
// a "done" notification click, lands back on the right step); only start on a
|
||||
// clean slate when nothing is going on.
|
||||
if ($petGenStatus.get() === 'idle') {
|
||||
resetPetGen()
|
||||
}
|
||||
|
||||
$petGenerateOpen.set(true)
|
||||
}
|
||||
|
||||
export function closePetGenerate(): void {
|
||||
$petGenerateOpen.set(false)
|
||||
}
|
||||
|
||||
export const $petGenToken = atom<string | null>(null)
|
||||
/** Prompt that produced the current draft token; hatch uses this for consistency. */
|
||||
export const $petGenPrompt = atom<string>('')
|
||||
export const $petGenDrafts = atom<PetDraft[]>([])
|
||||
export const $petGenSelected = atom<number | null>(null)
|
||||
/** The hatched-but-unadopted pet: its renderer payload, played in the preview. */
|
||||
export const $petGenPreview = atom<PetInfo | null>(null)
|
||||
|
||||
// Live composer inputs live in atoms (not component state) so closing the
|
||||
// overlay mid-flow — or letting it run in the background — and reopening (or
|
||||
// clicking the "done" notification) restores exactly what you had.
|
||||
export const $petGenInput = atom('')
|
||||
export const $petGenRefImage = atom<string | null>(null)
|
||||
export const $petGenRefName = atom('')
|
||||
|
||||
function isMissingMethod(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
|
||||
return /method not found|-32601|unknown method|no such method/i.test(message)
|
||||
}
|
||||
|
||||
/** Clear all generation state (before a fresh run). */
|
||||
export function resetPetGen(): void {
|
||||
$petGenStatus.set('idle')
|
||||
$petGenStage.set(null)
|
||||
$petGenError.set(null)
|
||||
$petGenToken.set(null)
|
||||
$petGenPrompt.set('')
|
||||
$petGenDrafts.set([])
|
||||
$petGenSelected.set(null)
|
||||
$petGenPreview.set(null)
|
||||
$petGenInput.set('')
|
||||
$petGenRefImage.set(null)
|
||||
$petGenRefName.set('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Close-time cleanup: if a pet is already hatched but not adopted, discard it so
|
||||
* abandoned previews do not accumulate on disk. In-flight generate/hatch runs
|
||||
* are intentionally left alone (background-resumable).
|
||||
*/
|
||||
export function cleanupPetGenOnClose(request: GatewayRequest): void {
|
||||
const status = $petGenStatus.get()
|
||||
const preview = $petGenPreview.get()
|
||||
|
||||
if ((status === 'preview' || status === 'adopting') && preview?.slug) {
|
||||
void request('pet.remove', { slug: preview.slug }).catch(() => {})
|
||||
resetPetGen()
|
||||
}
|
||||
}
|
||||
|
||||
// A finished background run (overlay closed) nudges the user back: an in-app
|
||||
// toast with a View action always, plus an OS notification when enabled and the
|
||||
// app is in the background. Clicking either reopens the overlay to its state.
|
||||
function notifyPetGenDone(title: string, message: string, kind: 'error' | 'success'): void {
|
||||
if ($petGenerateOpen.get()) {
|
||||
return
|
||||
}
|
||||
|
||||
notify({ kind, title, message, action: { label: 'View', onClick: openPetGenerate } })
|
||||
// Pet generation isn't tied to a chat session — mark it global so the OS
|
||||
// notification fires whenever the user is away, even with no active session
|
||||
// (the common case: generating from the command center with no conversation).
|
||||
dispatchNativeNotification({ kind: 'backgroundDone', title, body: message, global: true })
|
||||
}
|
||||
|
||||
interface GenerateOptions {
|
||||
prompt: string
|
||||
style?: string
|
||||
count?: number
|
||||
/** Optional data-URL reference image — every draft is grounded on it. */
|
||||
referenceImage?: string
|
||||
}
|
||||
|
||||
// A Stop (or a fresh round) must invalidate the in-flight call. This primitive
|
||||
// pairs a monotonic run id with the current run's cancel fn; `begin` opens a
|
||||
// run, `isCurrent` gates stale callbacks/events, `arm` registers the aborter,
|
||||
// `stop` supersedes + fires it. Drives both the draft and hatch flows.
|
||||
interface Run {
|
||||
begin: () => number
|
||||
isCurrent: (id: number) => boolean
|
||||
arm: (cancel: () => void) => void
|
||||
stop: () => void
|
||||
disarmIf: (id: number) => void
|
||||
}
|
||||
|
||||
function cancelableRun(): Run {
|
||||
let id = 0
|
||||
let cancel: (() => void) | null = null
|
||||
|
||||
return {
|
||||
begin: () => (id += 1),
|
||||
isCurrent: n => n === id,
|
||||
arm: fn => {
|
||||
cancel = fn
|
||||
},
|
||||
stop: () => {
|
||||
id += 1
|
||||
cancel?.()
|
||||
cancel = null
|
||||
},
|
||||
disarmIf: n => {
|
||||
if (n === id) {
|
||||
cancel = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const gen = cancelableRun()
|
||||
|
||||
/**
|
||||
* Stop the in-flight draft generation (real abort). If any drafts have already
|
||||
* streamed in, keep them and drop into the ready/picker state (no reason to wait
|
||||
* for all 4) — otherwise reset to idle.
|
||||
*/
|
||||
export function cancelGenerate(): void {
|
||||
gen.stop()
|
||||
$petGenError.set(null)
|
||||
|
||||
const drafts = $petGenDrafts.get()
|
||||
|
||||
if (drafts.length > 0) {
|
||||
if ($petGenSelected.get() === null) {
|
||||
$petGenSelected.set(drafts[0]?.index ?? 0)
|
||||
}
|
||||
|
||||
$petGenStatus.set('ready')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
$petGenStatus.set('idle')
|
||||
$petGenDrafts.set([])
|
||||
$petGenSelected.set(null)
|
||||
$petGenToken.set(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Abandon the current drafts and return to the prompt (step 1). Stops any
|
||||
* in-flight generation; keeps the prompt text so the user can tweak + retry.
|
||||
*/
|
||||
export function discardDrafts(): void {
|
||||
gen.stop()
|
||||
$petGenDrafts.set([])
|
||||
$petGenSelected.set(null)
|
||||
$petGenToken.set(null)
|
||||
$petGenError.set(null)
|
||||
$petGenStatus.set('idle')
|
||||
}
|
||||
|
||||
const hatch = cancelableRun()
|
||||
|
||||
// A Stop invalidates the in-flight hatch and drops back to the draft picker (the
|
||||
// server still finishes, so we delete the pet it created).
|
||||
/** Stop the in-flight hatch and return to the draft picker. */
|
||||
export function cancelHatch(): void {
|
||||
hatch.stop()
|
||||
$petGenStage.set(null)
|
||||
$petGenError.set(null)
|
||||
$petGenStatus.set($petGenDrafts.get().length > 0 ? 'ready' : 'idle')
|
||||
}
|
||||
|
||||
/** Generate (or retry) a fresh set of base-look drafts for `prompt`. */
|
||||
export async function generateDrafts(request: GatewayRequest, options: GenerateOptions): Promise<boolean> {
|
||||
const prompt = options.prompt.trim()
|
||||
const referenceImage = options.referenceImage
|
||||
|
||||
// Need *something* to ground on: a description or a reference image.
|
||||
if (!prompt && !referenceImage) {
|
||||
return false
|
||||
}
|
||||
|
||||
const runId = gen.begin()
|
||||
const controller = new AbortController()
|
||||
gen.arm(() => {
|
||||
controller.abort()
|
||||
const token = $petGenToken.get()
|
||||
|
||||
if (token) {
|
||||
void request('pet.cancel', { token }).catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
// Starting a fresh generation round supersedes any unadopted preview pet.
|
||||
const preview = $petGenPreview.get()
|
||||
|
||||
if (preview?.slug) {
|
||||
await request('pet.remove', { slug: preview.slug }).catch(() => {})
|
||||
}
|
||||
|
||||
$petGenStatus.set('generating')
|
||||
$petGenError.set(null)
|
||||
$petGenPreview.set(null)
|
||||
$petGenDrafts.set([])
|
||||
$petGenSelected.set(null)
|
||||
|
||||
// Stream drafts in as the backend finishes each one (pet.generate.progress),
|
||||
// so the grid fills live instead of sitting on placeholders until all N land.
|
||||
const off =
|
||||
$gateway.get()?.on<PetDraft & { token: string; count: number }>('pet.generate.progress', event => {
|
||||
const draft = event.payload
|
||||
|
||||
// Token-only init event (no draft yet): learn the token immediately so an
|
||||
// early Stop can still tell the backend to cancel this run.
|
||||
if (draft?.token && !draft.dataUri) {
|
||||
if (gen.isCurrent(runId) && $petGenStatus.get() === 'generating') {
|
||||
$petGenToken.set(draft.token)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!draft?.dataUri || typeof draft.index !== 'number') {
|
||||
return
|
||||
}
|
||||
|
||||
// Ignore events from a superseded/stopped run, and only stream while live.
|
||||
if (!gen.isCurrent(runId) || $petGenStatus.get() !== 'generating') {
|
||||
return
|
||||
}
|
||||
|
||||
// Capture the token from the stream so a Stop can still hatch the partial set.
|
||||
if (draft.token) {
|
||||
$petGenToken.set(draft.token)
|
||||
}
|
||||
|
||||
const current = $petGenDrafts.get()
|
||||
|
||||
if (current.some(d => d.index === draft.index)) {
|
||||
return
|
||||
}
|
||||
|
||||
$petGenDrafts.set(
|
||||
[...current, { index: draft.index, dataUri: draft.dataUri }].sort((a, b) => a.index - b.index)
|
||||
)
|
||||
}) ?? (() => {})
|
||||
|
||||
try {
|
||||
const result = await request<{ ok: boolean; token: string; drafts: PetDraft[] }>(
|
||||
'pet.generate',
|
||||
{
|
||||
prompt,
|
||||
style: options.style ?? 'auto',
|
||||
count: options.count ?? 4,
|
||||
...(referenceImage ? { referenceImage } : {}),
|
||||
...($petGenProvider.get() ? { provider: $petGenProvider.get() } : {})
|
||||
},
|
||||
GENERATE_TIMEOUT_MS,
|
||||
controller.signal
|
||||
)
|
||||
|
||||
// Stopped (or superseded by a newer round) while the RPC was in flight.
|
||||
if (!gen.isCurrent(runId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!result?.ok || !result.drafts?.length) {
|
||||
throw new Error('generation produced no drafts')
|
||||
}
|
||||
|
||||
$petGenToken.set(result.token)
|
||||
// Keep a concept for the hatch row prompts even on an image-only generate.
|
||||
$petGenPrompt.set(prompt || 'a custom pet')
|
||||
$petGenDrafts.set(result.drafts)
|
||||
$petGenSelected.set(result.drafts[0]?.index ?? 0)
|
||||
$petGenStatus.set('ready')
|
||||
notifyPetGenDone('Pet drafts ready', 'Your pet looks finished — pick one to hatch.', 'success')
|
||||
|
||||
return true
|
||||
} catch (e) {
|
||||
if (!gen.isCurrent(runId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (isMissingMethod(e)) {
|
||||
$petGenStatus.set('stale')
|
||||
} else {
|
||||
$petGenStatus.set('error')
|
||||
$petGenError.set(e instanceof Error ? e.message : 'Could not generate pet drafts.')
|
||||
notifyPetGenDone('Pet generation failed', 'Reopen to try again.', 'error')
|
||||
}
|
||||
|
||||
return false
|
||||
} finally {
|
||||
off()
|
||||
gen.disarmIf(runId)
|
||||
}
|
||||
}
|
||||
|
||||
interface HatchOptions {
|
||||
name: string
|
||||
description?: string
|
||||
prompt?: string
|
||||
style?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Hatch the selected draft into a full pet (installed but NOT yet active) and
|
||||
* load its renderer payload into the preview. Adoption is a separate, explicit
|
||||
* step (`adoptHatched`) so the user sees every frame play before committing.
|
||||
* Returns true when the preview is ready.
|
||||
*/
|
||||
export async function hatchSelected(request: GatewayRequest, options: HatchOptions): Promise<boolean> {
|
||||
const token = $petGenToken.get()
|
||||
const index = $petGenSelected.get()
|
||||
const name = options.name.trim()
|
||||
const concept = ($petGenPrompt.get() || options.prompt || name).trim()
|
||||
|
||||
if (token === null || index === null || !name) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Hatch cancellation rides its own token (not the draft token): hatching
|
||||
// mid-generation leaves pet.generate releasing that token, which would race
|
||||
// the arm. The draft token still locates the staged image server-side.
|
||||
const cancelToken = crypto.randomUUID()
|
||||
const hatchRunId = hatch.begin()
|
||||
const controller = new AbortController()
|
||||
hatch.arm(() => {
|
||||
controller.abort()
|
||||
void request('pet.cancel', { token: cancelToken }).catch(() => {})
|
||||
})
|
||||
|
||||
$petGenStatus.set('hatching')
|
||||
$petGenStage.set(null)
|
||||
$petGenError.set(null)
|
||||
|
||||
// Stream the hatch steps (which row is drawing, then compose/save) to the egg
|
||||
// screen so a multi-minute hatch shows live progress instead of a black box.
|
||||
const offProgress =
|
||||
$gateway
|
||||
.get()
|
||||
?.on<{ event: string; state?: string; done?: string; total?: string }>('pet.hatch.progress', event => {
|
||||
const p = event.payload
|
||||
|
||||
if (!p || !hatch.isCurrent(hatchRunId) || $petGenStatus.get() !== 'hatching') {
|
||||
return
|
||||
}
|
||||
|
||||
if (p.event === 'row' && p.state) {
|
||||
$petGenStage.set({
|
||||
phase: 'row',
|
||||
state: p.state,
|
||||
done: Number(p.done) || undefined,
|
||||
total: Number(p.total) || undefined
|
||||
})
|
||||
} else if (p.event === 'compose') {
|
||||
$petGenStage.set({ phase: 'compose' })
|
||||
} else if (p.event === 'save') {
|
||||
$petGenStage.set({ phase: 'save' })
|
||||
}
|
||||
}) ?? (() => {})
|
||||
|
||||
try {
|
||||
const result = await request<{ ok: boolean; slug: string; displayName: string; pet?: PetInfo }>(
|
||||
'pet.hatch',
|
||||
{
|
||||
token,
|
||||
cancelToken,
|
||||
index,
|
||||
name,
|
||||
description: options.description ?? '',
|
||||
prompt: concept,
|
||||
style: options.style ?? 'auto',
|
||||
...($petGenProvider.get() ? { provider: $petGenProvider.get() } : {})
|
||||
},
|
||||
HATCH_TIMEOUT_MS,
|
||||
controller.signal
|
||||
)
|
||||
|
||||
// Stopped mid-hatch: the server created the pet anyway, so delete it.
|
||||
if (!hatch.isCurrent(hatchRunId)) {
|
||||
if (result?.slug) {
|
||||
void request('pet.remove', { slug: result.slug }).catch(() => {})
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
if (!result?.ok || !result.pet?.spritesheetBase64) {
|
||||
throw new Error('hatch produced no preview')
|
||||
}
|
||||
|
||||
$petGenPreview.set({ ...result.pet, enabled: true })
|
||||
$petGenStatus.set('preview')
|
||||
notifyPetGenDone('Your pet hatched', 'Reopen to name and adopt it.', 'success')
|
||||
|
||||
return true
|
||||
} catch (e) {
|
||||
if (!hatch.isCurrent(hatchRunId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
$petGenStatus.set('error')
|
||||
$petGenError.set(e instanceof Error ? e.message : 'Could not hatch the pet.')
|
||||
notifyPetGenDone('Hatching failed', 'Reopen to try again.', 'error')
|
||||
|
||||
return false
|
||||
} finally {
|
||||
offProgress()
|
||||
|
||||
if (hatch.isCurrent(hatchRunId)) {
|
||||
$petGenStage.set(null)
|
||||
hatch.disarmIf(hatchRunId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface AdoptOutcome {
|
||||
ok: boolean
|
||||
slug?: string
|
||||
displayName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopt the previewed pet: optionally rename it to the user's chosen name (set
|
||||
* on the reveal screen), activate it (`pet.select`), refresh the gallery + live
|
||||
* mascot, and clear generation state. No-op unless a preview exists.
|
||||
*/
|
||||
export async function adoptHatched(request: GatewayRequest, name?: string): Promise<AdoptOutcome> {
|
||||
const preview = $petGenPreview.get()
|
||||
|
||||
if (!preview?.slug) {
|
||||
return { ok: false }
|
||||
}
|
||||
|
||||
$petGenStatus.set('adopting')
|
||||
$petGenError.set(null)
|
||||
|
||||
try {
|
||||
// Name is collected after hatch, so apply it before activating. The rename
|
||||
// also realigns the slug to the chosen name (so lists show what the user
|
||||
// typed, not the prompt), so adopt the *returned* slug. Best-effort: a
|
||||
// rename failure shouldn't block adopting under the provisional slug.
|
||||
const finalName = name?.trim()
|
||||
let adoptSlug = preview.slug
|
||||
|
||||
if (finalName && finalName !== preview.displayName) {
|
||||
const renamed = await request<{ ok: boolean; slug: string }>('pet.rename', {
|
||||
slug: preview.slug,
|
||||
name: finalName
|
||||
}).catch(() => null)
|
||||
|
||||
if (renamed?.slug) {
|
||||
adoptSlug = renamed.slug
|
||||
}
|
||||
}
|
||||
|
||||
const result = await request<{ ok: boolean; slug: string; displayName: string }>('pet.select', {
|
||||
slug: adoptSlug
|
||||
})
|
||||
|
||||
if (!result?.ok) {
|
||||
throw new Error('adopt failed')
|
||||
}
|
||||
|
||||
// pet.select already set the active mascot (disk + config). Reflect it
|
||||
// locally — no remote petdex manifest fetch — and close immediately.
|
||||
resetPetGen()
|
||||
void applyAdoptedPet(request, result.slug, result.displayName)
|
||||
|
||||
return { ok: true, slug: result.slug, displayName: result.displayName }
|
||||
} catch (e) {
|
||||
$petGenStatus.set('preview')
|
||||
$petGenError.set(e instanceof Error ? e.message : 'Could not adopt the pet.')
|
||||
|
||||
return { ok: false }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw away the previewed pet (`pet.remove`) and return to the draft picker so
|
||||
* the user can choose another base or regenerate. Best-effort on the delete.
|
||||
*/
|
||||
export async function discardHatched(request: GatewayRequest): Promise<void> {
|
||||
const preview = $petGenPreview.get()
|
||||
|
||||
if (preview?.slug) {
|
||||
await request('pet.remove', { slug: preview.slug }).catch(() => {})
|
||||
}
|
||||
|
||||
$petGenPreview.set(null)
|
||||
$petGenError.set(null)
|
||||
$petGenStatus.set($petGenDrafts.get().length > 0 ? 'ready' : 'idle')
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user