@bitseek/hermes-webui 0.1.0-beta.0 → 0.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/vendor/agent-frontend-shell/.bitseek-source.json +2 -2
- package/vendor/agent-frontend-shell/CHANGELOG.md +178 -1
- package/vendor/agent-frontend-shell/CONTRIBUTORS.md +5 -5
- package/vendor/agent-frontend-shell/api/agent_health.py +134 -0
- package/vendor/agent-frontend-shell/api/config.py +145 -104
- package/vendor/agent-frontend-shell/api/gateway_chat.py +56 -12
- package/vendor/agent-frontend-shell/api/helpers.py +4 -2
- package/vendor/agent-frontend-shell/api/models.py +202 -20
- package/vendor/agent-frontend-shell/api/paths.py +77 -0
- package/vendor/agent-frontend-shell/api/plugins.py +185 -0
- package/vendor/agent-frontend-shell/api/profiles.py +95 -16
- package/vendor/agent-frontend-shell/api/routes.py +831 -30
- package/vendor/agent-frontend-shell/api/run_journal.py +1 -0
- package/vendor/agent-frontend-shell/api/state_sync.py +5 -4
- package/vendor/agent-frontend-shell/api/streaming.py +211 -56
- package/vendor/agent-frontend-shell/api/todo_state.py +122 -0
- package/vendor/agent-frontend-shell/api/updates.py +30 -3
- package/vendor/agent-frontend-shell/api/upload.py +251 -18
- package/vendor/agent-frontend-shell/api/workspace.py +323 -65
- package/vendor/agent-frontend-shell/bitseek_docs/BitSeek_Claw_Operation_Manual_EN.docx +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/BitSeek_Claw_Operation_Manual_ZH.docx +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/00-Installation.md +174 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/01-Overview.md +128 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/02-Page-Operations.md +461 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/README.md +61 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/ai-colleagues.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/chat-area.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/kanban.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/main-page.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory-notes.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory-overview.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory-profile.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory-soul.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/navigation-bar.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-appearance.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-conversation.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-overview.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-plugins.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-preferences.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-providers.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-system.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/sidebar.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/skills.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/tasks.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/workspace-panel.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/md_to_docx.py +351 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/00-/345/256/211/350/243/205/345/220/257/345/212/250.md +174 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/01-/346/225/264/344/275/223/346/246/202/350/247/210.md +128 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/02-/351/241/265/351/235/242/346/223/215/344/275/234.md +463 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/README.md +61 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/ai-colleagues.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/chat-area.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/kanban.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/main-page.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory-notes.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory-overview.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory-profile.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory-soul.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/navigation-bar.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-appearance.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-conversation.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-overview.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-plugins.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-preferences.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-providers.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-system.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/sidebar.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/skills.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/tasks.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/workspace-panel.png +0 -0
- package/vendor/agent-frontend-shell/build-release.sh +62 -0
- package/vendor/agent-frontend-shell/ctl.sh +1 -0
- package/vendor/agent-frontend-shell/docker-compose.local.yml +33 -0
- package/vendor/agent-frontend-shell/docker-compose.yml +8 -0
- package/vendor/agent-frontend-shell/docker_init.bash +1 -0
- package/vendor/agent-frontend-shell/docs/rfcs/hermes-run-adapter-contract.md +74 -15
- package/vendor/agent-frontend-shell/extensions/common/index.css +6 -0
- package/vendor/agent-frontend-shell/extensions/manifest.json +6 -0
- package/vendor/agent-frontend-shell/extensions/pages/ai-teammates/page.js +60 -14
- package/vendor/agent-frontend-shell/readme-simple.md +103 -0
- package/vendor/agent-frontend-shell/requirements.txt +5 -0
- package/vendor/agent-frontend-shell/server.py +7 -0
- package/vendor/agent-frontend-shell/static/boot.js +53 -1
- package/vendor/agent-frontend-shell/static/commands.js +20 -10
- package/vendor/agent-frontend-shell/static/i18n.js +1142 -1016
- package/vendor/agent-frontend-shell/static/index.html +13 -3
- package/vendor/agent-frontend-shell/static/messages.js +48 -3
- package/vendor/agent-frontend-shell/static/panels.js +199 -30
- package/vendor/agent-frontend-shell/static/sessions.js +249 -39
- package/vendor/agent-frontend-shell/static/style.css +46 -2
- package/vendor/agent-frontend-shell/static/ui.js +323 -79
- package/vendor/agent-frontend-shell/static/workspace.js +185 -7
- package/vendor/agent-frontend-shell/README-CUSTOM.md +0 -76
- package/vendor/agent-frontend-shell/docker-compose.custom.yml +0 -26
|
@@ -262,6 +262,7 @@ def stale_interrupted_event(session_id: str, run_id: str, *, after_seq: int | No
|
|
|
262
262
|
return None
|
|
263
263
|
payload = {
|
|
264
264
|
"type": "interrupted",
|
|
265
|
+
"recovery_control": True,
|
|
265
266
|
"message": "The live worker stopped before this run finished.",
|
|
266
267
|
"hint": "The transcript was restored to the last journaled event. Start a new turn if you still need the task to continue.",
|
|
267
268
|
"session_id": session_id,
|
|
@@ -16,11 +16,12 @@ any double-counting risk.
|
|
|
16
16
|
import logging
|
|
17
17
|
import os
|
|
18
18
|
from pathlib import Path
|
|
19
|
+
from typing import Optional
|
|
19
20
|
|
|
20
21
|
logger = logging.getLogger(__name__)
|
|
21
22
|
|
|
22
23
|
|
|
23
|
-
def _get_state_db(profile: str=None):
|
|
24
|
+
def _get_state_db(profile: Optional[str] = None):
|
|
24
25
|
"""Get a SessionDB instance for a profile's state.db.
|
|
25
26
|
|
|
26
27
|
When ``profile`` is provided the function resolves *that* profile's
|
|
@@ -104,7 +105,7 @@ def _get_state_db(profile: str=None):
|
|
|
104
105
|
return None
|
|
105
106
|
|
|
106
107
|
|
|
107
|
-
def sync_session_start(session_id: str, model=None, profile: str=None) -> None:
|
|
108
|
+
def sync_session_start(session_id: str, model=None, profile: Optional[str] = None) -> None:
|
|
108
109
|
"""Register a WebUI session in state.db (idempotent).
|
|
109
110
|
Called when a session's first message is sent.
|
|
110
111
|
|
|
@@ -132,8 +133,8 @@ def sync_session_start(session_id: str, model=None, profile: str=None) -> None:
|
|
|
132
133
|
|
|
133
134
|
|
|
134
135
|
def sync_session_usage(session_id: str, input_tokens: int=0, output_tokens: int=0,
|
|
135
|
-
estimated_cost=None, model=None, title: str=None,
|
|
136
|
-
message_count: int=None, profile: str=None) -> None:
|
|
136
|
+
estimated_cost=None, model=None, title: Optional[str] = None,
|
|
137
|
+
message_count: Optional[int] = None, profile: Optional[str] = None) -> None:
|
|
137
138
|
"""Update token usage and title for a WebUI session in state.db.
|
|
138
139
|
Called after each turn completes. Uses absolute=True to set totals
|
|
139
140
|
(the WebUI Session already accumulates across turns).
|
|
@@ -203,7 +203,8 @@ WebUI progress guidance:
|
|
|
203
203
|
- Each update should say what you are about to check, what you just confirmed, or why the next tool call is needed.
|
|
204
204
|
- Keep updates concise, factual, and in the user's language. One or two short sentences are enough.
|
|
205
205
|
- Do not reveal hidden reasoning, chain-of-thought, private scratchpads, secrets, raw logs, or long tool output.
|
|
206
|
-
-
|
|
206
|
+
- Final visible assistant replies must be clear, user-facing, and in the user's language, not private planning notes.
|
|
207
|
+
- Do not include terse planning fragments or scratchpad shorthand in visible assistant text. Avoid fragments like "Need script", "Need check logs", "Need inspect email", or "maybe invite"; either omit them or rewrite them as clear user-facing progress.
|
|
207
208
|
- For direct answers or very short tasks, skip progress updates and answer normally.
|
|
208
209
|
""".strip()
|
|
209
210
|
|
|
@@ -244,6 +245,7 @@ def _webui_surface_context_prompt(surface_context: Optional[dict]) -> str:
|
|
|
244
245
|
def _webui_ephemeral_system_prompt(
|
|
245
246
|
personality_prompt: Optional[str],
|
|
246
247
|
surface_context: Optional[dict] = None,
|
|
248
|
+
config_data: Optional[dict] = None,
|
|
247
249
|
) -> str:
|
|
248
250
|
"""Build WebUI-only runtime instructions that are not persisted to history."""
|
|
249
251
|
parts = []
|
|
@@ -253,6 +255,9 @@ def _webui_ephemeral_system_prompt(
|
|
|
253
255
|
if surface_prompt:
|
|
254
256
|
parts.append(surface_prompt)
|
|
255
257
|
parts.append(_WEBUI_PROGRESS_PROMPT)
|
|
258
|
+
delivery_prompt = _webui_delivery_context_prompt(config_data)
|
|
259
|
+
if delivery_prompt:
|
|
260
|
+
parts.append(delivery_prompt)
|
|
256
261
|
return "\n\n".join(part for part in parts if part)
|
|
257
262
|
|
|
258
263
|
|
|
@@ -497,48 +502,45 @@ def _public_prefill_context_status(prefill_context: dict) -> dict:
|
|
|
497
502
|
}
|
|
498
503
|
|
|
499
504
|
|
|
500
|
-
def
|
|
501
|
-
"""Return
|
|
505
|
+
def _webui_delivery_context_prompt(config_data: Optional[dict] = None) -> str:
|
|
506
|
+
"""Return platform/delivery context for the ephemeral system prompt.
|
|
507
|
+
|
|
508
|
+
Connected platforms, home channels, and scheduled-task delivery hints
|
|
509
|
+
are injected into the system prompt (safe for role alternation) rather
|
|
510
|
+
than as a prefill ``user`` message, which strict chat templates (Mistral,
|
|
511
|
+
Gemma) reject.
|
|
502
512
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
delivery targets.
|
|
513
|
+
NOTE: This function only covers platform/delivery info. The session
|
|
514
|
+
framing (\"Source: WebUI\", \"Session ID\", \"Profile\", \"Workspace\") is
|
|
515
|
+
emitted by ``_webui_surface_context_prompt()``, which is called from
|
|
516
|
+
``_webui_ephemeral_system_prompt()`` before this helper. If you
|
|
517
|
+
refactor this area, keep that surface call in place — the two helpers
|
|
518
|
+
together produce the full session context block.
|
|
510
519
|
"""
|
|
511
520
|
cfg = config_data if isinstance(config_data, dict) else get_config()
|
|
512
|
-
lines = [
|
|
513
|
-
"## Current Session Context",
|
|
514
|
-
"",
|
|
515
|
-
"**Source:** WebUI (browser session)",
|
|
516
|
-
"**Session type:** Browser-originated Hermes WebUI chat. This is a separate WebUI transcript, not the same live Telegram/Discord/other messaging thread.",
|
|
517
|
-
]
|
|
521
|
+
lines: list[str] = []
|
|
518
522
|
|
|
523
|
+
display_hermes_home = None
|
|
519
524
|
try:
|
|
520
|
-
from
|
|
521
|
-
|
|
522
|
-
profile_name = get_active_profile_name() or "default"
|
|
525
|
+
from hermes_constants import get_hermes_home, display_hermes_home as _dh
|
|
526
|
+
display_hermes_home = _dh
|
|
523
527
|
except Exception:
|
|
524
|
-
|
|
525
|
-
lines.append(f"**Active Hermes profile:** {profile_name}")
|
|
528
|
+
get_hermes_home = None # type: ignore[assignment]
|
|
526
529
|
|
|
527
530
|
connected = ["local (files on this machine)"]
|
|
528
531
|
try:
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
connected.append(f"{name}: Connected ✓")
|
|
532
|
+
if get_hermes_home is not None:
|
|
533
|
+
state_path = get_hermes_home() / "gateway_state.json"
|
|
534
|
+
if state_path.exists():
|
|
535
|
+
raw_state = json.loads(state_path.read_text(encoding="utf-8"))
|
|
536
|
+
platforms = raw_state.get("platforms") if isinstance(raw_state, dict) else {}
|
|
537
|
+
if isinstance(platforms, dict):
|
|
538
|
+
for name in sorted(platforms):
|
|
539
|
+
pdata = platforms.get(name) or {}
|
|
540
|
+
if isinstance(pdata, dict) and pdata.get("state") == "connected" and name != "local":
|
|
541
|
+
connected.append(f"{name}: Connected ✓")
|
|
540
542
|
except Exception:
|
|
541
|
-
|
|
543
|
+
pass
|
|
542
544
|
lines.append(f"**Connected Platforms:** {', '.join(connected)}")
|
|
543
545
|
|
|
544
546
|
home_channels = {}
|
|
@@ -566,7 +568,7 @@ def _webui_session_context_message(config_data: Optional[dict] = None) -> dict:
|
|
|
566
568
|
lines.append("**Delivery options for scheduled tasks:**")
|
|
567
569
|
lines.append("- `\"origin\"` → Back to this WebUI/browser session when the WebUI runtime supports origin delivery; otherwise prefer an explicit platform target.")
|
|
568
570
|
try:
|
|
569
|
-
home_display = display_hermes_home() if display_hermes_home else "~/.hermes"
|
|
571
|
+
home_display = display_hermes_home() if display_hermes_home else "~/.hermes"
|
|
570
572
|
except Exception:
|
|
571
573
|
home_display = "~/.hermes"
|
|
572
574
|
lines.append(f"- `\"local\"` → Save to local files only ({home_display}/cron/output/)")
|
|
@@ -575,14 +577,19 @@ def _webui_session_context_message(config_data: Optional[dict] = None) -> dict:
|
|
|
575
577
|
lines.append("")
|
|
576
578
|
lines.append("*For explicit targeting, use `\"platform:chat_id\"` format if the user provides a specific chat ID. Do not invent private IDs.*")
|
|
577
579
|
|
|
578
|
-
return
|
|
580
|
+
return "\n".join(lines)
|
|
579
581
|
|
|
580
582
|
|
|
581
583
|
def _prefill_messages_with_webui_context(prefill_context: dict, config_data: Optional[dict] = None) -> list[dict]:
|
|
582
|
-
"""Combine recall prefill with WebUI
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
584
|
+
"""Combine recall prefill with WebUI session context.
|
|
585
|
+
|
|
586
|
+
The session context (connected platforms, delivery hints) is injected
|
|
587
|
+
via ``_webui_ephemeral_system_prompt`` / ``ephemeral_system_prompt``
|
|
588
|
+
instead of as a prefill ``user`` message. Adding it as a user message
|
|
589
|
+
creates two consecutive user turns (prefill + actual) which strict chat
|
|
590
|
+
templates (Mistral, Gemma) reject with a Jinja 500.
|
|
591
|
+
"""
|
|
592
|
+
return list(prefill_context.get("messages") or [])
|
|
586
593
|
|
|
587
594
|
|
|
588
595
|
def _has_new_assistant_reply(all_messages: list, prev_count: int) -> bool:
|
|
@@ -1569,24 +1576,109 @@ def _detect_title_language(text: str) -> str:
|
|
|
1569
1576
|
return ''
|
|
1570
1577
|
|
|
1571
1578
|
|
|
1579
|
+
def _script_counts(text: str) -> dict:
|
|
1580
|
+
"""Return per-script alphabetic character counts for *text*.
|
|
1581
|
+
|
|
1582
|
+
Buckets: ``latin``, ``cjk`` (Han/Hiragana/Katakana/Hangul), ``cyrillic``,
|
|
1583
|
+
``arabic``, ``hebrew``, ``greek``, ``devanagari``. Non-alphabetic and
|
|
1584
|
+
unclassified characters are ignored.
|
|
1585
|
+
"""
|
|
1586
|
+
counts: dict[str, int] = {}
|
|
1587
|
+
for ch in str(text or ''):
|
|
1588
|
+
if not ch.isalpha():
|
|
1589
|
+
continue
|
|
1590
|
+
o = ord(ch)
|
|
1591
|
+
if (0x0041 <= o <= 0x024F) or (0x1E00 <= o <= 0x1EFF):
|
|
1592
|
+
bucket = 'latin'
|
|
1593
|
+
elif (
|
|
1594
|
+
(0x4E00 <= o <= 0x9FFF) or (0x3400 <= o <= 0x4DBF) # Han
|
|
1595
|
+
or (0x3040 <= o <= 0x30FF) # Hiragana/Katakana
|
|
1596
|
+
or (0xAC00 <= o <= 0xD7A3) or (0x1100 <= o <= 0x11FF) # Hangul
|
|
1597
|
+
):
|
|
1598
|
+
bucket = 'cjk'
|
|
1599
|
+
elif 0x0400 <= o <= 0x04FF:
|
|
1600
|
+
bucket = 'cyrillic'
|
|
1601
|
+
elif (0x0600 <= o <= 0x06FF) or (0x0750 <= o <= 0x077F):
|
|
1602
|
+
bucket = 'arabic'
|
|
1603
|
+
elif 0x0590 <= o <= 0x05FF:
|
|
1604
|
+
bucket = 'hebrew'
|
|
1605
|
+
elif 0x0370 <= o <= 0x03FF:
|
|
1606
|
+
bucket = 'greek'
|
|
1607
|
+
elif 0x0900 <= o <= 0x097F:
|
|
1608
|
+
bucket = 'devanagari'
|
|
1609
|
+
else:
|
|
1610
|
+
continue
|
|
1611
|
+
counts[bucket] = counts.get(bucket, 0) + 1
|
|
1612
|
+
return counts
|
|
1613
|
+
|
|
1614
|
+
|
|
1615
|
+
def _dominant_script(text: str) -> str:
|
|
1616
|
+
"""Return a coarse writing-script bucket for *text*, or '' when undecidable.
|
|
1617
|
+
|
|
1618
|
+
Script-level (not language-level) classification is cheap and dependency-free.
|
|
1619
|
+
Returns the dominant script only when it holds a clear (≥60%) majority of the
|
|
1620
|
+
alphabetic characters, so mixed/borrowed text doesn't flip the bucket. Used
|
|
1621
|
+
to establish the conversation start's expected script for cross-script title
|
|
1622
|
+
drift detection (#3293).
|
|
1623
|
+
"""
|
|
1624
|
+
counts = _script_counts(text)
|
|
1625
|
+
total = sum(counts.values())
|
|
1626
|
+
if total < 2:
|
|
1627
|
+
return ''
|
|
1628
|
+
top, top_n = max(counts.items(), key=lambda kv: kv[1])
|
|
1629
|
+
if top_n / total >= 0.6:
|
|
1630
|
+
return top
|
|
1631
|
+
return ''
|
|
1632
|
+
|
|
1633
|
+
|
|
1572
1634
|
def _title_prompt_language_rule(user_text: str) -> str:
|
|
1573
1635
|
return "Match the language of the user question.\n"
|
|
1574
1636
|
|
|
1575
1637
|
|
|
1576
1638
|
def _title_language_mismatch(user_text: str, title: str) -> bool:
|
|
1577
|
-
"""Reject
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1639
|
+
"""Reject titles whose language clearly diverges from the conversation start.
|
|
1640
|
+
|
|
1641
|
+
Two independent signals:
|
|
1642
|
+
1. Cross-script drift (#3293): when the conversation start has a clear
|
|
1643
|
+
dominant writing script (e.g. latin/English) and the generated title
|
|
1644
|
+
introduces a *substantial* amount of a different script (e.g. CJK or
|
|
1645
|
+
Cyrillic), reject. This is language-agnostic and catches the common
|
|
1646
|
+
"English chat -> Chinese/Spanish/Russian title" drift. Because titles are
|
|
1647
|
+
short and frequently embed a borrowed Latin technical term (e.g. a CJK
|
|
1648
|
+
title containing the word "Python"), the title side uses a proportion
|
|
1649
|
+
threshold (>=35% of the title's alphabetic characters in a non-start
|
|
1650
|
+
script, min 2 chars) rather than a strict majority -- so a CJK title with
|
|
1651
|
+
one English word still trips, while an English title with a single
|
|
1652
|
+
foreign place-name does not.
|
|
1653
|
+
2. The legacy German-start → English-title heuristic, preserved verbatim so
|
|
1654
|
+
the original behavior keeps working for same-script (latin) drift that
|
|
1655
|
+
the script check can't see.
|
|
1656
|
+
"""
|
|
1657
|
+
candidate = str(title or '').strip()
|
|
1581
1658
|
if not candidate:
|
|
1582
1659
|
return False
|
|
1583
|
-
|
|
1660
|
+
|
|
1661
|
+
# (1) Cross-script mismatch — language-agnostic.
|
|
1662
|
+
user_script = _dominant_script(user_text)
|
|
1663
|
+
if user_script:
|
|
1664
|
+
title_counts = _script_counts(candidate)
|
|
1665
|
+
title_total = sum(title_counts.values())
|
|
1666
|
+
if title_total >= 2:
|
|
1667
|
+
for script, n in title_counts.items():
|
|
1668
|
+
if script != user_script and n >= 2 and (n / title_total) >= 0.35:
|
|
1669
|
+
return True
|
|
1670
|
+
|
|
1671
|
+
# (2) Legacy same-script German→English heuristic.
|
|
1672
|
+
if _detect_title_language(user_text) != 'de':
|
|
1673
|
+
return False
|
|
1674
|
+
candidate_lower = candidate.lower()
|
|
1675
|
+
if _detect_title_language(candidate_lower) == 'de':
|
|
1584
1676
|
return False
|
|
1585
1677
|
english_markers = {
|
|
1586
1678
|
'old', 'image', 'display', 'issue', 'problem', 'discussion', 'conversation',
|
|
1587
1679
|
'session', 'title', 'fix', 'bug', 'attachment', 'attachments', 'context',
|
|
1588
1680
|
}
|
|
1589
|
-
tokens = re.findall(r'[a-z]+',
|
|
1681
|
+
tokens = re.findall(r'[a-z]+', candidate_lower)
|
|
1590
1682
|
english_hits = sum(1 for tok in tokens if tok in english_markers)
|
|
1591
1683
|
return english_hits >= 2
|
|
1592
1684
|
|
|
@@ -1794,6 +1886,17 @@ def generate_title_raw_via_aux(
|
|
|
1794
1886
|
provider = ''
|
|
1795
1887
|
model = model or configured.get('model', '') or ''
|
|
1796
1888
|
base_url = base_url or configured.get('base_url', '') or ''
|
|
1889
|
+
try:
|
|
1890
|
+
from api.profiles import _split_webui_provider_model_value
|
|
1891
|
+
|
|
1892
|
+
normalized_model, normalized_provider = _split_webui_provider_model_value(
|
|
1893
|
+
model or None,
|
|
1894
|
+
provider or None,
|
|
1895
|
+
)
|
|
1896
|
+
model = normalized_model or ''
|
|
1897
|
+
provider = normalized_provider or ''
|
|
1898
|
+
except ValueError:
|
|
1899
|
+
pass
|
|
1797
1900
|
api_key = ''
|
|
1798
1901
|
if not caller_supplied_route:
|
|
1799
1902
|
api_key = str(configured.get('api_key', '') or '').strip()
|
|
@@ -2262,6 +2365,35 @@ def _run_background_title_refresh(session_id: str, user_text: str, assistant_tex
|
|
|
2262
2365
|
logger.debug("Background title refresh failed for session %s", session_id, exc_info=True)
|
|
2263
2366
|
|
|
2264
2367
|
|
|
2368
|
+
|
|
2369
|
+
|
|
2370
|
+
def generate_session_title_for_session(session, *, prefer_latest: bool = False, agent=None) -> tuple[Optional[str], str, str]:
|
|
2371
|
+
"""Generate a session title on demand from persisted conversation messages.
|
|
2372
|
+
|
|
2373
|
+
This helper powers explicit UI title-regeneration controls. It intentionally
|
|
2374
|
+
does not inspect or mutate ``llm_title_generated``; callers decide whether
|
|
2375
|
+
replacing the current title is allowed, then persist the returned title.
|
|
2376
|
+
"""
|
|
2377
|
+
messages = getattr(session, 'messages', None) or []
|
|
2378
|
+
if prefer_latest:
|
|
2379
|
+
user_text, assistant_text = _latest_exchange_snippets(messages)
|
|
2380
|
+
else:
|
|
2381
|
+
user_text, assistant_text = _first_exchange_snippets(messages)
|
|
2382
|
+
if not user_text:
|
|
2383
|
+
return None, 'empty_user_message', ''
|
|
2384
|
+
from api import profiles as profiles_api
|
|
2385
|
+
|
|
2386
|
+
with profiles_api.profile_env_for_background_worker(session, "manual title regeneration", logger_override=logger):
|
|
2387
|
+
next_title, llm_status, raw_preview = _generate_llm_session_title_via_aux(user_text, assistant_text, agent=agent)
|
|
2388
|
+
if next_title:
|
|
2389
|
+
return next_title, llm_status, raw_preview
|
|
2390
|
+
fallback_title = _fallback_title_from_exchange(user_text, assistant_text)
|
|
2391
|
+
if fallback_title and not _is_generic_fallback_title(fallback_title):
|
|
2392
|
+
reason = f'local_summary:{llm_status}' if llm_status else 'local_summary'
|
|
2393
|
+
return fallback_title, reason, raw_preview
|
|
2394
|
+
return None, llm_status or 'empty_title', raw_preview
|
|
2395
|
+
|
|
2396
|
+
|
|
2265
2397
|
def _preserve_pre_compression_snapshot(s, old_sid: str) -> None:
|
|
2266
2398
|
"""Persist old_sid as a read-only pre-compression snapshot.
|
|
2267
2399
|
|
|
@@ -3081,14 +3213,25 @@ def _merge_display_messages_after_agent_result(previous_display, previous_contex
|
|
|
3081
3213
|
# match are context-only gaps that get spliced in before the display msg.
|
|
3082
3214
|
if previous_display and previous_context:
|
|
3083
3215
|
_display_id_set = {_message_identity(m) for m in previous_display}
|
|
3084
|
-
_context_id_set = {
|
|
3216
|
+
_context_id_set = {
|
|
3217
|
+
_message_identity(m)
|
|
3218
|
+
for m in previous_context
|
|
3219
|
+
if not _is_context_compression_marker(m)
|
|
3220
|
+
}
|
|
3085
3221
|
_has_context_only_turns = bool(_context_id_set - _display_id_set)
|
|
3086
3222
|
if _has_context_only_turns:
|
|
3087
3223
|
context_keys = [_message_identity(m) for m in previous_context]
|
|
3088
3224
|
_backfilled = []
|
|
3089
|
-
|
|
3225
|
+
# #3300 fix: track ONLY context rows we splice in, so the
|
|
3226
|
+
# visible-display backbone is never suppressed. Sharing one set
|
|
3227
|
+
# between context inserts and display rows (and _message_identity
|
|
3228
|
+
# ignoring timestamps) dropped a legitimate second identical visible
|
|
3229
|
+
# user turn. Display rows are always appended in order; a context
|
|
3230
|
+
# row is backfilled only if it isn't already a display row and
|
|
3231
|
+
# hasn't already been inserted.
|
|
3232
|
+
_context_inserted = set()
|
|
3090
3233
|
_cursor = 0
|
|
3091
|
-
for _dmsg in previous_display:
|
|
3234
|
+
for _display_idx, _dmsg in enumerate(previous_display):
|
|
3092
3235
|
_dkey = _message_identity(_dmsg)
|
|
3093
3236
|
if _dkey is not None:
|
|
3094
3237
|
_j = _cursor
|
|
@@ -3098,21 +3241,32 @@ def _merge_display_messages_after_agent_result(previous_display, previous_contex
|
|
|
3098
3241
|
for _k in range(_cursor, _j):
|
|
3099
3242
|
_ckey = context_keys[_k]
|
|
3100
3243
|
_cmsg = previous_context[_k]
|
|
3101
|
-
if _ckey is not None and _ckey not in
|
|
3244
|
+
if _ckey is not None and _ckey not in _context_inserted and _ckey not in _display_id_set and not _is_context_compression_marker(_cmsg):
|
|
3102
3245
|
_backfilled.append(copy.deepcopy(_cmsg))
|
|
3103
|
-
|
|
3246
|
+
_context_inserted.add(_ckey)
|
|
3104
3247
|
_cursor = _j + 1
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3248
|
+
elif not any(
|
|
3249
|
+
_message_identity(_future_dmsg) in context_keys[_cursor:]
|
|
3250
|
+
for _future_dmsg in previous_display[_display_idx + 1:]
|
|
3251
|
+
):
|
|
3252
|
+
for _k in range(_cursor, len(context_keys)):
|
|
3253
|
+
_ckey = context_keys[_k]
|
|
3254
|
+
_cmsg = previous_context[_k]
|
|
3255
|
+
if _ckey is not None and _ckey not in _context_inserted and _ckey not in _display_id_set and not _is_context_compression_marker(_cmsg):
|
|
3256
|
+
_backfilled.append(copy.deepcopy(_cmsg))
|
|
3257
|
+
_context_inserted.add(_ckey)
|
|
3258
|
+
_cursor = len(context_keys)
|
|
3259
|
+
# The display row is the visible backbone — always preserve it,
|
|
3260
|
+
# in order, even when an earlier (identical-content) turn or a
|
|
3261
|
+
# backfilled context row shares its timestamp-less identity.
|
|
3262
|
+
_backfilled.append(_dmsg)
|
|
3109
3263
|
while _cursor < len(context_keys):
|
|
3110
3264
|
_ckey = context_keys[_cursor]
|
|
3111
3265
|
_cmsg = previous_context[_cursor]
|
|
3112
3266
|
_cursor += 1
|
|
3113
|
-
if _ckey is not None and _ckey not in
|
|
3267
|
+
if _ckey is not None and _ckey not in _context_inserted and _ckey not in _display_id_set and not _is_context_compression_marker(_cmsg):
|
|
3114
3268
|
_backfilled.append(copy.deepcopy(_cmsg))
|
|
3115
|
-
|
|
3269
|
+
_context_inserted.add(_ckey)
|
|
3116
3270
|
if len(_backfilled) > len(previous_display):
|
|
3117
3271
|
logger.debug(
|
|
3118
3272
|
"Backfilled %d context-only turns into previous_display (was %d, now %d)",
|
|
@@ -5146,6 +5300,7 @@ def _run_agent_streaming(
|
|
|
5146
5300
|
'profile': getattr(s, 'profile', None),
|
|
5147
5301
|
'workspace': s.workspace,
|
|
5148
5302
|
},
|
|
5303
|
+
config_data=_cfg,
|
|
5149
5304
|
)
|
|
5150
5305
|
_pending_started_at = getattr(s, 'pending_started_at', None)
|
|
5151
5306
|
# Normal chat-start sets pending_started_at before spawning this thread;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Derive settled todo snapshots for session GET responses.
|
|
2
|
+
|
|
3
|
+
The browser's Todos panel currently reconstructs state by reverse-scanning
|
|
4
|
+
loaded tool messages. That works only when the latest todo tool result is inside
|
|
5
|
+
the returned message window. This helper gives ``/api/session`` a compact,
|
|
6
|
+
explicit ``todo_state`` sidecar derived from the full settled transcript.
|
|
7
|
+
|
|
8
|
+
The detector intentionally mirrors ``run_agent.AIAgent._hydrate_todo_store``:
|
|
9
|
+
walk messages newest-first and use the first tool message whose JSON content has
|
|
10
|
+
a ``todos`` list. Empty ``todos`` is a valid snapshot so a cleared task list does
|
|
11
|
+
not fall through to an older non-empty write.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
from typing import Any, Iterable, Optional, Sequence
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
VERSION = 1
|
|
23
|
+
PAYLOAD_KEY = "todo_state"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _normalize_snapshot(data: Any) -> Optional[dict]:
|
|
27
|
+
"""Return the canonical todo snapshot shape, or ``None`` for non-todo data."""
|
|
28
|
+
if not isinstance(data, dict):
|
|
29
|
+
return None
|
|
30
|
+
todos = data.get("todos")
|
|
31
|
+
if not isinstance(todos, list):
|
|
32
|
+
return None
|
|
33
|
+
summary = data.get("summary")
|
|
34
|
+
if not isinstance(summary, dict):
|
|
35
|
+
summary = {}
|
|
36
|
+
return {
|
|
37
|
+
"todos": todos,
|
|
38
|
+
"summary": summary,
|
|
39
|
+
"version": VERSION,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def parse_todo_tool_result(function_result: Any) -> Optional[dict]:
|
|
44
|
+
"""Parse a todo tool result JSON string or pre-parsed dict into a snapshot."""
|
|
45
|
+
data: Any = function_result
|
|
46
|
+
if isinstance(function_result, str):
|
|
47
|
+
try:
|
|
48
|
+
data = json.loads(function_result)
|
|
49
|
+
except (TypeError, ValueError):
|
|
50
|
+
return None
|
|
51
|
+
return _normalize_snapshot(data)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _message_ts_float(ts_raw: Any) -> float:
|
|
55
|
+
"""Coerce a message ``timestamp`` field to a positive float, or 0.0."""
|
|
56
|
+
try:
|
|
57
|
+
return float(ts_raw) if ts_raw is not None else 0.0
|
|
58
|
+
except (TypeError, ValueError):
|
|
59
|
+
return 0.0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _max_timestamp_through(messages: Sequence[Any], upto_idx: int) -> float:
|
|
63
|
+
"""Return the largest valid timestamp at or before ``upto_idx``."""
|
|
64
|
+
best = 0.0
|
|
65
|
+
end = min(upto_idx, len(messages) - 1)
|
|
66
|
+
for i in range(end, -1, -1):
|
|
67
|
+
msg = messages[i]
|
|
68
|
+
if not isinstance(msg, dict):
|
|
69
|
+
continue
|
|
70
|
+
best = max(best, _message_ts_float(msg.get("timestamp")))
|
|
71
|
+
return best
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def derive_todo_state(messages: Optional[Iterable[dict]]) -> Optional[dict]:
|
|
75
|
+
"""Derive the latest settled todo snapshot from conversation history.
|
|
76
|
+
|
|
77
|
+
Returns ``None`` when the session has no todo writes. Malformed or
|
|
78
|
+
non-string tool contents are skipped so unrelated tool results never break
|
|
79
|
+
session loading.
|
|
80
|
+
"""
|
|
81
|
+
if not messages:
|
|
82
|
+
return None
|
|
83
|
+
if not isinstance(messages, (list, tuple)):
|
|
84
|
+
messages = list(messages)
|
|
85
|
+
|
|
86
|
+
for idx in range(len(messages) - 1, -1, -1):
|
|
87
|
+
msg = messages[idx]
|
|
88
|
+
if not isinstance(msg, dict) or msg.get("role") != "tool":
|
|
89
|
+
continue
|
|
90
|
+
content = msg.get("content", "")
|
|
91
|
+
if not isinstance(content, str) or '"todos"' not in content:
|
|
92
|
+
continue
|
|
93
|
+
snapshot = parse_todo_tool_result(content)
|
|
94
|
+
if snapshot is None:
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
ts_val = _message_ts_float(msg.get("timestamp"))
|
|
98
|
+
if ts_val <= 0:
|
|
99
|
+
ts_val = _max_timestamp_through(messages, idx)
|
|
100
|
+
if ts_val > 0:
|
|
101
|
+
snapshot["ts"] = ts_val
|
|
102
|
+
return snapshot
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def attach_todo_state(payload: dict, messages: Optional[Iterable[dict]]) -> bool:
|
|
107
|
+
"""Attach ``todo_state`` to a session payload when one can be derived.
|
|
108
|
+
|
|
109
|
+
Mutates ``payload`` in place. Errors are swallowed deliberately: a malformed
|
|
110
|
+
historical tool message must not make ``/api/session`` fail.
|
|
111
|
+
"""
|
|
112
|
+
if not messages:
|
|
113
|
+
return False
|
|
114
|
+
try:
|
|
115
|
+
snapshot = derive_todo_state(messages)
|
|
116
|
+
if snapshot is None:
|
|
117
|
+
return False
|
|
118
|
+
payload[PAYLOAD_KEY] = snapshot
|
|
119
|
+
return True
|
|
120
|
+
except Exception:
|
|
121
|
+
logger.debug("todo_state attach failed", exc_info=True)
|
|
122
|
+
return False
|
|
@@ -1049,10 +1049,37 @@ def _schedule_restart(delay: float = 2.0) -> None:
|
|
|
1049
1049
|
with _apply_lock:
|
|
1050
1050
|
_wait_until_restart_safe()
|
|
1051
1051
|
try:
|
|
1052
|
-
|
|
1052
|
+
# Re-exec into the just-pulled image.
|
|
1053
|
+
#
|
|
1054
|
+
# sys.argv[0]'s meaning depends on how the server was launched:
|
|
1055
|
+
#
|
|
1056
|
+
# * Source checkout (`python server.py` via bootstrap.py /
|
|
1057
|
+
# ctl.sh / start.sh): sys.argv[0] is the SCRIPT path
|
|
1058
|
+
# (e.g. "/root/hermes-webui/server.py"), sys.executable is
|
|
1059
|
+
# the interpreter. CPython treats argv[1] as the script to
|
|
1060
|
+
# run, so we must pass [sys.executable] + sys.argv.
|
|
1061
|
+
#
|
|
1062
|
+
# * Frozen/packaged build (PyInstaller, embedded zipapp,
|
|
1063
|
+
# etc.): sys.argv[0] == sys.executable == <binary>. Passing
|
|
1064
|
+
# [sys.executable] + sys.argv would re-insert the binary as
|
|
1065
|
+
# argv[1] — the kernel launches it, the interpreter treats
|
|
1066
|
+
# the binary itself as the "script" to run, and execv
|
|
1067
|
+
# effectively becomes a recursive no-op that never reaches
|
|
1068
|
+
# bind(), leaving the WebUI stuck "offline" after every
|
|
1069
|
+
# self-update. Pass argv as-is instead.
|
|
1070
|
+
#
|
|
1071
|
+
# Distinguish the two cases with sys.frozen (set by
|
|
1072
|
+
# PyInstaller / zipapp / similar). For source checkouts the
|
|
1073
|
+
# `[sys.executable] + sys.argv` form is the canonical CPython
|
|
1074
|
+
# re-exec idiom (same shape Flask/Django reloaders use) and
|
|
1075
|
+
# is the correct path.
|
|
1076
|
+
if getattr(sys, "frozen", False):
|
|
1077
|
+
os.execv(sys.executable, sys.argv)
|
|
1078
|
+
else:
|
|
1079
|
+
os.execv(sys.executable, [sys.executable] + sys.argv)
|
|
1053
1080
|
except Exception:
|
|
1054
|
-
# Last-resort: if execv fails
|
|
1055
|
-
#
|
|
1081
|
+
# Last-resort: if execv fails for any reason, just exit so the
|
|
1082
|
+
# process supervisor (start.sh / Docker) restarts us.
|
|
1056
1083
|
os._exit(0)
|
|
1057
1084
|
|
|
1058
1085
|
threading.Thread(target=_do, daemon=True).start()
|