Stabilize chat and cookbook workflows

This commit is contained in:
pewdiepie-archdaemon
2026-07-01 10:09:25 +00:00
parent d8e76003f1
commit d2959c1ae8
12 changed files with 1034 additions and 190 deletions
+56 -11
View File
@@ -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
+65 -52
View File
@@ -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 23 min to
# warm); /v1/models can take 5002500 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 23 min to
# warm); /v1/models can take 5002500 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):
+217 -19
View File
@@ -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",
+126 -23
View File
@@ -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 <tool_calls> 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"
"<<<FIND>>>\n"
"exact text from the active document\n"
"<<<REPLACE>>>\n"
"replacement text\n"
"<<<END>>>\n"
"```\n"
"For full rewrites only:\n"
"```update_document\n"
"entire new document content\n"
"```\n"
"For improvement suggestions:\n"
"```suggest_document\n"
"<<<FIND>>>\n"
"text to improve\n"
"<<<SUGGEST>>>\n"
"suggested replacement\n"
"<<<REASON>>>\n"
"why this improves it\n"
"<<<END>>>\n"
"```\n"
"Do not use native function-call JSON or <tool_calls> 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
+15
View File
@@ -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"<mm:think(\s+[^>]*)?>", r"<think\1>", content, flags=re.IGNORECASE)
content = re.sub(r"</mm:think>", "</think>", content, flags=re.IGNORECASE)
stripped = content.lstrip()
+17 -3
View File
@@ -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;
+270 -11
View File
@@ -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(/<?\|end\|>?/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 = '<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">▶</span><span class="agent-thread-tool">Writing</span><span class="agent-thread-wave">▁▂▃</span></div><div class="agent-thread-content"></div>';
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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 = `<div class="role">You <span class="queued-pill"><svg width="8" height="8" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><polygon points="6 4 20 12 6 20 6 4"></polygon></svg>Queued</span></div><div class="body">${_escapeQueueText(item.message)}</div>`;
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();
+53 -24
View File
@@ -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;
}
+41 -1
View File
@@ -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;
+111 -45
View File
@@ -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;
}
}
+3
View File
@@ -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;
+59
View File
@@ -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);
}