Compare commits
11 Commits
ff970ec844
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
678a7f6e49 | ||
|
|
87d4136a43 | ||
|
|
ce9aec1640 | ||
|
|
1a9dba7844 | ||
|
|
06bedc8e23 | ||
|
|
63b0207604 | ||
|
|
9c69b646ff | ||
|
|
57222c70e7 | ||
|
|
14a1924796 | ||
|
|
36da37ff13 | ||
|
|
b14ea4f9f6 |
30
CHANGELOG.md
30
CHANGELOG.md
@@ -2,6 +2,17 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Reasoning chip now appears after the model chip** in the composer toolbar — model is a more fundamental choice and should be stable in position regardless of whether reasoning is active. Order: Profile → Workspace → Model → Reasoning. (`static/index.html`)
|
||||||
|
|
||||||
|
## v0.50.184 — 2026-04-24
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Reasoning chip dropdown now opens correctly** — the dropdown was placed inside `.composer-left` which has `overflow-y: hidden`, clipping the upward-opening menu entirely. Moved `#composerReasoningDropdown` outside to sit alongside the model/profile/workspace dropdowns and added `_positionReasoningDropdown()` for consistent chip-aligned positioning. Z-index raised to 200 to match other composer dropdowns. (`static/index.html`, `static/style.css`, `static/ui.js`)
|
||||||
|
- **Reasoning chip icon is now a monochrome SVG** — replaced the `🧠` emoji in the label with a `stroke="currentColor"` brain-outline SVG matching the style of all other composer chips. (`static/index.html`, `static/ui.js`)
|
||||||
|
- **`/reasoning <level>` now immediately updates the chip** — previously called `syncReasoningChip()` which re-applied the stale cached value. Now calls `_applyReasoningChip(eff)` directly with the server-confirmed effort level. (`static/commands.js`)
|
||||||
|
- **`/btw` answer no longer vanishes after rendering** — `onerror` was firing when the server cleanly closed the SSE connection after `stream_end`, removing the just-rendered answer bubble. A `_streamDone` flag now prevents `onerror` from wiping the row after a successful stream. Also added `_ensureBtwRow()` call in `done` handler so the bubble renders even if no `token` events arrived. (`static/messages.js`) Closes #933.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- **Session attention indicators in the sidebar** — the session list now shows a
|
- **Session attention indicators in the sidebar** — the session list now shows a
|
||||||
spinning indicator while a session is actively streaming (even in the
|
spinning indicator while a session is actively streaming (even in the
|
||||||
@@ -29,6 +40,25 @@
|
|||||||
workspace subtree) and never enumerate blocked system roots. (`api/routes.py`,
|
workspace subtree) and never enumerate blocked system roots. (`api/routes.py`,
|
||||||
`api/workspace.py`, `static/panels.js`, `static/style.css`) (partial for #616)
|
`api/workspace.py`, `static/panels.js`, `static/style.css`) (partial for #616)
|
||||||
|
|
||||||
|
## [v0.50.183] — 2026-04-24
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **`/btw` slash command** — ask an ephemeral side question using current session context without adding to history. Creates a hidden session, streams the answer in a visually distinct bubble, then discards the session. Includes `attachBtwStream()` SSE consumer and `POST /api/btw` route. (`api/routes.py`, `api/background.py`, `static/commands.js`, `static/messages.js`, `static/style.css`)
|
||||||
|
- **`/background` slash command** — run a prompt in a parallel background agent without blocking the active conversation. Frontend polls `GET /api/background/status` for results and displays completed answers inline. Includes badge indicator in composer footer. (`api/routes.py`, `api/background.py`, `static/commands.js`, `static/messages.js`, `static/index.html`)
|
||||||
|
- **Undo button on last assistant message** — surfaced as an ↩ icon on the last assistant message, calling the existing `/undo` command for discoverability. (`static/ui.js`)
|
||||||
|
- **Reasoning effort chip in composer** — visual chip to set reasoning effort level from the composer footer without typing a command. (`static/ui.js`, `static/index.html`, `static/style.css`)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Background task completion hook wired** — `complete_background()` was never called after a background agent finished, so tasks stayed in `status="running"` forever and polling always returned `[]`. Fixed by wrapping `_run_agent_streaming` in `_run_bg_and_notify` which extracts the last assistant message and signals the tracker. Also fixed `get_results()` to retain in-flight tasks during polls so concurrent tasks are not dropped. (`api/background.py`, `api/routes.py`, `tests/test_background_tasks.py`)
|
||||||
|
- **Ephemeral sessions correctly skip persistence** — added `return` after the ephemeral `done` event in `_run_agent_streaming()`, preventing ephemeral session state from being written to disk after stream completion. (`api/streaming.py`)
|
||||||
|
|
||||||
|
Co-authored by @bergeouss.
|
||||||
|
|
||||||
|
## [v0.50.181] — 2026-04-24
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Vendor streaming-markdown@0.2.15** — self-hosts the incremental markdown parser instead of loading it from jsDelivr CDN. The library (12.6 KB) is committed to `static/vendor/smd.min.js` so the app works fully offline / air-gapped, and the exact bytes are pinned in version control. SHA-384 hash preserved in an HTML comment for manual audit. (`static/vendor/smd.min.js`, `static/index.html`) Co-authored by @bsgdigital.
|
||||||
|
|
||||||
## [v0.50.180] — 2026-04-23
|
## [v0.50.180] — 2026-04-23
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
87
api/background.py
Normal file
87
api/background.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
"""Background and ephemeral task tracking for /background and /btw commands."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
# parent_session_id -> list of task dicts
|
||||||
|
_BACKGROUND_TASKS: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
|
||||||
|
# btw ephemeral session tracking: parent_sid -> {ephemeral_sid, stream_id, question}
|
||||||
|
_BTW_TRACKING: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def track_background(parent_sid: str, bg_sid: str, stream_id: str,
|
||||||
|
task_id: str, prompt: str) -> None:
|
||||||
|
with _lock:
|
||||||
|
_BACKGROUND_TASKS.setdefault(parent_sid, []).append({
|
||||||
|
"task_id": task_id,
|
||||||
|
"bg_session_id": bg_sid,
|
||||||
|
"stream_id": stream_id,
|
||||||
|
"prompt": prompt,
|
||||||
|
"status": "running",
|
||||||
|
"started_at": time.time(),
|
||||||
|
"answer": None,
|
||||||
|
"completed_at": None,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def track_btw(parent_sid: str, ephemeral_sid: str, stream_id: str,
|
||||||
|
question: str) -> None:
|
||||||
|
with _lock:
|
||||||
|
_BTW_TRACKING[parent_sid] = {
|
||||||
|
"ephemeral_session_id": ephemeral_sid,
|
||||||
|
"stream_id": stream_id,
|
||||||
|
"question": question,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def complete_background(parent_sid: str, task_id: str, answer: str) -> None:
|
||||||
|
with _lock:
|
||||||
|
for t in _BACKGROUND_TASKS.get(parent_sid, []):
|
||||||
|
if t["task_id"] == task_id and t["status"] == "running":
|
||||||
|
t["status"] = "done"
|
||||||
|
t["answer"] = answer
|
||||||
|
t["completed_at"] = time.time()
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
def get_results(parent_sid: str) -> list[dict[str, Any]]:
|
||||||
|
"""Return completed background task results and remove only the done ones
|
||||||
|
from tracking. Tasks still in ``status="running"`` MUST stay in the list
|
||||||
|
so that ``complete_background()`` can still find them when the worker
|
||||||
|
thread finishes — otherwise the first poll during a long-running task
|
||||||
|
silently drops it and the result is lost forever.
|
||||||
|
"""
|
||||||
|
with _lock:
|
||||||
|
tasks = _BACKGROUND_TASKS.get(parent_sid, [])
|
||||||
|
done = [t for t in tasks if t["status"] == "done"]
|
||||||
|
still_running = [t for t in tasks if t["status"] != "done"]
|
||||||
|
if still_running:
|
||||||
|
_BACKGROUND_TASKS[parent_sid] = still_running
|
||||||
|
else:
|
||||||
|
_BACKGROUND_TASKS.pop(parent_sid, None)
|
||||||
|
return [{
|
||||||
|
"task_id": t["task_id"],
|
||||||
|
"prompt": t["prompt"],
|
||||||
|
"answer": t["answer"],
|
||||||
|
"completed_at": t["completed_at"],
|
||||||
|
} for t in done]
|
||||||
|
|
||||||
|
|
||||||
|
def get_background_tasks(parent_sid: str) -> list[dict[str, Any]]:
|
||||||
|
"""Return all background tasks (running and done) for a parent session."""
|
||||||
|
with _lock:
|
||||||
|
return list(_BACKGROUND_TASKS.get(parent_sid, []))
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_btw(parent_sid: str) -> dict[str, Any] | None:
|
||||||
|
"""Remove and return btw tracking for a parent session."""
|
||||||
|
with _lock:
|
||||||
|
return _BTW_TRACKING.pop(parent_sid, None)
|
||||||
140
api/routes.py
140
api/routes.py
@@ -741,6 +741,13 @@ def handle_get(handler, parsed) -> bool:
|
|||||||
except KeyError:
|
except KeyError:
|
||||||
return bad(handler, "Session not found", 404)
|
return bad(handler, "Session not found", 404)
|
||||||
|
|
||||||
|
if parsed.path == "/api/background/status":
|
||||||
|
sid = parse_qs(parsed.query).get("session_id", [""])[0]
|
||||||
|
if not sid:
|
||||||
|
return bad(handler, "Missing session_id")
|
||||||
|
from api.background import get_results
|
||||||
|
return j(handler, {"results": get_results(sid)})
|
||||||
|
|
||||||
if parsed.path == "/api/sessions":
|
if parsed.path == "/api/sessions":
|
||||||
webui_sessions = all_sessions()
|
webui_sessions = all_sessions()
|
||||||
settings = load_settings()
|
settings = load_settings()
|
||||||
@@ -1258,6 +1265,12 @@ def handle_post(handler, parsed) -> bool:
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return j(handler, {"error": str(e)})
|
return j(handler, {"error": str(e)})
|
||||||
|
|
||||||
|
if parsed.path == "/api/btw":
|
||||||
|
return _handle_btw(handler, body)
|
||||||
|
|
||||||
|
if parsed.path == "/api/background":
|
||||||
|
return _handle_background(handler, body)
|
||||||
|
|
||||||
if parsed.path == "/api/chat/start":
|
if parsed.path == "/api/chat/start":
|
||||||
return _handle_chat_start(handler, body)
|
return _handle_chat_start(handler, body)
|
||||||
|
|
||||||
@@ -2457,6 +2470,133 @@ def _handle_sessions_cleanup(handler, body, zero_only=False):
|
|||||||
return j(handler, {"ok": True, "cleaned": cleaned})
|
return j(handler, {"ok": True, "cleaned": cleaned})
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_btw(handler, body):
|
||||||
|
"""POST /api/btw — ephemeral side question using session context.
|
||||||
|
|
||||||
|
Creates a temporary hidden session, streams the answer via SSE, then
|
||||||
|
discards the session. The parent session is not modified.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
require(body, "session_id")
|
||||||
|
require(body, "question")
|
||||||
|
except ValueError as e:
|
||||||
|
return bad(handler, str(e))
|
||||||
|
try:
|
||||||
|
s = get_session(body["session_id"])
|
||||||
|
except KeyError:
|
||||||
|
return bad(handler, "Session not found", 404)
|
||||||
|
question = str(body["question"]).strip()
|
||||||
|
if not question:
|
||||||
|
return bad(handler, "question is required")
|
||||||
|
# Duplicate-stream guard (same pattern as chat/start)
|
||||||
|
current_stream_id = getattr(s, "active_stream_id", None)
|
||||||
|
if current_stream_id:
|
||||||
|
with STREAMS_LOCK:
|
||||||
|
if current_stream_id in STREAMS:
|
||||||
|
return j(handler, {"error": "session already has an active stream"}, status=409)
|
||||||
|
s.active_stream_id = None
|
||||||
|
# Create ephemeral hidden session inheriting context
|
||||||
|
from api.models import new_session as _new_session
|
||||||
|
ephemeral = _new_session(workspace=s.workspace, model=s.model, profile=getattr(s, 'profile', None))
|
||||||
|
# Copy conversation history for context (agent reads from messages)
|
||||||
|
ephemeral.messages = list(s.messages or [])
|
||||||
|
ephemeral.title = f"btw: {question[:60]}"
|
||||||
|
ephemeral.save()
|
||||||
|
stream_id = uuid.uuid4().hex
|
||||||
|
ephemeral.active_stream_id = stream_id
|
||||||
|
ephemeral.save()
|
||||||
|
q = queue.Queue()
|
||||||
|
with STREAMS_LOCK:
|
||||||
|
STREAMS[stream_id] = q
|
||||||
|
from api.background import track_btw
|
||||||
|
track_btw(body["session_id"], ephemeral.session_id, stream_id, question)
|
||||||
|
thr = threading.Thread(
|
||||||
|
target=_run_agent_streaming,
|
||||||
|
args=(ephemeral.session_id, question, s.model, s.workspace, stream_id, None),
|
||||||
|
kwargs={"ephemeral": True},
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
thr.start()
|
||||||
|
return j(handler, {"stream_id": stream_id, "session_id": ephemeral.session_id, "parent_session_id": body["session_id"]})
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_background(handler, body):
|
||||||
|
"""POST /api/background — run prompt in parallel background agent.
|
||||||
|
|
||||||
|
Creates a hidden session, starts streaming in a daemon thread.
|
||||||
|
Frontend polls /api/background/status for completed results.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
require(body, "session_id")
|
||||||
|
require(body, "prompt")
|
||||||
|
except ValueError as e:
|
||||||
|
return bad(handler, str(e))
|
||||||
|
try:
|
||||||
|
s = get_session(body["session_id"])
|
||||||
|
except KeyError:
|
||||||
|
return bad(handler, "Session not found", 404)
|
||||||
|
prompt = str(body["prompt"]).strip()
|
||||||
|
if not prompt:
|
||||||
|
return bad(handler, "prompt is required")
|
||||||
|
from api.models import new_session as _new_session
|
||||||
|
bg = _new_session(workspace=s.workspace, model=s.model, profile=getattr(s, 'profile', None))
|
||||||
|
bg.title = f"bg: {prompt[:60]}"
|
||||||
|
bg.save()
|
||||||
|
stream_id = uuid.uuid4().hex
|
||||||
|
bg.active_stream_id = stream_id
|
||||||
|
bg.save()
|
||||||
|
q = queue.Queue()
|
||||||
|
with STREAMS_LOCK:
|
||||||
|
STREAMS[stream_id] = q
|
||||||
|
task_id = uuid.uuid4().hex[:8]
|
||||||
|
from api.background import track_background, complete_background
|
||||||
|
parent_sid = body["session_id"]
|
||||||
|
bg_sid = bg.session_id
|
||||||
|
track_background(parent_sid, bg_sid, stream_id, task_id, prompt)
|
||||||
|
|
||||||
|
def _run_bg_and_notify():
|
||||||
|
"""Run the background agent, then mark the tracked task `done` with the
|
||||||
|
last assistant reply so `/api/background/status` can surface it. Without
|
||||||
|
this, `complete_background()` is never called and the result is lost —
|
||||||
|
`get_results()` would see a forever-`running` task and return nothing.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
_run_agent_streaming(bg_sid, prompt, s.model, s.workspace, stream_id, None)
|
||||||
|
# Reload the bg session from disk and extract the final assistant reply.
|
||||||
|
try:
|
||||||
|
from api.models import Session as _Session
|
||||||
|
reloaded = _Session.load(bg_sid)
|
||||||
|
_answer = ""
|
||||||
|
for _m in reversed((reloaded.messages if reloaded else None) or []):
|
||||||
|
if not isinstance(_m, dict) or _m.get("role") != "assistant":
|
||||||
|
continue
|
||||||
|
if _m.get("_error"):
|
||||||
|
continue
|
||||||
|
_content = str(_m.get("content") or "").strip()
|
||||||
|
if _content:
|
||||||
|
_answer = _content
|
||||||
|
break
|
||||||
|
complete_background(parent_sid, task_id, _answer or "(no answer produced)")
|
||||||
|
except Exception:
|
||||||
|
complete_background(parent_sid, task_id, "(background task failed)")
|
||||||
|
# Best-effort cleanup of the hidden bg session file so it doesn't
|
||||||
|
# clutter the sidebar or SESSION_DIR. The index is pruned on the
|
||||||
|
# next rebuild via _index_entry_exists().
|
||||||
|
try:
|
||||||
|
(SESSION_DIR / f"{bg_sid}.json").unlink(missing_ok=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
complete_background(parent_sid, task_id, "(background task failed)")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
thr = threading.Thread(target=_run_bg_and_notify, daemon=True)
|
||||||
|
thr.start()
|
||||||
|
return j(handler, {"task_id": task_id, "stream_id": stream_id, "session_id": bg.session_id})
|
||||||
|
|
||||||
|
|
||||||
def _handle_chat_start(handler, body):
|
def _handle_chat_start(handler, body):
|
||||||
try:
|
try:
|
||||||
require(body, "session_id")
|
require(body, "session_id")
|
||||||
|
|||||||
112
api/streaming.py
112
api/streaming.py
@@ -233,6 +233,43 @@ def _is_minimax_route(provider: str = '', model: str = '', base_url: str = '') -
|
|||||||
return 'minimax' in text or 'minimaxi.com' in text
|
return 'minimax' in text or 'minimaxi.com' in text
|
||||||
|
|
||||||
|
|
||||||
|
def _aux_title_configured() -> bool:
|
||||||
|
"""Return True when any auxiliary title_generation config field is meaningfully set."""
|
||||||
|
try:
|
||||||
|
from agent.auxiliary_client import _get_auxiliary_task_config
|
||||||
|
tg = _get_auxiliary_task_config('title_generation')
|
||||||
|
provider = tg.get('provider', '') or ''
|
||||||
|
model = tg.get('model', '') or ''
|
||||||
|
base_url = tg.get('base_url', '') or ''
|
||||||
|
return bool(model or base_url or (provider and provider.lower() != 'auto'))
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _aux_title_timeout(default: float = 15.0) -> float:
|
||||||
|
"""Return the configured timeout (seconds) for auxiliary title generation.
|
||||||
|
|
||||||
|
Only accepts positive numeric values. Falls back to *default* when the
|
||||||
|
value is ``None``, non-numeric, zero, or negative, and emits a debug log
|
||||||
|
so mis-configurations are visible in server output.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from agent.auxiliary_client import _get_auxiliary_task_config
|
||||||
|
tg = _get_auxiliary_task_config('title_generation')
|
||||||
|
raw = tg.get('timeout')
|
||||||
|
if raw is None:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
value = float(raw)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
logger.debug("aux title timeout: non-numeric value %r, falling back to %s", raw, default)
|
||||||
|
return default
|
||||||
|
if value > 0:
|
||||||
|
return value
|
||||||
|
logger.debug("aux title timeout: non-positive value %s, falling back to %s", value, default)
|
||||||
|
return default
|
||||||
|
except Exception:
|
||||||
|
return default
|
||||||
|
|
||||||
def _title_completion_budget(provider: str = '', model: str = '', base_url: str = '') -> int:
|
def _title_completion_budget(provider: str = '', model: str = '', base_url: str = '') -> int:
|
||||||
if _is_minimax_route(provider, model, base_url):
|
if _is_minimax_route(provider, model, base_url):
|
||||||
return 384
|
return 384
|
||||||
@@ -255,6 +292,7 @@ def generate_title_raw_via_aux(
|
|||||||
if _is_minimax_route(provider, model, base_url):
|
if _is_minimax_route(provider, model, base_url):
|
||||||
reasoning_extra["reasoning_split"] = True
|
reasoning_extra["reasoning_split"] = True
|
||||||
try:
|
try:
|
||||||
|
_timeout = _aux_title_timeout()
|
||||||
from agent.auxiliary_client import call_llm
|
from agent.auxiliary_client import call_llm
|
||||||
for idx, prompt in enumerate(prompts):
|
for idx, prompt in enumerate(prompts):
|
||||||
messages = [
|
messages = [
|
||||||
@@ -270,7 +308,7 @@ def generate_title_raw_via_aux(
|
|||||||
messages=messages,
|
messages=messages,
|
||||||
max_tokens=max_tokens,
|
max_tokens=max_tokens,
|
||||||
temperature=0.2,
|
temperature=0.2,
|
||||||
timeout=15.0,
|
timeout=_timeout,
|
||||||
extra_body=reasoning_extra,
|
extra_body=reasoning_extra,
|
||||||
)
|
)
|
||||||
raw = ''
|
raw = ''
|
||||||
@@ -388,14 +426,29 @@ def _generate_llm_session_title_for_agent(agent, user_text: str, assistant_text:
|
|||||||
return None, 'llm_invalid', str(raw)[:120]
|
return None, 'llm_invalid', str(raw)[:120]
|
||||||
|
|
||||||
|
|
||||||
def _generate_llm_session_title_via_aux(user_text: str, assistant_text: str, agent=None) -> tuple[Optional[str], str, str]:
|
def _generate_llm_session_title_via_aux(user_text: str, assistant_text: str, agent=None, *, use_agent_model: bool = False) -> tuple[Optional[str], str, str]:
|
||||||
"""Generate a title via dedicated auxiliary LLM route, then sanitize/validate result."""
|
"""Generate a title via dedicated auxiliary LLM route, then sanitize/validate result.
|
||||||
|
|
||||||
|
When use_agent_model is False (default), the auxiliary client resolves
|
||||||
|
provider/model/base_url from config.yaml auxiliary.title_generation, which
|
||||||
|
prevents the session's chat model (e.g. a Chinese model) from overriding
|
||||||
|
the dedicated title model. When True, the agent's attrs are passed through
|
||||||
|
(legacy fallback behaviour).
|
||||||
|
"""
|
||||||
|
if use_agent_model and agent:
|
||||||
|
provider = getattr(agent, 'provider', '')
|
||||||
|
model = getattr(agent, 'model', '')
|
||||||
|
base_url = getattr(agent, 'base_url', '')
|
||||||
|
else:
|
||||||
|
provider = ''
|
||||||
|
model = ''
|
||||||
|
base_url = ''
|
||||||
raw, status = generate_title_raw_via_aux(
|
raw, status = generate_title_raw_via_aux(
|
||||||
user_text,
|
user_text,
|
||||||
assistant_text,
|
assistant_text,
|
||||||
provider=getattr(agent, 'provider', '') if agent else '',
|
provider=provider,
|
||||||
model=getattr(agent, 'model', '') if agent else '',
|
model=model,
|
||||||
base_url=getattr(agent, 'base_url', '') if agent else '',
|
base_url=base_url,
|
||||||
)
|
)
|
||||||
if not raw:
|
if not raw:
|
||||||
return None, status, ''
|
return None, status, ''
|
||||||
@@ -522,14 +575,15 @@ def _run_background_title_update(session_id: str, user_text: str, assistant_text
|
|||||||
if not still_auto:
|
if not still_auto:
|
||||||
_put_title_status(put_event, session_id, 'skipped', 'manual_title', current)
|
_put_title_status(put_event, session_id, 'skipped', 'manual_title', current)
|
||||||
return
|
return
|
||||||
# Prefer the active session model when available so title generation
|
aux_title_configured = _aux_title_configured()
|
||||||
# matches the user's chosen runtime and can use provider-specific fixes.
|
if agent and not aux_title_configured:
|
||||||
if agent:
|
|
||||||
next_title, llm_status, raw_preview = _generate_llm_session_title_for_agent(agent, user_text, assistant_text)
|
next_title, llm_status, raw_preview = _generate_llm_session_title_for_agent(agent, user_text, assistant_text)
|
||||||
if not next_title and llm_status in ('llm_error', 'llm_invalid'):
|
if not next_title and llm_status in ('llm_error', 'llm_invalid'):
|
||||||
next_title, llm_status, raw_preview = _generate_llm_session_title_via_aux(user_text, assistant_text, agent=agent)
|
next_title, llm_status, raw_preview = _generate_llm_session_title_via_aux(user_text, assistant_text, agent=agent, use_agent_model=True)
|
||||||
else:
|
else:
|
||||||
next_title, llm_status, raw_preview = _generate_llm_session_title_via_aux(user_text, assistant_text, agent=agent)
|
next_title, llm_status, raw_preview = _generate_llm_session_title_via_aux(user_text, assistant_text)
|
||||||
|
if not next_title and agent and llm_status in ('llm_error_aux', 'llm_invalid_aux'):
|
||||||
|
next_title, llm_status, raw_preview = _generate_llm_session_title_for_agent(agent, user_text, assistant_text)
|
||||||
source = llm_status
|
source = llm_status
|
||||||
if not next_title:
|
if not next_title:
|
||||||
next_title = _fallback_title_from_exchange(user_text, assistant_text)
|
next_title = _fallback_title_from_exchange(user_text, assistant_text)
|
||||||
@@ -539,14 +593,7 @@ def _run_background_title_update(session_id: str, user_text: str, assistant_text
|
|||||||
wrote_title = False
|
wrote_title = False
|
||||||
effective_title = current
|
effective_title = current
|
||||||
if next_title:
|
if next_title:
|
||||||
# Hold _agent_lock only for in-memory mutation + save so title write
|
|
||||||
# is serialized with checkpoint saves, cancel_stream, and other
|
|
||||||
# session-mutating endpoints. The LLM round-trip above ran outside
|
|
||||||
# the lock to avoid blocking other writers.
|
|
||||||
with _get_session_agent_lock(session_id):
|
with _get_session_agent_lock(session_id):
|
||||||
# Stale-object guard: rebind to the canonical cached Session
|
|
||||||
# instance under LOCK before checking whether a user rename
|
|
||||||
# landed while the LLM title request was in-flight.
|
|
||||||
with LOCK:
|
with LOCK:
|
||||||
s = SESSIONS.get(session_id, s)
|
s = SESSIONS.get(session_id, s)
|
||||||
effective_title = str(s.title or '').strip()
|
effective_title = str(s.title or '').strip()
|
||||||
@@ -819,8 +866,12 @@ def _sse(handler, event, data):
|
|||||||
handler.wfile.flush()
|
handler.wfile.flush()
|
||||||
|
|
||||||
|
|
||||||
def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, attachments=None):
|
def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, attachments=None, *, ephemeral=False):
|
||||||
"""Run agent in background thread, writing SSE events to STREAMS[stream_id]."""
|
"""Run agent in background thread, writing SSE events to STREAMS[stream_id].
|
||||||
|
|
||||||
|
When ephemeral=True, session mutations are skipped — used by /btw to get
|
||||||
|
a streaming answer without persisting to the parent session.
|
||||||
|
"""
|
||||||
q = STREAMS.get(stream_id)
|
q = STREAMS.get(stream_id)
|
||||||
if q is None:
|
if q is None:
|
||||||
return
|
return
|
||||||
@@ -1289,6 +1340,27 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
|
|||||||
task_id=session_id,
|
task_id=session_id,
|
||||||
persist_user_message=msg_text,
|
persist_user_message=msg_text,
|
||||||
)
|
)
|
||||||
|
# ── Ephemeral mode (/btw): deliver answer, skip persistence, cleanup ──
|
||||||
|
if ephemeral:
|
||||||
|
_answer = ''
|
||||||
|
for _m in reversed(result.get('messages') or []):
|
||||||
|
if isinstance(_m, dict) and _m.get('role') == 'assistant':
|
||||||
|
_answer = str(_m.get('content', ''))
|
||||||
|
break
|
||||||
|
put('done', {
|
||||||
|
'session': {'session_id': session_id, 'messages': result.get('messages', [])},
|
||||||
|
'usage': {'input_tokens': 0, 'output_tokens': 0},
|
||||||
|
'ephemeral': True,
|
||||||
|
'answer': _answer,
|
||||||
|
})
|
||||||
|
if _checkpoint_stop is not None:
|
||||||
|
_checkpoint_stop.set()
|
||||||
|
try:
|
||||||
|
import pathlib
|
||||||
|
pathlib.Path(s.path).unlink(missing_ok=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return # skip all normal persistence for ephemeral sessions
|
||||||
if _checkpoint_stop is not None:
|
if _checkpoint_stop is not None:
|
||||||
_checkpoint_stop.set()
|
_checkpoint_stop.set()
|
||||||
if _ckpt_thread is not None:
|
if _ckpt_thread is not None:
|
||||||
|
|||||||
24
build.sh
Executable file
24
build.sh
Executable file
@@ -0,0 +1,24 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
IMAGE_NAME="isparkclaw-webui"
|
||||||
|
REGISTRY="gitea.clickthings.net"
|
||||||
|
TAG="${1:-latest}"
|
||||||
|
HERMES_VERSION=$(git describe --tags --always 2>/dev/null || echo "unknown")
|
||||||
|
|
||||||
|
echo "Building ${IMAGE_NAME}:${TAG} (version: ${HERMES_VERSION})"
|
||||||
|
|
||||||
|
docker build \
|
||||||
|
--build-arg HERMES_VERSION="${HERMES_VERSION}" \
|
||||||
|
-t "${IMAGE_NAME}:${TAG}" \
|
||||||
|
-t "${REGISTRY}/${IMAGE_NAME}:${TAG}" \
|
||||||
|
.
|
||||||
|
|
||||||
|
echo "Pushing to ${REGISTRY}"
|
||||||
|
docker push "${REGISTRY}/${IMAGE_NAME}:${TAG}"
|
||||||
|
|
||||||
|
if [ "$TAG" != "latest" ]; then
|
||||||
|
docker push "${REGISTRY}/${IMAGE_NAME}:latest"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Done!"
|
||||||
@@ -20,6 +20,8 @@ const COMMANDS=[
|
|||||||
{name:'title', desc:t('cmd_title'), fn:cmdTitle, arg:'[title]'},
|
{name:'title', desc:t('cmd_title'), fn:cmdTitle, arg:'[title]'},
|
||||||
{name:'retry', desc:t('cmd_retry'), fn:cmdRetry, noEcho:true},
|
{name:'retry', desc:t('cmd_retry'), fn:cmdRetry, noEcho:true},
|
||||||
{name:'undo', desc:t('cmd_undo'), fn:cmdUndo, noEcho:true},
|
{name:'undo', desc:t('cmd_undo'), fn:cmdUndo, noEcho:true},
|
||||||
|
{name:'btw', desc:t('cmd_btw'), fn:cmdBtw, arg:'question', noEcho:true},
|
||||||
|
{name:'background',desc:t('cmd_background'),fn:cmdBackground,arg:'prompt', noEcho:true},
|
||||||
{name:'status', desc:t('cmd_status'), fn:cmdStatus},
|
{name:'status', desc:t('cmd_status'), fn:cmdStatus},
|
||||||
{name:'voice', desc:t('cmd_voice'), fn:cmdVoice, noEcho:true},
|
{name:'voice', desc:t('cmd_voice'), fn:cmdVoice, noEcho:true},
|
||||||
{name:'reasoning', desc:t('cmd_reasoning'), fn:cmdReasoning, arg:'show|hide|none|minimal|low|medium|high|xhigh', subArgs:['show','hide','none','minimal','low','medium','high','xhigh'], noEcho:true},
|
{name:'reasoning', desc:t('cmd_reasoning'), fn:cmdReasoning, arg:'show|hide|none|minimal|low|medium|high|xhigh', subArgs:['show','hide','none','minimal','low','medium','high','xhigh'], noEcho:true},
|
||||||
@@ -573,6 +575,36 @@ async function cmdUndo(){
|
|||||||
showToast(`↩ ${t('undid_n_messages')} ${r.removed_count} ${t('undid_messages_suffix')}`);
|
showToast(`↩ ${t('undid_n_messages')} ${r.removed_count} ${t('undid_messages_suffix')}`);
|
||||||
}catch(e){showToast(t('undo_failed')+e.message);}
|
}catch(e){showToast(t('undo_failed')+e.message);}
|
||||||
}
|
}
|
||||||
|
async function undoLastExchange(){await cmdUndo();}
|
||||||
|
async function cmdBtw(args){
|
||||||
|
if(!S.session){showToast(t('no_active_session'));return;}
|
||||||
|
const question=(args||'').trim();
|
||||||
|
if(!question){showToast(t('cmd_btw_usage'));return;}
|
||||||
|
showToast(t('btw_asking'));
|
||||||
|
const activeSid=S.session.session_id;
|
||||||
|
try{
|
||||||
|
const r=await api('/api/btw',{method:'POST',body:JSON.stringify({session_id:activeSid,question})});
|
||||||
|
if(r&&r.error){showToast(r.error);return;}
|
||||||
|
// Connect to the ephemeral SSE stream
|
||||||
|
const streamId=r.stream_id;
|
||||||
|
const parentSid=r.parent_session_id;
|
||||||
|
if(typeof attachBtwStream==='function') attachBtwStream(parentSid,streamId,question);
|
||||||
|
}catch(e){showToast(t('btw_failed')+e.message);}
|
||||||
|
}
|
||||||
|
async function cmdBackground(args){
|
||||||
|
if(!S.session){showToast(t('no_active_session'));return;}
|
||||||
|
const prompt=(args||'').trim();
|
||||||
|
if(!prompt){showToast(t('cmd_background_usage'));return;}
|
||||||
|
showToast(t('bg_running'));
|
||||||
|
const activeSid=S.session.session_id;
|
||||||
|
try{
|
||||||
|
const r=await api('/api/background',{method:'POST',body:JSON.stringify({session_id:activeSid,prompt})});
|
||||||
|
if(r&&r.error){showToast(r.error);return;}
|
||||||
|
// Show background badge and start polling
|
||||||
|
if(typeof showBackgroundBadge==='function') showBackgroundBadge(r.task_id);
|
||||||
|
if(typeof startBackgroundPolling==='function') startBackgroundPolling(activeSid,r.task_id,prompt);
|
||||||
|
}catch(e){showToast(t('bg_failed')+e.message);}
|
||||||
|
}
|
||||||
async function cmdStatus(){
|
async function cmdStatus(){
|
||||||
if(!S.session){showToast(t('no_active_session'));return;}
|
if(!S.session){showToast(t('no_active_session'));return;}
|
||||||
try{
|
try{
|
||||||
@@ -622,7 +654,8 @@ function cmdReasoning(args){
|
|||||||
api('/api/reasoning',{method:'POST',body:JSON.stringify({effort:arg})})
|
api('/api/reasoning',{method:'POST',body:JSON.stringify({effort:arg})})
|
||||||
.then(function(st){
|
.then(function(st){
|
||||||
const eff=(st && st.reasoning_effort)||arg;
|
const eff=(st && st.reasoning_effort)||arg;
|
||||||
showToast(BRAIN+' Reasoning effort set to '+eff+' (saved; applies to next turn)');
|
showToast('Reasoning effort set to '+eff+' (saved; applies to next turn)');
|
||||||
|
if(typeof _applyReasoningChip==='function') _applyReasoningChip(eff);
|
||||||
})
|
})
|
||||||
.catch(function(e){
|
.catch(function(e){
|
||||||
showToast(BRAIN+' Failed to set effort: '+(e && e.message ? e.message : arg));
|
showToast(BRAIN+' Failed to set effort: '+(e && e.message ? e.message : arg));
|
||||||
|
|||||||
@@ -102,6 +102,21 @@ const LOCALES = {
|
|||||||
cmd_title:'Get or set the session title',
|
cmd_title:'Get or set the session title',
|
||||||
cmd_retry:'Resend the last message',
|
cmd_retry:'Resend the last message',
|
||||||
cmd_undo:'Remove the last exchange',
|
cmd_undo:'Remove the last exchange',
|
||||||
|
cmd_btw:'Ask a side question (ephemeral)',
|
||||||
|
cmd_btw_usage:'/btw <question> — ask a side question using session context',
|
||||||
|
cmd_background:'Run a prompt in background',
|
||||||
|
cmd_background_usage:'/background <prompt> — run in parallel without blocking',
|
||||||
|
btw_asking:'Asking side question...',
|
||||||
|
btw_label:'Side question — not in history',
|
||||||
|
btw_done:'Side question answered',
|
||||||
|
btw_no_answer:'No answer received.',
|
||||||
|
btw_failed:'Side question failed: ',
|
||||||
|
bg_running:'Running in background...',
|
||||||
|
bg_complete:'Background task complete',
|
||||||
|
bg_label:'Background result:',
|
||||||
|
bg_no_answer:'(no answer)',
|
||||||
|
bg_failed:'Background task failed: ',
|
||||||
|
undo_exchange:'Undo last exchange',
|
||||||
cmd_status:'Show session info',
|
cmd_status:'Show session info',
|
||||||
cmd_voice:'Toggle microphone input',
|
cmd_voice:'Toggle microphone input',
|
||||||
stream_stopped:'Response stopped.',
|
stream_stopped:'Response stopped.',
|
||||||
@@ -199,7 +214,7 @@ const LOCALES = {
|
|||||||
settings_label_language: 'Language',
|
settings_label_language: 'Language',
|
||||||
settings_label_token_usage: 'Show token usage',
|
settings_label_token_usage: 'Show token usage',
|
||||||
settings_label_sidebar_density: 'Sidebar density',
|
settings_label_sidebar_density: 'Sidebar density',
|
||||||
cmd_reasoning: 'Toggle thinking block visibility (show/hide) or set effort level',
|
cmd_reasoning: 'Toggle thinking visibility (show/hide), set effort level, or check current status',
|
||||||
settings_label_cli_sessions: 'Show agent sessions',
|
settings_label_cli_sessions: 'Show agent sessions',
|
||||||
settings_label_sync_insights: 'Sync to insights',
|
settings_label_sync_insights: 'Sync to insights',
|
||||||
settings_label_check_updates: 'Check for updates',
|
settings_label_check_updates: 'Check for updates',
|
||||||
@@ -655,7 +670,7 @@ const LOCALES = {
|
|||||||
settings_label_language: 'Язык',
|
settings_label_language: 'Язык',
|
||||||
settings_label_token_usage: 'Показывать использование токенов',
|
settings_label_token_usage: 'Показывать использование токенов',
|
||||||
settings_label_sidebar_density: 'Плотность боковой панели',
|
settings_label_sidebar_density: 'Плотность боковой панели',
|
||||||
cmd_reasoning: 'Toggle thinking block visibility (show/hide) or set effort level',
|
cmd_reasoning: 'Toggle thinking visibility (show/hide), set effort level, or check current status',
|
||||||
settings_label_cli_sessions: 'Показывать сеансы агента',
|
settings_label_cli_sessions: 'Показывать сеансы агента',
|
||||||
settings_label_sync_insights: 'Синхронизировать с Insights',
|
settings_label_sync_insights: 'Синхронизировать с Insights',
|
||||||
settings_label_check_updates: 'Проверять обновления',
|
settings_label_check_updates: 'Проверять обновления',
|
||||||
@@ -1139,7 +1154,7 @@ const LOCALES = {
|
|||||||
settings_label_language: 'Idioma',
|
settings_label_language: 'Idioma',
|
||||||
settings_label_token_usage: 'Mostrar uso de tokens',
|
settings_label_token_usage: 'Mostrar uso de tokens',
|
||||||
settings_label_sidebar_density: 'Densidad de la barra lateral',
|
settings_label_sidebar_density: 'Densidad de la barra lateral',
|
||||||
cmd_reasoning: 'Toggle thinking block visibility (show/hide) or set effort level',
|
cmd_reasoning: 'Toggle thinking visibility (show/hide), set effort level, or check current status',
|
||||||
settings_label_cli_sessions: 'Mostrar sesiones de CLI',
|
settings_label_cli_sessions: 'Mostrar sesiones de CLI',
|
||||||
settings_label_sync_insights: 'Sincronizar con insights',
|
settings_label_sync_insights: 'Sincronizar con insights',
|
||||||
settings_label_check_updates: 'Buscar actualizaciones',
|
settings_label_check_updates: 'Buscar actualizaciones',
|
||||||
@@ -1595,7 +1610,7 @@ const LOCALES = {
|
|||||||
settings_label_language: 'Sprache',
|
settings_label_language: 'Sprache',
|
||||||
settings_label_token_usage: 'Token-Verbrauch anzeigen',
|
settings_label_token_usage: 'Token-Verbrauch anzeigen',
|
||||||
settings_label_sidebar_density: 'Seitenleistendichte',
|
settings_label_sidebar_density: 'Seitenleistendichte',
|
||||||
cmd_reasoning: 'Toggle thinking block visibility (show/hide) or set effort level',
|
cmd_reasoning: 'Toggle thinking visibility (show/hide), set effort level, or check current status',
|
||||||
settings_label_cli_sessions: 'Agent-Sitzungen anzeigen',
|
settings_label_cli_sessions: 'Agent-Sitzungen anzeigen',
|
||||||
settings_label_sync_insights: 'Mit Insights synchronisieren',
|
settings_label_sync_insights: 'Mit Insights synchronisieren',
|
||||||
settings_label_check_updates: 'Nach Updates suchen',
|
settings_label_check_updates: 'Nach Updates suchen',
|
||||||
@@ -1852,7 +1867,7 @@ const LOCALES = {
|
|||||||
settings_label_language: '\u8bed\u8a00',
|
settings_label_language: '\u8bed\u8a00',
|
||||||
settings_label_token_usage: '\u663e\u793a token \u7528\u91cf',
|
settings_label_token_usage: '\u663e\u793a token \u7528\u91cf',
|
||||||
settings_label_sidebar_density: '侧边栏密度',
|
settings_label_sidebar_density: '侧边栏密度',
|
||||||
cmd_reasoning: 'Toggle thinking block visibility (show/hide) or set effort level',
|
cmd_reasoning: 'Toggle thinking visibility (show/hide), set effort level, or check current status',
|
||||||
settings_label_cli_sessions: '\u663e\u793a CLI \u4f1a\u8bdd',
|
settings_label_cli_sessions: '\u663e\u793a CLI \u4f1a\u8bdd',
|
||||||
settings_label_sync_insights: '\u540c\u6b65\u5230 insights',
|
settings_label_sync_insights: '\u540c\u6b65\u5230 insights',
|
||||||
settings_label_check_updates: '\u68c0\u67e5\u66f4\u65b0',
|
settings_label_check_updates: '\u68c0\u67e5\u66f4\u65b0',
|
||||||
@@ -2292,7 +2307,7 @@ const LOCALES = {
|
|||||||
settings_label_language: '\u8a9d\u8a00',
|
settings_label_language: '\u8a9d\u8a00',
|
||||||
settings_label_token_usage: '\u986f\u793a token \u7528\u91cf',
|
settings_label_token_usage: '\u986f\u793a token \u7528\u91cf',
|
||||||
settings_label_sidebar_density: '側邊欄密度',
|
settings_label_sidebar_density: '側邊欄密度',
|
||||||
cmd_reasoning: 'Toggle thinking block visibility (show/hide) or set effort level',
|
cmd_reasoning: 'Toggle thinking visibility (show/hide), set effort level, or check current status',
|
||||||
settings_label_cli_sessions: '\u986f\u793a CLI \u6703\u8a71',
|
settings_label_cli_sessions: '\u986f\u793a CLI \u6703\u8a71',
|
||||||
settings_label_sync_insights: '\u540c\u6b65\u5230 insights',
|
settings_label_sync_insights: '\u540c\u6b65\u5230 insights',
|
||||||
settings_label_check_updates: '\u6aa2\u67e5\u66f4\u65b0',
|
settings_label_check_updates: '\u6aa2\u67e5\u66f4\u65b0',
|
||||||
|
|||||||
@@ -22,8 +22,12 @@
|
|||||||
<!-- KaTeX math rendering CSS (loaded eagerly to prevent layout shift) -->
|
<!-- KaTeX math rendering CSS (loaded eagerly to prevent layout shift) -->
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.css" integrity="sha384-5TcZemv2l/9On385z///+d7MSYlvIEw9FuZTIdZ14vJLqWphw7e7ZPuOiCHJcFCP" crossorigin="anonymous">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.css" integrity="sha384-5TcZemv2l/9On385z///+d7MSYlvIEw9FuZTIdZ14vJLqWphw7e7ZPuOiCHJcFCP" crossorigin="anonymous">
|
||||||
<!-- streaming-markdown: incremental DOM-building markdown parser for live streams -->
|
<!-- streaming-markdown: incremental DOM-building markdown parser for live streams -->
|
||||||
|
<!-- Self-hosted from npm:streaming-markdown@0.2.15 — no CDN dependency. -->
|
||||||
|
<!-- sha384 of smd.min.js @0.2.15: sha384-T6r95ocN9t3W8tUK2Fa6FPaO7bJryyjyW0WCalrUnpgtm2qXr5xcN4vwPYEJ6vHa -->
|
||||||
|
<!-- ES module imports do not support the integrity= attribute (W3C limitation); -->
|
||||||
|
<!-- version is pinned in the vendored file path; hash documented above for audit. -->
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import * as smd from 'https://cdn.jsdelivr.net/npm/streaming-markdown@0.2.15/smd.min.js';
|
import * as smd from '/static/vendor/smd.min.js';
|
||||||
// SRI verification happens at the ES module level via importmap or SW; pinning version in URL.
|
// SRI verification happens at the ES module level via importmap or SW; pinning version in URL.
|
||||||
// sha384 of smd.min.js @0.2.15: sha384-T6r95ocN9t3W8tUK2Fa6FPaO7bJryyjyW0WCalrUnpgtm2qXr5xcN4vwPYEJ6vHa
|
// sha384 of smd.min.js @0.2.15: sha384-T6r95ocN9t3W8tUK2Fa6FPaO7bJryyjyW0WCalrUnpgtm2qXr5xcN4vwPYEJ6vHa
|
||||||
window.smd = smd;
|
window.smd = smd;
|
||||||
@@ -368,6 +372,13 @@
|
|||||||
</optgroup>
|
</optgroup>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="composer-reasoning-wrap" id="composerReasoningWrap" style="display:none">
|
||||||
|
<button class="composer-reasoning-chip" id="composerReasoningChip" type="button" onclick="toggleReasoningDropdown()" title="Reasoning effort level">
|
||||||
|
<span class="composer-reasoning-icon" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96-.46 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2Z"/><path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96-.46 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2Z"/></svg></span>
|
||||||
|
<span class="composer-reasoning-label" id="composerReasoningLabel"></span>
|
||||||
|
<span class="composer-reasoning-chevron" aria-hidden="true"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="composer-right">
|
<div class="composer-right">
|
||||||
<span class="composer-status" id="composerStatus" style="display:none"></span>
|
<span class="composer-status" id="composerStatus" style="display:none"></span>
|
||||||
@@ -392,12 +403,21 @@
|
|||||||
<button class="cancel-btn" id="btnCancel" onclick="cancelStream()" style="display:none" title="Stop generation" aria-label="Stop generation">
|
<button class="cancel-btn" id="btnCancel" onclick="cancelStream()" style="display:none" title="Stop generation" aria-label="Stop generation">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="5" width="14" height="14" rx="2"></rect></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="5" width="14" height="14" rx="2"></rect></svg>
|
||||||
</button>
|
</button>
|
||||||
|
<span class="bg-badge" id="bgBadge" style="display:none" title="Background tasks running">0</span>
|
||||||
<button class="send-btn" id="btnSend" title="Send message" disabled>
|
<button class="send-btn" id="btnSend" title="Send message" disabled>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="profile-dropdown" id="profileDropdown"></div>
|
<div class="profile-dropdown" id="profileDropdown"></div>
|
||||||
<div class="ws-dropdown ws-dropdown-footer" id="composerWsDropdown"></div>
|
<div class="ws-dropdown ws-dropdown-footer" id="composerWsDropdown"></div>
|
||||||
|
<div class="composer-reasoning-dropdown" id="composerReasoningDropdown">
|
||||||
|
<div class="reasoning-option" data-effort="none">None</div>
|
||||||
|
<div class="reasoning-option" data-effort="minimal">Minimal</div>
|
||||||
|
<div class="reasoning-option" data-effort="low">Low</div>
|
||||||
|
<div class="reasoning-option" data-effort="medium">Medium</div>
|
||||||
|
<div class="reasoning-option" data-effort="high">High</div>
|
||||||
|
<div class="reasoning-option" data-effort="xhigh">Extra High</div>
|
||||||
|
</div>
|
||||||
<div class="model-dropdown" id="composerModelDropdown"></div>
|
<div class="model-dropdown" id="composerModelDropdown"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="upload-bar-wrap" id="uploadBarWrap"><div class="upload-bar" id="uploadBar"></div></div>
|
<div class="upload-bar-wrap" id="uploadBarWrap"><div class="upload-bar" id="uploadBar"></div></div>
|
||||||
|
|||||||
@@ -1323,4 +1323,115 @@ function sendBrowserNotification(title,body){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── /btw ephemeral stream ────────────────────────────────────────────────────
|
||||||
|
// Connects to the ephemeral SSE stream from /api/btw and renders the answer
|
||||||
|
// in a visually distinct bubble that is NOT persisted to session history.
|
||||||
|
|
||||||
|
function attachBtwStream(parentSid, streamId, question){
|
||||||
|
if(!parentSid||!streamId) return;
|
||||||
|
const src=new EventSource('/api/stream?stream_id='+encodeURIComponent(streamId));
|
||||||
|
let answer='';
|
||||||
|
let btwRow=null;
|
||||||
|
let _streamDone=false;
|
||||||
|
function _ensureBtwRow(){
|
||||||
|
if(btwRow&&btwRow.isConnected) return;
|
||||||
|
const inner=$('msgInner');
|
||||||
|
if(!inner) return;
|
||||||
|
btwRow=document.createElement('div');
|
||||||
|
btwRow.className='msg-row msg-row-btw';
|
||||||
|
btwRow.dataset.role='assistant';
|
||||||
|
btwRow.dataset.btw='1';
|
||||||
|
const labelEl=document.createElement('div');
|
||||||
|
labelEl.className='msg-btw-label';
|
||||||
|
labelEl.textContent=t('btw_label');
|
||||||
|
const qEl=document.createElement('div');
|
||||||
|
qEl.className='msg-body';
|
||||||
|
qEl.textContent=question;
|
||||||
|
const ansEl=document.createElement('div');
|
||||||
|
ansEl.className='msg-body msg-btw-answer';
|
||||||
|
ansEl.textContent='...';
|
||||||
|
btwRow.appendChild(labelEl);
|
||||||
|
btwRow.appendChild(qEl);
|
||||||
|
btwRow.appendChild(ansEl);
|
||||||
|
inner.appendChild(btwRow);
|
||||||
|
btwRow.scrollIntoView({behavior:'smooth',block:'end'});
|
||||||
|
}
|
||||||
|
src.addEventListener('token',e=>{
|
||||||
|
try{answer+=JSON.parse(e.data).text||'';}catch(_){}
|
||||||
|
_ensureBtwRow();
|
||||||
|
const ansEl=btwRow&&btwRow.querySelector('.msg-btw-answer');
|
||||||
|
if(ansEl) ansEl.innerHTML=renderMd(answer);
|
||||||
|
});
|
||||||
|
src.addEventListener('done',e=>{
|
||||||
|
src.close();
|
||||||
|
_streamDone=true;
|
||||||
|
try{
|
||||||
|
const d=JSON.parse(e.data);
|
||||||
|
if(d.answer&&!answer) answer=d.answer;
|
||||||
|
}catch(_){}
|
||||||
|
_ensureBtwRow();
|
||||||
|
if(btwRow&&btwRow.isConnected){
|
||||||
|
const ansEl=btwRow.querySelector('.msg-btw-answer');
|
||||||
|
if(ansEl) ansEl.innerHTML=renderMd(answer||t('btw_no_answer'));
|
||||||
|
}
|
||||||
|
showToast(t('btw_done'));
|
||||||
|
});
|
||||||
|
src.addEventListener('apperror',e=>{
|
||||||
|
src.close();
|
||||||
|
_streamDone=true;
|
||||||
|
try{
|
||||||
|
const d=JSON.parse(e.data);
|
||||||
|
showToast(t('btw_failed')+(d.message||''));
|
||||||
|
}catch(_){showToast(t('btw_failed'));}
|
||||||
|
if(btwRow&&btwRow.isConnected) btwRow.remove();
|
||||||
|
});
|
||||||
|
src.addEventListener('stream_end',()=>{src.close();});
|
||||||
|
src.onerror=()=>{src.close();if(!_streamDone&&btwRow&&btwRow.isConnected) btwRow.remove();};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── /background task tracking ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let _bgPollTimers={};
|
||||||
|
let _bgActiveTasks=new Set();
|
||||||
|
|
||||||
|
function showBackgroundBadge(taskId){
|
||||||
|
_bgActiveTasks.add(taskId);
|
||||||
|
const badge=$('bgBadge');
|
||||||
|
if(badge){
|
||||||
|
badge.textContent=String(_bgActiveTasks.size);
|
||||||
|
badge.style.display=_bgActiveTasks.size?'':'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function hideBackgroundBadge(taskId){
|
||||||
|
_bgActiveTasks.delete(taskId);
|
||||||
|
const badge=$('bgBadge');
|
||||||
|
if(badge){
|
||||||
|
badge.textContent=String(_bgActiveTasks.size);
|
||||||
|
badge.style.display=_bgActiveTasks.size?'':'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function startBackgroundPolling(parentSid, taskId, prompt){
|
||||||
|
if(_bgPollTimers[taskId]) return;
|
||||||
|
async function _poll(){
|
||||||
|
try{
|
||||||
|
const r=await api('/api/background/status?session_id='+encodeURIComponent(parentSid));
|
||||||
|
if(r&&r.results){
|
||||||
|
for(const res of r.results){
|
||||||
|
if(res.task_id===taskId){
|
||||||
|
hideBackgroundBadge(taskId);
|
||||||
|
delete _bgPollTimers[taskId];
|
||||||
|
const msg={role:'assistant',content:`**${t('bg_label')}** ${prompt.slice(0,80)}\n\n${res.answer||t('bg_no_answer')}`,'_background':true,_ts:Date.now()/1000};
|
||||||
|
S.messages.push(msg);
|
||||||
|
renderMessages();
|
||||||
|
showToast(t('bg_complete'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}catch(_){}
|
||||||
|
_bgPollTimers[taskId]=setTimeout(_poll,3000);
|
||||||
|
}
|
||||||
|
_poll();
|
||||||
|
}
|
||||||
|
|
||||||
// ── Panel navigation (Chat / Tasks / Skills / Memory) ──
|
// ── Panel navigation (Chat / Tasks / Skills / Memory) ──
|
||||||
|
|||||||
@@ -593,6 +593,17 @@
|
|||||||
.composer-workspace-chip.active{color:var(--text);background:var(--accent-bg);border-color:var(--accent-bg);}
|
.composer-workspace-chip.active{color:var(--text);background:var(--accent-bg);border-color:var(--accent-bg);}
|
||||||
.composer-workspace-icon,.composer-workspace-chevron{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;line-height:1;}
|
.composer-workspace-icon,.composer-workspace-chevron{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;line-height:1;}
|
||||||
.composer-workspace-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
.composer-workspace-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||||
|
.composer-reasoning-wrap{position:relative;flex:0 1 auto;min-width:0;}
|
||||||
|
.composer-reasoning-chip{display:inline-flex;align-items:center;gap:8px;max-width:180px;padding:8px 10px 8px 12px;border-radius:999px;border:1px solid transparent;background-color:transparent;color:var(--muted);font-weight:500;cursor:pointer;transition:color .15s,background-color .15s,border-color .15s;}
|
||||||
|
.composer-reasoning-chip:hover{color:var(--text);background-color:var(--hover-bg);}
|
||||||
|
.composer-reasoning-chip.active{color:var(--text);background:var(--accent-bg);border-color:var(--accent-bg);}
|
||||||
|
.composer-reasoning-icon,.composer-reasoning-chevron{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;line-height:1;}
|
||||||
|
.composer-reasoning-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||||
|
.composer-reasoning-dropdown{display:none;position:absolute;bottom:calc(100% + 4px);left:0;background:var(--surface);border:1px solid var(--border2);border-radius:10px;box-shadow:0 -4px 24px rgba(0,0,0,.4);min-width:140px;z-index:200;padding:4px;animation:dropdown-in .12s ease-out;}
|
||||||
|
.composer-reasoning-dropdown.open{display:block;}
|
||||||
|
.reasoning-option{padding:8px 14px;border-radius:6px;cursor:pointer;font-size:13px;color:var(--text);white-space:nowrap;transition:background-color .1s;}
|
||||||
|
.reasoning-option:hover{background:var(--hover-bg);}
|
||||||
|
.reasoning-option.selected{color:var(--accent);font-weight:600;}
|
||||||
.composer-model-wrap{position:relative;flex:0 1 auto;min-width:0;}
|
.composer-model-wrap{position:relative;flex:0 1 auto;min-width:0;}
|
||||||
.composer-model-chip{display:inline-flex;align-items:center;gap:8px;max-width:220px;padding:8px 10px 8px 12px;border-radius:999px;border:1px solid transparent;background-color:transparent;color:var(--muted);font-weight:500;cursor:pointer;transition:color .15s,background-color .15s,border-color .15s;}
|
.composer-model-chip{display:inline-flex;align-items:center;gap:8px;max-width:220px;padding:8px 10px 8px 12px;border-radius:999px;border:1px solid transparent;background-color:transparent;color:var(--muted);font-weight:500;cursor:pointer;transition:color .15s,background-color .15s,border-color .15s;}
|
||||||
.composer-model-chip:hover{color:var(--text);background-color:var(--hover-bg);}
|
.composer-model-chip:hover{color:var(--text);background-color:var(--hover-bg);}
|
||||||
@@ -769,12 +780,15 @@
|
|||||||
.composer-profile-label,
|
.composer-profile-label,
|
||||||
.composer-workspace-label,
|
.composer-workspace-label,
|
||||||
.composer-model-label,
|
.composer-model-label,
|
||||||
|
.composer-reasoning-label,
|
||||||
.composer-profile-chevron,
|
.composer-profile-chevron,
|
||||||
.composer-workspace-chevron,
|
.composer-workspace-chevron,
|
||||||
.composer-model-chevron{display:none;}
|
.composer-model-chevron,
|
||||||
|
.composer-reasoning-chevron{display:none;}
|
||||||
.composer-profile-chip,
|
.composer-profile-chip,
|
||||||
.composer-workspace-chip,
|
.composer-workspace-chip,
|
||||||
.composer-model-chip{max-width:44px;min-width:44px;min-height:44px;padding:6px;justify-content:center;gap:0;font-size:11px;}
|
.composer-model-chip,
|
||||||
|
.composer-reasoning-chip{max-width:44px;min-width:44px;min-height:44px;padding:6px;justify-content:center;gap:0;font-size:11px;}
|
||||||
.composer-divider{display:none;}
|
.composer-divider{display:none;}
|
||||||
.composer-status{max-width:96px;font-size:10px;}
|
.composer-status{max-width:96px;font-size:10px;}
|
||||||
.send-btn{width:32px;height:32px;}
|
.send-btn{width:32px;height:32px;}
|
||||||
@@ -1909,3 +1923,40 @@ body.resizing{user-select:none;cursor:col-resize;}
|
|||||||
.assistant-turn .msg-foot{opacity:1;}
|
.assistant-turn .msg-foot{opacity:1;}
|
||||||
.msg-actions{opacity:1;}
|
.msg-actions{opacity:1;}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── /btw ephemeral side-question bubble ── */
|
||||||
|
.msg-row-btw {
|
||||||
|
border-left: 3px solid var(--accent-bg-strong);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--accent-bg);
|
||||||
|
margin: 6px 0 6px var(--msg-rail);
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
.msg-row-btw .msg-body { font-size: 14px; line-height: 1.75; }
|
||||||
|
.msg-btw-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
opacity: .8;
|
||||||
|
}
|
||||||
|
.msg-btw-answer {
|
||||||
|
margin-top: 6px;
|
||||||
|
border-top: 1px solid var(--accent-bg-strong);
|
||||||
|
padding-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── /background badge in composer footer ── */
|
||||||
|
.bg-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: var(--accent-bg-strong);
|
||||||
|
color: var(--accent-text);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|||||||
94
static/ui.js
94
static/ui.js
@@ -386,6 +386,7 @@ function toggleModelDropdown(){
|
|||||||
if(open){closeModelDropdown(); return;}
|
if(open){closeModelDropdown(); return;}
|
||||||
if(typeof closeProfileDropdown==='function') closeProfileDropdown();
|
if(typeof closeProfileDropdown==='function') closeProfileDropdown();
|
||||||
if(typeof closeWsDropdown==='function') closeWsDropdown();
|
if(typeof closeWsDropdown==='function') closeWsDropdown();
|
||||||
|
if(typeof closeReasoningDropdown==='function') closeReasoningDropdown();
|
||||||
renderModelDropdown();
|
renderModelDropdown();
|
||||||
dd.classList.add('open');
|
dd.classList.add('open');
|
||||||
_positionModelDropdown();
|
_positionModelDropdown();
|
||||||
@@ -405,6 +406,97 @@ document.addEventListener('click',e=>{
|
|||||||
window.addEventListener('resize',()=>{
|
window.addEventListener('resize',()=>{
|
||||||
const dd=$('composerModelDropdown');
|
const dd=$('composerModelDropdown');
|
||||||
if(dd&&dd.classList.contains('open')) _positionModelDropdown();
|
if(dd&&dd.classList.contains('open')) _positionModelDropdown();
|
||||||
|
// Keep the reasoning dropdown aligned under its chip when the window
|
||||||
|
// resizes while open — same pattern as the model dropdown above.
|
||||||
|
const rdd=$('composerReasoningDropdown');
|
||||||
|
if(rdd&&rdd.classList.contains('open')&&typeof _positionReasoningDropdown==='function'){
|
||||||
|
_positionReasoningDropdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Reasoning effort chip ────────────────────────────────────────────────────
|
||||||
|
let _currentReasoningEffort=null;
|
||||||
|
|
||||||
|
function _applyReasoningChip(eff){
|
||||||
|
_currentReasoningEffort=eff;
|
||||||
|
const wrap=$('composerReasoningWrap');
|
||||||
|
const label=$('composerReasoningLabel');
|
||||||
|
if(!wrap||!label) return;
|
||||||
|
if(!eff||eff==='none'){wrap.style.display='none';return;}
|
||||||
|
wrap.style.display='';
|
||||||
|
label.textContent=eff;
|
||||||
|
_highlightReasoningOption(eff);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchReasoningChip(){
|
||||||
|
api('/api/reasoning').then(function(st){
|
||||||
|
_applyReasoningChip((st&&st.reasoning_effort)||'');
|
||||||
|
}).catch(function(){_applyReasoningChip('');});
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncReasoningChip(){
|
||||||
|
if(_currentReasoningEffort===null){fetchReasoningChip();return;}
|
||||||
|
_applyReasoningChip(_currentReasoningEffort);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _highlightReasoningOption(effort){
|
||||||
|
const dd=$('composerReasoningDropdown');
|
||||||
|
if(!dd) return;
|
||||||
|
dd.querySelectorAll('.reasoning-option').forEach(function(opt){
|
||||||
|
opt.classList.toggle('selected',opt.dataset.effort===effort);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleReasoningDropdown(){
|
||||||
|
const dd=$('composerReasoningDropdown');
|
||||||
|
const chip=$('composerReasoningChip');
|
||||||
|
if(!dd||!chip) return;
|
||||||
|
const open=dd.classList.contains('open');
|
||||||
|
if(open){closeReasoningDropdown();return;}
|
||||||
|
if(typeof closeProfileDropdown==='function') closeProfileDropdown();
|
||||||
|
if(typeof closeWsDropdown==='function') closeWsDropdown();
|
||||||
|
closeModelDropdown();
|
||||||
|
_highlightReasoningOption(_currentReasoningEffort);
|
||||||
|
dd.classList.add('open');
|
||||||
|
_positionReasoningDropdown();
|
||||||
|
chip.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _positionReasoningDropdown(){
|
||||||
|
const dd=$('composerReasoningDropdown');
|
||||||
|
const chip=$('composerReasoningChip');
|
||||||
|
const footer=document.querySelector('.composer-footer');
|
||||||
|
if(!dd||!chip||!footer) return;
|
||||||
|
const chipRect=chip.getBoundingClientRect();
|
||||||
|
const footerRect=footer.getBoundingClientRect();
|
||||||
|
let left=chipRect.left-footerRect.left;
|
||||||
|
const maxLeft=Math.max(0,footer.clientWidth-dd.offsetWidth);
|
||||||
|
left=Math.max(0,Math.min(left,maxLeft));
|
||||||
|
dd.style.left=`${left}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeReasoningDropdown(){
|
||||||
|
const dd=$('composerReasoningDropdown');
|
||||||
|
const chip=$('composerReasoningChip');
|
||||||
|
if(dd) dd.classList.remove('open');
|
||||||
|
if(chip) chip.classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click',function(e){
|
||||||
|
if(!e.target.closest('#composerReasoningChip')&&!e.target.closest('#composerReasoningDropdown')) closeReasoningDropdown();
|
||||||
|
if(e.target.closest('.reasoning-option')){
|
||||||
|
const opt=e.target.closest('.reasoning-option');
|
||||||
|
const effort=opt&&opt.dataset.effort;
|
||||||
|
if(effort){
|
||||||
|
api('/api/reasoning',{method:'POST',body:JSON.stringify({effort:effort})})
|
||||||
|
.then(function(st){
|
||||||
|
_applyReasoningChip((st&&st.reasoning_effort)||effort);
|
||||||
|
showToast('🧠 Reasoning effort set to '+((st&&st.reasoning_effort)||effort));
|
||||||
|
})
|
||||||
|
.catch(function(){showToast('🧠 Failed to set effort');});
|
||||||
|
closeReasoningDropdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Scroll pinning ──────────────────────────────────────────────────────────
|
// ── Scroll pinning ──────────────────────────────────────────────────────────
|
||||||
@@ -1330,6 +1422,7 @@ function syncTopbar(){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if(typeof syncModelChip==='function') syncModelChip();
|
if(typeof syncModelChip==='function') syncModelChip();
|
||||||
|
if(typeof syncReasoningChip==='function') syncReasoningChip();
|
||||||
// Show Clear button only when session has messages
|
// Show Clear button only when session has messages
|
||||||
const clearBtn=$('btnClearConv');
|
const clearBtn=$('btnClearConv');
|
||||||
if(clearBtn) clearBtn.style.display=(S.messages&&S.messages.filter(msg=>msg.role!=='tool').length>0)?'':'none';
|
if(clearBtn) clearBtn.style.display=(S.messages&&S.messages.filter(msg=>msg.role!=='tool').length>0)?'':'none';
|
||||||
@@ -1693,6 +1786,7 @@ function renderMessages(){
|
|||||||
const bodyHtml = isUser ? esc(String(content)).replace(/\n/g,'<br>') : renderMd(_stripXmlToolCallsDisplay(String(content)));
|
const bodyHtml = isUser ? esc(String(content)).replace(/\n/g,'<br>') : renderMd(_stripXmlToolCallsDisplay(String(content)));
|
||||||
const isEditableUser=isUser&&rawIdx===lastUserRawIdx;
|
const isEditableUser=isUser&&rawIdx===lastUserRawIdx;
|
||||||
const editBtn = isEditableUser ? `<button class="msg-action-btn" title="${t('edit_message')}" onclick="editMessage(this)">${li('pencil',13)}</button>` : '';
|
const editBtn = isEditableUser ? `<button class="msg-action-btn" title="${t('edit_message')}" onclick="editMessage(this)">${li('pencil',13)}</button>` : '';
|
||||||
|
const undoBtn = isLastAssistant ? `<button class="msg-action-btn" title="${t('undo_exchange')}" onclick="undoLastExchange()">${li('undo-2',13)}</button>` : '';
|
||||||
const retryBtn = isLastAssistant ? `<button class="msg-action-btn" title="${t('regenerate')}" onclick="regenerateResponse(this)">${li('rotate-ccw',13)}</button>` : '';
|
const retryBtn = isLastAssistant ? `<button class="msg-action-btn" title="${t('regenerate')}" onclick="regenerateResponse(this)">${li('rotate-ccw',13)}</button>` : '';
|
||||||
const copyBtn = `<button class="msg-copy-btn msg-action-btn" title="${t('copy')}" onclick="copyMsg(this)">${li('copy',13)}</button>`;
|
const copyBtn = `<button class="msg-copy-btn msg-action-btn" title="${t('copy')}" onclick="copyMsg(this)">${li('copy',13)}</button>`;
|
||||||
const tsVal=m._ts||m.timestamp;
|
const tsVal=m._ts||m.timestamp;
|
||||||
|
|||||||
29
static/vendor/smd.min.js
vendored
Normal file
29
static/vendor/smd.min.js
vendored
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
var D=2,C=3,h=4,b=5,B=6,U=7,G=8,S=9,x=10,m=11,H=12,K=13,M=14,Q=15,w=16,q=17,W=18,P=19,Y=20,y=21,F=22,$=23,v=24,X=25,j=26,z=27,J=28,V=29,Z=30,p=31;var I=1,k=2,L=4,T=8,f=16;function ee(e){switch(e){case I:return"href";case k:return"src";case L:return"class";case T:return"checked";case f:return"start"}}var ne=e=>{switch(e){case 1:return 3;case 2:return 4;case 3:return 5;case 4:return 6;case 5:return 7;default:return 8}},te=ne;var O=24;function ae(e){let c=new Uint32Array(O);return c[0]=1,{renderer:e,text:"",pending:"",tokens:c,len:0,token:1,fence_end:0,blockquote_idx:0,hr_char:"",hr_chars:0,fence_start:0,spaces:new Uint8Array(O),indent:"",indent_len:0,table_state:0}}function ce(e){e.pending.length>0&&o(e,`
|
||||||
|
`)}function a(e){e.text.length!==0&&(e.renderer.add_text(e.renderer.data,e.text),e.text="")}function _(e){e.len-=1,e.token=e.tokens[e.len],e.renderer.end_token(e.renderer.data)}function i(e,c){(e.tokens[e.len]===24||e.tokens[e.len]===23)&&c!==25&&_(e),e.len+=1,e.tokens[e.len]=c,e.token=c,e.renderer.add_token(e.renderer.data,c)}function re(e,c,n){for(;n<=e.len;){if(e.tokens[n]===c)return n;n+=1}return-1}function l(e,c){for(e.fence_start=0;e.len>c;)_(e)}function u(e,c){let n=0;for(let t=0;t<=e.len&&(c-=e.spaces[t],!(c<0));t+=1)switch(e.tokens[t]){case 9:case 10:case 20:case 25:n=t;break}for(;e.len>n;)_(e);return c}function A(e,c){let n=-1,t=-1;for(let s=e.blockquote_idx+1;s<=e.len;s+=1)if(e.tokens[s]===25){if(e.indent_len<e.spaces[s]){t=-1;break}t=s}else e.tokens[s]===c&&(n=s);return t===-1?n===-1?(l(e,e.blockquote_idx),i(e,c),!0):(l(e,n),!1):(l(e,t),i(e,c),!0)}function g(e,c){i(e,25),e.spaces[e.len]=e.indent_len+c,E(e),e.token=103}function E(e){e.indent="",e.indent_len=0,e.pending=""}function N(e){switch(e){case 48:case 49:case 50:case 51:case 52:case 53:case 54:case 55:case 56:case 57:return!0;default:return!1}}function ie(e){switch(e){case 32:case 58:case 59:case 41:case 44:case 33:case 46:case 63:case 93:case 10:return!0;default:return!1}}function se(e){return N(e)||ie(e)}function o(e,c){for(let n of c){if(e.token===101){switch(n){case" ":e.indent_len+=1;continue;case" ":e.indent_len+=4;continue}let s=u(e,e.indent_len);e.indent_len=0,e.token=e.tokens[e.len],s>0&&o(e," ".repeat(s))}let t=e.pending+n;switch(e.token){case 21:case 1:case 20:case 24:case 23:switch(e.pending[0]){case void 0:e.pending=n;continue;case" ":e.pending=n,e.indent+=" ",e.indent_len+=1;continue;case" ":e.pending=n,e.indent+=" ",e.indent_len+=4;continue;case`
|
||||||
|
`:if(e.tokens[e.len]===25&&e.token===21){_(e),E(e),e.pending=n;continue}l(e,e.blockquote_idx),E(e),e.blockquote_idx=0,e.fence_start=0,e.pending=n;continue;case"#":switch(n){case"#":if(e.pending.length<6){e.pending=t;continue}break;case" ":u(e,e.indent_len),i(e,te(e.pending.length)),E(e);continue}break;case">":{let r=re(e,20,e.blockquote_idx+1);r===-1?(l(e,e.blockquote_idx),e.blockquote_idx+=1,e.fence_start=0,i(e,20)):e.blockquote_idx=r,E(e),e.pending=n;continue}case"-":case"*":case"_":if(e.hr_chars===0&&(e.hr_chars=1,e.hr_char=e.pending),e.hr_chars>0){switch(n){case e.hr_char:e.hr_chars+=1,e.pending=t;continue;case" ":e.pending=t;continue;case`
|
||||||
|
`:if(e.hr_chars<3)break;u(e,e.indent_len),e.renderer.add_token(e.renderer.data,22),e.renderer.end_token(e.renderer.data),E(e),e.hr_chars=0;continue}e.hr_chars=0}if(e.pending[0]!=="_"&&e.pending[1]===" "){A(e,23),g(e,2),o(e,t.slice(2));continue}break;case"`":if(e.pending.length<3){if(n==="`"){e.pending=t,e.fence_start=t.length;continue}e.fence_start=0;break}switch(n){case"`":e.pending.length===e.fence_start?(e.pending=t,e.fence_start=t.length):(i(e,2),E(e),e.fence_start=0,o(e,t));continue;case`
|
||||||
|
`:{u(e,e.indent_len),i(e,10),e.pending.length>e.fence_start&&e.renderer.set_attr(e.renderer.data,L,e.pending.slice(e.fence_start)),E(e),e.token=101;continue}default:e.pending=t;continue}case"+":if(n!==" ")break;A(e,23),g(e,2);continue;case"0":case"1":case"2":case"3":case"4":case"5":case"6":case"7":case"8":case"9":if(e.pending[e.pending.length-1]==="."){if(n!==" ")break;A(e,24)&&e.pending!=="1."&&e.renderer.set_attr(e.renderer.data,f,e.pending.slice(0,-1)),g(e,e.pending.length+1);continue}else{let r=n.charCodeAt(0);if(r===46||N(r)){e.pending=t;continue}}break;case"|":l(e,e.blockquote_idx),i(e,27),i(e,28),e.pending="",o(e,n);continue}let s=t;if(e.token===21)e.token=e.tokens[e.len],e.renderer.add_token(e.renderer.data,21),e.renderer.end_token(e.renderer.data);else if(e.indent_len>=4){let r=0;for(;r<4;r+=1)if(e.indent[r]===" "){r=r+1;break}s=e.indent.slice(r)+t,i(e,9)}else i(e,2);E(e),o(e,s);continue;case 27:if(e.table_state===1)switch(n){case"-":case" ":case"|":case":":e.pending=t;continue;case`
|
||||||
|
`:e.table_state=2,e.pending="";continue;default:_(e),e.table_state=0;break}else switch(e.pending){case"|":i(e,28),e.pending="",o(e,n);continue;case`
|
||||||
|
`:_(e),e.pending="",e.table_state=0,o(e,n);continue}break;case 28:switch(e.pending){case"":break;case"|":i(e,29),_(e),e.pending="",o(e,n);continue;case`
|
||||||
|
`:_(e),e.table_state=Math.min(e.table_state+1,2),e.pending="",o(e,n);continue;default:i(e,29),o(e,n);continue}break;case 29:if(e.pending==="|"){a(e),_(e),e.pending="",o(e,n);continue}break;case 9:switch(t){case`
|
||||||
|
`:case`
|
||||||
|
`:case`
|
||||||
|
`:case`
|
||||||
|
`:case`
|
||||||
|
`:e.text+=`
|
||||||
|
`,e.pending="";continue;case`
|
||||||
|
`:case`
|
||||||
|
`:case`
|
||||||
|
`:case`
|
||||||
|
`:e.pending=t;continue;default:e.pending.length!==0?(a(e),_(e),e.pending=n):e.text+=n;continue}case 10:switch(n){case"`":e.pending=t;continue;case`
|
||||||
|
`:if(t.length===e.fence_start+e.fence_end+1){a(e),_(e),e.pending="",e.fence_start=0,e.fence_end=0,e.token=101;continue}e.token=101;break;case" ":if(e.pending[0]===`
|
||||||
|
`){e.pending=t,e.fence_end+=1;continue}break}e.text+=e.pending,e.pending=n,e.fence_end=1;continue;case 11:switch(n){case"`":t.length===e.fence_start+ +(e.pending[0]===" ")?(a(e),_(e),e.pending="",e.fence_start=0):e.pending=t;continue;case`
|
||||||
|
`:e.text+=e.pending,e.pending="",e.token=21,e.blockquote_idx=0,a(e);continue;case" ":e.text+=e.pending,e.pending=n;continue;default:e.text+=t,e.pending="";continue}case 103:switch(e.pending.length){case 0:if(n!=="[")break;e.pending=t;continue;case 1:if(n!==" "&&n!=="x")break;e.pending=t;continue;case 2:if(n!=="]")break;e.pending=t;continue;case 3:if(n!==" ")break;e.renderer.add_token(e.renderer.data,26),e.pending[1]==="x"&&e.renderer.set_attr(e.renderer.data,T,""),e.renderer.end_token(e.renderer.data),e.pending=" ";continue}e.token=e.tokens[e.len],e.pending="",o(e,t);continue;case 14:case 15:{let r="*",d=12;if(e.token===15&&(r="_",d=13),r===e.pending){if(a(e),r===n){_(e),e.pending="";continue}i(e,d),e.pending=n;continue}break}case 12:case 13:{let r="*",d=14;switch(e.token===13&&(r="_",d=15),e.pending){case r:r===n?e.tokens[e.len-1]===d?e.pending=t:(a(e),i(e,d),e.pending=""):(a(e),_(e),e.pending=n);continue;case r+r:let R=e.token;a(e),_(e),_(e),r!==n?(i(e,R),e.pending=n):e.pending="";continue}break}case 16:if(t==="~~"){a(e),_(e),e.pending="";continue}break;case 105:n===`
|
||||||
|
`?(a(e),i(e,30),e.pending=""):(e.token=e.tokens[e.len],e.pending[0]==="\\"?e.text+="[":e.text+="$$",e.pending="",o(e,n));continue;case 30:if(t==="\\]"||t==="$$"){a(e),_(e),e.pending="";continue}break;case 31:if(t==="\\)"||e.pending[0]==="$"){a(e),_(e),n===")"?e.pending="":e.pending=n;continue}break;case 102:t==="http://"||t==="https://"?(a(e),i(e,18),e.pending=t,e.text=t):"http:/"[e.pending.length]===n||"https:/"[e.pending.length]===n?e.pending=t:(e.token=e.tokens[e.len],o(e,n));continue;case 17:case 19:if(e.pending==="]"){a(e),n==="("?e.pending=t:(_(e),e.pending=n);continue}if(e.pending[0]==="]"&&e.pending[1]==="("){if(n===")"){let r=e.token===17?I:k,d=e.pending.slice(2);e.renderer.set_attr(e.renderer.data,r,d),_(e),e.pending=""}else e.pending+=n;continue}break;case 18:n===" "||n===`
|
||||||
|
`||n==="\\"?(e.renderer.set_attr(e.renderer.data,I,e.pending),a(e),_(e),e.pending=n):(e.text+=n,e.pending=t);continue;case 104:if(t.startsWith("<br")){if(t.length===3||n===" "||n==="/"&&(t.length===4||e.pending[e.pending.length-1]===" ")){e.pending=t;continue}if(n===">"){a(e),e.token=e.tokens[e.len],e.renderer.add_token(e.renderer.data,21),e.renderer.end_token(e.renderer.data),e.pending="";continue}}e.token=e.tokens[e.len],e.text+="<",e.pending=e.pending.slice(1),o(e,n);continue}switch(e.pending[0]){case"\\":if(e.token===19||e.token===30||e.token===31)break;switch(n){case"(":a(e),i(e,31),e.pending="";continue;case"[":e.token=105,e.pending=t;continue;case`
|
||||||
|
`:e.pending=n;continue;default:let s=n.charCodeAt(0);e.pending="",e.text+=N(s)||s>=65&&s<=90||s>=97&&s<=122?t:n;continue}case`
|
||||||
|
`:switch(e.token){case 19:case 30:case 31:break;case 3:case 4:case 5:case 6:case 7:case 8:a(e),l(e,e.blockquote_idx),e.blockquote_idx=0,e.pending=n;continue;default:a(e),e.pending=n,e.token=21,e.blockquote_idx=0;continue}break;case"<":if(e.token!==19&&e.token!==30&&e.token!==31){a(e),e.pending=t,e.token=104;continue}break;case"`":if(e.token===19)break;n==="`"?(e.fence_start+=1,e.pending=t):(e.fence_start+=1,a(e),i(e,11),e.text=n===" "||n===`
|
||||||
|
`?"":n,e.pending="");continue;case"_":case"*":{if(e.token===19||e.token===30||e.token===31||e.token===14)break;let s=12,r=14,d=e.pending[0];if(d==="_"&&(s=13,r=15),e.pending.length===1){if(d===n){e.pending=t;continue}if(n!==" "&&n!==`
|
||||||
|
`){a(e),i(e,s),e.pending=n;continue}}else{if(d===n){a(e),i(e,r),i(e,s),e.pending="";continue}if(n!==" "&&n!==`
|
||||||
|
`){a(e),i(e,r),e.pending=n;continue}}break}case"~":if(e.token!==19&&e.token!==16){if(e.pending==="~"){if(n==="~"){e.pending=t;continue}}else if(n!==" "&&n!==`
|
||||||
|
`){a(e),i(e,16),e.pending=n;continue}}break;case"$":if(e.token!==19&&e.token!==16&&e.pending==="$")if(n==="$"){e.token=105,e.pending=t;continue}else{if(se(n.charCodeAt(0)))break;a(e),i(e,31),e.pending=n;continue}break;case"[":if(e.token!==19&&e.token!==17&&e.token!==30&&e.token!==31&&n!=="]"){a(e),i(e,17),e.pending=n;continue}break;case"!":if(e.token!==19&&n==="["){a(e),i(e,19),e.pending="";continue}break;case" ":if(e.pending.length===1&&n===" ")continue;break}if(e.token!==19&&e.token!==17&&e.token!==30&&e.token!==31&&n==="h"&&(e.pending===" "||e.pending==="")){e.text+=e.pending,e.pending=n,e.token=102;continue}e.text+=e.pending,e.pending=n}a(e)}function _e(e){return{add_token:oe,end_token:de,add_text:Ee,set_attr:le,data:{nodes:[e,,,,,],index:0}}}function oe(e,c){let n=e.nodes[e.index],t;switch(c){case 1:return;case 20:t=document.createElement("blockquote");break;case 2:t=document.createElement("p");break;case 21:t=document.createElement("br");break;case 22:t=document.createElement("hr");break;case 3:t=document.createElement("h1");break;case 4:t=document.createElement("h2");break;case 5:t=document.createElement("h3");break;case 6:t=document.createElement("h4");break;case 7:t=document.createElement("h5");break;case 8:t=document.createElement("h6");break;case 12:case 13:t=document.createElement("em");break;case 14:case 15:t=document.createElement("strong");break;case 16:t=document.createElement("s");break;case 11:t=document.createElement("code");break;case 18:case 17:t=document.createElement("a");break;case 19:t=document.createElement("img");break;case 23:t=document.createElement("ul");break;case 24:t=document.createElement("ol");break;case 25:t=document.createElement("li");break;case 26:let s=t=document.createElement("input");s.type="checkbox",s.disabled=!0;break;case 9:case 10:n=n.appendChild(document.createElement("pre")),t=document.createElement("code");break;case 27:t=document.createElement("table");break;case 28:switch(n.children.length){case 0:n=n.appendChild(document.createElement("thead"));break;case 1:n=n.appendChild(document.createElement("tbody"));break;default:n=n.children[1]}t=document.createElement("tr");break;case 29:t=document.createElement(n.parentElement?.tagName==="THEAD"?"th":"td");break;case 30:t=document.createElement("equation-block");break;case 31:t=document.createElement("equation-inline");break}e.nodes[++e.index]=n.appendChild(t)}function de(e){e.index-=1}function Ee(e,c){e.nodes[e.index].appendChild(document.createTextNode(c))}function le(e,c,n){e.nodes[e.index].setAttribute(ee(c),n)}export{Y as BLOCKQUOTE,j as CHECKBOX,T as CHECKED,S as CODE_BLOCK,x as CODE_FENCE,m as CODE_INLINE,Z as EQUATION_BLOCK,p as EQUATION_INLINE,C as HEADING_1,h as HEADING_2,b as HEADING_3,B as HEADING_4,U as HEADING_5,G as HEADING_6,I as HREF,P as IMAGE,H as ITALIC_AST,K as ITALIC_UND,L as LANG,y as LINE_BREAK,q as LINK,X as LIST_ITEM,v as LIST_ORDERED,$ as LIST_UNORDERED,D as PARAGRAPH,W as RAW_URL,F as RULE,k as SRC,f as START,w as STRIKE,M as STRONG_AST,Q as STRONG_UND,z as TABLE,V as TABLE_CELL,J as TABLE_ROW,_e as default_renderer,ae as parser,ce as parser_end,o as parser_write};
|
||||||
148
tests/test_background_tasks.py
Normal file
148
tests/test_background_tasks.py
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
"""Regression tests for the /background task tracker.
|
||||||
|
|
||||||
|
Covers two bugs caught in review of PR #932:
|
||||||
|
|
||||||
|
1. `get_results()` was calling `_BACKGROUND_TASKS.pop(parent_sid, [])`, which
|
||||||
|
removed EVERY task (including still-running ones) on the first poll. Once
|
||||||
|
popped, `complete_background()` could no longer find the task to mark done,
|
||||||
|
so the final answer was silently lost.
|
||||||
|
|
||||||
|
2. The `_handle_background` worker thread called `_run_agent_streaming` but
|
||||||
|
never invoked `complete_background()` after it returned. With no completion
|
||||||
|
hook, every background task stayed in `status="running"` forever —
|
||||||
|
`get_results()` filtered them out of its "done" list, and the user never
|
||||||
|
saw the result.
|
||||||
|
|
||||||
|
These two bugs together made the `/background` command completely
|
||||||
|
non-functional as originally shipped. The fix in api/background.py +
|
||||||
|
api/routes.py wires the completion hook and keeps running tasks in the
|
||||||
|
tracker until they resolve.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
|
# Ensure the repo root is importable without relying on CWD.
|
||||||
|
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||||
|
if str(REPO_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(REPO_ROOT))
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetResultsKeepsRunningTasks(unittest.TestCase):
|
||||||
|
"""get_results() MUST NOT drop still-running tasks from _BACKGROUND_TASKS."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
import api.background as bg
|
||||||
|
bg._BACKGROUND_TASKS.clear()
|
||||||
|
self.bg = bg
|
||||||
|
|
||||||
|
def test_running_tasks_survive_get_results_call(self):
|
||||||
|
"""A running task must remain in the tracker so complete_background()
|
||||||
|
can still find it after the first poll returns."""
|
||||||
|
parent = "parent-session-1"
|
||||||
|
self.bg.track_background(
|
||||||
|
parent_sid=parent, bg_sid="bg-a", stream_id="s-a",
|
||||||
|
task_id="task-a", prompt="long task",
|
||||||
|
)
|
||||||
|
|
||||||
|
# First poll: task is still running, no done results to return
|
||||||
|
results = self.bg.get_results(parent)
|
||||||
|
self.assertEqual(results, [], "no done tasks yet — nothing to return")
|
||||||
|
|
||||||
|
# The running task MUST still be tracked — otherwise the worker
|
||||||
|
# thread's complete_background call cannot find it.
|
||||||
|
remaining = self.bg.get_background_tasks(parent)
|
||||||
|
self.assertEqual(len(remaining), 1, (
|
||||||
|
"get_results dropped the still-running task — subsequent "
|
||||||
|
"complete_background() calls will silently no-op and the "
|
||||||
|
"result will be lost forever"
|
||||||
|
))
|
||||||
|
self.assertEqual(remaining[0]["status"], "running")
|
||||||
|
self.assertEqual(remaining[0]["task_id"], "task-a")
|
||||||
|
|
||||||
|
def test_done_tasks_are_returned_and_removed(self):
|
||||||
|
"""Done tasks are returned and popped; running tasks stay."""
|
||||||
|
parent = "parent-session-2"
|
||||||
|
self.bg.track_background(parent, "bg-done", "s-d", "task-done", "p1")
|
||||||
|
self.bg.track_background(parent, "bg-run", "s-r", "task-run", "p2")
|
||||||
|
self.bg.complete_background(parent, "task-done", "42")
|
||||||
|
|
||||||
|
results = self.bg.get_results(parent)
|
||||||
|
self.assertEqual(len(results), 1)
|
||||||
|
self.assertEqual(results[0]["task_id"], "task-done")
|
||||||
|
self.assertEqual(results[0]["answer"], "42")
|
||||||
|
|
||||||
|
# Done one is gone; running one is still tracked
|
||||||
|
remaining = self.bg.get_background_tasks(parent)
|
||||||
|
self.assertEqual(len(remaining), 1)
|
||||||
|
self.assertEqual(remaining[0]["task_id"], "task-run")
|
||||||
|
self.assertEqual(remaining[0]["status"], "running")
|
||||||
|
|
||||||
|
def test_complete_after_poll_still_reaches_tracker(self):
|
||||||
|
"""Regression for the original bug: poll → complete → poll must surface
|
||||||
|
the result. Before the fix, the first poll popped the running task and
|
||||||
|
complete_background()'s loop iterated over an empty list."""
|
||||||
|
parent = "parent-session-3"
|
||||||
|
self.bg.track_background(parent, "bg-x", "s-x", "task-x", "slow task")
|
||||||
|
|
||||||
|
# Frontend polls before the task finishes
|
||||||
|
first = self.bg.get_results(parent)
|
||||||
|
self.assertEqual(first, [])
|
||||||
|
|
||||||
|
# Worker thread finishes and calls complete_background
|
||||||
|
self.bg.complete_background(parent, "task-x", "answer!")
|
||||||
|
|
||||||
|
# Next poll must surface the answer
|
||||||
|
second = self.bg.get_results(parent)
|
||||||
|
self.assertEqual(len(second), 1)
|
||||||
|
self.assertEqual(second[0]["task_id"], "task-x")
|
||||||
|
self.assertEqual(second[0]["answer"], "answer!")
|
||||||
|
|
||||||
|
def test_empty_parent_is_cleaned_up(self):
|
||||||
|
"""When all tasks are done and returned, the parent key is removed from the dict."""
|
||||||
|
parent = "parent-session-4"
|
||||||
|
self.bg.track_background(parent, "bg-1", "s-1", "task-1", "p")
|
||||||
|
self.bg.complete_background(parent, "task-1", "ok")
|
||||||
|
self.bg.get_results(parent)
|
||||||
|
self.assertNotIn(parent, self.bg._BACKGROUND_TASKS)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBackgroundCompletionHookWiring(unittest.TestCase):
|
||||||
|
"""Static check: the _handle_background worker thread must call
|
||||||
|
complete_background() after _run_agent_streaming returns. Without this,
|
||||||
|
running tasks stay forever-running and the user never sees the result.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_run_bg_and_notify_calls_complete_background(self):
|
||||||
|
"""_handle_background must wrap _run_agent_streaming in a function
|
||||||
|
that subsequently invokes complete_background(parent_sid, task_id, answer)."""
|
||||||
|
routes_src = (REPO_ROOT / "api" / "routes.py").read_text(encoding="utf-8")
|
||||||
|
# Locate the _handle_background function
|
||||||
|
idx = routes_src.find("def _handle_background(")
|
||||||
|
self.assertGreater(idx, -1, "_handle_background() not found in routes.py")
|
||||||
|
# Take a generous window around the function body
|
||||||
|
end = routes_src.find("\ndef ", idx + 1)
|
||||||
|
body = routes_src[idx:end if end > 0 else idx + 3000]
|
||||||
|
|
||||||
|
self.assertIn("complete_background", body, (
|
||||||
|
"_handle_background worker must call complete_background() after "
|
||||||
|
"_run_agent_streaming returns — otherwise the tracker never "
|
||||||
|
"transitions the task to status='done' and /api/background/status "
|
||||||
|
"returns nothing forever. See api/background.py:complete_background."
|
||||||
|
))
|
||||||
|
# Must extract the last assistant message content from the bg session
|
||||||
|
self.assertIn("_run_agent_streaming", body)
|
||||||
|
self.assertIn("Session.load", body, (
|
||||||
|
"_run_bg_and_notify must reload the bg session to extract the "
|
||||||
|
"final assistant reply so complete_background gets an actual answer"
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
224
tests/test_reasoning_chip_btw_fixes.py
Normal file
224
tests/test_reasoning_chip_btw_fixes.py
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
"""Regression tests for PR #934 UI fixes.
|
||||||
|
|
||||||
|
Four invariants this file locks in place:
|
||||||
|
|
||||||
|
1. `#composerReasoningDropdown` lives OUTSIDE `.composer-left` (as a sibling of
|
||||||
|
the other composer dropdowns), so it isn't clipped by that container's
|
||||||
|
`overflow-y: hidden`. Regresses to invisible-dropdown if moved back.
|
||||||
|
|
||||||
|
2. The reasoning chip label uses an SVG icon (`stroke="currentColor"`) instead
|
||||||
|
of the `🧠` emoji, matching every other composer chip.
|
||||||
|
|
||||||
|
3. `cmdReasoning()` calls `_applyReasoningChip(eff)` directly with the
|
||||||
|
server-confirmed effort, not `syncReasoningChip()` which re-applies the
|
||||||
|
stale cached value.
|
||||||
|
|
||||||
|
4. `attachBtwStream()` sets a `_streamDone` flag in `done`/`apperror` and
|
||||||
|
gates `onerror`'s row removal on `!_streamDone` — otherwise the browser's
|
||||||
|
post-`stream_end` error event wipes the just-rendered answer.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
REPO = pathlib.Path(__file__).resolve().parent.parent
|
||||||
|
INDEX = (REPO / "static" / "index.html").read_text(encoding="utf-8")
|
||||||
|
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
|
||||||
|
COMMANDS_JS = (REPO / "static" / "commands.js").read_text(encoding="utf-8")
|
||||||
|
MESSAGES_JS = (REPO / "static" / "messages.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
# ── #1 dropdown escapes composer-left ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestReasoningDropdownEscapesComposerLeft:
|
||||||
|
"""The dropdown must sit as a sibling of .composer-footer, not inside
|
||||||
|
.composer-left which has overflow-y: hidden and clips absolute children."""
|
||||||
|
|
||||||
|
def test_dropdown_lives_outside_composer_left(self):
|
||||||
|
# Find the <div class="composer-left">...</div> block and confirm the
|
||||||
|
# reasoning dropdown is NOT inside it.
|
||||||
|
m = re.search(
|
||||||
|
r'<div class="composer-left"[^>]*>(?P<body>[\s\S]*?)<div class="composer-footer-right"',
|
||||||
|
INDEX,
|
||||||
|
)
|
||||||
|
# Some templates use different closing structures; fall back to a
|
||||||
|
# coarser search that at least locates composer-left.
|
||||||
|
if m:
|
||||||
|
inner = m.group("body")
|
||||||
|
assert 'id="composerReasoningDropdown"' not in inner, (
|
||||||
|
"composerReasoningDropdown is still nested inside .composer-left — "
|
||||||
|
"this is the exact bug #933 flagged: overflow-y: hidden clips "
|
||||||
|
"upward-opening absolute dropdowns. Move it alongside "
|
||||||
|
"#composerModelDropdown / #composerWsDropdown / #profileDropdown."
|
||||||
|
)
|
||||||
|
# Either way, check that the dropdown sits next to the other composer
|
||||||
|
# dropdowns (reliable structural marker).
|
||||||
|
assert '<div class="profile-dropdown" id="profileDropdown"></div>' in INDEX
|
||||||
|
assert 'id="composerReasoningDropdown"' in INDEX
|
||||||
|
|
||||||
|
def test_dropdown_is_sibling_of_other_composer_dropdowns(self):
|
||||||
|
# The four composer-level dropdowns must appear contiguously — if one
|
||||||
|
# of them is nested inside an overflow-hidden container, this would
|
||||||
|
# typically split the group.
|
||||||
|
positions = [
|
||||||
|
("profileDropdown", INDEX.find('id="profileDropdown"')),
|
||||||
|
("composerWsDropdown", INDEX.find('id="composerWsDropdown"')),
|
||||||
|
("composerReasoningDropdown", INDEX.find('id="composerReasoningDropdown"')),
|
||||||
|
("composerModelDropdown", INDEX.find('id="composerModelDropdown"')),
|
||||||
|
]
|
||||||
|
for name, pos in positions:
|
||||||
|
assert pos > -1, f"{name} not found in index.html"
|
||||||
|
# They should all be in the same area of the document — within ~1.5 KB
|
||||||
|
window = [p for _, p in positions]
|
||||||
|
assert max(window) - min(window) < 2000, (
|
||||||
|
"composer dropdowns are no longer grouped — reasoning dropdown may "
|
||||||
|
"have drifted back inside a nested container"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── #2 monochrome SVG replaces emoji ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestReasoningChipIcon:
|
||||||
|
"""The chip must render a currentColor SVG, not a 🧠 emoji, for cross-platform
|
||||||
|
rendering consistency with the other composer chips."""
|
||||||
|
|
||||||
|
def test_chip_button_contains_svg_with_currentColor(self):
|
||||||
|
# Locate the chip button and confirm it contains a stroke="currentColor" SVG
|
||||||
|
m = re.search(
|
||||||
|
r'<button class="composer-reasoning-chip"[^>]*>([\s\S]*?)</button>',
|
||||||
|
INDEX,
|
||||||
|
)
|
||||||
|
assert m, "composer-reasoning-chip button not found"
|
||||||
|
btn_body = m.group(1)
|
||||||
|
assert 'stroke="currentColor"' in btn_body, (
|
||||||
|
"reasoning chip must use stroke='currentColor' SVG matching other chips"
|
||||||
|
)
|
||||||
|
assert '<svg' in btn_body, "reasoning chip must contain an <svg> icon"
|
||||||
|
|
||||||
|
def test_apply_reasoning_chip_label_has_no_emoji(self):
|
||||||
|
# Locate _applyReasoningChip and confirm the label assignment doesn't
|
||||||
|
# concatenate a 🧠 emoji.
|
||||||
|
m = re.search(
|
||||||
|
r"function\s+_applyReasoningChip\b[\s\S]*?^\}",
|
||||||
|
UI_JS,
|
||||||
|
re.MULTILINE,
|
||||||
|
)
|
||||||
|
assert m, "_applyReasoningChip not found in ui.js"
|
||||||
|
fn = m.group(0)
|
||||||
|
assert "🧠" not in fn, (
|
||||||
|
"_applyReasoningChip should not concatenate a 🧠 emoji into the label — "
|
||||||
|
"the chip already has a monochrome SVG icon next to the label"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── #3 /reasoning immediately updates chip ────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestReasoningCommandUpdatesChip:
|
||||||
|
"""cmdReasoning must apply the SERVER-CONFIRMED effort, not the cached value."""
|
||||||
|
|
||||||
|
def test_cmd_reasoning_calls_apply_not_sync(self):
|
||||||
|
# Locate cmdReasoning and verify the success branch calls
|
||||||
|
# _applyReasoningChip(eff) directly, not syncReasoningChip() which
|
||||||
|
# would read stale _currentReasoningEffort.
|
||||||
|
m = re.search(
|
||||||
|
r"function\s+cmdReasoning\b[\s\S]*?(?=^function\s|\Z)",
|
||||||
|
COMMANDS_JS,
|
||||||
|
re.MULTILINE,
|
||||||
|
)
|
||||||
|
assert m, "cmdReasoning not found in commands.js"
|
||||||
|
fn = m.group(0)
|
||||||
|
assert "_applyReasoningChip(eff)" in fn, (
|
||||||
|
"cmdReasoning must call _applyReasoningChip(eff) with the "
|
||||||
|
"server-confirmed effort from the /api/reasoning POST response"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── #4 /btw answer not wiped by onerror after clean close ─────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestBtwStreamDoneGuard:
|
||||||
|
"""attachBtwStream must guard onerror with a _streamDone flag so the
|
||||||
|
browser's post-stream_end error event doesn't wipe the just-rendered row."""
|
||||||
|
|
||||||
|
def get_attach_btw(self):
|
||||||
|
m = re.search(
|
||||||
|
r"function\s+attachBtwStream\b[\s\S]*?(?=^function\s|\Z)",
|
||||||
|
MESSAGES_JS,
|
||||||
|
re.MULTILINE,
|
||||||
|
)
|
||||||
|
assert m, "attachBtwStream not found in messages.js"
|
||||||
|
return m.group(0)
|
||||||
|
|
||||||
|
def test_stream_done_flag_declared(self):
|
||||||
|
fn = self.get_attach_btw()
|
||||||
|
assert "_streamDone" in fn, (
|
||||||
|
"attachBtwStream must declare a _streamDone flag to distinguish "
|
||||||
|
"clean server-closed streams from real errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_stream_done_set_in_done_handler(self):
|
||||||
|
fn = self.get_attach_btw()
|
||||||
|
# Inside the 'done' listener body, _streamDone must be set true.
|
||||||
|
done_block_m = re.search(
|
||||||
|
r"addEventListener\('done'[\s\S]*?(?=addEventListener\(')",
|
||||||
|
fn,
|
||||||
|
)
|
||||||
|
assert done_block_m, "done handler not found in attachBtwStream"
|
||||||
|
assert "_streamDone=true" in done_block_m.group(0) or \
|
||||||
|
"_streamDone = true" in done_block_m.group(0), (
|
||||||
|
"_streamDone must be set to true in the done handler so onerror "
|
||||||
|
"knows the stream completed successfully"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_onerror_gated_on_stream_done(self):
|
||||||
|
fn = self.get_attach_btw()
|
||||||
|
# onerror must NOT unconditionally call btwRow.remove()
|
||||||
|
m = re.search(r"src\.onerror\s*=\s*\(?\)?\s*=>\s*\{[^}]*\}", fn)
|
||||||
|
assert m, "src.onerror assignment not found"
|
||||||
|
handler = m.group(0)
|
||||||
|
assert "_streamDone" in handler, (
|
||||||
|
"src.onerror must check !_streamDone before removing the btw row — "
|
||||||
|
"otherwise the browser's post-stream_end error fire wipes the "
|
||||||
|
"answer that was just rendered by the done handler"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_ensure_btw_row_called_in_done(self):
|
||||||
|
"""The done handler must create the row even if no token events arrived
|
||||||
|
(e.g., agent returned a non-streaming single-shot answer)."""
|
||||||
|
fn = self.get_attach_btw()
|
||||||
|
done_block_m = re.search(
|
||||||
|
r"addEventListener\('done'[\s\S]*?(?=addEventListener\(')",
|
||||||
|
fn,
|
||||||
|
)
|
||||||
|
assert done_block_m
|
||||||
|
assert "_ensureBtwRow()" in done_block_m.group(0), (
|
||||||
|
"done handler must call _ensureBtwRow() so the answer bubble exists "
|
||||||
|
"even if no token events arrived before done"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── #5 resize handler symmetry (non-blocking polish) ─────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestResizeHandlerSymmetry:
|
||||||
|
"""When the window resizes while either the model OR reasoning dropdown is
|
||||||
|
open, the dropdown must be re-positioned so it stays aligned under its chip."""
|
||||||
|
|
||||||
|
def test_resize_repositions_reasoning_dropdown(self):
|
||||||
|
# The global resize handler must handle both composerModelDropdown AND
|
||||||
|
# composerReasoningDropdown to keep them aligned when the window resizes.
|
||||||
|
m = re.search(
|
||||||
|
r"window\.addEventListener\(\s*['\"]resize['\"][\s\S]*?\}\s*\)\s*;",
|
||||||
|
UI_JS,
|
||||||
|
)
|
||||||
|
assert m, "window resize handler not found in ui.js"
|
||||||
|
handler = m.group(0)
|
||||||
|
assert "composerReasoningDropdown" in handler, (
|
||||||
|
"window resize handler must also re-position composerReasoningDropdown "
|
||||||
|
"while it's open (symmetric with the existing model-dropdown branch)"
|
||||||
|
)
|
||||||
295
tests/test_title_aux_routing.py
Normal file
295
tests/test_title_aux_routing.py
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
"""Regression tests for auxiliary title-generation config routing.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- _aux_title_configured() broad detection (provider, model, base_url)
|
||||||
|
- generate_title_raw_via_aux() reads timeout from config instead of hardcoding 15.0
|
||||||
|
- aux→agent fallback triggers on 'llm_invalid_aux' status (Comment 1)
|
||||||
|
- _aux_title_timeout rejects zero, negative, and non-numeric values (Comment 4)
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
# Stub agent.auxiliary_client so it is importable in the test environment
|
||||||
|
# (the real package lives in hermes-agent, which is not installed here).
|
||||||
|
_agent_stub = types.ModuleType('agent')
|
||||||
|
_aux_stub = types.ModuleType('agent.auxiliary_client')
|
||||||
|
sys.modules.setdefault('agent', _agent_stub)
|
||||||
|
sys.modules.setdefault('agent.auxiliary_client', _aux_stub)
|
||||||
|
_agent_stub.auxiliary_client = _aux_stub
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_tg_config(config_dict):
|
||||||
|
"""Return a patch context manager that makes _get_auxiliary_task_config return config_dict."""
|
||||||
|
return patch('agent.auxiliary_client._get_auxiliary_task_config', return_value=config_dict, create=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuxTitleConfigured(unittest.TestCase):
|
||||||
|
def _call(self, tg_config):
|
||||||
|
from api.streaming import _aux_title_configured
|
||||||
|
with _patch_tg_config(tg_config):
|
||||||
|
return _aux_title_configured()
|
||||||
|
|
||||||
|
def test_model_set_returns_true(self):
|
||||||
|
self.assertTrue(self._call({'provider': '', 'model': 'gpt-4o-mini', 'base_url': ''}))
|
||||||
|
|
||||||
|
def test_base_url_set_returns_true(self):
|
||||||
|
self.assertTrue(self._call({'provider': '', 'model': '', 'base_url': 'http://localhost:1234'}))
|
||||||
|
|
||||||
|
def test_provider_set_non_auto_returns_true(self):
|
||||||
|
self.assertTrue(self._call({'provider': 'openai', 'model': '', 'base_url': ''}))
|
||||||
|
|
||||||
|
def test_provider_auto_returns_false(self):
|
||||||
|
self.assertFalse(self._call({'provider': 'auto', 'model': '', 'base_url': ''}))
|
||||||
|
|
||||||
|
def test_provider_auto_case_insensitive_returns_false(self):
|
||||||
|
self.assertFalse(self._call({'provider': 'AUTO', 'model': '', 'base_url': ''}))
|
||||||
|
|
||||||
|
def test_all_empty_returns_false(self):
|
||||||
|
self.assertFalse(self._call({'provider': '', 'model': '', 'base_url': ''}))
|
||||||
|
|
||||||
|
def test_empty_dict_returns_false(self):
|
||||||
|
self.assertFalse(self._call({}))
|
||||||
|
|
||||||
|
def test_provider_configured_model_blank_returns_true(self):
|
||||||
|
"""Regression: provider set + blank model must still be treated as configured."""
|
||||||
|
self.assertTrue(self._call({'provider': 'anthropic', 'model': '', 'base_url': ''}))
|
||||||
|
|
||||||
|
def test_base_url_only_returns_true(self):
|
||||||
|
"""Regression: base_url alone (no model) must still be treated as configured."""
|
||||||
|
self.assertTrue(self._call({'provider': '', 'model': '', 'base_url': 'https://api.example.com'}))
|
||||||
|
|
||||||
|
def test_import_error_returns_false(self):
|
||||||
|
from api.streaming import _aux_title_configured
|
||||||
|
with patch('agent.auxiliary_client._get_auxiliary_task_config', side_effect=ImportError("no module"), create=True):
|
||||||
|
self.assertFalse(_aux_title_configured())
|
||||||
|
|
||||||
|
|
||||||
|
class TestGenerateTitleRawViaAuxTimeout(unittest.TestCase):
|
||||||
|
"""Verify generate_title_raw_via_aux() reads timeout from config rather than hardcoding 15.0."""
|
||||||
|
|
||||||
|
def _run_with_config(self, tg_config, expected_timeout):
|
||||||
|
from api.streaming import generate_title_raw_via_aux
|
||||||
|
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.choices = [MagicMock()]
|
||||||
|
mock_resp.choices[0].message.content = 'Test Title'
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_call_llm(**kwargs):
|
||||||
|
captured['timeout'] = kwargs.get('timeout')
|
||||||
|
return mock_resp
|
||||||
|
|
||||||
|
with _patch_tg_config(tg_config):
|
||||||
|
with patch('agent.auxiliary_client.call_llm', side_effect=fake_call_llm, create=True):
|
||||||
|
result, status = generate_title_raw_via_aux(
|
||||||
|
user_text='What is the weather?',
|
||||||
|
assistant_text='It is sunny.',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result, 'Test Title')
|
||||||
|
self.assertAlmostEqual(captured['timeout'], expected_timeout)
|
||||||
|
|
||||||
|
def test_default_timeout_when_not_set(self):
|
||||||
|
"""No timeout in config → uses 15.0 default."""
|
||||||
|
self._run_with_config({'provider': '', 'model': 'gpt-4o', 'base_url': ''}, 15.0)
|
||||||
|
|
||||||
|
def test_custom_timeout_from_config(self):
|
||||||
|
"""Regression: timeout set in config must be used instead of hardcoded 15.0."""
|
||||||
|
self._run_with_config(
|
||||||
|
{'provider': '', 'model': 'gpt-4o', 'base_url': '', 'timeout': 30.0},
|
||||||
|
30.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_integer_timeout_from_config(self):
|
||||||
|
"""Config timeout as int is coerced to float."""
|
||||||
|
self._run_with_config(
|
||||||
|
{'provider': '', 'model': 'gpt-4o', 'base_url': '', 'timeout': 5},
|
||||||
|
5.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_timeout_none_in_config_falls_back_to_default(self):
|
||||||
|
"""Explicit None in config falls back to 15.0."""
|
||||||
|
self._run_with_config(
|
||||||
|
{'provider': '', 'model': 'gpt-4o', 'base_url': '', 'timeout': None},
|
||||||
|
15.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuxTitleTimeoutEdgeCases(unittest.TestCase):
|
||||||
|
"""Comment 4: _aux_title_timeout must reject zero, negative, and non-numeric values."""
|
||||||
|
|
||||||
|
def _call(self, tg_config, default=15.0):
|
||||||
|
from api.streaming import _aux_title_timeout
|
||||||
|
with _patch_tg_config(tg_config):
|
||||||
|
return _aux_title_timeout(default=default)
|
||||||
|
|
||||||
|
def test_timeout_zero_falls_back_to_default(self):
|
||||||
|
"""timeout: 0 is not strictly positive → fall back to default."""
|
||||||
|
result = self._call({'timeout': 0}, default=15.0)
|
||||||
|
self.assertEqual(result, 15.0)
|
||||||
|
|
||||||
|
def test_timeout_negative_falls_back_to_default(self):
|
||||||
|
"""timeout: -1 is not strictly positive → fall back to default."""
|
||||||
|
result = self._call({'timeout': -1}, default=15.0)
|
||||||
|
self.assertEqual(result, 15.0)
|
||||||
|
|
||||||
|
def test_timeout_non_numeric_string_falls_back_to_default(self):
|
||||||
|
"""timeout: 'abc' cannot be coerced to float → fall back to default."""
|
||||||
|
result = self._call({'timeout': 'abc'}, default=15.0)
|
||||||
|
self.assertEqual(result, 15.0)
|
||||||
|
|
||||||
|
def test_timeout_empty_string_falls_back_to_default(self):
|
||||||
|
"""timeout: '' cannot be coerced to a positive float → fall back to default."""
|
||||||
|
result = self._call({'timeout': ''}, default=15.0)
|
||||||
|
self.assertEqual(result, 15.0)
|
||||||
|
|
||||||
|
def test_timeout_positive_passes_through(self):
|
||||||
|
"""A valid positive timeout is returned as-is."""
|
||||||
|
result = self._call({'timeout': 25.0}, default=15.0)
|
||||||
|
self.assertEqual(result, 25.0)
|
||||||
|
|
||||||
|
def test_custom_default_used_on_invalid(self):
|
||||||
|
"""When the value is invalid, the caller-supplied *default* is returned."""
|
||||||
|
result = self._call({'timeout': 0}, default=20.0)
|
||||||
|
self.assertEqual(result, 20.0)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuxInvalidAuxTriggersAgentFallback(unittest.TestCase):
|
||||||
|
"""Comment 1: when aux returns llm_invalid_aux, the agent route must be tried as fallback.
|
||||||
|
|
||||||
|
Pins the behaviour so the fallback tuple in _run_background_title_update
|
||||||
|
stays synchronised with the statuses that _generate_llm_session_title_via_aux
|
||||||
|
actually emits.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@patch('api.streaming._aux_title_configured', return_value=True)
|
||||||
|
@patch('api.streaming._generate_llm_session_title_via_aux')
|
||||||
|
@patch('api.streaming._generate_llm_session_title_for_agent')
|
||||||
|
@patch('api.streaming.get_session')
|
||||||
|
def test_llm_invalid_aux_triggers_agent_fallback(
|
||||||
|
self, mock_get_session, mock_agent_title, mock_aux_title, mock_configured,
|
||||||
|
):
|
||||||
|
"""Simulate aux returning (None, 'llm_invalid_aux', '...') and verify agent fallback fires."""
|
||||||
|
from api.streaming import _run_background_title_update
|
||||||
|
|
||||||
|
# Build a mock session that passes all the pre-checks
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_session.title = 'Untitled'
|
||||||
|
mock_session.llm_title_generated = False
|
||||||
|
mock_session.messages = [
|
||||||
|
{'role': 'user', 'content': 'What is the weather?'},
|
||||||
|
{'role': 'assistant', 'content': 'It is sunny and warm.'},
|
||||||
|
]
|
||||||
|
mock_get_session.return_value = mock_session
|
||||||
|
|
||||||
|
# aux route returns invalid title
|
||||||
|
mock_aux_title.return_value = (None, 'llm_invalid_aux', 'bad thinking preamble')
|
||||||
|
|
||||||
|
# agent route succeeds
|
||||||
|
mock_agent_title.return_value = ('Weather Report', 'llm', '')
|
||||||
|
|
||||||
|
events = []
|
||||||
|
|
||||||
|
def fake_put_event(event_type, data):
|
||||||
|
events.append((event_type, data))
|
||||||
|
|
||||||
|
_run_background_title_update(
|
||||||
|
session_id='test-session',
|
||||||
|
user_text='What is the weather?',
|
||||||
|
assistant_text='It is sunny and warm.',
|
||||||
|
placeholder_title='Untitled',
|
||||||
|
put_event=fake_put_event,
|
||||||
|
agent=MagicMock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# The agent fallback must have been invoked
|
||||||
|
mock_agent_title.assert_called_once()
|
||||||
|
|
||||||
|
# A title must have been produced via the agent route
|
||||||
|
title_events = [(e, d) for e, d in events if e == 'title']
|
||||||
|
self.assertTrue(len(title_events) > 0, "Expected a 'title' event to be emitted")
|
||||||
|
self.assertEqual(title_events[0][1]['title'], 'Weather Report')
|
||||||
|
|
||||||
|
@patch('api.streaming._aux_title_configured', return_value=True)
|
||||||
|
@patch('api.streaming._generate_llm_session_title_via_aux')
|
||||||
|
@patch('api.streaming._generate_llm_session_title_for_agent')
|
||||||
|
@patch('api.streaming.get_session')
|
||||||
|
def test_llm_error_aux_triggers_agent_fallback(
|
||||||
|
self, mock_get_session, mock_agent_title, mock_aux_title, mock_configured,
|
||||||
|
):
|
||||||
|
"""Simulate aux returning (None, 'llm_error_aux', '') and verify agent fallback fires."""
|
||||||
|
from api.streaming import _run_background_title_update
|
||||||
|
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_session.title = 'Untitled'
|
||||||
|
mock_session.llm_title_generated = False
|
||||||
|
mock_session.messages = [
|
||||||
|
{'role': 'user', 'content': 'Tell me a joke.'},
|
||||||
|
{'role': 'assistant', 'content': 'Why did the chicken cross the road?'},
|
||||||
|
]
|
||||||
|
mock_get_session.return_value = mock_session
|
||||||
|
|
||||||
|
mock_aux_title.return_value = (None, 'llm_error_aux', '')
|
||||||
|
mock_agent_title.return_value = ('Chicken Joke', 'llm', '')
|
||||||
|
|
||||||
|
events = []
|
||||||
|
|
||||||
|
def fake_put_event(event_type, data):
|
||||||
|
events.append((event_type, data))
|
||||||
|
|
||||||
|
_run_background_title_update(
|
||||||
|
session_id='test-session-2',
|
||||||
|
user_text='Tell me a joke.',
|
||||||
|
assistant_text='Why did the chicken cross the road?',
|
||||||
|
placeholder_title='Untitled',
|
||||||
|
put_event=fake_put_event,
|
||||||
|
agent=MagicMock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_agent_title.assert_called_once()
|
||||||
|
|
||||||
|
@patch('api.streaming._aux_title_configured', return_value=True)
|
||||||
|
@patch('api.streaming._generate_llm_session_title_via_aux')
|
||||||
|
@patch('api.streaming._generate_llm_session_title_for_agent')
|
||||||
|
@patch('api.streaming.get_session')
|
||||||
|
def test_success_status_does_not_trigger_agent_fallback(
|
||||||
|
self, mock_get_session, mock_agent_title, mock_aux_title, mock_configured,
|
||||||
|
):
|
||||||
|
"""When aux succeeds, the agent route must NOT be called."""
|
||||||
|
from api.streaming import _run_background_title_update
|
||||||
|
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_session.title = 'Untitled'
|
||||||
|
mock_session.llm_title_generated = False
|
||||||
|
mock_session.messages = [
|
||||||
|
{'role': 'user', 'content': 'Hello'},
|
||||||
|
{'role': 'assistant', 'content': 'Hi there'},
|
||||||
|
]
|
||||||
|
mock_get_session.return_value = mock_session
|
||||||
|
|
||||||
|
# aux succeeds on first try
|
||||||
|
mock_aux_title.return_value = ('Greeting', 'llm_aux', '')
|
||||||
|
|
||||||
|
events = []
|
||||||
|
|
||||||
|
def fake_put_event(event_type, data):
|
||||||
|
events.append((event_type, data))
|
||||||
|
|
||||||
|
_run_background_title_update(
|
||||||
|
session_id='test-session-3',
|
||||||
|
user_text='Hello',
|
||||||
|
assistant_text='Hi there',
|
||||||
|
placeholder_title='Untitled',
|
||||||
|
put_event=fake_put_event,
|
||||||
|
agent=MagicMock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Agent route must NOT have been invoked
|
||||||
|
mock_agent_title.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user