mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
chore: uptick
This commit is contained in:
parent
420f68e4e2
commit
db884f4646
240 changed files with 25206 additions and 3155 deletions
|
|
@ -9,6 +9,12 @@ node_modules
|
|||
.venv
|
||||
**/.venv
|
||||
|
||||
# Built artifacts that are regenerated inside the image. Excluded so local
|
||||
# rebuilds on the developer's machine don't invalidate the npm-install layer
|
||||
# that now depends on the full ui-tui/packages/hermes-ink/ tree being present.
|
||||
ui-tui/dist/
|
||||
ui-tui/packages/hermes-ink/dist/
|
||||
|
||||
# CI/CD
|
||||
.github
|
||||
|
||||
|
|
|
|||
10
.github/workflows/deploy-site.yml
vendored
10
.github/workflows/deploy-site.yml
vendored
|
|
@ -76,6 +76,16 @@ jobs:
|
|||
run: |
|
||||
mkdir -p _site/docs
|
||||
cp -r website/build/* _site/docs/
|
||||
# llms.txt / llms-full.txt are also published at the site root
|
||||
# (https://hermes-agent.nousresearch.com/llms.txt) because some
|
||||
# agents and IDE plugins probe the classic root-level path rather
|
||||
# than /docs/llms.txt. Same file, two URLs, one source of truth.
|
||||
if [ -f website/build/llms.txt ]; then
|
||||
cp website/build/llms.txt _site/llms.txt
|
||||
fi
|
||||
if [ -f website/build/llms-full.txt ]; then
|
||||
cp website/build/llms-full.txt _site/llms-full.txt
|
||||
fi
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3
|
||||
|
|
|
|||
26
Dockerfile
26
Dockerfile
|
|
@ -28,10 +28,26 @@ WORKDIR /opt/hermes
|
|||
# ---------- Layer-cached dependency install ----------
|
||||
# Copy only package manifests first so npm install + Playwright are cached
|
||||
# unless the lockfiles themselves change.
|
||||
#
|
||||
# ui-tui/packages/hermes-ink/ is copied IN FULL (not just its manifests)
|
||||
# because it is referenced as a `file:` workspace dependency from
|
||||
# ui-tui/package.json. Copying the tree up front lets npm resolve the
|
||||
# workspace to real content instead of stopping at a bare package.json.
|
||||
COPY package.json package-lock.json ./
|
||||
COPY web/package.json web/package-lock.json web/
|
||||
COPY ui-tui/package.json ui-tui/package-lock.json ui-tui/
|
||||
COPY ui-tui/packages/hermes-ink/package.json ui-tui/packages/hermes-ink/package-lock.json ui-tui/packages/hermes-ink/
|
||||
COPY ui-tui/packages/hermes-ink/ ui-tui/packages/hermes-ink/
|
||||
|
||||
# `npm_config_install_links=false` forces npm to install `file:` deps as
|
||||
# symlinks (the npm 10+ default) even on Debian's older bundled npm 9.x,
|
||||
# which defaults to `install-links=true` and installs file deps as *copies*.
|
||||
# The host-side package-lock.json is generated with a newer npm that uses
|
||||
# symlinks, so an install-as-copy produces a hidden node_modules/.package-lock.json
|
||||
# that permanently disagrees with the root lock on the @hermes/ink entry.
|
||||
# That disagreement trips the TUI launcher's `_tui_need_npm_install()`
|
||||
# check on every startup and triggers a runtime `npm install` that then
|
||||
# fails with EACCES (node_modules/ is root-owned from build time).
|
||||
ENV npm_config_install_links=false
|
||||
|
||||
RUN npm install --prefer-offline --no-audit && \
|
||||
npx playwright install --with-deps chromium --only-shell && \
|
||||
|
|
@ -45,13 +61,7 @@ COPY --chown=hermes:hermes . .
|
|||
|
||||
# Build browser dashboard and terminal UI assets.
|
||||
RUN cd web && npm run build && \
|
||||
cd ../ui-tui && npm run build && \
|
||||
rm -rf node_modules/@hermes/ink && \
|
||||
rm -rf packages/hermes-ink/node_modules && \
|
||||
cp -R packages/hermes-ink node_modules/@hermes/ink && \
|
||||
npm install --omit=dev --prefer-offline --no-audit --prefix node_modules/@hermes/ink && \
|
||||
rm -rf node_modules/@hermes/ink/node_modules/react && \
|
||||
node --input-type=module -e "await import('@hermes/ink')"
|
||||
cd ../ui-tui && npm run build
|
||||
|
||||
# ---------- Permissions ----------
|
||||
# Make install dir world-readable so any HERMES_UID can read it at runtime.
|
||||
|
|
|
|||
|
|
@ -164,6 +164,8 @@ class HermesACPAgent(acp.Agent):
|
|||
"context": "Show conversation context info",
|
||||
"reset": "Clear conversation history",
|
||||
"compact": "Compress conversation context",
|
||||
"steer": "Inject guidance into the currently running agent turn",
|
||||
"queue": "Queue a prompt to run after the current turn finishes",
|
||||
"version": "Show Hermes version",
|
||||
}
|
||||
|
||||
|
|
@ -193,6 +195,16 @@ class HermesACPAgent(acp.Agent):
|
|||
"name": "compact",
|
||||
"description": "Compress conversation context",
|
||||
},
|
||||
{
|
||||
"name": "steer",
|
||||
"description": "Inject guidance into the currently running agent turn",
|
||||
"input_hint": "guidance for the active turn",
|
||||
},
|
||||
{
|
||||
"name": "queue",
|
||||
"description": "Queue a prompt to run after the current turn finishes",
|
||||
"input_hint": "prompt to run next",
|
||||
},
|
||||
{
|
||||
"name": "version",
|
||||
"description": "Show Hermes version",
|
||||
|
|
@ -557,6 +569,9 @@ class HermesACPAgent(acp.Agent):
|
|||
async def cancel(self, session_id: str, **kwargs: Any) -> None:
|
||||
state = self.session_manager.get_session(session_id)
|
||||
if state and state.cancel_event:
|
||||
with state.runtime_lock:
|
||||
if state.is_running and state.current_prompt_text:
|
||||
state.interrupted_prompt_text = state.current_prompt_text
|
||||
state.cancel_event.set()
|
||||
try:
|
||||
if getattr(state, "agent", None) and hasattr(state.agent, "interrupt"):
|
||||
|
|
@ -654,6 +669,39 @@ class HermesACPAgent(acp.Agent):
|
|||
if not has_content:
|
||||
return PromptResponse(stop_reason="end_turn")
|
||||
|
||||
# /steer on an idle session has no in-flight tool call to inject into.
|
||||
# Rewrite it so the payload runs as a normal user prompt, matching the
|
||||
# gateway's behavior (gateway/run.py ~L4898). Two sub-cases:
|
||||
# 1. Zed-interrupt salvage — a prior prompt was cancelled by the
|
||||
# client right before /steer arrived; replay it with the steer
|
||||
# text attached as explicit correction/guidance so the user's
|
||||
# in-flight work isn't lost.
|
||||
# 2. Plain idle — no prior work to salvage; just run the steer
|
||||
# payload as a regular prompt. Without this, _cmd_steer would
|
||||
# silently append to state.queued_prompts and respond with
|
||||
# "No active turn — queued for the next turn", which looks like
|
||||
# /queue even though the user never typed /queue.
|
||||
if isinstance(user_content, str) and user_text.startswith("/steer"):
|
||||
steer_text = user_text.split(maxsplit=1)[1].strip() if len(user_text.split(maxsplit=1)) > 1 else ""
|
||||
interrupted_prompt = ""
|
||||
rewrite_idle = False
|
||||
with state.runtime_lock:
|
||||
if not state.is_running and steer_text:
|
||||
if state.interrupted_prompt_text:
|
||||
interrupted_prompt = state.interrupted_prompt_text
|
||||
state.interrupted_prompt_text = ""
|
||||
else:
|
||||
rewrite_idle = True
|
||||
if interrupted_prompt:
|
||||
user_text = (
|
||||
f"{interrupted_prompt}\n\n"
|
||||
f"User correction/guidance after interrupt: {steer_text}"
|
||||
)
|
||||
user_content = user_text
|
||||
elif rewrite_idle:
|
||||
user_text = steer_text
|
||||
user_content = steer_text
|
||||
|
||||
# Intercept slash commands — handle locally without calling the LLM.
|
||||
# Slash commands are text-only; if the client included images/resources,
|
||||
# send the whole multimodal prompt to the agent instead of treating it as
|
||||
|
|
@ -666,6 +714,24 @@ class HermesACPAgent(acp.Agent):
|
|||
await self._conn.session_update(session_id, update)
|
||||
return PromptResponse(stop_reason="end_turn")
|
||||
|
||||
# If Zed sends another regular prompt while the same ACP session is
|
||||
# still running, queue it instead of racing two AIAgent loops against
|
||||
# the same state.history. /steer and /queue are handled above and can
|
||||
# land immediately.
|
||||
with state.runtime_lock:
|
||||
if state.is_running:
|
||||
queued_text = user_text or "[Image attachment]"
|
||||
state.queued_prompts.append(queued_text)
|
||||
depth = len(state.queued_prompts)
|
||||
if self._conn:
|
||||
update = acp.update_agent_message_text(
|
||||
f"Queued for the next turn. ({depth} queued)"
|
||||
)
|
||||
await self._conn.session_update(session_id, update)
|
||||
return PromptResponse(stop_reason="end_turn")
|
||||
state.is_running = True
|
||||
state.current_prompt_text = user_text or "[Image attachment]"
|
||||
|
||||
logger.info("Prompt on session %s: %s", session_id, user_text[:100])
|
||||
|
||||
conn = self._conn
|
||||
|
|
@ -777,6 +843,9 @@ class HermesACPAgent(acp.Agent):
|
|||
result = await loop.run_in_executor(_executor, ctx.run, _run_agent)
|
||||
except Exception:
|
||||
logger.exception("Executor error for session %s", session_id)
|
||||
with state.runtime_lock:
|
||||
state.is_running = False
|
||||
state.current_prompt_text = ""
|
||||
return PromptResponse(stop_reason="end_turn")
|
||||
|
||||
if result.get("messages"):
|
||||
|
|
@ -802,6 +871,28 @@ class HermesACPAgent(acp.Agent):
|
|||
update = acp.update_agent_message_text(final_response)
|
||||
await conn.session_update(session_id, update)
|
||||
|
||||
# Mark this turn idle before draining queued work so recursive prompt()
|
||||
# calls can acquire the session. Queued turns are intentionally run as
|
||||
# normal follow-up user prompts, preserving role alternation and history.
|
||||
with state.runtime_lock:
|
||||
state.is_running = False
|
||||
state.current_prompt_text = ""
|
||||
|
||||
while True:
|
||||
with state.runtime_lock:
|
||||
if not state.queued_prompts:
|
||||
break
|
||||
next_prompt = state.queued_prompts.pop(0)
|
||||
if conn:
|
||||
await conn.session_update(
|
||||
session_id,
|
||||
acp.update_user_message_text(next_prompt),
|
||||
)
|
||||
await self.prompt(
|
||||
prompt=[TextContentBlock(type="text", text=next_prompt)],
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
usage = None
|
||||
if any(result.get(key) is not None for key in ("prompt_tokens", "completion_tokens", "total_tokens")):
|
||||
usage = Usage(
|
||||
|
|
@ -879,6 +970,8 @@ class HermesACPAgent(acp.Agent):
|
|||
"context": self._cmd_context,
|
||||
"reset": self._cmd_reset,
|
||||
"compact": self._cmd_compact,
|
||||
"steer": self._cmd_steer,
|
||||
"queue": self._cmd_queue,
|
||||
"version": self._cmd_version,
|
||||
}.get(cmd)
|
||||
|
||||
|
|
@ -975,10 +1068,16 @@ class HermesACPAgent(acp.Agent):
|
|||
if not hasattr(agent, "_compress_context"):
|
||||
return "Context compression not available for this agent."
|
||||
|
||||
from agent.model_metadata import estimate_messages_tokens_rough
|
||||
from agent.model_metadata import estimate_request_tokens_rough
|
||||
|
||||
original_count = len(state.history)
|
||||
approx_tokens = estimate_messages_tokens_rough(state.history)
|
||||
# Include system prompt + tool schemas so the figure reflects real
|
||||
# request pressure, not a transcript-only underestimate (#6217).
|
||||
_sys_prompt = getattr(agent, "_cached_system_prompt", "") or ""
|
||||
_tools = getattr(agent, "tools", None) or None
|
||||
approx_tokens = estimate_request_tokens_rough(
|
||||
state.history, system_prompt=_sys_prompt, tools=_tools
|
||||
)
|
||||
original_session_db = getattr(agent, "_session_db", None)
|
||||
|
||||
try:
|
||||
|
|
@ -998,7 +1097,13 @@ class HermesACPAgent(acp.Agent):
|
|||
self.session_manager.save_session(state.session_id)
|
||||
|
||||
new_count = len(state.history)
|
||||
new_tokens = estimate_messages_tokens_rough(state.history)
|
||||
_sys_prompt_after = getattr(agent, "_cached_system_prompt", "") or _sys_prompt
|
||||
_tools_after = getattr(agent, "tools", None) or _tools
|
||||
new_tokens = estimate_request_tokens_rough(
|
||||
state.history,
|
||||
system_prompt=_sys_prompt_after,
|
||||
tools=_tools_after,
|
||||
)
|
||||
return (
|
||||
f"Context compressed: {original_count} -> {new_count} messages\n"
|
||||
f"~{approx_tokens:,} -> ~{new_tokens:,} tokens"
|
||||
|
|
@ -1006,6 +1111,34 @@ class HermesACPAgent(acp.Agent):
|
|||
except Exception as e:
|
||||
return f"Compression failed: {e}"
|
||||
|
||||
def _cmd_steer(self, args: str, state: SessionState) -> str:
|
||||
steer_text = args.strip()
|
||||
if not steer_text:
|
||||
return "Usage: /steer <guidance>"
|
||||
|
||||
if state.is_running and hasattr(state.agent, "steer"):
|
||||
try:
|
||||
if state.agent.steer(steer_text):
|
||||
preview = steer_text[:80] + ("..." if len(steer_text) > 80 else "")
|
||||
return f"⏩ Steer queued for the active turn: {preview}"
|
||||
except Exception as exc:
|
||||
logger.warning("ACP steer failed for session %s: %s", state.session_id, exc)
|
||||
return f"⚠️ Steer failed: {exc}"
|
||||
|
||||
with state.runtime_lock:
|
||||
state.queued_prompts.append(steer_text)
|
||||
depth = len(state.queued_prompts)
|
||||
return f"No active turn — queued for the next turn. ({depth} queued)"
|
||||
|
||||
def _cmd_queue(self, args: str, state: SessionState) -> str:
|
||||
queued_text = args.strip()
|
||||
if not queued_text:
|
||||
return "Usage: /queue <prompt>"
|
||||
with state.runtime_lock:
|
||||
state.queued_prompts.append(queued_text)
|
||||
depth = len(state.queued_prompts)
|
||||
return f"Queued for the next turn. ({depth} queued)"
|
||||
|
||||
def _cmd_version(self, args: str, state: SessionState) -> str:
|
||||
return f"Hermes Agent v{HERMES_VERSION}"
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,33 @@ from typing import Any, Dict, List, Optional
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _win_path_to_wsl(path: str) -> str | None:
|
||||
"""Convert a Windows drive path to its WSL /mnt/<drive>/... equivalent."""
|
||||
match = re.match(r"^([A-Za-z]):[\\/](.*)$", path)
|
||||
if not match:
|
||||
return None
|
||||
drive = match.group(1).lower()
|
||||
tail = match.group(2).replace("\\", "/")
|
||||
return f"/mnt/{drive}/{tail}"
|
||||
|
||||
|
||||
def _translate_acp_cwd(cwd: str) -> str:
|
||||
"""Translate Windows ACP cwd values when Hermes itself is running in WSL.
|
||||
|
||||
Windows ACP clients can launch ``hermes acp`` inside WSL while still sending
|
||||
editor workspaces as Windows drive paths such as ``E:\\Projects``. Store
|
||||
and execute against the WSL mount path so agents, tools, and persisted ACP
|
||||
sessions all agree on the usable workspace. Native Linux/macOS keeps the
|
||||
original cwd unchanged.
|
||||
"""
|
||||
from hermes_constants import is_wsl
|
||||
|
||||
if not is_wsl():
|
||||
return cwd
|
||||
translated = _win_path_to_wsl(str(cwd))
|
||||
return translated if translated is not None else cwd
|
||||
|
||||
|
||||
def _normalize_cwd_for_compare(cwd: str | None) -> str:
|
||||
raw = str(cwd or ".").strip()
|
||||
if not raw:
|
||||
|
|
@ -34,11 +61,9 @@ def _normalize_cwd_for_compare(cwd: str | None) -> str:
|
|||
|
||||
# Normalize Windows drive paths into the equivalent WSL mount form so
|
||||
# ACP history filters match the same workspace across Windows and WSL.
|
||||
match = re.match(r"^([A-Za-z]):[\\/](.*)$", expanded)
|
||||
if match:
|
||||
drive = match.group(1).lower()
|
||||
tail = match.group(2).replace("\\", "/")
|
||||
expanded = f"/mnt/{drive}/{tail}"
|
||||
translated = _win_path_to_wsl(expanded)
|
||||
if translated is not None:
|
||||
expanded = translated
|
||||
elif re.match(r"^/mnt/[A-Za-z]/", expanded):
|
||||
expanded = f"/mnt/{expanded[5].lower()}/{expanded[7:]}"
|
||||
|
||||
|
|
@ -96,12 +121,18 @@ def _acp_stderr_print(*args, **kwargs) -> None:
|
|||
|
||||
|
||||
def _register_task_cwd(task_id: str, cwd: str) -> None:
|
||||
"""Bind a task/session id to the editor's working directory for tools."""
|
||||
"""Bind a task/session id to the editor's working directory for tools.
|
||||
|
||||
Zed can launch Hermes from a Windows workspace while the ACP process runs
|
||||
inside WSL. In that case ACP sends cwd as e.g. ``E:\\Projects\\POTI``;
|
||||
local tools need the WSL mount equivalent or subprocess creation fails
|
||||
before the command can run.
|
||||
"""
|
||||
if not task_id:
|
||||
return
|
||||
try:
|
||||
from tools.terminal_tool import register_task_env_overrides
|
||||
register_task_env_overrides(task_id, {"cwd": cwd})
|
||||
register_task_env_overrides(task_id, {"cwd": _translate_acp_cwd(cwd)})
|
||||
except Exception:
|
||||
logger.debug("Failed to register ACP task cwd override", exc_info=True)
|
||||
|
||||
|
|
@ -145,6 +176,11 @@ class SessionState:
|
|||
model: str = ""
|
||||
history: List[Dict[str, Any]] = field(default_factory=list)
|
||||
cancel_event: Any = None # threading.Event
|
||||
is_running: bool = False
|
||||
queued_prompts: List[str] = field(default_factory=list)
|
||||
runtime_lock: Any = field(default_factory=Lock)
|
||||
current_prompt_text: str = ""
|
||||
interrupted_prompt_text: str = ""
|
||||
|
||||
|
||||
class SessionManager:
|
||||
|
|
@ -175,6 +211,7 @@ class SessionManager:
|
|||
"""Create a new session with a unique ID and a fresh AIAgent."""
|
||||
import threading
|
||||
|
||||
cwd = _translate_acp_cwd(cwd)
|
||||
session_id = str(uuid.uuid4())
|
||||
agent = self._make_agent(session_id=session_id, cwd=cwd)
|
||||
state = SessionState(
|
||||
|
|
@ -217,6 +254,7 @@ class SessionManager:
|
|||
"""Deep-copy a session's history into a new session."""
|
||||
import threading
|
||||
|
||||
cwd = _translate_acp_cwd(cwd)
|
||||
original = self.get_session(session_id) # checks DB too
|
||||
if original is None:
|
||||
return None
|
||||
|
|
@ -318,6 +356,7 @@ class SessionManager:
|
|||
|
||||
def update_cwd(self, session_id: str, cwd: str) -> Optional[SessionState]:
|
||||
"""Update the working directory for a session and its tool overrides."""
|
||||
cwd = _translate_acp_cwd(cwd)
|
||||
state = self.get_session(session_id) # checks DB too
|
||||
if state is None:
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -1977,6 +1977,12 @@ def resolve_provider_client(
|
|||
(client, resolved_model) or (None, None) if auth is unavailable.
|
||||
"""
|
||||
_validate_proxy_env_urls()
|
||||
# Preserve the original provider name before alias normalization so a
|
||||
# user-declared ``custom_providers`` entry whose name coincidentally
|
||||
# matches a built-in alias (e.g. user names their custom provider "kimi"
|
||||
# which aliases to "kimi-coding") is still reachable via the named-custom
|
||||
# branch below.
|
||||
original_provider = (provider or "").strip().lower()
|
||||
# Normalise aliases
|
||||
provider = _normalize_aux_provider(provider)
|
||||
|
||||
|
|
@ -2163,7 +2169,18 @@ def resolve_provider_client(
|
|||
# ── Named custom providers (config.yaml providers dict / custom_providers list) ───
|
||||
try:
|
||||
from hermes_cli.runtime_provider import _get_named_custom_provider
|
||||
custom_entry = _get_named_custom_provider(provider)
|
||||
# When the raw requested name is an alias (``kimi`` → ``kimi-coding``)
|
||||
# and the user defined a ``custom_providers`` entry under that alias
|
||||
# name, the custom entry is the intended target — the built-in alias
|
||||
# rewriting would otherwise hijack the request. Only preferred when
|
||||
# the raw name is an alias (not a canonical provider name) so custom
|
||||
# entries that coincidentally match a canonical provider (e.g. ``nous``)
|
||||
# still defer to the built-in per `_get_named_custom_provider`'s guard.
|
||||
custom_entry = None
|
||||
if original_provider and original_provider != provider:
|
||||
custom_entry = _get_named_custom_provider(original_provider)
|
||||
if custom_entry is None:
|
||||
custom_entry = _get_named_custom_provider(provider)
|
||||
if custom_entry:
|
||||
custom_base = custom_entry.get("base_url", "").strip()
|
||||
custom_key = custom_entry.get("api_key", "").strip()
|
||||
|
|
@ -2273,6 +2290,12 @@ def resolve_provider_client(
|
|||
|
||||
creds = resolve_api_key_provider_credentials(provider)
|
||||
api_key = str(creds.get("api_key", "")).strip()
|
||||
# Honour an explicit api_key override (e.g. from a fallback_model entry
|
||||
# or a custom_providers entry) so callers that pass an explicit
|
||||
# credential can authenticate against endpoints where no built-in
|
||||
# credential is registered for this provider alias.
|
||||
if explicit_api_key:
|
||||
api_key = explicit_api_key.strip() or api_key
|
||||
if not api_key:
|
||||
tried_sources = list(pconfig.api_key_env_vars)
|
||||
if provider == "copilot":
|
||||
|
|
@ -2284,6 +2307,11 @@ def resolve_provider_client(
|
|||
|
||||
raw_base_url = str(creds.get("base_url", "")).strip().rstrip("/") or pconfig.inference_base_url
|
||||
base_url = _to_openai_base_url(raw_base_url)
|
||||
# Honour an explicit base_url override from the caller — used when a
|
||||
# fallback_model entry (or custom_providers lookup) routes through a
|
||||
# built-in provider name but targets a user-specified endpoint.
|
||||
if explicit_base_url:
|
||||
base_url = _to_openai_base_url(explicit_base_url.strip().rstrip("/"))
|
||||
|
||||
default_model = _API_KEY_PROVIDER_AUX_MODELS.get(provider, "")
|
||||
final_model = _normalize_resolved_model(model or default_model, provider)
|
||||
|
|
|
|||
|
|
@ -538,7 +538,7 @@ class ContextCompressor(ContextEngine):
|
|||
# Token-budget approach: walk backward accumulating tokens
|
||||
accumulated = 0
|
||||
boundary = len(result)
|
||||
min_protect = min(protect_tail_count, len(result) - 1)
|
||||
min_protect = min(protect_tail_count, len(result))
|
||||
for i in range(len(result) - 1, -1, -1):
|
||||
msg = result[i]
|
||||
raw_content = msg.get("content") or ""
|
||||
|
|
@ -992,8 +992,8 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
|||
def _get_tool_call_id(tc) -> str:
|
||||
"""Extract the call ID from a tool_call entry (dict or SimpleNamespace)."""
|
||||
if isinstance(tc, dict):
|
||||
return tc.get("id", "")
|
||||
return getattr(tc, "id", "") or ""
|
||||
return tc.get("call_id", "") or tc.get("id", "") or ""
|
||||
return getattr(tc, "call_id", "") or getattr(tc, "id", "") or ""
|
||||
|
||||
def _sanitize_tool_pairs(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Fix orphaned tool_call / tool_result pairs after compression.
|
||||
|
|
|
|||
199
agent/curator.py
199
agent/curator.py
|
|
@ -55,6 +55,7 @@ def _default_state() -> Dict[str, Any]:
|
|||
"last_run_at": None,
|
||||
"last_run_duration_seconds": None,
|
||||
"last_run_summary": None,
|
||||
"last_report_path": None,
|
||||
"paused": False,
|
||||
"run_count": 0,
|
||||
}
|
||||
|
|
@ -183,7 +184,16 @@ def should_run_now(now: Optional[datetime] = None) -> bool:
|
|||
Gates:
|
||||
- curator.enabled == True
|
||||
- not paused
|
||||
- last_run_at missing, OR older than interval_hours
|
||||
- last_run_at present AND older than interval_hours
|
||||
|
||||
First-run behavior: when there is no ``last_run_at`` (fresh install, or
|
||||
install that predates the curator), we DO NOT run immediately. The
|
||||
curator is designed to run after at least ``interval_hours`` (7 days by
|
||||
default) of skill activity, not on the first background tick after
|
||||
``hermes update``. On first observation we seed ``last_run_at`` to "now"
|
||||
and defer the first real pass by one full interval. Users who want to
|
||||
run it sooner can always invoke ``hermes curator run`` (with or without
|
||||
``--dry-run``) explicitly — that path bypasses this gate.
|
||||
|
||||
The idle check (min_idle_hours) is applied at the call site where we know
|
||||
whether an agent is actively running — here we only enforce the static
|
||||
|
|
@ -197,7 +207,21 @@ def should_run_now(now: Optional[datetime] = None) -> bool:
|
|||
state = load_state()
|
||||
last = _parse_iso(state.get("last_run_at"))
|
||||
if last is None:
|
||||
return True
|
||||
# Never run before. Seed state so we wait a full interval before the
|
||||
# first real pass. Report-only; do not auto-mutate the library the
|
||||
# very first time a gateway ticks after an update.
|
||||
if now is None:
|
||||
now = datetime.now(timezone.utc)
|
||||
try:
|
||||
state["last_run_at"] = now.isoformat()
|
||||
state["last_run_summary"] = (
|
||||
"deferred first run — curator seeded, will run after one "
|
||||
"interval; use `hermes curator run --dry-run` to preview now"
|
||||
)
|
||||
save_state(state)
|
||||
except Exception as e: # pragma: no cover — best-effort persistence
|
||||
logger.debug("Failed to seed curator last_run_at: %s", e)
|
||||
return False
|
||||
|
||||
if now is None:
|
||||
now = datetime.now(timezone.utc)
|
||||
|
|
@ -258,6 +282,33 @@ def apply_automatic_transitions(now: Optional[datetime] = None) -> Dict[str, int
|
|||
# Review prompt for the forked agent
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CURATOR_DRY_RUN_BANNER = (
|
||||
"═══════════════════════════════════════════════════════════════\n"
|
||||
"DRY-RUN — REPORT ONLY. DO NOT MUTATE THE SKILL LIBRARY.\n"
|
||||
"═══════════════════════════════════════════════════════════════\n"
|
||||
"\n"
|
||||
"This is a PREVIEW pass. Follow every instruction below EXCEPT:\n"
|
||||
"\n"
|
||||
" • DO NOT call skill_manage with action=patch, create, delete, "
|
||||
"write_file, or remove_file.\n"
|
||||
" • DO NOT call terminal to mv skill directories into .archive/.\n"
|
||||
" • DO NOT call terminal to mv, cp, rm, or rewrite any file under "
|
||||
"~/.hermes/skills/.\n"
|
||||
" • skills_list and skill_view are FINE — read as much as you need.\n"
|
||||
"\n"
|
||||
"Your output IS the deliverable. Produce the exact same "
|
||||
"human-readable summary and structured YAML block you would "
|
||||
"produce on a live run — but describe the actions you WOULD take, "
|
||||
"not actions you took. A downstream reviewer will read the report "
|
||||
"and decide whether to approve a live run with "
|
||||
"`hermes curator run` (no flag).\n"
|
||||
"\n"
|
||||
"If you accidentally take a mutating action, say so explicitly in "
|
||||
"the summary so the reviewer can revert it.\n"
|
||||
"═══════════════════════════════════════════════════════════════"
|
||||
)
|
||||
|
||||
|
||||
CURATOR_REVIEW_PROMPT = (
|
||||
"You are running as Hermes' background skill CURATOR. This is an "
|
||||
"UMBRELLA-BUILDING consolidation pass, not a passive audit and not a "
|
||||
|
|
@ -766,6 +817,39 @@ def _write_run_report(
|
|||
consolidated = classification["consolidated"]
|
||||
pruned = classification["pruned"]
|
||||
|
||||
# Rewrite cron job skill references. When the curator consolidates
|
||||
# skill X into umbrella Y, any cron job that lists X fails to load
|
||||
# it at run time — the scheduler skips it and the job runs without
|
||||
# the instructions it was scheduled to follow. Rewriting the
|
||||
# references in-place keeps scheduled jobs working across
|
||||
# consolidation passes. Best-effort: never let a cron-module issue
|
||||
# break the curator.
|
||||
cron_rewrites: Dict[str, Any] = {"rewrites": [], "jobs_updated": 0, "jobs_scanned": 0}
|
||||
try:
|
||||
consolidated_map = {
|
||||
e["name"]: e["into"]
|
||||
for e in consolidated
|
||||
if isinstance(e, dict) and e.get("name") and e.get("into")
|
||||
}
|
||||
pruned_names = [
|
||||
e["name"] for e in pruned
|
||||
if isinstance(e, dict) and e.get("name")
|
||||
]
|
||||
if consolidated_map or pruned_names:
|
||||
from cron.jobs import rewrite_skill_refs as _rewrite_cron_refs
|
||||
cron_rewrites = _rewrite_cron_refs(
|
||||
consolidated=consolidated_map,
|
||||
pruned=pruned_names,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Curator cron skill rewrite failed: %s", e, exc_info=True)
|
||||
cron_rewrites = {
|
||||
"rewrites": [],
|
||||
"jobs_updated": 0,
|
||||
"jobs_scanned": 0,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
payload = {
|
||||
"started_at": started_at.isoformat(),
|
||||
"duration_seconds": round(elapsed_seconds, 2),
|
||||
|
|
@ -781,6 +865,7 @@ def _write_run_report(
|
|||
"consolidated_this_run": len(consolidated),
|
||||
"pruned_this_run": len(pruned),
|
||||
"state_transitions": len(transitions),
|
||||
"cron_jobs_rewritten": int(cron_rewrites.get("jobs_updated", 0)),
|
||||
"tool_calls_total": sum(tc_counts.values()),
|
||||
},
|
||||
"tool_call_counts": tc_counts,
|
||||
|
|
@ -790,6 +875,7 @@ def _write_run_report(
|
|||
"pruned_names": [p["name"] for p in pruned],
|
||||
"added": added,
|
||||
"state_transitions": transitions,
|
||||
"cron_rewrites": cron_rewrites,
|
||||
"llm_final": llm_meta.get("final", ""),
|
||||
"llm_summary": llm_meta.get("summary", ""),
|
||||
"llm_error": llm_meta.get("error"),
|
||||
|
|
@ -812,6 +898,17 @@ def _write_run_report(
|
|||
except Exception as e:
|
||||
logger.debug("Curator REPORT.md write failed: %s", e)
|
||||
|
||||
# cron_rewrites.json — only when at least one job was touched, to
|
||||
# keep run dirs uncluttered for the common no-op case.
|
||||
try:
|
||||
if int(cron_rewrites.get("jobs_updated", 0)) > 0:
|
||||
(run_dir / "cron_rewrites.json").write_text(
|
||||
json.dumps(cron_rewrites, indent=2, ensure_ascii=False) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Curator cron_rewrites.json write failed: %s", e)
|
||||
|
||||
return run_dir
|
||||
|
||||
|
||||
|
|
@ -942,6 +1039,39 @@ def _render_report_markdown(p: Dict[str, Any]) -> str:
|
|||
lines.append(f"- `{t.get('name')}`: {t.get('from')} → {t.get('to')}")
|
||||
lines.append("")
|
||||
|
||||
# Cron job rewrites — show which scheduled jobs had their skill
|
||||
# references updated so users can audit that the auto-rewrite did
|
||||
# the right thing. Only present when at least one job changed.
|
||||
cron_rw = p.get("cron_rewrites") or {}
|
||||
cron_rewrites_list = cron_rw.get("rewrites") or []
|
||||
if cron_rewrites_list:
|
||||
lines.append(f"### Cron job skill references rewritten ({len(cron_rewrites_list)})\n")
|
||||
lines.append(
|
||||
"_Cron jobs that referenced a consolidated or pruned skill were "
|
||||
"updated in-place so they keep loading the right instructions "
|
||||
"on their next run. See `cron_rewrites.json` for the full record._\n"
|
||||
)
|
||||
SHOW = 25
|
||||
for entry in cron_rewrites_list[:SHOW]:
|
||||
job_name = entry.get("job_name") or entry.get("job_id") or "?"
|
||||
before = entry.get("before") or []
|
||||
after = entry.get("after") or []
|
||||
mapped = entry.get("mapped") or {}
|
||||
dropped = entry.get("dropped") or []
|
||||
lines.append(
|
||||
f"- `{job_name}`: `{', '.join(before)}` → `{', '.join(after) or '(none)'}`"
|
||||
)
|
||||
for old, new in mapped.items():
|
||||
lines.append(f" - `{old}` → `{new}` (consolidated)")
|
||||
for name in dropped:
|
||||
lines.append(f" - `{name}` dropped (pruned)")
|
||||
if len(cron_rewrites_list) > SHOW:
|
||||
lines.append(
|
||||
f"- … and {len(cron_rewrites_list) - SHOW} more "
|
||||
"(see `cron_rewrites.json`)"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
# Full LLM final response
|
||||
final = (p.get("llm_final") or "").strip()
|
||||
if final:
|
||||
|
|
@ -992,6 +1122,7 @@ def _render_candidate_list() -> str:
|
|||
def run_curator_review(
|
||||
on_summary: Optional[Callable[[str], None]] = None,
|
||||
synchronous: bool = False,
|
||||
dry_run: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Execute a single curator review pass.
|
||||
|
||||
|
|
@ -1004,9 +1135,43 @@ def run_curator_review(
|
|||
|
||||
If *synchronous* is True, the LLM review runs in the calling thread; the
|
||||
default is to spawn a daemon thread so the caller returns immediately.
|
||||
|
||||
If *dry_run* is True, the automatic stale/archive transitions are SKIPPED
|
||||
and the LLM review pass is instructed to produce a report only — no
|
||||
skill_manage mutations, no terminal archive moves. The REPORT.md still
|
||||
gets written and ``state.last_report_path`` still records it so users
|
||||
can read what the curator WOULD have done.
|
||||
"""
|
||||
start = datetime.now(timezone.utc)
|
||||
counts = apply_automatic_transitions(now=start)
|
||||
if dry_run:
|
||||
# Count candidates without mutating state.
|
||||
try:
|
||||
report = skill_usage.agent_created_report()
|
||||
counts = {
|
||||
"checked": len(report),
|
||||
"marked_stale": 0,
|
||||
"archived": 0,
|
||||
"reactivated": 0,
|
||||
}
|
||||
except Exception:
|
||||
counts = {"checked": 0, "marked_stale": 0, "archived": 0, "reactivated": 0}
|
||||
else:
|
||||
# Pre-mutation snapshot — best-effort, never blocks the run. A
|
||||
# failed snapshot logs at debug and continues (the alternative is
|
||||
# that a transient disk issue silently disables curator forever,
|
||||
# which is worse). Users who want to require snapshots can disable
|
||||
# curator entirely until they can fix disk space.
|
||||
try:
|
||||
from agent import curator_backup
|
||||
snap = curator_backup.snapshot_skills(reason="pre-curator-run")
|
||||
if snap is not None and on_summary:
|
||||
try:
|
||||
on_summary(f"curator: snapshot created ({snap.name})")
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("Curator pre-run snapshot failed: %s", e, exc_info=True)
|
||||
counts = apply_automatic_transitions(now=start)
|
||||
|
||||
auto_summary_parts = []
|
||||
if counts["marked_stale"]:
|
||||
|
|
@ -1018,11 +1183,16 @@ def run_curator_review(
|
|||
auto_summary = ", ".join(auto_summary_parts) if auto_summary_parts else "no changes"
|
||||
|
||||
# Persist state before the LLM pass so a crash mid-review still records
|
||||
# the run and doesn't immediately re-trigger.
|
||||
# the run and doesn't immediately re-trigger. In dry-run we do NOT bump
|
||||
# last_run_at or run_count — a preview shouldn't push the next scheduled
|
||||
# real pass out. We still record a summary so `hermes curator status`
|
||||
# shows that a preview ran.
|
||||
state = load_state()
|
||||
state["last_run_at"] = start.isoformat()
|
||||
state["run_count"] = int(state.get("run_count", 0)) + 1
|
||||
state["last_run_summary"] = f"auto: {auto_summary}"
|
||||
if not dry_run:
|
||||
state["last_run_at"] = start.isoformat()
|
||||
state["run_count"] = int(state.get("run_count", 0)) + 1
|
||||
prefix = "dry-run auto: " if dry_run else "auto: "
|
||||
state["last_run_summary"] = f"{prefix}{auto_summary}"
|
||||
save_state(state)
|
||||
|
||||
def _llm_pass():
|
||||
|
|
@ -1038,7 +1208,7 @@ def run_curator_review(
|
|||
try:
|
||||
candidate_list = _render_candidate_list()
|
||||
if "No agent-created skills" in candidate_list:
|
||||
final_summary = f"auto: {auto_summary}; llm: skipped (no candidates)"
|
||||
final_summary = f"{prefix}{auto_summary}; llm: skipped (no candidates)"
|
||||
llm_meta = {
|
||||
"final": "",
|
||||
"summary": "skipped (no candidates)",
|
||||
|
|
@ -1048,14 +1218,21 @@ def run_curator_review(
|
|||
"error": None,
|
||||
}
|
||||
else:
|
||||
prompt = f"{CURATOR_REVIEW_PROMPT}\n\n{candidate_list}"
|
||||
if dry_run:
|
||||
prompt = (
|
||||
f"{CURATOR_DRY_RUN_BANNER}\n\n"
|
||||
f"{CURATOR_REVIEW_PROMPT}\n\n"
|
||||
f"{candidate_list}"
|
||||
)
|
||||
else:
|
||||
prompt = f"{CURATOR_REVIEW_PROMPT}\n\n{candidate_list}"
|
||||
llm_meta = _run_llm_review(prompt)
|
||||
final_summary = (
|
||||
f"auto: {auto_summary}; llm: {llm_meta.get('summary', 'no change')}"
|
||||
f"{prefix}{auto_summary}; llm: {llm_meta.get('summary', 'no change')}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Curator LLM pass failed: %s", e, exc_info=True)
|
||||
final_summary = f"auto: {auto_summary}; llm: error ({e})"
|
||||
final_summary = f"{prefix}{auto_summary}; llm: error ({e})"
|
||||
llm_meta = {
|
||||
"final": "",
|
||||
"summary": f"error ({e})",
|
||||
|
|
|
|||
440
agent/curator_backup.py
Normal file
440
agent/curator_backup.py
Normal file
|
|
@ -0,0 +1,440 @@
|
|||
"""Curator snapshot + rollback.
|
||||
|
||||
A pre-run snapshot of ``~/.hermes/skills/`` (excluding ``.curator_backups/``
|
||||
itself) is taken before any mutating curator pass. Snapshots are tar.gz
|
||||
files under ``~/.hermes/skills/.curator_backups/<utc-iso>/`` with a
|
||||
companion ``manifest.json`` describing the snapshot (reason, time, size,
|
||||
counted skill files). Rollback picks a snapshot, moves the current
|
||||
``skills/`` tree aside into another snapshot so even the rollback itself
|
||||
is undoable, then extracts the chosen snapshot into place.
|
||||
|
||||
The snapshot does NOT include:
|
||||
- ``.curator_backups/`` (would recurse)
|
||||
- ``.hub/`` (hub-installed skills — managed by the hub, not us)
|
||||
|
||||
It DOES include:
|
||||
- all SKILL.md files + their directories (``scripts/``, ``references/``,
|
||||
``templates/``, ``assets/``)
|
||||
- ``.usage.json`` (usage telemetry — needed to rehydrate state cleanly)
|
||||
- ``.archive/`` (so rollback restores previously-archived skills too)
|
||||
- ``.curator_state`` (so rolling back also restores the last-run-at
|
||||
pointer — otherwise the curator would immediately re-fire on the next
|
||||
tick)
|
||||
- ``.bundled_manifest`` (so protection markers stay consistent)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tarfile
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
DEFAULT_KEEP = 5
|
||||
|
||||
# Entries under skills/ that should NEVER be rolled up into a snapshot.
|
||||
# .hub/ is managed by the skills hub; rolling it back would break lockfile
|
||||
# invariants. .curator_backups is the backup dir itself — recursion bomb.
|
||||
_EXCLUDE_TOP_LEVEL = {".curator_backups", ".hub"}
|
||||
|
||||
# Snapshot id regex: UTC ISO with colons replaced by dashes so the filename
|
||||
# is portable (Windows-safe). An optional ``-NN`` suffix handles two
|
||||
# snapshots landing in the same wallclock second.
|
||||
_ID_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z(-\d{2})?$")
|
||||
|
||||
|
||||
def _backups_dir() -> Path:
|
||||
return get_hermes_home() / "skills" / ".curator_backups"
|
||||
|
||||
|
||||
def _skills_dir() -> Path:
|
||||
return get_hermes_home() / "skills"
|
||||
|
||||
|
||||
def _utc_id(now: Optional[datetime] = None) -> str:
|
||||
"""UTC ISO-ish filesystem-safe timestamp: ``2026-05-01T13-05-42Z``."""
|
||||
if now is None:
|
||||
now = datetime.now(timezone.utc)
|
||||
# isoformat → "2026-05-01T13:05:42.123456+00:00"; strip subseconds and tz.
|
||||
s = now.replace(microsecond=0).isoformat()
|
||||
if s.endswith("+00:00"):
|
||||
s = s[:-6]
|
||||
return s.replace(":", "-") + "Z"
|
||||
|
||||
|
||||
def _load_config() -> Dict[str, Any]:
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
cfg = load_config()
|
||||
except Exception as e:
|
||||
logger.debug("Failed to load config for curator backup: %s", e)
|
||||
return {}
|
||||
if not isinstance(cfg, dict):
|
||||
return {}
|
||||
cur = cfg.get("curator") or {}
|
||||
if not isinstance(cur, dict):
|
||||
return {}
|
||||
bk = cur.get("backup") or {}
|
||||
return bk if isinstance(bk, dict) else {}
|
||||
|
||||
|
||||
def is_enabled() -> bool:
|
||||
"""Default ON — the whole point of the backup is safety by default."""
|
||||
return bool(_load_config().get("enabled", True))
|
||||
|
||||
|
||||
def get_keep() -> int:
|
||||
cfg = _load_config()
|
||||
try:
|
||||
n = int(cfg.get("keep", DEFAULT_KEEP))
|
||||
except (TypeError, ValueError):
|
||||
n = DEFAULT_KEEP
|
||||
return max(1, n)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Snapshot
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _count_skill_files(base: Path) -> int:
|
||||
try:
|
||||
return sum(1 for _ in base.rglob("SKILL.md"))
|
||||
except OSError:
|
||||
return 0
|
||||
|
||||
|
||||
def _write_manifest(dest: Path, reason: str, archive_path: Path,
|
||||
skills_counted: int) -> None:
|
||||
manifest = {
|
||||
"id": dest.name,
|
||||
"reason": reason,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"archive": archive_path.name,
|
||||
"archive_bytes": archive_path.stat().st_size,
|
||||
"skill_files": skills_counted,
|
||||
}
|
||||
(dest / "manifest.json").write_text(
|
||||
json.dumps(manifest, indent=2, sort_keys=True), encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def snapshot_skills(reason: str = "manual") -> Optional[Path]:
|
||||
"""Create a tar.gz snapshot of ``~/.hermes/skills/`` and prune old ones.
|
||||
|
||||
Returns the snapshot directory path, or ``None`` if the snapshot was
|
||||
skipped (backup disabled, skills dir missing, or an IO error occurred —
|
||||
in which case we log at debug and return None so the curator never
|
||||
aborts a pass because of a backup failure).
|
||||
"""
|
||||
if not is_enabled():
|
||||
logger.debug("Curator backup disabled by config; skipping snapshot")
|
||||
return None
|
||||
|
||||
skills = _skills_dir()
|
||||
if not skills.exists():
|
||||
logger.debug("No ~/.hermes/skills/ directory — nothing to back up")
|
||||
return None
|
||||
|
||||
backups = _backups_dir()
|
||||
try:
|
||||
backups.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as e:
|
||||
logger.debug("Failed to create backups dir %s: %s", backups, e)
|
||||
return None
|
||||
|
||||
# Uniquify: if a snapshot with the same second already exists (can
|
||||
# happen if two curator runs fire in the same second), append a short
|
||||
# counter. Avoids clobbering and avoids timestamp collisions.
|
||||
base_id = _utc_id()
|
||||
snap_id = base_id
|
||||
counter = 1
|
||||
while (backups / snap_id).exists():
|
||||
snap_id = f"{base_id}-{counter:02d}"
|
||||
counter += 1
|
||||
|
||||
dest = backups / snap_id
|
||||
try:
|
||||
dest.mkdir(parents=True, exist_ok=False)
|
||||
except OSError as e:
|
||||
logger.debug("Failed to create snapshot dir %s: %s", dest, e)
|
||||
return None
|
||||
|
||||
archive = dest / "skills.tar.gz"
|
||||
try:
|
||||
# Stream into the tarball — no tempdir copy needed.
|
||||
with tarfile.open(archive, "w:gz", compresslevel=6) as tf:
|
||||
for entry in sorted(skills.iterdir()):
|
||||
if entry.name in _EXCLUDE_TOP_LEVEL:
|
||||
continue
|
||||
# arcname: store paths relative to skills/ so extraction
|
||||
# drops cleanly back into the skills dir.
|
||||
tf.add(str(entry), arcname=entry.name, recursive=True)
|
||||
_write_manifest(dest, reason, archive, _count_skill_files(skills))
|
||||
except (OSError, tarfile.TarError) as e:
|
||||
logger.debug("Curator snapshot failed: %s", e, exc_info=True)
|
||||
# Clean up partial snapshot
|
||||
try:
|
||||
shutil.rmtree(dest, ignore_errors=True)
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
_prune_old(keep=get_keep())
|
||||
logger.info("Curator snapshot created: %s (%s)", snap_id, reason)
|
||||
return dest
|
||||
|
||||
|
||||
def _prune_old(keep: int) -> List[str]:
|
||||
"""Delete regular snapshots beyond the newest *keep*. Returns deleted
|
||||
ids. Staging dirs (``.rollback-staging-*``) are implementation detail
|
||||
and pruned independently on every call."""
|
||||
backups = _backups_dir()
|
||||
if not backups.exists():
|
||||
return []
|
||||
entries: List[Tuple[str, Path]] = []
|
||||
stale_staging: List[Path] = []
|
||||
for child in backups.iterdir():
|
||||
if not child.is_dir():
|
||||
continue
|
||||
if child.name.startswith(".rollback-staging-"):
|
||||
# Staging dirs are only supposed to exist briefly during a
|
||||
# rollback. If we find one here (e.g. from a crashed rollback),
|
||||
# clean it up opportunistically.
|
||||
stale_staging.append(child)
|
||||
continue
|
||||
if _ID_RE.match(child.name):
|
||||
entries.append((child.name, child))
|
||||
# Newest first (lexicographic works because the id is UTC ISO).
|
||||
entries.sort(key=lambda t: t[0], reverse=True)
|
||||
deleted: List[str] = []
|
||||
for _, path in entries[keep:]:
|
||||
try:
|
||||
shutil.rmtree(path)
|
||||
deleted.append(path.name)
|
||||
except OSError as e:
|
||||
logger.debug("Failed to prune %s: %s", path, e)
|
||||
for path in stale_staging:
|
||||
try:
|
||||
shutil.rmtree(path)
|
||||
except OSError as e:
|
||||
logger.debug("Failed to clean stale staging dir %s: %s", path, e)
|
||||
return deleted
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# List + rollback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _read_manifest(snap_dir: Path) -> Dict[str, Any]:
|
||||
mf = snap_dir / "manifest.json"
|
||||
if not mf.exists():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(mf.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
def list_backups() -> List[Dict[str, Any]]:
|
||||
"""Return all restorable snapshots, newest first. Only entries with a
|
||||
real ``skills.tar.gz`` tarball are listed — transient
|
||||
``.rollback-staging-*`` directories created mid-rollback are
|
||||
implementation detail and not shown."""
|
||||
backups = _backups_dir()
|
||||
if not backups.exists():
|
||||
return []
|
||||
out: List[Dict[str, Any]] = []
|
||||
for child in sorted(backups.iterdir(), reverse=True):
|
||||
if not child.is_dir():
|
||||
continue
|
||||
if not _ID_RE.match(child.name):
|
||||
continue
|
||||
if not (child / "skills.tar.gz").exists():
|
||||
continue
|
||||
mf = _read_manifest(child)
|
||||
mf.setdefault("id", child.name)
|
||||
mf.setdefault("path", str(child))
|
||||
if "archive_bytes" not in mf:
|
||||
arc = child / "skills.tar.gz"
|
||||
try:
|
||||
mf["archive_bytes"] = arc.stat().st_size
|
||||
except OSError:
|
||||
mf["archive_bytes"] = 0
|
||||
out.append(mf)
|
||||
return out
|
||||
|
||||
|
||||
def _resolve_backup(backup_id: Optional[str]) -> Optional[Path]:
|
||||
"""Return the path of the requested backup, or the newest one if
|
||||
*backup_id* is None. Returns None if no match."""
|
||||
backups = _backups_dir()
|
||||
if not backups.exists():
|
||||
return None
|
||||
if backup_id:
|
||||
target = backups / backup_id
|
||||
if (
|
||||
target.is_dir()
|
||||
and _ID_RE.match(backup_id)
|
||||
and (target / "skills.tar.gz").exists()
|
||||
):
|
||||
return target
|
||||
return None
|
||||
candidates = [
|
||||
c for c in sorted(backups.iterdir(), reverse=True)
|
||||
if c.is_dir() and _ID_RE.match(c.name) and (c / "skills.tar.gz").exists()
|
||||
]
|
||||
return candidates[0] if candidates else None
|
||||
|
||||
|
||||
def rollback(backup_id: Optional[str] = None) -> Tuple[bool, str, Optional[Path]]:
|
||||
"""Restore ``~/.hermes/skills/`` from a snapshot.
|
||||
|
||||
Strategy:
|
||||
1. Resolve the target snapshot (explicit id or newest regular).
|
||||
2. Take a safety snapshot of the CURRENT skills tree under
|
||||
``.curator_backups/pre-rollback-<ts>/`` so the rollback itself is
|
||||
undoable.
|
||||
3. Move all current top-level entries (except ``.curator_backups``
|
||||
and ``.hub``) into a tempdir.
|
||||
4. Extract the chosen snapshot into ``~/.hermes/skills/``.
|
||||
5. On failure during 4, move the tempdir contents back (best-effort)
|
||||
and return failure.
|
||||
|
||||
Returns ``(ok, message, snapshot_path)``.
|
||||
"""
|
||||
target = _resolve_backup(backup_id)
|
||||
if target is None:
|
||||
return (
|
||||
False,
|
||||
f"no matching backup found"
|
||||
+ (f" for id '{backup_id}'" if backup_id else "")
|
||||
+ " (use `hermes curator rollback --list` to see available snapshots)",
|
||||
None,
|
||||
)
|
||||
archive = target / "skills.tar.gz"
|
||||
if not archive.exists():
|
||||
return (False, f"snapshot {target.name} has no skills.tar.gz — corrupted?", None)
|
||||
|
||||
skills = _skills_dir()
|
||||
skills.mkdir(parents=True, exist_ok=True)
|
||||
backups = _backups_dir()
|
||||
backups.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Step 2: safety snapshot of current state FIRST. If this fails we bail
|
||||
# out before touching anything — otherwise a failed extract could leave
|
||||
# the user with no skills.
|
||||
try:
|
||||
snapshot_skills(reason=f"pre-rollback to {target.name}")
|
||||
except Exception as e:
|
||||
return (False, f"pre-rollback safety snapshot failed: {e}", None)
|
||||
|
||||
# Additionally move current entries into an internal staging dir so
|
||||
# the extract happens into an empty skills tree (predictable result).
|
||||
# This dir is implementation detail — not listed as a restorable
|
||||
# backup. The safety snapshot above is the user-facing undo handle.
|
||||
staged = backups / f".rollback-staging-{_utc_id()}"
|
||||
try:
|
||||
staged.mkdir(parents=True, exist_ok=False)
|
||||
except OSError as e:
|
||||
return (False, f"failed to create staging dir: {e}", None)
|
||||
|
||||
moved: List[Tuple[Path, Path]] = []
|
||||
try:
|
||||
for entry in list(skills.iterdir()):
|
||||
if entry.name in _EXCLUDE_TOP_LEVEL:
|
||||
continue
|
||||
dest = staged / entry.name
|
||||
shutil.move(str(entry), str(dest))
|
||||
moved.append((entry, dest))
|
||||
except OSError as e:
|
||||
# Best-effort rollback of the move
|
||||
for orig, dest in moved:
|
||||
try:
|
||||
shutil.move(str(dest), str(orig))
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
shutil.rmtree(staged, ignore_errors=True)
|
||||
except OSError:
|
||||
pass
|
||||
return (False, f"failed to stage current skills: {e}", None)
|
||||
|
||||
# Step 4: extract the snapshot into skills/
|
||||
try:
|
||||
with tarfile.open(archive, "r:gz") as tf:
|
||||
# Python 3.12+ supports filter='data' for safer extraction.
|
||||
# Fall back to the unfiltered call for older interpreters but
|
||||
# still reject absolute paths and .. components defensively.
|
||||
for member in tf.getmembers():
|
||||
name = member.name
|
||||
if name.startswith("/") or ".." in Path(name).parts:
|
||||
raise tarfile.TarError(
|
||||
f"refusing to extract unsafe path: {name!r}"
|
||||
)
|
||||
try:
|
||||
tf.extractall(str(skills), filter="data") # type: ignore[call-arg]
|
||||
except TypeError:
|
||||
# Python < 3.12 — no filter kwarg
|
||||
tf.extractall(str(skills))
|
||||
except (OSError, tarfile.TarError) as e:
|
||||
# Best-effort recover: move staged contents back
|
||||
for orig, dest in moved:
|
||||
try:
|
||||
shutil.move(str(dest), str(orig))
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
shutil.rmtree(staged, ignore_errors=True)
|
||||
except OSError:
|
||||
pass
|
||||
return (False, f"snapshot extract failed (state restored): {e}", None)
|
||||
|
||||
# Extract succeeded — the staging dir has served its purpose. The
|
||||
# user's undo handle is the safety snapshot tarball we took earlier.
|
||||
try:
|
||||
shutil.rmtree(staged, ignore_errors=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
logger.info("Curator rollback: restored from %s", target.name)
|
||||
return (True, f"restored from snapshot {target.name}", target)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Human-readable summary for CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def format_size(n: int) -> str:
|
||||
for unit in ("B", "KB", "MB", "GB"):
|
||||
if n < 1024 or unit == "GB":
|
||||
return f"{n:.1f} {unit}" if unit != "B" else f"{n} B"
|
||||
n /= 1024
|
||||
return f"{n:.1f} GB"
|
||||
|
||||
|
||||
def summarize_backups() -> str:
|
||||
rows = list_backups()
|
||||
if not rows:
|
||||
return "No curator snapshots yet."
|
||||
lines = [f"{'id':<24} {'reason':<40} {'skills':>6} {'size':>8}"]
|
||||
lines.append("─" * len(lines[0]))
|
||||
for r in rows:
|
||||
lines.append(
|
||||
f"{r.get('id','?'):<24} "
|
||||
f"{(r.get('reason','?') or '?')[:40]:<40} "
|
||||
f"{r.get('skill_files', 0):>6} "
|
||||
f"{format_size(int(r.get('archive_bytes', 0))):>8}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
|
@ -20,25 +20,25 @@ def summarize_manual_compression(
|
|||
headline = f"No changes from compression: {before_count} messages"
|
||||
if after_tokens == before_tokens:
|
||||
token_line = (
|
||||
f"Rough transcript estimate: ~{before_tokens:,} tokens (unchanged)"
|
||||
f"Approx request size: ~{before_tokens:,} tokens (unchanged)"
|
||||
)
|
||||
else:
|
||||
token_line = (
|
||||
f"Rough transcript estimate: ~{before_tokens:,} → "
|
||||
f"Approx request size: ~{before_tokens:,} → "
|
||||
f"~{after_tokens:,} tokens"
|
||||
)
|
||||
else:
|
||||
headline = f"Compressed: {before_count} → {after_count} messages"
|
||||
token_line = (
|
||||
f"Rough transcript estimate: ~{before_tokens:,} → "
|
||||
f"Approx request size: ~{before_tokens:,} → "
|
||||
f"~{after_tokens:,} tokens"
|
||||
)
|
||||
|
||||
note = None
|
||||
if not noop and after_count < before_count and after_tokens > before_tokens:
|
||||
note = (
|
||||
"Note: fewer messages can still raise this rough transcript estimate "
|
||||
"when compression rewrites the transcript into denser summaries."
|
||||
"Note: fewer messages can still raise this estimate when "
|
||||
"compression rewrites the transcript into denser summaries."
|
||||
)
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -81,15 +81,56 @@ def _repair_schema(node: Any, is_schema: bool = True) -> Any:
|
|||
return repaired
|
||||
|
||||
# Rule 2: when anyOf is present, type belongs only on the children.
|
||||
# Additionally, Moonshot rejects null-type branches inside anyOf
|
||||
# (enum value (<nil>) does not match any type in [string]).
|
||||
# Collapse the anyOf to the first non-null branch and infer its type.
|
||||
if "anyOf" in repaired and isinstance(repaired["anyOf"], list):
|
||||
repaired.pop("type", None)
|
||||
return repaired
|
||||
non_null = [b for b in repaired["anyOf"]
|
||||
if isinstance(b, dict) and b.get("type") != "null"]
|
||||
if non_null and len(non_null) < len(repaired["anyOf"]):
|
||||
# Drop the anyOf wrapper — keep only the non-null branch.
|
||||
# If there's a single non-null branch, promote it and fall
|
||||
# through to Rules 1/3 so nullable/enum cleanup still applies
|
||||
# to the merged node.
|
||||
if len(non_null) == 1:
|
||||
merge = {k: v for k, v in repaired.items() if k != "anyOf"}
|
||||
merge.update(non_null[0])
|
||||
repaired = merge
|
||||
else:
|
||||
repaired["anyOf"] = non_null
|
||||
return repaired
|
||||
else:
|
||||
# Nothing to collapse — parent type stripped, children already
|
||||
# repaired by the recursive walk above.
|
||||
return repaired
|
||||
|
||||
# Moonshot also rejects non-standard keywords like ``nullable`` on
|
||||
# parameter schemas — strip it.
|
||||
repaired.pop("nullable", None)
|
||||
|
||||
# Rule 1: property schemas without type need one. $ref nodes are exempt
|
||||
# — their type comes from the referenced definition.
|
||||
if "$ref" in repaired:
|
||||
return repaired
|
||||
return _fill_missing_type(repaired)
|
||||
# Fill missing type BEFORE Rule 3 so enum cleanup can check the type.
|
||||
if "$ref" not in repaired:
|
||||
repaired = _fill_missing_type(repaired)
|
||||
|
||||
# Rule 3: Moonshot rejects null/empty-string values inside enum arrays
|
||||
# when the parent type is a scalar (string, integer, etc.). The error:
|
||||
# "enum value (<nil>) does not match any type in [string]"
|
||||
# Strip null and empty-string from enum values, and if the enum becomes
|
||||
# empty, drop it entirely.
|
||||
if "enum" in repaired and isinstance(repaired["enum"], list):
|
||||
node_type = repaired.get("type")
|
||||
if node_type in ("string", "integer", "number", "boolean"):
|
||||
cleaned = [v for v in repaired["enum"]
|
||||
if v is not None and v != ""]
|
||||
if cleaned:
|
||||
repaired["enum"] = cleaned
|
||||
else:
|
||||
repaired.pop("enum")
|
||||
|
||||
return repaired
|
||||
|
||||
|
||||
def _fill_missing_type(node: Dict[str, Any]) -> Dict[str, Any]:
|
||||
|
|
|
|||
455
agent/tool_guardrails.py
Normal file
455
agent/tool_guardrails.py
Normal file
|
|
@ -0,0 +1,455 @@
|
|||
"""Pure tool-call loop guardrail primitives.
|
||||
|
||||
The controller in this module is intentionally side-effect free: it tracks
|
||||
per-turn tool-call observations and returns decisions. Runtime code owns whether
|
||||
those decisions become warning guidance, synthetic tool results, or controlled
|
||||
turn halts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Mapping
|
||||
|
||||
from utils import safe_json_loads
|
||||
|
||||
|
||||
IDEMPOTENT_TOOL_NAMES = frozenset(
|
||||
{
|
||||
"read_file",
|
||||
"search_files",
|
||||
"web_search",
|
||||
"web_extract",
|
||||
"session_search",
|
||||
"browser_snapshot",
|
||||
"browser_console",
|
||||
"browser_get_images",
|
||||
"mcp_filesystem_read_file",
|
||||
"mcp_filesystem_read_text_file",
|
||||
"mcp_filesystem_read_multiple_files",
|
||||
"mcp_filesystem_list_directory",
|
||||
"mcp_filesystem_list_directory_with_sizes",
|
||||
"mcp_filesystem_directory_tree",
|
||||
"mcp_filesystem_get_file_info",
|
||||
"mcp_filesystem_search_files",
|
||||
}
|
||||
)
|
||||
|
||||
MUTATING_TOOL_NAMES = frozenset(
|
||||
{
|
||||
"terminal",
|
||||
"execute_code",
|
||||
"write_file",
|
||||
"patch",
|
||||
"todo",
|
||||
"memory",
|
||||
"skill_manage",
|
||||
"browser_click",
|
||||
"browser_type",
|
||||
"browser_press",
|
||||
"browser_scroll",
|
||||
"browser_navigate",
|
||||
"send_message",
|
||||
"cronjob",
|
||||
"delegate_task",
|
||||
"process",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolCallGuardrailConfig:
|
||||
"""Thresholds for per-turn tool-call loop detection.
|
||||
|
||||
Warnings are enabled by default and never prevent tool execution. Hard stops
|
||||
are explicit opt-in so interactive CLI/TUI sessions get a gentle nudge unless
|
||||
the user enables circuit-breaker behavior in config.yaml.
|
||||
"""
|
||||
|
||||
warnings_enabled: bool = True
|
||||
hard_stop_enabled: bool = False
|
||||
exact_failure_warn_after: int = 2
|
||||
exact_failure_block_after: int = 5
|
||||
same_tool_failure_warn_after: int = 3
|
||||
same_tool_failure_halt_after: int = 8
|
||||
no_progress_warn_after: int = 2
|
||||
no_progress_block_after: int = 5
|
||||
idempotent_tools: frozenset[str] = field(default_factory=lambda: IDEMPOTENT_TOOL_NAMES)
|
||||
mutating_tools: frozenset[str] = field(default_factory=lambda: MUTATING_TOOL_NAMES)
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, data: Mapping[str, Any] | None) -> "ToolCallGuardrailConfig":
|
||||
"""Build config from the `tool_loop_guardrails` config.yaml section."""
|
||||
if not isinstance(data, Mapping):
|
||||
return cls()
|
||||
|
||||
warn_after = data.get("warn_after")
|
||||
if not isinstance(warn_after, Mapping):
|
||||
warn_after = {}
|
||||
hard_stop_after = data.get("hard_stop_after")
|
||||
if not isinstance(hard_stop_after, Mapping):
|
||||
hard_stop_after = {}
|
||||
|
||||
defaults = cls()
|
||||
return cls(
|
||||
warnings_enabled=_as_bool(data.get("warnings_enabled"), defaults.warnings_enabled),
|
||||
hard_stop_enabled=_as_bool(data.get("hard_stop_enabled"), defaults.hard_stop_enabled),
|
||||
exact_failure_warn_after=_positive_int(
|
||||
warn_after.get("exact_failure", data.get("exact_failure_warn_after")),
|
||||
defaults.exact_failure_warn_after,
|
||||
),
|
||||
same_tool_failure_warn_after=_positive_int(
|
||||
warn_after.get("same_tool_failure", data.get("same_tool_failure_warn_after")),
|
||||
defaults.same_tool_failure_warn_after,
|
||||
),
|
||||
no_progress_warn_after=_positive_int(
|
||||
warn_after.get("idempotent_no_progress", data.get("no_progress_warn_after")),
|
||||
defaults.no_progress_warn_after,
|
||||
),
|
||||
exact_failure_block_after=_positive_int(
|
||||
hard_stop_after.get("exact_failure", data.get("exact_failure_block_after")),
|
||||
defaults.exact_failure_block_after,
|
||||
),
|
||||
same_tool_failure_halt_after=_positive_int(
|
||||
hard_stop_after.get("same_tool_failure", data.get("same_tool_failure_halt_after")),
|
||||
defaults.same_tool_failure_halt_after,
|
||||
),
|
||||
no_progress_block_after=_positive_int(
|
||||
hard_stop_after.get("idempotent_no_progress", data.get("no_progress_block_after")),
|
||||
defaults.no_progress_block_after,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolCallSignature:
|
||||
"""Stable, non-reversible identity for a tool name plus canonical args."""
|
||||
|
||||
tool_name: str
|
||||
args_hash: str
|
||||
|
||||
@classmethod
|
||||
def from_call(cls, tool_name: str, args: Mapping[str, Any] | None) -> "ToolCallSignature":
|
||||
canonical = canonical_tool_args(args or {})
|
||||
return cls(tool_name=tool_name, args_hash=_sha256(canonical))
|
||||
|
||||
def to_metadata(self) -> dict[str, str]:
|
||||
"""Return public metadata without raw argument values."""
|
||||
return {"tool_name": self.tool_name, "args_hash": self.args_hash}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolGuardrailDecision:
|
||||
"""Decision returned by the tool-call guardrail controller."""
|
||||
|
||||
action: str = "allow" # allow | warn | block | halt
|
||||
code: str = "allow"
|
||||
message: str = ""
|
||||
tool_name: str = ""
|
||||
count: int = 0
|
||||
signature: ToolCallSignature | None = None
|
||||
|
||||
@property
|
||||
def allows_execution(self) -> bool:
|
||||
return self.action in {"allow", "warn"}
|
||||
|
||||
@property
|
||||
def should_halt(self) -> bool:
|
||||
return self.action in {"block", "halt"}
|
||||
|
||||
def to_metadata(self) -> dict[str, Any]:
|
||||
data: dict[str, Any] = {
|
||||
"action": self.action,
|
||||
"code": self.code,
|
||||
"message": self.message,
|
||||
"tool_name": self.tool_name,
|
||||
"count": self.count,
|
||||
}
|
||||
if self.signature is not None:
|
||||
data["signature"] = self.signature.to_metadata()
|
||||
return data
|
||||
|
||||
|
||||
def canonical_tool_args(args: Mapping[str, Any]) -> str:
|
||||
"""Return sorted compact JSON for parsed tool arguments."""
|
||||
if not isinstance(args, Mapping):
|
||||
raise TypeError(f"tool args must be a mapping, got {type(args).__name__}")
|
||||
return json.dumps(
|
||||
args,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
default=str,
|
||||
)
|
||||
|
||||
|
||||
def classify_tool_failure(tool_name: str, result: str | None) -> tuple[bool, str]:
|
||||
"""Safety-fallback classifier used only when callers don't pass ``failed``.
|
||||
|
||||
Mirrors ``agent.display._detect_tool_failure`` exactly so the guardrail
|
||||
never disagrees with the CLI's user-visible ``[error]`` tag. Production
|
||||
callers in ``run_agent.py`` always pass an explicit ``failed=`` derived
|
||||
from ``_detect_tool_failure``; this function exists so standalone callers
|
||||
(tests, tooling) still get consistent behavior.
|
||||
"""
|
||||
if result is None:
|
||||
return False, ""
|
||||
|
||||
if tool_name == "terminal":
|
||||
data = safe_json_loads(result)
|
||||
if isinstance(data, dict):
|
||||
exit_code = data.get("exit_code")
|
||||
if exit_code is not None and exit_code != 0:
|
||||
return True, f" [exit {exit_code}]"
|
||||
return False, ""
|
||||
|
||||
if tool_name == "memory":
|
||||
data = safe_json_loads(result)
|
||||
if isinstance(data, dict):
|
||||
if data.get("success") is False and "exceed the limit" in data.get("error", ""):
|
||||
return True, " [full]"
|
||||
|
||||
lower = result[:500].lower()
|
||||
if '"error"' in lower or '"failed"' in lower or result.startswith("Error"):
|
||||
return True, " [error]"
|
||||
|
||||
return False, ""
|
||||
|
||||
|
||||
class ToolCallGuardrailController:
|
||||
"""Per-turn controller for repeated failed/non-progressing tool calls."""
|
||||
|
||||
def __init__(self, config: ToolCallGuardrailConfig | None = None):
|
||||
self.config = config or ToolCallGuardrailConfig()
|
||||
self.reset_for_turn()
|
||||
|
||||
def reset_for_turn(self) -> None:
|
||||
self._exact_failure_counts: dict[ToolCallSignature, int] = {}
|
||||
self._same_tool_failure_counts: dict[str, int] = {}
|
||||
self._no_progress: dict[ToolCallSignature, tuple[str, int]] = {}
|
||||
self._halt_decision: ToolGuardrailDecision | None = None
|
||||
|
||||
@property
|
||||
def halt_decision(self) -> ToolGuardrailDecision | None:
|
||||
return self._halt_decision
|
||||
|
||||
def before_call(self, tool_name: str, args: Mapping[str, Any] | None) -> ToolGuardrailDecision:
|
||||
signature = ToolCallSignature.from_call(tool_name, _coerce_args(args))
|
||||
if not self.config.hard_stop_enabled:
|
||||
return ToolGuardrailDecision(tool_name=tool_name, signature=signature)
|
||||
|
||||
exact_count = self._exact_failure_counts.get(signature, 0)
|
||||
if exact_count >= self.config.exact_failure_block_after:
|
||||
decision = ToolGuardrailDecision(
|
||||
action="block",
|
||||
code="repeated_exact_failure_block",
|
||||
message=(
|
||||
f"Blocked {tool_name}: the same tool call failed {exact_count} "
|
||||
"times with identical arguments. Stop retrying it unchanged; "
|
||||
"change strategy or explain the blocker."
|
||||
),
|
||||
tool_name=tool_name,
|
||||
count=exact_count,
|
||||
signature=signature,
|
||||
)
|
||||
self._halt_decision = decision
|
||||
return decision
|
||||
|
||||
if self._is_idempotent(tool_name):
|
||||
record = self._no_progress.get(signature)
|
||||
if record is not None:
|
||||
_result_hash, repeat_count = record
|
||||
if repeat_count >= self.config.no_progress_block_after:
|
||||
decision = ToolGuardrailDecision(
|
||||
action="block",
|
||||
code="idempotent_no_progress_block",
|
||||
message=(
|
||||
f"Blocked {tool_name}: this read-only call returned the same "
|
||||
f"result {repeat_count} times. Stop repeating it unchanged; "
|
||||
"use the result already provided or try a different query."
|
||||
),
|
||||
tool_name=tool_name,
|
||||
count=repeat_count,
|
||||
signature=signature,
|
||||
)
|
||||
self._halt_decision = decision
|
||||
return decision
|
||||
|
||||
return ToolGuardrailDecision(tool_name=tool_name, signature=signature)
|
||||
|
||||
def after_call(
|
||||
self,
|
||||
tool_name: str,
|
||||
args: Mapping[str, Any] | None,
|
||||
result: str | None,
|
||||
*,
|
||||
failed: bool | None = None,
|
||||
) -> ToolGuardrailDecision:
|
||||
args = _coerce_args(args)
|
||||
signature = ToolCallSignature.from_call(tool_name, args)
|
||||
if failed is None:
|
||||
failed, _ = classify_tool_failure(tool_name, result)
|
||||
|
||||
if failed:
|
||||
exact_count = self._exact_failure_counts.get(signature, 0) + 1
|
||||
self._exact_failure_counts[signature] = exact_count
|
||||
self._no_progress.pop(signature, None)
|
||||
|
||||
same_count = self._same_tool_failure_counts.get(tool_name, 0) + 1
|
||||
self._same_tool_failure_counts[tool_name] = same_count
|
||||
|
||||
if self.config.hard_stop_enabled and same_count >= self.config.same_tool_failure_halt_after:
|
||||
decision = ToolGuardrailDecision(
|
||||
action="halt",
|
||||
code="same_tool_failure_halt",
|
||||
message=(
|
||||
f"Stopped {tool_name}: it failed {same_count} times this turn. "
|
||||
"Stop retrying the same failing tool path and choose a different approach."
|
||||
),
|
||||
tool_name=tool_name,
|
||||
count=same_count,
|
||||
signature=signature,
|
||||
)
|
||||
self._halt_decision = decision
|
||||
return decision
|
||||
|
||||
if self.config.warnings_enabled and exact_count >= self.config.exact_failure_warn_after:
|
||||
return ToolGuardrailDecision(
|
||||
action="warn",
|
||||
code="repeated_exact_failure_warning",
|
||||
message=(
|
||||
f"{tool_name} has failed {exact_count} times with identical arguments. "
|
||||
"This looks like a loop; inspect the error and change strategy "
|
||||
"instead of retrying it unchanged."
|
||||
),
|
||||
tool_name=tool_name,
|
||||
count=exact_count,
|
||||
signature=signature,
|
||||
)
|
||||
|
||||
if self.config.warnings_enabled and same_count >= self.config.same_tool_failure_warn_after:
|
||||
return ToolGuardrailDecision(
|
||||
action="warn",
|
||||
code="same_tool_failure_warning",
|
||||
message=(
|
||||
f"{tool_name} has failed {same_count} times this turn. "
|
||||
"This looks like a loop; change approach before retrying."
|
||||
),
|
||||
tool_name=tool_name,
|
||||
count=same_count,
|
||||
signature=signature,
|
||||
)
|
||||
|
||||
return ToolGuardrailDecision(tool_name=tool_name, count=exact_count, signature=signature)
|
||||
|
||||
self._exact_failure_counts.pop(signature, None)
|
||||
self._same_tool_failure_counts.pop(tool_name, None)
|
||||
|
||||
if not self._is_idempotent(tool_name):
|
||||
self._no_progress.pop(signature, None)
|
||||
return ToolGuardrailDecision(tool_name=tool_name, signature=signature)
|
||||
|
||||
result_hash = _result_hash(result)
|
||||
previous = self._no_progress.get(signature)
|
||||
repeat_count = 1
|
||||
if previous is not None and previous[0] == result_hash:
|
||||
repeat_count = previous[1] + 1
|
||||
self._no_progress[signature] = (result_hash, repeat_count)
|
||||
|
||||
if self.config.warnings_enabled and repeat_count >= self.config.no_progress_warn_after:
|
||||
return ToolGuardrailDecision(
|
||||
action="warn",
|
||||
code="idempotent_no_progress_warning",
|
||||
message=(
|
||||
f"{tool_name} returned the same result {repeat_count} times. "
|
||||
"Use the result already provided or change the query instead of "
|
||||
"repeating it unchanged."
|
||||
),
|
||||
tool_name=tool_name,
|
||||
count=repeat_count,
|
||||
signature=signature,
|
||||
)
|
||||
|
||||
return ToolGuardrailDecision(tool_name=tool_name, count=repeat_count, signature=signature)
|
||||
|
||||
def _is_idempotent(self, tool_name: str) -> bool:
|
||||
if tool_name in self.config.mutating_tools:
|
||||
return False
|
||||
return tool_name in self.config.idempotent_tools
|
||||
|
||||
|
||||
def toolguard_synthetic_result(decision: ToolGuardrailDecision) -> str:
|
||||
"""Build a synthetic role=tool content string for a blocked tool call."""
|
||||
return json.dumps(
|
||||
{
|
||||
"error": decision.message,
|
||||
"guardrail": decision.to_metadata(),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
def append_toolguard_guidance(result: str, decision: ToolGuardrailDecision) -> str:
|
||||
"""Append runtime guidance to the current tool result content."""
|
||||
if decision.action not in {"warn", "halt"} or not decision.message:
|
||||
return result
|
||||
label = "Tool loop hard stop" if decision.action == "halt" else "Tool loop warning"
|
||||
suffix = (
|
||||
f"\n\n[{label}: "
|
||||
f"{decision.code}; count={decision.count}; {decision.message}]"
|
||||
)
|
||||
return (result or "") + suffix
|
||||
|
||||
|
||||
def _coerce_args(args: Mapping[str, Any] | None) -> Mapping[str, Any]:
|
||||
return args if isinstance(args, Mapping) else {}
|
||||
|
||||
|
||||
def _result_hash(result: str | None) -> str:
|
||||
parsed = safe_json_loads(result or "")
|
||||
if parsed is not None:
|
||||
try:
|
||||
canonical = json.dumps(
|
||||
parsed,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
default=str,
|
||||
)
|
||||
except TypeError:
|
||||
canonical = str(parsed)
|
||||
else:
|
||||
canonical = result or ""
|
||||
return _sha256(canonical)
|
||||
|
||||
|
||||
def _as_bool(value: Any, default: bool) -> bool:
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, (int, float)):
|
||||
return bool(value)
|
||||
if isinstance(value, str):
|
||||
lowered = value.strip().lower()
|
||||
if lowered in {"1", "true", "yes", "on", "enabled"}:
|
||||
return True
|
||||
if lowered in {"0", "false", "no", "off", "disabled"}:
|
||||
return False
|
||||
return default
|
||||
|
||||
|
||||
def _positive_int(value: Any, default: int) -> int:
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return parsed if parsed >= 1 else default
|
||||
|
||||
|
||||
def _sha256(value: str) -> str:
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||||
|
|
@ -70,6 +70,30 @@ const APP_ICON_PATHS = [
|
|||
path.join(unpackedPathFor(APP_ROOT), 'dist', 'apple-touch-icon.png')
|
||||
]
|
||||
|
||||
const MEDIA_MIME_TYPES = {
|
||||
'.avi': 'video/x-msvideo',
|
||||
'.bmp': 'image/bmp',
|
||||
'.flac': 'audio/flac',
|
||||
'.gif': 'image/gif',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.m4a': 'audio/mp4',
|
||||
'.mkv': 'video/x-matroska',
|
||||
'.mov': 'video/quicktime',
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.mp4': 'video/mp4',
|
||||
'.ogg': 'audio/ogg',
|
||||
'.opus': 'audio/ogg; codecs=opus',
|
||||
'.png': 'image/png',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.wav': 'audio/wav',
|
||||
'.webm': 'video/webm',
|
||||
'.webp': 'image/webp'
|
||||
}
|
||||
|
||||
const PREVIEW_HTML_EXTENSIONS = new Set(['.html', '.htm'])
|
||||
const LOCAL_PREVIEW_HOSTS = new Set(['0.0.0.0', '127.0.0.1', '::1', '[::1]', 'localhost'])
|
||||
|
||||
app.setName(APP_NAME)
|
||||
app.setAboutPanelOptions({
|
||||
applicationName: APP_NAME,
|
||||
|
|
@ -80,6 +104,7 @@ let mainWindow = null
|
|||
let hermesProcess = null
|
||||
let connectionPromise = null
|
||||
const hermesLog = []
|
||||
const previewWatchers = new Map()
|
||||
|
||||
function rememberLog(chunk) {
|
||||
const text = String(chunk || '').trim()
|
||||
|
|
@ -462,13 +487,8 @@ function fetchJson(url, token, options = {}) {
|
|||
|
||||
function mimeTypeForPath(filePath) {
|
||||
const ext = path.extname(filePath || '').toLowerCase()
|
||||
if (ext === '.png') return 'image/png'
|
||||
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'
|
||||
if (ext === '.gif') return 'image/gif'
|
||||
if (ext === '.webp') return 'image/webp'
|
||||
if (ext === '.svg') return 'image/svg+xml'
|
||||
if (ext === '.bmp') return 'image/bmp'
|
||||
return 'application/octet-stream'
|
||||
|
||||
return MEDIA_MIME_TYPES[ext] || 'application/octet-stream'
|
||||
}
|
||||
|
||||
function extensionForMimeType(mimeType) {
|
||||
|
|
@ -552,6 +572,162 @@ async function saveImageFromUrl(rawUrl) {
|
|||
return true
|
||||
}
|
||||
|
||||
async function writeComposerImage(buffer, ext = '.png') {
|
||||
const rawExt = String(ext || '.png')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
const normalizedExt = rawExt.startsWith('.') ? rawExt : `.${rawExt}`
|
||||
const safeExt = /^\.[a-z0-9]{1,5}$/.test(normalizedExt) ? normalizedExt : '.png'
|
||||
const dir = path.join(app.getPath('userData'), 'composer-images')
|
||||
await fs.promises.mkdir(dir, { recursive: true })
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').replace('Z', '')
|
||||
const random = crypto.randomBytes(3).toString('hex')
|
||||
const filePath = path.join(dir, `composer_${stamp}_${random}${safeExt}`)
|
||||
await fs.promises.writeFile(filePath, buffer)
|
||||
return filePath
|
||||
}
|
||||
|
||||
function previewLabelForUrl(url) {
|
||||
return `${url.host}${url.pathname === '/' ? '' : url.pathname}`
|
||||
}
|
||||
|
||||
function expandUserPath(filePath) {
|
||||
const value = String(filePath || '').trim()
|
||||
|
||||
if (value === '~') {
|
||||
return app.getPath('home')
|
||||
}
|
||||
|
||||
if (value.startsWith(`~${path.sep}`) || value.startsWith('~/')) {
|
||||
return path.join(app.getPath('home'), value.slice(2))
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function previewFileTarget(rawTarget, baseDir) {
|
||||
const raw = String(rawTarget || '').trim()
|
||||
const base = baseDir ? path.resolve(expandUserPath(baseDir)) : resolveHermesCwd()
|
||||
const filePath = raw.startsWith('file:') ? fileURLToPath(raw) : path.resolve(base, expandUserPath(raw))
|
||||
let resolved = filePath
|
||||
|
||||
if (directoryExists(resolved)) {
|
||||
resolved = path.join(resolved, 'index.html')
|
||||
}
|
||||
|
||||
const ext = path.extname(resolved).toLowerCase()
|
||||
if (!PREVIEW_HTML_EXTENSIONS.has(ext) || !fileExists(resolved)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'file',
|
||||
label: path.basename(resolved),
|
||||
source: raw,
|
||||
url: pathToFileURL(resolved).toString()
|
||||
}
|
||||
}
|
||||
|
||||
function previewUrlTarget(rawTarget) {
|
||||
const raw = String(rawTarget || '').trim()
|
||||
const url = new URL(raw)
|
||||
|
||||
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!LOCAL_PREVIEW_HOSTS.has(url.hostname.toLowerCase())) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (url.hostname === '0.0.0.0') {
|
||||
url.hostname = '127.0.0.1'
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'url',
|
||||
label: previewLabelForUrl(url),
|
||||
source: raw,
|
||||
url: url.toString()
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePreviewTarget(rawTarget, baseDir) {
|
||||
const raw = String(rawTarget || '').trim()
|
||||
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
if (/^https?:\/\//i.test(raw)) {
|
||||
return previewUrlTarget(raw)
|
||||
}
|
||||
|
||||
return previewFileTarget(raw, baseDir)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function previewFilePathFromUrl(rawUrl) {
|
||||
const filePath = fileURLToPath(String(rawUrl || ''))
|
||||
const ext = path.extname(filePath).toLowerCase()
|
||||
|
||||
if (!PREVIEW_HTML_EXTENSIONS.has(ext) || !fileExists(filePath)) {
|
||||
throw new Error('Preview file is not a readable HTML file')
|
||||
}
|
||||
|
||||
return filePath
|
||||
}
|
||||
|
||||
function sendPreviewFileChanged(payload) {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return
|
||||
const { webContents } = mainWindow
|
||||
if (!webContents || webContents.isDestroyed()) return
|
||||
webContents.send('hermes:preview-file-changed', payload)
|
||||
}
|
||||
|
||||
function watchPreviewFile(rawUrl) {
|
||||
const filePath = previewFilePathFromUrl(rawUrl)
|
||||
const id = crypto.randomBytes(12).toString('base64url')
|
||||
let timer = null
|
||||
const watcher = fs.watch(filePath, () => {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = setTimeout(() => {
|
||||
sendPreviewFileChanged({ id, path: filePath, url: pathToFileURL(filePath).toString() })
|
||||
}, 120)
|
||||
})
|
||||
|
||||
previewWatchers.set(id, {
|
||||
close: () => {
|
||||
if (timer) clearTimeout(timer)
|
||||
watcher.close()
|
||||
}
|
||||
})
|
||||
|
||||
return { id, path: filePath }
|
||||
}
|
||||
|
||||
function stopPreviewFileWatch(id) {
|
||||
const watcher = previewWatchers.get(id)
|
||||
|
||||
if (!watcher) {
|
||||
return false
|
||||
}
|
||||
|
||||
watcher.close()
|
||||
previewWatchers.delete(id)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function closePreviewWatchers() {
|
||||
for (const id of previewWatchers.keys()) {
|
||||
stopPreviewFileWatch(id)
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForHermes(baseUrl, token) {
|
||||
const deadline = Date.now() + 45_000
|
||||
let lastError = null
|
||||
|
|
@ -843,6 +1019,7 @@ function createWindow() {
|
|||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.cjs'),
|
||||
contextIsolation: true,
|
||||
webviewTag: true,
|
||||
nodeIntegration: false,
|
||||
devTools: Boolean(DEV_SERVER)
|
||||
}
|
||||
|
|
@ -922,6 +1099,31 @@ ipcMain.handle('hermes:writeClipboard', (_event, text) => {
|
|||
|
||||
ipcMain.handle('hermes:saveImageFromUrl', (_event, url) => saveImageFromUrl(String(url || '')))
|
||||
|
||||
ipcMain.handle('hermes:saveImageBuffer', async (_event, payload) => {
|
||||
const data = payload?.data
|
||||
if (!data) throw new Error('saveImageBuffer: missing data')
|
||||
|
||||
const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data)
|
||||
return writeComposerImage(buffer, payload?.ext || '.png')
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:saveClipboardImage', async () => {
|
||||
const image = clipboard.readImage()
|
||||
if (!image || image.isEmpty()) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return writeComposerImage(image.toPNG(), '.png')
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:normalizePreviewTarget', (_event, target, baseDir) =>
|
||||
normalizePreviewTarget(String(target || ''), baseDir ? String(baseDir) : '')
|
||||
)
|
||||
|
||||
ipcMain.handle('hermes:watchPreviewFile', (_event, url) => watchPreviewFile(String(url || '')))
|
||||
|
||||
ipcMain.handle('hermes:stopPreviewFileWatch', (_event, id) => stopPreviewFileWatch(String(id || '')))
|
||||
|
||||
ipcMain.handle('hermes:openExternal', (_event, url) => shell.openExternal(url))
|
||||
|
||||
app.whenReady().then(() => {
|
||||
|
|
@ -936,6 +1138,8 @@ app.whenReady().then(() => {
|
|||
})
|
||||
|
||||
app.on('before-quit', () => {
|
||||
closePreviewWatchers()
|
||||
|
||||
if (hermesProcess && !hermesProcess.killed) {
|
||||
hermesProcess.kill('SIGTERM')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
const { contextBridge, ipcRenderer } = require('electron')
|
||||
const { contextBridge, ipcRenderer, webUtils } = require('electron')
|
||||
|
||||
contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
getConnection: () => ipcRenderer.invoke('hermes:connection'),
|
||||
|
|
@ -9,7 +9,24 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
|||
selectPaths: options => ipcRenderer.invoke('hermes:selectPaths', options),
|
||||
writeClipboard: text => ipcRenderer.invoke('hermes:writeClipboard', text),
|
||||
saveImageFromUrl: url => ipcRenderer.invoke('hermes:saveImageFromUrl', url),
|
||||
saveImageBuffer: (data, ext) => ipcRenderer.invoke('hermes:saveImageBuffer', { data, ext }),
|
||||
saveClipboardImage: () => ipcRenderer.invoke('hermes:saveClipboardImage'),
|
||||
getPathForFile: file => {
|
||||
try {
|
||||
return webUtils.getPathForFile(file) || ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
},
|
||||
normalizePreviewTarget: (target, baseDir) => ipcRenderer.invoke('hermes:normalizePreviewTarget', target, baseDir),
|
||||
watchPreviewFile: url => ipcRenderer.invoke('hermes:watchPreviewFile', url),
|
||||
stopPreviewFileWatch: id => ipcRenderer.invoke('hermes:stopPreviewFileWatch', id),
|
||||
openExternal: url => ipcRenderer.invoke('hermes:openExternal', url),
|
||||
onPreviewFileChanged: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:preview-file-changed', listener)
|
||||
return () => ipcRenderer.removeListener('hermes:preview-file-changed', listener)
|
||||
},
|
||||
onBackendExit: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:backend-exit', listener)
|
||||
|
|
|
|||
239
apps/desktop/package-lock.json
generated
239
apps/desktop/package-lock.json
generated
|
|
@ -32,6 +32,8 @@
|
|||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"tw-shimmer": "^0.4.11",
|
||||
"unicode-animations": "^1.0.3",
|
||||
"use-stick-to-bottom": "^1.1.4",
|
||||
"web-haptics": "^0.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -382,6 +384,16 @@
|
|||
"url": "https://opencollective.com/babel"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/core/node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/generator": {
|
||||
"version": "7.29.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
|
||||
|
|
@ -429,14 +441,14 @@
|
|||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
|
||||
"node_modules/@babel/helper-compilation-targets/node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"yallist": "^3.0.2"
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-create-class-features-plugin": {
|
||||
|
|
@ -461,6 +473,16 @@
|
|||
"@babel/core": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-globals": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
|
||||
|
|
@ -1062,6 +1084,16 @@
|
|||
"global-agent": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/get/node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/notarize": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz",
|
||||
|
|
@ -6939,19 +6971,6 @@
|
|||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.1.tgz",
|
||||
|
|
@ -7444,19 +7463,6 @@
|
|||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/app-builder-lib/node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/app-builder-lib/node_modules/which": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz",
|
||||
|
|
@ -9691,9 +9697,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.348",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.348.tgz",
|
||||
"integrity": "sha512-QC2X59nRlycQQMc4ZXjSVBX+tSgJfgRtcrYHbIZLgOV2dCvefoQGegLR7lLXKgpPpSuVmJU19LMzGrSa2C7k3Q==",
|
||||
"version": "1.5.349",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz",
|
||||
"integrity": "sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
|
|
@ -10214,6 +10220,16 @@
|
|||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-react/node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-unused-imports": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.4.1.tgz",
|
||||
|
|
@ -10933,20 +10949,6 @@
|
|||
"node": ">=10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/global-agent/node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/globals": {
|
||||
"version": "16.5.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz",
|
||||
|
|
@ -12184,6 +12186,16 @@
|
|||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom/node_modules/lru-cache": {
|
||||
"version": "11.3.5",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz",
|
||||
"integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom/node_modules/parse5": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
|
||||
|
|
@ -12695,13 +12707,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "11.3.5",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz",
|
||||
"integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==",
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
|
|
@ -13862,19 +13874,6 @@
|
|||
"node": ">=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-abi/node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz",
|
||||
|
|
@ -13893,19 +13892,6 @@
|
|||
"semver": "^7.3.5"
|
||||
}
|
||||
},
|
||||
"node_modules/node-api-version/node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/node-exports-info": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz",
|
||||
|
|
@ -13925,6 +13911,16 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/node-exports-info/node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp": {
|
||||
"version": "12.3.0",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.3.0.tgz",
|
||||
|
|
@ -13960,19 +13956,6 @@
|
|||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp/node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp/node_modules/undici": {
|
||||
"version": "6.25.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz",
|
||||
|
|
@ -14540,14 +14523,6 @@
|
|||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format/node_modules/react-is": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/proc-log": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz",
|
||||
|
|
@ -14594,6 +14569,13 @@
|
|||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types/node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proper-lockfile": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
|
||||
|
|
@ -14815,11 +14797,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-remove-scroll": {
|
||||
"version": "2.7.2",
|
||||
|
|
@ -15489,13 +15472,16 @@
|
|||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/semver-compare": {
|
||||
|
|
@ -15736,19 +15722,6 @@
|
|||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-update-notifier/node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/slice-ansi": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz",
|
||||
|
|
@ -16603,6 +16576,19 @@
|
|||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unicode-animations": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/unicode-animations/-/unicode-animations-1.0.3.tgz",
|
||||
"integrity": "sha512-+klB2oWwcYZjYWhwP4Pr8UZffWDFVx6jKeIahE6z0QYyM2dwDeDPyn5nevCYbyotxvtT9lh21cVURO1RX0+YMg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"unicode-animations": "^1.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"unicode-animations": "scripts/demo.cjs"
|
||||
}
|
||||
},
|
||||
"node_modules/unified": {
|
||||
"version": "11.0.5",
|
||||
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
|
||||
|
|
@ -16838,6 +16824,15 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-stick-to-bottom": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/use-stick-to-bottom/-/use-stick-to-bottom-1.1.4.tgz",
|
||||
"integrity": "sha512-2w/lydkrwhWMv1vCaEhYbzMDhgbwIodHpAHPV0/xKJErRkbjDEUe1EWmvr6Fwb+qhiERjc1EWgAEZaSaF69CpA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
|
|
|
|||
|
|
@ -60,6 +60,8 @@
|
|||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"tw-shimmer": "^0.4.11",
|
||||
"unicode-animations": "^1.0.3",
|
||||
"use-stick-to-bottom": "^1.1.4",
|
||||
"web-haptics": "^0.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -1,24 +1,21 @@
|
|||
import {
|
||||
Copy,
|
||||
Download,
|
||||
ExternalLink,
|
||||
FileImage,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Layers3,
|
||||
Link2,
|
||||
RefreshCw,
|
||||
Search,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import { Copy, ExternalLink, FileImage, FileText, FolderOpen, Layers3, Link2, RefreshCw, Search, X } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { ZoomableImage } from '@/components/assistant-ui/zoomable-image'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Pagination,
|
||||
PaginationButton,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationNext,
|
||||
PaginationPrevious
|
||||
} from '@/components/ui/pagination'
|
||||
import { getSessionMessages, listSessions } from '@/hermes'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
|
@ -49,9 +46,6 @@ const IMAGE_EXT_RE = /\.(?:png|jpe?g|gif|webp|svg|bmp)(?:\?.*)?$/i
|
|||
const FILE_EXT_RE = /\.(?:png|jpe?g|gif|webp|svg|bmp|pdf|txt|json|md|csv|zip|tar|gz|mp3|wav|mp4|mov)(?:\?.*)?$/i
|
||||
const KEY_HINT_RE = /(path|file|url|image|artifact|output|download|result|target)/i
|
||||
|
||||
const imageActionButtonClass =
|
||||
'absolute right-2 top-2 grid size-8 place-items-center rounded-full border border-border/70 bg-background/80 text-muted-foreground opacity-0 shadow-sm backdrop-blur transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 disabled:opacity-50'
|
||||
|
||||
const ARTIFACT_TIME_FMT = new Intl.DateTimeFormat(undefined, {
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
|
|
@ -308,6 +302,43 @@ function formatArtifactTime(timestamp: number): string {
|
|||
return ARTIFACT_TIME_FMT.format(new Date(timestamp))
|
||||
}
|
||||
|
||||
function pageRangeLabel(total: number, page: number, pageSize: number): string {
|
||||
if (total === 0) {
|
||||
return '0'
|
||||
}
|
||||
|
||||
const start = (page - 1) * pageSize + 1
|
||||
const end = Math.min(total, page * pageSize)
|
||||
|
||||
return `${start}-${end} of ${total}`
|
||||
}
|
||||
|
||||
function paginationItems(page: number, pageCount: number): Array<number | 'ellipsis'> {
|
||||
if (pageCount <= 7) {
|
||||
return Array.from({ length: pageCount }, (_, index) => index + 1)
|
||||
}
|
||||
|
||||
const pages: Array<number | 'ellipsis'> = [1]
|
||||
const start = Math.max(2, page - 1)
|
||||
const end = Math.min(pageCount - 1, page + 1)
|
||||
|
||||
if (start > 2) {
|
||||
pages.push('ellipsis')
|
||||
}
|
||||
|
||||
for (let nextPage = start; nextPage <= end; nextPage += 1) {
|
||||
pages.push(nextPage)
|
||||
}
|
||||
|
||||
if (end < pageCount - 1) {
|
||||
pages.push('ellipsis')
|
||||
}
|
||||
|
||||
pages.push(pageCount)
|
||||
|
||||
return pages
|
||||
}
|
||||
|
||||
interface ArtifactsViewProps extends React.ComponentProps<'section'> {
|
||||
setTitlebarActions?: (actions: ReactNode | null) => void
|
||||
}
|
||||
|
|
@ -318,15 +349,15 @@ export function ArtifactsView({ setTitlebarActions, ...props }: ArtifactsViewPro
|
|||
const [query, setQuery] = useState('')
|
||||
const [kindFilter, setKindFilter] = useState<'all' | ArtifactKind>('all')
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [savingArtifactId, setSavingArtifactId] = useState<string | null>(null)
|
||||
const [failedImageIds, setFailedImageIds] = useState<Set<string>>(() => new Set())
|
||||
const [lightboxArtifact, setLightboxArtifact] = useState<ArtifactRecord | null>(null)
|
||||
const [imagePage, setImagePage] = useState(1)
|
||||
const [filePage, setFilePage] = useState(1)
|
||||
|
||||
const refreshArtifacts = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
|
||||
try {
|
||||
const sessions = (await listSessions(30)).sessions
|
||||
const sessions = (await listSessions(30, 1)).sessions
|
||||
const results = await Promise.allSettled(sessions.map(session => getSessionMessages(session.id)))
|
||||
const nextArtifacts: ArtifactRecord[] = []
|
||||
|
||||
|
|
@ -372,6 +403,11 @@ export function ArtifactsView({ setTitlebarActions, ...props }: ArtifactsViewPro
|
|||
return () => setTitlebarActions(null)
|
||||
}, [refreshArtifacts, refreshing, setTitlebarActions])
|
||||
|
||||
useEffect(() => {
|
||||
setImagePage(1)
|
||||
setFilePage(1)
|
||||
}, [artifacts, kindFilter, query])
|
||||
|
||||
const visibleArtifacts = useMemo(() => {
|
||||
if (!artifacts) {
|
||||
return []
|
||||
|
|
@ -396,6 +432,31 @@ export function ArtifactsView({ setTitlebarActions, ...props }: ArtifactsViewPro
|
|||
})
|
||||
}, [artifacts, kindFilter, query])
|
||||
|
||||
const visibleImageArtifacts = useMemo(
|
||||
() => visibleArtifacts.filter(artifact => artifact.kind === 'image'),
|
||||
[visibleArtifacts]
|
||||
)
|
||||
|
||||
const visibleFileArtifacts = useMemo(
|
||||
() => visibleArtifacts.filter(artifact => artifact.kind !== 'image'),
|
||||
[visibleArtifacts]
|
||||
)
|
||||
|
||||
const imagePageCount = Math.max(1, Math.ceil(visibleImageArtifacts.length / 24))
|
||||
const filePageCount = Math.max(1, Math.ceil(visibleFileArtifacts.length / 100))
|
||||
const currentImagePage = Math.min(imagePage, imagePageCount)
|
||||
const currentFilePage = Math.min(filePage, filePageCount)
|
||||
|
||||
const pagedImageArtifacts = useMemo(
|
||||
() => visibleImageArtifacts.slice((currentImagePage - 1) * 24, currentImagePage * 24),
|
||||
[currentImagePage, visibleImageArtifacts]
|
||||
)
|
||||
|
||||
const pagedFileArtifacts = useMemo(
|
||||
() => visibleFileArtifacts.slice((currentFilePage - 1) * 100, currentFilePage * 100),
|
||||
[currentFilePage, visibleFileArtifacts]
|
||||
)
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const all = artifacts || []
|
||||
|
||||
|
|
@ -437,34 +498,6 @@ export function ArtifactsView({ setTitlebarActions, ...props }: ArtifactsViewPro
|
|||
}
|
||||
}, [])
|
||||
|
||||
const saveImageArtifact = useCallback(async (artifact: ArtifactRecord) => {
|
||||
if (artifact.kind !== 'image') {
|
||||
return
|
||||
}
|
||||
|
||||
setSavingArtifactId(artifact.id)
|
||||
|
||||
try {
|
||||
if (!window.hermesDesktop?.saveImageFromUrl) {
|
||||
throw new Error('Image saving is unavailable in this build.')
|
||||
}
|
||||
|
||||
const saved = await window.hermesDesktop.saveImageFromUrl(artifact.href)
|
||||
|
||||
if (saved) {
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: 'Image saved',
|
||||
message: artifact.label
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
notifyError(err, 'Save failed')
|
||||
} finally {
|
||||
setSavingArtifactId(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const markImageFailed = useCallback((id: string) => {
|
||||
setFailedImageIds(current => {
|
||||
if (current.has(id)) {
|
||||
|
|
@ -475,136 +508,208 @@ export function ArtifactsView({ setTitlebarActions, ...props }: ArtifactsViewPro
|
|||
})
|
||||
}, [])
|
||||
|
||||
const imageLightbox = lightboxArtifact ? (
|
||||
<Dialog onOpenChange={open => !open && setLightboxArtifact(null)} open>
|
||||
<DialogContent
|
||||
className="grid max-h-[calc(100vh-2rem)] w-auto max-w-[calc(100vw-2rem)] place-items-center overflow-visible border-0 bg-transparent p-0 shadow-none"
|
||||
showCloseButton={false}
|
||||
>
|
||||
<div className="group/lightbox relative max-h-[calc(100vh-2rem)] max-w-[calc(100vw-2rem)] overflow-auto">
|
||||
<img
|
||||
alt={lightboxArtifact.label}
|
||||
className="block max-h-[calc(100vh-2rem)] max-w-full cursor-zoom-out select-auto rounded-lg object-contain shadow-2xl"
|
||||
onClick={() => setLightboxArtifact(null)}
|
||||
src={lightboxArtifact.href}
|
||||
/>
|
||||
<button
|
||||
aria-label={savingArtifactId === lightboxArtifact.id ? 'Saving image' : 'Download image'}
|
||||
className={cn(imageActionButtonClass, 'group-hover/lightbox:opacity-100')}
|
||||
disabled={savingArtifactId === lightboxArtifact.id}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
void saveImageArtifact(lightboxArtifact)
|
||||
}}
|
||||
title={savingArtifactId === lightboxArtifact.id ? 'Saving image' : 'Download image'}
|
||||
type="button"
|
||||
>
|
||||
<Download className={cn('size-4', savingArtifactId === lightboxArtifact.id && 'animate-pulse')} />
|
||||
</button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<>
|
||||
<section
|
||||
{...props}
|
||||
className="flex h-[calc(100vh-0.375rem)] min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-background"
|
||||
>
|
||||
<header className={titlebarHeaderBaseClass}>
|
||||
<h2 className="text-base font-semibold leading-none tracking-tight">Artifacts</h2>
|
||||
<span className="text-xs text-muted-foreground">{counts.all} found</span>
|
||||
</header>
|
||||
<section
|
||||
{...props}
|
||||
className="flex h-[calc(100vh-0.375rem)] min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-background"
|
||||
>
|
||||
<header className={titlebarHeaderBaseClass}>
|
||||
<h2 className="text-base font-semibold leading-none tracking-tight">Artifacts</h2>
|
||||
<span className="text-xs text-muted-foreground">{counts.all} found</span>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden rounded-[1.0625rem] border border-border/50 bg-background/85">
|
||||
<div className="border-b border-border/50 px-4 py-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<FilterButton
|
||||
active={kindFilter === 'all'}
|
||||
icon={Layers3}
|
||||
label={`All (${counts.all})`}
|
||||
onClick={() => setKindFilter('all')}
|
||||
/>
|
||||
<FilterButton
|
||||
active={kindFilter === 'image'}
|
||||
icon={FileImage}
|
||||
label={`Images (${counts.image})`}
|
||||
onClick={() => setKindFilter('image')}
|
||||
/>
|
||||
<FilterButton
|
||||
active={kindFilter === 'file'}
|
||||
icon={FileText}
|
||||
label={`Files (${counts.file})`}
|
||||
onClick={() => setKindFilter('file')}
|
||||
/>
|
||||
<FilterButton
|
||||
active={kindFilter === 'link'}
|
||||
icon={Link2}
|
||||
label={`Links (${counts.link})`}
|
||||
onClick={() => setKindFilter('link')}
|
||||
/>
|
||||
<div className="ml-auto w-full max-w-sm min-w-64">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="h-8 rounded-lg pl-8 pr-8 text-sm"
|
||||
onChange={event => setQuery(event.target.value)}
|
||||
placeholder="Search artifacts..."
|
||||
value={query}
|
||||
/>
|
||||
{query && (
|
||||
<Button
|
||||
aria-label="Clear search"
|
||||
className="absolute right-1 top-1/2 h-6 w-6 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setQuery('')}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-hidden rounded-[1.0625rem] border border-border/50 bg-background/85">
|
||||
<div className="border-b border-border/50 px-4 py-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<FilterButton
|
||||
active={kindFilter === 'all'}
|
||||
icon={Layers3}
|
||||
label={`All (${counts.all})`}
|
||||
onClick={() => setKindFilter('all')}
|
||||
/>
|
||||
<FilterButton
|
||||
active={kindFilter === 'image'}
|
||||
icon={FileImage}
|
||||
label={`Images (${counts.image})`}
|
||||
onClick={() => setKindFilter('image')}
|
||||
/>
|
||||
<FilterButton
|
||||
active={kindFilter === 'file'}
|
||||
icon={FileText}
|
||||
label={`Files (${counts.file})`}
|
||||
onClick={() => setKindFilter('file')}
|
||||
/>
|
||||
<FilterButton
|
||||
active={kindFilter === 'link'}
|
||||
icon={Link2}
|
||||
label={`Links (${counts.link})`}
|
||||
onClick={() => setKindFilter('link')}
|
||||
/>
|
||||
<div className="ml-auto w-full max-w-sm min-w-64">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="h-8 rounded-lg pl-8 pr-8 text-sm"
|
||||
onChange={event => setQuery(event.target.value)}
|
||||
placeholder="Search artifacts..."
|
||||
value={query}
|
||||
/>
|
||||
{query && (
|
||||
<Button
|
||||
aria-label="Clear search"
|
||||
className="absolute right-1 top-1/2 h-6 w-6 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setQuery('')}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!artifacts ? (
|
||||
<PageLoader label="Indexing recent session artifacts" />
|
||||
) : visibleArtifacts.length === 0 ? (
|
||||
<div className="grid h-full place-items-center px-6 text-center">
|
||||
<div>
|
||||
<div className="text-sm font-medium">No artifacts found</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
Generated images and file outputs will appear here as sessions produce them.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full overflow-y-auto p-3">
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(13rem,1fr))] items-start gap-3">
|
||||
{visibleArtifacts.map(artifact => (
|
||||
<ArtifactCard
|
||||
artifact={artifact}
|
||||
failedImage={failedImageIds.has(artifact.id)}
|
||||
key={artifact.id}
|
||||
onCopy={copyArtifact}
|
||||
onImageError={markImageFailed}
|
||||
onOpen={openArtifact}
|
||||
onOpenChat={sessionId => navigate(sessionRoute(sessionId))}
|
||||
onSaveImage={saveImageArtifact}
|
||||
onZoom={setLightboxArtifact}
|
||||
saving={savingArtifactId === artifact.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
{imageLightbox}
|
||||
</>
|
||||
|
||||
{!artifacts ? (
|
||||
<PageLoader label="Indexing recent session artifacts" />
|
||||
) : visibleArtifacts.length === 0 ? (
|
||||
<div className="grid h-full place-items-center px-6 text-center">
|
||||
<div>
|
||||
<div className="text-sm font-medium">No artifacts found</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
Generated images and file outputs will appear here as sessions produce them.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="flex flex-col gap-4 px-2 pb-2">
|
||||
{visibleImageArtifacts.length > 0 && (
|
||||
<section aria-labelledby="artifacts-images-heading" className="flex flex-col">
|
||||
<div className="sticky top-0 z-10 -mx-2 flex h-7 items-center justify-between gap-3 overflow-x-auto bg-background px-3">
|
||||
<h3 className="shrink-0 text-xs font-semibold" id="artifacts-images-heading">
|
||||
Images
|
||||
</h3>
|
||||
<ArtifactsPagination
|
||||
className="justify-end px-0"
|
||||
itemLabel="images"
|
||||
onPageChange={setImagePage}
|
||||
page={currentImagePage}
|
||||
pageSize={24}
|
||||
total={visibleImageArtifacts.length}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(12rem,1fr))] items-start gap-2 pt-1.5">
|
||||
{pagedImageArtifacts.map(artifact => (
|
||||
<ArtifactImageCard
|
||||
artifact={artifact}
|
||||
failedImage={failedImageIds.has(artifact.id)}
|
||||
key={artifact.id}
|
||||
onImageError={markImageFailed}
|
||||
onOpenChat={sessionId => navigate(sessionRoute(sessionId))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{visibleFileArtifacts.length > 0 && (
|
||||
<section aria-labelledby="artifacts-files-heading" className="flex flex-col">
|
||||
<div className="sticky top-0 z-10 -mx-2 flex h-7 items-center justify-between gap-3 overflow-x-auto bg-background px-3">
|
||||
<h3 className="shrink-0 text-xs font-semibold" id="artifacts-files-heading">
|
||||
{kindFilter === 'link' ? 'Links' : kindFilter === 'file' ? 'Files' : 'Files and links'}
|
||||
</h3>
|
||||
<ArtifactsPagination
|
||||
className="justify-end px-0"
|
||||
itemLabel="files"
|
||||
onPageChange={setFilePage}
|
||||
page={currentFilePage}
|
||||
pageSize={100}
|
||||
total={visibleFileArtifacts.length}
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded-lg border border-border/50 bg-background/70 shadow-[0_0.125rem_0.5rem_color-mix(in_srgb,black_3%,transparent)]">
|
||||
<table className="w-full min-w-176 table-fixed text-left text-xs">
|
||||
<thead className="border-b border-border/50 bg-muted/35 text-[0.62rem] uppercase tracking-[0.08em] text-muted-foreground">
|
||||
<tr>
|
||||
<th className="w-[31%] px-2.5 py-1.5 font-medium">Name</th>
|
||||
<th className="w-[35%] px-2.5 py-1.5 font-medium">Location</th>
|
||||
<th className="w-[22%] px-2.5 py-1.5 font-medium">Session</th>
|
||||
<th className="w-[12%] px-2.5 py-1.5 text-right font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/45">
|
||||
{pagedFileArtifacts.map(artifact => (
|
||||
<ArtifactListRow
|
||||
artifact={artifact}
|
||||
key={artifact.id}
|
||||
onCopy={copyArtifact}
|
||||
onOpen={openArtifact}
|
||||
onOpenChat={sessionId => navigate(sessionRoute(sessionId))}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
interface ArtifactsPaginationProps {
|
||||
className?: string
|
||||
itemLabel: string
|
||||
onPageChange: (page: number) => void
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
}
|
||||
|
||||
function ArtifactsPagination({ className, itemLabel, onPageChange, page, pageSize, total }: ArtifactsPaginationProps) {
|
||||
const pageCount = Math.max(1, Math.ceil(total / pageSize))
|
||||
|
||||
return (
|
||||
<div className={cn('flex h-6 items-center justify-between gap-2 px-1', className)}>
|
||||
<div className="shrink-0 text-[0.62rem] text-muted-foreground">
|
||||
{pageRangeLabel(total, page, pageSize)} {itemLabel}
|
||||
</div>
|
||||
{pageCount > 1 && (
|
||||
<Pagination className="mx-0 w-auto min-w-0 justify-end">
|
||||
<PaginationContent className="gap-0.5">
|
||||
<PaginationItem>
|
||||
<PaginationPrevious disabled={page <= 1} onClick={() => onPageChange(Math.max(1, page - 1))} />
|
||||
</PaginationItem>
|
||||
{paginationItems(page, pageCount).map((item, index) => (
|
||||
<PaginationItem key={`${item}-${index}`}>
|
||||
{item === 'ellipsis' ? (
|
||||
<PaginationEllipsis />
|
||||
) : (
|
||||
<PaginationButton
|
||||
aria-label={`Go to ${itemLabel} page ${item}`}
|
||||
isActive={page === item}
|
||||
onClick={() => onPageChange(item)}
|
||||
>
|
||||
{item}
|
||||
</PaginationButton>
|
||||
)}
|
||||
</PaginationItem>
|
||||
))}
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
disabled={page >= pageCount}
|
||||
onClick={() => onPageChange(Math.min(pageCount, page + 1))}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -636,52 +741,106 @@ function FilterButton({
|
|||
)
|
||||
}
|
||||
|
||||
interface ArtifactCardProps {
|
||||
interface ArtifactImageCardProps {
|
||||
artifact: ArtifactRecord
|
||||
failedImage: boolean
|
||||
onCopy: (value: string) => void | Promise<void>
|
||||
onImageError: (id: string) => void
|
||||
onOpen: (href: string) => void | Promise<void>
|
||||
onOpenChat: (sessionId: string) => void
|
||||
onSaveImage: (artifact: ArtifactRecord) => void | Promise<void>
|
||||
onZoom: (artifact: ArtifactRecord) => void
|
||||
saving: boolean
|
||||
}
|
||||
|
||||
function ArtifactCard({
|
||||
artifact,
|
||||
failedImage,
|
||||
onCopy,
|
||||
onImageError,
|
||||
onOpen,
|
||||
onOpenChat,
|
||||
onSaveImage,
|
||||
onZoom,
|
||||
saving
|
||||
}: ArtifactCardProps) {
|
||||
const image = artifact.kind === 'image'
|
||||
|
||||
if (!image) {
|
||||
const Icon = artifact.kind === 'file' ? FileText : Link2
|
||||
|
||||
return (
|
||||
<article className="group/artifact grid grid-cols-[2rem_minmax(0,1fr)_auto] items-start gap-2 rounded-xl border border-border/50 bg-background/70 p-3 shadow-[0_0.1875rem_0.75rem_color-mix(in_srgb,black_3%,transparent)]">
|
||||
<div className="mt-0.5 grid size-8 place-items-center rounded-lg bg-muted text-muted-foreground">
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }: ArtifactImageCardProps) {
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
'group/artifact overflow-hidden rounded-lg border border-border/50 bg-background/70 shadow-[0_0.125rem_0.5rem_color-mix(in_srgb,black_3%,transparent)]',
|
||||
'bg-muted/20'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex h-44 w-full items-center justify-center overflow-hidden border-b border-border/50 bg-[color-mix(in_srgb,var(--dt-muted)_58%,var(--dt-background))] p-1.5',
|
||||
failedImage && 'cursor-default'
|
||||
)}
|
||||
>
|
||||
{!failedImage && (
|
||||
<ZoomableImage
|
||||
alt={artifact.label}
|
||||
className="max-h-40 max-w-full rounded-md object-contain shadow-sm"
|
||||
containerClassName="max-h-full"
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
onError={() => onImageError(artifact.id)}
|
||||
slot="artifact-media"
|
||||
src={artifact.href}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 p-2">
|
||||
<div className="min-w-0">
|
||||
<div className="mb-1 flex items-center gap-1.5 text-[0.68rem] uppercase tracking-[0.08em] text-muted-foreground">
|
||||
<div className="mb-0.5 flex items-center gap-1 text-[0.62rem] uppercase tracking-[0.08em] text-muted-foreground">
|
||||
<FileImage className="size-3" />
|
||||
{artifact.kind}
|
||||
</div>
|
||||
<div className="truncate text-sm font-medium">{artifact.label}</div>
|
||||
<div className="mt-0.5 truncate font-mono text-[0.68rem] text-muted-foreground/80">{artifact.value}</div>
|
||||
<div className="mt-2 truncate text-[0.68rem] text-muted-foreground">
|
||||
{artifact.sessionTitle} · {formatArtifactTime(artifact.timestamp)}
|
||||
</div>
|
||||
<div className="truncate text-xs font-medium">{artifact.label}</div>
|
||||
<div className="mt-0.5 truncate text-[0.62rem] text-muted-foreground">{artifact.value}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-0.5 opacity-70 transition-opacity group-hover/artifact:opacity-100">
|
||||
<div className="truncate text-[0.62rem] text-muted-foreground">
|
||||
{artifact.sessionTitle} · {formatArtifactTime(artifact.timestamp)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Button onClick={() => onOpenChat(artifact.sessionId)} size="xs" type="button" variant="outline">
|
||||
<FolderOpen className="size-3" />
|
||||
Chat
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
interface ArtifactListRowProps {
|
||||
artifact: ArtifactRecord
|
||||
onCopy: (value: string) => void | Promise<void>
|
||||
onOpen: (href: string) => void | Promise<void>
|
||||
onOpenChat: (sessionId: string) => void
|
||||
}
|
||||
|
||||
function ArtifactListRow({ artifact, onCopy, onOpen, onOpenChat }: ArtifactListRowProps) {
|
||||
const Icon = artifact.kind === 'file' ? FileText : Link2
|
||||
|
||||
return (
|
||||
<tr className="group/artifact transition-colors hover:bg-muted/30">
|
||||
<td className="px-2.5 py-1.5 align-middle">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="grid size-7 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground">
|
||||
<Icon className="size-3.5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium" title={artifact.label}>
|
||||
{artifact.label}
|
||||
</div>
|
||||
<div className="text-[0.6rem] uppercase tracking-[0.08em] text-muted-foreground">{artifact.kind}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-2.5 py-1.5 align-middle">
|
||||
<div className="truncate font-mono text-[0.68rem] text-muted-foreground/85" title={artifact.value}>
|
||||
{artifact.value}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-2.5 py-1.5 align-middle">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[0.68rem] text-muted-foreground" title={artifact.sessionTitle}>
|
||||
{artifact.sessionTitle}
|
||||
</div>
|
||||
<div className="text-[0.6rem] text-muted-foreground/75">{formatArtifactTime(artifact.timestamp)}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-2.5 py-1.5 align-middle">
|
||||
<div className="flex justify-end gap-0.5 opacity-70 transition-opacity group-hover/artifact:opacity-100">
|
||||
<Button
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => void onOpen(artifact.href)}
|
||||
|
|
@ -713,83 +872,7 @@ function ArtifactCard({
|
|||
<FolderOpen className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
'group/artifact overflow-hidden rounded-xl border border-border/50 bg-background/70 shadow-[0_0.1875rem_0.75rem_color-mix(in_srgb,black_3%,transparent)]',
|
||||
image && 'bg-muted/20'
|
||||
)}
|
||||
>
|
||||
{image && (
|
||||
<button
|
||||
aria-label={failedImage ? undefined : `Open ${artifact.label}`}
|
||||
className={cn(
|
||||
'relative flex h-56 w-full items-center justify-center overflow-hidden border-b border-border/50 bg-[color-mix(in_srgb,var(--dt-muted)_58%,var(--dt-background))] p-2',
|
||||
failedImage ? 'cursor-default' : 'cursor-zoom-in'
|
||||
)}
|
||||
disabled={failedImage}
|
||||
onClick={() => onZoom(artifact)}
|
||||
title={failedImage ? undefined : 'Open image'}
|
||||
type="button"
|
||||
>
|
||||
{!failedImage && (
|
||||
<>
|
||||
<img
|
||||
alt=""
|
||||
className="max-h-full max-w-full rounded-md object-contain shadow-sm"
|
||||
data-slot="artifact-media"
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
onError={() => onImageError(artifact.id)}
|
||||
src={artifact.href}
|
||||
/>
|
||||
<span
|
||||
aria-label={saving ? 'Saving image' : 'Download image'}
|
||||
className={cn(imageActionButtonClass, 'group-hover/artifact:opacity-100')}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
void onSaveImage(artifact)
|
||||
}}
|
||||
title={saving ? 'Saving image' : 'Download image'}
|
||||
>
|
||||
<Download className={cn('size-4', saving && 'animate-pulse')} />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 p-3">
|
||||
<div className="min-w-0">
|
||||
<div className="mb-1 flex items-center gap-1.5 text-[0.68rem] uppercase tracking-[0.08em] text-muted-foreground">
|
||||
{image ? (
|
||||
<FileImage className="size-3.5" />
|
||||
) : artifact.kind === 'file' ? (
|
||||
<FileText className="size-3.5" />
|
||||
) : (
|
||||
<Link2 className="size-3.5" />
|
||||
)}
|
||||
{artifact.kind}
|
||||
</div>
|
||||
<div className="truncate text-sm font-medium">{artifact.label}</div>
|
||||
<div className="mt-0.5 truncate text-[0.68rem] text-muted-foreground">{artifact.value}</div>
|
||||
</div>
|
||||
|
||||
<div className="truncate text-[0.68rem] text-muted-foreground">
|
||||
{artifact.sessionTitle} · {formatArtifactTime(artifact.timestamp)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Button onClick={() => onOpenChat(artifact.sessionId)} size="sm" type="button" variant="outline">
|
||||
<FolderOpen className="size-3.5" />
|
||||
Chat
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export function AttachmentList({
|
|||
onRemove?: (id: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5 px-1 pt-1">
|
||||
<div className="flex flex-wrap gap-1 px-1 pt-1">
|
||||
{attachments.map(a => (
|
||||
<AttachmentPill attachment={a} key={a.id} onRemove={onRemove} />
|
||||
))}
|
||||
|
|
@ -22,28 +22,30 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
|
|||
const Icon = { folder: FolderOpen, url: Link, image: ImageIcon, file: FileText }[attachment.kind]
|
||||
|
||||
return (
|
||||
<div className="group/attachment flex max-w-full items-center gap-2 rounded-2xl border border-border/70 bg-muted/35 py-1 pl-1 pr-1.5 text-xs text-foreground/90">
|
||||
{attachment.previewUrl ? (
|
||||
<img alt="" className="size-9 rounded-xl object-cover" draggable={false} src={attachment.previewUrl} />
|
||||
<div
|
||||
className="group/attachment relative shrink-0"
|
||||
title={attachment.label}
|
||||
>
|
||||
{attachment.previewUrl && attachment.kind === 'image' ? (
|
||||
<img
|
||||
alt={attachment.label}
|
||||
className="size-7 rounded-md border border-border/70 object-cover"
|
||||
draggable={false}
|
||||
src={attachment.previewUrl}
|
||||
/>
|
||||
) : (
|
||||
<span className="grid size-9 shrink-0 place-items-center rounded-xl bg-background/70 text-muted-foreground">
|
||||
<Icon className="size-4" />
|
||||
<span className="grid size-7 place-items-center rounded-md border border-border/70 bg-muted/30 text-muted-foreground">
|
||||
<Icon className="size-3.5" />
|
||||
</span>
|
||||
)}
|
||||
<span className="grid min-w-0 gap-0.5">
|
||||
<span className="truncate font-medium">{attachment.label}</span>
|
||||
{attachment.detail && (
|
||||
<span className="truncate text-[0.6875rem] text-muted-foreground">{attachment.detail}</span>
|
||||
)}
|
||||
</span>
|
||||
{onRemove && (
|
||||
<button
|
||||
aria-label={`Remove ${attachment.label}`}
|
||||
className="grid size-5 shrink-0 place-items-center rounded-full text-muted-foreground opacity-70 transition hover:bg-accent hover:text-foreground group-hover/attachment:opacity-100"
|
||||
className="absolute -right-1 -top-1 grid size-3.5 place-items-center rounded-full border border-border/70 bg-background text-muted-foreground opacity-0 shadow-xs transition hover:bg-accent hover:text-foreground group-hover/attachment:opacity-100 focus-visible:opacity-100"
|
||||
onClick={() => onRemove(attachment.id)}
|
||||
type="button"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
<X className="size-2.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { cn } from '@/lib/utils'
|
|||
import type { ConversationStatus } from './hooks/use-voice-conversation'
|
||||
import type { ChatBarState, VoiceStatus } from './types'
|
||||
|
||||
export const ICON_BTN = 'h-8 w-8 shrink-0 rounded-full'
|
||||
export const ICON_BTN = 'size-(--composer-control-size) shrink-0 rounded-full'
|
||||
export const GHOST_ICON_BTN = cn(ICON_BTN, 'text-muted-foreground hover:bg-accent hover:text-foreground')
|
||||
|
||||
interface ConversationProps {
|
||||
|
|
@ -47,7 +47,7 @@ export function ComposerControls({
|
|||
const showVoicePrimary = !busy && !hasComposerPayload
|
||||
|
||||
return (
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5">
|
||||
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
|
||||
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
|
||||
{showVoicePrimary ? (
|
||||
<Button
|
||||
|
|
@ -102,7 +102,7 @@ function ConversationPill({
|
|||
: 'Listening'
|
||||
|
||||
return (
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
|
||||
<Button
|
||||
aria-label={muted ? 'Unmute microphone' : 'Mute microphone'}
|
||||
aria-pressed={muted}
|
||||
|
|
@ -122,7 +122,7 @@ function ConversationPill({
|
|||
{listening && (
|
||||
<Button
|
||||
aria-label="Stop listening and send"
|
||||
className="h-8 shrink-0 gap-1.5 rounded-full px-2.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
className="h-(--composer-control-size) shrink-0 gap-1.5 rounded-full px-2.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
triggerHaptic('submit')
|
||||
|
|
@ -138,7 +138,7 @@ function ConversationPill({
|
|||
)}
|
||||
<Button
|
||||
aria-label="End voice conversation"
|
||||
className="h-8 gap-1.5 rounded-full bg-primary px-3 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
||||
className="h-(--composer-control-size) gap-1.5 rounded-full bg-primary px-3 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
triggerHaptic('close')
|
||||
|
|
|
|||
|
|
@ -2,22 +2,22 @@ import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-u
|
|||
import { useCallback } from 'react'
|
||||
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import {
|
||||
desktopSlashDescription,
|
||||
filterDesktopCommandsCatalog,
|
||||
isDesktopSlashSuggestion,
|
||||
type CommandsCatalogLike
|
||||
} from '@/lib/desktop-slash-commands'
|
||||
|
||||
import type { CompletionEntry, CompletionPayload } from './use-live-completion-adapter'
|
||||
import { useLiveCompletionAdapter } from './use-live-completion-adapter'
|
||||
|
||||
const PICKER_OWNED = new Set(['/model', '/provider', 'model', 'provider'])
|
||||
|
||||
interface SlashItemMetadata extends Record<string, string> {
|
||||
command: string
|
||||
display: string
|
||||
meta: string
|
||||
}
|
||||
|
||||
interface CommandsCatalogResponse {
|
||||
pairs?: [string, string][]
|
||||
}
|
||||
|
||||
function textValue(value: unknown, fallback = ''): string {
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
|
|
@ -53,14 +53,9 @@ export function useSlashCompletions(options: { gateway: HermesGateway | null }):
|
|||
|
||||
const text = `/${query}`
|
||||
|
||||
// Model/provider have a dedicated picker; suppress slash completions for them once typed.
|
||||
if (text.startsWith('/model') || text.startsWith('/provider')) {
|
||||
return { items: [], query }
|
||||
}
|
||||
|
||||
try {
|
||||
if (!query) {
|
||||
const catalog = await gateway.request<CommandsCatalogResponse>('commands.catalog')
|
||||
const catalog = filterDesktopCommandsCatalog(await gateway.request<CommandsCatalogLike>('commands.catalog'))
|
||||
|
||||
const items = (catalog.pairs ?? [])
|
||||
.map(([command, meta]) => ({
|
||||
|
|
@ -68,13 +63,17 @@ export function useSlashCompletions(options: { gateway: HermesGateway | null }):
|
|||
display: command,
|
||||
meta
|
||||
}))
|
||||
.filter(item => !PICKER_OWNED.has(item.text))
|
||||
|
||||
return { items, query }
|
||||
}
|
||||
|
||||
const result = await gateway.request<{ items?: CompletionEntry[] }>('complete.slash', { text })
|
||||
const items = (result.items ?? []).filter(item => !PICKER_OWNED.has(item.text))
|
||||
const items = (result.items ?? [])
|
||||
.filter(item => isDesktopSlashSuggestion(item.text))
|
||||
.map(item => ({
|
||||
...item,
|
||||
meta: desktopSlashDescription(item.text, textValue(item.meta))
|
||||
}))
|
||||
|
||||
return { items, query }
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -3,17 +3,27 @@ import './liquid-glass-overrides.css'
|
|||
import { ComposerPrimitive, useAui, useAuiState } from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import LiquidGlass from 'liquid-glass-react'
|
||||
import { type ClipboardEvent, type CSSProperties, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
type ClipboardEvent,
|
||||
type CSSProperties,
|
||||
type DragEvent as ReactDragEvent,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
|
||||
import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
|
||||
import { useMediaQuery } from '@/hooks/use-media-query'
|
||||
import { chatMessageText } from '@/lib/chat-messages'
|
||||
import { DATA_IMAGE_URL_RE, dataUrlToBlob } from '@/lib/embedded-images'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $composerAttachments } from '@/store/composer'
|
||||
import { $composerAttachments, $composerDraft } from '@/store/composer'
|
||||
import { $messages } from '@/store/session'
|
||||
import { $threadScrolledUp } from '@/store/thread-scroll'
|
||||
|
||||
import { extractDroppedFiles } from '../hooks/use-composer-actions'
|
||||
|
||||
import { AttachmentList } from './attachments'
|
||||
import { ContextMenu } from './context-menu'
|
||||
import { ComposerControls } from './controls'
|
||||
|
|
@ -24,13 +34,73 @@ import { useComposerGlassTweaks } from './hooks/use-composer-glass-tweaks'
|
|||
import { useSlashCompletions } from './hooks/use-slash-completions'
|
||||
import { useVoiceConversation } from './hooks/use-voice-conversation'
|
||||
import { useVoiceRecorder } from './hooks/use-voice-recorder'
|
||||
import { SkinSlashPopover } from './skin-slash-popover'
|
||||
import { SlashPopover } from './slash-popover'
|
||||
import type { ChatBarProps } from './types'
|
||||
import { UrlDialog } from './url-dialog'
|
||||
import { VoiceActivity, VoicePlaybackActivity } from './voice-activity'
|
||||
|
||||
const COMPOSER_SHELL_CLASS =
|
||||
'group/composer absolute bottom-0 left-1/2 z-30 w-[min(calc(100%-1rem),clamp(26rem,61.8%,56rem))] max-w-full -translate-x-1/2 pt-2 pb-[var(--composer-shell-pad-block-end)]'
|
||||
'group/composer absolute bottom-0 left-1/2 z-30 max-w-full -translate-x-1/2 pt-2 pb-[var(--composer-shell-pad-block-end)]'
|
||||
|
||||
function extractClipboardImageBlobs(clipboard: DataTransfer): Blob[] {
|
||||
const blobs: Blob[] = []
|
||||
const seen = new Set<Blob>()
|
||||
|
||||
const push = (blob: Blob | null) => {
|
||||
if (!blob || blob.size === 0 || seen.has(blob)) {
|
||||
return
|
||||
}
|
||||
|
||||
seen.add(blob)
|
||||
blobs.push(blob)
|
||||
}
|
||||
|
||||
if (clipboard.items?.length) {
|
||||
for (const item of clipboard.items) {
|
||||
if (item.kind === 'file' && item.type.startsWith('image/')) {
|
||||
push(item.getAsFile())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (clipboard.files?.length) {
|
||||
for (let i = 0; i < clipboard.files.length; i += 1) {
|
||||
const file = clipboard.files.item(i)
|
||||
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
push(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (blobs.length > 0) {
|
||||
return blobs
|
||||
}
|
||||
|
||||
const text = clipboard.getData('text/plain').trim()
|
||||
|
||||
if (DATA_IMAGE_URL_RE.test(text)) {
|
||||
push(dataUrlToBlob(text))
|
||||
}
|
||||
|
||||
if (blobs.length === 0) {
|
||||
const html = clipboard.getData('text/html')
|
||||
|
||||
if (html) {
|
||||
const matches = html.matchAll(/<img\b[^>]*?\bsrc\s*=\s*["'](data:image\/[^"']+)["']/gi)
|
||||
|
||||
for (const match of matches) {
|
||||
push(dataUrlToBlob(match[1]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return blobs
|
||||
}
|
||||
|
||||
// Below this composer width the input gets cramped — drop controls onto a second row.
|
||||
const COMPOSER_STACK_BREAKPOINT_PX = 380
|
||||
|
||||
const COMPOSER_SCROLLED_DIM_CLASS =
|
||||
'opacity-30 group-hover/composer:opacity-100 group-focus-within/composer:opacity-100'
|
||||
|
|
@ -58,6 +128,8 @@ export function ChatBar({
|
|||
state,
|
||||
onCancel,
|
||||
onAddUrl,
|
||||
onAttachDroppedItems,
|
||||
onAttachImageBlob,
|
||||
onPasteClipboardImage,
|
||||
onPickFiles,
|
||||
onPickFolders,
|
||||
|
|
@ -82,9 +154,11 @@ export function ChatBar({
|
|||
const [expanded, setExpanded] = useState(false)
|
||||
const [voiceConversationActive, setVoiceConversationActive] = useState(false)
|
||||
const [tight, setTight] = useState(false)
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
const dragDepthRef = useRef(0)
|
||||
const lastSpokenIdRef = useRef<string | null>(null)
|
||||
|
||||
const narrow = useMediaQuery('(max-width: 680px)')
|
||||
const narrow = useMediaQuery('(max-width: 480px)')
|
||||
|
||||
const [askPlaceholder] = useState(() => {
|
||||
const lines = [
|
||||
|
|
@ -108,9 +182,17 @@ export function ChatBar({
|
|||
const canSubmit = busy || hasComposerPayload
|
||||
const showHelpHint = draft === '?'
|
||||
|
||||
const placeholder = disabled
|
||||
? stacked
|
||||
? 'Starting...'
|
||||
: 'Starting Hermes...'
|
||||
: stacked
|
||||
? 'Ask anything'
|
||||
: askPlaceholder
|
||||
|
||||
const glassTweaks = useComposerGlassTweaks()
|
||||
|
||||
const focusInput = () => window.requestAnimationFrame(() => textareaRef.current?.focus())
|
||||
const focusInput = () => window.requestAnimationFrame(() => textareaRef.current?.focus({ preventScroll: true }))
|
||||
|
||||
useEffect(() => {
|
||||
if (!disabled) {
|
||||
|
|
@ -120,11 +202,22 @@ export function ChatBar({
|
|||
|
||||
useEffect(() => {
|
||||
draftRef.current = draft
|
||||
$composerDraft.set(draft)
|
||||
}, [draft])
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
$composerDraft.subscribe(value => {
|
||||
if (value !== draftRef.current) {
|
||||
aui.composer().setText(value)
|
||||
}
|
||||
}),
|
||||
[aui]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (urlOpen) {
|
||||
window.requestAnimationFrame(() => urlInputRef.current?.focus())
|
||||
window.requestAnimationFrame(() => urlInputRef.current?.focus({ preventScroll: true }))
|
||||
}
|
||||
}, [urlOpen])
|
||||
|
||||
|
|
@ -153,7 +246,7 @@ export function ChatBar({
|
|||
return
|
||||
}
|
||||
|
||||
const update = () => setTight(el.getBoundingClientRect().width < 500)
|
||||
const update = () => setTight(el.getBoundingClientRect().width < COMPOSER_STACK_BREAKPOINT_PX)
|
||||
|
||||
update()
|
||||
const ro = new ResizeObserver(update)
|
||||
|
|
@ -172,13 +265,45 @@ export function ChatBar({
|
|||
focusInput()
|
||||
}
|
||||
|
||||
const selectSkinSlashCommand = (command: string) => {
|
||||
draftRef.current = command
|
||||
aui.composer().setText(command)
|
||||
focusInput()
|
||||
}
|
||||
|
||||
const handlePaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const imageBlobs = extractClipboardImageBlobs(event.clipboardData)
|
||||
|
||||
if (imageBlobs.length > 0) {
|
||||
event.preventDefault()
|
||||
|
||||
if (onAttachImageBlob) {
|
||||
triggerHaptic('selection')
|
||||
|
||||
for (const blob of imageBlobs) {
|
||||
void onAttachImageBlob(blob)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const pastedText = event.clipboardData.getData('text')
|
||||
|
||||
if (!pastedText) {
|
||||
return
|
||||
}
|
||||
|
||||
// Some clipboard sources deliver an image as a giant `data:image/...;base64,...`
|
||||
// text/plain payload. Without this guard the whole base64 string would be
|
||||
// inserted into the textarea (and persisted as the user message). Drop it
|
||||
// outright — image pastes belong on the image-blob path above.
|
||||
if (DATA_IMAGE_URL_RE.test(pastedText.trim())) {
|
||||
event.preventDefault()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const trimmedText = pastedText.replace(/^[\t ]*(?:\r\n|\r|\n)+|(?:\r\n|\r|\n)+[\t ]*$/g, '')
|
||||
|
||||
if (trimmedText === pastedText) {
|
||||
|
|
@ -202,19 +327,99 @@ export function ChatBar({
|
|||
return
|
||||
}
|
||||
|
||||
current.focus()
|
||||
current.focus({ preventScroll: true })
|
||||
current.setSelectionRange(cursor, cursor)
|
||||
})
|
||||
}
|
||||
|
||||
const dragHasAttachments = (transfer: DataTransfer | null) => {
|
||||
if (!transfer) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (Array.from(transfer.types || []).includes('Files')) {
|
||||
return true
|
||||
}
|
||||
|
||||
return Array.from(transfer.items || []).some(item => item.kind === 'file')
|
||||
}
|
||||
|
||||
const resetDragState = () => {
|
||||
dragDepthRef.current = 0
|
||||
setDragActive(false)
|
||||
}
|
||||
|
||||
const handleDragEnter = (event: ReactDragEvent<HTMLFormElement>) => {
|
||||
if (!onAttachDroppedItems || !dragHasAttachments(event.dataTransfer)) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
dragDepthRef.current += 1
|
||||
|
||||
if (!dragActive) {
|
||||
setDragActive(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragOver = (event: ReactDragEvent<HTMLFormElement>) => {
|
||||
if (!onAttachDroppedItems || !dragHasAttachments(event.dataTransfer)) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.dataTransfer.dropEffect = 'copy'
|
||||
}
|
||||
|
||||
const handleDragLeave = (event: ReactDragEvent<HTMLFormElement>) => {
|
||||
if (!onAttachDroppedItems) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
|
||||
|
||||
if (dragDepthRef.current === 0) {
|
||||
setDragActive(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrop = (event: ReactDragEvent<HTMLFormElement>) => {
|
||||
if (!onAttachDroppedItems) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
resetDragState()
|
||||
|
||||
const candidates = extractDroppedFiles(event.dataTransfer)
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
void Promise.resolve(onAttachDroppedItems(candidates)).then(attached => {
|
||||
if (attached) {
|
||||
triggerHaptic('selection')
|
||||
focusInput()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const clearDraft = () => {
|
||||
aui.composer().setText('')
|
||||
draftRef.current = ''
|
||||
}
|
||||
|
||||
const submitDraft = () => {
|
||||
if (busy) {
|
||||
triggerHaptic('cancel')
|
||||
onCancel()
|
||||
} else if (draft.trim() || attachments.length > 0) {
|
||||
const submitted = draft
|
||||
triggerHaptic('submit')
|
||||
void onSubmit(draft)
|
||||
aui.composer().setText('')
|
||||
clearDraft()
|
||||
void onSubmit(submitted)
|
||||
}
|
||||
|
||||
focusInput()
|
||||
|
|
@ -281,9 +486,8 @@ export function ChatBar({
|
|||
}
|
||||
|
||||
triggerHaptic('submit')
|
||||
clearDraft()
|
||||
await onSubmit(text)
|
||||
aui.composer().setText('')
|
||||
draftRef.current = ''
|
||||
}
|
||||
|
||||
const conversation = useVoiceConversation({
|
||||
|
|
@ -339,13 +543,13 @@ export function ChatBar({
|
|||
const input = (
|
||||
<ComposerPrimitive.Input
|
||||
className={cn(
|
||||
'min-h-8 max-h-37.5 resize-none overflow-y-auto bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none placeholder:text-muted-foreground/80 disabled:cursor-not-allowed',
|
||||
'min-h-(--composer-input-min-height) max-h-(--composer-input-max-height) resize-none overflow-y-auto bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none placeholder:text-muted-foreground/80 disabled:cursor-not-allowed',
|
||||
stacked && 'pl-3',
|
||||
stacked ? 'w-full' : 'min-w-48 flex-1'
|
||||
stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1'
|
||||
)}
|
||||
disabled={disabled}
|
||||
onPaste={handlePaste}
|
||||
placeholder={disabled ? 'Starting Hermes...' : askPlaceholder}
|
||||
placeholder={placeholder}
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
unstable_focusOnScrollToBottom={false}
|
||||
|
|
@ -357,8 +561,13 @@ export function ChatBar({
|
|||
<ComposerPrimitive.Unstable_TriggerPopoverRoot>
|
||||
<ComposerPrimitive.Root
|
||||
className={COMPOSER_SHELL_CLASS}
|
||||
data-drag-active={dragActive ? '' : undefined}
|
||||
data-slot="composer-root"
|
||||
data-thread-scrolled-up={scrolledUp ? '' : undefined}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onSubmit={e => {
|
||||
e.preventDefault()
|
||||
submitDraft()
|
||||
|
|
@ -378,6 +587,7 @@ export function ChatBar({
|
|||
loading={at.loading}
|
||||
/>
|
||||
<SlashPopover adapter={slash.adapter} loading={slash.loading} />
|
||||
<SkinSlashPopover draft={draft} onSelect={selectSkinSlashCommand} />
|
||||
<div className="pointer-events-none absolute inset-0" style={{ background: glassTweaks.fadeBackground }} />
|
||||
<div className="relative w-full">
|
||||
<div
|
||||
|
|
@ -413,14 +623,23 @@ export function ChatBar({
|
|||
'relative z-4 isolate overflow-hidden rounded-(--composer-active-radius) border border-input/70 shadow-composer transition-[border-color,box-shadow] duration-200 ease-out',
|
||||
'group-focus-within/composer:border-ring/35 group-focus-within/composer:shadow-composer-focus',
|
||||
'group-has-data-[state=open]/composer:rounded-t-none group-has-data-[state=open]/composer:border-t-transparent',
|
||||
'group-has-data-[state=open]/composer:shadow-[0_0.0625rem_0_0.0625rem_color-mix(in_srgb,var(--dt-ring)_35%,transparent),0_0.5rem_1.5rem_color-mix(in_srgb,var(--shadow-ink)_6%,transparent)]'
|
||||
'group-has-data-[state=open]/composer:shadow-[0_0.0625rem_0_0.0625rem_color-mix(in_srgb,var(--dt-ring)_35%,transparent),0_0.5rem_1.5rem_color-mix(in_srgb,var(--shadow-ink)_6%,transparent)]',
|
||||
dragActive && 'border-primary/70 shadow-composer-focus ring-2 ring-primary/40'
|
||||
)}
|
||||
data-slot="composer-surface"
|
||||
>
|
||||
<div aria-hidden className={COMPOSER_FROST_CLASS} />
|
||||
{dragActive && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 z-3 flex items-center justify-center rounded-(--composer-active-radius) bg-primary/10 text-sm font-medium text-primary backdrop-blur-[1px]"
|
||||
>
|
||||
Drop files to attach
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-1 flex min-h-0 w-full flex-col gap-1.5 px-2 py-1.5 transition-opacity duration-200 ease-out',
|
||||
'relative z-1 flex min-h-0 w-full flex-col gap-(--composer-row-gap) px-(--composer-surface-pad-x) py-(--composer-surface-pad-y) transition-opacity duration-200 ease-out',
|
||||
scrolledUp ? COMPOSER_SCROLLED_DIM_CLASS : 'opacity-100'
|
||||
)}
|
||||
data-slot="composer-fade"
|
||||
|
|
@ -431,13 +650,13 @@ export function ChatBar({
|
|||
{stacked ? (
|
||||
<>
|
||||
{input}
|
||||
<div className="flex w-full items-center gap-1.5">
|
||||
<div className="flex w-full items-center gap-(--composer-control-gap)">
|
||||
{contextMenu}
|
||||
{controls}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex w-full items-end gap-1.5">
|
||||
<div className="flex w-full items-end gap-(--composer-control-gap)">
|
||||
{contextMenu}
|
||||
{input}
|
||||
{controls}
|
||||
|
|
@ -468,7 +687,7 @@ export function ChatBarFallback() {
|
|||
data-slot="composer-root"
|
||||
style={{ '--composer-active-radius': '1.25rem' } as CSSProperties}
|
||||
>
|
||||
<div className="relative isolate h-11 w-full overflow-hidden rounded-(--composer-active-radius) border border-input/70 shadow-composer">
|
||||
<div className="relative isolate h-(--composer-fallback-height) w-full overflow-hidden rounded-(--composer-active-radius) border border-input/70 shadow-composer">
|
||||
<div aria-hidden className={COMPOSER_FROST_CLASS} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
56
apps/desktop/src/app/chat/composer/skin-slash-popover.tsx
Normal file
56
apps/desktop/src/app/chat/composer/skin-slash-popover.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { desktopSkinSlashCompletions } from '@/lib/desktop-slash-commands'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { useTheme } from '@/themes/context'
|
||||
|
||||
import { COMPLETION_DRAWER_CLASS, COMPLETION_DRAWER_ROW_CLASS, CompletionDrawerEmpty } from './completion-drawer'
|
||||
|
||||
interface SkinSlashPopoverProps {
|
||||
draft: string
|
||||
onSelect: (command: string) => void
|
||||
}
|
||||
|
||||
export function SkinSlashPopover({ draft, onSelect }: SkinSlashPopoverProps) {
|
||||
const { availableThemes, themeName } = useTheme()
|
||||
const match = draft.match(/^\/skin\s+(\S*)$/i)
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
const items = desktopSkinSlashCompletions(availableThemes, themeName, match[1] ?? '')
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-label="Desktop theme suggestions"
|
||||
className={COMPLETION_DRAWER_CLASS}
|
||||
data-slot="composer-skin-completion-drawer"
|
||||
data-state="open"
|
||||
role="listbox"
|
||||
>
|
||||
<div className="grid gap-0.5 pt-0.5">
|
||||
{items.length === 0 ? (
|
||||
<CompletionDrawerEmpty title="No matching themes.">
|
||||
Try <span className="font-mono text-foreground/80">/skin list</span>.
|
||||
</CompletionDrawerEmpty>
|
||||
) : (
|
||||
items.map(item => (
|
||||
<button
|
||||
className={COMPLETION_DRAWER_ROW_CLASS}
|
||||
key={item.text}
|
||||
onClick={() => {
|
||||
triggerHaptic('selection')
|
||||
onSelect(item.text)
|
||||
}}
|
||||
onMouseDown={event => event.preventDefault()}
|
||||
role="option"
|
||||
type="button"
|
||||
>
|
||||
<span className="shrink-0 font-mono font-medium leading-5 text-foreground">{item.display}</span>
|
||||
<span className="min-w-0 truncate leading-5 text-muted-foreground/80">{item.meta}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -28,7 +28,7 @@ export function SlashPopover({ adapter, loading }: { adapter: Unstable_TriggerAd
|
|||
<div className="grid gap-0.5 pt-0.5">
|
||||
{items.length === 0 ? (
|
||||
<CompletionDrawerEmpty title={loading ? 'Looking up...' : 'No matching commands.'}>
|
||||
Try <span className="font-mono text-foreground/80">/help</span> for the full list.
|
||||
Try <span className="font-mono text-foreground/80">/help</span> for the desktop command list.
|
||||
</CompletionDrawerEmpty>
|
||||
) : (
|
||||
items.map((item, index) => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import type { HermesGateway } from '@/hermes'
|
||||
|
||||
import type { DroppedFile } from '../hooks/use-composer-actions'
|
||||
|
||||
export interface ContextSuggestion {
|
||||
text: string
|
||||
display: string
|
||||
|
|
@ -36,6 +38,8 @@ export interface ChatBarProps {
|
|||
onCancel: () => void
|
||||
onAddContextRef?: (refText: string, label?: string, detail?: string) => void
|
||||
onAddUrl?: (url: string) => void
|
||||
onAttachImageBlob?: (blob: Blob) => Promise<boolean | void> | boolean | void
|
||||
onAttachDroppedItems?: (candidates: DroppedFile[]) => Promise<boolean | void> | boolean | void
|
||||
onPasteClipboardImage?: () => void
|
||||
onPickFiles?: () => void
|
||||
onPickFolders?: () => void
|
||||
|
|
|
|||
|
|
@ -5,7 +5,88 @@ import { attachmentId, contextPath, pathLabel } from '@/lib/chat-runtime'
|
|||
import { addComposerAttachment, type ComposerAttachment, removeComposerAttachment } from '@/store/composer'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
import type { ImageAttachResponse, ImageDetachResponse } from '../../types'
|
||||
import type { ImageDetachResponse } from '../../types'
|
||||
|
||||
const IMAGE_EXTENSION_PATTERN = /\.(png|jpe?g|gif|webp|bmp|tiff?|svg|ico)$/i
|
||||
|
||||
const BLOB_MIME_EXTENSION: Record<string, string> = {
|
||||
'image/bmp': '.bmp',
|
||||
'image/gif': '.gif',
|
||||
'image/jpeg': '.jpg',
|
||||
'image/png': '.png',
|
||||
'image/svg+xml': '.svg',
|
||||
'image/tiff': '.tiff',
|
||||
'image/webp': '.webp',
|
||||
'image/x-icon': '.ico'
|
||||
}
|
||||
|
||||
function blobExtension(blob: Blob): string {
|
||||
const mime = blob.type.split(';')[0]?.trim().toLowerCase()
|
||||
|
||||
return (mime && BLOB_MIME_EXTENSION[mime]) || '.png'
|
||||
}
|
||||
|
||||
function isImagePath(filePath: string): boolean {
|
||||
return IMAGE_EXTENSION_PATTERN.test(filePath)
|
||||
}
|
||||
|
||||
export interface DroppedFile {
|
||||
file: File
|
||||
path: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Eagerly resolve files from a drop event into [File, path] pairs.
|
||||
*
|
||||
* Must be called synchronously from inside the drop handler — `DataTransfer`
|
||||
* items are detached as soon as the handler returns, and `webUtils.getPathForFile`
|
||||
* also requires the original (non-cloned) File reference.
|
||||
*/
|
||||
export function extractDroppedFiles(transfer: DataTransfer): DroppedFile[] {
|
||||
const result: DroppedFile[] = []
|
||||
const seen = new Set<File>()
|
||||
const getPath = window.hermesDesktop?.getPathForFile
|
||||
|
||||
const fileList = transfer.files
|
||||
if (fileList) {
|
||||
for (let i = 0; i < fileList.length; i += 1) {
|
||||
const file = fileList.item(i)
|
||||
if (!file || seen.has(file)) continue
|
||||
seen.add(file)
|
||||
let path = ''
|
||||
if (getPath) {
|
||||
try {
|
||||
path = getPath(file) || ''
|
||||
} catch {
|
||||
path = ''
|
||||
}
|
||||
}
|
||||
result.push({ file, path })
|
||||
}
|
||||
}
|
||||
|
||||
const items = transfer.items
|
||||
if (items) {
|
||||
for (let i = 0; i < items.length; i += 1) {
|
||||
const item = items[i]
|
||||
if (!item || item.kind !== 'file') continue
|
||||
const file = item.getAsFile()
|
||||
if (!file || seen.has(file)) continue
|
||||
seen.add(file)
|
||||
let path = ''
|
||||
if (getPath) {
|
||||
try {
|
||||
path = getPath(file) || ''
|
||||
} catch {
|
||||
path = ''
|
||||
}
|
||||
}
|
||||
result.push({ file, path })
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
interface ComposerActionsOptions {
|
||||
activeSessionId: string | null
|
||||
|
|
@ -13,7 +94,11 @@ interface ComposerActionsOptions {
|
|||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
}
|
||||
|
||||
export function useComposerActions({ activeSessionId, currentCwd, requestGateway }: ComposerActionsOptions) {
|
||||
export function useComposerActions({
|
||||
activeSessionId,
|
||||
currentCwd,
|
||||
requestGateway
|
||||
}: ComposerActionsOptions) {
|
||||
const addContextRefAttachment = useCallback((refText: string, label?: string, detail?: string) => {
|
||||
let kind: ComposerAttachment['kind'] = 'file'
|
||||
|
||||
|
|
@ -62,11 +147,93 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
|||
[currentCwd]
|
||||
)
|
||||
|
||||
const pickImages = useCallback(async () => {
|
||||
if (!activeSessionId) {
|
||||
return
|
||||
}
|
||||
const attachContextFilePath = useCallback(
|
||||
(filePath: string) => {
|
||||
if (!filePath) {
|
||||
return false
|
||||
}
|
||||
|
||||
const rel = contextPath(filePath, currentCwd)
|
||||
|
||||
addComposerAttachment({
|
||||
id: attachmentId('file', rel),
|
||||
kind: 'file',
|
||||
label: pathLabel(filePath),
|
||||
detail: rel,
|
||||
refText: `@file:${formatRefValue(rel)}`,
|
||||
path: filePath
|
||||
})
|
||||
|
||||
return true
|
||||
},
|
||||
[currentCwd]
|
||||
)
|
||||
|
||||
const attachImagePath = useCallback(
|
||||
async (filePath: string) => {
|
||||
if (!filePath) {
|
||||
return false
|
||||
}
|
||||
|
||||
const baseAttachment: ComposerAttachment = {
|
||||
id: attachmentId('image', filePath),
|
||||
kind: 'image',
|
||||
label: pathLabel(filePath),
|
||||
detail: filePath,
|
||||
path: filePath
|
||||
}
|
||||
|
||||
addComposerAttachment(baseAttachment)
|
||||
|
||||
try {
|
||||
const previewUrl = await window.hermesDesktop?.readFileDataUrl(filePath)
|
||||
|
||||
if (previewUrl) {
|
||||
addComposerAttachment({ ...baseAttachment, previewUrl })
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (err) {
|
||||
notifyError(err, 'Image preview failed')
|
||||
|
||||
return true
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const attachImageBlob = useCallback(
|
||||
async (blob: Blob) => {
|
||||
if (blob.size === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (blob.type && !blob.type.startsWith('image/')) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = await blob.arrayBuffer()
|
||||
const data = new Uint8Array(buffer)
|
||||
const savedPath = await window.hermesDesktop?.saveImageBuffer(data, blobExtension(blob))
|
||||
|
||||
if (!savedPath) {
|
||||
notify({ kind: 'error', title: 'Image attach', message: 'Failed to write image to disk.' })
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return attachImagePath(savedPath)
|
||||
} catch (err) {
|
||||
notifyError(err, 'Image attach failed')
|
||||
|
||||
return false
|
||||
}
|
||||
},
|
||||
[attachImagePath]
|
||||
)
|
||||
|
||||
const pickImages = useCallback(async () => {
|
||||
const paths = await window.hermesDesktop?.selectPaths({
|
||||
title: 'Attach images',
|
||||
defaultPath: currentCwd || undefined,
|
||||
|
|
@ -83,73 +250,82 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
|||
}
|
||||
|
||||
for (const path of paths) {
|
||||
try {
|
||||
const result = await requestGateway<ImageAttachResponse>('image.attach', {
|
||||
session_id: activeSessionId,
|
||||
path
|
||||
})
|
||||
|
||||
const attachedPath = result.path || path
|
||||
|
||||
if (result.attached) {
|
||||
const previewUrl = await window.hermesDesktop?.readFileDataUrl(attachedPath)
|
||||
|
||||
addComposerAttachment({
|
||||
id: attachmentId('image', attachedPath),
|
||||
kind: 'image',
|
||||
label: pathLabel(attachedPath),
|
||||
detail: attachedPath,
|
||||
previewUrl,
|
||||
path: attachedPath
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
notifyError(err, 'Image attach failed')
|
||||
}
|
||||
await attachImagePath(path)
|
||||
}
|
||||
}, [activeSessionId, currentCwd, requestGateway])
|
||||
}, [attachImagePath, currentCwd])
|
||||
|
||||
const pasteClipboardImage = useCallback(async () => {
|
||||
if (!activeSessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await requestGateway<ImageAttachResponse>('clipboard.paste', {
|
||||
session_id: activeSessionId
|
||||
})
|
||||
const path = await window.hermesDesktop?.saveClipboardImage()
|
||||
|
||||
if (!result.attached) {
|
||||
if (!path) {
|
||||
notify({
|
||||
kind: 'warning',
|
||||
title: 'Clipboard',
|
||||
message: result.message || 'No image found in clipboard'
|
||||
message: 'No image found in clipboard'
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const attachedPath = result.path || 'clipboard'
|
||||
const previewUrl = result.path && (await window.hermesDesktop?.readFileDataUrl(result.path))
|
||||
|
||||
addComposerAttachment({
|
||||
id: attachmentId('image', attachedPath),
|
||||
kind: 'image',
|
||||
label: pathLabel(attachedPath),
|
||||
detail: attachedPath,
|
||||
previewUrl: previewUrl || undefined,
|
||||
path: result.path
|
||||
})
|
||||
await attachImagePath(path)
|
||||
} catch (err) {
|
||||
notifyError(err, 'Clipboard paste failed')
|
||||
}
|
||||
}, [activeSessionId, requestGateway])
|
||||
}, [attachImagePath])
|
||||
|
||||
const attachDroppedItems = useCallback(
|
||||
async (candidates: DroppedFile[]) => {
|
||||
if (candidates.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
let attached = false
|
||||
let lastFailure: string | null = null
|
||||
|
||||
for (const { file, path: knownPath } of candidates) {
|
||||
const fallbackPath = !knownPath && window.hermesDesktop?.getPathForFile ? window.hermesDesktop.getPathForFile(file) : ''
|
||||
const filePath = knownPath || fallbackPath || ''
|
||||
const isImage = file.type.startsWith('image/') || isImagePath(file.name) || (filePath && isImagePath(filePath))
|
||||
|
||||
if (isImage) {
|
||||
if ((filePath && (await attachImagePath(filePath))) || (await attachImageBlob(file))) {
|
||||
attached = true
|
||||
continue
|
||||
}
|
||||
|
||||
lastFailure = `Could not attach ${file.name || 'image'}`
|
||||
continue
|
||||
}
|
||||
|
||||
if (filePath && attachContextFilePath(filePath)) {
|
||||
attached = true
|
||||
continue
|
||||
}
|
||||
|
||||
lastFailure = `Could not attach ${file.name || 'file'}`
|
||||
}
|
||||
|
||||
if (!attached && lastFailure) {
|
||||
notify({ kind: 'warning', title: 'Drop files', message: lastFailure })
|
||||
}
|
||||
|
||||
return attached
|
||||
},
|
||||
[attachContextFilePath, attachImageBlob, attachImagePath]
|
||||
)
|
||||
|
||||
const removeAttachment = useCallback(
|
||||
async (id: string) => {
|
||||
const removed = removeComposerAttachment(id)
|
||||
|
||||
if (removed?.kind === 'image' && removed.path && activeSessionId) {
|
||||
if (
|
||||
removed?.kind === 'image' &&
|
||||
removed.path &&
|
||||
activeSessionId &&
|
||||
removed.attachedSessionId &&
|
||||
removed.attachedSessionId === activeSessionId
|
||||
) {
|
||||
await requestGateway<ImageDetachResponse>('image.detach', {
|
||||
session_id: activeSessionId,
|
||||
path: removed.path
|
||||
|
|
@ -161,6 +337,9 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
|||
|
||||
return {
|
||||
addContextRefAttachment,
|
||||
attachDroppedItems,
|
||||
attachImageBlob,
|
||||
attachImagePath,
|
||||
pasteClipboardImage,
|
||||
pickContextPaths,
|
||||
pickImages,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import {
|
||||
type AppendMessage,
|
||||
AssistantRuntimeProvider,
|
||||
ExportedMessageRepository,
|
||||
type ThreadMessage,
|
||||
|
|
@ -42,6 +43,7 @@ import { titlebarHeaderBaseClass, titlebarHeaderShadowClass } from '../shell/tit
|
|||
|
||||
import { ChatBar, ChatBarFallback } from './composer'
|
||||
import type { ChatBarState } from './composer/types'
|
||||
import type { DroppedFile } from './hooks/use-composer-actions'
|
||||
import { ChatRightRail } from './right-rail'
|
||||
import { SessionActionsMenu } from './sidebar/session-actions-menu'
|
||||
|
||||
|
|
@ -54,6 +56,8 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
|
|||
onAddUrl: (url: string) => void
|
||||
onBranchInNewChat: (messageId: string) => void
|
||||
maxVoiceRecordingSeconds?: number
|
||||
onAttachImageBlob: (blob: Blob) => Promise<boolean | void> | boolean | void
|
||||
onAttachDroppedItems: (candidates: DroppedFile[]) => Promise<boolean | void> | boolean | void
|
||||
onPasteClipboardImage: () => void
|
||||
onPickFiles: () => void
|
||||
onPickFolders: () => void
|
||||
|
|
@ -65,20 +69,33 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
|
|||
onOpenModelPicker: () => void
|
||||
onSelectPersonality: (name: string) => void
|
||||
onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void
|
||||
onEdit: (message: AppendMessage) => Promise<void>
|
||||
onReload: (parentId: string | null) => Promise<void>
|
||||
onTranscribeAudio?: (audio: Blob) => Promise<string>
|
||||
}
|
||||
|
||||
function threadLoadingState(loadingSession: boolean, busy: boolean, awaitingResponse: boolean) {
|
||||
function threadLoadingState(
|
||||
loadingSession: boolean,
|
||||
busy: boolean,
|
||||
awaitingResponse: boolean,
|
||||
lastMessageIsUser: boolean
|
||||
) {
|
||||
if (loadingSession) {
|
||||
return 'session'
|
||||
}
|
||||
|
||||
if (!busy) {
|
||||
return undefined
|
||||
// Only show the response spinner when we're actually waiting for an
|
||||
// assistant reply to a user message. Previously any `busy && awaiting`
|
||||
// window showed the spinner — including the brief gateway-hydration blip
|
||||
// right after a session resume, which produced a visible flicker chain:
|
||||
// session spinner → response spinner → content.
|
||||
// Gating on `lastMessageIsUser` means the spinner only appears when the
|
||||
// user actually just sent something and there's no assistant reply yet.
|
||||
if (busy && awaitingResponse && lastMessageIsUser) {
|
||||
return 'response'
|
||||
}
|
||||
|
||||
return awaitingResponse ? 'response' : 'working'
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function ChatView({
|
||||
|
|
@ -88,6 +105,8 @@ export function ChatView({
|
|||
onCancel,
|
||||
onAddContextRef,
|
||||
onAddUrl,
|
||||
onAttachImageBlob,
|
||||
onAttachDroppedItems,
|
||||
onBranchInNewChat,
|
||||
maxVoiceRecordingSeconds,
|
||||
onPasteClipboardImage,
|
||||
|
|
@ -101,6 +120,7 @@ export function ChatView({
|
|||
onOpenModelPicker,
|
||||
onSelectPersonality,
|
||||
onThreadMessagesChange,
|
||||
onEdit,
|
||||
onReload,
|
||||
onTranscribeAudio
|
||||
}: ChatViewProps) {
|
||||
|
|
@ -129,8 +149,14 @@ export function ChatView({
|
|||
const showIntro =
|
||||
freshDraftReady && !isRoutedSessionView && !selectedSessionId && !activeSessionId && messages.length === 0
|
||||
|
||||
const loadingSession = isRoutedSessionView && messages.length === 0
|
||||
const threadLoading = threadLoadingState(loadingSession, busy, awaitingResponse)
|
||||
// Session is still loading if the route references a session we haven't
|
||||
// resumed yet. Once `activeSessionId` is set (runtime has resumed), the
|
||||
// session exists — even if it has zero messages (a brand-new routed
|
||||
// session). The flicker where `busy` flips true briefly during hydrate
|
||||
// is handled by `threadLoadingState`'s `lastMessageIsUser` gate.
|
||||
const loadingSession = isRoutedSessionView && messages.length === 0 && !activeSessionId
|
||||
const lastMessageIsUser = messages.at(-1)?.role === 'user'
|
||||
const threadLoading = threadLoadingState(loadingSession, busy, awaitingResponse, lastMessageIsUser)
|
||||
const showChatBar = !loadingSession
|
||||
const threadKey = selectedSessionId || activeSessionId || (isRoutedSessionView ? location.pathname : 'new')
|
||||
const title = activeStoredSession ? sessionTitle(activeStoredSession) : ''
|
||||
|
|
@ -221,6 +247,7 @@ export function ChatView({
|
|||
// Submission is handled explicitly by ChatBar.
|
||||
// Keeping this no-op avoids duplicate prompt.submit calls.
|
||||
},
|
||||
onEdit,
|
||||
onCancel: async () => onCancel(),
|
||||
onReload
|
||||
})
|
||||
|
|
@ -236,6 +263,7 @@ export function ChatView({
|
|||
onDelete={selectedSessionId ? onDeleteSelectedSession : undefined}
|
||||
onPin={selectedSessionId ? onToggleSelectedPin : undefined}
|
||||
pinned={selectedIsPinned}
|
||||
sessionId={selectedSessionId || activeSessionId || ''}
|
||||
sideOffset={8}
|
||||
title={title}
|
||||
>
|
||||
|
|
@ -273,6 +301,8 @@ export function ChatView({
|
|||
maxRecordingSeconds={maxVoiceRecordingSeconds}
|
||||
onAddContextRef={onAddContextRef}
|
||||
onAddUrl={onAddUrl}
|
||||
onAttachDroppedItems={onAttachDroppedItems}
|
||||
onAttachImageBlob={onAttachImageBlob}
|
||||
onCancel={onCancel}
|
||||
onPasteClipboardImage={onPasteClipboardImage}
|
||||
onPickFiles={onPickFiles}
|
||||
|
|
@ -300,4 +330,4 @@ export function ChatView({
|
|||
)
|
||||
}
|
||||
|
||||
export { SESSION_INSPECTOR_WIDTH } from './right-rail'
|
||||
export { PREVIEW_RAIL_WIDTH, SESSION_INSPECTOR_WIDTH } from './right-rail'
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type * as React from 'react'
|
|||
|
||||
import { SESSION_INSPECTOR_WIDTH, SessionInspector } from '@/components/session-inspector'
|
||||
import { $inspectorOpen } from '@/store/layout'
|
||||
import { $previewTarget } from '@/store/preview'
|
||||
import {
|
||||
$availablePersonalities,
|
||||
$busy,
|
||||
|
|
@ -14,6 +15,8 @@ import {
|
|||
$gatewayState
|
||||
} from '@/store/session'
|
||||
|
||||
import { PreviewPane } from './preview-pane'
|
||||
|
||||
interface ChatRightRailProps extends Pick<
|
||||
React.ComponentProps<typeof SessionInspector>,
|
||||
'onBrowseCwd' | 'onChangeCwd'
|
||||
|
|
@ -29,6 +32,7 @@ export function ChatRightRail({
|
|||
onSelectPersonality
|
||||
}: ChatRightRailProps) {
|
||||
const inspectorOpen = useStore($inspectorOpen)
|
||||
const previewTarget = useStore($previewTarget)
|
||||
const gatewayOpen = useStore($gatewayState) === 'open'
|
||||
const busy = useStore($busy)
|
||||
const cwd = useStore($currentCwd)
|
||||
|
|
@ -38,6 +42,10 @@ export function ChatRightRail({
|
|||
const personality = useStore($currentPersonality)
|
||||
const personalities = useStore($availablePersonalities)
|
||||
|
||||
if (previewTarget) {
|
||||
return <PreviewPane target={previewTarget} />
|
||||
}
|
||||
|
||||
return (
|
||||
<SessionInspector
|
||||
branch={branch}
|
||||
|
|
@ -58,3 +66,4 @@ export function ChatRightRail({
|
|||
}
|
||||
|
||||
export { SESSION_INSPECTOR_WIDTH }
|
||||
export const PREVIEW_RAIL_WIDTH = 'clamp(18rem, 36vw, 38rem)'
|
||||
|
|
|
|||
522
apps/desktop/src/app/chat/right-rail/preview-pane.tsx
Normal file
522
apps/desktop/src/app/chat/right-rail/preview-pane.tsx
Normal file
|
|
@ -0,0 +1,522 @@
|
|||
import { Bug, Check, Copy, ExternalLink, PanelBottom, RefreshCw, Send, Trash2, X } from 'lucide-react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $composerDraft, setComposerDraft } from '@/store/composer'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { type PreviewTarget, setPreviewTarget } from '@/store/preview'
|
||||
|
||||
type PreviewWebview = HTMLElement & {
|
||||
closeDevTools?: () => void
|
||||
isDevToolsOpened?: () => boolean
|
||||
openDevTools?: () => void
|
||||
reload?: () => void
|
||||
reloadIgnoringCache?: () => void
|
||||
}
|
||||
|
||||
interface ConsoleEntry {
|
||||
id: number
|
||||
level: number
|
||||
line?: number
|
||||
message: string
|
||||
source?: string
|
||||
}
|
||||
|
||||
const consoleLevelLabel: Record<number, string> = {
|
||||
0: 'log',
|
||||
1: 'info',
|
||||
2: 'warn',
|
||||
3: 'error'
|
||||
}
|
||||
|
||||
const consoleLevelClass: Record<number, string> = {
|
||||
0: 'text-foreground',
|
||||
1: 'text-sky-700 dark:text-sky-300',
|
||||
2: 'text-amber-700 dark:text-amber-300',
|
||||
3: 'text-destructive'
|
||||
}
|
||||
|
||||
function compactUrl(value: string): string {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
|
||||
if (url.protocol === 'file:') {
|
||||
return decodeURIComponent(url.pathname)
|
||||
}
|
||||
|
||||
return `${url.host}${url.pathname}${url.search}`
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
function formatLogLine(log: ConsoleEntry): string {
|
||||
const head = `[${consoleLevelLabel[log.level] || 'log'}]`
|
||||
const tail = log.source ? ` (${compactUrl(log.source)}${log.line ? `:${log.line}` : ''})` : ''
|
||||
|
||||
return `${head} ${log.message}${tail}`.trim()
|
||||
}
|
||||
|
||||
interface ConsoleRowProps {
|
||||
log: ConsoleEntry
|
||||
onCopy: () => void | Promise<void>
|
||||
onSend: () => void
|
||||
onToggleSelect: () => void
|
||||
selected: boolean
|
||||
}
|
||||
|
||||
function ConsoleRow({ log, onCopy, onSend, onToggleSelect, selected }: ConsoleRowProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group/row grid grid-cols-[3.25rem_minmax(0,1fr)_auto] items-start gap-2 rounded-md border border-transparent px-1 py-1 transition-colors hover:bg-accent/40',
|
||||
selected && 'border-border/60 bg-accent/40'
|
||||
)}
|
||||
>
|
||||
<button
|
||||
className={cn(
|
||||
'mt-0.5 cursor-pointer text-left uppercase opacity-70 transition-colors hover:opacity-100',
|
||||
consoleLevelClass[log.level] ?? consoleLevelClass[0]
|
||||
)}
|
||||
onClick={onToggleSelect}
|
||||
title={selected ? 'Deselect entry' : 'Select entry'}
|
||||
type="button"
|
||||
>
|
||||
{consoleLevelLabel[log.level] || 'log'}
|
||||
</button>
|
||||
<div className="min-w-0" data-selectable-text="true">
|
||||
<span className={cn('block wrap-break-word', consoleLevelClass[log.level] ?? consoleLevelClass[0])}>
|
||||
{log.message}
|
||||
</span>
|
||||
{log.source && (
|
||||
<span className="block truncate text-muted-foreground/60">
|
||||
{compactUrl(log.source)}
|
||||
{log.line ? `:${log.line}` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="opacity-0 transition-opacity group-hover/row:opacity-100">
|
||||
<button
|
||||
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={() => void onCopy()}
|
||||
title="Copy this entry"
|
||||
type="button"
|
||||
>
|
||||
<Copy className="size-3" />
|
||||
</button>
|
||||
<button
|
||||
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={onSend}
|
||||
title="Send this entry to chat"
|
||||
type="button"
|
||||
>
|
||||
<Send className="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function writeClipboardText(text: string) {
|
||||
if (!text) {
|
||||
return
|
||||
}
|
||||
|
||||
if (window.hermesDesktop?.writeClipboard) {
|
||||
await window.hermesDesktop.writeClipboard(text)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
}
|
||||
}
|
||||
|
||||
export function PreviewPane({ target }: { target: PreviewTarget }) {
|
||||
const consoleBodyRef = useRef<HTMLDivElement | null>(null)
|
||||
const hostRef = useRef<HTMLDivElement | null>(null)
|
||||
const logIdRef = useRef(0)
|
||||
const webviewRef = useRef<PreviewWebview | null>(null)
|
||||
const [consoleOpen, setConsoleOpen] = useState(true)
|
||||
const [currentUrl, setCurrentUrl] = useState(target.url)
|
||||
const [devtoolsOpen, setDevtoolsOpen] = useState(false)
|
||||
const [logs, setLogs] = useState<ConsoleEntry[]>([])
|
||||
const [selectedLogIds, setSelectedLogIds] = useState<Set<number>>(() => new Set())
|
||||
const [copiedAll, setCopiedAll] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const visibleSelection = useMemo(() => logs.filter(log => selectedLogIds.has(log.id)), [logs, selectedLogIds])
|
||||
const sendableLogs = visibleSelection.length > 0 ? visibleSelection : logs
|
||||
|
||||
function toggleLogSelection(id: number) {
|
||||
setSelectedLogIds(prev => {
|
||||
const next = new Set(prev)
|
||||
|
||||
if (!next.delete(id)) {
|
||||
next.add(id)
|
||||
}
|
||||
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
async function copyConsoleText(entries: ConsoleEntry[], successMessage: string) {
|
||||
if (!entries.length) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await writeClipboardText(entries.map(formatLogLine).join('\n'))
|
||||
notify({ kind: 'success', title: 'Console copied', message: successMessage })
|
||||
} catch (error) {
|
||||
notifyError(error, 'Could not copy console output')
|
||||
}
|
||||
}
|
||||
|
||||
function sendLogsToComposer(entries: ConsoleEntry[]) {
|
||||
if (!entries.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const block = ['Preview console:', '```', ...entries.map(formatLogLine), '```'].join('\n')
|
||||
const draft = $composerDraft.get()
|
||||
const next = draft && !draft.endsWith('\n') ? `${draft}\n\n${block}` : `${draft}${block}`
|
||||
|
||||
setComposerDraft(next)
|
||||
setSelectedLogIds(new Set())
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: 'Sent to chat',
|
||||
message: `${entries.length} log entr${entries.length === 1 ? 'y' : 'ies'} added to composer`
|
||||
})
|
||||
}
|
||||
|
||||
function toggleDevTools() {
|
||||
const webview = webviewRef.current
|
||||
|
||||
if (!webview?.openDevTools) {
|
||||
return
|
||||
}
|
||||
|
||||
if (webview.isDevToolsOpened?.()) {
|
||||
webview.closeDevTools?.()
|
||||
setDevtoolsOpen(false)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
webview.openDevTools()
|
||||
setDevtoolsOpen(true)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (consoleOpen) {
|
||||
consoleBodyRef.current?.scrollTo({ top: consoleBodyRef.current.scrollHeight })
|
||||
}
|
||||
}, [consoleOpen, logs])
|
||||
|
||||
useEffect(() => {
|
||||
if (target.kind !== 'file' || !window.hermesDesktop?.watchPreviewFile || !window.hermesDesktop?.onPreviewFileChanged) {
|
||||
return
|
||||
}
|
||||
|
||||
let active = true
|
||||
let watchId = ''
|
||||
|
||||
const unsubscribe = window.hermesDesktop.onPreviewFileChanged(payload => {
|
||||
if (!active || payload.id !== watchId) {
|
||||
return
|
||||
}
|
||||
|
||||
setLogs(prev => [
|
||||
...prev.slice(-199),
|
||||
{
|
||||
id: ++logIdRef.current,
|
||||
level: 1,
|
||||
message: `File changed, reloading preview: ${compactUrl(payload.url)}`
|
||||
}
|
||||
])
|
||||
|
||||
if (webviewRef.current?.reloadIgnoringCache) {
|
||||
webviewRef.current.reloadIgnoringCache()
|
||||
} else {
|
||||
webviewRef.current?.reload?.()
|
||||
}
|
||||
})
|
||||
|
||||
void window.hermesDesktop
|
||||
.watchPreviewFile(target.url)
|
||||
.then(watch => {
|
||||
if (!active) {
|
||||
void window.hermesDesktop?.stopPreviewFileWatch?.(watch.id)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
watchId = watch.id
|
||||
})
|
||||
.catch(error => {
|
||||
setLogs(prev => [
|
||||
...prev.slice(-199),
|
||||
{
|
||||
id: ++logIdRef.current,
|
||||
level: 2,
|
||||
message: `Could not watch preview file: ${error instanceof Error ? error.message : String(error)}`
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
unsubscribe()
|
||||
|
||||
if (watchId) {
|
||||
void window.hermesDesktop?.stopPreviewFileWatch?.(watchId)
|
||||
}
|
||||
}
|
||||
}, [target.kind, target.url])
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current
|
||||
|
||||
if (!host) {
|
||||
return
|
||||
}
|
||||
|
||||
host.replaceChildren()
|
||||
webviewRef.current = null
|
||||
setCurrentUrl(target.url)
|
||||
setDevtoolsOpen(false)
|
||||
setLogs([])
|
||||
setLoading(true)
|
||||
|
||||
const webview = document.createElement('webview') as PreviewWebview
|
||||
webview.className = 'hermes-preview-webview h-full w-full flex-1 bg-background'
|
||||
webview.setAttribute('partition', 'persist:hermes-preview')
|
||||
webview.setAttribute('src', target.url)
|
||||
webview.setAttribute('webpreferences', 'contextIsolation=yes,nodeIntegration=no,sandbox=yes')
|
||||
|
||||
const appendLog = (entry: Omit<ConsoleEntry, 'id'>) => {
|
||||
setLogs(prev => [...prev.slice(-199), { ...entry, id: ++logIdRef.current }])
|
||||
}
|
||||
|
||||
const onConsole = (event: Event) => {
|
||||
const detail = event as Event & {
|
||||
level?: number
|
||||
line?: number
|
||||
message?: string
|
||||
sourceId?: string
|
||||
}
|
||||
|
||||
appendLog({
|
||||
level: detail.level ?? 0,
|
||||
line: detail.line,
|
||||
message: detail.message || '',
|
||||
source: detail.sourceId
|
||||
})
|
||||
}
|
||||
|
||||
const onNavigate = (event: Event) => {
|
||||
const detail = event as Event & { url?: string }
|
||||
|
||||
if (detail.url) {
|
||||
setCurrentUrl(detail.url)
|
||||
}
|
||||
}
|
||||
|
||||
const onFail = (event: Event) => {
|
||||
const detail = event as Event & {
|
||||
errorCode?: number
|
||||
errorDescription?: string
|
||||
validatedURL?: string
|
||||
}
|
||||
|
||||
appendLog({
|
||||
level: 3,
|
||||
message: `Load failed${detail.errorCode ? ` (${detail.errorCode})` : ''}: ${
|
||||
detail.errorDescription || detail.validatedURL || 'unknown error'
|
||||
}`
|
||||
})
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const onStart = () => setLoading(true)
|
||||
const onStop = () => setLoading(false)
|
||||
|
||||
webview.addEventListener('console-message', onConsole)
|
||||
webview.addEventListener('did-fail-load', onFail)
|
||||
webview.addEventListener('did-navigate', onNavigate)
|
||||
webview.addEventListener('did-navigate-in-page', onNavigate)
|
||||
webview.addEventListener('did-start-loading', onStart)
|
||||
webview.addEventListener('did-stop-loading', onStop)
|
||||
host.appendChild(webview)
|
||||
webviewRef.current = webview
|
||||
|
||||
return () => {
|
||||
webview.removeEventListener('console-message', onConsole)
|
||||
webview.removeEventListener('did-fail-load', onFail)
|
||||
webview.removeEventListener('did-navigate', onNavigate)
|
||||
webview.removeEventListener('did-navigate-in-page', onNavigate)
|
||||
webview.removeEventListener('did-start-loading', onStart)
|
||||
webview.removeEventListener('did-stop-loading', onStop)
|
||||
webview.remove()
|
||||
}
|
||||
}, [target.url])
|
||||
|
||||
return (
|
||||
<aside className="relative flex h-screen min-w-0 flex-col overflow-hidden bg-transparent pb-2 pl-2 pr-3 pt-[calc(var(--titlebar-height)+0.25rem)] text-muted-foreground">
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl border border-border/60 bg-card/70 shadow-sm">
|
||||
<div className="flex items-center gap-1.5 border-b border-border/60 px-2 py-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs font-medium text-foreground">{target.label || 'Preview'}</div>
|
||||
<div className="truncate font-mono text-[0.625rem] text-muted-foreground">{compactUrl(currentUrl)}</div>
|
||||
</div>
|
||||
<Button
|
||||
aria-label={consoleOpen ? 'Hide preview console' : 'Show preview console'}
|
||||
className="h-7 shrink-0 rounded-lg px-2 text-[0.6875rem]"
|
||||
onClick={() => setConsoleOpen(open => !open)}
|
||||
size="xs"
|
||||
title={consoleOpen ? 'Hide Console' : 'Show Console'}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<PanelBottom className="size-3.5" />
|
||||
Console
|
||||
{logs.length > 0 && (
|
||||
<span className="ml-0.5 rounded-full bg-muted px-1.5 py-px text-[0.5625rem] text-muted-foreground">
|
||||
{logs.length}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={devtoolsOpen ? 'Hide preview DevTools' : 'Open preview DevTools'}
|
||||
className="h-7 shrink-0 rounded-lg px-2 text-[0.6875rem]"
|
||||
onClick={toggleDevTools}
|
||||
size="xs"
|
||||
title={devtoolsOpen ? 'Hide DevTools' : 'Open DevTools'}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Bug className="size-3.5" />
|
||||
{devtoolsOpen ? 'Hide DevTools' : 'DevTools'}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Reload preview"
|
||||
className="size-7 shrink-0 rounded-lg"
|
||||
onClick={() => webviewRef.current?.reload?.()}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<RefreshCw className={cn('size-3.5', loading && 'animate-spin')} />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Open preview externally"
|
||||
className="size-7 shrink-0 rounded-lg"
|
||||
onClick={() => void window.hermesDesktop?.openExternal(currentUrl)}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Close preview"
|
||||
className="size-7 shrink-0 rounded-lg"
|
||||
onClick={() => setPreviewTarget(null)}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 bg-background" ref={hostRef} />
|
||||
|
||||
{consoleOpen && (
|
||||
<div className="min-h-44 border-t border-border/60 bg-background/95">
|
||||
<div className="flex h-8 items-center justify-between border-b border-border/50 px-2">
|
||||
<div className="flex items-center gap-2 text-[0.6875rem] font-medium text-muted-foreground">
|
||||
<PanelBottom className="size-3.5" />
|
||||
Preview Console
|
||||
{selectedLogIds.size > 0 && (
|
||||
<span className="rounded-full bg-muted px-1.5 py-px text-[0.5625rem] text-muted-foreground">
|
||||
{selectedLogIds.size} selected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
|
||||
disabled={sendableLogs.length === 0}
|
||||
onClick={() => sendLogsToComposer(sendableLogs)}
|
||||
title={
|
||||
visibleSelection.length > 0
|
||||
? `Send ${visibleSelection.length} selected to chat`
|
||||
: 'Send all log entries to chat'
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Send className="size-3" />
|
||||
Send to chat
|
||||
</button>
|
||||
<button
|
||||
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
|
||||
disabled={sendableLogs.length === 0}
|
||||
onClick={async () => {
|
||||
await copyConsoleText(
|
||||
sendableLogs,
|
||||
visibleSelection.length > 0 ? `${visibleSelection.length} selected entries` : 'All console entries'
|
||||
)
|
||||
setCopiedAll(true)
|
||||
setTimeout(() => setCopiedAll(false), 1500)
|
||||
}}
|
||||
title={visibleSelection.length > 0 ? 'Copy selected to clipboard' : 'Copy all to clipboard'}
|
||||
type="button"
|
||||
>
|
||||
{copiedAll ? <Check className="size-3" /> : <Copy className="size-3" />}
|
||||
Copy
|
||||
</button>
|
||||
<button
|
||||
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
|
||||
disabled={logs.length === 0}
|
||||
onClick={() => {
|
||||
setLogs([])
|
||||
setSelectedLogIds(new Set())
|
||||
}}
|
||||
title="Clear console"
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-40 overflow-y-auto px-2 py-1.5 font-mono text-[0.6875rem] leading-relaxed" ref={consoleBodyRef}>
|
||||
{logs.length > 0 ? (
|
||||
logs.map(log => {
|
||||
const selected = selectedLogIds.has(log.id)
|
||||
|
||||
return (
|
||||
<ConsoleRow
|
||||
key={log.id}
|
||||
log={log}
|
||||
onCopy={() => copyConsoleText([log], 'Log entry copied')}
|
||||
onSend={() => sendLogsToComposer([log])}
|
||||
onToggleSelect={() => toggleLogSelection(log.id)}
|
||||
selected={selected}
|
||||
/>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className="py-2 text-muted-foreground/70">No console messages yet.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
|
@ -18,7 +18,6 @@ import { Skeleton } from '@/components/ui/skeleton'
|
|||
import type { SessionInfo } from '@/hermes'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$isSidebarResizing,
|
||||
$pinnedSessionIds,
|
||||
$sidebarOpen,
|
||||
$sidebarPinsOpen,
|
||||
|
|
@ -66,10 +65,10 @@ export function ChatSidebar({
|
|||
}: ChatSidebarProps) {
|
||||
const sidebarOpen = useStore($sidebarOpen)
|
||||
const pinnedSessionIds = useStore($pinnedSessionIds)
|
||||
const isSidebarResizing = useStore($isSidebarResizing)
|
||||
const pinsOpen = useStore($sidebarPinsOpen)
|
||||
const recentsOpen = useStore($sidebarRecentsOpen)
|
||||
const selectedSessionId = useStore($selectedStoredSessionId)
|
||||
const activeSidebarSessionId = currentView === 'chat' ? selectedSessionId : null
|
||||
const sessions = useStore($sessions)
|
||||
const sessionsLoading = useStore($sessionsLoading)
|
||||
const workingSessionIds = useStore($workingSessionIds)
|
||||
|
|
@ -101,13 +100,10 @@ export function ChatSidebar({
|
|||
return (
|
||||
<Sidebar
|
||||
className={cn(
|
||||
'relative h-screen min-w-0 overflow-hidden border-r border-t-0 border-b-0 border-l-0 text-foreground [backdrop-filter:blur(1.5rem)_saturate(1.08)]',
|
||||
isSidebarResizing
|
||||
? 'transition-none'
|
||||
: 'transition-[opacity,transform,border-color,background-color] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]',
|
||||
'relative h-screen min-w-0 overflow-hidden border-r border-t-0 border-b-0 border-l-0 text-foreground transition-none [backdrop-filter:blur(1.5rem)_saturate(1.08)]',
|
||||
sidebarOpen
|
||||
? 'translate-x-0 border-(--sidebar-edge-border) bg-[color-mix(in_srgb,var(--dt-sidebar-bg)_97%,transparent)] opacity-100'
|
||||
: 'pointer-events-none -translate-x-2 border-transparent bg-transparent opacity-0'
|
||||
? 'border-(--sidebar-edge-border) bg-[color-mix(in_srgb,var(--dt-sidebar-bg)_97%,transparent)] opacity-100'
|
||||
: 'pointer-events-none border-transparent bg-transparent opacity-0'
|
||||
)}
|
||||
collapsible="none"
|
||||
>
|
||||
|
|
@ -159,7 +155,7 @@ export function ChatSidebar({
|
|||
{pinnedSessions.map(session => (
|
||||
<SidebarSessionRow
|
||||
isPinned
|
||||
isSelected={session.id === selectedSessionId}
|
||||
isSelected={session.id === activeSidebarSessionId}
|
||||
isWorking={workingSessionIdSet.has(session.id)}
|
||||
key={session.id}
|
||||
onDelete={() => onDeleteSession(session.id)}
|
||||
|
|
@ -207,7 +203,7 @@ export function ChatSidebar({
|
|||
{recentSessions.map(session => (
|
||||
<SidebarSessionRow
|
||||
isPinned={false}
|
||||
isSelected={session.id === selectedSessionId}
|
||||
isSelected={session.id === activeSidebarSessionId}
|
||||
isWorking={workingSessionIdSet.has(session.id)}
|
||||
key={session.id}
|
||||
onDelete={() => onDeleteSession(session.id)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Archive, Pencil, Pin, Trash2 } from 'lucide-react'
|
||||
import { Archive, Copy, Pencil, Pin, Trash2 } from 'lucide-react'
|
||||
import type * as React from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
|
|
@ -11,6 +11,7 @@ import {
|
|||
} from '@/components/ui/dropdown-menu'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
interface SessionActionsMenuProps extends Pick<
|
||||
React.ComponentProps<typeof DropdownMenuContent>,
|
||||
|
|
@ -18,6 +19,7 @@ interface SessionActionsMenuProps extends Pick<
|
|||
> {
|
||||
children: ReactNode
|
||||
title: string
|
||||
sessionId: string
|
||||
pinned?: boolean
|
||||
onPin?: () => void
|
||||
onDelete?: () => void
|
||||
|
|
@ -26,6 +28,7 @@ interface SessionActionsMenuProps extends Pick<
|
|||
export function SessionActionsMenu({
|
||||
children,
|
||||
title,
|
||||
sessionId,
|
||||
pinned = false,
|
||||
onPin,
|
||||
onDelete,
|
||||
|
|
@ -34,6 +37,17 @@ export function SessionActionsMenu({
|
|||
}: SessionActionsMenuProps) {
|
||||
const itemClass = 'gap-2.5 text-foreground focus:bg-accent [&_svg]:size-4'
|
||||
|
||||
const copyId = async () => {
|
||||
triggerHaptic('selection')
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(sessionId)
|
||||
notify({ kind: 'success', message: 'Session ID copied', durationMs: 2_000 })
|
||||
} catch (err) {
|
||||
notifyError(err, 'Could not copy session ID')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
||||
|
|
@ -49,6 +63,10 @@ export function SessionActionsMenu({
|
|||
<Pin />
|
||||
<span>{pinned ? 'Unpin' : 'Pin'}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className={itemClass} onSelect={() => void copyId()}>
|
||||
<Copy />
|
||||
<span>Copy ID</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className={itemClass}>
|
||||
<Pencil />
|
||||
<span>Rename</span>
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ export function SidebarSessionRow({
|
|||
<span className="truncate text-sm font-medium text-foreground/90">{title}</span>
|
||||
</button>
|
||||
<div className="relative z-2 grid w-6 place-items-center">
|
||||
<SessionActionsMenu onDelete={onDelete} onPin={onPin} pinned={isPinned} title={title}>
|
||||
<SessionActionsMenu onDelete={onDelete} onPin={onPin} pinned={isPinned} sessionId={session.id} title={title}>
|
||||
<Button
|
||||
aria-label={`Actions for ${title}`}
|
||||
className="size-6 rounded-md bg-transparent text-transparent transition-colors duration-150 hover:bg-accent hover:text-foreground data-[state=open]:bg-accent data-[state=open]:text-foreground group-hover:text-muted-foreground"
|
||||
|
|
|
|||
|
|
@ -15,15 +15,18 @@ import {
|
|||
listSessions,
|
||||
setGlobalModel
|
||||
} from '../hermes'
|
||||
import { toChatMessages } from '../lib/chat-messages'
|
||||
import { chatMessageText, toChatMessages } from '../lib/chat-messages'
|
||||
import { BUILTIN_PERSONALITIES, normalizePersonalityValue, personalityNamesFromConfig } from '../lib/chat-runtime'
|
||||
import { extractPreviewCandidates } from '../lib/preview-targets'
|
||||
import { $pinnedSessionIds, pinSession, unpinSession } from '../store/layout'
|
||||
import { notify, notifyError } from '../store/notifications'
|
||||
import { $previewTarget, setPreviewTarget } from '../store/preview'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$currentCwd,
|
||||
$freshDraftReady,
|
||||
$gatewayState,
|
||||
$messages,
|
||||
$selectedStoredSessionId,
|
||||
setAvailablePersonalities,
|
||||
setAwaitingResponse,
|
||||
|
|
@ -40,9 +43,10 @@ import {
|
|||
setSessions,
|
||||
setSessionsLoading
|
||||
} from '../store/session'
|
||||
import { useTheme } from '../themes/context'
|
||||
|
||||
import { ArtifactsView } from './artifacts'
|
||||
import { ChatView, SESSION_INSPECTOR_WIDTH } from './chat'
|
||||
import { ChatView, PREVIEW_RAIL_WIDTH, SESSION_INSPECTOR_WIDTH } from './chat'
|
||||
import { useComposerActions } from './chat/hooks/use-composer-actions'
|
||||
import { ChatSidebar } from './chat/sidebar'
|
||||
import { useGatewayBoot } from './gateway/hooks/use-gateway-boot'
|
||||
|
|
@ -64,21 +68,40 @@ function normalizeRecordingLimit(value: unknown): number {
|
|||
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : DEFAULT_VOICE_RECORDING_SECONDS
|
||||
}
|
||||
|
||||
function gatewayEventPreviewText(event: { payload?: unknown }): string {
|
||||
const payload = event.payload && typeof event.payload === 'object' ? (event.payload as Record<string, unknown>) : {}
|
||||
const fields = ['text', 'rendered', 'preview', 'context', 'summary', 'message']
|
||||
|
||||
return fields
|
||||
.map(key => payload[key])
|
||||
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
export function DesktopController() {
|
||||
const queryClient = useQueryClient()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const busyRef = useRef(false)
|
||||
const creatingSessionRef = useRef(false)
|
||||
const gatewayState = useStore($gatewayState)
|
||||
const { availableThemes, setTheme, themeName } = useTheme()
|
||||
const activeSessionId = useStore($activeSessionId)
|
||||
const previewTarget = useStore($previewTarget)
|
||||
const messages = useStore($messages)
|
||||
const selectedStoredSessionId = useStore($selectedStoredSessionId)
|
||||
const currentCwd = useStore($currentCwd)
|
||||
const freshDraftReady = useStore($freshDraftReady)
|
||||
const routedSessionId = routeSessionId(location.pathname)
|
||||
const currentView = appViewForPath(location.pathname)
|
||||
const routeToken = `${currentView}:${routedSessionId || ''}:${location.pathname}:${location.search}:${location.hash}`
|
||||
const routeTokenRef = useRef(routeToken)
|
||||
routeTokenRef.current = routeToken
|
||||
const getRouteToken = useCallback(() => routeTokenRef.current, [])
|
||||
const settingsOpen = currentView === 'settings'
|
||||
const chatOpen = currentView === 'chat'
|
||||
const settingsReturnPathRef = useRef(NEW_CHAT_ROUTE)
|
||||
const refreshSessionsRequestRef = useRef(0)
|
||||
const [titlebarActions, setTitlebarActions] = useState<ReactNode>(null)
|
||||
const [voiceMaxRecordingSeconds, setVoiceMaxRecordingSeconds] = useState(DEFAULT_VOICE_RECORDING_SECONDS)
|
||||
const [sttEnabled, setSttEnabled] = useState(true)
|
||||
|
|
@ -115,13 +138,20 @@ export function DesktopController() {
|
|||
}, [])
|
||||
|
||||
const refreshSessions = useCallback(async () => {
|
||||
const requestId = refreshSessionsRequestRef.current + 1
|
||||
refreshSessionsRequestRef.current = requestId
|
||||
setSessionsLoading(true)
|
||||
|
||||
try {
|
||||
const result = await listSessions(50)
|
||||
setSessions(result.sessions)
|
||||
|
||||
if (refreshSessionsRequestRef.current === requestId) {
|
||||
setSessions(result.sessions)
|
||||
}
|
||||
} finally {
|
||||
setSessionsLoading(false)
|
||||
if (refreshSessionsRequestRef.current === requestId) {
|
||||
setSessionsLoading(false)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
|
@ -165,18 +195,25 @@ export function DesktopController() {
|
|||
return
|
||||
}
|
||||
|
||||
const sessionId = activeSessionId
|
||||
const cwd = currentCwd || ''
|
||||
|
||||
try {
|
||||
const result = await requestGateway<{ items?: ContextSuggestion[] }>('complete.path', {
|
||||
session_id: activeSessionId,
|
||||
session_id: sessionId,
|
||||
word: '@file:',
|
||||
cwd: currentCwd || undefined
|
||||
cwd: cwd || undefined
|
||||
})
|
||||
|
||||
setContextSuggestions((result.items || []).filter(item => item.text))
|
||||
if (activeSessionIdRef.current === sessionId && $currentCwd.get() === cwd) {
|
||||
setContextSuggestions((result.items || []).filter(item => item.text))
|
||||
}
|
||||
} catch {
|
||||
setContextSuggestions([])
|
||||
if (activeSessionIdRef.current === sessionId && $currentCwd.get() === cwd) {
|
||||
setContextSuggestions([])
|
||||
}
|
||||
}
|
||||
}, [activeSessionId, currentCwd, requestGateway])
|
||||
}, [activeSessionId, activeSessionIdRef, currentCwd, requestGateway])
|
||||
|
||||
const refreshCurrentModel = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -372,13 +409,6 @@ export function DesktopController() {
|
|||
[activeSessionId, refreshHermesConfig, requestGateway]
|
||||
)
|
||||
|
||||
const { addContextRefAttachment, pasteClipboardImage, pickContextPaths, pickImages, removeAttachment } =
|
||||
useComposerActions({
|
||||
activeSessionId,
|
||||
currentCwd,
|
||||
requestGateway
|
||||
})
|
||||
|
||||
const hydrateFromStoredSession = useCallback(
|
||||
async (
|
||||
attempts = 1,
|
||||
|
|
@ -423,6 +453,65 @@ export function DesktopController() {
|
|||
updateSessionState
|
||||
})
|
||||
|
||||
const lastPreviewUrlRef = useRef<string>('')
|
||||
|
||||
const openDetectedPreview = useCallback(
|
||||
async (text: string) => {
|
||||
const desktop = window.hermesDesktop
|
||||
const routeKey = lastPreviewRouteRef.current
|
||||
const sessionId = activeSessionIdRef.current
|
||||
const cwd = currentCwd || ''
|
||||
|
||||
if (!desktop?.normalizePreviewTarget) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const candidate of extractPreviewCandidates(text)) {
|
||||
const target = await desktop.normalizePreviewTarget(candidate, cwd || undefined).catch(() => null)
|
||||
|
||||
if (lastPreviewRouteRef.current !== routeKey || activeSessionIdRef.current !== sessionId || $currentCwd.get() !== cwd) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!target || target.url === lastPreviewUrlRef.current) {
|
||||
continue
|
||||
}
|
||||
|
||||
lastPreviewUrlRef.current = target.url
|
||||
setPreviewTarget(target)
|
||||
|
||||
return
|
||||
}
|
||||
},
|
||||
[activeSessionIdRef, currentCwd]
|
||||
)
|
||||
|
||||
const handleDesktopGatewayEvent = useCallback(
|
||||
(event: Parameters<typeof handleGatewayEvent>[0]) => {
|
||||
handleGatewayEvent(event)
|
||||
|
||||
if (event.session_id && event.session_id !== activeSessionIdRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const previewText = gatewayEventPreviewText(event)
|
||||
|
||||
if (previewText) {
|
||||
void openDetectedPreview(previewText)
|
||||
}
|
||||
},
|
||||
[activeSessionIdRef, handleGatewayEvent, openDetectedPreview]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const latestAssistant = [...messages].reverse().find(message => message.role === 'assistant' && !message.pending)
|
||||
const text = latestAssistant ? chatMessageText(latestAssistant) : ''
|
||||
|
||||
if (text) {
|
||||
void openDetectedPreview(text)
|
||||
}
|
||||
}, [messages, openDetectedPreview])
|
||||
|
||||
const {
|
||||
branchCurrentSession,
|
||||
createBackendSessionForSend,
|
||||
|
|
@ -435,7 +524,9 @@ export function DesktopController() {
|
|||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
busyRef,
|
||||
creatingSessionRef,
|
||||
ensureSessionState,
|
||||
getRouteToken,
|
||||
navigate,
|
||||
requestGateway,
|
||||
runtimeIdByStoredSessionIdRef,
|
||||
|
|
@ -446,41 +537,127 @@ export function DesktopController() {
|
|||
updateSessionState
|
||||
})
|
||||
|
||||
const {
|
||||
addContextRefAttachment,
|
||||
attachDroppedItems,
|
||||
attachImageBlob,
|
||||
pasteClipboardImage,
|
||||
pickContextPaths,
|
||||
pickImages,
|
||||
removeAttachment
|
||||
} = useComposerActions({
|
||||
activeSessionId,
|
||||
currentCwd,
|
||||
requestGateway
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (currentView !== 'settings') {
|
||||
settingsReturnPathRef.current = `${location.pathname}${location.search}${location.hash}`
|
||||
}
|
||||
}, [currentView, location.hash, location.pathname, location.search])
|
||||
|
||||
const previewRouteKey = `${currentView}:${routedSessionId || ''}:${selectedStoredSessionId || ''}`
|
||||
const lastPreviewRouteRef = useRef(previewRouteKey)
|
||||
|
||||
useEffect(() => {
|
||||
if (lastPreviewRouteRef.current !== previewRouteKey) {
|
||||
lastPreviewRouteRef.current = previewRouteKey
|
||||
lastPreviewUrlRef.current = ''
|
||||
setPreviewTarget(null)
|
||||
}
|
||||
}, [previewRouteKey])
|
||||
|
||||
const closeSettingsToPreviousRoute = useCallback(() => {
|
||||
navigate(settingsReturnPathRef.current || NEW_CHAT_ROUTE, { replace: true })
|
||||
}, [navigate])
|
||||
|
||||
const branchInNewChat = useCallback(
|
||||
async (messageId: string) => {
|
||||
async (messageId?: string) => {
|
||||
const branched = await branchCurrentSession(messageId)
|
||||
|
||||
if (branched) {
|
||||
await refreshSessions().catch(() => undefined)
|
||||
}
|
||||
|
||||
return branched
|
||||
},
|
||||
[branchCurrentSession, refreshSessions]
|
||||
)
|
||||
|
||||
const { cancelRun, handleThreadMessagesChange, reloadFromMessage, submitText, transcribeVoiceAudio } =
|
||||
const handleSkinCommand = useCallback(
|
||||
(rawArg: string) => {
|
||||
const arg = rawArg.trim()
|
||||
const names = availableThemes.map(theme => theme.name)
|
||||
|
||||
if (!availableThemes.length) {
|
||||
return 'No desktop themes are available.'
|
||||
}
|
||||
|
||||
const activeIndex = Math.max(
|
||||
0,
|
||||
availableThemes.findIndex(theme => theme.name === themeName)
|
||||
)
|
||||
|
||||
if (!arg || arg === 'next') {
|
||||
const next = availableThemes[(activeIndex + 1) % availableThemes.length]
|
||||
|
||||
setTheme(next.name)
|
||||
|
||||
return `Desktop theme switched to ${next.label}.`
|
||||
}
|
||||
|
||||
if (arg === 'list' || arg === 'ls' || arg === 'status') {
|
||||
const rows = availableThemes.map(theme => {
|
||||
const marker = theme.name === themeName ? '*' : ' '
|
||||
|
||||
return `${marker} ${theme.name.padEnd(10)} ${theme.label}`
|
||||
})
|
||||
|
||||
return [`Desktop themes:`, ...rows, '', 'Use /skin <name>, or /skin to cycle.'].join('\n')
|
||||
}
|
||||
|
||||
const normalized = arg.toLowerCase()
|
||||
|
||||
const aliases: Record<string, string> = {
|
||||
ares: 'ember',
|
||||
hermes: 'default'
|
||||
}
|
||||
|
||||
const targetName = aliases[normalized] || normalized
|
||||
|
||||
const target = availableThemes.find(
|
||||
theme => theme.name.toLowerCase() === targetName || theme.label.toLowerCase() === normalized
|
||||
)
|
||||
|
||||
if (!target) {
|
||||
return `Unknown desktop theme: ${arg}\nAvailable: ${names.join(', ')}`
|
||||
}
|
||||
|
||||
setTheme(target.name)
|
||||
|
||||
return `Desktop theme switched to ${target.label}.`
|
||||
},
|
||||
[availableThemes, setTheme, themeName]
|
||||
)
|
||||
|
||||
const { cancelRun, editMessage, handleThreadMessagesChange, reloadFromMessage, submitText, transcribeVoiceAudio } =
|
||||
usePromptActions({
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
branchCurrentSession: branchInNewChat,
|
||||
busyRef,
|
||||
createBackendSessionForSend,
|
||||
handleSkinCommand,
|
||||
requestGateway,
|
||||
selectedStoredSessionIdRef,
|
||||
startFreshSessionDraft,
|
||||
sttEnabled,
|
||||
updateSessionState
|
||||
})
|
||||
|
||||
useGatewayBoot({
|
||||
handleGatewayEvent,
|
||||
handleGatewayEvent: handleDesktopGatewayEvent,
|
||||
onConnectionReady: setBootConnection,
|
||||
onGatewayReady: setBootGateway,
|
||||
refreshHermesConfig,
|
||||
|
|
@ -516,7 +693,27 @@ export function DesktopController() {
|
|||
if (!alreadyActive) {
|
||||
void resumeSession(routedSessionId, true)
|
||||
}
|
||||
} else if (isNewChatRoute(location.pathname) && (selectedStoredSessionId || activeSessionId || !freshDraftReady)) {
|
||||
} else if (
|
||||
isNewChatRoute(location.pathname) &&
|
||||
!creatingSessionRef.current &&
|
||||
(selectedStoredSessionId || activeSessionId || !freshDraftReady)
|
||||
) {
|
||||
// Guard: during HashRouter boot the `location.pathname` can read `/`
|
||||
// briefly before the hash-portion (which holds the real route) is
|
||||
// parsed. If the window hash clearly references a session, defer —
|
||||
// `routedSessionId` will update in a tick and the routedSessionId
|
||||
// branch above will handle resume. Without this guard, a ctrl+R on
|
||||
// `#/:sessionId` calls startFreshSessionDraft → navigates to `/` →
|
||||
// wipes messages → races the real resume, producing the visible
|
||||
// "5 loading states" flash chain.
|
||||
if (typeof window !== 'undefined') {
|
||||
const rawHash = window.location.hash.replace(/^#/, '')
|
||||
|
||||
if (rawHash && rawHash !== '/' && !rawHash.startsWith('/settings') && !rawHash.startsWith('/skills') && !rawHash.startsWith('/artifacts')) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
startFreshSessionDraft(true)
|
||||
}
|
||||
}, [
|
||||
|
|
@ -567,6 +764,8 @@ export function DesktopController() {
|
|||
maxVoiceRecordingSeconds={voiceMaxRecordingSeconds}
|
||||
onAddContextRef={addContextRefAttachment}
|
||||
onAddUrl={url => addContextRefAttachment(`@url:${formatRefValue(url)}`, url)}
|
||||
onAttachDroppedItems={attachDroppedItems}
|
||||
onAttachImageBlob={attachImageBlob}
|
||||
onBranchInNewChat={messageId => void branchInNewChat(messageId)}
|
||||
onBrowseCwd={() => void browseSessionCwd()}
|
||||
onCancel={() => void cancelRun()}
|
||||
|
|
@ -576,6 +775,7 @@ export function DesktopController() {
|
|||
void removeSession(selectedStoredSessionId)
|
||||
}
|
||||
}}
|
||||
onEdit={editMessage}
|
||||
onOpenModelPicker={() => setModelPickerOpen(true)}
|
||||
onPasteClipboardImage={() => void pasteClipboardImage()}
|
||||
onPickFiles={() => void pickContextPaths('file')}
|
||||
|
|
@ -593,7 +793,7 @@ export function DesktopController() {
|
|||
|
||||
return (
|
||||
<AppShell
|
||||
inspectorWidth={SESSION_INSPECTOR_WIDTH}
|
||||
inspectorWidth={previewTarget ? PREVIEW_RAIL_WIDTH : SESSION_INSPECTOR_WIDTH}
|
||||
onOpenSettings={openSettings}
|
||||
overlays={overlays}
|
||||
rightRailOpen={chatOpen}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useEffect } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { HermesGateway } from '@/hermes'
|
||||
import { setGateway } from '@/store/gateway'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { setConnection, setGatewayState, setSessionsLoading } from '@/store/session'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
|
@ -22,6 +23,22 @@ export function useGatewayBoot({
|
|||
refreshHermesConfig,
|
||||
refreshSessions
|
||||
}: GatewayBootOptions) {
|
||||
const callbacksRef = useRef({
|
||||
handleGatewayEvent,
|
||||
onConnectionReady,
|
||||
onGatewayReady,
|
||||
refreshHermesConfig,
|
||||
refreshSessions
|
||||
})
|
||||
|
||||
callbacksRef.current = {
|
||||
handleGatewayEvent,
|
||||
onConnectionReady,
|
||||
onGatewayReady,
|
||||
refreshHermesConfig,
|
||||
refreshSessions
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const desktop = window.hermesDesktop
|
||||
|
|
@ -33,10 +50,11 @@ export function useGatewayBoot({
|
|||
}
|
||||
|
||||
const gateway = new HermesGateway()
|
||||
onGatewayReady(gateway)
|
||||
callbacksRef.current.onGatewayReady(gateway)
|
||||
setGateway(gateway)
|
||||
|
||||
const offState = gateway.onState(st => void setGatewayState(st))
|
||||
const offEvent = gateway.onEvent(handleGatewayEvent)
|
||||
const offEvent = gateway.onEvent(event => callbacksRef.current.handleGatewayEvent(event))
|
||||
|
||||
const offExit = desktop.onBackendExit(() => {
|
||||
notify({
|
||||
|
|
@ -55,7 +73,7 @@ export function useGatewayBoot({
|
|||
return
|
||||
}
|
||||
|
||||
onConnectionReady(conn)
|
||||
callbacksRef.current.onConnectionReady(conn)
|
||||
setConnection(conn)
|
||||
await gateway.connect(conn.wsUrl)
|
||||
|
||||
|
|
@ -63,13 +81,13 @@ export function useGatewayBoot({
|
|||
return
|
||||
}
|
||||
|
||||
await refreshHermesConfig()
|
||||
await callbacksRef.current.refreshHermesConfig()
|
||||
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
await refreshSessions()
|
||||
await callbacksRef.current.refreshSessions()
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
notifyError(err, 'Desktop boot failed')
|
||||
|
|
@ -86,8 +104,9 @@ export function useGatewayBoot({
|
|||
offEvent()
|
||||
offExit()
|
||||
gateway.close()
|
||||
onConnectionReady(null)
|
||||
onGatewayReady(null)
|
||||
callbacksRef.current.onConnectionReady(null)
|
||||
callbacksRef.current.onGatewayReady(null)
|
||||
setGateway(null)
|
||||
}
|
||||
}, [handleGatewayEvent, onConnectionReady, onGatewayReady, refreshHermesConfig, refreshSessions])
|
||||
}, [])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,15 +41,17 @@ export function useGatewayRequest() {
|
|||
return null
|
||||
}
|
||||
|
||||
const conn = connectionRef.current || (await desktop.getConnection())
|
||||
connectionRef.current = conn
|
||||
setConnection(conn)
|
||||
|
||||
try {
|
||||
const conn = await desktop.getConnection()
|
||||
connectionRef.current = conn
|
||||
setConnection(conn)
|
||||
await existing.connect(conn.wsUrl)
|
||||
|
||||
return existing
|
||||
} catch {
|
||||
connectionRef.current = null
|
||||
setConnection(null)
|
||||
|
||||
return null
|
||||
} finally {
|
||||
reconnectingRef.current = null
|
||||
|
|
|
|||
|
|
@ -2,18 +2,20 @@ import type { QueryClient } from '@tanstack/react-query'
|
|||
import { type MutableRefObject, useCallback } from 'react'
|
||||
|
||||
import {
|
||||
appendAssistantTextPart,
|
||||
appendReasoningPart,
|
||||
appendTextPart,
|
||||
assistantTextPart,
|
||||
type ChatMessage,
|
||||
type ChatMessagePart,
|
||||
chatMessageText,
|
||||
type GatewayEventPayload,
|
||||
reasoningPart,
|
||||
textPart,
|
||||
renderMediaTags,
|
||||
upsertToolPart
|
||||
} from '@/lib/chat-messages'
|
||||
import { coerceGatewayText, coerceThinkingText, normalizePersonalityValue } from '@/lib/chat-runtime'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { setClarifyRequest } from '@/store/clarify'
|
||||
import { notify } from '@/store/notifications'
|
||||
import {
|
||||
setCurrentBranch,
|
||||
|
|
@ -22,6 +24,7 @@ import {
|
|||
setCurrentPersonality,
|
||||
setCurrentProvider
|
||||
} from '@/store/session'
|
||||
import { recordToolDiff } from '@/store/tool-diffs'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
import type { ClientSessionState } from '../../types'
|
||||
|
|
@ -123,8 +126,8 @@ export function useMessageStream({
|
|||
|
||||
mutateStream(
|
||||
sessionId,
|
||||
parts => appendTextPart(parts, delta),
|
||||
() => [textPart(delta)]
|
||||
parts => appendAssistantTextPart(parts, delta),
|
||||
() => [assistantTextPart(delta)]
|
||||
)
|
||||
},
|
||||
[mutateStream]
|
||||
|
|
@ -181,7 +184,7 @@ export function useMessageStream({
|
|||
}
|
||||
|
||||
const streamId = state.streamId
|
||||
const finalText = text.trim()
|
||||
const finalText = renderMediaTags(text).trim()
|
||||
const normalize = (value: string) => value.replace(/\s+/g, ' ').trim()
|
||||
const dedupeReference = normalize(finalText)
|
||||
|
||||
|
|
@ -200,7 +203,7 @@ export function useMessageStream({
|
|||
return !(r && (dedupeReference.startsWith(r) || r.startsWith(dedupeReference)))
|
||||
})
|
||||
|
||||
return text ? [...kept, textPart(text)] : kept
|
||||
return finalText ? [...kept, assistantTextPart(finalText)] : kept
|
||||
}
|
||||
|
||||
const completeMessage = (message: ChatMessage): ChatMessage => ({
|
||||
|
|
@ -228,24 +231,24 @@ export function useMessageStream({
|
|||
nextMessages = prev.map((message, messageIndex) =>
|
||||
messageIndex === index ? completeMessage(message) : message
|
||||
)
|
||||
} else if (text) {
|
||||
} else if (finalText) {
|
||||
nextMessages = [
|
||||
...prev,
|
||||
{
|
||||
id: `assistant-${Date.now()}`,
|
||||
role: 'assistant',
|
||||
parts: [textPart(text)],
|
||||
parts: [assistantTextPart(finalText)],
|
||||
branchGroupId: state.pendingBranchGroup ?? undefined
|
||||
}
|
||||
]
|
||||
}
|
||||
} else if (text) {
|
||||
} else if (finalText) {
|
||||
nextMessages = [
|
||||
...prev,
|
||||
{
|
||||
id: `assistant-${Date.now()}`,
|
||||
role: 'assistant',
|
||||
parts: [textPart(text)],
|
||||
parts: [assistantTextPart(finalText)],
|
||||
branchGroupId: state.pendingBranchGroup ?? undefined
|
||||
}
|
||||
]
|
||||
|
|
@ -408,6 +411,29 @@ export function useMessageStream({
|
|||
if (sessionId) {
|
||||
upsertToolCall(sessionId, payload, 'complete')
|
||||
}
|
||||
|
||||
if (typeof payload?.inline_diff === 'string' && payload.inline_diff.trim()) {
|
||||
recordToolDiff(payload.tool_id || payload.name || '', payload.inline_diff)
|
||||
}
|
||||
} else if (event.type === 'clarify.request') {
|
||||
if (!isActiveEvent) {
|
||||
return
|
||||
}
|
||||
|
||||
// Surface the clarify tool's overlay. The Python side is blocked on
|
||||
// `clarify.respond`, so without this handler the agent would hang
|
||||
// forever (see tools/clarify_tool.py + tui_gateway/server.py:_block).
|
||||
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
|
||||
const question = typeof payload?.question === 'string' ? payload.question : ''
|
||||
|
||||
if (requestId && question) {
|
||||
setClarifyRequest({
|
||||
requestId,
|
||||
question,
|
||||
choices: Array.isArray(payload?.choices) ? payload!.choices!.filter(c => typeof c === 'string') : null,
|
||||
sessionId: sessionId ?? null
|
||||
})
|
||||
}
|
||||
} else if (event.type === 'error') {
|
||||
if (isActiveEvent) {
|
||||
notify({
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { ThreadMessage } from '@assistant-ui/react'
|
||||
import type { AppendMessage, ThreadMessage } from '@assistant-ui/react'
|
||||
import { type MutableRefObject, useCallback } from 'react'
|
||||
|
||||
import { transcribeAudio } from '@/hermes'
|
||||
|
|
@ -8,14 +8,21 @@ import {
|
|||
INTERRUPTED_MARKER,
|
||||
parseCommandDispatch,
|
||||
parseSlashCommand,
|
||||
pathLabel,
|
||||
SLASH_COMMAND_RE
|
||||
} from '@/lib/chat-runtime'
|
||||
import {
|
||||
type CommandsCatalogLike,
|
||||
desktopSlashUnavailableMessage,
|
||||
filterDesktopCommandsCatalog,
|
||||
isDesktopSlashCommand
|
||||
} from '@/lib/desktop-slash-commands'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { $composerAttachments, clearComposerAttachments } from '@/store/composer'
|
||||
import { $composerAttachments, addComposerAttachment, clearComposerAttachments, type ComposerAttachment } from '@/store/composer'
|
||||
import { clearNotifications, notify, notifyError } from '@/store/notifications'
|
||||
import { $busy, $messages, setAwaitingResponse, setBusy, setMessages } from '@/store/session'
|
||||
|
||||
import type { ClientSessionState, SlashExecResponse } from '../../types'
|
||||
import type { ClientSessionState, ImageAttachResponse, SlashExecResponse } from '../../types'
|
||||
|
||||
function blobToDataUrl(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
|
@ -37,9 +44,12 @@ interface PromptActionsOptions {
|
|||
activeSessionId: string | null
|
||||
activeSessionIdRef: MutableRefObject<string | null>
|
||||
busyRef: MutableRefObject<boolean>
|
||||
branchCurrentSession: () => Promise<boolean>
|
||||
createBackendSessionForSend: () => Promise<string | null>
|
||||
handleSkinCommand: (arg: string) => string
|
||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
selectedStoredSessionIdRef: MutableRefObject<string | null>
|
||||
startFreshSessionDraft: () => void
|
||||
sttEnabled: boolean
|
||||
updateSessionState: (
|
||||
sessionId: string,
|
||||
|
|
@ -48,15 +58,12 @@ interface PromptActionsOptions {
|
|||
) => ClientSessionState
|
||||
}
|
||||
|
||||
interface CommandsCatalogResponse {
|
||||
categories?: Array<{ name: string; pairs: [string, string][] }>
|
||||
pairs?: [string, string][]
|
||||
skill_count?: number
|
||||
warning?: string
|
||||
}
|
||||
function renderCommandsCatalog(catalog: CommandsCatalogLike): string {
|
||||
const desktopCatalog = filterDesktopCommandsCatalog(catalog)
|
||||
|
||||
function renderCommandsCatalog(catalog: CommandsCatalogResponse): string {
|
||||
const sections = catalog.categories?.length ? catalog.categories : [{ name: 'Commands', pairs: catalog.pairs ?? [] }]
|
||||
const sections = desktopCatalog.categories?.length
|
||||
? desktopCatalog.categories
|
||||
: [{ name: 'Desktop commands', pairs: desktopCatalog.pairs ?? [] }]
|
||||
|
||||
const body = sections
|
||||
.filter(section => section.pairs.length > 0)
|
||||
|
|
@ -68,22 +75,40 @@ function renderCommandsCatalog(catalog: CommandsCatalogResponse): string {
|
|||
.join('\n\n')
|
||||
|
||||
const tail = [
|
||||
catalog.skill_count ? `${catalog.skill_count} skill commands available.` : '',
|
||||
catalog.warning ? `warning: ${catalog.warning}` : ''
|
||||
desktopCatalog.skill_count ? `${desktopCatalog.skill_count} skill commands available.` : '',
|
||||
desktopCatalog.warning ? `warning: ${desktopCatalog.warning}` : ''
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
|
||||
return [body || 'No commands available.', tail].filter(Boolean).join('\n\n')
|
||||
return [body || 'No desktop commands available.', tail].filter(Boolean).join('\n\n')
|
||||
}
|
||||
|
||||
function slashStatusText(command: string, output: string): string {
|
||||
return [`slash:${command}`, output.trim()].filter(Boolean).join('\n')
|
||||
}
|
||||
|
||||
function appendText(message: AppendMessage): string {
|
||||
return message.content
|
||||
.map(part => ('text' in part ? part.text : ''))
|
||||
.join('')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function visibleUserOrdinal(messages: readonly ChatMessage[], end: number): number {
|
||||
return messages.slice(0, end).filter(m => m.role === 'user' && !m.hidden).length
|
||||
}
|
||||
|
||||
export function usePromptActions({
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
busyRef,
|
||||
branchCurrentSession,
|
||||
createBackendSessionForSend,
|
||||
handleSkinCommand,
|
||||
requestGateway,
|
||||
selectedStoredSessionIdRef,
|
||||
startFreshSessionDraft,
|
||||
sttEnabled,
|
||||
updateSessionState
|
||||
}: PromptActionsOptions) {
|
||||
|
|
@ -114,6 +139,39 @@ export function usePromptActions({
|
|||
[selectedStoredSessionIdRef, updateSessionState]
|
||||
)
|
||||
|
||||
const syncImageAttachmentsForSubmit = useCallback(
|
||||
async (sessionId: string, attachments: ComposerAttachment[]) => {
|
||||
const images = attachments.filter(attachment => attachment.kind === 'image' && attachment.path)
|
||||
|
||||
for (const attachment of images) {
|
||||
if (attachment.attachedSessionId === sessionId) {
|
||||
continue
|
||||
}
|
||||
|
||||
const result = await requestGateway<ImageAttachResponse>('image.attach', {
|
||||
session_id: sessionId,
|
||||
path: attachment.path
|
||||
})
|
||||
|
||||
if (!result.attached) {
|
||||
const label = attachment.label || (attachment.path ? pathLabel(attachment.path) : 'image')
|
||||
throw new Error(result.message || `Could not attach ${label}`)
|
||||
}
|
||||
|
||||
const attachedPath = result.path || attachment.path
|
||||
|
||||
addComposerAttachment({
|
||||
...attachment,
|
||||
id: attachment.id,
|
||||
label: attachedPath ? pathLabel(attachedPath) : attachment.label,
|
||||
path: attachedPath,
|
||||
attachedSessionId: sessionId
|
||||
})
|
||||
}
|
||||
},
|
||||
[requestGateway]
|
||||
)
|
||||
|
||||
const submitPromptText = useCallback(
|
||||
async (rawText: string) => {
|
||||
const visibleText = rawText.trim()
|
||||
|
|
@ -146,21 +204,33 @@ export function usePromptActions({
|
|||
]
|
||||
}
|
||||
|
||||
const releaseBusy = () => {
|
||||
busyRef.current = false
|
||||
setBusy(false)
|
||||
setAwaitingResponse(false)
|
||||
}
|
||||
|
||||
busyRef.current = true
|
||||
setBusy(true)
|
||||
setAwaitingResponse(true)
|
||||
clearNotifications()
|
||||
const sessionId = activeSessionId ? activeSessionId : await createBackendSessionForSend()
|
||||
|
||||
let sessionId = activeSessionId
|
||||
|
||||
if (!sessionId) {
|
||||
busyRef.current = false
|
||||
setBusy(false)
|
||||
setAwaitingResponse(false)
|
||||
notify({
|
||||
kind: 'error',
|
||||
title: 'Session unavailable',
|
||||
message: 'Could not create a new session'
|
||||
})
|
||||
try {
|
||||
sessionId = await createBackendSessionForSend()
|
||||
} catch (err) {
|
||||
releaseBusy()
|
||||
notifyError(err, 'Session unavailable')
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
releaseBusy()
|
||||
notify({ kind: 'error', title: 'Session unavailable', message: 'Could not create a new session' })
|
||||
|
||||
return
|
||||
}
|
||||
|
|
@ -180,20 +250,24 @@ export function usePromptActions({
|
|||
)
|
||||
|
||||
try {
|
||||
await syncImageAttachmentsForSubmit(sessionId, attachments)
|
||||
await requestGateway('prompt.submit', { session_id: sessionId, text })
|
||||
clearComposerAttachments()
|
||||
} catch (err) {
|
||||
busyRef.current = false
|
||||
updateSessionState(sessionId, state => ({
|
||||
...state,
|
||||
messages: state.messages.filter(message => message.id !== userMessage.id),
|
||||
busy: false,
|
||||
awaitingResponse: false
|
||||
}))
|
||||
releaseBusy()
|
||||
updateSessionState(sessionId, state => ({ ...state, busy: false, awaitingResponse: false }))
|
||||
notifyError(err, 'Prompt failed')
|
||||
}
|
||||
},
|
||||
[activeSessionId, createBackendSessionForSend, requestGateway, selectedStoredSessionIdRef, updateSessionState]
|
||||
[
|
||||
activeSessionId,
|
||||
busyRef,
|
||||
createBackendSessionForSend,
|
||||
requestGateway,
|
||||
selectedStoredSessionIdRef,
|
||||
syncImageAttachmentsForSubmit,
|
||||
updateSessionState
|
||||
]
|
||||
)
|
||||
|
||||
const executeSlashCommand = useCallback(
|
||||
|
|
@ -201,6 +275,36 @@ export function usePromptActions({
|
|||
const runSlash = async (commandText: string, sessionHint?: string, recordInput = true): Promise<void> => {
|
||||
const command = commandText.trim()
|
||||
const { name, arg } = parseSlashCommand(command)
|
||||
const normalizedName = name.toLowerCase()
|
||||
|
||||
if (!name) {
|
||||
const sessionId = sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend())
|
||||
|
||||
if (sessionId) {
|
||||
appendSessionTextMessage(sessionId, 'system', 'empty slash command')
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (normalizedName === 'new' || normalizedName === 'reset') {
|
||||
startFreshSessionDraft()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (normalizedName === 'branch' || normalizedName === 'fork') {
|
||||
await branchCurrentSession()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (normalizedName === 'skin' && !sessionHint && !activeSessionIdRef.current) {
|
||||
notify({ kind: 'success', message: handleSkinCommand(arg) })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const sessionId = sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend())
|
||||
|
||||
if (!sessionId) {
|
||||
|
|
@ -213,21 +317,18 @@ export function usePromptActions({
|
|||
return
|
||||
}
|
||||
|
||||
const renderSlashOutput = (text: string) => appendSessionTextMessage(sessionId, 'system', text)
|
||||
const renderSlashOutput = (text: string) =>
|
||||
appendSessionTextMessage(sessionId, 'system', recordInput ? slashStatusText(command, text) : text)
|
||||
|
||||
if (recordInput) {
|
||||
appendSessionTextMessage(sessionId, 'user', command)
|
||||
}
|
||||
|
||||
if (!name) {
|
||||
renderSlashOutput('empty slash command')
|
||||
if (normalizedName === 'skin') {
|
||||
renderSlashOutput(handleSkinCommand(arg))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (name === 'help' || name === 'commands') {
|
||||
try {
|
||||
const catalog = await requestGateway<CommandsCatalogResponse>('commands.catalog', { session_id: sessionId })
|
||||
const catalog = await requestGateway<CommandsCatalogLike>('commands.catalog', { session_id: sessionId })
|
||||
|
||||
renderSlashOutput(renderCommandsCatalog(catalog))
|
||||
} catch (err) {
|
||||
|
|
@ -237,6 +338,12 @@ export function usePromptActions({
|
|||
return
|
||||
}
|
||||
|
||||
if (!isDesktopSlashCommand(name)) {
|
||||
renderSlashOutput(desktopSlashUnavailableMessage(name) || `/${name} is not available in the desktop app.`)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await requestGateway<SlashExecResponse>('slash.exec', {
|
||||
session_id: sessionId,
|
||||
|
|
@ -306,7 +413,17 @@ export function usePromptActions({
|
|||
|
||||
await runSlash(rawCommand, options?.sessionId, options?.recordInput ?? true)
|
||||
},
|
||||
[activeSessionIdRef, appendSessionTextMessage, createBackendSessionForSend, requestGateway, submitPromptText]
|
||||
[
|
||||
activeSessionIdRef,
|
||||
appendSessionTextMessage,
|
||||
branchCurrentSession,
|
||||
busyRef,
|
||||
createBackendSessionForSend,
|
||||
handleSkinCommand,
|
||||
requestGateway,
|
||||
startFreshSessionDraft,
|
||||
submitPromptText
|
||||
]
|
||||
)
|
||||
|
||||
const submitText = useCallback(
|
||||
|
|
@ -433,6 +550,7 @@ export function usePromptActions({
|
|||
: messages.slice(absoluteUserIndex + 1).find(message => message.role === 'assistant')
|
||||
|
||||
const branchGroupId = targetAssistant?.branchGroupId ?? branchGroupForUser(userMessage)
|
||||
const truncateBeforeUserOrdinal = visibleUserOrdinal(messages, absoluteUserIndex)
|
||||
|
||||
clearNotifications()
|
||||
updateSessionState(activeSessionId, state => {
|
||||
|
|
@ -459,7 +577,11 @@ export function usePromptActions({
|
|||
})
|
||||
|
||||
try {
|
||||
await requestGateway('prompt.submit', { session_id: activeSessionId, text: userText })
|
||||
await requestGateway('prompt.submit', {
|
||||
session_id: activeSessionId,
|
||||
text: userText,
|
||||
truncate_before_user_ordinal: truncateBeforeUserOrdinal
|
||||
})
|
||||
} catch (err) {
|
||||
updateSessionState(activeSessionId, state => ({
|
||||
...state,
|
||||
|
|
@ -472,26 +594,80 @@ export function usePromptActions({
|
|||
[activeSessionId, requestGateway, updateSessionState]
|
||||
)
|
||||
|
||||
const editMessage = useCallback(
|
||||
async (edited: AppendMessage) => {
|
||||
const sessionId = activeSessionId || activeSessionIdRef.current
|
||||
const sourceId = edited.sourceId || edited.parentId
|
||||
const text = appendText(edited)
|
||||
|
||||
if (!sessionId || !sourceId || !text || edited.role !== 'user' || $busy.get()) {
|
||||
return
|
||||
}
|
||||
|
||||
const messages = $messages.get()
|
||||
const sourceIndex = messages.findIndex(m => m.id === sourceId)
|
||||
const source = messages[sourceIndex]
|
||||
|
||||
if (!source || source.role !== 'user' || chatMessageText(source).trim() === text) {
|
||||
return
|
||||
}
|
||||
|
||||
const truncate_before_user_ordinal = visibleUserOrdinal(messages, sourceIndex)
|
||||
const editedMessage: ChatMessage = { ...source, parts: [textPart(text)] }
|
||||
|
||||
clearNotifications()
|
||||
updateSessionState(sessionId, state => ({
|
||||
...state,
|
||||
busy: true,
|
||||
awaitingResponse: true,
|
||||
pendingBranchGroup: null,
|
||||
sawAssistantPayload: false,
|
||||
interrupted: false,
|
||||
messages: [...state.messages.slice(0, sourceIndex), editedMessage]
|
||||
}))
|
||||
|
||||
try {
|
||||
await requestGateway('prompt.submit', { session_id: sessionId, text, truncate_before_user_ordinal })
|
||||
} catch (err) {
|
||||
updateSessionState(sessionId, state => ({ ...state, busy: false, awaitingResponse: false }))
|
||||
notifyError(err, 'Edit failed')
|
||||
}
|
||||
},
|
||||
[activeSessionId, activeSessionIdRef, requestGateway, updateSessionState]
|
||||
)
|
||||
|
||||
const handleThreadMessagesChange = useCallback(
|
||||
(nextMessages: readonly ThreadMessage[]) => {
|
||||
const visibleIds = new Set(nextMessages.map(message => message.id))
|
||||
const visibleIds = new Set(nextMessages.map(m => m.id))
|
||||
const sessionId = activeSessionIdRef.current
|
||||
|
||||
if (!sessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
updateSessionState(sessionId, state => ({
|
||||
...state,
|
||||
messages: state.messages.map(message =>
|
||||
message.role === 'assistant' && message.branchGroupId
|
||||
? { ...message, hidden: !visibleIds.has(message.id) }
|
||||
: message
|
||||
)
|
||||
}))
|
||||
updateSessionState(sessionId, state => {
|
||||
let changed = false
|
||||
const messages = state.messages.map(message => {
|
||||
if (message.role !== 'assistant' || !message.branchGroupId) {
|
||||
return message
|
||||
}
|
||||
|
||||
const hidden = !visibleIds.has(message.id)
|
||||
|
||||
if (message.hidden === hidden) {
|
||||
return message
|
||||
}
|
||||
|
||||
changed = true
|
||||
|
||||
return { ...message, hidden }
|
||||
})
|
||||
|
||||
return changed ? { ...state, messages } : state
|
||||
})
|
||||
},
|
||||
[activeSessionIdRef, updateSessionState]
|
||||
)
|
||||
|
||||
return { cancelRun, handleThreadMessagesChange, reloadFromMessage, submitText, transcribeVoiceAudio }
|
||||
return { cancelRun, editMessage, handleThreadMessagesChange, reloadFromMessage, submitText, transcribeVoiceAudio }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@ import { useCallback, useRef } from 'react'
|
|||
import type { NavigateFunction } from 'react-router-dom'
|
||||
|
||||
import { deleteSession, getSessionMessages } from '@/hermes'
|
||||
import { chatMessageText, toChatMessages } from '@/lib/chat-messages'
|
||||
import { type ChatMessage, chatMessageText, toChatMessages } from '@/lib/chat-messages'
|
||||
import { normalizePersonalityValue } from '@/lib/chat-runtime'
|
||||
import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-images'
|
||||
import { clearComposerAttachments, clearComposerDraft } from '@/store/composer'
|
||||
import { $pinnedSessionIds } from '@/store/layout'
|
||||
import { clearNotifications, notify, notifyError } from '@/store/notifications'
|
||||
|
|
@ -25,7 +26,7 @@ import {
|
|||
setSelectedStoredSessionId,
|
||||
setSessions
|
||||
} from '@/store/session'
|
||||
import type { SessionCreateResponse, SessionResumeResponse } from '@/types/hermes'
|
||||
import type { SessionCreateResponse, SessionInfo, SessionResumeResponse } from '@/types/hermes'
|
||||
|
||||
import { NEW_CHAT_ROUTE, sessionRoute, SETTINGS_ROUTE } from '../../routes'
|
||||
import type { ClientSessionState, SidebarNavItem } from '../../types'
|
||||
|
|
@ -34,7 +35,9 @@ interface SessionActionsOptions {
|
|||
activeSessionId: string | null
|
||||
activeSessionIdRef: MutableRefObject<string | null>
|
||||
busyRef: MutableRefObject<boolean>
|
||||
creatingSessionRef: MutableRefObject<boolean>
|
||||
ensureSessionState: (sessionId: string, storedSessionId?: string | null) => ClientSessionState
|
||||
getRouteToken: () => string
|
||||
navigate: NavigateFunction
|
||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>>
|
||||
|
|
@ -49,11 +52,156 @@ interface SessionActionsOptions {
|
|||
) => ClientSessionState
|
||||
}
|
||||
|
||||
function withAppendedText(message: ChatMessage, suffix: string): ChatMessage {
|
||||
let appended = false
|
||||
const parts = message.parts.map(part => {
|
||||
if (part.type !== 'text' || appended) {
|
||||
return part
|
||||
}
|
||||
|
||||
appended = true
|
||||
|
||||
return { ...part, text: `${part.text}${suffix}` }
|
||||
})
|
||||
|
||||
return appended ? { ...message, parts } : message
|
||||
}
|
||||
|
||||
function preserveReasoningParts(message: ChatMessage, previous: ChatMessage): ChatMessage {
|
||||
if (message.parts.some(part => part.type === 'reasoning')) {
|
||||
return message
|
||||
}
|
||||
|
||||
const reasoningParts = previous.parts.filter(part => part.type === 'reasoning')
|
||||
|
||||
return reasoningParts.length ? { ...message, parts: [...reasoningParts, ...message.parts] } : message
|
||||
}
|
||||
|
||||
function chatMessagesEquivalent(a: ChatMessage, b: ChatMessage): boolean {
|
||||
if (a.id !== b.id || a.role !== b.role || a.pending !== b.pending || a.hidden !== b.hidden || a.branchGroupId !== b.branchGroupId) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (a.parts.length !== b.parts.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
return a.parts.every((part, index) => JSON.stringify(part) === JSON.stringify(b.parts[index]))
|
||||
}
|
||||
|
||||
function chatMessageArraysEquivalent(a: ChatMessage[], b: ChatMessage[]): boolean {
|
||||
return a.length === b.length && a.every((message, index) => chatMessagesEquivalent(message, b[index]))
|
||||
}
|
||||
|
||||
function reconcileResumeMessages(nextMessages: ChatMessage[], previousMessages: ChatMessage[]): ChatMessage[] {
|
||||
if (!previousMessages.length) {
|
||||
return nextMessages
|
||||
}
|
||||
|
||||
const previousByRoleOrdinal = new Map<string, ChatMessage>()
|
||||
const previousRoleCounts = new Map<string, number>()
|
||||
|
||||
for (const message of previousMessages) {
|
||||
const ordinal = previousRoleCounts.get(message.role) ?? 0
|
||||
previousRoleCounts.set(message.role, ordinal + 1)
|
||||
previousByRoleOrdinal.set(`${message.role}:${ordinal}`, message)
|
||||
}
|
||||
|
||||
const nextRoleCounts = new Map<string, number>()
|
||||
|
||||
return nextMessages.map(message => {
|
||||
const ordinal = nextRoleCounts.get(message.role) ?? 0
|
||||
nextRoleCounts.set(message.role, ordinal + 1)
|
||||
|
||||
const previous = previousByRoleOrdinal.get(`${message.role}:${ordinal}`)
|
||||
|
||||
if (!previous) {
|
||||
return message
|
||||
}
|
||||
|
||||
const nextText = chatMessageText(message).trim()
|
||||
const previousText = chatMessageText(previous)
|
||||
const previousVisibleText = textWithoutEmbeddedImages(previousText)
|
||||
let preserved = message
|
||||
|
||||
if (nextText === previousVisibleText || nextText === previousText.trim()) {
|
||||
preserved = preserveReasoningParts(preserved, previous)
|
||||
}
|
||||
|
||||
const previousImages = embeddedImageUrls(previousText)
|
||||
|
||||
if (!previousImages.length || embeddedImageUrls(chatMessageText(preserved)).length) {
|
||||
return preserved
|
||||
}
|
||||
|
||||
if (nextText !== previousVisibleText) {
|
||||
return preserved
|
||||
}
|
||||
|
||||
return withAppendedText(preserved, previousImages.map(url => `\n${url}`).join(''))
|
||||
})
|
||||
}
|
||||
|
||||
function upsertOptimisticSession(
|
||||
created: SessionCreateResponse,
|
||||
id: string,
|
||||
title: string | null = null,
|
||||
preview: string | null = null
|
||||
) {
|
||||
const now = Date.now() / 1000
|
||||
|
||||
const session: SessionInfo = {
|
||||
ended_at: null,
|
||||
id,
|
||||
input_tokens: 0,
|
||||
is_active: true,
|
||||
last_active: now,
|
||||
message_count: created.message_count ?? created.messages?.length ?? 0,
|
||||
model: created.info?.model ?? null,
|
||||
output_tokens: 0,
|
||||
preview,
|
||||
source: 'tui',
|
||||
started_at: now,
|
||||
title,
|
||||
tool_call_count: 0
|
||||
}
|
||||
|
||||
setSessions(prev => [session, ...prev.filter(s => s.id !== id)])
|
||||
}
|
||||
|
||||
function applyRuntimeInfo(info: SessionCreateResponse['info'] | undefined) {
|
||||
if (!info) {
|
||||
return
|
||||
}
|
||||
|
||||
if (info.model) {
|
||||
setCurrentModel(info.model)
|
||||
}
|
||||
|
||||
if (info.provider) {
|
||||
setCurrentProvider(info.provider)
|
||||
}
|
||||
|
||||
if (info.cwd) {
|
||||
setCurrentCwd(info.cwd)
|
||||
}
|
||||
|
||||
if (info.branch !== undefined) {
|
||||
setCurrentBranch(info.branch || '')
|
||||
}
|
||||
|
||||
if (typeof info.personality === 'string') {
|
||||
setCurrentPersonality(normalizePersonalityValue(info.personality))
|
||||
}
|
||||
}
|
||||
|
||||
export function useSessionActions({
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
busyRef,
|
||||
creatingSessionRef,
|
||||
ensureSessionState,
|
||||
getRouteToken,
|
||||
navigate,
|
||||
requestGateway,
|
||||
runtimeIdByStoredSessionIdRef,
|
||||
|
|
@ -86,43 +234,47 @@ export function useSessionActions({
|
|||
)
|
||||
|
||||
const createBackendSessionForSend = useCallback(async (): Promise<string | null> => {
|
||||
const created = await requestGateway<SessionCreateResponse>('session.create', { cols: 96 })
|
||||
const startingActiveSessionId = activeSessionIdRef.current
|
||||
const startingStoredSessionId = selectedStoredSessionIdRef.current
|
||||
const startingRouteToken = getRouteToken()
|
||||
|
||||
if (created.stored_session_id) {
|
||||
navigate(sessionRoute(created.stored_session_id), { replace: true })
|
||||
creatingSessionRef.current = true
|
||||
|
||||
try {
|
||||
const created = await requestGateway<SessionCreateResponse>('session.create', { cols: 96 })
|
||||
const stored = created.stored_session_id ?? null
|
||||
|
||||
if (
|
||||
activeSessionIdRef.current !== startingActiveSessionId ||
|
||||
selectedStoredSessionIdRef.current !== startingStoredSessionId ||
|
||||
getRouteToken() !== startingRouteToken
|
||||
) {
|
||||
await requestGateway('session.close', { session_id: created.session_id }).catch(() => undefined)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
activeSessionIdRef.current = created.session_id
|
||||
selectedStoredSessionIdRef.current = stored
|
||||
ensureSessionState(created.session_id, stored)
|
||||
|
||||
if (stored) {
|
||||
upsertOptimisticSession(created, stored)
|
||||
navigate(sessionRoute(stored), { replace: true })
|
||||
}
|
||||
|
||||
setFreshDraftReady(false)
|
||||
setActiveSessionId(created.session_id)
|
||||
setSelectedStoredSessionId(stored)
|
||||
applyRuntimeInfo(created.info)
|
||||
|
||||
return created.session_id
|
||||
} finally {
|
||||
window.setTimeout(() => {
|
||||
creatingSessionRef.current = false
|
||||
}, 0)
|
||||
}
|
||||
|
||||
setActiveSessionId(created.session_id)
|
||||
activeSessionIdRef.current = created.session_id
|
||||
ensureSessionState(created.session_id, created.stored_session_id ?? null)
|
||||
|
||||
if (created.stored_session_id) {
|
||||
setSelectedStoredSessionId(created.stored_session_id)
|
||||
selectedStoredSessionIdRef.current = created.stored_session_id
|
||||
}
|
||||
|
||||
if (created.info?.model) {
|
||||
setCurrentModel(created.info.model)
|
||||
}
|
||||
|
||||
if (created.info?.provider) {
|
||||
setCurrentProvider(created.info.provider)
|
||||
}
|
||||
|
||||
if (created.info?.cwd) {
|
||||
setCurrentCwd(created.info.cwd)
|
||||
}
|
||||
|
||||
if (created.info?.branch) {
|
||||
setCurrentBranch(created.info.branch)
|
||||
}
|
||||
|
||||
if (typeof created.info?.personality === 'string') {
|
||||
setCurrentPersonality(normalizePersonalityValue(created.info.personality))
|
||||
}
|
||||
|
||||
return created.session_id
|
||||
}, [activeSessionIdRef, ensureSessionState, navigate, requestGateway, selectedStoredSessionIdRef])
|
||||
}, [activeSessionIdRef, creatingSessionRef, ensureSessionState, getRouteToken, navigate, requestGateway, selectedStoredSessionIdRef])
|
||||
|
||||
const selectSidebarItem = useCallback(
|
||||
(item: SidebarNavItem) => {
|
||||
|
|
@ -187,41 +339,65 @@ export function useSessionActions({
|
|||
clearNotifications()
|
||||
setSelectedStoredSessionId(storedSessionId)
|
||||
selectedStoredSessionIdRef.current = storedSessionId
|
||||
setMessages([])
|
||||
|
||||
try {
|
||||
let resumeApplied = false
|
||||
// Load the local snapshot first, then ask the gateway to resume.
|
||||
// Previously these raced:
|
||||
// 1. clear messages to []
|
||||
// 2. local getSessionMessages -> 45 msgs
|
||||
// 3. a second resume path cleared [] again
|
||||
// 4. gateway resume -> 43 msgs
|
||||
// That is the ctrl+R flash chain. Avoid showing an empty thread
|
||||
// while we already have a route-scoped session id, and don't race the
|
||||
// local snapshot against gateway resume.
|
||||
let localSnapshot = $messages.get()
|
||||
|
||||
const storedMessagesPromise = getSessionMessages(storedSessionId)
|
||||
.then(storedMessages => {
|
||||
if (!resumeApplied && isCurrentResume()) {
|
||||
setMessages(toChatMessages(storedMessages.messages))
|
||||
try {
|
||||
const storedMessages = await getSessionMessages(storedSessionId)
|
||||
|
||||
if (isCurrentResume()) {
|
||||
localSnapshot = toChatMessages(storedMessages.messages)
|
||||
|
||||
if (!chatMessageArraysEquivalent($messages.get(), localSnapshot)) {
|
||||
setMessages(localSnapshot)
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal: gateway resume below can still hydrate the session.
|
||||
}
|
||||
|
||||
const resumePromise = requestGateway<SessionResumeResponse>('session.resume', {
|
||||
const resumed = await requestGateway<SessionResumeResponse>('session.resume', {
|
||||
session_id: storedSessionId,
|
||||
cols: 96
|
||||
})
|
||||
|
||||
void storedMessagesPromise
|
||||
|
||||
const resumed = await resumePromise
|
||||
|
||||
resumeApplied = true
|
||||
|
||||
if (!isCurrentResume()) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentMessages = $messages.get()
|
||||
const resumedMessages = reconcileResumeMessages(toChatMessages(resumed.messages), currentMessages)
|
||||
// Avoid a second visible transcript rebuild on resume/switch.
|
||||
// `getSessionMessages()` is the stable stored transcript snapshot and
|
||||
// paints first; `session.resume` can return a slightly different
|
||||
// runtime-shaped projection (e.g. tool/system coalescing), which was
|
||||
// causing a second full message-list replacement a second later.
|
||||
// Keep the already-painted local snapshot for the view/cache when it
|
||||
// exists; use gateway messages only as a fallback when no local
|
||||
// snapshot was available.
|
||||
const messagesForView = localSnapshot.length > 0
|
||||
? localSnapshot
|
||||
: chatMessageArraysEquivalent(currentMessages, resumedMessages)
|
||||
? currentMessages
|
||||
: resumedMessages
|
||||
|
||||
setActiveSessionId(resumed.session_id)
|
||||
activeSessionIdRef.current = resumed.session_id
|
||||
updateSessionState(
|
||||
resumed.session_id,
|
||||
state => ({
|
||||
...state,
|
||||
messages: toChatMessages(resumed.messages),
|
||||
messages: messagesForView,
|
||||
busy: false,
|
||||
awaitingResponse: false
|
||||
}),
|
||||
|
|
@ -229,24 +405,7 @@ export function useSessionActions({
|
|||
)
|
||||
clearComposerDraft()
|
||||
clearComposerAttachments()
|
||||
|
||||
if (resumed.info?.model) {
|
||||
setCurrentModel(resumed.info.model)
|
||||
}
|
||||
|
||||
if (resumed.info?.provider) {
|
||||
setCurrentProvider(resumed.info.provider)
|
||||
}
|
||||
|
||||
if (resumed.info?.cwd) {
|
||||
setCurrentCwd(resumed.info.cwd)
|
||||
}
|
||||
|
||||
setCurrentBranch(resumed.info?.branch || '')
|
||||
|
||||
if (typeof resumed.info?.personality === 'string') {
|
||||
setCurrentPersonality(normalizePersonalityValue(resumed.info.personality))
|
||||
}
|
||||
applyRuntimeInfo(resumed.info)
|
||||
} catch (err) {
|
||||
if (!isCurrentResume()) {
|
||||
return
|
||||
|
|
@ -304,9 +463,15 @@ export function useSessionActions({
|
|||
return false
|
||||
}
|
||||
|
||||
creatingSessionRef.current = true
|
||||
|
||||
try {
|
||||
const currentMessages = $messages.get()
|
||||
const targetIndex = messageId ? currentMessages.findIndex(message => message.id === messageId) : -1
|
||||
|
||||
const targetIndex = messageId
|
||||
? currentMessages.findIndex(message => message.id === messageId)
|
||||
: currentMessages.findLastIndex(message => message.role === 'assistant' || message.role === 'user')
|
||||
|
||||
const branchStart = targetIndex >= 0 ? targetIndex : Math.max(currentMessages.length - 1, 0)
|
||||
const branchEnd = targetIndex >= 0 ? targetIndex + 1 : currentMessages.length
|
||||
|
||||
|
|
@ -317,7 +482,7 @@ export function useSessionActions({
|
|||
source: message,
|
||||
role: message.role
|
||||
}))
|
||||
.filter(message => message.content.trim() && ['assistant', 'system', 'user'].includes(message.role))
|
||||
.filter(message => message.content.trim() && ['assistant', 'user'].includes(message.role))
|
||||
|
||||
if (!branchMessages.length) {
|
||||
notify({
|
||||
|
|
@ -338,8 +503,10 @@ export function useSessionActions({
|
|||
})
|
||||
|
||||
const routedSessionId = branched.stored_session_id ?? branched.session_id
|
||||
const preview = branchMessages.map(({ content }) => content).find(Boolean) ?? null
|
||||
|
||||
setFreshDraftReady(false)
|
||||
upsertOptimisticSession(branched, routedSessionId, 'Branch', preview)
|
||||
ensureSessionState(branched.session_id, routedSessionId)
|
||||
setActiveSessionId(branched.session_id)
|
||||
activeSessionIdRef.current = branched.session_id
|
||||
|
|
@ -359,35 +526,23 @@ export function useSessionActions({
|
|||
|
||||
clearComposerDraft()
|
||||
clearComposerAttachments()
|
||||
|
||||
if (branched.info?.model) {
|
||||
setCurrentModel(branched.info.model)
|
||||
}
|
||||
|
||||
if (branched.info?.provider) {
|
||||
setCurrentProvider(branched.info.provider)
|
||||
}
|
||||
|
||||
if (branched.info?.cwd) {
|
||||
setCurrentCwd(branched.info.cwd)
|
||||
}
|
||||
|
||||
setCurrentBranch(branched.info?.branch || '')
|
||||
|
||||
if (typeof branched.info?.personality === 'string') {
|
||||
setCurrentPersonality(normalizePersonalityValue(branched.info.personality))
|
||||
}
|
||||
applyRuntimeInfo(branched.info)
|
||||
|
||||
return true
|
||||
} catch (err) {
|
||||
notifyError(err, 'Branch failed')
|
||||
|
||||
return false
|
||||
} finally {
|
||||
window.setTimeout(() => {
|
||||
creatingSessionRef.current = false
|
||||
}, 0)
|
||||
}
|
||||
},
|
||||
[
|
||||
activeSessionIdRef,
|
||||
busyRef,
|
||||
creatingSessionRef,
|
||||
ensureSessionState,
|
||||
navigate,
|
||||
requestGateway,
|
||||
|
|
@ -399,49 +554,60 @@ export function useSessionActions({
|
|||
const removeSession = useCallback(
|
||||
async (storedSessionId: string) => {
|
||||
clearNotifications()
|
||||
|
||||
const removed = $sessions.get().find(s => s.id === storedSessionId)
|
||||
const wasSelected = selectedStoredSessionId === storedSessionId
|
||||
const closingRuntimeId = wasSelected ? activeSessionId : null
|
||||
const previousMessages = $messages.get()
|
||||
const previousPinnedSessionIds = $pinnedSessionIds.get()
|
||||
const previousPinned = $pinnedSessionIds.get()
|
||||
|
||||
setSessions(prev => prev.filter(s => s.id !== storedSessionId))
|
||||
$pinnedSessionIds.set(previousPinnedSessionIds.filter(id => id !== storedSessionId))
|
||||
$pinnedSessionIds.set(previousPinned.filter(id => id !== storedSessionId))
|
||||
|
||||
// Tear down before awaiting so the route effect can't resume the
|
||||
// doomed session via the stale /<sid> URL.
|
||||
if (wasSelected) {
|
||||
setSelectedStoredSessionId(null)
|
||||
selectedStoredSessionIdRef.current = null
|
||||
setMessages([])
|
||||
startFreshSessionDraft(true)
|
||||
}
|
||||
|
||||
try {
|
||||
if (wasSelected && activeSessionId) {
|
||||
await requestGateway('session.close', {
|
||||
session_id: activeSessionId
|
||||
}).catch(() => undefined)
|
||||
if (closingRuntimeId) {
|
||||
await requestGateway('session.close', { session_id: closingRuntimeId }).catch(() => undefined)
|
||||
}
|
||||
|
||||
await deleteSession(storedSessionId)
|
||||
|
||||
if (wasSelected) {
|
||||
startFreshSessionDraft()
|
||||
}
|
||||
} catch (err) {
|
||||
if (removed) {
|
||||
setSessions(prev => [removed, ...prev])
|
||||
}
|
||||
|
||||
$pinnedSessionIds.set(previousPinnedSessionIds)
|
||||
$pinnedSessionIds.set(previousPinned)
|
||||
|
||||
if (wasSelected) {
|
||||
setFreshDraftReady(false)
|
||||
setSelectedStoredSessionId(storedSessionId)
|
||||
selectedStoredSessionIdRef.current = storedSessionId
|
||||
setMessages(previousMessages)
|
||||
navigate(sessionRoute(storedSessionId), { replace: true })
|
||||
|
||||
if (closingRuntimeId) {
|
||||
setActiveSessionId(closingRuntimeId)
|
||||
activeSessionIdRef.current = closingRuntimeId
|
||||
}
|
||||
}
|
||||
|
||||
notifyError(err, 'Delete failed')
|
||||
}
|
||||
},
|
||||
[activeSessionId, selectedStoredSessionId, selectedStoredSessionIdRef, startFreshSessionDraft, requestGateway]
|
||||
[
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
navigate,
|
||||
requestGateway,
|
||||
selectedStoredSessionId,
|
||||
selectedStoredSessionIdRef,
|
||||
startFreshSessionDraft
|
||||
]
|
||||
)
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -7,13 +7,13 @@ import { triggerHaptic } from '@/lib/haptics'
|
|||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$inspectorOpen,
|
||||
$isSidebarResizing,
|
||||
$sidebarOpen,
|
||||
$sidebarWidth,
|
||||
setSidebarOpen,
|
||||
setSidebarResizing,
|
||||
setSidebarWidth
|
||||
} from '@/store/layout'
|
||||
import { $previewTarget } from '@/store/preview'
|
||||
import { $connection } from '@/store/session'
|
||||
|
||||
import { TITLEBAR_HEIGHT, titlebarControlsPosition } from './titlebar'
|
||||
|
|
@ -44,11 +44,16 @@ export function AppShell({
|
|||
const connection = useStore($connection)
|
||||
const sidebarOpen = useStore($sidebarOpen)
|
||||
const inspectorOpen = useStore($inspectorOpen)
|
||||
const isSidebarResizing = useStore($isSidebarResizing)
|
||||
const previewTarget = useStore($previewTarget)
|
||||
|
||||
const displayedSidebarWidth = sidebarOpen ? sidebarWidth : Math.round(sidebarWidth * 0.8)
|
||||
const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition)
|
||||
const showRightRail = rightRailOpen && inspectorOpen
|
||||
const showRightRail = rightRailOpen && (inspectorOpen || Boolean(previewTarget))
|
||||
|
||||
// Right rail yields to chat min-width before the chat column starts crushing the composer.
|
||||
const inspectorColumn = showRightRail
|
||||
? 'min(var(--inspector-width), max(0px, calc(100vw - var(--sidebar-width) - var(--chat-min-width) - 2 * var(--shell-gap))))'
|
||||
: '0px'
|
||||
|
||||
const startSidebarResize = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
|
|
@ -105,16 +110,13 @@ export function AppShell({
|
|||
|
||||
<main
|
||||
className={cn(
|
||||
'relative grid h-screen w-full grid-cols-[var(--sidebar-width)_minmax(0,1fr)_var(--inspector-col)] overflow-hidden bg-background pr-0.75 pb-0.75 pt-0.75',
|
||||
isSidebarResizing
|
||||
? 'transition-none'
|
||||
: 'transition-[grid-template-columns,gap] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]',
|
||||
sidebarOpen || showRightRail ? 'gap-2.5' : 'gap-0'
|
||||
'relative grid h-screen w-full grid-cols-[var(--sidebar-width)_minmax(0,1fr)_var(--inspector-col)] overflow-hidden bg-background pr-0.75 pb-0.75 pt-0.75 transition-none',
|
||||
sidebarOpen || showRightRail ? 'gap-(--shell-gap)' : 'gap-0'
|
||||
)}
|
||||
style={
|
||||
{
|
||||
'--inspector-width': inspectorWidth,
|
||||
'--inspector-col': showRightRail ? inspectorWidth : '0px'
|
||||
'--inspector-col': inspectorColumn
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
|
|
|
|||
284
apps/desktop/src/components/assistant-ui/clarify-tool.tsx
Normal file
284
apps/desktop/src/components/assistant-ui/clarify-tool.tsx
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
'use client'
|
||||
|
||||
import { type ToolCallMessagePartProps } from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { HelpCircle, Loader2, PencilLine } from 'lucide-react'
|
||||
import { type FormEvent, type KeyboardEvent, useCallback, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { ToolFallback } from '@/components/assistant-ui/tool-fallback'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $clarifyRequest, clearClarifyRequest } from '@/store/clarify'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
|
||||
interface ClarifyArgs {
|
||||
question?: string
|
||||
choices?: string[] | null
|
||||
}
|
||||
|
||||
function readClarifyArgs(args: unknown): ClarifyArgs {
|
||||
if (!args || typeof args !== 'object') {
|
||||
return {}
|
||||
}
|
||||
|
||||
const row = args as Record<string, unknown>
|
||||
const choices = Array.isArray(row.choices) ? row.choices.filter((c): c is string => typeof c === 'string') : null
|
||||
|
||||
return {
|
||||
question: typeof row.question === 'string' ? row.question : undefined,
|
||||
choices: choices && choices.length > 0 ? choices : null
|
||||
}
|
||||
}
|
||||
|
||||
export const ClarifyTool = (props: ToolCallMessagePartProps) => {
|
||||
const isPending = props.result === undefined
|
||||
|
||||
// Once Hermes records an answer, fall back to the standard tool block so
|
||||
// the past Q/A renders consistently with every other tool in the thread.
|
||||
if (!isPending) {
|
||||
return <ToolFallback {...props} />
|
||||
}
|
||||
|
||||
return <ClarifyToolPending {...props} />
|
||||
}
|
||||
|
||||
function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
const request = useStore($clarifyRequest)
|
||||
const gateway = useStore($gateway)
|
||||
const fromArgs = useMemo(() => readClarifyArgs(args), [args])
|
||||
|
||||
const matchingRequest = useMemo(() => {
|
||||
if (!request) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (fromArgs.question && request.question && fromArgs.question !== request.question) {
|
||||
return null
|
||||
}
|
||||
|
||||
return request
|
||||
}, [fromArgs.question, request])
|
||||
|
||||
const question = fromArgs.question || matchingRequest?.question || ''
|
||||
|
||||
const choices = useMemo(
|
||||
() => fromArgs.choices ?? matchingRequest?.choices ?? [],
|
||||
[fromArgs.choices, matchingRequest?.choices]
|
||||
)
|
||||
|
||||
const hasChoices = choices.length > 0
|
||||
|
||||
const [typing, setTyping] = useState(false)
|
||||
const [draft, setDraft] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
|
||||
// Race: tool.start fires a tick before clarify.request, so request_id
|
||||
// arrives slightly after the tool block mounts. Show the question (from
|
||||
// args) but disable submit until we have the request id from the gateway.
|
||||
const ready = Boolean(matchingRequest?.requestId)
|
||||
|
||||
const respond = useCallback(
|
||||
async (answer: string) => {
|
||||
if (!ready || !matchingRequest) {
|
||||
notifyError(new Error('Clarify request is not ready yet'), 'Could not send clarify response')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!gateway) {
|
||||
notifyError(new Error('Hermes gateway is not connected'), 'Could not send clarify response')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
|
||||
try {
|
||||
await gateway.request<{ ok?: boolean }>('clarify.respond', {
|
||||
request_id: matchingRequest.requestId,
|
||||
answer
|
||||
})
|
||||
triggerHaptic('submit')
|
||||
clearClarifyRequest(matchingRequest.requestId)
|
||||
// The matching tool.complete will land shortly after, swapping this
|
||||
// panel for the ToolFallback view above.
|
||||
} catch (error) {
|
||||
notifyError(error, 'Could not send clarify response')
|
||||
setSubmitting(false)
|
||||
}
|
||||
},
|
||||
[gateway, matchingRequest, ready]
|
||||
)
|
||||
|
||||
const handleTextareaKey = useCallback(
|
||||
(event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault()
|
||||
const trimmed = draft.trim()
|
||||
|
||||
if (trimmed) {
|
||||
void respond(trimmed)
|
||||
}
|
||||
}
|
||||
},
|
||||
[draft, respond]
|
||||
)
|
||||
|
||||
const handleSubmitFreeform = useCallback(
|
||||
(event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
const trimmed = draft.trim()
|
||||
|
||||
if (trimmed) {
|
||||
void respond(trimmed)
|
||||
}
|
||||
},
|
||||
[draft, respond]
|
||||
)
|
||||
|
||||
const handleChoiceKey = useCallback(
|
||||
(event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (typing || submitting) {
|
||||
return
|
||||
}
|
||||
|
||||
const numeric = Number.parseInt(event.key, 10)
|
||||
|
||||
if (Number.isFinite(numeric) && numeric >= 1 && numeric <= choices.length) {
|
||||
event.preventDefault()
|
||||
void respond(choices[numeric - 1]!)
|
||||
}
|
||||
},
|
||||
[choices, respond, submitting, typing]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'mb-3 mt-2 grid gap-3 rounded-xl border border-border/70 bg-card/40 px-4 py-3.5 text-sm',
|
||||
'shadow-[inset_0_1px_0_color-mix(in_srgb,var(--foreground)_3%,transparent)]'
|
||||
)}
|
||||
data-slot="clarify-inline"
|
||||
>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<span
|
||||
aria-hidden
|
||||
className="mt-0.5 grid size-6 shrink-0 place-items-center rounded-md bg-[color-mix(in_srgb,var(--dt-primary)_14%,transparent)] text-primary ring-1 ring-inset ring-primary/15"
|
||||
>
|
||||
<HelpCircle className="size-3.5" />
|
||||
</span>
|
||||
<div className="grid flex-1 gap-0.5">
|
||||
<span className="text-[0.6875rem] font-medium uppercase tracking-wide text-muted-foreground/85">
|
||||
Hermes is asking
|
||||
</span>
|
||||
<span className="whitespace-pre-wrap leading-snug text-foreground">
|
||||
{question || <em className="text-muted-foreground/70">Loading question…</em>}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!typing && hasChoices && (
|
||||
<div className="grid gap-1.5" onKeyDown={handleChoiceKey} role="group">
|
||||
{choices.map((choice, index) => (
|
||||
<button
|
||||
className={cn(
|
||||
'group/choice flex w-full items-center gap-3 rounded-lg border border-border/70 bg-background/60 px-3 py-2 text-left text-sm text-foreground/95',
|
||||
'transition-colors hover:border-border hover:bg-accent/60 disabled:cursor-not-allowed disabled:opacity-55'
|
||||
)}
|
||||
data-choice
|
||||
disabled={!ready || submitting}
|
||||
key={`${index}-${choice}`}
|
||||
onClick={() => void respond(choice)}
|
||||
type="button"
|
||||
>
|
||||
<span className="grid size-5 shrink-0 place-items-center rounded-md bg-muted text-[0.6875rem] font-mono tabular-nums text-muted-foreground group-hover/choice:bg-background">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="flex-1 wrap-anywhere">{choice}</span>
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-lg border border-dashed border-border/60 bg-transparent px-3 py-2 text-left text-sm text-muted-foreground',
|
||||
'transition-colors hover:border-border hover:bg-accent/40 hover:text-foreground'
|
||||
)}
|
||||
disabled={submitting}
|
||||
onClick={() => {
|
||||
setTyping(true)
|
||||
window.setTimeout(() => textareaRef.current?.focus({ preventScroll: true }), 0)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span aria-hidden className="grid size-5 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground">
|
||||
<PencilLine className="size-3" />
|
||||
</span>
|
||||
<span className="flex-1">Other (type your answer)</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(typing || !hasChoices) && (
|
||||
<form className="grid gap-2" onSubmit={handleSubmitFreeform}>
|
||||
<Textarea
|
||||
className="min-h-20 resize-y rounded-lg border-border/70 bg-background/60 text-sm"
|
||||
disabled={submitting}
|
||||
onChange={event => setDraft(event.target.value)}
|
||||
onKeyDown={handleTextareaKey}
|
||||
placeholder="Type your answer…"
|
||||
ref={textareaRef}
|
||||
value={draft}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[0.6875rem] text-muted-foreground/85">⌘/Ctrl + Enter to send</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{hasChoices && (
|
||||
<Button
|
||||
disabled={submitting}
|
||||
onClick={() => {
|
||||
setTyping(false)
|
||||
setDraft('')
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
disabled={!ready || submitting}
|
||||
onClick={() => void respond('')}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Skip
|
||||
</Button>
|
||||
<Button disabled={!ready || submitting || !draft.trim()} size="sm" type="submit">
|
||||
{submitting ? <Loader2 className="size-3.5 animate-spin" /> : 'Send'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{!typing && hasChoices && (
|
||||
<div className="flex items-center justify-between text-[0.6875rem] text-muted-foreground/85">
|
||||
<span>1–{choices.length} to pick</span>
|
||||
<button
|
||||
className="bg-transparent text-muted-foreground/85 underline-offset-2 hover:text-foreground hover:underline disabled:opacity-50"
|
||||
disabled={!ready || submitting}
|
||||
onClick={() => void respond('')}
|
||||
type="button"
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -6,6 +6,8 @@ import { AtSign, FileText, FolderOpen, ImageIcon, Link as LinkIcon, Wrench } fro
|
|||
import type { ComponentType, FC } from 'react'
|
||||
import { Fragment, useMemo } from 'react'
|
||||
|
||||
import { ZoomableImage } from '@/components/assistant-ui/zoomable-image'
|
||||
import { extractEmbeddedImages } from '@/lib/embedded-images'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const HERMES_REF_TYPES = ['file', 'folder', 'url', 'image', 'tool'] as const
|
||||
|
|
@ -188,7 +190,8 @@ function shortLabel(type: HermesRefType, id: string): string {
|
|||
* Unknown directive types fall through as plain text.
|
||||
*/
|
||||
export const DirectiveText: TextMessagePartComponent = ({ text }: TextMessagePartProps) => {
|
||||
const segments = useMemo(() => hermesDirectiveFormatter.parse(text ?? ''), [text])
|
||||
const { cleanedText, images } = useMemo(() => extractEmbeddedImages(text ?? ''), [text])
|
||||
const segments = useMemo(() => hermesDirectiveFormatter.parse(cleanedText), [cleanedText])
|
||||
|
||||
return (
|
||||
<span className="whitespace-pre-line" data-slot="aui_directive-text">
|
||||
|
|
@ -199,6 +202,20 @@ export const DirectiveText: TextMessagePartComponent = ({ text }: TextMessagePar
|
|||
<DirectiveChip id={segment.id} key={`m-${index}-${segment.id}`} label={segment.label} type={segment.type} />
|
||||
)
|
||||
)}
|
||||
{images.length > 0 && (
|
||||
<span className="mt-2 flex flex-wrap gap-2" data-slot="aui_embedded-images">
|
||||
{images.map((src, index) => (
|
||||
<ZoomableImage
|
||||
alt=""
|
||||
className="max-h-48 max-w-full rounded-lg border border-border/60 object-contain"
|
||||
draggable={false}
|
||||
key={`img-${index}`}
|
||||
slot="aui_embedded-image"
|
||||
src={src}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -177,7 +177,7 @@ export const Intro: FC<IntroProps> = ({ personality, seed }) => {
|
|||
}, [advanceFrame, frameOffset])
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 z-1 grid place-items-center content-center px-[calc(var(--vsq)*50)] pb-32 text-center text-muted-foreground">
|
||||
<div className="pointer-events-none absolute inset-0 z-1 flex flex-col items-center justify-center px-[calc(var(--vsq)*50)] text-center text-muted-foreground">
|
||||
<button
|
||||
aria-label="Change Hermes pose"
|
||||
className="pointer-events-auto mb-5 h-56 w-64 cursor-default border-0 bg-transparent p-0"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { preprocessMarkdown } from './markdown-text'
|
||||
|
||||
describe('preprocessMarkdown', () => {
|
||||
it('strips inline accidental triple-backtick starts', () => {
|
||||
const input = [
|
||||
'Working as intended.',
|
||||
"Here's your scene: ``` http://localhost:8812/",
|
||||
'',
|
||||
'- **Multicolored cube**',
|
||||
'- **Rotates**'
|
||||
].join('\n')
|
||||
|
||||
const output = preprocessMarkdown(input)
|
||||
|
||||
expect(output).not.toContain('```')
|
||||
expect(output).toContain("Here's your scene:")
|
||||
expect(output).not.toContain('http://localhost:8812/')
|
||||
expect(output).toContain('- **Multicolored cube**')
|
||||
})
|
||||
|
||||
it('demotes invalid fenced prose blocks with closers', () => {
|
||||
const fence = '```'
|
||||
const input = [
|
||||
`${fence} http://localhost:8812/`,
|
||||
'- **Scroll wheel** - zoom',
|
||||
'- **Right-drag/pan** - disabled',
|
||||
fence
|
||||
].join('\n')
|
||||
|
||||
const output = preprocessMarkdown(input)
|
||||
|
||||
expect(output).not.toContain('```')
|
||||
expect(output).not.toContain('http://localhost:8812/')
|
||||
expect(output).toContain('- **Scroll wheel** - zoom')
|
||||
})
|
||||
|
||||
it('demotes prose sentence masquerading as fence info', () => {
|
||||
const input = ['```Heads up - a bunny got added', '- Pure white (`#ffffff`)', '- Ambient dropped to 0.18'].join('\n')
|
||||
const output = preprocessMarkdown(input)
|
||||
|
||||
expect(output).not.toContain('```heads')
|
||||
expect(output).toContain('Heads up - a bunny got added')
|
||||
expect(output).toContain('- Pure white (`#ffffff`)')
|
||||
})
|
||||
|
||||
it('keeps valid code fences intact', () => {
|
||||
const fence = '```'
|
||||
const input = [`${fence}ts`, 'const value = 1;', fence].join('\n')
|
||||
|
||||
const output = preprocessMarkdown(input)
|
||||
|
||||
expect(output).toContain('```ts')
|
||||
expect(output).toContain('const value = 1;')
|
||||
})
|
||||
|
||||
it('keeps dangling real code fences during streaming', () => {
|
||||
const input = ['```ts', 'const value = 1;'].join('\n')
|
||||
const output = preprocessMarkdown(input)
|
||||
|
||||
expect(output.startsWith('```ts')).toBe(true)
|
||||
expect(output).toContain('const value = 1;')
|
||||
})
|
||||
|
||||
it('demotes dangling prose fences', () => {
|
||||
const input = ['```', '- Pure white (`#ffffff`)', '- Ambient dropped to 0.18'].join('\n')
|
||||
const output = preprocessMarkdown(input)
|
||||
|
||||
expect(output).not.toContain('```')
|
||||
expect(output).toContain('- Pure white (`#ffffff`)')
|
||||
})
|
||||
})
|
||||
|
|
@ -2,14 +2,24 @@
|
|||
|
||||
import { type StreamdownTextComponents, StreamdownTextPrimitive } from '@assistant-ui/react-streamdown'
|
||||
import { code } from '@streamdown/code'
|
||||
import { Check, Copy, Download } from 'lucide-react'
|
||||
import { type ComponentProps, memo, useMemo, useState } from 'react'
|
||||
import { Check, Copy } from 'lucide-react'
|
||||
import { type ComponentProps, memo, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { PreviewAttachment } from '@/components/assistant-ui/preview-attachment'
|
||||
import { SyntaxHighlighter } from '@/components/assistant-ui/shiki-highlighter'
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||
import { ZoomableImage } from '@/components/assistant-ui/zoomable-image'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import {
|
||||
filePathFromMediaPath,
|
||||
mediaExternalUrl,
|
||||
mediaKind,
|
||||
mediaMime,
|
||||
mediaName,
|
||||
mediaPathFromMarkdownHref
|
||||
} from '@/lib/media'
|
||||
import { isLikelyProseCodeBlock, isLikelyProseFence, sanitizeLanguageTag } from '@/lib/markdown-code'
|
||||
import { previewTargetFromMarkdownHref, stripPreviewTargets } from '@/lib/preview-targets'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
/**
|
||||
* Strip provider/model "thinking" blocks before markdown render.
|
||||
|
|
@ -18,14 +28,123 @@ import { notify, notifyError } from '@/store/notifications'
|
|||
* assistant text. Proper reasoning UI uses dedicated `reasoning.*` parts.
|
||||
*/
|
||||
const REASONING_BLOCK_RE = /<(think|thinking|reasoning|scratchpad|analysis)>[\s\S]*?<\/\1>\s*/gi
|
||||
const PREVIEW_MARKER_RE = /\[Preview:[^\]]+\]\(#preview[:/][^)]+\)/gi
|
||||
|
||||
function stripReasoning(text: string): string {
|
||||
return text.replace(REASONING_BLOCK_RE, '')
|
||||
const FENCE_LINE_RE = /^([ \t]*)(`{3,}|~{3,})([^\n]*)$/
|
||||
const MIDLINE_FENCE_RE = /([^\n])```+(?=\s|$)/g
|
||||
|
||||
function stripMidlineFenceStarts(text: string): string {
|
||||
return text.replace(MIDLINE_FENCE_RE, '$1')
|
||||
}
|
||||
|
||||
function pushProseFence(out: string[], indent: string, info: string, lines: string[]) {
|
||||
if (info) {
|
||||
out.push(`${indent}${info}`.trimEnd())
|
||||
}
|
||||
|
||||
out.push(...lines)
|
||||
}
|
||||
|
||||
function findClosingFence(lines: string[], start: number, marker: string): number {
|
||||
for (let cursor = start + 1; cursor < lines.length; cursor += 1) {
|
||||
const closeMatch = (lines[cursor] || '').match(FENCE_LINE_RE)
|
||||
|
||||
if (!closeMatch) {
|
||||
continue
|
||||
}
|
||||
|
||||
const closeMarker = closeMatch[2] || ''
|
||||
const closeInfo = (closeMatch[3] || '').trim()
|
||||
|
||||
if (!closeInfo && closeMarker[0] === marker[0] && closeMarker.length >= marker.length) {
|
||||
return cursor
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
function normalizeFenceBlocks(text: string): string {
|
||||
const sourceLines = text.split('\n')
|
||||
const out: string[] = []
|
||||
let index = 0
|
||||
|
||||
while (index < sourceLines.length) {
|
||||
const line = sourceLines[index] || ''
|
||||
const match = line.match(FENCE_LINE_RE)
|
||||
|
||||
if (!match) {
|
||||
out.push(line)
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const indent = match[1] || ''
|
||||
const marker = match[2] || '```'
|
||||
const infoRaw = (match[3] || '').trim()
|
||||
const languageToken = infoRaw.split(/\s+/, 1)[0] || ''
|
||||
const language = sanitizeLanguageTag(languageToken)
|
||||
const openerValid = !infoRaw || Boolean(language)
|
||||
|
||||
if (!openerValid) {
|
||||
out.push(`${indent}${infoRaw}`.trimEnd())
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const closeIndex = findClosingFence(sourceLines, index, marker)
|
||||
const bodyLines = sourceLines.slice(index + 1, closeIndex === -1 ? sourceLines.length : closeIndex)
|
||||
const body = bodyLines.join('\n')
|
||||
|
||||
if (closeIndex === -1) {
|
||||
if (!body.trim()) {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (isLikelyProseFence(infoRaw, body)) {
|
||||
pushProseFence(out, indent, infoRaw, bodyLines)
|
||||
} else {
|
||||
out.push(`${indent}${marker}${language}`)
|
||||
out.push(...bodyLines)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
if (isLikelyProseFence(infoRaw, body)) {
|
||||
pushProseFence(out, indent, infoRaw, bodyLines)
|
||||
index = closeIndex + 1
|
||||
continue
|
||||
}
|
||||
|
||||
out.push(`${indent}${marker}${language}`)
|
||||
out.push(...bodyLines)
|
||||
out.push(`${indent}${marker}`)
|
||||
index = closeIndex + 1
|
||||
}
|
||||
|
||||
return out.join('\n')
|
||||
}
|
||||
|
||||
export function preprocessMarkdown(text: string): string {
|
||||
const cleaned = text.replace(REASONING_BLOCK_RE, '').replace(PREVIEW_MARKER_RE, '')
|
||||
const normalizedFences = normalizeFenceBlocks(stripMidlineFenceStarts(cleaned))
|
||||
|
||||
return normalizedFences
|
||||
.split(/((?:```|~~~)[\s\S]*?(?:```|~~~))/g)
|
||||
.map(part => (/^(?:```|~~~)/.test(part) ? part : stripPreviewTargets(part)))
|
||||
.join('')
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
}
|
||||
|
||||
function CodeHeader({ language, code }: { language?: string; code?: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
if (isLikelyProseCodeBlock(language, code)) {
|
||||
return null
|
||||
}
|
||||
|
||||
async function handleCopy() {
|
||||
if (!code) {
|
||||
return
|
||||
|
|
@ -46,11 +165,12 @@ function CodeHeader({ language, code }: { language?: string; code?: string }) {
|
|||
}
|
||||
}
|
||||
|
||||
const label = language && language !== 'unknown' ? language : 'code'
|
||||
const cleanLanguage = sanitizeLanguageTag(language || '')
|
||||
const label = cleanLanguage && cleanLanguage !== 'unknown' ? cleanLanguage : ''
|
||||
|
||||
return (
|
||||
<div className="mt-4 flex items-center justify-between gap-2 rounded-t-md border border-b-0 border-border bg-muted/60 px-3 py-1.5 text-xs text-muted-foreground">
|
||||
<span className="font-mono uppercase tracking-wide">{label}</span>
|
||||
<div className="m-0 flex items-center justify-between gap-2 rounded-t-md border border-b-0 border-border bg-muted/60 px-3 py-1.5 text-xs text-muted-foreground">
|
||||
<span className="font-mono uppercase tracking-wide">{label || 'code'}</span>
|
||||
<button
|
||||
aria-label={copied ? 'Copied' : 'Copy code'}
|
||||
className="inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-[0.75rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
|
|
@ -64,164 +184,160 @@ function CodeHeader({ language, code }: { language?: string; code?: string }) {
|
|||
)
|
||||
}
|
||||
|
||||
function imageFilename(src?: string): string {
|
||||
if (!src) {
|
||||
return 'image'
|
||||
}
|
||||
async function typedBlobUrl(dataUrl: string, mime: string): Promise<string> {
|
||||
const blob = await fetch(dataUrl).then(response => response.blob())
|
||||
|
||||
try {
|
||||
const { pathname } = new URL(src, window.location.href)
|
||||
|
||||
return pathname.split('/').filter(Boolean).pop() || 'image'
|
||||
} catch {
|
||||
return src.split(/[\\/]/).filter(Boolean).pop() || 'image'
|
||||
}
|
||||
return URL.createObjectURL(new Blob([await blob.arrayBuffer()], { type: mime }))
|
||||
}
|
||||
|
||||
function isMissingIpcHandler(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : typeof error === 'string' ? error : ''
|
||||
|
||||
return message.includes("No handler registered for 'hermes:saveImageFromUrl'")
|
||||
}
|
||||
|
||||
async function startBrowserDownload(src: string) {
|
||||
const response = await fetch(src)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Could not fetch image: ${response.status}`)
|
||||
async function mediaSrc(path: string): Promise<string> {
|
||||
if (/^(?:https?|data):/i.test(path)) {
|
||||
return path
|
||||
}
|
||||
|
||||
const blobUrl = URL.createObjectURL(await response.blob())
|
||||
const link = document.createElement('a')
|
||||
link.href = blobUrl
|
||||
link.download = imageFilename(src)
|
||||
link.rel = 'noopener noreferrer'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 30_000)
|
||||
if (!window.hermesDesktop?.readFileDataUrl) {
|
||||
return mediaExternalUrl(path)
|
||||
}
|
||||
|
||||
const dataUrl = await window.hermesDesktop.readFileDataUrl(filePathFromMediaPath(path))
|
||||
|
||||
return ['audio', 'video'].includes(mediaKind(path)) ? typedBlobUrl(dataUrl, mediaMime(path)) : dataUrl
|
||||
}
|
||||
|
||||
const imageActionButtonClass =
|
||||
'absolute right-2 top-2 grid size-8 place-items-center rounded-full border border-border/70 bg-background/80 text-muted-foreground opacity-0 shadow-sm backdrop-blur transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 disabled:opacity-50'
|
||||
function OpenMediaButton({ kind, path }: { kind: 'audio' | 'video'; path: string }) {
|
||||
return (
|
||||
<button
|
||||
className="mt-2 bg-transparent text-xs font-medium text-muted-foreground underline underline-offset-4 hover:text-foreground"
|
||||
onClick={() => void window.hermesDesktop?.openExternal(mediaExternalUrl(path))}
|
||||
type="button"
|
||||
>
|
||||
Open {kind} file
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function MarkdownImage({ className, src, alt, ...props }: ComponentProps<'img'>) {
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false)
|
||||
const canOpen = Boolean(src)
|
||||
function MediaAttachment({ path }: { path: string }) {
|
||||
const [src, setSrc] = useState('')
|
||||
const [failed, setFailed] = useState(false)
|
||||
const kind = mediaKind(path)
|
||||
const name = mediaName(path)
|
||||
|
||||
async function handleDownload() {
|
||||
if (!src || saving) {
|
||||
return
|
||||
}
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
let objectUrl = ''
|
||||
|
||||
setSaving(true)
|
||||
|
||||
try {
|
||||
if (window.hermesDesktop?.saveImageFromUrl) {
|
||||
const saved = await window.hermesDesktop.saveImageFromUrl(src)
|
||||
|
||||
if (saved) {
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: 'Image saved',
|
||||
message: imageFilename(src)
|
||||
})
|
||||
setFailed(false)
|
||||
setSrc('')
|
||||
void mediaSrc(path)
|
||||
.then(value => {
|
||||
if (value.startsWith('blob:')) {
|
||||
objectUrl = value
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await startBrowserDownload(src)
|
||||
} catch (error) {
|
||||
if (isMissingIpcHandler(error)) {
|
||||
try {
|
||||
await startBrowserDownload(src)
|
||||
notify({
|
||||
kind: 'info',
|
||||
title: 'Download started',
|
||||
message: 'Restart Hermes Desktop to use Save Image.'
|
||||
})
|
||||
} catch (fallbackError) {
|
||||
notifyError(fallbackError, 'Restart Hermes Desktop to save images')
|
||||
if (!cancelled) {
|
||||
setSrc(value)
|
||||
} else if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setFailed(true)
|
||||
}
|
||||
})
|
||||
|
||||
return
|
||||
return () => {
|
||||
cancelled = true
|
||||
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
}
|
||||
|
||||
notifyError(error, 'Image download failed')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [path])
|
||||
|
||||
if (kind === 'image' && src) {
|
||||
return (
|
||||
<span className="block">
|
||||
<MarkdownImage alt={name} src={src} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function openLightbox() {
|
||||
if (canOpen) {
|
||||
setLightboxOpen(true)
|
||||
}
|
||||
if (kind === 'audio' && src) {
|
||||
return (
|
||||
<span className="my-3 block max-w-md rounded-xl border border-border/70 bg-card/70 p-3">
|
||||
<span className="mb-2 block truncate text-xs font-medium text-muted-foreground">{name}</span>
|
||||
<audio className="block w-full" controls onError={() => setFailed(true)} preload="metadata" src={src} />
|
||||
{failed && <OpenMediaButton kind="audio" path={path} />}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const lightbox = src ? (
|
||||
<Dialog onOpenChange={setLightboxOpen} open={lightboxOpen}>
|
||||
<DialogContent
|
||||
className="grid max-h-[calc(100vh-2rem)] w-auto max-w-[calc(100vw-2rem)] place-items-center overflow-visible border-0 bg-transparent p-0 shadow-none"
|
||||
showCloseButton={false}
|
||||
>
|
||||
<div className="group/lightbox relative max-h-[calc(100vh-2rem)] max-w-[calc(100vw-2rem)] overflow-auto">
|
||||
<img
|
||||
alt={alt ?? ''}
|
||||
className="block max-h-[calc(100vh-2rem)] max-w-full cursor-zoom-out select-auto rounded-lg object-contain shadow-2xl"
|
||||
onClick={() => setLightboxOpen(false)}
|
||||
src={src}
|
||||
/>
|
||||
<button
|
||||
aria-label={saving ? 'Saving image' : 'Download image'}
|
||||
className={cn(imageActionButtonClass, 'group-hover/lightbox:opacity-100')}
|
||||
disabled={saving}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
void handleDownload()
|
||||
}}
|
||||
title={saving ? 'Saving image' : 'Download image'}
|
||||
type="button"
|
||||
>
|
||||
<Download className={cn('size-4', saving && 'animate-pulse')} />
|
||||
</button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : null
|
||||
if (kind === 'video' && src) {
|
||||
return (
|
||||
<span className="my-3 block max-w-2xl rounded-xl border border-border/70 bg-card/70 p-3">
|
||||
<span className="mb-2 block truncate text-xs font-medium text-muted-foreground">{name}</span>
|
||||
<video
|
||||
className="block max-h-112 w-full rounded-lg bg-black"
|
||||
controls
|
||||
onError={() => setFailed(true)}
|
||||
src={src}
|
||||
/>
|
||||
{failed && <OpenMediaButton kind="video" path={path} />}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="group/image relative my-3 inline-block max-w-full align-top" data-slot="aui_markdown-image">
|
||||
<button
|
||||
className="block max-w-full cursor-zoom-in bg-transparent p-0 text-left"
|
||||
disabled={!canOpen}
|
||||
onClick={openLightbox}
|
||||
title={canOpen ? 'Open image' : undefined}
|
||||
type="button"
|
||||
>
|
||||
<img alt={alt ?? ''} className={className} src={src} {...props} />
|
||||
</button>
|
||||
{src && (
|
||||
<button
|
||||
aria-label={saving ? 'Saving image' : 'Download image'}
|
||||
className={cn(imageActionButtonClass, 'group-hover/image:opacity-100')}
|
||||
disabled={saving}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
void handleDownload()
|
||||
}}
|
||||
title={saving ? 'Saving image' : 'Download image'}
|
||||
type="button"
|
||||
>
|
||||
<Download className={cn('size-4', saving && 'animate-pulse')} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
{lightbox}
|
||||
</>
|
||||
<a
|
||||
className="font-medium text-foreground underline underline-offset-4 decoration-foreground/30 wrap-anywhere hover:decoration-foreground/70"
|
||||
href="#"
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
void window.hermesDesktop?.openExternal(mediaExternalUrl(path))
|
||||
}}
|
||||
>
|
||||
{failed ? `Open ${name}` : `Loading ${name}...`}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
function MarkdownLink({ className, href, ...props }: ComponentProps<'a'>) {
|
||||
const mediaPath = mediaPathFromMarkdownHref(href)
|
||||
const previewTarget = previewTargetFromMarkdownHref(href)
|
||||
|
||||
if (mediaPath) {
|
||||
return <MediaAttachment path={mediaPath} />
|
||||
}
|
||||
|
||||
if (previewTarget) {
|
||||
return <PreviewAttachment target={previewTarget} />
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
className={cn(
|
||||
'font-medium text-foreground underline underline-offset-4 decoration-foreground/30 wrap-anywhere hover:decoration-foreground/70',
|
||||
className
|
||||
)}
|
||||
href={href}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MarkdownImage({ className, src, alt, ...props }: ComponentProps<'img'>) {
|
||||
return (
|
||||
<ZoomableImage
|
||||
alt={alt}
|
||||
className={className}
|
||||
containerClassName="my-3"
|
||||
slot="aui_markdown-image"
|
||||
src={src}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -244,17 +360,7 @@ const MarkdownTextImpl = () => {
|
|||
p: ({ className, ...props }: ComponentProps<'p'>) => (
|
||||
<p className={cn('wrap-anywhere leading-relaxed', className)} {...props} />
|
||||
),
|
||||
a: ({ className, ...props }: ComponentProps<'a'>) => (
|
||||
<a
|
||||
className={cn(
|
||||
'font-medium text-foreground underline underline-offset-4 decoration-foreground/30 wrap-anywhere hover:decoration-foreground/70',
|
||||
className
|
||||
)}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
a: MarkdownLink,
|
||||
hr: ({ className, ...props }: ComponentProps<'hr'>) => (
|
||||
<hr className={cn('border-border/70', className)} {...props} />
|
||||
),
|
||||
|
|
@ -315,7 +421,7 @@ const MarkdownTextImpl = () => {
|
|||
mode="streaming"
|
||||
parseIncompleteMarkdown
|
||||
plugins={{ code }}
|
||||
preprocess={stripReasoning}
|
||||
preprocess={preprocessMarkdown}
|
||||
shikiTheme={['github-light-default', 'github-dark-default']}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { MonitorPlay } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { previewName } from '@/lib/preview-targets'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { $previewTarget, setPreviewTarget } from '@/store/preview'
|
||||
import { $currentCwd } from '@/store/session'
|
||||
|
||||
export function PreviewAttachment({ target }: { target: string }) {
|
||||
const cwd = useStore($currentCwd)
|
||||
const activePreview = useStore($previewTarget)
|
||||
const [opening, setOpening] = useState(false)
|
||||
const name = previewName(target)
|
||||
const isActive = activePreview?.source === target
|
||||
|
||||
function localFallbackPreview() {
|
||||
if (/^https?:\/\//i.test(target)) {
|
||||
return { kind: 'url' as const, label: previewName(target), source: target, url: target }
|
||||
}
|
||||
|
||||
if (/^file:\/\//i.test(target)) {
|
||||
return { kind: 'file' as const, label: previewName(target), source: target, url: target }
|
||||
}
|
||||
|
||||
if (/^(?:\/|\.{1,2}\/|~\/).+\.html?$/i.test(target)) {
|
||||
const path = target.startsWith('file://') ? target : `file://${encodeURI(target)}`
|
||||
|
||||
return { kind: 'file' as const, label: previewName(target), source: target, url: path }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function isMissingPreviewIpc(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : typeof error === 'string' ? error : ''
|
||||
|
||||
return message.includes("No handler registered for 'hermes:normalizePreviewTarget'")
|
||||
}
|
||||
|
||||
async function togglePreview() {
|
||||
if (opening) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isActive) {
|
||||
setPreviewTarget(null)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setOpening(true)
|
||||
|
||||
try {
|
||||
const preview = await window.hermesDesktop?.normalizePreviewTarget(target, cwd || undefined).catch(error => {
|
||||
if (isMissingPreviewIpc(error)) {
|
||||
return localFallbackPreview()
|
||||
}
|
||||
|
||||
throw error
|
||||
})
|
||||
|
||||
if (!preview) {
|
||||
throw new Error(`Could not open preview target: ${target}`)
|
||||
}
|
||||
|
||||
setPreviewTarget(preview)
|
||||
} catch (error) {
|
||||
notifyError(error, 'Preview unavailable')
|
||||
} finally {
|
||||
setOpening(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="inline-flex max-w-[min(100%,32rem)] items-center gap-3 rounded-xl border border-border/70 bg-card/70 p-3 text-sm">
|
||||
<div className="grid size-9 shrink-0 place-items-center rounded-lg bg-accent text-muted-foreground">
|
||||
<MonitorPlay className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0 max-w-64">
|
||||
<div className="truncate font-medium text-foreground">{name}</div>
|
||||
<div className="truncate font-mono text-xs text-muted-foreground">{target}</div>
|
||||
</div>
|
||||
<button
|
||||
className="shrink-0 rounded-lg border border-border/70 px-2.5 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-50"
|
||||
disabled={opening}
|
||||
onClick={() => void togglePreview()}
|
||||
type="button"
|
||||
>
|
||||
{opening ? 'Opening...' : isActive ? 'Hide Preview' : 'Toggle Preview'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -4,6 +4,8 @@ import type { SyntaxHighlighterProps } from '@assistant-ui/react-streamdown'
|
|||
import type { FC } from 'react'
|
||||
import ShikiHighlighter from 'react-shiki'
|
||||
|
||||
import { isLikelyProseCodeBlock } from '@/lib/markdown-code'
|
||||
|
||||
/**
|
||||
* assistant-ui's recommended `SyntaxHighlighter` slot.
|
||||
*
|
||||
|
|
@ -22,10 +24,13 @@ export const SyntaxHighlighter: FC<SyntaxHighlighterProps> = ({
|
|||
language,
|
||||
code
|
||||
}) => {
|
||||
// Markdown fences include the pre-closing newline in `code`, which
|
||||
// Shiki tokenizes into a blank final line. Trim so the box ends on
|
||||
// real code.
|
||||
const trimmed = (code ?? '').trimEnd()
|
||||
// Streamdown may hand us fence contents with edge newlines. Strip blank
|
||||
// fence padding without touching indentation on the first real line.
|
||||
const trimmed = (code ?? '').replace(/^\n+/, '').trimEnd()
|
||||
|
||||
if (isLikelyProseCodeBlock(language, trimmed)) {
|
||||
return <div className="whitespace-pre-wrap wrap-anywhere text-foreground">{trimmed}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<Pre className="aui-shiki m-0 overflow-hidden rounded-b-md border border-t-0 border-border bg-card font-mono text-sm leading-relaxed [&_pre]:m-0 [&_pre]:overflow-x-auto [&_pre]:bg-transparent! [&_pre]:px-4 [&_pre]:py-3 [&_pre]:font-mono [&_pre]:leading-relaxed">
|
||||
|
|
|
|||
|
|
@ -2,10 +2,12 @@ import {
|
|||
ActionBarPrimitive,
|
||||
AuiIf,
|
||||
BranchPickerPrimitive,
|
||||
ComposerPrimitive,
|
||||
ErrorPrimitive,
|
||||
MessagePrimitive,
|
||||
ThreadPrimitive,
|
||||
type ToolCallMessagePartProps,
|
||||
useAuiEvent,
|
||||
useAuiState
|
||||
} from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
|
|
@ -17,19 +19,42 @@ import {
|
|||
GitBranchIcon,
|
||||
Loader2Icon,
|
||||
MoreHorizontalIcon,
|
||||
PencilIcon,
|
||||
RefreshCwIcon,
|
||||
Volume2Icon,
|
||||
VolumeXIcon
|
||||
VolumeXIcon,
|
||||
XIcon
|
||||
} from 'lucide-react'
|
||||
import { type FC, type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
type FC,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
// Scroll behavior: delegated to `use-stick-to-bottom` (StackBlitz), the
|
||||
// reference implementation that powers bolt.new and several other streaming
|
||||
// chat UIs. It handles everything we care about — spring-animated catch-up,
|
||||
// resize-vs-user-scroll disambiguation, wheel/touch escape, text-selection
|
||||
// pause, subpixel overshoot, programmatic-scroll event suppression — via 665
|
||||
// lines of well-tested edge-case handling that we should NOT hand-roll.
|
||||
//
|
||||
// We only own the thin glue: jump-to-bottom on session switch / send, and
|
||||
// keeping `$threadScrolledUp` in sync with `isAtBottom` for the composer's
|
||||
// dim-when-scrolled-away treatment.
|
||||
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom'
|
||||
import spinners from 'unicode-animations'
|
||||
|
||||
import { useElapsedSeconds } from '@/components/assistant-ui/activity-timer'
|
||||
import { ActivityTimerText } from '@/components/assistant-ui/activity-timer-text'
|
||||
import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
|
||||
import { DirectiveText } from '@/components/assistant-ui/directive-text'
|
||||
import { GeneratedImageProvider, useGeneratedImageContext } from '@/components/assistant-ui/generated-image-context'
|
||||
import { ImageGenerationPlaceholder } from '@/components/assistant-ui/image-generation-placeholder'
|
||||
import { Intro, type IntroProps } from '@/components/assistant-ui/intro'
|
||||
import { MarkdownText } from '@/components/assistant-ui/markdown-text'
|
||||
import { PreviewAttachment } from '@/components/assistant-ui/preview-attachment'
|
||||
import { ToolFallback } from '@/components/assistant-ui/tool-fallback'
|
||||
import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button'
|
||||
import {
|
||||
|
|
@ -41,49 +66,16 @@ import {
|
|||
} from '@/components/ui/dropdown-menu'
|
||||
import { Loader } from '@/components/ui/loader'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { extractPreviewTargets } from '@/lib/preview-targets'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { setThreadScrolledUp } from '@/store/thread-scroll'
|
||||
import { $voicePlayback } from '@/store/voice-playback'
|
||||
|
||||
const THINKING_FACES = [
|
||||
'(。•́︿•̀。)',
|
||||
'(◔_◔)',
|
||||
'(¬‿¬)',
|
||||
'( •_•)>⌐■-■',
|
||||
'(⌐■_■)',
|
||||
'(´・_・`)',
|
||||
'◉_◉',
|
||||
'(°ロ°)',
|
||||
'( ˘⌣˘)♡',
|
||||
'ヽ(>∀<☆)☆',
|
||||
'٩(๑❛ᴗ❛๑)۶',
|
||||
'(⊙_⊙)',
|
||||
'(¬_¬)',
|
||||
'( ͡° ͜ʖ ͡°)',
|
||||
'ಠ_ಠ'
|
||||
]
|
||||
const RESPONSE_SPINNER = spinners.braille
|
||||
|
||||
const THINKING_VERBS = [
|
||||
'pondering',
|
||||
'contemplating',
|
||||
'musing',
|
||||
'cogitating',
|
||||
'ruminating',
|
||||
'deliberating',
|
||||
'mulling',
|
||||
'reflecting',
|
||||
'processing',
|
||||
'reasoning',
|
||||
'analyzing',
|
||||
'computing',
|
||||
'synthesizing',
|
||||
'formulating',
|
||||
'brainstorming'
|
||||
]
|
||||
|
||||
type ThreadLoadingState = 'response' | 'session' | 'working'
|
||||
type ThreadLoadingState = 'response' | 'session'
|
||||
|
||||
interface MessageActionProps {
|
||||
messageId: string
|
||||
|
|
@ -91,13 +83,8 @@ interface MessageActionProps {
|
|||
onBranchInNewChat?: (messageId: string) => void
|
||||
}
|
||||
|
||||
const BOTTOM_DISTANCE_PX = 24
|
||||
let readAloudAudio: HTMLAudioElement | null = null
|
||||
|
||||
function isNearBottom(el: HTMLElement): boolean {
|
||||
return el.scrollHeight - (el.scrollTop + el.clientHeight) <= BOTTOM_DISTANCE_PX
|
||||
}
|
||||
|
||||
function partText(part: unknown): string {
|
||||
if (typeof part === 'string') {
|
||||
return part
|
||||
|
|
@ -126,142 +113,298 @@ export const Thread: FC<{
|
|||
onBranchInNewChat?: (messageId: string) => void
|
||||
sessionKey?: string | null
|
||||
}> = ({ intro, loading, onBranchInNewChat, sessionKey }) => {
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null)
|
||||
const contentRef = useRef<HTMLDivElement | null>(null)
|
||||
const messageCount = useAuiState(s => s.thread.messages.length)
|
||||
const isRunning = useAuiState(s => s.thread.isRunning)
|
||||
const lastMessageId = useAuiState(s => s.thread.messages.at(-1)?.id ?? '')
|
||||
const shouldStickToBottomRef = useRef(true)
|
||||
const scrollFrameRef = useRef<number | null>(null)
|
||||
const sessionKeyRef = useRef<string | null>(sessionKey ?? null)
|
||||
|
||||
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
|
||||
const nearBottom = isNearBottom(event.currentTarget)
|
||||
shouldStickToBottomRef.current = nearBottom
|
||||
setThreadScrolledUp(!nearBottom)
|
||||
}, [])
|
||||
|
||||
const handleWheel = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
|
||||
if (event.deltaY < 0) {
|
||||
shouldStickToBottomRef.current = false
|
||||
setThreadScrolledUp(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
const viewport = viewportRef.current
|
||||
|
||||
if (!viewport) {
|
||||
return
|
||||
}
|
||||
|
||||
viewport.scrollTop = viewport.scrollHeight
|
||||
shouldStickToBottomRef.current = true
|
||||
setThreadScrolledUp(false)
|
||||
}, [])
|
||||
|
||||
const scheduleScrollToBottom = useCallback(() => {
|
||||
if (scrollFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(scrollFrameRef.current)
|
||||
}
|
||||
|
||||
scrollFrameRef.current = window.requestAnimationFrame(() => {
|
||||
scrollFrameRef.current = null
|
||||
scrollToBottom()
|
||||
})
|
||||
}, [scrollToBottom])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (scrollFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(scrollFrameRef.current)
|
||||
}
|
||||
|
||||
setThreadScrolledUp(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const viewport = viewportRef.current
|
||||
|
||||
if (!viewport) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextSessionKey = sessionKey ?? null
|
||||
const sessionChanged = sessionKeyRef.current !== nextSessionKey
|
||||
sessionKeyRef.current = nextSessionKey
|
||||
const force = loading === 'session' || sessionChanged
|
||||
|
||||
if (!force && !shouldStickToBottomRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
scheduleScrollToBottom()
|
||||
}, [isRunning, lastMessageId, loading, messageCount, scheduleScrollToBottom, sessionKey])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const content = contentRef.current
|
||||
const viewport = viewportRef.current
|
||||
|
||||
if (!content || !viewport) {
|
||||
return
|
||||
}
|
||||
|
||||
let previousHeight = content.getBoundingClientRect().height
|
||||
|
||||
const observer = new ResizeObserver(entries => {
|
||||
const height = entries[0]?.contentRect.height ?? content.getBoundingClientRect().height
|
||||
|
||||
if (height === previousHeight) {
|
||||
return
|
||||
}
|
||||
|
||||
previousHeight = height
|
||||
|
||||
if (!shouldStickToBottomRef.current && !isNearBottom(viewport)) {
|
||||
return
|
||||
}
|
||||
|
||||
scheduleScrollToBottom()
|
||||
})
|
||||
|
||||
observer.observe(content)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [scheduleScrollToBottom])
|
||||
|
||||
return (
|
||||
<GeneratedImageProvider>
|
||||
<ThreadPrimitive.Root className="relative grid h-full min-h-0 grid-rows-[minmax(0,1fr)] overflow-hidden bg-transparent">
|
||||
<AuiIf condition={s => Boolean(intro) && s.thread.isEmpty}>{intro && <Intro {...intro} />}</AuiIf>
|
||||
|
||||
<ThreadPrimitive.Viewport
|
||||
autoScroll={false}
|
||||
className="h-full min-h-0 overflow-y-auto overscroll-contain px-[clamp(1rem,10%,12rem)] pt-[calc(var(--vsq)*19)]"
|
||||
data-slot="aui_thread-viewport"
|
||||
onScroll={handleScroll}
|
||||
onWheel={handleWheel}
|
||||
ref={viewportRef}
|
||||
scrollToBottomOnInitialize
|
||||
scrollToBottomOnRunStart
|
||||
scrollToBottomOnThreadSwitch
|
||||
>
|
||||
<div className="flex w-full flex-col gap-3" ref={contentRef}>
|
||||
<ThreadPrimitive.Messages>
|
||||
{() => <ThreadMessage onBranchInNewChat={onBranchInNewChat} />}
|
||||
</ThreadPrimitive.Messages>
|
||||
{loading === 'response' && <ResponseLoadingIndicator />}
|
||||
{loading === 'working' && <WorkingIndicator />}
|
||||
</div>
|
||||
<ThreadPrimitive.ViewportFooter className="h-(--thread-composer-clearance) shrink-0" />
|
||||
</ThreadPrimitive.Viewport>
|
||||
<ThreadPrimitive.ViewportProvider>
|
||||
{/*
|
||||
* <StickToBottom> renders a wrapper <div>; <StickToBottom.Content>
|
||||
* renders an inner scroll container (inline height/width 100%) plus
|
||||
* an inner content div. So:
|
||||
* - `className` on <StickToBottom> = outer wrapper sizing
|
||||
* - `scrollClassName` on <.Content> = scroll container
|
||||
* - `className` on <.Content> = content (flex column)
|
||||
*
|
||||
* `initial: 'instant'`: no animation on first mount.
|
||||
* `resize: 'instant'`: during streaming, snap to bottom each token.
|
||||
* Spring animation ('smooth') visibly lags behind fast token
|
||||
* streams; users read that as jank. 'instant' matches ChatGPT.
|
||||
*
|
||||
* The composer is rendered OUTSIDE the scroller as `position:
|
||||
* absolute; bottom: 0` (floating glass treatment) and overlays the
|
||||
* bottom of the scroll surface. We compensate by putting a tall
|
||||
* bottom spacer (>= composer height + margin) inside the scroll
|
||||
* content so "scroll to bottom" naturally parks the last line of
|
||||
* content above the composer, not hidden behind it.
|
||||
*/}
|
||||
<StickToBottom
|
||||
className="relative h-full min-h-0"
|
||||
initial="instant"
|
||||
resize="instant"
|
||||
>
|
||||
<ThreadScrollSync sessionKey={sessionKey} />
|
||||
<StickToBottom.Content
|
||||
className="flex w-full flex-col gap-3 px-[clamp(1rem,10%,12rem)] pt-[calc(var(--vsq)*19)]"
|
||||
data-slot="aui_thread-content"
|
||||
scrollClassName="overflow-y-auto overscroll-contain"
|
||||
>
|
||||
<ThreadPrimitive.Messages
|
||||
components={{
|
||||
AssistantMessage: () => <AssistantMessage onBranchInNewChat={onBranchInNewChat} />,
|
||||
SystemMessage,
|
||||
UserEditComposer,
|
||||
UserMessage
|
||||
}}
|
||||
/>
|
||||
{loading === 'response' && <ResponseLoadingIndicator />}
|
||||
<ComposerClearance />
|
||||
</StickToBottom.Content>
|
||||
</StickToBottom>
|
||||
</ThreadPrimitive.ViewportProvider>
|
||||
{loading === 'session' && <CenteredThreadSpinner />}
|
||||
</ThreadPrimitive.Root>
|
||||
</GeneratedImageProvider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll glue for the chat thread. Replaces hand-rolled follow logic with
|
||||
* the exact pattern that assistant-ui's own `useThreadViewportAutoScroll`
|
||||
* uses internally: **raw DOM scroll + an armed behavior ref + a
|
||||
* ResizeObserver loop that re-pins to bottom until we actually reach it.**
|
||||
*
|
||||
* Why not use the library's `scrollToBottom` for sends?
|
||||
* - It wraps its work in `new Promise(requestAnimationFrame)` so even
|
||||
* `animation: 'instant'` is 1+ frame async.
|
||||
* - It does NOT clear `escapedFromLock` on call — if the user had
|
||||
* scrolled up before sending, the library's resize handler keeps
|
||||
* un-setting `isAtBottom` between our scroll and the next resize.
|
||||
* - `ignoreEscapes` only blocks NEW escapes during the animation; it
|
||||
* doesn't unstick an already-escaped state.
|
||||
*
|
||||
* The armed-ref pattern handles all of that:
|
||||
* 1. `thread.runStart` fires after the runtime has committed the user
|
||||
* message to state (so scrollHeight already reflects it).
|
||||
* 2. We arm a ref ('instant') and write `scrollTop = scrollHeight`
|
||||
* synchronously.
|
||||
* 3. A ResizeObserver on the content keeps re-pinning each time the
|
||||
* DOM grows (user message paints, assistant placeholder mounts,
|
||||
* assistant streams) until scrollTop is actually at bottom — then
|
||||
* we disarm.
|
||||
* 4. Any wheel-up or touch-scroll-up disarms immediately so the user
|
||||
* can always escape.
|
||||
*
|
||||
* This mirrors:
|
||||
* - assistant-ui's `useThreadViewportAutoScroll` (scrollToBottomBehaviorRef
|
||||
* + useOnResizeContent loop)
|
||||
* - Vercel ai-chatbot's `useScrollToBottom` (MutationObserver + RO on
|
||||
* container and children + isAtBottom/isUserScrolling flags)
|
||||
*
|
||||
* Must be rendered INSIDE a <StickToBottom> because useStickToBottomContext
|
||||
* reads from that component's context.
|
||||
*/
|
||||
const ThreadScrollSync: FC<{ sessionKey?: string | null }> = ({ sessionKey }) => {
|
||||
const { scrollRef, isAtBottom, state } = useStickToBottomContext()
|
||||
const sessionKeyRef = useRef<string | null>(sessionKey ?? null)
|
||||
|
||||
// "Armed" behavior ref. Non-null = "keep chasing bottom across resize
|
||||
// ticks until we get there." Null = "user owns the viewport."
|
||||
const armedRef = useRef<ScrollBehavior | null>(null)
|
||||
|
||||
const messageCount = useAuiState(s => s.thread.messages.length)
|
||||
const prevMessageCountRef = useRef(messageCount)
|
||||
|
||||
useEffect(() => {
|
||||
setThreadScrolledUp(!isAtBottom)
|
||||
}, [isAtBottom])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setThreadScrolledUp(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Slam to bottom + arm the ref. Also forces library state flags off
|
||||
// so its internal resize handler doesn't fight our re-pins.
|
||||
const armAndPin = useCallback((behavior: ScrollBehavior) => {
|
||||
const el = scrollRef.current
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
armedRef.current = behavior
|
||||
// Clear the library's escape/at-bottom flags directly on the mutable
|
||||
// state object so its resize handler sees a clean follow state.
|
||||
state.escapedFromLock = false
|
||||
state.isAtBottom = true
|
||||
el.scrollTop = el.scrollHeight
|
||||
}, [scrollRef, state])
|
||||
|
||||
// ResizeObserver loop — re-pins to bottom while armed, disarms when
|
||||
// actually at bottom. This is the assistant-ui pattern.
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
const behavior = armedRef.current
|
||||
|
||||
if (!behavior) {
|
||||
return
|
||||
}
|
||||
|
||||
const distance = el.scrollHeight - (el.scrollTop + el.clientHeight)
|
||||
|
||||
if (distance < 2) {
|
||||
armedRef.current = null
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
el.scrollTop = el.scrollHeight
|
||||
})
|
||||
|
||||
observer.observe(el)
|
||||
|
||||
const content = el.firstElementChild
|
||||
|
||||
if (content) {
|
||||
observer.observe(content)
|
||||
}
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [scrollRef])
|
||||
|
||||
// User-intent detection — any upward gesture disarms the chase.
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (e.deltaY < 0) {
|
||||
armedRef.current = null
|
||||
}
|
||||
}
|
||||
const onTouch = () => {
|
||||
armedRef.current = null
|
||||
}
|
||||
|
||||
el.addEventListener('wheel', onWheel, { passive: true })
|
||||
el.addEventListener('touchmove', onTouch, { passive: true })
|
||||
|
||||
return () => {
|
||||
el.removeEventListener('wheel', onWheel)
|
||||
el.removeEventListener('touchmove', onTouch)
|
||||
}
|
||||
}, [scrollRef])
|
||||
|
||||
// (1) Session switch — strong intent to see the bottom of the new thread.
|
||||
useEffect(() => {
|
||||
const next = sessionKey ?? null
|
||||
|
||||
if (sessionKeyRef.current === next) {
|
||||
return
|
||||
}
|
||||
|
||||
sessionKeyRef.current = next
|
||||
prevMessageCountRef.current = 0
|
||||
armAndPin('auto')
|
||||
}, [armAndPin, sessionKey])
|
||||
|
||||
// (2) Bulk message load (session history arriving from storage) — pin
|
||||
// to bottom and stay armed while the thread's markdown/code/images
|
||||
// settle over the next several frames.
|
||||
useEffect(() => {
|
||||
const prev = prevMessageCountRef.current
|
||||
prevMessageCountRef.current = messageCount
|
||||
|
||||
if (prev === 0 && messageCount > 0) {
|
||||
armAndPin('auto')
|
||||
}
|
||||
}, [armAndPin, messageCount])
|
||||
|
||||
// (3) User send — the runtime event `thread.runStart` fires after the
|
||||
// user message has been committed to state (scrollHeight already reflects
|
||||
// it). This is the canonical signal per assistant-ui's own code. We
|
||||
// arm-and-pin synchronously in the callback, then the RO loop above
|
||||
// keeps us at bottom as the assistant message placeholder + reply stream.
|
||||
useAuiEvent('thread.runStart', () => {
|
||||
armAndPin('instant')
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Invisible bottom spacer whose height matches the currently-measured
|
||||
* composer height (plus a small gap). Because the composer is rendered
|
||||
* OUTSIDE the scroll container as `position: absolute; bottom: 0`, "scroll
|
||||
* to bottom" would otherwise park the last content line behind it. By
|
||||
* extending the scroll content down with real (blank) space equal to the
|
||||
* composer's footprint, the library's scroll-to-scrollHeight naturally
|
||||
* leaves the last message line sitting above the composer.
|
||||
*
|
||||
* A ResizeObserver on the composer keeps the spacer in sync when the
|
||||
* textarea grows (multi-line input), attachments expand, or the composer
|
||||
* enters a focused/expanded state.
|
||||
*/
|
||||
const COMPOSER_BREATHING_ROOM_PX = 20
|
||||
|
||||
const ComposerClearance: FC = () => {
|
||||
const [height, setHeight] = useState<number>(() => {
|
||||
// Sensible default until the observer wires up (~ 8rem).
|
||||
if (typeof document === 'undefined') return 128
|
||||
const composer = document.querySelector<HTMLElement>('[data-slot="composer-root"]')
|
||||
|
||||
return composer ? composer.getBoundingClientRect().height + COMPOSER_BREATHING_ROOM_PX : 128
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const composer = document.querySelector<HTMLElement>('[data-slot="composer-root"]')
|
||||
|
||||
if (!composer) {
|
||||
return
|
||||
}
|
||||
|
||||
const apply = () => {
|
||||
const h = composer.getBoundingClientRect().height
|
||||
|
||||
setHeight(prev => {
|
||||
const next = Math.round(h + COMPOSER_BREATHING_ROOM_PX)
|
||||
|
||||
return Math.abs(prev - next) < 1 ? prev : next
|
||||
})
|
||||
}
|
||||
|
||||
apply()
|
||||
const observer = new ResizeObserver(apply)
|
||||
observer.observe(composer)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
return <div aria-hidden="true" className="shrink-0" style={{ height: `${height}px` }} />
|
||||
}
|
||||
|
||||
function pickPrimaryPreviewTarget(targets: string[]): string[] {
|
||||
if (targets.length <= 1) {
|
||||
return targets
|
||||
}
|
||||
|
||||
const localUrl = targets.find(value => /^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])/i.test(value))
|
||||
|
||||
return [localUrl || targets[targets.length - 1]]
|
||||
}
|
||||
|
||||
const CenteredThreadSpinner: FC = () => (
|
||||
<div
|
||||
aria-label="Loading session"
|
||||
|
|
@ -279,38 +422,16 @@ const CenteredThreadSpinner: FC = () => (
|
|||
</div>
|
||||
)
|
||||
|
||||
const ThreadMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }> = ({ onBranchInNewChat }) => {
|
||||
const role = useAuiState(s => s.message.role)
|
||||
const isEditing = useAuiState(s => s.message.composer.isEditing)
|
||||
|
||||
// The runtime synthesizes an empty assistant placeholder while isRunning is true
|
||||
// (last message is user). Rendering the full `MessagePrimitive.Root` for it adds
|
||||
// ~36px of invisible chrome (gap-2 + min-h-7 footer) which can push the
|
||||
// loading affordance too far below the user message. Skip it —
|
||||
// `ResponseLoadingIndicator` in the viewport handles the loading affordance directly.
|
||||
const isPlaceholder = useAuiState(
|
||||
s => s.message.role === 'assistant' && s.message.status?.type === 'running' && s.message.content.length === 0
|
||||
)
|
||||
|
||||
if (isEditing) {
|
||||
return <EditComposer />
|
||||
}
|
||||
|
||||
if (role === 'user') {
|
||||
return <UserMessage />
|
||||
}
|
||||
|
||||
if (isPlaceholder) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <AssistantMessage onBranchInNewChat={onBranchInNewChat} />
|
||||
}
|
||||
|
||||
const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }> = ({ onBranchInNewChat }) => {
|
||||
const messageId = useAuiState(s => s.message.id)
|
||||
const content = useAuiState(s => s.message.content)
|
||||
const messageText = messageContentText(content)
|
||||
const previewTargets = pickPrimaryPreviewTarget(extractPreviewTargets(messageText))
|
||||
const isPlaceholder = useAuiState(s => s.message.status?.type === 'running' && s.message.content.length === 0)
|
||||
|
||||
if (isPlaceholder) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
|
|
@ -326,6 +447,13 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
|
|||
tools: { Fallback: ChainToolFallback }
|
||||
}}
|
||||
/>
|
||||
{previewTargets.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{previewTargets.map(target => (
|
||||
<PreviewAttachment key={target} target={target} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<MessagePrimitive.Error>
|
||||
<ErrorPrimitive.Root
|
||||
className="mt-2 rounded-md border border-destructive/20 bg-destructive/5 px-3 py-2 text-sm text-destructive"
|
||||
|
|
@ -351,45 +479,28 @@ const StatusRow: FC<{ children: ReactNode; label: string }> = ({ children, label
|
|||
)
|
||||
|
||||
const ResponseLoadingIndicator: FC = () => {
|
||||
const [tick, setTick] = useState(0)
|
||||
const [frame, setFrame] = useState(0)
|
||||
const elapsed = useElapsedSeconds()
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => setTick(t => t + 1), 900)
|
||||
const id = window.setInterval(
|
||||
() => setFrame(current => (current + 1) % RESPONSE_SPINNER.frames.length),
|
||||
RESPONSE_SPINNER.interval
|
||||
)
|
||||
|
||||
return () => window.clearInterval(id)
|
||||
}, [])
|
||||
|
||||
const face = THINKING_FACES[tick % THINKING_FACES.length]
|
||||
const verb = THINKING_VERBS[tick % THINKING_VERBS.length]
|
||||
|
||||
return (
|
||||
<StatusRow label="Hermes is loading a response">
|
||||
<span className="shimmer shimmer-repeat-delay-0 min-w-0 truncate text-muted-foreground/55">
|
||||
{face} {verb}…
|
||||
<span aria-hidden="true" className="font-mono text-base leading-none text-muted-foreground/60">
|
||||
{RESPONSE_SPINNER.frames[frame]}
|
||||
</span>
|
||||
<ActivityTimerText seconds={elapsed} />
|
||||
</StatusRow>
|
||||
)
|
||||
}
|
||||
|
||||
const WorkingIndicator: FC = () => {
|
||||
const elapsed = useElapsedSeconds()
|
||||
|
||||
return (
|
||||
<StatusRow label="Hermes is still working">
|
||||
<Loader
|
||||
className="size-4 text-muted-foreground/60"
|
||||
label="Still working"
|
||||
strokeScale={0.65}
|
||||
type="spiral-search"
|
||||
/>
|
||||
<span className="shimmer min-w-0 truncate text-muted-foreground/60">Still working…</span>
|
||||
<ActivityTimerText seconds={elapsed} />
|
||||
</StatusRow>
|
||||
)
|
||||
}
|
||||
|
||||
const ImageGenerateTool: FC<ToolCallMessagePartProps> = ({ result }) => {
|
||||
const generatedImage = useGeneratedImageContext()
|
||||
const running = result === undefined
|
||||
|
|
@ -414,6 +525,10 @@ const ChainToolFallback: FC<ToolCallMessagePartProps> = props => {
|
|||
return <ImageGenerateTool {...props} />
|
||||
}
|
||||
|
||||
if (props.toolName === 'clarify') {
|
||||
return <ClarifyTool {...props} />
|
||||
}
|
||||
|
||||
return <ToolFallback {...props} />
|
||||
}
|
||||
|
||||
|
|
@ -637,21 +752,97 @@ const branchButtonClass =
|
|||
'grid size-6 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-35'
|
||||
|
||||
const UserMessage: FC = () => {
|
||||
const content = useAuiState(s => s.message.content)
|
||||
const messageText = messageContentText(content)
|
||||
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
className="group flex max-w-[min(72%,34rem)] flex-col gap-2 self-end rounded-2xl border border-[color-mix(in_srgb,var(--dt-user-bubble-border)_78%,transparent)] bg-[color-mix(in_srgb,var(--dt-user-bubble)_94%,transparent)] px-3 py-2"
|
||||
className="group flex max-w-[min(72%,34rem)] flex-col items-end gap-2 self-end"
|
||||
data-role="user"
|
||||
data-slot="aui_user-message-root"
|
||||
>
|
||||
<div className="wrap-anywhere whitespace-pre-line leading-[1.48] text-foreground/95">
|
||||
<div className="wrap-anywhere whitespace-pre-line rounded-2xl border border-[color-mix(in_srgb,var(--dt-user-bubble-border)_78%,transparent)] bg-[color-mix(in_srgb,var(--dt-user-bubble)_94%,transparent)] px-3 py-2 leading-[1.48] text-foreground/95">
|
||||
<MessagePrimitive.Parts components={{ Text: DirectiveText }} />
|
||||
</div>
|
||||
<div className="min-h-6">
|
||||
<UserActionBar messageText={messageText} />
|
||||
</div>
|
||||
</MessagePrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
const EditComposer: FC = () => {
|
||||
// Editing requires a real onEdit implementation against Hermes history.
|
||||
// Hide the edit composer until that contract is implemented.
|
||||
return null
|
||||
const UserActionBar: FC<{ messageText: string }> = ({ messageText }) => (
|
||||
<div className="relative h-6 w-14 shrink-0">
|
||||
<ActionBarPrimitive.Root className={ACTION_BAR_CLASS} hideWhenRunning>
|
||||
<CopyMessageButton text={messageText} />
|
||||
<ActionBarPrimitive.Edit asChild>
|
||||
<TooltipIconButton onClick={() => triggerHaptic('selection')} tooltip="Edit">
|
||||
<PencilIcon />
|
||||
</TooltipIconButton>
|
||||
</ActionBarPrimitive.Edit>
|
||||
</ActionBarPrimitive.Root>
|
||||
</div>
|
||||
)
|
||||
|
||||
const SLASH_STATUS_RE = /^slash:(?<command>\/[^\n]+)\n(?<output>[\s\S]*)$/
|
||||
|
||||
const SystemMessage: FC = () => {
|
||||
const text = useAuiState(s => messageContentText(s.message.content))
|
||||
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
|
||||
const slashStatus = text.match(SLASH_STATUS_RE)
|
||||
|
||||
if (slashStatus?.groups) {
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
className="max-w-[min(86%,44rem)] self-center px-2 py-0.5 text-center text-[0.6875rem] leading-5 text-muted-foreground/60"
|
||||
data-role="system"
|
||||
data-slot="aui_system-message-root"
|
||||
>
|
||||
<span className="font-mono text-muted-foreground/55">{slashStatus.groups.command}</span>
|
||||
<span className="mx-1.5 text-muted-foreground/35">·</span>
|
||||
<span className="whitespace-pre-wrap">{slashStatus.groups.output.trim()}</span>
|
||||
</MessagePrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
className="max-w-[min(86%,44rem)] self-center px-2 py-0.5 text-center text-[0.6875rem] leading-5 text-muted-foreground/55"
|
||||
data-role="system"
|
||||
data-slot="aui_system-message-root"
|
||||
>
|
||||
<span className="whitespace-pre-wrap">{text}</span>
|
||||
</MessagePrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
const UserEditComposer: FC = () => (
|
||||
<ComposerPrimitive.Root
|
||||
className="flex min-w-[min(18rem,72vw)] max-w-[min(72%,34rem)] flex-col gap-1.5 self-end rounded-2xl border border-[color-mix(in_srgb,var(--dt-user-bubble-border)_88%,transparent)] bg-[color-mix(in_srgb,var(--dt-user-bubble)_98%,transparent)] px-3 py-2 shadow-sm"
|
||||
data-slot="aui_edit-composer-root"
|
||||
>
|
||||
<ComposerPrimitive.Input
|
||||
autoFocus
|
||||
className="min-h-8 w-full resize-none bg-transparent leading-[1.48] text-foreground/95 outline-none"
|
||||
rows={1}
|
||||
submitMode="enter"
|
||||
unstable_focusOnScrollToBottom={false}
|
||||
/>
|
||||
<div className="flex justify-end gap-1">
|
||||
<ComposerPrimitive.Cancel asChild>
|
||||
<TooltipIconButton tooltip="Cancel edit">
|
||||
<XIcon />
|
||||
</TooltipIconButton>
|
||||
</ComposerPrimitive.Cancel>
|
||||
<ComposerPrimitive.Send asChild>
|
||||
<TooltipIconButton onClick={() => triggerHaptic('submit')} tooltip="Send edit">
|
||||
<CheckIcon />
|
||||
</TooltipIconButton>
|
||||
</ComposerPrimitive.Send>
|
||||
</div>
|
||||
</ComposerPrimitive.Root>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
'use client'
|
||||
|
||||
import { type ToolCallMessagePartProps } from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { useElapsedSeconds } from '@/components/assistant-ui/activity-timer'
|
||||
import { ActivityTimerText } from '@/components/assistant-ui/activity-timer-text'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $toolInlineDiffs } from '@/store/tool-diffs'
|
||||
|
||||
const TOOL_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
||||
|
||||
|
|
@ -78,6 +80,24 @@ function prettyJson(value: unknown): string {
|
|||
return typeof value === 'string' ? value : JSON.stringify(value, null, 2)
|
||||
}
|
||||
|
||||
function recordValue(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {}
|
||||
}
|
||||
|
||||
function stripAnsi(value: string): string {
|
||||
return value.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'), '')
|
||||
}
|
||||
|
||||
function stripInlineDiffChrome(value: string): string {
|
||||
return value ? stripAnsi(value).replace(/^\s*┊\s*review diff\s*\n/i, '').trim() : ''
|
||||
}
|
||||
|
||||
function inlineDiffFromResult(result: unknown): string {
|
||||
const value = recordValue(result).inline_diff
|
||||
|
||||
return typeof value === 'string' ? stripInlineDiffChrome(value) : ''
|
||||
}
|
||||
|
||||
function detailLabel(toolName: string): string {
|
||||
if (toolName === 'image_generate') {
|
||||
return 'Prompt'
|
||||
|
|
@ -121,7 +141,7 @@ function detailText(args: unknown, result: unknown): string {
|
|||
return prettyJson(args)
|
||||
}
|
||||
|
||||
export const ToolFallback = ({ toolName, args, result }: ToolCallMessagePartProps) => {
|
||||
export const ToolFallback = ({ toolCallId, toolName, args, result }: ToolCallMessagePartProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const isPending = result === undefined
|
||||
const [tick, setTick] = useState(0)
|
||||
|
|
@ -129,6 +149,9 @@ export const ToolFallback = ({ toolName, args, result }: ToolCallMessagePartProp
|
|||
const preview = compactPreview(args) || compactPreview(result)
|
||||
const label = toolLabel(toolName, isPending)
|
||||
const detail = detailText(args, result)
|
||||
const liveDiffs = useStore($toolInlineDiffs)
|
||||
const sideDiff = toolCallId ? liveDiffs[toolCallId] || '' : ''
|
||||
const inlineDiff = stripInlineDiffChrome(sideDiff) || inlineDiffFromResult(result)
|
||||
const spinnerFrame = TOOL_SPINNER_FRAMES[tick % TOOL_SPINNER_FRAMES.length]
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -173,6 +196,35 @@ export const ToolFallback = ({ toolName, args, result }: ToolCallMessagePartProp
|
|||
{detail}
|
||||
</div>
|
||||
)}
|
||||
{inlineDiff && <InlineDiff text={inlineDiff} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InlineDiff({ text }: { text: string }) {
|
||||
return (
|
||||
<pre className="ml-4 mt-2 max-h-96 max-w-full overflow-auto rounded-lg border border-border/60 bg-background/70 px-3 py-2 font-mono text-[0.6875rem] leading-relaxed">
|
||||
{text.split('\n').map((line, index) => {
|
||||
const added = line.startsWith('+') && !line.startsWith('+++')
|
||||
const removed = line.startsWith('-') && !line.startsWith('---')
|
||||
const hunk = line.startsWith('@@')
|
||||
const fileHeader = line.startsWith('---') || line.startsWith('+++') || / → /.test(line.slice(0, 60))
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'block min-w-max whitespace-pre',
|
||||
added && 'text-emerald-700 dark:text-emerald-300',
|
||||
removed && 'text-rose-700 dark:text-rose-300',
|
||||
hunk && 'text-sky-700 dark:text-sky-300',
|
||||
!added && !removed && !hunk && fileHeader && 'text-muted-foreground/80'
|
||||
)}
|
||||
key={`${index}-${line}`}
|
||||
>
|
||||
{line || ' '}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
170
apps/desktop/src/components/assistant-ui/zoomable-image.tsx
Normal file
170
apps/desktop/src/components/assistant-ui/zoomable-image.tsx
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
'use client'
|
||||
|
||||
import { Download } from 'lucide-react'
|
||||
import { type ComponentProps, useState } from 'react'
|
||||
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
function imageFilename(src?: string): string {
|
||||
if (!src) {
|
||||
return 'image'
|
||||
}
|
||||
|
||||
try {
|
||||
const { pathname } = new URL(src, window.location.href)
|
||||
|
||||
return pathname.split('/').filter(Boolean).pop() || 'image'
|
||||
} catch {
|
||||
return src.split(/[\\/]/).filter(Boolean).pop() || 'image'
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingIpcHandler(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : typeof error === 'string' ? error : ''
|
||||
|
||||
return message.includes("No handler registered for 'hermes:saveImageFromUrl'")
|
||||
}
|
||||
|
||||
async function startBrowserDownload(src: string) {
|
||||
const response = await fetch(src)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Could not fetch image: ${response.status}`)
|
||||
}
|
||||
|
||||
const blobUrl = URL.createObjectURL(await response.blob())
|
||||
const link = document.createElement('a')
|
||||
link.href = blobUrl
|
||||
link.download = imageFilename(src)
|
||||
link.rel = 'noopener noreferrer'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 30_000)
|
||||
}
|
||||
|
||||
const imageActionButtonClass =
|
||||
'absolute right-2 top-2 grid size-8 place-items-center rounded-full border border-border/70 bg-background/80 text-muted-foreground opacity-0 shadow-sm backdrop-blur transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 disabled:opacity-50'
|
||||
|
||||
export interface ZoomableImageProps extends ComponentProps<'img'> {
|
||||
containerClassName?: string
|
||||
slot?: string
|
||||
}
|
||||
|
||||
export function ZoomableImage({ className, containerClassName, src, alt, slot, ...props }: ZoomableImageProps) {
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false)
|
||||
const canOpen = Boolean(src)
|
||||
|
||||
async function handleDownload() {
|
||||
if (!src || saving) {
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
|
||||
try {
|
||||
if (window.hermesDesktop?.saveImageFromUrl) {
|
||||
const saved = await window.hermesDesktop.saveImageFromUrl(src)
|
||||
|
||||
if (saved) {
|
||||
notify({ kind: 'success', title: 'Image saved', message: imageFilename(src) })
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await startBrowserDownload(src)
|
||||
} catch (error) {
|
||||
if (isMissingIpcHandler(error)) {
|
||||
try {
|
||||
await startBrowserDownload(src)
|
||||
notify({
|
||||
kind: 'info',
|
||||
title: 'Download started',
|
||||
message: 'Restart Hermes Desktop to use Save Image.'
|
||||
})
|
||||
} catch (fallbackError) {
|
||||
notifyError(fallbackError, 'Restart Hermes Desktop to save images')
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
notifyError(error, 'Image download failed')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const lightbox = src ? (
|
||||
<Dialog onOpenChange={setLightboxOpen} open={lightboxOpen}>
|
||||
<DialogContent
|
||||
className="grid max-h-[calc(100vh-2rem)] w-auto max-w-[calc(100vw-2rem)] place-items-center overflow-visible border-0 bg-transparent p-0 shadow-none"
|
||||
showCloseButton={false}
|
||||
>
|
||||
<div className="group/lightbox relative max-h-[calc(100vh-2rem)] max-w-[calc(100vw-2rem)] overflow-auto">
|
||||
<img
|
||||
alt={alt ?? ''}
|
||||
className="block max-h-[calc(100vh-2rem)] max-w-full cursor-zoom-out select-auto rounded-lg object-contain shadow-2xl"
|
||||
onClick={() => setLightboxOpen(false)}
|
||||
src={src}
|
||||
/>
|
||||
<ImageActionButton onClick={handleDownload} saving={saving} variant="lightbox" />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
className={cn('group/image relative inline-block max-w-full align-top', containerClassName)}
|
||||
data-slot={slot ?? 'aui_zoomable-image'}
|
||||
>
|
||||
<button
|
||||
className="block max-w-full cursor-zoom-in bg-transparent p-0 text-left"
|
||||
disabled={!canOpen}
|
||||
onClick={() => canOpen && setLightboxOpen(true)}
|
||||
title={canOpen ? 'Open image' : undefined}
|
||||
type="button"
|
||||
>
|
||||
<img alt={alt ?? ''} className={className} src={src} {...props} />
|
||||
</button>
|
||||
{src && <ImageActionButton onClick={handleDownload} saving={saving} variant="inline" />}
|
||||
</span>
|
||||
{lightbox}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ImageActionButton({
|
||||
onClick,
|
||||
saving,
|
||||
variant
|
||||
}: {
|
||||
onClick: () => void
|
||||
saving: boolean
|
||||
variant: 'inline' | 'lightbox'
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
aria-label={saving ? 'Saving image' : 'Download image'}
|
||||
className={cn(
|
||||
imageActionButtonClass,
|
||||
variant === 'inline' ? 'group-hover/image:opacity-100' : 'group-hover/lightbox:opacity-100'
|
||||
)}
|
||||
disabled={saving}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
void onClick()
|
||||
}}
|
||||
title={saving ? 'Saving image' : 'Download image'}
|
||||
type="button"
|
||||
>
|
||||
<Download className={cn('size-4', saving && 'animate-pulse')} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
@ -60,8 +60,8 @@ export const SessionInspector: FC<SessionInspectorProps> = ({
|
|||
<aside
|
||||
aria-hidden={!open}
|
||||
className={cn(
|
||||
'relative flex h-screen w-full min-w-0 flex-col overflow-hidden bg-transparent pb-2 pl-2 pr-3 pt-[calc(var(--titlebar-height)+0.25rem)] text-muted-foreground transition-[opacity,transform] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]',
|
||||
open ? 'translate-x-0 opacity-100' : 'pointer-events-none translate-x-2 opacity-0'
|
||||
'relative flex h-screen w-full min-w-0 flex-col overflow-hidden bg-transparent pb-2 pl-2 pr-3 pt-[calc(var(--titlebar-height)+0.25rem)] text-muted-foreground transition-none',
|
||||
open ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||
)}
|
||||
data-open={open}
|
||||
>
|
||||
|
|
@ -132,7 +132,7 @@ function WorkspaceSection({
|
|||
<SectionLabel>cwd</SectionLabel>
|
||||
{editing ? (
|
||||
<Input
|
||||
className="h-7 bg-background px-2 font-mono text-[0.6875rem]"
|
||||
className={cn(bleed, 'h-7 bg-background px-1.5 font-mono text-[0.6875rem]')}
|
||||
onBlur={apply}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
|
|
@ -152,7 +152,8 @@ function WorkspaceSection({
|
|||
<div
|
||||
className={cn(
|
||||
quietControl,
|
||||
'group grid w-full min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-1 px-1.5 py-1 font-mono text-[0.6875rem] text-foreground/75'
|
||||
bleed,
|
||||
'group grid min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-1 px-1.5 py-1 font-mono text-[0.6875rem] text-foreground/75'
|
||||
)}
|
||||
>
|
||||
<button
|
||||
|
|
|
|||
105
apps/desktop/src/components/ui/pagination.tsx
Normal file
105
apps/desktop/src/components/ui/pagination.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react'
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Pagination({ className, ...props }: React.ComponentProps<'nav'>) {
|
||||
return (
|
||||
<nav
|
||||
aria-label="pagination"
|
||||
className={cn('mx-auto flex w-full justify-center', className)}
|
||||
data-slot="pagination"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationContent({ className, ...props }: React.ComponentProps<'ul'>) {
|
||||
return <ul className={cn('flex h-5 flex-row items-center gap-0.5', className)} data-slot="pagination-content" {...props} />
|
||||
}
|
||||
|
||||
function PaginationItem({ className, ...props }: React.ComponentProps<'li'>) {
|
||||
return <li className={cn('flex h-5 items-center', className)} data-slot="pagination-item" {...props} />
|
||||
}
|
||||
|
||||
interface PaginationButtonProps extends React.ComponentProps<'button'> {
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
function PaginationButton({ className, isActive, ...props }: PaginationButtonProps) {
|
||||
return (
|
||||
<button
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={cn(
|
||||
'inline-flex h-5 min-w-5 items-center justify-center rounded border border-transparent px-1 text-[0.6875rem] leading-none tabular-nums transition-colors disabled:pointer-events-none disabled:opacity-45',
|
||||
isActive
|
||||
? 'border-border bg-background text-foreground shadow-xs'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-foreground',
|
||||
className
|
||||
)}
|
||||
data-active={isActive}
|
||||
data-slot="pagination-button"
|
||||
type="button"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationPrevious({ className, ...props }: React.ComponentProps<'button'>) {
|
||||
return (
|
||||
<button
|
||||
aria-label="Go to previous page"
|
||||
className={cn(
|
||||
'inline-flex h-5 items-center justify-center gap-0.5 rounded border border-transparent px-1 text-[0.6875rem] leading-none text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-45',
|
||||
className
|
||||
)}
|
||||
data-slot="pagination-previous"
|
||||
type="button"
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeft className="size-3" />
|
||||
<span>Prev</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationNext({ className, ...props }: React.ComponentProps<'button'>) {
|
||||
return (
|
||||
<button
|
||||
aria-label="Go to next page"
|
||||
className={cn(
|
||||
'inline-flex h-5 items-center justify-center gap-0.5 rounded border border-transparent px-1 text-[0.6875rem] leading-none text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-45',
|
||||
className
|
||||
)}
|
||||
data-slot="pagination-next"
|
||||
type="button"
|
||||
{...props}
|
||||
>
|
||||
<span>Next</span>
|
||||
<ChevronRight className="size-3" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationEllipsis({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn('flex size-5 items-center justify-center', className)}
|
||||
data-slot="pagination-ellipsis"
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="size-3" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationButton,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationNext,
|
||||
PaginationPrevious
|
||||
}
|
||||
25
apps/desktop/src/global.d.ts
vendored
25
apps/desktop/src/global.d.ts
vendored
|
|
@ -11,7 +11,14 @@ declare global {
|
|||
selectPaths: (options?: HermesSelectPathsOptions) => Promise<string[]>
|
||||
writeClipboard: (text: string) => Promise<boolean>
|
||||
saveImageFromUrl: (url: string) => Promise<boolean>
|
||||
saveImageBuffer: (data: ArrayBuffer | Uint8Array, ext: string) => Promise<string>
|
||||
saveClipboardImage: () => Promise<string>
|
||||
getPathForFile: (file: File) => string
|
||||
normalizePreviewTarget: (target: string, baseDir?: string) => Promise<HermesPreviewTarget | null>
|
||||
watchPreviewFile: (url: string) => Promise<HermesPreviewWatch>
|
||||
stopPreviewFileWatch: (id: string) => Promise<boolean>
|
||||
openExternal: (url: string) => Promise<void>
|
||||
onPreviewFileChanged: (callback: (payload: HermesPreviewFileChanged) => void) => () => void
|
||||
onBackendExit: (callback: (payload: BackendExit) => void) => () => void
|
||||
}
|
||||
}
|
||||
|
|
@ -37,6 +44,24 @@ export interface HermesNotification {
|
|||
silent?: boolean
|
||||
}
|
||||
|
||||
export interface HermesPreviewTarget {
|
||||
kind: 'file' | 'url'
|
||||
label: string
|
||||
source: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface HermesPreviewWatch {
|
||||
id: string
|
||||
path: string
|
||||
}
|
||||
|
||||
export interface HermesPreviewFileChanged {
|
||||
id: string
|
||||
path: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface HermesSelectPathsOptions {
|
||||
title?: string
|
||||
defaultPath?: string
|
||||
|
|
|
|||
|
|
@ -180,9 +180,9 @@ export class HermesGateway {
|
|||
}
|
||||
}
|
||||
|
||||
export async function listSessions(limit = 40): Promise<PaginatedSessions> {
|
||||
export async function listSessions(limit = 40, minMessages = 0): Promise<PaginatedSessions> {
|
||||
const result = await window.hermesDesktop.api<PaginatedSessions>({
|
||||
path: `/api/sessions?limit=${limit}&offset=0&min_messages=1`
|
||||
path: `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}`
|
||||
})
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { chatMessageText, toChatMessages } from './chat-messages'
|
||||
import { appendAssistantTextPart, chatMessageText, renderMediaTags, toChatMessages, upsertToolPart } from './chat-messages'
|
||||
|
||||
describe('toChatMessages', () => {
|
||||
it('hides attached context payloads from user message display', () => {
|
||||
|
|
@ -15,4 +15,87 @@ describe('toChatMessages', () => {
|
|||
|
||||
expect(chatMessageText(message)).toBe('@file:tsconfig.tsbuildinfo\n\nwhat is this file')
|
||||
})
|
||||
|
||||
it('renders MEDIA tags as assistant attachment links', () => {
|
||||
const [message] = toChatMessages([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: "MEDIA:/Users/brooklyn/.hermes/cache/audio/tts_20260501_222725.mp3\n\nhow's that sound?",
|
||||
timestamp: 1
|
||||
}
|
||||
])
|
||||
|
||||
expect(chatMessageText(message)).toBe(
|
||||
"[Audio: tts_20260501_222725.mp3](#media:%2FUsers%2Fbrooklyn%2F.hermes%2Fcache%2Faudio%2Ftts_20260501_222725.mp3)\n\nhow's that sound?"
|
||||
)
|
||||
})
|
||||
|
||||
it('coerces non-string message content without throwing', () => {
|
||||
const [message] = toChatMessages([
|
||||
{
|
||||
content: {
|
||||
text: 'hello from object content'
|
||||
},
|
||||
role: 'assistant',
|
||||
timestamp: 1
|
||||
}
|
||||
])
|
||||
|
||||
expect(chatMessageText(message)).toBe('hello from object content')
|
||||
})
|
||||
|
||||
it('applies attached-context filtering when user content is object-shaped', () => {
|
||||
const [message] = toChatMessages([
|
||||
{
|
||||
content: {
|
||||
text:
|
||||
'look\n\n--- Attached Context ---\n\n📄 @file:foo.ts (10 tokens)\n```ts\nconst x = 1\n```'
|
||||
},
|
||||
role: 'user',
|
||||
timestamp: 1
|
||||
}
|
||||
])
|
||||
|
||||
expect(chatMessageText(message)).toBe('@file:foo.ts\n\nlook')
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderMediaTags', () => {
|
||||
it('renders standalone and inline MEDIA tags as links', () => {
|
||||
expect(renderMediaTags('here\nMEDIA:/tmp/voice.mp3\nthere')).toBe(
|
||||
'here\n[Audio: voice.mp3](#media:%2Ftmp%2Fvoice.mp3)\nthere'
|
||||
)
|
||||
expect(renderMediaTags('audio: MEDIA:/tmp/voice.mp3 done')).toBe(
|
||||
'audio: [Audio: voice.mp3](#media:%2Ftmp%2Fvoice.mp3) done'
|
||||
)
|
||||
expect(renderMediaTags('MEDIA:/tmp/demo.mp4')).toBe('[Video: demo.mp4](#media:%2Ftmp%2Fdemo.mp4)')
|
||||
})
|
||||
|
||||
it('renders streamed assistant media once the tag is complete', () => {
|
||||
const parts = appendAssistantTextPart(appendAssistantTextPart([], 'ok\nMEDIA:'), '/tmp/voice.mp3')
|
||||
const text = chatMessageText({ id: 'a', role: 'assistant', parts })
|
||||
|
||||
expect(text).toBe('ok\n[Audio: voice.mp3](#media:%2Ftmp%2Fvoice.mp3)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('upsertToolPart', () => {
|
||||
it('preserves inline diffs from tool completion events', () => {
|
||||
const parts = upsertToolPart(
|
||||
[],
|
||||
{
|
||||
inline_diff: '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new',
|
||||
name: 'patch',
|
||||
tool_id: 'tool-1'
|
||||
},
|
||||
'complete'
|
||||
)
|
||||
|
||||
const [part] = parts
|
||||
|
||||
expect(part?.type).toBe('tool-call')
|
||||
expect(part && 'result' in part ? part.result : undefined).toMatchObject({
|
||||
inline_diff: '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { ThreadMessageLike } from '@assistant-ui/react'
|
||||
|
||||
import { mediaDisplayLabel, mediaMarkdownHref } from '@/lib/media'
|
||||
import type { SessionMessage } from '@/types/hermes'
|
||||
|
||||
export type ChatMessagePart = Exclude<ThreadMessageLike['content'], string>[number]
|
||||
|
|
@ -25,6 +26,7 @@ export type GatewayEventPayload = {
|
|||
preview?: string
|
||||
summary?: string
|
||||
error?: string | boolean
|
||||
inline_diff?: string
|
||||
duration_s?: number
|
||||
todos?: unknown
|
||||
model?: string
|
||||
|
|
@ -33,6 +35,10 @@ export type GatewayEventPayload = {
|
|||
cwd?: string
|
||||
branch?: string
|
||||
personality?: string
|
||||
// clarify.request
|
||||
request_id?: string
|
||||
question?: string
|
||||
choices?: string[] | null
|
||||
}
|
||||
|
||||
export function textPart(text: string): ChatMessagePart {
|
||||
|
|
@ -43,6 +49,37 @@ export function reasoningPart(text: string): ChatMessagePart {
|
|||
return { type: 'reasoning', text }
|
||||
}
|
||||
|
||||
const MEDIA_LINE_RE =
|
||||
/(^|\n)[\t ]*[`"']?MEDIA:\s*(?<line>`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)[`"']?[\t ]*(?:\n|$)/g
|
||||
|
||||
const MEDIA_TAG_RE = /[`"']?MEDIA:\s*(?<inline>`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)[`"']?/g
|
||||
|
||||
function unquoteMediaPath(value: string): string {
|
||||
const trimmed = value.trim()
|
||||
const quote = trimmed[0]
|
||||
|
||||
return quote && quote === trimmed.at(-1) && ['"', "'", '`'].includes(quote) ? trimmed.slice(1, -1) : trimmed
|
||||
}
|
||||
|
||||
function mediaLink(value: string): string {
|
||||
const path = unquoteMediaPath(value)
|
||||
|
||||
return `[${mediaDisplayLabel(path)}](${mediaMarkdownHref(path)})`
|
||||
}
|
||||
|
||||
export function renderMediaTags(text: string): string {
|
||||
return text
|
||||
.replace(MEDIA_LINE_RE, (_match, lead: string, value: string) => `${lead}${mediaLink(value)}\n`)
|
||||
.replace(MEDIA_TAG_RE, (_match, value: string) => mediaLink(value))
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export function assistantTextPart(text: string): ChatMessagePart {
|
||||
return textPart(renderMediaTags(text))
|
||||
}
|
||||
|
||||
export function chatMessageText(message: ChatMessage): string {
|
||||
return message.parts
|
||||
.filter((part): part is Extract<ChatMessagePart, { type: 'text' }> => part.type === 'text')
|
||||
|
|
@ -54,19 +91,57 @@ const ATTACHED_CONTEXT_MARKER_RE = /(?:^|\n)--- Attached Context ---\s*\n/
|
|||
const CONTEXT_WARNINGS_MARKER_RE = /(?:^|\n)--- Context Warnings ---[\s\S]*$/
|
||||
const CONTEXT_REF_RE = /@(file|folder|url|image|tool):(?:"[^"\n]+"|'[^'\n]+'|`[^`\n]+`|\S+)/g
|
||||
|
||||
function displayContentForMessage(role: SessionMessage['role'], content: string): string {
|
||||
if (role !== 'user') {
|
||||
return content
|
||||
function textFromUnknown(value: unknown, depth = 0): string {
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
|
||||
const marker = content.match(ATTACHED_CONTEXT_MARKER_RE)
|
||||
if (value === null || value === undefined) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (depth > 2) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(item => textFromUnknown(item, depth + 1)).join('')
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
const row = value as Record<string, unknown>
|
||||
const textValue = row.text ?? row.output_text ?? row.content ?? row.message
|
||||
const nestedText = textFromUnknown(textValue, depth + 1)
|
||||
|
||||
if (nestedText) {
|
||||
return nestedText
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function displayContentForMessage(role: SessionMessage['role'], content: unknown): string {
|
||||
const textContent = textFromUnknown(content)
|
||||
|
||||
if (role !== 'user') {
|
||||
return textContent
|
||||
}
|
||||
|
||||
const marker = textContent.match(ATTACHED_CONTEXT_MARKER_RE)
|
||||
|
||||
if (!marker || marker.index === undefined) {
|
||||
return content.replace(CONTEXT_WARNINGS_MARKER_RE, '').trim()
|
||||
return textContent.replace(CONTEXT_WARNINGS_MARKER_RE, '').trim()
|
||||
}
|
||||
|
||||
const visibleText = content.slice(0, marker.index).replace(CONTEXT_WARNINGS_MARKER_RE, '').trim()
|
||||
const attachedContext = content.slice(marker.index + marker[0].length)
|
||||
const visibleText = textContent.slice(0, marker.index).replace(CONTEXT_WARNINGS_MARKER_RE, '').trim()
|
||||
const attachedContext = textContent.slice(marker.index + marker[0].length)
|
||||
const refs = [...new Set(Array.from(attachedContext.matchAll(CONTEXT_REF_RE)).map(match => match[0]))]
|
||||
|
||||
return [refs.join('\n'), visibleText].filter(Boolean).join('\n\n') || visibleText
|
||||
|
|
@ -87,6 +162,17 @@ export function appendTextPart(parts: ChatMessagePart[], delta: string): ChatMes
|
|||
return next
|
||||
}
|
||||
|
||||
export function appendAssistantTextPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] {
|
||||
const next = appendTextPart(parts, delta)
|
||||
const last = next.at(-1)
|
||||
|
||||
if (last?.type === 'text') {
|
||||
next[next.length - 1] = { ...last, text: renderMediaTags(last.text) }
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
export function appendReasoningPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] {
|
||||
const next = [...parts]
|
||||
const last = next.at(-1)
|
||||
|
|
@ -119,6 +205,7 @@ function toolArgs(payload: GatewayEventPayload | undefined): Record<string, unkn
|
|||
|
||||
function toolResult(payload: GatewayEventPayload | undefined): Record<string, unknown> {
|
||||
return {
|
||||
...(payload?.inline_diff ? { inline_diff: payload.inline_diff } : {}),
|
||||
...(payload?.summary ? { summary: payload.summary } : {}),
|
||||
...(payload?.message ? { message: payload.message } : {}),
|
||||
...(payload?.preview ? { preview: payload.preview } : {}),
|
||||
|
|
@ -198,15 +285,21 @@ function firstNonEmptyObject(...values: unknown[]): Record<string, unknown> {
|
|||
return {}
|
||||
}
|
||||
|
||||
function parseStoredToolResult(content: string): unknown {
|
||||
if (!content.trim()) {
|
||||
function parseStoredToolResult(content: unknown): unknown {
|
||||
if (content && typeof content === 'object') {
|
||||
return content
|
||||
}
|
||||
|
||||
const textContent = textFromUnknown(content)
|
||||
|
||||
if (!textContent.trim()) {
|
||||
return ''
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(content)
|
||||
return JSON.parse(textContent)
|
||||
} catch {
|
||||
return content
|
||||
return textContent
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -233,7 +326,7 @@ function toolPartFromStoredCall(call: unknown, fallbackIndex: number): ChatMessa
|
|||
function applyStoredToolResult(messages: ChatMessage[], toolMessage: SessionMessage): boolean {
|
||||
const toolCallId = toolMessage.tool_call_id || undefined
|
||||
const toolName = toolMessage.tool_name || toolMessage.name || 'tool'
|
||||
const content = toolMessage.content || toolMessage.text || toolMessage.context || toolMessage.name || ''
|
||||
const content = toolMessage.content || toolMessage.text || toolMessage.context || toolMessage.name
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||
const message = messages[i]
|
||||
|
|
@ -270,7 +363,7 @@ function applyStoredToolResult(messages: ChatMessage[], toolMessage: SessionMess
|
|||
function applyStoredToolResultToParts(parts: ChatMessagePart[], toolMessage: SessionMessage): ChatMessagePart[] | null {
|
||||
const toolCallId = toolMessage.tool_call_id || undefined
|
||||
const toolName = toolMessage.tool_name || toolMessage.name || 'tool'
|
||||
const content = toolMessage.content || toolMessage.text || toolMessage.context || toolMessage.name || ''
|
||||
const content = toolMessage.content || toolMessage.text || toolMessage.context || toolMessage.name
|
||||
|
||||
const partIndex = parts.findIndex(
|
||||
part =>
|
||||
|
|
@ -295,7 +388,7 @@ function applyStoredToolResultToParts(parts: ChatMessagePart[], toolMessage: Ses
|
|||
|
||||
function storedToolMessagePart(toolMessage: SessionMessage, fallbackIndex: number): ChatMessagePart {
|
||||
const name = toolMessage.tool_name || toolMessage.name || 'tool'
|
||||
const context = toolMessage.context || toolMessage.text || toolMessage.content || ''
|
||||
const context = textFromUnknown(toolMessage.context || toolMessage.text || toolMessage.content || '')
|
||||
const args = context ? { context } : {}
|
||||
|
||||
return {
|
||||
|
|
@ -385,7 +478,7 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
|
|||
return
|
||||
}
|
||||
|
||||
const content = message.content || message.text || message.context || message.name || ''
|
||||
const content = message.content || message.text || message.context || message.name
|
||||
const displayContent = displayContentForMessage(message.role, content)
|
||||
const parts: ChatMessagePart[] = []
|
||||
|
||||
|
|
@ -399,7 +492,7 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
|
|||
}
|
||||
|
||||
if (displayContent) {
|
||||
parts.push(textPart(displayContent))
|
||||
parts.push(message.role === 'assistant' ? assistantTextPart(displayContent) : textPart(displayContent))
|
||||
}
|
||||
|
||||
if (message.role === 'assistant' && Array.isArray(message.tool_calls)) {
|
||||
|
|
|
|||
108
apps/desktop/src/lib/desktop-slash-commands.test.ts
Normal file
108
apps/desktop/src/lib/desktop-slash-commands.test.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
desktopSlashDescription,
|
||||
desktopSlashUnavailableMessage,
|
||||
desktopSkinSlashCompletions,
|
||||
filterDesktopCommandsCatalog,
|
||||
isDesktopSlashCommand,
|
||||
isDesktopSlashSuggestion
|
||||
} from './desktop-slash-commands'
|
||||
|
||||
describe('desktop slash command curation', () => {
|
||||
it('keeps core desktop chat commands in suggestions', () => {
|
||||
expect(isDesktopSlashSuggestion('/new')).toBe(true)
|
||||
expect(isDesktopSlashSuggestion('/branch')).toBe(true)
|
||||
expect(isDesktopSlashSuggestion('/skin')).toBe(true)
|
||||
expect(isDesktopSlashSuggestion('/usage')).toBe(true)
|
||||
})
|
||||
|
||||
it('lets explicitly typed extension commands run without suggesting them', () => {
|
||||
expect(isDesktopSlashSuggestion('/my-skill')).toBe(false)
|
||||
expect(isDesktopSlashCommand('/my-skill')).toBe(true)
|
||||
})
|
||||
|
||||
it('hides terminal, messaging, and dedicated-UI commands from suggestions', () => {
|
||||
expect(isDesktopSlashSuggestion('/clear')).toBe(false)
|
||||
expect(isDesktopSlashSuggestion('/compact')).toBe(false)
|
||||
expect(isDesktopSlashSuggestion('/redraw')).toBe(false)
|
||||
expect(isDesktopSlashSuggestion('/approve')).toBe(false)
|
||||
expect(isDesktopSlashSuggestion('/model')).toBe(false)
|
||||
expect(isDesktopSlashSuggestion('/skills')).toBe(false)
|
||||
expect(isDesktopSlashSuggestion('/voice')).toBe(false)
|
||||
expect(isDesktopSlashSuggestion('/curator')).toBe(false)
|
||||
})
|
||||
|
||||
it('allows aliases to execute without cluttering the popover', () => {
|
||||
expect(isDesktopSlashSuggestion('/reset')).toBe(false)
|
||||
expect(isDesktopSlashCommand('/reset')).toBe(true)
|
||||
})
|
||||
|
||||
it('filters command catalogs down to core desktop commands', () => {
|
||||
const filtered = filterDesktopCommandsCatalog({
|
||||
categories: [
|
||||
{
|
||||
name: 'Session',
|
||||
pairs: [
|
||||
['/new', 'Start a new session'],
|
||||
['/clear', 'Clear terminal screen']
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'User commands',
|
||||
pairs: [['/ship-it', 'Run release checklist']]
|
||||
}
|
||||
],
|
||||
pairs: [
|
||||
['/new', 'Start a new session'],
|
||||
['/model', 'Switch model'],
|
||||
['/ship-it', 'Run release checklist']
|
||||
],
|
||||
skill_count: 2
|
||||
})
|
||||
|
||||
expect(filtered.categories).toEqual([{ name: 'Session', pairs: [['/new', 'Start a new desktop chat']] }])
|
||||
expect(filtered.pairs).toEqual([['/new', 'Start a new desktop chat']])
|
||||
expect(filtered.skill_count).toBe(2)
|
||||
})
|
||||
|
||||
it('uses desktop-specific labels for commands with different UI behavior', () => {
|
||||
expect(desktopSlashDescription('/branch', 'Branch the current session')).toBe(
|
||||
'Branch the latest message into a new chat'
|
||||
)
|
||||
expect(desktopSlashDescription('/skin', 'Show or change the display skin/theme')).toBe(
|
||||
'Switch desktop theme or cycle to the next one'
|
||||
)
|
||||
})
|
||||
|
||||
it('builds /skin completions from desktop themes', () => {
|
||||
const completions = desktopSkinSlashCompletions(
|
||||
[
|
||||
{ name: 'mono', label: 'Mono', description: 'Clean grayscale' },
|
||||
{ name: 'midnight', label: 'Midnight', description: 'Deep blue' },
|
||||
{ name: 'slate', label: 'Slate', description: 'Cool slate blue' }
|
||||
],
|
||||
'mono',
|
||||
'm'
|
||||
)
|
||||
|
||||
expect(completions).toEqual([
|
||||
{
|
||||
text: '/skin mono',
|
||||
display: '/skin mono',
|
||||
meta: 'Mono (current) - Clean grayscale'
|
||||
},
|
||||
{
|
||||
text: '/skin midnight',
|
||||
display: '/skin midnight',
|
||||
meta: 'Midnight - Deep blue'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('explains known commands that desktop owns elsewhere', () => {
|
||||
expect(desktopSlashUnavailableMessage('/model sonnet')).toContain('model picker')
|
||||
expect(desktopSlashUnavailableMessage('/skills')).toContain('desktop sidebar')
|
||||
expect(desktopSlashUnavailableMessage('/clear')).toContain('terminal interface')
|
||||
})
|
||||
})
|
||||
251
apps/desktop/src/lib/desktop-slash-commands.ts
Normal file
251
apps/desktop/src/lib/desktop-slash-commands.ts
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
export interface CommandsCatalogSection {
|
||||
name: string
|
||||
pairs: [string, string][]
|
||||
}
|
||||
|
||||
export interface CommandsCatalogLike {
|
||||
categories?: CommandsCatalogSection[]
|
||||
pairs?: [string, string][]
|
||||
skill_count?: number
|
||||
warning?: string
|
||||
}
|
||||
|
||||
export interface DesktopSlashCompletion {
|
||||
display: string
|
||||
meta: string
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface DesktopThemeCommandOption {
|
||||
description: string
|
||||
label: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const DESKTOP_COMMAND_META = [
|
||||
['/agents', 'Show active desktop sessions and running tasks'],
|
||||
['/background', 'Run a prompt in the background'],
|
||||
['/branch', 'Branch the latest message into a new chat'],
|
||||
['/compress', 'Compress this conversation context'],
|
||||
['/debug', 'Create a debug report'],
|
||||
['/goal', 'Manage the standing goal for this session'],
|
||||
['/help', 'Show desktop slash commands'],
|
||||
['/new', 'Start a new desktop chat'],
|
||||
['/queue', 'Queue a prompt for the next turn'],
|
||||
['/resume', 'Resume a saved session'],
|
||||
['/retry', 'Retry the last user message'],
|
||||
['/rollback', 'List or restore filesystem checkpoints'],
|
||||
['/skin', 'Switch desktop theme or cycle to the next one'],
|
||||
['/status', 'Show current session status'],
|
||||
['/steer', 'Steer the current run after the next tool call'],
|
||||
['/stop', 'Stop running background processes'],
|
||||
['/title', 'Rename the current session'],
|
||||
['/undo', 'Remove the last user/assistant exchange'],
|
||||
['/usage', 'Show token usage for this session']
|
||||
] as const
|
||||
|
||||
const DESKTOP_COMMANDS: ReadonlySet<string> = new Set(DESKTOP_COMMAND_META.map(([command]) => command))
|
||||
|
||||
const DESKTOP_ALIASES = new Map([
|
||||
['/bg', '/background'],
|
||||
['/btw', '/background'],
|
||||
['/fork', '/branch'],
|
||||
['/q', '/queue'],
|
||||
['/reload_mcp', '/reload-mcp'],
|
||||
['/reload_skills', '/reload-skills'],
|
||||
['/reset', '/new'],
|
||||
['/tasks', '/agents']
|
||||
])
|
||||
|
||||
const DESKTOP_COMMAND_DESCRIPTIONS: ReadonlyMap<string, string> = new Map(DESKTOP_COMMAND_META)
|
||||
|
||||
const PICKER_OWNED_COMMANDS = new Set(['/model', '/provider'])
|
||||
|
||||
const TERMINAL_ONLY_COMMANDS = new Set([
|
||||
'/browser',
|
||||
'/busy',
|
||||
'/clear',
|
||||
'/commands',
|
||||
'/compact',
|
||||
'/config',
|
||||
'/copy',
|
||||
'/cron',
|
||||
'/details',
|
||||
'/exit',
|
||||
'/footer',
|
||||
'/gateway',
|
||||
'/gquota',
|
||||
'/history',
|
||||
'/image',
|
||||
'/indicator',
|
||||
'/logs',
|
||||
'/mouse',
|
||||
'/paste',
|
||||
'/platforms',
|
||||
'/plugins',
|
||||
'/quit',
|
||||
'/redraw',
|
||||
'/reload',
|
||||
'/restart',
|
||||
'/save',
|
||||
'/sb',
|
||||
'/set-home',
|
||||
'/sethome',
|
||||
'/snap',
|
||||
'/snapshot',
|
||||
'/statusbar',
|
||||
'/toolsets',
|
||||
'/tools',
|
||||
'/update',
|
||||
'/verbose'
|
||||
])
|
||||
|
||||
const MESSAGING_ONLY_COMMANDS = new Set(['/approve', '/deny'])
|
||||
|
||||
const SETTINGS_OWNED_COMMANDS = new Set(['/skills'])
|
||||
|
||||
const ADVANCED_COMMANDS = new Set([
|
||||
'/curator',
|
||||
'/fast',
|
||||
'/insights',
|
||||
'/kanban',
|
||||
'/personality',
|
||||
'/profile',
|
||||
'/reasoning',
|
||||
'/reload-mcp',
|
||||
'/reload-skills',
|
||||
'/voice',
|
||||
'/yolo'
|
||||
])
|
||||
|
||||
const BLOCKED_COMMANDS = new Set([
|
||||
...PICKER_OWNED_COMMANDS,
|
||||
...TERMINAL_ONLY_COMMANDS,
|
||||
...MESSAGING_ONLY_COMMANDS,
|
||||
...SETTINGS_OWNED_COMMANDS,
|
||||
...ADVANCED_COMMANDS
|
||||
])
|
||||
|
||||
function normalizeCommand(command: string): string {
|
||||
const trimmed = command.trim()
|
||||
const base = (trimmed.startsWith('/') ? trimmed : `/${trimmed}`).split(/\s+/, 1)[0]?.toLowerCase() || ''
|
||||
|
||||
return base
|
||||
}
|
||||
|
||||
export function canonicalDesktopSlashCommand(command: string): string {
|
||||
const normalized = normalizeCommand(command)
|
||||
|
||||
return DESKTOP_ALIASES.get(normalized) || normalized
|
||||
}
|
||||
|
||||
export function isDesktopSlashCommand(command: string): boolean {
|
||||
const normalized = normalizeCommand(command)
|
||||
const canonical = canonicalDesktopSlashCommand(normalized)
|
||||
|
||||
if (BLOCKED_COMMANDS.has(normalized) || BLOCKED_COMMANDS.has(canonical)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return DESKTOP_COMMANDS.has(canonical) || !isKnownHermesSlashCommand(normalized)
|
||||
}
|
||||
|
||||
export function isDesktopSlashSuggestion(command: string): boolean {
|
||||
const normalized = normalizeCommand(command)
|
||||
const canonical = canonicalDesktopSlashCommand(normalized)
|
||||
|
||||
return DESKTOP_COMMANDS.has(canonical) && !DESKTOP_ALIASES.has(normalized)
|
||||
}
|
||||
|
||||
export function desktopSlashUnavailableMessage(command: string): string | null {
|
||||
const normalized = normalizeCommand(command)
|
||||
const canonical = canonicalDesktopSlashCommand(normalized)
|
||||
|
||||
if (PICKER_OWNED_COMMANDS.has(canonical)) {
|
||||
return `/${canonical.slice(1)} uses the desktop model picker instead of a slash command.`
|
||||
}
|
||||
|
||||
if (SETTINGS_OWNED_COMMANDS.has(canonical)) {
|
||||
return `/${canonical.slice(1)} is managed from the desktop sidebar.`
|
||||
}
|
||||
|
||||
if (MESSAGING_ONLY_COMMANDS.has(canonical)) {
|
||||
return `/${canonical.slice(1)} is only used from messaging platforms.`
|
||||
}
|
||||
|
||||
if (ADVANCED_COMMANDS.has(canonical)) {
|
||||
return `/${canonical.slice(1)} is not shown in the desktop slash palette. Use the relevant desktop control or terminal interface instead.`
|
||||
}
|
||||
|
||||
if (TERMINAL_ONLY_COMMANDS.has(normalized) || TERMINAL_ONLY_COMMANDS.has(canonical)) {
|
||||
return `/${canonical.slice(1)} is only available in the terminal interface.`
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function desktopSlashDescription(command: string, fallback = ''): string {
|
||||
const canonical = canonicalDesktopSlashCommand(command)
|
||||
|
||||
return DESKTOP_COMMAND_DESCRIPTIONS.get(canonical) || fallback
|
||||
}
|
||||
|
||||
export function desktopSkinSlashCompletions(
|
||||
themes: DesktopThemeCommandOption[],
|
||||
activeThemeName: string,
|
||||
argPrefix: string
|
||||
): DesktopSlashCompletion[] {
|
||||
const prefix = argPrefix.trim().toLowerCase()
|
||||
const commands: DesktopSlashCompletion[] = [
|
||||
{
|
||||
text: '/skin list',
|
||||
display: '/skin list',
|
||||
meta: 'Show available desktop themes'
|
||||
},
|
||||
{
|
||||
text: '/skin next',
|
||||
display: '/skin next',
|
||||
meta: 'Cycle to the next desktop theme'
|
||||
},
|
||||
...themes.map(theme => ({
|
||||
text: `/skin ${theme.name}`,
|
||||
display: `/skin ${theme.name}`,
|
||||
meta: `${theme.label}${theme.name === activeThemeName ? ' (current)' : ''} - ${theme.description}`
|
||||
}))
|
||||
]
|
||||
|
||||
if (!prefix) {
|
||||
return commands
|
||||
}
|
||||
|
||||
return commands.filter(item => item.text.slice('/skin '.length).toLowerCase().startsWith(prefix))
|
||||
}
|
||||
|
||||
export function filterDesktopCommandsCatalog(catalog: CommandsCatalogLike): CommandsCatalogLike {
|
||||
const categories = catalog.categories
|
||||
?.map(section => ({
|
||||
...section,
|
||||
pairs: section.pairs
|
||||
.filter(([command]) => isDesktopSlashSuggestion(command))
|
||||
.map(([command, description]) => [command, desktopSlashDescription(command, description)] as [string, string])
|
||||
}))
|
||||
.filter(section => section.pairs.length > 0)
|
||||
|
||||
const pairs = catalog.pairs
|
||||
?.filter(([command]) => isDesktopSlashSuggestion(command))
|
||||
.map(([command, description]) => [command, desktopSlashDescription(command, description)] as [string, string])
|
||||
|
||||
return {
|
||||
...catalog,
|
||||
...(categories ? { categories } : {}),
|
||||
...(pairs ? { pairs } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function isKnownHermesSlashCommand(command: string): boolean {
|
||||
return (
|
||||
DESKTOP_COMMANDS.has(command) ||
|
||||
DESKTOP_ALIASES.has(command) ||
|
||||
BLOCKED_COMMANDS.has(command)
|
||||
)
|
||||
}
|
||||
35
apps/desktop/src/lib/embedded-images.test.ts
Normal file
35
apps/desktop/src/lib/embedded-images.test.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { extractEmbeddedImages } from './embedded-images'
|
||||
|
||||
const SAMPLE_PNG_DATA_URL = 'data:image/png;base64,' + 'A'.repeat(120)
|
||||
|
||||
describe('extractEmbeddedImages', () => {
|
||||
it('returns text untouched when no data URL is present', () => {
|
||||
expect(extractEmbeddedImages('describe this')).toEqual({ cleanedText: 'describe this', images: [] })
|
||||
})
|
||||
|
||||
it('lifts a bare data:image URL out of prose', () => {
|
||||
const result = extractEmbeddedImages(`describe this ${SAMPLE_PNG_DATA_URL}`)
|
||||
|
||||
expect(result.cleanedText).toBe('describe this')
|
||||
expect(result.images).toEqual([SAMPLE_PNG_DATA_URL])
|
||||
})
|
||||
|
||||
it('lifts a JSON-wrapped image_url envelope out of prose', () => {
|
||||
const result = extractEmbeddedImages(
|
||||
`describe this{"type":"image_url","image_url":{"url":"${SAMPLE_PNG_DATA_URL}"}}`
|
||||
)
|
||||
|
||||
expect(result.cleanedText).toBe('describe this')
|
||||
expect(result.images).toEqual([SAMPLE_PNG_DATA_URL])
|
||||
})
|
||||
|
||||
it('extracts multiple embedded images', () => {
|
||||
const second = 'data:image/jpeg;base64,' + 'B'.repeat(96)
|
||||
const result = extractEmbeddedImages(`first ${SAMPLE_PNG_DATA_URL} mid ${second} tail`)
|
||||
|
||||
expect(result.cleanedText).toBe('first mid tail')
|
||||
expect(result.images).toEqual([SAMPLE_PNG_DATA_URL, second])
|
||||
})
|
||||
})
|
||||
59
apps/desktop/src/lib/embedded-images.ts
Normal file
59
apps/desktop/src/lib/embedded-images.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
const EMBEDDED_IMAGE_RE =
|
||||
/(\{\s*"type"\s*:\s*"image_url"\s*,\s*"image_url"\s*:\s*\{\s*"url"\s*:\s*")?(data:image\/[\w.+-]+;base64,[A-Za-z0-9+/=]{64,})("\s*\}\s*\})?/g
|
||||
|
||||
const DATA_URL_RE = /^data:([\w./+-]+);base64,(.*)$/i
|
||||
|
||||
export const DATA_IMAGE_URL_RE = /^data:image\/[\w.+-]+;base64,/i
|
||||
|
||||
export interface EmbeddedImageExtraction {
|
||||
cleanedText: string
|
||||
images: string[]
|
||||
}
|
||||
|
||||
export function dataUrlToBlob(dataUrl: string): Blob | null {
|
||||
const match = DATA_URL_RE.exec(dataUrl.trim())
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const bytes = atob(match[2])
|
||||
const buffer = new Uint8Array(bytes.length)
|
||||
|
||||
for (let i = 0; i < bytes.length; i += 1) {
|
||||
buffer[i] = bytes.charCodeAt(i)
|
||||
}
|
||||
|
||||
return new Blob([buffer], { type: match[1] })
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function extractEmbeddedImages(text: string): EmbeddedImageExtraction {
|
||||
if (!text || !text.includes('data:image/')) {
|
||||
return { cleanedText: text, images: [] }
|
||||
}
|
||||
|
||||
const images: string[] = []
|
||||
const cleanedText = text
|
||||
.replace(EMBEDDED_IMAGE_RE, (_match, _open, dataUrl: string) => {
|
||||
images.push(dataUrl)
|
||||
|
||||
return ''
|
||||
})
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
|
||||
return { cleanedText, images }
|
||||
}
|
||||
|
||||
export function embeddedImageUrls(text: string): string[] {
|
||||
return extractEmbeddedImages(text).images
|
||||
}
|
||||
|
||||
export function textWithoutEmbeddedImages(text: string): string {
|
||||
return extractEmbeddedImages(text).cleanedText
|
||||
}
|
||||
23
apps/desktop/src/lib/markdown-code.test.ts
Normal file
23
apps/desktop/src/lib/markdown-code.test.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { isLikelyProseCodeBlock } from './markdown-code'
|
||||
|
||||
describe('isLikelyProseCodeBlock', () => {
|
||||
it('detects prose that Streamdown mislabels as an unknown language', () => {
|
||||
expect(
|
||||
isLikelyProseCodeBlock(
|
||||
'heads',
|
||||
[
|
||||
'- Pure white (`#ffffff`), roughness 0.55, no emissive',
|
||||
'- Black wireframe edges at 35% opacity',
|
||||
'',
|
||||
'Want the bunny gone, or want me to keep riffing on it?'
|
||||
].join('\n')
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps real code blocks', () => {
|
||||
expect(isLikelyProseCodeBlock('ts', 'const value = { bunny: true };\nreturn value')).toBe(false)
|
||||
})
|
||||
})
|
||||
132
apps/desktop/src/lib/markdown-code.ts
Normal file
132
apps/desktop/src/lib/markdown-code.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
const VALID_LANGUAGE_RE = /^[a-z0-9][a-z0-9+#-]*$/i
|
||||
const NON_CODE_FENCE_LANGUAGES = new Set(['', 'text', 'plain', 'plaintext', 'md', 'markdown'])
|
||||
const COMMON_CODE_LANGUAGES = new Set([
|
||||
'bash',
|
||||
'c',
|
||||
'cpp',
|
||||
'css',
|
||||
'diff',
|
||||
'go',
|
||||
'html',
|
||||
'java',
|
||||
'javascript',
|
||||
'js',
|
||||
'json',
|
||||
'jsx',
|
||||
'markdown',
|
||||
'md',
|
||||
'php',
|
||||
'python',
|
||||
'py',
|
||||
'ruby',
|
||||
'rust',
|
||||
'rs',
|
||||
'sh',
|
||||
'sql',
|
||||
'swift',
|
||||
'tsx',
|
||||
'ts',
|
||||
'typescript',
|
||||
'xml',
|
||||
'yaml',
|
||||
'yml'
|
||||
])
|
||||
|
||||
interface CodeSignals {
|
||||
bulletLines: number
|
||||
codeSignals: number
|
||||
hasMarkdown: boolean
|
||||
proseLines: number
|
||||
trimmed: string
|
||||
}
|
||||
|
||||
export function sanitizeLanguageTag(tag: string): string {
|
||||
const trimmed = tag.trim()
|
||||
const first = trimmed.split(/\s/, 1)[0] || ''
|
||||
|
||||
return VALID_LANGUAGE_RE.test(first) && first.length <= 16 ? first.toLowerCase() : ''
|
||||
}
|
||||
|
||||
function proseLineCount(body: string): number {
|
||||
return body
|
||||
.split('\n')
|
||||
.filter(line => {
|
||||
const trimmed = line.trim()
|
||||
|
||||
return Boolean(trimmed) && /^[A-Za-z0-9"'`*-]/.test(trimmed)
|
||||
})
|
||||
.length
|
||||
}
|
||||
|
||||
const CODE_SIGNAL_RE = [
|
||||
/(^|\s)(const|let|var|function|class|import|export|return|if|for|while|switch)\b/gim,
|
||||
/=>|==|===|!=|!==|\{|\}|;|<\/?[a-z][^>]*>/gi,
|
||||
/^\s*(#include|SELECT|INSERT|UPDATE|DELETE|CREATE|DROP)\b/gim
|
||||
]
|
||||
|
||||
function codeSignalCount(body: string): number {
|
||||
return CODE_SIGNAL_RE.reduce((total, pattern) => total + (body.match(pattern)?.length ?? 0), 0)
|
||||
}
|
||||
|
||||
function codeSignals(body: string): CodeSignals {
|
||||
const trimmed = body.trim()
|
||||
const markdownSignals = (trimmed.match(/\*\*[^*]+\*\*/g) || []).length + (trimmed.match(/`[^`\n]+`/g) || []).length
|
||||
|
||||
return {
|
||||
bulletLines: (trimmed.match(/^\s*[-*]\s+\S+/gm) || []).length,
|
||||
codeSignals: codeSignalCount(trimmed),
|
||||
hasMarkdown: markdownSignals > 0,
|
||||
proseLines: proseLineCount(trimmed),
|
||||
trimmed
|
||||
}
|
||||
}
|
||||
|
||||
export function isLikelyProseFence(info: string, body: string): boolean {
|
||||
const trimmedInfo = info.trim()
|
||||
const rawInfo = trimmedInfo.toLowerCase()
|
||||
const language = sanitizeLanguageTag(info)
|
||||
const infoToken = trimmedInfo.split(/\s+/, 1)[0] || ''
|
||||
const hasInfoTail = Boolean(trimmedInfo) && trimmedInfo !== infoToken
|
||||
|
||||
if (/^[-*+]\s/.test(rawInfo) || /^https?:\/\//.test(rawInfo)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const signals = codeSignals(body)
|
||||
|
||||
if (!signals.trimmed) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (hasInfoTail && signals.codeSignals <= 2 && (signals.proseLines >= 2 || signals.bulletLines >= 1)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!NON_CODE_FENCE_LANGUAGES.has(language)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
(signals.bulletLines >= 2 && signals.hasMarkdown && signals.codeSignals <= 2) ||
|
||||
(signals.proseLines >= 3 && signals.codeSignals === 0)
|
||||
)
|
||||
}
|
||||
|
||||
export function isLikelyProseCodeBlock(language: string | undefined, code: string | undefined): boolean {
|
||||
const cleanLanguage = sanitizeLanguageTag(language || '')
|
||||
const signals = codeSignals(code || '')
|
||||
|
||||
if (!signals.trimmed || signals.codeSignals >= 3) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (signals.bulletLines >= 1 && (signals.hasMarkdown || signals.proseLines >= 2)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (NON_CODE_FENCE_LANGUAGES.has(cleanLanguage)) {
|
||||
return signals.proseLines >= 3 && signals.codeSignals === 0
|
||||
}
|
||||
|
||||
return !COMMON_CODE_LANGUAGES.has(cleanLanguage) && signals.proseLines >= 2 && signals.codeSignals <= 1
|
||||
}
|
||||
90
apps/desktop/src/lib/media.ts
Normal file
90
apps/desktop/src/lib/media.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
export type MediaKind = 'audio' | 'image' | 'video' | 'file'
|
||||
|
||||
interface MediaInfo {
|
||||
kind: MediaKind
|
||||
mime: string
|
||||
}
|
||||
|
||||
const MEDIA_BY_EXT: Record<string, MediaInfo> = {
|
||||
avi: { kind: 'video', mime: 'video/x-msvideo' },
|
||||
bmp: { kind: 'image', mime: 'image/bmp' },
|
||||
flac: { kind: 'audio', mime: 'audio/flac' },
|
||||
gif: { kind: 'image', mime: 'image/gif' },
|
||||
jpeg: { kind: 'image', mime: 'image/jpeg' },
|
||||
jpg: { kind: 'image', mime: 'image/jpeg' },
|
||||
m4a: { kind: 'audio', mime: 'audio/mp4' },
|
||||
mkv: { kind: 'video', mime: 'video/x-matroska' },
|
||||
mov: { kind: 'video', mime: 'video/quicktime' },
|
||||
mp3: { kind: 'audio', mime: 'audio/mpeg' },
|
||||
mp4: { kind: 'video', mime: 'video/mp4' },
|
||||
ogg: { kind: 'audio', mime: 'audio/ogg' },
|
||||
opus: { kind: 'audio', mime: 'audio/ogg; codecs=opus' },
|
||||
png: { kind: 'image', mime: 'image/png' },
|
||||
svg: { kind: 'image', mime: 'image/svg+xml' },
|
||||
wav: { kind: 'audio', mime: 'audio/wav' },
|
||||
webm: { kind: 'video', mime: 'video/webm' },
|
||||
webp: { kind: 'image', mime: 'image/webp' }
|
||||
}
|
||||
|
||||
function mediaInfo(path: string): MediaInfo | undefined {
|
||||
const ext = path.split(/[?#]/, 1)[0]?.split('.').pop()?.toLowerCase()
|
||||
|
||||
return ext ? MEDIA_BY_EXT[ext] : undefined
|
||||
}
|
||||
|
||||
export function mediaKind(path: string): MediaKind {
|
||||
return mediaInfo(path)?.kind ?? 'file'
|
||||
}
|
||||
|
||||
export function mediaMime(path: string): string {
|
||||
return mediaInfo(path)?.mime ?? 'application/octet-stream'
|
||||
}
|
||||
|
||||
export function mediaName(path: string): string {
|
||||
try {
|
||||
const url = new URL(path)
|
||||
|
||||
return url.pathname.split('/').filter(Boolean).pop() || path
|
||||
} catch {
|
||||
return path.split(/[\\/]/).filter(Boolean).pop() || path
|
||||
}
|
||||
}
|
||||
|
||||
export function mediaMarkdownHref(path: string): string {
|
||||
return `#media:${encodeURIComponent(path)}`
|
||||
}
|
||||
|
||||
export function mediaExternalUrl(path: string): string {
|
||||
return /^(?:https?|file):/i.test(path) ? path : `file://${path}`
|
||||
}
|
||||
|
||||
export function mediaPathFromMarkdownHref(href?: string): string | null {
|
||||
if (!href?.startsWith('#media:')) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return decodeURIComponent(href.slice('#media:'.length))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function filePathFromMediaPath(path: string): string {
|
||||
if (!path.startsWith('file:')) {
|
||||
return path
|
||||
}
|
||||
|
||||
try {
|
||||
return decodeURIComponent(new URL(path).pathname)
|
||||
} catch {
|
||||
return path.replace(/^file:\/\//, '')
|
||||
}
|
||||
}
|
||||
|
||||
export function mediaDisplayLabel(path: string): string {
|
||||
const escaped = mediaName(path).replace(/[[\]\\]/g, '\\$&')
|
||||
const kind = mediaKind(path)
|
||||
|
||||
return `${kind[0].toUpperCase()}${kind.slice(1)}: ${escaped}`
|
||||
}
|
||||
53
apps/desktop/src/lib/preview-targets.test.ts
Normal file
53
apps/desktop/src/lib/preview-targets.test.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
extractPreviewCandidates,
|
||||
extractPreviewTargets,
|
||||
isLikelyPreviewCandidate,
|
||||
previewTargetFromMarkdownHref,
|
||||
renderPreviewTargets,
|
||||
stripPreviewTargets
|
||||
} from './preview-targets'
|
||||
|
||||
describe('preview target detection', () => {
|
||||
it('extracts local server URLs and html files', () => {
|
||||
expect(
|
||||
extractPreviewCandidates(
|
||||
'Open http://localhost:5173/ and /tmp/mycelium-bunnies/index.html, not https://example.com/app.'
|
||||
)
|
||||
).toEqual(['http://localhost:5173/', '/tmp/mycelium-bunnies/index.html'])
|
||||
})
|
||||
|
||||
it('accepts relative html files and file URLs', () => {
|
||||
expect(extractPreviewCandidates('Wrote ./dist/index.html and file:///tmp/demo.html.')).toEqual([
|
||||
'./dist/index.html',
|
||||
'file:///tmp/demo.html'
|
||||
])
|
||||
})
|
||||
|
||||
it('ignores remote web URLs', () => {
|
||||
expect(isLikelyPreviewCandidate('https://example.com/demo')).toBe(false)
|
||||
expect(isLikelyPreviewCandidate('http://127.0.0.1:3000')).toBe(true)
|
||||
})
|
||||
|
||||
it('renders previewable paths as markdown links', () => {
|
||||
expect(renderPreviewTargets('ready\n/tmp/mycelium-bunnies.html\nopen it')).toBe(
|
||||
'ready\n[Preview: mycelium-bunnies.html](#preview/%2Ftmp%2Fmycelium-bunnies.html)\nopen it'
|
||||
)
|
||||
})
|
||||
|
||||
it('decodes preview markdown hrefs', () => {
|
||||
expect(previewTargetFromMarkdownHref('#preview/%2Ftmp%2Fdemo.html')).toBe('/tmp/demo.html')
|
||||
expect(previewTargetFromMarkdownHref('#preview:%2Ftmp%2Fdemo.html')).toBe('/tmp/demo.html')
|
||||
expect(previewTargetFromMarkdownHref('#media:%2Ftmp%2Fdemo.mp4')).toBeNull()
|
||||
})
|
||||
|
||||
it('extracts preview targets from already-rendered preview markers', () => {
|
||||
expect(extractPreviewTargets('[Preview: demo.html](#preview:%2Ftmp%2Fdemo.html)')).toEqual(['/tmp/demo.html'])
|
||||
})
|
||||
|
||||
it('strips preview targets from visible assistant text', () => {
|
||||
expect(stripPreviewTargets('ready\n/tmp/mycelium-bunnies.html\nopen it')).toBe('ready\nopen it')
|
||||
expect(stripPreviewTargets('[Preview: demo.html](#preview:%2Ftmp%2Fdemo.html)\nopen it')).toBe('open it')
|
||||
})
|
||||
})
|
||||
216
apps/desktop/src/lib/preview-targets.ts
Normal file
216
apps/desktop/src/lib/preview-targets.ts
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
const LOCAL_HOSTS = new Set(['0.0.0.0', '127.0.0.1', '::1', '[::1]', 'localhost'])
|
||||
const HTML_EXT_RE = /\.html?(?:[?#].*)?$/i
|
||||
const URL_RE = /\bhttps?:\/\/[^\s<>"'`)\]]+/gi
|
||||
const FILE_URL_RE = /\bfile:\/\/[^\s<>"'`)\]]+/gi
|
||||
const POSIX_HTML_PATH_RE = /(?:^|[\s("'`])(?<path>\/[^\s<>"'`]*?\.html?)(?:[),.;:!?]*)(?=$|[\s)"'`])/gi
|
||||
const RELATIVE_HTML_PATH_RE = /(?:^|[\s("'`])(?<path>\.{1,2}\/[^\s<>"'`]*?\.html?)(?:[),.;:!?]*)(?=$|[\s)"'`])/gi
|
||||
const PREVIEW_MARKDOWN_RE = /\[Preview:[^\]]+\]\((?<href>#preview[:/][^)]+)\)/gi
|
||||
|
||||
interface PreviewCandidateMatch {
|
||||
end: number
|
||||
index: number
|
||||
value: string
|
||||
}
|
||||
|
||||
function stripTrailingPunctuation(value: string): string {
|
||||
return value.replace(/[),.;:!?]+$/, '')
|
||||
}
|
||||
|
||||
function isLocalPreviewUrl(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
|
||||
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return LOCAL_HOSTS.has(url.hostname.toLowerCase())
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function isLikelyPreviewCandidate(value: string): boolean {
|
||||
const trimmed = stripTrailingPunctuation(value.trim())
|
||||
|
||||
return trimmed.startsWith('file://') || HTML_EXT_RE.test(trimmed) || isLocalPreviewUrl(trimmed)
|
||||
}
|
||||
|
||||
function collectPreviewMatches(text: string): PreviewCandidateMatch[] {
|
||||
const matches: PreviewCandidateMatch[] = []
|
||||
|
||||
const collect = (index: number | undefined, raw: string, value = raw) => {
|
||||
if (index === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const candidate = stripTrailingPunctuation(value.trim())
|
||||
|
||||
if (!candidate || !isLikelyPreviewCandidate(candidate)) {
|
||||
return
|
||||
}
|
||||
|
||||
const offset = raw.indexOf(value)
|
||||
const start = index + Math.max(0, offset)
|
||||
|
||||
matches.push({
|
||||
end: start + candidate.length,
|
||||
index: start,
|
||||
value: candidate
|
||||
})
|
||||
}
|
||||
|
||||
for (const match of text.matchAll(URL_RE)) {
|
||||
collect(match.index, match[0])
|
||||
}
|
||||
|
||||
for (const match of text.matchAll(FILE_URL_RE)) {
|
||||
collect(match.index, match[0])
|
||||
}
|
||||
|
||||
for (const match of text.matchAll(POSIX_HTML_PATH_RE)) {
|
||||
collect(match.index, match[0], match.groups?.path || '')
|
||||
}
|
||||
|
||||
for (const match of text.matchAll(RELATIVE_HTML_PATH_RE)) {
|
||||
collect(match.index, match[0], match.groups?.path || '')
|
||||
}
|
||||
|
||||
return matches.sort((a, b) => a.index - b.index)
|
||||
}
|
||||
|
||||
export function extractPreviewCandidates(text: string): string[] {
|
||||
const candidates: string[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
const push = (value: string) => {
|
||||
const candidate = stripTrailingPunctuation(value.trim())
|
||||
|
||||
if (!candidate || seen.has(candidate) || !isLikelyPreviewCandidate(candidate)) {
|
||||
return
|
||||
}
|
||||
|
||||
seen.add(candidate)
|
||||
candidates.push(candidate)
|
||||
}
|
||||
|
||||
for (const match of collectPreviewMatches(text)) {
|
||||
push(match.value)
|
||||
}
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
export function stripPreviewTargets(text: string): string {
|
||||
const matches = collectPreviewMatches(text)
|
||||
let cursor = 0
|
||||
let stripped = ''
|
||||
|
||||
for (const match of matches) {
|
||||
if (match.index < cursor) {
|
||||
continue
|
||||
}
|
||||
|
||||
const lineStart = text.lastIndexOf('\n', Math.max(0, match.index - 1)) + 1
|
||||
const nextLineBreak = text.indexOf('\n', match.end)
|
||||
const lineEnd = nextLineBreak === -1 ? text.length : nextLineBreak + 1
|
||||
const beforeOnLine = text.slice(lineStart, match.index)
|
||||
const afterOnLine = text.slice(match.end, nextLineBreak === -1 ? text.length : nextLineBreak)
|
||||
|
||||
if (lineStart >= cursor && !beforeOnLine.trim() && !afterOnLine.trim()) {
|
||||
stripped += text.slice(cursor, lineStart)
|
||||
cursor = lineEnd
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
stripped += text.slice(cursor, match.index)
|
||||
cursor = match.end
|
||||
}
|
||||
|
||||
stripped += text.slice(cursor)
|
||||
|
||||
return stripped
|
||||
.replace(PREVIEW_MARKDOWN_RE, '')
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export function extractPreviewTargets(text: string): string[] {
|
||||
const targets = extractPreviewCandidates(text)
|
||||
const seen = new Set(targets)
|
||||
|
||||
for (const match of text.matchAll(PREVIEW_MARKDOWN_RE)) {
|
||||
const target = previewTargetFromMarkdownHref(match.groups?.href)
|
||||
|
||||
if (target && !seen.has(target)) {
|
||||
seen.add(target)
|
||||
targets.push(target)
|
||||
}
|
||||
}
|
||||
|
||||
return targets
|
||||
}
|
||||
|
||||
export function previewMarkdownHref(target: string): string {
|
||||
return `#preview/${encodeURIComponent(target)}`
|
||||
}
|
||||
|
||||
export function previewTargetFromMarkdownHref(href?: string): string | null {
|
||||
if (!href?.startsWith('#preview:') && !href?.startsWith('#preview/')) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return decodeURIComponent(href.slice('#preview'.length + 1))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function previewName(target: string): string {
|
||||
try {
|
||||
const url = new URL(target)
|
||||
|
||||
if (url.protocol === 'file:') {
|
||||
return decodeURIComponent(url.pathname).split(/[\\/]/).filter(Boolean).pop() || target
|
||||
}
|
||||
|
||||
const file = url.pathname.split('/').filter(Boolean).pop()
|
||||
|
||||
return file || url.host
|
||||
} catch {
|
||||
return target.split(/[\\/]/).filter(Boolean).pop() || target
|
||||
}
|
||||
}
|
||||
|
||||
export function previewDisplayLabel(target: string): string {
|
||||
const escaped = previewName(target).replace(/[[\]\\]/g, '\\$&')
|
||||
|
||||
return `Preview: ${escaped}`
|
||||
}
|
||||
|
||||
function previewLink(value: string): string {
|
||||
return `[${previewDisplayLabel(value)}](${previewMarkdownHref(value)})`
|
||||
}
|
||||
|
||||
export function renderPreviewTargets(text: string): string {
|
||||
const matches = collectPreviewMatches(text)
|
||||
let cursor = 0
|
||||
let rendered = ''
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const match of matches) {
|
||||
if (match.index < cursor || seen.has(match.value)) {
|
||||
continue
|
||||
}
|
||||
|
||||
rendered += text.slice(cursor, match.index)
|
||||
rendered += previewLink(match.value)
|
||||
cursor = match.end
|
||||
seen.add(match.value)
|
||||
}
|
||||
|
||||
return rendered + text.slice(cursor)
|
||||
}
|
||||
32
apps/desktop/src/store/clarify.ts
Normal file
32
apps/desktop/src/store/clarify.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { atom } from 'nanostores'
|
||||
|
||||
export interface ClarifyRequest {
|
||||
requestId: string
|
||||
question: string
|
||||
choices: string[] | null
|
||||
sessionId: string | null
|
||||
}
|
||||
|
||||
// Holds the request_id (and metadata) for the most recent in-flight
|
||||
// clarify call. The inline ClarifyTool component (rendered inside the
|
||||
// assistant message stream) reads this to know which request_id to send
|
||||
// back over `clarify.respond`.
|
||||
export const $clarifyRequest = atom<ClarifyRequest | null>(null)
|
||||
|
||||
export function setClarifyRequest(request: ClarifyRequest): void {
|
||||
$clarifyRequest.set(request)
|
||||
}
|
||||
|
||||
export function clearClarifyRequest(requestId?: string): void {
|
||||
const current = $clarifyRequest.get()
|
||||
|
||||
if (!current) {
|
||||
return
|
||||
}
|
||||
|
||||
if (requestId && current.requestId !== requestId) {
|
||||
return
|
||||
}
|
||||
|
||||
$clarifyRequest.set(null)
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ export interface ComposerAttachment {
|
|||
refText?: string
|
||||
previewUrl?: string
|
||||
path?: string
|
||||
attachedSessionId?: string
|
||||
}
|
||||
|
||||
export const $composerDraft = atom('')
|
||||
|
|
|
|||
16
apps/desktop/src/store/gateway.ts
Normal file
16
apps/desktop/src/store/gateway.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { atom } from 'nanostores'
|
||||
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
|
||||
// The active gateway instance, exposed for inline message-stream components
|
||||
// (e.g. inline ClarifyTool) that need to call gateway methods without having
|
||||
// the instance threaded down through props from `ChatView`.
|
||||
export const $gateway = atom<HermesGateway | null>(null)
|
||||
|
||||
export function setGateway(gateway: HermesGateway | null): void {
|
||||
if ($gateway.get() === gateway) {
|
||||
return
|
||||
}
|
||||
|
||||
$gateway.set(gateway)
|
||||
}
|
||||
14
apps/desktop/src/store/preview.ts
Normal file
14
apps/desktop/src/store/preview.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { atom } from 'nanostores'
|
||||
|
||||
export interface PreviewTarget {
|
||||
kind: 'file' | 'url'
|
||||
label: string
|
||||
source: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export const $previewTarget = atom<PreviewTarget | null>(null)
|
||||
|
||||
export function setPreviewTarget(target: PreviewTarget | null) {
|
||||
$previewTarget.set(target)
|
||||
}
|
||||
23
apps/desktop/src/store/tool-diffs.ts
Normal file
23
apps/desktop/src/store/tool-diffs.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { atom } from 'nanostores'
|
||||
|
||||
const $toolDiffs = atom<Record<string, string>>({})
|
||||
|
||||
export function recordToolDiff(toolCallId: string, diff: string) {
|
||||
if (!toolCallId || !diff) {
|
||||
return
|
||||
}
|
||||
|
||||
const current = $toolDiffs.get()
|
||||
|
||||
if (current[toolCallId] === diff) {
|
||||
return
|
||||
}
|
||||
|
||||
$toolDiffs.set({ ...current, [toolCallId]: diff })
|
||||
}
|
||||
|
||||
export function getToolDiff(toolCallId: string): string {
|
||||
return toolCallId ? $toolDiffs.get()[toolCallId] || '' : ''
|
||||
}
|
||||
|
||||
export const $toolInlineDiffs = $toolDiffs
|
||||
|
|
@ -118,14 +118,29 @@
|
|||
--radius: 0.75rem;
|
||||
/* Thread ViewportFooter — gap from last msg → composer (scroll only) */
|
||||
--thread-composer-clearance: 8rem;
|
||||
/* Composer shell — gap under bar to chat pane bottom */
|
||||
/* Composer geometry — single source of truth for shell + controls. */
|
||||
--composer-shell-pad-block-end: 2.5rem;
|
||||
--composer-inline-clearance: clamp(1rem, 5vw, 4rem);
|
||||
--composer-min-width: 34rem;
|
||||
--composer-target-width: 68%;
|
||||
--composer-max-width: 56rem;
|
||||
--composer-control-size: 2rem;
|
||||
--composer-control-gap: 0.375rem;
|
||||
--composer-row-gap: 0.375rem;
|
||||
--composer-surface-pad-x: 0.5rem;
|
||||
--composer-surface-pad-y: 0.375rem;
|
||||
--composer-input-min-height: 2rem;
|
||||
--composer-input-max-height: 9.375rem;
|
||||
--composer-input-inline-min-width: 8rem;
|
||||
--composer-fallback-height: 2.75rem;
|
||||
--vsq: min(0.5vh, 0.5vw);
|
||||
--image-preview-max-width: 34rem;
|
||||
--image-preview-height: clamp(16.25rem, calc(var(--vsq) * 100), 26.25rem);
|
||||
|
||||
/* Sidebar layout */
|
||||
/* Shell layout */
|
||||
--sidebar-width: 14rem;
|
||||
--chat-min-width: 24rem;
|
||||
--shell-gap: 0.625rem;
|
||||
--titlebar-control-size: 1.25rem;
|
||||
--titlebar-control-height: 1.375rem;
|
||||
|
||||
|
|
@ -190,7 +205,9 @@ input,
|
|||
textarea,
|
||||
[contenteditable]:not([contenteditable='false']),
|
||||
[data-slot='aui_user-message-root'],
|
||||
[data-slot='aui_assistant-message-content'] {
|
||||
[data-slot='aui_assistant-message-content'],
|
||||
[data-selectable-text='true'],
|
||||
[data-selectable-text='true'] * {
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
|
@ -247,17 +264,23 @@ canvas {
|
|||
display: none;
|
||||
}
|
||||
|
||||
@supports (content-visibility: auto) {
|
||||
[data-slot='aui_user-message-root'],
|
||||
[data-slot='aui_assistant-message-root'] {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 10rem;
|
||||
}
|
||||
|
||||
[data-slot='aui_user-message-root'] {
|
||||
contain-intrinsic-size: auto 4rem;
|
||||
}
|
||||
}
|
||||
/*
|
||||
* Previously applied `content-visibility: auto` + `contain-intrinsic-size` to
|
||||
* message roots for virtualization-lite perf. REMOVED because it interacts
|
||||
* badly with a stick-to-bottom scroller:
|
||||
*
|
||||
* 1. Session loads, messages render at their real heights.
|
||||
* 2. Scroller pins to `scrollHeight - clientHeight`.
|
||||
* 3. A few seconds later the browser's content-visibility heuristic kicks
|
||||
* in for off-screen messages and collapses them to the 10rem intrinsic
|
||||
* placeholder — shrinking total scrollHeight by a large margin.
|
||||
* 4. The browser clamps scrollTop to the new (smaller) scrollHeight, and
|
||||
* the user's viewport "scrolls up by a weird %" a few seconds after
|
||||
* the session loads. Feels like a scroll bug; actually CSS.
|
||||
*
|
||||
* If we want perf here again, the correct path is a real virtualizer (e.g.
|
||||
* react-virtuoso) with stable item sizing — not a CSS heuristic.
|
||||
*/
|
||||
|
||||
.aui-md img {
|
||||
display: block;
|
||||
|
|
@ -281,6 +304,25 @@ canvas {
|
|||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.hermes-preview-webview {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
[data-slot='composer-root'] {
|
||||
width: clamp(var(--composer-min-width), var(--composer-target-width), var(--composer-max-width));
|
||||
max-width: calc(100% - var(--composer-inline-clearance));
|
||||
}
|
||||
|
||||
/* Thread scroll container (from use-stick-to-bottom).
|
||||
* `scroll-behavior: auto` is critical: use-stick-to-bottom writes scrollTop
|
||||
* directly and temporarily forces this to 'auto' during its programmatic
|
||||
* scrolls, but we default it to 'auto' anyway so no smooth-scroll fight can
|
||||
* ever happen. We leave overflow-anchor at the browser default ('auto'); the
|
||||
* library handles follow-mode imperatively. */
|
||||
[data-slot='aui_thread-content'] {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
.aui-md a,
|
||||
.aui-md code {
|
||||
overflow-wrap: anywhere;
|
||||
|
|
@ -314,6 +356,25 @@ canvas {
|
|||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
/* Streamdown wraps every fenced block in <div data-streamdown="code-block">
|
||||
* with `flex flex-col gap-2 p-2 border bg-sidebar rounded-xl my-4`. Our own
|
||||
* CodeHeader + SyntaxHighlighter already supply the chrome, so undo the
|
||||
* library's wrapper to keep the header flush with the code body. */
|
||||
.aui-md [data-streamdown='code-block'],
|
||||
[data-streamdown='code-block'] {
|
||||
padding: 0 !important;
|
||||
gap: 0 !important;
|
||||
border: 0 !important;
|
||||
background: transparent !important;
|
||||
border-radius: 0 !important;
|
||||
margin: 1rem 0 !important;
|
||||
}
|
||||
|
||||
.aui-md [data-streamdown='code-block'] > *,
|
||||
[data-streamdown='code-block'] > * {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.aui-md h1 {
|
||||
margin: 1.6rem 0 0.55rem;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@ import {
|
|||
DEFAULT_LAYOUT,
|
||||
DEFAULT_TYPOGRAPHY,
|
||||
defaultTheme,
|
||||
nousLightTheme
|
||||
nousLightTheme,
|
||||
nousTheme
|
||||
} from './presets'
|
||||
import type { DesktopTheme, DesktopThemeColors, ThemeDensity } from './types'
|
||||
|
||||
|
|
@ -37,6 +38,10 @@ const DENSITY_MULTIPLIERS: Record<ThemeDensity, string> = {
|
|||
|
||||
const INJECTED_FONT_URLS = new Set<string>()
|
||||
const SKIN_THEME_LIST = BUILTIN_THEME_LIST.filter(t => t.name !== 'nous-light')
|
||||
const NOUS_FONT_FAMILY_FALLBACK = {
|
||||
fontSans: nousTheme.typography?.fontSans ?? DEFAULT_TYPOGRAPHY.fontSans,
|
||||
fontMono: nousTheme.typography?.fontMono ?? DEFAULT_TYPOGRAPHY.fontMono
|
||||
}
|
||||
|
||||
function effectiveMode(mode: ThemeMode, systemDark = matchesQuery('(prefers-color-scheme: dark)')): 'light' | 'dark' {
|
||||
return mode === 'system' ? (systemDark ? 'dark' : 'light') : mode
|
||||
|
|
@ -101,8 +106,21 @@ function fontOnly(theme: DesktopTheme): DesktopTheme['typography'] {
|
|||
}
|
||||
|
||||
const { fontSans, fontMono, fontUrl } = theme.typography
|
||||
const typography: DesktopTheme['typography'] = {}
|
||||
|
||||
return { fontSans, fontMono, fontUrl }
|
||||
if (fontSans) {
|
||||
typography.fontSans = fontSans
|
||||
}
|
||||
|
||||
if (fontMono) {
|
||||
typography.fontMono = fontMono
|
||||
}
|
||||
|
||||
if (fontUrl) {
|
||||
typography.fontUrl = fontUrl
|
||||
}
|
||||
|
||||
return typography
|
||||
}
|
||||
|
||||
function lightColors(seed: DesktopTheme, skinName: string): DesktopThemeColors {
|
||||
|
|
@ -200,7 +218,7 @@ function applyTheme(theme: DesktopTheme, mode: 'light' | 'dark') {
|
|||
}
|
||||
|
||||
const root = document.documentElement
|
||||
const typo = { ...DEFAULT_TYPOGRAPHY, ...theme.typography }
|
||||
const typo = { ...DEFAULT_TYPOGRAPHY, ...NOUS_FONT_FAMILY_FALLBACK, ...theme.typography }
|
||||
const layout = { ...DEFAULT_LAYOUT, ...theme.layout }
|
||||
const c = theme.colors
|
||||
|
||||
|
|
|
|||
|
|
@ -133,14 +133,14 @@ export interface SessionInfo {
|
|||
|
||||
export interface SessionMessage {
|
||||
codex_reasoning_items?: unknown
|
||||
content: null | string
|
||||
context?: string
|
||||
content: unknown
|
||||
context?: unknown
|
||||
name?: string
|
||||
reasoning?: null | string
|
||||
reasoning_content?: null | string
|
||||
reasoning_details?: unknown
|
||||
role: 'assistant' | 'system' | 'tool' | 'user'
|
||||
text?: string
|
||||
text?: unknown
|
||||
timestamp?: number
|
||||
tool_call_id?: null | string
|
||||
tool_calls?: unknown
|
||||
|
|
|
|||
|
|
@ -289,6 +289,25 @@ browser:
|
|||
# after this period of no activity between agent loops (default: 120 = 2 minutes)
|
||||
inactivity_timeout: 120
|
||||
|
||||
# =============================================================================
|
||||
# Tool Loop Guardrails
|
||||
# =============================================================================
|
||||
# Soft warnings are enabled by default. They append guidance to repeated failed
|
||||
# or non-progressing tool results but still let the tool execute. Hard stops are
|
||||
# opt-in circuit breakers for autonomous/cron sessions where stopping a loop is
|
||||
# preferable to spending the full iteration budget.
|
||||
tool_loop_guardrails:
|
||||
warnings_enabled: true
|
||||
hard_stop_enabled: false
|
||||
warn_after:
|
||||
exact_failure: 2
|
||||
same_tool_failure: 3
|
||||
idempotent_no_progress: 2
|
||||
hard_stop_after:
|
||||
exact_failure: 5
|
||||
same_tool_failure: 8
|
||||
idempotent_no_progress: 5
|
||||
|
||||
# =============================================================================
|
||||
# Context Compression (Auto-shrinks long conversations)
|
||||
# =============================================================================
|
||||
|
|
|
|||
243
cli.py
243
cli.py
|
|
@ -15,7 +15,6 @@ Usage:
|
|||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import json
|
||||
|
|
@ -86,7 +85,7 @@ from hermes_cli.browser_connect import (
|
|||
try_launch_chrome_debug,
|
||||
)
|
||||
from hermes_cli.env_loader import load_hermes_dotenv
|
||||
from utils import base_url_host_matches
|
||||
from utils import base_url_host_matches, is_truthy_value
|
||||
|
||||
_hermes_home = get_hermes_home()
|
||||
_project_env = Path(__file__).parent / '.env'
|
||||
|
|
@ -600,6 +599,7 @@ def load_cli_config() -> Dict[str, Any]:
|
|||
# Load configuration at module startup
|
||||
CLI_CONFIG = load_cli_config()
|
||||
|
||||
|
||||
# Initialize centralized logging early — agent.log + errors.log in ~/.hermes/logs/.
|
||||
# This ensures CLI sessions produce a log trail even before AIAgent is instantiated.
|
||||
try:
|
||||
|
|
@ -934,6 +934,20 @@ def _run_state_db_auto_maintenance(session_db) -> None:
|
|||
try:
|
||||
from hermes_cli.config import load_config as _load_full_config
|
||||
from hermes_constants import get_hermes_home as _get_hermes_home
|
||||
_hermes_home_maint = _get_hermes_home()
|
||||
|
||||
# One-time prune of empty TUI ghost sessions.
|
||||
try:
|
||||
if not session_db.get_meta("ghost_session_prune_v1"):
|
||||
pruned = session_db.prune_empty_ghost_sessions(
|
||||
sessions_dir=_hermes_home_maint / "sessions"
|
||||
)
|
||||
session_db.set_meta("ghost_session_prune_v1", "1")
|
||||
if pruned:
|
||||
logger.info("Pruned %d empty TUI ghost sessions", pruned)
|
||||
except Exception as _prune_exc:
|
||||
logger.debug("Ghost session prune skipped: %s", _prune_exc)
|
||||
|
||||
cfg = (_load_full_config().get("sessions") or {})
|
||||
if not cfg.get("auto_prune", False):
|
||||
return
|
||||
|
|
@ -941,7 +955,7 @@ def _run_state_db_auto_maintenance(session_db) -> None:
|
|||
retention_days=int(cfg.get("retention_days", 90)),
|
||||
min_interval_hours=int(cfg.get("min_interval_hours", 24)),
|
||||
vacuum=bool(cfg.get("vacuum_after_prune", True)),
|
||||
sessions_dir=_get_hermes_home() / "sessions",
|
||||
sessions_dir=_hermes_home_maint / "sessions",
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("state.db auto-maintenance skipped: %s", exc)
|
||||
|
|
@ -2118,6 +2132,8 @@ class HermesCLI:
|
|||
|
||||
# Parse and validate toolsets
|
||||
self.enabled_toolsets = toolsets
|
||||
self.disabled_toolsets = CLI_CONFIG["agent"].get("disabled_toolsets") or []
|
||||
|
||||
if toolsets and "all" not in toolsets and "*" not in toolsets:
|
||||
# Validate each toolset — MCP server names are resolved via
|
||||
# live registry aliases (registered during discover_mcp_tools),
|
||||
|
|
@ -3568,6 +3584,7 @@ class HermesCLI:
|
|||
credential_pool=runtime.get("credential_pool"),
|
||||
max_iterations=self.max_turns,
|
||||
enabled_toolsets=self.enabled_toolsets,
|
||||
disabled_toolsets=self.disabled_toolsets,
|
||||
verbose_logging=self.verbose,
|
||||
quiet_mode=not self.verbose,
|
||||
ephemeral_system_prompt=self.system_prompt if self.system_prompt else None,
|
||||
|
|
@ -3615,14 +3632,18 @@ class HermesCLI:
|
|||
tuple(runtime.get("args") or ()),
|
||||
)
|
||||
|
||||
if self._pending_title and self._session_db:
|
||||
# Force-create DB row on /title intent, then apply title.
|
||||
if self._pending_title and self._session_db and self.agent:
|
||||
try:
|
||||
self._session_db.set_session_title(self.session_id, self._pending_title)
|
||||
_cprint(f" Session title applied: {self._pending_title}")
|
||||
self._pending_title = None
|
||||
self.agent._ensure_db_session()
|
||||
if self.agent._session_db_created:
|
||||
self._session_db.set_session_title(self.session_id, self._pending_title)
|
||||
_cprint(f" Session title applied: {self._pending_title}")
|
||||
self._pending_title = None
|
||||
# else: row creation failed transiently — keep _pending_title for retry
|
||||
except (ValueError, Exception) as e:
|
||||
_cprint(f" Could not apply pending title: {e}")
|
||||
self._pending_title = None
|
||||
# Keep _pending_title so it can be retried after row creation succeeds
|
||||
return True
|
||||
except Exception as e:
|
||||
ChatConsole().print(f"[bold red]Failed to initialize agent: {e}[/]")
|
||||
|
|
@ -4950,6 +4971,7 @@ class HermesCLI:
|
|||
|
||||
if self._session_db:
|
||||
try:
|
||||
self.agent._session_db_created = False
|
||||
self._session_db.create_session(
|
||||
session_id=self.session_id,
|
||||
source=os.environ.get("HERMES_SESSION_SOURCE", "cli"),
|
||||
|
|
@ -4959,6 +4981,7 @@ class HermesCLI:
|
|||
"reasoning_config": self.reasoning_config,
|
||||
},
|
||||
)
|
||||
self.agent._session_db_created = True
|
||||
except Exception:
|
||||
pass
|
||||
# Notify memory providers that session_id rotated to a fresh
|
||||
|
|
@ -6537,6 +6560,8 @@ class HermesCLI:
|
|||
# No active run — treat as a normal next-turn message.
|
||||
self._pending_input.put(payload)
|
||||
_cprint(f" No agent running; queued as next turn: {payload[:80]}{'...' if len(payload) > 80 else ''}")
|
||||
elif canonical == "goal":
|
||||
self._handle_goal_command(cmd_original)
|
||||
elif canonical == "skin":
|
||||
self._handle_skin_command(cmd_original)
|
||||
elif canonical == "voice":
|
||||
|
|
@ -6582,12 +6607,17 @@ class HermesCLI:
|
|||
self._console_print(f"[bold red]Quick command '{base_cmd}' has unsupported type (supported: 'exec', 'alias')[/]")
|
||||
# Check for plugin-registered slash commands
|
||||
elif base_cmd.lstrip("/") in _get_plugin_cmd_handler_names():
|
||||
from hermes_cli.plugins import get_plugin_command_handler
|
||||
from hermes_cli.plugins import (
|
||||
get_plugin_command_handler,
|
||||
resolve_plugin_command_result,
|
||||
)
|
||||
plugin_handler = get_plugin_command_handler(base_cmd.lstrip("/"))
|
||||
if plugin_handler:
|
||||
user_args = cmd_original[len(base_cmd):].strip()
|
||||
try:
|
||||
result = plugin_handler(user_args)
|
||||
result = resolve_plugin_command_result(
|
||||
plugin_handler(user_args)
|
||||
)
|
||||
if result:
|
||||
_cprint(str(result))
|
||||
except Exception as e:
|
||||
|
|
@ -7012,6 +7042,166 @@ class HermesCLI:
|
|||
print(" status Show current browser mode")
|
||||
print()
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# /goal — persistent cross-turn goals (Ralph-style loop)
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
def _get_goal_manager(self):
|
||||
"""Return the GoalManager bound to the current session_id.
|
||||
|
||||
Cached on ``self._goal_manager`` and rebound lazily when
|
||||
``session_id`` changes (e.g. after /new or a compression-driven
|
||||
session split).
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.goals import GoalManager
|
||||
from hermes_cli.config import load_config
|
||||
except Exception as exc:
|
||||
logging.debug("goal manager unavailable: %s", exc)
|
||||
return None
|
||||
|
||||
sid = getattr(self, "session_id", None) or ""
|
||||
if not sid:
|
||||
return None
|
||||
|
||||
existing = getattr(self, "_goal_manager", None)
|
||||
if existing is not None and getattr(existing, "session_id", None) == sid:
|
||||
return existing
|
||||
|
||||
try:
|
||||
cfg = load_config() or {}
|
||||
goals_cfg = cfg.get("goals") or {}
|
||||
max_turns = int(goals_cfg.get("max_turns", 20) or 20)
|
||||
except Exception:
|
||||
max_turns = 20
|
||||
|
||||
mgr = GoalManager(session_id=sid, default_max_turns=max_turns)
|
||||
self._goal_manager = mgr
|
||||
return mgr
|
||||
|
||||
def _handle_goal_command(self, cmd: str) -> None:
|
||||
"""Dispatch /goal subcommands: set / status / pause / resume / clear."""
|
||||
parts = (cmd or "").strip().split(None, 1)
|
||||
arg = parts[1].strip() if len(parts) > 1 else ""
|
||||
|
||||
mgr = self._get_goal_manager()
|
||||
if mgr is None:
|
||||
_cprint(f" {_DIM}Goals unavailable (no active session).{_RST}")
|
||||
return
|
||||
|
||||
lower = arg.lower()
|
||||
|
||||
# Bare /goal or /goal status → show current state
|
||||
if not arg or lower == "status":
|
||||
_cprint(f" {mgr.status_line()}")
|
||||
return
|
||||
|
||||
if lower == "pause":
|
||||
state = mgr.pause(reason="user-paused")
|
||||
if state is None:
|
||||
_cprint(f" {_DIM}No goal set.{_RST}")
|
||||
else:
|
||||
_cprint(f" ⏸ Goal paused: {state.goal}")
|
||||
return
|
||||
|
||||
if lower == "resume":
|
||||
state = mgr.resume()
|
||||
if state is None:
|
||||
_cprint(f" {_DIM}No goal to resume.{_RST}")
|
||||
else:
|
||||
_cprint(f" ▶ Goal resumed: {state.goal}")
|
||||
_cprint(
|
||||
f" {_DIM}Send any message (or press Enter on an empty prompt "
|
||||
f"is a no-op; type 'continue' to kick it off).{_RST}"
|
||||
)
|
||||
return
|
||||
|
||||
if lower in ("clear", "stop", "done"):
|
||||
had = mgr.has_goal()
|
||||
mgr.clear()
|
||||
if had:
|
||||
_cprint(" ✓ Goal cleared.")
|
||||
else:
|
||||
_cprint(f" {_DIM}No active goal.{_RST}")
|
||||
return
|
||||
|
||||
# Otherwise treat the arg as the goal text.
|
||||
try:
|
||||
state = mgr.set(arg)
|
||||
except ValueError as exc:
|
||||
_cprint(f" Invalid goal: {exc}")
|
||||
return
|
||||
|
||||
_cprint(f" ⊙ Goal set ({state.max_turns}-turn budget): {state.goal}")
|
||||
_cprint(
|
||||
f" {_DIM}After each turn, a judge model will check if the goal is done. "
|
||||
f"Hermes keeps working until it is, you pause/clear it, or the budget is "
|
||||
f"exhausted. Use /goal status, /goal pause, /goal resume, /goal clear.{_RST}"
|
||||
)
|
||||
# Kick the loop off immediately so the user doesn't have to send a
|
||||
# separate message after setting the goal.
|
||||
try:
|
||||
self._pending_input.put(state.goal)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _maybe_continue_goal_after_turn(self) -> None:
|
||||
"""Hook run after every CLI turn. Judges + maybe re-queues.
|
||||
|
||||
Safe to call when no goal is set — returns quickly.
|
||||
|
||||
Preemption is automatic: if a real user message is already in
|
||||
``_pending_input`` we skip judging (the user's new input takes
|
||||
priority and we'll re-judge after that turn). If judge says done,
|
||||
mark it done and tell the user. If judge says continue and we're
|
||||
under budget, push the continuation prompt onto the queue.
|
||||
"""
|
||||
mgr = self._get_goal_manager()
|
||||
if mgr is None or not mgr.is_active():
|
||||
return
|
||||
|
||||
# If a real user message is already queued, don't inject a
|
||||
# continuation prompt on top — let the user's turn go first.
|
||||
try:
|
||||
if getattr(self, "_pending_input", None) is not None \
|
||||
and not self._pending_input.empty():
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Extract the agent's final response for this turn.
|
||||
last_response = ""
|
||||
try:
|
||||
hist = self.conversation_history or []
|
||||
for msg in reversed(hist):
|
||||
if msg.get("role") == "assistant":
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, list):
|
||||
# Multimodal content — flatten text parts.
|
||||
parts = [
|
||||
p.get("text", "")
|
||||
for p in content
|
||||
if isinstance(p, dict) and p.get("type") in ("text", "output_text")
|
||||
]
|
||||
last_response = "\n".join(t for t in parts if t)
|
||||
else:
|
||||
last_response = str(content or "")
|
||||
break
|
||||
except Exception:
|
||||
last_response = ""
|
||||
|
||||
decision = mgr.evaluate_after_turn(last_response, user_initiated=True)
|
||||
msg = decision.get("message") or ""
|
||||
if msg:
|
||||
_cprint(f" {msg}")
|
||||
|
||||
if decision.get("should_continue"):
|
||||
prompt = decision.get("continuation_prompt")
|
||||
if prompt:
|
||||
try:
|
||||
self._pending_input.put(prompt)
|
||||
except Exception as exc:
|
||||
logging.debug("goal continuation enqueue failed: %s", exc)
|
||||
|
||||
def _handle_skin_command(self, cmd: str):
|
||||
"""Handle /skin [name] — show or change the display skin."""
|
||||
try:
|
||||
|
|
@ -7138,7 +7328,7 @@ class HermesCLI:
|
|||
import os
|
||||
from hermes_cli.colors import Colors as _Colors
|
||||
|
||||
current = bool(os.environ.get("HERMES_YOLO_MODE"))
|
||||
current = is_truthy_value(os.environ.get("HERMES_YOLO_MODE"))
|
||||
if current:
|
||||
os.environ.pop("HERMES_YOLO_MODE", None)
|
||||
_cprint(
|
||||
|
|
@ -7335,10 +7525,20 @@ class HermesCLI:
|
|||
original_count = len(self.conversation_history)
|
||||
with self._busy_command("Compressing context..."):
|
||||
try:
|
||||
from agent.model_metadata import estimate_messages_tokens_rough
|
||||
from agent.model_metadata import estimate_request_tokens_rough
|
||||
from agent.manual_compression_feedback import summarize_manual_compression
|
||||
original_history = list(self.conversation_history)
|
||||
approx_tokens = estimate_messages_tokens_rough(original_history)
|
||||
# Include system prompt + tool schemas in the estimate —
|
||||
# a transcript-only number understates real request pressure
|
||||
# and can even appear to grow after compression because a
|
||||
# dense handoff summary replaces many short turns (#6217).
|
||||
_sys_prompt = getattr(self.agent, "_cached_system_prompt", "") or ""
|
||||
_tools = getattr(self.agent, "tools", None) or None
|
||||
approx_tokens = estimate_request_tokens_rough(
|
||||
original_history,
|
||||
system_prompt=_sys_prompt,
|
||||
tools=_tools,
|
||||
)
|
||||
if focus_topic:
|
||||
print(f"🗜️ Compressing {original_count} messages (~{approx_tokens:,} tokens), "
|
||||
f"focus: \"{focus_topic}\"...")
|
||||
|
|
@ -7370,7 +7570,11 @@ class HermesCLI:
|
|||
):
|
||||
self.session_id = self.agent.session_id
|
||||
self._pending_title = None
|
||||
new_tokens = estimate_messages_tokens_rough(self.conversation_history)
|
||||
new_tokens = estimate_request_tokens_rough(
|
||||
self.conversation_history,
|
||||
system_prompt=_sys_prompt,
|
||||
tools=_tools,
|
||||
)
|
||||
summary = summarize_manual_compression(
|
||||
original_history,
|
||||
self.conversation_history,
|
||||
|
|
@ -11336,6 +11540,17 @@ class HermesCLI:
|
|||
|
||||
app.invalidate() # Refresh status line
|
||||
|
||||
# Goal continuation: if a standing goal is active, ask
|
||||
# the judge whether the turn satisfied it. If not, and
|
||||
# there's no real user message already queued, push the
|
||||
# continuation prompt back into _pending_input so the
|
||||
# next loop iteration picks it up naturally (and any
|
||||
# user input that arrives in between still preempts).
|
||||
try:
|
||||
self._maybe_continue_goal_after_turn()
|
||||
except Exception as _goal_exc:
|
||||
logging.debug("goal continuation hook failed: %s", _goal_exc)
|
||||
|
||||
# Continuous voice: auto-restart recording after agent responds.
|
||||
# Dispatch to a daemon thread so play_beep (sd.wait) and
|
||||
# AudioRecorder.start (lock acquire) never block process_loop —
|
||||
|
|
|
|||
118
cron/jobs.py
118
cron/jobs.py
|
|
@ -882,3 +882,121 @@ def save_job_output(job_id: str, output: str):
|
|||
raise
|
||||
|
||||
return output_file
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Skill reference rewriting (curator integration)
|
||||
# =============================================================================
|
||||
|
||||
def rewrite_skill_refs(
|
||||
consolidated: Optional[Dict[str, str]] = None,
|
||||
pruned: Optional[List[str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Rewrite cron job skill references after a curator consolidation pass.
|
||||
|
||||
When the curator consolidates a skill X into umbrella Y (or archives X
|
||||
as pruned), any cron job that lists ``X`` in its ``skills`` field will
|
||||
fail to load ``X`` at run time — the scheduler logs a warning and
|
||||
skips the skill, so the job runs without the instructions it was
|
||||
scheduled to follow. See cron/scheduler.py where ``skill_view`` is
|
||||
called per skill name.
|
||||
|
||||
This function repairs cron jobs in-place:
|
||||
|
||||
- A skill listed in ``consolidated`` is replaced with its umbrella
|
||||
target (the ``into`` value). If the umbrella is already in the
|
||||
job's skill list, the stale name is dropped without duplication.
|
||||
- A skill listed in ``pruned`` is dropped outright — there is no
|
||||
forwarding target.
|
||||
- Ordering and other skills in the list are preserved.
|
||||
- The legacy ``skill`` field is realigned via ``_apply_skill_fields``.
|
||||
|
||||
Args:
|
||||
consolidated: mapping of ``old_skill_name -> umbrella_skill_name``.
|
||||
pruned: list of skill names that were archived with no forwarding
|
||||
target.
|
||||
|
||||
Returns a report dict::
|
||||
|
||||
{
|
||||
"rewrites": [
|
||||
{
|
||||
"job_id": ...,
|
||||
"job_name": ...,
|
||||
"before": [...],
|
||||
"after": [...],
|
||||
"mapped": {"old": "new", ...},
|
||||
"dropped": ["old", ...],
|
||||
},
|
||||
...
|
||||
],
|
||||
"jobs_updated": N,
|
||||
"jobs_scanned": M,
|
||||
}
|
||||
|
||||
Best-effort: exceptions from loading/saving propagate to the caller so
|
||||
tests can assert behaviour; the curator invocation site wraps this
|
||||
call in a try/except so a failure here never breaks the curator.
|
||||
"""
|
||||
consolidated = dict(consolidated or {})
|
||||
pruned_set = set(pruned or [])
|
||||
# A skill listed in both wins as "consolidated" — it has a target,
|
||||
# which is the more useful of the two outcomes.
|
||||
pruned_set -= set(consolidated.keys())
|
||||
|
||||
if not consolidated and not pruned_set:
|
||||
return {"rewrites": [], "jobs_updated": 0, "jobs_scanned": 0}
|
||||
|
||||
with _jobs_file_lock:
|
||||
jobs = load_jobs()
|
||||
rewrites: List[Dict[str, Any]] = []
|
||||
changed = False
|
||||
|
||||
for job in jobs:
|
||||
skills_before = _normalize_skill_list(job.get("skill"), job.get("skills"))
|
||||
if not skills_before:
|
||||
continue
|
||||
|
||||
mapped: Dict[str, str] = {}
|
||||
dropped: List[str] = []
|
||||
new_skills: List[str] = []
|
||||
|
||||
for name in skills_before:
|
||||
if name in consolidated:
|
||||
target = consolidated[name]
|
||||
mapped[name] = target
|
||||
if target and target not in new_skills:
|
||||
new_skills.append(target)
|
||||
elif name in pruned_set:
|
||||
dropped.append(name)
|
||||
else:
|
||||
if name not in new_skills:
|
||||
new_skills.append(name)
|
||||
|
||||
if not mapped and not dropped:
|
||||
continue
|
||||
|
||||
job["skills"] = new_skills
|
||||
job["skill"] = new_skills[0] if new_skills else None
|
||||
changed = True
|
||||
|
||||
rewrites.append({
|
||||
"job_id": job.get("id"),
|
||||
"job_name": job.get("name") or job.get("id"),
|
||||
"before": list(skills_before),
|
||||
"after": list(new_skills),
|
||||
"mapped": mapped,
|
||||
"dropped": dropped,
|
||||
})
|
||||
|
||||
if changed:
|
||||
save_jobs(jobs)
|
||||
logger.info(
|
||||
"Curator rewrote skill references in %d cron job(s)", len(rewrites)
|
||||
)
|
||||
|
||||
return {
|
||||
"rewrites": rewrites,
|
||||
"jobs_updated": len(rewrites),
|
||||
"jobs_scanned": len(jobs),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ services:
|
|||
# - TEAMS_CLIENT_SECRET=${TEAMS_CLIENT_SECRET}
|
||||
# - TEAMS_TENANT_ID=${TEAMS_TENANT_ID}
|
||||
# - TEAMS_ALLOWED_USERS=${TEAMS_ALLOWED_USERS}
|
||||
# - TEAMS_PORT=3978
|
||||
# - TEAMS_PORT=${TEAMS_PORT:-3978}
|
||||
command: ["gateway", "run"]
|
||||
|
||||
dashboard:
|
||||
|
|
|
|||
|
|
@ -36,6 +36,26 @@ def _coerce_bool(value: Any, default: bool = True) -> bool:
|
|||
return is_truthy_value(value, default=default)
|
||||
|
||||
|
||||
def _coerce_float(value: Any, default: float) -> float:
|
||||
"""Coerce numeric config values, falling back on malformed input."""
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _coerce_int(value: Any, default: int) -> int:
|
||||
"""Coerce integer config values, falling back on malformed input."""
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _normalize_unauthorized_dm_behavior(value: Any, default: str = "pair") -> str:
|
||||
"""Normalize unauthorized DM behavior to a supported value."""
|
||||
if isinstance(value, str):
|
||||
|
|
@ -45,6 +65,15 @@ def _normalize_unauthorized_dm_behavior(value: Any, default: str = "pair") -> st
|
|||
return default
|
||||
|
||||
|
||||
def _normalize_notice_delivery(value: Any, default: str = "public") -> str:
|
||||
"""Normalize notice delivery mode to a supported value."""
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
if normalized in {"public", "private"}:
|
||||
return normalized
|
||||
return default
|
||||
|
||||
|
||||
# Module-level cache for bundled platform plugin names (lives outside the
|
||||
# enum so it doesn't become an accidental enum member).
|
||||
_Platform__bundled_plugin_names: Optional[set] = None
|
||||
|
|
@ -301,13 +330,13 @@ class StreamingConfig:
|
|||
if not data:
|
||||
return cls()
|
||||
return cls(
|
||||
enabled=data.get("enabled", False),
|
||||
enabled=_coerce_bool(data.get("enabled"), False),
|
||||
transport=data.get("transport", "edit"),
|
||||
edit_interval=float(data.get("edit_interval", 1.0)),
|
||||
buffer_threshold=int(data.get("buffer_threshold", 40)),
|
||||
edit_interval=_coerce_float(data.get("edit_interval"), 1.0),
|
||||
buffer_threshold=_coerce_int(data.get("buffer_threshold"), 40),
|
||||
cursor=data.get("cursor", " ▉"),
|
||||
fresh_final_after_seconds=float(
|
||||
data.get("fresh_final_after_seconds", 60.0)
|
||||
fresh_final_after_seconds=_coerce_float(
|
||||
data.get("fresh_final_after_seconds"), 60.0
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -572,6 +601,17 @@ class GatewayConfig:
|
|||
)
|
||||
return self.unauthorized_dm_behavior
|
||||
|
||||
def get_notice_delivery(self, platform: Optional[Platform] = None) -> str:
|
||||
"""Return the effective notice-delivery mode for a platform."""
|
||||
if platform:
|
||||
platform_cfg = self.platforms.get(platform)
|
||||
if platform_cfg and "notice_delivery" in platform_cfg.extra:
|
||||
return _normalize_notice_delivery(
|
||||
platform_cfg.extra.get("notice_delivery"),
|
||||
"public",
|
||||
)
|
||||
return "public"
|
||||
|
||||
|
||||
def load_gateway_config() -> GatewayConfig:
|
||||
"""
|
||||
|
|
@ -687,6 +727,11 @@ def load_gateway_config() -> GatewayConfig:
|
|||
platform_cfg.get("unauthorized_dm_behavior"),
|
||||
gw_data.get("unauthorized_dm_behavior", "pair"),
|
||||
)
|
||||
if "notice_delivery" in platform_cfg:
|
||||
bridged["notice_delivery"] = _normalize_notice_delivery(
|
||||
platform_cfg.get("notice_delivery"),
|
||||
"public",
|
||||
)
|
||||
if "reply_prefix" in platform_cfg:
|
||||
bridged["reply_prefix"] = platform_cfg["reply_prefix"]
|
||||
if "reply_in_thread" in platform_cfg:
|
||||
|
|
@ -900,6 +945,12 @@ def load_gateway_config() -> GatewayConfig:
|
|||
if "dm_mention_threads" in matrix_cfg and not os.getenv("MATRIX_DM_MENTION_THREADS"):
|
||||
os.environ["MATRIX_DM_MENTION_THREADS"] = str(matrix_cfg["dm_mention_threads"]).lower()
|
||||
|
||||
# Feishu settings → env vars (env vars take precedence)
|
||||
feishu_cfg = yaml_cfg.get("feishu", {})
|
||||
if isinstance(feishu_cfg, dict):
|
||||
if "allow_bots" in feishu_cfg and not os.getenv("FEISHU_ALLOW_BOTS"):
|
||||
os.environ["FEISHU_ALLOW_BOTS"] = str(feishu_cfg["allow_bots"]).lower()
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to process config.yaml — falling back to .env / gateway.json values. "
|
||||
|
|
@ -1051,7 +1102,14 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
|||
if Platform.WHATSAPP not in config.platforms:
|
||||
config.platforms[Platform.WHATSAPP] = PlatformConfig()
|
||||
config.platforms[Platform.WHATSAPP].enabled = True
|
||||
|
||||
whatsapp_home = os.getenv("WHATSAPP_HOME_CHANNEL")
|
||||
if whatsapp_home and Platform.WHATSAPP in config.platforms:
|
||||
config.platforms[Platform.WHATSAPP].home_channel = HomeChannel(
|
||||
platform=Platform.WHATSAPP,
|
||||
chat_id=whatsapp_home,
|
||||
name=os.getenv("WHATSAPP_HOME_CHANNEL_NAME", "Home"),
|
||||
)
|
||||
|
||||
# Slack
|
||||
slack_token = os.getenv("SLACK_BOT_TOKEN")
|
||||
if slack_token:
|
||||
|
|
|
|||
|
|
@ -53,9 +53,10 @@ class DeliveryTarget:
|
|||
- "telegram" → Telegram home channel
|
||||
- "telegram:123456" → specific Telegram chat
|
||||
"""
|
||||
target = target.strip().lower()
|
||||
target_stripped = target.strip()
|
||||
target_lower = target_stripped.lower()
|
||||
|
||||
if target == "origin":
|
||||
if target_lower == "origin":
|
||||
if origin:
|
||||
return cls(
|
||||
platform=origin.platform,
|
||||
|
|
@ -67,13 +68,14 @@ class DeliveryTarget:
|
|||
# Fallback to local if no origin
|
||||
return cls(platform=Platform.LOCAL, is_origin=True)
|
||||
|
||||
if target == "local":
|
||||
if target_lower == "local":
|
||||
return cls(platform=Platform.LOCAL)
|
||||
|
||||
# Check for platform:chat_id or platform:chat_id:thread_id format
|
||||
if ":" in target:
|
||||
parts = target.split(":", 2)
|
||||
platform_str = parts[0]
|
||||
# Use the original case for chat_id/thread_id to preserve case-sensitive IDs
|
||||
if ":" in target_stripped:
|
||||
parts = target_stripped.split(":", 2)
|
||||
platform_str = parts[0].lower() # Platform names are case-insensitive
|
||||
chat_id = parts[1] if len(parts) > 1 else None
|
||||
thread_id = parts[2] if len(parts) > 2 else None
|
||||
try:
|
||||
|
|
@ -85,7 +87,7 @@ class DeliveryTarget:
|
|||
|
||||
# Just a platform name (use home channel)
|
||||
try:
|
||||
platform = Platform(target)
|
||||
platform = Platform(target_lower)
|
||||
return cls(platform=platform)
|
||||
except ValueError:
|
||||
# Unknown platform, treat as local
|
||||
|
|
|
|||
|
|
@ -2351,10 +2351,11 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||
)
|
||||
if agent_ref is not None:
|
||||
agent_ref[0] = agent
|
||||
effective_task_id = session_id or str(uuid.uuid4())
|
||||
result = agent.run_conversation(
|
||||
user_message=user_message,
|
||||
conversation_history=conversation_history,
|
||||
task_id="default",
|
||||
task_id=effective_task_id,
|
||||
)
|
||||
usage = {
|
||||
"input_tokens": getattr(agent, "session_prompt_tokens", 0) or 0,
|
||||
|
|
@ -2551,10 +2552,11 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||
)
|
||||
self._active_run_agents[run_id] = agent
|
||||
def _run_sync():
|
||||
effective_task_id = session_id or run_id
|
||||
r = agent.run_conversation(
|
||||
user_message=user_message,
|
||||
conversation_history=conversation_history,
|
||||
task_id="default",
|
||||
task_id=effective_task_id,
|
||||
)
|
||||
u = {
|
||||
"input_tokens": getattr(agent, "session_prompt_tokens", 0) or 0,
|
||||
|
|
|
|||
|
|
@ -416,7 +416,7 @@ def is_host_excluded_by_no_proxy(hostname: str, no_proxy_value: str | None = Non
|
|||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Any, Callable, Awaitable, Tuple
|
||||
from typing import Dict, List, Optional, Any, Callable, Awaitable, Tuple, Union
|
||||
from enum import Enum
|
||||
|
||||
from pathlib import Path as _Path
|
||||
|
|
@ -981,7 +981,7 @@ def coerce_plaintext_gateway_command(event: "MessageEvent") -> None:
|
|||
return
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass
|
||||
class SendResult:
|
||||
"""Result of sending a message."""
|
||||
success: bool
|
||||
|
|
@ -991,6 +991,45 @@ class SendResult:
|
|||
retryable: bool = False # True for transient connection errors — base will retry automatically
|
||||
|
||||
|
||||
class EphemeralReply(str):
|
||||
"""System-notice reply that auto-deletes after a TTL.
|
||||
|
||||
Slash-command handlers in ``gateway/run.py`` can return this wrapper
|
||||
instead of a plain string to request that the reply message be deleted
|
||||
after ``ttl_seconds`` on platforms that support ``delete_message``.
|
||||
|
||||
Subclassing ``str`` keeps the wrapper transparent to anything that
|
||||
treats handler return values as text (existing tests use ``in`` /
|
||||
``startswith`` / equality; the ``_process_message_background`` pipeline
|
||||
extracts attachments from the string content). ``isinstance(r,
|
||||
EphemeralReply)`` still distinguishes ephemeral replies from plain
|
||||
strings so the send path can schedule deletion.
|
||||
|
||||
Platforms that don't override :meth:`BasePlatformAdapter.delete_message`
|
||||
silently ignore the TTL — the message is sent normally and left in
|
||||
place. When ``ttl_seconds`` is ``None``, the pipeline uses the
|
||||
configured ``display.ephemeral_system_ttl`` default. A default of ``0``
|
||||
disables auto-deletion globally, preserving prior behavior.
|
||||
"""
|
||||
|
||||
ttl_seconds: Optional[int]
|
||||
|
||||
def __new__(cls, text: str, ttl_seconds: Optional[int] = None):
|
||||
instance = super().__new__(cls, text)
|
||||
instance.ttl_seconds = ttl_seconds
|
||||
return instance
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
"""Return the underlying text.
|
||||
|
||||
Provided for call sites that want an explicit string conversion,
|
||||
though ``str(reply)`` and using ``reply`` directly where a string
|
||||
is expected both work identically.
|
||||
"""
|
||||
return str.__str__(self)
|
||||
|
||||
|
||||
def merge_pending_message_event(
|
||||
pending_messages: Dict[str, MessageEvent],
|
||||
session_key: str,
|
||||
|
|
@ -1034,6 +1073,11 @@ def merge_pending_message_event(
|
|||
existing.text = event.text
|
||||
if existing_is_photo or incoming_is_photo:
|
||||
existing.message_type = MessageType.PHOTO
|
||||
elif (
|
||||
getattr(existing, "message_type", None) == MessageType.TEXT
|
||||
and event.message_type != MessageType.TEXT
|
||||
):
|
||||
existing.message_type = event.message_type
|
||||
return
|
||||
|
||||
if (
|
||||
|
|
@ -1068,8 +1112,10 @@ _RETRYABLE_ERROR_PATTERNS = (
|
|||
)
|
||||
|
||||
|
||||
# Type for message handlers
|
||||
MessageHandler = Callable[[MessageEvent], Awaitable[Optional[str]]]
|
||||
# Type for message handlers. Handlers may return a plain string (normal
|
||||
# reply), an ``EphemeralReply`` to opt the reply into auto-deletion, or
|
||||
# ``None`` when the response was already delivered (e.g. via streaming).
|
||||
MessageHandler = Callable[[MessageEvent], Awaitable[Optional[Union[str, "EphemeralReply"]]]]
|
||||
|
||||
|
||||
def resolve_channel_prompt(
|
||||
|
|
@ -1454,6 +1500,64 @@ class BasePlatformAdapter(ABC):
|
|||
"""
|
||||
return False
|
||||
|
||||
def _get_ephemeral_system_ttl_default(self) -> int:
|
||||
"""Read ``display.ephemeral_system_ttl`` from config.
|
||||
|
||||
Returns the TTL in seconds to use when an :class:`EphemeralReply`
|
||||
does not specify one explicitly. ``0`` (the default) disables
|
||||
auto-deletion. Non-fatal if config is unreadable.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import load_config as _load_config
|
||||
except Exception:
|
||||
return 0
|
||||
try:
|
||||
cfg = _load_config()
|
||||
except Exception:
|
||||
return 0
|
||||
display = cfg.get("display", {}) if isinstance(cfg, dict) else {}
|
||||
if not isinstance(display, dict):
|
||||
return 0
|
||||
raw = display.get("ephemeral_system_ttl", 0)
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
def _schedule_ephemeral_delete(
|
||||
self,
|
||||
chat_id: str,
|
||||
message_id: str,
|
||||
ttl_seconds: int,
|
||||
) -> None:
|
||||
"""Spawn a detached task that deletes ``message_id`` after ``ttl_seconds``.
|
||||
|
||||
Best-effort — failures (gateway restart, permission denied, message
|
||||
too old for Telegram's 48h window) are swallowed at debug level.
|
||||
Does not block the caller.
|
||||
"""
|
||||
|
||||
async def _run_delete() -> None:
|
||||
try:
|
||||
await asyncio.sleep(max(1, int(ttl_seconds)))
|
||||
await self.delete_message(chat_id=chat_id, message_id=message_id)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"[%s] Ephemeral delete failed for %s/%s: %s",
|
||||
self.name, chat_id, message_id, e,
|
||||
)
|
||||
|
||||
coro = _run_delete()
|
||||
try:
|
||||
asyncio.create_task(coro)
|
||||
except RuntimeError:
|
||||
# No running loop (e.g. unit tests that never reach the async
|
||||
# path). Close the coroutine cleanly so Python doesn't warn
|
||||
# about it never being awaited, then drop silently.
|
||||
coro.close()
|
||||
|
||||
async def send_slash_confirm(
|
||||
self,
|
||||
chat_id: str,
|
||||
|
|
@ -1489,6 +1593,26 @@ class BasePlatformAdapter(ABC):
|
|||
"""
|
||||
return SendResult(success=False, error="Not supported")
|
||||
|
||||
async def send_private_notice(
|
||||
self,
|
||||
chat_id: str,
|
||||
user_id: Optional[str],
|
||||
content: str,
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send a notice privately when the platform supports it.
|
||||
|
||||
The default implementation falls back to a normal send so callers can
|
||||
use one code path across platforms.
|
||||
"""
|
||||
return await self.send(
|
||||
chat_id=chat_id,
|
||||
content=content,
|
||||
reply_to=reply_to,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
async def send_typing(self, chat_id: str, metadata=None) -> None:
|
||||
"""
|
||||
Send a typing indicator.
|
||||
|
|
@ -2043,6 +2167,28 @@ class BasePlatformAdapter(ABC):
|
|||
lowered = error.lower()
|
||||
return "timed out" in lowered or "readtimeout" in lowered or "writetimeout" in lowered
|
||||
|
||||
def _unwrap_ephemeral(self, response: Any) -> Tuple[Optional[str], int]:
|
||||
"""Unwrap a handler response into (text, ttl_seconds).
|
||||
|
||||
Accepts a plain string, ``None``, or an :class:`EphemeralReply`.
|
||||
Returns ``(text, ttl)`` where ``ttl > 0`` means the caller should
|
||||
schedule a deletion via :meth:`_schedule_ephemeral_delete` after
|
||||
the send succeeds. ``ttl`` is forced to 0 when the adapter
|
||||
doesn't override :meth:`delete_message` so non-supporting
|
||||
platforms silently degrade to normal sends.
|
||||
"""
|
||||
if isinstance(response, EphemeralReply):
|
||||
ttl = response.ttl_seconds
|
||||
if ttl is None:
|
||||
try:
|
||||
ttl = int(self._get_ephemeral_system_ttl_default())
|
||||
except Exception:
|
||||
ttl = 0
|
||||
if ttl and ttl > 0 and type(self).delete_message is BasePlatformAdapter.delete_message:
|
||||
ttl = 0
|
||||
return response.text, int(ttl or 0)
|
||||
return response, 0
|
||||
|
||||
async def _send_with_retry(
|
||||
self,
|
||||
chat_id: str,
|
||||
|
|
@ -2350,13 +2496,20 @@ class BasePlatformAdapter(ABC):
|
|||
release_guard=False,
|
||||
discard_pending=False,
|
||||
)
|
||||
if response:
|
||||
await self._send_with_retry(
|
||||
_text, _eph_ttl = self._unwrap_ephemeral(response)
|
||||
if _text:
|
||||
_r = await self._send_with_retry(
|
||||
chat_id=event.source.chat_id,
|
||||
content=response,
|
||||
content=_text,
|
||||
reply_to=event.message_id,
|
||||
metadata=thread_meta,
|
||||
)
|
||||
if _eph_ttl > 0 and _r.success and _r.message_id:
|
||||
self._schedule_ephemeral_delete(
|
||||
chat_id=event.source.chat_id,
|
||||
message_id=_r.message_id,
|
||||
ttl_seconds=_eph_ttl,
|
||||
)
|
||||
except Exception:
|
||||
# On failure, restore the original guard if one still exists so
|
||||
# we don't leave the session in a half-reset state.
|
||||
|
|
@ -2436,13 +2589,20 @@ class BasePlatformAdapter(ABC):
|
|||
try:
|
||||
_thread_meta = {"thread_id": event.source.thread_id} if event.source.thread_id else None
|
||||
response = await self._message_handler(event)
|
||||
if response:
|
||||
await self._send_with_retry(
|
||||
_text, _eph_ttl = self._unwrap_ephemeral(response)
|
||||
if _text:
|
||||
_r = await self._send_with_retry(
|
||||
chat_id=event.source.chat_id,
|
||||
content=response,
|
||||
content=_text,
|
||||
reply_to=event.message_id,
|
||||
metadata=_thread_meta,
|
||||
)
|
||||
if _eph_ttl > 0 and _r.success and _r.message_id:
|
||||
self._schedule_ephemeral_delete(
|
||||
chat_id=event.source.chat_id,
|
||||
message_id=_r.message_id,
|
||||
ttl_seconds=_eph_ttl,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("[%s] Command '/%s' dispatch failed: %s", self.name, cmd, e, exc_info=True)
|
||||
return
|
||||
|
|
@ -2516,7 +2676,6 @@ class BasePlatformAdapter(ABC):
|
|||
# Fall back to a new Event only if the entry was removed externally.
|
||||
interrupt_event = self._active_sessions.get(session_key) or asyncio.Event()
|
||||
self._active_sessions[session_key] = interrupt_event
|
||||
callback_generation = getattr(interrupt_event, "_hermes_run_generation", None)
|
||||
|
||||
# Start continuous typing indicator (refreshes every 2 seconds)
|
||||
_thread_metadata = {"thread_id": event.source.thread_id} if event.source.thread_id else None
|
||||
|
|
@ -2549,7 +2708,16 @@ class BasePlatformAdapter(ABC):
|
|||
|
||||
# Call the handler (this can take a while with tool calls)
|
||||
response = await self._message_handler(event)
|
||||
|
||||
|
||||
# Slash-command handlers may return an EphemeralReply sentinel to
|
||||
# request that their reply message auto-delete after a TTL (used
|
||||
# for system notices like "✨ New session started!" that the user
|
||||
# doesn't need to keep in the thread). Unwrap here so all the
|
||||
# downstream extract_media / text-processing logic sees a plain
|
||||
# string, and remember the TTL + platform capability so the
|
||||
# post-send block can schedule the deletion.
|
||||
response, _ephemeral_ttl = self._unwrap_ephemeral(response)
|
||||
|
||||
# Send response if any. A None/empty response is normal when
|
||||
# streaming already delivered the text (already_sent=True) or
|
||||
# when the message was queued behind an active agent. Log at
|
||||
|
|
@ -2638,6 +2806,21 @@ class BasePlatformAdapter(ABC):
|
|||
)
|
||||
_record_delivery(result)
|
||||
|
||||
# Schedule auto-deletion of system-notice replies.
|
||||
# Detached so the handler returns immediately; errors
|
||||
# (permission denied, message too old) are swallowed.
|
||||
if (
|
||||
_ephemeral_ttl
|
||||
and _ephemeral_ttl > 0
|
||||
and result.success
|
||||
and result.message_id
|
||||
):
|
||||
self._schedule_ephemeral_delete(
|
||||
chat_id=event.source.chat_id,
|
||||
message_id=result.message_id,
|
||||
ttl_seconds=_ephemeral_ttl,
|
||||
)
|
||||
|
||||
# Human-like pacing delay between text and media
|
||||
human_delay = self._get_human_delay()
|
||||
|
||||
|
|
@ -2815,7 +2998,20 @@ class BasePlatformAdapter(ABC):
|
|||
finally:
|
||||
# Fire any one-shot post-delivery callback registered for this
|
||||
# session (e.g. deferred background-review notifications).
|
||||
_callback_generation = callback_generation
|
||||
#
|
||||
# Snapshot the callback generation HERE (after the agent has run),
|
||||
# not at the top of this task. _hermes_run_generation is set on
|
||||
# the interrupt event by GatewayRunner._bind_adapter_run_generation
|
||||
# during _handle_message_with_agent — which happens DURING the
|
||||
# self._message_handler(event) await above. Snapshotting earlier
|
||||
# always captured None, which bypassed the generation-ownership
|
||||
# check in pop_post_delivery_callback and let stale runs fire a
|
||||
# fresher run's callbacks.
|
||||
_callback_generation = getattr(
|
||||
interrupt_event,
|
||||
"_hermes_run_generation",
|
||||
None,
|
||||
)
|
||||
if hasattr(self, "pop_post_delivery_callback"):
|
||||
_post_cb = self.pop_post_delivery_callback(
|
||||
session_key,
|
||||
|
|
|
|||
|
|
@ -2851,8 +2851,15 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
raw = os.getenv("DISCORD_FREE_RESPONSE_CHANNELS", "")
|
||||
if isinstance(raw, list):
|
||||
return {str(part).strip() for part in raw if str(part).strip()}
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
return {part.strip() for part in raw.split(",") if part.strip()}
|
||||
# Coerce non-list scalars (str/int/float) to str before splitting.
|
||||
# YAML parses a bare numeric value such as
|
||||
# `free_response_channels: 1491973769726791812` as int, which was
|
||||
# previously falling through the isinstance(str) branch and silently
|
||||
# returning an empty set. str() here accepts whatever scalar the YAML
|
||||
# loader hands us without changing existing string/CSV semantics.
|
||||
s = str(raw).strip() if raw is not None else ""
|
||||
if s:
|
||||
return {part.strip() for part in s.split(",") if part.strip()}
|
||||
return set()
|
||||
|
||||
def _thread_parent_channel(self, channel: Any) -> Any:
|
||||
|
|
@ -3078,6 +3085,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
async def send_update_prompt(
|
||||
self, chat_id: str, prompt: str, default: str = "",
|
||||
session_key: str = "",
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send an interactive button-based update prompt (Yes / No).
|
||||
|
||||
|
|
@ -3087,9 +3095,10 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
if not self._client or not DISCORD_AVAILABLE:
|
||||
return SendResult(success=False, error="Not connected")
|
||||
try:
|
||||
channel = self._client.get_channel(int(chat_id))
|
||||
target_id = metadata.get("thread_id") if metadata and metadata.get("thread_id") else chat_id
|
||||
channel = self._client.get_channel(int(target_id))
|
||||
if not channel:
|
||||
channel = await self._client.fetch_channel(int(chat_id))
|
||||
channel = await self._client.fetch_channel(int(target_id))
|
||||
|
||||
default_hint = f" (default: {default})" if default else ""
|
||||
embed = discord.Embed(
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ from dataclasses import dataclass, field
|
|||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Dict, List, Optional, Sequence
|
||||
from typing import Any, Dict, List, Literal, Optional, Sequence
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import Request, urlopen
|
||||
|
|
@ -141,6 +141,7 @@ from gateway.platforms.base import (
|
|||
)
|
||||
from gateway.status import acquire_scoped_lock, release_scoped_lock
|
||||
from hermes_constants import get_hermes_home
|
||||
from utils import atomic_json_write
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -387,6 +388,8 @@ class FeishuAdapterSettings:
|
|||
admins: frozenset[str] = frozenset()
|
||||
default_group_policy: str = ""
|
||||
group_rules: Dict[str, FeishuGroupRule] = field(default_factory=dict)
|
||||
allow_bots: str = "none" # "none" | "mentions" | "all"
|
||||
require_mention: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -396,6 +399,7 @@ class FeishuGroupRule:
|
|||
policy: str # "open" | "allowlist" | "blacklist" | "admin_only" | "disabled"
|
||||
allowlist: set[str] = field(default_factory=set)
|
||||
blacklist: set[str] = field(default_factory=set)
|
||||
require_mention: Optional[bool] = None # None = inherit global
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -405,6 +409,40 @@ class FeishuBatchState:
|
|||
counts: Dict[str, int] = field(default_factory=dict)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admission: policy types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
RejectReason = Literal[
|
||||
"self_echo",
|
||||
"self_ids_unknown",
|
||||
"bots_disabled",
|
||||
"bot_not_mentioned",
|
||||
"group_policy_rejected",
|
||||
]
|
||||
|
||||
|
||||
def _is_bot_sender(sender: Any) -> bool:
|
||||
# receive_v1 docs say {user, bot}; accept "app" defensively.
|
||||
return getattr(sender, "sender_type", "") in ("bot", "app")
|
||||
|
||||
|
||||
def _sender_identity(sender: Any) -> frozenset:
|
||||
# Take any non-empty id variant — tenant sender_id_type decides which are populated.
|
||||
sid = getattr(sender, "sender_id", None)
|
||||
if sid is None:
|
||||
return frozenset()
|
||||
return frozenset(
|
||||
v for v in (
|
||||
getattr(sid, "open_id", None),
|
||||
getattr(sid, "user_id", None),
|
||||
getattr(sid, "union_id", None),
|
||||
)
|
||||
if v
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Markdown rendering helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -1377,10 +1415,16 @@ class FeishuAdapter(BasePlatformAdapter):
|
|||
for chat_id, rule_cfg in raw_group_rules.items():
|
||||
if not isinstance(rule_cfg, dict):
|
||||
continue
|
||||
# Only override when the key is explicitly set — missing vs false
|
||||
# must not collapse.
|
||||
per_chat_require_mention: Optional[bool] = None
|
||||
if "require_mention" in rule_cfg:
|
||||
per_chat_require_mention = _to_boolean(rule_cfg.get("require_mention"))
|
||||
group_rules[str(chat_id)] = FeishuGroupRule(
|
||||
policy=str(rule_cfg.get("policy", "open")).strip().lower(),
|
||||
allowlist=set(str(u).strip() for u in rule_cfg.get("allowlist", []) if str(u).strip()),
|
||||
blacklist=set(str(u).strip() for u in rule_cfg.get("blacklist", []) if str(u).strip()),
|
||||
require_mention=per_chat_require_mention,
|
||||
)
|
||||
|
||||
# Bot-level admins
|
||||
|
|
@ -1390,6 +1434,16 @@ class FeishuAdapter(BasePlatformAdapter):
|
|||
# Default group policy (for groups not in group_rules)
|
||||
default_group_policy = str(extra.get("default_group_policy", "")).strip().lower()
|
||||
|
||||
# Env-only so adapter and gateway auth bypass share one source; yaml
|
||||
# feishu.allow_bots is bridged to this env var at config load.
|
||||
allow_bots = os.getenv("FEISHU_ALLOW_BOTS", "none").strip().lower()
|
||||
if allow_bots not in ("none", "mentions", "all"):
|
||||
logger.warning(
|
||||
"[Feishu] Unknown allow_bots=%r, falling back to 'none'. Valid: none, mentions, all.",
|
||||
allow_bots,
|
||||
)
|
||||
allow_bots = "none"
|
||||
|
||||
return FeishuAdapterSettings(
|
||||
app_id=str(extra.get("app_id") or os.getenv("FEISHU_APP_ID", "")).strip(),
|
||||
app_secret=str(extra.get("app_secret") or os.getenv("FEISHU_APP_SECRET", "")).strip(),
|
||||
|
|
@ -1446,6 +1500,10 @@ class FeishuAdapter(BasePlatformAdapter):
|
|||
admins=admins,
|
||||
default_group_policy=default_group_policy,
|
||||
group_rules=group_rules,
|
||||
allow_bots=allow_bots,
|
||||
require_mention=_to_boolean(
|
||||
extra.get("require_mention", os.getenv("FEISHU_REQUIRE_MENTION", "true"))
|
||||
),
|
||||
)
|
||||
|
||||
def _apply_settings(self, settings: FeishuAdapterSettings) -> None:
|
||||
|
|
@ -1476,6 +1534,8 @@ class FeishuAdapter(BasePlatformAdapter):
|
|||
self._ws_reconnect_interval = settings.ws_reconnect_interval
|
||||
self._ws_ping_interval = settings.ws_ping_interval
|
||||
self._ws_ping_timeout = settings.ws_ping_timeout
|
||||
self._allow_bots = settings.allow_bots
|
||||
self._require_mention = settings.require_mention
|
||||
|
||||
def _build_event_handler(self) -> Any:
|
||||
if EventDispatcherHandler is None:
|
||||
|
|
@ -2189,30 +2249,28 @@ class FeishuAdapter(BasePlatformAdapter):
|
|||
event = getattr(data, "event", None)
|
||||
message = getattr(event, "message", None)
|
||||
sender = getattr(event, "sender", None)
|
||||
sender_id = getattr(sender, "sender_id", None)
|
||||
if not message or not sender_id:
|
||||
logger.debug("[Feishu] Dropping malformed inbound event: missing message or sender_id")
|
||||
if not message or not sender or not getattr(sender, "sender_id", None):
|
||||
logger.debug("[Feishu] Dropping malformed inbound event: missing message/sender")
|
||||
return
|
||||
|
||||
message_id = getattr(message, "message_id", None)
|
||||
if not message_id or self._is_duplicate(message_id):
|
||||
logger.debug("[Feishu] Dropping duplicate/missing message_id: %s", message_id)
|
||||
return
|
||||
if self._is_self_sent_bot_message(event):
|
||||
logger.debug("[Feishu] Dropping self-sent bot event: %s", message_id)
|
||||
|
||||
reason = self._admit(sender, message)
|
||||
if reason is not None:
|
||||
logger.debug("[Feishu] dropping inbound event: %s", reason)
|
||||
return
|
||||
|
||||
chat_type = getattr(message, "chat_type", "p2p")
|
||||
chat_id = getattr(message, "chat_id", "") or ""
|
||||
if chat_type != "p2p" and not self._should_accept_group_message(message, sender_id, chat_id):
|
||||
logger.debug("[Feishu] Dropping group message that failed mention/policy gate: %s", message_id)
|
||||
return
|
||||
await self._process_inbound_message(
|
||||
data=data,
|
||||
message=message,
|
||||
sender_id=sender_id,
|
||||
sender_id=getattr(sender, "sender_id", None),
|
||||
chat_type=chat_type,
|
||||
message_id=message_id,
|
||||
is_bot=_is_bot_sender(sender),
|
||||
)
|
||||
|
||||
def _on_message_read_event(self, data: P2ImMessageMessageReadV1) -> None:
|
||||
|
|
@ -2389,10 +2447,11 @@ class FeishuAdapter(BasePlatformAdapter):
|
|||
msg = items[0] if items else None
|
||||
if not msg:
|
||||
return
|
||||
# GET im/v1/messages returns sender.id=app_id for bot messages —
|
||||
# peer bots and us share sender_type="app" but differ on app_id.
|
||||
sender = getattr(msg, "sender", None)
|
||||
sender_type = str(getattr(sender, "sender_type", "") or "").lower()
|
||||
if sender_type != "app":
|
||||
return # only route reactions on our own bot messages
|
||||
if str(getattr(sender, "id", "") or "") != self._app_id:
|
||||
return # only route reactions on this bot's own messages
|
||||
chat_id = str(getattr(msg, "chat_id", "") or "")
|
||||
chat_type_raw = str(getattr(msg, "chat_type", "p2p") or "p2p")
|
||||
if not chat_id:
|
||||
|
|
@ -2679,6 +2738,7 @@ class FeishuAdapter(BasePlatformAdapter):
|
|||
sender_id: Any,
|
||||
chat_type: str,
|
||||
message_id: str,
|
||||
is_bot: bool = False,
|
||||
) -> None:
|
||||
text, inbound_type, media_urls, media_types, mentions = await self._extract_message_content(message)
|
||||
|
||||
|
|
@ -2704,19 +2764,27 @@ class FeishuAdapter(BasePlatformAdapter):
|
|||
)
|
||||
reply_to_text = await self._fetch_message_text(reply_to_message_id) if reply_to_message_id else None
|
||||
|
||||
sender_primary = (
|
||||
getattr(sender_id, "open_id", None)
|
||||
or getattr(sender_id, "user_id", None)
|
||||
or getattr(sender_id, "union_id", None)
|
||||
or "<unknown>"
|
||||
)
|
||||
logger.info(
|
||||
"[Feishu] Inbound %s message received: id=%s type=%s chat_id=%s text=%r media=%d",
|
||||
"[Feishu] Inbound %s message received: id=%s type=%s chat_id=%s sender=%s:%s text=%r media=%d",
|
||||
"dm" if chat_type == "p2p" else "group",
|
||||
message_id,
|
||||
inbound_type.value,
|
||||
getattr(message, "chat_id", "") or "",
|
||||
"bot" if is_bot else "user",
|
||||
sender_primary,
|
||||
text[:120],
|
||||
len(media_urls),
|
||||
)
|
||||
|
||||
chat_id = getattr(message, "chat_id", "") or ""
|
||||
chat_info = await self.get_chat_info(chat_id)
|
||||
sender_profile = await self._resolve_sender_profile(sender_id)
|
||||
sender_profile = await self._resolve_sender_profile(sender_id, is_bot=is_bot)
|
||||
source = self.build_source(
|
||||
chat_id=chat_id,
|
||||
chat_name=chat_info.get("name") or chat_id or "Feishu Chat",
|
||||
|
|
@ -2725,6 +2793,7 @@ class FeishuAdapter(BasePlatformAdapter):
|
|||
user_name=sender_profile["user_name"],
|
||||
thread_id=getattr(message, "thread_id", None) or None,
|
||||
user_id_alt=sender_profile["user_id_alt"],
|
||||
is_bot=is_bot,
|
||||
)
|
||||
normalized = MessageEvent(
|
||||
text=text,
|
||||
|
|
@ -3447,7 +3516,12 @@ class FeishuAdapter(BasePlatformAdapter):
|
|||
return "dm"
|
||||
return "group"
|
||||
|
||||
async def _resolve_sender_profile(self, sender_id: Any) -> Dict[str, Optional[str]]:
|
||||
async def _resolve_sender_profile(
|
||||
self,
|
||||
sender_id: Any,
|
||||
*,
|
||||
is_bot: bool = False,
|
||||
) -> Dict[str, Optional[str]]:
|
||||
"""Map Feishu's three-tier user IDs onto Hermes' SessionSource fields.
|
||||
|
||||
Preference order for the primary ``user_id`` field:
|
||||
|
|
@ -3464,7 +3538,11 @@ class FeishuAdapter(BasePlatformAdapter):
|
|||
union_id = getattr(sender_id, "union_id", None) or None
|
||||
# Prefer tenant-scoped user_id; fall back to app-scoped open_id.
|
||||
primary_id = user_id or open_id
|
||||
display_name = await self._resolve_sender_name_from_api(primary_id or union_id)
|
||||
# bot/v3/bots/basic_batch only accepts open_id.
|
||||
name_lookup_id = open_id if is_bot else (primary_id or union_id)
|
||||
display_name = await self._resolve_sender_name_from_api(
|
||||
name_lookup_id, is_bot=is_bot,
|
||||
)
|
||||
return {
|
||||
"user_id": primary_id,
|
||||
"user_name": display_name,
|
||||
|
|
@ -3484,11 +3562,14 @@ class FeishuAdapter(BasePlatformAdapter):
|
|||
self._sender_name_cache.pop(sender_id, None)
|
||||
return None
|
||||
|
||||
async def _resolve_sender_name_from_api(self, sender_id: Optional[str]) -> Optional[str]:
|
||||
"""Fetch the sender's display name from the Feishu contact API with a 10-minute cache.
|
||||
|
||||
ID-type detection mirrors openclaw: ou_ → open_id, on_ → union_id, else user_id.
|
||||
Failures are silently suppressed; the message pipeline must not block on name resolution.
|
||||
async def _resolve_sender_name_from_api(
|
||||
self,
|
||||
sender_id: Optional[str],
|
||||
*,
|
||||
is_bot: bool = False,
|
||||
) -> Optional[str]:
|
||||
"""Bots divert to bot/basic_batch — contact API doesn't return bot names.
|
||||
Failures are silent so the pipeline never blocks on name resolution.
|
||||
"""
|
||||
if not sender_id or not self._client:
|
||||
return None
|
||||
|
|
@ -3498,7 +3579,16 @@ class FeishuAdapter(BasePlatformAdapter):
|
|||
now = time.time()
|
||||
cached_name = self._get_cached_sender_name(trimmed)
|
||||
if cached_name is not None:
|
||||
return cached_name
|
||||
return cached_name or None # "" cached means "known nameless"
|
||||
if is_bot:
|
||||
names = await self._fetch_bot_names([trimmed])
|
||||
if names is None:
|
||||
return None
|
||||
expire_at = now + _FEISHU_SENDER_NAME_TTL_SECONDS
|
||||
for oid, name in names.items():
|
||||
self._sender_name_cache[oid] = (name, expire_at)
|
||||
hit = self._sender_name_cache.get(trimmed)
|
||||
return (hit[0] or None) if hit else None
|
||||
try:
|
||||
from lark_oapi.api.contact.v3 import GetUserRequest # lazy import
|
||||
if trimmed.startswith("ou_"):
|
||||
|
|
@ -3527,6 +3617,35 @@ class FeishuAdapter(BasePlatformAdapter):
|
|||
logger.debug("[Feishu] Failed to resolve sender name for %s", sender_id, exc_info=True)
|
||||
return None
|
||||
|
||||
async def _fetch_bot_names(self, bot_ids: List[str]) -> Optional[Dict[str, str]]:
|
||||
if not self._client or not bot_ids:
|
||||
return None
|
||||
try:
|
||||
req = (
|
||||
BaseRequest.builder()
|
||||
.http_method(HttpMethod.GET)
|
||||
.uri("/open-apis/bot/v3/bots/basic_batch")
|
||||
.queries([("bot_ids", oid) for oid in bot_ids])
|
||||
.token_types({AccessTokenType.TENANT})
|
||||
.build()
|
||||
)
|
||||
resp = await asyncio.to_thread(self._client.request, req)
|
||||
content = getattr(getattr(resp, "raw", None), "content", None)
|
||||
if not content:
|
||||
return None
|
||||
payload = json.loads(content)
|
||||
if payload.get("code") != 0:
|
||||
return None
|
||||
bots = (payload.get("data") or {}).get("bots") or {}
|
||||
return {
|
||||
oid: str(info.get("name") or "").strip()
|
||||
for oid, info in bots.items()
|
||||
if oid
|
||||
}
|
||||
except Exception:
|
||||
logger.debug("[Feishu] Failed to fetch bot names for %s", bot_ids, exc_info=True)
|
||||
return None
|
||||
|
||||
async def _fetch_message_text(self, message_id: str) -> Optional[str]:
|
||||
if not self._client or not message_id:
|
||||
return None
|
||||
|
|
@ -3590,10 +3709,60 @@ class FeishuAdapter(BasePlatformAdapter):
|
|||
logger.exception("[Feishu] Background inbound processing failed")
|
||||
|
||||
# =========================================================================
|
||||
# Group policy and mention gating
|
||||
# Inbound admission
|
||||
# =========================================================================
|
||||
|
||||
def _allow_group_message(self, sender_id: Any, chat_id: str = "") -> bool:
|
||||
def _admit(self, sender: Any, message: Any) -> Optional[RejectReason]:
|
||||
sender_ids = _sender_identity(sender)
|
||||
self_ids = frozenset(v for v in (self._bot_open_id, self._bot_user_id) if v)
|
||||
is_bot = _is_bot_sender(sender)
|
||||
is_group = getattr(message, "chat_type", "p2p") != "p2p"
|
||||
chat_id = getattr(message, "chat_id", "") or ""
|
||||
require_mention = is_group and self._require_mention_for(chat_id)
|
||||
|
||||
# Defensive only — Feishu doesn't echo our outbound back as inbound,
|
||||
# and open_id is always populated on both sides.
|
||||
if self_ids and sender_ids & self_ids:
|
||||
return "self_echo"
|
||||
|
||||
if is_bot:
|
||||
mode = self._allow_bots
|
||||
if mode != "mentions" and mode != "all":
|
||||
return "bots_disabled"
|
||||
# Defensive: pre-hydration or malformed payloads.
|
||||
if not self_ids or not sender_ids:
|
||||
return "self_ids_unknown"
|
||||
# Step 4 covers mention enforcement for groups when require_mention
|
||||
# is on; check here only on paths step 4 won't reach.
|
||||
if mode == "mentions" and not require_mention and not self._mentions_self(message):
|
||||
return "bot_not_mentioned"
|
||||
|
||||
if not is_group:
|
||||
return None
|
||||
|
||||
if not self._allow_group_message(
|
||||
getattr(sender, "sender_id", None), chat_id, is_bot=is_bot,
|
||||
):
|
||||
return "group_policy_rejected"
|
||||
if require_mention and not self._mentions_self(message):
|
||||
return "group_policy_rejected"
|
||||
return None
|
||||
|
||||
def _require_mention_for(self, chat_id: str) -> bool:
|
||||
rule = self._group_rules.get(chat_id) if chat_id else None
|
||||
if rule and rule.require_mention is not None:
|
||||
return rule.require_mention
|
||||
return self._require_mention
|
||||
|
||||
# --- Group policy ---------------------------------------------------------
|
||||
|
||||
def _allow_group_message(
|
||||
self,
|
||||
sender_id: Any,
|
||||
chat_id: str = "",
|
||||
*,
|
||||
is_bot: bool = False,
|
||||
) -> bool:
|
||||
"""Per-group policy gate for non-DM traffic."""
|
||||
sender_open_id = getattr(sender_id, "open_id", None)
|
||||
sender_user_id = getattr(sender_id, "user_id", None)
|
||||
|
|
@ -3612,12 +3781,17 @@ class FeishuAdapter(BasePlatformAdapter):
|
|||
allowlist = self._allowed_group_users
|
||||
blacklist = set()
|
||||
|
||||
# Channel locks apply to everyone; allowlist/blacklist only gate humans
|
||||
# (bots were already cleared upstream by FEISHU_ALLOW_BOTS).
|
||||
if policy == "disabled":
|
||||
return False
|
||||
if policy == "open":
|
||||
return True
|
||||
if policy == "admin_only":
|
||||
return False
|
||||
if is_bot:
|
||||
return True
|
||||
|
||||
if policy == "allowlist":
|
||||
return bool(sender_ids and (sender_ids & allowlist))
|
||||
if policy == "blacklist":
|
||||
|
|
@ -3625,17 +3799,16 @@ class FeishuAdapter(BasePlatformAdapter):
|
|||
|
||||
return bool(sender_ids and (sender_ids & self._allowed_group_users))
|
||||
|
||||
def _should_accept_group_message(self, message: Any, sender_id: Any, chat_id: str = "") -> bool:
|
||||
"""Require an explicit @mention before group messages enter the agent."""
|
||||
if not self._allow_group_message(sender_id, chat_id):
|
||||
return False
|
||||
# @_all is Feishu's @everyone placeholder — always route to the bot.
|
||||
# --- Mention detection ----------------------------------------------------
|
||||
|
||||
def _mentions_self(self, message: Any) -> bool:
|
||||
# @_all is Feishu's @everyone placeholder.
|
||||
raw_content = getattr(message, "content", "") or ""
|
||||
if "@_all" in raw_content:
|
||||
return True
|
||||
mentions = getattr(message, "mentions", None) or []
|
||||
if mentions:
|
||||
return self._message_mentions_bot(mentions)
|
||||
if mentions and self._message_mentions_bot(mentions):
|
||||
return True
|
||||
normalized = normalize_feishu_message(
|
||||
message_type=getattr(message, "message_type", "") or "",
|
||||
raw_content=raw_content,
|
||||
|
|
@ -3644,23 +3817,6 @@ class FeishuAdapter(BasePlatformAdapter):
|
|||
)
|
||||
return self._post_mentions_bot(normalized.mentions)
|
||||
|
||||
def _is_self_sent_bot_message(self, event: Any) -> bool:
|
||||
"""Return True only for Feishu events emitted by this Hermes bot."""
|
||||
sender = getattr(event, "sender", None)
|
||||
sender_type = str(getattr(sender, "sender_type", "") or "").strip().lower()
|
||||
if sender_type not in {"bot", "app"}:
|
||||
return False
|
||||
|
||||
sender_id = getattr(sender, "sender_id", None)
|
||||
sender_open_id = str(getattr(sender_id, "open_id", "") or "").strip()
|
||||
sender_user_id = str(getattr(sender_id, "user_id", "") or "").strip()
|
||||
|
||||
if self._bot_open_id and sender_open_id == self._bot_open_id:
|
||||
return True
|
||||
if self._bot_user_id and sender_user_id == self._bot_user_id:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _message_mentions_bot(self, mentions: List[Any]) -> bool:
|
||||
# IDs trump names: when both sides have open_id (or both user_id),
|
||||
# match requires equal IDs. Name fallback only when either side
|
||||
|
|
@ -3804,7 +3960,7 @@ class FeishuAdapter(BasePlatformAdapter):
|
|||
recent = self._seen_message_order[-self._dedup_cache_size:]
|
||||
# Save as {msg_id: timestamp} so TTL filtering works across restarts.
|
||||
payload = {"message_ids": {k: self._seen_message_ids[k] for k in recent if k in self._seen_message_ids}}
|
||||
self._dedup_state_path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
||||
atomic_json_write(self._dedup_state_path, payload, indent=None)
|
||||
except OSError:
|
||||
logger.warning("[Feishu] Failed to persist dedup state to %s", self._dedup_state_path, exc_info=True)
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import time
|
|||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Dict
|
||||
|
||||
from utils import atomic_json_write
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from gateway.platforms.base import MessageEvent
|
||||
|
||||
|
|
@ -237,12 +239,11 @@ class ThreadParticipationTracker:
|
|||
|
||||
def _save(self) -> None:
|
||||
path = self._state_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
thread_list = list(self._threads)
|
||||
if len(thread_list) > self._max_tracked:
|
||||
thread_list = thread_list[-self._max_tracked:]
|
||||
self._threads = set(thread_list)
|
||||
path.write_text(json.dumps(thread_list), encoding="utf-8")
|
||||
atomic_json_write(path, thread_list, indent=None)
|
||||
|
||||
def mark(self, thread_id: str) -> None:
|
||||
"""Mark *thread_id* as participated and persist."""
|
||||
|
|
|
|||
|
|
@ -534,6 +534,18 @@ class SignalAdapter(BasePlatformAdapter):
|
|||
except Exception:
|
||||
logger.exception("Signal: failed to fetch attachment %s", att_id)
|
||||
|
||||
# Skip envelopes with no meaningful content (no text, no attachments).
|
||||
# Catches profile key updates, empty messages, and other metadata-only
|
||||
# envelopes that still carry a dataMessage wrapper but have nothing
|
||||
# worth processing. See issue: signal-cli logs "Profile key update" +
|
||||
# Hermes receives msg='' triggering a full agent turn for nothing.
|
||||
if (not text or not text.strip()) and not media_urls:
|
||||
logger.debug(
|
||||
"Signal: skipping contentless envelope from %s (%d attachments)",
|
||||
redact_phone(sender), len(media_urls) if media_urls else 0,
|
||||
)
|
||||
return
|
||||
|
||||
# Build session source
|
||||
source = self.build_source(
|
||||
chat_id=chat_id,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ Uses slack-bolt (Python) with Socket Mode for:
|
|||
"""
|
||||
|
||||
import asyncio
|
||||
import contextvars
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
|
@ -21,6 +22,7 @@ try:
|
|||
from slack_bolt.async_app import AsyncApp
|
||||
from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler
|
||||
from slack_sdk.web.async_client import AsyncWebClient
|
||||
import aiohttp
|
||||
SLACK_AVAILABLE = True
|
||||
except ImportError:
|
||||
SLACK_AVAILABLE = False
|
||||
|
|
@ -50,6 +52,16 @@ from gateway.platforms.base import (
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ContextVar carrying the user_id of the slash-command invoker.
|
||||
# Set in _handle_slash_command, read in send() to match the correct
|
||||
# stashed response_url when multiple users issue commands on the same
|
||||
# channel concurrently. ContextVars propagate to child asyncio.Tasks
|
||||
# (Python 3.7+), so the value set in _handle_slash_command's task is
|
||||
# visible in _process_message_background's child task.
|
||||
_slash_user_id: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
|
||||
"_slash_user_id", default=None,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ThreadContextCache:
|
||||
|
|
@ -310,6 +322,11 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
# Track active assistant thread status indicators so stop_typing can
|
||||
# clear them (chat_id → thread_ts).
|
||||
self._active_status_threads: Dict[str, str] = {}
|
||||
# Slash-command contexts: stash response_url + user_id so send()
|
||||
# can route the first reply ephemerally. Keyed by
|
||||
# (channel_id, user_id) to avoid cross-user collisions.
|
||||
# Each value: {"response_url": str, "ts": float}
|
||||
self._slash_command_contexts: Dict[Tuple[str, str], Dict[str, Any]] = {}
|
||||
|
||||
def _describe_slack_api_error(self, response: Any, *, file_obj: Optional[Dict[str, Any]] = None) -> Optional[str]:
|
||||
"""Convert Slack API auth/permission failures into actionable user-facing text."""
|
||||
|
|
@ -368,6 +385,103 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
)
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Slash-command ephemeral helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
_SLASH_CTX_TTL = 120.0 # seconds — response_url is valid for 30 min;
|
||||
# we use a much shorter TTL to avoid routing unrelated messages
|
||||
# as ephemeral if the command handler was slow or dropped.
|
||||
|
||||
def _pop_slash_context(
|
||||
self, chat_id: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Return and remove the slash-command context for *chat_id*, if fresh.
|
||||
|
||||
Contexts older than ``_SLASH_CTX_TTL`` seconds are silently discarded.
|
||||
|
||||
Uses the ``_slash_user_id`` ContextVar (set in ``_handle_slash_command``)
|
||||
to match the exact ``(channel_id, user_id)`` key. This prevents a
|
||||
concurrent slash command from a different user on the same channel from
|
||||
stealing another user's ephemeral context. Falls back to a
|
||||
channel-only scan when the ContextVar is unset (e.g. send() called
|
||||
from a non-slash code path — should not match anything).
|
||||
"""
|
||||
now = time.monotonic()
|
||||
# Clean up stale entries on every lookup — dict is small.
|
||||
stale_keys = [
|
||||
k for k, v in self._slash_command_contexts.items()
|
||||
if now - v["ts"] > self._SLASH_CTX_TTL
|
||||
]
|
||||
for k in stale_keys:
|
||||
self._slash_command_contexts.pop(k, None)
|
||||
|
||||
# Precise match: (channel_id, user_id) from ContextVar.
|
||||
uid = _slash_user_id.get()
|
||||
if uid:
|
||||
return self._slash_command_contexts.pop((chat_id, uid), None)
|
||||
|
||||
# Fallback: channel-only scan (only reachable when ContextVar is
|
||||
# unset, i.e. send() called outside a slash-command async context).
|
||||
match_key = None
|
||||
for key in list(self._slash_command_contexts):
|
||||
if key[0] == chat_id:
|
||||
match_key = key
|
||||
break
|
||||
if match_key is None:
|
||||
return None
|
||||
return self._slash_command_contexts.pop(match_key)
|
||||
|
||||
async def _send_slash_ephemeral(
|
||||
self,
|
||||
ctx: Dict[str, Any],
|
||||
content: str,
|
||||
) -> "SendResult":
|
||||
"""Replace the initial ephemeral ack via ``response_url``.
|
||||
|
||||
Slack's ``response_url`` accepts a POST with ``replace_original``
|
||||
for up to 30 minutes after the slash command was invoked. This
|
||||
lets us swap the "Running /cmd…" placeholder with the real reply,
|
||||
and the message stays ephemeral ("Only visible to you").
|
||||
|
||||
Falls back to a simple ``True`` SendResult if the POST fails —
|
||||
the user already saw the initial ack, so a delivery failure here
|
||||
is non-critical.
|
||||
"""
|
||||
formatted = self.format_message(content)
|
||||
# Slack's response_url has the same ~40k char limit as chat_postMessage.
|
||||
# Truncate to MAX_MESSAGE_LENGTH and use only the first chunk — the
|
||||
# response_url replaces a single ephemeral ack, so multi-chunk isn't
|
||||
# possible. Long responses are rare for command replies.
|
||||
chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH)
|
||||
text = chunks[0] if chunks else formatted
|
||||
payload = {
|
||||
"response_type": "ephemeral",
|
||||
"replace_original": True,
|
||||
"text": text,
|
||||
}
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
ctx["response_url"],
|
||||
json=payload,
|
||||
timeout=aiohttp.ClientTimeout(total=10),
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
return SendResult(success=True, message_id=None)
|
||||
body = await resp.text()
|
||||
logger.warning(
|
||||
"[Slack] response_url POST returned %s: %s",
|
||||
resp.status,
|
||||
body[:200],
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"[Slack] response_url POST failed: %s", e,
|
||||
)
|
||||
# Non-fatal — the user saw the initial ack already.
|
||||
return SendResult(success=True, message_id=None)
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""Connect to Slack via Socket Mode."""
|
||||
if not SLACK_AVAILABLE:
|
||||
|
|
@ -446,12 +560,16 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
async def handle_message_event(event, say):
|
||||
await self._handle_slack_message(event)
|
||||
|
||||
# Acknowledge app_mention events to prevent Bolt 404 errors.
|
||||
# The "message" handler above already processes @mentions in
|
||||
# channels, so this is intentionally a no-op to avoid duplicates.
|
||||
# Handle app_mention explicitly. In some Slack app configurations,
|
||||
# channel mentions arrive only as app_mention events rather than the
|
||||
# generic message event. Forward them into the normal message
|
||||
# pipeline so @mentions reliably produce replies.
|
||||
# NOTE: when Slack fires BOTH message and app_mention for the same
|
||||
# @mention, they share the same event ts — the dedup in
|
||||
# _handle_slack_message (MessageDeduplicator) suppresses the second.
|
||||
@self._app.event("app_mention")
|
||||
async def handle_app_mention(event, say):
|
||||
pass
|
||||
await self._handle_slack_message(event)
|
||||
|
||||
# File lifecycle events can arrive around snippet uploads even when
|
||||
# the actual user message is what we care about. Ack them so Slack
|
||||
|
|
@ -502,7 +620,11 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
|
||||
@self._app.command(_slash_pattern)
|
||||
async def handle_hermes_command(ack, command):
|
||||
await ack()
|
||||
slash = (command.get("command") or "").lstrip("/")
|
||||
await ack(
|
||||
response_type="ephemeral",
|
||||
text=f"Running `/{slash}`…",
|
||||
)
|
||||
await self._handle_slash_command(command)
|
||||
|
||||
# Register Block Kit action handlers for approval buttons
|
||||
|
|
@ -574,6 +696,17 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
return SendResult(success=False, error="Not connected")
|
||||
|
||||
try:
|
||||
# Check for a pending slash-command context. When the user ran a
|
||||
# native slash command (e.g. /q, /stop, /model), the initial ack
|
||||
# already showed an ephemeral "Running /cmd…" message. If we have
|
||||
# a stashed response_url for this channel, replace that ack with
|
||||
# the actual command reply ephemerally instead of posting publicly.
|
||||
slash_ctx = self._pop_slash_context(chat_id)
|
||||
if slash_ctx:
|
||||
return await self._send_slash_ephemeral(
|
||||
slash_ctx, content,
|
||||
)
|
||||
|
||||
# Convert standard markdown → Slack mrkdwn
|
||||
formatted = self.format_message(content)
|
||||
|
||||
|
|
@ -601,6 +734,10 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
|
||||
last_result = await self._get_client(chat_id).chat_postMessage(**kwargs)
|
||||
|
||||
# Clear Slack Assistant status as soon as the final message is posted.
|
||||
if thread_ts:
|
||||
await self.stop_typing(chat_id)
|
||||
|
||||
# Track the sent message ts so we can auto-respond to thread
|
||||
# replies without requiring @mention.
|
||||
sent_ts = last_result.get("ts") if last_result else None
|
||||
|
|
@ -624,6 +761,42 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
logger.error("[Slack] Send error: %s", e, exc_info=True)
|
||||
return SendResult(success=False, error=str(e))
|
||||
|
||||
async def send_private_notice(
|
||||
self,
|
||||
chat_id: str,
|
||||
user_id: str,
|
||||
content: str,
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send a Slack ephemeral message visible only to one user."""
|
||||
if not self._app:
|
||||
return SendResult(success=False, error="Not connected")
|
||||
if not chat_id or not user_id:
|
||||
return SendResult(success=False, error="chat_id and user_id are required")
|
||||
|
||||
try:
|
||||
formatted = self.format_message(content)
|
||||
thread_ts = self._resolve_thread_ts(reply_to, metadata)
|
||||
kwargs = {
|
||||
"channel": chat_id,
|
||||
"user": user_id,
|
||||
"text": formatted,
|
||||
"mrkdwn": True,
|
||||
}
|
||||
if thread_ts:
|
||||
kwargs["thread_ts"] = thread_ts
|
||||
|
||||
result = await self._get_client(chat_id).chat_postEphemeral(**kwargs)
|
||||
return SendResult(
|
||||
success=True,
|
||||
message_id=result.get("message_ts") or result.get("ts"),
|
||||
raw_response=result,
|
||||
)
|
||||
except Exception as e: # pragma: no cover - defensive logging
|
||||
logger.error("[Slack] Ephemeral send error: %s", e, exc_info=True)
|
||||
return SendResult(success=False, error=str(e))
|
||||
|
||||
async def edit_message(
|
||||
self,
|
||||
chat_id: str,
|
||||
|
|
@ -642,6 +815,8 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
ts=message_id,
|
||||
text=formatted,
|
||||
)
|
||||
if finalize:
|
||||
await self.stop_typing(chat_id)
|
||||
return SendResult(success=True, message_id=message_id)
|
||||
except Exception as e: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
|
|
@ -682,7 +857,7 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
# in an assistant-enabled context. Falls back to reactions.
|
||||
logger.debug("[Slack] assistant.threads.setStatus failed: %s", e)
|
||||
|
||||
async def stop_typing(self, chat_id: str) -> None:
|
||||
async def stop_typing(self, chat_id: str, metadata=None) -> None:
|
||||
"""Clear the assistant thread status indicator."""
|
||||
if not self._app:
|
||||
return
|
||||
|
|
@ -969,7 +1144,7 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
return _ph(f'<{url}|{label}>')
|
||||
|
||||
text = re.sub(
|
||||
r'\[([^\]]+)\]\(([^()]*(?:\([^()]*\)[^()]*)*)\)',
|
||||
r'(?<!!)\[([^\]]+)\]\(([^()]*(?:\([^()]*\)[^()]*)*)\)',
|
||||
_convert_markdown_link,
|
||||
text,
|
||||
)
|
||||
|
|
@ -1016,9 +1191,11 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
)
|
||||
|
||||
# 10) Convert italic: _text_ stays as _text_ (already Slack italic)
|
||||
# Single *text* → _text_ (Slack italic)
|
||||
# Single *text* → _text_ (Slack italic), but only when the
|
||||
# emphasized text touches non-whitespace on both sides so literal
|
||||
# delimiters like "a * b * c" are preserved.
|
||||
text = re.sub(
|
||||
r'(?<!\*)\*([^*\n]+)\*(?!\*)',
|
||||
r'(?<!\*)\*(\S(?:[^*\n]*?\S)?)\*(?!\*)',
|
||||
lambda m: _ph(f'_{m.group(1)}_'),
|
||||
text,
|
||||
)
|
||||
|
|
@ -2524,9 +2701,14 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
# gateway command dispatcher by prepending the slash.
|
||||
text = f"/{slash_name} {text}".strip()
|
||||
|
||||
# Slack slash commands can originate from DMs or shared channels.
|
||||
# Preserve DM semantics only for DM channel IDs; shared channels must
|
||||
# keep group semantics so different users do not collide into one
|
||||
# session key.
|
||||
is_dm = str(channel_id).startswith("D")
|
||||
source = self.build_source(
|
||||
chat_id=channel_id,
|
||||
chat_type="dm", # Slash commands are always in DM-like context
|
||||
chat_type="dm" if is_dm else "group",
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
|
|
@ -2537,7 +2719,26 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
raw_message=command,
|
||||
)
|
||||
|
||||
await self.handle_message(event)
|
||||
# Stash the Slack response_url so the first reply for this
|
||||
# channel+user can be routed ephemerally (replaces the initial
|
||||
# "Running /cmd…" ack shown by handle_hermes_command).
|
||||
# Only stash for COMMAND events (text starts with "/") — free-form
|
||||
# questions via "/hermes <question>" must produce public replies so
|
||||
# the whole channel can see the agent's answer.
|
||||
response_url = command.get("response_url", "")
|
||||
if response_url and user_id and channel_id and text.startswith("/"):
|
||||
self._slash_command_contexts[(channel_id, user_id)] = {
|
||||
"response_url": response_url,
|
||||
"ts": time.monotonic(),
|
||||
}
|
||||
|
||||
# Set the ContextVar so send() can match the correct stashed
|
||||
# response_url even when multiple users slash concurrently.
|
||||
_slash_user_id_token = _slash_user_id.set(user_id or None)
|
||||
try:
|
||||
await self.handle_message(event)
|
||||
finally:
|
||||
_slash_user_id.reset(_slash_user_id_token)
|
||||
|
||||
def _has_active_session_for_thread(
|
||||
self,
|
||||
|
|
@ -2698,6 +2899,13 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
raw = os.getenv("SLACK_FREE_RESPONSE_CHANNELS", "")
|
||||
if isinstance(raw, list):
|
||||
return {str(part).strip() for part in raw if str(part).strip()}
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
return {part.strip() for part in raw.split(",") if part.strip()}
|
||||
# Coerce non-list scalars (str/int/float) to str before splitting.
|
||||
# A bare numeric YAML value (`free_response_channels: 1234567890`) is
|
||||
# loaded as int and was previously falling through the isinstance(str)
|
||||
# branch to return an empty set. str() here accepts whatever scalar
|
||||
# the YAML loader hands us without changing existing string/CSV
|
||||
# semantics.
|
||||
s = str(raw).strip() if raw is not None else ""
|
||||
if s:
|
||||
return {part.strip() for part in s.split(",") if part.strip()}
|
||||
return set()
|
||||
|
|
|
|||
|
|
@ -290,14 +290,53 @@ class TelegramAdapter(BasePlatformAdapter):
|
|||
# and any other slash-confirm prompts; see GatewayRunner._request_slash_confirm).
|
||||
self._slash_confirm_state: Dict[str, str] = {}
|
||||
|
||||
@staticmethod
|
||||
def _is_callback_user_authorized(user_id: str) -> bool:
|
||||
def _is_callback_user_authorized(
|
||||
self,
|
||||
user_id: str,
|
||||
*,
|
||||
chat_id: Optional[str] = None,
|
||||
chat_type: Optional[str] = None,
|
||||
thread_id: Optional[str] = None,
|
||||
user_name: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Return whether a Telegram inline-button caller may perform gated actions."""
|
||||
normalized_user_id = str(user_id or "").strip()
|
||||
if not normalized_user_id:
|
||||
return False
|
||||
|
||||
runner = getattr(getattr(self, "_message_handler", None), "__self__", None)
|
||||
auth_fn = getattr(runner, "_is_user_authorized", None)
|
||||
if callable(auth_fn):
|
||||
try:
|
||||
from gateway.session import SessionSource
|
||||
|
||||
normalized_chat_type = str(chat_type or "dm").strip().lower() or "dm"
|
||||
if normalized_chat_type == "private":
|
||||
normalized_chat_type = "dm"
|
||||
elif normalized_chat_type == "supergroup":
|
||||
normalized_chat_type = "forum" if thread_id is not None else "group"
|
||||
|
||||
source = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id=str(chat_id or normalized_user_id),
|
||||
chat_type=normalized_chat_type,
|
||||
user_id=normalized_user_id,
|
||||
user_name=str(user_name).strip() if user_name else None,
|
||||
thread_id=str(thread_id) if thread_id is not None else None,
|
||||
)
|
||||
return bool(auth_fn(source))
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"[Telegram] Falling back to env-only callback auth for user %s",
|
||||
normalized_user_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
allowed_csv = os.getenv("TELEGRAM_ALLOWED_USERS", "").strip()
|
||||
if not allowed_csv:
|
||||
return True
|
||||
allowed_ids = {uid.strip() for uid in allowed_csv.split(",") if uid.strip()}
|
||||
return "*" in allowed_ids or user_id in allowed_ids
|
||||
return "*" in allowed_ids or normalized_user_id in allowed_ids
|
||||
|
||||
@classmethod
|
||||
def _metadata_thread_id(cls, metadata: Optional[Dict[str, Any]]) -> Optional[str]:
|
||||
|
|
@ -722,6 +761,20 @@ class TelegramAdapter(BasePlatformAdapter):
|
|||
# Persist thread_id to config so we don't recreate on next restart
|
||||
self._persist_dm_topic_thread_id(int(chat_id), topic_name, thread_id)
|
||||
|
||||
# Send a seed message so the topic is visible in Telegram's client.
|
||||
# Empty topics are hidden by the client UI until they contain a message.
|
||||
try:
|
||||
await self._bot.send_message(
|
||||
chat_id=int(chat_id),
|
||||
message_thread_id=thread_id,
|
||||
text=f"\U0001f4cc {topic_name}",
|
||||
)
|
||||
except Exception as seed_err:
|
||||
logger.debug(
|
||||
"[%s] Could not send seed message to topic '%s': %s",
|
||||
self.name, topic_name, seed_err,
|
||||
)
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""Connect to Telegram via polling or webhook.
|
||||
|
||||
|
|
@ -1321,6 +1374,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
|||
async def send_update_prompt(
|
||||
self, chat_id: str, prompt: str, default: str = "",
|
||||
session_key: str = "",
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send an inline-keyboard update prompt (Yes / No buttons).
|
||||
|
||||
|
|
@ -1338,11 +1392,14 @@ class TelegramAdapter(BasePlatformAdapter):
|
|||
InlineKeyboardButton("✗ No", callback_data="update_prompt:n"),
|
||||
]
|
||||
])
|
||||
thread_id = self._metadata_thread_id(metadata)
|
||||
message_thread_id = self._message_thread_id_for_send(thread_id)
|
||||
msg = await self._bot.send_message(
|
||||
chat_id=int(chat_id),
|
||||
text=text,
|
||||
parse_mode=ParseMode.MARKDOWN,
|
||||
reply_markup=keyboard,
|
||||
message_thread_id=message_thread_id,
|
||||
**self._link_preview_kwargs(),
|
||||
)
|
||||
return SendResult(success=True, message_id=str(msg.message_id))
|
||||
|
|
@ -1760,6 +1817,12 @@ class TelegramAdapter(BasePlatformAdapter):
|
|||
if not query or not query.data:
|
||||
return
|
||||
data = query.data
|
||||
query_message = getattr(query, "message", None)
|
||||
query_chat_id = getattr(query_message, "chat_id", None)
|
||||
query_chat = getattr(query_message, "chat", None)
|
||||
query_chat_type = getattr(query_chat, "type", None)
|
||||
query_thread_id = getattr(query_message, "message_thread_id", None)
|
||||
query_user_name = getattr(query.from_user, "first_name", None)
|
||||
|
||||
# --- Model picker callbacks ---
|
||||
if data.startswith(("mp:", "mm:", "mb", "mx", "mg:")):
|
||||
|
|
@ -1781,7 +1844,13 @@ class TelegramAdapter(BasePlatformAdapter):
|
|||
|
||||
# Only authorized users may click approval buttons.
|
||||
caller_id = str(getattr(query.from_user, "id", ""))
|
||||
if not self._is_callback_user_authorized(caller_id):
|
||||
if not self._is_callback_user_authorized(
|
||||
caller_id,
|
||||
chat_id=query_chat_id,
|
||||
chat_type=str(query_chat_type) if query_chat_type is not None else None,
|
||||
thread_id=str(query_thread_id) if query_thread_id is not None else None,
|
||||
user_name=query_user_name,
|
||||
):
|
||||
await query.answer(text="⛔ You are not authorized to approve commands.")
|
||||
return
|
||||
|
||||
|
|
@ -1831,8 +1900,14 @@ class TelegramAdapter(BasePlatformAdapter):
|
|||
choice = parts[1] # once, always, cancel
|
||||
confirm_id = parts[2]
|
||||
|
||||
caller_id = str(getattr(query.from_user, "id", ""))
|
||||
if not self._is_callback_user_authorized(caller_id):
|
||||
caller_id = str(getattr(query.from_user, "id", ""))
|
||||
if not self._is_callback_user_authorized(
|
||||
caller_id,
|
||||
chat_id=query_chat_id,
|
||||
chat_type=str(query_chat_type) if query_chat_type is not None else None,
|
||||
thread_id=str(query_thread_id) if query_thread_id is not None else None,
|
||||
user_name=query_user_name,
|
||||
):
|
||||
await query.answer(text="⛔ You are not authorized to answer this prompt.")
|
||||
return
|
||||
|
||||
|
|
@ -1891,7 +1966,13 @@ class TelegramAdapter(BasePlatformAdapter):
|
|||
return
|
||||
answer = data.split(":", 1)[1] # "y" or "n"
|
||||
caller_id = str(getattr(query.from_user, "id", ""))
|
||||
if not self._is_callback_user_authorized(caller_id):
|
||||
if not self._is_callback_user_authorized(
|
||||
caller_id,
|
||||
chat_id=query_chat_id,
|
||||
chat_type=str(query_chat_type) if query_chat_type is not None else None,
|
||||
thread_id=str(query_thread_id) if query_thread_id is not None else None,
|
||||
user_name=query_user_name,
|
||||
):
|
||||
await query.answer(text="⛔ You are not authorized to answer update prompts.")
|
||||
return
|
||||
await query.answer(text=f"Sent '{answer}' to the update process.")
|
||||
|
|
|
|||
|
|
@ -1896,10 +1896,12 @@ class OwnerCommandMiddleware(InboundMiddleware):
|
|||
if cmd not in cls.ALLOWLIST:
|
||||
return None, None, False
|
||||
|
||||
# Sender identity check: bot owner <-> push.from_account == push.bot_owner_id
|
||||
# owner_id = (push or {}).get("bot_owner_id") or ""
|
||||
# is_owner = bool(owner_id) and owner_id == from_account
|
||||
is_owner = True
|
||||
# Sender identity check: bot owner <-> push.from_account == push.bot_owner_id.
|
||||
# The allowlisted commands (/approve, /deny, /stop, /reset, ...) are
|
||||
# privileged — leaking them to non-owners lets any group member approve
|
||||
# a dangerous tool call, kill the owner's task, or wipe session state.
|
||||
owner_id = str((push or {}).get("bot_owner_id") or "").strip()
|
||||
is_owner = bool(owner_id) and owner_id == from_account
|
||||
return cmd, cmd_line, is_owner
|
||||
|
||||
async def handle(self, ctx: InboundContext, next_fn) -> None:
|
||||
|
|
|
|||
681
gateway/run.py
681
gateway/run.py
File diff suppressed because it is too large
Load diff
|
|
@ -458,6 +458,15 @@ class SessionEntry:
|
|||
was_auto_reset: bool = False
|
||||
auto_reset_reason: Optional[str] = None # "idle" or "daily"
|
||||
reset_had_activity: bool = False # whether the expired session had any messages
|
||||
|
||||
# Set by reset_session() when the user explicitly sends /new or /reset.
|
||||
# Consumed once by _handle_message_with_agent to trigger topic/channel
|
||||
# skill re-injection on the first message of the new session. We can't
|
||||
# reuse was_auto_reset for this because that flag fires the "session
|
||||
# expired due to inactivity" user-facing notice and a misleading
|
||||
# context-note prepend — both wrong for an explicit manual reset.
|
||||
# See issue #6508.
|
||||
is_fresh_reset: bool = False
|
||||
|
||||
# Set by the background expiry watcher after it finalizes an expired
|
||||
# session (invoking on_session_finalize hooks and evicting the cached
|
||||
|
|
@ -508,6 +517,7 @@ class SessionEntry:
|
|||
if self.last_resume_marked_at
|
||||
else None
|
||||
),
|
||||
"is_fresh_reset": self.is_fresh_reset,
|
||||
}
|
||||
if self.origin:
|
||||
result["origin"] = self.origin.to_dict()
|
||||
|
|
@ -556,6 +566,7 @@ class SessionEntry:
|
|||
resume_pending=data.get("resume_pending", False),
|
||||
resume_reason=data.get("resume_reason"),
|
||||
last_resume_marked_at=last_resume_marked_at,
|
||||
is_fresh_reset=data.get("is_fresh_reset", False),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1132,6 +1143,7 @@ class SessionStore:
|
|||
display_name=old_entry.display_name,
|
||||
platform=old_entry.platform,
|
||||
chat_type=old_entry.chat_type,
|
||||
is_fresh_reset=True,
|
||||
)
|
||||
|
||||
self._entries[session_key] = new_entry
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ from datetime import datetime, timezone
|
|||
from pathlib import Path
|
||||
from hermes_constants import get_hermes_home
|
||||
from typing import Any, Optional
|
||||
from utils import atomic_json_write
|
||||
|
||||
if sys.platform == "win32":
|
||||
import msvcrt
|
||||
|
|
@ -34,6 +35,10 @@ _IS_WINDOWS = sys.platform == "win32"
|
|||
_UNSET = object()
|
||||
_GATEWAY_LOCK_FILENAME = "gateway.lock"
|
||||
_gateway_lock_handle = None
|
||||
# Windows byte-range locks are mandatory for other readers. Lock a byte well
|
||||
# past the JSON payload so runtime status / PID readers can still read the file
|
||||
# while another process holds the mutual-exclusion lock.
|
||||
_WINDOWS_LOCK_OFFSET = 1024 * 1024
|
||||
|
||||
|
||||
def _get_pid_path() -> Path:
|
||||
|
|
@ -205,8 +210,7 @@ def _read_json_file(path: Path) -> Optional[dict[str, Any]]:
|
|||
|
||||
|
||||
def _write_json_file(path: Path, payload: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(payload))
|
||||
atomic_json_write(path, payload, indent=None, separators=(",", ":"))
|
||||
|
||||
|
||||
def _read_pid_record(pid_path: Optional[Path] = None) -> Optional[dict]:
|
||||
|
|
@ -286,7 +290,7 @@ def _try_acquire_file_lock(handle) -> bool:
|
|||
if handle.tell() == 0:
|
||||
handle.write("\n")
|
||||
handle.flush()
|
||||
handle.seek(0)
|
||||
handle.seek(_WINDOWS_LOCK_OFFSET)
|
||||
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
|
||||
else:
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
|
|
@ -298,7 +302,7 @@ def _try_acquire_file_lock(handle) -> bool:
|
|||
def _release_file_lock(handle) -> None:
|
||||
try:
|
||||
if _IS_WINDOWS:
|
||||
handle.seek(0)
|
||||
handle.seek(_WINDOWS_LOCK_OFFSET)
|
||||
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
||||
else:
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ import yaml
|
|||
|
||||
from hermes_cli.config import get_hermes_home, get_config_path, read_raw_config
|
||||
from hermes_constants import OPENROUTER_BASE_URL
|
||||
from utils import atomic_replace
|
||||
from utils import atomic_replace, atomic_yaml_write, is_truthy_value
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -2480,8 +2480,8 @@ def _resolve_verify(
|
|||
tls_state = tls_state if isinstance(tls_state, dict) else {}
|
||||
|
||||
effective_insecure = (
|
||||
bool(insecure) if insecure is not None
|
||||
else bool(tls_state.get("insecure", False))
|
||||
is_truthy_value(insecure, default=False) if insecure is not None
|
||||
else is_truthy_value(tls_state.get("insecure", False), default=False)
|
||||
)
|
||||
effective_ca = (
|
||||
ca_bundle
|
||||
|
|
@ -3653,7 +3653,7 @@ def _update_config_for_provider(
|
|||
|
||||
config["model"] = model_cfg
|
||||
|
||||
config_path.write_text(yaml.safe_dump(config, sort_keys=False))
|
||||
atomic_yaml_write(config_path, config, sort_keys=False)
|
||||
return config_path
|
||||
|
||||
|
||||
|
|
@ -3712,7 +3712,7 @@ def _reset_config_provider() -> Path:
|
|||
model["provider"] = "auto"
|
||||
if "base_url" in model:
|
||||
model["base_url"] = OPENROUTER_BASE_URL
|
||||
config_path.write_text(yaml.safe_dump(config, sort_keys=False))
|
||||
atomic_yaml_write(config_path, config, sort_keys=False)
|
||||
return config_path
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ from collections.abc import Callable, Mapping
|
|||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from utils import is_truthy_value
|
||||
|
||||
# prompt_toolkit is an optional CLI dependency — only needed for
|
||||
# SlashCommandCompleter and SlashCommandAutoSuggest. Gateway and test
|
||||
# environments that lack it must still be able to import this module
|
||||
|
|
@ -93,6 +95,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
|||
aliases=("q",), args_hint="<prompt>"),
|
||||
CommandDef("steer", "Inject a message after the next tool call without interrupting", "Session",
|
||||
args_hint="<prompt>"),
|
||||
CommandDef("goal", "Set a standing goal Hermes works on across turns until achieved", "Session",
|
||||
args_hint="[text | pause | resume | clear | status]"),
|
||||
CommandDef("status", "Show session info", "Session"),
|
||||
CommandDef("profile", "Show active profile name and home directory", "Info"),
|
||||
CommandDef("sethome", "Set this chat as the home channel", "Session",
|
||||
|
|
@ -371,7 +375,7 @@ def _resolve_config_gates() -> set[str]:
|
|||
else:
|
||||
val = None
|
||||
break
|
||||
if val:
|
||||
if is_truthy_value(val, default=False):
|
||||
result.add(cmd.name)
|
||||
return result
|
||||
|
||||
|
|
@ -834,6 +838,13 @@ def discord_skill_commands_by_category(
|
|||
_SLACK_MAX_SLASH_COMMANDS = 50
|
||||
_SLACK_NAME_LIMIT = 32
|
||||
_SLACK_INVALID_CHARS = re.compile(r"[^a-z0-9_\-]")
|
||||
_SLACK_RESERVED_COMMANDS = frozenset({
|
||||
# Built-in Slack slash commands that cannot be registered by apps.
|
||||
# https://slack.com/help/articles/201259356-Use-built-in-slash-commands
|
||||
"me", "status", "away", "dnd", "shrug", "remind", "msg", "feed",
|
||||
"who", "collapse", "expand", "leave", "join", "open", "search",
|
||||
"topic", "mute", "pro", "shortcuts",
|
||||
})
|
||||
|
||||
|
||||
def _sanitize_slack_name(raw: str) -> str:
|
||||
|
|
@ -860,6 +871,10 @@ def slack_native_slashes() -> list[tuple[str, str, str]]:
|
|||
documented form (e.g. ``/background``, ``/bg``, and ``/btw`` all work).
|
||||
Plugin-registered slash commands are included too.
|
||||
|
||||
Commands whose sanitized name collides with a Slack built-in
|
||||
(e.g. ``/status``, ``/me``, ``/join``) are silently skipped. Users
|
||||
can still reach them via ``/hermes <command>``.
|
||||
|
||||
Results are clamped to Slack's 50-command limit with duplicate-name
|
||||
avoidance. ``/hermes`` is always reserved as the first entry so the
|
||||
legacy ``/hermes <subcommand>`` form keeps working for anything that
|
||||
|
|
@ -877,6 +892,8 @@ def slack_native_slashes() -> list[tuple[str, str, str]]:
|
|||
slack_name = _sanitize_slack_name(name)
|
||||
if not slack_name or slack_name in seen:
|
||||
return
|
||||
if slack_name in _SLACK_RESERVED_COMMANDS:
|
||||
return
|
||||
if len(entries) >= _SLACK_MAX_SLASH_COMMANDS:
|
||||
return
|
||||
# Slack description cap is 2000 chars; keep it short.
|
||||
|
|
|
|||
|
|
@ -457,6 +457,7 @@ DEFAULT_CONFIG = {
|
|||
# remains available as a tool regardless of this setting — the routing
|
||||
# only controls how inbound user images are presented.
|
||||
"image_input_mode": "auto",
|
||||
"disabled_toolsets": [],
|
||||
},
|
||||
|
||||
"terminal": {
|
||||
|
|
@ -606,6 +607,24 @@ DEFAULT_CONFIG = {
|
|||
"max_line_length": 2000,
|
||||
},
|
||||
|
||||
# Tool loop guardrails nudge models when they repeat failed or
|
||||
# non-progressing tool calls. Soft warnings are always-on by default;
|
||||
# hard stops are opt-in so interactive CLI/TUI sessions keep flowing.
|
||||
"tool_loop_guardrails": {
|
||||
"warnings_enabled": True,
|
||||
"hard_stop_enabled": False,
|
||||
"warn_after": {
|
||||
"exact_failure": 2,
|
||||
"same_tool_failure": 3,
|
||||
"idempotent_no_progress": 2,
|
||||
},
|
||||
"hard_stop_after": {
|
||||
"exact_failure": 5,
|
||||
"same_tool_failure": 8,
|
||||
"idempotent_no_progress": 5,
|
||||
},
|
||||
},
|
||||
|
||||
"compression": {
|
||||
"enabled": True,
|
||||
"threshold": 0.50, # compress when context usage exceeds this ratio
|
||||
|
|
@ -756,6 +775,14 @@ DEFAULT_CONFIG = {
|
|||
"tool_progress_command": False, # Enable /verbose command in messaging gateway
|
||||
"tool_progress_overrides": {}, # DEPRECATED — use display.platforms instead
|
||||
"tool_preview_length": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands)
|
||||
# Auto-delete system-notice replies (e.g. "✨ New session started!",
|
||||
# "♻ Restarting gateway…", "⚡ Stopped…") after N seconds on platforms
|
||||
# that support message deletion (currently Telegram; other platforms
|
||||
# ignore and leave the message in place). Only affects slash-command
|
||||
# replies wrapped with gateway.platforms.base.EphemeralReply — agent
|
||||
# responses and content messages are never touched. Default 0
|
||||
# (disabled) preserves prior behavior.
|
||||
"ephemeral_system_ttl": 0,
|
||||
"platforms": {}, # Per-platform display overrides: {"telegram": {"tool_progress": "all"}, "slack": {"tool_progress": "off"}}
|
||||
# Gateway runtime-metadata footer appended to the FINAL message of a turn
|
||||
# (disabled by default to keep replies minimal). When enabled, renders
|
||||
|
|
@ -931,7 +958,23 @@ DEFAULT_CONFIG = {
|
|||
# injected at the start of every API call for few-shot priming.
|
||||
# Never saved to sessions, logs, or trajectories.
|
||||
"prefill_messages_file": "",
|
||||
|
||||
|
||||
# Goals — persistent cross-turn goals (Ralph-style loop).
|
||||
# After every turn, a lightweight judge call asks the auxiliary model
|
||||
# whether the active /goal is satisfied by the assistant's last
|
||||
# response. If not, Hermes feeds a continuation prompt back into the
|
||||
# same session and keeps working until the goal is done, the turn
|
||||
# budget is exhausted, or the user pauses/clears it. Judge failures
|
||||
# fail OPEN (continue) so a flaky judge never wedges progress — the
|
||||
# turn budget is the real backstop.
|
||||
"goals": {
|
||||
# Max continuation turns before Hermes auto-pauses the goal and
|
||||
# asks the user to /goal resume. Protects against judge false
|
||||
# negatives (goal actually done but judge says continue) and
|
||||
# unbounded model spend on fuzzy / unachievable goals.
|
||||
"max_turns": 20,
|
||||
},
|
||||
|
||||
# Skills — external skill directories for sharing skills across tools/agents.
|
||||
# Each path is expanded (~, ${VAR}) and resolved. Read-only — skill creation
|
||||
# always goes to ~/.hermes/skills/.
|
||||
|
|
@ -985,6 +1028,14 @@ DEFAULT_CONFIG = {
|
|||
# Archive a skill (move to skills/.archive/) after this many days
|
||||
# without use. Archived skills are recoverable — no auto-deletion.
|
||||
"archive_after_days": 90,
|
||||
# Pre-run backup: before every real curator pass (dry-run is
|
||||
# skipped), snapshot ~/.hermes/skills/ into
|
||||
# ~/.hermes/skills/.curator_backups/<utc-iso>/skills.tar.gz so the
|
||||
# user can roll back with `hermes curator rollback`.
|
||||
"backup": {
|
||||
"enabled": True,
|
||||
"keep": 5, # retain last N regular snapshots
|
||||
},
|
||||
},
|
||||
|
||||
# Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth.
|
||||
|
|
@ -2425,7 +2476,17 @@ def get_missing_skill_config_vars() -> List[Dict[str, Any]]:
|
|||
except Exception:
|
||||
return []
|
||||
|
||||
all_vars = discover_all_skill_config_vars()
|
||||
try:
|
||||
all_vars = discover_all_skill_config_vars()
|
||||
except Exception as e:
|
||||
# A malformed SKILL.md, unreadable external skill dir, or similar
|
||||
# should never break `hermes update`. Skill-config prompting is a
|
||||
# post-migration nicety, not a blocker.
|
||||
import logging
|
||||
logging.getLogger(__name__).debug(
|
||||
"discover_all_skill_config_vars failed: %s", e
|
||||
)
|
||||
return []
|
||||
if not all_vars:
|
||||
return []
|
||||
|
||||
|
|
|
|||
|
|
@ -160,7 +160,11 @@ def _cmd_run(args) -> int:
|
|||
print("curator: disabled via config; enable with `curator.enabled: true`")
|
||||
return 1
|
||||
|
||||
print("curator: running review pass...")
|
||||
dry = bool(getattr(args, "dry_run", False))
|
||||
if dry:
|
||||
print("curator: running DRY-RUN (report only, no mutations)...")
|
||||
else:
|
||||
print("curator: running review pass...")
|
||||
|
||||
def _on_summary(msg: str) -> None:
|
||||
print(msg)
|
||||
|
|
@ -168,17 +172,29 @@ def _cmd_run(args) -> int:
|
|||
result = curator.run_curator_review(
|
||||
on_summary=_on_summary,
|
||||
synchronous=bool(args.synchronous),
|
||||
dry_run=dry,
|
||||
)
|
||||
auto = result.get("auto_transitions", {})
|
||||
if auto:
|
||||
print(
|
||||
f"auto: checked={auto.get('checked', 0)} "
|
||||
f"stale={auto.get('marked_stale', 0)} "
|
||||
f"archived={auto.get('archived', 0)} "
|
||||
f"reactivated={auto.get('reactivated', 0)}"
|
||||
)
|
||||
if dry:
|
||||
print(
|
||||
f"auto (preview): {auto.get('checked', 0)} candidate skill(s) "
|
||||
"— no transitions applied in dry-run"
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f"auto: checked={auto.get('checked', 0)} "
|
||||
f"stale={auto.get('marked_stale', 0)} "
|
||||
f"archived={auto.get('archived', 0)} "
|
||||
f"reactivated={auto.get('reactivated', 0)}"
|
||||
)
|
||||
if not args.synchronous:
|
||||
print("llm pass running in background — check `hermes curator status` later")
|
||||
if dry:
|
||||
print(
|
||||
"dry-run: no changes applied. When the report lands, read it with "
|
||||
"`hermes curator status` and run `hermes curator run` (no flag) to apply."
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
|
|
@ -229,6 +245,86 @@ def _cmd_restore(args) -> int:
|
|||
return 0 if ok else 1
|
||||
|
||||
|
||||
def _cmd_backup(args) -> int:
|
||||
"""Take a manual snapshot of the skills tree. Same mechanism as the
|
||||
automatic pre-run snapshot, just user-initiated."""
|
||||
from agent import curator_backup
|
||||
if not curator_backup.is_enabled():
|
||||
print(
|
||||
"curator: backups are disabled via config "
|
||||
"(`curator.backup.enabled: false`); re-enable to snapshot"
|
||||
)
|
||||
return 1
|
||||
reason = getattr(args, "reason", None) or "manual"
|
||||
snap = curator_backup.snapshot_skills(reason=reason)
|
||||
if snap is None:
|
||||
print("curator: snapshot failed — check logs (backup disabled or IO error)")
|
||||
return 1
|
||||
print(f"curator: snapshot created at ~/.hermes/skills/.curator_backups/{snap.name}")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_rollback(args) -> int:
|
||||
"""Restore the skills tree from a snapshot. Defaults to newest.
|
||||
|
||||
``--list`` prints available snapshots and exits. ``--id <stamp>`` picks
|
||||
a specific one. Without ``-y``, prompts for confirmation. A safety
|
||||
snapshot of the current tree is always taken first, so rollbacks are
|
||||
themselves undoable.
|
||||
"""
|
||||
from agent import curator_backup
|
||||
|
||||
if getattr(args, "list", False):
|
||||
print(curator_backup.summarize_backups())
|
||||
return 0
|
||||
|
||||
backup_id = getattr(args, "backup_id", None)
|
||||
target_path = curator_backup._resolve_backup(backup_id)
|
||||
if target_path is None:
|
||||
rows = curator_backup.list_backups()
|
||||
if not rows:
|
||||
print(
|
||||
"curator: no snapshots exist yet. Take one with "
|
||||
"`hermes curator backup` or wait for the next curator run."
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f"curator: no snapshot matching "
|
||||
f"{'id ' + repr(backup_id) if backup_id else 'your query'}."
|
||||
)
|
||||
print("Available:")
|
||||
print(curator_backup.summarize_backups())
|
||||
return 1
|
||||
|
||||
manifest = curator_backup._read_manifest(target_path)
|
||||
print(f"Rollback target: {target_path.name}")
|
||||
if manifest:
|
||||
print(f" reason: {manifest.get('reason', '?')}")
|
||||
print(f" created_at: {manifest.get('created_at', '?')}")
|
||||
print(f" skill files: {manifest.get('skill_files', '?')}")
|
||||
print(
|
||||
"\nThis will replace the current ~/.hermes/skills/ tree (a safety "
|
||||
"snapshot of the current state is taken first so this is undoable)."
|
||||
)
|
||||
|
||||
if not getattr(args, "yes", False):
|
||||
try:
|
||||
ans = input("Proceed? [y/N] ").strip().lower()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print("\ncancelled")
|
||||
return 1
|
||||
if ans not in ("y", "yes"):
|
||||
print("cancelled")
|
||||
return 1
|
||||
|
||||
ok, msg, _ = curator_backup.rollback(backup_id=target_path.name)
|
||||
if ok:
|
||||
print(f"curator: {msg}")
|
||||
return 0
|
||||
print(f"curator: rollback failed — {msg}")
|
||||
return 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# argparse wiring (called from hermes_cli.main)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -250,6 +346,11 @@ def register_cli(parent: argparse.ArgumentParser) -> None:
|
|||
"--sync", "--synchronous", dest="synchronous", action="store_true",
|
||||
help="Wait for the LLM review pass to finish (default: background thread)",
|
||||
)
|
||||
p_run.add_argument(
|
||||
"--dry-run", dest="dry_run", action="store_true",
|
||||
help="Report only — no state changes, no archives, no consolidation "
|
||||
"(use this to preview what curator would do)",
|
||||
)
|
||||
p_run.set_defaults(func=_cmd_run)
|
||||
|
||||
p_pause = subs.add_parser("pause", help="Pause the curator until resumed")
|
||||
|
|
@ -270,6 +371,36 @@ def register_cli(parent: argparse.ArgumentParser) -> None:
|
|||
p_restore.add_argument("skill", help="Skill name")
|
||||
p_restore.set_defaults(func=_cmd_restore)
|
||||
|
||||
p_backup = subs.add_parser(
|
||||
"backup",
|
||||
help="Take a manual tar.gz snapshot of ~/.hermes/skills/ "
|
||||
"(curator also does this automatically before every real run)",
|
||||
)
|
||||
p_backup.add_argument(
|
||||
"--reason", default=None,
|
||||
help="Free-text label stored in manifest.json (default: 'manual')",
|
||||
)
|
||||
p_backup.set_defaults(func=_cmd_backup)
|
||||
|
||||
p_rollback = subs.add_parser(
|
||||
"rollback",
|
||||
help="Restore ~/.hermes/skills/ from a curator snapshot "
|
||||
"(defaults to the newest)",
|
||||
)
|
||||
p_rollback.add_argument(
|
||||
"--list", action="store_true",
|
||||
help="List available snapshots and exit without restoring",
|
||||
)
|
||||
p_rollback.add_argument(
|
||||
"--id", dest="backup_id", default=None,
|
||||
help="Snapshot id to restore (see `--list`); default: newest",
|
||||
)
|
||||
p_rollback.add_argument(
|
||||
"-y", "--yes", action="store_true",
|
||||
help="Skip confirmation prompt",
|
||||
)
|
||||
p_rollback.set_defaults(func=_cmd_rollback)
|
||||
|
||||
|
||||
def cli_main(argv=None) -> int:
|
||||
"""Standalone entry (also usable by hermes_cli.main fallthrough)."""
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import shutil
|
|||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
|
@ -59,6 +60,13 @@ class GatewayRuntimeSnapshot:
|
|||
def has_process_service_mismatch(self) -> bool:
|
||||
return self.service_installed and self.running and not self.service_running
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProfileGatewayProcess:
|
||||
profile: str
|
||||
path: Path
|
||||
pid: int
|
||||
|
||||
def _get_service_pids() -> set:
|
||||
"""Return PIDs currently managed by systemd or launchd gateway services.
|
||||
|
||||
|
|
@ -180,7 +188,7 @@ def _graceful_restart_via_sigusr1(pid: int, drain_timeout: float) -> bool:
|
|||
|
||||
SIGUSR1 is wired in gateway/run.py to ``request_restart(via_service=True)``
|
||||
which drains in-flight agent runs (up to ``agent.restart_drain_timeout``
|
||||
seconds), then exits with code 75. Both systemd (``Restart=on-failure``
|
||||
seconds), then exits with code 75. Both systemd (``Restart=always``
|
||||
+ ``RestartForceExitStatus=75``) and launchd (``KeepAlive.SuccessfulExit
|
||||
= false``) relaunch the process after the graceful exit.
|
||||
|
||||
|
|
@ -371,6 +379,83 @@ def find_gateway_pids(exclude_pids: set | None = None, all_profiles: bool = Fals
|
|||
return pids
|
||||
|
||||
|
||||
def find_profile_gateway_processes(
|
||||
exclude_pids: set | None = None,
|
||||
) -> list[ProfileGatewayProcess]:
|
||||
"""Return running gateway PIDs mapped to Hermes profiles via PID files."""
|
||||
_exclude = set(exclude_pids or set())
|
||||
processes: list[ProfileGatewayProcess] = []
|
||||
try:
|
||||
from gateway.status import get_running_pid
|
||||
from hermes_cli.profiles import list_profiles
|
||||
except Exception:
|
||||
return processes
|
||||
|
||||
seen: set[int] = set()
|
||||
for profile in list_profiles():
|
||||
try:
|
||||
pid = get_running_pid(profile.path / "gateway.pid", cleanup_stale=False)
|
||||
except Exception:
|
||||
continue
|
||||
if pid is None or pid <= 0 or pid in _exclude or pid in seen:
|
||||
continue
|
||||
seen.add(pid)
|
||||
processes.append(ProfileGatewayProcess(profile=profile.name, path=profile.path, pid=pid))
|
||||
return processes
|
||||
|
||||
|
||||
def _gateway_run_args_for_profile(profile: str) -> list[str]:
|
||||
args = [get_python_path(), "-m", "hermes_cli.main"]
|
||||
if profile != "default":
|
||||
args.extend(["--profile", profile])
|
||||
args.extend(["gateway", "run", "--replace"])
|
||||
return args
|
||||
|
||||
|
||||
def launch_detached_profile_gateway_restart(profile: str, old_pid: int) -> bool:
|
||||
"""Relaunch a manually-run profile gateway after its current PID exits."""
|
||||
if old_pid <= 0:
|
||||
return False
|
||||
|
||||
watcher = textwrap.dedent(
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
pid = int(sys.argv[1])
|
||||
cmd = sys.argv[2:]
|
||||
deadline = time.monotonic() + 120
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
break
|
||||
except PermissionError:
|
||||
pass
|
||||
time.sleep(0.2)
|
||||
subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
"""
|
||||
).strip()
|
||||
|
||||
try:
|
||||
subprocess.Popen(
|
||||
[sys.executable, "-c", watcher, str(old_pid), *_gateway_run_args_for_profile(profile)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _probe_systemd_service_running(system: bool = False) -> tuple[bool, bool]:
|
||||
selected_system = _select_systemd_scope(system)
|
||||
unit_exists = get_systemd_unit_path(system=selected_system).exists()
|
||||
|
|
@ -1570,8 +1655,7 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None)
|
|||
Description={SERVICE_DESCRIPTION}
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
StartLimitIntervalSec=600
|
||||
StartLimitBurst=5
|
||||
StartLimitIntervalSec=0
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
|
|
@ -1585,8 +1669,10 @@ Environment="LOGNAME={username}"
|
|||
Environment="PATH={sane_path}"
|
||||
Environment="VIRTUAL_ENV={venv_dir}"
|
||||
Environment="HERMES_HOME={hermes_home}"
|
||||
Restart=on-failure
|
||||
RestartSec=30
|
||||
Restart=always
|
||||
RestartSec=60
|
||||
RestartMaxDelaySec=300
|
||||
RestartSteps=5
|
||||
RestartForceExitStatus={GATEWAY_SERVICE_RESTART_EXIT_CODE}
|
||||
KillMode=mixed
|
||||
KillSignal=SIGTERM
|
||||
|
|
@ -1606,9 +1692,9 @@ WantedBy=multi-user.target
|
|||
sane_path = ":".join(path_entries)
|
||||
return f"""[Unit]
|
||||
Description={SERVICE_DESCRIPTION}
|
||||
After=network.target
|
||||
StartLimitIntervalSec=600
|
||||
StartLimitBurst=5
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
StartLimitIntervalSec=0
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
|
|
@ -1617,8 +1703,10 @@ WorkingDirectory={working_dir}
|
|||
Environment="PATH={sane_path}"
|
||||
Environment="VIRTUAL_ENV={venv_dir}"
|
||||
Environment="HERMES_HOME={hermes_home}"
|
||||
Restart=on-failure
|
||||
RestartSec=30
|
||||
Restart=always
|
||||
RestartSec=60
|
||||
RestartMaxDelaySec=300
|
||||
RestartSteps=5
|
||||
RestartForceExitStatus={GATEWAY_SERVICE_RESTART_EXIT_CODE}
|
||||
KillMode=mixed
|
||||
KillSignal=SIGTERM
|
||||
|
|
@ -2366,7 +2454,7 @@ def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False):
|
|||
print()
|
||||
|
||||
# Exit with code 1 if gateway fails to connect any platform,
|
||||
# so systemd Restart=on-failure will retry on transient errors
|
||||
# so systemd Restart=always will retry on transient errors
|
||||
verbosity = None if quiet else verbose
|
||||
try:
|
||||
success = asyncio.run(start_gateway(replace=replace, verbosity=verbosity))
|
||||
|
|
@ -4377,4 +4465,4 @@ def _gateway_command_inner(args):
|
|||
if not supports_systemd_services() and not is_macos():
|
||||
print("Legacy unit migration only applies to systemd-based Linux hosts.")
|
||||
return
|
||||
remove_legacy_hermes_units(interactive=not yes, dry_run=dry_run)
|
||||
remove_legacy_hermes_units(interactive=not yes, dry_run=dry_run)
|
||||
|
|
|
|||
535
hermes_cli/goals.py
Normal file
535
hermes_cli/goals.py
Normal file
|
|
@ -0,0 +1,535 @@
|
|||
"""Persistent session goals — the Ralph loop for Hermes.
|
||||
|
||||
A goal is a free-form user objective that stays active across turns. After
|
||||
each turn completes, a small judge call asks an auxiliary model "is this
|
||||
goal satisfied by the assistant's last response?". If not, Hermes feeds a
|
||||
continuation prompt back into the same session and keeps working until the
|
||||
goal is done, turn budget is exhausted, the user pauses/clears it, or the
|
||||
user sends a new message (which takes priority and pauses the goal loop).
|
||||
|
||||
State is persisted in SessionDB's ``state_meta`` table keyed by
|
||||
``goal:<session_id>`` so ``/resume`` picks it up.
|
||||
|
||||
Design notes / invariants:
|
||||
|
||||
- The continuation prompt is just a normal user message appended to the
|
||||
session via ``run_conversation``. No system-prompt mutation, no toolset
|
||||
swap — prompt caching stays intact.
|
||||
- Judge failures are fail-OPEN: ``continue``. A broken judge must not wedge
|
||||
progress; the turn budget is the backstop.
|
||||
- When a real user message arrives mid-loop it preempts the continuation
|
||||
prompt and also pauses the goal loop for that turn (we still re-judge
|
||||
after, so if the user's message happens to complete the goal the judge
|
||||
will say ``done``).
|
||||
- This module has zero hard dependency on ``cli.HermesCLI`` or the gateway
|
||||
runner — both wire the same ``GoalManager`` in.
|
||||
|
||||
Nothing in this module touches the agent's system prompt or toolset.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass, asdict
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Constants & defaults
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
DEFAULT_MAX_TURNS = 20
|
||||
DEFAULT_JUDGE_TIMEOUT = 30.0
|
||||
# Cap how much of the last response + recent messages we send to the judge.
|
||||
_JUDGE_RESPONSE_SNIPPET_CHARS = 4000
|
||||
|
||||
|
||||
CONTINUATION_PROMPT_TEMPLATE = (
|
||||
"[Continuing toward your standing goal]\n"
|
||||
"Goal: {goal}\n\n"
|
||||
"Continue working toward this goal. Take the next concrete step. "
|
||||
"If you believe the goal is complete, state so explicitly and stop. "
|
||||
"If you are blocked and need input from the user, say so clearly and stop."
|
||||
)
|
||||
|
||||
|
||||
JUDGE_SYSTEM_PROMPT = (
|
||||
"You are a strict judge evaluating whether an autonomous agent has "
|
||||
"achieved a user's stated goal. You receive the goal text and the "
|
||||
"agent's most recent response. Your only job is to decide whether "
|
||||
"the goal is fully satisfied based on that response.\n\n"
|
||||
"A goal is DONE only when:\n"
|
||||
"- The response explicitly confirms the goal was completed, OR\n"
|
||||
"- The response clearly shows the final deliverable was produced, OR\n"
|
||||
"- The response explains the goal is unachievable / blocked / needs "
|
||||
"user input (treat this as DONE with reason describing the block).\n\n"
|
||||
"Otherwise the goal is NOT done — CONTINUE.\n\n"
|
||||
"Reply ONLY with a single JSON object on one line:\n"
|
||||
'{\"done\": <true|false>, \"reason\": \"<one-sentence rationale>\"}'
|
||||
)
|
||||
|
||||
|
||||
JUDGE_USER_PROMPT_TEMPLATE = (
|
||||
"Goal:\n{goal}\n\n"
|
||||
"Agent's most recent response:\n{response}\n\n"
|
||||
"Is the goal satisfied?"
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Dataclass
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class GoalState:
|
||||
"""Serializable goal state stored per session."""
|
||||
|
||||
goal: str
|
||||
status: str = "active" # active | paused | done | cleared
|
||||
turns_used: int = 0
|
||||
max_turns: int = DEFAULT_MAX_TURNS
|
||||
created_at: float = 0.0
|
||||
last_turn_at: float = 0.0
|
||||
last_verdict: Optional[str] = None # "done" | "continue" | "skipped"
|
||||
last_reason: Optional[str] = None
|
||||
paused_reason: Optional[str] = None # why we auto-paused (budget, etc.)
|
||||
|
||||
def to_json(self) -> str:
|
||||
return json.dumps(asdict(self), ensure_ascii=False)
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, raw: str) -> "GoalState":
|
||||
data = json.loads(raw)
|
||||
return cls(
|
||||
goal=data.get("goal", ""),
|
||||
status=data.get("status", "active"),
|
||||
turns_used=int(data.get("turns_used", 0) or 0),
|
||||
max_turns=int(data.get("max_turns", DEFAULT_MAX_TURNS) or DEFAULT_MAX_TURNS),
|
||||
created_at=float(data.get("created_at", 0.0) or 0.0),
|
||||
last_turn_at=float(data.get("last_turn_at", 0.0) or 0.0),
|
||||
last_verdict=data.get("last_verdict"),
|
||||
last_reason=data.get("last_reason"),
|
||||
paused_reason=data.get("paused_reason"),
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Persistence (SessionDB state_meta)
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _meta_key(session_id: str) -> str:
|
||||
return f"goal:{session_id}"
|
||||
|
||||
|
||||
_DB_CACHE: Dict[str, Any] = {}
|
||||
|
||||
|
||||
def _get_session_db() -> Optional[Any]:
|
||||
"""Return a SessionDB instance for the current HERMES_HOME.
|
||||
|
||||
SessionDB has no built-in singleton, but opening a new connection per
|
||||
/goal call would thrash the file. We cache one instance per
|
||||
``hermes_home`` path so profile switches still pick up the right DB.
|
||||
Defensive against import/instantiation failures so tests and
|
||||
non-standard launchers can still use the GoalManager.
|
||||
"""
|
||||
try:
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_state import SessionDB
|
||||
|
||||
home = str(get_hermes_home())
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.debug("GoalManager: SessionDB bootstrap failed (%s)", exc)
|
||||
return None
|
||||
|
||||
cached = _DB_CACHE.get(home)
|
||||
if cached is not None:
|
||||
return cached
|
||||
try:
|
||||
db = SessionDB()
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.debug("GoalManager: SessionDB() raised (%s)", exc)
|
||||
return None
|
||||
_DB_CACHE[home] = db
|
||||
return db
|
||||
|
||||
|
||||
def load_goal(session_id: str) -> Optional[GoalState]:
|
||||
"""Load the goal for a session, or None if none exists."""
|
||||
if not session_id:
|
||||
return None
|
||||
db = _get_session_db()
|
||||
if db is None:
|
||||
return None
|
||||
try:
|
||||
raw = db.get_meta(_meta_key(session_id))
|
||||
except Exception as exc:
|
||||
logger.debug("GoalManager: get_meta failed: %s", exc)
|
||||
return None
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return GoalState.from_json(raw)
|
||||
except Exception as exc:
|
||||
logger.warning("GoalManager: could not parse stored goal for %s: %s", session_id, exc)
|
||||
return None
|
||||
|
||||
|
||||
def save_goal(session_id: str, state: GoalState) -> None:
|
||||
"""Persist a goal to SessionDB. No-op if DB unavailable."""
|
||||
if not session_id:
|
||||
return
|
||||
db = _get_session_db()
|
||||
if db is None:
|
||||
return
|
||||
try:
|
||||
db.set_meta(_meta_key(session_id), state.to_json())
|
||||
except Exception as exc:
|
||||
logger.debug("GoalManager: set_meta failed: %s", exc)
|
||||
|
||||
|
||||
def clear_goal(session_id: str) -> None:
|
||||
"""Mark a goal cleared in the DB (preserved for audit, status=cleared)."""
|
||||
state = load_goal(session_id)
|
||||
if state is None:
|
||||
return
|
||||
state.status = "cleared"
|
||||
save_goal(session_id, state)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Judge
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _truncate(text: str, limit: int) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[:limit] + "… [truncated]"
|
||||
|
||||
|
||||
_JSON_OBJECT_RE = re.compile(r"\{.*?\}", re.DOTALL)
|
||||
|
||||
|
||||
def _parse_judge_response(raw: str) -> Tuple[bool, str]:
|
||||
"""Parse the judge's reply. Fail-open to ``(False, "<reason>")``.
|
||||
|
||||
Returns ``(done, reason)``.
|
||||
"""
|
||||
if not raw:
|
||||
return False, "judge returned empty response"
|
||||
|
||||
text = raw.strip()
|
||||
|
||||
# Strip markdown code fences the model may wrap JSON in.
|
||||
if text.startswith("```"):
|
||||
text = text.strip("`")
|
||||
# Peel off leading json/JSON/etc tag
|
||||
nl = text.find("\n")
|
||||
if nl != -1:
|
||||
text = text[nl + 1:]
|
||||
|
||||
# First try: parse the whole blob.
|
||||
data: Optional[Dict[str, Any]] = None
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except Exception:
|
||||
# Second try: pull the first JSON object out.
|
||||
match = _JSON_OBJECT_RE.search(text)
|
||||
if match:
|
||||
try:
|
||||
data = json.loads(match.group(0))
|
||||
except Exception:
|
||||
data = None
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return False, f"judge reply was not JSON: {_truncate(raw, 200)!r}"
|
||||
|
||||
done_val = data.get("done")
|
||||
if isinstance(done_val, str):
|
||||
done = done_val.strip().lower() in ("true", "yes", "1", "done")
|
||||
else:
|
||||
done = bool(done_val)
|
||||
reason = str(data.get("reason") or "").strip()
|
||||
if not reason:
|
||||
reason = "no reason provided"
|
||||
return done, reason
|
||||
|
||||
|
||||
def judge_goal(
|
||||
goal: str,
|
||||
last_response: str,
|
||||
*,
|
||||
timeout: float = DEFAULT_JUDGE_TIMEOUT,
|
||||
) -> Tuple[str, str]:
|
||||
"""Ask the auxiliary model whether the goal is satisfied.
|
||||
|
||||
Returns ``(verdict, reason)`` where verdict is ``"done"``, ``"continue"``,
|
||||
or ``"skipped"`` (when the judge couldn't be reached).
|
||||
|
||||
This is deliberately fail-open: any error returns ``("continue", "...")``
|
||||
so a broken judge doesn't wedge progress — the turn budget is the
|
||||
backstop.
|
||||
"""
|
||||
if not goal.strip():
|
||||
return "skipped", "empty goal"
|
||||
if not last_response.strip():
|
||||
# No substantive reply this turn — almost certainly not done yet.
|
||||
return "continue", "empty response (nothing to evaluate)"
|
||||
|
||||
try:
|
||||
from agent.auxiliary_client import get_text_auxiliary_client
|
||||
except Exception as exc:
|
||||
logger.debug("goal judge: auxiliary client import failed: %s", exc)
|
||||
return "continue", "auxiliary client unavailable"
|
||||
|
||||
try:
|
||||
client, model = get_text_auxiliary_client("goal_judge")
|
||||
except Exception as exc:
|
||||
logger.debug("goal judge: get_text_auxiliary_client failed: %s", exc)
|
||||
return "continue", "auxiliary client unavailable"
|
||||
|
||||
if client is None or not model:
|
||||
return "continue", "no auxiliary client configured"
|
||||
|
||||
prompt = JUDGE_USER_PROMPT_TEMPLATE.format(
|
||||
goal=_truncate(goal, 2000),
|
||||
response=_truncate(last_response, _JUDGE_RESPONSE_SNIPPET_CHARS),
|
||||
)
|
||||
|
||||
try:
|
||||
resp = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[
|
||||
{"role": "system", "content": JUDGE_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
temperature=0,
|
||||
max_tokens=200,
|
||||
timeout=timeout,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.info("goal judge: API call failed (%s) — falling through to continue", exc)
|
||||
return "continue", f"judge error: {type(exc).__name__}"
|
||||
|
||||
try:
|
||||
raw = resp.choices[0].message.content or ""
|
||||
except Exception:
|
||||
raw = ""
|
||||
|
||||
done, reason = _parse_judge_response(raw)
|
||||
verdict = "done" if done else "continue"
|
||||
logger.info("goal judge: verdict=%s reason=%s", verdict, _truncate(reason, 120))
|
||||
return verdict, reason
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# GoalManager — the orchestration surface CLI + gateway talk to
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class GoalManager:
|
||||
"""Per-session goal state + continuation decisions.
|
||||
|
||||
The CLI and gateway each hold one ``GoalManager`` per live session.
|
||||
|
||||
Methods:
|
||||
|
||||
- ``set(goal)`` — start a new standing goal.
|
||||
- ``clear()`` — remove the active goal.
|
||||
- ``pause()`` / ``resume()`` — explicit user controls.
|
||||
- ``status()`` — printable one-liner.
|
||||
- ``evaluate_after_turn(last_response)`` — call the judge, update state,
|
||||
and return a decision dict the caller uses to drive the next turn.
|
||||
- ``next_continuation_prompt()`` — the canonical user-role message to
|
||||
feed back into ``run_conversation``.
|
||||
"""
|
||||
|
||||
def __init__(self, session_id: str, *, default_max_turns: int = DEFAULT_MAX_TURNS):
|
||||
self.session_id = session_id
|
||||
self.default_max_turns = int(default_max_turns or DEFAULT_MAX_TURNS)
|
||||
self._state: Optional[GoalState] = load_goal(session_id)
|
||||
|
||||
# --- introspection ------------------------------------------------
|
||||
|
||||
@property
|
||||
def state(self) -> Optional[GoalState]:
|
||||
return self._state
|
||||
|
||||
def is_active(self) -> bool:
|
||||
return self._state is not None and self._state.status == "active"
|
||||
|
||||
def has_goal(self) -> bool:
|
||||
return self._state is not None and self._state.status in ("active", "paused")
|
||||
|
||||
def status_line(self) -> str:
|
||||
s = self._state
|
||||
if s is None or s.status in ("cleared",):
|
||||
return "No active goal. Set one with /goal <text>."
|
||||
turns = f"{s.turns_used}/{s.max_turns} turns"
|
||||
if s.status == "active":
|
||||
return f"⊙ Goal (active, {turns}): {s.goal}"
|
||||
if s.status == "paused":
|
||||
extra = f" — {s.paused_reason}" if s.paused_reason else ""
|
||||
return f"⏸ Goal (paused, {turns}{extra}): {s.goal}"
|
||||
if s.status == "done":
|
||||
return f"✓ Goal done ({turns}): {s.goal}"
|
||||
return f"Goal ({s.status}, {turns}): {s.goal}"
|
||||
|
||||
# --- mutation -----------------------------------------------------
|
||||
|
||||
def set(self, goal: str, *, max_turns: Optional[int] = None) -> GoalState:
|
||||
goal = (goal or "").strip()
|
||||
if not goal:
|
||||
raise ValueError("goal text is empty")
|
||||
state = GoalState(
|
||||
goal=goal,
|
||||
status="active",
|
||||
turns_used=0,
|
||||
max_turns=int(max_turns) if max_turns else self.default_max_turns,
|
||||
created_at=time.time(),
|
||||
last_turn_at=0.0,
|
||||
)
|
||||
self._state = state
|
||||
save_goal(self.session_id, state)
|
||||
return state
|
||||
|
||||
def pause(self, reason: str = "user-paused") -> Optional[GoalState]:
|
||||
if not self._state:
|
||||
return None
|
||||
self._state.status = "paused"
|
||||
self._state.paused_reason = reason
|
||||
save_goal(self.session_id, self._state)
|
||||
return self._state
|
||||
|
||||
def resume(self, *, reset_budget: bool = True) -> Optional[GoalState]:
|
||||
if not self._state:
|
||||
return None
|
||||
self._state.status = "active"
|
||||
self._state.paused_reason = None
|
||||
if reset_budget:
|
||||
self._state.turns_used = 0
|
||||
save_goal(self.session_id, self._state)
|
||||
return self._state
|
||||
|
||||
def clear(self) -> None:
|
||||
if self._state is None:
|
||||
return
|
||||
self._state.status = "cleared"
|
||||
save_goal(self.session_id, self._state)
|
||||
self._state = None
|
||||
|
||||
def mark_done(self, reason: str) -> None:
|
||||
if not self._state:
|
||||
return
|
||||
self._state.status = "done"
|
||||
self._state.last_verdict = "done"
|
||||
self._state.last_reason = reason
|
||||
save_goal(self.session_id, self._state)
|
||||
|
||||
# --- the main entry point called after every turn -----------------
|
||||
|
||||
def evaluate_after_turn(
|
||||
self,
|
||||
last_response: str,
|
||||
*,
|
||||
user_initiated: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""Run the judge and update state. Return a decision dict.
|
||||
|
||||
``user_initiated`` distinguishes a real user prompt (True) from a
|
||||
continuation prompt we fed ourselves (False). Both increment
|
||||
``turns_used`` because both consume model budget.
|
||||
|
||||
Decision keys:
|
||||
- ``status``: current goal status after update
|
||||
- ``should_continue``: bool — caller should fire another turn
|
||||
- ``continuation_prompt``: str or None
|
||||
- ``verdict``: "done" | "continue" | "skipped" | "inactive"
|
||||
- ``reason``: str
|
||||
- ``message``: user-visible one-liner to print/send
|
||||
"""
|
||||
state = self._state
|
||||
if state is None or state.status != "active":
|
||||
return {
|
||||
"status": state.status if state else None,
|
||||
"should_continue": False,
|
||||
"continuation_prompt": None,
|
||||
"verdict": "inactive",
|
||||
"reason": "no active goal",
|
||||
"message": "",
|
||||
}
|
||||
|
||||
# Count the turn that just finished.
|
||||
state.turns_used += 1
|
||||
state.last_turn_at = time.time()
|
||||
|
||||
verdict, reason = judge_goal(state.goal, last_response)
|
||||
state.last_verdict = verdict
|
||||
state.last_reason = reason
|
||||
|
||||
if verdict == "done":
|
||||
state.status = "done"
|
||||
save_goal(self.session_id, state)
|
||||
return {
|
||||
"status": "done",
|
||||
"should_continue": False,
|
||||
"continuation_prompt": None,
|
||||
"verdict": "done",
|
||||
"reason": reason,
|
||||
"message": f"✓ Goal achieved: {reason}",
|
||||
}
|
||||
|
||||
if state.turns_used >= state.max_turns:
|
||||
state.status = "paused"
|
||||
state.paused_reason = f"turn budget exhausted ({state.turns_used}/{state.max_turns})"
|
||||
save_goal(self.session_id, state)
|
||||
return {
|
||||
"status": "paused",
|
||||
"should_continue": False,
|
||||
"continuation_prompt": None,
|
||||
"verdict": "continue",
|
||||
"reason": reason,
|
||||
"message": (
|
||||
f"⏸ Goal paused — {state.turns_used}/{state.max_turns} turns used. "
|
||||
"Use /goal resume to keep going, or /goal clear to stop."
|
||||
),
|
||||
}
|
||||
|
||||
save_goal(self.session_id, state)
|
||||
return {
|
||||
"status": "active",
|
||||
"should_continue": True,
|
||||
"continuation_prompt": self.next_continuation_prompt(),
|
||||
"verdict": "continue",
|
||||
"reason": reason,
|
||||
"message": (
|
||||
f"↻ Continuing toward goal ({state.turns_used}/{state.max_turns}): {reason}"
|
||||
),
|
||||
}
|
||||
|
||||
def next_continuation_prompt(self) -> Optional[str]:
|
||||
if not self._state or self._state.status != "active":
|
||||
return None
|
||||
return CONTINUATION_PROMPT_TEMPLATE.format(goal=self._state.goal)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"GoalState",
|
||||
"GoalManager",
|
||||
"CONTINUATION_PROMPT_TEMPLATE",
|
||||
"DEFAULT_MAX_TURNS",
|
||||
"load_goal",
|
||||
"save_goal",
|
||||
"clear_goal",
|
||||
"judge_goal",
|
||||
]
|
||||
|
|
@ -800,6 +800,8 @@ def _print_tui_exit_summary(session_id: Optional[str], active_session_file: Opti
|
|||
|
||||
title = db.get_session_title(target)
|
||||
message_count = int(session.get("message_count") or 0)
|
||||
if message_count == 0:
|
||||
return # No real conversation — don't show resume info
|
||||
input_tokens = int(session.get("input_tokens") or 0)
|
||||
output_tokens = int(session.get("output_tokens") or 0)
|
||||
cache_read_tokens = int(session.get("cache_read_tokens") or 0)
|
||||
|
|
@ -5431,6 +5433,45 @@ def _find_stale_dashboard_pids() -> list[int]:
|
|||
return dashboard_pids
|
||||
|
||||
|
||||
def _print_curator_first_run_notice() -> None:
|
||||
"""Print a short heads-up about the skill curator after `hermes update`.
|
||||
|
||||
Only fires when the curator is enabled AND has no recorded run yet, which
|
||||
is exactly the window where the gateway ticker used to fire Curator
|
||||
against a fresh skill library immediately after an update. We defer the
|
||||
first real pass by one ``interval_hours``; this notice tells the user how
|
||||
to preview or disable before then. Silent on steady state.
|
||||
"""
|
||||
try:
|
||||
from agent import curator
|
||||
except Exception:
|
||||
return
|
||||
try:
|
||||
if not curator.is_enabled():
|
||||
return
|
||||
state = curator.load_state()
|
||||
except Exception:
|
||||
return
|
||||
if state.get("last_run_at"):
|
||||
# Curator has run before (real or already seeded) — no notice needed.
|
||||
return
|
||||
try:
|
||||
hours = curator.get_interval_hours()
|
||||
except Exception:
|
||||
hours = 24 * 7
|
||||
days = max(1, hours // 24)
|
||||
print()
|
||||
print("ℹ Skill curator")
|
||||
print(
|
||||
f" Background skill maintenance is enabled. First pass is deferred "
|
||||
f"~{days}d after installation; only agent-created skills are in "
|
||||
f"scope and nothing is ever auto-deleted (archive is recoverable)."
|
||||
)
|
||||
print(" Preview now: hermes curator run --dry-run")
|
||||
print(" Pause it: hermes curator pause")
|
||||
print(" Docs: https://hermes-agent.nousresearch.com/docs/user-guide/features/curator")
|
||||
|
||||
|
||||
def _kill_stale_dashboard_processes(
|
||||
reason: str = "the running backend no longer matches the updated frontend",
|
||||
) -> None:
|
||||
|
|
@ -5668,6 +5709,10 @@ def _update_via_zip(args):
|
|||
|
||||
print()
|
||||
print("✓ Update complete!")
|
||||
try:
|
||||
_print_curator_first_run_notice()
|
||||
except Exception as e:
|
||||
logger.debug("Curator first-run notice failed: %s", e)
|
||||
_kill_stale_dashboard_processes()
|
||||
|
||||
|
||||
|
|
@ -6673,6 +6718,7 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
|||
if gateway_mode
|
||||
else None
|
||||
)
|
||||
assume_yes = bool(getattr(args, "yes", False))
|
||||
|
||||
print("⚕ Updating Hermes Agent...")
|
||||
print()
|
||||
|
|
@ -6792,8 +6838,10 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
|||
else:
|
||||
auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT)
|
||||
|
||||
prompt_for_restore = auto_stash_ref is not None and (
|
||||
gateway_mode or (sys.stdin.isatty() and sys.stdout.isatty())
|
||||
prompt_for_restore = (
|
||||
auto_stash_ref is not None
|
||||
and not assume_yes
|
||||
and (gateway_mode or (sys.stdin.isatty() and sys.stdout.isatty()))
|
||||
)
|
||||
|
||||
# Check if there are updates
|
||||
|
|
@ -7054,7 +7102,10 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
|||
print(f" ℹ️ {len(missing_config)} new config option(s) available")
|
||||
|
||||
print()
|
||||
if gateway_mode:
|
||||
if assume_yes:
|
||||
print(" ℹ --yes: auto-applying config migration (skipping API-key prompts).")
|
||||
response = "y"
|
||||
elif gateway_mode:
|
||||
response = (
|
||||
_gateway_prompt(
|
||||
"Would you like to configure new options now? [Y/n]", "n"
|
||||
|
|
@ -7080,14 +7131,17 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
|||
|
||||
if response in ("", "y", "yes"):
|
||||
print()
|
||||
# In gateway mode, run auto-migrations only (no input() prompts
|
||||
# for API keys which would hang the detached process).
|
||||
results = migrate_config(interactive=not gateway_mode, quiet=False)
|
||||
# In gateway mode OR under --yes, run auto-migrations only (no
|
||||
# input() prompts for API keys which would hang the detached
|
||||
# process / defeat the point of --yes).
|
||||
results = migrate_config(
|
||||
interactive=not (gateway_mode or assume_yes), quiet=False
|
||||
)
|
||||
|
||||
if results["env_added"] or results["config_added"]:
|
||||
print()
|
||||
print("✓ Configuration updated!")
|
||||
if gateway_mode and missing_env:
|
||||
if (gateway_mode or assume_yes) and missing_env:
|
||||
print(" ℹ API keys require manual entry: hermes config migrate")
|
||||
else:
|
||||
print()
|
||||
|
|
@ -7098,6 +7152,15 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
|||
print()
|
||||
print("✓ Update complete!")
|
||||
|
||||
# Curator first-run heads-up. Only prints when curator is enabled AND
|
||||
# has never run — i.e. the window where the ticker would otherwise
|
||||
# have fired against a fresh skill library. Kept silent on steady
|
||||
# state so we don't nag.
|
||||
try:
|
||||
_print_curator_first_run_notice()
|
||||
except Exception as e:
|
||||
logger.debug("Curator first-run notice failed: %s", e)
|
||||
|
||||
# Repair RHEL-family root installs where /usr/local/bin isn't on PATH
|
||||
# for non-login interactive shells. No-op on every other platform.
|
||||
try:
|
||||
|
|
@ -7137,6 +7200,8 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
|||
supports_systemd_services,
|
||||
_ensure_user_systemd_env,
|
||||
find_gateway_pids,
|
||||
find_profile_gateway_processes,
|
||||
launch_detached_profile_gateway_restart,
|
||||
_get_service_pids,
|
||||
_graceful_restart_via_sigusr1,
|
||||
)
|
||||
|
|
@ -7240,6 +7305,7 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
|||
|
||||
restarted_services = []
|
||||
killed_pids = set()
|
||||
relaunched_profiles = []
|
||||
|
||||
# --- Systemd services (Linux) ---
|
||||
# Discover all hermes-gateway* units (default + profiles)
|
||||
|
|
@ -7429,7 +7495,33 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
|||
manual_pids = find_gateway_pids(
|
||||
exclude_pids=service_pids, all_profiles=True
|
||||
)
|
||||
profile_processes = {
|
||||
proc.pid: proc
|
||||
for proc in find_profile_gateway_processes(exclude_pids=service_pids)
|
||||
if proc.pid in manual_pids
|
||||
}
|
||||
for pid, proc in profile_processes.items():
|
||||
if not launch_detached_profile_gateway_restart(proc.profile, pid):
|
||||
continue
|
||||
# Prefer a graceful SIGUSR1 drain so in-flight agent runs
|
||||
# finish before the watcher respawns the gateway. If the
|
||||
# gateway doesn't support SIGUSR1 or doesn't exit within
|
||||
# the drain budget, fall back to SIGTERM — the watcher
|
||||
# still sees the exit and relaunches either way.
|
||||
drained = _graceful_restart_via_sigusr1(
|
||||
pid, drain_timeout=_drain_budget,
|
||||
)
|
||||
if not drained:
|
||||
try:
|
||||
os.kill(pid, _signal.SIGTERM)
|
||||
except (ProcessLookupError, PermissionError):
|
||||
pass
|
||||
killed_pids.add(pid)
|
||||
relaunched_profiles.append(proc.profile)
|
||||
|
||||
for pid in manual_pids:
|
||||
if pid in profile_processes:
|
||||
continue
|
||||
try:
|
||||
os.kill(pid, _signal.SIGTERM)
|
||||
killed_pids.add(pid)
|
||||
|
|
@ -7440,11 +7532,14 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
|||
print()
|
||||
for svc in restarted_services:
|
||||
print(f" ✓ Restarted {svc}")
|
||||
if killed_pids:
|
||||
print(f" → Stopped {len(killed_pids)} manual gateway process(es)")
|
||||
if relaunched_profiles:
|
||||
names = ", ".join(relaunched_profiles)
|
||||
print(f" ✓ Restarting manual gateway profile(s): {names}")
|
||||
unmapped_count = len(killed_pids) - len(relaunched_profiles)
|
||||
if unmapped_count:
|
||||
print(f" → Stopped {unmapped_count} manual gateway process(es)")
|
||||
print(" Restart manually: hermes gateway run")
|
||||
# Also restart for each profile if needed
|
||||
if len(killed_pids) > 1:
|
||||
if unmapped_count > 1:
|
||||
print(
|
||||
" (or: hermes -p <profile> gateway run for each profile)"
|
||||
)
|
||||
|
|
@ -7453,6 +7548,42 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
|||
# No gateways were running — nothing to do
|
||||
pass
|
||||
|
||||
# --- Post-restart survivor sweep -----------------------------
|
||||
# Issue #17648: some gateways ignore SIGTERM (stuck drain,
|
||||
# blocked I/O, PID dead but zombie). The detached profile
|
||||
# watchers wait 120s for the old PID to exit — if it never
|
||||
# does, no respawn happens and the user keeps hitting
|
||||
# ImportError against a stale sys.modules. Give the
|
||||
# graceful paths a brief window to complete, then SIGKILL
|
||||
# any remaining pre-update PIDs so the watcher / service
|
||||
# manager can relaunch with fresh code.
|
||||
try:
|
||||
_time.sleep(3.0)
|
||||
_service_pids_after = _get_service_pids()
|
||||
_surviving = find_gateway_pids(
|
||||
exclude_pids=_service_pids_after, all_profiles=True,
|
||||
)
|
||||
# Scope to PIDs we already tried to kill during this
|
||||
# update (killed_pids). Anything new is a gateway that
|
||||
# started AFTER our restart attempt — respecting user
|
||||
# intent, we don't kill those.
|
||||
_stuck = [pid for pid in _surviving if pid in killed_pids]
|
||||
if _stuck:
|
||||
print()
|
||||
print(
|
||||
f" ⚠ {len(_stuck)} gateway process(es) ignored SIGTERM — force-killing"
|
||||
)
|
||||
for pid in _stuck:
|
||||
try:
|
||||
os.kill(pid, _signal.SIGKILL)
|
||||
except (ProcessLookupError, PermissionError):
|
||||
pass
|
||||
# Give the OS a beat to reap the processes so the
|
||||
# watchers see them exit and respawn.
|
||||
_time.sleep(1.5)
|
||||
except Exception as _sweep_exc:
|
||||
logger.debug("Post-restart survivor sweep failed: %s", _sweep_exc)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug("Gateway restart during update failed: %s", e)
|
||||
|
||||
|
|
@ -9861,6 +9992,13 @@ Examples:
|
|||
default=False,
|
||||
help="Force a pre-update backup for this run (off by default; overrides updates.pre_update_backup)",
|
||||
)
|
||||
update_parser.add_argument(
|
||||
"--yes",
|
||||
"-y",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Assume yes for interactive prompts (config migration, stash restore). API-key entry is skipped; run 'hermes config migrate' separately for those.",
|
||||
)
|
||||
update_parser.set_defaults(func=cmd_update)
|
||||
|
||||
# =========================================================================
|
||||
|
|
|
|||
|
|
@ -891,14 +891,19 @@ def switch_model(
|
|||
if not validation.get("accepted"):
|
||||
override = False
|
||||
if user_providers:
|
||||
for up in user_providers:
|
||||
if isinstance(up, dict) and up.get("provider") == target_provider:
|
||||
cfg_models = up.get("models", [])
|
||||
if new_model in cfg_models or any(
|
||||
m.get("name") == new_model for m in cfg_models if isinstance(m, dict)
|
||||
):
|
||||
# user_providers is a dict: {provider_slug: config_dict}
|
||||
for slug, cfg in user_providers.items():
|
||||
if slug == target_provider:
|
||||
cfg_models = cfg.get("models", {})
|
||||
# Direct membership works for dict (keys) and list (strings)
|
||||
if new_model in cfg_models:
|
||||
override = True
|
||||
break
|
||||
# Also accept if models is a list of dicts with 'name' field
|
||||
if isinstance(cfg_models, list):
|
||||
if any(m.get("name") == new_model for m in cfg_models if isinstance(m, dict)):
|
||||
override = True
|
||||
break
|
||||
if override:
|
||||
validation = {"accepted": True, "persist": True, "recognized": False, "message": validation.get("message", "")}
|
||||
else:
|
||||
|
|
@ -1412,14 +1417,17 @@ def list_authenticated_providers(
|
|||
models_list = list(fb)
|
||||
|
||||
# Prefer the endpoint's live /models list when credentials are
|
||||
# available. This keeps OpenAI-compatible relays (for example CRS)
|
||||
# in sync when the server catalog changes without requiring the
|
||||
# user to mirror every model into config.yaml.
|
||||
# available, unless the provider explicitly opts out via
|
||||
# discover_models: false (e.g. dedicated endpoints that expose
|
||||
# the entire aggregator catalog via /models).
|
||||
api_key = str(ep_cfg.get("api_key", "") or "").strip()
|
||||
if not api_key:
|
||||
key_env = str(ep_cfg.get("key_env", "") or "").strip()
|
||||
api_key = os.environ.get(key_env, "").strip() if key_env else ""
|
||||
if api_url and api_key:
|
||||
discover = ep_cfg.get("discover_models", True)
|
||||
if isinstance(discover, str):
|
||||
discover = discover.lower() not in ("false", "no", "0")
|
||||
if api_url and api_key and discover:
|
||||
try:
|
||||
from hermes_cli.models import fetch_api_models
|
||||
live_models = fetch_api_models(api_key, api_url)
|
||||
|
|
|
|||
|
|
@ -774,7 +774,6 @@ CANONICAL_PROVIDERS: list[ProviderEntry] = [
|
|||
ProviderEntry("nous", "Nous Portal", "Nous Portal (Nous Research subscription)"),
|
||||
ProviderEntry("openrouter", "OpenRouter", "OpenRouter (100+ models, pay-per-use)"),
|
||||
ProviderEntry("lmstudio", "LM Studio", "LM Studio (local desktop app with built-in model server)"),
|
||||
ProviderEntry("ai-gateway", "Vercel AI Gateway", "Vercel AI Gateway (200+ models, $5 free credit, no markup)"),
|
||||
ProviderEntry("anthropic", "Anthropic", "Anthropic (Claude models — API key or Claude Code)"),
|
||||
ProviderEntry("openai-codex", "OpenAI Codex", "OpenAI Codex"),
|
||||
ProviderEntry("xiaomi", "Xiaomi MiMo", "Xiaomi MiMo (MiMo-V2.5 and V2 models — pro, omni, flash)"),
|
||||
|
|
@ -804,6 +803,7 @@ CANONICAL_PROVIDERS: list[ProviderEntry] = [
|
|||
ProviderEntry("opencode-go", "OpenCode Go", "OpenCode Go (open models, $10/month subscription)"),
|
||||
ProviderEntry("bedrock", "AWS Bedrock", "AWS Bedrock (Claude, Nova, Llama, DeepSeek — IAM or API key)"),
|
||||
ProviderEntry("azure-foundry", "Azure Foundry", "Azure Foundry (OpenAI-style or Anthropic-style endpoint — your Azure AI deployment)"),
|
||||
ProviderEntry("ai-gateway", "Vercel AI Gateway", "Vercel AI Gateway"),
|
||||
]
|
||||
|
||||
# Derived dicts — used throughout the codebase
|
||||
|
|
|
|||
|
|
@ -33,12 +33,15 @@ so plugin-defined tools appear alongside the built-in tools.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
import importlib.metadata
|
||||
import importlib.util
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import types
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
|
@ -1226,6 +1229,55 @@ def get_plugin_command_handler(name: str) -> Optional[Callable]:
|
|||
return entry["handler"] if entry else None
|
||||
|
||||
|
||||
_PLUGIN_COMMAND_AWAIT_TIMEOUT_SECS = 30.0
|
||||
|
||||
|
||||
def resolve_plugin_command_result(result: Any) -> Any:
|
||||
"""Resolve a plugin command return value, awaiting async handlers when needed.
|
||||
|
||||
Sync CLI/TUI dispatch sites call plugin handlers from plain functions.
|
||||
If a handler is async, await it directly when no loop is running; if
|
||||
we're already inside an active loop, run it in a helper thread with its
|
||||
own loop so the caller still gets a concrete result synchronously. The
|
||||
threaded path is bounded by a 30s timeout so a hung async handler cannot
|
||||
wedge the terminal indefinitely.
|
||||
"""
|
||||
if not inspect.isawaitable(result):
|
||||
return result
|
||||
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return asyncio.run(result)
|
||||
|
||||
outcome: Dict[str, Any] = {}
|
||||
failure: Dict[str, BaseException] = {}
|
||||
done = threading.Event()
|
||||
|
||||
def _runner() -> None:
|
||||
try:
|
||||
outcome["value"] = asyncio.run(result)
|
||||
except BaseException as exc: # pragma: no cover - re-raised below
|
||||
failure["exc"] = exc
|
||||
finally:
|
||||
done.set()
|
||||
|
||||
thread = threading.Thread(
|
||||
target=_runner,
|
||||
name="hermes-plugin-command-await",
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
if not done.wait(timeout=_PLUGIN_COMMAND_AWAIT_TIMEOUT_SECS):
|
||||
raise TimeoutError(
|
||||
"Plugin command async handler did not complete within "
|
||||
f"{_PLUGIN_COMMAND_AWAIT_TIMEOUT_SECS:.0f}s"
|
||||
)
|
||||
if "exc" in failure:
|
||||
raise failure["exc"]
|
||||
return outcome.get("value")
|
||||
|
||||
|
||||
def get_plugin_commands() -> Dict[str, dict]:
|
||||
"""Return the full plugin commands dict (name → {handler, description, plugin}).
|
||||
|
||||
|
|
|
|||
|
|
@ -15,13 +15,18 @@ import shutil
|
|||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.config import cfg_get
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PluginOperationError(Exception):
|
||||
"""Recoverable plugin install/update failure (CLI exits; HTTP maps to 4xx)."""
|
||||
|
||||
|
||||
# Minimum manifest version this installer understands.
|
||||
# Plugins may declare ``manifest_version: 1`` in plugin.yaml;
|
||||
# future breaking changes to the manifest schema bump this.
|
||||
|
|
@ -150,6 +155,24 @@ def _copy_example_files(plugin_dir: Path, console) -> None:
|
|||
)
|
||||
|
||||
|
||||
def _missing_requires_env_names(manifest: dict) -> list[str]:
|
||||
"""Return declared ``requires_env`` names that are unset in ``~/.hermes/.env``."""
|
||||
requires_env = manifest.get("requires_env") or []
|
||||
if not requires_env:
|
||||
return []
|
||||
|
||||
from hermes_cli.config import get_env_value
|
||||
|
||||
env_specs: list[dict] = []
|
||||
for entry in requires_env:
|
||||
if isinstance(entry, str):
|
||||
env_specs.append({"name": entry})
|
||||
elif isinstance(entry, dict) and entry.get("name"):
|
||||
env_specs.append(entry)
|
||||
|
||||
return [s["name"] for s in env_specs if s.get("name") and not get_env_value(s["name"])]
|
||||
|
||||
|
||||
def _prompt_plugin_env_vars(manifest: dict, console) -> None:
|
||||
"""Prompt for required environment variables declared in plugin.yaml.
|
||||
|
||||
|
|
@ -283,6 +306,95 @@ def _require_installed_plugin(name: str, plugins_dir: Path, console) -> Path:
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _install_plugin_core(identifier: str, *, force: bool) -> tuple[Path, dict, str]:
|
||||
"""Clone Git plugin into ``~/.hermes/plugins``.
|
||||
|
||||
Returns ``(target_dir, installed_manifest, canonical_name)``.
|
||||
Raises ``PluginOperationError`` on failure.
|
||||
"""
|
||||
import tempfile
|
||||
|
||||
try:
|
||||
git_url = _resolve_git_url(identifier)
|
||||
except ValueError as e:
|
||||
raise PluginOperationError(str(e)) from e
|
||||
|
||||
plugins_dir = _plugins_dir()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_target = Path(tmp) / "plugin"
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "clone", "--depth", "1", git_url, str(tmp_target)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
raise PluginOperationError(
|
||||
"git is not installed or not in PATH.",
|
||||
) from e
|
||||
except subprocess.TimeoutExpired as e:
|
||||
raise PluginOperationError(
|
||||
"Git clone timed out after 60 seconds.",
|
||||
) from e
|
||||
|
||||
if result.returncode != 0:
|
||||
err = (result.stderr or result.stdout or "").strip()
|
||||
raise PluginOperationError(f"Git clone failed:\n{err}")
|
||||
|
||||
manifest = _read_manifest(tmp_target)
|
||||
plugin_name = manifest.get("name") or _repo_name_from_url(git_url)
|
||||
|
||||
try:
|
||||
target = _sanitize_plugin_name(plugin_name, plugins_dir)
|
||||
except ValueError as e:
|
||||
raise PluginOperationError(str(e)) from e
|
||||
|
||||
mv = manifest.get("manifest_version")
|
||||
if mv is not None:
|
||||
try:
|
||||
mv_int = int(mv)
|
||||
except (ValueError, TypeError):
|
||||
raise PluginOperationError(
|
||||
f"Plugin '{plugin_name}' has invalid manifest_version "
|
||||
f"'{mv}' (expected an integer).",
|
||||
) from None
|
||||
if mv_int > _SUPPORTED_MANIFEST_VERSION:
|
||||
from hermes_cli.config import recommended_update_command
|
||||
|
||||
raise PluginOperationError(
|
||||
f"Plugin '{plugin_name}' requires manifest_version {mv}, "
|
||||
f"but this installer only supports up to {_SUPPORTED_MANIFEST_VERSION}. "
|
||||
f"Run {recommended_update_command()} to update Hermes.",
|
||||
) from None
|
||||
|
||||
if target.exists():
|
||||
if not force:
|
||||
raise PluginOperationError(
|
||||
f"Plugin '{plugin_name}' already exists. Use force reinstall "
|
||||
f"or run `hermes plugins update {plugin_name}`.",
|
||||
)
|
||||
shutil.rmtree(target)
|
||||
|
||||
shutil.move(str(tmp_target), str(target))
|
||||
|
||||
has_yaml = (target / "plugin.yaml").exists() or (target / "plugin.yml").exists()
|
||||
if not has_yaml and not (target / "__init__.py").exists():
|
||||
logger.warning(
|
||||
"%s has no plugin.yaml / __init__.py; may not be a valid plugin",
|
||||
plugin_name,
|
||||
)
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
_copy_example_files(target, Console())
|
||||
installed_manifest = _read_manifest(target)
|
||||
installed_name = installed_manifest.get("name") or target.name
|
||||
return target, installed_manifest, installed_name
|
||||
|
||||
|
||||
def cmd_install(
|
||||
identifier: str,
|
||||
force: bool = False,
|
||||
|
|
@ -293,7 +405,6 @@ def cmd_install(
|
|||
After install, prompt "Enable now? [y/N]" unless *enable* is provided
|
||||
(True = auto-enable without prompting, False = install disabled).
|
||||
"""
|
||||
import tempfile
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
|
|
@ -304,114 +415,41 @@ def cmd_install(
|
|||
console.print(f"[red]Error:[/red] {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Warn about insecure / local URL schemes
|
||||
if git_url.startswith(("http://", "file://")):
|
||||
console.print(
|
||||
"[yellow]Warning:[/yellow] Using insecure/local URL scheme. "
|
||||
"Consider using https:// or git@ for production installs."
|
||||
"Consider using https:// or git@ for production installs.",
|
||||
)
|
||||
|
||||
plugins_dir = _plugins_dir()
|
||||
console.print(f"[dim]Cloning {git_url}...[/dim]")
|
||||
|
||||
# Clone into a temp directory first so we can read plugin.yaml for the name
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_target = Path(tmp) / "plugin"
|
||||
console.print(f"[dim]Cloning {git_url}...[/dim]")
|
||||
try:
|
||||
target, installed_manifest, installed_name = _install_plugin_core(
|
||||
identifier,
|
||||
force=force,
|
||||
)
|
||||
except PluginOperationError as e:
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "clone", "--depth", "1", git_url, str(tmp_target)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
console.print("[red]Error:[/red] git is not installed or not in PATH.")
|
||||
sys.exit(1)
|
||||
except subprocess.TimeoutExpired:
|
||||
console.print("[red]Error:[/red] Git clone timed out after 60 seconds.")
|
||||
sys.exit(1)
|
||||
|
||||
if result.returncode != 0:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Git clone failed:\n{result.stderr.strip()}"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# Read manifest
|
||||
manifest = _read_manifest(tmp_target)
|
||||
plugin_name = manifest.get("name") or _repo_name_from_url(git_url)
|
||||
|
||||
# Sanitize plugin name against path traversal
|
||||
try:
|
||||
target = _sanitize_plugin_name(plugin_name, plugins_dir)
|
||||
except ValueError as e:
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Check manifest_version compatibility
|
||||
mv = manifest.get("manifest_version")
|
||||
if mv is not None:
|
||||
try:
|
||||
mv_int = int(mv)
|
||||
except (ValueError, TypeError):
|
||||
console.print(
|
||||
f"[red]Error:[/red] Plugin '{plugin_name}' has invalid "
|
||||
f"manifest_version '{mv}' (expected an integer)."
|
||||
)
|
||||
sys.exit(1)
|
||||
if mv_int > _SUPPORTED_MANIFEST_VERSION:
|
||||
from hermes_cli.config import recommended_update_command
|
||||
console.print(
|
||||
f"[red]Error:[/red] Plugin '{plugin_name}' requires manifest_version "
|
||||
f"{mv}, but this installer only supports up to {_SUPPORTED_MANIFEST_VERSION}.\n"
|
||||
f"Run [bold]{recommended_update_command()}[/bold] to get a newer installer."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
if target.exists():
|
||||
if not force:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Plugin '{plugin_name}' already exists at {target}.\n"
|
||||
f"Use [bold]--force[/bold] to remove and reinstall, or "
|
||||
f"[bold]hermes plugins update {plugin_name}[/bold] to pull latest."
|
||||
)
|
||||
sys.exit(1)
|
||||
console.print(f"[dim] Removing existing {plugin_name}...[/dim]")
|
||||
shutil.rmtree(target)
|
||||
|
||||
# Move from temp to final location
|
||||
shutil.move(str(tmp_target), str(target))
|
||||
|
||||
# Validate it looks like a plugin
|
||||
if not (target / "plugin.yaml").exists() and not (target / "__init__.py").exists():
|
||||
if not (target / "plugin.yaml").exists() and not (target / "plugin.yml").exists() and not (
|
||||
target / "__init__.py"
|
||||
).exists():
|
||||
console.print(
|
||||
f"[yellow]Warning:[/yellow] {plugin_name} doesn't contain plugin.yaml "
|
||||
f"or __init__.py. It may not be a valid Hermes plugin."
|
||||
f"[yellow]Warning:[/yellow] {installed_name} doesn't contain plugin.yaml "
|
||||
f"or __init__.py. It may not be a valid Hermes plugin.",
|
||||
)
|
||||
|
||||
# Copy .example files to their real names (e.g. config.yaml.example → config.yaml)
|
||||
_copy_example_files(target, console)
|
||||
|
||||
# Re-read manifest from installed location (for env var prompting)
|
||||
installed_manifest = _read_manifest(target)
|
||||
|
||||
# Prompt for required environment variables before showing after-install docs
|
||||
_prompt_plugin_env_vars(installed_manifest, console)
|
||||
|
||||
_display_after_install(target, identifier)
|
||||
|
||||
# Determine the canonical plugin name for enable-list bookkeeping.
|
||||
installed_name = installed_manifest.get("name") or target.name
|
||||
|
||||
# Decide whether to enable: explicit flag > interactive prompt > default off
|
||||
should_enable = enable
|
||||
if should_enable is None:
|
||||
# Interactive prompt unless stdin isn't a TTY (scripted install).
|
||||
if sys.stdin.isatty() and sys.stdout.isatty():
|
||||
try:
|
||||
answer = input(
|
||||
f" Enable '{installed_name}' now? [y/N]: "
|
||||
f" Enable '{installed_name}' now? [y/N]: ",
|
||||
).strip().lower()
|
||||
should_enable = answer in ("y", "yes")
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
|
|
@ -427,12 +465,12 @@ def cmd_install(
|
|||
_save_enabled_set(enabled)
|
||||
_save_disabled_set(disabled)
|
||||
console.print(
|
||||
f"[green]✓[/green] Plugin [bold]{installed_name}[/bold] enabled."
|
||||
f"[green]✓[/green] Plugin [bold]{installed_name}[/bold] enabled.",
|
||||
)
|
||||
else:
|
||||
console.print(
|
||||
f"[dim]Plugin installed but not enabled. "
|
||||
f"Run `hermes plugins enable {installed_name}` to activate.[/dim]"
|
||||
f"Run `hermes plugins enable {installed_name}` to activate.[/dim]",
|
||||
)
|
||||
|
||||
console.print("[dim]Restart the gateway for the plugin to take effect:[/dim]")
|
||||
|
|
@ -462,36 +500,22 @@ def cmd_update(name: str) -> None:
|
|||
|
||||
console.print(f"[dim]Updating {name}...[/dim]")
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "pull", "--ff-only"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
cwd=str(target),
|
||||
)
|
||||
except FileNotFoundError:
|
||||
console.print("[red]Error:[/red] git is not installed or not in PATH.")
|
||||
sys.exit(1)
|
||||
except subprocess.TimeoutExpired:
|
||||
console.print("[red]Error:[/red] Git pull timed out after 60 seconds.")
|
||||
sys.exit(1)
|
||||
|
||||
if result.returncode != 0:
|
||||
console.print(f"[red]Error:[/red] Git pull failed:\n{result.stderr.strip()}")
|
||||
ok, output = _git_pull_plugin_dir(target)
|
||||
if not ok:
|
||||
console.print(f"[red]Error:[/red] {output}")
|
||||
sys.exit(1)
|
||||
|
||||
# Copy any new .example files
|
||||
_copy_example_files(target, console)
|
||||
|
||||
output = result.stdout.strip()
|
||||
if "Already up to date" in output:
|
||||
out = output.strip()
|
||||
if "Already up to date" in out:
|
||||
console.print(
|
||||
f"[green]✓[/green] Plugin [bold]{name}[/bold] is already up to date."
|
||||
)
|
||||
else:
|
||||
console.print(f"[green]✓[/green] Plugin [bold]{name}[/bold] updated.")
|
||||
console.print(f"[dim]{output}[/dim]")
|
||||
console.print(f"[dim]{out}[/dim]")
|
||||
|
||||
|
||||
def cmd_remove(name: str) -> None:
|
||||
|
|
@ -1244,6 +1268,247 @@ def _run_composite_fallback(plugin_names, plugin_labels, plugin_selected,
|
|||
print()
|
||||
|
||||
|
||||
def dashboard_install_plugin(
|
||||
identifier: str,
|
||||
*,
|
||||
force: bool,
|
||||
enable: bool,
|
||||
) -> dict[str, Any]:
|
||||
"""Non-interactive install for the web dashboard. Returns a JSON-serializable dict."""
|
||||
warnings: list[str] = []
|
||||
try:
|
||||
git_url = _resolve_git_url(identifier)
|
||||
if git_url.startswith(("http://", "file://")):
|
||||
warnings.append(
|
||||
"Insecure URL scheme; prefer https:// or git@ for production installs.",
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
try:
|
||||
target, installed_manifest, installed_name = _install_plugin_core(
|
||||
identifier,
|
||||
force=force,
|
||||
)
|
||||
except PluginOperationError as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
|
||||
missing_env = _missing_requires_env_names(installed_manifest)
|
||||
if enable:
|
||||
en = _get_enabled_set()
|
||||
dis = _get_disabled_set()
|
||||
en.add(installed_name)
|
||||
dis.discard(installed_name)
|
||||
_save_enabled_set(en)
|
||||
_save_disabled_set(dis)
|
||||
|
||||
hint: str | None = None
|
||||
ap = target / "after-install.md"
|
||||
if ap.exists():
|
||||
hint = str(ap)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"plugin_name": installed_name,
|
||||
"warnings": warnings,
|
||||
"missing_env": missing_env,
|
||||
"after_install_path": hint,
|
||||
"enabled": enable,
|
||||
}
|
||||
|
||||
|
||||
def _get_plugin_toolset_key(name: str) -> Optional[str]:
|
||||
"""Return the toolset key a plugin registers its tools under, or None.
|
||||
|
||||
Queries the live tool registry — the plugin must already be loaded.
|
||||
Falls back to reading ``provides_tools`` from plugin.yaml and looking
|
||||
up the toolset from the registry for the first tool name found.
|
||||
"""
|
||||
try:
|
||||
from tools.registry import registry
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# Check the plugin manager for tools this plugin registered
|
||||
try:
|
||||
from hermes_cli.plugins import discover_plugins, get_plugin_manager
|
||||
discover_plugins() # idempotent — ensures plugins are loaded
|
||||
manager = get_plugin_manager()
|
||||
for _key, loaded in manager._plugins.items():
|
||||
if loaded.manifest.name == name or _key == name:
|
||||
for tool_name in loaded.tools_registered:
|
||||
entry = registry.get_entry(tool_name)
|
||||
if entry and entry.toolset:
|
||||
return entry.toolset
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback: read provides_tools from manifest on disk and query registry
|
||||
try:
|
||||
from hermes_cli.plugins import get_bundled_plugins_dir
|
||||
for base in (get_bundled_plugins_dir(), _plugins_dir()):
|
||||
if not base.is_dir():
|
||||
continue
|
||||
candidate = base / name
|
||||
if candidate.is_dir():
|
||||
manifest = _read_manifest(candidate)
|
||||
for tool_name in manifest.get("provides_tools") or []:
|
||||
entry = registry.get_entry(tool_name)
|
||||
if entry and entry.toolset:
|
||||
return entry.toolset
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _toggle_plugin_toolset(name: str, *, enable: bool) -> None:
|
||||
"""Add or remove a plugin's toolset from platform_toolsets for all platforms.
|
||||
|
||||
Only acts if the plugin actually provides tools (has a toolset key).
|
||||
"""
|
||||
toolset_key = _get_plugin_toolset_key(name)
|
||||
if not toolset_key:
|
||||
return
|
||||
|
||||
from hermes_cli.config import load_config, save_config
|
||||
|
||||
config = load_config()
|
||||
platform_toolsets = config.get("platform_toolsets")
|
||||
if not isinstance(platform_toolsets, dict):
|
||||
platform_toolsets = {}
|
||||
config["platform_toolsets"] = platform_toolsets
|
||||
|
||||
changed = False
|
||||
for platform, ts_list in platform_toolsets.items():
|
||||
if not isinstance(ts_list, list):
|
||||
continue
|
||||
if enable:
|
||||
if toolset_key not in ts_list:
|
||||
ts_list.append(toolset_key)
|
||||
changed = True
|
||||
else:
|
||||
if toolset_key in ts_list:
|
||||
ts_list.remove(toolset_key)
|
||||
changed = True
|
||||
|
||||
# If enabling and no platforms have toolset lists yet, add to "cli" at minimum
|
||||
if enable and not changed and not platform_toolsets:
|
||||
platform_toolsets["cli"] = [toolset_key]
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
save_config(config)
|
||||
|
||||
|
||||
def dashboard_set_agent_plugin_enabled(name: str, *, enabled: bool) -> dict[str, Any]:
|
||||
"""Enable or disable a plugin in ``config.yaml`` (runtime allow/deny lists).
|
||||
|
||||
For plugins that provide tools (toolsets), also toggles the toolset in
|
||||
``platform_toolsets`` so the agent actually sees the tools in sessions.
|
||||
"""
|
||||
if not _plugin_exists(name):
|
||||
return {"ok": False, "error": f"Plugin '{name}' is not installed or bundled."}
|
||||
|
||||
en = _get_enabled_set()
|
||||
dis = _get_disabled_set()
|
||||
|
||||
if enabled:
|
||||
if name in en and name not in dis:
|
||||
return {"ok": True, "name": name, "unchanged": True}
|
||||
en.add(name)
|
||||
dis.discard(name)
|
||||
_save_enabled_set(en)
|
||||
_save_disabled_set(dis)
|
||||
_toggle_plugin_toolset(name, enable=True)
|
||||
return {"ok": True, "name": name, "unchanged": False}
|
||||
|
||||
if name not in en and name in dis:
|
||||
return {"ok": True, "name": name, "unchanged": True}
|
||||
|
||||
en.discard(name)
|
||||
dis.add(name)
|
||||
_save_enabled_set(en)
|
||||
_save_disabled_set(dis)
|
||||
_toggle_plugin_toolset(name, enable=False)
|
||||
return {"ok": True, "name": name, "unchanged": False}
|
||||
|
||||
|
||||
def _user_installed_plugin_dir(name: str) -> Optional[Path]:
|
||||
"""Resolved path under ``~/.hermes/plugins/<name>`` if it exists."""
|
||||
plugins_dir = _plugins_dir()
|
||||
try:
|
||||
target = _sanitize_plugin_name(name, plugins_dir)
|
||||
except ValueError:
|
||||
return None
|
||||
return target if target.is_dir() else None
|
||||
|
||||
|
||||
def dashboard_update_user_plugin(name: str) -> dict[str, Any]:
|
||||
"""``git pull`` inside ``~/.hermes/plugins/<name>``."""
|
||||
target = _user_installed_plugin_dir(name)
|
||||
if target is None:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": f"Plugin '{name}' was not found under {_plugins_dir()}.",
|
||||
}
|
||||
|
||||
if not (target / ".git").exists():
|
||||
return {
|
||||
"ok": False,
|
||||
"error": f"Plugin '{name}' is not a git checkout; cannot pull updates.",
|
||||
}
|
||||
|
||||
ok, msg = _git_pull_plugin_dir(target)
|
||||
if not ok:
|
||||
return {"ok": False, "error": msg}
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
_copy_example_files(target, Console())
|
||||
unchanged = "Already up to date" in msg
|
||||
return {"ok": True, "name": name, "output": msg, "unchanged": unchanged}
|
||||
|
||||
|
||||
def _git_pull_plugin_dir(target: Path) -> tuple[bool, str]:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "pull", "--ff-only"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
cwd=str(target),
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return False, "git is not installed or not in PATH."
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "Git pull timed out after 60 seconds."
|
||||
|
||||
if result.returncode != 0:
|
||||
err = (result.stderr or "").strip() or result.stdout.strip()
|
||||
return False, err or "git pull failed."
|
||||
return True, result.stdout.strip()
|
||||
|
||||
|
||||
def dashboard_remove_user_plugin(name: str) -> dict[str, Any]:
|
||||
"""Delete a plugin tree under ``~/.hermes/plugins/`` only."""
|
||||
plugins_dir = _plugins_dir()
|
||||
for n, _ver, _d, src, _path in _discover_all_plugins():
|
||||
if n == name and src == "bundled":
|
||||
return {"ok": False, "error": "Bundled plugins cannot be removed from the dashboard."}
|
||||
|
||||
target = _user_installed_plugin_dir(name)
|
||||
if target is None:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": f"Plugin '{name}' was not found under {plugins_dir}.",
|
||||
}
|
||||
|
||||
shutil.rmtree(target)
|
||||
return {"ok": True, "name": name}
|
||||
|
||||
|
||||
def plugins_command(args) -> None:
|
||||
"""Dispatch hermes plugins subcommands."""
|
||||
action = getattr(args, "plugins_action", None)
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue