mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-07-08 11:56:59 +00:00
Stabilize chat and cookbook workflows
This commit is contained in:
+56
-11
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user