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 shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -66,6 +67,9 @@ _HF_TOKEN_STATUS_SNIPPET = (
|
|||||||
def setup_cookbook_routes() -> APIRouter:
|
def setup_cookbook_routes() -> APIRouter:
|
||||||
router = APIRouter(tags=["cookbook"])
|
router = APIRouter(tags=["cookbook"])
|
||||||
_cookbook_state_path = Path(COOKBOOK_STATE_FILE)
|
_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:
|
def _mask_secret(value: str) -> str:
|
||||||
if not value:
|
if not value:
|
||||||
@@ -2463,19 +2467,29 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
async def get_cookbook_state(request: Request):
|
async def get_cookbook_state(request: Request):
|
||||||
"""Load saved cookbook state (tasks, servers, presets, settings)."""
|
"""Load saved cookbook state (tasks, servers, presets, settings)."""
|
||||||
require_admin(request)
|
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():
|
if _cookbook_state_path.exists():
|
||||||
try:
|
try:
|
||||||
state = json.loads(_cookbook_state_path.read_text(encoding="utf-8"))
|
state = json.loads(_cookbook_state_path.read_text(encoding="utf-8"))
|
||||||
saved_tasks = state.get("tasks", [])
|
saved_tasks = state.get("tasks", [])
|
||||||
tasks = saved_tasks if isinstance(saved_tasks, list) else list(saved_tasks.values()) if isinstance(saved_tasks, dict) else []
|
tasks = saved_tasks if isinstance(saved_tasks, list) else list(saved_tasks.values()) if isinstance(saved_tasks, dict) else []
|
||||||
try:
|
client_state = _state_for_client(state)
|
||||||
_maybe_sweep_orphans(tasks, state)
|
_state_get_cache.update({"ts": now, "mtime": mtime, "value": client_state})
|
||||||
except Exception as _sweep_e:
|
return client_state
|
||||||
logger.warning(f"state orphan sweep failed (non-fatal): {_sweep_e!r}")
|
|
||||||
return _state_for_client(state)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return _state_for_client({})
|
client_state = _state_for_client({})
|
||||||
return _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")
|
@router.post("/api/cookbook/state")
|
||||||
async def save_cookbook_state(request: Request):
|
async def save_cookbook_state(request: Request):
|
||||||
@@ -2583,7 +2597,19 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
f"not in incoming body (race guard): "
|
f"not in incoming body (race guard): "
|
||||||
f"{[t.get('sessionId') for t in preserved]}")
|
f"{[t.get('sessionId') for t in preserved]}")
|
||||||
data["tasks"] = incoming_tasks + 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)}
|
return {"ok": True, "preserved": len(preserved)}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"ok": False, "error": str(e)}
|
return {"ok": False, "error": str(e)}
|
||||||
@@ -2705,10 +2731,10 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
|
|
||||||
return {"models": out}
|
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.
|
# work is genuinely sparse even on an actively-polled cookbook page.
|
||||||
_last_orphan_sweep_ts = [0.0]
|
_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.
|
# Concurrency guard so two requests racing don't both spawn a sweep.
|
||||||
_orphan_sweep_inflight = [False]
|
_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
|
event loop. Now the whole body runs in a worker thread via
|
||||||
asyncio.to_thread so other requests stay responsive."""
|
asyncio.to_thread so other requests stay responsive."""
|
||||||
require_admin(request)
|
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():
|
def _cookbook_tasks_status_sync():
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|||||||
+65
-52
@@ -1479,6 +1479,7 @@ def setup_model_routes(model_discovery):
|
|||||||
# within ~8s of the user noticing.
|
# within ~8s of the user noticing.
|
||||||
_LOCAL_PROBE_TTL = 8.0
|
_LOCAL_PROBE_TTL = 8.0
|
||||||
_local_probe_cache: Dict[str, Any] = {"data": None, "time": 0.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")
|
@router.get("/model-endpoints/probe-local")
|
||||||
async def probe_local_endpoints(request: Request):
|
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):
|
(now - _local_probe_cache["time"]) < _LOCAL_PROBE_TTL):
|
||||||
return _local_probe_cache["data"]
|
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
|
import asyncio as _asyncio
|
||||||
results_list = await _asyncio.gather(
|
task = _local_probe_inflight.get("task")
|
||||||
*[_probe_one(data) for data in grouped.values()],
|
if task is not None and not task.done():
|
||||||
return_exceptions=False,
|
return await task
|
||||||
)
|
|
||||||
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
|
async def _compute_local_probe() -> Dict[str, Any]:
|
||||||
_local_probe_cache["time"] = now
|
db = SessionLocal()
|
||||||
return results
|
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")
|
@router.get("/ping")
|
||||||
def ping_endpoints(request: Request):
|
def ping_endpoints(request: Request):
|
||||||
|
|||||||
@@ -5113,8 +5113,8 @@
|
|||||||
{
|
{
|
||||||
"name": "deepseek-ai/DeepSeek-V4-Flash",
|
"name": "deepseek-ai/DeepSeek-V4-Flash",
|
||||||
"provider": "deepseek-ai",
|
"provider": "deepseek-ai",
|
||||||
"parameter_count": "284B",
|
"parameter_count": "158.1B",
|
||||||
"parameters_raw": 284000000000,
|
"parameters_raw": 158069433298,
|
||||||
"active_parameters": 13000000000,
|
"active_parameters": 13000000000,
|
||||||
"is_moe": true,
|
"is_moe": true,
|
||||||
"min_ram_gb": 200.0,
|
"min_ram_gb": 200.0,
|
||||||
@@ -5130,15 +5130,40 @@
|
|||||||
],
|
],
|
||||||
"pipeline_tag": "text-generation",
|
"pipeline_tag": "text-generation",
|
||||||
"architecture": "deepseek_v4_moe",
|
"architecture": "deepseek_v4_moe",
|
||||||
"hf_downloads": 3542202,
|
"hf_downloads": 1882337,
|
||||||
"hf_likes": 0,
|
"hf_likes": 1651,
|
||||||
"release_date": "2026-05-15"
|
"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",
|
"name": "deepseek-ai/DeepSeek-V4-Flash-Base",
|
||||||
"provider": "deepseek-ai",
|
"provider": "deepseek-ai",
|
||||||
"parameter_count": "284B",
|
"parameter_count": "292.0B",
|
||||||
"parameters_raw": 284000000000,
|
"parameters_raw": 292021347282,
|
||||||
"active_parameters": 13000000000,
|
"active_parameters": 13000000000,
|
||||||
"is_moe": true,
|
"is_moe": true,
|
||||||
"min_ram_gb": 290.0,
|
"min_ram_gb": 290.0,
|
||||||
@@ -5153,15 +5178,15 @@
|
|||||||
],
|
],
|
||||||
"pipeline_tag": "text-generation",
|
"pipeline_tag": "text-generation",
|
||||||
"architecture": "deepseek_v4_moe",
|
"architecture": "deepseek_v4_moe",
|
||||||
"hf_downloads": 0,
|
"hf_downloads": 76030,
|
||||||
"hf_likes": 0,
|
"hf_likes": 256,
|
||||||
"release_date": "2026-05-15"
|
"release_date": "2026-04-27"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "deepseek-ai/DeepSeek-V4-Pro",
|
"name": "deepseek-ai/DeepSeek-V4-Pro",
|
||||||
"provider": "deepseek-ai",
|
"provider": "deepseek-ai",
|
||||||
"parameter_count": "1.6T",
|
"parameter_count": "861.6B",
|
||||||
"parameters_raw": 1600000000000,
|
"parameters_raw": 861608274846,
|
||||||
"active_parameters": 49000000000,
|
"active_parameters": 49000000000,
|
||||||
"is_moe": true,
|
"is_moe": true,
|
||||||
"min_ram_gb": 1100.0,
|
"min_ram_gb": 1100.0,
|
||||||
@@ -5177,15 +5202,40 @@
|
|||||||
],
|
],
|
||||||
"pipeline_tag": "text-generation",
|
"pipeline_tag": "text-generation",
|
||||||
"architecture": "deepseek_v4_moe",
|
"architecture": "deepseek_v4_moe",
|
||||||
"hf_downloads": 0,
|
"hf_downloads": 1154610,
|
||||||
"hf_likes": 0,
|
"hf_likes": 5118,
|
||||||
"release_date": "2026-05-15"
|
"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",
|
"name": "deepseek-ai/DeepSeek-V4-Pro-Base",
|
||||||
"provider": "deepseek-ai",
|
"provider": "deepseek-ai",
|
||||||
"parameter_count": "1.6T",
|
"parameter_count": "1.6T",
|
||||||
"parameters_raw": 1600000000000,
|
"parameters_raw": 1600790440862,
|
||||||
"active_parameters": 49000000000,
|
"active_parameters": 49000000000,
|
||||||
"is_moe": true,
|
"is_moe": true,
|
||||||
"min_ram_gb": 1700.0,
|
"min_ram_gb": 1700.0,
|
||||||
@@ -5200,9 +5250,9 @@
|
|||||||
],
|
],
|
||||||
"pipeline_tag": "text-generation",
|
"pipeline_tag": "text-generation",
|
||||||
"architecture": "deepseek_v4_moe",
|
"architecture": "deepseek_v4_moe",
|
||||||
"hf_downloads": 0,
|
"hf_downloads": 25387,
|
||||||
"hf_likes": 0,
|
"hf_likes": 305,
|
||||||
"release_date": "2026-05-15"
|
"release_date": "2026-04-27"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "deepseek-ai/deepseek-coder-6.7b-base",
|
"name": "deepseek-ai/deepseek-coder-6.7b-base",
|
||||||
@@ -13308,6 +13358,106 @@
|
|||||||
"_discovered": true,
|
"_discovered": true,
|
||||||
"gguf_sources": []
|
"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",
|
"name": "cyankiwi/Qwen3.5-35B-A3B-AWQ-4bit",
|
||||||
"provider": "cyankiwi",
|
"provider": "cyankiwi",
|
||||||
@@ -18955,6 +19105,54 @@
|
|||||||
"active_experts": 8,
|
"active_experts": 8,
|
||||||
"active_parameters": 13600000000
|
"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",
|
"name": "bullerwins/MiniMax-M2.7-REAP-172B-fp8",
|
||||||
"provider": "bullerwins",
|
"provider": "bullerwins",
|
||||||
@@ -19276,4 +19474,4 @@
|
|||||||
],
|
],
|
||||||
"_discovered": true
|
"_discovered": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
+126
-23
@@ -1076,6 +1076,16 @@ def _turn_targets_active_document(intent: Dict[str, object], last_user: str, act
|
|||||||
text,
|
text,
|
||||||
):
|
):
|
||||||
return True
|
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(
|
return bool(re.search(
|
||||||
r"\b("
|
r"\b("
|
||||||
r"document|doc|draft|text|poem|story|essay|outline|letter|paragraph|"
|
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"
|
"markdown\n"
|
||||||
"Document content\n"
|
"Document content\n"
|
||||||
"```\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."
|
"Use saved user memory facts when the user asks for something relating to them."
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
system = (
|
system = (
|
||||||
"You are Odysseus. Use the provided function when it is needed. "
|
"You are Odysseus. Edit or suggest changes to the active document using exactly one fenced tool block when needed.\n"
|
||||||
"After a successful tool call, answer briefly."
|
"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}]
|
out = [{"role": "system", "content": system}]
|
||||||
memory_message = _minimal_saved_memory_message(messages)
|
memory_message = _minimal_saved_memory_message(messages)
|
||||||
@@ -1177,9 +1215,51 @@ def _minimal_odysseus_doc_messages(messages: List[Dict], active_document, stream
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _normalize_stream_document_fences(text: str) -> str:
|
_DOC_MODEL_ARTIFACT_RE = re.compile(
|
||||||
"""Treat visible ```document blocks as create_document tool blocks."""
|
r"(?:\|end\|)+\|?assistan(?:t)?\|?"
|
||||||
return re.sub(r"```document(\s*\n)", r"```create_document\1", text or "")
|
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:
|
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:
|
except Exception as _e:
|
||||||
logger.debug(f"[tool-rag] skill-aware tool include skipped: {_e}")
|
logger.debug(f"[tool-rag] skill-aware tool include skipped: {_e}")
|
||||||
|
|
||||||
|
_intent_domains = set(_intent.get("domains") or set())
|
||||||
_ody_doc_finetune_mode = (
|
_ody_doc_finetune_mode = (
|
||||||
(model or "").lower().startswith("odysseus-qwen3")
|
(model or "").lower().startswith("odysseus-qwen3")
|
||||||
and "documents" in (_intent.get("domains") or set())
|
and (
|
||||||
and "files" not in (_intent.get("domains") or set())
|
"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
|
and not guide_only
|
||||||
)
|
)
|
||||||
_ody_doc_stream_create_mode = _ody_doc_finetune_mode and _prompt_active_document is None
|
_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_opened = False # whether doc_stream_open was sent
|
||||||
_doc_last_len = 0 # last content length sent
|
_doc_last_len = 0 # last content length sent
|
||||||
_doc_stream_create_completed = False
|
_doc_stream_create_completed = False
|
||||||
|
_ody_doc_tool_completed = False
|
||||||
|
|
||||||
# Set when the loop runs out of rounds while the agent was still actively
|
# 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
|
# 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
|
all_tool_schemas = base_schemas + mcp_schemas
|
||||||
if _ody_doc_finetune_mode:
|
if _ody_doc_finetune_mode:
|
||||||
if _ody_doc_stream_create_mode:
|
all_tool_schemas = []
|
||||||
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
|
|
||||||
]
|
|
||||||
if disabled_tools:
|
if disabled_tools:
|
||||||
all_tool_schemas = [
|
all_tool_schemas = [
|
||||||
t for t in all_tool_schemas
|
t for t in all_tool_schemas
|
||||||
@@ -2916,7 +2995,7 @@ async def stream_agent_loop(
|
|||||||
max_tokens=max_tokens,
|
max_tokens=max_tokens,
|
||||||
prompt_type=prompt_type if round_num == 1 else None,
|
prompt_type=prompt_type if round_num == 1 else None,
|
||||||
tools=all_tool_schemas if all_tool_schemas 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,
|
timeout=agent_stream_timeout,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
):
|
):
|
||||||
@@ -3040,9 +3119,12 @@ async def stream_agent_loop(
|
|||||||
if data.get("thinking"):
|
if data.get("thinking"):
|
||||||
round_reasoning += data["delta"]
|
round_reasoning += data["delta"]
|
||||||
else:
|
else:
|
||||||
round_response += data["delta"]
|
_delta_text = _strip_doc_model_artifacts(data["delta"]) if _ody_doc_finetune_mode else data["delta"]
|
||||||
full_response += data["delta"]
|
round_response += _delta_text
|
||||||
yield chunk # Stream all rounds
|
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
|
# Detect text-fence doc streaming. Normal agent prompts
|
||||||
# use ```create_document; the doc LoRA streaming path
|
# use ```create_document; the doc LoRA streaming path
|
||||||
# uses neutral ```document to avoid triggering learned
|
# 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"))
|
and not (tool_policy and tool_policy.blocks("create_document"))
|
||||||
):
|
):
|
||||||
_fence_markers = (
|
_fence_markers = (
|
||||||
('```document\n', 'document')
|
('```document\n', '```documen\n')
|
||||||
if _ody_doc_stream_create_mode
|
if _ody_doc_stream_create_mode
|
||||||
else ('```create_document\n',)
|
else ('```create_document\n',)
|
||||||
)
|
)
|
||||||
@@ -3122,12 +3204,20 @@ async def stream_agent_loop(
|
|||||||
_round_first_event_logged,
|
_round_first_event_logged,
|
||||||
_round_first_token_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(
|
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,
|
native_tool_calls,
|
||||||
round_num,
|
round_num,
|
||||||
is_api_model=(_is_api_model and not guide_only),
|
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:
|
if _ody_doc_stream_create_mode and tool_blocks:
|
||||||
create_idx = next(
|
create_idx = next(
|
||||||
@@ -3782,6 +3872,12 @@ async def stream_agent_loop(
|
|||||||
and result.get("action") == "create"
|
and result.get("action") == "create"
|
||||||
):
|
):
|
||||||
_doc_stream_create_completed = True
|
_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 was hit, stop the loop
|
||||||
if budget_hit:
|
if budget_hit:
|
||||||
@@ -3801,6 +3897,13 @@ async def stream_agent_loop(
|
|||||||
logger.info("[agent] odysseus doc stream-create completed after one create_document")
|
logger.info("[agent] odysseus doc stream-create completed after one create_document")
|
||||||
break
|
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
|
# Feed results back to LLM for next round
|
||||||
# Pass the CONVERTED calls (aligned 1:1 with tool_result_texts), not the
|
# 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
|
# 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)
|
_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:
|
def _harmony_suffix_hold_len(text: str) -> int:
|
||||||
"""Return how many trailing chars could be the start of a harmony marker."""
|
"""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:
|
if reasoning:
|
||||||
yield _stream_delta_event(reasoning, thinking=True)
|
yield _stream_delta_event(reasoning, thinking=True)
|
||||||
if content:
|
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(\s+[^>]*)?>", r"<think\1>", content, flags=re.IGNORECASE)
|
||||||
content = re.sub(r"</mm:think>", "</think>", content, flags=re.IGNORECASE)
|
content = re.sub(r"</mm:think>", "</think>", content, flags=re.IGNORECASE)
|
||||||
stripped = content.lstrip()
|
stripped = content.lstrip()
|
||||||
|
|||||||
+17
-3
@@ -2186,7 +2186,7 @@ function initializeEventListeners() {
|
|||||||
const pickerWrap = el('model-picker-wrap');
|
const pickerWrap = el('model-picker-wrap');
|
||||||
if (!inputTop || !pickerWrap) return;
|
if (!inputTop || !pickerWrap) return;
|
||||||
|
|
||||||
const PLACEHOLDER_HIDE_WIDTH = 400;
|
const PLACEHOLDER_COMPACT_WIDTH = 400;
|
||||||
const PICKER_HIDE_WIDTH = 220;
|
const PICKER_HIDE_WIDTH = 220;
|
||||||
const TOOLBAR_HIDE_WIDTH = 160;
|
const TOOLBAR_HIDE_WIDTH = 160;
|
||||||
const textarea = el('message');
|
const textarea = el('message');
|
||||||
@@ -2199,9 +2199,10 @@ function initializeEventListeners() {
|
|||||||
const w = inputTop.clientWidth;
|
const w = inputTop.clientWidth;
|
||||||
// Hide model picker
|
// Hide model picker
|
||||||
pickerWrap.classList.toggle('picker-auto-hidden', w < PICKER_HIDE_WIDTH);
|
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) {
|
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
|
// Hide entire bottom toolbar (tools, mode toggle) — only send button remains
|
||||||
if (inputBottom) {
|
if (inputBottom) {
|
||||||
@@ -3449,6 +3450,12 @@ function initializeEventListeners() {
|
|||||||
function startOdysseusApp() {
|
function startOdysseusApp() {
|
||||||
if (window.__odysseusAppStarted) return;
|
if (window.__odysseusAppStarted) return;
|
||||||
window.__odysseusAppStarted = true;
|
window.__odysseusAppStarted = true;
|
||||||
|
const _bumpChatPriority = (ms = 10000) => {
|
||||||
|
try {
|
||||||
|
window.__odysseusChatBusyUntil = Math.max(window.__odysseusChatBusyUntil || 0, Date.now() + ms);
|
||||||
|
} catch (_) {}
|
||||||
|
};
|
||||||
|
_bumpChatPriority(10000);
|
||||||
// Set CSS variables
|
// Set CSS variables
|
||||||
document.documentElement.style.setProperty('--line-height', '20px');
|
document.documentElement.style.setProperty('--line-height', '20px');
|
||||||
initRailHoverLabels();
|
initRailHoverLabels();
|
||||||
@@ -3617,9 +3624,16 @@ function startOdysseusApp() {
|
|||||||
const chatForm = document.getElementById('chat-form');
|
const chatForm = document.getElementById('chat-form');
|
||||||
const originalSubmit = chatModule.handleChatSubmit;
|
const originalSubmit = chatModule.handleChatSubmit;
|
||||||
let _submitting = false;
|
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) {
|
function handleSubmit(e) {
|
||||||
if (e) e.preventDefault();
|
if (e) e.preventDefault();
|
||||||
|
_bumpChatPriority(30000);
|
||||||
// Debounce: prevent double-submit while a request is being initiated
|
// Debounce: prevent double-submit while a request is being initiated
|
||||||
if (_submitting) return;
|
if (_submitting) return;
|
||||||
_submitting = true;
|
_submitting = true;
|
||||||
|
|||||||
+270
-11
@@ -43,6 +43,13 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
|||||||
let _sendInFlight = false; // covers the window from click → streaming start
|
let _sendInFlight = false; // covers the window from click → streaming start
|
||||||
let _displayOverride = null; // Override visible user bubble text (hides injected prompts)
|
let _displayOverride = null; // Override visible user bubble text (hides injected prompts)
|
||||||
let _hideUserBubble = false; // Skip user bubble entirely (e.g. continue after stop)
|
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
|
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
|
// ── 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
|
// 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');
|
const body = msgEl.querySelector('.body');
|
||||||
if (body) chatRenderer.appendReportButton(body, sessionId);
|
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 currentAccumulated = ''; // Track accumulated text across function scope
|
||||||
let currentHolder = null; // Track current message holder
|
let currentHolder = null; // Track current message holder
|
||||||
let currentSpinner = null; // Track current spinner for stop cleanup
|
let currentSpinner = null; // Track current spinner for stop cleanup
|
||||||
@@ -241,12 +336,14 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
|||||||
submitBtn.dataset.mode = 'streaming';
|
submitBtn.dataset.mode = 'streaming';
|
||||||
submitBtn.dataset.phase = 'processing';
|
submitBtn.dataset.phase = 'processing';
|
||||||
isStreaming = true;
|
isStreaming = true;
|
||||||
|
_setForegroundChatBusy(true);
|
||||||
_startStallWatchdog();
|
_startStallWatchdog();
|
||||||
} else if (state === 'idle') {
|
} else if (state === 'idle') {
|
||||||
submitBtn.dataset.mode = '';
|
submitBtn.dataset.mode = '';
|
||||||
delete submitBtn.dataset.phase;
|
delete submitBtn.dataset.phase;
|
||||||
submitBtn.classList.remove('recording');
|
submitBtn.classList.remove('recording');
|
||||||
isStreaming = false;
|
isStreaming = false;
|
||||||
|
_setForegroundChatBusy(false);
|
||||||
_stopStallWatchdog();
|
_stopStallWatchdog();
|
||||||
// Defer to global updater which handles mic/newchat/send modes
|
// Defer to global updater which handles mic/newchat/send modes
|
||||||
if (window._updateSendBtnIcon) {
|
if (window._updateSendBtnIcon) {
|
||||||
@@ -267,6 +364,126 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
|||||||
// API key pattern for the guard in handleChatSubmit
|
// 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 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
|
* Handle chat form submission
|
||||||
@@ -290,8 +507,23 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
|||||||
return;
|
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) {
|
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()) {
|
if (fileHandlerModule.isUploading && fileHandlerModule.isUploading()) {
|
||||||
fileHandlerModule.cancelUpload && fileHandlerModule.cancelUpload();
|
fileHandlerModule.cancelUpload && fileHandlerModule.cancelUpload();
|
||||||
}
|
}
|
||||||
@@ -343,6 +575,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
|||||||
const messageInput = uiModule.el('message');
|
const messageInput = uiModule.el('message');
|
||||||
if (messageInput) messageInput.disabled = false;
|
if (messageInput) messageInput.disabled = false;
|
||||||
currentAccumulated = '';
|
currentAccumulated = '';
|
||||||
|
_drainQueuedAgentRequests();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Render whatever was accumulated so far
|
// 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 ---
|
// --- Send-path entry: block re-clicks between submit and stream start ---
|
||||||
if (_sendInFlight) return;
|
if (_sendInFlight) return;
|
||||||
_sendInFlight = true;
|
_sendInFlight = true;
|
||||||
|
_setForegroundChatBusy(true);
|
||||||
// Instant visual feedback so the user sees their click was accepted
|
// Instant visual feedback so the user sees their click was accepted
|
||||||
// even before the streaming button state kicks in below.
|
// even before the streaming button state kicks in below.
|
||||||
const _earlyMessageInput = uiModule.el('message');
|
const _earlyMessageInput = uiModule.el('message');
|
||||||
@@ -427,6 +661,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
|||||||
if (submitBtn) submitBtn.classList.add('send-pending');
|
if (submitBtn) submitBtn.classList.add('send-pending');
|
||||||
const _releaseSendFlag = () => {
|
const _releaseSendFlag = () => {
|
||||||
_sendInFlight = false;
|
_sendInFlight = false;
|
||||||
|
_setForegroundChatBusy(isStreaming);
|
||||||
if (_earlyMessageInput) _earlyMessageInput.disabled = false;
|
if (_earlyMessageInput) _earlyMessageInput.disabled = false;
|
||||||
if (submitBtn) submitBtn.classList.remove('send-pending');
|
if (submitBtn) submitBtn.classList.remove('send-pending');
|
||||||
};
|
};
|
||||||
@@ -1104,12 +1339,14 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
|||||||
'web_search': 'Searching',
|
'web_search': 'Searching',
|
||||||
'bash': 'Running',
|
'bash': 'Running',
|
||||||
'python': 'Running',
|
'python': 'Running',
|
||||||
'create_document': 'Writing',
|
|
||||||
'update_document': 'Writing',
|
|
||||||
'read_document': 'Reading',
|
'read_document': 'Reading',
|
||||||
'edit_file': 'Editing',
|
'edit_file': 'Editing',
|
||||||
'read_file': 'Reading',
|
'read_file': 'Reading',
|
||||||
'write_file': 'Writing',
|
'write_file': 'Writing',
|
||||||
|
'create_document': 'Writing',
|
||||||
|
'edit_document': 'Editing',
|
||||||
|
'update_document': 'Rewriting',
|
||||||
|
'suggest_document': 'Reviewing',
|
||||||
'list_files': 'Browsing',
|
'list_files': 'Browsing',
|
||||||
'image_gen': 'Generating',
|
'image_gen': 'Generating',
|
||||||
'generate_image': 'Generating',
|
'generate_image': 'Generating',
|
||||||
@@ -1208,7 +1445,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
|||||||
|
|
||||||
// Direct render helper for streaming text
|
// Direct render helper for streaming text
|
||||||
_renderStream = () => {
|
_renderStream = () => {
|
||||||
let dt = markdownModule.normalizeThinkingMarkup(stripToolBlocks(roundText));
|
let dt = markdownModule.normalizeThinkingMarkup(stripToolBlocks(_stripDocumentFenceForChat(roundText)));
|
||||||
const bodyEl = roundHolder.querySelector('.body');
|
const bodyEl = roundHolder.querySelector('.body');
|
||||||
const contentEl = _ensureStreamLayout(bodyEl);
|
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)
|
// 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.
|
// re-parse/re-highlight of the whole message on every token.
|
||||||
// See streamingRenderer.js / streamingSegmenter.js.
|
// See streamingRenderer.js / streamingSegmenter.js.
|
||||||
|
if (_docFenceOpened && !dt.trim()) {
|
||||||
|
_showDocumentWritingStatus(contentEl);
|
||||||
|
uiModule.scrollHistory();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const renderer = contentEl._streamRenderer ||
|
const renderer = contentEl._streamRenderer ||
|
||||||
(contentEl._streamRenderer = createStreamRenderer(contentEl, {
|
(contentEl._streamRenderer = createStreamRenderer(contentEl, {
|
||||||
render: (t) => markdownModule.processWithThinking(markdownModule.squashOutsideCode(t)),
|
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) ---
|
// --- 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'))) {
|
if (!_docFenceOpened && documentModule && (roundText.includes('```create_document\n') || roundText.includes('```document\n') || roundText.includes('```documen\n'))) {
|
||||||
const fenceMarker = roundText.includes('```document\n') ? '```document\n' : '```create_document\n';
|
const fenceMarker = roundText.includes('```document\n') ? '```document\n' : (roundText.includes('```documen\n') ? '```documen\n' : '```create_document\n');
|
||||||
const fenceIdx = roundText.indexOf(fenceMarker);
|
const fenceIdx = roundText.indexOf(fenceMarker);
|
||||||
const afterFence = roundText.slice(fenceIdx + fenceMarker.length);
|
const afterFence = roundText.slice(fenceIdx + fenceMarker.length);
|
||||||
const fenceLines = afterFence.split('\n');
|
const fenceLines = afterFence.split('\n');
|
||||||
@@ -2089,7 +2331,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
|||||||
if (!roundFinalized) {
|
if (!roundFinalized) {
|
||||||
roundFinalized = true;
|
roundFinalized = true;
|
||||||
if (spinner && spinner.element) spinner.destroy();
|
if (spinner && spinner.element) spinner.destroy();
|
||||||
const dt = markdownModule.normalizeThinkingMarkup(stripToolBlocks(roundText));
|
const dt = markdownModule.normalizeThinkingMarkup(stripToolBlocks(_stripDocumentFenceForChat(roundText)));
|
||||||
if (dt.trim()) {
|
if (dt.trim()) {
|
||||||
var _body3 = roundHolder.querySelector('.body');
|
var _body3 = roundHolder.querySelector('.body');
|
||||||
var _contentEl3 = _ensureStreamLayout(_body3);
|
var _contentEl3 = _ensureStreamLayout(_body3);
|
||||||
@@ -2559,9 +2801,13 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
|||||||
// Clear streaming minHeight lock
|
// Clear streaming minHeight lock
|
||||||
const _streamContent = roundHolder.querySelector('.stream-content');
|
const _streamContent = roundHolder.querySelector('.stream-content');
|
||||||
if (_streamContent) _streamContent.style.minHeight = '';
|
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
|
// 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()) {
|
if (finalDisplay.trim()) {
|
||||||
var _body4 = roundHolder.querySelector('.body');
|
var _body4 = roundHolder.querySelector('.body');
|
||||||
// Preserve sources expanded state before final render
|
// Preserve sources expanded state before final render
|
||||||
@@ -3033,6 +3279,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
|||||||
sessionModule.loadSessions();
|
sessionModule.loadSessions();
|
||||||
}
|
}
|
||||||
}, 3000);
|
}, 3000);
|
||||||
|
_drainQueuedAgentRequests();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3240,6 +3487,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
|||||||
// Clear local state WITHOUT aborting the fetch
|
// Clear local state WITHOUT aborting the fetch
|
||||||
currentAbort = null;
|
currentAbort = null;
|
||||||
isStreaming = false;
|
isStreaming = false;
|
||||||
|
_setForegroundChatBusy(false);
|
||||||
currentHolder = null;
|
currentHolder = null;
|
||||||
currentAccumulated = '';
|
currentAccumulated = '';
|
||||||
// Reset submit button so the new chat is ready to send
|
// Reset submit button so the new chat is ready to send
|
||||||
@@ -3302,6 +3550,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
|||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let buffer = '';
|
let buffer = '';
|
||||||
let roundText = '';
|
let roundText = '';
|
||||||
|
let docFenceOpened = false;
|
||||||
let gotDelta = false;
|
let gotDelta = false;
|
||||||
let leftSession = false;
|
let leftSession = false;
|
||||||
let metricsData = null;
|
let metricsData = null;
|
||||||
@@ -3316,8 +3565,12 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderDelta = () => {
|
const renderDelta = () => {
|
||||||
const dt = markdownModule.normalizeThinkingMarkup(stripToolBlocks(roundText));
|
const dt = markdownModule.normalizeThinkingMarkup(stripToolBlocks(_stripDocumentFenceForChat(roundText, { final: docFenceOpened })));
|
||||||
contentDiv.innerHTML = markdownModule.mdToHtml(markdownModule.squashOutsideCode(dt));
|
if (docFenceOpened && !dt.trim()) {
|
||||||
|
_showDocumentWritingStatus(contentDiv);
|
||||||
|
} else {
|
||||||
|
contentDiv.innerHTML = markdownModule.mdToHtml(markdownModule.squashOutsideCode(dt));
|
||||||
|
}
|
||||||
uiModule.scrollHistory();
|
uiModule.scrollHistory();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -3348,6 +3601,10 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
|||||||
try { json = JSON.parse(payload); } catch (_) { continue; }
|
try { json = JSON.parse(payload); } catch (_) { continue; }
|
||||||
if (json.delta) {
|
if (json.delta) {
|
||||||
roundText += 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 (_) {} }
|
if (!gotDelta) { gotDelta = true; try { spinner.destroy(); } catch (_) {} }
|
||||||
renderDelta();
|
renderDelta();
|
||||||
} else if (json.type === 'doc_stream_open') {
|
} else if (json.type === 'doc_stream_open') {
|
||||||
@@ -3355,7 +3612,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
|||||||
if (documentModule) documentModule.streamDocOpen(json.title || '', json.lang || '');
|
if (documentModule) documentModule.streamDocOpen(json.title || '', json.lang || '');
|
||||||
} else if (json.type === 'doc_stream_delta') {
|
} else if (json.type === 'doc_stream_delta') {
|
||||||
rich = true;
|
rich = true;
|
||||||
if (documentModule && json.delta) documentModule.streamDocDelta(json.delta);
|
if (documentModule) documentModule.streamDocDelta(json.content || json.delta || '');
|
||||||
} else if (json.type === 'metrics') {
|
} else if (json.type === 'metrics') {
|
||||||
metricsData = json.data || metricsData;
|
metricsData = json.data || metricsData;
|
||||||
} else if (json.type === 'tool_start' || json.type === 'tool_output' ||
|
} else if (json.type === 'tool_start' || json.type === 'tool_output' ||
|
||||||
@@ -3372,6 +3629,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
|||||||
}
|
}
|
||||||
|
|
||||||
cleanup();
|
cleanup();
|
||||||
|
if (docFenceOpened) _finishDocumentWritingStatus(holder, true);
|
||||||
if (leftSession) { if (holder.parentNode) holder.remove(); return true; }
|
if (leftSession) { if (holder.parentNode) holder.remove(); return true; }
|
||||||
|
|
||||||
const onThisSession = sessionModule.getCurrentSessionId &&
|
const onThisSession = sessionModule.getCurrentSessionId &&
|
||||||
@@ -3391,6 +3649,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
|||||||
|
|
||||||
// Rich response (tools, sources, docs, multi-round) or user moved on:
|
// Rich response (tools, sources, docs, multi-round) or user moved on:
|
||||||
// reload from the DB for the full canonical render.
|
// reload from the DB for the full canonical render.
|
||||||
|
if (holder._docWritingThread && holder._docWritingThread.parentNode) holder._docWritingThread.remove();
|
||||||
if (holder.parentNode) holder.remove();
|
if (holder.parentNode) holder.remove();
|
||||||
if (onThisSession) sessionModule.selectSession(sessionId);
|
if (onThisSession) sessionModule.selectSession(sessionId);
|
||||||
else sessionModule.loadSessions();
|
else sessionModule.loadSessions();
|
||||||
|
|||||||
+53
-24
@@ -242,8 +242,7 @@ export function _renderGpuToggles(system) {
|
|||||||
container._activeCount = undefined; // default to the new pool's max
|
container._activeCount = undefined; // default to the new pool's max
|
||||||
delete container.dataset.rendered; // force a count-button rebuild
|
delete container.dataset.rendered; // force a count-button rebuild
|
||||||
_renderGpuToggles(system);
|
_renderGpuToggles(system);
|
||||||
_hwfitCache = null;
|
_hwfitFetch(false, { keepPrevious: true, forceRevalidate: true });
|
||||||
_hwfitFetch();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,8 +274,7 @@ export function _renderGpuToggles(system) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_hwfitCache = null;
|
_hwfitFetch(false, { keepPrevious: true, forceRevalidate: true });
|
||||||
_hwfitFetch();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -436,6 +434,27 @@ function _readScanCache(sig) {
|
|||||||
return null;
|
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) {
|
function _writeScanCache(sig, data) {
|
||||||
try {
|
try {
|
||||||
const all = JSON.parse(localStorage.getItem(_SCAN_CACHE_KEY) || '{}');
|
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 = {}) {
|
export async function _hwfitFetch(fresh = false, opts = {}) {
|
||||||
const _tk = ++_hwfitFetchToken;
|
const _tk = ++_hwfitFetchToken;
|
||||||
const allowNetwork = fresh || opts.allowNetwork !== false;
|
const allowNetwork = fresh || opts.allowNetwork !== false;
|
||||||
|
const keepPrevious = !!opts.keepPrevious;
|
||||||
|
const forceRevalidate = !!opts.forceRevalidate;
|
||||||
const useCase = document.getElementById('hwfit-usecase')?.value || '';
|
const useCase = document.getElementById('hwfit-usecase')?.value || '';
|
||||||
const search = document.getElementById('hwfit-search')?.value?.trim() || '';
|
const search = document.getElementById('hwfit-search')?.value?.trim() || '';
|
||||||
const remoteHost = _envState.remoteHost || '';
|
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
|
// 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.
|
// swap it in. If there's no cache hit, fall back to the spinner.
|
||||||
const _sig = _scanSig();
|
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 wp = spinnerModule.createWhirlpool(18);
|
||||||
const _paintedFromCache = !!_cached;
|
const _paintedFromCache = !!_cached;
|
||||||
if (_cached) {
|
if (_cached) {
|
||||||
@@ -590,7 +614,10 @@ export async function _hwfitFetch(fresh = false, opts = {}) {
|
|||||||
}
|
}
|
||||||
_hwfitRenderList(list, _applyEngineFilter(_cached.models));
|
_hwfitRenderList(list, _applyEngineFilter(_cached.models));
|
||||||
} else {
|
} else {
|
||||||
if (!allowNetwork) {
|
const canKeepPrevious = keepPrevious && _hwfitCache && Array.isArray(_hwfitCache.models);
|
||||||
|
if (canKeepPrevious) {
|
||||||
|
try { wp.destroy(); } catch {}
|
||||||
|
} else if (!allowNetwork) {
|
||||||
_hwfitCache = null;
|
_hwfitCache = null;
|
||||||
_hwfitRenderHw(hw, null);
|
_hwfitRenderHw(hw, null);
|
||||||
const loadingDiv = document.createElement('div');
|
const loadingDiv = document.createElement('div');
|
||||||
@@ -618,23 +645,25 @@ export async function _hwfitFetch(fresh = false, opts = {}) {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Show spinner while scanning — stack the spinner above a text label
|
if (!canKeepPrevious) {
|
||||||
// (the .hwfit-loading class is a centered flex ROW, so force column here).
|
// Show spinner while scanning — stack the spinner above a text label
|
||||||
const loadingDiv = document.createElement('div');
|
// (the .hwfit-loading class is a centered flex ROW, so force column here).
|
||||||
loadingDiv.className = 'hwfit-loading';
|
const loadingDiv = document.createElement('div');
|
||||||
loadingDiv.style.flexDirection = 'column';
|
loadingDiv.className = 'hwfit-loading';
|
||||||
loadingDiv.style.gap = '6px';
|
loadingDiv.style.flexDirection = 'column';
|
||||||
loadingDiv.appendChild(wp.element);
|
loadingDiv.style.gap = '6px';
|
||||||
// Text label like the other cookbook tabs: "Loading…", then if the scan runs
|
loadingDiv.appendChild(wp.element);
|
||||||
// long (remote SSH hardware probe), switch to "Scanning hardware…".
|
// Text label like the other cookbook tabs: "Loading…", then if the scan runs
|
||||||
const loadingLbl = document.createElement('div');
|
// long (remote SSH hardware probe), switch to "Scanning hardware…".
|
||||||
loadingLbl.textContent = 'Loading…';
|
const loadingLbl = document.createElement('div');
|
||||||
loadingLbl.style.cssText = 'text-align:center;opacity:0.5;font-size:11px;';
|
loadingLbl.textContent = 'Loading…';
|
||||||
loadingDiv.appendChild(loadingLbl);
|
loadingLbl.style.cssText = 'text-align:center;opacity:0.5;font-size:11px;';
|
||||||
setTimeout(() => { if (loadingLbl.isConnected) loadingLbl.textContent = 'Scanning hardware…'; }, 2000);
|
loadingDiv.appendChild(loadingLbl);
|
||||||
list.innerHTML = '';
|
setTimeout(() => { if (loadingLbl.isConnected) loadingLbl.textContent = 'Scanning hardware…'; }, 2000);
|
||||||
list.appendChild(loadingDiv);
|
list.innerHTML = '';
|
||||||
_hwfitCache = null; // no instant paint — clear until the fetch returns
|
list.appendChild(loadingDiv);
|
||||||
|
_hwfitCache = null; // no instant paint — clear until the fetch returns
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!allowNetwork) {
|
if (!allowNetwork) {
|
||||||
try { wp.destroy(); } catch {}
|
try { wp.destroy(); } catch {}
|
||||||
@@ -674,7 +703,7 @@ export async function _hwfitFetch(fresh = false, opts = {}) {
|
|||||||
_setLastCacheHost('');
|
_setLastCacheHost('');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (_paintedFromCache) {
|
if (_paintedFromCache && !forceRevalidate) {
|
||||||
try { wp.destroy(); } catch {}
|
try { wp.destroy(); } catch {}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-1
@@ -3095,8 +3095,48 @@ export function isVisible() {
|
|||||||
|
|
||||||
let _sharedSyncInFlight = false;
|
let _sharedSyncInFlight = false;
|
||||||
let _sharedSyncLast = 0;
|
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 = '') {
|
async function _refreshSharedCookbookState(reason = '') {
|
||||||
if (!isVisible() || _sharedSyncInFlight) return;
|
if (!_canRefreshSharedCookbookState()) return;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - _sharedSyncLast < 1500) return;
|
if (now - _sharedSyncLast < 1500) return;
|
||||||
_sharedSyncInFlight = true;
|
_sharedSyncInFlight = true;
|
||||||
|
|||||||
+111
-45
@@ -369,7 +369,7 @@ const SERVE_STATE_KEY = 'cookbook-serve-state';
|
|||||||
|
|
||||||
// Polling / timeout intervals
|
// Polling / timeout intervals
|
||||||
const TASK_POLL_INTERVAL_MS = 3000; // delay between reconnect-loop iterations
|
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 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
|
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 ──
|
// ── Background monitor ──
|
||||||
|
|
||||||
let _bgMonitorInterval = null;
|
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) {
|
function _hasLiveTasks(tasks = null) {
|
||||||
const list = tasks || _loadTasks();
|
const list = tasks || _loadTasks();
|
||||||
@@ -3475,67 +3479,122 @@ function _isRunningTabVisible() {
|
|||||||
return activeTab === 'Running';
|
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
|
// 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
|
// while the model server inside it has crashed (so no "Process exited" line
|
||||||
// ever appears) — leaving the card showing "running" forever. So we actively
|
// ever appears) — leaving the card showing "running" forever. So we actively
|
||||||
// probe the registered endpoint (same /probe-local the model picker uses) and
|
// probe the registered endpoint (same /probe-local the model picker uses) and
|
||||||
// flag the card "unreachable" (red) when the server stops answering.
|
// flag the card "unreachable" (red) when the server stops answering.
|
||||||
|
let _serveReachabilityInFlight = false;
|
||||||
|
let _serveReachabilityLastAt = 0;
|
||||||
async function _checkServeReachability() {
|
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;
|
let serveTasks;
|
||||||
try {
|
try {
|
||||||
serveTasks = _loadTasks().filter(t => t.type === 'serve' && t.status === 'running');
|
serveTasks = _loadTasks().filter(t => t.type === 'serve' && t.status === 'running');
|
||||||
} catch { return; }
|
} catch {
|
||||||
if (!serveTasks.length) return;
|
_serveReachabilityInFlight = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!serveTasks.length) {
|
||||||
|
_serveReachabilityInFlight = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
let eps = [], probe = {};
|
let eps = [], probe = {};
|
||||||
try {
|
try {
|
||||||
[eps, probe] = await Promise.all([
|
[eps, probe] = await Promise.all([
|
||||||
fetch('/api/model-endpoints', { credentials: 'same-origin' }).then(r => r.json()).catch(() => []),
|
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(() => ({})),
|
fetch('/api/model-endpoints/probe-local', { credentials: 'same-origin' }).then(r => r.json()).catch(() => ({})),
|
||||||
]);
|
]);
|
||||||
} catch { return; }
|
for (const task of serveTasks) {
|
||||||
for (const task of serveTasks) {
|
const host = _connectHostFromRemote(task.remoteHost);
|
||||||
const host = _connectHostFromRemote(task.remoteHost);
|
const portMatch = task.payload?._cmd?.match(/--port\s+(\d+)/);
|
||||||
const portMatch = task.payload?._cmd?.match(/--port\s+(\d+)/);
|
const port = portMatch ? portMatch[1] : '8000';
|
||||||
const port = portMatch ? portMatch[1] : '8000';
|
const baseUrl = `http://${host}:${port}/v1`;
|
||||||
const baseUrl = `http://${host}:${port}/v1`;
|
const ep = (eps || []).find(e => e.base_url === baseUrl);
|
||||||
const ep = (eps || []).find(e => e.base_url === baseUrl);
|
if (!ep) continue; // not registered yet — can't judge
|
||||||
if (!ep) continue; // not registered yet — can't judge
|
const pr = probe[ep.id];
|
||||||
const pr = probe[ep.id];
|
if (!pr || pr.alive === undefined) continue; // not probed (non-local) — skip
|
||||||
if (!pr || pr.alive === undefined) continue; // not probed (non-local) — skip
|
// Record the first time it actually answers. Until then the server is still
|
||||||
// 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
|
||||||
// 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
|
||||||
// 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
|
||||||
// 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.
|
||||||
// alarm. Only treat it as unreachable once it has been reachable at least once.
|
if (pr.alive === true && !task._everReachable) {
|
||||||
if (pr.alive === true && !task._everReachable) {
|
task._everReachable = true;
|
||||||
task._everReachable = true;
|
_updateTask(task.sessionId, { _everReachable: true });
|
||||||
_updateTask(task.sessionId, { _everReachable: true });
|
}
|
||||||
}
|
const unreachable = pr.alive === false;
|
||||||
const unreachable = pr.alive === false;
|
if (unreachable && !task._everReachable) continue; // still coming up, not crashed
|
||||||
if (unreachable && !task._everReachable) continue; // still coming up, not crashed
|
if (!!task._unreachable !== unreachable) {
|
||||||
if (!!task._unreachable !== unreachable) {
|
_updateTask(task.sessionId, { _unreachable: unreachable });
|
||||||
_updateTask(task.sessionId, { _unreachable: unreachable });
|
}
|
||||||
}
|
const el = document.querySelector(`.cookbook-task[data-task-id="${task.sessionId}"]`);
|
||||||
const el = document.querySelector(`.cookbook-task[data-task-id="${task.sessionId}"]`);
|
if (el) {
|
||||||
if (el) {
|
el.classList.toggle('cookbook-task-unreachable', unreachable);
|
||||||
el.classList.toggle('cookbook-task-unreachable', unreachable);
|
const badge = el.querySelector('.cookbook-task-status');
|
||||||
const badge = el.querySelector('.cookbook-task-status');
|
if (badge) {
|
||||||
if (badge) {
|
if (unreachable) {
|
||||||
if (unreachable) {
|
badge.textContent = 'unreachable';
|
||||||
badge.textContent = 'unreachable';
|
badge.className = 'cookbook-task-status cookbook-task-error';
|
||||||
badge.className = 'cookbook-task-status cookbook-task-error';
|
badge.title = pr.error || 'Server not responding — it may have crashed';
|
||||||
badge.title = pr.error || 'Server not responding — it may have crashed';
|
} else if (badge.textContent === 'unreachable') {
|
||||||
} else if (badge.textContent === 'unreachable') {
|
// Recovered — restore the normal running label.
|
||||||
// Recovered — restore the normal running label.
|
badge.textContent = _statusLabel('running', task.type);
|
||||||
badge.textContent = _statusLabel('running', task.type);
|
badge.className = 'cookbook-task-status cookbook-task-running';
|
||||||
badge.className = 'cookbook-task-status cookbook-task-running';
|
badge.title = '';
|
||||||
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) {
|
function _serveTaskFailed(task) {
|
||||||
@@ -3687,6 +3746,7 @@ export async function _selfHealStaleTasks(opts = {}) {
|
|||||||
export function _startBackgroundMonitor() {
|
export function _startBackgroundMonitor() {
|
||||||
if (_bgMonitorInterval) return;
|
if (_bgMonitorInterval) return;
|
||||||
_bgMonitorInterval = setInterval(() => {
|
_bgMonitorInterval = setInterval(() => {
|
||||||
|
if (!_canBackgroundPoll()) return;
|
||||||
_pollBackgroundStatus();
|
_pollBackgroundStatus();
|
||||||
_checkServeReachability();
|
_checkServeReachability();
|
||||||
// Auto-reconnect: every cycle, look for download tasks marked finished/
|
// Auto-reconnect: every cycle, look for download tasks marked finished/
|
||||||
@@ -3697,8 +3757,10 @@ export function _startBackgroundMonitor() {
|
|||||||
_selfHealStaleTasks().catch(() => {});
|
_selfHealStaleTasks().catch(() => {});
|
||||||
}
|
}
|
||||||
}, BG_MONITOR_INTERVAL_MS);
|
}, BG_MONITOR_INTERVAL_MS);
|
||||||
_pollBackgroundStatus();
|
if (_canBackgroundPoll()) {
|
||||||
_checkServeReachability();
|
_pollBackgroundStatus();
|
||||||
|
_checkServeReachability();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function _stopBackgroundMonitor() {
|
function _stopBackgroundMonitor() {
|
||||||
@@ -3748,6 +3810,8 @@ async function _probeEndpointUntilOnline(epId, host, port) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function _pollBackgroundStatus() {
|
async function _pollBackgroundStatus() {
|
||||||
|
if (!_canBackgroundPoll() || _bgPollInFlight) return;
|
||||||
|
_bgPollInFlight = true;
|
||||||
try {
|
try {
|
||||||
// Pull any tasks the server knows about that aren't in localStorage
|
// Pull any tasks the server knows about that aren't in localStorage
|
||||||
// yet (e.g. agent-spawned downloads/serves). Without this merge,
|
// yet (e.g. agent-spawned downloads/serves). Without this merge,
|
||||||
@@ -4013,6 +4077,8 @@ async function _pollBackgroundStatus() {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Silent fail
|
// Silent fail
|
||||||
|
} finally {
|
||||||
|
_bgPollInFlight = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -227,6 +227,9 @@ function _initModelPickerDropdown() {
|
|||||||
const _LOCAL_PROBE_TTL_MS = 5000;
|
const _LOCAL_PROBE_TTL_MS = 5000;
|
||||||
|
|
||||||
async function _refreshLocalProbe() {
|
async function _refreshLocalProbe() {
|
||||||
|
try {
|
||||||
|
if (window.__odysseusChatBusy || Date.now() < (window.__odysseusChatBusyUntil || 0)) return;
|
||||||
|
} catch (_) {}
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - _localProbeFetchedAt < _LOCAL_PROBE_TTL_MS) return;
|
if (now - _localProbeFetchedAt < _LOCAL_PROBE_TTL_MS) return;
|
||||||
_localProbeFetchedAt = now;
|
_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-meta { font-size:12px; color:color-mix(in srgb, var(--fg) 60%, transparent); margin-bottom:6px; }
|
||||||
.chat-history {
|
.chat-history {
|
||||||
|
display:flex;
|
||||||
|
flex-direction:column;
|
||||||
flex:1;
|
flex:1;
|
||||||
overflow-y:auto;
|
overflow-y:auto;
|
||||||
overflow-x:hidden;
|
overflow-x:hidden;
|
||||||
@@ -1854,6 +1856,9 @@ body.bg-pattern-sparkles {
|
|||||||
padding-left: max(0px, calc((100% - var(--chat-max)) / 2));
|
padding-left: max(0px, calc((100% - var(--chat-max)) / 2));
|
||||||
padding-right: max(12px, calc((100% - var(--chat-max)) / 2 + 12px));
|
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
|
/* 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).
|
a header was clickable (the Newest sort on the Model column was invisible).
|
||||||
Show a pointer + hover highlight, and underline the active sort column. */
|
Show a pointer + hover highlight, and underline the active sort column. */
|
||||||
@@ -2019,6 +2024,17 @@ body.bg-pattern-sparkles {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transform: translate(-50%, -58%) scale(0.96);
|
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 {
|
.chat-container.welcome-active textarea#message {
|
||||||
max-height: min(34vh, 150px);
|
max-height: min(34vh, 150px);
|
||||||
}
|
}
|
||||||
@@ -2124,6 +2140,49 @@ body.bg-pattern-sparkles {
|
|||||||
.msg-user .body {
|
.msg-user .body {
|
||||||
color: var(--fg);
|
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 {
|
.msg-ai .body {
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user