feat(commands): /background, /btw slash commands + undo button + reasoning chip
Rebased onto master after #931 (aux title routing) to resolve streaming.py conflict. All changes from both PRs are cleanly integrated. 2088 tests passing (2065 master + 23 from #931). Co-authored-by: bergeouss <bergeouss@gmail.com>
This commit is contained in:
12
CHANGELOG.md
12
CHANGELOG.md
@@ -29,10 +29,18 @@
|
||||
workspace subtree) and never enumerate blocked system roots. (`api/routes.py`,
|
||||
`api/workspace.py`, `static/panels.js`, `static/style.css`) (partial for #616)
|
||||
|
||||
## [v0.50.182] — 2026-04-24
|
||||
## [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
|
||||
- **Auxiliary title model now respected** — when `auxiliary.title_generation` is explicitly configured in `config.yaml`, the WebUI now routes title generation through that dedicated model instead of silently using the chat session's model. Adds `_aux_title_configured()` to detect meaningful auxiliary config, `_aux_title_timeout()` to respect the configured per-task timeout (was hardcoded to 15.0 s), and adds the missing `llm_invalid_aux` fallback path so invalid auxiliary outputs trigger the agent-model fallback. (`api/streaming.py`, `tests/test_title_aux_routing.py`) Co-authored by @starship-s.
|
||||
- **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
|
||||
|
||||
|
||||
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:
|
||||
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":
|
||||
webui_sessions = all_sessions()
|
||||
settings = load_settings()
|
||||
@@ -1258,6 +1265,12 @@ def handle_post(handler, parsed) -> bool:
|
||||
except ValueError as 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":
|
||||
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})
|
||||
|
||||
|
||||
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):
|
||||
try:
|
||||
require(body, "session_id")
|
||||
|
||||
@@ -866,8 +866,12 @@ def _sse(handler, event, data):
|
||||
handler.wfile.flush()
|
||||
|
||||
|
||||
def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, attachments=None):
|
||||
"""Run agent in background thread, writing SSE events to STREAMS[stream_id]."""
|
||||
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].
|
||||
|
||||
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)
|
||||
if q is None:
|
||||
return
|
||||
@@ -1336,6 +1340,27 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
|
||||
task_id=session_id,
|
||||
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:
|
||||
_checkpoint_stop.set()
|
||||
if _ckpt_thread is not None:
|
||||
|
||||
@@ -20,6 +20,8 @@ const COMMANDS=[
|
||||
{name:'title', desc:t('cmd_title'), fn:cmdTitle, arg:'[title]'},
|
||||
{name:'retry', desc:t('cmd_retry'), fn:cmdRetry, 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:'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},
|
||||
@@ -573,6 +575,36 @@ async function cmdUndo(){
|
||||
showToast(`↩ ${t('undid_n_messages')} ${r.removed_count} ${t('undid_messages_suffix')}`);
|
||||
}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(){
|
||||
if(!S.session){showToast(t('no_active_session'));return;}
|
||||
try{
|
||||
@@ -623,6 +655,7 @@ function cmdReasoning(args){
|
||||
.then(function(st){
|
||||
const eff=(st && st.reasoning_effort)||arg;
|
||||
showToast(BRAIN+' Reasoning effort set to '+eff+' (saved; applies to next turn)');
|
||||
if(typeof syncReasoningChip==='function') syncReasoningChip();
|
||||
})
|
||||
.catch(function(e){
|
||||
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_retry:'Resend the last message',
|
||||
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_voice:'Toggle microphone input',
|
||||
stream_stopped:'Response stopped.',
|
||||
@@ -199,7 +214,7 @@ const LOCALES = {
|
||||
settings_label_language: 'Language',
|
||||
settings_label_token_usage: 'Show token usage',
|
||||
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_sync_insights: 'Sync to insights',
|
||||
settings_label_check_updates: 'Check for updates',
|
||||
@@ -655,7 +670,7 @@ const LOCALES = {
|
||||
settings_label_language: 'Язык',
|
||||
settings_label_token_usage: 'Показывать использование токенов',
|
||||
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_sync_insights: 'Синхронизировать с Insights',
|
||||
settings_label_check_updates: 'Проверять обновления',
|
||||
@@ -1139,7 +1154,7 @@ const LOCALES = {
|
||||
settings_label_language: 'Idioma',
|
||||
settings_label_token_usage: 'Mostrar uso de tokens',
|
||||
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_sync_insights: 'Sincronizar con insights',
|
||||
settings_label_check_updates: 'Buscar actualizaciones',
|
||||
@@ -1595,7 +1610,7 @@ const LOCALES = {
|
||||
settings_label_language: 'Sprache',
|
||||
settings_label_token_usage: 'Token-Verbrauch anzeigen',
|
||||
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_sync_insights: 'Mit Insights synchronisieren',
|
||||
settings_label_check_updates: 'Nach Updates suchen',
|
||||
@@ -1852,7 +1867,7 @@ const LOCALES = {
|
||||
settings_label_language: '\u8bed\u8a00',
|
||||
settings_label_token_usage: '\u663e\u793a token \u7528\u91cf',
|
||||
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_sync_insights: '\u540c\u6b65\u5230 insights',
|
||||
settings_label_check_updates: '\u68c0\u67e5\u66f4\u65b0',
|
||||
@@ -2292,7 +2307,7 @@ const LOCALES = {
|
||||
settings_label_language: '\u8a9d\u8a00',
|
||||
settings_label_token_usage: '\u986f\u793a token \u7528\u91cf',
|
||||
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_sync_insights: '\u540c\u6b65\u5230 insights',
|
||||
settings_label_check_updates: '\u6aa2\u67e5\u66f4\u65b0',
|
||||
|
||||
@@ -346,6 +346,21 @@
|
||||
<span class="composer-workspace-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 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="M12 2a7 7 0 0 1 7 7c0 2.38-1.19 4.47-3 5.74V17a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1v-2.26C6.19 13.47 5 11.38 5 9a7 7 0 0 1 7-7z"/><line x1="9" y1="21" x2="15" y2="21"/></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 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>
|
||||
<div class="composer-model-wrap">
|
||||
<button class="composer-model-chip" id="composerModelChip" type="button" onclick="toggleModelDropdown()" title="Conversation model">
|
||||
<span class="composer-model-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"><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><path d="M15 2v2"/><path d="M15 20v2"/><path d="M2 15h2"/><path d="M2 9h2"/><path d="M20 15h2"/><path d="M20 9h2"/><path d="M9 2v2"/><path d="M9 20v2"/></svg></span>
|
||||
@@ -396,6 +411,7 @@
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
|
||||
@@ -1323,4 +1323,111 @@ 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;
|
||||
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();
|
||||
try{
|
||||
const d=JSON.parse(e.data);
|
||||
if(d.answer&&!answer) answer=d.answer;
|
||||
}catch(_){}
|
||||
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();
|
||||
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(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) ──
|
||||
|
||||
@@ -593,6 +593,17 @@
|
||||
.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-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% + 6px);left:0;background:var(--surface);border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,.15);min-width:140px;z-index:100;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-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);}
|
||||
@@ -769,12 +780,15 @@
|
||||
.composer-profile-label,
|
||||
.composer-workspace-label,
|
||||
.composer-model-label,
|
||||
.composer-reasoning-label,
|
||||
.composer-profile-chevron,
|
||||
.composer-workspace-chevron,
|
||||
.composer-model-chevron{display:none;}
|
||||
.composer-model-chevron,
|
||||
.composer-reasoning-chevron{display:none;}
|
||||
.composer-profile-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-status{max-width:96px;font-size:10px;}
|
||||
.send-btn{width:32px;height:32px;}
|
||||
@@ -1909,3 +1923,40 @@ body.resizing{user-select:none;cursor:col-resize;}
|
||||
.assistant-turn .msg-foot{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;
|
||||
}
|
||||
|
||||
74
static/ui.js
74
static/ui.js
@@ -386,6 +386,7 @@ function toggleModelDropdown(){
|
||||
if(open){closeModelDropdown(); return;}
|
||||
if(typeof closeProfileDropdown==='function') closeProfileDropdown();
|
||||
if(typeof closeWsDropdown==='function') closeWsDropdown();
|
||||
if(typeof closeReasoningDropdown==='function') closeReasoningDropdown();
|
||||
renderModelDropdown();
|
||||
dd.classList.add('open');
|
||||
_positionModelDropdown();
|
||||
@@ -407,6 +408,77 @@ window.addEventListener('resize',()=>{
|
||||
if(dd&&dd.classList.contains('open')) _positionModelDropdown();
|
||||
});
|
||||
|
||||
// ── 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');
|
||||
chip.classList.add('active');
|
||||
}
|
||||
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
// When streaming, auto-scroll only if the user hasn't manually scrolled up.
|
||||
// Once the user scrolls back to within 150px of the bottom, re-pin.
|
||||
@@ -1330,6 +1402,7 @@ function syncTopbar(){
|
||||
}
|
||||
}
|
||||
if(typeof syncModelChip==='function') syncModelChip();
|
||||
if(typeof syncReasoningChip==='function') syncReasoningChip();
|
||||
// Show Clear button only when session has messages
|
||||
const clearBtn=$('btnClearConv');
|
||||
if(clearBtn) clearBtn.style.display=(S.messages&&S.messages.filter(msg=>msg.role!=='tool').length>0)?'':'none';
|
||||
@@ -1693,6 +1766,7 @@ function renderMessages(){
|
||||
const bodyHtml = isUser ? esc(String(content)).replace(/\n/g,'<br>') : renderMd(_stripXmlToolCallsDisplay(String(content)));
|
||||
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 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 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;
|
||||
|
||||
Reference in New Issue
Block a user