diff --git a/routes/cookbook_routes.py b/routes/cookbook_routes.py index f95cdcea6..9c3e05f1b 100644 --- a/routes/cookbook_routes.py +++ b/routes/cookbook_routes.py @@ -9,6 +9,7 @@ import shlex import shutil import subprocess import sys +import time import urllib.request import uuid from pathlib import Path @@ -66,6 +67,9 @@ _HF_TOKEN_STATUS_SNIPPET = ( def setup_cookbook_routes() -> APIRouter: router = APIRouter(tags=["cookbook"]) _cookbook_state_path = Path(COOKBOOK_STATE_FILE) + _state_get_cache = {"ts": 0.0, "mtime": 0.0, "value": None} + _tasks_status_cache = {"ts": 0.0, "value": None} + _tasks_status_inflight = {"task": None} def _mask_secret(value: str) -> str: if not value: @@ -2463,19 +2467,29 @@ def setup_cookbook_routes() -> APIRouter: async def get_cookbook_state(request: Request): """Load saved cookbook state (tasks, servers, presets, settings).""" require_admin(request) + now = time.monotonic() + try: + mtime = _cookbook_state_path.stat().st_mtime if _cookbook_state_path.exists() else 0.0 + except Exception: + mtime = 0.0 + cached = _state_get_cache.get("value") + if cached is not None and _state_get_cache.get("mtime") == mtime and now - float(_state_get_cache.get("ts") or 0) < 1.5: + return cached if _cookbook_state_path.exists(): try: state = json.loads(_cookbook_state_path.read_text(encoding="utf-8")) saved_tasks = state.get("tasks", []) tasks = saved_tasks if isinstance(saved_tasks, list) else list(saved_tasks.values()) if isinstance(saved_tasks, dict) else [] - try: - _maybe_sweep_orphans(tasks, state) - except Exception as _sweep_e: - logger.warning(f"state orphan sweep failed (non-fatal): {_sweep_e!r}") - return _state_for_client(state) + client_state = _state_for_client(state) + _state_get_cache.update({"ts": now, "mtime": mtime, "value": client_state}) + return client_state except Exception: - return _state_for_client({}) - return _state_for_client({}) + client_state = _state_for_client({}) + _state_get_cache.update({"ts": now, "mtime": mtime, "value": client_state}) + return client_state + client_state = _state_for_client({}) + _state_get_cache.update({"ts": now, "mtime": mtime, "value": client_state}) + return client_state @router.post("/api/cookbook/state") async def save_cookbook_state(request: Request): @@ -2583,7 +2597,19 @@ def setup_cookbook_routes() -> APIRouter: f"not in incoming body (race guard): " f"{[t.get('sessionId') for t in preserved]}") data["tasks"] = incoming_tasks + preserved - atomic_write_json(str(_cookbook_state_path), _state_for_storage(data, on_disk), indent=2) + storage_state = _state_for_storage(data, on_disk) + if storage_state == on_disk: + return {"ok": True, "preserved": len(preserved), "unchanged": True} + atomic_write_json(str(_cookbook_state_path), storage_state, indent=2) + try: + mtime = _cookbook_state_path.stat().st_mtime + _state_get_cache.update({ + "ts": time.monotonic(), + "mtime": mtime, + "value": _state_for_client(storage_state), + }) + except Exception: + pass return {"ok": True, "preserved": len(preserved)} except Exception as e: return {"ok": False, "error": str(e)} @@ -2705,10 +2731,10 @@ def setup_cookbook_routes() -> APIRouter: return {"models": out} - # Rate-limit for the orphan-tmux adoption sweep. 60s interval so SSH + # Rate-limit for the orphan-tmux adoption sweep. Five-minute interval so SSH # work is genuinely sparse even on an actively-polled cookbook page. _last_orphan_sweep_ts = [0.0] - _ORPHAN_SWEEP_MIN_INTERVAL_S = 60.0 + _ORPHAN_SWEEP_MIN_INTERVAL_S = 300.0 # Concurrency guard so two requests racing don't both spawn a sweep. _orphan_sweep_inflight = [False] @@ -3263,7 +3289,26 @@ def setup_cookbook_routes() -> APIRouter: event loop. Now the whole body runs in a worker thread via asyncio.to_thread so other requests stay responsive.""" require_admin(request) - return await asyncio.to_thread(_cookbook_tasks_status_sync) + now = time.monotonic() + cached = _tasks_status_cache.get("value") + if cached is not None and now - float(_tasks_status_cache.get("ts") or 0) < 2.0: + return cached + inflight = _tasks_status_inflight.get("task") + if inflight and not inflight.done(): + return await inflight + + async def _compute(): + data = await asyncio.to_thread(_cookbook_tasks_status_sync) + _tasks_status_cache.update({"ts": time.monotonic(), "value": data}) + return data + + task = asyncio.create_task(_compute()) + _tasks_status_inflight["task"] = task + try: + return await task + finally: + if _tasks_status_inflight.get("task") is task: + _tasks_status_inflight["task"] = None def _cookbook_tasks_status_sync(): import subprocess diff --git a/routes/model_routes.py b/routes/model_routes.py index 1e827ad77..bab782802 100644 --- a/routes/model_routes.py +++ b/routes/model_routes.py @@ -1479,6 +1479,7 @@ def setup_model_routes(model_discovery): # within ~8s of the user noticing. _LOCAL_PROBE_TTL = 8.0 _local_probe_cache: Dict[str, Any] = {"data": None, "time": 0.0} + _local_probe_inflight: Dict[str, Any] = {"task": None} @router.get("/model-endpoints/probe-local") async def probe_local_endpoints(request: Request): @@ -1493,60 +1494,72 @@ def setup_model_routes(model_discovery): (now - _local_probe_cache["time"]) < _LOCAL_PROBE_TTL): return _local_probe_cache["data"] - db = SessionLocal() - try: - if _disable_stale_cookbook_local_endpoints(db): - _invalidate_models_cache() - endpoints = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).all() - local_eps = [] - for ep in endpoints: - base = _normalize_base(ep.base_url) - kind = _effective_endpoint_kind(ep, base) - if _classify_endpoint(base, kind) == "local": - local_eps.append((ep.id, base, ep.api_key)) - finally: - db.close() - - grouped: Dict[str, Dict[str, Any]] = {} - for ep_id, base, api_key in local_eps: - key = _refresh_key(base, api_key) - grouped.setdefault(key, {"base": base, "api_key": api_key, "endpoint_ids": []})["endpoint_ids"].append(ep_id) - - async def _probe_one(data: Dict[str, Any]) -> Dict[str, Any]: - t0 = _time.time() - try: - import asyncio as _asyncio - # Bumped 1.5s → 3.5s. The previous 1.5s budget was clipping - # local vLLM endpoints on Tailscale links where the model - # server is still loading (Qwen3.5-122B takes 2–3 min to - # warm); /v1/models can take 500–2500 ms on a busy box, - # which pushed _ping_endpoint's full path-discovery sweep - # past the cap and marked the row offline despite the - # user actively chatting with it. - ping = await _asyncio.to_thread(_ping_endpoint, data["base"], data.get("api_key"), 3.5) - lat = round((_time.time() - t0) * 1000) - return { - "alive": bool(ping.get("reachable")), - "latency_ms": lat, - "status_code": ping.get("status_code"), - "error": ping.get("error"), - } - except Exception as e: - return {"alive": False, "latency_ms": None, "status_code": None, "error": str(e)[:120]} - import asyncio as _asyncio - results_list = await _asyncio.gather( - *[_probe_one(data) for data in grouped.values()], - return_exceptions=False, - ) - results: Dict[str, Any] = {} - for data, r in zip(grouped.values(), results_list): - for eid in data["endpoint_ids"]: - results[eid] = r + task = _local_probe_inflight.get("task") + if task is not None and not task.done(): + return await task - _local_probe_cache["data"] = results - _local_probe_cache["time"] = now - return results + async def _compute_local_probe() -> Dict[str, Any]: + db = SessionLocal() + try: + if _disable_stale_cookbook_local_endpoints(db): + _invalidate_models_cache() + endpoints = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).all() + local_eps = [] + for ep in endpoints: + base = _normalize_base(ep.base_url) + kind = _effective_endpoint_kind(ep, base) + if _classify_endpoint(base, kind) == "local": + local_eps.append((ep.id, base, ep.api_key)) + finally: + db.close() + + grouped: Dict[str, Dict[str, Any]] = {} + for ep_id, base, api_key in local_eps: + key = _refresh_key(base, api_key) + grouped.setdefault(key, {"base": base, "api_key": api_key, "endpoint_ids": []})["endpoint_ids"].append(ep_id) + + async def _probe_one(data: Dict[str, Any]) -> Dict[str, Any]: + t0 = _time.time() + try: + # Bumped 1.5s → 3.5s. The previous 1.5s budget was clipping + # local vLLM endpoints on Tailscale links where the model + # server is still loading (Qwen3.5-122B takes 2–3 min to + # warm); /v1/models can take 500–2500 ms on a busy box, + # which pushed _ping_endpoint's full path-discovery sweep + # past the cap and marked the row offline despite the + # user actively chatting with it. + ping = await _asyncio.to_thread(_ping_endpoint, data["base"], data.get("api_key"), 3.5) + lat = round((_time.time() - t0) * 1000) + return { + "alive": bool(ping.get("reachable")), + "latency_ms": lat, + "status_code": ping.get("status_code"), + "error": ping.get("error"), + } + except Exception as e: + return {"alive": False, "latency_ms": None, "status_code": None, "error": str(e)[:120]} + + results_list = await _asyncio.gather( + *[_probe_one(data) for data in grouped.values()], + return_exceptions=False, + ) + results: Dict[str, Any] = {} + for data, r in zip(grouped.values(), results_list): + for eid in data["endpoint_ids"]: + results[eid] = r + + _local_probe_cache["data"] = results + _local_probe_cache["time"] = _time.time() + return results + + task = _asyncio.create_task(_compute_local_probe()) + _local_probe_inflight["task"] = task + try: + return await task + finally: + if _local_probe_inflight.get("task") is task: + _local_probe_inflight["task"] = None @router.get("/ping") def ping_endpoints(request: Request): diff --git a/services/hwfit/data/hf_models.json b/services/hwfit/data/hf_models.json index f3a89cf0e..0f9ef7ff1 100644 --- a/services/hwfit/data/hf_models.json +++ b/services/hwfit/data/hf_models.json @@ -5113,8 +5113,8 @@ { "name": "deepseek-ai/DeepSeek-V4-Flash", "provider": "deepseek-ai", - "parameter_count": "284B", - "parameters_raw": 284000000000, + "parameter_count": "158.1B", + "parameters_raw": 158069433298, "active_parameters": 13000000000, "is_moe": true, "min_ram_gb": 200.0, @@ -5130,15 +5130,40 @@ ], "pipeline_tag": "text-generation", "architecture": "deepseek_v4_moe", - "hf_downloads": 3542202, - "hf_likes": 0, - "release_date": "2026-05-15" + "hf_downloads": 1882337, + "hf_likes": 1651, + "release_date": "2026-06-22" + }, + { + "name": "deepseek-ai/DeepSeek-V4-Flash-DSpark", + "provider": "deepseek-ai", + "parameter_count": "165.3B", + "parameters_raw": 165265454782, + "active_parameters": 13000000000, + "is_moe": true, + "active_experts": 6, + "min_ram_gb": 170.0, + "recommended_ram_gb": 250.0, + "min_vram_gb": 165.0, + "quantization": "FP8-Mixed", + "context_length": 1000000, + "use_case": "General-purpose reasoning, long-context", + "capabilities": [ + "long_context", + "reasoning", + "moe" + ], + "pipeline_tag": "text-generation", + "architecture": "deepseek_v4_moe", + "hf_downloads": 4446, + "hf_likes": 107, + "release_date": "2026-06-27" }, { "name": "deepseek-ai/DeepSeek-V4-Flash-Base", "provider": "deepseek-ai", - "parameter_count": "284B", - "parameters_raw": 284000000000, + "parameter_count": "292.0B", + "parameters_raw": 292021347282, "active_parameters": 13000000000, "is_moe": true, "min_ram_gb": 290.0, @@ -5153,15 +5178,15 @@ ], "pipeline_tag": "text-generation", "architecture": "deepseek_v4_moe", - "hf_downloads": 0, - "hf_likes": 0, - "release_date": "2026-05-15" + "hf_downloads": 76030, + "hf_likes": 256, + "release_date": "2026-04-27" }, { "name": "deepseek-ai/DeepSeek-V4-Pro", "provider": "deepseek-ai", - "parameter_count": "1.6T", - "parameters_raw": 1600000000000, + "parameter_count": "861.6B", + "parameters_raw": 861608274846, "active_parameters": 49000000000, "is_moe": true, "min_ram_gb": 1100.0, @@ -5177,15 +5202,40 @@ ], "pipeline_tag": "text-generation", "architecture": "deepseek_v4_moe", - "hf_downloads": 0, - "hf_likes": 0, - "release_date": "2026-05-15" + "hf_downloads": 1154610, + "hf_likes": 5118, + "release_date": "2026-06-22" + }, + { + "name": "deepseek-ai/DeepSeek-V4-Pro-DSpark", + "provider": "deepseek-ai", + "parameter_count": "889.5B", + "parameters_raw": 889484881098, + "active_parameters": 49000000000, + "is_moe": true, + "active_experts": 6, + "min_ram_gb": 900.0, + "recommended_ram_gb": 1250.0, + "min_vram_gb": 890.0, + "quantization": "FP8-Mixed", + "context_length": 1000000, + "use_case": "Flagship reasoning, long-context", + "capabilities": [ + "long_context", + "reasoning", + "moe" + ], + "pipeline_tag": "text-generation", + "architecture": "deepseek_v4_moe", + "hf_downloads": 6939, + "hf_likes": 241, + "release_date": "2026-06-27" }, { "name": "deepseek-ai/DeepSeek-V4-Pro-Base", "provider": "deepseek-ai", "parameter_count": "1.6T", - "parameters_raw": 1600000000000, + "parameters_raw": 1600790440862, "active_parameters": 49000000000, "is_moe": true, "min_ram_gb": 1700.0, @@ -5200,9 +5250,9 @@ ], "pipeline_tag": "text-generation", "architecture": "deepseek_v4_moe", - "hf_downloads": 0, - "hf_likes": 0, - "release_date": "2026-05-15" + "hf_downloads": 25387, + "hf_likes": 305, + "release_date": "2026-04-27" }, { "name": "deepseek-ai/deepseek-coder-6.7b-base", @@ -13308,6 +13358,106 @@ "_discovered": true, "gguf_sources": [] }, + { + "name": "zai-org/GLM-5.2", + "provider": "zai-org", + "parameter_count": "753.3B", + "parameters_raw": 753329940480, + "min_ram_gb": 1510.0, + "recommended_ram_gb": 1800.0, + "min_vram_gb": 1510.0, + "quantization": "BF16", + "context_length": 1048576, + "use_case": "General purpose reasoning, coding, long-context", + "capabilities": [ + "long_context", + "reasoning", + "coding", + "moe" + ], + "pipeline_tag": "text-generation", + "architecture": "glm_moe_dsa", + "hf_downloads": 142547, + "hf_likes": 2996, + "release_date": "2026-06-23", + "is_moe": true, + "active_experts": 8, + "gguf_sources": [ + { + "repo": "unsloth/GLM-5.2-GGUF", + "provider": "unsloth", + "file": "UD-Q4_K_M/*.gguf", + "quant": "Q4_K_M" + } + ] + }, + { + "name": "zai-org/GLM-5.2-FP8", + "provider": "zai-org", + "parameter_count": "753.4B", + "parameters_raw": 753375793584, + "min_ram_gb": 760.0, + "recommended_ram_gb": 900.0, + "min_vram_gb": 760.0, + "quantization": "FP8", + "context_length": 1048576, + "use_case": "General purpose reasoning, coding, long-context", + "capabilities": [ + "long_context", + "reasoning", + "coding", + "moe" + ], + "pipeline_tag": "text-generation", + "architecture": "glm_moe_dsa", + "hf_downloads": 884226, + "hf_likes": 182, + "release_date": "2026-06-23", + "is_moe": true, + "active_experts": 8, + "gguf_sources": [ + { + "repo": "unsloth/GLM-5.2-GGUF", + "provider": "unsloth", + "file": "UD-Q4_K_M/*.gguf", + "quant": "Q4_K_M" + } + ] + }, + { + "name": "unsloth/GLM-5.2-GGUF", + "provider": "unsloth", + "parameter_count": "753.9B", + "parameters_raw": 753864139008, + "min_ram_gb": 452.0, + "recommended_ram_gb": 620.0, + "min_vram_gb": 452.0, + "quantization": "Q4_K_M", + "context_length": 1048576, + "use_case": "General purpose reasoning, coding, long-context (GGUF)", + "capabilities": [ + "long_context", + "reasoning", + "coding", + "moe" + ], + "pipeline_tag": "text-generation", + "architecture": "glm-dsa", + "hf_downloads": 180394, + "hf_likes": 474, + "release_date": "2026-06-23", + "is_moe": true, + "active_experts": 8, + "is_gguf": true, + "gguf_sources": [ + { + "repo": "unsloth/GLM-5.2-GGUF", + "provider": "unsloth", + "file": "UD-Q4_K_M/*.gguf", + "quant": "Q4_K_M" + } + ] + }, { "name": "cyankiwi/Qwen3.5-35B-A3B-AWQ-4bit", "provider": "cyankiwi", @@ -18955,6 +19105,54 @@ "active_experts": 8, "active_parameters": 13600000000 }, + { + "name": "MiniMaxAI/MiniMax-M3", + "provider": "MiniMaxAI", + "parameter_count": "427.0B", + "parameters_raw": 427040140160, + "min_ram_gb": 855.0, + "recommended_ram_gb": 1025.0, + "min_vram_gb": 855.0, + "quantization": "BF16", + "context_length": 1000000, + "use_case": "Vision, chat, coding, agentic tool use", + "capabilities": [ + "vision", + "tool_use", + "coding", + "moe" + ], + "pipeline_tag": "image-text-to-text", + "architecture": "minimax_m3_vl", + "hf_downloads": 192311, + "hf_likes": 1267, + "release_date": "2026-06-23", + "is_moe": true + }, + { + "name": "MiniMaxAI/MiniMax-M3-MXFP8", + "provider": "MiniMaxAI", + "parameter_count": "440.3B", + "parameters_raw": 440279845760, + "min_ram_gb": 445.0, + "recommended_ram_gb": 560.0, + "min_vram_gb": 445.0, + "quantization": "MXFP8", + "context_length": 1000000, + "use_case": "Vision, chat, coding, agentic tool use", + "capabilities": [ + "vision", + "tool_use", + "coding", + "moe" + ], + "pipeline_tag": "image-text-to-text", + "architecture": "minimax_m3_vl", + "hf_downloads": 572278, + "hf_likes": 43, + "release_date": "2026-06-15", + "is_moe": true + }, { "name": "bullerwins/MiniMax-M2.7-REAP-172B-fp8", "provider": "bullerwins", @@ -19276,4 +19474,4 @@ ], "_discovered": true } -] \ No newline at end of file +] diff --git a/src/agent_loop.py b/src/agent_loop.py index e8bd12f82..d8079eaca 100644 --- a/src/agent_loop.py +++ b/src/agent_loop.py @@ -1076,6 +1076,16 @@ def _turn_targets_active_document(intent: Dict[str, object], last_user: str, act text, ): return True + if re.search( + r"\b(?:add|insert|include|apply|put)\b.+\b(?:to it|to this|there|in it|in this|in the text|in the document)\b", + text, + ): + return True + if re.search( + r"\b(?:make it|make this|expand it|expand this|extend it|extend this|continue it|continue this)\b.*\b(?:longer|shorter|bigger|smaller|more detailed|more concise|expanded|extended)?\b", + text, + ): + return True return bool(re.search( r"\b(" r"document|doc|draft|text|poem|story|essay|outline|letter|paragraph|" @@ -1149,13 +1159,41 @@ def _minimal_odysseus_doc_messages(messages: List[Dict], active_document, stream "markdown\n" "Document content\n" "```\n" - "Do not use function calls or tool calls. Do not write anything before the fence. " + "Do not use native function-call JSON or markup. " + "Use only the fenced document block above. Do not write anything before the fence. " "Use saved user memory facts when the user asks for something relating to them." ) else: system = ( - "You are Odysseus. Use the provided function when it is needed. " - "After a successful tool call, answer briefly." + "You are Odysseus. Edit or suggest changes to the active document using exactly one fenced tool block when needed.\n" + "If the user asks to add, remove, rewrite, transform, change, capitalize, shorten, expand, or otherwise apply a change, use edit_document or update_document, not suggest_document.\n" + "Use suggest_document only when the user explicitly asks for suggestions, feedback, or proposed improvements without applying them.\n" + "For targeted edits:\n" + "```edit_document\n" + "<<>>\n" + "exact text from the active document\n" + "<<>>\n" + "replacement text\n" + "<<>>\n" + "```\n" + "For full rewrites only:\n" + "```update_document\n" + "entire new document content\n" + "```\n" + "For improvement suggestions:\n" + "```suggest_document\n" + "<<>>\n" + "text to improve\n" + "<<>>\n" + "suggested replacement\n" + "<<>>\n" + "why this improves it\n" + "<<>>\n" + "```\n" + "Do not use native function-call JSON or markup. " + "FIND text must be copied exactly from the active document with no labels like content:, title:, or markdown. " + "Use only the fenced tool blocks above. Do not write anything before the fenced block. " + "After the tool succeeds, Odysseus will answer Done." ) out = [{"role": "system", "content": system}] memory_message = _minimal_saved_memory_message(messages) @@ -1177,9 +1215,51 @@ def _minimal_odysseus_doc_messages(messages: List[Dict], active_document, stream return out -def _normalize_stream_document_fences(text: str) -> str: - """Treat visible ```document blocks as create_document tool blocks.""" - return re.sub(r"```document(\s*\n)", r"```create_document\1", text or "") +_DOC_MODEL_ARTIFACT_RE = re.compile( + r"(?:\|end\|)+\|?assistan(?:t)?\|?" + r"|\|assistan(?:t)?\|" + r"|<\|im_start\|>\s*assistant" + r"|<\|im_end\|>", + re.IGNORECASE, +) + + +def _strip_doc_model_artifacts(text: str) -> str: + return _DOC_MODEL_ARTIFACT_RE.sub("", text or "") + + +def _normalize_stream_document_fences(text: str, target_tool: str = "create_document") -> str: + """Treat visible ```document/documen blocks as document tool blocks. + + The document LoRA occasionally emits a neutral/truncated `documen` fence. + For new documents that maps to create_document. For active-document turns, + the same shape is a full replacement of the open document, so map it to + update_document and drop the title/language header lines. + """ + text = _strip_doc_model_artifacts(text or "") + + def repl(match: re.Match) -> str: + body = match.group(1) or "" + if target_tool == "update_document": + lines = body.splitlines() + if lines and not lines[0].lstrip().startswith("#"): + lines = lines[1:] + if lines and lines[0].strip().lower() in { + "markdown", "md", "text", "txt", "html", "email", + "python", "javascript", "typescript", "json", "yaml", + }: + lines = lines[1:] + while lines and not lines[0].strip(): + lines = lines[1:] + body = "\n".join(lines) + return f"```{target_tool}\n{body}" + + return re.sub( + r"```documen(?:t)?\s*\n([\s\S]*?)(?=\n```|$)", + repl, + text, + flags=re.IGNORECASE, + ) def _recent_context_for_retrieval(messages: List[Dict], max_user: int = 3, max_chars: int = 600) -> str: @@ -2535,10 +2615,15 @@ async def stream_agent_loop( except Exception as _e: logger.debug(f"[tool-rag] skill-aware tool include skipped: {_e}") + _intent_domains = set(_intent.get("domains") or set()) _ody_doc_finetune_mode = ( (model or "").lower().startswith("odysseus-qwen3") - and "documents" in (_intent.get("domains") or set()) - and "files" not in (_intent.get("domains") or set()) + and ( + "documents" in _intent_domains + or _active_document_relevant + or _prompt_active_document is not None + ) + and "files" not in _intent_domains and not guide_only ) _ody_doc_stream_create_mode = _ody_doc_finetune_mode and _prompt_active_document is None @@ -2808,6 +2893,7 @@ async def stream_agent_loop( _doc_opened = False # whether doc_stream_open was sent _doc_last_len = 0 # last content length sent _doc_stream_create_completed = False + _ody_doc_tool_completed = False # Set when the loop runs out of rounds while the agent was still actively # using tools — i.e. it was cut off, not finished. Drives a "Continue" event @@ -2863,14 +2949,7 @@ async def stream_agent_loop( ] all_tool_schemas = base_schemas + mcp_schemas if _ody_doc_finetune_mode: - if _ody_doc_stream_create_mode: - all_tool_schemas = [] - else: - _doc_schema_names = {"edit_document", "update_document", "suggest_document"} - all_tool_schemas = [ - t for t in all_tool_schemas - if t.get("function", {}).get("name") in _doc_schema_names - ] + all_tool_schemas = [] if disabled_tools: all_tool_schemas = [ t for t in all_tool_schemas @@ -2916,7 +2995,7 @@ async def stream_agent_loop( max_tokens=max_tokens, prompt_type=prompt_type if round_num == 1 else None, tools=all_tool_schemas if all_tool_schemas else None, - tool_choice_none=_ody_doc_stream_create_mode, + tool_choice_none=_ody_doc_finetune_mode, timeout=agent_stream_timeout, session_id=session_id, ): @@ -3040,9 +3119,12 @@ async def stream_agent_loop( if data.get("thinking"): round_reasoning += data["delta"] else: - round_response += data["delta"] - full_response += data["delta"] - yield chunk # Stream all rounds + _delta_text = _strip_doc_model_artifacts(data["delta"]) if _ody_doc_finetune_mode else data["delta"] + round_response += _delta_text + full_response += _delta_text + data["delta"] = _delta_text + if not _ody_doc_finetune_mode or data.get("thinking"): + yield f"data: {json.dumps(data)}\n\n" # Detect text-fence doc streaming. Normal agent prompts # use ```create_document; the doc LoRA streaming path # uses neutral ```document to avoid triggering learned @@ -3053,7 +3135,7 @@ async def stream_agent_loop( and not (tool_policy and tool_policy.blocks("create_document")) ): _fence_markers = ( - ('```document\n', 'document') + ('```document\n', '```documen\n') if _ody_doc_stream_create_mode else ('```create_document\n',) ) @@ -3122,12 +3204,20 @@ async def stream_agent_loop( _round_first_event_logged, _round_first_token_logged, ) + _normalized_doc_round = ( + _normalize_stream_document_fences( + round_response, + "create_document" if _ody_doc_stream_create_mode else "update_document", + ) + if _ody_doc_finetune_mode + else round_response + ) tool_blocks, used_native, converted_calls = _resolve_tool_blocks( - _normalize_stream_document_fences(round_response) if _ody_doc_stream_create_mode else round_response, + _normalized_doc_round, native_tool_calls, round_num, is_api_model=(_is_api_model and not guide_only), - allow_fenced_for_api=_ody_doc_stream_create_mode, + allow_fenced_for_api=_ody_doc_finetune_mode, ) if _ody_doc_stream_create_mode and tool_blocks: create_idx = next( @@ -3782,6 +3872,12 @@ async def stream_agent_loop( and result.get("action") == "create" ): _doc_stream_create_completed = True + if ( + _ody_doc_finetune_mode + and block.tool_type in ("create_document", "update_document", "edit_document", "suggest_document") + and not result.get("error") + ): + _ody_doc_tool_completed = True # If budget was hit, stop the loop if budget_hit: @@ -3801,6 +3897,13 @@ async def stream_agent_loop( logger.info("[agent] odysseus doc stream-create completed after one create_document") break + if _ody_doc_tool_completed: + if not full_response.strip() or full_response.strip().startswith("```"): + full_response = "Done." + yield 'data: ' + json.dumps({"delta": "Done."}) + '\n\n' + logger.info("[agent] odysseus doc tool completed after one textual tool block") + break + # Feed results back to LLM for next round # Pass the CONVERTED calls (aligned 1:1 with tool_result_texts), not the # raw native_tool_calls: a call that failed to convert is dropped from diff --git a/src/llm_core.py b/src/llm_core.py index 588347aff..7df7bbd9a 100644 --- a/src/llm_core.py +++ b/src/llm_core.py @@ -110,6 +110,18 @@ _HARMONY_MARKERS = ( ) _HARMONY_MAX_MARKER_LEN = max(len(marker) for marker in _HARMONY_MARKERS) +_VISIBLE_CHAT_TEMPLATE_ARTIFACT_RE = re.compile( + r"(?:\|end\|)+\|?assistan(?:t)?\|?" + r"|\|assistan(?:t)?\|" + r"|<\|im_start\|>\s*assistant" + r"|<\|im_end\|>", + re.IGNORECASE, +) + + +def _strip_visible_chat_template_artifacts(text: str) -> str: + return _VISIBLE_CHAT_TEMPLATE_ARTIFACT_RE.sub("", text or "") + def _harmony_suffix_hold_len(text: str) -> int: """Return how many trailing chars could be the start of a harmony marker.""" @@ -2317,6 +2329,9 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl if reasoning: yield _stream_delta_event(reasoning, thinking=True) if content: + content = _strip_visible_chat_template_artifacts(content) + if not content: + continue content = re.sub(r"]*)?>", r"", content, flags=re.IGNORECASE) content = re.sub(r"", "", content, flags=re.IGNORECASE) stripped = content.lstrip() diff --git a/static/app.js b/static/app.js index 5888f517c..46776dd67 100644 --- a/static/app.js +++ b/static/app.js @@ -2186,7 +2186,7 @@ function initializeEventListeners() { const pickerWrap = el('model-picker-wrap'); if (!inputTop || !pickerWrap) return; - const PLACEHOLDER_HIDE_WIDTH = 400; + const PLACEHOLDER_COMPACT_WIDTH = 400; const PICKER_HIDE_WIDTH = 220; const TOOLBAR_HIDE_WIDTH = 160; const textarea = el('message'); @@ -2199,9 +2199,10 @@ function initializeEventListeners() { const w = inputTop.clientWidth; // Hide model picker pickerWrap.classList.toggle('picker-auto-hidden', w < PICKER_HIDE_WIDTH); - // Hide placeholder text + // Keep a prompt inside the composer even when the picker crowds the row. + // A blank placeholder makes the mobile/compact empty state feel broken. if (textarea) { - textarea.setAttribute('placeholder', w < PLACEHOLDER_HIDE_WIDTH ? '' : 'Message Odysseus...'); + textarea.setAttribute('placeholder', w < PLACEHOLDER_COMPACT_WIDTH ? 'Message...' : 'Message Odysseus...'); } // Hide entire bottom toolbar (tools, mode toggle) — only send button remains if (inputBottom) { @@ -3449,6 +3450,12 @@ function initializeEventListeners() { function startOdysseusApp() { if (window.__odysseusAppStarted) return; window.__odysseusAppStarted = true; + const _bumpChatPriority = (ms = 10000) => { + try { + window.__odysseusChatBusyUntil = Math.max(window.__odysseusChatBusyUntil || 0, Date.now() + ms); + } catch (_) {} + }; + _bumpChatPriority(10000); // Set CSS variables document.documentElement.style.setProperty('--line-height', '20px'); initRailHoverLabels(); @@ -3617,9 +3624,16 @@ function startOdysseusApp() { const chatForm = document.getElementById('chat-form'); const originalSubmit = chatModule.handleChatSubmit; let _submitting = false; + const _messageInput = document.getElementById('message') || document.getElementById('message-input'); + if (_messageInput) { + _messageInput.addEventListener('focus', () => _bumpChatPriority(15000)); + _messageInput.addEventListener('input', () => _bumpChatPriority(15000)); + _messageInput.addEventListener('pointerdown', () => _bumpChatPriority(15000), { passive: true }); + } function handleSubmit(e) { if (e) e.preventDefault(); + _bumpChatPriority(30000); // Debounce: prevent double-submit while a request is being initiated if (_submitting) return; _submitting = true; diff --git a/static/js/chat.js b/static/js/chat.js index 542d5ec88..6bca955b7 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -43,6 +43,13 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer let _sendInFlight = false; // covers the window from click → streaming start let _displayOverride = null; // Override visible user bubble text (hides injected prompts) let _hideUserBubble = false; // Skip user bubble entirely (e.g. continue after stop) + + function _setForegroundChatBusy(active) { + try { + window.__odysseusChatBusy = !!active; + window.__odysseusChatBusyUntil = active ? Date.now() + 120000 : Date.now() + 1200; + } catch (_) {} + } let _pendingContinue = null; // Stores the stopped AI element to merge with new response // ── Auto-recovery: when a turn's stream silently dies (connection drop) or // goes quiet while the connection is alive, re-engage the model with a @@ -99,6 +106,94 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer const body = msgEl.querySelector('.body'); if (body) chatRenderer.appendReportButton(body, sessionId); } + + function _stripDocumentFenceForChat(text, { final = false } = {}) { + let s = String(text || '').replace(/?/g, ''); + const markerMatch = /```(?:create_document|documen(?:t)?)\s*\n/i.exec(s); + if (!markerMatch) return s; + const before = s.slice(0, markerMatch.index).trimEnd(); + const fenceStart = markerMatch.index; + const openingEnd = s.indexOf('\n', fenceStart); + const closeIdx = openingEnd >= 0 ? s.indexOf('\n```', openingEnd + 1) : -1; + const after = closeIdx >= 0 ? s.slice(closeIdx + 4).trimStart() : ''; + const visible = [before, after].filter(Boolean).join('\n\n').trim(); + return final && !visible ? 'Done.' : visible; + } + + function _showDocumentWritingStatus(contentEl) { + const msg = contentEl && contentEl.closest ? contentEl.closest('.msg') : null; + const chatBox = document.getElementById('chat-history'); + if (!msg || !chatBox) { + if (contentEl) contentEl.textContent = 'Writing...'; + return; + } + let thread = msg._docWritingThread; + if (!thread || !thread.isConnected) { + thread = document.createElement('div'); + thread.className = 'agent-thread streaming has-bottom'; + thread.dataset.docWriting = '1'; + const prev = msg.previousElementSibling; + if (prev && (prev.classList.contains('msg') || prev.classList.contains('agent-thread'))) { + thread.classList.add('has-top'); + } + const node = document.createElement('div'); + node.className = 'agent-thread-node running'; + node.innerHTML = '
Writing▁▂▃
'; + thread.appendChild(node); + chatBox.insertBefore(thread, msg); + msg._docWritingThread = thread; + + const waveEl = node.querySelector('.agent-thread-wave'); + if (waveEl) { + const waveFrames = ['▁▂▃', '▂▃▄', '▃▄▅', '▄▅▆', '▅▆▇', '▆▅▄', '▅▄▃', '▄▃▂']; + let waveIdx = 0; + node._waveInterval = setInterval(() => { + waveIdx = (waveIdx + 1) % waveFrames.length; + waveEl.textContent = waveFrames[waveIdx]; + }, 100); + } + node._startTime = Date.now(); + node._elapsedTicker = setInterval(() => { + const hdr = node.querySelector('.agent-thread-header'); + if (!hdr) return; + let el = hdr.querySelector('.agent-thread-elapsed'); + if (!el) { + el = document.createElement('span'); + el.className = 'agent-thread-elapsed'; + const icon = hdr.querySelector('.agent-thread-icon'); + if (icon && icon.nextSibling) hdr.insertBefore(el, icon.nextSibling); + else hdr.appendChild(el); + } + const s = (Date.now() - node._startTime) / 1000; + el.textContent = s < 60 ? `${s.toFixed(2)}s` : `${Math.floor(s / 60)}m ${(s % 60).toFixed(2).padStart(5, '0')}s`; + }, 50); + } + msg.style.display = 'none'; + } + + function _finishDocumentWritingStatus(msg, ok = true) { + const thread = msg && msg._docWritingThread; + if (!thread || !thread.isConnected) return; + thread.classList.remove('streaming'); + const node = thread.querySelector('.agent-thread-node'); + if (!node) return; + if (node._waveInterval) { clearInterval(node._waveInterval); node._waveInterval = null; } + if (node._elapsedTicker) { clearInterval(node._elapsedTicker); node._elapsedTicker = null; } + node.classList.remove('running'); + if (!ok) node.classList.add('error'); + const icon = node.querySelector('.agent-thread-icon'); + if (icon) icon.textContent = ok ? '✓' : '✗'; + const wave = node.querySelector('.agent-thread-wave'); + if (wave) wave.remove(); + if (!node.querySelector('.agent-thread-status')) { + const status = document.createElement('span'); + status.className = 'agent-thread-status'; + status.textContent = ok ? 'done' : 'failed'; + const header = node.querySelector('.agent-thread-header'); + if (header) header.appendChild(status); + } + } + let currentAccumulated = ''; // Track accumulated text across function scope let currentHolder = null; // Track current message holder let currentSpinner = null; // Track current spinner for stop cleanup @@ -241,12 +336,14 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer submitBtn.dataset.mode = 'streaming'; submitBtn.dataset.phase = 'processing'; isStreaming = true; + _setForegroundChatBusy(true); _startStallWatchdog(); } else if (state === 'idle') { submitBtn.dataset.mode = ''; delete submitBtn.dataset.phase; submitBtn.classList.remove('recording'); isStreaming = false; + _setForegroundChatBusy(false); _stopStallWatchdog(); // Defer to global updater which handles mic/newchat/send modes if (window._updateSendBtnIcon) { @@ -267,6 +364,126 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer // API key pattern for the guard in handleChatSubmit const API_KEY_RE = /^(sk-[a-zA-Z0-9_\-]{20,}|gsk_[a-zA-Z0-9]{20,}|AIza[a-zA-Z0-9_\-]{30,}|xai-[a-zA-Z0-9]{20,})$/; + const _queuedAgentRequests = []; + let _queuedDrainTimer = null; + let _queuedPromoteTimer = null; + let _queuedRequestSeq = 0; + let _queuedBubbleHost = null; + + function _escapeQueueText(s) { + return String(s || '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + } + + function _ensureQueuedBubbleHost() { + const chatBox = document.getElementById('chat-history'); + if (!chatBox) return null; + if (_queuedBubbleHost && _queuedBubbleHost.isConnected) return _queuedBubbleHost; + let host = document.getElementById('chat-queued-bubble-host'); + if (!host) { + host = document.createElement('div'); + host.id = 'chat-queued-bubble-host'; + host.className = 'chat-queued-bubble-host'; + } + chatBox.appendChild(host); + _queuedBubbleHost = host; + return host; + } + + function _createQueuedBubble(item) { + const host = _ensureQueuedBubbleHost(); + if (!host) return null; + const wrap = document.createElement('div'); + wrap.className = 'msg msg-user msg-user-queued'; + wrap.dataset.queueId = item.id; + wrap.title = 'Queued - click to send now and stop the current response'; + wrap.innerHTML = `
You Queued
${_escapeQueueText(item.message)}
`; + wrap.addEventListener('click', (ev) => { + if (ev.target && ev.target.closest && ev.target.closest('button, a, textarea, input')) return; + _promoteQueuedRequest(item.id); + }); + host.appendChild(wrap); + uiModule.scrollHistory(); + return wrap; + } + + function _removeQueuedRequest(id) { + const idx = _queuedAgentRequests.findIndex(item => item.id === id); + if (idx < 0) return null; + const [item] = _queuedAgentRequests.splice(idx, 1); + if (item && item.el && item.el.parentNode) item.el.remove(); + return item; + } + + function _setComposerAndSend(message) { + const input = uiModule.el('message'); + if (!input) return false; + input.value = message; + input.dispatchEvent(new Event('input', { bubbles: true })); + if (uiModule.autoResize) uiModule.autoResize(input); + setTimeout(() => { + handleChatSubmit({ preventDefault() {} }).catch(err => { + console.error('queued send failed', err); + try { uiModule.showError && uiModule.showError('Queued send failed: ' + (err?.message || err)); } catch (_) {} + }); + }, 0); + return true; + } + + function _sendQueuedWhenIdle(item) { + if (!item) return; + const trySend = () => { + if (isStreaming || _sendInFlight) { + _queuedPromoteTimer = setTimeout(trySend, 220); + return; + } + _queuedPromoteTimer = null; + _setComposerAndSend(item.message); + }; + if (_queuedPromoteTimer) clearTimeout(_queuedPromoteTimer); + _queuedPromoteTimer = setTimeout(trySend, 320); + } + + function _promoteQueuedRequest(id) { + const item = _removeQueuedRequest(id); + if (!item) return; + if (!isStreaming && !_sendInFlight) { + _setComposerAndSend(item.message); + return; + } + try { uiModule.showToast && uiModule.showToast('Sending queued request now'); } catch (_) {} + const input = uiModule.el('message'); + const submitBtn = document.querySelector('.send-btn'); + if (input) { + input.value = ''; + input.dispatchEvent(new Event('input', { bubbles: true })); + } + if (submitBtn) submitBtn.click(); + _sendQueuedWhenIdle(item); + } + + function _queueAgentRequest(message) { + const msg = String(message || '').trim(); + if (!msg) return false; + const item = { id: `q${++_queuedRequestSeq}`, message: msg, createdAt: Date.now(), el: null }; + item.el = _createQueuedBubble(item); + _queuedAgentRequests.push(item); + try { uiModule.showToast && uiModule.showToast(_queuedAgentRequests.length === 1 ? 'Queued for after this response' : `${_queuedAgentRequests.length} requests queued`); } catch (_) {} + return true; + } + + function _drainQueuedAgentRequests() { + if (isStreaming || _sendInFlight || !_queuedAgentRequests.length) return; + if (_queuedDrainTimer) return; + _queuedDrainTimer = setTimeout(() => { + _queuedDrainTimer = null; + if (isStreaming || _sendInFlight || !_queuedAgentRequests.length) return; + const next = _queuedAgentRequests[0]; + if (!next) return; + _removeQueuedRequest(next.id); + _setComposerAndSend(next.message); + }, 180); + } + /** * Handle chat form submission @@ -290,8 +507,23 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer return; } - // If currently streaming, stop it + // If currently streaming, a non-empty composer means "queue this next". + // Empty composer keeps the existing Stop behavior. if (isStreaming) { + const queuedInput = uiModule.el('message'); + const queuedText = (queuedInput && queuedInput.value || '').trim(); + if (queuedText) { + if (fileHandlerModule.getPendingCount && fileHandlerModule.getPendingCount()) { + try { uiModule.showError && uiModule.showError('Finish the current response before queueing messages with attachments.'); } catch (_) {} + return; + } + if (_queueAgentRequest(queuedText)) { + queuedInput.value = ''; + queuedInput.dispatchEvent(new Event('input', { bubbles: true })); + if (uiModule.autoResize) uiModule.autoResize(queuedInput); + } + return; + } if (fileHandlerModule.isUploading && fileHandlerModule.isUploading()) { fileHandlerModule.cancelUpload && fileHandlerModule.cancelUpload(); } @@ -343,6 +575,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer const messageInput = uiModule.el('message'); if (messageInput) messageInput.disabled = false; currentAccumulated = ''; + _drainQueuedAgentRequests(); return; } // Render whatever was accumulated so far @@ -420,6 +653,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer // --- Send-path entry: block re-clicks between submit and stream start --- if (_sendInFlight) return; _sendInFlight = true; + _setForegroundChatBusy(true); // Instant visual feedback so the user sees their click was accepted // even before the streaming button state kicks in below. const _earlyMessageInput = uiModule.el('message'); @@ -427,6 +661,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer if (submitBtn) submitBtn.classList.add('send-pending'); const _releaseSendFlag = () => { _sendInFlight = false; + _setForegroundChatBusy(isStreaming); if (_earlyMessageInput) _earlyMessageInput.disabled = false; if (submitBtn) submitBtn.classList.remove('send-pending'); }; @@ -1104,12 +1339,14 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer 'web_search': 'Searching', 'bash': 'Running', 'python': 'Running', - 'create_document': 'Writing', - 'update_document': 'Writing', 'read_document': 'Reading', 'edit_file': 'Editing', 'read_file': 'Reading', 'write_file': 'Writing', + 'create_document': 'Writing', + 'edit_document': 'Editing', + 'update_document': 'Rewriting', + 'suggest_document': 'Reviewing', 'list_files': 'Browsing', 'image_gen': 'Generating', 'generate_image': 'Generating', @@ -1208,7 +1445,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer // Direct render helper for streaming text _renderStream = () => { - let dt = markdownModule.normalizeThinkingMarkup(stripToolBlocks(roundText)); + let dt = markdownModule.normalizeThinkingMarkup(stripToolBlocks(_stripDocumentFenceForChat(roundText))); const bodyEl = roundHolder.querySelector('.body'); const contentEl = _ensureStreamLayout(bodyEl); @@ -1287,6 +1524,11 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer // what keeps code-block hover buttons from flickering and avoids the O(N^2) // re-parse/re-highlight of the whole message on every token. // See streamingRenderer.js / streamingSegmenter.js. + if (_docFenceOpened && !dt.trim()) { + _showDocumentWritingStatus(contentEl); + uiModule.scrollHistory(); + return; + } const renderer = contentEl._streamRenderer || (contentEl._streamRenderer = createStreamRenderer(contentEl, { render: (t) => markdownModule.processWithThinking(markdownModule.squashOutsideCode(t)), @@ -1476,8 +1718,8 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer } // --- Text-fence doc streaming (for models that don't use native tool calls) --- - if (!_docFenceOpened && documentModule && (roundText.includes('```create_document\n') || roundText.includes('```document\n'))) { - const fenceMarker = roundText.includes('```document\n') ? '```document\n' : '```create_document\n'; + if (!_docFenceOpened && documentModule && (roundText.includes('```create_document\n') || roundText.includes('```document\n') || roundText.includes('```documen\n'))) { + const fenceMarker = roundText.includes('```document\n') ? '```document\n' : (roundText.includes('```documen\n') ? '```documen\n' : '```create_document\n'); const fenceIdx = roundText.indexOf(fenceMarker); const afterFence = roundText.slice(fenceIdx + fenceMarker.length); const fenceLines = afterFence.split('\n'); @@ -2089,7 +2331,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer if (!roundFinalized) { roundFinalized = true; if (spinner && spinner.element) spinner.destroy(); - const dt = markdownModule.normalizeThinkingMarkup(stripToolBlocks(roundText)); + const dt = markdownModule.normalizeThinkingMarkup(stripToolBlocks(_stripDocumentFenceForChat(roundText))); if (dt.trim()) { var _body3 = roundHolder.querySelector('.body'); var _contentEl3 = _ensureStreamLayout(_body3); @@ -2559,9 +2801,13 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer // Clear streaming minHeight lock const _streamContent = roundHolder.querySelector('.stream-content'); if (_streamContent) _streamContent.style.minHeight = ''; + if (_docFenceOpened) { + _finishDocumentWritingStatus(roundHolder, true); + roundHolder.style.display = ''; + } // Finalize the last round's bubble — flatten stream-content wrapper for clean DOM - const finalDisplay = stripToolBlocks(roundText); + const finalDisplay = stripToolBlocks(_stripDocumentFenceForChat(roundText, { final: _docFenceOpened })); if (finalDisplay.trim()) { var _body4 = roundHolder.querySelector('.body'); // Preserve sources expanded state before final render @@ -3033,6 +3279,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer sessionModule.loadSessions(); } }, 3000); + _drainQueuedAgentRequests(); } } @@ -3240,6 +3487,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer // Clear local state WITHOUT aborting the fetch currentAbort = null; isStreaming = false; + _setForegroundChatBusy(false); currentHolder = null; currentAccumulated = ''; // Reset submit button so the new chat is ready to send @@ -3302,6 +3550,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer const decoder = new TextDecoder(); let buffer = ''; let roundText = ''; + let docFenceOpened = false; let gotDelta = false; let leftSession = false; let metricsData = null; @@ -3316,8 +3565,12 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer }; const renderDelta = () => { - const dt = markdownModule.normalizeThinkingMarkup(stripToolBlocks(roundText)); - contentDiv.innerHTML = markdownModule.mdToHtml(markdownModule.squashOutsideCode(dt)); + const dt = markdownModule.normalizeThinkingMarkup(stripToolBlocks(_stripDocumentFenceForChat(roundText, { final: docFenceOpened }))); + if (docFenceOpened && !dt.trim()) { + _showDocumentWritingStatus(contentDiv); + } else { + contentDiv.innerHTML = markdownModule.mdToHtml(markdownModule.squashOutsideCode(dt)); + } uiModule.scrollHistory(); }; @@ -3348,6 +3601,10 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer try { json = JSON.parse(payload); } catch (_) { continue; } if (json.delta) { roundText += json.delta; + if (!docFenceOpened && (roundText.includes('```create_document\n') || roundText.includes('```document\n') || roundText.includes('```documen\n'))) { + docFenceOpened = true; + rich = true; + } if (!gotDelta) { gotDelta = true; try { spinner.destroy(); } catch (_) {} } renderDelta(); } else if (json.type === 'doc_stream_open') { @@ -3355,7 +3612,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer if (documentModule) documentModule.streamDocOpen(json.title || '', json.lang || ''); } else if (json.type === 'doc_stream_delta') { rich = true; - if (documentModule && json.delta) documentModule.streamDocDelta(json.delta); + if (documentModule) documentModule.streamDocDelta(json.content || json.delta || ''); } else if (json.type === 'metrics') { metricsData = json.data || metricsData; } else if (json.type === 'tool_start' || json.type === 'tool_output' || @@ -3372,6 +3629,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer } cleanup(); + if (docFenceOpened) _finishDocumentWritingStatus(holder, true); if (leftSession) { if (holder.parentNode) holder.remove(); return true; } const onThisSession = sessionModule.getCurrentSessionId && @@ -3391,6 +3649,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer // Rich response (tools, sources, docs, multi-round) or user moved on: // reload from the DB for the full canonical render. + if (holder._docWritingThread && holder._docWritingThread.parentNode) holder._docWritingThread.remove(); if (holder.parentNode) holder.remove(); if (onThisSession) sessionModule.selectSession(sessionId); else sessionModule.loadSessions(); diff --git a/static/js/cookbook-hwfit.js b/static/js/cookbook-hwfit.js index a4833d72b..841a81baf 100644 --- a/static/js/cookbook-hwfit.js +++ b/static/js/cookbook-hwfit.js @@ -242,8 +242,7 @@ export function _renderGpuToggles(system) { container._activeCount = undefined; // default to the new pool's max delete container.dataset.rendered; // force a count-button rebuild _renderGpuToggles(system); - _hwfitCache = null; - _hwfitFetch(); + _hwfitFetch(false, { keepPrevious: true, forceRevalidate: true }); }); } @@ -275,8 +274,7 @@ export function _renderGpuToggles(system) { } } } - _hwfitCache = null; - _hwfitFetch(); + _hwfitFetch(false, { keepPrevious: true, forceRevalidate: true }); }); } } @@ -436,6 +434,27 @@ function _readScanCache(sig) { return null; } +function _readNearestScanCache(sig) { + try { + const wanted = JSON.parse(sig || '{}'); + const all = JSON.parse(localStorage.getItem(_SCAN_CACHE_KEY) || '{}'); + let best = null; + for (const [key, entry] of Object.entries(all)) { + if (!entry || !entry.data || (Date.now() - (entry.ts || 0)) >= _SCAN_CACHE_TTL) continue; + let parsed = null; + try { parsed = JSON.parse(key); } catch { continue; } + if (!parsed) continue; + if ((parsed.h || '') !== (wanted.h || '')) continue; + if ((parsed.hk || '') !== (wanted.hk || '')) continue; + if (JSON.stringify(parsed.m || {}) !== JSON.stringify(wanted.m || {})) continue; + if (JSON.stringify(parsed.d || []) !== JSON.stringify(wanted.d || [])) continue; + if (!best || (entry.ts || 0) > (best.ts || 0)) best = entry; + } + return best?.data || null; + } catch {} + return null; +} + function _writeScanCache(sig, data) { try { const all = JSON.parse(localStorage.getItem(_SCAN_CACHE_KEY) || '{}'); @@ -565,6 +584,8 @@ function _ollamaToHwfitRows(libModels, vramAvail, ramAvail) { export async function _hwfitFetch(fresh = false, opts = {}) { const _tk = ++_hwfitFetchToken; const allowNetwork = fresh || opts.allowNetwork !== false; + const keepPrevious = !!opts.keepPrevious; + const forceRevalidate = !!opts.forceRevalidate; const useCase = document.getElementById('hwfit-usecase')?.value || ''; const search = document.getElementById('hwfit-search')?.value?.trim() || ''; const remoteHost = _envState.remoteHost || ''; @@ -577,7 +598,10 @@ export async function _hwfitFetch(fresh = false, opts = {}) { // reload shows the last result with no spinner. We still fetch fresh below and // swap it in. If there's no cache hit, fall back to the spinner. const _sig = _scanSig(); - const _cached = fresh ? null : _readScanCache(_sig); + let _cached = fresh ? null : _readScanCache(_sig); + if (!_cached && !fresh && (!allowNetwork || keepPrevious)) { + _cached = _readNearestScanCache(_sig); + } const wp = spinnerModule.createWhirlpool(18); const _paintedFromCache = !!_cached; if (_cached) { @@ -590,7 +614,10 @@ export async function _hwfitFetch(fresh = false, opts = {}) { } _hwfitRenderList(list, _applyEngineFilter(_cached.models)); } else { - if (!allowNetwork) { + const canKeepPrevious = keepPrevious && _hwfitCache && Array.isArray(_hwfitCache.models); + if (canKeepPrevious) { + try { wp.destroy(); } catch {} + } else if (!allowNetwork) { _hwfitCache = null; _hwfitRenderHw(hw, null); const loadingDiv = document.createElement('div'); @@ -618,23 +645,25 @@ export async function _hwfitFetch(fresh = false, opts = {}) { } return; } - // Show spinner while scanning — stack the spinner above a text label - // (the .hwfit-loading class is a centered flex ROW, so force column here). - const loadingDiv = document.createElement('div'); - loadingDiv.className = 'hwfit-loading'; - loadingDiv.style.flexDirection = 'column'; - loadingDiv.style.gap = '6px'; - loadingDiv.appendChild(wp.element); - // Text label like the other cookbook tabs: "Loading…", then if the scan runs - // long (remote SSH hardware probe), switch to "Scanning hardware…". - const loadingLbl = document.createElement('div'); - loadingLbl.textContent = 'Loading…'; - loadingLbl.style.cssText = 'text-align:center;opacity:0.5;font-size:11px;'; - loadingDiv.appendChild(loadingLbl); - setTimeout(() => { if (loadingLbl.isConnected) loadingLbl.textContent = 'Scanning hardware…'; }, 2000); - list.innerHTML = ''; - list.appendChild(loadingDiv); - _hwfitCache = null; // no instant paint — clear until the fetch returns + if (!canKeepPrevious) { + // Show spinner while scanning — stack the spinner above a text label + // (the .hwfit-loading class is a centered flex ROW, so force column here). + const loadingDiv = document.createElement('div'); + loadingDiv.className = 'hwfit-loading'; + loadingDiv.style.flexDirection = 'column'; + loadingDiv.style.gap = '6px'; + loadingDiv.appendChild(wp.element); + // Text label like the other cookbook tabs: "Loading…", then if the scan runs + // long (remote SSH hardware probe), switch to "Scanning hardware…". + const loadingLbl = document.createElement('div'); + loadingLbl.textContent = 'Loading…'; + loadingLbl.style.cssText = 'text-align:center;opacity:0.5;font-size:11px;'; + loadingDiv.appendChild(loadingLbl); + setTimeout(() => { if (loadingLbl.isConnected) loadingLbl.textContent = 'Scanning hardware…'; }, 2000); + list.innerHTML = ''; + list.appendChild(loadingDiv); + _hwfitCache = null; // no instant paint — clear until the fetch returns + } } if (!allowNetwork) { try { wp.destroy(); } catch {} @@ -674,7 +703,7 @@ export async function _hwfitFetch(fresh = false, opts = {}) { _setLastCacheHost(''); }); } - if (_paintedFromCache) { + if (_paintedFromCache && !forceRevalidate) { try { wp.destroy(); } catch {} return; } diff --git a/static/js/cookbook.js b/static/js/cookbook.js index cc11cfa1a..73965faf4 100644 --- a/static/js/cookbook.js +++ b/static/js/cookbook.js @@ -3095,8 +3095,48 @@ export function isVisible() { let _sharedSyncInFlight = false; let _sharedSyncLast = 0; +const SHARED_STATE_LEADER_KEY = 'odysseus-cookbook-shared-state-leader'; +const SHARED_STATE_LEADER_ID = `${Date.now()}-${Math.random().toString(36).slice(2)}`; +const SHARED_STATE_LEADER_TTL_MS = 12000; + +function _foregroundChatBusy() { + try { + return !!window.__odysseusChatBusy || Date.now() < (window.__odysseusChatBusyUntil || 0); + } catch (_) { + return false; + } +} + +function _claimSharedStateLeader() { + if (document.visibilityState !== 'visible') return false; + const now = Date.now(); + try { + const raw = localStorage.getItem(SHARED_STATE_LEADER_KEY); + const current = raw ? JSON.parse(raw) : null; + if ( + !current + || !current.id + || current.id === SHARED_STATE_LEADER_ID + || now - Number(current.ts || 0) > SHARED_STATE_LEADER_TTL_MS + ) { + localStorage.setItem(SHARED_STATE_LEADER_KEY, JSON.stringify({ id: SHARED_STATE_LEADER_ID, ts: now })); + return true; + } + return current.id === SHARED_STATE_LEADER_ID; + } catch (_) { + return true; + } +} + +function _canRefreshSharedCookbookState() { + if (!isVisible() || _sharedSyncInFlight) return false; + if (document.visibilityState !== 'visible') return false; + if (_foregroundChatBusy()) return false; + return _claimSharedStateLeader(); +} + async function _refreshSharedCookbookState(reason = '') { - if (!isVisible() || _sharedSyncInFlight) return; + if (!_canRefreshSharedCookbookState()) return; const now = Date.now(); if (now - _sharedSyncLast < 1500) return; _sharedSyncInFlight = true; diff --git a/static/js/cookbookRunning.js b/static/js/cookbookRunning.js index a812dce75..3fd77cfad 100644 --- a/static/js/cookbookRunning.js +++ b/static/js/cookbookRunning.js @@ -369,7 +369,7 @@ const SERVE_STATE_KEY = 'cookbook-serve-state'; // Polling / timeout intervals const TASK_POLL_INTERVAL_MS = 3000; // delay between reconnect-loop iterations -const BG_MONITOR_INTERVAL_MS = 5000; // background task status poll +const BG_MONITOR_INTERVAL_MS = 10000; // background task status poll const STALE_PROGRESS_MS = 5 * 60 * 1000; // download with no progress this long = stale const STARTUP_STALE_PROGRESS_MS = 45 * 1000; // 0%-forever startup stall: retry much sooner @@ -3457,6 +3457,10 @@ async function _reconnectTask(el, task) { // ── Background monitor ── let _bgMonitorInterval = null; +let _bgPollInFlight = false; +const BG_LEADER_KEY = 'odysseus-cookbook-bg-leader'; +const BG_LEADER_ID = `${Date.now()}-${Math.random().toString(36).slice(2)}`; +const BG_LEADER_TTL_MS = 15000; function _hasLiveTasks(tasks = null) { const list = tasks || _loadTasks(); @@ -3475,67 +3479,122 @@ function _isRunningTabVisible() { return activeTab === 'Running'; } +function _foregroundChatBusy() { + try { + return !!window.__odysseusChatBusy || Date.now() < (window.__odysseusChatBusyUntil || 0); + } catch { + return false; + } +} + +function _claimBackgroundLeader() { + if (document.visibilityState !== 'visible') return false; + const now = Date.now(); + try { + const raw = localStorage.getItem(BG_LEADER_KEY); + const current = raw ? JSON.parse(raw) : null; + if ( + !current + || !current.id + || current.id === BG_LEADER_ID + || now - Number(current.ts || 0) > BG_LEADER_TTL_MS + ) { + localStorage.setItem(BG_LEADER_KEY, JSON.stringify({ id: BG_LEADER_ID, ts: now })); + return true; + } + return current.id === BG_LEADER_ID; + } catch (_) { + return true; + } +} + +function _canBackgroundPoll() { + if (_foregroundChatBusy()) return false; + if (document.visibilityState !== 'visible') return false; + return _claimBackgroundLeader(); +} + // Reachability check for running serve tasks. The tmux pane can stay alive // while the model server inside it has crashed (so no "Process exited" line // ever appears) — leaving the card showing "running" forever. So we actively // probe the registered endpoint (same /probe-local the model picker uses) and // flag the card "unreachable" (red) when the server stops answering. +let _serveReachabilityInFlight = false; +let _serveReachabilityLastAt = 0; async function _checkServeReachability() { + // This reaches out to local model servers. Keep it out of the normal chat + // path unless the user is actively looking at the Running tab. + if (_foregroundChatBusy()) return; + if (!_isRunningTabVisible()) return; + const now = Date.now(); + if (_serveReachabilityInFlight || now - _serveReachabilityLastAt < 10000) return; + _serveReachabilityInFlight = true; + _serveReachabilityLastAt = now; let serveTasks; try { serveTasks = _loadTasks().filter(t => t.type === 'serve' && t.status === 'running'); - } catch { return; } - if (!serveTasks.length) return; + } catch { + _serveReachabilityInFlight = false; + return; + } + if (!serveTasks.length) { + _serveReachabilityInFlight = false; + return; + } let eps = [], probe = {}; try { [eps, probe] = await Promise.all([ fetch('/api/model-endpoints', { credentials: 'same-origin' }).then(r => r.json()).catch(() => []), fetch('/api/model-endpoints/probe-local', { credentials: 'same-origin' }).then(r => r.json()).catch(() => ({})), ]); - } catch { return; } - for (const task of serveTasks) { - const host = _connectHostFromRemote(task.remoteHost); - const portMatch = task.payload?._cmd?.match(/--port\s+(\d+)/); - const port = portMatch ? portMatch[1] : '8000'; - const baseUrl = `http://${host}:${port}/v1`; - const ep = (eps || []).find(e => e.base_url === baseUrl); - if (!ep) continue; // not registered yet — can't judge - const pr = probe[ep.id]; - if (!pr || pr.alive === undefined) continue; // not probed (non-local) — skip - // Record the first time it actually answers. Until then the server is still - // LOADING/warming (the endpoint can get registered on the 300s timeout for a - // big model that hasn't finished loading), and a not-yet-answering server is - // not "unreachable" — flagging it as such while you're launching is a false - // alarm. Only treat it as unreachable once it has been reachable at least once. - if (pr.alive === true && !task._everReachable) { - task._everReachable = true; - _updateTask(task.sessionId, { _everReachable: true }); - } - const unreachable = pr.alive === false; - if (unreachable && !task._everReachable) continue; // still coming up, not crashed - if (!!task._unreachable !== unreachable) { - _updateTask(task.sessionId, { _unreachable: unreachable }); - } - const el = document.querySelector(`.cookbook-task[data-task-id="${task.sessionId}"]`); - if (el) { - el.classList.toggle('cookbook-task-unreachable', unreachable); - const badge = el.querySelector('.cookbook-task-status'); - if (badge) { - if (unreachable) { - badge.textContent = 'unreachable'; - badge.className = 'cookbook-task-status cookbook-task-error'; - badge.title = pr.error || 'Server not responding — it may have crashed'; - } else if (badge.textContent === 'unreachable') { - // Recovered — restore the normal running label. - badge.textContent = _statusLabel('running', task.type); - badge.className = 'cookbook-task-status cookbook-task-running'; - badge.title = ''; + for (const task of serveTasks) { + const host = _connectHostFromRemote(task.remoteHost); + const portMatch = task.payload?._cmd?.match(/--port\s+(\d+)/); + const port = portMatch ? portMatch[1] : '8000'; + const baseUrl = `http://${host}:${port}/v1`; + const ep = (eps || []).find(e => e.base_url === baseUrl); + if (!ep) continue; // not registered yet — can't judge + const pr = probe[ep.id]; + if (!pr || pr.alive === undefined) continue; // not probed (non-local) — skip + // Record the first time it actually answers. Until then the server is still + // LOADING/warming (the endpoint can get registered on the 300s timeout for a + // big model that hasn't finished loading), and a not-yet-answering server is + // not "unreachable" — flagging it as such while you're launching is a false + // alarm. Only treat it as unreachable once it has been reachable at least once. + if (pr.alive === true && !task._everReachable) { + task._everReachable = true; + _updateTask(task.sessionId, { _everReachable: true }); + } + const unreachable = pr.alive === false; + if (unreachable && !task._everReachable) continue; // still coming up, not crashed + if (!!task._unreachable !== unreachable) { + _updateTask(task.sessionId, { _unreachable: unreachable }); + } + const el = document.querySelector(`.cookbook-task[data-task-id="${task.sessionId}"]`); + if (el) { + el.classList.toggle('cookbook-task-unreachable', unreachable); + const badge = el.querySelector('.cookbook-task-status'); + if (badge) { + if (unreachable) { + badge.textContent = 'unreachable'; + badge.className = 'cookbook-task-status cookbook-task-error'; + badge.title = pr.error || 'Server not responding — it may have crashed'; + } else if (badge.textContent === 'unreachable') { + // Recovered — restore the normal running label. + badge.textContent = _statusLabel('running', task.type); + badge.className = 'cookbook-task-status cookbook-task-running'; + badge.title = ''; + } } } + if (unreachable) _showCookbookNotif(true); } - if (unreachable) _showCookbookNotif(true); + _refreshServerDots(); + } catch { + // Non-fatal: the normal task status poll continues separately. + } finally { + _serveReachabilityInFlight = false; } - _refreshServerDots(); } function _serveTaskFailed(task) { @@ -3687,6 +3746,7 @@ export async function _selfHealStaleTasks(opts = {}) { export function _startBackgroundMonitor() { if (_bgMonitorInterval) return; _bgMonitorInterval = setInterval(() => { + if (!_canBackgroundPoll()) return; _pollBackgroundStatus(); _checkServeReachability(); // Auto-reconnect: every cycle, look for download tasks marked finished/ @@ -3697,8 +3757,10 @@ export function _startBackgroundMonitor() { _selfHealStaleTasks().catch(() => {}); } }, BG_MONITOR_INTERVAL_MS); - _pollBackgroundStatus(); - _checkServeReachability(); + if (_canBackgroundPoll()) { + _pollBackgroundStatus(); + _checkServeReachability(); + } } function _stopBackgroundMonitor() { @@ -3748,6 +3810,8 @@ async function _probeEndpointUntilOnline(epId, host, port) { } async function _pollBackgroundStatus() { + if (!_canBackgroundPoll() || _bgPollInFlight) return; + _bgPollInFlight = true; try { // Pull any tasks the server knows about that aren't in localStorage // yet (e.g. agent-spawned downloads/serves). Without this merge, @@ -4013,6 +4077,8 @@ async function _pollBackgroundStatus() { } } catch (e) { // Silent fail + } finally { + _bgPollInFlight = false; } } diff --git a/static/js/modelPicker.js b/static/js/modelPicker.js index 53077b73b..2feda9add 100644 --- a/static/js/modelPicker.js +++ b/static/js/modelPicker.js @@ -227,6 +227,9 @@ function _initModelPickerDropdown() { const _LOCAL_PROBE_TTL_MS = 5000; async function _refreshLocalProbe() { + try { + if (window.__odysseusChatBusy || Date.now() < (window.__odysseusChatBusyUntil || 0)) return; + } catch (_) {} const now = Date.now(); if (now - _localProbeFetchedAt < _LOCAL_PROBE_TTL_MS) return; _localProbeFetchedAt = now; diff --git a/static/style.css b/static/style.css index fd5cc6044..3d48db469 100644 --- a/static/style.css +++ b/static/style.css @@ -1843,6 +1843,8 @@ body.bg-pattern-sparkles { } .chat-meta { font-size:12px; color:color-mix(in srgb, var(--fg) 60%, transparent); margin-bottom:6px; } .chat-history { + display:flex; + flex-direction:column; flex:1; overflow-y:auto; overflow-x:hidden; @@ -1854,6 +1856,9 @@ body.bg-pattern-sparkles { padding-left: max(0px, calc((100% - var(--chat-max)) / 2)); padding-right: max(12px, calc((100% - var(--chat-max)) / 2 + 12px)); } + .chat-history > * { + flex: 0 0 auto; + } /* Sortable Cookbook column headers had no visual cue, so users couldn't tell a header was clickable (the Newest sort on the Model column was invisible). Show a pointer + hover highlight, and underline the active sort column. */ @@ -2019,6 +2024,17 @@ body.bg-pattern-sparkles { pointer-events: none; transform: translate(-50%, -58%) scale(0.96); } + .chat-container.welcome-active:has(.input-icon-btn.expanded) #welcome-screen, + .chat-container.welcome-active:has(.send-btn.newchat-expanded) #welcome-screen, + .chat-container.welcome-active:has(#model-picker-wrap:not(.picker-auto-hidden) .model-picker-menu:not(.hidden)) #welcome-screen { + opacity: 0.45; + transform: translate(-50%, -76%) scale(0.94); + } + .chat-container.welcome-active:has(.input-icon-btn.expanded) #welcome-screen .welcome-tip, + .chat-container.welcome-active:has(.send-btn.newchat-expanded) #welcome-screen .welcome-tip, + .chat-container.welcome-active:has(#model-picker-wrap:not(.picker-auto-hidden) .model-picker-menu:not(.hidden)) #welcome-screen .welcome-tip { + opacity: 0.14; + } .chat-container.welcome-active textarea#message { max-height: min(34vh, 150px); } @@ -2124,6 +2140,49 @@ body.bg-pattern-sparkles { .msg-user .body { color: var(--fg); } + .chat-queued-bubble-host { + order: 2147483647; + display: flex; + flex-direction: column; + align-items: stretch; + flex: 0 0 auto; + width: 100%; + min-width: 0; + } + .msg-user.msg-user-queued { + opacity: 0.62; + cursor: pointer; + animation: none; + border: 1px dashed color-mix(in srgb, var(--accent, var(--red)) 55%, transparent); + background: color-mix(in srgb, var(--accent, var(--red)) 8%, var(--input-bg, var(--panel))); + transition: opacity 0.12s ease, border-color 0.12s ease, transform 0.12s ease; + } + .msg-user.msg-user-queued:hover { + opacity: 0.9; + border-color: color-mix(in srgb, var(--accent, var(--red)) 85%, transparent); + transform: translateY(-1px); + } + .msg-user.msg-user-queued .queued-pill { + display: inline-flex; + align-items: center; + gap: 3px; + height: 14px; + padding: 0 5px; + margin-left: 5px; + border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--accent, var(--red)) 45%, transparent); + color: var(--accent, var(--red)); + background: color-mix(in srgb, var(--accent, var(--red)) 10%, transparent); + font-size: 8px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.03em; + vertical-align: 1px; + } + .msg-user.msg-user-queued .queued-pill svg { + flex: 0 0 auto; + opacity: 0.9; + } .msg-ai .body { color: var(--fg); }